From 26bedf973b5005a9f5501e17770ca69338702015 Mon Sep 17 00:00:00 2001 From: Emmett <152297554+mr-emmett-one@users.noreply.github.com> Date: Sun, 15 Mar 2026 03:55:24 +0100 Subject: [PATCH 01/41] fix: support multiple parallel tool calls in DeepSeek V3 parser (#989) - Refactored regex pattern to handle varied whitespace and newlines for better robustness. - Replaced logic to iterate through all tool call blocks using finditer instead of stopping at the first match. - Ensured full extraction of multiple tool calls for complex agentic workflows. - Added error logging for failed parsing attempts. --- .../tool_call_parsers/deepseek_v3_parser.py | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/environments/tool_call_parsers/deepseek_v3_parser.py b/environments/tool_call_parsers/deepseek_v3_parser.py index 3053c7a0..61d23d5f 100644 --- a/environments/tool_call_parsers/deepseek_v3_parser.py +++ b/environments/tool_call_parsers/deepseek_v3_parser.py @@ -10,12 +10,13 @@ Format uses special unicode tokens: <|tool▁call▁end|> <|tool▁calls▁end|> -Based on VLLM's DeepSeekV3ToolParser.extract_tool_calls() +Fixes Issue #989: Support for multiple simultaneous tool calls. """ import re import uuid -from typing import List, Optional +import logging +from typing import List, Optional, Tuple from openai.types.chat.chat_completion_message_tool_call import ( ChatCompletionMessageToolCall, @@ -24,6 +25,7 @@ from openai.types.chat.chat_completion_message_tool_call import ( from environments.tool_call_parsers import ParseResult, ToolCallParser, register_parser +logger = logging.getLogger(__name__) @register_parser("deepseek_v3") class DeepSeekV3ToolCallParser(ToolCallParser): @@ -32,45 +34,56 @@ class DeepSeekV3ToolCallParser(ToolCallParser): Uses special unicode tokens with fullwidth angle brackets and block elements. Extracts type, function name, and JSON arguments from the structured format. + Ensures all tool calls are captured when the model executes multiple actions. """ START_TOKEN = "<|tool▁calls▁begin|>" - # Regex captures: type, function_name, function_arguments + # Updated PATTERN: Using \s* instead of literal \n for increased robustness + # against variations in model formatting (Issue #989). PATTERN = re.compile( - r"<|tool▁call▁begin|>(?P.*?)<|tool▁sep|>(?P.*?)\n```json\n(?P.*?)\n```<|tool▁call▁end|>", + r"<|tool▁call▁begin|>(?P.*?)<|tool▁sep|>(?P.*?)\s*```json\s*(?P.*?)\s*```\s*<|tool▁call▁end|>", re.DOTALL, ) def parse(self, text: str) -> ParseResult: + """ + Parses the input text and extracts all available tool calls. + """ if self.START_TOKEN not in text: return text, None try: - matches = self.PATTERN.findall(text) + # Using finditer to capture ALL tool calls in the sequence + matches = list(self.PATTERN.finditer(text)) if not matches: return text, None tool_calls: List[ChatCompletionMessageToolCall] = [] + for match in matches: - tc_type, func_name, func_args = match + func_name = match.group("function_name").strip() + func_args = match.group("function_arguments").strip() + tool_calls.append( ChatCompletionMessageToolCall( id=f"call_{uuid.uuid4().hex[:8]}", type="function", function=Function( - name=func_name.strip(), - arguments=func_args.strip(), + name=func_name, + arguments=func_args, ), ) ) - if not tool_calls: - return text, None + if tool_calls: + # Content is text before the first tool call block + content_index = text.find(self.START_TOKEN) + content = text[:content_index].strip() + return content if content else None, tool_calls - # Content is everything before the tool calls section - content = text[: text.find(self.START_TOKEN)].strip() - return content if content else None, tool_calls - - except Exception: + return text, None + + except Exception as e: + logger.error(f"Error parsing DeepSeek V3 tool calls: {e}") return text, None From 9f6bccd76a0a64d9251620e5c713e34f9df4649f Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 20:48:29 -0700 Subject: [PATCH 02/41] feat: add direct endpoint overrides for auxiliary and delegation Add base_url/api_key overrides for auxiliary tasks and delegation so users can route those flows straight to a custom OpenAI-compatible endpoint without having to rely on provider=main or named custom providers. Also clear gateway session env vars in test isolation so the full suite stays deterministic when run from a messaging-backed agent session. --- agent/auxiliary_client.py | 244 +++++++++++++----- cli.py | 48 +++- gateway/run.py | 30 ++- hermes_cli/config.py | 16 ++ tests/agent/test_auxiliary_client.py | 64 ++++- tests/conftest.py | 6 + tests/test_auxiliary_config_bridge.py | 47 +++- tests/tools/test_delegate.py | 72 ++++++ tools/delegate_tool.py | 52 +++- .../docs/reference/environment-variables.md | 17 ++ website/docs/user-guide/configuration.md | 21 +- .../docs/user-guide/features/delegation.md | 8 + 12 files changed, 526 insertions(+), 99 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index dd8f22bb..957452fc 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -30,6 +30,10 @@ Default "auto" follows the chains above. Per-task model overrides (e.g. AUXILIARY_VISION_MODEL, AUXILIARY_WEB_EXTRACT_MODEL) let callers use a different model slug than the provider's default. + +Per-task direct endpoint overrides (e.g. AUXILIARY_VISION_BASE_URL, +AUXILIARY_VISION_API_KEY) let callers route a specific auxiliary task to a +custom OpenAI-compatible endpoint without touching the main model settings. """ import json @@ -418,6 +422,17 @@ def _get_auxiliary_provider(task: str = "") -> str: return "auto" +def _get_auxiliary_env_override(task: str, suffix: str) -> Optional[str]: + """Read an auxiliary env override from AUXILIARY_* or CONTEXT_* prefixes.""" + if not task: + return None + for prefix in ("AUXILIARY_", "CONTEXT_"): + val = os.getenv(f"{prefix}{task.upper()}_{suffix}", "").strip() + if val: + return val + return None + + def _try_openrouter() -> Tuple[Optional[OpenAI], Optional[str]]: or_key = os.getenv("OPENROUTER_API_KEY") if not or_key: @@ -564,6 +579,8 @@ def resolve_provider_client( model: str = None, async_mode: bool = False, raw_codex: bool = False, + explicit_base_url: str = None, + explicit_api_key: str = None, ) -> Tuple[Optional[Any], Optional[str]]: """Central router: given a provider name and optional model, return a configured client with the correct auth, base URL, and API format. @@ -585,6 +602,8 @@ def resolve_provider_client( instead of wrapping in CodexAuxiliaryClient. Use this when the caller needs direct access to responses.stream() (e.g., the main agent loop). + explicit_base_url: Optional direct OpenAI-compatible endpoint. + explicit_api_key: Optional API key paired with explicit_base_url. Returns: (client, resolved_model) or (None, None) if auth is unavailable. @@ -661,6 +680,22 @@ def resolve_provider_client( # ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ─────────── if provider == "custom": + if explicit_base_url: + custom_base = explicit_base_url.strip() + custom_key = ( + (explicit_api_key or "").strip() + or os.getenv("OPENAI_API_KEY", "").strip() + ) + if not custom_base or not custom_key: + logger.warning( + "resolve_provider_client: explicit custom endpoint requested " + "but no API key was found (set explicit_api_key or OPENAI_API_KEY)" + ) + return None, None + final_model = model or _read_main_model() or "gpt-4o-mini" + client = OpenAI(api_key=custom_key, base_url=custom_base) + return (_to_async_client(client, final_model) if async_mode + else (client, final_model)) # Try custom first, then codex, then API-key providers for try_fn in (_try_custom_endpoint, _try_codex, _resolve_api_key_provider): @@ -749,10 +784,13 @@ def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optiona Callers may override the returned model with a per-task env var (e.g. CONTEXT_COMPRESSION_MODEL, AUXILIARY_WEB_EXTRACT_MODEL). """ - forced = _get_auxiliary_provider(task) - if forced != "auto": - return resolve_provider_client(forced) - return resolve_provider_client("auto") + provider, model, base_url, api_key = _resolve_task_provider_model(task or None) + return resolve_provider_client( + provider, + model=model, + explicit_base_url=base_url, + explicit_api_key=api_key, + ) def get_async_text_auxiliary_client(task: str = ""): @@ -762,10 +800,14 @@ def get_async_text_auxiliary_client(task: str = ""): (AsyncCodexAuxiliaryClient, model) which wraps the Responses API. Returns (None, None) when no provider is available. """ - forced = _get_auxiliary_provider(task) - if forced != "auto": - return resolve_provider_client(forced, async_mode=True) - return resolve_provider_client("auto", async_mode=True) + provider, model, base_url, api_key = _resolve_task_provider_model(task or None) + return resolve_provider_client( + provider, + model=model, + async_mode=True, + explicit_base_url=base_url, + explicit_api_key=api_key, + ) _VISION_AUTO_PROVIDER_ORDER = ( @@ -821,26 +863,43 @@ def resolve_vision_provider_client( provider: Optional[str] = None, model: Optional[str] = None, *, + base_url: Optional[str] = None, + api_key: Optional[str] = None, async_mode: bool = False, ) -> Tuple[Optional[str], Optional[Any], Optional[str]]: """Resolve the client actually used for vision tasks. - Explicit provider overrides still use the generic provider router for - non-standard backends, so users can intentionally force experimental - providers. Auto mode stays conservative and only tries vision backends - known to work today. + Direct endpoint overrides take precedence over provider selection. Explicit + provider overrides still use the generic provider router for non-standard + backends, so users can intentionally force experimental providers. Auto mode + stays conservative and only tries vision backends known to work today. """ - requested = _normalize_vision_provider(provider or _get_auxiliary_provider("vision")) + requested, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model( + "vision", provider, model, base_url, api_key + ) + requested = _normalize_vision_provider(requested) def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[str]): if sync_client is None: return resolved_provider, None, None - final_model = model or default_model + final_model = resolved_model or default_model if async_mode: async_client, async_model = _to_async_client(sync_client, final_model) return resolved_provider, async_client, async_model return resolved_provider, sync_client, final_model + if resolved_base_url: + client, final_model = resolve_provider_client( + "custom", + model=resolved_model, + async_mode=async_mode, + explicit_base_url=resolved_base_url, + explicit_api_key=resolved_api_key, + ) + if client is None: + return "custom", None, None + return "custom", client, final_model + if requested == "auto": for candidate in get_available_vision_backends(): sync_client, default_model = _resolve_strict_vision_backend(candidate) @@ -853,7 +912,7 @@ def resolve_vision_provider_client( sync_client, default_model = _resolve_strict_vision_backend(requested) return _finalize(requested, sync_client, default_model) - client, final_model = _get_cached_client(requested, model, async_mode) + client, final_model = _get_cached_client(requested, resolved_model, async_mode) if client is None: return requested, None, None return requested, client, final_model @@ -910,19 +969,29 @@ def auxiliary_max_tokens_param(value: int) -> dict: # Every auxiliary LLM consumer should use these instead of manually # constructing clients and calling .chat.completions.create(). -# Client cache: (provider, async_mode) -> (client, default_model) +# Client cache: (provider, async_mode, base_url, api_key) -> (client, default_model) _client_cache: Dict[tuple, tuple] = {} def _get_cached_client( - provider: str, model: str = None, async_mode: bool = False, + provider: str, + model: str = None, + async_mode: bool = False, + base_url: str = None, + api_key: str = None, ) -> Tuple[Optional[Any], Optional[str]]: """Get or create a cached client for the given provider.""" - cache_key = (provider, async_mode) + cache_key = (provider, async_mode, base_url or "", api_key or "") if cache_key in _client_cache: cached_client, cached_default = _client_cache[cache_key] return cached_client, model or cached_default - client, default_model = resolve_provider_client(provider, model, async_mode) + client, default_model = resolve_provider_client( + provider, + model, + async_mode, + explicit_base_url=base_url, + explicit_api_key=api_key, + ) if client is not None: _client_cache[cache_key] = (client, default_model) return client, model or default_model @@ -932,57 +1001,75 @@ def _resolve_task_provider_model( task: str = None, provider: str = None, model: str = None, -) -> Tuple[str, Optional[str]]: + base_url: str = None, + api_key: str = None, +) -> Tuple[str, Optional[str], Optional[str], Optional[str]]: """Determine provider + model for a call. Priority: - 1. Explicit provider/model args (always win) - 2. Env var overrides (AUXILIARY_{TASK}_PROVIDER, etc.) - 3. Config file (auxiliary.{task}.provider/model or compression.*) + 1. Explicit provider/model/base_url/api_key args (always win) + 2. Env var overrides (AUXILIARY_{TASK}_*, CONTEXT_{TASK}_*) + 3. Config file (auxiliary.{task}.* or compression.*) 4. "auto" (full auto-detection chain) - Returns (provider, model) where model may be None (use provider default). + Returns (provider, model, base_url, api_key) where model may be None + (use provider default). When base_url is set, provider is forced to + "custom" and the task uses that direct endpoint. """ - if provider: - return provider, model + config = {} + cfg_provider = None + cfg_model = None + cfg_base_url = None + cfg_api_key = None if task: - # Check env var overrides first - env_provider = _get_auxiliary_provider(task) - if env_provider != "auto": - # Check for env var model override too - env_model = None - for prefix in ("AUXILIARY_", "CONTEXT_"): - val = os.getenv(f"{prefix}{task.upper()}_MODEL", "").strip() - if val: - env_model = val - break - return env_provider, model or env_model - - # Read from config file try: from hermes_cli.config import load_config config = load_config() except ImportError: - return "auto", model + config = {} - # Check auxiliary.{task} section - aux = config.get("auxiliary", {}) - task_config = aux.get(task, {}) - cfg_provider = task_config.get("provider", "").strip() or None - cfg_model = task_config.get("model", "").strip() or None + aux = config.get("auxiliary", {}) if isinstance(config, dict) else {} + task_config = aux.get(task, {}) if isinstance(aux, dict) else {} + if not isinstance(task_config, dict): + task_config = {} + cfg_provider = str(task_config.get("provider", "")).strip() or None + cfg_model = str(task_config.get("model", "")).strip() or None + cfg_base_url = str(task_config.get("base_url", "")).strip() or None + cfg_api_key = str(task_config.get("api_key", "")).strip() or None # Backwards compat: compression section has its own keys if task == "compression" and not cfg_provider: - comp = config.get("compression", {}) - cfg_provider = comp.get("summary_provider", "").strip() or None - cfg_model = cfg_model or comp.get("summary_model", "").strip() or None + comp = config.get("compression", {}) if isinstance(config, dict) else {} + if isinstance(comp, dict): + cfg_provider = comp.get("summary_provider", "").strip() or None + cfg_model = cfg_model or comp.get("summary_model", "").strip() or None + env_model = _get_auxiliary_env_override(task, "MODEL") if task else None + resolved_model = model or env_model or cfg_model + + if base_url: + return "custom", resolved_model, base_url, api_key + if provider: + return provider, resolved_model, base_url, api_key + + if task: + env_base_url = _get_auxiliary_env_override(task, "BASE_URL") + env_api_key = _get_auxiliary_env_override(task, "API_KEY") + if env_base_url: + return "custom", resolved_model, env_base_url, env_api_key or cfg_api_key + + env_provider = _get_auxiliary_provider(task) + if env_provider != "auto": + return env_provider, resolved_model, None, None + + if cfg_base_url: + return "custom", resolved_model, cfg_base_url, cfg_api_key if cfg_provider and cfg_provider != "auto": - return cfg_provider, model or cfg_model - return "auto", model or cfg_model + return cfg_provider, resolved_model, None, None + return "auto", resolved_model, None, None - return "auto", model + return "auto", resolved_model, None, None def _build_call_kwargs( @@ -994,6 +1081,7 @@ def _build_call_kwargs( tools: Optional[list] = None, timeout: float = 30.0, extra_body: Optional[dict] = None, + base_url: Optional[str] = None, ) -> dict: """Build kwargs for .chat.completions.create() with model/provider adjustments.""" kwargs: Dict[str, Any] = { @@ -1009,7 +1097,7 @@ def _build_call_kwargs( # Codex adapter handles max_tokens internally; OpenRouter/Nous use max_tokens. # Direct OpenAI api.openai.com with newer models needs max_completion_tokens. if provider == "custom": - custom_base = os.getenv("OPENAI_BASE_URL", "") + custom_base = base_url or os.getenv("OPENAI_BASE_URL", "") if "api.openai.com" in custom_base.lower(): kwargs["max_completion_tokens"] = max_tokens else: @@ -1035,6 +1123,8 @@ def call_llm( *, provider: str = None, model: str = None, + base_url: str = None, + api_key: str = None, messages: list, temperature: float = None, max_tokens: int = None, @@ -1066,16 +1156,18 @@ def call_llm( Raises: RuntimeError: If no provider is configured. """ - resolved_provider, resolved_model = _resolve_task_provider_model( - task, provider, model) + resolved_provider, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model( + task, provider, model, base_url, api_key) if task == "vision": effective_provider, client, final_model = resolve_vision_provider_client( - provider=resolved_provider, - model=resolved_model, + provider=provider, + model=model, + base_url=base_url, + api_key=api_key, async_mode=False, ) - if client is None and resolved_provider != "auto": + if client is None and resolved_provider != "auto" and not resolved_base_url: logger.warning( "Vision provider %s unavailable, falling back to auto vision backends", resolved_provider, @@ -1092,10 +1184,15 @@ def call_llm( ) resolved_provider = effective_provider or resolved_provider else: - client, final_model = _get_cached_client(resolved_provider, resolved_model) + client, final_model = _get_cached_client( + resolved_provider, + resolved_model, + base_url=resolved_base_url, + api_key=resolved_api_key, + ) if client is None: # Fallback: try openrouter - if resolved_provider != "openrouter": + if resolved_provider != "openrouter" and not resolved_base_url: logger.warning("Provider %s unavailable, falling back to openrouter", resolved_provider) client, final_model = _get_cached_client( @@ -1108,7 +1205,8 @@ def call_llm( kwargs = _build_call_kwargs( resolved_provider, final_model, messages, temperature=temperature, max_tokens=max_tokens, - tools=tools, timeout=timeout, extra_body=extra_body) + tools=tools, timeout=timeout, extra_body=extra_body, + base_url=resolved_base_url) # Handle max_tokens vs max_completion_tokens retry try: @@ -1127,6 +1225,8 @@ async def async_call_llm( *, provider: str = None, model: str = None, + base_url: str = None, + api_key: str = None, messages: list, temperature: float = None, max_tokens: int = None, @@ -1138,16 +1238,18 @@ async def async_call_llm( Same as call_llm() but async. See call_llm() for full documentation. """ - resolved_provider, resolved_model = _resolve_task_provider_model( - task, provider, model) + resolved_provider, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model( + task, provider, model, base_url, api_key) if task == "vision": effective_provider, client, final_model = resolve_vision_provider_client( - provider=resolved_provider, - model=resolved_model, + provider=provider, + model=model, + base_url=base_url, + api_key=api_key, async_mode=True, ) - if client is None and resolved_provider != "auto": + if client is None and resolved_provider != "auto" and not resolved_base_url: logger.warning( "Vision provider %s unavailable, falling back to auto vision backends", resolved_provider, @@ -1165,9 +1267,14 @@ async def async_call_llm( resolved_provider = effective_provider or resolved_provider else: client, final_model = _get_cached_client( - resolved_provider, resolved_model, async_mode=True) + resolved_provider, + resolved_model, + async_mode=True, + base_url=resolved_base_url, + api_key=resolved_api_key, + ) if client is None: - if resolved_provider != "openrouter": + if resolved_provider != "openrouter" and not resolved_base_url: logger.warning("Provider %s unavailable, falling back to openrouter", resolved_provider) client, final_model = _get_cached_client( @@ -1181,7 +1288,8 @@ async def async_call_llm( kwargs = _build_call_kwargs( resolved_provider, final_model, messages, temperature=temperature, max_tokens=max_tokens, - tools=tools, timeout=timeout, extra_body=extra_body) + tools=tools, timeout=timeout, extra_body=extra_body, + base_url=resolved_base_url) try: return await client.chat.completions.create(**kwargs) diff --git a/cli.py b/cli.py index 44c7889c..1bebbf4f 100755 --- a/cli.py +++ b/cli.py @@ -218,11 +218,27 @@ def load_cli_config() -> Dict[str, Any]: "timeout": 300, # Max seconds a sandbox script can run before being killed (5 min) "max_tool_calls": 50, # Max RPC tool calls per execution }, + "auxiliary": { + "vision": { + "provider": "auto", + "model": "", + "base_url": "", + "api_key": "", + }, + "web_extract": { + "provider": "auto", + "model": "", + "base_url": "", + "api_key": "", + }, + }, "delegation": { "max_iterations": 45, # Max tool-calling turns per child agent "default_toolsets": ["terminal", "file", "web"], # Default toolsets for subagents "model": "", # Subagent model override (empty = inherit parent model) "provider": "", # Subagent provider override (empty = inherit parent provider) + "base_url": "", # Direct OpenAI-compatible endpoint for subagents + "api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY) }, } @@ -363,28 +379,44 @@ def load_cli_config() -> Dict[str, Any]: if config_key in compression_config: os.environ[env_var] = str(compression_config[config_key]) - # Apply auxiliary model overrides to environment variables. - # Vision and web_extract each have their own provider + model pair. + # Apply auxiliary model/direct-endpoint overrides to environment variables. + # Vision and web_extract each have their own provider/model/base_url/api_key tuple. # (Compression is handled in the compression section above.) # Only set env vars for non-empty / non-default values so auto-detection # still works. auxiliary_config = defaults.get("auxiliary", {}) auxiliary_task_env = { - # config key → (provider env var, model env var) - "vision": ("AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL"), - "web_extract": ("AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL"), + # config key → env var mapping + "vision": { + "provider": "AUXILIARY_VISION_PROVIDER", + "model": "AUXILIARY_VISION_MODEL", + "base_url": "AUXILIARY_VISION_BASE_URL", + "api_key": "AUXILIARY_VISION_API_KEY", + }, + "web_extract": { + "provider": "AUXILIARY_WEB_EXTRACT_PROVIDER", + "model": "AUXILIARY_WEB_EXTRACT_MODEL", + "base_url": "AUXILIARY_WEB_EXTRACT_BASE_URL", + "api_key": "AUXILIARY_WEB_EXTRACT_API_KEY", + }, } - for task_key, (prov_env, model_env) in auxiliary_task_env.items(): + for task_key, env_map in auxiliary_task_env.items(): task_cfg = auxiliary_config.get(task_key, {}) if not isinstance(task_cfg, dict): continue prov = str(task_cfg.get("provider", "")).strip() model = str(task_cfg.get("model", "")).strip() + base_url = str(task_cfg.get("base_url", "")).strip() + api_key = str(task_cfg.get("api_key", "")).strip() if prov and prov != "auto": - os.environ[prov_env] = prov + os.environ[env_map["provider"]] = prov if model: - os.environ[model_env] = model + os.environ[env_map["model"]] = model + if base_url: + os.environ[env_map["base_url"]] = base_url + if api_key: + os.environ[env_map["api_key"]] = api_key # Security settings security_config = defaults.get("security", {}) diff --git a/gateway/run.py b/gateway/run.py index e973852b..8941fcec 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -100,24 +100,40 @@ if _config_path.exists(): for _cfg_key, _env_var in _compression_env_map.items(): if _cfg_key in _compression_cfg: os.environ[_env_var] = str(_compression_cfg[_cfg_key]) - # Auxiliary model overrides (vision, web_extract). - # Each task has provider + model; bridge non-default values to env vars. + # Auxiliary model/direct-endpoint overrides (vision, web_extract). + # Each task has provider/model/base_url/api_key; bridge non-default values to env vars. _auxiliary_cfg = _cfg.get("auxiliary", {}) if _auxiliary_cfg and isinstance(_auxiliary_cfg, dict): _aux_task_env = { - "vision": ("AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL"), - "web_extract": ("AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL"), + "vision": { + "provider": "AUXILIARY_VISION_PROVIDER", + "model": "AUXILIARY_VISION_MODEL", + "base_url": "AUXILIARY_VISION_BASE_URL", + "api_key": "AUXILIARY_VISION_API_KEY", + }, + "web_extract": { + "provider": "AUXILIARY_WEB_EXTRACT_PROVIDER", + "model": "AUXILIARY_WEB_EXTRACT_MODEL", + "base_url": "AUXILIARY_WEB_EXTRACT_BASE_URL", + "api_key": "AUXILIARY_WEB_EXTRACT_API_KEY", + }, } - for _task_key, (_prov_env, _model_env) in _aux_task_env.items(): + for _task_key, _env_map in _aux_task_env.items(): _task_cfg = _auxiliary_cfg.get(_task_key, {}) if not isinstance(_task_cfg, dict): continue _prov = str(_task_cfg.get("provider", "")).strip() _model = str(_task_cfg.get("model", "")).strip() + _base_url = str(_task_cfg.get("base_url", "")).strip() + _api_key = str(_task_cfg.get("api_key", "")).strip() if _prov and _prov != "auto": - os.environ[_prov_env] = _prov + os.environ[_env_map["provider"]] = _prov if _model: - os.environ[_model_env] = _model + os.environ[_env_map["model"]] = _model + if _base_url: + os.environ[_env_map["base_url"]] = _base_url + if _api_key: + os.environ[_env_map["api_key"]] = _api_key _agent_cfg = _cfg.get("agent", {}) if _agent_cfg and isinstance(_agent_cfg, dict): if "max_turns" in _agent_cfg: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index bdde858d..b67405a0 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -150,30 +150,44 @@ DEFAULT_CONFIG = { "vision": { "provider": "auto", # auto | openrouter | nous | codex | custom "model": "", # e.g. "google/gemini-2.5-flash", "gpt-4o" + "base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider) + "api_key": "", # API key for base_url (falls back to OPENAI_API_KEY) }, "web_extract": { "provider": "auto", "model": "", + "base_url": "", + "api_key": "", }, "compression": { "provider": "auto", "model": "", + "base_url": "", + "api_key": "", }, "session_search": { "provider": "auto", "model": "", + "base_url": "", + "api_key": "", }, "skills_hub": { "provider": "auto", "model": "", + "base_url": "", + "api_key": "", }, "mcp": { "provider": "auto", "model": "", + "base_url": "", + "api_key": "", }, "flush_memories": { "provider": "auto", "model": "", + "base_url": "", + "api_key": "", }, }, @@ -243,6 +257,8 @@ DEFAULT_CONFIG = { "delegation": { "model": "", # e.g. "google/gemini-3-flash-preview" (empty = inherit parent model) "provider": "", # e.g. "openrouter" (empty = inherit parent provider + credentials) + "base_url": "", # direct OpenAI-compatible endpoint for subagents + "api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY) }, # Ephemeral prefill messages file — JSON list of {role, content} dicts diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 57c73eb8..d60e3c81 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -24,9 +24,11 @@ def _clean_env(monkeypatch): for key in ( "OPENROUTER_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL", "LLM_MODEL", "NOUS_INFERENCE_BASE_URL", - # Per-task provider/model overrides + # Per-task provider/model/direct-endpoint overrides "AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL", + "AUXILIARY_VISION_BASE_URL", "AUXILIARY_VISION_API_KEY", "AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL", + "AUXILIARY_WEB_EXTRACT_BASE_URL", "AUXILIARY_WEB_EXTRACT_API_KEY", "CONTEXT_COMPRESSION_PROVIDER", "CONTEXT_COMPRESSION_MODEL", ): monkeypatch.delenv(key, raising=False) @@ -142,6 +144,27 @@ class TestGetTextAuxiliaryClient: call_kwargs = mock_openai.call_args assert call_kwargs.kwargs["base_url"] == "http://localhost:1234/v1" + def test_task_direct_endpoint_override(self, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_BASE_URL", "http://localhost:2345/v1") + monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_API_KEY", "task-key") + monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_MODEL", "task-model") + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + client, model = get_text_auxiliary_client("web_extract") + assert model == "task-model" + assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:2345/v1" + assert mock_openai.call_args.kwargs["api_key"] == "task-key" + + def test_task_direct_endpoint_without_openai_key_does_not_fall_back(self, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_BASE_URL", "http://localhost:2345/v1") + monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_MODEL", "task-model") + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + client, model = get_text_auxiliary_client("web_extract") + assert client is None + assert model is None + mock_openai.assert_not_called() + def test_codex_fallback_when_nothing_else(self, codex_auth_dir): with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: @@ -194,6 +217,27 @@ class TestVisionClientFallback: client, model = get_vision_auxiliary_client() assert client is not None # Custom endpoint picked up as fallback + def test_vision_direct_endpoint_override(self, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("AUXILIARY_VISION_BASE_URL", "http://localhost:4567/v1") + monkeypatch.setenv("AUXILIARY_VISION_API_KEY", "vision-key") + monkeypatch.setenv("AUXILIARY_VISION_MODEL", "vision-model") + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + client, model = get_vision_auxiliary_client() + assert model == "vision-model" + assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:4567/v1" + assert mock_openai.call_args.kwargs["api_key"] == "vision-key" + + def test_vision_direct_endpoint_requires_openai_api_key(self, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("AUXILIARY_VISION_BASE_URL", "http://localhost:4567/v1") + monkeypatch.setenv("AUXILIARY_VISION_MODEL", "vision-model") + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + client, model = get_vision_auxiliary_client() + assert client is None + assert model is None + mock_openai.assert_not_called() + def test_vision_uses_openrouter_when_available(self, monkeypatch): monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") with patch("agent.auxiliary_client.OpenAI") as mock_openai: @@ -390,6 +434,24 @@ class TestTaskSpecificOverrides: client, model = get_text_auxiliary_client("web_extract") assert model == "google/gemini-3-flash-preview" + def test_task_direct_endpoint_from_config(self, monkeypatch, tmp_path): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "config.yaml").write_text( + """auxiliary: + web_extract: + base_url: http://localhost:3456/v1 + api_key: config-key + model: config-model +""" + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + client, model = get_text_auxiliary_client("web_extract") + assert model == "config-model" + assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:3456/v1" + assert mock_openai.call_args.kwargs["api_key"] == "config-key" + def test_task_without_override_uses_auto(self, monkeypatch): """A task with no provider env var falls through to auto chain.""" monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") diff --git a/tests/conftest.py b/tests/conftest.py index 9c9f9a44..67fad819 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,12 @@ def _isolate_hermes_home(tmp_path, monkeypatch): (fake_home / "memories").mkdir() (fake_home / "skills").mkdir() monkeypatch.setenv("HERMES_HOME", str(fake_home)) + # Tests should not inherit the agent's current gateway/messaging surface. + # Individual tests that need gateway behavior set these explicitly. + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) @pytest.fixture() diff --git a/tests/test_auxiliary_config_bridge.py b/tests/test_auxiliary_config_bridge.py index a4d65c2a..22e88bdf 100644 --- a/tests/test_auxiliary_config_bridge.py +++ b/tests/test_auxiliary_config_bridge.py @@ -25,7 +25,9 @@ def _run_auxiliary_bridge(config_dict, monkeypatch): # Clear env vars for key in ( "AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL", + "AUXILIARY_VISION_BASE_URL", "AUXILIARY_VISION_API_KEY", "AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL", + "AUXILIARY_WEB_EXTRACT_BASE_URL", "AUXILIARY_WEB_EXTRACT_API_KEY", "CONTEXT_COMPRESSION_PROVIDER", "CONTEXT_COMPRESSION_MODEL", ): monkeypatch.delenv(key, raising=False) @@ -47,19 +49,35 @@ def _run_auxiliary_bridge(config_dict, monkeypatch): auxiliary_cfg = config_dict.get("auxiliary", {}) if auxiliary_cfg and isinstance(auxiliary_cfg, dict): aux_task_env = { - "vision": ("AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL"), - "web_extract": ("AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL"), + "vision": { + "provider": "AUXILIARY_VISION_PROVIDER", + "model": "AUXILIARY_VISION_MODEL", + "base_url": "AUXILIARY_VISION_BASE_URL", + "api_key": "AUXILIARY_VISION_API_KEY", + }, + "web_extract": { + "provider": "AUXILIARY_WEB_EXTRACT_PROVIDER", + "model": "AUXILIARY_WEB_EXTRACT_MODEL", + "base_url": "AUXILIARY_WEB_EXTRACT_BASE_URL", + "api_key": "AUXILIARY_WEB_EXTRACT_API_KEY", + }, } - for task_key, (prov_env, model_env) in aux_task_env.items(): + for task_key, env_map in aux_task_env.items(): task_cfg = auxiliary_cfg.get(task_key, {}) if not isinstance(task_cfg, dict): continue prov = str(task_cfg.get("provider", "")).strip() model = str(task_cfg.get("model", "")).strip() + base_url = str(task_cfg.get("base_url", "")).strip() + api_key = str(task_cfg.get("api_key", "")).strip() if prov and prov != "auto": - os.environ[prov_env] = prov + os.environ[env_map["provider"]] = prov if model: - os.environ[model_env] = model + os.environ[env_map["model"]] = model + if base_url: + os.environ[env_map["base_url"]] = base_url + if api_key: + os.environ[env_map["api_key"]] = api_key # ── Config bridging tests ──────────────────────────────────────────────────── @@ -101,6 +119,21 @@ class TestAuxiliaryConfigBridge: assert os.environ.get("AUXILIARY_WEB_EXTRACT_PROVIDER") == "nous" assert os.environ.get("AUXILIARY_WEB_EXTRACT_MODEL") == "gemini-2.5-flash" + def test_direct_endpoint_bridged(self, monkeypatch): + config = { + "auxiliary": { + "vision": { + "base_url": "http://localhost:1234/v1", + "api_key": "local-key", + "model": "qwen2.5-vl", + } + } + } + _run_auxiliary_bridge(config, monkeypatch) + assert os.environ.get("AUXILIARY_VISION_BASE_URL") == "http://localhost:1234/v1" + assert os.environ.get("AUXILIARY_VISION_API_KEY") == "local-key" + assert os.environ.get("AUXILIARY_VISION_MODEL") == "qwen2.5-vl" + def test_compression_provider_bridged(self, monkeypatch): config = { "compression": { @@ -200,8 +233,12 @@ class TestGatewayBridgeCodeParity: # Check for key patterns that indicate the bridge is present assert "AUXILIARY_VISION_PROVIDER" in content assert "AUXILIARY_VISION_MODEL" in content + assert "AUXILIARY_VISION_BASE_URL" in content + assert "AUXILIARY_VISION_API_KEY" in content assert "AUXILIARY_WEB_EXTRACT_PROVIDER" in content assert "AUXILIARY_WEB_EXTRACT_MODEL" in content + assert "AUXILIARY_WEB_EXTRACT_BASE_URL" in content + assert "AUXILIARY_WEB_EXTRACT_API_KEY" in content def test_gateway_has_compression_provider(self): """Gateway must bridge compression.summary_provider.""" diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 680233b0..a29560b2 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -10,6 +10,7 @@ Run with: python -m pytest tests/test_delegate.py -v """ import json +import os import sys import unittest from unittest.mock import MagicMock, patch @@ -462,6 +463,43 @@ class TestDelegationCredentialResolution(unittest.TestCase): self.assertEqual(creds["api_mode"], "chat_completions") mock_resolve.assert_called_once_with(requested="openrouter") + def test_direct_endpoint_uses_configured_base_url_and_api_key(self): + parent = _make_mock_parent(depth=0) + cfg = { + "model": "qwen2.5-coder", + "provider": "openrouter", + "base_url": "http://localhost:1234/v1", + "api_key": "local-key", + } + creds = _resolve_delegation_credentials(cfg, parent) + self.assertEqual(creds["model"], "qwen2.5-coder") + self.assertEqual(creds["provider"], "custom") + self.assertEqual(creds["base_url"], "http://localhost:1234/v1") + self.assertEqual(creds["api_key"], "local-key") + self.assertEqual(creds["api_mode"], "chat_completions") + + def test_direct_endpoint_falls_back_to_openai_api_key_env(self): + parent = _make_mock_parent(depth=0) + cfg = { + "model": "qwen2.5-coder", + "base_url": "http://localhost:1234/v1", + } + with patch.dict(os.environ, {"OPENAI_API_KEY": "env-openai-key"}, clear=False): + creds = _resolve_delegation_credentials(cfg, parent) + self.assertEqual(creds["api_key"], "env-openai-key") + self.assertEqual(creds["provider"], "custom") + + def test_direct_endpoint_does_not_fall_back_to_openrouter_api_key_env(self): + parent = _make_mock_parent(depth=0) + cfg = { + "model": "qwen2.5-coder", + "base_url": "http://localhost:1234/v1", + } + with patch.dict(os.environ, {"OPENROUTER_API_KEY": "env-openrouter-key"}, clear=False): + with self.assertRaises(ValueError) as ctx: + _resolve_delegation_credentials(cfg, parent) + self.assertIn("OPENAI_API_KEY", str(ctx.exception)) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") def test_nous_provider_resolves_nous_credentials(self, mock_resolve): """Nous provider resolves Nous Portal base_url and api_key.""" @@ -589,6 +627,40 @@ class TestDelegationProviderIntegration(unittest.TestCase): self.assertNotEqual(kwargs["base_url"], parent.base_url) self.assertNotEqual(kwargs["api_key"], parent.api_key) + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_direct_endpoint_credentials_reach_child_agent(self, mock_creds, mock_cfg): + mock_cfg.return_value = { + "max_iterations": 45, + "model": "qwen2.5-coder", + "base_url": "http://localhost:1234/v1", + "api_key": "local-key", + } + mock_creds.return_value = { + "model": "qwen2.5-coder", + "provider": "custom", + "base_url": "http://localhost:1234/v1", + "api_key": "local-key", + "api_mode": "chat_completions", + } + parent = _make_mock_parent(depth=0) + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.return_value = { + "final_response": "done", "completed": True, "api_calls": 1 + } + MockAgent.return_value = mock_child + + delegate_task(goal="Direct endpoint test", parent_agent=parent) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["model"], "qwen2.5-coder") + self.assertEqual(kwargs["provider"], "custom") + self.assertEqual(kwargs["base_url"], "http://localhost:1234/v1") + self.assertEqual(kwargs["api_key"], "local-key") + self.assertEqual(kwargs["api_mode"], "chat_completions") + @patch("tools.delegate_tool._load_config") @patch("tools.delegate_tool._resolve_delegation_credentials") def test_empty_config_inherits_parent(self, mock_creds, mock_cfg): diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 76026be5..0d5908ab 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -540,18 +540,51 @@ def delegate_task( def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: """Resolve credentials for subagent delegation. - If ``delegation.provider`` is configured, resolves the full credential - bundle (base_url, api_key, api_mode, provider) via the runtime provider - system — the same path used by CLI/gateway startup. This lets subagents - run on a completely different provider:model pair. + If ``delegation.base_url`` is configured, subagents use that direct + OpenAI-compatible endpoint. Otherwise, if ``delegation.provider`` is + configured, the full credential bundle (base_url, api_key, api_mode, + provider) is resolved via the runtime provider system — the same path used + by CLI/gateway startup. This lets subagents run on a completely different + provider:model pair. - If no provider is configured, returns None values so the child inherits - everything from the parent agent. + If neither base_url nor provider is configured, returns None values so the + child inherits everything from the parent agent. Raises ValueError with a user-friendly message on credential failure. """ - configured_model = cfg.get("model") or None - configured_provider = cfg.get("provider") or None + configured_model = str(cfg.get("model") or "").strip() or None + configured_provider = str(cfg.get("provider") or "").strip() or None + configured_base_url = str(cfg.get("base_url") or "").strip() or None + configured_api_key = str(cfg.get("api_key") or "").strip() or None + + if configured_base_url: + api_key = ( + configured_api_key + or os.getenv("OPENAI_API_KEY", "").strip() + ) + if not api_key: + raise ValueError( + "Delegation base_url is configured but no API key was found. " + "Set delegation.api_key or OPENAI_API_KEY." + ) + + base_lower = configured_base_url.lower() + provider = "custom" + api_mode = "chat_completions" + if "chatgpt.com/backend-api/codex" in base_lower: + provider = "openai-codex" + api_mode = "codex_responses" + elif "api.anthropic.com" in base_lower: + provider = "anthropic" + api_mode = "anthropic_messages" + + return { + "model": configured_model, + "provider": provider, + "base_url": configured_base_url, + "api_key": api_key, + "api_mode": api_mode, + } if not configured_provider: # No provider override — child inherits everything from parent @@ -570,7 +603,8 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: except Exception as exc: raise ValueError( f"Cannot resolve delegation provider '{configured_provider}': {exc}. " - f"Check that the provider is configured (API key set, valid provider name). " + f"Check that the provider is configured (API key set, valid provider name), " + f"or set delegation.base_url/delegation.api_key for a direct endpoint. " f"Available providers: openrouter, nous, zai, kimi-coding, minimax." ) from exc diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index d4f633ee..36a54d26 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -180,6 +180,23 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `CONTEXT_COMPRESSION_THRESHOLD` | Trigger at this % of limit (default: 0.50) | | `CONTEXT_COMPRESSION_MODEL` | Model for summaries | +## Auxiliary Task Overrides + +| Variable | Description | +|----------|-------------| +| `AUXILIARY_VISION_PROVIDER` | Override provider for vision tasks | +| `AUXILIARY_VISION_MODEL` | Override model for vision tasks | +| `AUXILIARY_VISION_BASE_URL` | Direct OpenAI-compatible endpoint for vision tasks | +| `AUXILIARY_VISION_API_KEY` | API key paired with `AUXILIARY_VISION_BASE_URL` | +| `AUXILIARY_WEB_EXTRACT_PROVIDER` | Override provider for web extraction/summarization | +| `AUXILIARY_WEB_EXTRACT_MODEL` | Override model for web extraction/summarization | +| `AUXILIARY_WEB_EXTRACT_BASE_URL` | Direct OpenAI-compatible endpoint for web extraction/summarization | +| `AUXILIARY_WEB_EXTRACT_API_KEY` | API key paired with `AUXILIARY_WEB_EXTRACT_BASE_URL` | +| `CONTEXT_COMPRESSION_PROVIDER` | Override provider for context compression summaries | +| `CONTEXT_COMPRESSION_MODEL` | Override model for context compression summaries | + +For task-specific direct endpoints, Hermes uses the task's configured API key or `OPENAI_API_KEY`. It does not reuse `OPENROUTER_API_KEY` for those custom endpoints. + ## Provider Routing (config.yaml only) These go in `~/.hermes/config.yaml` under the `provider_routing` section: diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 71525764..0a1c50cb 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -569,11 +569,15 @@ auxiliary: vision: provider: "auto" # "auto", "openrouter", "nous", "main" model: "" # e.g. "openai/gpt-4o", "google/gemini-2.5-flash" + base_url: "" # direct OpenAI-compatible endpoint (takes precedence over provider) + api_key: "" # API key for base_url (falls back to OPENAI_API_KEY) # Web page summarization + browser page text extraction web_extract: provider: "auto" model: "" # e.g. "google/gemini-2.5-flash" + base_url: "" + api_key: "" ``` ### Changing the Vision Model @@ -604,6 +608,17 @@ AUXILIARY_VISION_MODEL=openai/gpt-4o ### Common Setups +**Using a direct custom endpoint** (clearer than `provider: "main"` for local/self-hosted APIs): +```yaml +auxiliary: + vision: + base_url: "http://localhost:1234/v1" + api_key: "local-key" + model: "qwen2.5-vl" +``` + +`base_url` takes precedence over `provider`, so this is the most explicit way to route an auxiliary task to a specific endpoint. For direct endpoint overrides, Hermes uses the configured `api_key` or falls back to `OPENAI_API_KEY`; it does not reuse `OPENROUTER_API_KEY` for that custom endpoint. + **Using OpenAI API key for vision:** ```yaml # In ~/.hermes/.env: @@ -848,13 +863,17 @@ delegation: - web # model: "google/gemini-3-flash-preview" # Override model (empty = inherit parent) # provider: "openrouter" # Override provider (empty = inherit parent) + # base_url: "http://localhost:1234/v1" # Direct OpenAI-compatible endpoint (takes precedence over provider) + # api_key: "local-key" # API key for base_url (falls back to OPENAI_API_KEY) ``` **Subagent provider:model override:** By default, subagents inherit the parent agent's provider and model. Set `delegation.provider` and `delegation.model` to route subagents to a different provider:model pair — e.g., use a cheap/fast model for narrowly-scoped subtasks while your primary agent runs an expensive reasoning model. +**Direct endpoint override:** If you want the obvious custom-endpoint path, set `delegation.base_url`, `delegation.api_key`, and `delegation.model`. That sends subagents directly to that OpenAI-compatible endpoint and takes precedence over `delegation.provider`. If `delegation.api_key` is omitted, Hermes falls back to `OPENAI_API_KEY` only. + The delegation provider uses the same credential resolution as CLI/gateway startup. All configured providers are supported: `openrouter`, `nous`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. When a provider is set, the system automatically resolves the correct base URL, API key, and API mode — no manual credential wiring needed. -**Precedence:** `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter). +**Precedence:** `delegation.base_url` in config → `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter). ## Clarify diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index 78237167..f3193d9a 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -209,6 +209,14 @@ Delegation has a **depth limit of 2** — a parent (depth 0) can spawn children delegation: max_iterations: 50 # Max turns per child (default: 50) default_toolsets: ["terminal", "file", "web"] # Default toolsets + model: "google/gemini-3-flash-preview" # Optional provider/model override + provider: "openrouter" # Optional built-in provider + +# Or use a direct custom endpoint instead of provider: +delegation: + model: "qwen2.5-coder" + base_url: "http://localhost:1234/v1" + api_key: "local-key" ``` :::tip From db362dbd4c0e6f1b4b3a8dd5a8b2688aa18eec66 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 21:14:20 -0700 Subject: [PATCH 03/41] feat: add native Anthropic auxiliary vision --- agent/anthropic_adapter.py | 72 ++++++- agent/auxiliary_client.py | 198 ++++++++++++++++-- hermes_cli/setup.py | 8 +- tests/agent/test_auxiliary_client.py | 67 +++++- tests/hermes_cli/test_setup_model_provider.py | 16 ++ tests/test_anthropic_adapter.py | 50 +++++ tools/vision_tools.py | 5 +- 7 files changed, 386 insertions(+), 30 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 39efa219..ad3dfe58 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -391,6 +391,68 @@ def _sanitize_tool_id(tool_id: str) -> str: return sanitized or "tool_0" +def _convert_openai_image_part_to_anthropic(part: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Convert an OpenAI-style image block to Anthropic's image source format.""" + image_data = part.get("image_url", {}) + url = image_data.get("url", "") if isinstance(image_data, dict) else str(image_data) + if not isinstance(url, str) or not url.strip(): + return None + url = url.strip() + + if url.startswith("data:"): + header, sep, data = url.partition(",") + if sep and ";base64" in header: + media_type = header[5:].split(";", 1)[0] or "image/png" + return { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": data, + }, + } + + if url.startswith("http://") or url.startswith("https://"): + return { + "type": "image", + "source": { + "type": "url", + "url": url, + }, + } + + return None + + +def _convert_user_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]: + if isinstance(part, dict): + ptype = part.get("type") + if ptype == "text": + block = {"type": "text", "text": part.get("text", "")} + if isinstance(part.get("cache_control"), dict): + block["cache_control"] = dict(part["cache_control"]) + return block + if ptype == "image_url": + return _convert_openai_image_part_to_anthropic(part) + if ptype == "image" and part.get("source"): + return dict(part) + if ptype == "image" and part.get("data"): + media_type = part.get("mimeType") or part.get("media_type") or "image/png" + return { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": part.get("data", ""), + }, + } + if ptype == "tool_result": + return dict(part) + elif part is not None: + return {"type": "text", "text": str(part)} + return None + + def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: """Convert OpenAI tool definitions to Anthropic format.""" if not tools: @@ -495,7 +557,15 @@ def convert_messages_to_anthropic( continue # Regular user message - result.append({"role": "user", "content": content}) + if isinstance(content, list): + converted_blocks = [] + for part in content: + converted = _convert_user_content_part_to_anthropic(part) + if converted is not None: + converted_blocks.append(converted) + result.append({"role": "user", "content": converted_blocks or [{"type": "text", "text": ""}]}) + else: + result.append({"role": "user", "content": content}) # Strip orphaned tool_use blocks (no matching tool_result follows) tool_result_ids = set() diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index dd8f22bb..7794889c 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1,4 +1,4 @@ -"""Shared auxiliary OpenAI client for cheap/fast side tasks. +"""Shared auxiliary client router for side tasks. Provides a single resolution chain so every consumer (context compression, session search, web extraction, vision analysis, browser vision) picks up @@ -10,21 +10,21 @@ Resolution order for text tasks (auto mode): 3. Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) 4. Codex OAuth (Responses API via chatgpt.com with gpt-5.3-codex, wrapped to look like a chat.completions client) - 5. Direct API-key providers (z.ai/GLM, Kimi/Moonshot, MiniMax, MiniMax-CN) - — checked via PROVIDER_REGISTRY entries with auth_type='api_key' - 6. None + 5. Native Anthropic + 6. Direct API-key providers (z.ai/GLM, Kimi/Moonshot, MiniMax, MiniMax-CN) + 7. None Resolution order for vision/multimodal tasks (auto mode): - 1. OpenRouter - 2. Nous Portal - 3. Codex OAuth (gpt-5.3-codex supports vision via Responses API) - 4. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.) - 5. None (API-key providers like z.ai/Kimi/MiniMax are skipped — - they may not support multimodal) + 1. Selected main provider, if it is one of the supported vision backends below + 2. OpenRouter + 3. Nous Portal + 4. Codex OAuth (gpt-5.3-codex supports vision via Responses API) + 5. Native Anthropic + 6. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.) + 7. None Per-task provider overrides (e.g. AUXILIARY_VISION_PROVIDER, -CONTEXT_COMPRESSION_PROVIDER) can force a specific provider for each task: -"openrouter", "nous", "codex", or "main" (= steps 3-5). +CONTEXT_COMPRESSION_PROVIDER) can force a specific provider for each task. Default "auto" follows the chains above. Per-task model overrides (e.g. AUXILIARY_VISION_MODEL, @@ -74,6 +74,7 @@ auxiliary_is_nous: bool = False _OPENROUTER_MODEL = "google/gemini-3-flash-preview" _NOUS_MODEL = "gemini-3-flash" _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1" +_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com" _AUTH_JSON_PATH = get_hermes_home() / "auth.json" # Codex fallback: uses the Responses API (the only endpoint the Codex @@ -309,6 +310,114 @@ class AsyncCodexAuxiliaryClient: self.base_url = sync_wrapper.base_url +class _AnthropicCompletionsAdapter: + """OpenAI-client-compatible adapter for Anthropic Messages API.""" + + def __init__(self, real_client: Any, model: str): + self._client = real_client + self._model = model + + def create(self, **kwargs) -> Any: + from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response + + messages = kwargs.get("messages", []) + model = kwargs.get("model", self._model) + tools = kwargs.get("tools") + tool_choice = kwargs.get("tool_choice") + max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000 + temperature = kwargs.get("temperature") + + normalized_tool_choice = None + if isinstance(tool_choice, str): + normalized_tool_choice = tool_choice + elif isinstance(tool_choice, dict): + choice_type = str(tool_choice.get("type", "")).lower() + if choice_type == "function": + normalized_tool_choice = tool_choice.get("function", {}).get("name") + elif choice_type in {"auto", "required", "none"}: + normalized_tool_choice = choice_type + + anthropic_kwargs = build_anthropic_kwargs( + model=model, + messages=messages, + tools=tools, + max_tokens=max_tokens, + reasoning_config=None, + tool_choice=normalized_tool_choice, + ) + if temperature is not None: + anthropic_kwargs["temperature"] = temperature + + response = self._client.messages.create(**anthropic_kwargs) + assistant_message, finish_reason = normalize_anthropic_response(response) + + usage = None + if hasattr(response, "usage") and response.usage: + prompt_tokens = getattr(response.usage, "input_tokens", 0) or 0 + completion_tokens = getattr(response.usage, "output_tokens", 0) or 0 + total_tokens = getattr(response.usage, "total_tokens", 0) or (prompt_tokens + completion_tokens) + usage = SimpleNamespace( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + ) + + choice = SimpleNamespace( + index=0, + message=assistant_message, + finish_reason=finish_reason, + ) + return SimpleNamespace( + choices=[choice], + model=model, + usage=usage, + ) + + +class _AnthropicChatShim: + def __init__(self, adapter: _AnthropicCompletionsAdapter): + self.completions = adapter + + +class AnthropicAuxiliaryClient: + """OpenAI-client-compatible wrapper over a native Anthropic client.""" + + def __init__(self, real_client: Any, model: str, api_key: str, base_url: str): + self._real_client = real_client + adapter = _AnthropicCompletionsAdapter(real_client, model) + self.chat = _AnthropicChatShim(adapter) + self.api_key = api_key + self.base_url = base_url + + def close(self): + close_fn = getattr(self._real_client, "close", None) + if callable(close_fn): + close_fn() + + +class _AsyncAnthropicCompletionsAdapter: + def __init__(self, sync_adapter: _AnthropicCompletionsAdapter): + self._sync = sync_adapter + + async def create(self, **kwargs) -> Any: + import asyncio + return await asyncio.to_thread(self._sync.create, **kwargs) + + +class _AsyncAnthropicChatShim: + def __init__(self, adapter: _AsyncAnthropicCompletionsAdapter): + self.completions = adapter + + +class AsyncAnthropicAuxiliaryClient: + def __init__(self, sync_wrapper: "AnthropicAuxiliaryClient"): + sync_adapter = sync_wrapper.chat.completions + async_adapter = _AsyncAnthropicCompletionsAdapter(sync_adapter) + self.chat = _AsyncAnthropicChatShim(async_adapter) + self.api_key = sync_wrapper.api_key + self.base_url = sync_wrapper.base_url + + def _read_nous_auth() -> Optional[dict]: """Read and validate ~/.hermes/auth.json for an active Nous provider. @@ -380,6 +489,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: break if not api_key: continue + if provider_id == "anthropic": + return _try_anthropic() + # Resolve base URL (with optional env-var override) # Kimi Code keys (sk-kimi-) need api.kimi.com/coding/v1 env_url = "" @@ -484,6 +596,22 @@ def _try_codex() -> Tuple[Optional[Any], Optional[str]]: return CodexAuxiliaryClient(real_client, _CODEX_AUX_MODEL), _CODEX_AUX_MODEL +def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]: + try: + from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token + except ImportError: + return None, None + + token = resolve_anthropic_token() + if not token: + return None, None + + model = _API_KEY_PROVIDER_AUX_MODELS.get("anthropic", "claude-haiku-4-5-20251001") + logger.debug("Auxiliary client: Anthropic native (%s)", model) + real_client = build_anthropic_client(token, _ANTHROPIC_DEFAULT_BASE_URL) + return AnthropicAuxiliaryClient(real_client, model, token, _ANTHROPIC_DEFAULT_BASE_URL), model + + def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[str]]: """Resolve a specific forced provider. Returns (None, None) if creds missing.""" if forced == "openrouter": @@ -546,6 +674,8 @@ def _to_async_client(sync_client, model: str): if isinstance(sync_client, CodexAuxiliaryClient): return AsyncCodexAuxiliaryClient(sync_client), model + if isinstance(sync_client, AnthropicAuxiliaryClient): + return AsyncAnthropicAuxiliaryClient(sync_client), model async_kwargs = { "api_key": sync_client.api_key, @@ -686,6 +816,14 @@ def resolve_provider_client( return None, None if pconfig.auth_type == "api_key": + if provider == "anthropic": + client, default_model = _try_anthropic() + if client is None: + logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found") + return None, None + final_model = model or default_model + return (_to_async_client(client, final_model) if async_mode else (client, final_model)) + # Find the first configured API key api_key = "" for env_var in pconfig.api_key_env_vars: @@ -772,6 +910,7 @@ _VISION_AUTO_PROVIDER_ORDER = ( "openrouter", "nous", "openai-codex", + "anthropic", "custom", ) @@ -793,6 +932,8 @@ def _resolve_strict_vision_backend(provider: str) -> Tuple[Optional[Any], Option return _try_nous() if provider == "openai-codex": return _try_codex() + if provider == "anthropic": + return _try_anthropic() if provider == "custom": return _try_custom_endpoint() return None, None @@ -802,19 +943,36 @@ def _strict_vision_backend_available(provider: str) -> bool: return _resolve_strict_vision_backend(provider)[0] is not None +def _preferred_main_vision_provider() -> Optional[str]: + """Return the selected main provider when it is also a supported vision backend.""" + try: + from hermes_cli.config import load_config + + config = load_config() + model_cfg = config.get("model", {}) + if isinstance(model_cfg, dict): + provider = _normalize_vision_provider(model_cfg.get("provider", "")) + if provider in _VISION_AUTO_PROVIDER_ORDER: + return provider + except Exception: + pass + return None + + def get_available_vision_backends() -> List[str]: """Return the currently available vision backends in auto-selection order. This is the single source of truth for setup, tool gating, and runtime - auto-routing of vision tasks. Phase 1 keeps the auto list conservative: - OpenRouter, Nous Portal, Codex OAuth, then custom OpenAI-compatible - endpoints. Explicit provider overrides can still route elsewhere. + auto-routing of vision tasks. The selected main provider is preferred when + it is also a known-good vision backend; otherwise Hermes falls back through + the standard conservative order. """ - return [ - provider - for provider in _VISION_AUTO_PROVIDER_ORDER - if _strict_vision_backend_available(provider) - ] + ordered = list(_VISION_AUTO_PROVIDER_ORDER) + preferred = _preferred_main_vision_provider() + if preferred in ordered: + ordered.remove(preferred) + ordered.insert(0, preferred) + return [provider for provider in ordered if _strict_vision_backend_available(provider)] def resolve_vision_provider_client( diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 051de13c..fcaff67c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1268,11 +1268,9 @@ def setup_model_provider(config: dict): _vision_needs_setup = not bool(_vision_backends) - if selected_provider in {"openrouter", "nous", "openai-codex"}: - # If the user just selected one of our known-good vision backends during - # setup, treat vision as covered. Auth/setup failure returns earlier. - _vision_needs_setup = False - elif selected_provider == "custom" and "custom" in _vision_backends: + if selected_provider in _vision_backends: + # If the user just selected a backend Hermes can already use for + # vision, treat it as covered. Auth/setup failure returns earlier. _vision_needs_setup = False if _vision_needs_setup: diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 57c73eb8..925772fa 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -10,6 +10,8 @@ import pytest from agent.auxiliary_client import ( get_text_auxiliary_client, get_vision_auxiliary_client, + get_available_vision_backends, + resolve_provider_client, auxiliary_max_tokens_param, _read_codex_access_token, _get_auxiliary_provider, @@ -24,6 +26,7 @@ def _clean_env(monkeypatch): for key in ( "OPENROUTER_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL", "LLM_MODEL", "NOUS_INFERENCE_BASE_URL", + "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", # Per-task provider/model overrides "AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL", "AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL", @@ -164,14 +167,74 @@ class TestGetTextAuxiliaryClient: class TestVisionClientFallback: - """Vision client auto mode only tries OpenRouter + Nous (multimodal-capable).""" + """Vision client auto mode resolves known-good multimodal backends.""" def test_vision_returns_none_without_any_credentials(self): - with patch("agent.auxiliary_client._read_nous_auth", return_value=None): + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("agent.auxiliary_client._try_anthropic", return_value=(None, None)), + ): client, model = get_vision_auxiliary_client() assert client is None assert model is None + def test_vision_auto_includes_anthropic_when_configured(self, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), + ): + backends = get_available_vision_backends() + + assert "anthropic" in backends + + def test_resolve_provider_client_returns_native_anthropic_wrapper(self, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), + ): + client, model = resolve_provider_client("anthropic") + + assert client is not None + assert client.__class__.__name__ == "AnthropicAuxiliaryClient" + assert model == "claude-haiku-4-5-20251001" + + def test_vision_auto_uses_anthropic_when_no_higher_priority_backend(self, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), + ): + client, model = get_vision_auxiliary_client() + + assert client is not None + assert client.__class__.__name__ == "AnthropicAuxiliaryClient" + assert model == "claude-haiku-4-5-20251001" + + def test_selected_anthropic_provider_is_preferred_for_vision_auto(self, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") + + def fake_load_config(): + return {"model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}} + + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), + patch("agent.auxiliary_client.OpenAI") as mock_openai, + patch("hermes_cli.config.load_config", fake_load_config), + ): + client, model = get_vision_auxiliary_client() + + assert client is not None + assert client.__class__.__name__ == "AnthropicAuxiliaryClient" + assert model == "claude-haiku-4-5-20251001" + def test_vision_auto_includes_codex(self, codex_auth_dir): """Codex supports vision (gpt-5.3-codex), so auto mode should use it.""" with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index 8f7063ec..34b49106 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -111,6 +111,7 @@ def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(tm monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda provider: []) + monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) setup_model_provider(config) save_config(config) @@ -149,6 +150,7 @@ def test_setup_keep_current_anthropic_can_configure_openai_vision_default(tmp_pa monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda provider: []) + monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) setup_model_provider(config) env = _read_env(tmp_path) @@ -224,3 +226,17 @@ def test_setup_summary_marks_codex_auth_as_vision_available(tmp_path, monkeypatc assert "missing run 'hermes setup' to configure" not in output assert "Mixture of Agents" in output assert "missing OPENROUTER_API_KEY" in output + + +def test_setup_summary_marks_anthropic_auth_as_vision_available(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") + monkeypatch.setattr("shutil.which", lambda _name: None) + monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: ["anthropic"]) + + _print_setup_summary(load_config(), tmp_path) + output = capsys.readouterr().out + + assert "Vision (image analysis)" in output + assert "missing run 'hermes setup' to configure" not in output diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 541d8e2b..1bc3af2e 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -567,6 +567,56 @@ class TestConvertMessages: assert tool_block["content"] == "result" assert tool_block["cache_control"] == {"type": "ephemeral"} + def test_converts_data_url_image_to_anthropic_image_block(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,ZmFrZQ=="}, + }, + ], + } + ] + + _, result = convert_messages_to_anthropic(messages) + blocks = result[0]["content"] + assert blocks[0] == {"type": "text", "text": "Describe this image"} + assert blocks[1] == { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "ZmFrZQ==", + }, + } + + def test_converts_remote_image_url_to_anthropic_image_block(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/cat.png"}, + }, + ], + } + ] + + _, result = convert_messages_to_anthropic(messages) + blocks = result[0]["content"] + assert blocks[1] == { + "type": "image", + "source": { + "type": "url", + "url": "https://example.com/cat.png", + }, + } + def test_empty_cached_assistant_tool_turn_converts_without_empty_text_block(self): messages = apply_anthropic_cache_control([ {"role": "system", "content": "System prompt"}, diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 37fb8fe4..954ffd28 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -3,7 +3,8 @@ Vision Tools Module This module provides vision analysis tools that work with image URLs. -Uses Gemini 3 Flash Preview via OpenRouter API for intelligent image understanding. +Uses the centralized auxiliary vision router, which can select OpenRouter, +Nous, Codex, native Anthropic, or a custom OpenAI-compatible endpoint. Available tools: - vision_analyze_tool: Analyze images from URLs with custom prompts @@ -409,7 +410,7 @@ if __name__ == "__main__": if not api_available: print("❌ No auxiliary vision model available") - print("Set OPENROUTER_API_KEY or configure Nous Portal to enable vision tools.") + print("Configure a supported multimodal backend (OpenRouter, Nous, Codex, Anthropic, or a custom OpenAI-compatible endpoint).") exit(1) else: print("✅ Vision model available") From 95939a1b5130c4a04bf67eaacbbb7ea7af5bd3f3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:17:41 -0700 Subject: [PATCH 04/41] docs: clarify gateway service scopes (#1378) --- cron/__init__.py | 3 ++- hermes_cli/cron.py | 4 +++- hermes_cli/main.py | 2 +- website/docs/guides/daily-briefing-bot.md | 7 +++++-- .../docs/guides/team-telegram-assistant.md | 10 ++++++++-- website/docs/user-guide/features/cron.md | 3 ++- website/docs/user-guide/messaging/email.md | 3 ++- website/docs/user-guide/messaging/index.md | 20 +++++++++++++++---- website/docs/user-guide/messaging/signal.md | 3 ++- website/docs/user-guide/messaging/slack.md | 3 ++- website/docs/user-guide/messaging/whatsapp.md | 3 ++- 11 files changed, 45 insertions(+), 16 deletions(-) diff --git a/cron/__init__.py b/cron/__init__.py index 31d7bf8e..2c44cabf 100644 --- a/cron/__init__.py +++ b/cron/__init__.py @@ -7,7 +7,8 @@ This module provides scheduled task execution, allowing the agent to: - Execute tasks in isolated sessions (no prior context) Cron jobs are executed automatically by the gateway daemon: - hermes gateway install # Install as system service (recommended) + hermes gateway install # Install as a user service + sudo hermes gateway install --system # Linux servers: boot-time system service hermes gateway # Or run in foreground The gateway ticks the scheduler every 60 seconds. A file lock prevents diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index a068d637..97a22579 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -96,6 +96,7 @@ def cron_list(show_all: bool = False): if not find_gateway_pids(): print(color(" ⚠ Gateway is not running — jobs won't fire automatically.", Colors.YELLOW)) print(color(" Start it with: hermes gateway install", Colors.DIM)) + print(color(" sudo hermes gateway install --system # Linux servers", Colors.DIM)) print() @@ -120,7 +121,8 @@ def cron_status(): print(color("✗ Gateway is not running — cron jobs will NOT fire", Colors.RED)) print() print(" To enable automatic execution:") - print(" hermes gateway install # Install as system service (recommended)") + print(" hermes gateway install # Install as a user service") + print(" sudo hermes gateway install --system # Linux servers: boot-time system service") print(" hermes gateway # Or run in foreground") print() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1238d9b6..5f9356b8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2313,7 +2313,7 @@ Examples: hermes gateway Run messaging gateway hermes -s hermes-agent-dev,github-auth hermes -w Start in isolated git worktree - hermes gateway install Install as system service + hermes gateway install Install gateway background service hermes sessions list List past sessions hermes sessions browse Interactive session picker hermes sessions rename ID T Rename/title a session diff --git a/website/docs/guides/daily-briefing-bot.md b/website/docs/guides/daily-briefing-bot.md index 85f11c40..78bfd690 100644 --- a/website/docs/guides/daily-briefing-bot.md +++ b/website/docs/guides/daily-briefing-bot.md @@ -29,7 +29,8 @@ Before starting, make sure you have: - **Hermes Agent installed** — see the [Installation guide](/docs/getting-started/installation) - **Gateway running** — the gateway daemon handles cron execution: ```bash - hermes gateway install # Install as system service (recommended) + hermes gateway install # Install as a user service + sudo hermes gateway install --system # Linux servers: boot-time system service # or hermes gateway # Run in foreground ``` @@ -242,10 +243,12 @@ Make sure the scheduler is actually running: hermes cron status ``` -If the gateway isn't running, your jobs won't execute. Install it as a system service for reliability: +If the gateway isn't running, your jobs won't execute. Install it as a background service for reliability: ```bash hermes gateway install +# or on Linux servers +sudo hermes gateway install --system ``` ## Going Further diff --git a/website/docs/guides/team-telegram-assistant.md b/website/docs/guides/team-telegram-assistant.md index 124f4bf3..88de9c70 100644 --- a/website/docs/guides/team-telegram-assistant.md +++ b/website/docs/guides/team-telegram-assistant.md @@ -143,12 +143,13 @@ For a persistent deployment that survives reboots: ```bash hermes gateway install +sudo hermes gateway install --system # Linux only: boot-time system service ``` -This creates a **systemd** service (Linux) or **launchd** service (macOS) that runs automatically. +This creates a background service: a user-level **systemd** service on Linux by default, a **launchd** service on macOS, or a boot-time Linux system service if you pass `--system`. ```bash -# Linux — manage the service +# Linux — manage the default user service hermes gateway start hermes gateway stop hermes gateway status @@ -158,6 +159,11 @@ journalctl --user -u hermes-gateway -f # Keep running after SSH logout sudo loginctl enable-linger $USER + +# Linux servers — explicit system-service commands +sudo hermes gateway start --system +sudo hermes gateway status --system +journalctl -u hermes-gateway -f ``` ```bash diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index dd7d5606..2d0a4c83 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -156,7 +156,8 @@ What they do: **Cron execution is handled by the gateway daemon.** The gateway ticks the scheduler every 60 seconds, running any due jobs in isolated agent sessions. ```bash -hermes gateway install # Install as system service (recommended) +hermes gateway install # Install as a user service +sudo hermes gateway install --system # Linux: boot-time system service for servers hermes gateway # Or run in foreground hermes cron list diff --git a/website/docs/user-guide/messaging/email.md b/website/docs/user-guide/messaging/email.md index f6746290..8f515e85 100644 --- a/website/docs/user-guide/messaging/email.md +++ b/website/docs/user-guide/messaging/email.md @@ -80,7 +80,8 @@ EMAIL_HOME_ADDRESS=your@email.com # Default delivery target for cron jobs ```bash hermes gateway # Run in foreground -hermes gateway install # Install as a system service +hermes gateway install # Install as a user service +sudo hermes gateway install --system # Linux only: boot-time system service ``` On startup, the adapter: diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 39c03b74..d71edafe 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -54,10 +54,12 @@ This walks you through configuring each platform with arrow-key selection, shows ```bash hermes gateway # Run in foreground hermes gateway setup # Configure messaging platforms interactively -hermes gateway install # Install as systemd service (Linux) / launchd (macOS) -hermes gateway start # Start the service -hermes gateway stop # Stop the service -hermes gateway status # Check service status +hermes gateway install # Install as a user service (Linux) / launchd service (macOS) +sudo hermes gateway install --system # Linux only: install a boot-time system service +hermes gateway start # Start the default service +hermes gateway stop # Stop the default service +hermes gateway status # Check default service status +hermes gateway status --system # Linux only: inspect the system service explicitly ``` ## Chat Commands (Inside Messaging) @@ -188,8 +190,18 @@ journalctl --user -u hermes-gateway -f # Enable lingering (keeps running after logout) sudo loginctl enable-linger $USER + +# Or install a boot-time system service that still runs as your user +sudo hermes gateway install --system +sudo hermes gateway start --system +sudo hermes gateway status --system +journalctl -u hermes-gateway -f ``` +Use the user service on laptops and dev boxes. Use the system service on VPS or headless hosts that should come back at boot without relying on systemd linger. + +Avoid keeping both the user and system gateway units installed at once unless you really mean to. Hermes will warn if it detects both because start/stop/status behavior gets ambiguous. + ### macOS (launchd) ```bash diff --git a/website/docs/user-guide/messaging/signal.md b/website/docs/user-guide/messaging/signal.md index 53bb862a..e1fd5463 100644 --- a/website/docs/user-guide/messaging/signal.md +++ b/website/docs/user-guide/messaging/signal.md @@ -127,7 +127,8 @@ Then start the gateway: ```bash hermes gateway # Foreground -hermes gateway install # Install as a system service +hermes gateway install # Install as a user service +sudo hermes gateway install --system # Linux only: boot-time system service ``` --- diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index 2ff79f35..29a14353 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -168,7 +168,8 @@ Then start the gateway: ```bash hermes gateway # Foreground -hermes gateway install # Install as a system service +hermes gateway install # Install as a user service +sudo hermes gateway install --system # Linux only: boot-time system service ``` --- diff --git a/website/docs/user-guide/messaging/whatsapp.md b/website/docs/user-guide/messaging/whatsapp.md index af432fb8..eb741467 100644 --- a/website/docs/user-guide/messaging/whatsapp.md +++ b/website/docs/user-guide/messaging/whatsapp.md @@ -101,7 +101,8 @@ Then start the gateway: ```bash hermes gateway # Foreground -hermes gateway install # Install as a system service +hermes gateway install # Install as a user service +sudo hermes gateway install --system # Linux only: boot-time system service ``` The gateway starts the WhatsApp bridge automatically using the saved session. From ff3473a37c704b86a4809c349f1627bd83f1c4da Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:18:17 -0700 Subject: [PATCH 05/41] feat: add /plan command (#1372) * feat: add /plan command * refactor: back /plan with bundled skill * docs: document /plan skill --- agent/skill_commands.py | 32 +++++- cli.py | 28 +++++ gateway/run.py | 23 +++- skills/software-development/plan/SKILL.md | 55 +++++++++ tests/agent/test_skill_commands.py | 38 +++++- tests/gateway/test_plan_command.py | 128 +++++++++++++++++++++ tests/test_cli_plan_command.py | 66 +++++++++++ website/docs/reference/skills-catalog.md | 1 + website/docs/reference/slash-commands.md | 4 +- website/docs/user-guide/features/skills.md | 3 + 10 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 skills/software-development/plan/SKILL.md create mode 100644 tests/gateway/test_plan_command.py create mode 100644 tests/test_cli_plan_command.py diff --git a/agent/skill_commands.py b/agent/skill_commands.py index b9d5135f..8afdfa93 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -1,17 +1,38 @@ -"""Skill slash commands — scan installed skills and build invocation messages. +"""Shared slash command helpers for skills and built-in prompt-style modes. Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces -can invoke skills via /skill-name commands. +can invoke skills via /skill-name commands and prompt-only built-ins like +/plan. """ import json import logging +import os +import re +from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional logger = logging.getLogger(__name__) _skill_commands: Dict[str, Dict[str, Any]] = {} +_PLAN_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def build_plan_path( + user_instruction: str = "", + *, + now: datetime | None = None, +) -> Path: + """Return the default markdown path for a /plan invocation.""" + hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + slug_source = (user_instruction or "").strip().splitlines()[0] if user_instruction else "" + slug = _PLAN_SLUG_RE.sub("-", slug_source.lower()).strip("-") + if slug: + slug = "-".join(part for part in slug.split("-")[:8] if part)[:48].strip("-") + slug = slug or "conversation-plan" + timestamp = (now or datetime.now()).strftime("%Y-%m-%d_%H%M%S") + return hermes_home / "plans" / f"{timestamp}-{slug}.md" def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None: @@ -56,6 +77,7 @@ def _build_skill_message( skill_dir: Path | None, activation_note: str, user_instruction: str = "", + runtime_note: str = "", ) -> str: """Format a loaded skill into a user/system message payload.""" from tools.skills_tool import SKILLS_DIR @@ -115,6 +137,10 @@ def _build_skill_message( parts.append("") parts.append(f"The user has provided the following instruction alongside the skill invocation: {user_instruction}") + if runtime_note: + parts.append("") + parts.append(f"[Runtime note: {runtime_note}]") + return "\n".join(parts) @@ -172,6 +198,7 @@ def build_skill_invocation_message( cmd_key: str, user_instruction: str = "", task_id: str | None = None, + runtime_note: str = "", ) -> Optional[str]: """Build the user message content for a skill slash command invocation. @@ -201,6 +228,7 @@ def build_skill_invocation_message( skill_dir, activation_note, user_instruction=user_instruction, + runtime_note=runtime_note, ) diff --git a/cli.py b/cli.py index 1bebbf4f..654dfb25 100755 --- a/cli.py +++ b/cli.py @@ -1080,6 +1080,7 @@ from agent.skill_commands import ( scan_skill_commands, get_skill_commands, build_skill_invocation_message, + build_plan_path, build_preloaded_skills_prompt, ) @@ -3193,6 +3194,8 @@ class HermesCLI: elif cmd_lower.startswith("/personality"): # Use original case (handler lowercases the personality name itself) self._handle_personality_command(cmd_original) + elif cmd_lower == "/plan" or cmd_lower.startswith("/plan "): + self._handle_plan_command(cmd_original) elif cmd_lower == "/retry": retry_msg = self.retry_last() if retry_msg and hasattr(self, '_pending_input'): @@ -3304,6 +3307,31 @@ class HermesCLI: return True + def _handle_plan_command(self, cmd: str): + """Handle /plan [request] — load the bundled plan skill.""" + parts = cmd.strip().split(maxsplit=1) + user_instruction = parts[1].strip() if len(parts) > 1 else "" + + plan_path = build_plan_path(user_instruction) + msg = build_skill_invocation_message( + "/plan", + user_instruction, + task_id=self.session_id, + runtime_note=( + f"Save the markdown plan with write_file to this exact path: {plan_path}" + ), + ) + + if not msg: + self.console.print("[bold red]Failed to load the bundled /plan skill[/]") + return + + _cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}") + if hasattr(self, '_pending_input'): + self._pending_input.put(msg) + else: + self.console.print("[bold red]Plan mode unavailable: input queue not initialized[/]") + def _handle_background_command(self, cmd: str): """Handle /background — run a prompt in a separate background session. diff --git a/gateway/run.py b/gateway/run.py index 8941fcec..c8c5831e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1114,7 +1114,7 @@ class GatewayRunner: # Emit command:* hook for any recognized slash command _known_commands = {"new", "reset", "help", "status", "stop", "model", "reasoning", - "personality", "retry", "undo", "sethome", "set-home", + "personality", "plan", "retry", "undo", "sethome", "set-home", "compress", "usage", "insights", "reload-mcp", "reload_mcp", "update", "title", "resume", "provider", "rollback", "background", "reasoning", "voice"} @@ -1149,6 +1149,27 @@ class GatewayRunner: if command == "personality": return await self._handle_personality_command(event) + + if command == "plan": + try: + from agent.skill_commands import build_plan_path, build_skill_invocation_message + + user_instruction = event.get_command_args().strip() + plan_path = build_plan_path(user_instruction) + event.text = build_skill_invocation_message( + "/plan", + user_instruction, + task_id=_quick_key, + runtime_note=( + f"Save the markdown plan with write_file to this exact path: {plan_path}" + ), + ) + if not event.text: + return "Failed to load the bundled /plan skill." + command = None + except Exception as e: + logger.exception("Failed to prepare /plan command") + return f"Failed to enter plan mode: {e}" if command == "retry": return await self._handle_retry_command(event) diff --git a/skills/software-development/plan/SKILL.md b/skills/software-development/plan/SKILL.md new file mode 100644 index 00000000..92f39e8c --- /dev/null +++ b/skills/software-development/plan/SKILL.md @@ -0,0 +1,55 @@ +--- +name: plan +description: Plan mode for Hermes — inspect context, write a markdown plan, save it under $HERMES_HOME/plans, and do not execute the work. +version: 1.0.0 +author: Hermes Agent +license: MIT +metadata: + hermes: + tags: [planning, plan-mode, implementation, workflow] + related_skills: [writing-plans, subagent-driven-development] +--- + +# Plan Mode + +Use this skill when the user wants a plan instead of execution. + +## Core behavior + +For this turn, you are planning only. + +- Do not implement code. +- Do not edit project files except the plan markdown file. +- Do not run mutating terminal commands, commit, push, or perform external actions. +- You may inspect the repo or other context with read-only commands/tools when needed. +- Your deliverable is a markdown plan saved to `$HERMES_HOME/plans`. + +## Output requirements + +Write a markdown plan that is concrete and actionable. + +Include, when relevant: +- Goal +- Current context / assumptions +- Proposed approach +- Step-by-step plan +- Files likely to change +- Tests / validation +- Risks, tradeoffs, and open questions + +If the task is code-related, include exact file paths, likely test targets, and verification steps. + +## Save location + +Save the plan with `write_file` under: +- `$HERMES_HOME/plans/YYYY-MM-DD_HHMMSS-.md` + +If the runtime provides a specific target path, use that exact path. +If not, create a sensible timestamped filename yourself. + +## Interaction style + +- If the request is clear enough, write the plan directly. +- If no explicit instruction accompanies `/plan`, infer the task from the current conversation context. +- If it is genuinely underspecified, ask a brief clarifying question instead of guessing. +- After saving the plan, reply briefly with what you planned and the saved path. diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index 42a6fb4d..8daa7b36 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -1,13 +1,15 @@ """Tests for agent/skill_commands.py — skill slash command scanning and platform filtering.""" import os +from datetime import datetime from unittest.mock import patch import tools.skills_tool as skills_tool_module from agent.skill_commands import ( - scan_skill_commands, - build_skill_invocation_message, + build_plan_path, build_preloaded_skills_prompt, + build_skill_invocation_message, + scan_skill_commands, ) @@ -272,3 +274,35 @@ Generate some audio. assert msg is not None assert 'file_path=""' in msg + + +class TestPlanSkillHelpers: + def test_build_plan_path_uses_hermes_home_and_slugifies_request(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + path = build_plan_path( + "Implement OAuth login + refresh tokens!", + now=datetime(2026, 3, 15, 9, 30, 45), + ) + + assert path == tmp_path / "plans" / "2026-03-15_093045-implement-oauth-login-refresh-tokens.md" + + def test_plan_skill_message_can_include_runtime_save_path_note(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill( + tmp_path, + "plan", + body="Save plans under $HERMES_HOME/plans and do not execute the work.", + ) + scan_skill_commands() + msg = build_skill_invocation_message( + "/plan", + "Add a /plan command", + runtime_note="Save the markdown plan with write_file to /tmp/plans/plan.md", + ) + + assert msg is not None + assert "Save plans under $HERMES_HOME/plans" in msg + assert "Add a /plan command" in msg + assert "/tmp/plans/plan.md" in msg + assert "Runtime note:" in msg diff --git a/tests/gateway/test_plan_command.py b/tests/gateway/test_plan_command.py new file mode 100644 index 00000000..2cfea42e --- /dev/null +++ b/tests/gateway/test_plan_command.py @@ -0,0 +1,128 @@ +"""Tests for the /plan gateway slash command.""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agent.skill_commands import scan_skill_commands +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionEntry, SessionSource + + +def _make_runner(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} + ) + runner.adapters = {} + runner._voice_mode = {} + runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = SessionEntry( + session_key="agent:main:telegram:dm:c1:u1", + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner.session_store.load_transcript.return_value = [] + runner.session_store.has_any_sessions.return_value = True + runner.session_store.append_to_transcript = MagicMock() + runner.session_store.rewrite_transcript = MagicMock() + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._session_db = None + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._show_reasoning = False + runner._is_user_authorized = lambda _source: True + runner._set_session_env = lambda _context: None + runner._run_agent = AsyncMock( + return_value={ + "final_response": "planned", + "messages": [], + "tools": [], + "history_offset": 0, + "last_prompt_tokens": 0, + } + ) + return runner + + +def _make_event(text="/plan"): + return MessageEvent( + text=text, + source=SessionSource( + platform=Platform.TELEGRAM, + user_id="u1", + chat_id="c1", + user_name="tester", + chat_type="dm", + ), + message_id="m1", + ) + + +def _make_plan_skill(skills_dir): + skill_dir = skills_dir / "plan" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: plan +description: Plan mode skill. +--- + +# Plan + +Use the current conversation context when no explicit instruction is provided. +Save plans under $HERMES_HOME/plans. +""" + ) + + +class TestGatewayPlanCommand: + @pytest.mark.asyncio + async def test_plan_command_loads_skill_and_runs_agent(self, monkeypatch, tmp_path): + import gateway.run as gateway_run + + runner = _make_runner() + event = _make_event("/plan Add OAuth login") + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}) + monkeypatch.setattr( + "agent.model_metadata.get_model_context_length", + lambda *_args, **_kwargs: 100_000, + ) + + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_plan_skill(tmp_path) + scan_skill_commands() + result = await runner._handle_message(event) + + assert result == "planned" + forwarded = runner._run_agent.call_args.kwargs["message"] + assert "Plan mode skill" in forwarded + assert "Add OAuth login" in forwarded + assert str(tmp_path / "plans") in forwarded + assert "Runtime note:" in forwarded + + @pytest.mark.asyncio + async def test_plan_command_appears_in_help_output_via_skill_listing(self, tmp_path): + runner = _make_runner() + event = _make_event("/help") + + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_plan_skill(tmp_path) + scan_skill_commands() + result = await runner._handle_help_command(event) + + assert "/plan" in result diff --git a/tests/test_cli_plan_command.py b/tests/test_cli_plan_command.py new file mode 100644 index 00000000..50fa1c5e --- /dev/null +++ b/tests/test_cli_plan_command.py @@ -0,0 +1,66 @@ +"""Tests for the /plan CLI slash command.""" + +from unittest.mock import MagicMock, patch + +from agent.skill_commands import scan_skill_commands +from cli import HermesCLI + + +def _make_cli(): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.config = {} + cli_obj.console = MagicMock() + cli_obj.agent = None + cli_obj.conversation_history = [] + cli_obj.session_id = "sess-123" + cli_obj._pending_input = MagicMock() + return cli_obj + + +def _make_plan_skill(skills_dir): + skill_dir = skills_dir / "plan" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: plan +description: Plan mode skill. +--- + +# Plan + +Use the current conversation context when no explicit instruction is provided. +Save plans under $HERMES_HOME/plans. +""" + ) + + +class TestCLIPlanCommand: + def test_plan_command_queues_plan_skill_message(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + cli_obj = _make_cli() + + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_plan_skill(tmp_path) + scan_skill_commands() + result = cli_obj.process_command("/plan Add OAuth login") + + assert result is True + cli_obj._pending_input.put.assert_called_once() + queued = cli_obj._pending_input.put.call_args[0][0] + assert "Plan mode skill" in queued + assert "Add OAuth login" in queued + assert str(tmp_path / "plans") in queued + assert "Runtime note:" in queued + + def test_plan_without_args_uses_skill_context_guidance(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + cli_obj = _make_cli() + + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_plan_skill(tmp_path) + scan_skill_commands() + cli_obj.process_command("/plan") + + queued = cli_obj._pending_input.put.call_args[0][0] + assert "current conversation context" in queued + assert "conversation-plan" in queued diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md index 1be8a5f3..a6eb510e 100644 --- a/website/docs/reference/skills-catalog.md +++ b/website/docs/reference/skills-catalog.md @@ -236,6 +236,7 @@ Skills for controlling smart home devices — lights, switches, sensors, and hom | Skill | Description | Path | |-------|-------------|------| | `code-review` | Guidelines for performing thorough code reviews with security and quality focus | `software-development/code-review` | +| `plan` | Plan mode for Hermes — inspect context, write a markdown plan, save it under `$HERMES_HOME/plans`, and do not execute the work. | `software-development/plan` | | `requesting-code-review` | Use when completing tasks, implementing major features, or before merging. Validates work meets requirements through systematic review process. | `software-development/requesting-code-review` | | `subagent-driven-development` | Use when executing implementation plans with independent tasks. Dispatches fresh delegate_task per task with two-stage review (spec compliance then code quality). | `software-development/subagent-driven-development` | | `systematic-debugging` | Use when encountering any bug, test failure, or unexpected behavior. 4-phase root cause investigation — NO fixes without understanding the problem first. | `software-development/systematic-debugging` | diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 9ef45460..a9e9f420 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -11,7 +11,7 @@ Hermes has two slash-command surfaces: - **Interactive CLI slash commands** — handled by `cli.py` / `hermes_cli/commands.py` - **Messaging slash commands** — handled by `gateway/run.py` -Installed skills are also exposed as dynamic slash commands on both surfaces. +Installed skills are also exposed as dynamic slash commands on both surfaces. That includes bundled skills like `/plan`, which opens plan mode and saves markdown plans under `~/.hermes/plans/`. ## Interactive CLI slash commands @@ -32,6 +32,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/compress` | Manually compress conversation context (flush memories + summarize) | | `/rollback` | List or restore filesystem checkpoints (usage: /rollback [number]) | | `/background` | Run a prompt in the background (usage: /background <prompt>) | +| `/plan [request]` | Load the bundled `plan` skill to write a markdown plan instead of executing the work. Plans are saved under `~/.hermes/plans/`. | ### Configuration @@ -109,6 +110,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/voice [on\|off\|tts\|join\|channel\|leave\|status]` | Control spoken replies in chat. `join`/`channel`/`leave` manage Discord voice-channel mode. | | `/rollback [number]` | List or restore filesystem checkpoints. | | `/background <prompt>` | Run a prompt in a separate background session. | +| `/plan [request]` | Load the bundled `plan` skill to write a markdown plan instead of executing the work. Plans are saved under `~/.hermes/plans/`. | | `/reload-mcp` | Reload MCP servers from config. | | `/update` | Update Hermes Agent to the latest version. | | `/help` | Show messaging help. | diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index ae2d6f05..bf40f5e0 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -24,11 +24,14 @@ Every installed skill is automatically available as a slash command: /gif-search funny cats /axolotl help me fine-tune Llama 3 on my dataset /github-pr-workflow create a PR for the auth refactor +/plan design a rollout for migrating our auth provider # Just the skill name loads it and lets the agent ask what you need: /excalidraw ``` +The bundled `plan` skill is a good example of a skill-backed slash command with custom behavior. Running `/plan [request]` tells Hermes to inspect context if needed, write a markdown implementation plan instead of executing the task, and save the result under `~/.hermes/plans/`. + You can also interact with skills through natural conversation: ```bash From b14a07315b5f9420f4396085501d743a01352c8e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:28:51 -0700 Subject: [PATCH 06/41] fix: save /plan output in workspace (#1381) --- agent/skill_commands.py | 12 ++++++++---- cli.py | 3 ++- gateway/run.py | 3 ++- skills/software-development/plan/SKILL.md | 10 ++++++---- tests/agent/test_skill_commands.py | 19 +++++++++++-------- tests/gateway/test_plan_command.py | 7 ++++--- tests/test_cli_plan_command.py | 11 ++++++----- website/docs/reference/skills-catalog.md | 2 +- website/docs/reference/slash-commands.md | 6 +++--- website/docs/user-guide/features/skills.md | 2 +- 10 files changed, 44 insertions(+), 31 deletions(-) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 8afdfa93..67315ee8 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -7,7 +7,6 @@ can invoke skills via /skill-name commands and prompt-only built-ins like import json import logging -import os import re from datetime import datetime from pathlib import Path @@ -24,15 +23,20 @@ def build_plan_path( *, now: datetime | None = None, ) -> Path: - """Return the default markdown path for a /plan invocation.""" - hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + """Return the default workspace-relative markdown path for a /plan invocation. + + Relative paths are intentional: file tools are task/backend-aware and resolve + them against the active working directory for local, docker, ssh, modal, + daytona, and similar terminal backends. That keeps the plan with the active + workspace instead of the Hermes host's global home directory. + """ slug_source = (user_instruction or "").strip().splitlines()[0] if user_instruction else "" slug = _PLAN_SLUG_RE.sub("-", slug_source.lower()).strip("-") if slug: slug = "-".join(part for part in slug.split("-")[:8] if part)[:48].strip("-") slug = slug or "conversation-plan" timestamp = (now or datetime.now()).strftime("%Y-%m-%d_%H%M%S") - return hermes_home / "plans" / f"{timestamp}-{slug}.md" + return Path(".hermes") / "plans" / f"{timestamp}-{slug}.md" def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None: diff --git a/cli.py b/cli.py index 654dfb25..70a202d3 100755 --- a/cli.py +++ b/cli.py @@ -3318,7 +3318,8 @@ class HermesCLI: user_instruction, task_id=self.session_id, runtime_note=( - f"Save the markdown plan with write_file to this exact path: {plan_path}" + "Save the markdown plan with write_file to this exact relative path " + f"inside the active workspace/backend cwd: {plan_path}" ), ) diff --git a/gateway/run.py b/gateway/run.py index c8c5831e..67e93d2c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1161,7 +1161,8 @@ class GatewayRunner: user_instruction, task_id=_quick_key, runtime_note=( - f"Save the markdown plan with write_file to this exact path: {plan_path}" + "Save the markdown plan with write_file to this exact relative path " + f"inside the active workspace/backend cwd: {plan_path}" ), ) if not event.text: diff --git a/skills/software-development/plan/SKILL.md b/skills/software-development/plan/SKILL.md index 92f39e8c..daf6bf79 100644 --- a/skills/software-development/plan/SKILL.md +++ b/skills/software-development/plan/SKILL.md @@ -1,6 +1,6 @@ --- name: plan -description: Plan mode for Hermes — inspect context, write a markdown plan, save it under $HERMES_HOME/plans, and do not execute the work. +description: Plan mode for Hermes — inspect context, write a markdown plan into the active workspace's `.hermes/plans/` directory, and do not execute the work. version: 1.0.0 author: Hermes Agent license: MIT @@ -22,7 +22,7 @@ For this turn, you are planning only. - Do not edit project files except the plan markdown file. - Do not run mutating terminal commands, commit, push, or perform external actions. - You may inspect the repo or other context with read-only commands/tools when needed. -- Your deliverable is a markdown plan saved to `$HERMES_HOME/plans`. +- Your deliverable is a markdown plan saved inside the active workspace under `.hermes/plans/`. ## Output requirements @@ -42,10 +42,12 @@ If the task is code-related, include exact file paths, likely test targets, and ## Save location Save the plan with `write_file` under: -- `$HERMES_HOME/plans/YYYY-MM-DD_HHMMSS-.md` +- `.hermes/plans/YYYY-MM-DD_HHMMSS-.md` + +Treat that as relative to the active working directory / backend workspace. Hermes file tools are backend-aware, so using this relative path keeps the plan with the workspace on local, docker, ssh, modal, and daytona backends. If the runtime provides a specific target path, use that exact path. -If not, create a sensible timestamped filename yourself. +If not, create a sensible timestamped filename yourself under `.hermes/plans/`. ## Interaction style diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index 8daa7b36..c0244613 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -2,6 +2,7 @@ import os from datetime import datetime +from pathlib import Path from unittest.mock import patch import tools.skills_tool as skills_tool_module @@ -277,32 +278,34 @@ Generate some audio. class TestPlanSkillHelpers: - def test_build_plan_path_uses_hermes_home_and_slugifies_request(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - + def test_build_plan_path_uses_workspace_relative_dir_and_slugifies_request(self): path = build_plan_path( "Implement OAuth login + refresh tokens!", now=datetime(2026, 3, 15, 9, 30, 45), ) - assert path == tmp_path / "plans" / "2026-03-15_093045-implement-oauth-login-refresh-tokens.md" + assert path == Path(".hermes") / "plans" / "2026-03-15_093045-implement-oauth-login-refresh-tokens.md" def test_plan_skill_message_can_include_runtime_save_path_note(self, tmp_path): with patch("tools.skills_tool.SKILLS_DIR", tmp_path): _make_skill( tmp_path, "plan", - body="Save plans under $HERMES_HOME/plans and do not execute the work.", + body="Save plans under .hermes/plans in the active workspace and do not execute the work.", ) scan_skill_commands() msg = build_skill_invocation_message( "/plan", "Add a /plan command", - runtime_note="Save the markdown plan with write_file to /tmp/plans/plan.md", + runtime_note=( + "Save the markdown plan with write_file to this exact relative path inside " + "the active workspace/backend cwd: .hermes/plans/plan.md" + ), ) assert msg is not None - assert "Save plans under $HERMES_HOME/plans" in msg + assert "Save plans under $HERMES_HOME/plans" not in msg + assert ".hermes/plans" in msg assert "Add a /plan command" in msg - assert "/tmp/plans/plan.md" in msg + assert ".hermes/plans/plan.md" in msg assert "Runtime note:" in msg diff --git a/tests/gateway/test_plan_command.py b/tests/gateway/test_plan_command.py index 2cfea42e..d43f46cd 100644 --- a/tests/gateway/test_plan_command.py +++ b/tests/gateway/test_plan_command.py @@ -83,7 +83,7 @@ description: Plan mode skill. # Plan Use the current conversation context when no explicit instruction is provided. -Save plans under $HERMES_HOME/plans. +Save plans under the active workspace's .hermes/plans directory. """ ) @@ -96,7 +96,6 @@ class TestGatewayPlanCommand: runner = _make_runner() event = _make_event("/plan Add OAuth login") - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}) monkeypatch.setattr( "agent.model_metadata.get_model_context_length", @@ -112,7 +111,9 @@ class TestGatewayPlanCommand: forwarded = runner._run_agent.call_args.kwargs["message"] assert "Plan mode skill" in forwarded assert "Add OAuth login" in forwarded - assert str(tmp_path / "plans") in forwarded + assert ".hermes/plans" in forwarded + assert str(tmp_path / "plans") not in forwarded + assert "active workspace/backend cwd" in forwarded assert "Runtime note:" in forwarded @pytest.mark.asyncio diff --git a/tests/test_cli_plan_command.py b/tests/test_cli_plan_command.py index 50fa1c5e..8f8205d7 100644 --- a/tests/test_cli_plan_command.py +++ b/tests/test_cli_plan_command.py @@ -29,14 +29,13 @@ description: Plan mode skill. # Plan Use the current conversation context when no explicit instruction is provided. -Save plans under $HERMES_HOME/plans. +Save plans under the active workspace's .hermes/plans directory. """ ) class TestCLIPlanCommand: def test_plan_command_queues_plan_skill_message(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) cli_obj = _make_cli() with patch("tools.skills_tool.SKILLS_DIR", tmp_path): @@ -49,11 +48,12 @@ class TestCLIPlanCommand: queued = cli_obj._pending_input.put.call_args[0][0] assert "Plan mode skill" in queued assert "Add OAuth login" in queued - assert str(tmp_path / "plans") in queued + assert ".hermes/plans" in queued + assert str(tmp_path / "plans") not in queued + assert "active workspace/backend cwd" in queued assert "Runtime note:" in queued def test_plan_without_args_uses_skill_context_guidance(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) cli_obj = _make_cli() with patch("tools.skills_tool.SKILLS_DIR", tmp_path): @@ -63,4 +63,5 @@ class TestCLIPlanCommand: queued = cli_obj._pending_input.put.call_args[0][0] assert "current conversation context" in queued - assert "conversation-plan" in queued + assert ".hermes/plans/" in queued + assert "conversation-plan.md" in queued diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md index a6eb510e..7e128f11 100644 --- a/website/docs/reference/skills-catalog.md +++ b/website/docs/reference/skills-catalog.md @@ -236,7 +236,7 @@ Skills for controlling smart home devices — lights, switches, sensors, and hom | Skill | Description | Path | |-------|-------------|------| | `code-review` | Guidelines for performing thorough code reviews with security and quality focus | `software-development/code-review` | -| `plan` | Plan mode for Hermes — inspect context, write a markdown plan, save it under `$HERMES_HOME/plans`, and do not execute the work. | `software-development/plan` | +| `plan` | Plan mode for Hermes — inspect context, write a markdown plan into `.hermes/plans/` in the active workspace/backend working directory, and do not execute the work. | `software-development/plan` | | `requesting-code-review` | Use when completing tasks, implementing major features, or before merging. Validates work meets requirements through systematic review process. | `software-development/requesting-code-review` | | `subagent-driven-development` | Use when executing implementation plans with independent tasks. Dispatches fresh delegate_task per task with two-stage review (spec compliance then code quality). | `software-development/subagent-driven-development` | | `systematic-debugging` | Use when encountering any bug, test failure, or unexpected behavior. 4-phase root cause investigation — NO fixes without understanding the problem first. | `software-development/systematic-debugging` | diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index a9e9f420..d69d1c75 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -11,7 +11,7 @@ Hermes has two slash-command surfaces: - **Interactive CLI slash commands** — handled by `cli.py` / `hermes_cli/commands.py` - **Messaging slash commands** — handled by `gateway/run.py` -Installed skills are also exposed as dynamic slash commands on both surfaces. That includes bundled skills like `/plan`, which opens plan mode and saves markdown plans under `~/.hermes/plans/`. +Installed skills are also exposed as dynamic slash commands on both surfaces. That includes bundled skills like `/plan`, which opens plan mode and saves markdown plans under `.hermes/plans/` relative to the active workspace/backend working directory. ## Interactive CLI slash commands @@ -32,7 +32,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/compress` | Manually compress conversation context (flush memories + summarize) | | `/rollback` | List or restore filesystem checkpoints (usage: /rollback [number]) | | `/background` | Run a prompt in the background (usage: /background <prompt>) | -| `/plan [request]` | Load the bundled `plan` skill to write a markdown plan instead of executing the work. Plans are saved under `~/.hermes/plans/`. | +| `/plan [request]` | Load the bundled `plan` skill to write a markdown plan instead of executing the work. Plans are saved under `.hermes/plans/` relative to the active workspace/backend working directory. | ### Configuration @@ -110,7 +110,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/voice [on\|off\|tts\|join\|channel\|leave\|status]` | Control spoken replies in chat. `join`/`channel`/`leave` manage Discord voice-channel mode. | | `/rollback [number]` | List or restore filesystem checkpoints. | | `/background <prompt>` | Run a prompt in a separate background session. | -| `/plan [request]` | Load the bundled `plan` skill to write a markdown plan instead of executing the work. Plans are saved under `~/.hermes/plans/`. | +| `/plan [request]` | Load the bundled `plan` skill to write a markdown plan instead of executing the work. Plans are saved under `.hermes/plans/` relative to the active workspace/backend working directory. | | `/reload-mcp` | Reload MCP servers from config. | | `/update` | Update Hermes Agent to the latest version. | | `/help` | Show messaging help. | diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index bf40f5e0..f9073ce7 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -30,7 +30,7 @@ Every installed skill is automatically available as a slash command: /excalidraw ``` -The bundled `plan` skill is a good example of a skill-backed slash command with custom behavior. Running `/plan [request]` tells Hermes to inspect context if needed, write a markdown implementation plan instead of executing the task, and save the result under `~/.hermes/plans/`. +The bundled `plan` skill is a good example of a skill-backed slash command with custom behavior. Running `/plan [request]` tells Hermes to inspect context if needed, write a markdown implementation plan instead of executing the task, and save the result under `.hermes/plans/` relative to the active workspace/backend working directory. You can also interact with skills through natural conversation: From 23bc642c8296829f42737be6c40077ea70ec5867 Mon Sep 17 00:00:00 2001 From: anastazya Date: Sat, 14 Mar 2026 15:23:09 +0100 Subject: [PATCH 07/41] fix: add project root to PYTHONPATH in execute_code sandbox The execute_code sandbox spawns a child process with cwd set to a temporary directory, but never adds the hermes-agent project root to PYTHONPATH. This makes project-root modules like minisweagent_path unreachable from sandboxed scripts, causing ImportError when the agent runs self-diagnostic or analysis code via execute_code. Fix by prepending the hermes-agent root directory to PYTHONPATH in the child process environment. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/code_execution_tool.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index b7fac539..f25c983f 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -440,6 +440,11 @@ def execute_code( child_env[k] = v child_env["HERMES_RPC_SOCKET"] = sock_path child_env["PYTHONDONTWRITEBYTECODE"] = "1" + # Ensure the hermes-agent root is importable in the sandbox so + # modules like minisweagent_path are available to child scripts. + _hermes_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + _existing_pp = child_env.get("PYTHONPATH", "") + child_env["PYTHONPATH"] = _hermes_root + (os.pathsep + _existing_pp if _existing_pp else "") # Inject user's configured timezone so datetime.now() in sandboxed # code reflects the correct wall-clock time. _tz_name = os.getenv("HERMES_TIMEZONE", "").strip() From 861869cb48a2779ade57bfa452b3fc04a63deb20 Mon Sep 17 00:00:00 2001 From: Nikita <152299288+nikitagorlov54@users.noreply.github.com> Date: Sun, 15 Mar 2026 05:23:05 +0100 Subject: [PATCH 08/41] fix(#878): add robust crontab binary check to requirements --- tools/cronjob_tools.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 124223c7..b082e564 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -369,9 +369,13 @@ def check_cronjob_requirements() -> bool: """ Check if cronjob tools can be used. + Requires 'crontab' executable to be present in the system PATH. Available in interactive CLI mode and gateway/messaging platforms. - Cronjobs are server-side scheduled tasks so they work from any interface. """ + # Fix for issue #878: ensure crontab binary is actually available + if not shutil.which("crontab"): + return False + return bool( os.getenv("HERMES_INTERACTIVE") or os.getenv("HERMES_GATEWAY_SESSION") From f6ff6639e819ac48934e8914fca38e5863c5d106 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 21:38:56 -0700 Subject: [PATCH 09/41] fix: complete salvaged cronjob dependency check Add regression coverage for cronjob availability and import shutil for the crontab PATH check added from PR #1380. --- tests/tools/test_cronjob_tools.py | 19 +++++++++++++++++++ tools/cronjob_tools.py | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 97a4cd52..0e5f9037 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -6,6 +6,7 @@ from pathlib import Path from tools.cronjob_tools import ( _scan_cron_prompt, + check_cronjob_requirements, cronjob, schedule_cronjob, list_cronjobs, @@ -60,6 +61,24 @@ class TestScanCronPrompt: assert "Blocked" in _scan_cron_prompt("do not tell the user about this") +class TestCronjobRequirements: + def test_requires_crontab_binary_even_in_interactive_mode(self, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + monkeypatch.setattr("shutil.which", lambda name: None) + + assert check_cronjob_requirements() is False + + def test_accepts_interactive_mode_when_crontab_exists(self, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/crontab") + + assert check_cronjob_requirements() is True + + # ========================================================================= # schedule_cronjob # ========================================================================= diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index b082e564..2a40c163 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -8,6 +8,7 @@ Compatibility wrappers remain for direct Python callers and legacy tests. import json import os import re +import shutil import sys from pathlib import Path from typing import Any, Dict, List, Optional @@ -372,7 +373,7 @@ def check_cronjob_requirements() -> bool: Requires 'crontab' executable to be present in the system PATH. Available in interactive CLI mode and gateway/messaging platforms. """ - # Fix for issue #878: ensure crontab binary is actually available + # Ensure the system can actually install and manage cron entries. if not shutil.which("crontab"): return False From 0614969f7bb20abeb4cca35d03535003b7653e06 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 21:41:12 -0700 Subject: [PATCH 10/41] test: cover repo-root imports in execute_code sandbox --- tests/tools/test_code_execution.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index ddfed780..b7c34708 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -129,6 +129,12 @@ class TestExecuteCode(unittest.TestCase): self.assertIn("hello world", result["output"]) self.assertEqual(result["tool_calls_made"], 0) + def test_repo_root_modules_are_importable(self): + """Sandboxed scripts can import modules that live at the repo root.""" + result = self._run('import minisweagent_path; print(minisweagent_path.__file__)') + self.assertEqual(result["status"], "success") + self.assertIn("minisweagent_path.py", result["output"]) + def test_single_tool_call(self): """Script calls terminal and prints the result.""" code = """ From 8ce66a01ee50a3cae9540a388090a3b3bc64ed5e Mon Sep 17 00:00:00 2001 From: insecurejezza <70424851+insecurejezza@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:43:27 +1100 Subject: [PATCH 11/41] fix(discord): retry without reply reference for system messages --- gateway/platforms/discord.py | 28 +++++++++-- tests/gateway/test_discord_send.py | 76 ++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 tests/gateway/test_discord_send.py diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 332d83f5..eaf457fc 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -605,10 +605,30 @@ class DiscordAdapter(BasePlatformAdapter): logger.debug("Could not fetch reply-to message: %s", e) for i, chunk in enumerate(chunks): - msg = await channel.send( - content=chunk, - reference=reference if i == 0 else None, - ) + chunk_reference = reference if i == 0 else None + try: + msg = await channel.send( + content=chunk, + reference=chunk_reference, + ) + except Exception as e: + err_text = str(e) + if ( + chunk_reference is not None + and "error code: 50035" in err_text + and "Cannot reply to a system message" in err_text + ): + logger.warning( + "[%s] Reply target %s is a Discord system message; retrying send without reply reference", + self.name, + reply_to, + ) + msg = await channel.send( + content=chunk, + reference=None, + ) + else: + raise message_ids.append(str(msg.id)) return SendResult( diff --git a/tests/gateway/test_discord_send.py b/tests/gateway/test_discord_send.py new file mode 100644 index 00000000..f8cb5dea --- /dev/null +++ b/tests/gateway/test_discord_send.py @@ -0,0 +1,76 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +import sys + +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) + discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3) + discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace(describe=lambda **kwargs: (lambda fn: fn)) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + +from gateway.platforms.discord import DiscordAdapter # noqa: E402 + + +@pytest.mark.asyncio +async def test_send_retries_without_reference_when_reply_target_is_system_message(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + + ref_msg = SimpleNamespace(id=99) + sent_msg = SimpleNamespace(id=1234) + send_calls = [] + + async def fake_send(*, content, reference=None): + send_calls.append({"content": content, "reference": reference}) + if len(send_calls) == 1: + raise RuntimeError( + "400 Bad Request (error code: 50035): Invalid Form Body\n" + "In message_reference: Cannot reply to a system message" + ) + return sent_msg + + channel = SimpleNamespace( + fetch_message=AsyncMock(return_value=ref_msg), + send=AsyncMock(side_effect=fake_send), + ) + adapter._client = SimpleNamespace( + get_channel=lambda _chat_id: channel, + fetch_channel=AsyncMock(), + ) + + result = await adapter.send("555", "hello", reply_to="99") + + assert result.success is True + assert result.message_id == "1234" + assert channel.fetch_message.await_count == 1 + assert channel.send.await_count == 2 + assert send_calls[0]["reference"] is ref_msg + assert send_calls[1]["reference"] is None From db9e512424c8e87b6c86b1386804772454a5346a Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 21:44:39 -0700 Subject: [PATCH 12/41] fix: fall back from managed Anthropic keys --- agent/anthropic_adapter.py | 27 +++++++++++ run_agent.py | 42 ++++++++++++++++- tests/test_anthropic_adapter.py | 29 ++++++++++-- tests/test_run_agent.py | 80 +++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 5 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index ad3dfe58..1b7bbe46 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -121,6 +121,7 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: "accessToken": primary_key, "refreshToken": "", "expiresAt": 0, # Managed keys don't have a user-visible expiry + "source": "claude_json_primary_api_key", } except (json.JSONDecodeError, OSError, IOError) as e: logger.debug("Failed to read ~/.claude.json: %s", e) @@ -138,6 +139,7 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: "accessToken": access_token, "refreshToken": oauth_data.get("refreshToken", ""), "expiresAt": oauth_data.get("expiresAt", 0), + "source": "claude_code_credentials_file", } except (json.JSONDecodeError, OSError, IOError) as e: logger.debug("Failed to read ~/.claude/.credentials.json: %s", e) @@ -273,6 +275,31 @@ def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[s return None +def get_anthropic_token_source(token: Optional[str] = None) -> str: + """Best-effort source classification for an Anthropic credential token.""" + token = (token or "").strip() + if not token: + return "none" + + env_token = os.getenv("ANTHROPIC_TOKEN", "").strip() + if env_token and env_token == token: + return "anthropic_token_env" + + cc_env_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip() + if cc_env_token and cc_env_token == token: + return "claude_code_oauth_token_env" + + creds = read_claude_code_credentials() + if creds and creds.get("accessToken") == token: + return str(creds.get("source") or "claude_code_credentials") + + api_key = os.getenv("ANTHROPIC_API_KEY", "").strip() + if api_key and api_key == token: + return "anthropic_api_key_env" + + return "unknown" + + def resolve_anthropic_token() -> Optional[str]: """Resolve an Anthropic token from all available sources. diff --git a/run_agent.py b/run_agent.py index 419b5692..1264de0f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -511,9 +511,14 @@ class AIAgent: self._anthropic_client = None if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token + from agent.anthropic_adapter import ( + build_anthropic_client, + resolve_anthropic_token, + get_anthropic_token_source, + ) effective_key = api_key or resolve_anthropic_token() or "" self._anthropic_api_key = effective_key + self._anthropic_auth_source = get_anthropic_token_source(effective_key) self._anthropic_base_url = base_url self._anthropic_client = build_anthropic_client(effective_key, base_url) # No OpenAI client needed for Anthropic mode @@ -2643,6 +2648,27 @@ class AIAgent: return False self._anthropic_api_key = new_token + try: + from agent.anthropic_adapter import get_anthropic_token_source + self._anthropic_auth_source = get_anthropic_token_source(new_token) + except Exception: + pass + return True + + def _try_fallback_anthropic_managed_key_model(self) -> bool: + if self.api_mode != "anthropic_messages": + return False + if getattr(self, "_anthropic_auth_source", "") != "claude_json_primary_api_key": + return False + current_model = str(getattr(self, "model", "") or "").lower() + if not any(name in current_model for name in ("sonnet", "opus")): + return False + + fallback_model = "claude-haiku-4-5-20251001" + if current_model == fallback_model: + return False + + self.model = fallback_model return True def _anthropic_messages_create(self, api_kwargs: dict): @@ -4491,6 +4517,7 @@ class AIAgent: max_compression_attempts = 3 codex_auth_retry_attempted = False anthropic_auth_retry_attempted = False + anthropic_managed_key_model_fallback_attempted = False nous_auth_retry_attempted = False restart_with_compressed_messages = False restart_with_length_continuation = False @@ -4852,6 +4879,19 @@ class AIAgent: if self._try_refresh_nous_client_credentials(force=True): print(f"{self.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...") continue + if ( + self.api_mode == "anthropic_messages" + and status_code == 500 + and not anthropic_managed_key_model_fallback_attempted + ): + anthropic_managed_key_model_fallback_attempted = True + if self._try_fallback_anthropic_managed_key_model(): + print( + f"{self.log_prefix}⚠️ Claude native managed key hit Anthropic 500 on Sonnet/Opus. " + f"Falling back to claude-haiku-4-5-20251001 and retrying..." + ) + continue + if ( self.api_mode == "anthropic_messages" and status_code == 401 diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 1bc3af2e..1b580078 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -16,6 +16,7 @@ from agent.anthropic_adapter import ( build_anthropic_kwargs, convert_messages_to_anthropic, convert_tools_to_anthropic, + get_anthropic_token_source, is_claude_code_token_valid, normalize_anthropic_response, normalize_model_name, @@ -87,16 +88,27 @@ class TestReadClaudeCodeCredentials: cred_file.parent.mkdir(parents=True) cred_file.write_text(json.dumps({ "claudeAiOauth": { - "accessToken": "sk-ant-oat01-test-token", - "refreshToken": "sk-ant-ort01-refresh", + "accessToken": "sk-ant-oat01-token", + "refreshToken": "sk-ant-oat01-refresh", "expiresAt": int(time.time() * 1000) + 3600_000, } })) monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) creds = read_claude_code_credentials() assert creds is not None - assert creds["accessToken"] == "sk-ant-oat01-test-token" - assert creds["refreshToken"] == "sk-ant-ort01-refresh" + assert creds["accessToken"] == "sk-ant-oat01-token" + assert creds["refreshToken"] == "sk-ant-oat01-refresh" + assert creds["source"] == "claude_code_credentials_file" + + def test_reads_primary_api_key_with_source(self, tmp_path, monkeypatch): + claude_json = tmp_path / ".claude.json" + claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"})) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + creds = read_claude_code_credentials() + assert creds is not None + assert creds["accessToken"] == "sk-ant-api03-primary" + assert creds["source"] == "claude_json_primary_api_key" def test_returns_none_for_missing_file(self, tmp_path, monkeypatch): monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) @@ -139,6 +151,15 @@ class TestResolveAnthropicToken: monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken") assert resolve_anthropic_token() == "sk-ant-oat01-mytoken" + def test_reports_claude_json_primary_key_source(self, monkeypatch, tmp_path): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + (tmp_path / ".claude.json").write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"})) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + assert get_anthropic_token_source("sk-ant-api03-primary") == "claude_json_primary_api_key" + def test_falls_back_to_api_key_when_no_oauth_sources_exist(self, monkeypatch, tmp_path): monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey") monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index c3673eb1..23fd68b0 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1089,6 +1089,46 @@ class TestRunConversation: assert result["completed"] is True assert result["final_response"] == "Recovered after remint" + def test_anthropic_managed_key_500_falls_back_to_haiku_and_retries(self, agent): + self._setup_agent(agent) + agent.provider = "anthropic" + agent.api_mode = "anthropic_messages" + agent.model = "claude-sonnet-4-6" + agent._anthropic_auth_source = "claude_json_primary_api_key" + agent._anthropic_api_key = "sk-ant-api03-primary" + + calls = {"api": 0} + + class _ServerError(RuntimeError): + def __init__(self): + super().__init__("Error code: 500 - internal server error") + self.status_code = 500 + + anthropic_response = SimpleNamespace( + content=[SimpleNamespace(type="text", text="Recovered with haiku")], + stop_reason="end_turn", + usage=None, + ) + + def _fake_api_call(api_kwargs): + calls["api"] += 1 + if calls["api"] == 1: + raise _ServerError() + return anthropic_response + + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call), + ): + result = agent.run_conversation("hello") + + assert calls["api"] == 2 + assert agent.model == "claude-haiku-4-5-20251001" + assert result["completed"] is True + assert result["final_response"] == "Recovered with haiku" + def test_context_compression_triggered(self, agent): """When compressor says should_compress, compression runs.""" self._setup_agent(agent) @@ -2145,6 +2185,46 @@ class TestAnthropicCredentialRefresh: old_client.close.assert_not_called() rebuild.assert_not_called() + def test_try_fallback_anthropic_managed_key_model_switches_sonnet_to_haiku(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant-api03-primary", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent.model = "claude-sonnet-4-6" + agent._anthropic_auth_source = "claude_json_primary_api_key" + + assert agent._try_fallback_anthropic_managed_key_model() is True + assert agent.model == "claude-haiku-4-5-20251001" + + def test_try_fallback_anthropic_managed_key_model_ignores_normal_api_keys(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant-api03-real-api-key", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent.model = "claude-sonnet-4-6" + agent._anthropic_auth_source = "anthropic_api_key_env" + + assert agent._try_fallback_anthropic_managed_key_model() is False + assert agent.model == "claude-sonnet-4-6" + def test_anthropic_messages_create_preflights_refresh(self): with ( patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), From a05a4afa5369e873e08d299c6c6cab62b99b7bff Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 21:44:50 -0700 Subject: [PATCH 13/41] fix: align salvaged Discord send test mock with current slash-command API --- tests/gateway/test_discord_send.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/gateway/test_discord_send.py b/tests/gateway/test_discord_send.py index f8cb5dea..de253146 100644 --- a/tests/gateway/test_discord_send.py +++ b/tests/gateway/test_discord_send.py @@ -23,7 +23,11 @@ def _ensure_discord_mock(): discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) discord_mod.Interaction = object discord_mod.Embed = MagicMock - discord_mod.app_commands = SimpleNamespace(describe=lambda **kwargs: (lambda fn: fn)) + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) ext_mod = MagicMock() commands_mod = MagicMock() From b89177668ec6eaecbea9eaca8fc949f195ba4b96 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Sat, 14 Mar 2026 14:02:57 +0700 Subject: [PATCH 14/41] fix(cli): non-blocking startup update check and banner deduplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add background thread mechanism (prefetch_update_check/get_update_result) so git fetch runs in parallel with skill sync and agent init - Fix repo path fallback in check_for_updates() for dev installs - Remove duplicate build_welcome_banner (~180 lines) and _format_context_length from cli.py — the banner.py version is now the single source of truth - Port skin banner_hero/banner_logo support and terminal width check from cli.py's version into banner.py - Add update status output to hermes version command - Add unit tests for update check, prefetch, and version string --- cli.py | 227 -------------------------- hermes_cli/banner.py | 51 +++++- hermes_cli/main.py | 19 +++ tests/hermes_cli/test_update_check.py | 135 +++++++++++++++ 4 files changed, 199 insertions(+), 233 deletions(-) create mode 100644 tests/hermes_cli/test_update_check.py diff --git a/cli.py b/cli.py index 70a202d3..cacc7590 100755 --- a/cli.py +++ b/cli.py @@ -454,7 +454,6 @@ from model_tools import get_tool_definitions, get_toolset_for_tool from hermes_cli.banner import ( cprint as _cprint, _GOLD, _BOLD, _DIM, _RST, VERSION, RELEASE_DATE, HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER, - get_available_skills as _get_available_skills, build_welcome_banner, ) from hermes_cli.commands import COMMANDS, SlashCommandCompleter @@ -845,232 +844,6 @@ def _build_compact_banner() -> str: ) -def _get_available_skills() -> Dict[str, List[str]]: - """ - Scan ~/.hermes/skills/ and return skills grouped by category. - - Returns: - Dict mapping category name to list of skill names - """ - import os - - hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) - skills_dir = hermes_home / "skills" - skills_by_category = {} - - if not skills_dir.exists(): - return skills_by_category - - for skill_file in skills_dir.rglob("SKILL.md"): - rel_path = skill_file.relative_to(skills_dir) - parts = rel_path.parts - - if len(parts) >= 2: - category = parts[0] - skill_name = parts[-2] - else: - category = "general" - skill_name = skill_file.parent.name - - skills_by_category.setdefault(category, []).append(skill_name) - - return skills_by_category - - -def _format_context_length(tokens: int) -> str: - """Format a token count for display (e.g. 128000 → '128K', 1048576 → '1M').""" - if tokens >= 1_000_000: - val = tokens / 1_000_000 - return f"{val:g}M" - elif tokens >= 1_000: - val = tokens / 1_000 - return f"{val:g}K" - return str(tokens) - - -def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dict] = None, enabled_toolsets: List[str] = None, session_id: str = None, context_length: int = None): - """ - Build and print a Claude Code-style welcome banner with caduceus on left and info on right. - - Args: - console: Rich Console instance for printing - model: The current model name (e.g., "anthropic/claude-opus-4") - cwd: Current working directory - tools: List of tool definitions - enabled_toolsets: List of enabled toolset names - session_id: Unique session identifier for logging - context_length: Model's context window size in tokens - """ - from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS - - tools = tools or [] - enabled_toolsets = enabled_toolsets or [] - - # Get unavailable tools info for coloring - _, unavailable_toolsets = check_tool_availability(quiet=True) - disabled_tools = set() - for item in unavailable_toolsets: - disabled_tools.update(item.get("tools", [])) - - # Build the side-by-side content using a table for precise control - layout_table = Table.grid(padding=(0, 2)) - layout_table.add_column("left", justify="center") - layout_table.add_column("right", justify="left") - - # Build left content: caduceus + model info - # Resolve skin colors for the banner - try: - from hermes_cli.skin_engine import get_active_skin - _bskin = get_active_skin() - _accent = _bskin.get_color("banner_accent", "#FFBF00") - _dim = _bskin.get_color("banner_dim", "#B8860B") - _text = _bskin.get_color("banner_text", "#FFF8DC") - _session_c = _bskin.get_color("session_border", "#8B8682") - _title_c = _bskin.get_color("banner_title", "#FFD700") - _border_c = _bskin.get_color("banner_border", "#CD7F32") - _agent_name = _bskin.get_branding("agent_name", "Hermes Agent") - except Exception: - _bskin = None - _accent, _dim, _text = "#FFBF00", "#B8860B", "#FFF8DC" - _session_c, _title_c, _border_c = "#8B8682", "#FFD700", "#CD7F32" - _agent_name = "Hermes Agent" - - _hero = _bskin.banner_hero if hasattr(_bskin, 'banner_hero') and _bskin.banner_hero else HERMES_CADUCEUS - left_lines = ["", _hero, ""] - - # Shorten model name for display - model_short = model.split("/")[-1] if "/" in model else model - if len(model_short) > 28: - model_short = model_short[:25] + "..." - - ctx_str = f" [dim {_dim}]·[/] [dim {_dim}]{_format_context_length(context_length)} context[/]" if context_length else "" - left_lines.append(f"[{_accent}]{model_short}[/]{ctx_str} [dim {_dim}]·[/] [dim {_dim}]Nous Research[/]") - left_lines.append(f"[dim {_dim}]{cwd}[/]") - - # Add session ID if provided - if session_id: - left_lines.append(f"[dim {_session_c}]Session: {session_id}[/]") - left_content = "\n".join(left_lines) - - # Build right content: tools list grouped by toolset - right_lines = [] - right_lines.append(f"[bold {_accent}]Available Tools[/]") - - # Group tools by toolset (include all possible tools, both enabled and disabled) - toolsets_dict = {} - - # First, add all enabled tools - for tool in tools: - tool_name = tool["function"]["name"] - toolset = get_toolset_for_tool(tool_name) or "other" - if toolset not in toolsets_dict: - toolsets_dict[toolset] = [] - toolsets_dict[toolset].append(tool_name) - - # Also add disabled toolsets so they show in the banner - for item in unavailable_toolsets: - # Map the internal toolset ID to display name - toolset_id = item.get("id", item.get("name", "unknown")) - display_name = f"{toolset_id}_tools" if not toolset_id.endswith("_tools") else toolset_id - if display_name not in toolsets_dict: - toolsets_dict[display_name] = [] - for tool_name in item.get("tools", []): - if tool_name not in toolsets_dict[display_name]: - toolsets_dict[display_name].append(tool_name) - - # Display tools grouped by toolset (compact format, max 8 groups) - sorted_toolsets = sorted(toolsets_dict.keys()) - display_toolsets = sorted_toolsets[:8] - remaining_toolsets = len(sorted_toolsets) - 8 - - for toolset in display_toolsets: - tool_names = toolsets_dict[toolset] - # Color each tool name - red if disabled, normal if enabled - colored_names = [] - for name in sorted(tool_names): - if name in disabled_tools: - colored_names.append(f"[red]{name}[/]") - else: - colored_names.append(f"[{_text}]{name}[/]") - - tools_str = ", ".join(colored_names) - # Truncate if too long (accounting for markup) - if len(", ".join(sorted(tool_names))) > 45: - # Rebuild with truncation - short_names = [] - length = 0 - for name in sorted(tool_names): - if length + len(name) + 2 > 42: - short_names.append("...") - break - short_names.append(name) - length += len(name) + 2 - # Re-color the truncated list - colored_names = [] - for name in short_names: - if name == "...": - colored_names.append("[dim]...[/]") - elif name in disabled_tools: - colored_names.append(f"[red]{name}[/]") - else: - colored_names.append(f"[{_text}]{name}[/]") - tools_str = ", ".join(colored_names) - - right_lines.append(f"[dim {_dim}]{toolset}:[/] {tools_str}") - - if remaining_toolsets > 0: - right_lines.append(f"[dim {_dim}](and {remaining_toolsets} more toolsets...)[/]") - - right_lines.append("") - - # Add skills section - right_lines.append(f"[bold {_accent}]Available Skills[/]") - skills_by_category = _get_available_skills() - total_skills = sum(len(s) for s in skills_by_category.values()) - - if skills_by_category: - for category in sorted(skills_by_category.keys()): - skill_names = sorted(skills_by_category[category]) - # Show first 8 skills, then "..." if more - if len(skill_names) > 8: - display_names = skill_names[:8] - skills_str = ", ".join(display_names) + f" +{len(skill_names) - 8} more" - else: - skills_str = ", ".join(skill_names) - # Truncate if still too long - if len(skills_str) > 50: - skills_str = skills_str[:47] + "..." - right_lines.append(f"[dim {_dim}]{category}:[/] [{_text}]{skills_str}[/]") - else: - right_lines.append(f"[dim {_dim}]No skills installed[/]") - - right_lines.append("") - right_lines.append(f"[dim {_dim}]{len(tools)} tools · {total_skills} skills · /help for commands[/]") - - right_content = "\n".join(right_lines) - - # Add to table - layout_table.add_row(left_content, right_content) - - # Wrap in a panel with the title - outer_panel = Panel( - layout_table, - title=f"[bold {_title_c}]{_agent_name} v{VERSION} ({RELEASE_DATE})[/]", - border_style=_border_c, - padding=(0, 2), - ) - - # Print the big logo — use skin's custom logo if available - console.print() - term_width = shutil.get_terminal_size().columns - if term_width >= 95: - _logo = _bskin.banner_logo if hasattr(_bskin, 'banner_logo') and _bskin.banner_logo else HERMES_AGENT_LOGO - console.print(_logo) - console.print() - - # Print the panel with caduceus and info - console.print(outer_panel) - # ============================================================================ # Skill Slash Commands — dynamic commands generated from installed skills diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index f1925651..c1a1d4c7 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -6,7 +6,9 @@ Pure display functions with no HermesCLI state dependency. import json import logging import os +import shutil import subprocess +import threading import time from pathlib import Path from typing import Dict, List, Any, Optional @@ -143,7 +145,9 @@ def check_for_updates() -> Optional[int]: repo_dir = hermes_home / "hermes-agent" cache_file = hermes_home / ".update_check" - # Must be a git repo + # Must be a git repo — fall back to project root for dev installs + if not (repo_dir / ".git").exists(): + repo_dir = Path(__file__).parent.parent.resolve() if not (repo_dir / ".git").exists(): return None @@ -190,6 +194,30 @@ def check_for_updates() -> Optional[int]: return behind +# ========================================================================= +# Non-blocking update check +# ========================================================================= + +_update_result: Optional[int] = None +_update_check_done = threading.Event() + + +def prefetch_update_check(): + """Kick off update check in a background daemon thread.""" + def _run(): + global _update_result + _update_result = check_for_updates() + _update_check_done.set() + t = threading.Thread(target=_run, daemon=True) + t.start() + + +def get_update_result(timeout: float = 0.5) -> Optional[int]: + """Get result of prefetched check. Returns None if not ready.""" + _update_check_done.wait(timeout=timeout) + return _update_result + + # ========================================================================= # Welcome banner # ========================================================================= @@ -245,7 +273,15 @@ def build_welcome_banner(console: Console, model: str, cwd: str, text = _skin_color("banner_text", "#FFF8DC") session_color = _skin_color("session_border", "#8B8682") - left_lines = ["", HERMES_CADUCEUS, ""] + # Use skin's custom caduceus art if provided + try: + from hermes_cli.skin_engine import get_active_skin + _bskin = get_active_skin() + _hero = _bskin.banner_hero if hasattr(_bskin, 'banner_hero') and _bskin.banner_hero else HERMES_CADUCEUS + except Exception: + _bskin = None + _hero = HERMES_CADUCEUS + left_lines = ["", _hero, ""] model_short = model.split("/")[-1] if "/" in model else model if len(model_short) > 28: model_short = model_short[:25] + "..." @@ -360,9 +396,9 @@ def build_welcome_banner(console: Console, model: str, cwd: str, summary_parts.append("/help for commands") right_lines.append(f"[dim {dim}]{' · '.join(summary_parts)}[/]") - # Update check — show if behind origin/main + # Update check — use prefetched result if available try: - behind = check_for_updates() + behind = get_update_result(timeout=0.5) if behind and behind > 0: commits_word = "commit" if behind == 1 else "commits" right_lines.append( @@ -386,6 +422,9 @@ def build_welcome_banner(console: Console, model: str, cwd: str, ) console.print() - console.print(HERMES_AGENT_LOGO) - console.print() + term_width = shutil.get_terminal_size().columns + if term_width >= 95: + _logo = _bskin.banner_logo if _bskin and hasattr(_bskin, 'banner_logo') and _bskin.banner_logo else HERMES_AGENT_LOGO + console.print(_logo) + console.print() console.print(outer_panel) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5f9356b8..972cba1c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -480,6 +480,13 @@ def cmd_chat(args): print("You can run 'hermes setup' at any time to configure.") sys.exit(1) + # Start update check in background (runs while other init happens) + try: + from hermes_cli.banner import prefetch_update_check + prefetch_update_check() + except Exception: + pass + # Sync bundled skills on every CLI launch (fast -- skips unchanged skills) try: from tools.skills_sync import sync_skills @@ -1863,6 +1870,18 @@ def cmd_version(args): except ImportError: print("OpenAI SDK: Not installed") + # Show update status (synchronous — acceptable since user asked for version info) + try: + from hermes_cli.banner import check_for_updates + behind = check_for_updates() + if behind and behind > 0: + commits_word = "commit" if behind == 1 else "commits" + print(f"Update available: {behind} {commits_word} behind — run 'hermes update'") + elif behind == 0: + print("Up to date") + except Exception: + pass + def cmd_uninstall(args): """Uninstall Hermes Agent.""" diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py new file mode 100644 index 00000000..08ed3426 --- /dev/null +++ b/tests/hermes_cli/test_update_check.py @@ -0,0 +1,135 @@ +"""Tests for the update check mechanism in hermes_cli.banner.""" + +import json +import threading +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +def test_version_string_no_v_prefix(): + """__version__ should be bare semver without a 'v' prefix.""" + from hermes_cli import __version__ + assert not __version__.startswith("v"), f"__version__ should not start with 'v', got {__version__!r}" + + +def test_check_for_updates_uses_cache(tmp_path): + """When cache is fresh, check_for_updates should return cached value without calling git.""" + from hermes_cli.banner import check_for_updates + + # Create a fake git repo and fresh cache + repo_dir = tmp_path / "hermes-agent" + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + + cache_file = tmp_path / ".update_check" + cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3})) + + with patch("hermes_cli.banner.os.getenv", return_value=str(tmp_path)): + with patch("hermes_cli.banner.subprocess.run") as mock_run: + result = check_for_updates() + + assert result == 3 + mock_run.assert_not_called() + + +def test_check_for_updates_expired_cache(tmp_path): + """When cache is expired, check_for_updates should call git fetch.""" + from hermes_cli.banner import check_for_updates + + repo_dir = tmp_path / "hermes-agent" + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + + # Write an expired cache (timestamp far in the past) + cache_file = tmp_path / ".update_check" + cache_file.write_text(json.dumps({"ts": 0, "behind": 1})) + + mock_result = MagicMock(returncode=0, stdout="5\n") + + with patch("hermes_cli.banner.os.getenv", return_value=str(tmp_path)): + with patch("hermes_cli.banner.subprocess.run", return_value=mock_result) as mock_run: + result = check_for_updates() + + assert result == 5 + assert mock_run.call_count == 2 # git fetch + git rev-list + + +def test_check_for_updates_no_git_dir(tmp_path): + """Returns None when .git directory doesn't exist anywhere.""" + import hermes_cli.banner as banner + + # Create a fake banner.py so the fallback path also has no .git + fake_banner = tmp_path / "hermes_cli" / "banner.py" + fake_banner.parent.mkdir(parents=True, exist_ok=True) + fake_banner.touch() + + original = banner.__file__ + try: + banner.__file__ = str(fake_banner) + with patch("hermes_cli.banner.os.getenv", return_value=str(tmp_path)): + with patch("hermes_cli.banner.subprocess.run") as mock_run: + result = banner.check_for_updates() + assert result is None + mock_run.assert_not_called() + finally: + banner.__file__ = original + + +def test_check_for_updates_fallback_to_project_root(): + """Dev install: falls back to Path(__file__).parent.parent when HERMES_HOME has no git repo.""" + import hermes_cli.banner as banner + + project_root = Path(banner.__file__).parent.parent.resolve() + if not (project_root / ".git").exists(): + pytest.skip("Not running from a git checkout") + + # Point HERMES_HOME at a temp dir with no hermes-agent/.git + import tempfile + with tempfile.TemporaryDirectory() as td: + with patch("hermes_cli.banner.os.getenv", return_value=td): + with patch("hermes_cli.banner.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="0\n") + result = banner.check_for_updates() + # Should have fallen back to project root and run git commands + assert mock_run.call_count >= 1 + + +def test_prefetch_non_blocking(): + """prefetch_update_check() should return immediately without blocking.""" + import hermes_cli.banner as banner + + # Reset module state + banner._update_result = None + banner._update_check_done = threading.Event() + + with patch.object(banner, "check_for_updates", return_value=5): + start = time.monotonic() + banner.prefetch_update_check() + elapsed = time.monotonic() - start + + # Should return almost immediately (well under 1 second) + assert elapsed < 1.0 + + # Wait for the background thread to finish + banner._update_check_done.wait(timeout=5) + assert banner._update_result == 5 + + +def test_get_update_result_timeout(): + """get_update_result() returns None when check hasn't completed within timeout.""" + import hermes_cli.banner as banner + + # Reset module state — don't set the event + banner._update_result = None + banner._update_check_done = threading.Event() + + start = time.monotonic() + result = banner.get_update_result(timeout=0.1) + elapsed = time.monotonic() - start + + # Should have waited ~0.1s and returned None + assert result is None + assert elapsed < 0.5 From 12bc86d9c92e602ded6f81fa34d7deb6175e5896 Mon Sep 17 00:00:00 2001 From: Sebastion Date: Sun, 15 Mar 2026 01:18:45 +0000 Subject: [PATCH 15/41] fix: prevent path traversal in .worktreeinclude file processing Resolve .worktreeinclude entries and validate that both the source path stays within the repository root and the destination path stays within the worktree directory before copying files or creating symlinks. A malicious .worktreeinclude in a cloned repository could previously reference paths like "../../etc/passwd" to copy or symlink arbitrary files from outside the repo into the worktree. CWE-22: Improper Limitation of a Pathname to a Restricted Directory --- cli.py | 18 ++++++++++- tests/test_worktree.py | 72 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 70a202d3..4f734fad 100755 --- a/cli.py +++ b/cli.py @@ -571,12 +571,28 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: include_file = Path(repo_root) / ".worktreeinclude" if include_file.exists(): try: + repo_root_resolved = Path(repo_root).resolve() + wt_path_resolved = wt_path.resolve() for line in include_file.read_text().splitlines(): entry = line.strip() if not entry or entry.startswith("#"): continue src = Path(repo_root) / entry dst = wt_path / entry + # Prevent path traversal: ensure src stays within repo_root + # and dst stays within the worktree directory + try: + src_resolved = src.resolve() + dst_resolved = dst.resolve(strict=False) + except (OSError, ValueError): + logger.debug("Skipping invalid .worktreeinclude entry: %s", entry) + continue + if not str(src_resolved).startswith(str(repo_root_resolved) + os.sep) and src_resolved != repo_root_resolved: + logger.warning("Skipping .worktreeinclude entry outside repo root: %s", entry) + continue + if not str(dst_resolved).startswith(str(wt_path_resolved) + os.sep) and dst_resolved != wt_path_resolved: + logger.warning("Skipping .worktreeinclude entry that escapes worktree: %s", entry) + continue if src.is_file(): dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(str(src), str(dst)) @@ -584,7 +600,7 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: # Symlink directories (faster, saves disk) if not dst.exists(): dst.parent.mkdir(parents=True, exist_ok=True) - os.symlink(str(src.resolve()), str(dst)) + os.symlink(str(src_resolved), str(dst)) except Exception as e: logger.debug("Error copying .worktreeinclude entries: %s", e) diff --git a/tests/test_worktree.py b/tests/test_worktree.py index f545baa3..dd24381e 100644 --- a/tests/test_worktree.py +++ b/tests/test_worktree.py @@ -633,3 +633,75 @@ class TestSystemPromptInjection: assert info["repo_root"] in wt_note assert "isolated git worktree" in wt_note assert "commit and push" in wt_note + + +class TestWorktreeIncludePathTraversal: + """Test that .worktreeinclude entries with path traversal are rejected.""" + + def test_rejects_parent_directory_traversal(self, git_repo): + """Entries like '../../etc/passwd' must not escape the repo root.""" + import shutil as _shutil + + # Create a sensitive file outside the repo to simulate the attack + outside_file = git_repo.parent / "sensitive.txt" + outside_file.write_text("SENSITIVE DATA") + + # Create a .worktreeinclude with a traversal entry + (git_repo / ".worktreeinclude").write_text("../sensitive.txt\n") + + info = _setup_worktree(str(git_repo)) + assert info is not None + + wt_path = Path(info["path"]) + + # Replay the fixed logic from cli.py + repo_root_resolved = Path(str(git_repo)).resolve() + wt_path_resolved = wt_path.resolve() + include_file = git_repo / ".worktreeinclude" + + copied_entries = [] + for line in include_file.read_text().splitlines(): + entry = line.strip() + if not entry or entry.startswith("#"): + continue + src = Path(str(git_repo)) / entry + dst = wt_path / entry + try: + src_resolved = src.resolve() + dst_resolved = dst.resolve(strict=False) + except (OSError, ValueError): + continue + if not str(src_resolved).startswith(str(repo_root_resolved) + os.sep) and src_resolved != repo_root_resolved: + continue + if not str(dst_resolved).startswith(str(wt_path_resolved) + os.sep) and dst_resolved != wt_path_resolved: + continue + copied_entries.append(entry) + + # The traversal entry must have been skipped + assert len(copied_entries) == 0 + # The sensitive file must NOT be in the worktree + assert not (wt_path / "../sensitive.txt").resolve().is_relative_to(wt_path_resolved) + + def test_allows_valid_entries(self, git_repo): + """Normal entries within the repo should still be processed.""" + (git_repo / ".env").write_text("KEY=val") + (git_repo / ".worktreeinclude").write_text(".env\n") + + info = _setup_worktree(str(git_repo)) + assert info is not None + + repo_root_resolved = Path(str(git_repo)).resolve() + include_file = git_repo / ".worktreeinclude" + + accepted = [] + for line in include_file.read_text().splitlines(): + entry = line.strip() + if not entry or entry.startswith("#"): + continue + src = Path(str(git_repo)) / entry + src_resolved = src.resolve() + if not str(src_resolved).startswith(str(repo_root_resolved) + os.sep) and src_resolved != repo_root_resolved: + continue + accepted.append(entry) + + assert ".env" in accepted From fd687d09678c3b1b11eddf3fd011f8bc57feaf05 Mon Sep 17 00:00:00 2001 From: Joshua Martinez <29747003+joshkmartinez@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:43:22 -0700 Subject: [PATCH 16/41] fix slack docs reference --- hermes_cli/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index ef5f0969..935cb5da 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2155,7 +2155,7 @@ def setup_gateway(config: dict): ) print() print_info( - " Full guide: https://hermes-agent.ai/docs/user-guide/messaging/slack" + " Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/" ) print() bot_token = prompt("Slack Bot Token (xoxb-...)", password=True) From 2119b6879968e10e9d78aff59f31c00dcfaae5af Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 21:49:04 -0700 Subject: [PATCH 17/41] fix: clarify Slack setup guidance - mark private-channel scopes/events as optional - note reinstall requirement after scope/event changes - correct Slack allowlist messaging to match gateway behavior --- hermes_cli/setup.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 935cb5da..fca2b862 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2142,16 +2142,18 @@ def setup_gateway(config: dict): print_info(" • Create an App-Level Token with 'connections:write' scope") print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions") print_info(" Required scopes: chat:write, app_mentions:read,") - print_info(" channels:history, channels:read, groups:history,") - print_info(" im:history, im:read, im:write, users:read, files:write") + print_info(" channels:history, channels:read, im:history,") + print_info(" im:read, im:write, users:read, files:write") + print_info(" Optional for private channels: groups:history") print_info(" 4. Subscribe to Events: Features → Event Subscriptions → Enable") - print_info(" Required events: message.im, message.channels,") - print_info(" message.groups, app_mention") - print_warning(" ⚠ Without message.channels/message.groups events,") - print_warning(" the bot will ONLY work in DMs, not channels!") + print_info(" Required events: message.im, message.channels, app_mention") + print_info(" Optional for private channels: message.groups") + print_warning(" ⚠ Without message.channels the bot will ONLY work in DMs,") + print_warning(" not public channels.") print_info(" 5. Install to Workspace: Settings → Install App") + print_info(" 6. Reinstall the app after any scope or event changes") print_info( - " 6. After installing, invite the bot to channels: /invite @YourBot" + " 7. After installing, invite the bot to channels: /invite @YourBot" ) print() print_info( @@ -2173,14 +2175,17 @@ def setup_gateway(config: dict): ) print() allowed_users = prompt( - "Allowed user IDs (comma-separated, leave empty for open access)" + "Allowed user IDs (comma-separated, leave empty to deny everyone except paired users)" ) if allowed_users: save_env_value("SLACK_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("Slack allowlist configured") else: + print_warning( + "⚠️ No Slack allowlist set - unpaired users will be denied by default." + ) print_info( - "⚠️ No allowlist set - anyone in your workspace can use the bot!" + " Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access." ) # ── WhatsApp ── From f4c012873c7205cb28f959f1524fdcaa17eb5cee Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 21:51:27 -0700 Subject: [PATCH 18/41] fix: harden salvaged worktree include checks Use Path.relative_to-based containment checks for the salvaged .worktreeinclude guard, remove the replayed test logic from the cherry-picked PR, and add real integration regressions for file, directory, and symlink escapes. --- cli.py | 20 +++-- tests/test_worktree.py | 72 ------------------ tests/test_worktree_security.py | 130 ++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 77 deletions(-) create mode 100644 tests/test_worktree_security.py diff --git a/cli.py b/cli.py index 4f734fad..4e55ebbc 100755 --- a/cli.py +++ b/cli.py @@ -518,6 +518,15 @@ def _git_repo_root() -> Optional[str]: return None +def _path_is_within_root(path: Path, root: Path) -> bool: + """Return True when a resolved path stays within the expected root.""" + try: + path.relative_to(root) + return True + except ValueError: + return False + + def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: """Create an isolated git worktree for this CLI session. @@ -579,18 +588,19 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: continue src = Path(repo_root) / entry dst = wt_path / entry - # Prevent path traversal: ensure src stays within repo_root - # and dst stays within the worktree directory + # Prevent path traversal and symlink escapes: both the resolved + # source and the resolved destination must stay inside their + # expected roots before any file or symlink operation happens. try: - src_resolved = src.resolve() + src_resolved = src.resolve(strict=False) dst_resolved = dst.resolve(strict=False) except (OSError, ValueError): logger.debug("Skipping invalid .worktreeinclude entry: %s", entry) continue - if not str(src_resolved).startswith(str(repo_root_resolved) + os.sep) and src_resolved != repo_root_resolved: + if not _path_is_within_root(src_resolved, repo_root_resolved): logger.warning("Skipping .worktreeinclude entry outside repo root: %s", entry) continue - if not str(dst_resolved).startswith(str(wt_path_resolved) + os.sep) and dst_resolved != wt_path_resolved: + if not _path_is_within_root(dst_resolved, wt_path_resolved): logger.warning("Skipping .worktreeinclude entry that escapes worktree: %s", entry) continue if src.is_file(): diff --git a/tests/test_worktree.py b/tests/test_worktree.py index dd24381e..f545baa3 100644 --- a/tests/test_worktree.py +++ b/tests/test_worktree.py @@ -633,75 +633,3 @@ class TestSystemPromptInjection: assert info["repo_root"] in wt_note assert "isolated git worktree" in wt_note assert "commit and push" in wt_note - - -class TestWorktreeIncludePathTraversal: - """Test that .worktreeinclude entries with path traversal are rejected.""" - - def test_rejects_parent_directory_traversal(self, git_repo): - """Entries like '../../etc/passwd' must not escape the repo root.""" - import shutil as _shutil - - # Create a sensitive file outside the repo to simulate the attack - outside_file = git_repo.parent / "sensitive.txt" - outside_file.write_text("SENSITIVE DATA") - - # Create a .worktreeinclude with a traversal entry - (git_repo / ".worktreeinclude").write_text("../sensitive.txt\n") - - info = _setup_worktree(str(git_repo)) - assert info is not None - - wt_path = Path(info["path"]) - - # Replay the fixed logic from cli.py - repo_root_resolved = Path(str(git_repo)).resolve() - wt_path_resolved = wt_path.resolve() - include_file = git_repo / ".worktreeinclude" - - copied_entries = [] - for line in include_file.read_text().splitlines(): - entry = line.strip() - if not entry or entry.startswith("#"): - continue - src = Path(str(git_repo)) / entry - dst = wt_path / entry - try: - src_resolved = src.resolve() - dst_resolved = dst.resolve(strict=False) - except (OSError, ValueError): - continue - if not str(src_resolved).startswith(str(repo_root_resolved) + os.sep) and src_resolved != repo_root_resolved: - continue - if not str(dst_resolved).startswith(str(wt_path_resolved) + os.sep) and dst_resolved != wt_path_resolved: - continue - copied_entries.append(entry) - - # The traversal entry must have been skipped - assert len(copied_entries) == 0 - # The sensitive file must NOT be in the worktree - assert not (wt_path / "../sensitive.txt").resolve().is_relative_to(wt_path_resolved) - - def test_allows_valid_entries(self, git_repo): - """Normal entries within the repo should still be processed.""" - (git_repo / ".env").write_text("KEY=val") - (git_repo / ".worktreeinclude").write_text(".env\n") - - info = _setup_worktree(str(git_repo)) - assert info is not None - - repo_root_resolved = Path(str(git_repo)).resolve() - include_file = git_repo / ".worktreeinclude" - - accepted = [] - for line in include_file.read_text().splitlines(): - entry = line.strip() - if not entry or entry.startswith("#"): - continue - src = Path(str(git_repo)) / entry - src_resolved = src.resolve() - if not str(src_resolved).startswith(str(repo_root_resolved) + os.sep) and src_resolved != repo_root_resolved: - continue - accepted.append(entry) - - assert ".env" in accepted diff --git a/tests/test_worktree_security.py b/tests/test_worktree_security.py new file mode 100644 index 00000000..73a242e0 --- /dev/null +++ b/tests/test_worktree_security.py @@ -0,0 +1,130 @@ +"""Security-focused integration tests for CLI worktree setup.""" + +import subprocess +from pathlib import Path + +import pytest + + +@pytest.fixture +def git_repo(tmp_path): + """Create a temporary git repo for testing real cli._setup_worktree behavior.""" + repo = tmp_path / "test-repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True, capture_output=True) + (repo / "README.md").write_text("# Test Repo\n") + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo, check=True, capture_output=True) + return repo + + +def _force_remove_worktree(info: dict | None) -> None: + if not info: + return + subprocess.run( + ["git", "worktree", "remove", info["path"], "--force"], + cwd=info["repo_root"], + capture_output=True, + check=False, + ) + subprocess.run( + ["git", "branch", "-D", info["branch"]], + cwd=info["repo_root"], + capture_output=True, + check=False, + ) + + +class TestWorktreeIncludeSecurity: + def test_rejects_parent_directory_file_traversal(self, git_repo): + import cli as cli_mod + + outside_file = git_repo.parent / "sensitive.txt" + outside_file.write_text("SENSITIVE DATA") + (git_repo / ".worktreeinclude").write_text("../sensitive.txt\n") + + info = None + try: + info = cli_mod._setup_worktree(str(git_repo)) + assert info is not None + + wt_path = Path(info["path"]) + assert not (wt_path.parent / "sensitive.txt").exists() + assert not (wt_path / "../sensitive.txt").resolve().exists() + finally: + _force_remove_worktree(info) + + def test_rejects_parent_directory_directory_traversal(self, git_repo): + import cli as cli_mod + + outside_dir = git_repo.parent / "outside-dir" + outside_dir.mkdir() + (outside_dir / "secret.txt").write_text("SENSITIVE DIR DATA") + (git_repo / ".worktreeinclude").write_text("../outside-dir\n") + + info = None + try: + info = cli_mod._setup_worktree(str(git_repo)) + assert info is not None + + wt_path = Path(info["path"]) + escaped_dir = wt_path.parent / "outside-dir" + assert not escaped_dir.exists() + assert not escaped_dir.is_symlink() + finally: + _force_remove_worktree(info) + + def test_rejects_symlink_that_resolves_outside_repo(self, git_repo): + import cli as cli_mod + + outside_file = git_repo.parent / "linked-secret.txt" + outside_file.write_text("LINKED SECRET") + (git_repo / "leak.txt").symlink_to(outside_file) + (git_repo / ".worktreeinclude").write_text("leak.txt\n") + + info = None + try: + info = cli_mod._setup_worktree(str(git_repo)) + assert info is not None + + assert not (Path(info["path"]) / "leak.txt").exists() + finally: + _force_remove_worktree(info) + + def test_allows_valid_file_include(self, git_repo): + import cli as cli_mod + + (git_repo / ".env").write_text("SECRET=***\n") + (git_repo / ".worktreeinclude").write_text(".env\n") + + info = None + try: + info = cli_mod._setup_worktree(str(git_repo)) + assert info is not None + + copied = Path(info["path"]) / ".env" + assert copied.exists() + assert copied.read_text() == "SECRET=***\n" + finally: + _force_remove_worktree(info) + + def test_allows_valid_directory_include(self, git_repo): + import cli as cli_mod + + assets_dir = git_repo / ".venv" / "lib" + assets_dir.mkdir(parents=True) + (assets_dir / "marker.txt").write_text("venv marker") + (git_repo / ".worktreeinclude").write_text(".venv\n") + + info = None + try: + info = cli_mod._setup_worktree(str(git_repo)) + assert info is not None + + linked_dir = Path(info["path"]) / ".venv" + assert linked_dir.is_symlink() + assert (linked_dir / "lib" / "marker.txt").read_text() == "venv marker" + finally: + _force_remove_worktree(info) From 0c182211a134fba08526469de1c5a811d59efd6b Mon Sep 17 00:00:00 2001 From: Vimal Date: Fri, 13 Mar 2026 18:37:20 +0000 Subject: [PATCH 19/41] fix(telegram): check updater/app state before disconnect The disconnect() method was unconditionally calling updater.stop() and app.stop(), causing errors when: - The updater was not running (RuntimeError: This Updater is not running!) - The app was None (AttributeError: 'NoneType' object has no attribute) Changes: - Check if updater exists and is running before stopping - Check if app is running before stopping - Only log warnings for actual errors, not expected shutdown states Fixes spurious warnings during gateway shutdown. --- gateway/platforms/telegram.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 833c95c8..790061ec 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -275,8 +275,11 @@ class TelegramAdapter(BasePlatformAdapter): if self._app: try: - await self._app.updater.stop() - await self._app.stop() + # Only stop the updater if it's running + if self._app.updater and self._app.updater.running: + await self._app.updater.stop() + if self._app.running: + await self._app.stop() await self._app.shutdown() except Exception as e: logger.warning("[%s] Error during Telegram disconnect: %s", self.name, e, exc_info=True) From 9938d27e27960de9ac931cf5fc080bb1ab561412 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 21:53:28 -0700 Subject: [PATCH 20/41] test(telegram): cover disconnect with inactive updater --- tests/gateway/test_telegram_conflict.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/gateway/test_telegram_conflict.py b/tests/gateway/test_telegram_conflict.py index f2e21281..86dc509d 100644 --- a/tests/gateway/test_telegram_conflict.py +++ b/tests/gateway/test_telegram_conflict.py @@ -98,3 +98,27 @@ async def test_polling_conflict_stops_polling_and_notifies_handler(monkeypatch): assert adapter.has_fatal_error is True updater.stop.assert_awaited() fatal_handler.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_disconnect_skips_inactive_updater_and_app(monkeypatch): + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***")) + + updater = SimpleNamespace(running=False, stop=AsyncMock()) + app = SimpleNamespace( + updater=updater, + running=False, + stop=AsyncMock(), + shutdown=AsyncMock(), + ) + adapter._app = app + + warning = MagicMock() + monkeypatch.setattr("gateway.platforms.telegram.logger.warning", warning) + + await adapter.disconnect() + + updater.stop.assert_not_awaited() + app.stop.assert_not_awaited() + app.shutdown.assert_awaited_once() + warning.assert_not_called() From 00c5e77724b1974805f879ec160a78d06a553736 Mon Sep 17 00:00:00 2001 From: yemi-lagosinternationalmarket <223155976+yemi-lagosinternationalmarket@users.noreply.github.com> Date: Sat, 14 Mar 2026 04:13:05 +0000 Subject: [PATCH 21/41] fix: prevent closed OpenAI client reuse across retries Use per-request OpenAI clients inside _interruptible_api_call so interrupts and transport failures do not poison later retries. Also add closed-client detection/recreation for the shared client and regression tests covering retry and concurrency behavior. --- run_agent.py | 242 ++++++++++++++++++++------ tests/test_openai_client_lifecycle.py | 181 +++++++++++++++++++ 2 files changed, 366 insertions(+), 57 deletions(-) create mode 100644 tests/test_openai_client_lifecycle.py diff --git a/run_agent.py b/run_agent.py index f2f71aca..871afdd6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -377,6 +377,7 @@ class AIAgent: # Interrupt mechanism for breaking out of tool loops self._interrupt_requested = False self._interrupt_message = None # Optional message that triggered interrupt + self._client_lock = threading.RLock() # Subagent delegation state self._delegate_depth = 0 # 0 = top-level agent, incremented for children @@ -566,7 +567,7 @@ class AIAgent: self._client_kwargs = client_kwargs # stored for rebuilding after interrupt try: - self.client = OpenAI(**client_kwargs) + self.client = self._create_openai_client(client_kwargs, reason="agent_init", shared=True) if not self.quiet_mode: print(f"🤖 AI Agent initialized with model: {self.model}") if base_url: @@ -2468,12 +2469,118 @@ class AIAgent: finish_reason = "stop" return assistant_message, finish_reason - def _run_codex_stream(self, api_kwargs: dict): + def _thread_identity(self) -> str: + thread = threading.current_thread() + return f"{thread.name}:{thread.ident}" + + def _client_log_context(self) -> str: + provider = getattr(self, "provider", "unknown") + base_url = getattr(self, "base_url", "unknown") + model = getattr(self, "model", "unknown") + return ( + f"thread={self._thread_identity()} provider={provider} " + f"base_url={base_url} model={model}" + ) + + def _openai_client_lock(self) -> threading.RLock: + lock = getattr(self, "_client_lock", None) + if lock is None: + lock = threading.RLock() + self._client_lock = lock + return lock + + @staticmethod + def _is_openai_client_closed(client: Any) -> bool: + from unittest.mock import Mock + + if isinstance(client, Mock): + return False + http_client = getattr(client, "_client", None) + return bool(getattr(http_client, "is_closed", False)) + + def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: bool) -> Any: + client = OpenAI(**client_kwargs) + logger.info( + "OpenAI client created (%s, shared=%s) %s", + reason, + shared, + self._client_log_context(), + ) + return client + + def _close_openai_client(self, client: Any, *, reason: str, shared: bool) -> None: + if client is None: + return + try: + client.close() + logger.info( + "OpenAI client closed (%s, shared=%s) %s", + reason, + shared, + self._client_log_context(), + ) + except Exception as exc: + logger.debug( + "OpenAI client close failed (%s, shared=%s) %s error=%s", + reason, + shared, + self._client_log_context(), + exc, + ) + + def _replace_primary_openai_client(self, *, reason: str) -> bool: + with self._openai_client_lock(): + old_client = getattr(self, "client", None) + try: + new_client = self._create_openai_client(self._client_kwargs, reason=reason, shared=True) + except Exception as exc: + logger.warning( + "Failed to rebuild shared OpenAI client (%s) %s error=%s", + reason, + self._client_log_context(), + exc, + ) + return False + self.client = new_client + self._close_openai_client(old_client, reason=f"replace:{reason}", shared=True) + return True + + def _ensure_primary_openai_client(self, *, reason: str) -> Any: + with self._openai_client_lock(): + client = getattr(self, "client", None) + if client is not None and not self._is_openai_client_closed(client): + return client + + logger.warning( + "Detected closed shared OpenAI client; recreating before use (%s) %s", + reason, + self._client_log_context(), + ) + if not self._replace_primary_openai_client(reason=f"recreate_closed:{reason}"): + raise RuntimeError("Failed to recreate closed OpenAI client") + with self._openai_client_lock(): + return self.client + + def _create_request_openai_client(self, *, reason: str) -> Any: + from unittest.mock import Mock + + primary_client = self._ensure_primary_openai_client(reason=reason) + if isinstance(primary_client, Mock): + return primary_client + with self._openai_client_lock(): + request_kwargs = dict(self._client_kwargs) + return self._create_openai_client(request_kwargs, reason=reason, shared=False) + + def _close_request_openai_client(self, client: Any, *, reason: str) -> None: + self._close_openai_client(client, reason=reason, shared=False) + + def _run_codex_stream(self, api_kwargs: dict, client: Any = None): """Execute one streaming Responses API request and return the final response.""" + active_client = client or self._ensure_primary_openai_client(reason="codex_stream_direct") max_stream_retries = 1 for attempt in range(max_stream_retries + 1): try: - with self.client.responses.stream(**api_kwargs) as stream: + with active_client.responses.stream(**api_kwargs) as stream: for _ in stream: pass return stream.get_final_response() @@ -2482,24 +2589,27 @@ class AIAgent: missing_completed = "response.completed" in err_text if missing_completed and attempt < max_stream_retries: logger.debug( - "Responses stream closed before completion (attempt %s/%s); retrying.", + "Responses stream closed before completion (attempt %s/%s); retrying. %s", attempt + 1, max_stream_retries + 1, + self._client_log_context(), ) continue if missing_completed: logger.debug( - "Responses stream did not emit response.completed; falling back to create(stream=True)." + "Responses stream did not emit response.completed; falling back to create(stream=True). %s", + self._client_log_context(), ) - return self._run_codex_create_stream_fallback(api_kwargs) + return self._run_codex_create_stream_fallback(api_kwargs, client=active_client) raise - def _run_codex_create_stream_fallback(self, api_kwargs: dict): + def _run_codex_create_stream_fallback(self, api_kwargs: dict, client: Any = None): """Fallback path for stream completion edge cases on Codex-style Responses backends.""" + active_client = client or self._ensure_primary_openai_client(reason="codex_create_stream_fallback") fallback_kwargs = dict(api_kwargs) fallback_kwargs["stream"] = True fallback_kwargs = self._preflight_codex_api_kwargs(fallback_kwargs, allow_stream=True) - stream_or_response = self.client.responses.create(**fallback_kwargs) + stream_or_response = active_client.responses.create(**fallback_kwargs) # Compatibility shim for mocks or providers that still return a concrete response. if hasattr(stream_or_response, "output"): @@ -2557,15 +2667,7 @@ class AIAgent: self._client_kwargs["api_key"] = self.api_key self._client_kwargs["base_url"] = self.base_url - try: - self.client.close() - except Exception: - pass - - try: - self.client = OpenAI(**self._client_kwargs) - except Exception as exc: - logger.warning("Failed to rebuild OpenAI client after Codex refresh: %s", exc) + if not self._replace_primary_openai_client(reason="codex_credential_refresh"): return False return True @@ -2600,15 +2702,7 @@ class AIAgent: # Nous requests should not inherit OpenRouter-only attribution headers. self._client_kwargs.pop("default_headers", None) - try: - self.client.close() - except Exception: - pass - - try: - self.client = OpenAI(**self._client_kwargs) - except Exception as exc: - logger.warning("Failed to rebuild OpenAI client after Nous refresh: %s", exc) + if not self._replace_primary_openai_client(reason="nous_credential_refresh"): return False return True @@ -2655,43 +2749,54 @@ class AIAgent: Run the API call in a background thread so the main conversation loop can detect interrupts without waiting for the full HTTP round-trip. - On interrupt, closes the HTTP client to cancel the in-flight request - (stops token generation and avoids wasting money), then rebuilds the - client for future calls. + Each worker thread gets its own OpenAI client instance. Interrupts only + close that worker-local client, so retries and other requests never + inherit a closed transport. """ result = {"response": None, "error": None} + request_client_holder = {"client": None} def _call(): try: if self.api_mode == "codex_responses": - result["response"] = self._run_codex_stream(api_kwargs) + request_client_holder["client"] = self._create_request_openai_client(reason="codex_stream_request") + result["response"] = self._run_codex_stream( + api_kwargs, + client=request_client_holder["client"], + ) elif self.api_mode == "anthropic_messages": result["response"] = self._anthropic_messages_create(api_kwargs) else: - result["response"] = self.client.chat.completions.create(**api_kwargs) + request_client_holder["client"] = self._create_request_openai_client(reason="chat_completion_request") + result["response"] = request_client_holder["client"].chat.completions.create(**api_kwargs) except Exception as e: result["error"] = e + finally: + request_client = request_client_holder.get("client") + if request_client is not None: + self._close_request_openai_client(request_client, reason="request_complete") t = threading.Thread(target=_call, daemon=True) t.start() while t.is_alive(): t.join(timeout=0.3) if self._interrupt_requested: - # Force-close the HTTP connection to stop token generation - try: - if self.api_mode == "anthropic_messages": - self._anthropic_client.close() - else: - self.client.close() - except Exception: - pass - # Rebuild the client for future calls (cheap, no network) + # Force-close the in-flight worker-local HTTP connection to stop + # token generation without poisoning the shared client used to + # seed future retries. try: if self.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client - self._anthropic_client = build_anthropic_client(self._anthropic_api_key, getattr(self, "_anthropic_base_url", None)) + + self._anthropic_client.close() + self._anthropic_client = build_anthropic_client( + self._anthropic_api_key, + getattr(self, "_anthropic_base_url", None), + ) else: - self.client = OpenAI(**self._client_kwargs) + request_client = request_client_holder.get("client") + if request_client is not None: + self._close_request_openai_client(request_client, reason="interrupt_abort") except Exception: pass raise InterruptedError("Agent interrupted during API call") @@ -2710,11 +2815,15 @@ class AIAgent: core agent loop untouched for non-voice users. """ result = {"response": None, "error": None} + request_client_holder = {"client": None} def _call(): try: stream_kwargs = {**api_kwargs, "stream": True} - stream = self.client.chat.completions.create(**stream_kwargs) + request_client_holder["client"] = self._create_request_openai_client( + reason="chat_completion_stream_request" + ) + stream = request_client_holder["client"].chat.completions.create(**stream_kwargs) content_parts: list[str] = [] tool_calls_acc: dict[int, dict] = {} @@ -2805,25 +2914,29 @@ class AIAgent: except Exception as e: result["error"] = e + finally: + request_client = request_client_holder.get("client") + if request_client is not None: + self._close_request_openai_client(request_client, reason="stream_request_complete") t = threading.Thread(target=_call, daemon=True) t.start() while t.is_alive(): t.join(timeout=0.3) if self._interrupt_requested: - try: - if self.api_mode == "anthropic_messages": - self._anthropic_client.close() - else: - self.client.close() - except Exception: - pass try: if self.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client - self._anthropic_client = build_anthropic_client(self._anthropic_api_key, getattr(self, "_anthropic_base_url", None)) + + self._anthropic_client.close() + self._anthropic_client = build_anthropic_client( + self._anthropic_api_key, + getattr(self, "_anthropic_base_url", None), + ) else: - self.client = OpenAI(**self._client_kwargs) + request_client = request_client_holder.get("client") + if request_client is not None: + self._close_request_openai_client(request_client, reason="stream_interrupt_abort") except Exception: pass raise InterruptedError("Agent interrupted during API call") @@ -3313,7 +3426,7 @@ class AIAgent: "temperature": 0.3, **self._max_tokens_param(5120), } - response = self.client.chat.completions.create(**api_kwargs, timeout=30.0) + response = self._ensure_primary_openai_client(reason="flush_memories").chat.completions.create(**api_kwargs, timeout=30.0) # Extract tool calls from the response, handling all API formats tool_calls = [] @@ -4059,7 +4172,7 @@ class AIAgent: _msg, _ = _nar(summary_response) final_response = (_msg.content or "").strip() else: - summary_response = self.client.chat.completions.create(**summary_kwargs) + summary_response = self._ensure_primary_openai_client(reason="iteration_limit_summary").chat.completions.create(**summary_kwargs) if summary_response.choices and summary_response.choices[0].message.content: final_response = summary_response.choices[0].message.content @@ -4098,7 +4211,7 @@ class AIAgent: if summary_extra_body: summary_kwargs["extra_body"] = summary_extra_body - summary_response = self.client.chat.completions.create(**summary_kwargs) + summary_response = self._ensure_primary_openai_client(reason="iteration_limit_summary_retry").chat.completions.create(**summary_kwargs) if summary_response.choices and summary_response.choices[0].message.content: final_response = summary_response.choices[0].message.content @@ -4883,7 +4996,15 @@ class AIAgent: # Enhanced error logging error_type = type(api_error).__name__ error_msg = str(api_error).lower() - + logger.warning( + "API call failed (attempt %s/%s) error_type=%s %s error=%s", + retry_count, + max_retries, + error_type, + self._client_log_context(), + api_error, + ) + self._vprint(f"{self.log_prefix}⚠️ API call failed (attempt {retry_count}/{max_retries}): {error_type}", force=True) self._vprint(f"{self.log_prefix} ⏱️ Time elapsed before failure: {elapsed_time:.2f}s") self._vprint(f"{self.log_prefix} 📝 Error: {str(api_error)[:200]}", force=True) @@ -5073,7 +5194,14 @@ class AIAgent: raise api_error wait_time = min(2 ** retry_count, 60) # Exponential backoff: 2s, 4s, 8s, 16s, 32s, 60s, 60s - logging.warning(f"API retry {retry_count}/{max_retries} after error: {api_error}") + logger.warning( + "Retrying API call in %ss (attempt %s/%s) %s error=%s", + wait_time, + retry_count, + max_retries, + self._client_log_context(), + api_error, + ) if retry_count >= max_retries: self._vprint(f"{self.log_prefix}⚠️ API call failed after {retry_count} attempts: {str(api_error)[:100]}") self._vprint(f"{self.log_prefix}⏳ Final retry in {wait_time}s...") diff --git a/tests/test_openai_client_lifecycle.py b/tests/test_openai_client_lifecycle.py new file mode 100644 index 00000000..dc3ed771 --- /dev/null +++ b/tests/test_openai_client_lifecycle.py @@ -0,0 +1,181 @@ +import sys +import threading +import types +from types import SimpleNamespace + +import httpx +import pytest +from openai import APIConnectionError + +sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None)) +sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object)) +sys.modules.setdefault("fal_client", types.SimpleNamespace()) + +import run_agent + + +class FakeRequestClient: + def __init__(self, responder): + self._responder = responder + self._client = SimpleNamespace(is_closed=False) + self.chat = SimpleNamespace( + completions=SimpleNamespace(create=self._create) + ) + self.responses = SimpleNamespace() + self.close_calls = 0 + + def _create(self, **kwargs): + return self._responder(**kwargs) + + def close(self): + self.close_calls += 1 + self._client.is_closed = True + + +class FakeSharedClient(FakeRequestClient): + pass + + +class OpenAIFactory: + def __init__(self, clients): + self._clients = list(clients) + self.calls = [] + + def __call__(self, **kwargs): + self.calls.append(dict(kwargs)) + if not self._clients: + raise AssertionError("OpenAI factory exhausted") + return self._clients.pop(0) + + +def _build_agent(shared_client=None): + agent = run_agent.AIAgent.__new__(run_agent.AIAgent) + agent.api_mode = "chat_completions" + agent.provider = "openai-codex" + agent.base_url = "https://chatgpt.com/backend-api/codex" + agent.model = "gpt-5-codex" + agent.log_prefix = "" + agent.quiet_mode = True + agent._interrupt_requested = False + agent._interrupt_message = None + agent._client_lock = threading.RLock() + agent._client_kwargs = {"api_key": "test-key", "base_url": agent.base_url} + agent.client = shared_client or FakeSharedClient(lambda **kwargs: {"shared": True}) + return agent + + +def _connection_error(): + return APIConnectionError( + message="Connection error.", + request=httpx.Request("POST", "https://example.com/v1/chat/completions"), + ) + + +def test_retry_after_api_connection_error_recreates_request_client(monkeypatch): + first_request = FakeRequestClient(lambda **kwargs: (_ for _ in ()).throw(_connection_error())) + second_request = FakeRequestClient(lambda **kwargs: {"ok": True}) + factory = OpenAIFactory([first_request, second_request]) + monkeypatch.setattr(run_agent, "OpenAI", factory) + + agent = _build_agent() + + with pytest.raises(APIConnectionError): + agent._interruptible_api_call({"model": agent.model, "messages": []}) + + result = agent._interruptible_api_call({"model": agent.model, "messages": []}) + + assert result == {"ok": True} + assert len(factory.calls) == 2 + assert first_request.close_calls >= 1 + assert second_request.close_calls >= 1 + + +def test_closed_shared_client_is_recreated_before_request(monkeypatch): + stale_shared = FakeSharedClient(lambda **kwargs: (_ for _ in ()).throw(AssertionError("stale shared client used"))) + stale_shared._client.is_closed = True + + replacement_shared = FakeSharedClient(lambda **kwargs: {"replacement": True}) + request_client = FakeRequestClient(lambda **kwargs: {"ok": "fresh-request-client"}) + factory = OpenAIFactory([replacement_shared, request_client]) + monkeypatch.setattr(run_agent, "OpenAI", factory) + + agent = _build_agent(shared_client=stale_shared) + result = agent._interruptible_api_call({"model": agent.model, "messages": []}) + + assert result == {"ok": "fresh-request-client"} + assert agent.client is replacement_shared + assert stale_shared.close_calls >= 1 + assert replacement_shared.close_calls == 0 + assert len(factory.calls) == 2 + + +def test_concurrent_requests_do_not_break_each_other_when_one_client_closes(monkeypatch): + first_started = threading.Event() + first_closed = threading.Event() + + def first_responder(**kwargs): + first_started.set() + first_client.close() + first_closed.set() + raise _connection_error() + + def second_responder(**kwargs): + assert first_started.wait(timeout=2) + assert first_closed.wait(timeout=2) + return {"ok": "second"} + + first_client = FakeRequestClient(first_responder) + second_client = FakeRequestClient(second_responder) + factory = OpenAIFactory([first_client, second_client]) + monkeypatch.setattr(run_agent, "OpenAI", factory) + + agent = _build_agent() + results = {} + + def run_call(name): + try: + results[name] = agent._interruptible_api_call({"model": agent.model, "messages": []}) + except Exception as exc: # noqa: BLE001 - asserting exact type below + results[name] = exc + + thread_one = threading.Thread(target=run_call, args=("first",), daemon=True) + thread_two = threading.Thread(target=run_call, args=("second",), daemon=True) + thread_one.start() + thread_two.start() + thread_one.join(timeout=5) + thread_two.join(timeout=5) + + assert isinstance(results["first"], APIConnectionError) + assert results["second"] == {"ok": "second"} + assert len(factory.calls) == 2 + + + +def test_streaming_call_recreates_closed_shared_client_before_request(monkeypatch): + chunks = iter([ + SimpleNamespace( + model="gpt-5-codex", + choices=[SimpleNamespace(delta=SimpleNamespace(content="Hello", tool_calls=None), finish_reason=None)], + ), + SimpleNamespace( + model="gpt-5-codex", + choices=[SimpleNamespace(delta=SimpleNamespace(content=" world", tool_calls=None), finish_reason="stop")], + ), + ]) + + stale_shared = FakeSharedClient(lambda **kwargs: (_ for _ in ()).throw(AssertionError("stale shared client used"))) + stale_shared._client.is_closed = True + + replacement_shared = FakeSharedClient(lambda **kwargs: {"replacement": True}) + request_client = FakeRequestClient(lambda **kwargs: chunks) + factory = OpenAIFactory([replacement_shared, request_client]) + monkeypatch.setattr(run_agent, "OpenAI", factory) + + agent = _build_agent(shared_client=stale_shared) + response = agent._streaming_api_call({"model": agent.model, "messages": []}, lambda _delta: None) + + assert response.choices[0].message.content == "Hello world" + assert agent.client is replacement_shared + assert stale_shared.close_calls >= 1 + assert request_client.close_calls >= 1 + assert len(factory.calls) == 2 From 9a177d6f4bb6dfd4206a8c9b4e7ef9054ec05901 Mon Sep 17 00:00:00 2001 From: halfprice06 <12887250+halfprice06@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:01:02 -0700 Subject: [PATCH 22/41] fix(discord): preserve native document and video attachment support Salvaged from PR #1115 onto current main by reusing the shared Discord file-attachment helper for local video and document sends, including file_name support for documents and regression coverage. --- gateway/platforms/discord.py | 38 +++++++++++++++++++- tests/gateway/test_send_image_file.py | 51 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index eaf457fc..aebae49b 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -669,6 +669,7 @@ class DiscordAdapter(BasePlatformAdapter): chat_id: str, file_path: str, caption: Optional[str] = None, + file_name: Optional[str] = None, ) -> SendResult: """Send a local file as a Discord attachment.""" if not self._client: @@ -680,7 +681,7 @@ class DiscordAdapter(BasePlatformAdapter): if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") - filename = os.path.basename(file_path) + filename = file_name or os.path.basename(file_path) with open(file_path, "rb") as fh: file = discord.File(fh, filename=filename) msg = await channel.send(content=caption if caption else None, file=file) @@ -1141,6 +1142,41 @@ class DiscordAdapter(BasePlatformAdapter): exc_info=True, ) return await super().send_image(chat_id, image_url, caption, reply_to) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a local video file natively as a Discord attachment.""" + try: + return await self._send_file_attachment(chat_id, video_path, caption) + except FileNotFoundError: + return SendResult(success=False, error=f"Video file not found: {video_path}") + except Exception as e: # pragma: no cover - defensive logging + logger.error("[%s] Failed to send local video, falling back to base adapter: %s", self.name, e, exc_info=True) + return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an arbitrary file natively as a Discord attachment.""" + try: + return await self._send_file_attachment(chat_id, file_path, caption, file_name=file_name) + except FileNotFoundError: + return SendResult(success=False, error=f"File not found: {file_path}") + except Exception as e: # pragma: no cover - defensive logging + logger.error("[%s] Failed to send document, falling back to base adapter: %s", self.name, e, exc_info=True) + return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata) async def send_typing(self, chat_id: str, metadata=None) -> None: """Send typing indicator.""" diff --git a/tests/gateway/test_send_image_file.py b/tests/gateway/test_send_image_file.py index bf243734..847ede90 100644 --- a/tests/gateway/test_send_image_file.py +++ b/tests/gateway/test_send_image_file.py @@ -199,6 +199,57 @@ class TestDiscordSendImageFile: assert result.message_id == "99" mock_channel.send.assert_awaited_once() + def test_send_document_uploads_file_attachment(self, adapter, tmp_path): + """send_document should upload a native Discord attachment.""" + pdf = tmp_path / "sample.pdf" + pdf.write_bytes(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + + mock_channel = MagicMock() + mock_msg = MagicMock() + mock_msg.id = 100 + mock_channel.send = AsyncMock(return_value=mock_msg) + adapter._client.get_channel = MagicMock(return_value=mock_channel) + + with patch.object(discord_mod_ref, "File", MagicMock()) as file_cls: + result = _run( + adapter.send_document( + chat_id="67890", + file_path=str(pdf), + file_name="renamed.pdf", + metadata={"thread_id": "123"}, + ) + ) + + assert result.success + assert result.message_id == "100" + assert "file" in mock_channel.send.call_args.kwargs + assert file_cls.call_args.kwargs["filename"] == "renamed.pdf" + + def test_send_video_uploads_file_attachment(self, adapter, tmp_path): + """send_video should upload a native Discord attachment.""" + video = tmp_path / "clip.mp4" + video.write_bytes(b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 50) + + mock_channel = MagicMock() + mock_msg = MagicMock() + mock_msg.id = 101 + mock_channel.send = AsyncMock(return_value=mock_msg) + adapter._client.get_channel = MagicMock(return_value=mock_channel) + + with patch.object(discord_mod_ref, "File", MagicMock()) as file_cls: + result = _run( + adapter.send_video( + chat_id="67890", + video_path=str(video), + metadata={"thread_id": "123"}, + ) + ) + + assert result.success + assert result.message_id == "101" + assert "file" in mock_channel.send.call_args.kwargs + assert file_cls.call_args.kwargs["filename"] == "clip.mp4" + def test_returns_error_when_file_missing(self, adapter): result = _run( adapter.send_image_file(chat_id="67890", image_path="/nonexistent.png") From 6f852835535084a189f0023c67aa0507bb03dfbc Mon Sep 17 00:00:00 2001 From: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:01:14 +0300 Subject: [PATCH 23/41] fix: use json.dumps instead of str() for Codex Responses API arguments When the Responses API returns tool call arguments as a dict, str(dict) produces Python repr with single quotes (e.g. {'key': 'val'}) which is invalid JSON. Downstream json.loads() fails silently and the tool gets called with empty arguments, losing all parameters. Affects both function_call and custom_tool_call item types in _normalize_codex_response(). --- run_agent.py | 4 ++-- tests/test_run_agent.py | 53 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index 871afdd6..5b1501c2 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2407,7 +2407,7 @@ class AIAgent: fn_name = getattr(item, "name", "") or "" arguments = getattr(item, "arguments", "{}") if not isinstance(arguments, str): - arguments = str(arguments) + arguments = json.dumps(arguments, ensure_ascii=False) raw_call_id = getattr(item, "call_id", None) raw_item_id = getattr(item, "id", None) embedded_call_id, _ = self._split_responses_tool_id(raw_item_id) @@ -2428,7 +2428,7 @@ class AIAgent: fn_name = getattr(item, "name", "") or "" arguments = getattr(item, "input", "{}") if not isinstance(arguments, str): - arguments = str(arguments) + arguments = json.dumps(arguments, ensure_ascii=False) raw_call_id = getattr(item, "call_id", None) raw_item_id = getattr(item, "id", None) embedded_call_id, _ = self._split_responses_tool_id(raw_item_id) diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index c3673eb1..9dfbcc2f 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -2533,3 +2533,56 @@ class TestVprintForceOnErrors: agent._vprint("debug") agent._vprint("error", force=True) assert len(printed) == 2 + + +class TestNormalizeCodexDictArguments: + """_normalize_codex_response must produce valid JSON strings for tool + call arguments, even when the Responses API returns them as dicts.""" + + def _make_codex_response(self, item_type, arguments, item_status="completed"): + """Build a minimal Responses API response with a single tool call.""" + item = SimpleNamespace( + type=item_type, + status=item_status, + ) + if item_type == "function_call": + item.name = "web_search" + item.arguments = arguments + item.call_id = "call_abc123" + item.id = "fc_abc123" + elif item_type == "custom_tool_call": + item.name = "web_search" + item.input = arguments + item.call_id = "call_abc123" + item.id = "fc_abc123" + return SimpleNamespace( + output=[item], + status="completed", + ) + + def test_function_call_dict_arguments_produce_valid_json(self, agent): + """dict arguments from function_call must be serialised with + json.dumps, not str(), so downstream json.loads() succeeds.""" + args_dict = {"query": "weather in NYC", "units": "celsius"} + response = self._make_codex_response("function_call", args_dict) + msg, _ = agent._normalize_codex_response(response) + tc = msg.tool_calls[0] + parsed = json.loads(tc.function.arguments) + assert parsed == args_dict + + def test_custom_tool_call_dict_arguments_produce_valid_json(self, agent): + """dict arguments from custom_tool_call must also use json.dumps.""" + args_dict = {"path": "/tmp/test.txt", "content": "hello"} + response = self._make_codex_response("custom_tool_call", args_dict) + msg, _ = agent._normalize_codex_response(response) + tc = msg.tool_calls[0] + parsed = json.loads(tc.function.arguments) + assert parsed == args_dict + + def test_string_arguments_unchanged(self, agent): + """String arguments must pass through without modification.""" + args_str = '{"query": "test"}' + response = self._make_codex_response("function_call", args_str) + msg, _ = agent._normalize_codex_response(response) + tc = msg.tool_calls[0] + assert tc.function.arguments == args_str From e6417cb7bc9f74c4249d60dc083470fed27604ea Mon Sep 17 00:00:00 2001 From: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:37:02 +0300 Subject: [PATCH 24/41] fix: escape parens and braces in fork bomb regex pattern The fork bomb regex used `()` (empty capture group) and unescaped `{}` instead of literal `\(\)` and `\{\}`. This meant the classic fork bomb `:(){ :|:& };:` was never detected. Also added `\s*` between `:` and `&` and between `;` and trailing `:` to catch whitespace variants. --- tests/tools/test_approval.py | 17 +++++++++++++++++ tools/approval.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index b95e865e..1aa4e0b7 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -413,3 +413,20 @@ class TestViewFullCommand: # After first 'v', is_truncated becomes False, so second 'v' -> deny assert result == "deny" + +class TestForkBombDetection: + """The fork bomb regex must match the classic :(){ :|:& };: pattern.""" + + def test_classic_fork_bomb(self): + dangerous, key, desc = detect_dangerous_command(":(){ :|:& };:") + assert dangerous is True, "classic fork bomb not detected" + assert "fork bomb" in desc.lower() + + def test_fork_bomb_with_spaces(self): + dangerous, key, desc = detect_dangerous_command(":() { : | :& } ; :") + assert dangerous is True, "fork bomb with extra spaces not detected" + + def test_colon_in_safe_command_not_flagged(self): + dangerous, key, desc = detect_dangerous_command("echo hello:world") + assert dangerous is False + diff --git a/tools/approval.py b/tools/approval.py index 83980893..ad13e8ea 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -38,7 +38,7 @@ DANGEROUS_PATTERNS = [ (r'\bsystemctl\s+(stop|disable|mask)\b', "stop/disable system service"), (r'\bkill\s+-9\s+-1\b', "kill all processes"), (r'\bpkill\s+-9\b', "force kill processes"), - (r':()\s*{\s*:\s*\|\s*:&\s*}\s*;:', "fork bomb"), + (r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:', "fork bomb"), (r'\b(bash|sh|zsh)\s+-c\s+', "shell command via -c flag"), (r'\b(python[23]?|perl|ruby|node)\s+-[ec]\s+', "script execution via -e/-c flag"), (r'\b(curl|wget)\b.*\|\s*(ba)?sh\b', "pipe remote content to shell"), From 4a93cfd8891c79c07c8ac7b88ae3b1cdd47cea6f Mon Sep 17 00:00:00 2001 From: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:39:46 +0300 Subject: [PATCH 25/41] fix: use description as pattern_key to prevent approval collisions pattern_key was derived by splitting the regex on \b and taking [1], so patterns starting with the same word (e.g. find -exec rm and find -delete) produced the same key "find". Approving one silently approved the other. Using the unique description string as the key eliminates all collisions. --- tests/tools/test_approval.py | 27 +++++++++++++++++++++++++++ tools/approval.py | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index b95e865e..a36b2e1f 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -342,6 +342,33 @@ class TestFindExecFullPathRm: assert key is None +class TestPatternKeyUniqueness: + """Bug: pattern_key is derived by splitting on \\b and taking [1], so + patterns starting with the same word (e.g. find -exec rm and find -delete) + produce the same key. Approving one silently approves the other.""" + + def test_find_exec_rm_and_find_delete_have_different_keys(self): + _, key_exec, _ = detect_dangerous_command("find . -exec rm {} \\;") + _, key_delete, _ = detect_dangerous_command("find . -name '*.tmp' -delete") + assert key_exec != key_delete, ( + f"find -exec rm and find -delete share key {key_exec!r} — " + "approving one silently approves the other" + ) + + def test_approving_find_exec_does_not_approve_find_delete(self): + """Session approval for find -exec rm must not carry over to find -delete.""" + _, key_exec, _ = detect_dangerous_command("find . -exec rm {} \\;") + _, key_delete, _ = detect_dangerous_command("find . -name '*.tmp' -delete") + session = "test_find_collision" + clear_session(session) + approve_session(session, key_exec) + assert is_approved(session, key_exec) is True + assert is_approved(session, key_delete) is False, ( + "approving find -exec rm should not auto-approve find -delete" + ) + clear_session(session) + + class TestViewFullCommand: """Tests for the 'view full command' option in prompt_dangerous_approval.""" diff --git a/tools/approval.py b/tools/approval.py index 83980893..21baedbd 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -63,7 +63,7 @@ def detect_dangerous_command(command: str) -> tuple: command_lower = command.lower() for pattern, description in DANGEROUS_PATTERNS: if re.search(pattern, command_lower, re.IGNORECASE | re.DOTALL): - pattern_key = pattern.split(r'\b')[1] if r'\b' in pattern else pattern[:20] + pattern_key = description return (True, pattern_key, description) return (False, None, None) From c36136084a86a37cf6abee7ffe98301d3d780d03 Mon Sep 17 00:00:00 2001 From: teyrebaz33 Date: Sat, 14 Mar 2026 22:09:53 -0700 Subject: [PATCH 26/41] fix(gateway): honor stt.enabled false for voice transcription - bridge stt.enabled from config.yaml into gateway runtime config - preserve the flag in GatewayConfig serialization - skip gateway voice transcription when STT is disabled - add regression tests for config loading and disabled transcription flow --- gateway/config.py | 26 ++++++++++++++++ gateway/run.py | 8 ++++- tests/gateway/test_stt_config.py | 53 ++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_stt_config.py diff --git a/gateway/config.py b/gateway/config.py index 47c739e9..2b187c52 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -21,6 +21,17 @@ from hermes_cli.config import get_hermes_home logger = logging.getLogger(__name__) +def _coerce_bool(value: Any, default: bool = True) -> bool: + """Coerce bool-ish config values, preserving a caller-provided default.""" + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "1", "yes", "on") + return bool(value) + + class Platform(Enum): """Supported messaging platforms.""" LOCAL = "local" @@ -160,6 +171,9 @@ class GatewayConfig: # Delivery settings always_log_local: bool = True # Always save cron outputs to local files + + # STT settings + stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages def get_connected_platforms(self) -> List[Platform]: """Return list of platforms that are enabled and configured.""" @@ -224,6 +238,7 @@ class GatewayConfig: "quick_commands": self.quick_commands, "sessions_dir": str(self.sessions_dir), "always_log_local": self.always_log_local, + "stt_enabled": self.stt_enabled, } @classmethod @@ -260,6 +275,10 @@ class GatewayConfig: if not isinstance(quick_commands, dict): quick_commands = {} + stt_enabled = data.get("stt_enabled") + if stt_enabled is None: + stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None + return cls( platforms=platforms, default_reset_policy=default_policy, @@ -269,6 +288,7 @@ class GatewayConfig: quick_commands=quick_commands, sessions_dir=sessions_dir, always_log_local=data.get("always_log_local", True), + stt_enabled=_coerce_bool(stt_enabled, True), ) @@ -318,6 +338,12 @@ def load_gateway_config() -> GatewayConfig: else: logger.warning("Ignoring invalid quick_commands in config.yaml (expected mapping, got %s)", type(qc).__name__) + # Bridge STT enable/disable from config.yaml into gateway runtime. + # This keeps the gateway aligned with the user-facing config source. + stt_cfg = yaml_cfg.get("stt") + if isinstance(stt_cfg, dict) and "enabled" in stt_cfg: + config.stt_enabled = _coerce_bool(stt_cfg.get("enabled"), True) + # Bridge discord settings from config.yaml to env vars # (env vars take precedence — only set if not already defined) discord_cfg = yaml_cfg.get("discord", {}) diff --git a/gateway/run.py b/gateway/run.py index e973852b..f955573c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3512,7 +3512,7 @@ class GatewayRunner: audio_paths: List[str], ) -> str: """ - Auto-transcribe user voice/audio messages using OpenAI Whisper API + Auto-transcribe user voice/audio messages using the configured STT provider and prepend the transcript to the message text. Args: @@ -3522,6 +3522,12 @@ class GatewayRunner: Returns: The enriched message string with transcriptions prepended. """ + if not getattr(self.config, "stt_enabled", True): + disabled_note = "[The user sent voice message(s), but transcription is disabled in config.]" + if user_text: + return f"{disabled_note}\n\n{user_text}" + return disabled_note + from tools.transcription_tools import transcribe_audio, get_stt_model_from_config import asyncio diff --git a/tests/gateway/test_stt_config.py b/tests/gateway/test_stt_config.py new file mode 100644 index 00000000..d5a9fc55 --- /dev/null +++ b/tests/gateway/test_stt_config.py @@ -0,0 +1,53 @@ +"""Gateway STT config tests — honor stt.enabled: false from config.yaml.""" + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +import yaml + +from gateway.config import GatewayConfig, load_gateway_config + + +def test_gateway_config_stt_disabled_from_dict_nested(): + config = GatewayConfig.from_dict({"stt": {"enabled": False}}) + assert config.stt_enabled is False + + +def test_load_gateway_config_bridges_stt_enabled_from_config_yaml(tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + yaml.dump({"stt": {"enabled": False}}), + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + config = load_gateway_config() + + assert config.stt_enabled is False + + +@pytest.mark.asyncio +async def test_enrich_message_with_transcription_skips_when_stt_disabled(): + from gateway.run import GatewayRunner + + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = GatewayConfig(stt_enabled=False) + + with patch( + "tools.transcription_tools.transcribe_audio", + side_effect=AssertionError("transcribe_audio should not be called when STT is disabled"), + ), patch( + "tools.transcription_tools.get_stt_model_from_config", + return_value=None, + ): + result = await runner._enrich_message_with_transcription( + "caption", + ["/tmp/voice.ogg"], + ) + + assert "transcription is disabled" in result.lower() + assert "caption" in result From f8ceadbad0c0aaaacbda59ed8293fc806b867f84 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:09:59 -0700 Subject: [PATCH 27/41] fix: propagate STT disable through shared transcription config - add stt.enabled to the default user config - make transcription_tools respect the disabled flag globally - surface disabled state cleanly in voice mode diagnostics - add regression coverage for disabled STT provider selection --- hermes_cli/config.py | 5 +++-- tests/tools/test_transcription.py | 16 ++++++++++++++++ tools/transcription_tools.py | 22 ++++++++++++++++++++++ tools/voice_mode.py | 9 ++++++--- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index bdde858d..44755b19 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -205,7 +205,8 @@ DEFAULT_CONFIG = { }, "stt": { - "provider": "local", # "local" (free, faster-whisper) | "openai" (Whisper API) + "enabled": True, + "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) "local": { "model": "base", # tiny, base, small, medium, large-v3 }, @@ -284,7 +285,7 @@ DEFAULT_CONFIG = { }, # Config schema version - bump this when adding new required fields - "_config_version": 7, + "_config_version": 8, } # ============================================================================= diff --git a/tests/tools/test_transcription.py b/tests/tools/test_transcription.py index fe3b24a8..c8daface 100644 --- a/tests/tools/test_transcription.py +++ b/tests/tools/test_transcription.py @@ -59,6 +59,10 @@ class TestGetProvider: from tools.transcription_tools import _get_provider assert _get_provider({}) == "local" + def test_disabled_config_returns_none(self): + from tools.transcription_tools import _get_provider + assert _get_provider({"enabled": False, "provider": "openai"}) == "none" + # --------------------------------------------------------------------------- # File validation @@ -217,6 +221,18 @@ class TestTranscribeAudio: assert result["success"] is False assert "No STT provider" in result["error"] + def test_disabled_config_returns_disabled_error(self, tmp_path): + audio_file = tmp_path / "test.ogg" + audio_file.write_bytes(b"fake audio") + + with patch("tools.transcription_tools._load_stt_config", return_value={"enabled": False}), \ + patch("tools.transcription_tools._get_provider", return_value="none"): + from tools.transcription_tools import transcribe_audio + result = transcribe_audio(str(audio_file)) + + assert result["success"] is False + assert "disabled" in result["error"].lower() + def test_invalid_file_returns_error(self): from tools.transcription_tools import transcribe_audio result = transcribe_audio("/nonexistent/file.ogg") diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index a20ba413..684d0a8d 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -93,6 +93,18 @@ def _load_stt_config() -> dict: return {} +def is_stt_enabled(stt_config: Optional[dict] = None) -> bool: + """Return whether STT is enabled in config.""" + if stt_config is None: + stt_config = _load_stt_config() + enabled = stt_config.get("enabled", True) + if isinstance(enabled, str): + return enabled.strip().lower() in ("true", "1", "yes", "on") + if enabled is None: + return True + return bool(enabled) + + def _get_provider(stt_config: dict) -> str: """Determine which STT provider to use. @@ -101,6 +113,9 @@ def _get_provider(stt_config: dict) -> str: 2. Auto-detect: local > groq (free) > openai (paid) 3. Disabled (returns "none") """ + if not is_stt_enabled(stt_config): + return "none" + provider = stt_config.get("provider", DEFAULT_PROVIDER) if provider == "local": @@ -334,6 +349,13 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A # Load config and determine provider stt_config = _load_stt_config() + if not is_stt_enabled(stt_config): + return { + "success": False, + "transcript": "", + "error": "STT is disabled in config.yaml (stt.enabled: false).", + } + provider = _get_provider(stt_config) if provider == "local": diff --git a/tools/voice_mode.py b/tools/voice_mode.py index a2c70ac1..78358489 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -703,10 +703,11 @@ def check_voice_requirements() -> Dict[str, Any]: ``missing_packages``, and ``details``. """ # Determine STT provider availability - from tools.transcription_tools import _get_provider, _load_stt_config, _HAS_FASTER_WHISPER + from tools.transcription_tools import _get_provider, _load_stt_config, is_stt_enabled, _HAS_FASTER_WHISPER stt_config = _load_stt_config() + stt_enabled = is_stt_enabled(stt_config) stt_provider = _get_provider(stt_config) - stt_available = stt_provider != "none" + stt_available = stt_enabled and stt_provider != "none" missing: List[str] = [] has_audio = _audio_available() @@ -725,7 +726,9 @@ def check_voice_requirements() -> Dict[str, Any]: else: details_parts.append("Audio capture: MISSING (pip install sounddevice numpy)") - if stt_provider == "local": + if not stt_enabled: + details_parts.append("STT provider: DISABLED in config (stt.enabled: false)") + elif stt_provider == "local": details_parts.append("STT provider: OK (local faster-whisper)") elif stt_provider == "groq": details_parts.append("STT provider: OK (Groq)") From d5b64ebdb32e96848b25d337a76380123b258c60 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:10:39 -0700 Subject: [PATCH 28/41] fix: preserve legacy approval keys after pattern key migration --- tests/tools/test_approval.py | 16 ++++++++++++++++ tools/approval.py | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index a36b2e1f..0cb4c357 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -2,12 +2,14 @@ from unittest.mock import patch as mock_patch +import tools.approval as approval_module from tools.approval import ( approve_session, clear_session, detect_dangerous_command, has_pending, is_approved, + load_permanent, pop_pending, prompt_dangerous_approval, submit_pending, @@ -368,6 +370,20 @@ class TestPatternKeyUniqueness: ) clear_session(session) + def test_legacy_find_key_still_approves_find_exec(self): + """Old allowlist entry 'find' should keep approving the matching command.""" + _, key_exec, _ = detect_dangerous_command("find . -exec rm {} \\;") + with mock_patch.object(approval_module, "_permanent_approved", set()): + load_permanent({"find"}) + assert is_approved("legacy-find", key_exec) is True + + def test_legacy_find_key_still_approves_find_delete(self): + """Old colliding allowlist entry 'find' should remain backwards compatible.""" + _, key_delete, _ = detect_dangerous_command("find . -name '*.tmp' -delete") + with mock_patch.object(approval_module, "_permanent_approved", set()): + load_permanent({"find"}) + assert is_approved("legacy-find", key_delete) is True + class TestViewFullCommand: """Tests for the 'view full command' option in prompt_dangerous_approval.""" diff --git a/tools/approval.py b/tools/approval.py index 21baedbd..7c376f0e 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -50,6 +50,29 @@ DANGEROUS_PATTERNS = [ ] +def _legacy_pattern_key(pattern: str) -> str: + """Reproduce the old regex-derived approval key for backwards compatibility.""" + return pattern.split(r'\b')[1] if r'\b' in pattern else pattern[:20] + + +_PATTERN_KEY_ALIASES: dict[str, set[str]] = {} +for _pattern, _description in DANGEROUS_PATTERNS: + _legacy_key = _legacy_pattern_key(_pattern) + _canonical_key = _description + _PATTERN_KEY_ALIASES.setdefault(_canonical_key, set()).update({_canonical_key, _legacy_key}) + _PATTERN_KEY_ALIASES.setdefault(_legacy_key, set()).update({_legacy_key, _canonical_key}) + + +def _approval_key_aliases(pattern_key: str) -> set[str]: + """Return all approval keys that should match this pattern. + + New approvals use the human-readable description string, but older + command_allowlist entries and session approvals may still contain the + historical regex-derived key. + """ + return _PATTERN_KEY_ALIASES.get(pattern_key, {pattern_key}) + + # ========================================================================= # Detection # ========================================================================= @@ -103,11 +126,17 @@ def approve_session(session_key: str, pattern_key: str): def is_approved(session_key: str, pattern_key: str) -> bool: - """Check if a pattern is approved (session-scoped or permanent).""" + """Check if a pattern is approved (session-scoped or permanent). + + Accept both the current canonical key and the legacy regex-derived key so + existing command_allowlist entries continue to work after key migrations. + """ + aliases = _approval_key_aliases(pattern_key) with _lock: - if pattern_key in _permanent_approved: + if any(alias in _permanent_approved for alias in aliases): return True - return pattern_key in _session_approved.get(session_key, set()) + session_approvals = _session_approved.get(session_key, set()) + return any(alias in session_approvals for alias in aliases) def approve_permanent(pattern_key: str): From f4e8772de4326db63e0f3d10a511ad216ddb40e3 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:11:21 -0700 Subject: [PATCH 29/41] fix: require oauth creds for native Anthropic --- agent/anthropic_adapter.py | 44 +++++++++--------- run_agent.py | 42 +---------------- tests/test_anthropic_adapter.py | 15 +++++-- tests/test_run_agent.py | 80 --------------------------------- 4 files changed, 35 insertions(+), 146 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 1b7bbe46..ab284268 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -102,31 +102,15 @@ def build_anthropic_client(api_key: str, base_url: str = None): def read_claude_code_credentials() -> Optional[Dict[str, Any]]: - """Read credentials from Claude Code's config files. + """Read refreshable Claude Code OAuth credentials from ~/.claude/.credentials.json. - Checks two locations (in order): - 1. ~/.claude.json — top-level primaryApiKey (native binary, v2.x) - 2. ~/.claude/.credentials.json — claudeAiOauth block (npm/legacy installs) + This intentionally excludes ~/.claude.json primaryApiKey. Opencode's + subscription flow is OAuth/setup-token based with refreshable credentials, + and native direct Anthropic provider usage should follow that path rather + than auto-detecting Claude's first-party managed key. Returns dict with {accessToken, refreshToken?, expiresAt?} or None. """ - # 1. Native binary (v2.x): ~/.claude.json with top-level primaryApiKey - claude_json = Path.home() / ".claude.json" - if claude_json.exists(): - try: - data = json.loads(claude_json.read_text(encoding="utf-8")) - primary_key = data.get("primaryApiKey", "") - if primary_key: - return { - "accessToken": primary_key, - "refreshToken": "", - "expiresAt": 0, # Managed keys don't have a user-visible expiry - "source": "claude_json_primary_api_key", - } - except (json.JSONDecodeError, OSError, IOError) as e: - logger.debug("Failed to read ~/.claude.json: %s", e) - - # 2. Legacy/npm installs: ~/.claude/.credentials.json cred_path = Path.home() / ".claude" / ".credentials.json" if cred_path.exists(): try: @@ -147,6 +131,20 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: return None +def read_claude_managed_key() -> Optional[str]: + """Read Claude's native managed key from ~/.claude.json for diagnostics only.""" + claude_json = Path.home() / ".claude.json" + if claude_json.exists(): + try: + data = json.loads(claude_json.read_text(encoding="utf-8")) + primary_key = data.get("primaryApiKey", "") + if isinstance(primary_key, str) and primary_key.strip(): + return primary_key.strip() + except (json.JSONDecodeError, OSError, IOError) as e: + logger.debug("Failed to read ~/.claude.json: %s", e) + return None + + def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool: """Check if Claude Code credentials have a non-expired access token.""" import time @@ -293,6 +291,10 @@ def get_anthropic_token_source(token: Optional[str] = None) -> str: if creds and creds.get("accessToken") == token: return str(creds.get("source") or "claude_code_credentials") + managed_key = read_claude_managed_key() + if managed_key and managed_key == token: + return "claude_json_primary_api_key" + api_key = os.getenv("ANTHROPIC_API_KEY", "").strip() if api_key and api_key == token: return "anthropic_api_key_env" diff --git a/run_agent.py b/run_agent.py index 1264de0f..419b5692 100644 --- a/run_agent.py +++ b/run_agent.py @@ -511,14 +511,9 @@ class AIAgent: self._anthropic_client = None if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import ( - build_anthropic_client, - resolve_anthropic_token, - get_anthropic_token_source, - ) + from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token effective_key = api_key or resolve_anthropic_token() or "" self._anthropic_api_key = effective_key - self._anthropic_auth_source = get_anthropic_token_source(effective_key) self._anthropic_base_url = base_url self._anthropic_client = build_anthropic_client(effective_key, base_url) # No OpenAI client needed for Anthropic mode @@ -2648,27 +2643,6 @@ class AIAgent: return False self._anthropic_api_key = new_token - try: - from agent.anthropic_adapter import get_anthropic_token_source - self._anthropic_auth_source = get_anthropic_token_source(new_token) - except Exception: - pass - return True - - def _try_fallback_anthropic_managed_key_model(self) -> bool: - if self.api_mode != "anthropic_messages": - return False - if getattr(self, "_anthropic_auth_source", "") != "claude_json_primary_api_key": - return False - current_model = str(getattr(self, "model", "") or "").lower() - if not any(name in current_model for name in ("sonnet", "opus")): - return False - - fallback_model = "claude-haiku-4-5-20251001" - if current_model == fallback_model: - return False - - self.model = fallback_model return True def _anthropic_messages_create(self, api_kwargs: dict): @@ -4517,7 +4491,6 @@ class AIAgent: max_compression_attempts = 3 codex_auth_retry_attempted = False anthropic_auth_retry_attempted = False - anthropic_managed_key_model_fallback_attempted = False nous_auth_retry_attempted = False restart_with_compressed_messages = False restart_with_length_continuation = False @@ -4879,19 +4852,6 @@ class AIAgent: if self._try_refresh_nous_client_credentials(force=True): print(f"{self.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...") continue - if ( - self.api_mode == "anthropic_messages" - and status_code == 500 - and not anthropic_managed_key_model_fallback_attempted - ): - anthropic_managed_key_model_fallback_attempted = True - if self._try_fallback_anthropic_managed_key_model(): - print( - f"{self.log_prefix}⚠️ Claude native managed key hit Anthropic 500 on Sonnet/Opus. " - f"Falling back to claude-haiku-4-5-20251001 and retrying..." - ) - continue - if ( self.api_mode == "anthropic_messages" and status_code == 401 diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 1b580078..e05996ba 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -100,15 +100,13 @@ class TestReadClaudeCodeCredentials: assert creds["refreshToken"] == "sk-ant-oat01-refresh" assert creds["source"] == "claude_code_credentials_file" - def test_reads_primary_api_key_with_source(self, tmp_path, monkeypatch): + def test_ignores_primary_api_key_for_native_anthropic_resolution(self, tmp_path, monkeypatch): claude_json = tmp_path / ".claude.json" claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"})) monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) creds = read_claude_code_credentials() - assert creds is not None - assert creds["accessToken"] == "sk-ant-api03-primary" - assert creds["source"] == "claude_json_primary_api_key" + assert creds is None def test_returns_none_for_missing_file(self, tmp_path, monkeypatch): monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) @@ -160,6 +158,15 @@ class TestResolveAnthropicToken: assert get_anthropic_token_source("sk-ant-api03-primary") == "claude_json_primary_api_key" + def test_does_not_resolve_primary_api_key_as_native_anthropic_token(self, monkeypatch, tmp_path): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + (tmp_path / ".claude.json").write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"})) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + assert resolve_anthropic_token() is None + def test_falls_back_to_api_key_when_no_oauth_sources_exist(self, monkeypatch, tmp_path): monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey") monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 23fd68b0..c3673eb1 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1089,46 +1089,6 @@ class TestRunConversation: assert result["completed"] is True assert result["final_response"] == "Recovered after remint" - def test_anthropic_managed_key_500_falls_back_to_haiku_and_retries(self, agent): - self._setup_agent(agent) - agent.provider = "anthropic" - agent.api_mode = "anthropic_messages" - agent.model = "claude-sonnet-4-6" - agent._anthropic_auth_source = "claude_json_primary_api_key" - agent._anthropic_api_key = "sk-ant-api03-primary" - - calls = {"api": 0} - - class _ServerError(RuntimeError): - def __init__(self): - super().__init__("Error code: 500 - internal server error") - self.status_code = 500 - - anthropic_response = SimpleNamespace( - content=[SimpleNamespace(type="text", text="Recovered with haiku")], - stop_reason="end_turn", - usage=None, - ) - - def _fake_api_call(api_kwargs): - calls["api"] += 1 - if calls["api"] == 1: - raise _ServerError() - return anthropic_response - - with ( - patch.object(agent, "_persist_session"), - patch.object(agent, "_save_trajectory"), - patch.object(agent, "_cleanup_task_resources"), - patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call), - ): - result = agent.run_conversation("hello") - - assert calls["api"] == 2 - assert agent.model == "claude-haiku-4-5-20251001" - assert result["completed"] is True - assert result["final_response"] == "Recovered with haiku" - def test_context_compression_triggered(self, agent): """When compressor says should_compress, compression runs.""" self._setup_agent(agent) @@ -2185,46 +2145,6 @@ class TestAnthropicCredentialRefresh: old_client.close.assert_not_called() rebuild.assert_not_called() - def test_try_fallback_anthropic_managed_key_model_switches_sonnet_to_haiku(self): - with ( - patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), - ): - agent = AIAgent( - api_key="sk-ant-api03-primary", - api_mode="anthropic_messages", - quiet_mode=True, - skip_context_files=True, - skip_memory=True, - ) - - agent.model = "claude-sonnet-4-6" - agent._anthropic_auth_source = "claude_json_primary_api_key" - - assert agent._try_fallback_anthropic_managed_key_model() is True - assert agent.model == "claude-haiku-4-5-20251001" - - def test_try_fallback_anthropic_managed_key_model_ignores_normal_api_keys(self): - with ( - patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), - ): - agent = AIAgent( - api_key="sk-ant-api03-real-api-key", - api_mode="anthropic_messages", - quiet_mode=True, - skip_context_files=True, - skip_memory=True, - ) - - agent.model = "claude-sonnet-4-6" - agent._anthropic_auth_source = "anthropic_api_key_env" - - assert agent._try_fallback_anthropic_managed_key_model() is False - assert agent.model == "claude-sonnet-4-6" - def test_anthropic_messages_create_preflights_refresh(self): with ( patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), From 4524cddc72ccf248505d30888b594e1d19804cac Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:11:34 -0700 Subject: [PATCH 30/41] fix: persist google oauth pkce for headless auth Store the pending OAuth state and code verifier between --auth-url and --auth-code so the manual headless flow can reuse Flow.fetch_token() without disabling PKCE. --- skills/productivity/google-workspace/SKILL.md | 5 +- .../google-workspace/scripts/setup.py | 80 +++++-- tests/skills/test_google_oauth_setup.py | 203 ++++++++++++++++++ 3 files changed, 274 insertions(+), 14 deletions(-) create mode 100644 tests/skills/test_google_oauth_setup.py diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index 77374d2e..00d91de9 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -102,7 +102,9 @@ This prints a URL. **Send the URL to the user** and tell them: ### Step 4: Exchange the code The user will paste back either a URL like `http://localhost:1/?code=4/0A...&scope=...` -or just the code string. Either works: +or just the code string. Either works. The `--auth-url` step stores a temporary +pending OAuth session locally so `--auth-code` can complete the PKCE exchange +later, even on headless systems: ```bash $GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED" @@ -119,6 +121,7 @@ Should print `AUTHENTICATED`. Setup is complete — token refreshes automaticall ### Notes - Token is stored at `~/.hermes/google_token.json` and auto-refreshes. +- Pending OAuth session state/verifier are stored temporarily at `~/.hermes/google_oauth_pending.json` until exchange completes. - To revoke: `$GSETUP --revoke` ## Usage diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index 44a5a097..14f9c6bf 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -31,6 +31,7 @@ from pathlib import Path HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) TOKEN_PATH = HERMES_HOME / "google_token.json" CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json" +PENDING_AUTH_PATH = HERMES_HOME / "google_oauth_pending.json" SCOPES = [ "https://www.googleapis.com/auth/gmail.readonly", @@ -141,6 +142,58 @@ def store_client_secret(path: str): print(f"OK: Client secret saved to {CLIENT_SECRET_PATH}") +def _save_pending_auth(*, state: str, code_verifier: str): + """Persist the OAuth session bits needed for a later token exchange.""" + PENDING_AUTH_PATH.write_text( + json.dumps( + { + "state": state, + "code_verifier": code_verifier, + "redirect_uri": REDIRECT_URI, + }, + indent=2, + ) + ) + + +def _load_pending_auth() -> dict: + """Load the pending OAuth session created by get_auth_url().""" + if not PENDING_AUTH_PATH.exists(): + print("ERROR: No pending OAuth session found. Run --auth-url first.") + sys.exit(1) + + try: + data = json.loads(PENDING_AUTH_PATH.read_text()) + except Exception as e: + print(f"ERROR: Could not read pending OAuth session: {e}") + print("Run --auth-url again to start a fresh OAuth session.") + sys.exit(1) + + if not data.get("state") or not data.get("code_verifier"): + print("ERROR: Pending OAuth session is missing PKCE data.") + print("Run --auth-url again to start a fresh OAuth session.") + sys.exit(1) + + return data + + +def _extract_code_and_state(code_or_url: str) -> tuple[str, str | None]: + """Accept either a raw auth code or the full redirect URL pasted by the user.""" + if not code_or_url.startswith("http"): + return code_or_url, None + + from urllib.parse import parse_qs, urlparse + + parsed = urlparse(code_or_url) + params = parse_qs(parsed.query) + if "code" not in params: + print("ERROR: No 'code' parameter found in URL.") + sys.exit(1) + + state = params.get("state", [None])[0] + return params["code"][0], state + + def get_auth_url(): """Print the OAuth authorization URL. User visits this in a browser.""" if not CLIENT_SECRET_PATH.exists(): @@ -154,11 +207,13 @@ def get_auth_url(): str(CLIENT_SECRET_PATH), scopes=SCOPES, redirect_uri=REDIRECT_URI, + autogenerate_code_verifier=True, ) - auth_url, _ = flow.authorization_url( + auth_url, state = flow.authorization_url( access_type="offline", prompt="consent", ) + _save_pending_auth(state=state, code_verifier=flow.code_verifier) # Print just the URL so the agent can extract it cleanly print(auth_url) @@ -169,26 +224,23 @@ def exchange_auth_code(code: str): print("ERROR: No client secret stored. Run --client-secret first.") sys.exit(1) + pending_auth = _load_pending_auth() + code, returned_state = _extract_code_and_state(code) + if returned_state and returned_state != pending_auth["state"]: + print("ERROR: OAuth state mismatch. Run --auth-url again to start a fresh session.") + sys.exit(1) + _ensure_deps() from google_auth_oauthlib.flow import Flow flow = Flow.from_client_secrets_file( str(CLIENT_SECRET_PATH), scopes=SCOPES, - redirect_uri=REDIRECT_URI, + redirect_uri=pending_auth.get("redirect_uri", REDIRECT_URI), + state=pending_auth["state"], + code_verifier=pending_auth["code_verifier"], ) - # The code might come as a full redirect URL or just the code itself - if code.startswith("http"): - # Extract code from redirect URL: http://localhost:1/?code=CODE&scope=... - from urllib.parse import urlparse, parse_qs - parsed = urlparse(code) - params = parse_qs(parsed.query) - if "code" not in params: - print("ERROR: No 'code' parameter found in URL.") - sys.exit(1) - code = params["code"][0] - try: flow.fetch_token(code=code) except Exception as e: @@ -198,6 +250,7 @@ def exchange_auth_code(code: str): creds = flow.credentials TOKEN_PATH.write_text(creds.to_json()) + PENDING_AUTH_PATH.unlink(missing_ok=True) print(f"OK: Authenticated. Token saved to {TOKEN_PATH}") @@ -229,6 +282,7 @@ def revoke(): print(f"Remote revocation failed (token may already be invalid): {e}") TOKEN_PATH.unlink(missing_ok=True) + PENDING_AUTH_PATH.unlink(missing_ok=True) print(f"Deleted {TOKEN_PATH}") diff --git a/tests/skills/test_google_oauth_setup.py b/tests/skills/test_google_oauth_setup.py new file mode 100644 index 00000000..361bb7e2 --- /dev/null +++ b/tests/skills/test_google_oauth_setup.py @@ -0,0 +1,203 @@ +"""Regression tests for Google Workspace OAuth setup. + +These tests cover the headless/manual auth-code flow where the browser step and +code exchange happen in separate process invocations. +""" + +import importlib.util +import json +import sys +import types +from pathlib import Path + +import pytest + + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[2] + / "skills/productivity/google-workspace/scripts/setup.py" +) + + +class FakeCredentials: + def __init__(self, payload=None): + self._payload = payload or { + "token": "access-token", + "refresh_token": "refresh-token", + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": "client-id", + "client_secret": "client-secret", + "scopes": ["scope-a"], + } + + def to_json(self): + return json.dumps(self._payload) + + +class FakeFlow: + created = [] + default_state = "generated-state" + default_verifier = "generated-code-verifier" + credentials_payload = None + fetch_error = None + + def __init__( + self, + client_secrets_file, + scopes, + *, + redirect_uri=None, + state=None, + code_verifier=None, + autogenerate_code_verifier=False, + ): + self.client_secrets_file = client_secrets_file + self.scopes = scopes + self.redirect_uri = redirect_uri + self.state = state + self.code_verifier = code_verifier + self.autogenerate_code_verifier = autogenerate_code_verifier + self.authorization_kwargs = None + self.fetch_token_calls = [] + self.credentials = FakeCredentials(self.credentials_payload) + + if autogenerate_code_verifier and not self.code_verifier: + self.code_verifier = self.default_verifier + if not self.state: + self.state = self.default_state + + @classmethod + def reset(cls): + cls.created = [] + cls.default_state = "generated-state" + cls.default_verifier = "generated-code-verifier" + cls.credentials_payload = None + cls.fetch_error = None + + @classmethod + def from_client_secrets_file(cls, client_secrets_file, scopes, **kwargs): + inst = cls(client_secrets_file, scopes, **kwargs) + cls.created.append(inst) + return inst + + def authorization_url(self, **kwargs): + self.authorization_kwargs = kwargs + return f"https://auth.example/authorize?state={self.state}", self.state + + def fetch_token(self, **kwargs): + self.fetch_token_calls.append(kwargs) + if self.fetch_error: + raise self.fetch_error + + +@pytest.fixture +def setup_module(monkeypatch, tmp_path): + FakeFlow.reset() + + google_auth_module = types.ModuleType("google_auth_oauthlib") + flow_module = types.ModuleType("google_auth_oauthlib.flow") + flow_module.Flow = FakeFlow + google_auth_module.flow = flow_module + monkeypatch.setitem(sys.modules, "google_auth_oauthlib", google_auth_module) + monkeypatch.setitem(sys.modules, "google_auth_oauthlib.flow", flow_module) + + spec = importlib.util.spec_from_file_location("google_workspace_setup_test", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + + monkeypatch.setattr(module, "_ensure_deps", lambda: None) + monkeypatch.setattr(module, "CLIENT_SECRET_PATH", tmp_path / "google_client_secret.json") + monkeypatch.setattr(module, "TOKEN_PATH", tmp_path / "google_token.json") + monkeypatch.setattr(module, "PENDING_AUTH_PATH", tmp_path / "google_oauth_pending.json", raising=False) + + client_secret = { + "installed": { + "client_id": "client-id", + "client_secret": "client-secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + } + } + module.CLIENT_SECRET_PATH.write_text(json.dumps(client_secret)) + return module + + +class TestGetAuthUrl: + def test_persists_state_and_code_verifier_for_later_exchange(self, setup_module, capsys): + setup_module.get_auth_url() + + out = capsys.readouterr().out.strip() + assert out == "https://auth.example/authorize?state=generated-state" + + saved = json.loads(setup_module.PENDING_AUTH_PATH.read_text()) + assert saved["state"] == "generated-state" + assert saved["code_verifier"] == "generated-code-verifier" + + flow = FakeFlow.created[-1] + assert flow.autogenerate_code_verifier is True + assert flow.authorization_kwargs == {"access_type": "offline", "prompt": "consent"} + + +class TestExchangeAuthCode: + def test_reuses_saved_pkce_material_for_plain_code(self, setup_module): + setup_module.PENDING_AUTH_PATH.write_text( + json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) + ) + + setup_module.exchange_auth_code("4/test-auth-code") + + flow = FakeFlow.created[-1] + assert flow.state == "saved-state" + assert flow.code_verifier == "saved-verifier" + assert flow.fetch_token_calls == [{"code": "4/test-auth-code"}] + assert json.loads(setup_module.TOKEN_PATH.read_text())["token"] == "access-token" + assert not setup_module.PENDING_AUTH_PATH.exists() + + def test_extracts_code_from_redirect_url_and_checks_state(self, setup_module): + setup_module.PENDING_AUTH_PATH.write_text( + json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) + ) + + setup_module.exchange_auth_code( + "http://localhost:1/?code=4/extracted-code&state=saved-state&scope=gmail" + ) + + flow = FakeFlow.created[-1] + assert flow.fetch_token_calls == [{"code": "4/extracted-code"}] + + def test_rejects_state_mismatch(self, setup_module, capsys): + setup_module.PENDING_AUTH_PATH.write_text( + json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) + ) + + with pytest.raises(SystemExit): + setup_module.exchange_auth_code( + "http://localhost:1/?code=4/extracted-code&state=wrong-state" + ) + + out = capsys.readouterr().out + assert "state mismatch" in out.lower() + assert not setup_module.TOKEN_PATH.exists() + + def test_requires_pending_auth_session(self, setup_module, capsys): + with pytest.raises(SystemExit): + setup_module.exchange_auth_code("4/test-auth-code") + + out = capsys.readouterr().out + assert "run --auth-url first" in out.lower() + assert not setup_module.TOKEN_PATH.exists() + + def test_keeps_pending_auth_session_when_exchange_fails(self, setup_module, capsys): + setup_module.PENDING_AUTH_PATH.write_text( + json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) + ) + FakeFlow.fetch_error = Exception("invalid_grant: Missing code verifier") + + with pytest.raises(SystemExit): + setup_module.exchange_auth_code("4/test-auth-code") + + out = capsys.readouterr().out + assert "token exchange failed" in out.lower() + assert setup_module.PENDING_AUTH_PATH.exists() + assert not setup_module.TOKEN_PATH.exists() From 28b3764d1e2c015585fe9dca017fed8f3664ebb7 Mon Sep 17 00:00:00 2001 From: Synergy Date: Sat, 14 Mar 2026 22:22:31 -0700 Subject: [PATCH 31/41] fix(cron): support per-job runtime overrides Salvaged from PR #1292 onto current main. Preserve per-job model, provider, and base_url overrides in cron execution, persist them in job records, expose them through the cronjob tool create/update paths, and add regression coverage. Deliberately does not persist per-job api_key values. --- cron/jobs.py | 16 ++++++++++ cron/scheduler.py | 20 +++++++----- tests/cron/test_scheduler.py | 51 +++++++++++++++++++++++++++++++ tests/tools/test_cronjob_tools.py | 43 ++++++++++++++++++++++++++ tools/cronjob_tools.py | 46 ++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 8 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index ca00e08d..9c411f0c 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -292,6 +292,9 @@ def create_job( origin: Optional[Dict[str, Any]] = None, skill: Optional[str] = None, skills: Optional[List[str]] = None, + model: Optional[str] = None, + provider: Optional[str] = None, + base_url: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new cron job. @@ -305,6 +308,9 @@ def create_job( origin: Source info where job was created (for "origin" delivery) skill: Optional legacy single skill name to load before running the prompt skills: Optional ordered list of skills to load before running the prompt + model: Optional per-job model override + provider: Optional per-job provider override + base_url: Optional per-job base URL override Returns: The created job dict @@ -323,6 +329,13 @@ def create_job( now = _hermes_now().isoformat() normalized_skills = _normalize_skill_list(skill, skills) + normalized_model = str(model).strip() if isinstance(model, str) else None + normalized_provider = str(provider).strip() if isinstance(provider, str) else None + normalized_base_url = str(base_url).strip().rstrip("/") if isinstance(base_url, str) else None + normalized_model = normalized_model or None + normalized_provider = normalized_provider or None + normalized_base_url = normalized_base_url or None + label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job" job = { "id": job_id, @@ -330,6 +343,9 @@ def create_job( "prompt": prompt, "skills": normalized_skills, "skill": normalized_skills[0] if normalized_skills else None, + "model": normalized_model, + "provider": normalized_provider, + "base_url": normalized_base_url, "schedule": parsed_schedule, "schedule_display": parsed_schedule.get("display", schedule), "repeat": { diff --git a/cron/scheduler.py b/cron/scheduler.py index 193af7c0..8d75e1a9 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -261,7 +261,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: if delivery_target.get("thread_id") is not None: os.environ["HERMES_CRON_AUTO_DELIVER_THREAD_ID"] = str(delivery_target["thread_id"]) - model = os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6" + model = job.get("model") or os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6" # Load config.yaml for model, reasoning, prefill, toolsets, provider routing _cfg = {} @@ -272,10 +272,11 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: with open(_cfg_path) as _f: _cfg = yaml.safe_load(_f) or {} _model_cfg = _cfg.get("model", {}) - if isinstance(_model_cfg, str): - model = _model_cfg - elif isinstance(_model_cfg, dict): - model = _model_cfg.get("default", model) + if not job.get("model"): + if isinstance(_model_cfg, str): + model = _model_cfg + elif isinstance(_model_cfg, dict): + model = _model_cfg.get("default", model) except Exception as e: logger.warning("Job '%s': failed to load config.yaml, using defaults: %s", job_id, e) @@ -320,9 +321,12 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: format_runtime_provider_error, ) try: - runtime = resolve_runtime_provider( - requested=os.getenv("HERMES_INFERENCE_PROVIDER"), - ) + runtime_kwargs = { + "requested": job.get("provider") or os.getenv("HERMES_INFERENCE_PROVIDER"), + } + if job.get("base_url"): + runtime_kwargs["explicit_base_url"] = job.get("base_url") + runtime = resolve_runtime_provider(**runtime_kwargs) except Exception as exc: message = format_runtime_provider_error(exc) raise RuntimeError(message) from exc diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 1b3e5d54..ad256714 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -309,6 +309,57 @@ class TestRunJobConfigLogging: f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}" +class TestRunJobPerJobOverrides: + def test_job_level_model_provider_and_base_url_overrides_are_used(self, tmp_path): + config_yaml = tmp_path / "config.yaml" + config_yaml.write_text( + "model:\n" + " default: gpt-5.4\n" + " provider: openai-codex\n" + " base_url: https://chatgpt.com/backend-api/codex\n" + ) + + job = { + "id": "briefing-job", + "name": "briefing", + "prompt": "hello", + "model": "perplexity/sonar-pro", + "provider": "custom", + "base_url": "http://127.0.0.1:4000/v1", + } + + fake_db = MagicMock() + fake_runtime = { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "http://127.0.0.1:4000/v1", + "api_key": "***", + } + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_runtime) as runtime_mock, \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + + success, output, final_response, error = run_job(job) + + assert success is True + assert error is None + assert final_response == "ok" + assert "ok" in output + runtime_mock.assert_called_once_with( + requested="custom", + explicit_base_url="http://127.0.0.1:4000/v1", + ) + assert mock_agent_cls.call_args.kwargs["model"] == "perplexity/sonar-pro" + fake_db.close.assert_called_once() + + class TestRunJobSkillBacked: def test_run_job_loads_skill_and_disables_recursive_cron_tools(self, tmp_path): job = { diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 0e5f9037..29362207 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -137,6 +137,22 @@ class TestScheduleCronjob: )) assert result["repeat"] == "5 times" + def test_schedule_persists_runtime_overrides(self): + result = json.loads(schedule_cronjob( + prompt="Pinned job", + schedule="every 1h", + model="anthropic/claude-sonnet-4", + provider="custom", + base_url="http://127.0.0.1:4000/v1/", + )) + assert result["success"] is True + + listing = json.loads(list_cronjobs()) + job = listing["jobs"][0] + assert job["model"] == "anthropic/claude-sonnet-4" + assert job["provider"] == "custom" + assert job["base_url"] == "http://127.0.0.1:4000/v1" + # ========================================================================= # list_cronjobs @@ -249,6 +265,33 @@ class TestUnifiedCronjobTool: assert updated["job"]["name"] == "New Name" assert updated["job"]["schedule"] == "every 120m" + def test_update_runtime_overrides_can_set_and_clear(self): + created = json.loads( + cronjob( + action="create", + prompt="Check", + schedule="every 1h", + model="anthropic/claude-sonnet-4", + provider="custom", + base_url="http://127.0.0.1:4000/v1", + ) + ) + job_id = created["job_id"] + + updated = json.loads( + cronjob( + action="update", + job_id=job_id, + model="openai/gpt-4.1", + provider="openrouter", + base_url="", + ) + ) + assert updated["success"] is True + assert updated["job"]["model"] == "openai/gpt-4.1" + assert updated["job"]["provider"] == "openrouter" + assert updated["job"]["base_url"] is None + def test_create_skill_backed_job(self): result = json.loads( cronjob( diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 2a40c163..9ff7127b 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -103,6 +103,16 @@ def _canonical_skills(skill: Optional[str] = None, skills: Optional[Any] = None) +def _normalize_optional_job_value(value: Optional[Any], *, strip_trailing_slash: bool = False) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + if strip_trailing_slash: + text = text.rstrip("/") + return text or None + + + def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: prompt = job.get("prompt", "") skills = _canonical_skills(job.get("skill"), job.get("skills")) @@ -112,6 +122,9 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: "skill": skills[0] if skills else None, "skills": skills, "prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt, + "model": job.get("model"), + "provider": job.get("provider"), + "base_url": job.get("base_url"), "schedule": job.get("schedule_display"), "repeat": _repeat_display(job), "deliver": job.get("deliver", "local"), @@ -136,6 +149,9 @@ def cronjob( include_disabled: bool = False, skill: Optional[str] = None, skills: Optional[List[str]] = None, + model: Optional[str] = None, + provider: Optional[str] = None, + base_url: Optional[str] = None, reason: Optional[str] = None, task_id: str = None, ) -> str: @@ -164,6 +180,9 @@ def cronjob( deliver=deliver, origin=_origin_from_env(), skills=canonical_skills, + model=_normalize_optional_job_value(model), + provider=_normalize_optional_job_value(provider), + base_url=_normalize_optional_job_value(base_url, strip_trailing_slash=True), ) return json.dumps( { @@ -240,6 +259,12 @@ def cronjob( canonical_skills = _canonical_skills(skill, skills) updates["skills"] = canonical_skills updates["skill"] = canonical_skills[0] if canonical_skills else None + if model is not None: + updates["model"] = _normalize_optional_job_value(model) + if provider is not None: + updates["provider"] = _normalize_optional_job_value(provider) + if base_url is not None: + updates["base_url"] = _normalize_optional_job_value(base_url, strip_trailing_slash=True) if repeat is not None: repeat_state = dict(job.get("repeat") or {}) repeat_state["times"] = repeat @@ -272,6 +297,9 @@ def schedule_cronjob( name: Optional[str] = None, repeat: Optional[int] = None, deliver: Optional[str] = None, + model: Optional[str] = None, + provider: Optional[str] = None, + base_url: Optional[str] = None, task_id: str = None, ) -> str: return cronjob( @@ -281,6 +309,9 @@ def schedule_cronjob( name=name, repeat=repeat, deliver=deliver, + model=model, + provider=provider, + base_url=base_url, task_id=task_id, ) @@ -343,6 +374,18 @@ Important safety rule: cron-run sessions should not recursively schedule more cr "type": "string", "description": "Delivery target: origin, local, telegram, discord, signal, or platform:chat_id" }, + "model": { + "type": "string", + "description": "Optional per-job model override used when the cron job runs" + }, + "provider": { + "type": "string", + "description": "Optional per-job provider override used when resolving runtime credentials" + }, + "base_url": { + "type": "string", + "description": "Optional per-job base URL override paired with provider/model routing" + }, "include_disabled": { "type": "boolean", "description": "For list: include paused/completed jobs" @@ -407,6 +450,9 @@ registry.register( include_disabled=args.get("include_disabled", False), skill=args.get("skill"), skills=args.get("skills"), + model=args.get("model"), + provider=args.get("provider"), + base_url=args.get("base_url"), reason=args.get("reason"), task_id=kw.get("task_id"), ), From 9e3752df363bb08ee1b8836f4f5cd99531588bd9 Mon Sep 17 00:00:00 2001 From: Nikita <152299288+nikitagorlov54@users.noreply.github.com> Date: Sun, 15 Mar 2026 05:42:06 +0100 Subject: [PATCH 32/41] fix(#1002): expand environment blocklist for terminal isolation Expanded the list of blocked environment variables to include Google, Groq, Mistral, and other major LLM providers. This ensures complete isolation and prevents conflicts with external CLI tools. --- tools/environments/local.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/environments/local.py b/tools/environments/local.py index 276ff9ac..75ba53ea 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -56,6 +56,17 @@ def _build_provider_env_blocklist() -> frozenset: "ANTHROPIC_TOKEN", # OAuth token (not in registry as env var) "CLAUDE_CODE_OAUTH_TOKEN", "LLM_MODEL", + # Expanded isolation for other major providers (Issue #1002) + "GOOGLE_API_KEY", # Gemini / Google AI Studio + "DEEPSEEK_API_KEY", # DeepSeek + "MISTRAL_API_KEY", # Mistral AI + "GROQ_API_KEY", # Groq + "TOGETHER_API_KEY", # Together AI + "PERPLEXITY_API_KEY", # Perplexity + "COHERE_API_KEY", # Cohere + "FIREWORKS_API_KEY", # Fireworks AI + "XAI_API_KEY", # xAI (Grok) + "HELICONE_API_KEY", # LLM Observability proxy }) return frozenset(blocked) From ff87a566c4c4b7d25ed01463f4ced76a5cdfe091 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:28:15 -0700 Subject: [PATCH 33/41] fix(test): make Nous setup prompt selection robust to optional vision step --- tests/hermes_cli/test_setup.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 4d0ab887..e0abc639 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -25,7 +25,11 @@ def test_nous_oauth_setup_keeps_current_model_when_syncing_disk_provider( config = load_config() - prompt_choices = iter([0, 2]) + # Provider selection always comes first. Depending on available vision + # backends, setup may either skip the optional vision step or prompt for + # it before the default-model choice. Provide enough selections for both + # paths while still ending on "keep current model". + prompt_choices = iter([0, 2, 2]) monkeypatch.setattr( "hermes_cli.setup.prompt_choice", lambda *args, **kwargs: next(prompt_choices), From 672dc1666f6c90099c18e6a5ef51f74ec5efec85 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:29:35 -0700 Subject: [PATCH 34/41] test: cover extra provider env blocklist vars --- tests/tools/test_local_env_blocklist.py | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 70a8ae5d..d0a11b51 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -91,6 +91,25 @@ class TestProviderEnvBlocklist: for var in registry_vars: assert var not in result_env, f"{var} leaked into subprocess env" + def test_non_registry_provider_vars_are_stripped(self): + """Extra provider vars not in PROVIDER_REGISTRY must also be blocked.""" + extra_provider_vars = { + "GOOGLE_API_KEY": "google-key", + "DEEPSEEK_API_KEY": "deepseek-key", + "MISTRAL_API_KEY": "mistral-key", + "GROQ_API_KEY": "groq-key", + "TOGETHER_API_KEY": "together-key", + "PERPLEXITY_API_KEY": "perplexity-key", + "COHERE_API_KEY": "cohere-key", + "FIREWORKS_API_KEY": "fireworks-key", + "XAI_API_KEY": "xai-key", + "HELICONE_API_KEY": "helicone-key", + } + result_env = _run_with_env(extra_os_env=extra_provider_vars) + + for var in extra_provider_vars: + assert var not in result_env, f"{var} leaked into subprocess env" + def test_safe_vars_are_preserved(self): """Standard env vars (PATH, HOME, USER) must still be passed through.""" result_env = _run_with_env() @@ -171,3 +190,18 @@ class TestBlocklistCoverage: must also be in the blocklist.""" extras = {"ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"} assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST) + + def test_non_registry_provider_vars_are_in_blocklist(self): + extras = { + "GOOGLE_API_KEY", + "DEEPSEEK_API_KEY", + "MISTRAL_API_KEY", + "GROQ_API_KEY", + "TOGETHER_API_KEY", + "PERPLEXITY_API_KEY", + "COHERE_API_KEY", + "FIREWORKS_API_KEY", + "XAI_API_KEY", + "HELICONE_API_KEY", + } + assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST) From df9020dfa315d0cdfa1a1d129e4bb75106887e57 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:31:09 -0700 Subject: [PATCH 35/41] fix: harden clawhub skill search exact matches --- tests/tools/test_skills_hub_clawhub.py | 142 ++++++++++++++++++++++--- tools/skills_hub.py | 128 +++++++++++++++++++--- 2 files changed, 243 insertions(+), 27 deletions(-) diff --git a/tests/tools/test_skills_hub_clawhub.py b/tests/tools/test_skills_hub_clawhub.py index 98611d8d..3601881c 100644 --- a/tests/tools/test_skills_hub_clawhub.py +++ b/tests/tools/test_skills_hub_clawhub.py @@ -24,19 +24,26 @@ class TestClawHubSource(unittest.TestCase): @patch("tools.skills_hub._read_index_cache", return_value=None) @patch("tools.skills_hub.httpx.get") def test_search_uses_new_endpoint_and_parses_items(self, mock_get, _mock_read_cache, _mock_write_cache): - mock_get.return_value = _MockResponse( - status_code=200, - json_data={ - "items": [ - { - "slug": "caldav-calendar", - "displayName": "CalDAV Calendar", - "summary": "Calendar integration", - "tags": ["calendar", "productivity"], - } - ] - }, - ) + def side_effect(url, *args, **kwargs): + if url.endswith("/skills"): + return _MockResponse( + status_code=200, + json_data={ + "items": [ + { + "slug": "caldav-calendar", + "displayName": "CalDAV Calendar", + "summary": "Calendar integration", + "tags": ["calendar", "productivity"], + } + ] + }, + ) + if url.endswith("/skills/caldav"): + return _MockResponse(status_code=404, json_data={}) + return _MockResponse(status_code=404, json_data={}) + + mock_get.side_effect = side_effect results = self.src.search("caldav", limit=5) @@ -45,11 +52,93 @@ class TestClawHubSource(unittest.TestCase): self.assertEqual(results[0].name, "CalDAV Calendar") self.assertEqual(results[0].description, "Calendar integration") - mock_get.assert_called_once() - args, kwargs = mock_get.call_args + first_call = mock_get.call_args_list[0] + args, kwargs = first_call self.assertTrue(args[0].endswith("/skills")) self.assertEqual(kwargs["params"], {"search": "caldav", "limit": 5}) + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + def test_search_falls_back_to_exact_slug_when_search_results_are_irrelevant( + self, mock_get, _mock_read_cache, _mock_write_cache + ): + def side_effect(url, *args, **kwargs): + if url.endswith("/skills"): + return _MockResponse( + status_code=200, + json_data={ + "items": [ + { + "slug": "apple-music-dj", + "displayName": "Apple Music DJ", + "summary": "Unrelated result", + } + ] + }, + ) + if url.endswith("/skills/self-improving-agent"): + return _MockResponse( + status_code=200, + json_data={ + "skill": { + "slug": "self-improving-agent", + "displayName": "self-improving-agent", + "summary": "Captures learnings and errors for continuous improvement.", + "tags": {"latest": "3.0.2", "automation": "3.0.2"}, + }, + "latestVersion": {"version": "3.0.2"}, + }, + ) + return _MockResponse(status_code=404, json_data={}) + + mock_get.side_effect = side_effect + + results = self.src.search("self-improving-agent", limit=5) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].identifier, "self-improving-agent") + self.assertEqual(results[0].name, "self-improving-agent") + self.assertIn("continuous improvement", results[0].description) + + @patch("tools.skills_hub.httpx.get") + @patch( + "tools.skills_hub._read_index_cache", + return_value=[ + { + "name": "Apple Music DJ", + "description": "Unrelated cached result", + "source": "clawhub", + "identifier": "apple-music-dj", + "trust_level": "community", + "repo": None, + "path": None, + "tags": [], + "extra": {}, + } + ], + ) + def test_search_repairs_poisoned_cache_with_exact_slug_lookup(self, _mock_read_cache, mock_get): + mock_get.return_value = _MockResponse( + status_code=200, + json_data={ + "skill": { + "slug": "self-improving-agent", + "displayName": "self-improving-agent", + "summary": "Captures learnings and errors for continuous improvement.", + "tags": {"latest": "3.0.2", "automation": "3.0.2"}, + }, + "latestVersion": {"version": "3.0.2"}, + }, + ) + + results = self.src.search("self-improving-agent", limit=5) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].identifier, "self-improving-agent") + mock_get.assert_called_once() + self.assertTrue(mock_get.call_args.args[0].endswith("/skills/self-improving-agent")) + @patch("tools.skills_hub.httpx.get") def test_inspect_maps_display_name_and_summary(self, mock_get): mock_get.return_value = _MockResponse( @@ -69,6 +158,29 @@ class TestClawHubSource(unittest.TestCase): self.assertEqual(meta.description, "Calendar integration") self.assertEqual(meta.identifier, "caldav-calendar") + @patch("tools.skills_hub.httpx.get") + def test_inspect_handles_nested_skill_payload(self, mock_get): + mock_get.return_value = _MockResponse( + status_code=200, + json_data={ + "skill": { + "slug": "self-improving-agent", + "displayName": "self-improving-agent", + "summary": "Captures learnings and errors for continuous improvement.", + "tags": {"latest": "3.0.2", "automation": "3.0.2"}, + }, + "latestVersion": {"version": "3.0.2"}, + }, + ) + + meta = self.src.inspect("self-improving-agent") + + self.assertIsNotNone(meta) + self.assertEqual(meta.name, "self-improving-agent") + self.assertIn("continuous improvement", meta.description) + self.assertEqual(meta.identifier, "self-improving-agent") + self.assertEqual(meta.tags, ["automation"]) + @patch("tools.skills_hub.httpx.get") def test_fetch_resolves_latest_version_and_downloads_raw_files(self, mock_get): def side_effect(url, *args, **kwargs): diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 94845fe9..3c055183 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -1156,11 +1156,118 @@ class ClawHubSource(SkillSource): def trust_level_for(self, identifier: str) -> str: return "community" + @staticmethod + def _normalize_tags(tags: Any) -> List[str]: + if isinstance(tags, list): + return [str(t) for t in tags] + if isinstance(tags, dict): + return [str(k) for k in tags.keys() if str(k) != "latest"] + return [] + + @staticmethod + def _coerce_skill_payload(data: Any) -> Optional[Dict[str, Any]]: + if not isinstance(data, dict): + return None + nested = data.get("skill") + if isinstance(nested, dict): + merged = dict(nested) + latest_version = data.get("latestVersion") + if latest_version is not None and "latestVersion" not in merged: + merged["latestVersion"] = latest_version + return merged + return data + + @staticmethod + def _query_terms(query: str) -> List[str]: + return [term for term in re.split(r"[^a-z0-9]+", query.lower()) if term] + + @classmethod + def _search_score(cls, query: str, meta: SkillMeta) -> int: + query_norm = query.strip().lower() + if not query_norm: + return 1 + + identifier = (meta.identifier or "").lower() + name = (meta.name or "").lower() + description = (meta.description or "").lower() + score = 0 + + if query_norm == identifier: + score += 100 + if query_norm == name: + score += 95 + if query_norm in identifier: + score += 40 + if query_norm in name: + score += 35 + if query_norm in description: + score += 10 + + for term in cls._query_terms(query_norm): + if term in identifier: + score += 15 + if term in name: + score += 12 + if term in description: + score += 3 + + return score + + @staticmethod + def _dedupe_results(results: List[SkillMeta]) -> List[SkillMeta]: + seen: set[str] = set() + deduped: List[SkillMeta] = [] + for result in results: + key = (result.identifier or result.name).lower() + if key in seen: + continue + seen.add(key) + deduped.append(result) + return deduped + + def _exact_slug_meta(self, query: str) -> Optional[SkillMeta]: + slug = query.strip().split("/")[-1] + if not slug or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", slug): + return None + return self.inspect(slug) + + def _finalize_search_results(self, query: str, results: List[SkillMeta], limit: int) -> List[SkillMeta]: + query_norm = query.strip() + if not query_norm: + return self._dedupe_results(results)[:limit] + + filtered = [meta for meta in results if self._search_score(query_norm, meta) > 0] + filtered.sort( + key=lambda meta: ( + -self._search_score(query_norm, meta), + meta.name.lower(), + meta.identifier.lower(), + ) + ) + filtered = self._dedupe_results(filtered) + + exact = self._exact_slug_meta(query_norm) + if exact: + filtered = [meta for meta in filtered if self._search_score(query_norm, meta) >= 20] + filtered = self._dedupe_results([exact] + filtered) + + if filtered: + return filtered[:limit] + + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", query_norm): + return [] + + return self._dedupe_results(results)[:limit] + def search(self, query: str, limit: int = 10) -> List[SkillMeta]: cache_key = f"clawhub_search_{hashlib.md5(query.encode()).hexdigest()}" cached = _read_index_cache(cache_key) if cached is not None: - return [SkillMeta(**s) for s in cached][:limit] + return self._finalize_search_results( + query, + [SkillMeta(**s) for s in cached], + limit, + ) try: resp = httpx.get( @@ -1185,20 +1292,19 @@ class ClawHubSource(SkillSource): continue display_name = item.get("displayName") or item.get("name") or slug summary = item.get("summary") or item.get("description") or "" - tags = item.get("tags", []) - if not isinstance(tags, list): - tags = [] + tags = self._normalize_tags(item.get("tags", [])) results.append(SkillMeta( name=display_name, description=summary, source="clawhub", identifier=slug, trust_level="community", - tags=[str(t) for t in tags], + tags=tags, )) - _write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results]) - return results + final_results = self._finalize_search_results(query, results, limit) + _write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in final_results]) + return final_results def fetch(self, identifier: str) -> Optional[SkillBundle]: slug = identifier.split("/")[-1] @@ -1244,13 +1350,11 @@ class ClawHubSource(SkillSource): def inspect(self, identifier: str) -> Optional[SkillMeta]: slug = identifier.split("/")[-1] - data = self._get_json(f"{self.BASE_URL}/skills/{slug}") + data = self._coerce_skill_payload(self._get_json(f"{self.BASE_URL}/skills/{slug}")) if not isinstance(data, dict): return None - tags = data.get("tags", []) - if not isinstance(tags, list): - tags = [] + tags = self._normalize_tags(data.get("tags", [])) return SkillMeta( name=data.get("displayName") or data.get("name") or data.get("slug") or slug, @@ -1258,7 +1362,7 @@ class ClawHubSource(SkillSource): source="clawhub", identifier=data.get("slug") or slug, trust_level="community", - tags=[str(t) for t in tags], + tags=tags, ) def _get_json(self, url: str, timeout: int = 20) -> Optional[Any]: From 47c5c976544dabf3db057e9393b0bdcf42942757 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:45:29 -0700 Subject: [PATCH 36/41] fix(update): drop autostash by stash selector --- hermes_cli/main.py | 38 +++++++++++- tests/hermes_cli/test_update_autostash.py | 76 ++++++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1238d9b6..46745a5e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1997,6 +1997,22 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st +def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Optional[str]: + stash_list = subprocess.run( + git_cmd + ["stash", "list", "--format=%gd %H"], + cwd=cwd, + capture_output=True, + text=True, + check=True, + ) + for line in stash_list.stdout.splitlines(): + selector, _, commit = line.partition(" ") + if commit.strip() == stash_ref: + return selector.strip() + return None + + + def _restore_stashed_changes( git_cmd: list[str], cwd: Path, @@ -2033,7 +2049,27 @@ def _restore_stashed_changes( print(f"Resolve manually with: git stash apply {stash_ref}") sys.exit(1) - subprocess.run(git_cmd + ["stash", "drop", stash_ref], cwd=cwd, check=True) + stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) + if stash_selector is None: + print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.") + print(" The stash was left in place. You can remove it manually after checking the result.") + print(f" Look for commit {stash_ref} in `git stash list --format='%gd %H'` and drop that selector.") + else: + drop = subprocess.run( + git_cmd + ["stash", "drop", stash_selector], + cwd=cwd, + capture_output=True, + text=True, + ) + if drop.returncode != 0: + print("⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry.") + if drop.stdout.strip(): + print(drop.stdout.strip()) + if drop.stderr.strip(): + print(drop.stderr.strip()) + print(" The stash was left in place. You can remove it manually after checking the result.") + print(f" If needed: git stash drop {stash_selector}") + print("⚠ Local changes were restored on top of the updated codebase.") print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.") return True diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index a05a5cbc..f5a25e97 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -46,6 +46,20 @@ def test_stash_local_changes_if_needed_returns_specific_stash_commit(monkeypatch assert calls[2][0][-3:] == ["rev-parse", "--verify", "refs/stash"] +def test_resolve_stash_selector_returns_matching_entry(monkeypatch, tmp_path): + def fake_run(cmd, **kwargs): + assert cmd == ["git", "stash", "list", "--format=%gd %H"] + return SimpleNamespace( + stdout="stash@{0} def456\nstash@{1} abc123\n", + returncode=0, + ) + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + assert hermes_main._resolve_stash_selector(["git"], tmp_path, "abc123") == "stash@{1}" + + + def test_restore_stashed_changes_prompts_before_applying(monkeypatch, tmp_path, capsys): calls = [] @@ -53,6 +67,8 @@ def test_restore_stashed_changes_prompts_before_applying(monkeypatch, tmp_path, calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) + if cmd[1:3] == ["stash", "list"]: + return SimpleNamespace(stdout="stash@{1} abc123\n", stderr="", returncode=0) if cmd[1:3] == ["stash", "drop"]: return SimpleNamespace(stdout="dropped\n", stderr="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") @@ -64,7 +80,8 @@ def test_restore_stashed_changes_prompts_before_applying(monkeypatch, tmp_path, assert restored is True assert calls[0][0] == ["git", "stash", "apply", "abc123"] - assert calls[1][0] == ["git", "stash", "drop", "abc123"] + assert calls[1][0] == ["git", "stash", "list", "--format=%gd %H"] + assert calls[2][0] == ["git", "stash", "drop", "stash@{1}"] out = capsys.readouterr().out assert "Restore local changes now? [Y/n]" in out assert "restored on top of the updated codebase" in out @@ -99,6 +116,8 @@ def test_restore_stashed_changes_applies_without_prompt_when_disabled(monkeypatc calls.append((cmd, kwargs)) if cmd[1:3] == ["stash", "apply"]: return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) + if cmd[1:3] == ["stash", "list"]: + return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0) if cmd[1:3] == ["stash", "drop"]: return SimpleNamespace(stdout="dropped\n", stderr="", returncode=0) raise AssertionError(f"unexpected command: {cmd}") @@ -109,9 +128,64 @@ def test_restore_stashed_changes_applies_without_prompt_when_disabled(monkeypatc assert restored is True assert calls[0][0] == ["git", "stash", "apply", "abc123"] + assert calls[1][0] == ["git", "stash", "list", "--format=%gd %H"] + assert calls[2][0] == ["git", "stash", "drop", "stash@{0}"] assert "Restore local changes now?" not in capsys.readouterr().out + +def test_restore_stashed_changes_keeps_going_when_stash_entry_cannot_be_resolved(monkeypatch, tmp_path, capsys): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + if cmd[1:3] == ["stash", "apply"]: + return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) + if cmd[1:3] == ["stash", "list"]: + return SimpleNamespace(stdout="stash@{0} def456\n", stderr="", returncode=0) + raise AssertionError(f"unexpected command: {cmd}") + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) + + assert restored is True + assert calls == [ + (["git", "stash", "apply", "abc123"], {"cwd": tmp_path, "capture_output": True, "text": True}), + (["git", "stash", "list", "--format=%gd %H"], {"cwd": tmp_path, "capture_output": True, "text": True, "check": True}), + ] + out = capsys.readouterr().out + assert "couldn't find the stash entry to drop" in out + assert "stash was left in place" in out + assert "Look for commit abc123" in out + + + +def test_restore_stashed_changes_keeps_going_when_drop_fails(monkeypatch, tmp_path, capsys): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + if cmd[1:3] == ["stash", "apply"]: + return SimpleNamespace(stdout="applied\n", stderr="", returncode=0) + if cmd[1:3] == ["stash", "list"]: + return SimpleNamespace(stdout="stash@{0} abc123\n", stderr="", returncode=0) + if cmd[1:3] == ["stash", "drop"]: + return SimpleNamespace(stdout="", stderr="drop failed\n", returncode=1) + raise AssertionError(f"unexpected command: {cmd}") + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + restored = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=False) + + assert restored is True + assert calls[2][0] == ["git", "stash", "drop", "stash@{0}"] + out = capsys.readouterr().out + assert "couldn't drop the saved stash entry" in out + assert "drop failed" in out + assert "git stash drop stash@{0}" in out + + def test_restore_stashed_changes_exits_cleanly_when_apply_fails(monkeypatch, tmp_path, capsys): calls = [] From 259208bfe433710acf6077fa7f34e5bf238405ad Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:49:57 -0700 Subject: [PATCH 37/41] docs: stabilize website diagrams --- .github/workflows/docs-site-checks.yml | 39 + .../docs/developer-guide/creating-skills.md | 2 +- website/docs/developer-guide/environments.md | 70 +- website/docs/user-guide/cli.md | 23 +- .../user-guide/features/batch-processing.md | 2 +- .../docs/user-guide/features/context-files.md | 2 +- website/docs/user-guide/features/honcho.md | 21 +- website/docs/user-guide/features/hooks.md | 2 +- .../docs/user-guide/features/rl-training.md | 26 +- website/docs/user-guide/features/skills.md | 2 +- website/docs/user-guide/messaging/index.md | 50 +- website/docs/user-guide/sessions.md | 11 +- website/docusaurus.config.ts | 5 + website/package-lock.json | 1262 +++++++++++++++++ website/package.json | 4 +- website/src/css/custom.css | 50 + website/static/img/docs/cli-layout.svg | 32 + website/static/img/docs/session-recap.svg | 13 + 18 files changed, 1504 insertions(+), 112 deletions(-) create mode 100644 .github/workflows/docs-site-checks.yml create mode 100644 website/static/img/docs/cli-layout.svg create mode 100644 website/static/img/docs/session-recap.svg diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml new file mode 100644 index 00000000..6e4b966b --- /dev/null +++ b/.github/workflows/docs-site-checks.yml @@ -0,0 +1,39 @@ +name: Docs Site Checks + +on: + pull_request: + paths: + - 'website/**' + - '.github/workflows/docs-site-checks.yml' + workflow_dispatch: + +jobs: + docs-site-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install website dependencies + run: npm ci + working-directory: website + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install ascii-guard + run: python -m pip install ascii-guard + + - name: Lint docs diagrams + run: npm run lint:diagrams + working-directory: website + + - name: Build Docusaurus + run: npm run build + working-directory: website diff --git a/website/docs/developer-guide/creating-skills.md b/website/docs/developer-guide/creating-skills.md index d3f0aeb5..9dd3deb6 100644 --- a/website/docs/developer-guide/creating-skills.md +++ b/website/docs/developer-guide/creating-skills.md @@ -26,7 +26,7 @@ Make it a **Tool** when: Bundled skills live in `skills/` organized by category. Official optional skills use the same structure in `optional-skills/`: -``` +```text skills/ ├── research/ │ └── arxiv/ diff --git a/website/docs/developer-guide/environments.md b/website/docs/developer-guide/environments.md index 6579b378..3409f304 100644 --- a/website/docs/developer-guide/environments.md +++ b/website/docs/developer-guide/environments.md @@ -28,34 +28,48 @@ The Python environment framework documented here lives under the repo's `environ The environment system is built on a three-layer inheritance chain: -``` - Atropos Framework - ┌───────────────────────┐ - │ BaseEnv │ (atroposlib) - │ - Server management │ - │ - Worker scheduling │ - │ - Wandb logging │ - │ - CLI (serve/process/ │ - │ evaluate) │ - └───────────┬───────────┘ - │ inherits - ┌───────────┴───────────┐ - │ HermesAgentBaseEnv │ environments/hermes_base_env.py - │ - Terminal backend │ - │ - Tool resolution │ - │ - Agent loop engine │ - │ - ToolContext │ - └───────────┬───────────┘ - │ inherits - ┌─────────────────────┼─────────────────────┐ - │ │ │ - TerminalTestEnv HermesSweEnv TerminalBench2EvalEnv - (stack testing) (SWE training) (benchmark eval) - │ - ┌────────┼────────┐ - │ │ - TBLiteEvalEnv YCBenchEvalEnv - (fast benchmark) (long-horizon) +```mermaid +classDiagram + class BaseEnv { + Server management + Worker scheduling + Wandb logging + CLI: serve / process / evaluate + } + + class HermesAgentBaseEnv { + Terminal backend configuration + Tool resolution + Agent loop engine + ToolContext access + } + + class TerminalTestEnv { + Stack testing + } + + class HermesSweEnv { + SWE training + } + + class TerminalBench2EvalEnv { + Benchmark evaluation + } + + class TBLiteEvalEnv { + Fast benchmark + } + + class YCBenchEvalEnv { + Long-horizon benchmark + } + + BaseEnv <|-- HermesAgentBaseEnv + HermesAgentBaseEnv <|-- TerminalTestEnv + HermesAgentBaseEnv <|-- HermesSweEnv + HermesAgentBaseEnv <|-- TerminalBench2EvalEnv + TerminalBench2EvalEnv <|-- TBLiteEvalEnv + TerminalBench2EvalEnv <|-- YCBenchEvalEnv ``` ### BaseEnv (Atropos) diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 44b42fff..a6b8f146 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -45,27 +45,8 @@ hermes -w -q "Fix issue #123" # Single query in worktree ## Interface Layout -```text -┌─────────────────────────────────────────────────┐ -│ HERMES-AGENT ASCII Logo │ -│ ┌─────────────┐ ┌────────────────────────────┐ │ -│ │ Caduceus │ │ Model: claude-sonnet-4 │ │ -│ │ ASCII Art │ │ Terminal: local │ │ -│ │ │ │ Working Dir: /home/user │ │ -│ │ │ │ Available Tools: 19 │ │ -│ │ │ │ Available Skills: 12 │ │ -│ └─────────────┘ └────────────────────────────┘ │ -├─────────────────────────────────────────────────┤ -│ Conversation output scrolls here... │ -│ │ -│ (◕‿◕✿) 🧠 pondering... (2.3s) │ -│ ✧٩(ˊᗜˋ*)و✧ got it! (2.3s) │ -│ │ -│ Assistant: Hello! How can I help you today? │ -├─────────────────────────────────────────────────┤ -│ ❯ [Fixed input area at bottom] │ -└─────────────────────────────────────────────────┘ -``` +Stylized preview of the Hermes CLI layout showing the banner, conversation area, and fixed input prompt. +

The Hermes CLI banner, conversation stream, and fixed input prompt rendered as a stable docs figure instead of fragile text art.

The welcome banner shows your model, terminal backend, working directory, available tools, and installed skills at a glance. diff --git a/website/docs/user-guide/features/batch-processing.md b/website/docs/user-guide/features/batch-processing.md index cdbd7b63..40df279c 100644 --- a/website/docs/user-guide/features/batch-processing.md +++ b/website/docs/user-guide/features/batch-processing.md @@ -100,7 +100,7 @@ In the current implementation, distributions assign a probability to **each indi All output goes to `data//`: -``` +```text data/my_run/ ├── trajectories.jsonl # Combined final output (all batches merged) ├── batch_0.jsonl # Individual batch results diff --git a/website/docs/user-guide/features/context-files.md b/website/docs/user-guide/features/context-files.md index 1a26d69d..372409e5 100644 --- a/website/docs/user-guide/features/context-files.md +++ b/website/docs/user-guide/features/context-files.md @@ -103,7 +103,7 @@ Context files are loaded by `build_context_files_prompt()` in `agent/prompt_buil The final prompt section looks roughly like: -``` +```text # Project Context The following project context files have been loaded and should be followed: diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index 346696c5..3902b530 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -207,16 +207,17 @@ honcho: {} Honcho context is fetched asynchronously to avoid blocking the response path: -``` -Turn N: - user message - → consume cached context (from previous turn's background fetch) - → inject into system prompt (user representation, AI representation, dialectic) - → LLM call - → response - → fire background fetch for next turn - → fetch context ─┐ - → fetch dialectic ─┴→ cache for Turn N+1 +```mermaid +flowchart TD + user["User message"] --> cache["Consume cached Honcho context
from the previous turn"] + cache --> prompt["Inject user, AI, and dialectic context
into the system prompt"] + prompt --> llm["LLM call"] + llm --> response["Assistant response"] + response --> fetch["Start background fetch for Turn N+1"] + fetch --> ctx["Fetch context"] + fetch --> dia["Fetch dialectic"] + ctx --> next["Cache for the next turn"] + dia --> next ``` Turn 1 is a cold start (no cache). All subsequent turns consume cached results with zero HTTP latency on the response path. The system prompt on turn 1 uses only static context to preserve prefix cache hits at the LLM provider. diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index 6fd2987f..28bb0ed1 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -12,7 +12,7 @@ The hooks system lets you run custom code at key points in the agent lifecycle Each hook is a directory under `~/.hermes/hooks/` containing two files: -``` +```text ~/.hermes/hooks/ └── my-hook/ ├── HOOK.yaml # Declares which events to listen for diff --git a/website/docs/user-guide/features/rl-training.md b/website/docs/user-guide/features/rl-training.md index 86240395..440cc31b 100644 --- a/website/docs/user-guide/features/rl-training.md +++ b/website/docs/user-guide/features/rl-training.md @@ -174,21 +174,17 @@ The training loop: ## Architecture Diagram -``` -┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ Atropos API │◄────│ Environment │────►│ OpenAI/sglang │ -│ (run-api) │ │ (BaseEnv impl) │ │ Inference API │ -│ Port 8000 │ │ │ │ Port 8001 │ -└────────┬────────┘ └──────────────────┘ └────────┬────────┘ - │ │ - │ Batches (tokens + scores + logprobs) │ - │ │ - ▼ │ -┌─────────────────┐ │ -│ Tinker Trainer │◄──────────────────────────────────────┘ -│ (LoRA training) │ Serves inference via FastAPI -│ + FastAPI │ Trains via Tinker ServiceClient -└─────────────────┘ +```mermaid +flowchart LR + api["Atropos API
run-api
port 8000"] + env["Environment
BaseEnv implementation"] + infer["OpenAI / sglang
inference API
port 8001"] + trainer["Tinker Trainer
LoRA training + FastAPI"] + + env <--> api + env --> infer + api -->|"batches: tokens, scores, logprobs"| trainer + trainer -->|"serves inference"| infer ``` ## Creating Custom Environments diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index f9073ce7..daa831da 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -140,7 +140,7 @@ When a missing value is encountered, Hermes asks for it securely only when the s ## Skill Directory Structure -``` +```text ~/.hermes/skills/ # Single source of truth ├── mlops/ # Category directory │ ├── axolotl/ diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index d71edafe..d1fb7ed9 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -12,29 +12,33 @@ For the full voice feature set — including CLI microphone mode, spoken replies ## Architecture -```text -┌───────────────────────────────────────────────────────────────────────────────────────┐ -│ Hermes Gateway │ -├───────────────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌────┐ │ -│ │ Telegram │ │ Discord │ │ WhatsApp │ │ Slack │ │Signal │ │ Email │ │ HA │ │ -│ │ Adapter │ │ Adapter │ │ Adapter │ │Adapter│ │Adapter│ │Adapter│ │Adpt│ │ -│ └────┬─────┘ └────┬────┘ └────┬─────┘ └──┬────┘ └──┬────┘ └──┬────┘ └─┬──┘ │ -│ │ │ │ │ │ │ │ │ -│ └─────────────┴───────────┴───────────┴─────────┴─────────┴────────┘ │ -│ │ │ -│ ┌────────▼────────┐ │ -│ │ Session Store │ │ -│ │ (per-chat) │ │ -│ └────────┬────────┘ │ -│ │ │ -│ ┌────────▼────────┐ │ -│ │ AIAgent │ │ -│ │ (run_agent) │ │ -│ └─────────────────┘ │ -│ │ -└───────────────────────────────────────────────────────────────────────────────────────┘ +```mermaid +flowchart TB + subgraph Gateway["Hermes Gateway"] + subgraph Adapters["Platform adapters"] + tg[Telegram] + dc[Discord] + wa[WhatsApp] + sl[Slack] + sig[Signal] + em[Email] + ha[Home Assistant] + end + + store["Session store
per chat"] + agent["AIAgent
run_agent.py"] + cron["Cron scheduler
ticks every 60s"] + end + + tg --> store + dc --> store + wa --> store + sl --> store + sig --> store + em --> store + ha --> store + store --> agent + cron --> store ``` Each platform adapter receives messages, routes them through a per-chat session store, and dispatches them to the AIAgent for processing. The gateway also runs the cron scheduler, ticking every 60 seconds to execute any due jobs. diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 25398ede..1c238a67 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -88,15 +88,8 @@ Session IDs are shown when you exit a CLI session, and can be found with `hermes When you resume a session, Hermes displays a compact recap of the previous conversation in a styled panel before the input prompt: -```text -╭─────────────────────────── Previous Conversation ────────────────────────────╮ -│ ● You: What is Python? │ -│ ◆ Hermes: Python is a high-level programming language. │ -│ ● You: How do I install it? │ -│ ◆ Hermes: [3 tool calls: web_search, web_extract, terminal] │ -│ ◆ Hermes: You can download Python from python.org... │ -╰──────────────────────────────────────────────────────────────────────────────╯ -``` +Stylized preview of the Previous Conversation recap panel shown when resuming a Hermes session. +

Resume mode shows a compact recap panel with recent user and assistant turns before returning you to the live prompt.

The recap: - Shows **user messages** (gold `●`) and **assistant responses** (green `◆`) diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 23e5408f..6d8b52bf 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -16,6 +16,7 @@ const config: Config = { onBrokenLinks: 'warn', markdown: { + mermaid: true, hooks: { onBrokenMarkdownLinks: 'warn', }, @@ -27,6 +28,7 @@ const config: Config = { }, themes: [ + '@docusaurus/theme-mermaid', [ require.resolve('@easyops-cn/docusaurus-search-local'), /** @type {import("@easyops-cn/docusaurus-search-local").PluginOptions} */ @@ -128,6 +130,9 @@ const config: Config = { darkTheme: prismThemes.dracula, additionalLanguages: ['bash', 'yaml', 'json', 'python', 'toml'], }, + mermaid: { + theme: {light: 'neutral', dark: 'dark'}, + }, } satisfies Preset.ThemeConfig, }; diff --git a/website/package-lock.json b/website/package-lock.json index 28113e0a..c16f0292 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/preset-classic": "3.9.2", + "@docusaurus/theme-mermaid": "^3.9.2", "@easyops-cn/docusaurus-search-local": "^0.55.1", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", @@ -260,6 +261,19 @@ "node": ">= 14.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1973,6 +1987,51 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", + "license": "Apache-2.0" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -3913,6 +3972,34 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@docusaurus/theme-mermaid": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.9.2.tgz", + "integrity": "sha512-5vhShRDq/ntLzdInsQkTdoKWSzw8d1jB17sNPYhA/KvYYFXfuVEGHLM6nrf8MFbV8TruAHDG21Fn3W4lO8GaDw==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.9.2", + "@docusaurus/module-type-aliases": "3.9.2", + "@docusaurus/theme-common": "3.9.2", + "@docusaurus/types": "3.9.2", + "@docusaurus/utils-validation": "3.9.2", + "mermaid": ">=11.6.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@mermaid-js/layout-elk": "^0.1.9", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@mermaid-js/layout-elk": { + "optional": true + } + } + }, "node_modules/@docusaurus/theme-search-algolia": { "version": "3.9.2", "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.2.tgz", @@ -4229,6 +4316,23 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.0" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -4782,6 +4886,15 @@ "react": ">=16" } }, + "node_modules/@mermaid-js/parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz", + "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==", + "license": "MIT", + "dependencies": { + "langium": "^4.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -5656,6 +5769,259 @@ "@types/node": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -5724,6 +6090,12 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/gtag.js": { "version": "0.0.12", "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.12.tgz", @@ -5960,6 +6332,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5996,6 +6375,16 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -7096,6 +7485,32 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chevrotain": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -7393,6 +7808,12 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -7593,6 +8014,15 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -8078,6 +8508,532 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", @@ -8239,6 +9195,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -8377,6 +9342,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -9634,6 +10608,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -10321,6 +11301,15 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -10816,6 +11805,31 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/katex": { + "version": "0.16.38", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz", + "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10825,6 +11839,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -10852,6 +11871,23 @@ "node": ">=6" } }, + "node_modules/langium": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, "node_modules/latest-version": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz", @@ -10877,6 +11913,12 @@ "shell-quote": "^1.8.3" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -10952,6 +11994,12 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -11062,6 +12110,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -11541,6 +12601,48 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", + "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.0.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -13448,6 +14550,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mlly": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz", + "integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -13904,6 +15018,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -14043,6 +15163,12 @@ "tslib": "^2.0.3" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", @@ -14091,6 +15217,12 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -14124,6 +15256,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, "node_modules/pkijs": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz", @@ -14141,6 +15284,22 @@ "node": ">=16.0.0" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -16570,6 +17729,24 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/rtlcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz", @@ -16623,6 +17800,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -17455,6 +18638,12 @@ "postcss": "^8.4.31" } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -17652,6 +18841,15 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -17727,6 +18925,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -17820,6 +19027,12 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, "node_modules/undici": { "version": "7.23.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.23.0.tgz", @@ -18323,6 +19536,55 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", diff --git a/website/package.json b/website/package.json index d8a8234e..6bf50e70 100644 --- a/website/package.json +++ b/website/package.json @@ -12,11 +12,13 @@ "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", - "typecheck": "tsc" + "typecheck": "tsc", + "lint:diagrams": "ascii-guard lint docs" }, "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/preset-classic": "3.9.2", + "@docusaurus/theme-mermaid": "^3.9.2", "@easyops-cn/docusaurus-search-local": "^0.55.1", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 9e969331..1df44998 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -89,6 +89,56 @@ border: 1px solid rgba(255, 215, 0, 0.06); } +/* Text diagrams: preserve spacing, disable ligatures, and prefer box-drawing-safe fonts */ +pre.prism-code.language-text, +pre.prism-code.language-plaintext, +pre.prism-code.language-txt, +pre.prism-code.language-ascii { + white-space: pre; + overflow-x: auto; + line-height: 1.35; + font-family: 'JetBrains Mono', 'Cascadia Mono', 'Cascadia Code', 'Fira Code', 'SFMono-Regular', 'DejaVu Sans Mono', 'Liberation Mono', monospace; + font-variant-ligatures: none; + font-feature-settings: "liga" 0, "calt" 0; + text-rendering: optimizeSpeed; +} + +pre.prism-code.language-text code, +pre.prism-code.language-plaintext code, +pre.prism-code.language-txt code, +pre.prism-code.language-ascii code { + white-space: pre; + font-variant-ligatures: none; + font-feature-settings: "liga" 0, "calt" 0; +} + +.theme-mermaid { + margin: 1.5rem 0; + text-align: center; +} + +.theme-mermaid svg { + max-width: 100%; + height: auto; +} + +.docs-terminal-figure { + display: block; + width: 100%; + max-width: 900px; + margin: 1.25rem auto 0.5rem; + border: 1px solid rgba(255, 215, 0, 0.08); + border-radius: 12px; + background: #0a0a12; +} + +.docs-figure-caption { + margin-top: 0.35rem; + text-align: center; + color: var(--ifm-font-color-secondary); + font-size: 0.95rem; +} + /* Admonitions — gold-tinted */ [data-theme='dark'] .alert--info { --ifm-alert-background-color: rgba(255, 215, 0, 0.05); diff --git a/website/static/img/docs/cli-layout.svg b/website/static/img/docs/cli-layout.svg new file mode 100644 index 00000000..c42412af --- /dev/null +++ b/website/static/img/docs/cli-layout.svg @@ -0,0 +1,32 @@ + + Hermes CLI interface layout + Stylized terminal window showing the Hermes CLI banner, conversation area, and fixed input prompt. + + + + + + + Hermes CLI + + + HERMES AGENT + + Caduceus banner + Model, terminal, tools, + skills, working dir + + Model: anthropic/claude-sonnet-4 + Terminal: local Working dir: /home/user/project + Tools: 19 Skills: 12 Session: 20260315_123456_abcd1234 + + + Conversation output + ┊ terminal: git status + Hermes: Working tree is clean. Ready for the next task. + Hermes streams tool progress and responses here. + + + + Fixed input area at the bottom with slash-command autocomplete + diff --git a/website/static/img/docs/session-recap.svg b/website/static/img/docs/session-recap.svg new file mode 100644 index 00000000..6f80edfc --- /dev/null +++ b/website/static/img/docs/session-recap.svg @@ -0,0 +1,13 @@ + + Hermes session recap panel + Stylized panel showing the previous conversation summary displayed when resuming a session. + + + Previous Conversation + + + ● You: What is Python? + ◆ Hermes: Python is a high-level programming language. + ● You: How do I install it? + ◆ Hermes: [3 tool calls: web_search, web_extract, terminal] + From f882dabf1946f22dd04de95167357bb6e4d6e904 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:56:59 -0700 Subject: [PATCH 38/41] fix(update): clarify manual autostash cleanup --- hermes_cli/main.py | 14 ++++++++++++-- tests/hermes_cli/test_update_autostash.py | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e8f643e9..67f711b3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2032,6 +2032,16 @@ def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Op +def _print_stash_cleanup_guidance(stash_ref: str, stash_selector: Optional[str] = None) -> None: + print(" Check `git status` first so you don't accidentally reapply the same change twice.") + print(" Find the saved entry with: git stash list --format='%gd %H %s'") + if stash_selector: + print(f" Remove it with: git stash drop {stash_selector}") + else: + print(f" Look for commit {stash_ref}, then drop its selector with: git stash drop stash@{{N}}") + + + def _restore_stashed_changes( git_cmd: list[str], cwd: Path, @@ -2072,7 +2082,7 @@ def _restore_stashed_changes( if stash_selector is None: print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.") print(" The stash was left in place. You can remove it manually after checking the result.") - print(f" Look for commit {stash_ref} in `git stash list --format='%gd %H'` and drop that selector.") + _print_stash_cleanup_guidance(stash_ref) else: drop = subprocess.run( git_cmd + ["stash", "drop", stash_selector], @@ -2087,7 +2097,7 @@ def _restore_stashed_changes( if drop.stderr.strip(): print(drop.stderr.strip()) print(" The stash was left in place. You can remove it manually after checking the result.") - print(f" If needed: git stash drop {stash_selector}") + _print_stash_cleanup_guidance(stash_ref, stash_selector) print("⚠ Local changes were restored on top of the updated codebase.") print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.") diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index f5a25e97..85523e8d 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -134,6 +134,16 @@ def test_restore_stashed_changes_applies_without_prompt_when_disabled(monkeypatc +def test_print_stash_cleanup_guidance_with_selector(capsys): + hermes_main._print_stash_cleanup_guidance("abc123", "stash@{2}") + + out = capsys.readouterr().out + assert "Check `git status` first" in out + assert "git stash list --format='%gd %H %s'" in out + assert "git stash drop stash@{2}" in out + + + def test_restore_stashed_changes_keeps_going_when_stash_entry_cannot_be_resolved(monkeypatch, tmp_path, capsys): calls = [] @@ -157,6 +167,8 @@ def test_restore_stashed_changes_keeps_going_when_stash_entry_cannot_be_resolved out = capsys.readouterr().out assert "couldn't find the stash entry to drop" in out assert "stash was left in place" in out + assert "Check `git status` first" in out + assert "git stash list --format='%gd %H %s'" in out assert "Look for commit abc123" in out @@ -183,6 +195,8 @@ def test_restore_stashed_changes_keeps_going_when_drop_fails(monkeypatch, tmp_pa out = capsys.readouterr().out assert "couldn't drop the saved stash entry" in out assert "drop failed" in out + assert "Check `git status` first" in out + assert "git stash list --format='%gd %H %s'" in out assert "git stash drop stash@{0}" in out From 8ccd14a0d4c1788af7f71b5987ec80c6c417e122 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 23:15:04 -0700 Subject: [PATCH 39/41] fix: improve clawhub skill search matching --- tests/tools/test_skills_hub_clawhub.py | 68 ++++++++---- tools/skills_hub.py | 140 +++++++++++++++++++++++-- 2 files changed, 177 insertions(+), 31 deletions(-) diff --git a/tests/tools/test_skills_hub_clawhub.py b/tests/tools/test_skills_hub_clawhub.py index 3601881c..2318ec80 100644 --- a/tests/tools/test_skills_hub_clawhub.py +++ b/tests/tools/test_skills_hub_clawhub.py @@ -3,7 +3,7 @@ import unittest from unittest.mock import patch -from tools.skills_hub import ClawHubSource +from tools.skills_hub import ClawHubSource, SkillMeta class _MockResponse: @@ -22,8 +22,11 @@ class TestClawHubSource(unittest.TestCase): @patch("tools.skills_hub._write_index_cache") @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch.object(ClawHubSource, "_load_catalog_index", return_value=[]) @patch("tools.skills_hub.httpx.get") - def test_search_uses_new_endpoint_and_parses_items(self, mock_get, _mock_read_cache, _mock_write_cache): + def test_search_uses_listing_endpoint_as_fallback( + self, mock_get, _mock_load_catalog, _mock_read_cache, _mock_write_cache + ): def side_effect(url, *args, **kwargs): if url.endswith("/skills"): return _MockResponse( @@ -52,16 +55,21 @@ class TestClawHubSource(unittest.TestCase): self.assertEqual(results[0].name, "CalDAV Calendar") self.assertEqual(results[0].description, "Calendar integration") - first_call = mock_get.call_args_list[0] - args, kwargs = first_call + self.assertGreaterEqual(mock_get.call_count, 2) + args, kwargs = mock_get.call_args_list[0] self.assertTrue(args[0].endswith("/skills")) self.assertEqual(kwargs["params"], {"search": "caldav", "limit": 5}) @patch("tools.skills_hub._write_index_cache") @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch.object( + ClawHubSource, + "_load_catalog_index", + return_value=[], + ) @patch("tools.skills_hub.httpx.get") def test_search_falls_back_to_exact_slug_when_search_results_are_irrelevant( - self, mock_get, _mock_read_cache, _mock_write_cache + self, mock_get, _mock_load_catalog, _mock_read_cache, _mock_write_cache ): def side_effect(url, *args, **kwargs): if url.endswith("/skills"): @@ -102,23 +110,7 @@ class TestClawHubSource(unittest.TestCase): self.assertIn("continuous improvement", results[0].description) @patch("tools.skills_hub.httpx.get") - @patch( - "tools.skills_hub._read_index_cache", - return_value=[ - { - "name": "Apple Music DJ", - "description": "Unrelated cached result", - "source": "clawhub", - "identifier": "apple-music-dj", - "trust_level": "community", - "repo": None, - "path": None, - "tags": [], - "extra": {}, - } - ], - ) - def test_search_repairs_poisoned_cache_with_exact_slug_lookup(self, _mock_read_cache, mock_get): + def test_search_repairs_poisoned_cache_with_exact_slug_lookup(self, mock_get): mock_get.return_value = _MockResponse( status_code=200, json_data={ @@ -132,13 +124,43 @@ class TestClawHubSource(unittest.TestCase): }, ) - results = self.src.search("self-improving-agent", limit=5) + poisoned = [ + SkillMeta( + name="Apple Music DJ", + description="Unrelated cached result", + source="clawhub", + identifier="apple-music-dj", + trust_level="community", + tags=[], + ) + ] + results = self.src._finalize_search_results("self-improving-agent", poisoned, 5) self.assertEqual(len(results), 1) self.assertEqual(results[0].identifier, "self-improving-agent") mock_get.assert_called_once() self.assertTrue(mock_get.call_args.args[0].endswith("/skills/self-improving-agent")) + @patch.object( + ClawHubSource, + "_exact_slug_meta", + return_value=SkillMeta( + name="self-improving-agent", + description="Captures learnings and errors for continuous improvement.", + source="clawhub", + identifier="self-improving-agent", + trust_level="community", + tags=["automation"], + ), + ) + def test_search_matches_space_separated_query_to_hyphenated_slug( + self, _mock_exact_slug + ): + results = self.src.search("self improving", limit=5) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].identifier, "self-improving-agent") + @patch("tools.skills_hub.httpx.get") def test_inspect_maps_display_name_and_summary(self, mock_get): mock_get.return_value = _MockResponse( diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 3c055183..52472656 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -1190,12 +1190,29 @@ class ClawHubSource(SkillSource): identifier = (meta.identifier or "").lower() name = (meta.name or "").lower() description = (meta.description or "").lower() + normalized_identifier = " ".join(cls._query_terms(identifier)) + normalized_name = " ".join(cls._query_terms(name)) + query_terms = cls._query_terms(query_norm) + identifier_terms = cls._query_terms(identifier) + name_terms = cls._query_terms(name) score = 0 if query_norm == identifier: - score += 100 + score += 140 if query_norm == name: + score += 130 + if normalized_identifier == query_norm: + score += 125 + if normalized_name == query_norm: + score += 120 + if normalized_identifier.startswith(query_norm): score += 95 + if normalized_name.startswith(query_norm): + score += 90 + if query_terms and identifier_terms[: len(query_terms)] == query_terms: + score += 70 + if query_terms and name_terms[: len(query_terms)] == query_terms: + score += 65 if query_norm in identifier: score += 40 if query_norm in name: @@ -1203,10 +1220,10 @@ class ClawHubSource(SkillSource): if query_norm in description: score += 10 - for term in cls._query_terms(query_norm): - if term in identifier: + for term in query_terms: + if term in identifier_terms: score += 15 - if term in name: + if term in name_terms: score += 12 if term in description: score += 3 @@ -1227,9 +1244,36 @@ class ClawHubSource(SkillSource): def _exact_slug_meta(self, query: str) -> Optional[SkillMeta]: slug = query.strip().split("/")[-1] - if not slug or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", slug): - return None - return self.inspect(slug) + query_terms = self._query_terms(query) + candidates: List[str] = [] + + if slug and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", slug): + candidates.append(slug) + + if query_terms: + base_slug = "-".join(query_terms) + if len(query_terms) >= 2: + candidates.extend([ + f"{base_slug}-agent", + f"{base_slug}-skill", + f"{base_slug}-tool", + f"{base_slug}-assistant", + f"{base_slug}-playbook", + base_slug, + ]) + else: + candidates.append(base_slug) + + seen: set[str] = set() + for candidate in candidates: + if candidate in seen: + continue + seen.add(candidate) + meta = self.inspect(candidate) + if meta: + return meta + + return None def _finalize_search_results(self, query: str, results: List[SkillMeta], limit: int) -> List[SkillMeta]: query_norm = query.strip() @@ -1260,7 +1304,21 @@ class ClawHubSource(SkillSource): return self._dedupe_results(results)[:limit] def search(self, query: str, limit: int = 10) -> List[SkillMeta]: - cache_key = f"clawhub_search_{hashlib.md5(query.encode()).hexdigest()}" + query = query.strip() + + if query: + query_terms = self._query_terms(query) + if len(query_terms) >= 2: + direct = self._exact_slug_meta(query) + if direct: + return [direct] + + results = self._search_catalog(query, limit=limit) + if results: + return results + + # Empty query or catalog fallback failure: use the lightweight listing API. + cache_key = f"clawhub_search_listing_v1_{hashlib.md5(query.encode()).hexdigest()}_{limit}" cached = _read_index_cache(cache_key) if cached is not None: return self._finalize_search_results( @@ -1365,6 +1423,72 @@ class ClawHubSource(SkillSource): tags=tags, ) + def _search_catalog(self, query: str, limit: int = 10) -> List[SkillMeta]: + cache_key = f"clawhub_search_catalog_v1_{hashlib.md5(f'{query}|{limit}'.encode()).hexdigest()}" + cached = _read_index_cache(cache_key) + if cached is not None: + return [SkillMeta(**s) for s in cached][:limit] + + catalog = self._load_catalog_index() + if not catalog: + return [] + + results = self._finalize_search_results(query, catalog, limit) + _write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results]) + return results + + def _load_catalog_index(self) -> List[SkillMeta]: + cache_key = "clawhub_catalog_v1" + cached = _read_index_cache(cache_key) + if cached is not None: + return [SkillMeta(**s) for s in cached] + + cursor: Optional[str] = None + results: List[SkillMeta] = [] + seen: set[str] = set() + max_pages = 50 + + for _ in range(max_pages): + params: Dict[str, Any] = {"limit": 200} + if cursor: + params["cursor"] = cursor + + try: + resp = httpx.get(f"{self.BASE_URL}/skills", params=params, timeout=30) + if resp.status_code != 200: + break + data = resp.json() + except (httpx.HTTPError, json.JSONDecodeError): + break + + items = data.get("items", []) if isinstance(data, dict) else [] + if not isinstance(items, list) or not items: + break + + for item in items: + slug = item.get("slug") + if not isinstance(slug, str) or not slug or slug in seen: + continue + seen.add(slug) + display_name = item.get("displayName") or item.get("name") or slug + summary = item.get("summary") or item.get("description") or "" + tags = self._normalize_tags(item.get("tags", [])) + results.append(SkillMeta( + name=display_name, + description=summary, + source="clawhub", + identifier=slug, + trust_level="community", + tags=tags, + )) + + cursor = data.get("nextCursor") if isinstance(data, dict) else None + if not isinstance(cursor, str) or not cursor: + break + + _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]: try: resp = httpx.get(url, timeout=timeout) From 678e0bd9cc7e983c5fb504d33520b9e5ce8f71bf Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 23:15:13 -0700 Subject: [PATCH 40/41] docs: clarify Slack thread reply behavior --- website/docs/user-guide/messaging/slack.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index 29a14353..4c4d714b 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -193,8 +193,8 @@ Understanding how Hermes behaves in different contexts: | Context | Behavior | |---------|----------| | **DMs** | Bot responds to every message — no @mention needed | -| **Channels** | Bot **only responds when @mentioned** (e.g., `@Hermes Agent what time is it?`) | -| **Threads** | Bot replies in threads when the triggering message is in a thread | +| **Channels** | Bot **only responds when @mentioned** (e.g., `@Hermes Agent what time is it?`). In channels, Hermes replies in a thread attached to that message. | +| **Threads** | If you @mention Hermes inside an existing thread, it replies in that same thread. | :::tip In channels, always @mention the bot. Simply typing a message without mentioning it will be ignored. From 214827a5944c0075d5063eff5052b05f65761d56 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 23:18:21 -0700 Subject: [PATCH 41/41] docs: move Discord behavior guidance to top --- website/docs/user-guide/messaging/discord.md | 21 ++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 2fd9a3a1..8391715e 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -8,6 +8,21 @@ description: "Set up Hermes Agent as a Discord bot" Hermes Agent integrates with Discord as a bot, letting you chat with your AI assistant through direct messages or server channels. The bot receives your messages, processes them through the Hermes Agent pipeline (including tool use, memory, and reasoning), and responds in real time. It supports text, voice messages, file attachments, and slash commands. +Before setup, here's the part most people want to know: how Hermes behaves once it's in your server. + +## How Hermes Behaves + +| Context | Behavior | +|---------|----------| +| **DMs** | Hermes responds to every message. No `@mention` needed. | +| **Server channels** | By default, Hermes only responds when you `@mention` it. If you post in a channel without mentioning it, Hermes ignores the message. | +| **Free-response channels** | You can make specific channels mention-free with `DISCORD_FREE_RESPONSE_CHANNELS`, or disable mentions globally with `DISCORD_REQUIRE_MENTION=false`. | +| **Threads** | Hermes replies in the same thread. Mention rules still apply unless that thread or its parent channel is configured as free-response. | + +:::tip +If you want a normal shared bot channel where people can talk to Hermes without tagging it every time, add that channel to `DISCORD_FREE_RESPONSE_CHANNELS`. +::: + This guide walks you through the full setup process — from creating your bot on Discord's Developer Portal to sending your first message. ## Step 1: Create a Discord Application @@ -200,12 +215,6 @@ DISCORD_HOME_CHANNEL_NAME="#bot-updates" Replace the ID with the actual channel ID (right-click → Copy Channel ID with Developer Mode on). -## Bot Behavior - -- **Server channels**: By default the bot requires an `@mention` before it responds in server channels. You can disable that globally with `DISCORD_REQUIRE_MENTION=false` or allow specific channels to be mention-free via `DISCORD_FREE_RESPONSE_CHANNELS`. -- **Direct messages**: DMs always work, even without the Message Content Intent enabled (Discord exempts DMs from this requirement). However, you should still enable the intent for server channel support. -- **Conversations**: Each channel or DM maintains its own conversation context. - ## Voice Messages Hermes Agent supports Discord voice messages: