Merge remote-tracking branch 'origin/main' into bb/gui
# Conflicts: # apps/dashboard/src/i18n/af.ts # apps/dashboard/src/i18n/de.ts # apps/dashboard/src/i18n/es.ts # apps/dashboard/src/i18n/fr.ts # apps/dashboard/src/i18n/ga.ts # apps/dashboard/src/i18n/hu.ts # apps/dashboard/src/i18n/it.ts # apps/dashboard/src/i18n/ja.ts # apps/dashboard/src/i18n/ko.ts # apps/dashboard/src/i18n/pt.ts # apps/dashboard/src/i18n/ru.ts # apps/dashboard/src/i18n/tr.ts # apps/dashboard/src/i18n/uk.ts # apps/dashboard/src/i18n/zh-hant.ts # gateway/config.py # hermes_cli/main.py # plugins/strike-freedom-cockpit/README.md # tui_gateway/server.py
This commit is contained in:
+85
-7
@@ -221,6 +221,40 @@ HARDLINE_PATTERNS_COMPILED = [
|
||||
]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Sudo stdin guard — block password guessing via "sudo -S"
|
||||
# =========================================================================
|
||||
# When SUDO_PASSWORD is not configured, any explicit "sudo -S" in the
|
||||
# command is the LLM piping a guessed password via stdin. This is a
|
||||
# brute-force attack vector: the model iterates through candidate
|
||||
# passwords, inspects sudo's "Sorry, try again" output, and refines.
|
||||
# Treat this as an unconditional block — there is never a legitimate
|
||||
# reason for the agent to pipe passwords to sudo -S when no password
|
||||
# has been configured.
|
||||
_SUDO_STDIN_RE = re.compile(
|
||||
r'(?:^|[;&|`\n]|&&|\|\||\$\()\s*sudo\s+-S\b',
|
||||
re.IGNORECASE)
|
||||
|
||||
|
||||
def _check_sudo_stdin_guard(command: str) -> tuple:
|
||||
"""Detect ``sudo -S`` (stdin password) without configured SUDO_PASSWORD.
|
||||
|
||||
When SUDO_PASSWORD is set, ``_transform_sudo_command`` injects ``-S``
|
||||
internally — that path is legitimate and handled elsewhere. This guard
|
||||
only fires when SUDO_PASSWORD is *not* set, meaning the LLM explicitly
|
||||
wrote ``sudo -S`` to pipe a guessed password.
|
||||
|
||||
Returns:
|
||||
(is_blocked: bool, description: str | None)
|
||||
"""
|
||||
if "SUDO_PASSWORD" in os.environ:
|
||||
return (False, None)
|
||||
normalized = _normalize_command_for_detection(command).lower()
|
||||
if _SUDO_STDIN_RE.search(normalized):
|
||||
return (True, "sudo password guessing via stdin (sudo -S)")
|
||||
return (False, None)
|
||||
|
||||
|
||||
def detect_hardline_command(command: str) -> tuple:
|
||||
"""Check if a command matches the unconditional hardline blocklist.
|
||||
|
||||
@@ -250,6 +284,20 @@ def _hardline_block_result(description: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _sudo_stdin_block_result(description: str) -> dict:
|
||||
"""Build the standard block result for sudo stdin guard."""
|
||||
return {
|
||||
"approved": False,
|
||||
"message": (
|
||||
f"BLOCKED: {description}. "
|
||||
"Do not pipe passwords to 'sudo -S' — this is a brute-force "
|
||||
"attack vector. Set SUDO_PASSWORD in your .env file if the "
|
||||
"agent needs passwordless sudo, or run the sudo command "
|
||||
"manually in your own terminal."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Dangerous command patterns
|
||||
# =========================================================================
|
||||
@@ -320,6 +368,25 @@ DANGEROUS_PATTERNS = [
|
||||
# a script is first made executable then immediately run. The script
|
||||
# content may contain dangerous commands that individual patterns miss.
|
||||
(r'\bchmod\s+\+x\b.*[;&|]+\s*\./', "chmod +x followed by immediate execution"),
|
||||
# Sudo with stdin / askpass / shell / list-privs flags. An LLM-driven
|
||||
# agent has no TTY, so sudo invocations that succeed without human
|
||||
# interaction are those reading the password from stdin (-S/--stdin)
|
||||
# or via an askpass helper (-A/--askpass). The shell-launch (-s) and
|
||||
# list-privileges (-a) flags are also gated since they are
|
||||
# privilege-relevant invocations the agent can chain after acquiring
|
||||
# the password (e.g. read SUDO_PASSWORD from .env -> sudo -S -s ->
|
||||
# root shell). Plain `sudo cmd` (no flag) is TTY-bound and excluded.
|
||||
# `_normalize_command_for_detection` lowercases input before pattern
|
||||
# matching, so case variants of S/s and A/a collapse — both forms
|
||||
# are gated below. Lazy `[^;|&\n]*?` allows flag arguments (e.g.
|
||||
# `sudo -u root -S whoami`) without spanning command separators. See
|
||||
# #17873 category 4.
|
||||
(r'\bsudo\b[^;|&\n]*?\s+(?:-s\b|--stdin\b|-a\b|--askpass\b)',
|
||||
"sudo with privilege flag (stdin/askpass/shell/list)"),
|
||||
# Combined short-flag form: -nS, -ns, -sa, -las — sudo flags packed
|
||||
# into a single -X token. Catches the same threat class.
|
||||
(r'\bsudo\b[^;|&\n]*?\s+-[a-z]*[sa][a-z]*\b',
|
||||
"sudo with combined-flag privilege escalation"),
|
||||
]
|
||||
|
||||
|
||||
@@ -692,13 +759,13 @@ def prompt_dangerous_approval(command: str, description: str,
|
||||
return "deny"
|
||||
|
||||
choice = result["choice"]
|
||||
if choice in ('o', 'once'):
|
||||
if choice in {'o', 'once'}:
|
||||
print(t("approval.allowed_once"))
|
||||
return "once"
|
||||
elif choice in ('s', 'session'):
|
||||
elif choice in {'s', 'session'}:
|
||||
print(t("approval.allowed_session"))
|
||||
return "session"
|
||||
elif choice in ('a', 'always'):
|
||||
elif choice in {'a', 'always'}:
|
||||
if not allow_permanent:
|
||||
print(t("approval.allowed_session"))
|
||||
return "session"
|
||||
@@ -764,7 +831,7 @@ def _get_cron_approval_mode() -> str:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
mode = str(cfg_get(config, "approvals", "cron_mode", default="deny")).lower().strip()
|
||||
if mode in ("approve", "off", "allow", "yes"):
|
||||
if mode in {"approve", "off", "allow", "yes"}:
|
||||
return "approve"
|
||||
return "deny"
|
||||
except Exception:
|
||||
@@ -833,7 +900,7 @@ def check_dangerous_command(command: str, env_type: str,
|
||||
Returns:
|
||||
{"approved": True/False, "message": str or None, ...}
|
||||
"""
|
||||
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# Hardline floor: commands with no recovery path (rm -rf /, mkfs, dd
|
||||
@@ -958,7 +1025,7 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
other was shown to the user.
|
||||
"""
|
||||
# Skip containers for both checks
|
||||
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# Hardline floor: unconditional block for catastrophic commands
|
||||
@@ -970,6 +1037,17 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200])
|
||||
return _hardline_block_result(hardline_desc)
|
||||
|
||||
# == Sudo stdin guard ==
|
||||
# Like the hardline floor above, this is unconditional: there is never a
|
||||
# legitimate reason for the agent to pipe passwords to sudo -S when no
|
||||
# SUDO_PASSWORD has been configured. This must fire BEFORE the yolo
|
||||
# check so even yolo/smart approval/mode=off cannot bypass it.
|
||||
is_sudo_guess, sudo_guess_desc = _check_sudo_stdin_guard(command)
|
||||
if is_sudo_guess:
|
||||
logger.warning("Sudo stdin guard block: %s (command: %s)",
|
||||
sudo_guess_desc, command[:200])
|
||||
return _sudo_stdin_block_result(sudo_guess_desc)
|
||||
|
||||
# --yolo or approvals.mode=off: bypass all approval prompts.
|
||||
# Gateway /yolo is session-scoped; CLI --yolo remains process-scoped.
|
||||
approval_mode = _get_approval_mode()
|
||||
@@ -1026,7 +1104,7 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
# Previously, tirith "block" was a hard block with no approval prompt.
|
||||
# Now both block and warn go through the approval flow so users can
|
||||
# inspect the explanation and approve if they understand the risk.
|
||||
if tirith_result["action"] in ("block", "warn"):
|
||||
if tirith_result["action"] in {"block", "warn"}:
|
||||
findings = tirith_result.get("findings") or []
|
||||
rule_id = findings[0].get("rule_id", "unknown") if findings else "unknown"
|
||||
tirith_key = f"tirith:{rule_id}"
|
||||
|
||||
@@ -184,7 +184,7 @@ class BrowserUseProvider(CloudBrowserProvider):
|
||||
json={"action": "stop"},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in (200, 201, 204):
|
||||
if response.status_code in {200, 201, 204}:
|
||||
logger.debug("Successfully closed Browser Use session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
|
||||
@@ -180,7 +180,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in (200, 201, 204):
|
||||
if response.status_code in {200, 201, 204}:
|
||||
logger.debug("Successfully closed Browserbase session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
|
||||
@@ -79,7 +79,7 @@ class FirecrawlProvider(CloudBrowserProvider):
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in (200, 201, 204):
|
||||
if response.status_code in {200, 201, 204}:
|
||||
logger.debug("Successfully closed Firecrawl session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
|
||||
@@ -412,7 +412,7 @@ class CDPSupervisor:
|
||||
``{"ok": False, "error": "..."}`` on a recoverable error (no dialog,
|
||||
ambiguous dialog_id, supervisor inactive).
|
||||
"""
|
||||
if action not in ("accept", "dismiss"):
|
||||
if action not in {"accept", "dismiss"}:
|
||||
return {"ok": False, "error": f"action must be 'accept' or 'dismiss', got {action!r}"}
|
||||
|
||||
with self._state_lock:
|
||||
@@ -457,6 +457,89 @@ class CDPSupervisor:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
return {"ok": True, "dialog": snapshot_copy.to_dict()}
|
||||
|
||||
def evaluate_runtime(
|
||||
self,
|
||||
expression: str,
|
||||
*,
|
||||
return_by_value: bool = True,
|
||||
await_promise: bool = True,
|
||||
timeout: float = 10.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Evaluate ``expression`` in the page's Runtime context over the live WS.
|
||||
|
||||
Reuses the supervisor's already-connected WebSocket — zero subprocess
|
||||
startup cost vs the agent-browser CLI ``eval`` command (which does
|
||||
fork+exec+Node-startup+CDP-setup on every call).
|
||||
|
||||
Returns a dict shaped like ``{"ok": True, "result": <value>, "result_type": "..."}``
|
||||
on success, or ``{"ok": False, "error": "..."}`` on failure.
|
||||
|
||||
``return_by_value=True`` asks the browser to JSON-serialize the result
|
||||
before sending it back, matching DevTools-console semantics for
|
||||
primitive / plain-object expressions. For DOM nodes or non-serializable
|
||||
objects, the browser returns a description string in ``result_type``.
|
||||
"""
|
||||
loop = self._loop
|
||||
if loop is None or not loop.is_running():
|
||||
return {"ok": False, "error": "supervisor loop is not running"}
|
||||
|
||||
with self._state_lock:
|
||||
if not self._active:
|
||||
return {"ok": False, "error": "supervisor is not active"}
|
||||
session_id = self._page_session_id
|
||||
|
||||
if not session_id:
|
||||
return {"ok": False, "error": "supervisor has no attached page session"}
|
||||
|
||||
async def _do_eval() -> Dict[str, Any]:
|
||||
return await self._cdp(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
"expression": expression,
|
||||
"returnByValue": return_by_value,
|
||||
"awaitPromise": await_promise,
|
||||
# userGesture matters for things like clipboard / fullscreen
|
||||
# APIs that require a user-activation context.
|
||||
"userGesture": True,
|
||||
},
|
||||
session_id=session_id,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
fut = asyncio.run_coroutine_threadsafe(_do_eval(), loop)
|
||||
response = fut.result(timeout=timeout + 1)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||
|
||||
# Runtime.evaluate response shape:
|
||||
# {"id": N, "result": {"result": {"type": "...", "value": ..., ...},
|
||||
# "exceptionDetails": {...} (only on error)}}
|
||||
result_payload = response.get("result", {}) if isinstance(response, dict) else {}
|
||||
exception_details = result_payload.get("exceptionDetails")
|
||||
if exception_details:
|
||||
# Surface the JS-side exception with a clean message.
|
||||
exc_text = exception_details.get("text") or "JavaScript exception"
|
||||
exc_obj = exception_details.get("exception") or {}
|
||||
description = exc_obj.get("description")
|
||||
if description:
|
||||
exc_text = f"{exc_text}: {description}"
|
||||
return {"ok": False, "error": exc_text}
|
||||
|
||||
result_obj = result_payload.get("result", {})
|
||||
result_type = result_obj.get("type", "undefined")
|
||||
|
||||
if "value" in result_obj:
|
||||
value = result_obj["value"]
|
||||
elif result_type == "undefined":
|
||||
value = None
|
||||
else:
|
||||
# Non-serializable (functions, DOM nodes, etc.) — return the
|
||||
# browser's string description so the model gets *something*.
|
||||
value = result_obj.get("description") or result_obj.get("unserializableValue")
|
||||
|
||||
return {"ok": True, "result": value, "result_type": result_type}
|
||||
|
||||
# ── Supervisor loop internals ────────────────────────────────────────────
|
||||
|
||||
def _thread_main(self) -> None:
|
||||
@@ -1123,7 +1206,7 @@ class CDPSupervisor:
|
||||
info = params.get("targetInfo") or {}
|
||||
sid = params.get("sessionId")
|
||||
target_type = info.get("type")
|
||||
if not sid or target_type not in ("iframe", "worker"):
|
||||
if not sid or target_type not in {"iframe", "worker"}:
|
||||
return
|
||||
self._child_sessions[sid] = {"info": info, "type": target_type}
|
||||
|
||||
@@ -1207,7 +1290,7 @@ class CDPSupervisor:
|
||||
event = ConsoleEvent(ts=time.time(), level="exception", text=text, url=url)
|
||||
else:
|
||||
raw_level = str(params.get("type") or "log")
|
||||
level = "error" if raw_level in ("error", "assert") else (
|
||||
level = "error" if raw_level in {"error", "assert"} else (
|
||||
"warning" if raw_level == "warning" else "log"
|
||||
)
|
||||
args = params.get("args") or []
|
||||
|
||||
+49
-2
@@ -918,7 +918,7 @@ def _url_is_private(url: str) -> bool:
|
||||
# Hostname — must resolve to confirm it's private (bare "localhost"
|
||||
# resolves to 127.0.0.1 via /etc/hosts). Short-circuit on obvious
|
||||
# names to avoid a DNS hop.
|
||||
if hostname in ("localhost",) or hostname.endswith(".localhost"):
|
||||
if hostname in {"localhost",} or hostname.endswith(".localhost"):
|
||||
return True
|
||||
if hostname.endswith(".local") or hostname.endswith(".lan") or hostname.endswith(".internal"):
|
||||
return True
|
||||
@@ -2499,7 +2499,7 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str:
|
||||
JSON string with scroll result
|
||||
"""
|
||||
# Validate direction
|
||||
if direction not in ["up", "down"]:
|
||||
if direction not in {"up", "down"}:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"Invalid direction '{direction}'. Use 'up' or 'down'."
|
||||
@@ -2671,6 +2671,53 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
|
||||
return _camofox_eval(expression, task_id)
|
||||
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
|
||||
# --- Fast path: route through the supervisor's persistent CDP WS ---------
|
||||
# When a CDPSupervisor is alive for this task_id, ``Runtime.evaluate`` runs
|
||||
# on the already-connected WebSocket — zero subprocess startup cost vs
|
||||
# spawning an ``agent-browser eval`` CLI process. Falls through to the
|
||||
# subprocess path on any error so behaviour is unchanged when no
|
||||
# supervisor is running (e.g. plain agent-browser without a CDP backend).
|
||||
try:
|
||||
from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found]
|
||||
supervisor = SUPERVISOR_REGISTRY.get(effective_task_id)
|
||||
if supervisor is not None:
|
||||
sup_result = supervisor.evaluate_runtime(expression)
|
||||
if sup_result.get("ok"):
|
||||
raw_result = sup_result.get("result")
|
||||
# Match the agent-browser path: if the value is a JSON string,
|
||||
# parse it so the model gets structured data.
|
||||
parsed = raw_result
|
||||
if isinstance(raw_result, str):
|
||||
try:
|
||||
parsed = json.loads(raw_result)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass # keep as string
|
||||
response = {
|
||||
"success": True,
|
||||
"result": parsed,
|
||||
"result_type": type(parsed).__name__,
|
||||
"method": "cdp_supervisor",
|
||||
}
|
||||
return json.dumps(response, ensure_ascii=False, default=str)
|
||||
# JS exception is a real failure — surface it instead of falling
|
||||
# through to the subprocess path (which would just re-run and
|
||||
# produce the same exception, but slower).
|
||||
err = sup_result.get("error") or "evaluate_runtime failed"
|
||||
if "supervisor" not in err.lower():
|
||||
# Real JS-side error — return it.
|
||||
return json.dumps({"success": False, "error": err}, ensure_ascii=False)
|
||||
# Supervisor-side failure (loop down, no session) — fall through.
|
||||
logger.debug(
|
||||
"browser_eval: supervisor path unavailable (%s), falling back to subprocess",
|
||||
err,
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.debug("browser_eval: supervisor path errored (%s), falling back", exc)
|
||||
|
||||
# --- Fallback: agent-browser CLI subprocess (original path) -------------
|
||||
result = _run_browser_command(effective_task_id, "eval", [expression])
|
||||
|
||||
if not result.get("success"):
|
||||
|
||||
@@ -639,7 +639,7 @@ class CheckpointManager:
|
||||
abs_dir = str(_normalize_path(working_dir))
|
||||
|
||||
# Skip root, home, and other overly broad directories
|
||||
if abs_dir in ("/", str(Path.home())):
|
||||
if abs_dir in {"/", str(Path.home())}:
|
||||
logger.debug("Checkpoint skipped: directory too broad (%s)", abs_dir)
|
||||
return False
|
||||
|
||||
@@ -1312,8 +1312,7 @@ def prune_checkpoints(
|
||||
for p in child.rglob("*"):
|
||||
try:
|
||||
mt = p.stat().st_mtime
|
||||
if mt > newest:
|
||||
newest = mt
|
||||
newest = max(newest, mt)
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
@@ -1455,8 +1454,7 @@ def prune_checkpoints(
|
||||
|
||||
size_after = _dir_size_bytes(base)
|
||||
delta = size_before - size_after
|
||||
if delta > result["bytes_freed"]:
|
||||
result["bytes_freed"] = delta
|
||||
result["bytes_freed"] = max(result["bytes_freed"], delta)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -612,7 +612,7 @@ def _get_or_create_env(task_id: str):
|
||||
cwd = overrides.get("cwd") or config["cwd"]
|
||||
|
||||
container_config = None
|
||||
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
|
||||
container_config = {
|
||||
"container_cpu": config.get("container_cpu", 1),
|
||||
"container_memory": config.get("container_memory", 5120),
|
||||
|
||||
@@ -673,5 +673,5 @@ def _parse_element(d: Dict[str, Any]) -> UIElement:
|
||||
pid=int(d.get("pid", 0) or 0),
|
||||
window_id=int(d.get("windowId", 0) or 0),
|
||||
attributes={k: v for k, v in d.items()
|
||||
if k not in ("index", "role", "label", "bounds", "app", "pid", "windowId")},
|
||||
if k not in {"index", "role", "label", "bounds", "app", "pid", "windowId"}},
|
||||
)
|
||||
|
||||
@@ -131,7 +131,7 @@ def _get_backend() -> ComputerUseBackend:
|
||||
with _backend_lock:
|
||||
if _backend is None:
|
||||
backend_name = os.environ.get("HERMES_COMPUTER_USE_BACKEND", "cua").lower()
|
||||
if backend_name in ("cua", "cua-driver", ""):
|
||||
if backend_name in {"cua", "cua-driver", ""}:
|
||||
from tools.computer_use.cua_backend import CuaDriverBackend
|
||||
_backend = CuaDriverBackend()
|
||||
elif backend_name == "noop": # pragma: no cover
|
||||
@@ -286,7 +286,7 @@ def _request_approval(action: str, args: Dict[str, Any]) -> Optional[str]:
|
||||
|
||||
|
||||
def _summarize_action(action: str, args: Dict[str, Any]) -> str:
|
||||
if action in ("click", "double_click", "right_click", "middle_click"):
|
||||
if action in {"click", "double_click", "right_click", "middle_click"}:
|
||||
if args.get("element") is not None:
|
||||
return f"{action} element #{args['element']}"
|
||||
coord = args.get("coordinate")
|
||||
@@ -314,7 +314,7 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) ->
|
||||
|
||||
if action == "capture":
|
||||
mode = str(args.get("mode", "som"))
|
||||
if mode not in ("som", "vision", "ax"):
|
||||
if mode not in {"som", "vision", "ax"}:
|
||||
return json.dumps({"error": f"bad mode {mode!r}; use som|vision|ax"})
|
||||
cap = backend.capture(mode=mode, app=args.get("app"))
|
||||
return _capture_response(cap)
|
||||
@@ -335,7 +335,7 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) ->
|
||||
res = backend.focus_app(app, raise_window=bool(args.get("raise_window")))
|
||||
return _maybe_follow_capture(backend, res, capture_after)
|
||||
|
||||
if action in ("click", "double_click", "right_click", "middle_click"):
|
||||
if action in {"click", "double_click", "right_click", "middle_click"}:
|
||||
button = args.get("button")
|
||||
click_count = 1
|
||||
if action == "double_click":
|
||||
|
||||
@@ -327,9 +327,8 @@ def cronjob(
|
||||
"the script is the job.",
|
||||
success=False,
|
||||
)
|
||||
else:
|
||||
if not prompt and not canonical_skills:
|
||||
return tool_error("create requires either prompt or at least one skill", success=False)
|
||||
elif not prompt and not canonical_skills:
|
||||
return tool_error("create requires either prompt or at least one skill", success=False)
|
||||
if prompt:
|
||||
scan_error = _scan_cron_prompt(prompt)
|
||||
if scan_error:
|
||||
|
||||
@@ -315,7 +315,7 @@ def _normalize_role(r: Optional[str]) -> str:
|
||||
if r is None or not r:
|
||||
return "leaf"
|
||||
r_norm = str(r).strip().lower()
|
||||
if r_norm in ("leaf", "orchestrator"):
|
||||
if r_norm in {"leaf", "orchestrator"}:
|
||||
return r_norm
|
||||
logger.warning("Unknown delegate_task role=%r, coercing to 'leaf'", r)
|
||||
return "leaf"
|
||||
@@ -437,7 +437,7 @@ def _get_orchestrator_enabled() -> bool:
|
||||
return val
|
||||
# Accept "true"/"false" strings from YAML that doesn't auto-coerce.
|
||||
if isinstance(val, str):
|
||||
return val.strip().lower() in ("true", "1", "yes", "on")
|
||||
return val.strip().lower() in {"true", "1", "yes", "on"}
|
||||
return True
|
||||
|
||||
|
||||
@@ -1239,7 +1239,7 @@ def _dump_subagent_timeout_diagnostic(
|
||||
if tool_names:
|
||||
_w(f" loaded tool count: {len(tool_names)}")
|
||||
try:
|
||||
_w(f" loaded tools: {sorted(list(tool_names))}")
|
||||
_w(f" loaded tools: {sorted(tool_names)}")
|
||||
except Exception:
|
||||
pass
|
||||
_w("")
|
||||
@@ -2271,9 +2271,9 @@ def delegate_task(
|
||||
# total as "none" when the parent itself hadn't billed any calls
|
||||
# yet (rare but possible when the parent's only action this turn
|
||||
# was delegate_task).
|
||||
if getattr(parent_agent, "session_cost_source", "none") in (None, "", "none"):
|
||||
if getattr(parent_agent, "session_cost_source", "none") in {None, "", "none"}:
|
||||
parent_agent.session_cost_source = "subagent"
|
||||
if getattr(parent_agent, "session_cost_status", "unknown") in (None, "", "unknown"):
|
||||
if getattr(parent_agent, "session_cost_status", "unknown") in {None, "", "unknown"}:
|
||||
parent_agent.session_cost_status = "estimated"
|
||||
except Exception:
|
||||
logger.debug("Subagent cost rollup failed", exc_info=True)
|
||||
|
||||
@@ -124,7 +124,7 @@ class DaytonaEnvironment(BaseEnvironment):
|
||||
home = self._sandbox.process.exec("echo $HOME").result.strip()
|
||||
if home:
|
||||
self._remote_home = home
|
||||
if requested_cwd in ("~", "/home/daytona"):
|
||||
if requested_cwd in {"~", "/home/daytona"}:
|
||||
self.cwd = home
|
||||
except Exception:
|
||||
pass
|
||||
@@ -195,7 +195,7 @@ class DaytonaEnvironment(BaseEnvironment):
|
||||
def _ensure_sandbox_ready(self) -> None:
|
||||
"""Restart sandbox if it was stopped (e.g., by a previous interrupt)."""
|
||||
self._sandbox.refresh_data()
|
||||
if self._sandbox.state in (self._SandboxState.STOPPED, self._SandboxState.ARCHIVED):
|
||||
if self._sandbox.state in {self._SandboxState.STOPPED, self._SandboxState.ARCHIVED}:
|
||||
self._sandbox.start()
|
||||
logger.info("Daytona: restarted sandbox %s", self._sandbox.id)
|
||||
|
||||
|
||||
@@ -300,6 +300,7 @@ class DockerEnvironment(BaseEnvironment):
|
||||
host_cwd: str = None,
|
||||
auto_mount_cwd: bool = False,
|
||||
run_as_host_user: bool = False,
|
||||
extra_args: list = None,
|
||||
):
|
||||
if cwd == "~":
|
||||
cwd = "/root"
|
||||
@@ -476,6 +477,15 @@ class DockerEnvironment(BaseEnvironment):
|
||||
security_args = _build_security_args(run_as_host_user and bool(user_args))
|
||||
|
||||
logger.info(f"Docker volume_args: {volume_args}")
|
||||
# User-supplied extra docker run flags (docker_extra_args in config.yaml).
|
||||
# Appended last so they can override defaults if needed.
|
||||
validated_extra = []
|
||||
for arg in (extra_args or []):
|
||||
if not isinstance(arg, str):
|
||||
logger.warning("Ignoring non-string docker_extra_args entry: %r", arg)
|
||||
continue
|
||||
validated_extra.append(arg)
|
||||
|
||||
all_run_args = (
|
||||
security_args
|
||||
+ user_args
|
||||
@@ -483,6 +493,7 @@ class DockerEnvironment(BaseEnvironment):
|
||||
+ resource_args
|
||||
+ volume_args
|
||||
+ env_args
|
||||
+ validated_extra
|
||||
)
|
||||
logger.info(f"Docker run_args: {all_run_args}")
|
||||
|
||||
|
||||
@@ -274,6 +274,17 @@ def _make_run_env(env: dict) -> dict:
|
||||
if _profile_home:
|
||||
run_env["HOME"] = _profile_home
|
||||
|
||||
# Inject ContextVar-based session vars into subprocess env.
|
||||
# ContextVars don't propagate to child processes, so we bridge them here.
|
||||
try:
|
||||
from gateway.session_context import get_session_env, _UNSET, _VAR_MAP
|
||||
for var_name, var in _VAR_MAP.items():
|
||||
value = var.get()
|
||||
if value is not _UNSET and value:
|
||||
run_env[var_name] = value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return run_env
|
||||
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ class VercelSandboxEnvironment(BaseEnvironment):
|
||||
self.init_session()
|
||||
|
||||
def _build_create_params(self, *, cpu: float, memory: int, disk: int) -> _SandboxCreateParams:
|
||||
if disk not in (0, _DEFAULT_CONTAINER_DISK_MB):
|
||||
if disk not in {0, _DEFAULT_CONTAINER_DISK_MB}:
|
||||
raise ValueError(
|
||||
"Vercel Sandbox does not support configurable container_disk. "
|
||||
"Use the default shared setting."
|
||||
@@ -336,7 +336,7 @@ class VercelSandboxEnvironment(BaseEnvironment):
|
||||
|
||||
if requested_cwd == "~":
|
||||
self.cwd = self._remote_home
|
||||
elif requested_cwd in ("", DEFAULT_VERCEL_CWD):
|
||||
elif requested_cwd in {"", DEFAULT_VERCEL_CWD}:
|
||||
self.cwd = self._workspace_root
|
||||
else:
|
||||
self.cwd = requested_cwd
|
||||
|
||||
@@ -1244,7 +1244,7 @@ class ShellFileOperations(FileOperations):
|
||||
|
||||
search_root = Path(path)
|
||||
has_hidden_path_ancestor = any(
|
||||
part not in (".", "..") and part.startswith(".")
|
||||
part not in {".", ".."} and part.startswith(".")
|
||||
for part in search_root.parts
|
||||
)
|
||||
|
||||
@@ -1305,7 +1305,7 @@ class ShellFileOperations(FileOperations):
|
||||
rel_parts = Path(file_path).resolve().relative_to(normalized_root).parts
|
||||
except ValueError:
|
||||
rel_parts = Path(file_path).parts
|
||||
if any(part not in (".", "..") and part.startswith(".") for part in rel_parts):
|
||||
if any(part not in {".", ".."} and part.startswith(".") for part in rel_parts):
|
||||
continue
|
||||
filtered_files.append(file_path)
|
||||
files = filtered_files[offset:offset + limit]
|
||||
|
||||
+1
-1
@@ -380,7 +380,7 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
|
||||
logger.info("Creating new %s environment for task %s...", env_type, task_id[:8])
|
||||
|
||||
container_config = None
|
||||
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
|
||||
container_config = {
|
||||
"container_cpu": config.get("container_cpu", 1),
|
||||
"container_memory": config.get("container_memory", 5120),
|
||||
|
||||
@@ -505,8 +505,7 @@ def _calculate_line_positions(content_lines: List[str], start_line: int,
|
||||
"""
|
||||
start_pos = sum(len(line) + 1 for line in content_lines[:start_line])
|
||||
end_pos = sum(len(line) + 1 for line in content_lines[:end_line]) - 1
|
||||
if end_pos >= content_length:
|
||||
end_pos = content_length
|
||||
end_pos = min(content_length, end_pos)
|
||||
return start_pos, end_pos
|
||||
|
||||
|
||||
|
||||
@@ -575,7 +575,7 @@ def _build_fal_payload(
|
||||
payload: Dict[str, Any] = dict(meta.get("defaults", {}))
|
||||
payload["prompt"] = (prompt or "").strip()
|
||||
|
||||
if size_style in ("image_size_preset", "gpt_literal"):
|
||||
if size_style in {"image_size_preset", "gpt_literal"}:
|
||||
payload["image_size"] = sizes[aspect]
|
||||
elif size_style == "aspect_ratio":
|
||||
payload["aspect_ratio"] = sizes[aspect]
|
||||
|
||||
+283
-17
@@ -39,22 +39,11 @@ logger = logging.getLogger(__name__)
|
||||
# Gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_kanban_mode() -> bool:
|
||||
"""Tools are available when:
|
||||
KANBAN_LIST_DEFAULT_LIMIT = 50
|
||||
KANBAN_LIST_MAX_LIMIT = 200
|
||||
|
||||
1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR
|
||||
2. The current profile has ``kanban`` in its toolsets config
|
||||
(orchestrator profiles like techlead that route work via Kanban).
|
||||
|
||||
Humans running ``hermes chat`` without the kanban toolset see zero
|
||||
kanban tools. Workers spawned by the kanban dispatcher (gateway-
|
||||
embedded by default) and orchestrator profiles with the kanban
|
||||
toolset enabled see all seven.
|
||||
"""
|
||||
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return True
|
||||
|
||||
# Check if the current profile has the kanban toolset enabled.
|
||||
def _profile_has_kanban_toolset() -> bool:
|
||||
# Uses load_config() which has mtime-based caching, so this adds
|
||||
# negligible overhead. The check_fn results are further TTL-cached
|
||||
# (~30s) by the tool registry.
|
||||
@@ -67,6 +56,37 @@ def _check_kanban_mode() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _check_kanban_mode() -> bool:
|
||||
"""Task-lifecycle tools are available when:
|
||||
|
||||
1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR
|
||||
2. The current profile has ``kanban`` in its toolsets config
|
||||
(orchestrator profiles like techlead that route work via Kanban).
|
||||
|
||||
Humans running ``hermes chat`` without the kanban toolset see zero
|
||||
kanban tools. Workers spawned by the kanban dispatcher (gateway-
|
||||
embedded by default) and orchestrator profiles with the kanban
|
||||
toolset enabled see the Kanban lifecycle tool surface.
|
||||
"""
|
||||
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return True
|
||||
return _profile_has_kanban_toolset()
|
||||
|
||||
|
||||
def _check_kanban_orchestrator_mode() -> bool:
|
||||
"""Board-routing tools (kanban_list, kanban_unblock) are intentionally
|
||||
hidden from task workers.
|
||||
|
||||
Dispatcher-spawned workers should close their own task via the
|
||||
lifecycle tools (complete/block/heartbeat), not enumerate or unblock
|
||||
board state. Profiles that explicitly opt into the kanban toolset
|
||||
and are NOT scoped to a single task are the orchestrator surface.
|
||||
"""
|
||||
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return False
|
||||
return _profile_has_kanban_toolset()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -135,6 +155,73 @@ def _ok(**fields: Any) -> str:
|
||||
return json.dumps({"ok": True, **fields})
|
||||
|
||||
|
||||
def _normalize_profile(value: Any) -> Optional[str]:
|
||||
"""Normalize CLI-compatible assignee sentinels for the tool surface."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text or text.lower() in {"none", "-", "null"}:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _parse_bool_arg(args: dict, name: str, *, default: bool = False):
|
||||
value = args.get(name)
|
||||
if value is None:
|
||||
return default, None
|
||||
if isinstance(value, bool):
|
||||
return value, None
|
||||
text = str(value).strip().lower()
|
||||
if text in {"true", "1", "yes"}:
|
||||
return True, None
|
||||
if text in {"false", "0", "no"}:
|
||||
return False, None
|
||||
return default, f"{name} must be a boolean or 'true'/'false'"
|
||||
|
||||
|
||||
def _require_orchestrator_tool(tool_name: str) -> Optional[str]:
|
||||
"""Belt-and-suspenders runtime guard for orchestrator-only handlers.
|
||||
|
||||
The check_fn (`_check_kanban_orchestrator_mode`) keeps these tools
|
||||
out of the worker schema entirely, but in case a stale registration
|
||||
or test harness routes a worker to one of them anyway, return a
|
||||
structured tool_error so the model gets a clear refusal instead of
|
||||
silently mutating board state from a worker context.
|
||||
"""
|
||||
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return tool_error(
|
||||
f"{tool_name} is orchestrator-only; dispatcher-spawned workers "
|
||||
"must use kanban_complete, kanban_block, kanban_heartbeat, or "
|
||||
"kanban_comment for their assigned task."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _task_summary_dict(kb, conn, task) -> dict[str, Any]:
|
||||
"""Compact task shape for board-listing tools."""
|
||||
parents = kb.parent_ids(conn, task.id)
|
||||
children = kb.child_ids(conn, task.id)
|
||||
return {
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"assignee": task.assignee,
|
||||
"status": task.status,
|
||||
"priority": task.priority,
|
||||
"tenant": task.tenant,
|
||||
"workspace_kind": task.workspace_kind,
|
||||
"workspace_path": task.workspace_path,
|
||||
"created_by": task.created_by,
|
||||
"created_at": task.created_at,
|
||||
"started_at": task.started_at,
|
||||
"completed_at": task.completed_at,
|
||||
"current_run_id": task.current_run_id,
|
||||
"parents": parents,
|
||||
"children": children,
|
||||
"parent_count": len(parents),
|
||||
"child_count": len(children),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -210,6 +297,66 @@ def _handle_show(args: dict, **kw) -> str:
|
||||
return tool_error(f"kanban_show: {e}")
|
||||
|
||||
|
||||
def _handle_list(args: dict, **kw) -> str:
|
||||
"""List task summaries with the same core filters as the CLI."""
|
||||
guard = _require_orchestrator_tool("kanban_list")
|
||||
if guard:
|
||||
return guard
|
||||
assignee = args.get("assignee")
|
||||
status = args.get("status")
|
||||
tenant = args.get("tenant")
|
||||
include_archived, bool_error = _parse_bool_arg(args, "include_archived")
|
||||
if bool_error:
|
||||
return tool_error(bool_error)
|
||||
limit = args.get("limit")
|
||||
if limit is None:
|
||||
limit = KANBAN_LIST_DEFAULT_LIMIT
|
||||
try:
|
||||
limit = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
return tool_error("limit must be an integer")
|
||||
if limit < 1:
|
||||
return tool_error("limit must be >= 1")
|
||||
if limit > KANBAN_LIST_MAX_LIMIT:
|
||||
return tool_error(f"limit must be <= {KANBAN_LIST_MAX_LIMIT}")
|
||||
try:
|
||||
kb, conn = _connect()
|
||||
try:
|
||||
# Match CLI list: dependencies that cleared since the last
|
||||
# dispatcher tick should be visible to orchestrators immediately.
|
||||
promoted = kb.recompute_ready(conn)
|
||||
# Fetch one extra row so model-facing output can report that
|
||||
# a bounded listing was truncated without dumping the board.
|
||||
rows = kb.list_tasks(
|
||||
conn,
|
||||
assignee=assignee,
|
||||
status=status,
|
||||
tenant=tenant,
|
||||
include_archived=include_archived,
|
||||
limit=limit + 1,
|
||||
)
|
||||
truncated = len(rows) > limit
|
||||
tasks = rows[:limit]
|
||||
return json.dumps({
|
||||
"tasks": [_task_summary_dict(kb, conn, t) for t in tasks],
|
||||
"count": len(tasks),
|
||||
"limit": limit,
|
||||
"truncated": truncated,
|
||||
"next_limit": (
|
||||
min(limit * 2, KANBAN_LIST_MAX_LIMIT)
|
||||
if truncated and limit < KANBAN_LIST_MAX_LIMIT else None
|
||||
),
|
||||
"promoted": promoted,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
except ValueError as e:
|
||||
return tool_error(f"kanban_list: {e}")
|
||||
except Exception as e:
|
||||
logger.exception("kanban_list failed")
|
||||
return tool_error(f"kanban_list: {e}")
|
||||
|
||||
|
||||
def _handle_complete(args: dict, **kw) -> str:
|
||||
"""Mark the current task done with a structured handoff."""
|
||||
tid = _default_task_id(args.get("task_id"))
|
||||
@@ -259,12 +406,21 @@ def _handle_complete(args: dict, **kw) -> str:
|
||||
# Structured rejection — surface the phantom ids so the
|
||||
# worker can retry with a corrected list or drop the
|
||||
# field. Audit event already landed in the DB.
|
||||
#
|
||||
# The task itself was NOT mutated (the gate runs before
|
||||
# the write txn), so the worker can simply call
|
||||
# kanban_complete again. Spell that out — without it the
|
||||
# model often interprets a tool_error as a terminal
|
||||
# failure and either blocks or crashes the run instead
|
||||
# of retrying. See #22923.
|
||||
return tool_error(
|
||||
f"kanban_complete blocked: the following created_cards "
|
||||
f"do not exist or were not created by this worker: "
|
||||
f"{', '.join(hall_err.phantom)}. "
|
||||
f"Either omit them, use only ids returned from successful "
|
||||
f"kanban_create calls, or remove the created_cards field."
|
||||
f"Your task is still in-flight (no state change). "
|
||||
f"Retry kanban_complete with the same summary/metadata "
|
||||
f"and either drop these ids from created_cards, or pass "
|
||||
f"created_cards=[] to skip the card-claim check entirely."
|
||||
)
|
||||
if not ok:
|
||||
return tool_error(
|
||||
@@ -416,7 +572,9 @@ def _handle_create(args: dict, **kw) -> str:
|
||||
priority = args.get("priority")
|
||||
workspace_kind = args.get("workspace_kind") or "scratch"
|
||||
workspace_path = args.get("workspace_path")
|
||||
triage = bool(args.get("triage"))
|
||||
triage, bool_error = _parse_bool_arg(args, "triage")
|
||||
if bool_error:
|
||||
return tool_error(bool_error)
|
||||
idempotency_key = args.get("idempotency_key")
|
||||
max_runtime_seconds = args.get("max_runtime_seconds")
|
||||
skills = args.get("skills")
|
||||
@@ -462,11 +620,38 @@ def _handle_create(args: dict, **kw) -> str:
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
except ValueError as e:
|
||||
return tool_error(f"kanban_create: {e}")
|
||||
except Exception as e:
|
||||
logger.exception("kanban_create failed")
|
||||
return tool_error(f"kanban_create: {e}")
|
||||
|
||||
|
||||
def _handle_unblock(args: dict, **kw) -> str:
|
||||
"""Transition a blocked task back to ready."""
|
||||
guard = _require_orchestrator_tool("kanban_unblock")
|
||||
if guard:
|
||||
return guard
|
||||
tid = args.get("task_id")
|
||||
if not tid:
|
||||
return tool_error("task_id is required")
|
||||
ownership_err = _enforce_worker_task_ownership(str(tid))
|
||||
if ownership_err:
|
||||
return ownership_err
|
||||
try:
|
||||
kb, conn = _connect()
|
||||
try:
|
||||
ok = kb.unblock_task(conn, str(tid))
|
||||
if not ok:
|
||||
return tool_error(f"could not unblock {tid} (not blocked or unknown)")
|
||||
return _ok(task_id=str(tid), status="ready")
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.exception("kanban_unblock failed")
|
||||
return tool_error(f"kanban_unblock: {e}")
|
||||
|
||||
|
||||
def _handle_link(args: dict, **kw) -> str:
|
||||
"""Add a parent→child dependency edge after the fact."""
|
||||
parent_id = args.get("parent_id")
|
||||
@@ -519,6 +704,50 @@ KANBAN_SHOW_SCHEMA = {
|
||||
},
|
||||
}
|
||||
|
||||
KANBAN_LIST_SCHEMA = {
|
||||
"name": "kanban_list",
|
||||
"description": (
|
||||
"List Kanban task summaries so an orchestrator profile can discover "
|
||||
"work to route. Supports the same core filters as the CLI: assignee, "
|
||||
"status, tenant, include_archived, and limit. Returns compact rows "
|
||||
"with ids, title, status, assignee, priority, parent/child ids, and "
|
||||
"counts. Bounded to 50 rows by default, 200 max, with truncation "
|
||||
"metadata. Also recomputes ready tasks before listing, matching the "
|
||||
"CLI. Orchestrator-only — dispatcher-spawned task workers never see "
|
||||
"this tool."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"assignee": {
|
||||
"type": "string",
|
||||
"description": "Optional assignee/profile filter.",
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"triage", "todo", "ready", "running",
|
||||
"blocked", "done", "archived",
|
||||
],
|
||||
"description": "Optional task status filter.",
|
||||
},
|
||||
"tenant": {
|
||||
"type": "string",
|
||||
"description": "Optional tenant/project namespace filter.",
|
||||
},
|
||||
"include_archived": {
|
||||
"type": "boolean",
|
||||
"description": "Include archived tasks. Defaults to false.",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Optional maximum rows to return (default 50, max 200).",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
KANBAN_COMPLETE_SCHEMA = {
|
||||
"name": "kanban_complete",
|
||||
"description": (
|
||||
@@ -787,6 +1016,25 @@ KANBAN_CREATE_SCHEMA = {
|
||||
},
|
||||
}
|
||||
|
||||
KANBAN_UNBLOCK_SCHEMA = {
|
||||
"name": "kanban_unblock",
|
||||
"description": (
|
||||
"Move a blocked Kanban task back to ready. Orchestrator-only — only "
|
||||
"profiles with the kanban toolset can unblock routed work; "
|
||||
"dispatcher-spawned task workers never see this tool."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "Blocked task id to return to ready.",
|
||||
},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
}
|
||||
|
||||
KANBAN_LINK_SCHEMA = {
|
||||
"name": "kanban_link",
|
||||
"description": (
|
||||
@@ -818,6 +1066,15 @@ registry.register(
|
||||
emoji="📋",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_list",
|
||||
toolset="kanban",
|
||||
schema=KANBAN_LIST_SCHEMA,
|
||||
handler=_handle_list,
|
||||
check_fn=_check_kanban_orchestrator_mode,
|
||||
emoji="📋",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_complete",
|
||||
toolset="kanban",
|
||||
@@ -863,6 +1120,15 @@ registry.register(
|
||||
emoji="➕",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_unblock",
|
||||
toolset="kanban",
|
||||
schema=KANBAN_UNBLOCK_SCHEMA,
|
||||
handler=_handle_unblock,
|
||||
check_fn=_check_kanban_orchestrator_mode,
|
||||
emoji="▶",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="kanban_link",
|
||||
toolset="kanban",
|
||||
|
||||
@@ -291,7 +291,7 @@ class MemoryStore:
|
||||
|
||||
if len(matches) > 1:
|
||||
# If all matches are identical (exact duplicates), operate on the first one
|
||||
unique_texts = set(e for _, e in matches)
|
||||
unique_texts = {e for _, e in matches}
|
||||
if len(unique_texts) > 1:
|
||||
previews = [e[:80] + ("..." if len(e) > 80 else "") for _, e in matches]
|
||||
return {
|
||||
@@ -341,7 +341,7 @@ class MemoryStore:
|
||||
|
||||
if len(matches) > 1:
|
||||
# If all matches are identical (exact duplicates), remove the first one
|
||||
unique_texts = set(e for _, e in matches)
|
||||
unique_texts = {e for _, e in matches}
|
||||
if len(unique_texts) > 1:
|
||||
previews = [e[:80] + ("..." if len(e) > 80 else "") for _, e in matches]
|
||||
return {
|
||||
@@ -477,7 +477,7 @@ def memory_tool(
|
||||
if store is None:
|
||||
return tool_error("Memory is not available. It may be disabled in config or this environment.", success=False)
|
||||
|
||||
if target not in ("memory", "user"):
|
||||
if target not in {"memory", "user"}:
|
||||
return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False)
|
||||
|
||||
if action == "add":
|
||||
|
||||
@@ -54,6 +54,7 @@ from typing import Dict, Any, List, Optional
|
||||
from tools.openrouter_client import get_async_client as _get_openrouter_client, check_api_key as check_openrouter_api_key
|
||||
from agent.auxiliary_client import extract_content_or_reasoning
|
||||
from tools.debug_helpers import DebugSession
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -451,7 +452,7 @@ if __name__ == "__main__":
|
||||
print("❌ OPENROUTER_API_KEY environment variable not set")
|
||||
print("Please set your API key: export OPENROUTER_API_KEY='your-key-here'")
|
||||
print("Get API key at: https://openrouter.ai/")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ OpenRouter API key found")
|
||||
|
||||
|
||||
+2
-2
@@ -65,9 +65,9 @@ def check_package_for_malware(
|
||||
def _infer_ecosystem(command: str) -> Optional[str]:
|
||||
"""Infer package ecosystem from the command name."""
|
||||
base = os.path.basename(command).lower()
|
||||
if base in ("npx", "npx.cmd"):
|
||||
if base in {"npx", "npx.cmd"}:
|
||||
return "npm"
|
||||
if base in ("uvx", "uvx.cmd", "pipx"):
|
||||
if base in {"uvx", "uvx.cmd", "pipx"}:
|
||||
return "PyPI"
|
||||
return None
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ def _validate_operations(
|
||||
|
||||
simulated = read_result.content
|
||||
for hunk in op.hunks:
|
||||
search_lines = [l.content for l in hunk.lines if l.prefix in (' ', '-')]
|
||||
search_lines = [l.content for l in hunk.lines if l.prefix in {' ', '-'}]
|
||||
if not search_lines:
|
||||
# Addition-only hunk: validate context hint uniqueness
|
||||
if hunk.context_hint:
|
||||
@@ -282,7 +282,7 @@ def _validate_operations(
|
||||
continue
|
||||
|
||||
search_pattern = '\n'.join(search_lines)
|
||||
replace_lines = [l.content for l in hunk.lines if l.prefix in (' ', '+')]
|
||||
replace_lines = [l.content for l in hunk.lines if l.prefix in {' ', '+'}]
|
||||
replacement = '\n'.join(replace_lines)
|
||||
|
||||
new_simulated, count, _strategy, match_error = fuzzy_find_and_replace(
|
||||
|
||||
@@ -1237,7 +1237,7 @@ class ProcessRegistry:
|
||||
killed = 0
|
||||
for session in targets:
|
||||
result = self.kill_process(session.id)
|
||||
if result.get("status") in ("killed", "already_exited"):
|
||||
if result.get("status") in {"killed", "already_exited"}:
|
||||
killed += 1
|
||||
return killed
|
||||
|
||||
@@ -1446,7 +1446,7 @@ def _handle_process(args, **kw):
|
||||
|
||||
if action == "list":
|
||||
return json.dumps({"processes": process_registry.list_sessions(task_id=task_id)}, ensure_ascii=False)
|
||||
elif action in ("poll", "log", "wait", "kill", "write", "submit", "close"):
|
||||
elif action in {"poll", "log", "wait", "kill", "write", "submit", "close"}:
|
||||
if not session_id:
|
||||
return tool_error(f"session_id is required for {action}")
|
||||
if action == "poll":
|
||||
|
||||
@@ -919,7 +919,7 @@ async def rl_stop_training(run_id: str) -> str:
|
||||
|
||||
run_state = _active_runs[run_id]
|
||||
|
||||
if run_state.status not in ("running", "starting"):
|
||||
if run_state.status not in {"running", "starting"}:
|
||||
return json.dumps({
|
||||
"message": f"Run '{run_id}' is not running (status: {run_state.status})",
|
||||
}, indent=2)
|
||||
|
||||
+24
-11
@@ -1034,7 +1034,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non
|
||||
filename=os.path.basename(media_path),
|
||||
)
|
||||
async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return _error(f"Discord forum thread creation error ({resp.status}): {body}")
|
||||
data = await resp.json()
|
||||
@@ -1052,7 +1052,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non
|
||||
},
|
||||
**_req_kw,
|
||||
) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return _error(f"Discord forum thread creation error ({resp.status}): {body}")
|
||||
data = await resp.json()
|
||||
@@ -1076,7 +1076,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non
|
||||
# Send text message (skip if empty and media is present)
|
||||
if message.strip() or not media_files:
|
||||
async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return _error(f"Discord API error ({resp.status}): {body}")
|
||||
last_data = await resp.json()
|
||||
@@ -1094,7 +1094,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non
|
||||
with open(media_path, "rb") as f:
|
||||
form.add_field("files[0]", f, filename=filename)
|
||||
async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}")
|
||||
logger.error(warning)
|
||||
@@ -1457,7 +1457,7 @@ async def _send_mattermost(token, extra, chat_id, message):
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||
async with session.post(url, headers=headers, json={"channel_id": chat_id, "message": message}) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return _error(f"Mattermost API error ({resp.status}): {body}")
|
||||
data = await resp.json()
|
||||
@@ -1501,7 +1501,7 @@ async def _send_matrix(token, extra, chat_id, message):
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||
async with session.put(url, headers=headers, json=payload) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return _error(f"Matrix API error ({resp.status}): {body}")
|
||||
data = await resp.json()
|
||||
@@ -1585,7 +1585,7 @@ async def _send_homeassistant(token, extra, chat_id, message):
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||
async with session.post(url, headers=headers, json={"message": message, "target": chat_id}) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return _error(f"Home Assistant API error ({resp.status}): {body}")
|
||||
return {"success": True, "platform": "homeassistant", "chat_id": chat_id}
|
||||
@@ -1757,7 +1757,20 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No
|
||||
|
||||
|
||||
def _check_send_message():
|
||||
"""Gate send_message on gateway running (always available on messaging platforms)."""
|
||||
"""Gate send_message on gateway running (always available on messaging platforms).
|
||||
|
||||
Also passes for kanban workers — the dispatcher sets ``HERMES_KANBAN_TASK``
|
||||
on every spawned worker, but those workers run with the assignee profile's
|
||||
``HERMES_HOME`` which has no ``gateway.pid``, so the gateway-running check
|
||||
would fail even though the parent gateway is alive. Honoring the env var
|
||||
lets workers call ``send_message`` to deliver rich content directly to the
|
||||
originating chat (paired with ``kanban_complete`` for the short notifier
|
||||
summary), which is the canonical pattern for any worker that needs to
|
||||
reply with more than the ~200-char first-line truncation the kanban
|
||||
notifier applies.
|
||||
"""
|
||||
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return True
|
||||
from gateway.session_context import get_session_env
|
||||
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
|
||||
if platform and platform != "local":
|
||||
@@ -1814,7 +1827,7 @@ async def _send_qqbot(pconfig, chat_id, message):
|
||||
# Try channel endpoint first (works for guild channels)
|
||||
url = f"https://api.sgroup.qq.com/channels/{chat_id}/messages"
|
||||
resp = await client.post(url, json=payload, headers=headers)
|
||||
if resp.status_code in (200, 201):
|
||||
if resp.status_code in {200, 201}:
|
||||
data = resp.json()
|
||||
return {"success": True, "platform": "qqbot", "chat_id": chat_id,
|
||||
"message_id": data.get("id")}
|
||||
@@ -1822,7 +1835,7 @@ async def _send_qqbot(pconfig, chat_id, message):
|
||||
# If channel endpoint failed (likely "频道不存在"), try C2C endpoint
|
||||
url_c2c = f"https://api.sgroup.qq.com/v2/users/{chat_id}/messages"
|
||||
resp_c2c = await client.post(url_c2c, json=payload, headers=headers)
|
||||
if resp_c2c.status_code in (200, 201):
|
||||
if resp_c2c.status_code in {200, 201}:
|
||||
data = resp_c2c.json()
|
||||
return {"success": True, "platform": "qqbot", "chat_id": chat_id,
|
||||
"message_id": data.get("id")}
|
||||
@@ -1830,7 +1843,7 @@ async def _send_qqbot(pconfig, chat_id, message):
|
||||
# If C2C also failed, try group endpoint
|
||||
url_group = f"https://api.sgroup.qq.com/v2/groups/{chat_id}/messages"
|
||||
resp_group = await client.post(url_group, json=payload, headers=headers)
|
||||
if resp_group.status_code in (200, 201):
|
||||
if resp_group.status_code in {200, 201}:
|
||||
data = resp_group.json()
|
||||
return {"success": True, "platform": "qqbot", "chat_id": chat_id,
|
||||
"message_id": data.get("id")}
|
||||
|
||||
@@ -780,7 +780,7 @@ def skill_manage(
|
||||
if action == "create":
|
||||
if is_background_review():
|
||||
mark_agent_created(name)
|
||||
elif action in ("patch", "edit", "write_file", "remove_file"):
|
||||
elif action in {"patch", "edit", "write_file", "remove_file"}:
|
||||
bump_patch(name)
|
||||
elif action == "delete":
|
||||
forget(name)
|
||||
|
||||
@@ -814,7 +814,7 @@ def _check_structure(skill_dir: Path) -> List[Finding]:
|
||||
))
|
||||
|
||||
# Executable permission on non-script files
|
||||
if ext not in ('.sh', '.bash', '.py', '.rb', '.pl') and f.stat().st_mode & 0o111:
|
||||
if ext not in {'.sh', '.bash', '.py', '.rb', '.pl'} and f.stat().st_mode & 0o111:
|
||||
findings.append(Finding(
|
||||
pattern_id="unexpected_executable",
|
||||
severity="medium",
|
||||
@@ -928,5 +928,5 @@ def _build_summary(name: str, source: str, trust: str, verdict: str, findings: L
|
||||
if not findings:
|
||||
return f"{name}: clean scan, no threats detected"
|
||||
|
||||
categories = set(f.category for f in findings)
|
||||
categories = {f.category for f in findings}
|
||||
return f"{name}: {verdict} — {len(findings)} finding(s) in {', '.join(sorted(categories))}"
|
||||
|
||||
+2
-2
@@ -101,7 +101,7 @@ def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bo
|
||||
|
||||
normalized = raw.replace("\\", "/")
|
||||
path = PurePosixPath(normalized)
|
||||
parts = [part for part in path.parts if part not in ("", ".")]
|
||||
parts = [part for part in path.parts if part not in {"", "."}]
|
||||
|
||||
if normalized.startswith("/") or path.is_absolute():
|
||||
raise ValueError(f"Unsafe {field_name}: {path_value}")
|
||||
@@ -1415,7 +1415,7 @@ class SkillsShSource(SkillSource):
|
||||
dir_name = entry["name"]
|
||||
if dir_name.startswith((".", "_")):
|
||||
continue
|
||||
if dir_name in ("skills", ".agents", ".claude"):
|
||||
if dir_name in {"skills", ".agents", ".claude"}:
|
||||
continue # already tried
|
||||
# Try direct: repo/dir/skill_token
|
||||
direct_id = f"{repo}/{dir_name}/{skill_token}"
|
||||
|
||||
@@ -345,7 +345,7 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:
|
||||
manifest = _read_manifest()
|
||||
bundled_dir = _get_bundled_dir()
|
||||
bundled_skills = _discover_bundled_skills(bundled_dir)
|
||||
bundled_by_name = {skill_name: skill_dir for skill_name, skill_dir in bundled_skills}
|
||||
bundled_by_name = dict(bundled_skills)
|
||||
|
||||
in_manifest = name in manifest
|
||||
is_bundled = name in bundled_by_name
|
||||
|
||||
@@ -721,7 +721,7 @@ def skills_list(category: str = None, task_id: str = None) -> str:
|
||||
|
||||
# Extract unique categories
|
||||
categories = sorted(
|
||||
set(s.get("category") for s in all_skills if s.get("category"))
|
||||
{s.get("category") for s in all_skills if s.get("category")}
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
@@ -1133,7 +1133,7 @@ def skill_view(
|
||||
available_files["assets"].append(rel)
|
||||
elif rel.startswith("scripts/"):
|
||||
available_files["scripts"].append(rel)
|
||||
elif f.suffix in [
|
||||
elif f.suffix in {
|
||||
".md",
|
||||
".py",
|
||||
".yaml",
|
||||
@@ -1141,7 +1141,7 @@ def skill_view(
|
||||
".json",
|
||||
".tex",
|
||||
".sh",
|
||||
]:
|
||||
}:
|
||||
available_files["other"].append(rel)
|
||||
|
||||
# Remove empty categories
|
||||
|
||||
+17
-12
@@ -139,7 +139,7 @@ def _check_vercel_sandbox_requirements(config: dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
disk = config.get("container_disk", 51200)
|
||||
if disk not in (0, 51200):
|
||||
if disk not in {0, 51200}:
|
||||
logger.error(
|
||||
"Vercel Sandbox does not support custom TERMINAL_CONTAINER_DISK=%s. "
|
||||
"Use the default shared setting (51200 MB).",
|
||||
@@ -416,7 +416,7 @@ def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str:
|
||||
chars = []
|
||||
while True:
|
||||
c = msvcrt.getwch()
|
||||
if c in ("\r", "\n"):
|
||||
if c in {"\r", "\n"}:
|
||||
break
|
||||
if c == "\x03":
|
||||
raise KeyboardInterrupt
|
||||
@@ -432,7 +432,7 @@ def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str:
|
||||
chars = []
|
||||
while True:
|
||||
b = os.read(tty_fd, 1)
|
||||
if not b or b in (b"\n", b"\r"):
|
||||
if not b or b in {b"\n", b"\r"}:
|
||||
break
|
||||
chars.append(b)
|
||||
result["password"] = b"".join(chars).decode("utf-8", errors="replace")
|
||||
@@ -707,7 +707,7 @@ def _rewrite_compound_background(command: str) -> str:
|
||||
continue
|
||||
|
||||
# Quoted tokens — consume whole string via the shared tokenizer.
|
||||
if ch in ("'", '"'):
|
||||
if ch in {"'", '"'}:
|
||||
_, next_i = _read_shell_token(command, i)
|
||||
i = max(next_i, i + 1)
|
||||
continue
|
||||
@@ -888,6 +888,7 @@ from tools.environments.docker import DockerEnvironment as _DockerEnvironment
|
||||
from tools.environments.modal import ModalEnvironment as _ModalEnvironment
|
||||
from tools.environments.managed_modal import ManagedModalEnvironment as _ManagedModalEnvironment
|
||||
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||
import sys
|
||||
|
||||
|
||||
# Tool description for LLM
|
||||
@@ -1009,7 +1010,7 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
default_image = "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
env_type = os.getenv("TERMINAL_ENV", "local")
|
||||
|
||||
mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in ("true", "1", "yes")
|
||||
mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"}
|
||||
|
||||
# Default cwd: local uses the host's current directory, ssh uses the
|
||||
# remote home, Vercel uses its documented workspace root, and everything
|
||||
@@ -1041,7 +1042,7 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
):
|
||||
host_cwd = candidate
|
||||
cwd = "/workspace"
|
||||
elif env_type in ("modal", "docker", "singularity", "daytona", "vercel_sandbox") and cwd:
|
||||
elif env_type in {"modal", "docker", "singularity", "daytona", "vercel_sandbox"} and cwd:
|
||||
# Host paths and relative paths that won't work inside containers
|
||||
is_host_path = any(cwd.startswith(p) for p in host_prefixes)
|
||||
is_relative = not os.path.isabs(cwd) # e.g. "." or "src/"
|
||||
@@ -1076,17 +1077,18 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
"ssh_persistent": os.getenv(
|
||||
"TERMINAL_SSH_PERSISTENT",
|
||||
os.getenv("TERMINAL_PERSISTENT_SHELL", "true"),
|
||||
).lower() in ("true", "1", "yes"),
|
||||
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in ("true", "1", "yes"),
|
||||
).lower() in {"true", "1", "yes"},
|
||||
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"},
|
||||
# Container resource config (applies to docker, singularity, modal,
|
||||
# daytona, and vercel_sandbox -- ignored for local/ssh)
|
||||
"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"),
|
||||
"container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"},
|
||||
"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_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"},
|
||||
"docker_extra_args": _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1129,6 +1131,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
||||
volumes = cc.get("docker_volumes", [])
|
||||
docker_forward_env = cc.get("docker_forward_env", [])
|
||||
docker_env = cc.get("docker_env", {})
|
||||
docker_extra_args = cc.get("docker_extra_args", [])
|
||||
|
||||
if env_type == "local":
|
||||
return _LocalEnvironment(cwd=cwd, timeout=timeout)
|
||||
@@ -1144,6 +1147,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
||||
forward_env=docker_forward_env,
|
||||
env=docker_env,
|
||||
run_as_host_user=cc.get("docker_run_as_host_user", False),
|
||||
extra_args=docker_extra_args,
|
||||
)
|
||||
|
||||
elif env_type == "singularity":
|
||||
@@ -1779,7 +1783,7 @@ def terminal_tool(
|
||||
}
|
||||
|
||||
container_config = None
|
||||
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
|
||||
container_config = {
|
||||
"container_cpu": config.get("container_cpu", 1),
|
||||
"container_memory": config.get("container_memory", 5120),
|
||||
@@ -1792,6 +1796,7 @@ def terminal_tool(
|
||||
"docker_forward_env": config.get("docker_forward_env", []),
|
||||
"docker_env": config.get("docker_env", {}),
|
||||
"docker_run_as_host_user": config.get("docker_run_as_host_user", False),
|
||||
"docker_extra_args": config.get("docker_extra_args", []),
|
||||
}
|
||||
|
||||
local_config = None
|
||||
@@ -2239,7 +2244,7 @@ if __name__ == "__main__":
|
||||
|
||||
if not check_terminal_requirements():
|
||||
print("\n❌ Requirements not met. Please check the messages above.")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
print("\n✅ All requirements met!")
|
||||
print("\nAvailable Tool:")
|
||||
|
||||
@@ -52,7 +52,7 @@ def _env_bool(key: str, default: bool) -> bool:
|
||||
val = os.getenv(key)
|
||||
if val is None:
|
||||
return default
|
||||
return val.lower() in ("1", "true", "yes")
|
||||
return val.lower() in {"1", "true", "yes"}
|
||||
|
||||
|
||||
def _env_int(key: str, default: int) -> int:
|
||||
@@ -189,14 +189,14 @@ def _detect_target() -> str | None:
|
||||
# Android (Termux) is ABI-compatible with Linux — reuse Linux binaries.
|
||||
if system == "Darwin":
|
||||
plat = "apple-darwin"
|
||||
elif system in ("Linux", "Android"):
|
||||
elif system in {"Linux", "Android"}:
|
||||
plat = "unknown-linux-gnu"
|
||||
else:
|
||||
return None
|
||||
|
||||
if machine in ("x86_64", "amd64"):
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
arch = "x86_64"
|
||||
elif machine in ("aarch64", "arm64"):
|
||||
elif machine in {"aarch64", "arm64"}:
|
||||
arch = "aarch64"
|
||||
else:
|
||||
return None
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ class TodoStore:
|
||||
# cause the model to re-do finished work after compression.
|
||||
active_items = [
|
||||
item for item in self._items
|
||||
if item["status"] in ("pending", "in_progress")
|
||||
if item["status"] in {"pending", "in_progress"}
|
||||
]
|
||||
if not active_items:
|
||||
return None
|
||||
|
||||
+16
-17
@@ -466,13 +466,12 @@ def _shell_quote_context(command_template: str, position: int) -> Optional[str]:
|
||||
escaped = True
|
||||
elif char == '"':
|
||||
quote = None
|
||||
else:
|
||||
if char == "'":
|
||||
quote = "'"
|
||||
elif char == '"':
|
||||
quote = '"'
|
||||
elif char == "\\":
|
||||
i += 1
|
||||
elif char == "'":
|
||||
quote = "'"
|
||||
elif char == '"':
|
||||
quote = '"'
|
||||
elif char == "\\":
|
||||
i += 1
|
||||
i += 1
|
||||
return quote
|
||||
|
||||
@@ -849,13 +848,13 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
||||
OpenAIClient = _import_openai_client()
|
||||
client = OpenAIClient(api_key=api_key, base_url=base_url)
|
||||
try:
|
||||
create_kwargs = dict(
|
||||
model=model,
|
||||
voice=voice,
|
||||
input=text,
|
||||
response_format=response_format,
|
||||
extra_headers={"x-idempotency-key": str(uuid.uuid4())},
|
||||
)
|
||||
create_kwargs = {
|
||||
"model": model,
|
||||
"voice": voice,
|
||||
"input": text,
|
||||
"response_format": response_format,
|
||||
"extra_headers": {"x-idempotency-key": str(uuid.uuid4())},
|
||||
}
|
||||
if speed != 1.0:
|
||||
create_kwargs["speed"] = max(0.25, min(4.0, speed))
|
||||
response = client.audio.speech.create(**create_kwargs)
|
||||
@@ -1613,7 +1612,7 @@ def text_to_speech_tool(
|
||||
file_path = out_dir / f"tts_{timestamp}.{fmt}"
|
||||
# Use .ogg for Telegram with providers that support native Opus output,
|
||||
# otherwise fall back to .mp3 (Edge TTS will attempt ffmpeg conversion later).
|
||||
elif want_opus and provider in ("openai", "elevenlabs", "mistral", "gemini"):
|
||||
elif want_opus and provider in {"openai", "elevenlabs", "mistral", "gemini"}:
|
||||
file_path = out_dir / f"tts_{timestamp}.ogg"
|
||||
else:
|
||||
file_path = out_dir / f"tts_{timestamp}.mp3"
|
||||
@@ -1763,12 +1762,12 @@ def text_to_speech_tool(
|
||||
if opus_path:
|
||||
file_str = opus_path
|
||||
voice_compatible = file_str.endswith(".ogg")
|
||||
elif provider in ("edge", "neutts", "minimax", "xai", "kittentts", "piper") and not file_str.endswith(".ogg"):
|
||||
elif provider in {"edge", "neutts", "minimax", "xai", "kittentts", "piper"} and not file_str.endswith(".ogg"):
|
||||
opus_path = _convert_to_opus(file_str)
|
||||
if opus_path:
|
||||
file_str = opus_path
|
||||
voice_compatible = True
|
||||
elif provider in ("elevenlabs", "openai", "mistral", "gemini"):
|
||||
elif provider in {"elevenlabs", "openai", "mistral", "gemini"}:
|
||||
voice_compatible = file_str.endswith(".ogg")
|
||||
|
||||
file_size = os.path.getsize(file_str)
|
||||
|
||||
+2
-2
@@ -96,10 +96,10 @@ def _global_allow_private_urls() -> bool:
|
||||
|
||||
# 1. Env var override (highest priority)
|
||||
env_val = os.getenv("HERMES_ALLOW_PRIVATE_URLS", "").strip().lower()
|
||||
if env_val in ("true", "1", "yes"):
|
||||
if env_val in {"true", "1", "yes"}:
|
||||
_cached_allow_private = True
|
||||
return _cached_allow_private
|
||||
if env_val in ("false", "0", "no"):
|
||||
if env_val in {"false", "0", "no"}:
|
||||
# Explicit false — don't fall through to config
|
||||
return _cached_allow_private
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning
|
||||
from hermes_constants import get_hermes_dir
|
||||
from tools.debug_helpers import DebugSession
|
||||
from tools.website_policy import check_website_access
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -346,7 +347,7 @@ def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None,
|
||||
data_url = _image_to_base64_data_url(image_path, mime_type=mime_type)
|
||||
return data_url # fall through to size-check in caller
|
||||
# Convert RGBA to RGB for JPEG output
|
||||
if pil_format == "JPEG" and img.mode in ("RGBA", "P"):
|
||||
if pil_format == "JPEG" and img.mode in {"RGBA", "P"}:
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Strategy: halve dimensions until base64 fits, up to 4 rounds.
|
||||
@@ -937,7 +938,7 @@ if __name__ == "__main__":
|
||||
if not api_available:
|
||||
print("❌ No auxiliary vision model available")
|
||||
print("Configure a supported multimodal backend (OpenRouter, Nous, Codex, Anthropic, or a custom OpenAI-compatible endpoint).")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ Vision model available")
|
||||
|
||||
|
||||
+1
-2
@@ -456,8 +456,7 @@ class AudioRecorder:
|
||||
# Compute RMS for level display and silence detection
|
||||
rms = int(np.sqrt(np.mean(indata.astype(np.float64) ** 2)))
|
||||
self._current_rms = rms
|
||||
if rms > self._peak_rms:
|
||||
self._peak_rms = rms
|
||||
self._peak_rms = max(self._peak_rms, rms)
|
||||
|
||||
# Silence detection
|
||||
if self._on_silence_stop is not None:
|
||||
|
||||
+14
-14
@@ -100,6 +100,7 @@ from tools.managed_tool_gateway import (
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway
|
||||
from tools.url_safety import is_safe_url
|
||||
from tools.website_policy import check_website_access
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -126,7 +127,7 @@ def _get_backend() -> str:
|
||||
keys manually without running setup.
|
||||
"""
|
||||
configured = (_load_web_config().get("backend") or "").lower().strip()
|
||||
if configured in ("parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs"):
|
||||
if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs"}:
|
||||
return configured
|
||||
|
||||
# Fallback for manual / legacy config — pick the highest-priority
|
||||
@@ -1074,7 +1075,7 @@ def _parallel_search(query: str, limit: int = 5) -> dict:
|
||||
return {"error": "Interrupted", "success": False}
|
||||
|
||||
mode = os.getenv("PARALLEL_SEARCH_MODE", "agentic").lower().strip()
|
||||
if mode not in ("fast", "one-shot", "agentic"):
|
||||
if mode not in {"fast", "one-shot", "agentic"}:
|
||||
mode = "agentic"
|
||||
|
||||
logger.info("Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit)
|
||||
@@ -1397,7 +1398,7 @@ async def web_extract_tool(
|
||||
"include_images": False,
|
||||
})
|
||||
results = _normalize_tavily_documents(raw, fallback_url=safe_urls[0] if safe_urls else "")
|
||||
elif backend in ("searxng", "brave-free", "ddgs"):
|
||||
elif backend in {"searxng", "brave-free", "ddgs"}:
|
||||
# These backends are search-only — they cannot extract URL content
|
||||
_label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend]
|
||||
return json.dumps({
|
||||
@@ -1781,7 +1782,7 @@ async def web_crawl_tool(
|
||||
return cleaned_result
|
||||
|
||||
# SearXNG / Brave Search (free tier) / DuckDuckGo (ddgs) are search-only — they cannot crawl
|
||||
if backend in ("searxng", "brave-free", "ddgs"):
|
||||
if backend in {"searxng", "brave-free", "ddgs"}:
|
||||
_label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend]
|
||||
return json.dumps({
|
||||
"error": f"{_label} is a search-only backend and cannot crawl URLs. "
|
||||
@@ -2084,7 +2085,7 @@ def check_firecrawl_api_key() -> bool:
|
||||
def check_web_api_key() -> bool:
|
||||
"""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"):
|
||||
if configured in {"exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs"}:
|
||||
return _is_backend_available(configured)
|
||||
return any(
|
||||
_is_backend_available(backend)
|
||||
@@ -2130,15 +2131,14 @@ if __name__ == "__main__":
|
||||
print(" Using Brave Search free tier (search only)")
|
||||
elif backend == "ddgs":
|
||||
print(" Using DuckDuckGo via ddgs package (search only)")
|
||||
elif firecrawl_url_available:
|
||||
print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}")
|
||||
elif firecrawl_key_available:
|
||||
print(" Using direct Firecrawl cloud API")
|
||||
elif tool_gateway_available:
|
||||
print(f" Using Firecrawl tool-gateway: {_get_firecrawl_gateway_url()}")
|
||||
else:
|
||||
if firecrawl_url_available:
|
||||
print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}")
|
||||
elif firecrawl_key_available:
|
||||
print(" Using direct Firecrawl cloud API")
|
||||
elif tool_gateway_available:
|
||||
print(f" Using Firecrawl tool-gateway: {_get_firecrawl_gateway_url()}")
|
||||
else:
|
||||
print(" Firecrawl backend selected but not configured")
|
||||
print(" Firecrawl backend selected but not configured")
|
||||
else:
|
||||
print("❌ No web search backend configured")
|
||||
print(
|
||||
@@ -2154,7 +2154,7 @@ if __name__ == "__main__":
|
||||
print(f"✅ Auxiliary model available: {default_summarizer_model}")
|
||||
|
||||
if not web_available:
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
print("🛠️ Web tools ready for use!")
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ async def query_group_members(
|
||||
hint = {"mention_hint": MENTION_HINT} if mention else {}
|
||||
|
||||
if action == "list_bots":
|
||||
bots = [m for m in all_members if m["role"] in ("yuanbao_ai", "bot")]
|
||||
bots = [m for m in all_members if m["role"] in {"yuanbao_ai", "bot"}]
|
||||
if not bots:
|
||||
return {"success": False, "error": "No bots found in this group."}
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user