diff --git a/.github/actions/setup-pm/action.yml b/.github/actions/setup-pm/action.yml index af86ab8643..787a5bea0c 100644 --- a/.github/actions/setup-pm/action.yml +++ b/.github/actions/setup-pm/action.yml @@ -112,14 +112,16 @@ runs: # The key covers the PM code and this action, not only the lock: a # provisioning change must re-run the cold path on every lane rather - # than restore a store the previous code wrote. + # than restore a store the previous code wrote. The prepare step above + # imports pm and writes pm/__pycache__; that bytecode embeds the source + # mtime of this checkout, so it is excluded or no two jobs share a key. - name: Cache verified PM tools id: tools-cache if: inputs.cache == 'true' && inputs.save-tools-cache == 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.prepare.outputs.store }} - key: setup-pm-tools-v1-${{ steps.prepare.outputs.target }}-${{ inputs.toolchain }}${{ steps.prepare.outputs.extra-packages }}-${{ hashFiles('pm/**', '.github/actions/setup-pm/**', 'scripts/ci/setup_toolchain.py') }}-${{ inputs.cache-suffix }} + key: setup-pm-tools-v1-${{ steps.prepare.outputs.target }}-${{ inputs.toolchain }}${{ steps.prepare.outputs.extra-packages }}-${{ hashFiles('pm/**', '.github/actions/setup-pm/**', 'scripts/ci/setup_toolchain.py', '!**/__pycache__/**') }}-${{ inputs.cache-suffix }} - name: Restore verified PM tools without saving id: tools-cache-restore @@ -127,7 +129,7 @@ runs: uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.prepare.outputs.store }} - key: setup-pm-tools-v1-${{ steps.prepare.outputs.target }}-${{ inputs.toolchain }}${{ steps.prepare.outputs.extra-packages }}-${{ hashFiles('pm/**', '.github/actions/setup-pm/**', 'scripts/ci/setup_toolchain.py') }}-${{ inputs.cache-suffix }} + key: setup-pm-tools-v1-${{ steps.prepare.outputs.target }}-${{ inputs.toolchain }}${{ steps.prepare.outputs.extra-packages }}-${{ hashFiles('pm/**', '.github/actions/setup-pm/**', 'scripts/ci/setup_toolchain.py', '!**/__pycache__/**') }}-${{ inputs.cache-suffix }} - name: Preserve and retrieve the pinned toolchain if: inputs.archive-inputs == 'true' diff --git a/acp_adapter/commands.py b/acp_adapter/commands.py index f452e381ed..66e915f603 100644 --- a/acp_adapter/commands.py +++ b/acp_adapter/commands.py @@ -151,7 +151,7 @@ class SlashCommandsMixin: try: from model_tools import get_tool_definitions from types import SimpleNamespace - from agent.memory_manager import inject_memory_provider_tools + from agent.memory_manager_tools import inject_memory_provider_tools toolsets = _expand_acp_enabled_toolsets(getattr(state.agent, "enabled_toolsets", None)) tools = get_tool_definitions( diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 3d3ae4e4c0..8baa96fc0e 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -445,7 +445,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent): return try: from model_tools import get_tool_definitions - from agent.memory_manager import inject_memory_provider_tools + from agent.memory_manager_tools import inject_memory_provider_tools agent = state.agent agent.enabled_toolsets = _expand_acp_enabled_toolsets( diff --git a/agent/agent_init.py b/agent/agent_init.py index 65f5e3d7c2..59006475de 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1379,7 +1379,7 @@ def _init_memory(agent, _agent_cfg, skip_memory, platform, memory_manager=None): _ra().logger.warning("Memory provider plugin init failed: %s", _mpe) agent._memory_manager = None - from agent.memory_manager import inject_memory_provider_tools + from agent.memory_manager_tools import inject_memory_provider_tools inject_memory_provider_tools(agent) @@ -2132,7 +2132,7 @@ def _inject_context_engine_tools(agent): _existing_tool_names = { t.get("function", {}).get("name") for t in agent.tools if isinstance(t, dict) } - from agent.memory_manager import normalize_tool_schema + from agent.memory_manager_tools import normalize_tool_schema for _raw_schema in agent.context_compressor.get_tool_schemas(): _schema = normalize_tool_schema(_raw_schema) if _schema is None: diff --git a/agent/display.py b/agent/display.py index 83e8d9a49e..590be47d76 100644 --- a/agent/display.py +++ b/agent/display.py @@ -1079,9 +1079,60 @@ def _cute_path(p) -> str: return "." * limit if limit <= 3 else "..." + p[-(limit - 3):] +class _CuteRow(str): + """A rendered completion row that also keeps its emoji, verb and detail, so the CLI's glyph + layout can rebuild the row without re-parsing the padded ``┊ emoji verb`` string.""" + + emoji: str + verb: str + detail: str + + def __new__(cls, text: str, emoji: str, verb: str, detail: str): + row = super().__new__(cls, text) + row.emoji, row.verb, row.detail = emoji.strip(), verb, detail + return row + + def __reduce__(self): # copy/pickle as a plain str; the custom __new__ can't be re-called bare + return (str, (str(self),)) + + def _cute_row(emoji: str, verb: str, detail: str = "") -> str: """``┊ {emoji} {verb:9} {detail}`` -- verb column from ``display.cute.verb.`` (9 cells wide).""" - return f"┊ {emoji} {t(f'display.cute.verb.{verb}'):9} {detail}" + label = t(f'display.cute.verb.{verb}') + return _CuteRow(f"┊ {emoji} {label:9} {detail}", emoji, label, detail) + + +def _cute_fallback_row(tool_name: str, args: dict) -> _CuteRow: + """The row for a tool with no dedicated renderer: its registered (or skin) emoji, its name, and + its preview. One source for both layouts, so their icons can't drift.""" + detail = _cute_trunc(build_tool_preview(tool_name, args) or "") + emoji = get_tool_emoji(tool_name) + return _CuteRow(f"┊ {emoji} {tool_name[:9]:9} {detail}", emoji, tool_name, detail) + + +def get_tool_row_parts(tool_name: str, args: dict | None, result: str | None = None, *, is_error: bool = False): + """``[(emoji, verb, detail, failure), ...]`` for one completed call: the parts the ``┊`` line + uses, without its padding. ``failure`` is None for a row that succeeded, else that row's + suffix (``" [exit 1]"``, possibly ``""``); batch calls mark the entry that failed.""" + try: + args = redact_tool_args_for_display(tool_name, args) or args or {} + is_failure, suffix = _detect_tool_failure(tool_name, result) + call_failure = suffix if is_failure else ("" if is_error else None) + labels = bridge_tool_labels(tool_name, args) + if labels: + suffixes = _bridge_row_suffixes(len(labels), result, suffix if is_failure else "") + marks = [s or None for s in suffixes] + if call_failure is not None and not any(suffixes): + marks[-1] = call_failure # the call failed without naming an entry + return [(lb.emoji, _cute_trunc(lb.text), _cute_trunc(lb.preview) if lb.preview else "", mark) + for lb, mark in zip(labels, marks)] + render = _CUTE_LINES.get(tool_name) + row = render(args, result) if render else None + row = row if isinstance(row, _CuteRow) else _cute_fallback_row(tool_name, args) + return [(row.emoji, row.verb, row.detail, call_failure)] + except Exception: + logger.debug("Tool row parts failed for %s", tool_name, exc_info=True) + return [("⚡", str(tool_name), "", None)] def _cute_web_extract(a: dict, _r) -> str: @@ -1257,7 +1308,7 @@ def _get_cute_tool_message(tool_name: str, args: dict, duration: float, result: render = _CUTE_LINES.get(tool_name) rows = _cute_bridge_rows(tool_name, args) if rows is None: - body = render(args, result) if render else f"┊ ⚡ {tool_name[:9]:9} {_cute_trunc(build_tool_preview(tool_name, args) or '')}" + body = render(args, result) if render else _cute_fallback_row(tool_name, args) rows, suffixes = [body], [failure_suffix if is_failure else ""] else: suffixes = _bridge_row_suffixes(len(rows), result, failure_suffix if is_failure else "") diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 3938594c5c..addf3917fe 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -531,19 +531,46 @@ _REASONING_REQUIRED_MARKERS = ( "always enabled", "cannot be turned off", ) +# Same rejection, wording by *value*: an endpoint that publishes a closed level set refuses the +# thinking-OFF encoding because ``none`` is not in it — "'reasoning_effort' must be one of: +# 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'" (Alibaba token-plan / DashScope +# routes, code ``invalid_parameter_error``). The field is understood and has to stay on the wire; +# only the disabled level is invalid, so the floor step-up (the weakest accepted level) is the +# right recovery. Neither the strip rung nor its "unsupported" wording matches this message, so +# without these markers the title lane fails outright instead of retrying at the floor. +_REASONING_LEVEL_REJECTION_MARKERS = ("must be one of", "invalid value for") -def is_reasoning_required_rejection(error_msg: str) -> bool: - """Provider 400 saying the model's reasoning cannot be switched OFF ("Reasoning is mandatory for - this endpoint and cannot be disabled", the Nous Portal on gpt-6-astra). The opposite of - ``is_reasoning_field_rejection``: the field is understood, the *disable* is refused, so the right - reaction is to step the effort up to the lowest level rather than drop the field (a dropped field - also works, but tells the caller nothing about the next call).""" + +def is_reasoning_level_rejection(error_msg: str) -> bool: + """Provider 400 rejecting the *value* of a reasoning wire control against the endpoint's closed + level set ("'reasoning_effort' must be one of: 'minimal', 'low', …", Alibaba token-plan / + DashScope routes, code ``invalid_parameter_error``). The reasoning-field token must be present + next to the wording — a 400 enum complaint about some other field is not this class. The field is + understood and has to stay on the wire; only the disabled level ("none") is invalid, so the + recovery is the floor step-up in the auxiliary ladder and the disable-drop in the main loop, not + the strip rung and not an abort to the fallback chain.""" msg = (error_msg or "").lower() token = _REASONING_FIELD_TOKEN.search(msg) if token is None: return False near = msg[max(0, token.start() - 48):token.end() + 96] - return any(m in near for m in _REASONING_REQUIRED_MARKERS) + return any(m in near for m in _REASONING_LEVEL_REJECTION_MARKERS) + + +def is_reasoning_required_rejection(error_msg: str) -> bool: + """Provider 400 saying the model's reasoning cannot be switched OFF ("Reasoning is mandatory for + this endpoint and cannot be disabled", the Nous Portal on gpt-6-astra) — or refusing the OFF + *level* because the endpoint's closed set starts at ``minimal`` + (``is_reasoning_level_rejection``). The opposite of ``is_reasoning_field_rejection``: the field is + understood, the *disable* is refused, so the right reaction is to step the effort up to the + lowest level rather than drop the field (a dropped field also works, but tells the caller nothing + about the next call).""" + msg = (error_msg or "").lower() + token = _REASONING_FIELD_TOKEN.search(msg) + if token is None: + return False + near = msg[max(0, token.start() - 48):token.end() + 96] + return any(m in near for m in _REASONING_REQUIRED_MARKERS) or is_reasoning_level_rejection(msg) def is_reasoning_field_rejection(error_msg: str) -> bool: @@ -1170,10 +1197,14 @@ def _classify_400(c: _Ctx) -> Verdict: ): return _V_INVALID_ENCRYPTED # Route rejecting a reasoning disable: a reasoning-mandatory route (GLM-5.3 on Nous Portal / - # OpenRouter) or a chat-only relay that does not accept ``reasoning_effort: none`` at all - # (#114460). Deterministic for the request shape, but the only bad field is the disable — the - # loop drops it and retries once. Must precede request-validation, which would abort as format_error. - if _REASONING_MANDATORY_PATTERN in msg or is_reasoning_field_rejection(msg): + # OpenRouter), a chat-only relay that does not accept ``reasoning_effort: none`` at all + # (#114460), or an endpoint whose closed level set starts above the disable ("must be one of: + # 'minimal', …" — Alibaba token-plan). Deterministic for the request shape, but the only bad + # value is the disable — the loop drops it (or, when the disable is refused rather than the + # field, steps the effort up to the floor) and retries once. Must precede request-validation, + # which would abort as format_error. + if (_REASONING_MANDATORY_PATTERN in msg or is_reasoning_field_rejection(msg) + or is_reasoning_level_rejection(msg)): return _V_REASONING_MANDATORY # 400 blaming a field this route never sent (Codex OAuth injects then rejects # prompt_cache_retention ~20% of the time): transient, retry identical request. diff --git a/agent/memory_manager.py b/agent/memory_manager.py index fec9ec8d7d..c6525e0c1c 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -12,6 +12,7 @@ import json import logging import re import threading +import time from concurrent.futures import Future, ThreadPoolExecutor, wait from functools import partial from typing import Any, Callable, Dict, List, Optional @@ -19,11 +20,14 @@ from typing import Any, Callable, Dict, List, Optional from agent.redact import redact_for_egress from agent.memory_caller_scope import bound_scope, caller_scope, claim_instance, in_owner_home, refused_tool_message -# Moved to agent.memory_context; still importable here because agent.memory_manager is a public +# Moved to agent.memory_context / agent.memory_manager_tools; still importable here because agent.memory_manager is a public # module for plugins (hermes_cli/plugin_validate_private_imports.py). from agent.memory_context import StreamingContextScrubber, build_memory_context_block, sanitize_context +from agent.memory_manager_tools import (inject_memory_provider_tools, memory_provider_tools_enabled, + memory_provider_tools_exposed, normalize_tool_schema) from agent.memory_provider import MemoryProvider, PRE_COMPRESS_CHECKPOINT_API_VERSION, ctx_bound, spawn_context_thread from agent.skill_commands import extract_user_instruction_from_skill_message +from hermes_constants import hermes_home_key from tools.hook_output_spill import get_spill_config, spill_if_oversized from tools.registry import tool_error @@ -36,6 +40,21 @@ _LEGACY_PRE_COMPRESS_API_VERSION = 1 # blocks interpreter exit. _SYNC_DRAIN_TIMEOUT_S = 5.0 _EXTERNAL_PREFETCH_TIMEOUT_S = 8.0 +# A background provider call (sync_turn / queue_prefetch) running longer than this is treated as +# stuck. Well above the shutdown drain bound and the prefetch timeout so a slow-but-healthy backend +# is never counted. +_BACKGROUND_CALL_STALL_S = 30.0 +# Admission for external provider calls per (home, provider); see _StuckCallGate. Python cannot stop a +# thread, so every stuck call pins one until it returns. New calls stop once this many calls that started +# after the provider's last successful return are overdue; older orphans belong to an outage it has +# since recovered from. +_MAX_STUCK_CALLS = 4 +# While the gate is closed, one call per interval goes through, so a recovered backend proves itself +# without waiting for an orphan to return. +_PROBE_INTERVAL_S = 60.0 +# Hard cap on calls in flight per (home, provider), orphans included: failed probes pin one more thread +# each, so admission (probes too) stops here and a backend that stays wedged pins a bounded number. +_MAX_INFLIGHT_CALLS = 16 # -- Signature introspection (providers are duck-typed; call shapes vary) ----- @@ -65,105 +84,6 @@ def _accepts_require_checkpoint(fn: Callable[..., Any]) -> bool: return _has_var_kwargs(params) or kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) -# -- Tool-schema plumbing ----------------------------------------------------- - -def normalize_tool_schema(schema: Any) -> Optional[dict[str, Any]]: - """Return a bare function-tool dict with a resolvable top-level ``name``, else None. - - Providers should return ``{"name", "description", "parameters"}`` but some return the - wrapped OpenAI form; wrapping that twice yields a nameless ``function`` and strict - providers (DeepSeek) reject the ENTIRE request, so both shapes are normalized here. - """ - if not isinstance(schema, dict): - return None - if schema.get("type") == "function" and isinstance(schema.get("function"), dict): - schema = schema["function"] - name = schema.get("name", "") - return schema if name and isinstance(name, str) else None - - -def memory_provider_tools_enabled(enabled_toolsets: Optional[list[str]], disabled_toolsets: Optional[list[str]] = None, - *, memory_tool_present: bool = False) -> bool: - """Return whether external memory-provider tools should be exposed.""" - if disabled_toolsets and "memory" in disabled_toolsets: - return False - if memory_tool_present or enabled_toolsets is None: - return True - if not enabled_toolsets: - return False - if "memory" in enabled_toolsets: - return True - try: - from toolsets import resolve_toolset - - return any("memory" in resolve_toolset(name) for name in enabled_toolsets) - except Exception: - logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True) - return False - - -def _tool_name(tool: Any) -> Any: - return tool.get("function", {}).get("name") if isinstance(tool, dict) else None - - -def memory_provider_tools_exposed(agent: Any) -> bool: - """Whether external memory-provider tools are exposed on ``agent``. - - Same gate as ``inject_memory_provider_tools`` so a provider's ``system_prompt_block()`` - never advertises tools absent from the tool surface. - """ - tools = getattr(agent, "tools", None) - present = isinstance(tools, (list, tuple)) and any(_tool_name(t) == "memory" for t in tools) - enabled, disabled = getattr(agent, "enabled_toolsets", None), getattr(agent, "disabled_toolsets", None) - return memory_provider_tools_enabled(enabled, disabled, memory_tool_present=present) - - -def inject_memory_provider_tools(agent: Any) -> int: - """Append external memory-provider tool schemas to an agent tool surface; return count added.""" - memory_manager = getattr(agent, "_memory_manager", None) - tools = getattr(agent, "tools", None) - if not memory_manager or tools is None: - return 0 - - if not memory_provider_tools_exposed(agent): - # Say so once: a silent 0 leaves the provider looking "half on" with no clue which - # config key (platform_toolsets / disabled_toolsets) gated it. - # See #81014. - _providers = [p for p in getattr(memory_manager, "providers", None) or [] - if getattr(p, "name", "") != "builtin"] - if _providers: - logger.info( - "Memory provider(s) %s configured but the 'memory' toolset is " - "gated off for this session (platform_toolsets / " - "agent.disabled_toolsets) — provider tools and system-prompt " - "block are both withheld.", - [getattr(p, "name", type(p).__name__) for p in _providers], - ) - return 0 - - get_schemas = getattr(memory_manager, "get_all_tool_schemas", None) - if not callable(get_schemas): - return 0 - - if getattr(agent, "valid_tool_names", None) is None: - agent.valid_tool_names = set() - existing_tool_names = {_tool_name(tool) for tool in tools if isinstance(tool, dict)} - added = 0 - for raw_schema in get_schemas(): - schema = normalize_tool_schema(raw_schema) - if schema is None: - logger.warning( - "Memory provider returned a tool schema with no resolvable " - "name; skipping to avoid poisoning the request (%r)", raw_schema, - ) - elif schema["name"] not in existing_tool_names: - tools.append({"type": "function", "function": schema}) - agent.valid_tool_names.add(schema["name"]) - existing_tool_names.add(schema["name"]) - added += 1 - return added - - # -- Provider egress scrubbing ----------------------------------------------- def _scrub(text: str) -> str: @@ -219,6 +139,60 @@ def _redact_messages_for_egress(messages: Optional[list[Any]]) -> Optional[list[ return None if messages is None else list(_redact_for_provider(messages)) +class _StuckCallGate: + """Process-wide admission for one kind of external provider call, keyed by (Hermes home, provider). + + A new MemoryManager is built per agent, so a per-instance view would let every fresh manager start + one more call against a hung backend and pin one more thread. Shared, the gate closes once + _MAX_STUCK_CALLS calls that started after the key's last successful return are past their deadline, + admits one half-open probe per _PROBE_INTERVAL_S while closed, and never lets more than + _MAX_INFLIGHT_CALLS be in flight. Calls not yet overdue are not counted, so healthy concurrent + sessions run side by side. Each call removes only its own entry. + """ + + def __init__(self) -> None: + self.lock = threading.Lock() + self.calls: dict[tuple[str, str], dict[object, tuple[float, float]]] = {} # token -> (start, deadline) + self.last_ok: dict[tuple[str, str], float] = {} + self.probe_at: dict[tuple[str, str], float] = {} # interval start: gate close, then each probe + self.skips: dict[tuple[str, str], int] = {} + + def enter(self, key: tuple[str, str], token: object, timeout: float) -> Optional[tuple[int, int, int]]: + """Register ``token`` and return None, or ``(stuck, in_flight, skips)`` when the call is skipped.""" + now = time.monotonic() + with self.lock: + calls = self.calls.setdefault(key, {}) + last_ok = self.last_ok.get(key, float("-inf")) + stuck = sum(start > last_ok and now > deadline for start, deadline in calls.values()) + if len(calls) >= _MAX_INFLIGHT_CALLS: + admit = False + elif stuck < _MAX_STUCK_CALLS: + admit = True + self.probe_at.pop(key, None) + self.skips.pop(key, None) # gate open: the next outage warns on its first skip + else: + admit = now - self.probe_at.setdefault(key, now) >= _PROBE_INTERVAL_S + if admit: + self.probe_at[key] = now + if not admit: + skips = self.skips[key] = self.skips.get(key, 0) + 1 + return stuck, len(calls), skips + calls[token] = (now, now + timeout) + return None + + def leave(self, key: tuple[str, str], token: object, *, ok: bool) -> None: + with self.lock: + calls = self.calls.get(key) + if calls is None or calls.pop(token, None) is None: + return + if calls: + if ok: + self.last_ok[key] = time.monotonic() + return + for state in (self.calls, self.last_ok, self.probe_at, self.skips): + state.pop(key, None) + + class MemoryManager: """Builtin provider (always first) plus at most one external provider. @@ -226,6 +200,12 @@ class MemoryManager: swallows per-provider exceptions. """ + # External prefetch threads (#134392) and background sync_turn / queue_prefetch calls. The home is + # part of the key because one gateway process can serve several profiles, each with its own config + # and credentials for a same-named provider: one profile's hung backend must not silence another's. + _PREFETCH_GATE = _StuckCallGate() + _BACKGROUND_GATE = _StuckCallGate() + def __init__(self, *, external_prefetch_timeout: Optional[float] = None) -> None: self._providers: list[MemoryProvider] = [] self._tool_to_provider: dict[str, MemoryProvider] = {} @@ -236,8 +216,8 @@ class MemoryManager: if timeout <= 0: raise ValueError("external_prefetch_timeout must be positive") self._external_prefetch_timeout = timeout - self._external_prefetch_threads: dict[str, threading.Thread] = {} - self._external_prefetch_lock = threading.Lock() + # Captured at build time, inside the profile scope the agent is built for. + self._home_key = hermes_home_key() # Single-worker background executor for end-of-turn sync/prefetch, created lazily so # the builtin-only path spawns no threads; one worker serializes a provider's writes. self._sync_executor: Optional[ThreadPoolExecutor] = None @@ -246,6 +226,7 @@ class MemoryManager: # within a bound, then report exactly what it abandoned. self._background_futures: dict[Future, str] = {} self._shutting_down = False + self._teardown_claim: Optional[str] = None # see claim_teardown() self._shutdown_drain_state: dict[str, Any] = { "status": "not_started", "abandoned_writes": 0, "abandoned_prefetches": 0, "active_tasks": 0, } @@ -375,38 +356,44 @@ class MemoryManager: def _prefetch_provider(self, provider: MemoryProvider, query: str, *, session_id: str = "") -> str: """Run one provider's prefetch; external providers are bounded by a timeout. A stuck external - call keeps running on its daemon thread and the provider is skipped on later turns until it returns.""" + call keeps running on its daemon thread; _PREFETCH_GATE decides whether later turns call again.""" if provider.name == "builtin": return provider.prefetch(query, session_id=session_id) result_box: dict[str, Any] = {} + key = self._registry_key(provider) + def _run() -> None: try: result_box["value"] = provider.prefetch(query, session_id=session_id) or "" except Exception as exc: # pragma: no cover - re-raised by caller result_box["error"] = exc + finally: + self._PREFETCH_GATE.leave(key, thread, ok="error" not in result_box) thread = spawn_context_thread(_run, name=f"memory-prefetch-{provider.name}") - with self._external_prefetch_lock: - existing = self._external_prefetch_threads.get(provider.name) - if existing is not None and existing.is_alive(): - logger.debug("Memory provider '%s' prefetch is still running; skipping this turn", provider.name) - return "" - self._external_prefetch_threads[provider.name] = thread + skipped = self._PREFETCH_GATE.enter(key, thread, self._external_prefetch_timeout) + if skipped: + logger.debug("Memory provider '%s' prefetch skipped (%d stuck, %d in flight)", + provider.name, *skipped[:2]) + return "" + try: thread.start() + except RuntimeError: + # Never started, so its finally never runs: drop the entry or it would count forever. + self._PREFETCH_GATE.leave(key, thread, ok=False) + raise thread.join(self._external_prefetch_timeout) if thread.is_alive(): logger.warning( - "Memory provider '%s' prefetch timed out after %.1fs; skipping it until " - "the stuck call returns", provider.name, self._external_prefetch_timeout, + "Memory provider '%s' prefetch timed out after %.1fs; the call keeps running in the " + "background (later turns may skip it while the provider stays stuck)", provider.name, + self._external_prefetch_timeout, ) return "" - with self._external_prefetch_lock: - if self._external_prefetch_threads.get(provider.name) is thread: - self._external_prefetch_threads.pop(provider.name, None) if "error" in result_box: raise result_box["error"] result = result_box.get("value", "") @@ -440,7 +427,8 @@ class MemoryManager: return clean_query = _redact_for_provider(clean_query) self._submit_background(lambda: self._each_provider( - "queue_prefetch failed (non-fatal)", lambda p: p.queue_prefetch(clean_query, session_id=session_id), + "queue_prefetch failed (non-fatal)", + lambda p: self._call_unless_stalled(p, lambda: p.queue_prefetch(clean_query, session_id=session_id)), providers=providers, ), kind="prefetch") @@ -476,7 +464,8 @@ class MemoryManager: for keyword, value in optional_kwargs.items(): if value is not None and self._provider_sync_accepts(provider, keyword): kwargs[keyword] = value - provider.sync_turn(clean_user_content, assistant_content, **kwargs) + self._call_unless_stalled( + provider, lambda: provider.sync_turn(clean_user_content, assistant_content, **kwargs)) self._submit_background( lambda: self._each_provider("sync_turn failed", _sync, level=logging.WARNING, providers=providers) @@ -521,6 +510,37 @@ class MemoryManager: except Exception as e: # pragma: no cover - fn guards internally logger.debug("Inline memory background task failed: %s", e) + def _call_unless_stalled(self, provider: MemoryProvider, call: Callable[[], Any]) -> None: + """Run a background provider call unless _BACKGROUND_GATE skips it (the provider has stuck calls); + skipping costs that turn's write/prefetch but stops a wedged backend from pinning one more + thread per fresh manager. The builtin provider is never gated.""" + if provider.name == "builtin": + call() + return + key = self._registry_key(provider) + token = object() + skipped = self._BACKGROUND_GATE.enter(key, token, _BACKGROUND_CALL_STALL_S) + if skipped: + stuck, in_flight, skips = skipped + if skips == 1 or skips % 100 == 0: + logger.warning( + "Memory provider '%s' has %d background calls stuck for over %.0fs (%d in flight); " + "skipping its sync/prefetch until it recovers (%d skipped)", provider.name, stuck, + _BACKGROUND_CALL_STALL_S, in_flight, skips, + ) + return + ok = False + try: + call() + ok = True + finally: + self._BACKGROUND_GATE.leave(key, token, ok=ok) + + def _registry_key(self, provider: MemoryProvider) -> tuple[str, str]: + """Process-wide registry key: the home the providers were bound to (``initialize_all``, which may + name another profile than the one this manager was built in), else the build-time home.""" + return ((self._bound_scope or {}).get("home_key") or self._home_key, provider.name) + def _forget_background_future(self, future: Future) -> None: with self._sync_executor_lock: self._background_futures.pop(future, None) @@ -815,6 +835,26 @@ class MemoryManager: lambda p: p.on_delegation(task, result, child_session_id=child_session_id, **kwargs), ) + def claim_teardown(self, *, session_end: bool) -> bool: + """Claim extraction + ``shutdown_all()`` for end-of-session shutdown or soft release. The first + claimer wins and the loser skips, so the two never overlap and providers (no idempotency + contract) are never shut down twice. Session-end callers may re-claim (the gateway claims before + its flush, then ``shutdown_memory_provider`` claims again); a soft release is once-only and also + loses to a manager another owner is already draining.""" + with self._sync_executor_lock: + if self._teardown_claim is None and (session_end or not self._shutting_down): + self._teardown_claim = "session_end" if session_end else "soft_release" + return True + return session_end and self._teardown_claim == "session_end" + + def release_soft(self) -> None: + """Soft release (cache eviction, CLI agent rebuild): stop provider writer threads, which otherwise leak + one per evicted agent, WITHOUT on_session_end(): sweeps commit first and a pending session end owns + both. Flush first, since shutdown_all()'s short drain would cancel writes the old worker finished.""" + if self.claim_teardown(session_end=False): + self.flush_pending(timeout=10) + self.shutdown_all() + def shutdown_all(self) -> None: """Drain the background executor (bounded), then shut providers down in reverse order.""" self._drain_sync_executor() diff --git a/agent/memory_manager_tools.py b/agent/memory_manager_tools.py new file mode 100644 index 0000000000..9ea93c833c --- /dev/null +++ b/agent/memory_manager_tools.py @@ -0,0 +1,106 @@ +"""Memory-provider tool-schema plumbing: the gate that decides whether external memory-provider +tools are exposed on an agent's tool surface, and the injection of their schemas.""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +def normalize_tool_schema(schema: Any) -> Optional[dict[str, Any]]: + """Return a bare function-tool dict with a resolvable top-level ``name``, else None. + + Providers should return ``{"name", "description", "parameters"}`` but some return the + wrapped OpenAI form; wrapping that twice yields a nameless ``function`` and strict + providers (DeepSeek) reject the ENTIRE request, so both shapes are normalized here. + """ + if not isinstance(schema, dict): + return None + if schema.get("type") == "function" and isinstance(schema.get("function"), dict): + schema = schema["function"] + name = schema.get("name", "") + return schema if name and isinstance(name, str) else None + + +def memory_provider_tools_enabled(enabled_toolsets: Optional[list[str]], disabled_toolsets: Optional[list[str]] = None, + *, memory_tool_present: bool = False) -> bool: + """Return whether external memory-provider tools should be exposed.""" + if disabled_toolsets and "memory" in disabled_toolsets: + return False + if memory_tool_present or enabled_toolsets is None: + return True + if not enabled_toolsets: + return False + if "memory" in enabled_toolsets: + return True + try: + from toolsets import resolve_toolset + + return any("memory" in resolve_toolset(name) for name in enabled_toolsets) + except Exception: + logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True) + return False + + +def _tool_name(tool: Any) -> Any: + return tool.get("function", {}).get("name") if isinstance(tool, dict) else None + + +def memory_provider_tools_exposed(agent: Any) -> bool: + """Whether external memory-provider tools are exposed on ``agent``. + + Same gate as ``inject_memory_provider_tools`` so a provider's ``system_prompt_block()`` + never advertises tools absent from the tool surface. + """ + tools = getattr(agent, "tools", None) + present = isinstance(tools, (list, tuple)) and any(_tool_name(t) == "memory" for t in tools) + enabled, disabled = getattr(agent, "enabled_toolsets", None), getattr(agent, "disabled_toolsets", None) + return memory_provider_tools_enabled(enabled, disabled, memory_tool_present=present) + + +def inject_memory_provider_tools(agent: Any) -> int: + """Append external memory-provider tool schemas to an agent tool surface; return count added.""" + memory_manager = getattr(agent, "_memory_manager", None) + tools = getattr(agent, "tools", None) + if not memory_manager or tools is None: + return 0 + + if not memory_provider_tools_exposed(agent): + # Say so once: a silent 0 leaves the provider looking "half on" with no clue which + # config key (platform_toolsets / disabled_toolsets) gated it. + # See #81014. + _providers = [p for p in getattr(memory_manager, "providers", None) or [] + if getattr(p, "name", "") != "builtin"] + if _providers: + logger.info( + "Memory provider(s) %s configured but the 'memory' toolset is " + "gated off for this session (platform_toolsets / " + "agent.disabled_toolsets) — provider tools and system-prompt " + "block are both withheld.", + [getattr(p, "name", type(p).__name__) for p in _providers], + ) + return 0 + + get_schemas = getattr(memory_manager, "get_all_tool_schemas", None) + if not callable(get_schemas): + return 0 + + if getattr(agent, "valid_tool_names", None) is None: + agent.valid_tool_names = set() + existing_tool_names = {_tool_name(tool) for tool in tools if isinstance(tool, dict)} + added = 0 + for raw_schema in get_schemas(): + schema = normalize_tool_schema(raw_schema) + if schema is None: + logger.warning( + "Memory provider returned a tool schema with no resolvable " + "name; skipping to avoid poisoning the request (%r)", raw_schema, + ) + elif schema["name"] not in existing_tool_names: + tools.append({"type": "function", "function": schema}) + agent.valid_tool_names.add(schema["name"]) + existing_tool_names.add(schema["name"]) + added += 1 + return added diff --git a/agent/system_prompt.py b/agent/system_prompt.py index dc09facac3..03ede0060d 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -534,7 +534,7 @@ def _memory_parts(agent: Any) -> list[str]: # configuration has already gated off (#81014). if agent._memory_manager: try: - from agent.memory_manager import memory_provider_tools_exposed as _mem_exposed + from agent.memory_manager_tools import memory_provider_tools_exposed as _mem_exposed except Exception: _mem_exposed = None if _mem_exposed is None or _mem_exposed(agent): diff --git a/apps/desktop/electron/local-gateway-python-bundled.test.ts b/apps/desktop/electron/local-gateway-python-bundled.test.ts new file mode 100644 index 0000000000..7a10189c2a --- /dev/null +++ b/apps/desktop/electron/local-gateway-python-bundled.test.ts @@ -0,0 +1,66 @@ +import fs from 'node:fs/promises' +import net from 'node:net' +import path from 'node:path' + +import { expect, test } from 'vitest' + +import type { InstallStamp } from './install-stamp' +import { closeGatewayTicketBridges, createGatewayTicketResolver } from './local-gateway-python' +import { shortSocketTmpDir } from './local-gateway.test-helpers' +import { bundledPayload, payloadTicketRuntime } from './payload-backend' + +// #138906 (RT-21): the Store/MSIX bundled backend is `kind: 'python'` but its command is the +// payload's CLI shim (bin/hermes.exe), which rejects the ticket helper's `-P -u -c`. This runs +// the real resolver and helper process against a payload laid out the way the builder stamps it. +test.skipIf(process.platform === 'win32')('a bundled payload mints tickets with its interpreter, not the CLI shim', async () => { + const home = await shortSocketTmpDir('gw-bundled-') + const resources = await shortSocketTmpDir('gw-bundled-res-') + const repo = path.resolve('../..') + const python = process.env.HERMES_TEST_PYTHON || 'python3' + const root = path.join(resources, 'agent-payload') + await fs.mkdir(path.join(root, 'bin'), { recursive: true }) + await fs.mkdir(path.join(root, 'tools', 'python', 'bin'), { recursive: true }) + await fs.mkdir(path.join(root, 'venv', 'site-packages'), { recursive: true }) + await fs.symlink(repo, path.join(root, 'app')) + // The CLI shim: like hermes.exe, it is not an interpreter and refuses interpreter flags. + await fs.writeFile(path.join(root, 'bin', 'hermes'), '#!/bin/sh\necho "hermes: unrecognized arguments: $1" >&2\nexit 2\n', { mode: 0o755 }) + const { execFileSync } = await import('node:child_process') + const realPython = execFileSync(python, ['-c', 'import sys; print(sys.executable)'], { encoding: 'utf8' }).trim() + await fs.symlink(realPython, path.join(root, 'tools', 'python', 'bin', 'python3')) + + const stamp = { + schemaVersion: 1, commit: 'a'.repeat(40), commitDate: null, branch: null, builtAt: null, dirty: false, + source: 'ci', distribution: 'desktop-app', updateMechanism: 'microsoft-store', baseVersion: null, + displayVersion: null, distance: null, payload: 'bundled', tag: 'v1.0.0', + runtime: { repoDir: 'app', toolsDir: 'tools', storePython: 'tools/python/bin/python3', + sitePackages: 'venv/site-packages', commands: { hermes: 'bin/hermes' } } + } as unknown as InstallStamp + + const payload = bundledPayload(resources, stamp)! + const endpoint = { profile_id: home, instance_id: 'owner', runtime_protocol: 1, control_home: null } + + // The shape resolveHermesBackend() returns for a bundled payload (main.ts). + const backend = { + kind: 'python', command: payload.shim, shell: false, root: payload.repoDir, + env: { PYTHONPATH: '', PYTHONHOME: '', HERMES_RUNTIME_DIR: payload.toolsDir }, ...payloadTicketRuntime(payload) + } + + const server = net.createServer(socket => socket.once('data', () => { + socket.end(JSON.stringify({ protocol: 1, id: 1, ok: true, result: { ...endpoint, ticket: 'bundled-grant' } }) + '\n') + })) + + await new Promise(resolve => server.listen(path.join(home, 'gateway.sock'), resolve)) + await fs.chmod(path.join(home, 'gateway.sock'), 0o600) + await fs.chmod(home, 0o700) + + try { + const mint = createGatewayTicketResolver(async () => backend, () => home) + const ticket = await mint(endpoint, 'native-http') + expect(ticket).toBe('bundled-grant') + } finally { + closeGatewayTicketBridges() + await new Promise(resolve => server.close(() => resolve())) + await fs.rm(home, { recursive: true, force: true }) + await fs.rm(resources, { recursive: true, force: true }) + } +}, 30_000) diff --git a/apps/desktop/electron/local-gateway-python-launcher-argv.test.ts b/apps/desktop/electron/local-gateway-python-launcher-argv.test.ts new file mode 100644 index 0000000000..b03f70fabf --- /dev/null +++ b/apps/desktop/electron/local-gateway-python-launcher-argv.test.ts @@ -0,0 +1,57 @@ +import type * as ChildProcessAPI from 'node:child_process' +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' + +import { afterEach, expect, test, vi } from 'vitest' + +const spawned = vi.hoisted(() => [] as { command: string; args: string[]; options: any }[]) + +vi.mock('node:child_process', async importOriginal => { + const actual = await importOriginal() + + return { ...actual, spawn: (command: string, args: string[], options: any) => { + spawned.push({ command, args, options }) + const child = Object.assign(new EventEmitter(), { stdout: new PassThrough(), kill: () => true }) + setImmediate(() => { + child.stdout.end(JSON.stringify([process.execPath, '-I', '-c', 'bootstrap'])) + child.emit('close', 0) + }) + + return child + } } +}) + +const { ticketRuntimeFor } = await import('./local-gateway-python') + +const realPlatform = process.platform + +afterEach(() => { + Object.defineProperty(process, 'platform', { value: realPlatform }) + spawned.length = 0 +}) + +// RT-10 (#138774): a HERMES_DESKTOP_HERMES=...\hermes.cmd override is a shell backend. With +// shell:true Node hands cmd.exe one unquoted line, which a profile path with a space splits at +// `C:\Users\John` (#74064). The runtime-command probe must quote the launcher like every other +// shell spawn (windowsShellCommand), and keep the probe flag a separate argument. +test('a .cmd override in a path with spaces asks its launcher for the runtime as one quoted token', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + const command = 'C:\\Users\\John Doe\\AppData\\Local\\hermes\\bin\\hermes.cmd' + const root = 'C:\\Users\\John Doe\\AppData\\Local\\hermes\\hermes-agent' + + await expect(ticketRuntimeFor({ command, kind: 'command', shell: true, root })) + .resolves.toEqual({ command: process.execPath, env: { PYTHONPATH: root } }) + expect(spawned).toHaveLength(1) + expect(spawned[0].command).toBe(`"${command}"`) + expect(spawned[0].args).toEqual(['--print-runtime-command']) + expect(spawned[0].options).toMatchObject({ shell: true, cwd: root, windowsHide: true }) +}) + +test('a launcher spawned without a shell keeps its bare path', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + const command = 'C:\\Users\\John Doe\\AppData\\Local\\hermes\\bin\\hermes.exe' + + await ticketRuntimeFor({ command, kind: 'command', shell: false, root: 'C:\\r' }) + expect(spawned[0].command).toBe(command) + expect(spawned[0].options).toMatchObject({ shell: false }) +}) diff --git a/apps/desktop/electron/local-gateway-python-runtime.test.ts b/apps/desktop/electron/local-gateway-python-runtime.test.ts index 85091b4214..5afdabe2d0 100644 --- a/apps/desktop/electron/local-gateway-python-runtime.test.ts +++ b/apps/desktop/electron/local-gateway-python-runtime.test.ts @@ -24,6 +24,22 @@ test('a Python backend is the helper interpreter as-is', async () => { await expect(ticketRuntimeFor(backend)).resolves.toBe(backend) }) +test('a bundled launcher keeps its CLI command but names the payload Python for ticket minting', async () => { + const backend = { + command: '/resources/agent-payload/bin/hermes.exe', + kind: 'python', + shell: false, + env: { HERMES_RUNTIME_DIR: '/resources/agent-payload/tools' }, + ticketCommand: '/resources/agent-payload/tools/python/python.exe', + ticketEnv: { PYTHONPATH: '/resources/agent-payload/repo;/resources/agent-payload/venv/Lib/site-packages' } + } + + await expect(ticketRuntimeFor(backend)).resolves.toEqual({ + command: backend.ticketCommand, + env: { ...backend.env, ...backend.ticketEnv } + }) +}) + test.skipIf(process.platform === 'win32')('an installed launcher backend mints with the interpreter it reports', async () => { // The normal Windows install resolves to kind 'command' (the installation launcher). const python = process.execPath diff --git a/apps/desktop/electron/local-gateway-python.ts b/apps/desktop/electron/local-gateway-python.ts index 876a538039..25f6be5afd 100644 --- a/apps/desktop/electron/local-gateway-python.ts +++ b/apps/desktop/electron/local-gateway-python.ts @@ -3,7 +3,7 @@ import { statSync } from 'node:fs' import path from 'node:path' import type { GatewayTicketPurpose } from './local-gateway' -import { hiddenWindowsChildOptions } from './windows-child-options' +import { hiddenWindowsChildOptions, windowsShellCommand } from './windows-child-options' interface TicketEndpoint { profile_id: string @@ -164,6 +164,9 @@ interface ResolvedTicketBackend { shell?: boolean env?: NodeJS.ProcessEnv root?: string + /** A bundled CLI still launches normally, but ticket minting needs its interpreter. */ + ticketCommand?: string + ticketEnv?: NodeJS.ProcessEnv } const RUNTIME_COMMAND_TIMEOUT_MS = 30_000 @@ -173,7 +176,9 @@ function launcherRuntimePython(backend: ResolvedTicketBackend & { root: string } return new Promise((resolve, reject) => { const fail = (detail: string) => reject(new Error(`Gateway ticket bootstrap could not resolve the installed Hermes Python runtime: ${detail}`)) - const child = spawn(backend.command, ['--print-runtime-command'], hiddenWindowsChildOptions({ + // A .cmd override is a shell backend: quote it as the backend spawn does, or cmd.exe + // splits a launcher path with a space at the first one (#74064). + const child = spawn(windowsShellCommand(backend.command, Boolean(backend.shell)), ['--print-runtime-command'], hiddenWindowsChildOptions({ cwd: backend.root, env: { ...process.env, ...backend.env }, shell: Boolean(backend.shell), stdio: ['ignore', 'pipe', 'ignore'] })) @@ -222,6 +227,10 @@ function isFile(candidate: string): boolean { * PYTHONPATH is its whole import closure (-P still keeps the cwd out). */ export async function ticketRuntimeFor(backend: ResolvedTicketBackend): Promise<{ command: string; env?: NodeJS.ProcessEnv }> { + if (backend.ticketCommand) { + return { command: backend.ticketCommand, env: { ...backend.env, ...backend.ticketEnv } } + } + if (backend.kind === 'python' && !backend.shell) { return backend } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b5a846f9cc..1eac74c94c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -417,7 +417,14 @@ import { import { wireOauthSessionResponse } from './oauth-session-response' import { listWindowsProcesses, reapPackageRootedProcesses } from './package-process-reap' import { createParentStartMarkerResolver } from './parent-process-identity' -import { bundledPayload, installIdForRoot, type PayloadInfo, payloadPythonPath } from './payload-backend' +import { + bundledPayload, + installIdForRoot, + type PayloadInfo, + payloadPythonPath, + payloadTicketRuntime, + requireBundledPayload +} from './payload-backend' import { petOverlayClickThrough, shouldPopInOnOverlayClosed } from './pet-overlay' import { placePetOverlay, registerPetOverlayIpc } from './pet-overlay-ipc' import { @@ -3674,21 +3681,6 @@ function createNativePackagedStrategy( return new ExternalStrategy(INSTALL_STAMP) } -/** - * The bundled Python payload for a strategy whose update check runs inside - * it. A stamp that names such a mechanism without a payload is a broken - * install; say so instead of failing on `payload.storePython`. - */ -function requireBundledPayload(mechanism: UpdaterStrategy['mechanism']): PayloadInfo { - const payload: PayloadInfo | null = bundledPayload(process.resourcesPath) - - if (!payload) { - throw new Error(`${mechanism} updates require the bundled application payload, which this install has none of`) - } - - return payload -} - /** * The checkout updater strategy (windows-handoff / posix-handoff). The flow * lives in updater/checkout.ts; this is the composition root that hands it @@ -5026,6 +5018,7 @@ async function resolveHermesBackend(backendArgs: string[]): Promise | null activeRoot?: string isPackaged?: boolean diff --git a/apps/desktop/electron/payload-backend.test.ts b/apps/desktop/electron/payload-backend.test.ts index 4492fd2cca..a52e83705e 100644 --- a/apps/desktop/electron/payload-backend.test.ts +++ b/apps/desktop/electron/payload-backend.test.ts @@ -6,7 +6,7 @@ import path from 'node:path' import { test, vi } from 'vitest' import type { InstallStamp, PayloadRuntime } from './install-stamp' -import { bundledPayload, installIdForRoot, payloadPythonPath } from './payload-backend' +import { bundledPayload, installIdForRoot, payloadPythonPath, payloadTicketRuntime } from './payload-backend' function stamp(runtime: PayloadRuntime, payload: InstallStamp['payload'] = 'bundled'): InstallStamp { return { @@ -66,6 +66,21 @@ test('bundled launch paths come from build metadata without filesystem access', } }) +test('ticket minting runs the payload python, never the CLI shim', () => { + const payload = { + storePython: '/p/tools/python', + repoDir: '/p/repo', + sitePackages: '/p/venv/site-packages', + shim: '/p/bin/hermes' + } + + const runtime = payloadTicketRuntime(payload) + + assert.equal(runtime.ticketCommand, payload.storePython) + assert.notEqual(runtime.ticketCommand, payload.shim) + assert.deepEqual(runtime.ticketEnv, { PYTHONPATH: payloadPythonPath(payload) }) +}) + // ─── update channel helpers ───────────────────────────────────────── test('installIdForRoot matches the Python install id (sha16 of the canonical path)', () => { diff --git a/apps/desktop/electron/payload-backend.ts b/apps/desktop/electron/payload-backend.ts index ba1c2a39f8..a1398f8fe3 100644 --- a/apps/desktop/electron/payload-backend.ts +++ b/apps/desktop/electron/payload-backend.ts @@ -46,6 +46,33 @@ export function payloadPythonPath(payload: Pick): { + ticketCommand: string + ticketEnv: NodeJS.ProcessEnv +} { + return { ticketCommand: payload.storePython, ticketEnv: { PYTHONPATH: payloadPythonPath(payload) } } +} + +/** + * The bundled Python payload for a strategy whose update check runs inside + * it. A stamp that names such a mechanism without a payload is a broken + * install; say so instead of failing on `payload.storePython`. + */ +export function requireBundledPayload(mechanism: string, resourcesPath = process.resourcesPath): PayloadInfo { + const payload: PayloadInfo | null = bundledPayload(resourcesPath) + + if (!payload) { + throw new Error(`${mechanism} updates require the bundled application payload, which this install has none of`) + } + + return payload +} + // ─── update channel ───────────────────────────────────────────────────────── // // The CLI owns the channel records; Electron only reads the install id for diff --git a/apps/desktop/src/api/canonical-protocol.ts b/apps/desktop/src/api/canonical-protocol.ts index 7173b03b53..4d47f6acb9 100644 --- a/apps/desktop/src/api/canonical-protocol.ts +++ b/apps/desktop/src/api/canonical-protocol.ts @@ -246,12 +246,16 @@ export class CanonicalDesktopProtocol { // failure carries no reason; its outcome is unknown and the token stays for the exact retry. if (typeof reason === 'string') { this.settleModelConfirmation(params) } + // Any typed refusal of a rewind wrote nothing. A derived /undo or /retry is retained by operation + // alone, so keeping the refused request would resend it for every later /undo instead of deriving. + if (typeof reason === 'string' && params.operation === 'rewind') { this.forgetMutation(params) } + // A typed busy refusal of a rewind or a move wrote nothing either; the retry is a new request at the new revision. if (reason !== 'revision_conflict' && reason !== 'stale_generation' && !(reason === 'session_busy' && ['rewind', 'workspace'].includes(params.operation as string))) { return } // A confirmed fence refusal did not mutate. Ambiguous transport failures keep // the original revision/id so a retry cannot overwrite another user's edit. - for (const [key, mutation] of this.mutations) { if (mutation.request_id === params.request_id) { this.mutations.delete(key) } } + this.forgetMutation(params) // The owner names its current fences on the refusal: adopt them so the next attempt presents them. const data = (error as { data?: { revision?: unknown; execution_generation?: unknown } })?.data const owner = canonicalSessionKey(params.session_id, params.profile) @@ -289,9 +293,13 @@ export class CanonicalDesktopProtocol { return POLICY_EDIT_METHODS.has(method) && typeof prepared.operation === 'string' ? 'session.mutate' : method } - private retainedMutation(sessionId: unknown, profile: unknown, operation: string, payload: Record, withGeneration: boolean): Record { + // `derived`: the payload was derived from a snapshot (a typed /undo or /retry names the Nth newest user + // turn). Keyed by operation alone, as Ink's mutateCanonical and the classic PreparedMutations do: a + // retry after a lost reply re-presents the original target, never one re-derived from a transcript the + // first attempt already rewound. + private retainedMutation(sessionId: unknown, profile: unknown, operation: string, payload: Record, withGeneration: boolean, derived = false): Record { const owner = canonicalSessionKey(sessionId, profile) - const key = JSON.stringify([owner, operation, payload]) + const key = JSON.stringify([owner, operation, derived ? null : payload]) this.retireMutations(owner, key) const retained = this.mutations.get(key) @@ -319,6 +327,38 @@ export class CanonicalDesktopProtocol { return method !== 'session.create' && params.profile !== undefined ? { ...prepared, profile: params.profile } : prepared } + private fencedMutation(method: string, params: Record, fenced: { operation: string; payload: Record }): Record { + // An edit / regenerate / restore names its target explicitly; a typed /undo or /retry derives it. + const derived = method === 'session.rewind' && params.derived_target === true + + // A replay-only probe only ever re-presents the retained rewind (localAnswer handles the rest). + if (derived && params.replay_only === true && !this.retainsDerivedRewind(params.session_id, params.profile)) { + throw new Error('No retained rewind to replay') + } + + return this.retainedMutation(params.session_id, params.profile, fenced.operation, fenced.payload, true, derived) + } + + // A typed /undo or /retry whose reply was lost is still retained for its exact retry. + private retainsDerivedRewind(sessionId: unknown, profile: unknown): boolean { + return this.mutations.has(JSON.stringify([canonicalSessionKey(sessionId, profile), 'rewind', null])) + } + + // A request this protocol answers without the owner, or undefined. A replay-only /undo or /retry + // probe with no retained rewind owes nothing: no request is sent, and the command derives its target. + localAnswer(method: string, params: Record): Record | undefined { + if (method === 'session.rewind' && params.replay_only === true && !this.retainsDerivedRewind(params.session_id, params.profile)) { + return { session_id: params.session_id, status: 'nothing', rewound_count: 0 } + } + + return undefined + } + + // Drop the retained request the owner answered: its outcome is known, so no retry may resend it. + private forgetMutation(params: Record) { + for (const [key, mutation] of this.mutations) { if (mutation.request_id === params.request_id) { this.mutations.delete(key) } } + } + // Only a session's very next control may retry an ambiguous one: any other verb retires the rest. private retireMutations(owner: string, keep?: string) { for (const key of [...this.mutations.keys()]) { if (key !== keep && JSON.parse(key)[0] === owner) { this.mutations.delete(key) } } @@ -379,7 +419,7 @@ export class CanonicalDesktopProtocol { 'session.rewind': () => ({ operation: 'rewind', payload: { target_message_id: params.target_message_id } }) } as Record { operation: string; payload: Record }>)[method]?.() - if (fenced) { return this.retainedMutation(params.session_id, params.profile, fenced.operation, fenced.payload, true) } + if (fenced) { return this.fencedMutation(method, params, fenced) } if (method === 'session.model') { const payload = { ...(params.payload as Record), ...(typeof params.confirm === 'string' ? { confirm: params.confirm } : {}) } @@ -625,7 +665,7 @@ export class CanonicalDesktopProtocol { private mutationReceipt(method: string, params: Record, value: any): any { if (value.session_id !== params.session_id) { throw new Error('Metadata receipt destination mismatch') } - for (const [key, mutation] of this.mutations) { if (mutation.request_id === params.request_id) { this.mutations.delete(key) } } + this.forgetMutation(params) this.settleModelConfirmation(params) this.retireSupersededMutations(params, value.revision) diff --git a/apps/desktop/src/api/canonical-rewind-lost-reply.test.ts b/apps/desktop/src/api/canonical-rewind-lost-reply.test.ts new file mode 100644 index 0000000000..770d469fa1 --- /dev/null +++ b/apps/desktop/src/api/canonical-rewind-lost-reply.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from 'vitest' + +import { CanonicalDesktopProtocol } from './canonical-protocol' + +// A typed /undo or /retry derives its target from a fresh snapshot. After a lost reply the owner has +// already rewound, so the repeat's snapshot names the turn before: the retained rewind, not a new one at +// that target, must be what the repeat sends (as Ink's mutateCanonical and the classic CLI do). +function primed() { + const protocol = new CanonicalDesktopProtocol() + protocol.result('session.resume', { session_id: 's' }, { session_id: 's', revision: 7, execution_generation: 3 }) + + return protocol +} + +const derived = (target: number) => ({ session_id: 's', target_message_id: target, derived_target: true }) + +test('a derived rewind whose reply was lost is re-sent for the repeat, not re-derived', () => { + const protocol = primed() + const lost = protocol.prepare('session.rewind', derived(5)) + protocol.failure(lost, new Error('Hermes gateway connection lost')) + + // The repeat's snapshot already lacks row 5 and names row 3; the original request goes out again. + protocol.result('session.resume', { session_id: 's' }, { session_id: 's', revision: 8, execution_generation: 4 }) + const repeat = protocol.prepare('session.rewind', derived(3)) + + expect(repeat).toEqual(lost) + expect(repeat).toMatchObject({ payload: { target_message_id: 5 }, expected_revision: 7, expected_generation: 3 }) +}) + +test('an answered derived rewind is not replayed for the next /undo', () => { + const protocol = primed() + const first = protocol.prepare('session.rewind', derived(5)) + protocol.result('session.rewind', first, { + session_id: 's', + revision: 8, + rewound_count: 2, + target_message: { id: 5 } + }) + + const next = protocol.prepare('session.rewind', derived(3)) + + expect(next.request_id).not.toBe(first.request_id) + expect(next).toMatchObject({ payload: { target_message_id: 3 }, expected_revision: 8 }) +}) + +test('a typed refusal of a derived rewind lets the repeat derive again', () => { + const protocol = primed() + const refused = protocol.prepare('session.rewind', derived(5)) + protocol.failure(refused, { data: { reason: 'revision_conflict', revision: 9 } }) + + const retry = protocol.prepare('session.rewind', derived(5)) + + expect(retry.request_id).not.toBe(refused.request_id) + expect(retry).toMatchObject({ payload: { target_message_id: 5 }, expected_revision: 9 }) +}) + +test('another control retires a lost derived rewind', () => { + const protocol = primed() + const lost = protocol.prepare('session.rewind', derived(5)) + protocol.failure(lost, new Error('socket closed')) + + protocol.prepare('session.title', { session_id: 's', title: 'renamed' }) + const later = protocol.prepare('session.rewind', derived(3)) + + expect(later.request_id).not.toBe(lost.request_id) + expect(later).toMatchObject({ payload: { target_message_id: 3 } }) +}) + +test('an explicit rewind (edit, regenerate, restore) stays keyed by its own target', () => { + const protocol = primed() + const lost = protocol.prepare('session.rewind', { session_id: 's', target_message_id: 5 }) + protocol.failure(lost, new Error('socket closed')) + + expect(protocol.prepare('session.rewind', { session_id: 's', target_message_id: 5 })).toEqual(lost) + + const other = protocol.prepare('session.rewind', { session_id: 's', target_message_id: 3 }) + + expect(other.request_id).not.toBe(lost.request_id) + expect(other).toMatchObject({ payload: { target_message_id: 3 } }) +}) + +test('an owner refusal that names a reason lets the next /undo derive again', () => { + const protocol = primed() + const refused = protocol.prepare('session.rewind', derived(5)) + protocol.failure(refused, { data: { reason: 'invalid_params', field: 'target_message_id' } }) + + const next = protocol.prepare('session.rewind', derived(3)) + + expect(next.request_id).not.toBe(refused.request_id) + expect(next).toMatchObject({ payload: { target_message_id: 3 }, expected_revision: 7 }) +}) + +test('a replay-only probe re-presents only a retained derived rewind', () => { + const protocol = primed() + const probe = { session_id: 's', derived_target: true, replay_only: true } + + expect(protocol.localAnswer('session.rewind', probe)).toMatchObject({ status: 'nothing', rewound_count: 0 }) + expect(() => protocol.prepare('session.rewind', probe)).toThrow('No retained rewind') + + const lost = protocol.prepare('session.rewind', derived(5)) + protocol.failure(lost, new Error('socket closed')) + + expect(protocol.localAnswer('session.rewind', probe)).toBeUndefined() + expect(protocol.prepare('session.rewind', probe)).toEqual(lost) +}) diff --git a/apps/desktop/src/api/canonical-rewind-redial-profile.test.ts b/apps/desktop/src/api/canonical-rewind-redial-profile.test.ts new file mode 100644 index 0000000000..6611fbaacd --- /dev/null +++ b/apps/desktop/src/api/canonical-rewind-redial-profile.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment node +import { expect, test } from 'vitest' + +import { HermesGateway } from './client' + +// Review (#141492, RT-40): on a named launch profile the owner names itself in the snapshot's +// `info.profile_name`, and a redial forgets that route. A repeated /undo or /retry after a lost +// rewind reply must still find the retained rewind (keyed under the named profile) and re-present +// it: the replay-only probe is answered locally only after the attach has re-learned the owner. +test('a named-profile owner re-presents a lost rewind after a redial', async () => { + const wsPackage = 'ws' + const { WebSocketServer } = await import(wsPackage) + const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }) + await new Promise(resolve => server.once('listening', resolve)) + const mutations: any[] = [] + let revision = 4 + + server.on('connection', (peer: any) => { + peer.on('message', (bytes: Buffer) => { + const { id, method, params } = JSON.parse(bytes.toString()) + + if (method === 'session.mutate') { + mutations.push(params) + + // The first rewind commits on the owner, and its reply is lost with the socket. + if (mutations.length === 1) { + revision += 1 + + return peer.terminate() + } + } + + const result = method === 'session.mutate' + ? { session_id: 's', revision, operation: 'rewind', rewound_count: 2, target_message: { id: 14, content: 'second question' } } + : { session_id: 's', stored_session_id: 's', revision, execution_generation: 2, info: { profile_name: 'work' }, messages: [], prompts: [] } + + peer.send(JSON.stringify({ jsonrpc: '2.0', id, result })) + }) + }) + + const client = new HermesGateway() + const url = `ws://127.0.0.1:${(server.address() as { port: number }).port}/api/ws?native_dial=fixture&ticket=one-use` + + try { + await client.connect(url) + await client.request('session.resume', { session_id: 's' }) + + const closed = new Promise(resolve => client.onState(state => { if (state === 'closed') { resolve() } })) + + await expect(client.request('session.rewind', { session_id: 's', target_message_id: 14, derived_target: true })) + .rejects.toThrow() + await closed + + await client.connect(url) + const replayed = await client.request<{ rewound_count?: number }>('session.rewind', { session_id: 's', derived_target: true, replay_only: true }) + + expect(mutations).toHaveLength(2) + expect(mutations[1]).toEqual(mutations[0]) + expect(mutations[1]).toMatchObject({ profile: 'work', operation: 'rewind', payload: { target_message_id: 14 } }) + expect(replayed.rewound_count).toBe(2) + } finally { + client.close() + + for (const peer of server.clients) { peer.terminate() } + await new Promise(resolve => server.close(() => resolve())) + } +}) diff --git a/apps/desktop/src/api/client.ts b/apps/desktop/src/api/client.ts index 2b7390caf0..3834ee9fab 100644 --- a/apps/desktop/src/api/client.ts +++ b/apps/desktop/src/api/client.ts @@ -414,6 +414,12 @@ export class HermesGateway extends JsonRpcGatewayClient { // implicit route. Its revision and generation are cached under that owner. params = this.routeCanonicalParams(method, params) + // Answered locally only once the owner's route is known: a named launch profile's + // retained rewind is keyed under its name, which a redial forgets until the attach. + const local = this.protocol.localAnswer(method, params) + + if (local) { return local as T } + let prepared = this.protocol.prepare(method, params) const wireMethod = this.protocol.wire(method, prepared) diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 1cdbd3fa53..a248b165be 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -39,6 +39,7 @@ import { sessionTileOwnerRoute } from '@/store/session-states' import { broadcastSessionsChanged } from '@/store/session-sync' +import { flushStreamDeltas } from '@/store/stream-delta-flush' import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' import { setSessionDraftingTool } from '@/store/tool-drafting' @@ -48,7 +49,6 @@ import type { GatewayRequester } from '../contrib/types' import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions' import { isStaleTargetError } from '../session/hooks/use-prompt-actions' import { - appendMidTurnUserMessage, applyBranchVisibility, applyReloadOptimistic, applyRewindOptimistic, @@ -61,6 +61,7 @@ import { rebindSurvivorRowIds, revalidateEditPlan, runRewindSubmit, + sealAcceptedCorrection, type SurvivorUserRowIds } from '../session/hooks/use-prompt-actions/rewind' import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit' @@ -477,20 +478,8 @@ export function useSessionTileActions({ requestGateway, runtimeId, scope, stored if (result?.status === 'redirected') { if (mode === 'interrupt') { - mutate(state => { - const message = state.messages.find(candidate => candidate.id === messageId) - - // A newer reply may precede this ACK; it already owns its boundary. - const hasNewReply = state.messages.slice(state.messages.findIndex(candidate => candidate.id === messageId) + 1) - .some(candidate => candidate.role === 'assistant') - - return message && state.streamId && !hasNewReply - ? appendMidTurnUserMessage( - { ...state, messages: state.messages.filter(candidate => candidate.id !== messageId) }, - message - ) - : state - }) + flushStreamDeltas(sessionId) + mutate(state => sealAcceptedCorrection(state, messageId)) } triggerHaptic('submit') @@ -499,7 +488,12 @@ export function useSessionTileActions({ requestGateway, runtimeId, scope, stored } if (result?.status === 'queued') { - if (mode === 'interrupt') { + // A redirect queued in the build window is the next turn; an + // accepted steer joins this one after its running tool. + if (mode === 'steer') { + flushStreamDeltas(sessionId) + mutate(state => sealAcceptedCorrection(state, messageId, { keepRunningTools: true })) + } else { moveOptimisticMessageToEnd() } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 5730ee918d..4035aaaa1e 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -12,6 +12,7 @@ import { chatMessageText, completeOpenTimelineParts, type GatewayEventPayload, + hasUnresolvedToolCall, mergeFinalAssistantText, normalizeWs, partsText, @@ -33,6 +34,7 @@ import type { ScopedServerRequest } from '@/store/gateway' import { dispatchNativeNotification } from '@/store/native-notifications' import { isDiskFullErrorMessage, notifyError } from '@/store/notifications' import { broadcastSessionsChanged } from '@/store/session-sync' +import { registerStreamDeltaFlush } from '@/store/stream-delta-flush' import { upsertSubagent } from '@/store/subagents' import { $todosBySession, setSessionTodos } from '@/store/todos' import { broadcastTranscriptChanged } from '@/store/transcript-sync' @@ -205,15 +207,23 @@ export function useMessageStream({ } ] } else { - nextMessages = prev.map(m => - m.id === streamId - ? { - ...m, - parts: transform(m.parts, m), - pending: patchesSealedBubble ? (m.pending ?? false) : opts.pending ? opts.pending(m) : true - } - : m - ) + nextMessages = prev.map(m => { + if (m.id !== streamId) { + return m + } + + const patched = { ...m, parts: transform(m.parts, m) } + + // A bubble a steer sealed stays pending only while its own tools run. + return { + ...patched, + pending: patchesSealedBubble + ? Boolean(m.pending) && hasUnresolvedToolCall(patched) + : opts.pending + ? opts.pending(m) + : true + } + }) } if (patchesSealedBubble) { @@ -473,6 +483,9 @@ export function useMessageStream({ [flushQueuedDeltas] ) + // A steer seals the live bubble from outside this hook; it flushes our buffer first. + useEffect(() => registerStreamDeltaFlush(sessionId => flushQueuedDeltas(sessionId)), [flushQueuedDeltas]) + // Page Visibility does not report every Windows/Linux focus transition. // Flush queued deltas on both signals so returning to a chat cannot leave a // completed chunk waiting for the next throttled timer. @@ -1037,6 +1050,11 @@ export function useMessageStream({ // degraded websocket leaves its tool row spinning forever. The turn is // provably done here — nothing can still be running — so seal any // tool-call parts that never saw their completion event. + // A bubble a steer sealed while its tool ran stays pending for that tool's + // result; the turn is over now, so a lost tool.complete must not keep it spinning. + nextMessages = nextMessages.map(message => + message.pending && message.interim ? { ...message, pending: false } : message + ) nextMessages = bindReceiptUserRows(sealOpenToolParts(nextMessages), persistedTurn) const hasInlineError = nextMessages.some( diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/steer-arrival-order.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/steer-arrival-order.test.tsx index cbb46e2584..15454be5de 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/steer-arrival-order.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/steer-arrival-order.test.tsx @@ -37,7 +37,7 @@ import { useMessageStream } from './index' const SID = 'steer-order-session' let handleEvent: ((event: GatewayEvent) => void) | null = null -let redirect: ((text: string) => Promise) | null = null +let redirect: ((text: string, mode?: 'interrupt' | 'steer') => Promise) | null = null let states: Map /** Stub only the RPC boundary; both action handlers and streaming reducers are real. */ @@ -278,6 +278,64 @@ describe('steer mid-turn keeps arrival order (user bubble never above prior outp ]) }) + it.each([false, true])('an accepted steer marks its arrival: the tool keeps running above, the answer streams below (tile=%s)', async tile => { + await mountHarness(tile) + + emit({ payload: {}, session_id: SID, type: 'message.start' }) + emit({ payload: { text: 'checking first' }, session_id: SID, type: 'message.delta' }) + await flushDeltas() + emit({ payload: { args: { command: 'sleep 5' }, name: 'terminal', tool_id: 't1' }, session_id: SID, type: 'tool.start' }) + + requestGatewayMock.mockImplementationOnce(async () => ({ status: 'queued' })) + await act(async () => { + await expect(redirect!('use the other file', 'steer')).resolves.toBe(true) + }) + expect(requestGatewayMock).toHaveBeenLastCalledWith('session.steer', { session_id: SID, text: 'use the other file' }) + + // The steer interrupts nothing: the tool it waits on still reads as running. + const sealed = states.get(SID)!.messages[0] + expect(sealed.parts.find(part => part.type === 'tool-call')).not.toHaveProperty('completedAt') + expect(sealed.pending).toBe(true) + + emit({ payload: { name: 'terminal', result: 'ok', tool_id: 't1' }, session_id: SID, type: 'tool.complete' }) + expect(states.get(SID)!.messages[0].pending).toBe(false) + + emit({ payload: { text: 'switched to the other file' }, session_id: SID, type: 'message.delta' }) + await flushDeltas() + emit({ payload: { text: 'switched to the other file' }, session_id: SID, type: 'message.complete' }) + + expect(transcript()).toEqual([ + 'assistant:checking first', + 'user:use the other file', + 'assistant:switched to the other file' + ]) + }) + + it.each([false, true])('an accepted steer paints text still buffered above it, and a lost tool.complete cannot outlive the turn (tile=%s)', async tile => { + await mountHarness(tile) + + emit({ payload: {}, session_id: SID, type: 'message.start' }) + emit({ payload: { text: 'Hello' }, session_id: SID, type: 'message.delta' }) + await flushDeltas() + emit({ payload: { args: { command: 'sleep 5' }, name: 'terminal', tool_id: 't1' }, session_id: SID, type: 'tool.start' }) + // Buffered, not yet painted: the delta timer has not fired. + emit({ payload: { text: ' world' }, session_id: SID, type: 'message.delta' }) + + requestGatewayMock.mockImplementationOnce(async () => ({ status: 'queued' })) + await act(async () => { + await expect(redirect!('change course', 'steer')).resolves.toBe(true) + }) + + // tool.complete for t1 is lost; the answer to the steer completes the turn. + emit({ payload: { text: 'new direction' }, session_id: SID, type: 'message.delta' }) + await flushDeltas() + emit({ payload: { text: 'new direction' }, session_id: SID, type: 'message.complete' }) + + expect(transcript()).toEqual(['assistant:Hello world', 'user:change course', 'assistant:new direction']) + expect(states.get(SID)!.messages.every(message => !message.pending)).toBe(true) + expect(states.get(SID)!.messages[0].parts.find(part => part.type === 'tool-call')).toHaveProperty('completedAt') + }) + it.each([false, true])('a refused redirect preserves the continuing stream (tile=%s)', async tile => { await mountHarness(tile) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 1834b2705c..e11a31df21 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -41,6 +41,7 @@ import { setTurnStartedAt } from '@/store/session' import { $sessionStates, isLiveTurnAwaitingEvents, isSessionRemote } from '@/store/session-states' +import { flushStreamDeltas } from '@/store/stream-delta-flush' import { clearSessionSubagents } from '@/store/subagents' import { runGatewayRestart } from '@/store/system-actions' import { clearSessionTodos } from '@/store/todos' @@ -73,6 +74,7 @@ import { rebindSurvivorRowIds, retryKeepsDeepConfirm, runRewindSubmit, + sealAcceptedCorrection, type SurvivorUserRowIds } from './rewind' import { useSlashCommand } from './slash' @@ -898,21 +900,8 @@ export function usePromptActions({ if (result?.status === 'redirected') { if (mode === 'interrupt') { - updateSessionState(id, state => { - const message = state.messages.find(candidate => candidate.id === messageId) - - // A newer reply may precede this ACK; it already owns its boundary. - const hasNewReply = state.messages - .slice(state.messages.findIndex(candidate => candidate.id === messageId) + 1) - .some(candidate => candidate.role === 'assistant') - - return message && state.streamId && !hasNewReply - ? appendMidTurnUserMessage( - { ...state, messages: state.messages.filter(candidate => candidate.id !== messageId) }, - message - ) - : state - }) + flushStreamDeltas(id) + updateSessionState(id, state => sealAcceptedCorrection(state, messageId)) } triggerHaptic('submit') @@ -921,9 +910,12 @@ export function usePromptActions({ } if (result?.status === 'queued') { - // Build-window redirects become the next turn, not part of the - // active reply, so retain the optimistic row at the tail. - if (mode === 'interrupt') { + // A redirect queued in the build window is the next turn; an + // accepted steer joins this one after its running tool. + if (mode === 'steer') { + flushStreamDeltas(id) + updateSessionState(id, state => sealAcceptedCorrection(state, messageId, { keepRunningTools: true })) + } else { moveOptimisticMessageToEnd() } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts index ecf390dc9e..dc8a385135 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts @@ -19,6 +19,7 @@ import { type ChatMessagePart, chatMessageText, finalizeInterruptedMessages, + hasUnresolvedToolCall, textPart } from '@/lib/chat-messages' import type { ConfirmRequest } from '@/store/confirm' @@ -460,32 +461,77 @@ export function finalizeStoppedMessages( * user row, which made the post-correction reply a sibling of the sealed * partial and dropped the partial and the correction from view (#119015). */ -export function appendMidTurnUserMessage< - State extends { - interimBoundaryPending: boolean - messages: ChatMessage[] - pendingBranchGroup?: null | string - streamId: null | string - } ->(state: State, message: ChatMessage): State { - const liveId = state.streamId - const sealed = finalizeUserInterruptedMessages(state.messages, liveId) - const sealedLiveKept = liveId !== null && sealed.some(row => row.id === liveId) +export function appendMidTurnUserMessage(state: State, message: ChatMessage): State { + const sealed = sealLiveStream(state) - const messages = [ - ...(sealedLiveKept ? sealed.map(row => (row.id === liveId ? { ...row, interim: true } : row)) : sealed), - message - ] + return { ...sealed, messages: [...sealed.messages, message] } +} + +interface LiveStreamState { + interimBoundaryPending: boolean + messages: ChatMessage[] + pendingBranchGroup?: null | string + streamId: null | string +} + +/** + * The seal half of `appendMidTurnUserMessage`, for a mid-turn arrival that is + * rendered separately (a typed /steer's notice row). + * + * A steer interrupts nothing (`keepRunningTools`): the tool it waits on keeps + * its live row in the sealed bubble, where its result still lands by call id. + */ +export function sealLiveStream(state: State, { keepRunningTools = false } = {}): State { + const liveId = state.streamId + + const sealed = keepRunningTools + ? sealKeepingRunningTools(state.messages, liveId) + : finalizeUserInterruptedMessages(state.messages, liveId) + + const sealedLiveKept = liveId !== null && sealed.some(row => row.id === liveId) return { ...state, - messages, + messages: sealedLiveKept ? sealed.map(row => (row.id === liveId ? { ...row, interim: true } : row)) : sealed, ...(state.pendingBranchGroup ? { pendingBranchGroup: null } : {}), streamId: null, interimBoundaryPending: state.interimBoundaryPending || sealedLiveKept } } +/** `finalizeInterruptedMessages` minus the seal on tool calls still waiting for a result: + * their bubble stays pending (a tool row only renders running inside a running message) + * until the result lands by call id, or the turn's completion settles it. */ +function sealKeepingRunningTools(messages: ChatMessage[], streamId: null | string): ChatMessage[] { + return messages.flatMap(message => + (message.pending || message.id === streamId) && hasUnresolvedToolCall(message) + ? [{ ...message, interim: true }] + : finalizeInterruptedMessages([message], streamId) + ) +} + +/** An accepted correction's reserved bubble becomes the arrival boundary: the + * output already on screen stays above it, everything after streams below. */ +export function sealAcceptedCorrection( + state: State, + messageId: string | undefined, + { keepRunningTools = false } = {} +): State { + const index = state.messages.findIndex(candidate => candidate.id === messageId) + + // A newer reply may precede this ACK; it already owns its boundary. + if (index < 0 || !state.streamId || state.messages.slice(index + 1).some(row => row.role === 'assistant')) { + return state + } + + const sealed = sealLiveStream( + { ...state, messages: state.messages.filter(candidate => candidate.id !== messageId) }, + { keepRunningTools } + ) + + return { ...sealed, messages: [...sealed.messages, state.messages[index]] } +} + // --------------------------------------------------------------------------- // Reload (regenerate) // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash-session-commands.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash-session-commands.test.tsx index 5753d5ad3b..f161dc3701 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash-session-commands.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash-session-commands.test.tsx @@ -1,7 +1,10 @@ +import { JsonRpcGatewayError } from '@hermes/shared' import { cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CanonicalDesktopProtocol } from '@/api/canonical-protocol' import { en } from '@/i18n/en' +import { textPart } from '@/lib/chat-messages' import { createClientSessionState } from '@/lib/chat-runtime' import { resolveDesktopCommand } from '@/lib/desktop-slash-commands' import { $composerDraft, setComposerDraft } from '@/store/composer' @@ -11,6 +14,7 @@ import { dropSessionState, publishSessionState } from '@/store/session-states' import { actRender, Harness, type HarnessHandle, RUNTIME_SESSION_ID } from './index-test-utils' import { clearSingleFlightSessionResumeState } from './single-flight-resume' +import { truncateRewoundMessages } from './slash-session-commands' // /undo, /retry, /steer and /queue run against the session owner's canonical verbs (the Ink TUI's // behaviour on the same gateway), never as `slash.exec`, which the shared gateway refuses. @@ -45,8 +49,15 @@ const SNAPSHOT = { type Responder = (method: string, params?: Record) => unknown +// A canonical client answers the replay-only rewind probe locally while nothing is retained +// (CanonicalDesktopProtocol.localAnswer); these single-shot peers retain nothing. function gateway(respond: Responder) { - return vi.fn(async (method: string, params?: Record) => (respond(method, params) ?? {}) as never) + return vi.fn( + async (method: string, params?: Record) => + (method === 'session.rewind' && params?.replay_only + ? { status: 'nothing', rewound_count: 0 } + : (respond(method, params) ?? {})) as never + ) } async function mount(requestGateway: ReturnType) { @@ -58,8 +69,12 @@ async function mount(requestGateway: ReturnType) { return handle! } +// The replay-only probe each /undo and /retry sends first (answered locally while nothing is +// retained) is asserted on its own; these lists hold the requests that name a target. const callsOf = (requestGateway: ReturnType, method: string) => - requestGateway.mock.calls.filter(([name]) => name === method).map(([, params]) => params) + requestGateway.mock.calls + .filter(([name, params]) => name === method && !(params as { replay_only?: unknown } | undefined)?.replay_only) + .map(([, params]) => params) const renderedText = (handle: HarnessHandle) => handle @@ -74,6 +89,15 @@ const queuedTexts = () => const canonical = () => $connection.set({ mode: 'local', wsUrl: 'ws://localhost/api/ws?native_dial=unminted' } as never) +// The shared owner's typed refusal of a rewind while a turn runs, as the wire delivers it. +const BUSY = new JsonRpcGatewayError('session_busy', { code: 4001, data: { reason: 'session_busy' } }) + +// The same refusal while another writer holds the compression lock: Stop does not end it. +const COMPRESSING = new JsonRpcGatewayError('session_busy', { + code: 4001, + data: { field: 'compression', reason: 'session_busy' } +}) + beforeEach(() => { clearSingleFlightSessionResumeState() window.localStorage.removeItem('hermes.desktop.composerQueue.v1') @@ -122,9 +146,12 @@ describe('/undo on a canonical session', () => { const handle = await mount(requestGateway) await handle.submitText('/undo') - expect(callsOf(requestGateway, 'session.rewind')).toEqual([ - { session_id: RUNTIME_SESSION_ID, target_message_id: 14 } - ]) + expect(requestGateway.mock.calls.filter(([name]) => name === 'session.rewind').map(([, params]) => params)).toEqual( + [ + { session_id: RUNTIME_SESSION_ID, derived_target: true, replay_only: true }, + { session_id: RUNTIME_SESSION_ID, target_message_id: 14, derived_target: true } + ] + ) expect(callsOf(requestGateway, 'slash.exec')).toEqual([]) expect($composerDraft.get()).toBe('second question') expect(renderedText(handle)).toContain(copy.undidOther('2')) @@ -145,7 +172,7 @@ describe('/undo on a canonical session', () => { await handle.submitText('/undo 2') expect(callsOf(requestGateway, 'session.rewind')).toEqual([ - { session_id: RUNTIME_SESSION_ID, target_message_id: 11 } + { session_id: RUNTIME_SESSION_ID, target_message_id: 11, derived_target: true } ]) }) @@ -158,6 +185,31 @@ describe('/undo on a canonical session', () => { expect(callsOf(requestGateway, 'session.rewind')).toEqual([]) expect(renderedText(handle)).toContain(copy.undoNothing) }) + + it('refuses during a running turn with main’s stop-first guidance, leaving the composer alone', async () => { + const requestGateway = gateway(method => { + if (method === 'session.resume') { + return SNAPSHOT + } + + if (method === 'session.rewind') { + throw BUSY + } + }) + + publishSessionState(RUNTIME_SESSION_ID, { ...createClientSessionState(RUNTIME_SESSION_ID), busy: true }) + setComposerDraft('half-typed follow-up') + const handle = await mount(requestGateway) + await handle.submitText('/undo') + + expect(renderedText(handle)).toContain('Stop the current reply first') + expect(renderedText(handle)).toContain('then run /undo') + expect(renderedText(handle)).not.toContain('error: session_busy') + expect($composerDraft.get()).toBe('half-typed follow-up') + expect(callsOf(requestGateway, 'session.interrupt')).toEqual([]) + expect(callsOf(requestGateway, 'prompt.submit')).toEqual([]) + expect(queuedTexts()).toEqual([]) + }) }) describe('/retry on a canonical session', () => { @@ -178,7 +230,7 @@ describe('/retry on a canonical session', () => { await handle.submitText('/retry') expect(callsOf(requestGateway, 'session.rewind')).toEqual([ - { session_id: RUNTIME_SESSION_ID, target_message_id: 14 } + { session_id: RUNTIME_SESSION_ID, target_message_id: 14, derived_target: true } ]) expect(callsOf(requestGateway, 'prompt.submit')).toEqual([ expect.objectContaining({ session_id: RUNTIME_SESSION_ID, text: 'second question' }) @@ -200,6 +252,349 @@ describe('/retry on a canonical session', () => { expect(callsOf(requestGateway, 'prompt.submit')).toEqual([]) expect(renderedText(handle)).toContain(copy.retryUnsupported) }) + + it('refuses during a running turn with main’s stop-first guidance and resubmits nothing', async () => { + const requestGateway = gateway(method => { + if (method === 'session.resume') { + return SNAPSHOT + } + + if (method === 'session.rewind') { + throw BUSY + } + }) + + publishSessionState(RUNTIME_SESSION_ID, { ...createClientSessionState(RUNTIME_SESSION_ID), busy: true }) + const handle = await mount(requestGateway) + await handle.submitText('/retry') + + expect(renderedText(handle)).toContain('Stop the current reply first') + expect(renderedText(handle)).toContain('then run /retry') + expect(callsOf(requestGateway, 'prompt.submit')).toEqual([]) + expect(callsOf(requestGateway, 'session.interrupt')).toEqual([]) + expect(queuedTexts()).toEqual([]) + }) +}) + +describe('/undo and /retry while a compression holds the session', () => { + beforeEach(canonical) + + for (const command of ['undo', 'retry'] as const) { + it(`/${command} says to wait for the compression instead of pressing Stop`, async () => { + const requestGateway = gateway(method => { + if (method === 'session.resume') { + return SNAPSHOT + } + + if (method === 'session.rewind') { + throw COMPRESSING + } + }) + + const handle = await mount(requestGateway) + await handle.submitText(`/${command}`) + + expect(renderedText(handle)).toContain(`a /compress is running; run /${command} when it finishes`) + expect(renderedText(handle)).not.toContain('Stop the current reply first') + expect(callsOf(requestGateway, 'session.interrupt')).toEqual([]) + expect(callsOf(requestGateway, 'prompt.submit')).toEqual([]) + }) + } + + it('keeps the wait text for a busy refusal only, not another refusal naming the same field', async () => { + const other = new JsonRpcGatewayError('invalid_params', { + code: 4001, + data: { field: 'compression', reason: 'invalid_params' } + }) + + const requestGateway = gateway(method => { + if (method === 'session.resume') { + return SNAPSHOT + } + + if (method === 'session.rewind') { + throw other + } + }) + + const handle = await mount(requestGateway) + await handle.submitText('/undo') + + expect(renderedText(handle)).toContain('error: invalid_params') + expect(renderedText(handle)).not.toContain('a /compress is running') + }) +}) + +// The real CanonicalDesktopProtocol in front of a revision-fenced owner that answers an exact retry with +// its original receipt (the authority's durable receipt by request_id). The first rewind commits and its +// reply is lost; the user repeats the command. +const THREE_EXCHANGES: { content: unknown; role: string }[] = [ + { role: 'user', content: 'first question' }, + { role: 'assistant', content: 'first answer' }, + { role: 'user', content: 'second question' }, + { role: 'assistant', content: 'second answer' }, + { role: 'user', content: 'third question' }, + { role: 'assistant', content: 'third answer' } +] + +function lostRewindReply(transcript = THREE_EXCHANGES) { + const protocol = new CanonicalDesktopProtocol() + const rows = transcript.map((row, index) => ({ ...row, row_id: 11 + index, active: true })) + + const receipts = new Map>() + let revision = 4 + let loseReply = true + + const owner = (wire: string, params: Record) => { + if (wire === 'session.resume') { + const messages = rows.filter(row => row.active).map(({ active: _active, ...row }) => row) + + return { session_id: RUNTIME_SESSION_ID, revision, execution_generation: 2, messages } + } + + const replay = receipts.get(params.request_id) + + if (replay) { + return replay + } + + if (params.expected_revision !== revision) { + throw Object.assign(new Error('revision_conflict'), { data: { reason: 'revision_conflict', revision } }) + } + + const target = (params.payload as { target_message_id: number }).target_message_id + const rewound = rows.filter(row => row.active && row.row_id >= target) + rewound.forEach(row => (row.active = false)) + revision += 1 + + const receipt = { + session_id: RUNTIME_SESSION_ID, + revision, + rewound_count: rewound.length, + target_message: { id: target, content: rewound[0].content } + } + + receipts.set(params.request_id, receipt) + + if (loseReply) { + loseReply = false + + throw new Error('Hermes gateway connection lost') + } + + return receipt + } + + const requestGateway = vi.fn(async (method: string, params: Record = {}) => { + if (method !== 'session.resume' && method !== 'session.rewind') { + return {} as never + } + + const local = protocol.localAnswer(method, params) + + if (local) { + return local as never + } + + const prepared = protocol.prepare(method, params) + + try { + return protocol.result(method, prepared, owner(protocol.wire(method, prepared), prepared)) as never + } catch (error) { + protocol.failure(prepared, error) + + throw error + } + }) + + return { requestGateway, active: () => rows.filter(row => row.active).map(row => row.content) } +} + +/** Rendered bubbles bound to the owner's durable rows (a hydrated or refreshed transcript). */ +const boundMessages = (texts: string[]) => + texts.map((text, index) => ({ + id: `m${index}`, + parts: [textPart(text)], + role: index % 2 ? ('assistant' as const) : ('user' as const), + rowId: 11 + index, + timestamp: index + })) + +async function mountWith(requestGateway: ReturnType, seedMessages: unknown[]) { + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={seedMessages} + /> + ) + + return handle! +} + +describe('a repeat after a lost rewind reply on a canonical session', () => { + beforeEach(canonical) + + it('/undo removes only the newest exchange and restores its text', async () => { + const owner = lostRewindReply() + let handle: HarnessHandle | null = null + + const seedMessages = [ + 'first question', + 'first answer', + 'second question', + 'second answer', + 'third question', + 'third answer' + ].map((text, index) => ({ + id: `m${index}`, + parts: [textPart(text)], + role: index % 2 ? ('assistant' as const) : ('user' as const), + rowId: 11 + index, + timestamp: index + })) + + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={owner.requestGateway} + seedMessages={seedMessages} + /> + ) + + await handle!.submitText('/undo') + expect(renderedText(handle!)).toContain('Hermes gateway connection lost') + + await handle!.submitText('/undo') + + expect(owner.active()).toEqual(['first question', 'first answer', 'second question', 'second answer']) + expect($composerDraft.get()).toBe('third question') + expect(renderedText(handle!)).toContain(copy.undidOther('2')) + // The view drops the rewound rows the owner named, not the turn this repeat's snapshot pointed at. + expect(renderedText(handle!)).toContain('second answer') + expect(renderedText(handle!)).not.toContain('third question') + }) + + it('/retry resends the newest question, not the one before it', async () => { + const owner = lostRewindReply() + const handle = await mount(owner.requestGateway) + + await handle.submitText('/retry') + await handle.submitText('/retry') + + expect(owner.active()).toEqual(['first question', 'first answer', 'second question', 'second answer']) + expect(callsOf(owner.requestGateway, 'prompt.submit')).toEqual([ + expect.objectContaining({ session_id: RUNTIME_SESSION_ID, text: 'third question' }) + ]) + }) + + // The repeat re-presents the retained rewind before deriving (or checking) a target of its own: + // the rewound transcript may hold no turn the command would accept. + it('/retry that removed the only user turn resends it', async () => { + const owner = lostRewindReply(THREE_EXCHANGES.slice(0, 2)) + const handle = await mount(owner.requestGateway) + + await handle.submitText('/retry') + await handle.submitText('/retry') + + expect(renderedText(handle)).not.toContain(copy.retryNothing) + expect(callsOf(owner.requestGateway, 'prompt.submit')).toEqual([ + expect.objectContaining({ session_id: RUNTIME_SESSION_ID, text: 'first question' }) + ]) + }) + + it('/undo N restores the removed question when fewer than N turns remain', async () => { + const owner = lostRewindReply() + const handle = await mount(owner.requestGateway) + + await handle.submitText('/undo 2') + await handle.submitText('/undo 2') + + expect(owner.active()).toEqual(['first question', 'first answer']) + expect($composerDraft.get()).toBe('second question') + expect(renderedText(handle)).toContain(copy.undidOther('4')) + }) + + it('a text-only /retry is not blocked by an older media turn', async () => { + const media = [{ type: 'image_url', image_url: 'x' }] + const owner = lostRewindReply([{ role: 'user', content: media }, ...THREE_EXCHANGES.slice(1, 4)]) + const handle = await mount(owner.requestGateway) + + await handle.submitText('/retry') + await handle.submitText('/retry') + + expect(renderedText(handle)).not.toContain(copy.retryUnsupported) + expect(callsOf(owner.requestGateway, 'prompt.submit')).toEqual([ + expect.objectContaining({ session_id: RUNTIME_SESSION_ID, text: 'second question' }) + ]) + }) + + it('a replayed /undo 2 cuts both turns from a view without row ids', async () => { + const owner = lostRewindReply() + + const unbound = THREE_EXCHANGES.map((row, index) => ({ + id: `u${index}`, + parts: [textPart(String(row.content))], + role: row.role as 'assistant' | 'user', + timestamp: index + })) + + const handle = await mountWith(owner.requestGateway, unbound) + + await handle.submitText('/undo 2') + await handle.submitText('/undo') + + expect(owner.active()).toEqual(['first question', 'first answer']) + expect(renderedText(handle)).toContain('first answer') + expect(renderedText(handle)).not.toContain('second question') + expect(renderedText(handle)).not.toContain('third question') + }) + + it('a view refreshed after the reconnect keeps every exchange the owner still holds', async () => { + const owner = lostRewindReply() + + // The reconnect refresh already shows the owner's rewound transcript (the third exchange gone). + const handle = await mountWith( + owner.requestGateway, + boundMessages(['first question', 'first answer', 'second question', 'second answer']) + ) + + await handle.submitText('/undo') + await handle.submitText('/undo') + + expect(owner.active()).toEqual(['first question', 'first answer', 'second question', 'second answer']) + expect(renderedText(handle)).toContain('second question') + expect(renderedText(handle)).toContain('second answer') + }) +}) + +describe('truncateRewoundMessages', () => { + const live = (text: string, role: 'assistant' | 'user') => ({ id: text, parts: [textPart(text)], role, timestamp: 9 }) + + it('is a no-op when the bound view no longer holds the rewound row', () => { + const messages = boundMessages(['first question', 'first answer', 'second question', 'second answer']) + + expect(truncateRewoundMessages(messages as never, 15, 1)).toEqual(messages) + }) + + it('cuts unbound live bubbles after the bound history by position', () => { + const messages = [ + ...boundMessages(['first question', 'first answer']), + live('new question', 'user'), + live('new answer', 'assistant') + ] + + expect(truncateRewoundMessages(messages as never, 13, 1).map(message => message.id)).toEqual(['m0', 'm1']) + }) + + it('falls back by position on a wholly unbound view', () => { + const messages = [live('a', 'user'), live('b', 'assistant'), live('c', 'user'), live('d', 'assistant')] + + expect(truncateRewoundMessages(messages as never, 13, 1).map(message => message.id)).toEqual(['a', 'b']) + }) }) describe('/steer', () => { @@ -218,6 +613,32 @@ describe('/steer', () => { expect(renderedText(handle)).toContain(copy.steerQueued('check the tests too')) }) + it('seals the live reply at the steer notice, keeping its running tool live', async () => { + const requestGateway = gateway(method => (method === 'session.steer' ? { status: 'queued' } : undefined)) + const tool = { type: 'tool-call', toolCallId: 't1', toolName: 'terminal', args: {}, argsText: '{}', timestamp: 2 } + const live = { id: 'stream-1', role: 'assistant', pending: true, parts: [{ type: 'text', text: 'before', timestamp: 1 }, tool] } + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={[live]} + seedStreamId="stream-1" + /> + ) + publishSessionState(RUNTIME_SESSION_ID, { ...handle!.state(), busy: true }) + + await handle!.submitText('/steer use the other file') + + // The next delta (the model's answer to the steer) seeds a bubble BELOW the notice. + const { messages, streamId } = handle!.state() + expect(streamId).toBeNull() + expect(messages.map(message => message.id).indexOf('stream-1')).toBe(0) + expect(renderedText(handle!)).toContain(copy.steerQueued('use the other file')) + expect(messages[0]).toMatchObject({ pending: true, parts: [expect.anything(), tool] }) + }) + it('queues the text as the next turn when the steer is not accepted', async () => { publishSessionState(RUNTIME_SESSION_ID, { ...createClientSessionState(RUNTIME_SESSION_ID), busy: true }) const requestGateway = gateway(method => (method === 'session.steer' ? { status: 'rejected' } : undefined)) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash-session-commands.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash-session-commands.ts index bf6509372a..5ba0f7fba3 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash-session-commands.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash-session-commands.ts @@ -7,9 +7,17 @@ import { setComposerDraft } from '@/store/composer' import { enqueueQueuedPrompt, getQueuedPrompts, serverOwnsComposerQueue } from '@/store/composer-queue' import { $sessions, resolveComposerSessionKey } from '@/store/session' import { $sessionStates } from '@/store/session-states' +import { flushStreamDeltas } from '@/store/stream-delta-flush' +import { sealLiveStream } from './rewind' import type { SubmissionDestination } from './submission-destination' -import { type GatewayRequest, isTargetSessionBusy, type SubmitTextOptions } from './utils' +import { + type GatewayRequest, + isCompressionBusyError, + isSessionBusyError, + isTargetSessionBusy, + type SubmitTextOptions +} from './utils' // Typed /undo, /retry, /steer and /queue: one behaviour per command on every surface, mirroring the // Ink TUI on the same shared gateway (ui-tui/src/app/slash/commands/core.ts + @@ -30,9 +38,11 @@ interface SnapshotRow { } interface RewindReceipt { + /** Client-side: the snapshot row a freshly derived rewind named (a replayed receipt has none). */ + derivedRowId?: number rewound_count?: number status?: string - target_message?: { content?: unknown } | null + target_message?: { content?: unknown; id?: unknown } | null } interface SlashOutput { @@ -59,11 +69,23 @@ export interface SessionCommandEnv { const copy = () => runtimeTranslations().promptNotices.sessionCommands +// The cut (`turnsBack`) of each session's derived rewind still awaiting its reply. A repeat after a +// lost reply replays THAT request, so an unbound view cuts by its count, not the repeat's own. +const pendingRewindCuts = new Map() + /** Clip a queued/steered prompt for the confirmation line (Ink's previewOf). */ const previewOf = (text: string) => `${text.slice(0, PREVIEW_CHARS)}${text.length > PREVIEW_CHARS ? '…' : ''}` const errorText = (err: unknown) => `error: ${err instanceof Error ? err.message : String(err)}` +/** A refused rewind: main's busy text (wait for a running /compress, else stop the reply), or the raw error. */ +const rewindErrorText = (err: unknown, command: 'retry' | 'undo') => + isCompressionBusyError(err) + ? copy().busyCompressing(command) + : isSessionBusyError(err) + ? copy().busy(command) + : errorText(err) + /** A human-authored user turn (`split_user_originated_turn`): no display-only kind other than a * typed /steer, a durable row id, and not a compaction handoff carrier. */ function isUserTurn(row: SnapshotRow): boolean { @@ -110,14 +132,25 @@ export function userTurnFromEnd(rows: readonly SnapshotRow[], turnsBack: number) } /** Drop the rewound rows from the rendered transcript: everything from the first message bound to a - * durable row at or after the target. Unbound bubbles fall back to the Nth newest user bubble. */ + * durable row at or after the target. A view bound to durable rows that holds none of them already + * shows the cut (a refresh after reconnect): only unbound live bubbles after it fall back to the Nth + * newest user bubble, and a wholly unbound view falls back by position. */ export function truncateRewoundMessages(messages: ChatMessage[], rowId: number, turnsBack: number): ChatMessage[] { + const bound = (message: ChatMessage) => + message.rowId !== undefined || message.parts.some(part => part.sourceRowId !== undefined) + let cut = messages.findIndex( message => (message.rowId ?? -1) >= rowId || message.parts.some(part => (part.sourceRowId ?? -1) >= rowId) ) if (cut < 0) { - const users = messages.flatMap((message, index) => (message.role === 'user' && !message.hidden ? [index] : [])) + const tail = messages.length - [...messages].reverse().findIndex(bound) + const after = tail > messages.length ? 0 : tail + + const users = messages.flatMap((message, index) => + index >= after && message.role === 'user' && !message.hidden ? [index] : [] + ) + cut = users[users.length - turnsBack] ?? -1 } @@ -136,6 +169,56 @@ export function sessionCommandHandlers(env: SessionCo ): Promise { const { render, sessionId, storedSessionId } = out const nothing = intent === 'retry' ? copy().retryNothing : copy().undoNothing + + // An earlier /undo or /retry whose reply was lost is resolved FIRST: the owner already rewound, so + // the transcript a new target would come from may hold no turn this command accepts. The protocol + // re-sends that retained rewind (the owner answers with the original receipt), or answers + // 'nothing' locally, without a request, when none is retained. + const replayed = await requestGateway('session.rewind', { + session_id: sessionId, + derived_target: true, + replay_only: true + }) + + const replay = Number(replayed?.rewound_count ?? 0) > 0 + const receipt = replay ? replayed : await deriveAndRewind(out, turnsBack, intent) + const cut = replay ? (pendingRewindCuts.get(sessionId) ?? turnsBack) : turnsBack + pendingRewindCuts.delete(sessionId) + + if (!receipt) { + return + } + + const count = Number(receipt?.rewound_count ?? 0) + + if (receipt?.status === 'nothing' || count <= 0) { + return void render(nothing) + } + + // The receipt names the row the owner rewound; a replayed receipt's row is not this snapshot's. + const receiptRowId = receipt?.target_message?.id + const rowId = Number.isSafeInteger(receiptRowId) ? (receiptRowId as number) : receipt.derivedRowId + + if (rowId !== undefined) { + updateSessionState( + sessionId, + state => ({ ...state, messages: truncateRewoundMessages(state.messages, rowId, cut) }), + storedSessionId + ) + } + + return { count, content: receipt?.target_message?.content } + } + + // Derive the Nth newest user turn from the owner's snapshot and rewind it. Resolves undefined after + // it rendered why nothing was rewound. + async function deriveAndRewind( + out: SlashOutput, + turnsBack: number, + intent: 'retry' | 'undo' + ): Promise { + const { render, sessionId } = out + const nothing = intent === 'retry' ? copy().retryNothing : copy().undoNothing // The resume snapshot carries every row's durable id and primes the CAS revision/generation the // rewind mutation presents, as Ink's mutateCanonical does. const snapshot = await requestGateway<{ messages?: SnapshotRow[] }>('session.resume', { session_id: sessionId }) @@ -157,26 +240,16 @@ export function sessionCommandHandlers(env: SessionCo } } - const rowId = target.row_id as number + // A derived target: retained by operation alone, so a repeat after a lost reply re-sends it. + pendingRewindCuts.set(sessionId, turnsBack) const receipt = await requestGateway('session.rewind', { session_id: sessionId, - target_message_id: rowId + target_message_id: target.row_id, + derived_target: true }) - const count = Number(receipt?.rewound_count ?? 0) - - if (receipt?.status === 'nothing' || count <= 0) { - return void render(nothing) - } - - updateSessionState( - sessionId, - state => ({ ...state, messages: truncateRewoundMessages(state.messages, rowId, turnsBack) }), - storedSessionId - ) - - return { count, content: receipt?.target_message?.content } + return { ...receipt, derivedRowId: target.row_id as number } } // The next-turn queue the composer uses while busy: the owner's durable FIFO for a canonical @@ -227,7 +300,7 @@ export function sessionCommandHandlers(env: SessionCo out.render(rewound.count === 1 ? copy().undidOne('1') : copy().undidOther(String(rewound.count))) } catch (err) { - out.render(errorText(err)) + out.render(rewindErrorText(err, 'undo')) } }, retry: async (ctx: C) => { @@ -257,7 +330,7 @@ export function sessionCommandHandlers(env: SessionCo await submitPromptText(text, { destination, sessionId: out.sessionId, storedSessionId: out.storedSessionId }) } catch (err) { - out.render(errorText(err)) + out.render(rewindErrorText(err, 'retry')) } }, steer: async (ctx: C) => { @@ -292,6 +365,11 @@ export function sessionCommandHandlers(env: SessionCo } if (accepted) { + // The notice marks where the steer arrived: seal the live reply above it so the + // output the model writes after reading the steer streams below, not above it. + flushStreamDeltas(out.sessionId) + updateSessionState(out.sessionId, state => sealLiveStream(state, { keepRunningTools: true })) + return out.render(copy().steerQueued(previewOf(text))) } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index d640468e4c..b504be1955 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -298,6 +298,13 @@ export function isSessionBusyError(error: unknown): boolean { return /session[ _]busy/i.test(error instanceof Error ? error.message : String(error)) } +/** A busy refusal because another writer holds the compression lock (`field: 'compression'`). */ +export function isCompressionBusyError(error: unknown): boolean { + const data = error instanceof JsonRpcGatewayError ? (error.data as { field?: unknown; reason?: unknown } | undefined) : null + + return data?.reason === 'session_busy' && data.field === 'compression' +} + // prompt.submit refused because another surface (TUI, messaging gateway) // holds this session's lease (4090 / SESSION_NOT_OWNED, #106217). The gateway // stamps the machine reason in `error.data.reason`; the prose fallback covers diff --git a/apps/desktop/src/i18n/ar_session_commands.ts b/apps/desktop/src/i18n/ar_session_commands.ts index abd05f272b..3190589d39 100644 --- a/apps/desktop/src/i18n/ar_session_commands.ts +++ b/apps/desktop/src/i18n/ar_session_commands.ts @@ -6,6 +6,10 @@ export const arSessionCommands = { undidOther: (count: string) => `تم التراجع عن ${count} رسائل — الرسالة المحذوفة موجودة في مربع الكتابة`, retryNothing: 'لا يوجد ما يمكن إعادة محاولته', retryUnsupported: '/retry: لا يمكن إعادة محاولة الوسائط أو أجزاء المحتوى غير المعروفة', + busy: (command: string) => + `الجلسة مشغولة — ما زال Hermes يرد. أوقف الرد الحالي أولاً (زر الإيقاف، أو Ctrl+C في الطرفية)، ثم شغّل /${command}.`, + busyCompressing: (command: string) => + `الجلسة مشغولة — يجري تنفيذ /compress؛ شغّل /${command} عند انتهائه.`, steerUsage: 'الاستخدام: /steer <الطلب>', steerQueued: (preview: string) => `أُضيف التوجيه إلى قائمة الانتظار — يصل بعد استدعاء الأداة التالي: "${preview}"`, steerNoActiveTurn: (preview: string) => `لا يوجد دور نشط — أُضيف إلى قائمة الانتظار للدور التالي: "${preview}"`, diff --git a/apps/desktop/src/i18n/de_session_commands.ts b/apps/desktop/src/i18n/de_session_commands.ts index 55f339fab1..a943d5b9cc 100644 --- a/apps/desktop/src/i18n/de_session_commands.ts +++ b/apps/desktop/src/i18n/de_session_commands.ts @@ -7,6 +7,10 @@ export const deSessionCommands = { `${count} Nachrichten rückgängig gemacht — die entfernte Nachricht steht im Eingabefeld`, retryNothing: 'nichts zum Wiederholen', retryUnsupported: '/retry: Medien oder unbekannte Inhaltsteile können nicht wiederholt werden', + busy: (command: string) => + `Sitzung beschäftigt — Hermes antwortet noch. Beende zuerst die aktuelle Antwort (Stopp-Taste oder Strg+C im Terminal), dann führe /${command} aus.`, + busyCompressing: (command: string) => + `Sitzung beschäftigt — ein /compress läuft; führe /${command} aus, wenn es fertig ist.`, steerUsage: 'Verwendung: /steer ', steerQueued: (preview: string) => `Steuerung eingereiht — kommt nach dem nächsten Tool-Aufruf an: „${preview}“`, steerNoActiveTurn: (preview: string) => `kein aktiver Turn — für den nächsten eingereiht: „${preview}“`, diff --git a/apps/desktop/src/i18n/en_session_commands.ts b/apps/desktop/src/i18n/en_session_commands.ts index 3f5483c579..8b6f08605e 100644 --- a/apps/desktop/src/i18n/en_session_commands.ts +++ b/apps/desktop/src/i18n/en_session_commands.ts @@ -8,6 +8,10 @@ export const enSessionCommands: SessionCommandNotices = { undidOther: (count: string) => `undid ${count} messages — the removed message is in the composer`, retryNothing: 'nothing to retry', retryUnsupported: '/retry: retry does not support media or unknown content parts', + busy: (command: string) => + `session busy — Hermes is still replying. Stop the current reply first (Stop button, or Ctrl+C in a terminal), then run /${command}.`, + busyCompressing: (command: string) => + `session busy — a /compress is running; run /${command} when it finishes.`, steerUsage: 'usage: /steer ', steerQueued: (preview: string) => `steer queued — arrives after next tool call: "${preview}"`, steerNoActiveTurn: (preview: string) => `no active turn — queued for next: "${preview}"`, diff --git a/apps/desktop/src/i18n/es_session_commands.ts b/apps/desktop/src/i18n/es_session_commands.ts index 37206f01f6..0a8e8649c7 100644 --- a/apps/desktop/src/i18n/es_session_commands.ts +++ b/apps/desktop/src/i18n/es_session_commands.ts @@ -6,6 +6,10 @@ export const esSessionCommands = { undidOther: (count: string) => `se deshicieron ${count} mensajes — el mensaje eliminado está en el compositor`, retryNothing: 'nada que reintentar', retryUnsupported: '/retry: no se pueden reintentar contenidos multimedia o partes desconocidas', + busy: (command: string) => + `sesión ocupada — Hermes sigue respondiendo. Detén primero la respuesta actual (botón Detener o Ctrl+C en una terminal) y luego ejecuta /${command}.`, + busyCompressing: (command: string) => + `sesión ocupada — se está ejecutando un /compress; ejecuta /${command} cuando termine.`, steerUsage: 'uso: /steer ', steerQueued: (preview: string) => `indicación en cola — llega tras la próxima llamada a herramienta: "${preview}"`, steerNoActiveTurn: (preview: string) => `no hay turno activo — en cola para el siguiente: "${preview}"`, diff --git a/apps/desktop/src/i18n/fr_session_commands.ts b/apps/desktop/src/i18n/fr_session_commands.ts index bc7ecb038e..a2ae6c7568 100644 --- a/apps/desktop/src/i18n/fr_session_commands.ts +++ b/apps/desktop/src/i18n/fr_session_commands.ts @@ -6,6 +6,10 @@ export const frSessionCommands = { undidOther: (count: string) => `${count} messages annulés — le message retiré est dans la zone de saisie`, retryNothing: 'rien à réessayer', retryUnsupported: '/retry : impossible de réessayer un contenu multimédia ou des parties inconnues', + busy: (command: string) => + `session occupée — Hermes est encore en train de répondre. Arrêtez d’abord la réponse en cours (bouton Arrêter ou Ctrl+C dans un terminal), puis lancez /${command}.`, + busyCompressing: (command: string) => + `session occupée — un /compress est en cours ; lancez /${command} quand il sera terminé.`, steerUsage: 'utilisation : /steer ', steerQueued: (preview: string) => `orientation en file — arrive après le prochain appel d’outil : « ${preview} »`, steerNoActiveTurn: (preview: string) => `aucun tour actif — mis en file pour le suivant : « ${preview} »`, diff --git a/apps/desktop/src/i18n/ja_session_commands.ts b/apps/desktop/src/i18n/ja_session_commands.ts index df60b1c7cc..bfc05d2c62 100644 --- a/apps/desktop/src/i18n/ja_session_commands.ts +++ b/apps/desktop/src/i18n/ja_session_commands.ts @@ -6,6 +6,10 @@ export const jaSessionCommands = { undidOther: (count: string) => `${count} 件のメッセージを元に戻しました — 削除したメッセージは入力欄にあります`, retryNothing: '再試行するものはありません', retryUnsupported: '/retry: メディアや不明なコンテンツを含むターンは再試行できません', + busy: (command: string) => + `セッションが使用中です — Hermes はまだ応答中です。先に現在の応答を停止してから(停止ボタン、またはターミナルでは Ctrl+C)/${command} を実行してください。`, + busyCompressing: (command: string) => + `セッションが使用中です — /compress を実行中です。完了したら /${command} を実行してください。`, steerUsage: '使い方: /steer <プロンプト>', steerQueued: (preview: string) => `誘導を予約しました — 次のツール呼び出しの後に届きます: 「${preview}」`, steerNoActiveTurn: (preview: string) => `実行中のターンがありません — 次のターンに追加しました: 「${preview}」`, diff --git a/apps/desktop/src/i18n/ru_session_commands.ts b/apps/desktop/src/i18n/ru_session_commands.ts index 8904968b02..3ef66ad1cb 100644 --- a/apps/desktop/src/i18n/ru_session_commands.ts +++ b/apps/desktop/src/i18n/ru_session_commands.ts @@ -6,6 +6,10 @@ export const ruSessionCommands = { undidOther: (count: string) => `отменено сообщений: ${count} — удалённое сообщение в поле ввода`, retryNothing: 'нечего повторять', retryUnsupported: '/retry: нельзя повторить медиа или неизвестные части содержимого', + busy: (command: string) => + `сеанс занят — Hermes ещё отвечает. Сначала остановите текущий ответ (кнопка «Стоп» или Ctrl+C в терминале), затем выполните /${command}.`, + busyCompressing: (command: string) => + `сеанс занят — выполняется /compress; выполните /${command}, когда он завершится.`, steerUsage: 'использование: /steer <запрос>', steerQueued: (preview: string) => `уточнение в очереди — придёт после следующего вызова инструмента: «${preview}»`, steerNoActiveTurn: (preview: string) => `нет активного хода — поставлено в очередь на следующий: «${preview}»`, diff --git a/apps/desktop/src/i18n/types_notices.ts b/apps/desktop/src/i18n/types_notices.ts index 223439a47d..1798117f1c 100644 --- a/apps/desktop/src/i18n/types_notices.ts +++ b/apps/desktop/src/i18n/types_notices.ts @@ -42,6 +42,10 @@ export interface SessionCommandNotices { undidOther: (count: string) => string retryNothing: string retryUnsupported: string + /** A rewind refused while a reply is running: main's stop-first guidance (tui_gateway busy_message). */ + busy: (command: string) => string + /** The same refusal while another writer holds the compression lock: Stop does not end it, so wait. */ + busyCompressing: (command: string) => string steerUsage: string steerQueued: (preview: string) => string steerNoActiveTurn: (preview: string) => string diff --git a/apps/desktop/src/i18n/zh-hant_session_commands.ts b/apps/desktop/src/i18n/zh-hant_session_commands.ts index ea0494314d..59f80f1447 100644 --- a/apps/desktop/src/i18n/zh-hant_session_commands.ts +++ b/apps/desktop/src/i18n/zh-hant_session_commands.ts @@ -6,6 +6,10 @@ export const zhHantSessionCommands = { undidOther: (count: string) => `已復原 ${count} 則訊息 — 被移除的訊息已放回輸入框`, retryNothing: '沒有可重試的內容', retryUnsupported: '/retry:無法重試包含媒體或未知內容的訊息', + busy: (command: string) => + `會話忙碌中 — Hermes 仍在回覆。請先停止目前回覆(Stop 按鈕,或在終端機中按 Ctrl+C),然後再執行 /${command}。`, + busyCompressing: (command: string) => + `會話忙碌中 — 正在執行 /compress;完成後再執行 /${command}。`, steerUsage: '用法:/steer <提示>', steerQueued: (preview: string) => `引導已排入佇列 — 將在下一次工具呼叫後送達:「${preview}」`, steerNoActiveTurn: (preview: string) => `目前沒有進行中的回合 — 已排入下一回合:「${preview}」`, diff --git a/apps/desktop/src/i18n/zh_session_commands.ts b/apps/desktop/src/i18n/zh_session_commands.ts index 27c6053961..fa91d6a9c5 100644 --- a/apps/desktop/src/i18n/zh_session_commands.ts +++ b/apps/desktop/src/i18n/zh_session_commands.ts @@ -6,6 +6,10 @@ export const zhSessionCommands = { undidOther: (count: string) => `已撤销 ${count} 条消息 — 被移除的消息已放回输入框`, retryNothing: '没有可重试的内容', retryUnsupported: '/retry:无法重试包含媒体或未知内容的消息', + busy: (command: string) => + `会话忙碌中 — Hermes 仍在回复。请先停止当前回复(Stop 按钮,或在终端中按 Ctrl+C),然后再运行 /${command}。`, + busyCompressing: (command: string) => + `会话忙碌中 — 正在运行 /compress;完成后再运行 /${command}。`, steerUsage: '用法:/steer <提示>', steerQueued: (preview: string) => `引导已排队 — 将在下一次工具调用后送达:“${preview}”`, steerNoActiveTurn: (preview: string) => `当前没有进行中的回合 — 已排队到下一回合:“${preview}”`, diff --git a/apps/desktop/src/lib/chat-messages/index.ts b/apps/desktop/src/lib/chat-messages/index.ts index 1837f9e836..ab9bc3b498 100644 --- a/apps/desktop/src/lib/chat-messages/index.ts +++ b/apps/desktop/src/lib/chat-messages/index.ts @@ -25,6 +25,7 @@ export { spliceOlderPreservedRows } from './reconciliation' export { + hasUnresolvedToolCall, QUESTION_CARD_TOOLS, restorePendingBlockingToolCall, restorePendingClarifyToolCall, diff --git a/apps/desktop/src/lib/chat-messages/tool-parts.ts b/apps/desktop/src/lib/chat-messages/tool-parts.ts index e216e07686..ebb7651f9e 100644 --- a/apps/desktop/src/lib/chat-messages/tool-parts.ts +++ b/apps/desktop/src/lib/chat-messages/tool-parts.ts @@ -412,6 +412,10 @@ export interface SettledClarifyProjection { streamId: string | null } +/** A tool call that neither returned nor was sealed: it is still running. */ +export const hasUnresolvedToolCall = (message: ChatMessage) => + message.parts.some(part => part.type === 'tool-call' && part.result === undefined && part.completedAt === undefined) + /** * Find the message that owns a tool call, by its stable call id, anywhere in * the transcript — not just in the currently-streaming bubble. diff --git a/apps/desktop/src/store/agent-plugins.test.ts b/apps/desktop/src/store/agent-plugins.test.ts index 3e3e406939..b1576ce9eb 100644 --- a/apps/desktop/src/store/agent-plugins.test.ts +++ b/apps/desktop/src/store/agent-plugins.test.ts @@ -118,6 +118,25 @@ describe('saveAgentPluginSettings (#46600, #87934)', () => { expect($agentPlugins.get()[0].settings_schema).toEqual([]) }) + // The backend's settings save is what announces `plugin..settings.changed`; a new API key alone + // is the change a plugin's Desktop half most needs to hear about. + it('still makes the settings call when only a secret changed', async () => { + $agentPluginsProfile.set('workbot') + const request = vi.fn(async () => ({ ok: true, plugin: row({ key: 'demo', source: 'user' }) })) + + await saveAgentPluginSettings(request as never, { + failMessage: 'fail', + key: 'demo', + profile: 'workbot', + secrets: { DEMO_API_KEY: 'sk-2' }, + values: {}, + writeSecret: vi.fn(async () => ({ ok: true })) + }) + + expect(request).toHaveBeenCalledWith('plugins.manage', { action: 'settings', key: 'demo', profile: 'workbot', values: {} }) + expect(JSON.stringify(request.mock.calls)).not.toContain('sk-2') + }) + // The list is shared across profiles: a save that lands after the list moved // to another profile must neither patch that list nor refetch over it. it('leaves a list loaded for another profile alone', async () => { diff --git a/apps/desktop/src/store/agent-plugins.ts b/apps/desktop/src/store/agent-plugins.ts index 3dd96e0365..41a048a3bd 100644 --- a/apps/desktop/src/store/agent-plugins.ts +++ b/apps/desktop/src/store/agent-plugins.ts @@ -476,8 +476,12 @@ export async function saveAgentPluginSettings( } } + // A secret-only save still makes the (empty) settings call: the backend's save is what tells a + // Desktop half `plugin..settings.changed`, and a new API key is the change it most needs. + const wroteSecret = Object.values(opts.secrets).some(Boolean) + const result = - Object.keys(opts.values).length > 0 + Object.keys(opts.values).length > 0 || wroteSecret ? await request<{ ok?: boolean; plugin?: AgentPluginRow | null }>( 'plugins.manage', withProfile({ action: 'settings', key: opts.key, values: opts.values }, opts.profile) diff --git a/apps/desktop/src/store/stream-delta-flush.ts b/apps/desktop/src/store/stream-delta-flush.ts new file mode 100644 index 0000000000..393138222c --- /dev/null +++ b/apps/desktop/src/store/stream-delta-flush.ts @@ -0,0 +1,20 @@ +/** Lets a mid-turn boundary (an accepted steer) paint the session's buffered stream + * deltas before it seals the live bubble, so text the user had already been sent + * never lands on the far side of the boundary. The stream hook owns the buffer. */ +type DeltaFlush = (sessionId: string) => void + +let activeFlush: DeltaFlush | null = null + +export function registerStreamDeltaFlush(flush: DeltaFlush): () => void { + activeFlush = flush + + return () => { + if (activeFlush === flush) { + activeFlush = null + } + } +} + +export function flushStreamDeltas(sessionId: string) { + activeFlush?.(sessionId) +} diff --git a/cli.py b/cli.py index 7c7d80058a..73d015f08d 100644 --- a/cli.py +++ b/cli.py @@ -829,11 +829,14 @@ class _VoiceInputMessage: class _SeededQueryMessage: """Sentinel for a ``-q`` prompt seeded into an interactive session; treated LITERALLY (no slash/!/file-drop).""" - __slots__ = ("images", "text") + __slots__ = ("admission_id", "images", "text") - def __init__(self, text: str, images=None): + def __init__(self, text: str, images=None, admission_id=None): self.text = text or "" self.images = list(images or []) + # A ``/queue`` admission the gateway is running (#134889 N5): ``run_conversation`` pumps that + # admission by this id, never by comparing the (sanitized) text. + self.admission_id = admission_id def __str__(self) -> str: return self.text diff --git a/contributors/emails/14843184+bablobanov@users.noreply.github.com b/contributors/emails/14843184+bablobanov@users.noreply.github.com new file mode 100644 index 0000000000..896a3dace6 --- /dev/null +++ b/contributors/emails/14843184+bablobanov@users.noreply.github.com @@ -0,0 +1 @@ +bablobanov diff --git a/contributors/emails/mail@sshabalin.ru b/contributors/emails/mail@sshabalin.ru new file mode 100644 index 0000000000..c2dec68aa0 --- /dev/null +++ b/contributors/emails/mail@sshabalin.ru @@ -0,0 +1,2 @@ +Neckyserg +# PR #117987 author (commit 904a695 on Neckyserg/hermes-agent) diff --git a/contributors/emails/pickett.austin@gmail.com b/contributors/emails/pickett.austin@gmail.com new file mode 100644 index 0000000000..09766e1fe7 --- /dev/null +++ b/contributors/emails/pickett.austin@gmail.com @@ -0,0 +1,2 @@ +austinpickett +# PR #139174 diff --git a/gateway/run_runtime.py b/gateway/run_runtime.py index ccf9cd87dd..42e80bf538 100644 --- a/gateway/run_runtime.py +++ b/gateway/run_runtime.py @@ -235,7 +235,8 @@ def stop_authority_turns(authority, *, in_process=False, reason=None): through its own fenced path). ``in_process`` also hard-interrupts the running agent of each executing in-process turn, or latches the generation ``adopt_agent`` consumes before it exists. ``reason`` (a gateway restart/shutdown) is recorded on each worker it signals, so the turn settles - with that cause; a worker already stopped (the user's Stop came first) keeps none.""" + with that cause; a worker already stopped (the user's Stop came first) keeps none. A worker + still being spawned gets the same Stop and cause through latches (``_stop_spawning_managed``).""" from hermes_state_runtime import RuntimeStoreError signalled = 0 workers = getattr(authority, '_managed_workers', {}) @@ -250,6 +251,7 @@ def stop_authority_turns(authority, *, in_process=False, reason=None): # loop escalates on that latch alone (terminate after STOP_ACK_SECONDS). logger.debug('managed Stop not queued (%s); the latch escalates it', exc.reason) signalled += 1 + signalled += _stop_spawning_managed(authority, workers, reason) if not in_process: return signalled from agent.interrupt_compat import request_hard_interrupt @@ -257,7 +259,7 @@ def stop_authority_turns(authority, *, in_process=False, reason=None): running = getattr(authority.runner, '_running_agents', {}) for sid, live in list(authority.sessions.items()): generation = live.event_stream.execution.get('execution_generation') - if generation is None or sid in workers: + if generation is None or sid in workers or sid in getattr(authority, '_managed_spawning', {}): continue adopted_generation, agent = authority.adopted.get(sid, (None, None)) if adopted_generation != generation: @@ -270,6 +272,26 @@ def stop_authority_turns(authority, *, in_process=False, reason=None): return signalled +def _stop_spawning_managed(authority, workers, reason): + """A managed turn whose worker is still being spawned has nothing to signal yet: latch the + Stop for its generation (``adopt_agent`` delivers it to the worker, which is never + bootstrapped) and, for a gateway restart/shutdown, the cause its pre-bootstrap interrupted + result carries (#134889 GW-22). A user's Stop latched first keeps no cause.""" + stopped = 0 + for sid, generation in list(getattr(authority, '_managed_spawning', {}).items()): + if sid in workers: + continue + stops = authority.pending_stops + if reason and stops.get(sid) != generation: + causes = getattr(authority, 'pending_stop_causes', None) + if causes is None: + causes = authority.pending_stop_causes = {} + causes[sid] = (generation, reason) + stops[sid] = generation + stopped += 1 + return stopped + + def stop_managed_turns(runner, reason=None): """``stop_authority_turns`` across every served authority (whole-runtime shutdown).""" return sum(stop_authority_turns(authority, reason=reason) for authority in _authorities(runner)) diff --git a/gateway/run_shutdown.py b/gateway/run_shutdown.py index f0277bca10..9cc69130b5 100644 --- a/gateway/run_shutdown.py +++ b/gateway/run_shutdown.py @@ -1268,18 +1268,15 @@ class GatewayShutdownMixin(GatewaySessionEndMixin): return with suppress(Exception): if hasattr(agent, "shutdown_memory_provider"): - # Drain queued memory writes BEFORE teardown (shutdown_all() gives the worker only ~5s, so a - # /reset or rotation could drop them). Bounded; a failure never blocks teardown. - # The memory manager persists per-turn sync and end-of-session extraction on a single - # serialized background worker. shutdown_memory_provider() -> shutdown_all() only gives that - # worker a ~5s bounded drain and abandons (cancels) anything still queued past it, so a - # /reset — or any gateway session rotation that reaches this cleanup path — could silently - # drop writes the session had already handed off. The next session then loads stale memory - # (#73297). Give pending work a bounded head start through the manager's own barrier first, - # mirroring the CLI exit path (cli.py). Best-effort: a flush failure must never block - # teardown. + # Drain queued memory writes BEFORE teardown: shutdown_all() gives the single serialized + # worker only ~5s and cancels the rest, so a /reset or rotation could drop writes and the + # next session loads stale memory (#73297). Bounded, like the CLI exit path; never blocks. + # The teardown claim comes first: a /new that stops waiting on this job evicts the agent, + # and that soft release must leave extraction + provider shutdown to us. _mm = getattr(agent, "_memory_manager", None) - if _mm is not None and hasattr(_mm, "flush_pending"): + if _mm is not None: + with suppress(Exception): + _mm.claim_teardown(session_end=True) with suppress(Exception): _mm.flush_pending(timeout=10) # Pass the real transcript so ``on_session_end`` hooks don't see the empty default. diff --git a/gateway/session_authority.py b/gateway/session_authority.py index 8e82a226da..0fb0b5b349 100644 --- a/gateway/session_authority.py +++ b/gateway/session_authority.py @@ -135,6 +135,9 @@ class SessionAuthority: # Stops accepted for a running generation whose agent does not exist yet # (first-turn construction); consumed by adopt_agent, keyed session -> generation. self.pending_stops = {} + # session -> (generation, reason): a gateway restart/shutdown Stop latched before that + # generation's managed worker existed; its pre-bootstrap result carries the cause (GW-22). + self.pending_stop_causes = {} # session -> (generation, [steer text]) accepted before that generation adopted an agent; # adopt_agent hands it to that agent (never a later generation's), the drain drops the rest. self.pending_steers = {} @@ -625,11 +628,15 @@ class SessionAuthority: return results async def submit(self, actor: Principal, request: Submission, *, _authorize_write=None, queue_while_restarting=False, - _replay_guard=None): + _replay_guard=None, _kanban_card=None): """``queue_while_restarting``: the caller is an interactive client attached to a restart's after-turn wait, which commits the input queued for the next owner. Machine callers (a2a, kanban, hosted rooms) keep the plain ``runtime_draining`` refusal and retry against the next - owner with their own stable request id; none of them reads a released waiter as queued.""" + owner with their own stable request id; none of them reads a released waiter as queued. + + ``_kanban_card`` is the Kanban dispatcher's trusted producer marker (``session_kanban.run_task`` + only): committed as ``kanban_card_v1``, it lets a card attempt lost across an owner restart + settle interrupted (#134889 KB-8). Never a payload field: a client cannot supply it.""" self.authorize(actor, request.ref, 'session:submit') async with self.sessions[request.ref.session_id].mutation_lock: # Deletion can retire this session while the caller waits on its writer. @@ -637,9 +644,11 @@ class SessionAuthority: from gateway.session_ingress_media import capture_lease with capture_lease(): # captured bytes are held from capture until commit or refusal return await self._submit(actor, request, _authorize_write=_authorize_write, - queue_only=queue_while_restarting is True, _replay_guard=_replay_guard) + queue_only=queue_while_restarting is True, _replay_guard=_replay_guard, + _kanban_card=_kanban_card) - async def _submit(self, actor, request, *, _authorize_write=None, queue_only=False, _replay_guard=None): + async def _submit(self, actor, request, *, _authorize_write=None, queue_only=False, _replay_guard=None, + _kanban_card=None): self._require_admission_open(queue_only=queue_only) if (request.intent != 'queue' or not {'text'} <= set(request.payload) <= { 'text', 'attachments', 'finite', 'unattended', 'surface', 'voice_context', 'interrupted', @@ -661,6 +670,8 @@ class SessionAuthority: payload = {'text': request.payload['text'], **finite, **admit_surface(request.payload), **admit_display(request.payload), **admit_cli_turn(request.payload), **admit_attachments(request.payload.get('attachments'), admitted=admitted)} + if _kanban_card is not None: + payload['kanban_card_v1'] = _kanban_card source = self.sessions[request.ref.session_id].source if source is not None and source.user_id != actor.subject: # Durable server authorization, not a client payload field. The original @@ -1147,6 +1158,7 @@ class SessionAuthority: with live.event_stream.lock: live.event_stream.execution = {} self.pending_stops.pop(ref.session_id, None) + self.pending_stop_causes.pop(ref.session_id, None) self.pending_steers.pop(ref.session_id, None) self.adopted.pop(ref.session_id, None) # An automation turn nobody waits on settles here, at the exit that fenced it (GW-10), @@ -1270,6 +1282,7 @@ class SessionAuthority: with live.event_stream.lock: live.event_stream.execution = {} self.pending_stops.pop(ref.session_id, None) + self.pending_stop_causes.pop(ref.session_id, None) self.pending_steers.pop(ref.session_id, None) self.adopted.pop(ref.session_id, None) from gateway.session_queued_controls import publish_transfers diff --git a/gateway/session_busy_controls.py b/gateway/session_busy_controls.py index 47d35849b5..a2aee6871e 100644 --- a/gateway/session_busy_controls.py +++ b/gateway/session_busy_controls.py @@ -17,7 +17,8 @@ def handlers(connection): def authorize(connection, ref, params, capability): connection.authority.authorize(connection.actor, ref, capability) - if 'profile' in params: + # The contract's ``profile`` is nullable; null means this owner, as an omitted key does. + if params.get('profile') is not None: from hermes_cli.profiles import profile_matches_home profile = params['profile'] if not isinstance(profile, str): diff --git a/gateway/session_cli_turn.py b/gateway/session_cli_turn.py index 7ccd382b00..62140b3b70 100644 --- a/gateway/session_cli_turn.py +++ b/gateway/session_cli_turn.py @@ -108,7 +108,10 @@ def one_turn_runtime(override, model, runtime_kwargs, config): runtime = dict(runtime_kwargs) current_provider = runtime_kwargs.get('provider') or '' same_provider = (result.target_provider or '').strip().lower() == current_provider.strip().lower() - if same_provider and not result.resolved_via_alias: + # Only a ``model_aliases`` entry with its own ``base_url`` routes away from the session, as switch_model + # applied it to this result (``_apply_direct_alias_endpoint``); a built-in shortcut, an endpointless entry + # or a process provider's declared id names a model like a plain pick. + if same_provider and not result.alias_endpoint_applied: # A plain model pick on the session's provider: the explicit provider re-resolved that provider's # default endpoint and key, and the session already has both (a mirror, a proxy, a pool), so keep # them. The wire follows the target only where the provider picks it per model (Copilot, Nous diff --git a/gateway/session_controls.py b/gateway/session_controls.py index fbcf87b8d7..3e535162fa 100644 --- a/gateway/session_controls.py +++ b/gateway/session_controls.py @@ -58,7 +58,7 @@ def _log_refusal(profile, method, exc: RuntimeStoreError) -> None: args = (method, _profile_label(profile), exc.reason, f' field={exc.field}' if getattr(exc, 'field', None) else '', f' detail={exc.detail}' if getattr(exc, 'detail', None) else '') - if getattr(exc, 'field', None) or getattr(exc, 'detail', None): + if exc.reason != 'session_busy' and (getattr(exc, 'field', None) or getattr(exc, 'detail', None)): logger.warning(line, *args) else: logger.debug(line, *args) @@ -773,13 +773,13 @@ class AuthorityConnection: async def mutate(self, ref, params): from gateway.session_mutations import mutate_session - from hermes_state_mutation_guards import MUTATION_GUARD_REFUSALS + from hermes_state_mutation_guards import MUTATION_GUARD_REFUSALS, busy_refusal try: result = await mutate_session(self.authority, self.actor, ref, params) except MUTATION_GUARD_REFUSALS as exc: # A live turn lease / compression lock is the same retryable busy verdict a # running admission gets (REST maps it to 409), not an internal error. - raise RuntimeStoreError('session_busy') from exc + raise busy_refusal(exc) from exc except RuntimeStoreError as exc: if exc.reason in {'revision_conflict', 'stale_generation'}: # The fresh fences ride on the refusal, so the client retries once without a re-read. diff --git a/gateway/session_kanban.py b/gateway/session_kanban.py index 0eb71e5907..6b89dfa373 100644 --- a/gateway/session_kanban.py +++ b/gateway/session_kanban.py @@ -141,7 +141,14 @@ async def run_task(connection, params): payload: dict = {'text': f'work kanban task {params["task_id"]}'} if committed is None or 'finite' in committed['payload']: payload['finite'] = True - receipt = await authority.submit(connection.actor, Submission(request_id, ref, payload, 'queue')) + # The dispatcher's trusted producer marker (#134889 KB-8): nobody waits on a card attempt, so one + # lost across an owner restart settles interrupted instead of pausing the card session. An + # attempt main committed without it resubmits unmarked (same digest, original receipt). + card = None + if committed is None or 'kanban_card_v1' in committed['payload']: + card = {'board': context.get('board'), 'task_id': params['task_id'], 'run_id': params['run_id']} + receipt = await authority.submit(connection.actor, Submission(request_id, ref, payload, 'queue'), + _kanban_card=card) return {'session_id': ref.session_id, 'receipt': asdict(receipt)} @@ -238,10 +245,19 @@ def _worker_workspace(frame): if not session_id: yield None return + from gateway.session_authorities import served_profile_name from gateway.session_context import clear_session_vars, set_session_vars from gateway.session_policy import bind_turn_workspace, restore_policy + from hermes_state_keys import profile_from_session_key policy = restore_policy(frame['policy']) - tokens = set_session_vars(session_key=frame.get('route') or session_id, session_id=session_id) + route = frame.get('route') or session_id + # The profile its in-process turns bind, so persistent Docker keys the container they and the + # session's side agents use (#140665): a served secondary's name, which its routes carry + # (``agent::``); none for the launch home, whatever its name (``hermes -p beta gateway`` + # keys ``agent:main:``, and only the default profile runs the multiplexer). + profile = served_profile_name(frame.get('home')) + tokens = set_session_vars(session_key=route, session_id=session_id, + profile=profile if profile_from_session_key(route) == profile else '') try: bind_turn_workspace(policy, session_id) yield session_id diff --git a/gateway/session_managed_worker.py b/gateway/session_managed_worker.py index ca11678401..b2fdfd3f2b 100644 --- a/gateway/session_managed_worker.py +++ b/gateway/session_managed_worker.py @@ -454,6 +454,13 @@ def _interrupted_before_bootstrap(authority, row, cause=None): return '' +def _latched_stop_cause(authority, session_id, generation): + """The gateway restart/shutdown cause latched for this generation before its worker existed + (``run_runtime._stop_spawning_managed``), consumed once; None for a user's Stop.""" + latched = getattr(authority, 'pending_stop_causes', {}).pop(session_id, None) + return latched[1] if latched and latched[0] == generation else None + + def _with_stop_cause(accepted, worker): """The wire result carries no interruption cause; the owner recorded which stop it sent.""" if accepted.get('interrupted') and worker.stop_cause: @@ -514,14 +521,35 @@ def _compression_ancestors(db, session_id): return ancestors[::-1] -async def execute_managed(authority, ref, row, policy): - from gateway.run_turn_progress import publish_worker_tool_event - # This route never passes the inbound prepare step, so the worker would read ``@`` tokens as typed. - from gateway.session_context_refs import managed_turn_text, refused_turn_result - text, refusal = await managed_turn_text(authority, row, policy) - if refusal is not None: - authority.pending_results[row['admission_id']] = {'result': refused_turn_result(row, refusal), 'usage': {}} - return refusal +def _mark_spawning(authority, ref, row): + """Mark the generation pre-worker: until the worker registers, a gateway stop has nothing to + signal, so it latches for this generation instead (#134889 GW-22). Set before ``@`` expansion, + which may await the network, so a Stop there is never lost.""" + spawning = getattr(authority, '_managed_spawning', None) + if spawning is None: + spawning = authority._managed_spawning = {} + spawning[ref.session_id] = row.get('generation') + return spawning + + +def _stopped_before_spawn(authority, ref, row): + """A Stop latched for this generation while it had no worker (a gateway stop during ``@`` + expansion, or a user's Stop): consume it and record the interrupted result, never spawning.""" + generation = row.get('generation') + stops = getattr(authority, 'pending_stops', {}) + if generation is None or stops.get(ref.session_id) != generation: + return False + del stops[ref.session_id] + authority._managed_spawning.pop(ref.session_id, None) + _interrupted_before_bootstrap(authority, row, _latched_stop_cause(authority, ref.session_id, generation)) + return True + + +async def _spawn_registered_worker(authority, ref, row): + """Spawn the turn's child and register it for Stops: ``(worker, process)``, or None when the spawn was cancelled (the + interrupted result is already recorded). The generation stays marked spawning until registration.""" + spawning = _mark_spawning(authority, ref, row) + generation = row.get('generation') try: env = await asyncio.to_thread(_worker_env, authority) cwd = (await asyncio.to_thread(Path(__file__).resolve)).parents[1] @@ -533,7 +561,12 @@ async def execute_managed(authority, ref, row, policy): stderr=subprocess.DEVNULL if log_path is None else subprocess.PIPE, **WORKER_POPEN) except asyncio.CancelledError: # acquire_process owns late-child cleanup; no bootstrap or reservation was sent. - return _interrupted_before_bootstrap(authority, row) + spawning.pop(ref.session_id, None) + _interrupted_before_bootstrap(authority, row, _latched_stop_cause(authority, ref.session_id, generation)) + return None + except BaseException: + spawning.pop(ref.session_id, None) + raise worker = ManagedWorker(process) if log_path is not None: worker.drain_stderr(log_path) @@ -541,6 +574,32 @@ async def execute_managed(authority, ref, row, policy): if workers is None: workers = authority._managed_workers = {} workers[ref.session_id] = worker + spawning.pop(ref.session_id, None) + worker.stop_cause = _latched_stop_cause(authority, ref.session_id, generation) + return worker, process + + +async def execute_managed(authority, ref, row, policy): + from gateway.run_turn_progress import publish_worker_tool_event + # This route never passes the inbound prepare step, so the worker would read ``@`` tokens as typed. + from gateway.session_context_refs import managed_turn_text, refused_turn_result + _mark_spawning(authority, ref, row) + try: + text, refusal = await managed_turn_text(authority, row, policy) + except BaseException: + authority._managed_spawning.pop(ref.session_id, None) + raise + if refusal is not None: + authority._managed_spawning.pop(ref.session_id, None) + authority.pending_results[row['admission_id']] = {'result': refused_turn_result(row, refusal), 'usage': {}} + return refusal + if _stopped_before_spawn(authority, ref, row): + return '' + spawned = await _spawn_registered_worker(authority, ref, row) + if spawned is None: + return '' + worker, process = spawned + workers = authority._managed_workers # A Stop acknowledged during the env/spawn awaits found no worker and was latched for this # generation; consume it in the same step that makes the worker reachable to interrupt_managed. authority.adopt_agent(ref.session_id, row['generation'], worker) diff --git a/gateway/session_operator.py b/gateway/session_operator.py index 40d411e755..37e91ac50f 100644 --- a/gateway/session_operator.py +++ b/gateway/session_operator.py @@ -9,7 +9,7 @@ from hermes_state_runtime import RuntimeStoreError # Fields ``SessionAuthority.submit`` commits for a local session's input; producers commit their own envelope. LOCAL_INPUT_FIELDS = frozenset({'text', 'attachments_v1', 'finite', 'unattended', 'surface_v1', 'display_v1', - 'cli_turn_v1'}) + 'cli_turn_v1', 'kanban_card_v1'}) def is_viewer_input(row): diff --git a/gateway/session_results.py b/gateway/session_results.py index 13b8ab653a..6eec49c229 100644 --- a/gateway/session_results.py +++ b/gateway/session_results.py @@ -84,8 +84,17 @@ def finish_result(db, *, epoch, row, response, outcome, result=None, prepared=No result = load_result(saved[0]) return dict(current), result['result'].get('final_response') or '' outcome, stored = prepared or prepare_result(row, response, outcome, result) + # A turn the owner's restart/stop interrupted: its reason commits as a Hermes transcript notice + # in this same write, so a viewer that reconnects after the restart reads why (#134889 GW-21); + # the live ``message.complete.warning`` carries the same copy. A retry that finds the row + # terminal returns above, so the notice is written once. + from gateway.session_settlement_recovery import owner_stop_notice + notice = owner_stop_notice(result, outcome, row) + write = None if notice is None else (lambda conn, _current: close_discarded_turn( + db, conn, row, notice, replace_after=row.get('transcript_mark'))) settled = settle_session_input(db, epoch=epoch, admission_id=row['admission_id'], - generation=row['generation'], outcome=outcome, result=stored, _transfers=transfers) + generation=row['generation'], outcome=outcome, result=stored, _transfers=transfers, + _terminal_write=write) return settled, response @@ -99,7 +108,7 @@ def admission_result(db, admission_id): return load_result(saved[0]) if saved else None -def close_discarded_turn(db, conn, row, notice=None): +def close_discarded_turn(db, conn, row, notice=None, *, replace_after=None): """Close a discarded ``unknown`` turn the way a failed turn is closed, on the resolution's own transaction (``resolve_unknown_session_input(_terminal_write=...)``). @@ -116,14 +125,23 @@ def close_discarded_turn(db, conn, row, notice=None): gets the same boundary (merged onto the call row, whose calls are then pruned). ``notice`` (an unattended automation turn settled by the owner, GW-10) replaces the hedged copy - and is written even on a closed tail: nobody resolved it, so the transcript must say why.""" + and is written even on a closed tail: nobody resolved it, so the transcript must say why. + + ``replace_after`` (the claim's transcript mark, an owner-stop notice): when the tail is a + failed-turn boundary written after that mark, it is THIS turn's own (the agent's core closer + wrote "Send it again" for the interrupted turn), so the notice replaces its copy instead of + stacking a second boundary (#134889 GW-21). An earlier turn's boundary stays.""" import time from agent.turn_failure_copy import FAILED_TURN_DISPLAY_KIND, PARTIAL_FAILED_TURN_NOTICE from hermes_state_local_lineage import local_physical_target from hermes_state_runtime import _canonical_chain target = _canonical_chain(conn, local_physical_target(conn, row['target_session_id']))[-1] - tail = conn.execute("SELECT role, tool_calls FROM messages WHERE session_id=? AND active=1 " + tail = conn.execute("SELECT role, tool_calls, id, display_kind FROM messages WHERE session_id=? AND active=1 " "AND role NOT IN ('session_meta','system') ORDER BY id DESC LIMIT 1", (target,)).fetchone() + if (notice is not None and type(replace_after) is int and tail is not None and tail[0] == 'assistant' + and tail[3] == FAILED_TURN_DISPLAY_KIND and tail[2] > replace_after): + conn.execute("UPDATE messages SET content=?, display_metadata=NULL WHERE id=?", (notice, tail[2])) + return True if notice is None and (tail is None or not ( tail[0] == 'user' or (tail[0] == 'assistant' and tail[1] not in (None, '', '[]')))): return False diff --git a/gateway/session_settlement_recovery.py b/gateway/session_settlement_recovery.py index b6da9cd249..bec411d9f9 100644 --- a/gateway/session_settlement_recovery.py +++ b/gateway/session_settlement_recovery.py @@ -99,7 +99,7 @@ def _publish_completion(authority, live, ref, row, settled, response, captured): except Exception: logger.exception('Terminal control cleanup failed for admission %s', row['admission_id']) complete = completion_payload(row, settled, response, captured) - notice = owner_stop_notice(captured, settled['outcome']) + notice = owner_stop_notice(captured, settled['outcome'], row) if notice and not complete.get('warning'): # ``warning`` is the slot every surface already renders on message.complete (Ink activity # line, Desktop toast), so a turn the owner's stop cut short says why (#134889 F1). @@ -113,17 +113,25 @@ SHUTDOWN_INTERRUPT_NOTICE = ('Gateway shutting down: this turn was interrupted a 'Send your message again once Hermes is back.') -def owner_stop_notice(captured, outcome): +def owner_stop_notice(captured, outcome, row=None): """The named notice for a turn the owner's restart/stop interrupted, or None. With the default ``agent.restart_drain_timeout: 0`` a restart interrupts in-flight turns at once; the admission - settles ``interrupted`` and is never replayed, so the viewer is told the restart did it.""" + settles ``interrupted`` and is never replayed, so the viewer is told the restart did it. + An unattended ``row`` (cron, automation, webhook) has nobody to resend it, so it gets the + neutral automation copy instead of resend advice (#134889 GW-20, GW-10's rule).""" # The cause is the one the turn recorded when it was interrupted (stop() interrupts with these # reasons), never the runner's flags at publication: a user Stop whose settlement was delayed # until after shutdown began is still the user's Stop. from gateway.run import _INTERRUPT_REASON_GATEWAY_RESTART, _INTERRUPT_REASON_GATEWAY_SHUTDOWN cause = ((captured or {}).get('result') or {}).get('interrupt_message') if outcome == 'interrupted' else None - return {_INTERRUPT_REASON_GATEWAY_RESTART: RESTART_INTERRUPT_NOTICE, - _INTERRUPT_REASON_GATEWAY_SHUTDOWN: SHUTDOWN_INTERRUPT_NOTICE}.get(cause) + notice = {_INTERRUPT_REASON_GATEWAY_RESTART: RESTART_INTERRUPT_NOTICE, + _INTERRUPT_REASON_GATEWAY_SHUTDOWN: SHUTDOWN_INTERRUPT_NOTICE}.get(cause) + if notice is not None and row is not None: + from gateway.session_unattended_unknown import is_unattended + if is_unattended(row): + from agent.turn_failure_copy import AUTOMATION_INTERRUPTED_NOTICE + return AUTOMATION_INTERRUPTED_NOTICE + return notice def _release_media(authority, row): diff --git a/gateway/session_tui_ops.py b/gateway/session_tui_ops.py index 415c39f447..fe01a86cca 100644 --- a/gateway/session_tui_ops.py +++ b/gateway/session_tui_ops.py @@ -29,7 +29,8 @@ def handlers(connection): 'rollback.diff': partial(rollback, connection, kind='diff'), 'rollback.restore': partial(rollback, connection, kind='restore'), 'rollback.session_diff': partial(rollback, connection, kind='session_diff'), - 'session.context_breakdown': partial(context_breakdown, connection)} + 'session.context_breakdown': partial(context_breakdown, connection), + 'session.history': partial(session_history, connection)} def _profile_home(authority): @@ -116,6 +117,21 @@ async def export_session(connection, ref, params): return {'format': fmt, 'filename': default_save_filename(target, fmt), 'content': content} +async def session_history(connection, ref, params): + """Main's ``session.history``: the display transcript of the conversation's current physical row (compression tip or local receipt entry), ancestors included, each row with its durable ``row_id``; Desktop's edit resolves its turn by it.""" + _live_ref(connection, ref, params, {'session_id', 'profile'}, 'session:read') + authority = connection.authority + from gateway.session_local_recovery import transcript_target + # On the loop, as ``attach`` resolves it: a local restore publishes the live entry. + target = transcript_target(authority, ref) + + def read(): + from gateway.session_controls import display_projection + rows = authority.db.get_messages_as_conversation(target, include_ancestors=True, include_row_ids=True) + return {'count': len(rows), 'messages': display_projection(rows, authority.profile_id)} + return await asyncio.to_thread(read) + + async def reload_env(connection, ref, params): """``/reload``: re-read the profile's ``.env`` (main's ``reload.env`` / CLI ``/reload``). diff --git a/gateway/session_tui_side.py b/gateway/session_tui_side.py index 00892989e5..76d745efba 100644 --- a/gateway/session_tui_side.py +++ b/gateway/session_tui_side.py @@ -56,11 +56,17 @@ def _admit(connection, ref, params, prefix, required='text', optional=()): def _side_scope(authority, policy, task_id): """The owning profile's home/secrets, the session's frozen policy, and the task's own session identity (approval key, HERMES_SESSION_ID) for the side agent's thread.""" - from gateway.session_authorities import owner_scope + from gateway.session_authorities import owner_scope, served_profile_name from gateway.session_context import clear_session_vars, set_session_vars from gateway.session_policy import bind_turn_workspace, policy_scope, unbind_turn_workspace + from tools.environments.local import _is_routed_home + # The owning profile, as its own turns bind it: a served secondary by its name, so persistent + # Docker keys ITS workspace container (never the launch profile's live one for the same folder); + # the launch home binds none, like its ``agent:main`` turns. + home = authority.profile_id + profile = served_profile_name(home) if _is_routed_home(home) else '' with owner_scope(authority, hydrate_secrets=True), policy_scope(policy, authority=authority): - tokens = set_session_vars(session_key=task_id, session_id=task_id, cwd=policy.cwd) + tokens = set_session_vars(session_key=task_id, session_id=task_id, cwd=policy.cwd, profile=profile) try: # The session's workspace, as its own turns get it (#140665): mounted at /workspace in a # per-task container, or the session's own persistent workspace container (same digest). diff --git a/gateway/session_unattended_unknown.py b/gateway/session_unattended_unknown.py index a5587ce89f..f60b77e420 100644 --- a/gateway/session_unattended_unknown.py +++ b/gateway/session_unattended_unknown.py @@ -4,7 +4,7 @@ A turn lost across an owner restart is fenced ``unknown`` and pauses its session only the person who sent it knows whether to resend it. An automation delivery has no such person: a cron run (``CRON_PRINCIPAL_ID``), a process completion or heartbeat (``automation:``, ``local_automation_v1``, a native envelope's ``automation`` block) or a webhook delivery -(``webhook_delivery``). Pausing on it stalls every message queued behind it until an operator +(``webhook_delivery``) or a Kanban card attempt (``kanban_card_v1``, set by the dispatcher). Pausing on it stalls every message queued behind it until an operator notices (a Bot Chat sat ten hours behind one). Such a row settles through the same fenced resolution a person's Discard uses @@ -28,7 +28,10 @@ def is_unattended(row) -> bool: payload = row.get('payload') or {} native = payload.get('native_text_v1') or {} # A webhook delivery: the provider already got its 202 and nobody can be asked to resend it. - return 'local_automation_v1' in payload or 'automation' in native or 'webhook_delivery' in native + # A Kanban card attempt (#134889 KB-8): the dispatcher's marker, committed only by + # ``SessionAuthority.submit(_kanban_card=...)``; a card row main minted without it still pauses. + return ('local_automation_v1' in payload or 'kanban_card_v1' in payload + or 'automation' in native or 'webhook_delivery' in native) def _unattended_unknown(rows): diff --git a/hermes_cli/agent_plugins.py b/hermes_cli/agent_plugins.py index 73192ca32b..f983c39060 100644 --- a/hermes_cli/agent_plugins.py +++ b/hermes_cli/agent_plugins.py @@ -490,6 +490,15 @@ def load_agent_plugin(plugin_root: Path, data_root: Path) -> AgentPluginPackage: ) +def manifest_config_schema(manifest: Any) -> Any: + """Raw ``extensions.com.nousresearch.hermes.config_schema`` of a parsed ``plugin.json`` (Agent + Plugins v1 forbids unknown top-level fields), else ``None``. Unvalidated: the settings form + filters it, the loader and ``plugins validate`` report what is malformed.""" + extensions = manifest.get("extensions") if isinstance(manifest, dict) else None + hermes = extensions.get(_HERMES_EXTENSION) if isinstance(extensions, dict) else None + return hermes.get("config_schema") if isinstance(hermes, dict) else None + + def read_agent_plugin_manifest(plugin_root: Path) -> tuple[dict, tuple[AgentPluginDiagnostic, ...]]: """Validate only root ``plugin.json`` without discovering components.""" _root, manifest, diagnostics = _validate_root(plugin_root) diff --git a/hermes_cli/auth_plugin_providers.py b/hermes_cli/auth_plugin_providers.py index ffff857bc3..09b775ed8b 100644 --- a/hermes_cli/auth_plugin_providers.py +++ b/hermes_cli/auth_plugin_providers.py @@ -182,7 +182,13 @@ def dispatch_plugin_auth(action: str, args: Any, provider: str) -> bool: True = the plugin owned the action (core prints nothing more). False = run the built-in path. A handler exception becomes a readable ``SystemExit`` naming provider and action. + ``:`` names an OAuth client a plugin declares under ``oauth:`` (:mod:`hermes_cli.plugin_oauth`). """ + from hermes_cli.plugin_oauth import handle_auth_action + + owned = handle_auth_action(action, args, provider) + if owned is not None: + return owned handler = plugin_auth_handler(provider) if handler is None: return False diff --git a/hermes_cli/cli_chat_turn_mixin.py b/hermes_cli/cli_chat_turn_mixin.py index da41cb7dca..c236cb921f 100644 --- a/hermes_cli/cli_chat_turn_mixin.py +++ b/hermes_cli/cli_chat_turn_mixin.py @@ -71,11 +71,18 @@ class CLIChatTurnMixin: turn_route = self._resolve_turn_agent_config(message) if turn_route["signature"] != self._active_agent_route_signature: _retire_agent(self) - if self.agent is None: - _cprint(f"{_DIM}{t('cli.chat.initializing_agent')}{_RST}") - if not self._init_agent(model_override=turn_route["model"], runtime_override=turn_route["runtime"], - request_overrides=turn_route.get("request_overrides")): - return None + # A first-turn agent build shows in the live spinner (the spinner thread repaints it), never + # as a scrollback line. + initializing = t("cli.chat.initializing_agent") if self.agent is None else "" + if initializing: + self._spinner_text = initializing + try: + if not self._init_agent(model_override=turn_route["model"], runtime_override=turn_route["runtime"], + request_overrides=turn_route.get("request_overrides")): + return None + finally: + if initializing and getattr(self, "_spinner_text", "") == initializing: + self._spinner_text = "" agent = self.agent if agent is None: return None @@ -92,8 +99,9 @@ class CLIChatTurnMixin: if isinstance(message, TimelineNotification): message = str(message) # UI metadata is on the staged row, never in model content. - ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") - _cprint("") + if not getattr(self, "_modern_layout", False): + ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") + _cprint("") from agent.notification_presentation import notification_config_snapshot, notification_policy_snapshot with notification_policy_snapshot(agent, "cli", notification_config_snapshot()): @@ -246,6 +254,10 @@ class CLIChatTurnMixin: # display_callback only when token streaming is off: with streaming on, # _stream_delta already renders the text and this would print it twice. def display_callback(sentence: str): + if not turn.box_opened and getattr(self, "_modern_layout", False): + from hermes_cli.cli_modern_layout import answer_label + turn.box_opened = True + _cprint(answer_label()) if not turn.box_opened: turn.box_opened = True label = " ☤ Hermes " @@ -624,7 +636,12 @@ class CLIChatTurnMixin: # intermediate turn boundaries (tool loops) and re-rendered the box after the answer. if self.show_reasoning and turn.result and not self._reasoning_shown_this_turn: reasoning = turn.result.get("last_reasoning") - if reasoning: + if reasoning and getattr(self, "_modern_layout", False): + from hermes_cli.cli_modern_layout import thought_rows + _cprint("") + for line in thought_rows(None, reasoning, self.reasoning_full): + _cprint(line) + elif reasoning: w = self._scrollback_box_width() r_label = f" {t('cli.chat.reasoning_label')} " r_top = f"{_DIM}┌─{r_label}{'─' * max(w - 3 - len(r_label), 0)}┐{_RST}" @@ -667,15 +684,22 @@ class CLIChatTurnMixin: already_streamed = ( (self._stream_started and self._stream_box_opened) or _interrupted_streamed ) and not is_error_response + modern = getattr(self, "_modern_layout", False) if turn.use_streaming_tts and turn.box_opened and not is_error_response: # Text already printed sentence-by-sentence; just close the box. - _cprint(f"\n{_ACCENT}╰{'─' * (self._scrollback_box_width() - 2)}╯{_RST}") + if not modern: + _cprint(f"\n{_ACCENT}╰{'─' * (self._scrollback_box_width() - 2)}╯{_RST}") elif already_streamed: # _flush_stream() already closed the streamed box; a post-stream transform # hook shows a suffix for append-only changes, else the full replacement. _post_stream_text = _post_stream_transform_output(response, turn.result) if _post_stream_text.strip(): _cprint(_post_stream_text) + elif modern: + from hermes_cli.cli_modern_layout import answer_label + _cprint(answer_label()) + ChatConsole().print(_render_final_assistant_content(response, mode=self.final_response_markdown), + style=_resp_text, soft_wrap=True) # terminal wraps; resize re-flows else: ChatConsole().print(Panel( _render_final_assistant_content(response, mode=self.final_response_markdown), diff --git a/hermes_cli/cli_config_load.py b/hermes_cli/cli_config_load.py index c382cc3d00..9639720df0 100644 --- a/hermes_cli/cli_config_load.py +++ b/hermes_cli/cli_config_load.py @@ -209,6 +209,7 @@ def _cli_config_defaults(): "resume_display": "full", "resume_exchanges": 10, "resume_max_user_chars": 300, "resume_max_assistant_chars": 200, "resume_max_assistant_lines": 3, "resume_skip_tool_only": True, "show_reasoning": True, "reasoning_full": False, "streaming": True, "busy_input_mode": "interrupt", + "cli_layout": "modern", "persistent_output": True, "persistent_output_max_lines": 200, # Also clear scrollback on redraw/resize recovery; off because users prefer history. "cli_rebuild_scrollback_on_redraw": False, diff --git a/hermes_cli/cli_init_mixin.py b/hermes_cli/cli_init_mixin.py index 6119553d1d..65b202d9f1 100644 --- a/hermes_cli/cli_init_mixin.py +++ b/hermes_cli/cli_init_mixin.py @@ -121,8 +121,11 @@ class CLIInitMixin: enabled=display.get("persistent_output", True), max_lines=display.get("persistent_output_max_lines", 200), ) + from hermes_cli.cli_modern_layout import busy_input_mode_from_config, layout_from_config, set_active + self._modern_layout = layout_from_config(display) # display.cli_layout: modern | classic + set_active(self._modern_layout) # busy_input_mode: "interrupt" (redirect the run) | "queue" (next turn) | "steer" (inject mid-run). - _bim = str(display.get("busy_input_mode", "interrupt")).strip().lower() + _bim = busy_input_mode_from_config(display) self.busy_input_mode = _bim if _bim in ("queue", "steer") else "interrupt" # verbose ONLY controls global DEBUG logging; tool_progress="verbose" is independent @@ -220,6 +223,7 @@ class CLIInitMixin: # --api-key wins; otherwise a URL-bearing startup alias carries its own credential. # See #28660. self._explicit_api_key = api_key or _startup_api_key_override or None + self._typed_api_key = api_key or None # a startup resume it cannot bind is refused; an alias's falls back self._explicit_base_url = base_url or _startup_base_url_override or None # Resolved lazily at use-time via _ensure_runtime_credentials(). diff --git a/hermes_cli/cli_loops_mixin.py b/hermes_cli/cli_loops_mixin.py index 413b45cd42..e1c4fa607d 100644 --- a/hermes_cli/cli_loops_mixin.py +++ b/hermes_cli/cli_loops_mixin.py @@ -10,6 +10,7 @@ import threading import time from agent.i18n import t +from hermes_cli.gateway_client import GatewayClientError from rich.markup import escape as _escape @@ -352,29 +353,79 @@ class CLILoopsMixin: q.not_empty.notify_all() return before, after + def _gateway_queue_store(self): + """The gateway session store when this CLI is a viewer of the gateway's session, else None.""" + from hermes_cli.gateway_tools_notify import gateway_store + return gateway_store(self) if getattr(self, "session_id", None) else None + + def _queue_items(self) -> list: + """``[(admission_id | None, item)]``: the owner's queued admissions (run first, in its FIFO + order), then the turns still waiting in ``_pending_input``.""" + items: list = [] + store = self._gateway_queue_store() + if store is not None: + from hermes_cli import gateway_queue + items = list(gateway_queue.queued_rows(self, store)) + return items + [(None, item) for item in self._pending_input_items()] + def _queue_enqueue(self, text: str) -> None: from cli import _cprint payload = self._expand_paste_references(text) - self._pending_input.put(payload) + store = self._gateway_queue_store() if self._agent_running else None + if store is not None: + # #134889 N5: the gateway's durable FIFO holds it (committed at accept, kept across an owner + # restart, run once after the current turn), never this process. + from hermes_cli import gateway_queue + try: + gateway_queue.submit_queued(self, store, payload) + except gateway_queue.QueueOutcomeUnknown as exc: # sent, no answer: it may be queued + _cprint(f" {t('cli.queue.submit_unknown', error=exc)}") + return + except GatewayClientError as exc: # the owner refused, or it was never sent: nothing is held here + _cprint(f" {t('cli.queue.gateway_refused', error=exc)}") + return + else: + self._pending_input.put(payload) key = "cli.queue.queued_next_turn" if self._agent_running else "cli.queue.queued" _cprint(f" {t(key, preview=_preview(payload))}") def _queue_list(self, rest: str) -> None: from cli import _VoiceInputMessage, _cprint - items = self._pending_input_items() + items = self._queue_items() if not items: _cprint(f" {t('cli.queue.empty')}" + ("" if rest else " " + _queue_usage())) return _cprint(f" {t('cli.queue.pending_header', count=len(items))}") - for idx, item in enumerate(items, 1): + for idx, (_, item) in enumerate(items, 1): tag = " [voice]" if isinstance(item, _VoiceInputMessage) else "" _cprint(f" {idx}. {_preview(str(item).replace(chr(10), ' '))}{tag}") + def _queue_gateway_call(self, verb, index, *args): + """One owner verb on queued item ``index``; its refusal is printed (in its own words), never raised.""" + from cli import _cprint + from hermes_cli import gateway_queue + from hermes_cli.gateway_client import GatewayRPCError + try: + return getattr(gateway_queue, verb)(self, self._gateway_queue_store(), *args) + except GatewayRPCError as exc: + if str(exc) in gateway_queue.STARTED_REFUSALS: # the drain claimed it first: it runs as sent + _cprint(f" {t('cli.queue.already_started', index=index)}") + else: + key = "cli.queue.edit_refused" if verb == "replace" else "cli.queue.remove_refused" + _cprint(f" {t(key, index=index, error=exc)}") + except GatewayClientError as exc: # no answer: the owner may have applied it + _cprint(f" {t('cli.queue.change_unknown', index=index, error=exc)}") + return None + def _queue_clear(self, rest: str) -> None: from cli import _cprint + owned = [admission for admission, _ in self._queue_items() if admission] + cleared = sum(1 for idx, admission in enumerate(owned, 1) + if self._queue_gateway_call("cancel", idx, admission) == "cancelled") before, _ = self._mutate_pending_input(lambda items: []) - key = "cli.queue.cleared_one" if len(before) == 1 else "cli.queue.cleared_other" - _cprint(f" {t(key, count=len(before))}") + cleared += len(before) + key = "cli.queue.cleared_one" if cleared == 1 else "cli.queue.cleared_other" + _cprint(f" {t(key, count=cleared)}") def _queue_remove(self, rest: str) -> None: from cli import _cprint @@ -383,13 +434,26 @@ class CLILoopsMixin: if idx is None: _cprint(f" {t('cli.queue.usage_remove')}") return + items = self._queue_items() + owned = sum(1 for admission, _ in items if admission) + if 1 <= idx <= owned: + admission, text = items[idx - 1] + verdict = self._queue_gateway_call("cancel", idx, admission) + if verdict == "cancelled": + _cprint(f" {t('cli.queue.removed', index=idx, preview=_preview(str(text)))}") + elif verdict == "stopped": # claimed between the list and the cancel: the owner interrupted it + _cprint(f" {t('cli.queue.started_stopped', index=idx)}") + elif verdict == "started": + _cprint(f" {t('cli.queue.already_started', index=idx)}") + return + local = idx - owned removed: list = [] before, _ = self._mutate_pending_input( - lambda items: (removed.append(items.pop(idx - 1)) or items) if 1 <= idx <= len(items) else items) + lambda items: (removed.append(items.pop(local - 1)) or items) if 1 <= local <= len(items) else items) if removed: _cprint(f" {t('cli.queue.removed', index=idx, preview=_preview(str(removed[0])))}") else: - _cprint(f" {t('cli.queue.item_not_found', index=idx, size=len(before))}") + _cprint(f" {t('cli.queue.item_not_found', index=idx, size=owned + len(before))}") def _queue_edit(self, rest: str) -> None: from cli import _VoiceInputMessage, _cprint @@ -402,18 +466,26 @@ class CLILoopsMixin: return new_prompt = bits[1] new_text = self._expand_paste_references(new_prompt.strip()) + items = self._queue_items() + owned = sum(1 for admission, _ in items if admission) + if 1 <= idx <= owned: + # The owner admits the edit as a new admission at the back of its FIFO. + if self._queue_gateway_call("replace", idx, items[idx - 1][0], new_text): + _cprint(f" {t('cli.queue.updated', index=idx, preview=_preview(new_text))}") + return + local = idx - owned def _edit(items: list) -> list: - if 1 <= idx <= len(items): + if 1 <= local <= len(items): # A voice-queued item keeps its sentinel so the concise voice-response # prefix still applies (#65827). - voice = isinstance(items[idx - 1], _VoiceInputMessage) - items[idx - 1] = _VoiceInputMessage(new_text) if voice else new_text + voice = isinstance(items[local - 1], _VoiceInputMessage) + items[local - 1] = _VoiceInputMessage(new_text) if voice else new_text return items before, after = self._mutate_pending_input(_edit) if before == after: - _cprint(f" {t('cli.queue.item_not_found', index=idx, size=len(before))}") + _cprint(f" {t('cli.queue.item_not_found', index=idx, size=owned + len(before))}") else: _cprint(f" {t('cli.queue.updated', index=idx, preview=_preview(new_text))}") @@ -424,6 +496,12 @@ class CLILoopsMixin: if src is None or dst is None: _cprint(f" {t('cli.queue.usage_move')}") return + owned = sum(1 for admission, _ in self._queue_items() if admission) + if owned and min(src, dst) <= owned and src != dst: + # The owner's FIFO runs admissions in the order they were sent; it has no reorder verb. + _cprint(f" {t('cli.queue.move_gateway_fifo')}") + return + src, dst = src - owned, dst - owned def _move(items: list) -> list: if 1 <= src <= len(items) and 1 <= dst <= len(items): @@ -432,9 +510,9 @@ class CLILoopsMixin: before, after = self._mutate_pending_input(_move) if before == after and src != dst: - _cprint(f" {t('cli.queue.move_out_of_range', size=len(before))}") + _cprint(f" {t('cli.queue.move_out_of_range', size=owned + len(before))}") else: - _cprint(f" {t('cli.queue.moved', source=src, destination=dst)}") + _cprint(f" {t('cli.queue.moved', source=src + owned, destination=dst + owned)}") def _cmd_queue(self, cmd_original: str): """``/queue `` enqueues; a leading management verb whose arguments fit diff --git a/hermes_cli/cli_modern_layout.py b/hermes_cli/cli_modern_layout.py new file mode 100644 index 0000000000..373588e8fb --- /dev/null +++ b/hermes_cli/cli_modern_layout.py @@ -0,0 +1,379 @@ +"""Glyph-grammar transcript and bottom-anchored chrome for the classic CLI (``display.cli_layout``). + +Every row this module prints into scrollback is width-independent (a column-0 glyph, no boxes, no +full-width rules), so resizing the terminal cannot re-wrap and garble what is already printed. +Width-dependent chrome (the composer's top rule and the footer) lives only in prompt_toolkit's live +region, which is redrawn on every resize. ``display.cli_layout: classic`` restores the boxed look. +""" + +from __future__ import annotations + +import logging +import os +import re +import time +from datetime import datetime + +from agent.i18n import t + +logger = logging.getLogger(__name__) + +_SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" +_RST, _BOLD, _DIM = "\x1b[0m", "\x1b[1m", "\x1b[2m" +_RED = "\x1b[38;2;239;83;80m" +_TITLE_MAX = 72 + + +def is_modern(cli) -> bool: + return bool(getattr(cli, "_modern_layout", False)) + + +def layout_from_config(display: dict) -> bool: + """True for the glyph layout (default); ``classic`` keeps the boxed transcript.""" + return str((display or {}).get("cli_layout", "modern") or "modern").strip().lower() != "classic" + + +def busy_input_mode_from_config(display: dict) -> str: + """``display.busy_input_mode``; unset interrupts in both layouts (Tab still queues in this one).""" + return str((display or {}).get("busy_input_mode") or "interrupt").strip().lower() + + +def _skin_hex(key: str, fallback: str) -> str: + from hermes_cli.skin_engine import get_active_skin + return get_active_skin().get_color(key, fallback) or fallback + + +def _fg(hex_color: str, bold: bool = False) -> str: + try: + r, g, b = (int(hex_color.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)) + return f"\x1b[{'1;' if bold else ''}38;2;{r};{g};{b}m" + except (ValueError, IndexError): + return _BOLD if bold else "" + + +def _brand_glyph() -> tuple[str, str]: + """(glyph, name) from the skin's response label, e.g. ``" ☤ Hermes "`` → ("☤", "Hermes").""" + from hermes_cli.skin_engine import get_active_skin + label = get_active_skin().get_branding("response_label", " ☤ Hermes ") + parts = label.split(None, 1) + return (parts[0], parts[1].strip()) if len(parts) == 2 else ("☤", label.strip() or "Hermes") + + +def prompt_glyph() -> str: + from hermes_cli.skin_engine import get_active_skin + return get_active_skin().get_branding("prompt_symbol", "❯").strip() or "❯" + + +# ── transcript rows (printed into scrollback) ─────────────────────────────────────────────── + +# Fixed width, not the terminal's: a 40-cell rule never re-wraps on resize (classic's user rule). +_SEPARATOR_WIDTH = 40 + + +def separator(accent: bool) -> str: + """The bar above each user message and answer (accent) or reasoning block (dim).""" + color = _fg(_skin_hex("ui_accent", "#FFBF00")) if accent else _DIM + return f"{color}{'─' * _SEPARATOR_WIDTH}{_RST}" + + +def user_echo_markup(text: str, accent_hex: str, ts_suffix: str = "") -> str: + """Rich markup for a submitted single-line user message: ``❯ text``.""" + from rich.markup import escape + return f"[bold {accent_hex}]{escape(prompt_glyph())}[/] [bold]{escape(text)}[/]{ts_suffix}" + + +def answer_label(timestamp: str = "") -> str: + """The rows that open an answer: the accent bar, then the skin's brand glyph + name.""" + glyph, name = _brand_glyph() + stamp = f" {_DIM}{timestamp}{_RST}" if timestamp else "" + return f"\n{separator(True)}\n{_fg(_skin_hex('ui_accent', '#FFBF00'), bold=True)}{glyph} {name}{_RST}{stamp}" + + +def thought_title(text: str) -> str: + """Short title for a reasoning block: its first ``**bold**`` heading, else its first sentence.""" + text = (text or "").strip() + match = re.search(r"\*\*(.+?)\*\*", text) + title = match.group(1) if match else re.split(r"(?<=[.!?])\s|\n", text, maxsplit=1)[0] + title = " ".join(title.split()).strip("*# ") + return title if len(title) <= _TITLE_MAX else title[:_TITLE_MAX - 1].rstrip() + "…" + + +def thought_line(seconds: float | None, text: str) -> str: + """``∴ Thought for 3s · Planning the change`` (dim italic), closing a reasoning block.""" + title = thought_title(text) + took = t("cli.modern.thought_for", seconds=f"{max(seconds or 0.0, 0.0):.0f}") if seconds else t("cli.modern.thought") + body = f"{took} · {title}" if title else took + return f"\x1b[2;3m∴ {body}{_RST}" + + +_REASONING_PREVIEW_LINES = 10 # classic's post-hoc reasoning box shows this many unless /reasoning full + + +def reasoning_text_line(line: str) -> str: + """One streamed reasoning line: dim, no frame (the dim bar above opens the block).""" + return f"{_DIM}{line}{_RST}" + + +def thought_rows(seconds: float | None, text: str, full: bool) -> list[str]: + """A reasoning block printed after the fact (no live stream): dim bar, its text (first 10 + lines unless ``full``, as classic), then the ``∴ Thought for 3s`` summary.""" + lines = (text or "").strip().splitlines() + shown = lines if full or len(lines) <= _REASONING_PREVIEW_LINES else lines[:_REASONING_PREVIEW_LINES] + rows = [separator(False), *(reasoning_text_line(line) for line in shown)] + if len(shown) < len(lines): + rows.append(f"{_DIM} {t('cli.chat.reasoning_more_lines', count=len(lines) - len(shown))}{_RST}") + return [*rows, thought_line(seconds, text)] + + +def completed_tool_line(cli, function_name: str, args: dict | None, duration: float, result=None, + is_error: bool = False) -> str: + """The scrollback line for a finished tool call in the active layout.""" + if is_modern(cli): + return tool_row(function_name, args, duration, result, is_error) + from agent.display import get_cute_tool_message + return f" {get_cute_tool_message(function_name, args, duration, result=result)}" + + +def tool_row(function_name: str, args: dict | None, duration: float, result=None, is_error: bool = False) -> str: + """``📖 Read calc.py 0.2s`` / ``✗ $ pytest -q 0.4s [exit 1]`` — one row per completed call, led by + the tool's emoji (the classic ``┊`` row's), or a red ``✗`` when that entry failed.""" + from agent.display import get_tool_row_parts + rows = get_tool_row_parts(function_name, args or {}, result, is_error=bool(is_error)) + out = [] + for index, (emoji, verb, detail, suffix) in enumerate(rows): + verb = (verb or function_name).strip() + mark = f"{_RED}✗{_RST}" if suffix is not None else (emoji or "●") + line = f"{mark} {_BOLD}{verb[:1].upper()}{verb[1:]}{_RST}" + if detail: + line += f" {detail}" + if index == len(rows) - 1 and duration >= 0.1: + line += f" {_DIM}{duration:.1f}s{_RST}" + if suffix: + line += f"{_RED}{suffix}{_RST}" + out.append(line) + return "\n".join(out) + + +_ACTIVE = False # one CLI per process; read by printers that get no CLI handle (gateway inline diffs) +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") +_DIFF_FILE_RE = re.compile(r"^a/(.+?) → b/(.+)$") + + +def set_active(enabled: bool) -> None: + global _ACTIVE + _ACTIVE = bool(enabled) + + +def _display_path(path: str) -> str: + path = path[1:] if path.startswith("//") else path + cwd = os.getenv("TERMINAL_CWD") or os.getcwd() + try: + rel = os.path.relpath(path, cwd) if os.path.isabs(path) else path + except ValueError: + return path + return path if rel.startswith("..") else rel + + +def gutter_print(print_fn): + """Wrap a line printer so a diff hangs under its tool row behind a dim ``│``: the ``review + diff`` header is dropped and ``a/ → b/`` collapses to one cwd-relative path.""" + header = _ANSI_RE.sub("", t("display.diff.review_header")).strip() + + def _print(text): + for line in str(text).split("\n"): + plain = _ANSI_RE.sub("", line).strip() + if plain == header: + continue + match = _DIFF_FILE_RE.match(plain) + if match: + old, new = _display_path(match.group(1)), _display_path(match.group(2)) + line = f"{_BOLD}{new if old == new else f'{old} → {new}'}{_RST}" + print_fn(f"{_DIM}│{_RST} {line}" if plain else f"{_DIM}│{_RST}") + return _print + + +def diff_printer(print_fn): + """``print_fn`` with the gutter when the glyph layout is active in this process.""" + return gutter_print(print_fn) if _ACTIVE else print_fn + + +def turn_footer(summary: str) -> str: + """``─ 12.4s · read 3 files · edited 1 file +4 -0 · 7:02 AM`` closing each turn.""" + body = summary.lstrip("⋯ ").strip() + clock = datetime.now().strftime("%-I:%M %p") if os.name != "nt" else datetime.now().strftime("%I:%M %p") + return f"{_DIM}─ {body} · {clock}{_RST}" if body else f"{_DIM}─ {clock}{_RST}" + + +# ── live region (prompt_toolkit fragments, redrawn every frame) ───────────────────────────── + +def _cwd_label() -> str: + cwd = os.getenv("TERMINAL_CWD") or os.getcwd() + home = os.path.expanduser("~") + short = "~" + cwd[len(home):] if cwd == home or cwd.startswith(home + os.sep) else cwd + from hermes_cli.status_bar_git import current_git_branch + branch = current_git_branch(cwd) + return f"{short} ({branch})" if branch else short + + +def activity_text(cli) -> str: + """What the agent is doing: the classic spinner's text (kaomoji face or tool emoji included).""" + if getattr(cli, "_command_running", False): + return str(getattr(cli, "_command_status", "") or "") + text = str(getattr(cli, "_spinner_text", "") or "").strip() + return text or (t("cli.modern.working") if getattr(cli, "_agent_running", False) else "") + + +def _turn_elapsed(cli) -> str: + started = getattr(cli, "_prompt_start_time", None) + if started is None: + return "" + from cli import format_duration_compact + return format_duration_compact(max(0.0, time.time() - float(started))) + + +def rule_style() -> str: + """Composer rules are quiet chrome: the skin's session border, not the accent.""" + return f"fg:{_skin_hex('session_border', '#8B8682')}" + + +def _no_bg(frags: list) -> list: + """The footer sits on the terminal background, not the classic status bar's fill.""" + return [(f"{style} bg:default".strip(), text) for style, text in frags] + + +def _fit(cli, frags: list, width: int) -> list: + used = sum(cli._status_bar_display_width(text) for _, text in frags) + if used <= width: + return frags + plain = "".join(text for _, text in frags) + return [(rule_style(), cli._trim_status_bar_text(plain, width))] + + +def top_rule_fragments(cli) -> list: + """``── ⠹ Running tests · 14s ─────────── calc helpers ─``: live status left, session title right.""" + width = max(10, cli._get_tui_terminal_width()) + rule, accent = rule_style(), f"fg:{_skin_hex('ui_accent', '#FFBF00')} bold" + busy = getattr(cli, "_agent_running", False) or getattr(cli, "_command_running", False) + left: list = [] + if busy: + frame = _SPIN[int(time.monotonic() * 10) % len(_SPIN)] + left = [(rule, "── "), (accent, frame + " "), ("", activity_text(cli))] + # Classic's spinner suffix: turn time and the streamed-token count (``↓ 547 tok``). + for extra in (_turn_elapsed(cli), cli._spinner_token_flow()): + if extra: + left.append(("class:status-bar-dim bg:default", f" · {extra}")) + left.append((rule, " ")) + title = cli._get_status_bar_session_title() + right = [(rule, " "), (f"fg:{_skin_hex('ui_label', '#DAA520')}", title), (rule, " ─")] if title else [] + used = sum(cli._status_bar_display_width(text) for _, text in left + right) + if used >= width: + right, used = [], sum(cli._status_bar_display_width(text) for _, text in left) + return _fit(cli, left + [(rule, "─" * max(0, width - used))] + right, width) + + +def hint_pairs(cli) -> list[tuple[str, str]]: + """Keys that do something right now, for the footer's right half.""" + if any(getattr(cli, attr, None) for attr in ( + "_approval_state", "_clarify_state", "_sudo_state", "_secret_state", "_connection_state", + "_slash_confirm_state", "_model_picker_state", "_command_palette_state")): + return [] + if getattr(cli, "_agent_running", False): + enter = {"steer": "steer", "queue": "queue"}.get(getattr(cli, "busy_input_mode", ""), "interrupt") + pairs = [("enter", t(f"cli.modern.hint_{enter}"))] + if enter != "queue": + pairs.append(("tab", t("cli.modern.hint_queue"))) + return pairs + [("ctrl+c", t("cli.modern.hint_stop"))] + return [("/", t("cli.modern.hint_commands")), ("@", t("cli.modern.hint_files")), ("!", t("cli.modern.hint_shell"))] + + +def status_line_fragments(cli) -> list: + """Footer under the composer: model · context gauge · cwd (branch) left, live key hints right. + An explicit ``display.status_bar.fields`` list keeps the user's chosen segments on the left.""" + return _render_safely(_status_line_fragments, cli) + + +def _render_safely(render, cli) -> list: + """Boundary for prompt_toolkit render callbacks: a bad frame degrades to a bare line.""" + try: + return render(cli) + except Exception: # health: allow BLE001 -- a render callback must never take down the app frame + logger.debug("%s failed", render.__name__, exc_info=True) + return [("", f" {getattr(cli, 'model', '') or 'Hermes'}")] + + +def _status_line_fragments(cli) -> list: + """The classic bar's segments (same fields, same width tiers, same ``display.status_bar.fields`` + filter), then cwd (branch) and the live key hints when there is room.""" + width = max(10, cli._get_tui_terminal_width()) + dim, strong = "class:status-bar-dim", "class:status-bar-strong" + snapshot = cli._get_status_bar_snapshot() + field_set = cli._get_status_bar_field_set() + + def _on(name): + return field_set is None or name in field_set + + left: list = [] + for seg in cli._status_bar_segments(snapshot, width, field_set, cli._is_session_yolo_active(), styled=True): + if left: + left.append((dim, " · ")) + left.extend(seg) + stash = cli._prompt_stash.indicator() if _on("stash") and hasattr(cli, "_prompt_stash") else "" + if stash: # a parked draft is never silently forgotten + left += [(dim, " · "), (strong, stash)] + battery = snapshot.get("battery_label") or "" # only set while /battery is on + if battery and _on("battery"): + left[0:0] = [("", " "), (cli._battery_status_style(snapshot.get("battery_category", "dim")), battery), + (dim, " ·")] + vim = cli._vim_mode_label() + if vim: + left += [(dim, " · "), (strong, vim)] + right: list = [] + for key, text in hint_pairs(cli) if width >= 76 else []: + if right: + right.append((dim, " · ")) + right += [(strong, key), (dim, f" {text}")] + used_left = sum(cli._status_bar_display_width(text) for _, text in left) + used_right = sum(cli._status_bar_display_width(text) for _, text in right) + room = width - used_left - used_right - 6 + if field_set is None and room >= 12: # cwd takes whatever the hints leave, trimmed from the left + cwd = _cwd_label() + cwd = cwd if len(cwd) <= room else "…" + cwd[-(room - 1):] + left += [(dim, " · "), (dim, cwd)] + used_left += 3 + cli._status_bar_display_width(cwd) + if right and used_left + used_right + 2 > width: + right, used_right = [], 0 + pad = [("", " " * max(1, width - used_left - used_right - 1))] if right else [] + return _no_bg(_fit(cli, left + pad + right + ([("", " ")] if right else []), width)) + + +def modern_input_rules(cli, kb): + """The composer's rules for this layout (the top one carries live status), plus Tab-to-queue.""" + from prompt_toolkit.filters import Condition + from prompt_toolkit.layout import FormattedTextControl, Window + + top = Window(FormattedTextControl(lambda: _render_safely(top_rule_fragments, cli)), + height=lambda: cli._tui_input_rule_height("top"), wrap_lines=False) + bottom = Window(char="─", height=lambda: cli._tui_input_rule_height("bottom"), style=rule_style) + + def _can_queue() -> bool: + from prompt_toolkit.application import get_app + from cli import _looks_like_slash_command + from hermes_cli.cli_subagent_monitor import modal_prompt_active + buf = get_app().current_buffer + text = buf.text.strip() + return bool(getattr(cli, "_agent_running", False) and text and buf.complete_state is None + and not _looks_like_slash_command(text) and not text.startswith("!") + and not modal_prompt_active(cli)) + + @kb.add("tab", filter=Condition(_can_queue), eager=True) + def _queue_while_busy(event): + # Not _tui_handle_enter: its paste/continuation rules would turn this key into a newline. + buf = event.app.current_buffer + text, images = buf.text.strip(), list(cli._attached_images) + cli._attached_images.clear() + cli._tui_enter_while_busy(text, images, (text, images) if images else text, mode="queue") + cli._inline_pastes(buf) + buf.reset(append_to_history=True) + event.app.invalidate() + + return top, bottom diff --git a/hermes_cli/cli_panel.py b/hermes_cli/cli_panel.py new file mode 100644 index 0000000000..1d177945a5 --- /dev/null +++ b/hermes_cli/cli_panel.py @@ -0,0 +1,28 @@ +"""Bordered overlay panels (approval, clarify, sudo, pickers) for the classic CLI's live region.""" + +from hermes_cli.cli_render import _append_blank_panel_line, _append_panel_line + + +class _Panel: + """Fragment accumulator for one bordered overlay panel (``(style, text)`` tuples).""" + + def __init__(self, border: str, box_width: int, title: str = "", title_style: str = ""): + self.lines, self.border, self.width = [], border, box_width + self._row, self._blank = _append_panel_line, _append_blank_panel_line + if title: + # Title inlined into the top rule: ``╭─ Title ───╮``. + self.lines.append((border, "╭─ ")) + self.lines.append((title_style, title)) + self.lines.append((border, " " + ("─" * max(0, box_width - len(title) - 3)) + "╮\n")) + else: + self.lines.append((border, "╭" + ("─" * box_width) + "╮\n")) + + def row(self, style: str, text: str) -> None: + self._row(self.lines, self.border, style, text, self.width) + + def blank(self) -> None: + self._blank(self.lines, self.border, self.width) + + def close(self) -> list: + self.lines.append((self.border, "╰" + ("─" * self.width) + "╯\n")) + return self.lines diff --git a/hermes_cli/cli_process_notifications.py b/hermes_cli/cli_process_notifications.py index 4932175154..ccfc2c9394 100644 --- a/hermes_cli/cli_process_notifications.py +++ b/hermes_cli/cli_process_notifications.py @@ -90,6 +90,8 @@ class CLIProcessNotificationsMixin: user_input = user_input.text is_seeded_query = isinstance(user_input, _SeededQueryMessage) if is_seeded_query: + from hermes_cli.gateway_tools_notify import arm_adopted + arm_adopted(self, user_input) user_input = (user_input.text, user_input.images) if user_input.images else user_input.text return user_input, is_voice_input, is_seeded_query diff --git a/hermes_cli/cli_render.py b/hermes_cli/cli_render.py index 49a0d68a0f..f52ab7c294 100644 --- a/hermes_cli/cli_render.py +++ b/hermes_cli/cli_render.py @@ -958,9 +958,11 @@ def _panel_box_width(title: str, content_lines: list[str], min_width: int = 46, def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "", *, keep_ws: bool = False) -> list[str]: - """Wrap panel text; ``keep_ws`` preserves whitespace (command/detail previews).""" + """Wrap panel text; ``keep_ws`` preserves whitespace (command/detail previews). Each source + line wraps on its own: a newline inside a row would break the panel's right border.""" kw = dict(replace_whitespace=False, drop_whitespace=False) if keep_ws else dict(break_long_words=False, break_on_hyphens=False) - wrapped = textwrap.wrap(text, width=max(8, width), subsequent_indent=subsequent_indent, **kw) + wrapped = [row for part in (text.expandtabs(4).splitlines() if keep_ws else [text]) + for row in (textwrap.wrap(part, width=max(8, width), subsequent_indent=subsequent_indent, **kw) or [""])] return wrapped or [""] diff --git a/hermes_cli/cli_session_dock.py b/hermes_cli/cli_session_dock.py index 268db5a60a..4626ea83a9 100644 --- a/hermes_cli/cli_session_dock.py +++ b/hermes_cli/cli_session_dock.py @@ -86,9 +86,16 @@ def _prompt_text(item) -> str: def queued_prompts(cli) -> list[str]: """One-line previews of the turns waiting in ``_pending_input``, oldest first.""" + from hermes_cli.gateway_tools_notify import gateway_store + owned: list = [] + store = gateway_store(cli) if getattr(cli, "session_id", None) else None + if store is not None: + # The gateway's FIFO runs first (#134889 N5): its queued admissions as the owner last published them. + from hermes_cli.gateway_queue import published_queue + owned = published_queue(cli, store) pending = getattr(cli, "_pending_input", None) if pending is None: - return [] + return [text for text in map(_prompt_text, owned) if text] with pending.mutex: items = list(pending.queue) - return [text for text in map(_prompt_text, items) if text] + return [text for text in map(_prompt_text, owned + items) if text] diff --git a/hermes_cli/cli_status_bar_mixin.py b/hermes_cli/cli_status_bar_mixin.py index 80be27e52d..14dbe60293 100644 --- a/hermes_cli/cli_status_bar_mixin.py +++ b/hermes_cli/cli_status_bar_mixin.py @@ -539,14 +539,14 @@ class CLIStatusBarMixin: def _agent_spacer_height(self, width: Optional[int] = None) -> int: """Spacer height above the status bar while the agent runs.""" - if not getattr(self, "_agent_running", False): + if not getattr(self, "_agent_running", False) or getattr(self, "_modern_layout", False): return 0 return 0 if self._use_minimal_tui_chrome(width=width) else 1 def _spinner_widget_height(self, width: Optional[int] = None) -> int: """Visible height of the spinner/status line above the status bar.""" spinner_line = self._render_spinner_text() - if not spinner_line or self._use_minimal_tui_chrome(width=width): + if not spinner_line or self._use_minimal_tui_chrome(width=width) or getattr(self, "_modern_layout", False): return 0 width = width or self._get_tui_terminal_width() if width and width > 10: @@ -597,7 +597,9 @@ class CLIStatusBarMixin: if getattr(self, "tool_progress_mode", "all") == "off": return False agent = getattr(self, "agent", None) - if agent is not None and getattr(agent, "quiet_mode", False): + # The interactive agent itself runs quiet unless --verbose, so in the classic layout this + # line only shows under --verbose; the glyph layout closes every interactive turn with it. + if agent is not None and getattr(agent, "quiet_mode", False) and not getattr(self, "_modern_layout", False): return False return bool(getattr(self, "_interactive_turn", False)) @@ -638,7 +640,10 @@ class CLIStatusBarMixin: try: started = getattr(self, "_turn_summary_start", 0.0) or 0.0 line = collector.render(max(0.0, time.monotonic() - started) if started else 0.0) - if line: + if line and getattr(self, "_modern_layout", False): + from hermes_cli.cli_modern_layout import turn_footer + _cprint(turn_footer(line)) + elif line: _cprint(f" {_D}{line}{_RST}") except Exception: logger.debug("Turn summary render failed", exc_info=True) @@ -1131,6 +1136,9 @@ class CLIStatusBarMixin: or getattr(self, "_model_picker_state", None) or getattr(self, "_command_palette_state", None)): return [] + if getattr(self, "_modern_layout", False): + from hermes_cli.cli_modern_layout import status_line_fragments + return status_line_fragments(self) try: snapshot = self._get_status_bar_snapshot() # prompt_toolkit's own width: shutil's can be stale (esp. over SSH) and an overflow diff --git a/hermes_cli/cli_stream_mixin.py b/hermes_cli/cli_stream_mixin.py index cb8efde892..9cc5eea3ca 100644 --- a/hermes_cli/cli_stream_mixin.py +++ b/hermes_cli/cli_stream_mixin.py @@ -185,8 +185,10 @@ class CLIStreamMixin: f" [dim]{datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))}[/]" if getattr(self, "show_timestamps", False) else "") lines = user_input.split("\n") + from hermes_cli.cli_modern_layout import is_modern, prompt_glyph + mark = _escape(prompt_glyph()) if is_modern(self) else "●" if len(lines) <= 1: - return f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]{ts_suffix}" + return f"[bold {_accent_hex()}]{mark}[/] [bold]{_escape(user_input)}[/]{ts_suffix}" first_lines = max(1, int(getattr(self, "user_message_preview_first_lines", 2))) last_lines = max(0, int(getattr(self, "user_message_preview_last_lines", 2))) @@ -198,7 +200,7 @@ class CLIStreamMixin: hidden_middle_count = 0 tail = [] - preview_lines = [f"[bold {_accent_hex()}]●[/] [bold]{_escape(head[0])}[/]{ts_suffix}"] + preview_lines = [f"[bold {_accent_hex()}]{mark}[/] [bold]{_escape(head[0])}[/]{ts_suffix}"] preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in head[1:]) if hidden_middle_count > 0: key = "cli.stream.preview_more_lines_one" if hidden_middle_count == 1 else "cli.stream.preview_more_lines_other" @@ -228,17 +230,26 @@ class CLIStreamMixin: def _print_user_message_preview(self, user_input: str) -> None: """Render a user message using the normal chat scrollback style.""" - from cli import ChatConsole, _accent_hex + from cli import ChatConsole, _accent_hex, _cprint from tools.process_registry_notifications import TimelineNotification if isinstance(user_input, TimelineNotification): from gateway.warning_notifications import render_notification render_notification(lambda: ChatConsole().print(f"[dim]◈ {_escape(user_input.display_text)}[/dim]"), platform="cli", diagnostic=user_input.notification_category == "diagnostic") return - ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") + modern = getattr(self, "_modern_layout", False) + if modern: + from hermes_cli.cli_modern_layout import separator + _cprint("\n" + separator(True)) + else: + ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") text = str(user_input or "") if "\n" in text: - ChatConsole().print(self._format_submitted_user_message_preview(text)) + # soft_wrap in the glyph layout: the terminal wraps a long prompt, so a resize re-flows it. + ChatConsole().print(self._format_submitted_user_message_preview(text), soft_wrap=modern) + elif modern: + from hermes_cli.cli_modern_layout import user_echo_markup + ChatConsole().print(user_echo_markup(text, _accent_hex()), soft_wrap=True) else: ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(text)}[/]") @@ -255,6 +266,22 @@ class CLIStreamMixin: self._reasoning_shown_this_turn = True if getattr(self, "_stream_box_opened", False): return + if getattr(self, "_modern_layout", False): + from hermes_cli.cli_modern_layout import reasoning_text_line, separator + if not getattr(self, "_reasoning_box_opened", False): + self._reasoning_box_opened, self._reasoning_started_at = True, time.monotonic() + self._reasoning_buf, self._reasoning_full_text = "", "" + _cprint("\n" + separator(False)) + self._reasoning_full_text = getattr(self, "_reasoning_full_text", "") + text + self._reasoning_buf = getattr(self, "_reasoning_buf", "") + text + # Live, as classic: complete lines now, long partial lines force-flushed. + while "\n" in self._reasoning_buf: + line, self._reasoning_buf = self._reasoning_buf.split("\n", 1) + _cprint(reasoning_text_line(line)) + if len(self._reasoning_buf) > 80: + _cprint(reasoning_text_line(self._reasoning_buf)) + self._reasoning_buf = "" + return if not getattr(self, "_reasoning_box_opened", False): self._reasoning_box_opened = True w = self._scrollback_box_width() @@ -296,6 +323,17 @@ class CLIStreamMixin: from cli import _DIM, _RST, _cprint if not getattr(self, "_reasoning_box_opened", False): return + if getattr(self, "_modern_layout", False): + from hermes_cli.cli_modern_layout import reasoning_text_line, thought_line + if getattr(self, "_reasoning_buf", ""): + _cprint(reasoning_text_line(self._reasoning_buf)) + started = getattr(self, "_reasoning_started_at", 0.0) + _cprint(thought_line(time.monotonic() - started if started else None, + getattr(self, "_reasoning_full_text", ""))) + self._reasoning_buf, self._reasoning_full_text, self._reasoning_box_opened = "", "", False + if not getattr(self, "_stream_box_live", False): + self._release_held_status_lines() + return buf = getattr(self, "_reasoning_buf", "") if buf: _cprint(f"{_DIM}{buf}{_RST}") @@ -451,9 +489,14 @@ class CLIStreamMixin: self._stream_text_ansi = "" if self.show_timestamps: label = f"{label} {datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))}" - w = self._scrollback_box_width() - fill = w - 2 - HermesCLI._status_bar_display_width(label) - _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") + if getattr(self, "_modern_layout", False): + from hermes_cli.cli_modern_layout import answer_label + _cprint(answer_label(datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M')) + if self.show_timestamps else "")) + else: + w = self._scrollback_box_width() + fill = w - 2 - HermesCLI._status_bar_display_width(label) + _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") # Turn-level record of what actually reached the screen; survives _reset_stream_state at # tool-call boundaries so an interrupted reply isn't re-rendered as a Panel (#65666). @@ -519,7 +562,7 @@ class CLIStreamMixin: line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == "strip" else self._stream_buf self._emit_stream_line(line) self._stream_buf = "" - if self._stream_box_opened and getattr(self, "_stream_box_live", False): + if self._stream_box_opened and getattr(self, "_stream_box_live", False) and not getattr(self, "_modern_layout", False): w = self._scrollback_box_width() _cprint(f"{_ACCENT}╰{'─' * (w - 2)}╯{_RST}") self._stream_box_live = False @@ -661,6 +704,10 @@ class CLIStreamMixin: announced.add(tool_name) from agent.display import bridge_generating_phrase, get_tool_emoji what = bridge_generating_phrase(tool_name) or tool_name + if getattr(self, "_modern_layout", False): # the composer rule shows it; scrollback stays quiet + self._spinner_text = f"{get_tool_emoji(tool_name, default='⚡')} {t('cli.modern.preparing', what=what)}" + self._invalidate() + return _cprint(f" ┊ {t('cli.stream.tool_preparing', emoji=get_tool_emoji(tool_name, default='⚡'), what=what)}") def _on_tool_progress(self, event_type: str, function_name: str | None = None, preview: str | None = None, function_args: dict | None = None, **kwargs): @@ -731,9 +778,9 @@ class CLIStreamMixin: return self._last_scrollback_tool = function_name try: - from agent.display import get_cute_tool_message - line = get_cute_tool_message(function_name, stored_args, duration, result=kwargs.get("result")) - _cprint(f" {line}") + from hermes_cli.cli_modern_layout import completed_tool_line + _cprint(completed_tool_line(self, function_name, stored_args, duration, kwargs.get("result"), + kwargs.get("is_error", False))) except Exception: pass # One-time /verbose hint on the first long tool in the noisiest mode; latched @@ -800,8 +847,9 @@ class CLIStreamMixin: snapshot = self._pending_edit_snapshots.pop(tool_call_id, None) try: from agent.display import render_edit_diff_with_delta + from hermes_cli.cli_modern_layout import diff_printer render_edit_diff_with_delta( function_name, function_result, function_args=function_args, snapshot=snapshot, - print_fn=_cprint) + print_fn=diff_printer(_cprint)) except Exception: logger.debug("Edit diff preview failed for %s", function_name, exc_info=True) diff --git a/hermes_cli/cli_subagent_monitor.py b/hermes_cli/cli_subagent_monitor.py index bc2dabe389..d5f2c45fed 100644 --- a/hermes_cli/cli_subagent_monitor.py +++ b/hermes_cli/cli_subagent_monitor.py @@ -42,6 +42,7 @@ class SubagentMonitor: self.app = None self.opening = False self.collapsed = False + self.detail = False # the monitor's detail view is open (its tick fetches the gateway tail) @property def has_rows(self): @@ -65,18 +66,23 @@ class SubagentMonitor: row = self.selected return row if row and row.get('kind') == 'process' else None + def subagents(self): + """The subagent backend: the gateway session's view when its children run there (CLI-12), + else this process's own registry; both answer the same ``subagent_*`` calls.""" + from hermes_cli.gateway_tools import LocalSubagents, process_view + view = process_view() + if view is not None: + return view + if getattr(self, '_local', None) is None: + self._local = LocalSubagents(self.cli) + return self._local + def refresh(self, now=None): - from tools.delegate_tool_registry import _list_payload, list_active_subagents now = time.time() if now is None else now - parent = getattr(self.cli, 'agent', None) - entries = _list_payload(parent)['subagents'] if parent is not None else [] - # The scoped control-plane snapshot supplies authority and transcript paths; - # its matching public lifecycle record supplies the latest observed tool. - activity = {r['subagent_id']: r for r in list_active_subagents()} if entries else {} + entries = self.subagents().subagent_rows(busy=bool(getattr(self.cli, '_agent_running', False))) for row in entries: - live = activity.get(row['subagent_id'], {}) - row['elapsed'] = max(0, int(now - live.get('started_at', now))) - row['last_tool'] = live.get('last_tool') or '' + row['elapsed'] = max(0, int(now - (row.get('started_at') or now))) + row['last_tool'] = row.get('last_tool') or '' row['key'] = row['subagent_id'] row.pop('running_seconds', None) processes = procs.process_rows(now) @@ -108,17 +114,33 @@ class SubagentMonitor: # invalidate call, which reads the loop more than once. _run_on_app_loop(app, app.invalidate) + def poll_soon(self): + """Let the spinner thread's next tick poll the gateway, instead of the caller blocking the + UI loop on it.""" + self._last_poll = 0 + def tick(self): now = time.monotonic() if now - self._last_poll < 1: return self._last_poll = now - if self.refresh(): + changed = self.refresh() + if self._poll_tail() or changed: if self.app is not None: self.invalidate() else: self.cli._invalidate() + def _poll_tail(self): + """Off the render path (the spinner thread's tick): refresh the gateway tail the open detail + view paints from the cache. True when the text changed.""" + row = self.selected + if not self.detail or row is None or row.get('kind') == 'process': + return False + backend = self.subagents() + before = backend.subagent_tail(row['subagent_id']) + return backend.subagent_tail(row['subagent_id'], fetch=True) != before + def select(self, delta): roster = self.roster if roster: @@ -131,8 +153,7 @@ class SubagentMonitor: if action != 'stop': return {'error': t('cli.subagents.cannot_steer_process')} return procs.kill(target) - from tools.delegate_tool_registry import _handle_control_action - return json.loads(_handle_control_action(action, target, message, getattr(self.cli, 'agent', None))) + return self.subagents().subagent_control(action, target, message) def _counts(self, *, session=True): """Count fragment: ``2 live``, ``1 proc``, ``2 live · 3 procs``; the collapsed heading @@ -260,10 +281,19 @@ def read_tail(path): return t('cli.subagents.transcript_unavailable') +def subagent_tail(monitor, row): + """The child's transcript tail from the monitor's backend (the gateway's is fetched and filtered by + the monitor's tick, never here: the detail view calls this every frame).""" + text = monitor.subagents().subagent_tail(row['subagent_id']) + return t('cli.subagents.transcript_unavailable') if text is None else text + + def modal_prompt_active(cli): + """A prompt owns the composer (its text is an answer or a secret, never a chat message): + every composer shortcut stays off while one is open.""" return any(getattr(cli, name, None) for name in ( '_clarify_state', '_approval_state', '_slash_confirm_state', '_sudo_state', - '_secret_state', '_model_picker_state', '_command_palette_state')) + '_secret_state', '_connection_state', '_model_picker_state', '_command_palette_state')) def build_monitor_application(monitor, **kwargs): @@ -275,7 +305,7 @@ def build_monitor_application(monitor, **kwargs): from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.widgets import TextArea - state = {'detail': False, 'steering': False, 'confirm': False, 'notice': ''} + state = {'steering': False, 'confirm': False, 'notice': ''} steer = TextArea(height=1, prompt=t('cli.subagents.steer_prompt'), multiline=False) tail = TextArea(read_only=True, scrollbar=True, wrap_lines=True) @@ -312,7 +342,7 @@ def build_monitor_application(monitor, **kwargs): elif row.get('kind') == 'process': text = procs.process_tail(row['id']) else: - text = read_tail(row.get('live_transcript')) + text = subagent_tail(monitor, row) if text != tail.text: following = tail.buffer.cursor_position == len(tail.text) position = tail.buffer.cursor_position @@ -322,7 +352,7 @@ def build_monitor_application(monitor, **kwargs): def header(): row = monitor.selected title = f"{monitor._title()} · {monitor._counts(session=False)}" - if state['detail'] and row: + if monitor.detail and row: title += f" · {monitor._key(row)} · {row.get('goal') or row.get('command') or ''}" return [('class:subagent-dock.heading', _clip(title, app.output.get_size().columns))] @@ -337,14 +367,31 @@ def build_monitor_application(monitor, **kwargs): return t('cli.subagents.footer_steering_compact' if narrow else 'cli.subagents.footer_steering') steer = '' if process else t('cli.subagents.footer_steer_key') if narrow: - return (t('cli.subagents.footer_detail_compact', steer=steer) if state['detail'] + return (t('cli.subagents.footer_detail_compact', steer=steer) if monitor.detail else t('cli.subagents.footer_roster_compact')) steer = '' if process else t('cli.subagents.footer_steer_key_long') - return t('cli.subagents.footer_detail' if state['detail'] else 'cli.subagents.footer_roster', steer=steer) + return t('cli.subagents.footer_detail' if monitor.detail else 'cli.subagents.footer_roster', steer=steer) + + def run_control(action, message, target): + """Steer/stop off the UI loop: a stalled gateway call (up to its timeout) must not freeze the + monitor's repaint, Esc or Ctrl+C. The answer lands in the notice line when it arrives.""" + import asyncio + state['notice'] = t('cli.subagents.control_pending') + + def done(future): + try: + result = future.result() + except Exception as exc: # health: allow BLE001 -- a control failure is shown, never raised into the UI loop + result = {'error': str(exc)} + state['notice'] = result.get('error') or result.get('note') or str(result) + app.invalidate() + + asyncio.get_running_loop().run_in_executor( + None, lambda: monitor.control(action, message, target=target)).add_done_callback(done) kb = KeyBindings() normal = Condition(lambda: not state['steering'] and not state['confirm']) - listing = normal & Condition(lambda: not state['detail']) + listing = normal & Condition(lambda: not monitor.detail) @kb.add('up', filter=listing) def up(event): @@ -357,7 +404,8 @@ def build_monitor_application(monitor, **kwargs): @kb.add('enter', filter=listing) def detail(event): if monitor.selected: - state['detail'] = True + monitor.detail = True + monitor.poll_soon() # the spinner thread's next tick fetches the tail update_tail() app.layout.focus(tail) @@ -372,11 +420,10 @@ def build_monitor_application(monitor, **kwargs): def send_steer(event): if not steer.text.strip(): return - result = monitor.control('steer', steer.text, target=state['target']) - state['notice'] = result.get('error') or result.get('note') or str(result) + run_control('steer', steer.text, state['target']) steer.text = '' state['steering'] = False - app.layout.focus(tail if state['detail'] else roster) + app.layout.focus(tail if monitor.detail else roster) @kb.add('x', filter=normal) def stop(event): @@ -386,17 +433,16 @@ def build_monitor_application(monitor, **kwargs): @kb.add('y', filter=Condition(lambda: state['confirm'])) def confirm(event): - result = monitor.control('stop', target=state['target']) - state['notice'] = result.get('error') or result.get('note') or str(result) + run_control('stop', None, state['target']) state['confirm'] = False @kb.add('escape', eager=True) def back(event): if state['steering'] or state['confirm']: state['steering'] = state['confirm'] = False - app.layout.focus(tail if state['detail'] else roster) - elif state['detail']: - state['detail'] = False + app.layout.focus(tail if monitor.detail else roster) + elif monitor.detail: + monitor.detail = False app.layout.focus(roster) else: app.exit() @@ -410,8 +456,8 @@ def build_monitor_application(monitor, **kwargs): layout = Layout(HSplit([ Window(FormattedTextControl(header), height=1), - ConditionalContainer(roster, filter=Condition(lambda: not state['detail'])), - ConditionalContainer(tail, filter=Condition(lambda: state['detail'])), + ConditionalContainer(roster, filter=Condition(lambda: not monitor.detail)), + ConditionalContainer(tail, filter=Condition(lambda: monitor.detail)), ConditionalContainer(steer, filter=Condition(lambda: state['steering'])), Window(FormattedTextControl(lambda: _clip(state['notice'], app.output.get_size().columns)), height=1), Window(FormattedTextControl(footer), height=1), @@ -421,7 +467,7 @@ def build_monitor_application(monitor, **kwargs): # first frame if a prompt won the race with in_terminal() acquisition. if modal_prompt_active(monitor.cli) and not app.is_done: app.exit() - elif state['detail']: + elif monitor.detail: update_tail() from prompt_toolkit.styles import Style @@ -443,12 +489,14 @@ def open_monitor(cli): async def run(): try: async with in_terminal(): - monitor.refresh() + # Paint the roster the dock's tick already holds; refreshing here would block the UI + # loop on the gateway's subagent.list. The spinner thread's next tick re-reads it. + monitor.poll_soon() monitor.app = build_monitor_application(monitor) await monitor.app.run_async() finally: monitor.app = None - monitor.opening = False + monitor.detail = monitor.opening = False cli._invalidate() asyncio.get_running_loop().create_task(run()) diff --git a/hermes_cli/cli_tui_mixin.py b/hermes_cli/cli_tui_mixin.py index 61c1dd7387..00cedb86f5 100644 --- a/hermes_cli/cli_tui_mixin.py +++ b/hermes_cli/cli_tui_mixin.py @@ -39,6 +39,7 @@ from prompt_toolkit.widgets import TextArea from typing import Optional from hermes_cli.cli_footer_split import FooterSplit +from hermes_cli.cli_panel import _Panel from hermes_cli.cli_tui_wrap import _prefix_wrapped_rows, _wrap_rows # Rows below an overlay panel taken by spinner/tool-progress, status bar, input, separators and @@ -85,30 +86,6 @@ def _term_rows() -> int: return shutil.get_terminal_size((100, 24)).lines -class _Panel: - """Fragment accumulator for one bordered overlay panel (``(style, text)`` tuples).""" - - def __init__(self, border: str, box_width: int, title: str = "", title_style: str = ""): - from cli import _append_blank_panel_line, _append_panel_line - self.lines, self.border, self.width = [], border, box_width - self._row, self._blank = _append_panel_line, _append_blank_panel_line - if title: - # Title inlined into the top rule: ``╭─ Title ───╮``. - self.lines.append((border, "╭─ ")) - self.lines.append((title_style, title)) - self.lines.append((border, " " + ("─" * max(0, box_width - len(title) - 3)) + "╮\n")) - else: - self.lines.append((border, "╭" + ("─" * box_width) + "╮\n")) - - def row(self, style: str, text: str) -> None: - self._row(self.lines, self.border, style, text, self.width) - - def blank(self) -> None: - self._blank(self.lines, self.border, self.width) - - def close(self) -> list: - self.lines.append((self.border, "╰" + ("─" * self.width) + "╯\n")) - return self.lines class CLITuiMixin: @@ -420,6 +397,8 @@ class CLITuiMixin: input_rule_bot, voice_status_bar, completions_menu] + if getattr(self, "_modern_layout", False): # footer under the composer and its completions + ordered.append(ordered.pop(ordered.index(status_bar))) return [item for item in ordered if item is not None] def _tui_spinner_loop(self): @@ -801,6 +780,8 @@ class CLITuiMixin: if self._command_running: return f"{self._command_spinner_frame()} {self._command_status or t('cli.tui.placeholder_processing_command')}" if self._agent_running: + if getattr(self, "_modern_layout", False): + return t(f"cli.modern.placeholder_busy_{self.busy_input_mode}") return t("cli.tui.placeholder_busy") if self._voice_mode: return t("cli.tui.placeholder_voice_idle", shortcut=self._voice_record_key_label()) @@ -1483,16 +1464,14 @@ class CLITuiMixin: event.app.invalidate() return True - def _tui_enter_while_busy(self, text: str, images: list, payload) -> None: + def _tui_enter_while_busy(self, text: str, images: list, payload, mode: str | None = None) -> None: """Route a submission typed while the agent runs, per ``busy_input_mode``. - steer → agent.steer(text) mid-run (images can't ride along and a missing/rejecting - steer() falls back to queue so nothing is lost). interrupt → agent.redirect() when the - agent supports active-turn redirect, else the legacy interrupt queue (older agents, - multimodal follow-ups, or a turn that finished in the race). queue → next turn. - """ + steer → agent.steer(text) mid-run (images can't ride along; a missing/rejecting steer() falls back + to queue). interrupt → agent.redirect() when supported, else the legacy interrupt queue. queue → + next turn: text-only on the gateway's session is a ``/queue`` FIFO admission (#134889 N5).""" from cli import CLI_CONFIG, _ACCENT, _DIM, _RST, _cprint, _hermes_home - _effective_mode = self.busy_input_mode + _effective_mode = mode or self.busy_input_mode redirected = False if _effective_mode == "steer": if images or not text: @@ -1512,7 +1491,9 @@ class CLITuiMixin: _cprint(f" {_ACCENT}{t('cli.tui.steered', preview=preview)}{_RST}") else: _effective_mode = "queue" - if _effective_mode == "queue": + if _effective_mode == "queue" and text and not images and self._gateway_queue_store() is not None: + self._queue_enqueue(text) + elif _effective_mode == "queue": self._pending_input.put(payload) preview = text if text else _tn("cli.tui.images_attached_preview", len(images)) preview = preview[:80] + ('...' if len(preview) > 80 else '') @@ -2145,6 +2126,9 @@ class CLITuiMixin: input_rule_bot = Window( char='─', height=lambda: cli_ref._tui_input_rule_height("bottom"), style='class:input-rule', ) + if getattr(self, "_modern_layout", False): + from hermes_cli.cli_modern_layout import modern_input_rules + input_rule_top, input_rule_bot = modern_input_rules(self, kb) image_bar = Window( content=FormattedTextControl(self._tui_image_bar_fragments), height=Condition(lambda: bool(cli_ref._attached_images))) diff --git a/hermes_cli/cli_tui_runtime_mixin.py b/hermes_cli/cli_tui_runtime_mixin.py index 75d9af9753..5dc81cd048 100644 --- a/hermes_cli/cli_tui_runtime_mixin.py +++ b/hermes_cli/cli_tui_runtime_mixin.py @@ -30,15 +30,24 @@ class CLITuiRuntimeMixin: def _tui_process_loop(self): """REPL worker thread: drain ``_pending_input``, run idle housekeeping, dispatch each input.""" + held = None while not self._should_exit: try: + if held is not None: + held, user_input = self._tui_hold_step(held) + if user_input is not None: + self._tui_process_one_input(user_input) + continue try: user_input = self._pending_input.get(timeout=0.1) except queue.Empty: if not self._agent_running: self._tui_idle_tick() continue - self._tui_process_one_input(user_input) + from hermes_cli.gateway_queue import OwnQueueHold + held = OwnQueueHold.begin(self, user_input) + if held is None: + self._tui_process_one_input(user_input) except Exception as e: if isinstance(e, OSError) and e.errno == errno.EIO: self._mark_terminal_io_broken("process_loop") @@ -46,6 +55,20 @@ class CLITuiRuntimeMixin: continue logger.warning("process_loop unhandled error (msg may be lost): %s", e) + def _tui_hold_step(self, hold): + """One step of holding a local input behind this viewer's own ``/queue`` admission (#134889 N5): + ``(hold or None, input to run now or None)``. A turn the owner runs now goes first; the held + input runs once that admission was taken, left the queue, or the bounded wait ran out.""" + with suppress(Exception): + self._drain_process_notifications("cli-idle") # adopts the admission on its message.start + item = hold.take_gateway_turn(self._pending_input) + if item is not None: + return hold, item + if hold.done(): + return None, hold.item + time.sleep(0.05) + return hold, None + def _tui_idle_tick(self): """Idle housekeeping between inputs (agent not running).""" self._check_config_mcp_changes() # auto-reload MCP on mcp_servers change diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 68074c0d49..0cd83db004 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -850,9 +850,9 @@ DEFAULT_CONFIG = { # continues, Shift+Enter reported distinctly. False restores the c-j submit fallback for # POSIX PTYs whose plain Enter arrives as LF. "cli_multiline_shortcuts": True, - # Interface bare `hermes`/`hermes chat` launches: "cli" (prompt_toolkit REPL) | "tui" (Ink). - # Flags win: `--cli` forces the REPL, `--tui` / HERMES_TUI=1 forces the TUI. - "interface": "cli", + # `hermes`/`hermes chat` launches "cli" (prompt_toolkit REPL) | "tui" (Ink); --cli, --tui, HERMES_TUI=1 win. + # cli_layout = the REPL's look: "modern" (glyph rows, status under the composer) | "classic" (boxed panels). + "interface": "cli", "cli_layout": "modern", # Native TUI uses the terminal's primary buffer and scrollback instead of the custom # alternate-screen viewport. Flags win: `--native` / `--tui-native` and `--cli`. "tui_native": False, diff --git a/hermes_cli/gateway_agent.py b/hermes_cli/gateway_agent.py index 0635617fda..269bebe7fc 100644 --- a/hermes_cli/gateway_agent.py +++ b/hermes_cli/gateway_agent.py @@ -662,10 +662,14 @@ class GatewayAgent: def _take_turn_route(self): """The ``/model --once`` pick for the admission being built, consumed here: one admission carries it, even when a later ``run_conversation`` submits while this one's ``prompt.submit`` still blocks. - A refusal that admitted nothing (``session_busy``) puts it back with ``_give_back_turn_route``.""" + A refusal that admitted nothing (``session_busy``) puts it back with ``_give_back_turn_route``. + + Staging already compared the pick with the session's route (``_route_off_session``), so it is not + compared again: its ``model`` may be an alias spelled like the session's model, and the owner runs a + plain pick of the session's own route on the session's endpoint and key (``one_turn_runtime``).""" pending, reasoning = self._turn_route, self._turn_reasoning self._turn_route = self._turn_reasoning = self._taken_turn_route = None - if not pending or not self._route_off_session(**pending): + if not pending: return None self._taken_turn_route = (pending, reasoning) return {**pending, **({"reasoning": reasoning} if reasoning else {})} @@ -708,8 +712,10 @@ class GatewayAgent: adopted = take_admitted_turn(self, persist_user_message if isinstance(persist_user_message, str) else user_message) if adopted is not None: - # A notification turn the gateway already admitted and is running (its frames are queued). + # A turn the gateway already admitted and is running (its frames are queued). A recovered + # ``/queue`` turn missed some frames: its reply is completed from the receipt. receipt = adopted + turn.gapped = bool(adopted.get("recovered")) else: self._drain_stale() self._taken_turn_route = None @@ -745,7 +751,8 @@ class GatewayAgent: self._generation = receipt["execution_generation"] starts: dict = {} try: - outcome = self._pump(stream_callback, starts, turn) + settled = (adopted or {}).get("receipt") + outcome = {"_receipt": settled} if settled else self._pump(stream_callback, starts, turn) finally: if self._owns(turn): self._generation_done() @@ -760,7 +767,9 @@ class GatewayAgent: return result def _drain_stale(self): - """Frames left from an earlier turn of this viewer (a late settlement) are not this turn's.""" + """Frames left from an earlier turn of this viewer (a late settlement) are not this turn's. A + ``/queue`` admission's start among them is remembered: that turn is painted after this one.""" + from hermes_cli.gateway_tools_notify import missed_start while True: try: frame = self.connection.events.get_nowait() @@ -771,6 +780,9 @@ class GatewayAgent: continue # a connection a reconnect already replaced self.connection.events.put(frame) return + params = frame.get("params") if isinstance(frame, dict) else None + if isinstance(params, dict) and params.get("session_id") == self.session_id: + missed_start(self._state(), params) def _generation_done(self): self._generation = None @@ -818,6 +830,8 @@ class GatewayAgent: kind, payload = params.get("type"), params.get("payload") or {} admission = params.get("admission_id") or (payload.get("admission_id") if isinstance(payload, dict) else None) if self._admission and admission and admission != self._admission: + from hermes_cli.gateway_tools_notify import missed_start + missed_start(self._state(), params) # a /queue turn run ahead of this one: painted after it continue if params.get("execution_generation") is not None: self._generation = params["execution_generation"] @@ -942,8 +956,10 @@ class GatewayAgent: diff = payload.get("inline_diff") if isinstance(diff, str) and diff: from cli import _cprint + from hermes_cli.cli_modern_layout import diff_printer + emit = diff_printer(_cprint) for line in diff.rstrip("\n").splitlines(): - _cprint(line) + emit(line) if name == "delegate_task" and self.tool_complete_callback is not None: self.tool_complete_callback(call_id, name, args, result) elif self.tool_complete_callback is not None: diff --git a/hermes_cli/gateway_chat.py b/hermes_cli/gateway_chat.py index 4e780c2fb8..f06c7bec78 100644 --- a/hermes_cli/gateway_chat.py +++ b/hermes_cli/gateway_chat.py @@ -9,7 +9,7 @@ from pathlib import Path import sys import uuid -from hermes_cli.gateway_client import GatewayClientError, connect_gateway +from hermes_cli.gateway_client import RESUME_KEY_MISMATCH, GatewayClientError, connect_gateway # These options change execution or require frontend facilities not yet exposed by # the authority. Reject them, rather than mutate process-wide gateway settings. @@ -133,8 +133,7 @@ async def _resume(client, args, **params): return await client.rpc("session.resume", **params, **({"api_key": key} if key else {})) except GatewayClientError as exc: if key and str(exc) == "admission_conflict": - raise GatewayClientError("--api-key on resume must be the key this session was launched with " - "(a session launched without --api-key keeps its profile credentials).") from None + raise GatewayClientError(RESUME_KEY_MISMATCH) from None raise @@ -267,7 +266,7 @@ def resume_policy_error(keys): def exit_on_resume_refusal(exc): """A refused resume is a usage error on every classic path (interactive startup's copy): ``Error:``, exit 2.""" - if isinstance(exc, GatewayClientError) and str(exc).startswith(_RESUME_POLICY_MISMATCH): + if isinstance(exc, GatewayClientError) and str(exc).startswith((_RESUME_POLICY_MISMATCH, RESUME_KEY_MISMATCH)): print(f"Error: {exc}", file=sys.stderr) raise SystemExit(2) from None diff --git a/hermes_cli/gateway_client.py b/hermes_cli/gateway_client.py index ba9f0a7444..45e459da7a 100644 --- a/hermes_cli/gateway_client.py +++ b/hermes_cli/gateway_client.py @@ -16,6 +16,9 @@ GATEWAY_WS_TICKET_PREFIX = "hermes-gateway-ticket." # Per-request answer budget. Owner verbs are admissions/reads that answer promptly; the one that # legitimately runs long passes its own budget (``rpc(..., _timeout=...)``). DEFAULT_RPC_TIMEOUT = 30 +# A typed ``--api-key`` on a resume that the owner does not bind (``rebind_launch_key``): every CLI says this. +RESUME_KEY_MISMATCH = ("--api-key on resume must be the key this session was launched with " + "(a session launched without --api-key keeps its profile credentials).") # Canonical ``/compress`` answers only after the summary call(s) and the commit: a long session's # summary routinely takes more than 30 s (``auxiliary.compression.timeout`` defaults to 120 s per # request, ``compression.context_total_ceiling_seconds`` to 600 s). Same budget as Desktop's diff --git a/hermes_cli/gateway_connection.py b/hermes_cli/gateway_connection.py index 0d2784af69..a5f070f537 100644 --- a/hermes_cli/gateway_connection.py +++ b/hermes_cli/gateway_connection.py @@ -16,7 +16,7 @@ import queue import threading import time -from hermes_cli.gateway_client import DEFAULT_RPC_TIMEOUT, GatewayClientError +from hermes_cli.gateway_client import DEFAULT_RPC_TIMEOUT, RESUME_KEY_MISMATCH, GatewayClientError, GatewayRPCError logger = logging.getLogger(__name__) @@ -26,12 +26,29 @@ DISCONNECTED = ("Gateway disconnected; turn outcome is unknown. Resume the print NOT_SENT = "Gateway disconnected before the request was sent; nothing was sent." # Resume refusals meaning the session itself is gone for this viewer (gateway/session_controls). _GONE = frozenset({"not_found", "permission_denied", "profile_mismatch"}) +# Keyed-resume refusals a plain resume answers as main did: the key does not apply to this session +# (``rebind_launch_key``: keyless or non-local, another key held, another fingerprint), or the viewer +# lacks the ``session:control`` a rebind needs but may still read. Any other refusal is the resume's. +_KEY_NOT_TAKEN = frozenset({"admission_conflict", "permission_denied"}) class CallStopped(GatewayClientError): """The caller stopped waiting (:meth:`GatewayConnection.call_until`); the request was sent.""" +def _resume_plain(exc, session_id, typed): + """A keyed ``session.resume`` the owner refused with ``exc``: returns when the caller resumes plain, + else raises. Only a refusal in ``_KEY_NOT_TAKEN`` resumes plain (a keyed startup alias on a Desktop/TUI + session); any other is raised, not hidden by a keyless attach whose first turn fails. ``typed`` (a typed + ``--api-key`` the user resumes with) never resumes plain: ``admission_conflict`` is the thin client's + refusal.""" + if typed and str(exc) == "admission_conflict": + raise GatewayClientError(RESUME_KEY_MISMATCH) from None + if typed or str(exc) not in _KEY_NOT_TAKEN: + raise exc + logger.debug("session.resume %s: launch key not taken (%s); resuming without it", session_id, exc) + + def lost_before(frame, epoch) -> bool: """``frame`` is a transport error from a connection older than ``epoch``: a reconnect replaced it, so it is no outcome of anything submitted on ``epoch``.""" @@ -61,6 +78,24 @@ class SessionState: self.request_listeners: list = [] # A gateway-admitted notification turn the idle CLI queued a header for (gateway_tools_notify). self.admitted_turn: dict | None = None + # The owner's pending admissions as last published (snapshot or ``session.info``), and the + # ``/queue`` inputs this viewer admitted there (``admission_id -> text``), so the turn the owner + # runs for one is painted like a typed turn (``gateway_tools_notify.queue_admitted_turn``). + self.pending: list = [] + self.queued_inputs: dict = {} + # Started ``/queue`` admissions waiting for their turn (``admission_id -> receipt-like``), and the + # one whose item the input loop just took (``gateway_tools_notify.arm_adopted``). + self.adopted: dict = {} + self.armed_admission: str | None = None + # Each pending list the owner published (or a snapshot carried) bumps ``pending_seq``; a + # ``/queue`` admission whose ``message.start`` this viewer dropped unpainted (a local turn's + # drain or pump) is in ``missed``. Both make the idle tick read its receipt (``recover_queued``). + self.pending_seq = 0 + # ``admission_id -> pending_seq`` when this viewer queued it: the dock shows it only until the + # owner publishes a list after that (``gateway_queue.published_queue``). + self.queued_at: dict = {} + self.recovered_seq = 0 + self.missed: set = set() # ``session.replay_gap``s seen, and how many a re-attach covered: the owner retired this viewer's # subscription on overflow, so its frames stop until ``GatewayConnection.reattach``. self.gaps = self.attached_gaps = 0 @@ -71,6 +106,9 @@ class SessionState: self.subscription_id = snapshot.get("subscription_id", self.subscription_id) self.execution_generation = snapshot.get("execution_generation", self.execution_generation) self.messages = list(snapshot.get("messages") or []) + if isinstance(snapshot.get("pending"), list): + self.pending = list(snapshot["pending"]) + self.pending_seq += 1 # a (re)attach replays no frames: the idle tick checks this viewer's /queue rows if self.info.get("title"): self.title = self.info["title"] @@ -83,6 +121,9 @@ class SessionState: def apply_event(self, kind, payload): if kind in ("session.info", "session.updated") and isinstance(payload, dict): self.info.update({k: v for k, v in payload.items() if k != "session_id"}) + if isinstance(payload.get("pending"), list): + self.pending = list(payload["pending"]) + self.pending_seq += 1 if payload.get("title"): self.title = payload["title"] elif kind == "todo.updated" and isinstance(payload, dict): @@ -137,6 +178,12 @@ class GatewayConnection: self._side_lock = threading.Lock() # The wake events of :meth:`call_until` commands in flight, each on its own socket. self._commands: set[threading.Event] = set() + # This CLI's launch-only key (``--api-key`` or a keyed startup alias), set by the session store: + # an owner restart revokes it, so every resume re-supplies it (the owner's ``rebind_launch_key``). + self.launch_api_key = None + # True when that key is the typed ``--api-key`` (not a keyed startup alias's): a startup resume the + # owner will not bind it to is refused as the thin client refuses it, never attached plain. + self.launch_api_key_typed = False @classmethod def shared(cls): @@ -248,13 +295,36 @@ class GatewayConnection: if state.subscription_id is None: continue try: - state.adopt_snapshot(await client.rpc("session.resume", session_id=session_id)) + state.adopt_snapshot(await self._resume(client, session_id)) except GatewayClientError as exc: if str(exc) not in _GONE: raise logger.debug("session.resume %s after reconnect: %s", session_id, exc) state.subscription_id = None + def resume(self, call, session_id, *, typed=False): + """``session.resume`` through ``call`` (the store's attach), re-supplying :attr:`launch_api_key`; + a refusal of the keyed resume is :func:`_resume_plain`'s to decide. ``typed``: the user resumes with + a typed ``--api-key``.""" + key = self.launch_api_key + if key: + try: + return call("session.resume", session_id=session_id, api_key=key) + except GatewayRPCError as exc: + _resume_plain(exc, session_id, typed) + return call("session.resume", session_id=session_id) + + async def _resume(self, client, session_id): + """:meth:`resume` for :meth:`_resubscribe`, awaited on this loop so a cancelled reconnect cancels + its request. A reconnect types nothing: never ``typed``.""" + key = self.launch_api_key + if key: + try: + return await client.rpc("session.resume", session_id=session_id, api_key=key) + except GatewayRPCError as exc: + _resume_plain(exc, session_id, typed=False) + return await client.rpc("session.resume", session_id=session_id) + async def _pump(self, client): while True: frame = await client.events.get() diff --git a/hermes_cli/gateway_queue.py b/hermes_cli/gateway_queue.py new file mode 100644 index 0000000000..c0face814e --- /dev/null +++ b/hermes_cli/gateway_queue.py @@ -0,0 +1,235 @@ +"""The classic CLI's ``/queue`` over the gateway's durable FIFO (refs #134889, N5). + +On main a prompt queued while a turn ran sat in the CLI's ``_pending_input``: no +``session_admissions`` row, so an owner restart or a disconnect lost it. Over the session authority +``/queue `` typed while a turn runs is an ordinary queued admission (``prompt.submit +{queued: true}``, the same path Ink and Desktop queue busy input on): the owner commits it at accept, +keeps it across a restart and runs it once, in FIFO order, after the current turn. + +The CLI keeps no copy to resend. It remembers only which admissions it queued +(``SessionState.queued_inputs``) so the turn the owner runs for one is painted like a typed turn +(``gateway_tools_notify.queue_admitted_turn`` adopts it), and ``/queue list|edit|rm|clear`` read and +change the owner's queue (``prompt.receipt`` / ``prompt.replace_queued`` / ``prompt.cancel``). +""" +from __future__ import annotations + +import logging +import uuid + +from hermes_cli.gateway_client import GatewayClientError + +logger = logging.getLogger(__name__) + + +def _state(cli, store): + return store.connection.state(cli.session_id) + + +class QueueOutcomeUnknown(GatewayClientError): + """The submit was sent but no answer came back (timeout, disconnect): the owner may hold it. + + No admission id came back, so nothing enters ``queued_inputs``. If the owner did queue it, the row + shows in ``/queue list`` (from the owner's published list) and runs once, but this CLI never adopts + its turn: the reply lands in the session's history, not painted live here. The user is told to + check ``/queue list`` instead of re-sending. This is rare (two lost answers in a row). + """ + + +def submit_queued(cli, store, text: str) -> str: + """Admit ``text`` behind the running turn in the owner's FIFO; returns its admission id. + + A refusal (``GatewayRPCError``) or a request never sent raises as is: nothing was queued. A lost + answer is asked once more under the SAME ``submission_id``, which the owner answers with the + admission it already holds (``admit_session_input`` keys it by principal, session and request id), + so the prompt is queued exactly once. Still no answer: :class:`QueueOutcomeUnknown`. + """ + from hermes_cli.gateway_client import GatewayRPCError + from hermes_cli.gateway_connection import NOT_SENT + submission = uuid.uuid4().hex + for attempt in (1, 2): + try: + receipt = store.call("prompt.submit", session_id=cli.session_id, submission_id=submission, + text=text, queued=True) + break + except GatewayRPCError: + raise + except GatewayClientError as exc: + if str(exc) == NOT_SENT and attempt == 1: + raise # never reached a socket: nothing is in doubt + if attempt == 2: + raise QueueOutcomeUnknown(str(exc)) from exc + logger.debug("prompt.submit answer lost (%s); asking again under the same identity", exc) + admission = (receipt or {}).get("admission_id") + if not admission: + raise GatewayClientError("the gateway did not admit the queued prompt") + state = _state(cli, store) + state.queued_inputs[admission] = text + state.queued_at[admission] = state.pending_seq + return admission + + +def queued_rows(cli, store) -> list[tuple[str, str]]: + """``[(admission_id, text)]`` still queued in the owner's FIFO, in run order. + + This viewer's own admissions are read back with ``prompt.receipt`` (one it finds started or settled + is left for the idle tick to paint); other viewers' queued rows come from the last pending list the owner published. + """ + state = _state(cli, store) + rows: dict[str, tuple[int, str]] = {} + for row in state.pending or (): + if isinstance(row, dict) and row.get("status") == "queued" and row.get("admission_id"): + rows[row["admission_id"]] = (int(row.get("sequence") or 0), str(row.get("text") or "")) + for admission, text in list(state.queued_inputs.items()): + try: + receipt = store.call("prompt.receipt", session_id=cli.session_id, admission_id=admission) + except GatewayClientError as exc: + logger.debug("prompt.receipt %s failed: %s", admission, exc) + rows.setdefault(admission, (1 << 62, text)) # unread: still shown, never dropped + continue + if receipt.get("status") == "queued": + rows[admission] = (int(receipt.get("sequence") or 0), text) + else: + # Started or settled: no longer queued, but its turn may not be painted yet. The idle tick + # paints it (``gateway_tools_notify.recover_queued``); listing never erases that. + rows.pop(admission, None) + state.missed.add(admission) + return [(admission, text) for admission, (_, text) in sorted(rows.items(), key=lambda kv: kv[1][0])] + + +def published_queue(cli, store) -> list[str]: + """The texts the owner last published as queued, plus this viewer's admissions submitted after that + list (no RPC: the dock repaints on every tick). + + The owner's published list is authoritative: an admission it no longer lists (cancelled, replaced or + steered by another client, or started) is not shown as queued, even while ``queued_inputs`` still + owes its turn a paint.""" + state = _state(cli, store) + for gone in [admission for admission in state.queued_at if admission not in state.queued_inputs]: + state.queued_at.pop(gone, None) + rows = [row for row in state.pending or () if isinstance(row, dict)] + known = {row.get("admission_id") for row in rows} + fresh = [text for admission, text in state.queued_inputs.items() + if admission not in known and state.queued_at.get(admission, -1) >= state.pending_seq] + return [str(row.get("text") or "") for row in rows if row.get("status") == "queued"] + fresh + + +# How long the input loop holds a local input behind this viewer's own ``/queue`` admission at most. +OWN_QUEUE_HOLD_S = 2.0 + + +def _own_head(state): + """This viewer's ``/queue`` admission at the head of the owner's queue, not yet adopted; None when + the head is someone else's, nothing is queued, or the FIFO is paused on an ``unknown`` row.""" + rows = [row for row in state.pending or () if isinstance(row, dict)] + if any(row.get("status") == "unknown" for row in rows): + return None # paused until the owner resolves that row: never hold typing behind it + queued = sorted((row for row in rows if row.get("status") == "queued" and row.get("admission_id")), + key=lambda row: int(row.get("sequence") or 0)) + if queued: + head = queued[0]["admission_id"] + else: # submitted after the last published list (``published_queue``'s fresh rows) + listed = {row.get("admission_id") for row in rows} + head = next((a for a in state.queued_inputs + if a not in listed and state.queued_at.get(a, -1) >= state.pending_seq), None) + return head if head in state.queued_inputs and head not in state.adopted else None + + +class OwnQueueHold: + """A local input (a slash command, a typed turn) taken while this viewer's own ``/queue`` admission + is the owner's next turn. On main both sat in ``_pending_input`` in entry order; now the admission + is in the owner's FIFO and its turn is adopted only when the owner's ``message.start`` arrives, so + the input loop holds the local item until that admission was taken (or left the queue). + + Bounded: an ``unknown`` row (a paused FIFO) never starts a hold; an owner that publishes an idle + list still queuing it (beyond the one a just-settled turn publishes before its claim) releases it; + and ``OWN_QUEUE_HOLD_S`` caps the wait. The admission itself is never touched. + """ + + def __init__(self, item, state, admission): + import time + self.item, self.state, self.admission = item, state, admission + self.seq = state.pending_seq + # A turn the owner reports running settles with one idle list (still queuing this admission) + # before its drain claims it: that list is expected, a further one means the FIFO is paused. + self.allowed_idle_lists = 1 if state.info.get("running") else 0 + self.idle_lists = 0 + self.deadline = time.monotonic() + OWN_QUEUE_HOLD_S + self.taken = False + + @classmethod + def begin(cls, cli, item): + from cli import _SeededQueryMessage + from hermes_cli.gateway_tools_notify import gateway_store + from tools.process_registry_notifications import TimelineNotification + if getattr(cli, "_agent_running", False) or isinstance(item, (_SeededQueryMessage, TimelineNotification)): + return None # a turn the owner already runs (or a seeded prompt) is never held + store = gateway_store(cli) if getattr(cli, "session_id", None) else None + if store is None: + return None + state = _state(cli, store) + admission = _own_head(state) + return cls(item, state, admission) if admission else None + + def take_gateway_turn(self, pending): + """Pop a turn the owner is running now (an adopted admission, a notification header) from the + head of ``_pending_input``: those run before the held item, typed items stay behind it.""" + from tools.process_registry_notifications import TimelineNotification + with pending.mutex: + if not pending.queue: + return None + head = pending.queue[0] + if not (getattr(head, "admission_id", None) or isinstance(head, TimelineNotification)): + return None + pending.queue.popleft() + pending.unfinished_tasks = max(0, pending.unfinished_tasks - 1) + if getattr(head, "admission_id", None) == self.admission: + self.taken = True + return head + + def done(self) -> bool: + import time + if self.taken or time.monotonic() >= self.deadline: + return True + state = self.state + if _own_head(state) != self.admission: + # Adopted: its item is at the head of ``_pending_input``, taken on the next step. Otherwise + # it started unseen (the idle tick recovers it), was cancelled, or is no longer next. + return self.admission not in state.adopted + if state.pending_seq != self.seq: + self.seq = state.pending_seq + if not state.info.get("running"): + self.idle_lists += 1 + return self.idle_lists > self.allowed_idle_lists # idle and still not claiming it: stop waiting + + +STARTED_REFUSALS = frozenset({"stale_generation", "admission_started"}) + + +def cancel(cli, store, admission: str) -> str: + """Retire one queued admission: ``"cancelled"`` when it never ran, ``"stopped"`` when the drain had + claimed it and the owner interrupted that run instead (``session_controls.cancel``), ``"started"`` + when it already ran. A turn that ran is still owed a paint, so only a cancelled one leaves + ``queued_inputs``.""" + receipt = store.call("prompt.cancel", session_id=cli.session_id, admission_id=admission) or {} + if receipt.get("status") == "terminal" and receipt.get("outcome") == "cancelled": + state = _state(cli, store) + state.queued_inputs.pop(admission, None) + state.missed.discard(admission) + return "cancelled" + _state(cli, store).missed.add(admission) # the idle tick paints whatever ran + if receipt.get("status") == "started" or receipt.get("outcome") == "interrupted": + return "stopped" + return "started" # it already ran to its end: nothing was stopped + + +def replace(cli, store, admission: str, text: str) -> str | None: + """``prompt.replace_queued``: the edited prompt is a new admission at the back of the FIFO.""" + receipt = store.call("prompt.replace_queued", session_id=cli.session_id, admission_id=admission, + submission_id=uuid.uuid4().hex, text=text) + state = _state(cli, store) + state.queued_inputs.pop(admission, None) + new = (receipt or {}).get("admission_id") + if new: + state.queued_inputs[new] = text + state.queued_at[new] = state.pending_seq + return new diff --git a/hermes_cli/gateway_session_store.py b/hermes_cli/gateway_session_store.py index 916e584f8d..fb31667a63 100644 --- a/hermes_cli/gateway_session_store.py +++ b/hermes_cli/gateway_session_store.py @@ -121,20 +121,26 @@ class GatewaySessionStore: self.connection.state(sid).adopt_snapshot(snapshot) return sid - def attach(self, session_id): - """``session.resume`` once per session id this viewer uses (idempotent).""" + def attach(self, session_id, *, typed=False): + """``session.resume`` once per session id this viewer uses (idempotent), carrying this CLI's launch + key as the thin client does (``GatewayConnection.resume``, the reconnect's too). A session that does + not take the key (a keyed startup alias resuming a Desktop/TUI session) attaches as is. ``typed`` + (only :meth:`open_session`'s startup resume, where the user typed ``--api-key`` for that session) + refuses it instead, as the thin client does.""" if session_id in self.snapshots: return self.snapshots[session_id] - snapshot = self.call("session.resume", session_id=session_id) + snapshot = self.connection.resume(self.call, session_id, typed=typed) self._adopt(snapshot) return snapshot def open_session(self, cli, launch=None): """Create (or resume) the gateway session for this CLI and adopt its id. A resume target the gateway does not know is left as is: main's own "Session not found" path reports it.""" + key = self.connection.launch_api_key = (launch or {}).get("api_key") or None + self.connection.launch_api_key_typed = key is not None and key == getattr(cli, "_typed_api_key", None) if getattr(cli, "_resumed", False): try: - snapshot = self.attach(cli.session_id) + snapshot = self.attach(cli.session_id, typed=self.connection.launch_api_key_typed) except GatewayClientError as exc: if str(exc) in {"not_found", "permission_denied"}: return None diff --git a/hermes_cli/gateway_setting_commands.py b/hermes_cli/gateway_setting_commands.py index 6e3d32fcf3..490fa277f5 100644 --- a/hermes_cli/gateway_setting_commands.py +++ b/hermes_cli/gateway_setting_commands.py @@ -19,8 +19,16 @@ def _mirror_busy(cli, arg): cli.busy_input_mode = arg.lower() +def _watch_reviewer(cli, _arg): + """/review spawns its reviewer off-turn: have the live-work dock poll for it right away.""" + from hermes_cli.gateway_tools import process_view + view = process_view() + if view is not None: + view.watch_subagents() + + GATEWAY_SETTINGS = {"personality": None, "busy": _mirror_busy, "approvals": None, "footer": None, "fast": None, - "refine": None, "review": None} + "refine": None, "review": _watch_reviewer} def run(cli, canonical, cmd): @@ -47,3 +55,4 @@ def run(cli, canonical, cmd): if mirror is not None: mirror(cli, arg) return True + diff --git a/hermes_cli/gateway_tools.py b/hermes_cli/gateway_tools.py index 8efbe3b2ea..71e7757161 100644 --- a/hermes_cli/gateway_tools.py +++ b/hermes_cli/gateway_tools.py @@ -51,6 +51,12 @@ class GatewayProcessView: self.delegations = 0 self._polled = 0.0 self._lock = threading.Lock() + self.subagents: list = [] + self._subagents_polled = 0.0 + self._subagents_seen = 0.0 + self._subagents_lock = threading.Lock() + self._tail = None + self._subagents_watch_until = 0.0 @property def session_id(self): @@ -128,6 +134,107 @@ class GatewayProcessView: value = usage.get("active_subagents") return value if isinstance(value, int) else 0 + # -- the subagent registry main's Ctrl+T monitor and dock read --------------------------- + def watch_subagents(self, seconds=_RETAIN_SECONDS): + """Poll ``subagent.list`` for a while even with no turn running: an off-turn spawn + (``/review``) moves neither ``busy`` nor the usage count the gate below reads.""" + self._subagents_watch_until = time.time() + seconds + self._subagents_polled = 0.0 + + def subagent_rows(self, *, busy): + """The session's live children (``subagent.list``). Asks at most once a second, and only + while a turn runs (``busy``), children were listed last time, the gateway counts some, or + an off-turn spawn asked to be watched.""" + now = time.time() + wanted = (busy or self.subagents or now < self._subagents_watch_until + or self.active_delegations()) + if wanted and now - self._subagents_polled >= _POLL_SECONDS and self._subagents_lock.acquire(blocking=False): + try: + self._subagents_polled = now + result = self.connection.call("subagent.list", session_id=self.session_id, timeout=5) + self.subagents = list((result or {}).get("subagents") or []) + self._subagents_seen = now + except (GatewayClientError, RuntimeError) as exc: + logger.debug("subagent.list failed: %s", exc) + if now - self._subagents_seen > _RETAIN_SECONDS: + self.subagents = [] # no answer for a minute: drop the rows and stop polling + finally: + self._subagents_lock.release() + return [dict(row) for row in self.subagents] + + def subagent_tail(self, subagent_id, *, fetch=False): + """The child's live transcript tail (``subagent.tail``); None when the gateway has none. + The detail view repaints every frame and reads only the cache; ``fetch`` (the monitor's + tick, on the spinner thread) asks the gateway at most once a second, failures included.""" + now = time.time() + cached = self._tail if self._tail and self._tail[0] == subagent_id else None + if not fetch or (cached and now - cached[1] < _POLL_SECONDS): + return cached[2] if cached else None + try: + result = self.connection.call("subagent.tail", session_id=self.session_id, + subagent_id=subagent_id, timeout=5) or {} + text = result.get("text") if result.get("available") else None + if text is not None: # filter once here, not on every repaint of the detail view + text = "".join(c for c in text if c.isprintable() or c in "\n\t") + except (GatewayClientError, RuntimeError) as exc: + logger.debug("subagent.tail failed: %s", exc) + text = None + self._tail = (subagent_id, now, text) + return text + + def subagent_control(self, action, subagent_id, message=None): + """Steer (``subagent.steer``) or stop (``subagent.interrupt``) one child of this session, in + the shape ``delegate_tool_registry._handle_control_action`` answers: ``note`` or ``error``.""" + from tools.delegate_tool_registry import _CONTROL_OUTCOMES + + status, note, failure = _CONTROL_OUTCOMES[action] + try: + if action == "steer": + result = self.connection.call("subagent.steer", session_id=self.session_id, + subagent_id=subagent_id, text=message, timeout=5) or {} + ok = result.get("status") == "queued" + else: + result = self.connection.call("subagent.interrupt", session_id=self.session_id, + subagent_id=subagent_id, timeout=5) or {} + ok = bool(result.get("found")) + except GatewayClientError as exc: + return {"error": str(exc)} + self._subagents_polled = 0.0 # the next tick re-reads the roster + if ok: + return {"action": action, "subagent_id": subagent_id, "status": status, "note": note} + return {"error": failure.format(sid=subagent_id)} + + +class LocalSubagents: + """This process's own subagent registry behind the view's ``subagent_*`` surface, for a CLI that + runs its agent in-process: the dock and monitor talk to one backend shape either way.""" + + def __init__(self, cli): + self.cli = cli + self._paths: dict = {} + + def subagent_rows(self, *, busy): + from tools.delegate_tool_registry import _list_payload, list_active_subagents + parent = getattr(self.cli, "agent", None) + entries = _list_payload(parent)["subagents"] if parent is not None else [] + # The scoped control-plane snapshot supplies authority and transcript paths; the public + # lifecycle record supplies start time and the latest observed tool (as subagent.list does). + live = {r["subagent_id"]: r for r in list_active_subagents()} if entries else {} + for row in entries: + record = live.get(row["subagent_id"], {}) + row["started_at"], row["last_tool"] = record.get("started_at"), record.get("last_tool") or "" + self._paths = {row["subagent_id"]: row.get("live_transcript") for row in entries} + return entries + + def subagent_tail(self, subagent_id, *, fetch=False): + from hermes_cli.cli_subagent_monitor import read_tail + return read_tail(self._paths.get(subagent_id)) + + def subagent_control(self, action, subagent_id, message=None): + import json + from tools.delegate_tool_registry import _handle_control_action + return json.loads(_handle_control_action(action, subagent_id, message, getattr(self.cli, "agent", None))) + class _OpenedSession: """The CLI's open gateway session before any chat agent exists (``_init_agent`` builds that on the diff --git a/hermes_cli/gateway_tools_notify.py b/hermes_cli/gateway_tools_notify.py index 5e176c2b58..533899498c 100644 --- a/hermes_cli/gateway_tools_notify.py +++ b/hermes_cli/gateway_tools_notify.py @@ -41,12 +41,15 @@ def queue_admitted_turn(cli, store) -> None: from tools.process_registry_notifications import TimelineNotification connection, session_id = store.connection, getattr(cli, "session_id", None) state = connection.state(session_id) + if _adoption_waiting(cli, state): + return # an adopted /queue turn is waiting for the input loop: its frames stay queued for its pump state.admitted_turn = None # a header queued earlier whose turn never ran is stale now events = connection.events while True: try: frame = events.get_nowait() except queue.Empty: + recover_queued(cli, store) return if isinstance(frame, Exception): from hermes_cli.gateway_connection import lost_before @@ -66,6 +69,16 @@ def queue_admitted_turn(cli, store) -> None: continue if params.get("type") != "message.start" or not params.get("admission_id"): continue + queued_text = state.queued_inputs.get(params["admission_id"]) + if queued_text is not None: + # A ``/queue`` prompt this viewer admitted (#134889 N5): the owner runs it now, and main's + # input path paints and pumps it as a typed turn. Literal: it is a prompt, never a command. + # Adopted by admission id (the item carries it), never by text: the input path may rewrite + # the text, and a second ``prompt.submit`` would run the prompt twice. + from cli import _SeededQueryMessage + _adopt(cli, state, {"admission_id": params["admission_id"], + "execution_generation": params.get("execution_generation")}, queued_text) + return card = _card(params) if card is None: continue @@ -77,6 +90,84 @@ def queue_admitted_turn(cli, store) -> None: return +def _adopt(cli, state, adopted, text): + from cli import _SeededQueryMessage + state.missed.discard(adopted["admission_id"]) + state.adopted[adopted["admission_id"]] = adopted + _put_first(cli._pending_input, _SeededQueryMessage(text, admission_id=adopted["admission_id"])) + + +def _adoption_waiting(cli, state) -> bool: + """An adopted item is still in ``_pending_input`` (one ``/queue clear`` dropped is forgotten).""" + if not state.adopted: + return False + pending = getattr(cli, "_pending_input", None) + if pending is None: + return False + with pending.mutex: + waiting = {getattr(item, "admission_id", None) for item in pending.queue} + waiting.add(state.armed_admission) # taken by the input loop, its run_conversation not started yet + for admission in [a for a in state.adopted if a not in waiting]: + state.adopted.pop(admission, None) + return bool(state.adopted) + + +_NEVER_RAN = {"cancelled", "rejected"} + + +def recover_queued(cli, store) -> None: + """Idle tick, after the stream had no ``message.start`` for them: this viewer's ``/queue`` + admissions the owner started or settled without this viewer painting the turn (a local turn's + drain or pump dropped the start, or it ran across a reconnect, which replays no frames). Read on + a new published pending list or a dropped start only, never every tick. + + Still running: adopted and pumped live, its reply completed from the receipt (``recovered``). + Settled: adopted with its saved result, painted without a pump. One that never ran (cancelled, + rejected) is forgotten: the only point where ``queued_inputs`` drops a row unpainted. + """ + from hermes_cli.gateway_client import GatewayClientError + state = store.connection.state(getattr(cli, "session_id", None)) + if not state.queued_inputs or (state.recovered_seq == state.pending_seq and not state.missed): + return + state.recovered_seq = state.pending_seq + listed = {row.get("admission_id"): row.get("status") for row in state.pending or () if isinstance(row, dict)} + for admission, text in list(state.queued_inputs.items()): + if admission in state.adopted or (admission not in state.missed and listed.get(admission) == "queued"): + continue + try: + receipt = store.call("prompt.receipt", session_id=cli.session_id, admission_id=admission, + include_result=True) or {} + except GatewayClientError: + state.recovered_seq = -1 # unread: try again on the next tick + continue + status = receipt.get("status") + if status == "queued": + state.missed.discard(admission) + continue + if status == "terminal" and receipt.get("outcome") in _NEVER_RAN: + state.queued_inputs.pop(admission, None) + state.missed.discard(admission) + continue + if status not in ("started", "terminal"): + # unknown: the owner resolves it (``/discard`` or recovery settles it terminal, and the next + # read forgets or paints it). Until then it stays here and is re-read once per new pending + # list, never every tick; the row is rare and the read is one receipt. + continue + adopted = {"admission_id": admission, "execution_generation": receipt.get("execution_generation"), + "recovered": True} + if status == "terminal": + adopted["receipt"] = receipt + _adopt(cli, state, adopted, text) + return # one turn at a time: the next one is recovered after this one ran + + +def missed_start(state, params) -> None: + """A frame dropped outside its turn: a ``/queue`` admission's ``message.start`` is remembered so + the idle tick paints that turn from its receipt instead of losing it.""" + if params.get("type") == "message.start" and params.get("admission_id") in state.queued_inputs: + state.missed.add(params["admission_id"]) + + def _put_first(pending, item): """Ahead of anything already queued: the gateway is running this turn NOW (typed inputs the post-turn path re-queued are admitted behind it), and a typed turn run first would drop it.""" @@ -86,10 +177,25 @@ def _put_first(pending, item): pending.not_empty.notify() +def arm_adopted(cli, item) -> None: + """The input loop took ``item``: when it is an adopted ``/queue`` admission, the next + ``run_conversation`` pumps exactly that admission (``take_admitted_turn``).""" + admission = getattr(item, "admission_id", None) + store = gateway_store(cli) if admission else None + if store is not None: + store.connection.state(getattr(cli, "session_id", None)).armed_admission = admission + + def take_admitted_turn(agent, text): """The admitted turn ``run_conversation(text)`` replays instead of submitting; None (and the - stale adoption dropped) for any other turn.""" + stale notification adoption dropped) for any other turn. A ``/queue`` admission is matched by the + id ``arm_adopted`` recorded, a notification turn by its model text.""" state = agent._state() + armed, state.armed_admission = getattr(state, "armed_admission", None), None + adopted = state.adopted.pop(armed, None) if armed else None + if adopted is not None: + state.queued_inputs.pop(armed, None) # painted now: the admission's turn is this one + return adopted adopted = getattr(state, "admitted_turn", None) state.admitted_turn = None if not adopted or adopted.get("text") != text: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3907a239f7..40cc1e34d7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1668,7 +1668,7 @@ def _first_run_setup_guard(args) -> None: sys.exit(1) -# args attr -> (kwarg, default) passed through to _launch_tui / cli.main. +# args attr -> (kwarg, default) passed through to _launch_tui / cli.main; only cli.main takes --api-key/--base-url. _CHAT_PASSTHROUGH = ( ("provider", None), ("toolsets", None), ("skills", None), ("verbose", None), ("quiet", False), ("query", None), ("image", None), ("resume", None), @@ -1757,7 +1757,7 @@ def cmd_chat(args): "ignore_rules": getattr(args, "ignore_rules", False) or safe_mode, "ignore_user_config": getattr(args, "ignore_user_config", False) or safe_mode, "compact": getattr(args, "compact", False), - **{k: getattr(args, k, d) for k, d in _CHAT_PASSTHROUGH}, + **{k: getattr(args, k, d) for k, d in _CHAT_PASSTHROUGH + (("api_key", None), ("base_url", None))}, } kwargs = {k: v for k, v in kwargs.items() if v is not None} diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index ae5a820917..5d364272ce 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -462,6 +462,10 @@ class ModelSwitchResult: runtime_capabilities: Optional[dict[str, bool]] = None model_info: Optional[ModelInfo] = None is_global: bool = False + # The resolved direct alias's own ``base_url`` (and the credential decided for it) replaced the + # provider's route. ``resolved_via_alias`` alone is also set by a built-in shortcut, an endpointless + # entry or a process provider's declared id, which name a model like a plain pick. + alias_endpoint_applied: bool = False @dataclass(frozen=True) @@ -1186,6 +1190,7 @@ class _Switch: new_model: str = "" target_provider: str = "" resolved_alias: str = "" + alias_endpoint_applied: bool = False provider_label: str = "" api_key: str = "" base_url: str = "" @@ -1586,6 +1591,7 @@ def _resolve_switch_credentials(st: _Switch) -> Optional[ModelSwitchResult]: da = DIRECT_ALIASES.get(st.resolved_alias) if da is not None and da.base_url: _apply_direct_alias_endpoint(st, da) + st.alias_endpoint_applied = True # Fills an empty mode (alias cleared it) and overrides a STALE mode carried from previous # session state when the host mandates one wire protocol (e.g. gpt-5.x on api.openai.com @@ -1738,7 +1744,7 @@ def _build_switch_result(st: _Switch) -> ModelSwitchResult: provider_label=st.provider_label, resolved_via_alias=st.resolved_alias, capabilities=capabilities, runtime_capabilities={ k: v for k, v in runtime_capabilities.items() if isinstance(k, str) and isinstance(v, bool)}, - model_info=model_info, is_global=st.is_global) + model_info=model_info, is_global=st.is_global, alias_endpoint_applied=st.alias_endpoint_applied) def switch_model( diff --git a/hermes_cli/plugin_oauth.py b/hermes_cli/plugin_oauth.py new file mode 100644 index 0000000000..56688593c8 --- /dev/null +++ b/hermes_cli/plugin_oauth.py @@ -0,0 +1,564 @@ +"""OAuth clients a plugin declares in ``plugin.yaml``: Hermes signs in, stores and refreshes the grant. + +A plugin that calls a third-party API with the user's own OAuth client (a Google Cloud client, for example) +declares it under ``oauth:``. ``hermes auth add :`` runs the PKCE login of +:mod:`hermes_cli.auth_oauth_pkce_plugin` in a terminal, the grant lives in the active profile's +``auth.json`` (section ``plugin_oauth``) and Hermes refreshes it. The plugin calls +``credentialed_provider_request(, ...)``: Hermes attaches the bearer for the declared hosts only, so +the plugin never holds a token (#139077; the same contract as the Codex lane, #136117). + +Each grant remembers a fingerprint of the ``oauth:`` entry it was made for. A later version of the plugin +that changes the token URL, hosts, scopes or client variables gets ``ProviderNotSignedIn`` until the user +signs in again: the grant is used only for what the user consented to. Like capabilities, this is +consent and visibility, not a sandbox. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import time +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Callable, Mapping, Optional +from urllib.parse import urlparse + +_SECTION = "plugin_oauth" +_EXPIRY_MARGIN_MS = 120_000 # refresh this long before expiry, as the credential pool does +_TOKEN_TIMEOUT = 10.0 # below AUTH_LOCK_TIMEOUT_SECONDS: a peer waiting for the refresh lock outlives the POST +_CLIENT_ERROR_CODES = frozenset({"invalid_client", "unauthorized_client", "invalid_scope"}) +_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +_ENV_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") +# Lowercase DNS name with at least one dot whose last label starts with a letter: no IP literal, port or scheme. +_HOST_RE = re.compile(r"^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z](?:[a-z0-9-]*[a-z0-9])?$") + + +@dataclass(frozen=True) +class OAuthClient: + """One ``oauth:`` entry of a plugin manifest. Holds variable names, never their values.""" + + id: str + authorize_url: str + token_url: str + scopes: tuple[str, ...] + client_id_env: str + hosts: tuple[str, ...] + client_secret_env: str = "" + authorize_params: tuple[tuple[str, str], ...] = () + + @property + def fingerprint(self) -> str: + """Hash of every field: a grant is used only with the entry it was made for.""" + return hashlib.sha256(json.dumps(asdict(self), sort_keys=True).encode("utf-8")).hexdigest() + + +# ── Manifest ─────────────────────────────────────────────────────────────────────────────────────── + +def _parse_url(url: Any): + """``urlparse`` of *url*; None for a non-string or a URL it cannot read (bad IPv6 literal or port).""" + if not isinstance(url, str): + return None + try: + parsed = urlparse(url) + _ = parsed.port # urlparse checks the port only when it is read + except ValueError: + return None + return parsed + + +def _url_problems(where: str, url: Any) -> list[str]: + parsed = _parse_url(url) + if (parsed is None or not parsed.hostname or parsed.username is not None or parsed.password is not None + or parsed.fragment): + return [f"{where}: must be an absolute URL without user info or fragment"] + if parsed.scheme != "https": # no loopback exception: the code, refresh token and client secret travel here + return [f"{where}: must use https"] + return [] + + +def _hermes_owned_env(name: str) -> bool: + """A variable Hermes itself reads (a provider key, a platform secret): never a plugin's client variable, + or a manifest could have Hermes POST that secret to the token URL it names.""" + from hermes_cli.auth import PROVIDER_REGISTRY + from hermes_cli.config_env_routing import is_registered_env_name + + return is_registered_env_name(name) or any(name in p.api_key_env_vars for p in PROVIDER_REGISTRY.values()) + + +def _entry_problems(entry: dict, where: str) -> list[str]: + oauth_id = entry.get("id") + problems = [] if isinstance(oauth_id, str) and _ID_RE.match(oauth_id) else [ + f"{where}.id: {entry.get('id')!r} must be lowercase letters, digits and dashes"] + for field in ("authorize_url", "token_url"): + problems += _url_problems(f"{where}.{field}", entry.get(field)) + scopes = entry.get("scopes") + if not (isinstance(scopes, list) and scopes and all(isinstance(s, str) and s.strip() for s in scopes)): + problems.append(f"{where}.scopes: must be a non-empty list of scope strings") + for field, required in (("client_id_env", True), ("client_secret_env", False)): + name = entry.get(field) + if (required or name) and not (isinstance(name, str) and _ENV_RE.match(name)): + problems.append(f"{where}.{field}: {name!r} must be an UPPER_SNAKE environment variable name") + elif name and _hermes_owned_env(name): + problems.append(f"{where}.{field}: {name} is a variable Hermes uses itself; declare the plugin's own") + hosts = entry.get("hosts") + if not (isinstance(hosts, list) and hosts and all(isinstance(h, str) and _HOST_RE.match(h) for h in hosts)): + problems.append(f"{where}.hosts: must list lowercase DNS host names (no scheme, port, path or IP address)") + params = entry.get("authorize_params") or {} + if not (isinstance(params, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in params.items())): + problems.append(f"{where}.authorize_params: must map strings to strings") + return problems + + +def _client(entry: dict) -> OAuthClient: + return OAuthClient( + id=entry["id"], authorize_url=entry["authorize_url"], token_url=entry["token_url"], + scopes=tuple(entry["scopes"]), client_id_env=entry["client_id_env"], hosts=tuple(entry["hosts"]), + client_secret_env=entry.get("client_secret_env") or "", + authorize_params=tuple(sorted((entry.get("authorize_params") or {}).items()))) + + +def parse_oauth_clients(manifest: Mapping[str, Any]) -> tuple[list[OAuthClient], list[str]]: + """``oauth:`` from a manifest dict: the well-formed clients, and one problem string per defect.""" + raw = (manifest or {}).get("oauth") + if raw is None or raw == []: + return [], [] + if not isinstance(raw, list): + return [], ["oauth: must be a list of client entries"] + clients: list[OAuthClient] = [] + problems: list[str] = [] + for index, entry in enumerate(raw): + where = f"oauth[{index}]" + entry_problems = _entry_problems(entry, where) if isinstance(entry, dict) else [f"{where}: must be a mapping"] + if not entry_problems and any(c.id == entry["id"] for c in clients): + entry_problems = [f"{where}.id: {entry['id']!r} is declared twice"] + problems += entry_problems + if not entry_problems: + clients.append(_client(entry)) + return clients, problems + + +def _declared_clients(plugin_dir: Path) -> list[OAuthClient]: + """The plugin's own ``oauth:`` clients; a malformed block fails loud instead of reading as undeclared.""" + from pm.plugin_declarations import native_manifest_file, read_native_manifest + + manifest_file = native_manifest_file(plugin_dir) + clients, problems = parse_oauth_clients(read_native_manifest(manifest_file) if manifest_file else {}) + if problems: + raise ValueError(f"Plugin '{plugin_dir.name}' has an invalid oauth: block in plugin.yaml: " + "; ".join(problems)) + return clients + + +def plugin_key(plugin_dir: Path) -> str: + """The plugin's key under the plugins root it lives in (``voice``, ``web/exa``); grants are stored by it.""" + from hermes_cli.plugin_provider_requests import _plugin_roots + + for path in (plugin_dir.absolute(), plugin_dir.resolve()): + for root in _plugin_roots(): + if path != root and path.is_relative_to(root): + return path.relative_to(root).as_posix() + return plugin_dir.name + + +def _idp_hosts(client: OAuthClient) -> str: + return ", ".join(dict.fromkeys(urlparse(url).hostname for url in (client.authorize_url, client.token_url))) + + +def oauth_notice(manifest: Mapping[str, Any]) -> str: + """Install/show text for the declared clients; empty when the plugin declares none.""" + name = str((manifest or {}).get("name") or "") + return "; ".join( + f"Signs in to {c.id} at {_idp_hosts(c)} for {', '.join(c.hosts)} with `hermes auth add {name}:{c.id}` " + "(Hermes stores and refreshes the token; the plugin never receives it)" + for c in parse_oauth_clients(manifest)[0]) + + +def oauth_surface(manifest: Mapping[str, Any]) -> set[str]: + """One line per client for the re-pin consent delta: a new entry, or a changed authorize host, token host, + hosts or scopes, shows up as added. Any other change only alters the fingerprint, so the user signs in again.""" + lines: set[str] = set() + for c in parse_oauth_clients(manifest)[0]: + lines.add(f"{c.id} ({_idp_hosts(c)} -> {', '.join(c.hosts)}; {' '.join(c.scopes)})") + return lines + + +def _declared_env_names(manifest: Mapping[str, Any]) -> set[str]: + names: set[str] = set() + for field in ("requires_env", "optional_env"): + for entry in manifest.get(field) or []: + names.add(str(entry.get("name") if isinstance(entry, dict) else entry)) + return names + + +def oauth_admission_problems(manifest: Mapping[str, Any], plugin_dir: Path) -> list[str]: + """Catalog admission on top of the shape: declared variables, no clash with core sign-ins or ``custom:``.""" + from hermes_cli.plugin_provider_requests import _PROVIDERS + + clients, problems = parse_oauth_clients(manifest) + declared = _declared_env_names(manifest) + for client in clients: + if client.id in _PROVIDERS: + problems.append(f"oauth id {client.id!r} is a Hermes sign-in provider; pick another id") + for name in filter(None, (client.client_id_env, client.client_secret_env)): + if name not in declared: + problems.append(f"oauth {client.id!r}: {name} must be declared under requires_env or optional_env") + if clients and plugin_dir.name == "custom": + problems.append("a plugin named 'custom' cannot declare oauth: `hermes auth add custom:` is a custom provider") + return problems + + +def _parent(host: str) -> str: + """Registered domain, roughly: ``vendor.co.uk`` keeps three labels (no public-suffix list in core).""" + labels = host.split(".") + keep = 3 if len(labels) > 2 and len(labels[-1]) == 2 and len(labels[-2]) <= 3 else 2 + return ".".join(labels[-keep:]) + + +def oauth_admission_warnings(manifest: Mapping[str, Any], plugin_dir: Path) -> list[str]: + """Reviewer warnings: a host outside the sign-in provider's domains (does the IdP own it?), and plugin + code that names a declared token URL (Hermes refreshes the grant).""" + clients = parse_oauth_clients(manifest)[0] + warnings = [f"oauth {c.id!r}: host {host} shares no domain with {_idp_hosts(c)}; check the provider owns it" + for c in clients for host in c.hosts + if _parent(host) not in {_parent(urlparse(u).hostname or "") for u in (c.authorize_url, c.token_url)}] + urls = sorted({c.token_url for c in clients}) + for path in sorted(plugin_dir.rglob("*.py")): + try: + text = path.read_text(encoding="utf-8-sig", errors="replace") + except OSError: + continue + warnings += [f"oauth: {path.relative_to(plugin_dir).as_posix()} names the token URL {url}; Hermes " + "refreshes the grant, so the plugin should not call it" for url in urls if url in text] + return warnings + + +# ── Grant store: section ``plugin_oauth`` of the active profile's auth.json ──────────────────────── + +def _read_grant(key: str) -> Optional[dict]: + from hermes_cli.auth import _load_auth_store + + section = _load_auth_store().get(_SECTION) + row = section.get(key) if isinstance(section, dict) else None + return dict(row) if isinstance(row, dict) else None + + +def forget_plugin_grants(plugin: str) -> None: + """Drop every grant of plugin key *plugin* from the active profile (``hermes plugins remove``).""" + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store + + with _auth_store_lock(): + store = _load_auth_store() + section = store.get(_SECTION) + stale = [k for k in section if k.rsplit(":", 1)[0] == plugin] if isinstance(section, dict) else [] + if stale: + for key in stale: + section.pop(key) + _save_auth_store(store) + + +def _write_grant(key: str, row: Optional[dict]) -> None: + """Replace one grant (``None`` removes it) under the auth-store lock; other sections stay as they are.""" + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store, _store_section + + with _auth_store_lock(): + store = _load_auth_store() + section = _store_section(store, _SECTION) + if row is None: + section.pop(key, None) + else: + section[key] = row + _save_auth_store(store) + + +# ── Token endpoint ───────────────────────────────────────────────────────────────────────────────── + +def _auth_error(key: str, message: str, code: str): + from hermes_cli.auth_constants import AuthError + + return AuthError(f"{key}: {message}", provider=key, code=code) + + +def _env(name: str, code: str, key: str) -> str: + from agent.secret_scope import get_secret + + value = str(get_secret(name) or "").strip() + if not value: + raise _auth_error(key, f"{name} is not set. Add it to this profile's .env (the plugin declares it).", code) + return value + + +def _json_object(response: Any) -> dict: + try: + payload = response.json() + except ValueError: + return {} + return payload if isinstance(payload, dict) else {} + + +def _token_error(key: str, status: int, payload: Mapping[str, Any]): + """Keep the OAuth ``error`` code a caller can act on; never echo the response body.""" + from hermes_cli.auth import _OAUTH_GRANT_DEAD_CODES + + error = str(payload.get("error") or "").strip() + if error in _OAUTH_GRANT_DEAD_CODES or error in _CLIENT_ERROR_CODES: + return _auth_error(key, f"the token endpoint refused the request (HTTP {status}, {error}).", error) + return _auth_error(key, f"the token endpoint failed with HTTP {status}.", "oauth_refresh_failed") + + +def _require_granted_scopes(client: OAuthClient, payload: Mapping[str, Any], key: str) -> None: + granted = payload.get("scope") + if not isinstance(granted, str) or not granted.strip(): + return # the IdP did not say which scopes it granted + missing = [scope for scope in client.scopes if scope not in granted.split()] + if missing: + raise _auth_error(key, "not every permission was granted, so nothing was saved. Missing: " + + ", ".join(missing), "oauth_scope_not_granted") + + +def _token_request(client: OAuthClient, key: str) -> Callable[[dict[str, str]], Mapping[str, Any]]: + """``OAuthPKCEConfig.token_request``: the client credentials are read right before each POST, so the + secret never sits in the frozen config, whose repr would show it.""" + + def post(fields: dict[str, str]) -> Mapping[str, Any]: + from hermes_cli.auth import _default_verify + from hermes_cli.auth_constants import httpx + + form = {**fields, "client_id": _env(client.client_id_env, "oauth_client_id_missing", key)} + if client.client_secret_env: + form["client_secret"] = _env(client.client_secret_env, "oauth_client_secret_missing", key) + failure = "" + try: + response = httpx.post(client.token_url, data=form, headers={"Accept": "application/json"}, + timeout=_TOKEN_TIMEOUT, verify=_default_verify(), follow_redirects=False) + except httpx.HTTPError as exc: + failure = type(exc).__name__ + if failure: # raised outside the handler: httpx's error keeps the request, whose body holds the secrets + raise _auth_error(key, f"the token request failed ({failure}).", "oauth_refresh_failed") + payload = _json_object(response) + if not 200 <= response.status_code < 300: # a redirect is a failure too: it is never followed + raise _token_error(key, response.status_code, payload) + if fields.get("grant_type") == "authorization_code": + _require_granted_scopes(client, payload, key) + return payload + + return post + + +def _pkce_config(client: OAuthClient, key: str, *, timeout: Optional[float] = None): + from hermes_cli.auth_oauth_pkce_plugin import OAuthPKCEConfig + + return OAuthPKCEConfig( + client_id=_env(client.client_id_env, "oauth_client_id_missing", key), authorize_url=client.authorize_url, + token_url=client.token_url, scopes=client.scopes, extra_authorize_params=dict(client.authorize_params), + label=key, token_request=_token_request(client, key), timeout_seconds=timeout or 180.0) + + +# ── Bearer for credentialed_provider_request ─────────────────────────────────────────────────────── + +def _not_signed_in(message: str): + from hermes_cli.plugin_provider_requests import ProviderNotSignedIn + + return ProviderNotSignedIn(message) + + +def _revoked(client: OAuthClient, key: str, code: str): + return _not_signed_in(f"The {client.id} sign-in for plugin '{key.rsplit(':', 1)[0]}' was revoked ({code}). " + f"Run `hermes auth add {key}` to sign in again.") + + +def _usable_grant(row: Optional[dict], client: OAuthClient, key: str) -> dict: + plugin = key.rsplit(":", 1)[0] + if row is None: + raise _not_signed_in(f"Not signed in to {client.id} for plugin '{plugin}' in this Hermes profile. " + f"Run `hermes auth add {key}`.") + if row.get("dead"): + raise _revoked(client, key, str(row["dead"])) + if row.get("fingerprint") != client.fingerprint: + raise _not_signed_in(f"Plugin '{plugin}' changed its oauth entry '{client.id}' since you signed in. " + f"Run `hermes auth add {key}` to sign in again.") + return row + + +def _live_token(row: Mapping[str, Any]) -> str: + token = str(row.get("access_token") or "") + expires = row.get("expires_at_ms") + live = expires is None or int(expires) > int(time.time() * 1000) + _EXPIRY_MARGIN_MS + return token if token and live else "" + + +def _refresh(client: OAuthClient, key: str, row: dict) -> str: + """Rotate the grant; the caller holds the auth-store lock, so a peer process waits instead of spending + the same refresh token, and a logout cannot be undone by a refresh in flight.""" + from hermes_cli.auth import _OAUTH_GRANT_DEAD_CODES + from hermes_cli.auth_constants import AuthError + from hermes_cli.auth_oauth_pkce_plugin import _post_token + + refresh_token = str(row.get("refresh_token") or "") + if not refresh_token: + raise _revoked(client, key, "no_refresh_token") + try: + fields = _post_token(key, _pkce_config(client, key), + {"grant_type": "refresh_token", "refresh_token": refresh_token}, code="oauth_refresh_failed") + except AuthError as exc: + if exc.code in _OAUTH_GRANT_DEAD_CODES: + _write_grant(key, {"dead": exc.code, "fingerprint": row.get("fingerprint")}) # keep no dead tokens + raise _revoked(client, key, str(exc.code)) from None + raise + _write_grant(key, {**row, **fields}) + return str(fields["access_token"]) + + +def _bearer(plugin_dir: Path, client: OAuthClient) -> str: + """A live access token for the plugin's grant. Reading takes no lock (writes are atomic); a refresh + re-reads under the lock, because a peer may have rotated the grant meanwhile.""" + from hermes_cli.auth import _auth_store_lock + + key = f"{plugin_key(plugin_dir)}:{client.id}" + token = _live_token(_usable_grant(_read_grant(key), client, key)) + if token: + return token + with _auth_store_lock(): + row = _usable_grant(_read_grant(key), client, key) + return _live_token(row) or _refresh(client, key, row) + + +def _plugin_oauth_spec(plugin_dir: Path, provider: str): + """``(origins, resolver)`` for *provider* when the calling plugin declares it under ``oauth:``, else None. + + Core-only, like the ``_PROVIDERS`` resolvers: the resolver returns the bearer, so plugins go through + ``credentialed_provider_request``, which attributes the caller and checks the origin first.""" + client = next((c for c in _declared_clients(plugin_dir) if c.id == provider), None) + if client is None: + return None + + def resolve(url: str) -> tuple[dict[str, str], str]: + parsed = urlparse(url) + if parsed.username is not None or parsed.password is not None: + raise PermissionError(f"Refusing to send the {client.id} sign-in to a URL with user info") + # The grant is valid for every declared host, so the request URL (origin already checked) is its base. + return {"Authorization": f"Bearer {_bearer(plugin_dir, client)}"}, url + + return _origins(client), resolve + + +def _origins(client: OAuthClient) -> frozenset: + """Origins the bearer may go to: each declared host, HTTPS on the default port.""" + return frozenset(("https", host, 443) for host in client.hosts) + + +# ── hermes auth add|status|refresh|logout : ──────────────────────────────────────────── + +def _installed_client(plugin: str, oauth_id: str) -> Optional[tuple[Path, OAuthClient]]: + """Resolve *plugin* the way ``hermes plugins show`` does, then its ``oauth:`` entry *oauth_id*.""" + from hermes_cli.plugins_cmd import _find_plugin_entry + + entry = _find_plugin_entry(plugin) + if entry is None or not entry[4]: + return None + plugin_dir = Path(str(entry[4])) + client = next((c for c in _declared_clients(plugin_dir) if c.id == oauth_id), None) + return (plugin_dir, client) if client is not None else None + + +def _unattended() -> bool: + from tools.approval_context import _is_gateway_approval_context, _no_user_can_answer + + return _no_user_can_answer() or _is_gateway_approval_context() + + +def _cli_add(args: Any, client: OAuthClient, key: str) -> bool: + from hermes_cli.auth_constants import AuthError + from hermes_cli.auth_oauth_pkce_plugin import login + + if _unattended(): + raise SystemExit(f"`hermes auth add {key}` signs you in through a browser; run it in your own terminal, " + "not from a gateway, cron job or agent session.") + secret = " and the client secret" if client.client_secret_env else "" + print(f"Plugin '{key.rsplit(':', 1)[0]}' signs in to {client.id} at {urlparse(client.authorize_url).hostname}. " + f"The sign-in code{secret} go to {urlparse(client.token_url).hostname}; the token is sent only to " + f"{', '.join(client.hosts)}.") + try: + fields = login(key, _pkce_config(client, key, timeout=getattr(args, "timeout", None)), + open_browser=not getattr(args, "no_browser", False)) + except AuthError as exc: + raise SystemExit(f"Login failed: {exc}") from None + _write_grant(key, {**fields, "fingerprint": client.fingerprint}) + print(f"Signed in to {client.id} for plugin '{key.rsplit(':', 1)[0]}'; the grant is stored in this Hermes profile.") + return True + + +def _cli_status(args: Any, client: OAuthClient, key: str) -> bool: + from hermes_cli.plugin_provider_requests import ProviderNotSignedIn + + row = _read_grant(key) + try: + _usable_grant(row, client, key) + except ProviderNotSignedIn as exc: + state = "logged out" if row is None else "revoked" if row.get("dead") else "sign-in needed" + print(f"{key}: {state}\n {exc}") + return True + expires = row.get("expires_at_ms") if row else None + print(f"{key}: logged in\n hosts: {', '.join(client.hosts)}" + ( + f"\n access token valid until {datetime.fromtimestamp(int(expires) / 1000, UTC).isoformat(timespec='seconds')}" + if expires else "")) + return True + + +def _cli_refresh(args: Any, client: OAuthClient, key: str) -> bool: + from hermes_cli.auth import _auth_store_lock + from hermes_cli.auth_constants import AuthError + from hermes_cli.plugin_provider_requests import ProviderNotSignedIn + + try: + with _auth_store_lock(): + _refresh(client, key, _usable_grant(_read_grant(key), client, key)) + except (AuthError, ProviderNotSignedIn) as exc: + raise SystemExit(f"Refresh failed: {exc}") from None + print(f"{key}: refreshed") + return True + + +def _cli_logout(args: Any, client: OAuthClient, key: str) -> bool: + _write_grant(key, None) + print(f"Signed out of {client.id} for plugin '{key.rsplit(':', 1)[0]}'; the grant was removed from this profile.") + return True + + +def _logout_key(plugin: str, oauth_id: str) -> Optional[str]: + """The stored grant key ``hermes auth logout :`` means, without parsing the manifest.""" + from hermes_cli.plugins_cmd import _find_plugin_entry + + entry = _find_plugin_entry(plugin) + names = [f"{plugin_key(Path(str(entry[4])))}:{oauth_id}"] if entry is not None and entry[4] else [] + return next((key for key in (*names, f"{plugin}:{oauth_id}") if _read_grant(key) is not None), None) + + +_CLI_ACTIONS = {"add": _cli_add, "status": _cli_status, "refresh": _cli_refresh, "logout": _cli_logout} + + +def handle_auth_action(action: str, args: Any, provider: str) -> Optional[bool]: + """``hermes auth add|status|refresh|logout :``; None when *provider* is not a plugin's + ``oauth:`` entry (``custom:`` stays the custom-provider pool).""" + plugin, sep, oauth_id = str(provider or "").partition(":") + handler = _CLI_ACTIONS.get(action) + if handler is None or not (sep and plugin and oauth_id) or plugin == "custom": + return None + if action == "logout" and (key := _logout_key(plugin, oauth_id)) is not None: + _write_grant(key, None) # needs no valid oauth: entry: the plugin may be gone or its manifest broken + print(f"Signed out of {key}; the grant was removed from this profile.") + return True + try: + found = _installed_client(plugin, oauth_id) + except ValueError as exc: + raise SystemExit(str(exc)) from None + if found is None: + return None + plugin_dir, client = found + return handler(args, client, f"{plugin_key(plugin_dir)}:{client.id}") + + +__all__ = [ + "OAuthClient", "forget_plugin_grants", "handle_auth_action", "oauth_admission_problems", + "oauth_admission_warnings", "oauth_notice", "oauth_surface", "parse_oauth_clients", "plugin_key", +] diff --git a/hermes_cli/plugin_provider_requests.py b/hermes_cli/plugin_provider_requests.py index 62614f0bf2..46fbc65b99 100644 --- a/hermes_cli/plugin_provider_requests.py +++ b/hermes_cli/plugin_provider_requests.py @@ -72,14 +72,19 @@ _PROVIDERS: dict[str, tuple[frozenset, Callable[[str], tuple[dict[str, str], str } -def _calling_plugin_dir() -> Optional[Path]: - """Directory of the plugin whose code is nearest on the calling stack, or None.""" +def _plugin_roots() -> set[Path]: + """Every plugins directory an installed plugin can live under, as given and resolved.""" from hermes_cli.plugins import get_bundled_plugins_dir from hermes_constants import get_default_hermes_root, get_hermes_home, get_process_hermes_home bases = (get_hermes_home() / "plugins", get_process_hermes_home() / "plugins", get_default_hermes_root() / "plugins", get_bundled_plugins_dir()) - roots = {r for base in bases for r in (base.absolute(), base.resolve())} + return {r for base in bases for r in (base.absolute(), base.resolve())} + + +def _calling_plugin_dir() -> Optional[Path]: + """Directory of the plugin whose code is nearest on the calling stack, or None.""" + roots = _plugin_roots() frame = sys._getframe(2) while frame is not None: raw = Path(frame.f_code.co_filename) @@ -97,6 +102,12 @@ def _calling_plugin_dir() -> Optional[Path]: return None +def _detached(exc: BaseException) -> BaseException: + """*exc* without traceback or chain: those frames hold the token, refresh token or client secret.""" + exc.__cause__ = exc.__context__ = None + return exc.with_traceback(None) + + def declared_auth_providers(manifest: Mapping[str, Any]) -> list[str]: """``requires_auth`` from a manifest dict: the providers whose sign-in the plugin uses.""" raw = (manifest or {}).get("requires_auth") @@ -105,9 +116,12 @@ def declared_auth_providers(manifest: Mapping[str, Any]) -> list[str]: def requires_auth_notice(manifest: Mapping[str, Any]) -> str: """One line for install/show surfaces; empty when the plugin declares no sign-in use.""" + from hermes_cli.plugin_oauth import oauth_notice + providers = declared_auth_providers(manifest) - return (f"Uses your sign-in for: {', '.join(providers)} (Hermes sends the requests; " - "the plugin never receives the token)") if providers else "" + sign_in = (f"Uses your sign-in for: {', '.join(providers)} (Hermes sends the requests; " + "the plugin never receives the token)") if providers else "" + return "; ".join(part for part in (sign_in, oauth_notice(manifest)) if part) def _declared_providers(plugin_dir: Path) -> list[str]: @@ -123,32 +137,60 @@ def credentialed_provider_request( ) -> ProviderResponse: """Send ``method url`` with the user's *provider* sign-in attached by Hermes. + *provider* is a Hermes sign-in (listed under ``requires_auth``) or an ``id`` the calling plugin + declares under ``oauth:`` (:mod:`hermes_cli.plugin_oauth`); the plugin's own entry wins. Refuses (``PermissionError``) a caller outside an installed plugin, a plugin whose ``plugin.yaml`` does not list *provider* under ``requires_auth``, and any origin outside the provider's allowlist; raises :class:`ProviderNotSignedIn` when this profile has no sign-in. Redirects are returned as-is, never followed. The result carries no credential. """ - spec = _PROVIDERS.get(provider) - if spec is None: - raise ValueError(f"No signed-in requests for provider {provider!r} (supported: {', '.join(_PROVIDERS)})") - origins, resolve = spec + from hermes_cli.plugin_oauth import _plugin_oauth_spec + plugin_dir = _calling_plugin_dir() if plugin_dir is None: raise PermissionError("credentialed_provider_request must be called from an installed plugin's code") - if provider not in _declared_providers(plugin_dir): - raise PermissionError( - f"Plugin '{plugin_dir.name}' must declare `requires_auth: [{provider}]` in plugin.yaml") + # The plugin's own oauth: entry first, so a core provider added later under the same id cannot take it over. + spec = _plugin_oauth_spec(plugin_dir, provider) + if spec is None: + spec = _PROVIDERS.get(provider) + if spec is None: + raise ValueError(f"No signed-in requests for provider {provider!r} (supported: {', '.join(_PROVIDERS)}, " + "or an id the plugin declares under oauth: in plugin.yaml)") + if provider not in _declared_providers(plugin_dir): + raise PermissionError( + f"Plugin '{plugin_dir.name}' must declare `requires_auth: [{provider}]` in plugin.yaml") + origins, resolve = spec if url_origin(url) not in origins: raise PermissionError(f"Refusing to send the {provider} sign-in to {url_origin(url)[1] or url!r}") - auth_headers, credential_base = resolve(url) - if url_origin(credential_base) not in origins: - # A pooled gateway key belongs to its own host only (#121486). - raise PermissionError(f"This profile's {provider} credential routes to a custom endpoint; refusing") - sent = {k: v for k, v in (headers or {}).items() if str(k).lower() not in {h.lower() for h in auth_headers}} import httpx + # Every error the plugin receives past this point is rebuilt without traceback, chain or request: those + # reach the frames and httpx objects that hold the token, refresh token or client secret. + refusal: Optional[BaseException] = None with httpx.Client(timeout=timeout, follow_redirects=False) as client: - response = client.request(method, url, json=json, headers={**sent, **auth_headers}) + request = client.build_request(method, url, json=json, headers=dict(headers or {})) # no credential yet + try: + auth_headers, credential_base = resolve(url) + except httpx.HTTPError as exc: # a resolver's own refresh POST failed: its request holds the refresh token + refusal = type(exc)(str(exc)) + except (RuntimeError, OSError, ValueError, LookupError, TypeError) as exc: + refusal = _detached(exc) + else: + if url_origin(credential_base) not in origins: + # A pooled gateway key belongs to its own host only (#121486). + refusal = PermissionError( + f"This profile's {provider} credential routes to a custom endpoint; refusing") + else: + for name in auth_headers: + request.headers.pop(name, None) + request.headers.update(auth_headers) + try: + response = client.send(request) + except httpx.HTTPError as exc: + refusal = type(exc)(str(exc)) + auth_headers = request = None + if refusal is not None: + raise refusal return ProviderResponse(response.status_code, dict(response.headers), response.content) diff --git a/hermes_cli/plugin_validate.py b/hermes_cli/plugin_validate.py index a7c689c97c..916a483478 100644 --- a/hermes_cli/plugin_validate.py +++ b/hermes_cli/plugin_validate.py @@ -185,6 +185,20 @@ def _check_requires_env(report: ValidationReport, manifest: dict) -> None: report.add("requires_env", True, "all entries UPPER_SNAKE") +def _check_oauth(report: ValidationReport, manifest: dict, plugin_dir: Path) -> None: + """``oauth:`` clients (hermes_cli.plugin_oauth): shape, https and declared variables fail admission; a + host outside the IdP's domains and plugin code that names a token URL are reviewer warnings.""" + from hermes_cli.plugin_oauth import oauth_admission_problems, oauth_admission_warnings + + if manifest.get("oauth") in (None, []): + report.add("oauth", True, "not declared") + return + problems = oauth_admission_problems(manifest, plugin_dir) + report.add("oauth", not problems, "; ".join(problems) or "clients valid") + for warning in oauth_admission_warnings(manifest, plugin_dir): + report.warn(warning) + + # ─── Capability probe (subprocess-isolated) ────────────────────────────────── # Self-contained harness run in a scratch child process. Imports the plugin @@ -529,6 +543,7 @@ def validate_plugin_dir( _check_requires_hermes(report, manifest) _check_config_spec(report, manifest) _check_requires_env(report, manifest) + _check_oauth(report, manifest, plugin_dir) _check_loadable(report, plugin_dir, manifest) _check_python_dependencies(report, plugin_dir) recorded = _check_capabilities(report, manifest, plugin_dir, probe) @@ -646,6 +661,8 @@ def _validate_portable_plugin(report: ValidationReport, plugin_dir: Path) -> Val bool(name), "name present" if name else "plugin.json missing required 'name'", ) + from hermes_cli.agent_plugins import manifest_config_schema + _check_config_spec(report, {"config_schema": manifest_config_schema(manifest)}) for server_name, config in package.mcp_servers.items(): if config.get("trust") == "untrusted": report.add(f"server trust: {server_name}", True, diff --git a/hermes_cli/plugin_validate_desktop.py b/hermes_cli/plugin_validate_desktop.py index c5915012a4..e57db92ab9 100644 --- a/hermes_cli/plugin_validate_desktop.py +++ b/hermes_cli/plugin_validate_desktop.py @@ -14,9 +14,10 @@ from pathlib import Path from typing import List, Tuple # (rule, regex) applied to comment-stripped source; every hit fails the "desktop surface" check. +# ``X.prototype.y =`` is checked separately (``_foreign_prototype_writes``): it passes only where ``X`` +# is provably the file's own class/function, because a bundled library setting fields on its own +# classes (three.js: ``Vector2.prototype.isVector2 = true``) is not reaching into the renderer. _FORBIDDEN: tuple[tuple[str, re.Pattern[str]], ...] = ( - ("prototype patching", - re.compile(r"\b[A-Za-z_$][\w$]*\.prototype\.[\w$]+\s*=[^=]")), ("prototype patching", re.compile(r"\bObject\.definePropert(?:y|ies)\(\s*[\w$.]+\.prototype\b")), ("prototype patching", @@ -79,11 +80,201 @@ def _mask_regex_literals(source: str) -> str: return _REGEXP_CTOR_MATCHER.sub(lambda m: " " * len(m.group(0)), masked) +_PROTOTYPE_WRITE = re.compile(r"\b([A-Za-z_$][\w$]*)\.prototype\.[\w$]+\s*=[^=]") +_RENDERER_GLOBAL_NAMES = ( + r"Object|Function|Array|String|Number|Boolean|Symbol|Promise|RegExp|Date|Error|JSON|Map|Set" + r"|WeakMap|WeakSet|Proxy|Reflect|Storage|Window|Document|Node|EventTarget|Event|Location|History" + r"|Navigator|WebSocket|XMLHttpRequest|Request|Response|Headers|URL|Blob|File|FileReader" + r"|MutationObserver|Worker|BroadcastChannel|MessagePort|IDB\w*|\w*Element|\w*Event|CSS\w*") +# A regex cannot see scope, so a same-named local declaration elsewhere in the file must not excuse +# a write to a renderer global: these receivers always fail, declared or not. +_RENDERER_GLOBAL = re.compile(rf"^(?:{_RENDERER_GLOBAL_NAMES})$") +# A regex cannot see scope, and minifiers reuse one-letter names (three.js minified is +# ``class s{constructor(){s.prototype.isVector2=!0}}`` beside a dozen unrelated ``s=`` locals), so +# file-wide name checks cannot decide. The write passes only where ``X`` provably IS the class: +# inside the body of ``class X``/``function X``, or after such a declaration statement in the same +# block, with nothing rebinding ``X`` in between. ``window["Storage"]``, ``(0, window.Storage)`` and every +# other aliasing spelling then fail by default, wherever an unrelated ``class X`` lives. +_OWN_DECLARATION = r"(?:\bclass\s+{0}\b[^{{]*|\bfunction\s*\*?\s*{0}\s*\([^)]*\)\s*)\{{" +# ``var X = class {...}`` / ``X = function () {...}``: what bundlers emit; binds ``X`` in its block. +_ASSIGNED_DECLARATION = (r"(?>>?|[-+*/%&|^])?=(?![=>])" + r"|\b(?:let|const|var|class|function\s*\*?)\s+{0}(?![\w$])" + # a PATTERN opens where an expression cannot (not ``obj[`` / ``f()[``), names ``X`` itself as a + # target (not ``X.name``, not the key in ``{X: other}``), and is assigned or iterated + r"|(?:^|(?<=[;(,{{}}=?:&|!\n])|\b(?:let|const|var|return|yield|void))\s*[\[{{][^;=()]*?(?])|\b(?:of|in)\b)" + r"|(?:\bfunction\s*\*?\s*[\w$]*|(?|(?" + r"|\bcatch\s*\([^)]*(? tuple[bool, bool]: + """(may be a regex, may be division) for the ``/`` at *at* in code. After ``)`` or ``}`` both + are legal JavaScript (``if (x) /re/.test(s)`` vs ``(a) / b``); only a parser could tell.""" + if source.startswith("/", at + 1): + return False, True + j = at - 1 + while j >= 0 and source[j] in " \t\r\n": + j -= 1 + if j < 0: + return True, False + prev = source[j] + if prev in ")}": + return True, True + if prev in "+-" and j > 0 and source[j - 1] == prev: # ``a++ / b`` + return False, True + if prev in "(,=:[!&|?{;+-*%<>~^": + return True, False + keyword = _REGEX_AFTER_WORD.search(source, max(0, j - 12), j + 1) + if keyword: # an expression must follow ``return`` etc.; ``of`` may also be a plain name + return True, source[j - 1:j + 1] == "of" + return False, True + + +def _step(source: str, i: int, state: tuple) -> tuple[bool, list[tuple]]: + """Advance one reading of the source by the character at *i*: (is that character literal text, + next readings). An empty list means this reading is not valid JavaScript (it dies).""" + kind, holes = state + c = source[i] + if kind in ("\"", "'"): + if c == "\\": + return True, [(kind + "\\", holes)] + if c == "\n": + return True, [] + return c != kind, [("c" if c == kind else kind, holes)] + if kind in ("\"\\", "'\\", "t\\", "r\\", "k\\"): + return True, [(kind[0], holes)] + if kind == "t": + if c == "\\": + return True, [("t\\", holes)] + if c == "`": + return False, [("c", holes)] + if c == "$" and source.startswith("${", i): + return True, [("$", holes + (0,))] + return True, [("t", holes)] + if kind == "$": # the ``{`` of ``${`` + return True, [("c", holes)] + if kind in ("r", "k"): + if c == "\n": + return True, [] + if c == "\\": + return True, [(kind + "\\", holes)] + if kind == "r" and c == "/": + return False, [("c", holes)] + nxt = {"[": "k"} if kind == "r" else {"]": "r"} + return True, [(nxt.get(c, kind), holes)] + # code + if c in "\"'": + return False, [(c, holes)] + if c == "`": + return False, [("t", holes)] + if c == "/": + regex, division = _slash_readings(source, i) + return False, ([("r", holes)] if regex else []) + ([("c", holes)] if division else []) + if holes and c == "{": + return False, [("c", holes[:-1] + (holes[-1] + 1,))] + if holes and c == "}": + if holes[-1] == 0: + return True, [("t", holes[:-1])] + return False, [("c", holes[:-1] + (holes[-1] - 1,))] + return False, [("c", holes)] + + +def _code_view(source: str) -> str: + """*source* with the text of quoted strings, template literals and regex literals blanked, same + length; a template's ``${…}`` stays code. Where a ``/`` could be either a regex or division, + BOTH readings are followed, and a character is blanked only when every live reading agrees it + is literal text. Since the true reading is always among them, executable code is never blanked + (a wrong guess cannot hide a patch); ambiguity only costs a refusal for manual review.""" + out = list(source) + readings: set[tuple] = {("c", ())} + for i in range(len(source)): + literal_everywhere = True + nxt: set[tuple] = set() + for state in readings: + literal, follow = _step(source, i, state) + literal_everywhere &= literal + nxt.update(follow) + if literal_everywhere and source[i] != "\n": + out[i] = " " + if not nxt or len(nxt) > _MAX_READINGS: # beyond what we can follow: blank nothing more + break + readings = nxt + return "".join(out) + + +def _brace_pairs(code: str) -> dict[int, int]: + """``{`` offset -> matching ``}`` offset in *code* (a :func:`_code_view`).""" + pairs: dict[int, int] = {} + stack: list[int] = [] + for i, ch in enumerate(code): + if ch == "{": + stack.append(i) + elif ch == "}" and stack: + pairs[stack.pop()] = i + return pairs + + +def _enclosing_block_end(pairs: dict[int, int], at: int, size: int) -> int: + """End of the innermost ``{...}`` containing *at* (``size`` at top level).""" + ends = [close for open_, close in pairs.items() if open_ < at < close] + return min(ends) if ends else size + + +def _own_class_write(source: str, name: str, at: int, pairs: dict[int, int]) -> bool: + """The write is inside ``X``'s own body (any declaration form), or follows a ``class X`` / + ``function X`` STATEMENT in the same block; either way with no rebinding of ``X`` in between.""" + escaped = re.escape(name) + rebinding = re.compile(_REBINDING.format(escaped)) + named = re.compile(_OWN_DECLARATION.format(escaped)) + assigned = re.compile(_ASSIGNED_DECLARATION.format(escaped)) + decls = sorted([(d, False) for d in named.finditer(source, 0, at)] + [(d, True) for d in assigned.finditer(source, 0, at)], + key=lambda item: item[0].start()) + for decl, is_assignment in decls: + body = decl.end() - 1 + close = pairs.get(body, -1) + if close > at: + if not rebinding.search(source, body + 1, at): + return True + continue + # A class EXPRESSION's own name is visible only in its body; a statement or ``X = class`` + # binds ``X`` for the rest of its block. + statement = is_assignment or not re.search( + r"[=(,:?&|]\s*$|\breturn\s*$", source[max(0, decl.start() - 20):decl.start()]) + if (statement and close != -1 and _enclosing_block_end(pairs, decl.start(), len(source)) > at + and not rebinding.search(source, close + 1, at)): + return True + return False + + +def _foreign_prototype_writes(source: str) -> list[int]: + """Offsets of ``X.prototype.y =`` that are not provably on ``X``'s own class (see above).""" + code = _code_view(source) # offsets unchanged; string/template text never reads as code + writes = [(m.start(), m.group(1)) for m in _PROTOTYPE_WRITE.finditer(code)] + pairs = _brace_pairs(code) if writes else {} + return [at for at, name in writes + if _RENDERER_GLOBAL.match(name) or not _own_class_write(code, name, at, pairs)] + + def desktop_surface_findings(source: str) -> list[tuple[str, int]]: """Return ``[(rule, line)]`` for every forbidden construct in a plugin.js source.""" stripped = _strip_comments(source) no_regex = _mask_regex_literals(stripped) - findings: list[tuple[str, int]] = [] + findings = [("prototype patching", stripped.count("\n", 0, at) + 1) + for at in _foreign_prototype_writes(stripped)] for rule, pattern in _FORBIDDEN: haystack = no_regex if rule in _MARKUP_RULES else stripped for match in pattern.finditer(haystack): diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 01e11246d3..2dc5bb13c7 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1063,6 +1063,16 @@ def _catalog(): return plugins_cmd_catalog +def _action_update(args): + if args.name: + if getattr(args, "yes", False): + _fail(_console(), "[red]Error:[/red] --yes only applies to `hermes plugins update` with no name.") + cmd_update(args.name) + return + from hermes_cli.plugins_cmd_update_all import cmd_update_all + cmd_update_all(yes=getattr(args, "yes", False)) + + def _action_pack(args): from hermes_cli.plugin_packs import pack_command pack_command(args) @@ -1083,7 +1093,7 @@ _PLUGIN_ACTIONS = { "browse": lambda args: _catalog().cmd_search(""), "validate": lambda args: _catalog().cmd_validate( args.path, as_json=getattr(args, "json", False), install_deps=getattr(args, "install_deps", False)), - "update": lambda args: cmd_update(args.name), + "update": _action_update, "adopt": lambda args: cmd_adopt(args.name), "trust-update-url": lambda args: cmd_trust_update_url(args.name), "check-updates": lambda args: cmd_check_updates(args), diff --git a/hermes_cli/plugins_cmd_catalog.py b/hermes_cli/plugins_cmd_catalog.py index 8ef022a0da..eecd7ae59d 100644 --- a/hermes_cli/plugins_cmd_catalog.py +++ b/hermes_cli/plugins_cmd_catalog.py @@ -497,11 +497,12 @@ class RepinResult(NamedTuple): # new manifest adds (``desktop`` = a Desktop half appeared). Compared as sets — removals are not consent events. _SURFACE_LABELS = {"capabilities": "host capabilities", "tools": "tools", "hooks": "hooks", "python_dependencies": "Python dependencies", "desktop": "Desktop UI half", - "requires_auth": "your sign-in for"} + "requires_auth": "your sign-in for", "oauth": "your OAuth sign-in for"} def plugin_surface(manifest: dict, tree: Path) -> dict[str, set]: """What an installed tree exposes: declared host capabilities, tools, hooks, Python deps, Desktop half.""" + from hermes_cli.plugin_oauth import oauth_surface from hermes_cli.plugins_cmd import _declared_capabilities_from_manifest manifest = manifest or {} @@ -516,6 +517,7 @@ def plugin_surface(manifest: dict, tree: Path) -> dict[str, set]: "capabilities": set(_declared_capabilities_from_manifest(manifest, str(manifest.get("name") or "?"))), "tools": _list("provides_tools"), "hooks": _list("provides_hooks", "hooks"), "python_dependencies": _list("python_dependencies"), "requires_auth": _list("requires_auth"), + "oauth": oauth_surface(manifest), "desktop": {"desktop/plugin.js"} if (tree / "desktop" / "plugin.js").is_file() else set(), } @@ -650,10 +652,11 @@ def repin_catalog_plugin( return RepinResult(entry.sha, True, installed_name, warnings) -def cmd_update_catalog(name: str, target: Path, sidecar: dict, console, *, interactive: bool = True) -> None: +def cmd_update_catalog(name: str, target: Path, sidecar: dict, console, *, interactive: bool = True) -> bool: + """Re-pin a catalog install, printing progress; True when the pin moved. Refusals raise + :class:`RepinConsentRequired` / ``PluginOperationError`` with nothing changed (the caller reports them).""" from hermes_cli.plugins_cmd import ( - PluginOperationError, _ask_yes, _declared_capabilities_from_manifest, _fail, _is_tty, _read_manifest, - _run_capability_consent) + _ask_yes, _declared_capabilities_from_manifest, _is_tty, _read_manifest, _run_capability_consent) console.print(f"[dim]Checking catalog pin for {name}...[/dim]") def _confirm_widening(delta: dict[str, list[str]]) -> bool: @@ -666,19 +669,7 @@ def cmd_update_catalog(name: str, target: Path, sidecar: dict, console, *, inter return False return _ask_yes(" Apply this update? [y/N]: ") - try: - result = repin_catalog_plugin( - target, - sidecar, - interactive=interactive, - consent_cb=_confirm_widening, - ) - except RepinConsentRequired as exc: - _fail(console, f"[yellow]Update of {name} not applied:[/yellow] {exc}") - raise SystemExit(1) - except PluginOperationError as exc: - _fail(console, f"[red]Error:[/red] {exc}") - raise SystemExit(1) + result = repin_catalog_plugin(target, sidecar, interactive=interactive, consent_cb=_confirm_widening) verb = "updated to" if result.changed else "is already at catalog pin" console.print(f"[green]✓[/green] Plugin [bold]{result.installed_name}[/bold] {verb} {result.sha[:8]}.") for warning in result.warnings: @@ -697,6 +688,7 @@ def cmd_update_catalog(name: str, target: Path, sidecar: dict, console, *, inter console.print( f"[yellow]Plugin {result.installed_name} has new capabilities; review them with " f"`hermes plugins capabilities {result.installed_name}`.[/yellow]") + return result.changed diff --git a/hermes_cli/plugins_cmd_remove.py b/hermes_cli/plugins_cmd_remove.py index 4bcdccf844..e3b53a733f 100644 --- a/hermes_cli/plugins_cmd_remove.py +++ b/hermes_cli/plugins_cmd_remove.py @@ -133,8 +133,11 @@ def _remove_user_plugin(plugins_dir: Path, name: str, target: Path) -> dict[str, entry = next((e for e in _pc()._discover_all_plugins() if Path(str(e[4])) == target), None) key = entry[5] if entry else target.name aliases = _pc()._plugin_aliases(key) | {target.name} + from hermes_cli.plugin_oauth import forget_plugin_grants, plugin_key + grant_key = plugin_key(target) # grants are stored by directory key, which the manifest name may not match _pc()._toggle_plugin_toolset(key, enable=False) _remove_plugin_core(target) + forget_plugin_grants(grant_key) # a later plugin installed under the same key must not inherit the sign-in return {"ok": True, "name": name, **_pc()._forget_plugin_config(aliases)} diff --git a/hermes_cli/plugins_cmd_update.py b/hermes_cli/plugins_cmd_update.py index 8ea12530e2..20eb0cc115 100644 --- a/hermes_cli/plugins_cmd_update.py +++ b/hermes_cli/plugins_cmd_update.py @@ -67,28 +67,27 @@ def _reclone_plugin_update(target: Path, source: str, previous_revision: object) return f"Re-installed from {source}: {previous[:8]}..{revision[:8]}" -def cmd_update(name: str, *, interactive: bool = True) -> None: - """Update an installed plugin by pulling latest from its git remote.""" +def update_one(name: str, target: Path, console, *, interactive: bool = True) -> bool: + """Update one installed plugin, printing progress and consent prompts; True when it moved. + + Refusals raise :class:`~hermes_cli.plugins_cmd_catalog.RepinConsentRequired` or + ``PluginOperationError`` with nothing changed; :func:`cmd_update` turns them into exit 1 and + ``hermes plugins update`` with no name records them per plugin and moves on. + """ from rich.markup import escape from hermes_cli import plugins_cmd_catalog as catalog - console = _pc()._console() - target = _pc()._require_installed_plugin(name, _pc()._plugins_dir(), console) sidecar = catalog.catalog_install_record(target) if sidecar: # catalog installs re-pin to the reviewed SHA — never `git pull` - catalog.cmd_update_catalog(name, target, sidecar, console, interactive=interactive) - return - try: - output = _pull_plugin_update( - target, - lambda rec: ( - f"Plugin '{name}' is pinned to {rec.get('revision')}. To move it, run " - f"`hermes plugins install {escape(str(rec.get('source', '')))} --force " - "--ref <40-character commit SHA>`."), - lambda: f"Plugin '{name}' was not installed from git (no .git directory). Cannot update.", - before_pull=lambda: console.print(f"[dim]Updating {name}...[/dim]"), - interactive=interactive) - except _pc().PluginOperationError as exc: - _pc()._fail(console, f"[red]Error:[/red] {exc}") + return catalog.cmd_update_catalog(name, target, sidecar, console, interactive=interactive) + output = _pull_plugin_update( + target, + lambda rec: ( + f"Plugin '{name}' is pinned to {rec.get('revision')}. To move it, run " + f"`hermes plugins install {escape(str(rec.get('source', '')))} --force " + "--ref <40-character commit SHA>`."), + lambda: f"Plugin '{name}' was not installed from git (no .git directory). Cannot update.", + before_pull=lambda: console.print(f"[dim]Updating {name}...[/dim]"), + interactive=interactive) _post_pull_housekeeping(target, console) # Update-time re-consent (#64228): if the new version declares @@ -110,9 +109,23 @@ def cmd_update(name: str, *, interactive: bool = True) -> None: out = output.strip() if "Already up to date" in out: console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] is already up to date.") - else: - console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.") - console.print(f"[dim]{out}[/dim]") + return False + console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.") + console.print(f"[dim]{out}[/dim]") + return True + + +def cmd_update(name: str, *, interactive: bool = True) -> None: + """``hermes plugins update ``: :func:`update_one`, exit 1 on a refusal.""" + from hermes_cli.plugins_cmd_catalog import RepinConsentRequired + console = _pc()._console() + target = _pc()._require_installed_plugin(name, _pc()._plugins_dir(), console) + try: + update_one(name, target, console, interactive=interactive) + except RepinConsentRequired as exc: + _pc()._fail(console, f"[yellow]Update of {name} not applied:[/yellow] {exc}") + except _pc().PluginOperationError as exc: + _pc()._fail(console, f"[red]Error:[/red] {exc}") def _post_pull_housekeeping(target: Path, console) -> None: @@ -269,7 +282,8 @@ def cmd_check_updates(args: Any | None = None) -> None: console.print() console.print(table) console.print() - console.print("[dim]Check-only. Apply with: hermes plugins update [/dim]") + console.print("[dim]Check-only. Apply with: hermes plugins update , or " + "hermes plugins update to update them all[/dim]") def dashboard_update_user_plugin(name: str, *, accept_capabilities: bool = False) -> dict[str, Any]: diff --git a/hermes_cli/plugins_cmd_update_all.py b/hermes_cli/plugins_cmd_update_all.py new file mode 100644 index 0000000000..b1df35b0b3 --- /dev/null +++ b/hermes_cli/plugins_cmd_update_all.py @@ -0,0 +1,148 @@ +"""``hermes plugins update`` with no name: update every installed plugin that has an update. + +The list comes from the read-only ``check-updates`` probe (:func:`hermes_cli.plugins_updates.run_checks`); +each plugin is then updated through the single-plugin +path (:func:`~hermes_cli.plugins_cmd_update.update_one`), so catalog consent, the security scan and +capability re-consent still apply per plugin. + +Sibling of :mod:`hermes_cli.plugins_cmd` (the facade is imported late, never at module level). +""" + +from __future__ import annotations + +import logging +import sys +from dataclasses import dataclass, field +from typing import Any + +# Only these classes have an update path `hermes plugins update ` can drive: a catalog install +# re-pins to the reviewed sha, a tracked git install pulls. A pip row is a PyPI distribution that +# Hermes never upgrades (#132494), so offering it would promise an update nothing applies. +_UPDATE_KIND = {"catalog": "catalog re-pin", "git": "git pull"} +logger = logging.getLogger(__name__) + + +def _pc(): + from hermes_cli import plugins_cmd + return plugins_cmd + + +@dataclass +class UpdatePlan: + """What ``update`` with no name would do: *movable* rows it updates, *skipped* ``(row, reason)`` + pairs it cannot move, *current* the count already up to date.""" + + movable: list[Any] = field(default_factory=list) + skipped: list[tuple[Any, str]] = field(default_factory=list) + current: int = 0 + + +def plan_update_all(results: list[Any]) -> UpdatePlan: + """Split ``run_checks`` rows into the plugins ``update`` can move and the ones it cannot (with why).""" + plan = UpdatePlan() + for row in results: + if row.needs_fixing: + plan.skipped.append((row, f"needs fixing: {row.needs_fixing}")) + elif row.update_available is True and row.klass in _UPDATE_KIND: + plan.movable.append(row) + elif row.update_available is True: + plan.skipped.append((row, f"{row.klass} distribution; Hermes does not upgrade it")) + elif row.update_available is None: + plan.skipped.append((row, row.reason or "update status unknown")) + elif row.reason: # up to date by construction but worth naming (pinned) + plan.skipped.append((row, row.reason)) + else: + plan.current += 1 + return plan + + +def _short(reason: str) -> str: + first = reason.strip().splitlines()[0] if reason.strip() else "" + return first if len(first) <= 200 else first[:197] + "..." + + +def _update_one(row, console, *, interactive: bool) -> tuple[str, str]: + """Run the single-plugin update for one plan row; return ``(status, detail)``, status being + updated/current/failed. A refusal is printed like ``update `` prints it, then recorded.""" + from rich.markup import escape + from hermes_cli.plugins_cmd_catalog import RepinConsentRequired + from hermes_cli.plugins_cmd_update import update_one + target = _pc()._user_installed_plugin_dir(row.name) + if target is None: + return "failed", "no longer installed" + try: + moved = update_one(row.name, target, console, interactive=interactive) + except RepinConsentRequired as exc: + console.print(f"[yellow]Update of {escape(row.name)} not applied:[/yellow] {escape(str(exc))}") + return "failed", _short(f"not applied: {exc}") + except _pc().PluginOperationError as exc: + console.print(f"[red]Error:[/red] {escape(str(exc))}") + return "failed", _short(str(exc)) + except Exception as exc: # per-plugin batch boundary: a feed timeout or crash must not stop the rest + logger.warning("update of plugin %s failed", row.name, exc_info=True) + console.print(f"[red]Error:[/red] {escape(type(exc).__name__)}: {escape(str(exc))}") + return "failed", _short(f"{type(exc).__name__}: {exc}") + if not moved: + return "current", "" + return "updated", f"{(row.current or '')[:8]}..{(row.latest or '')[:8]}" + + +def _print_plan(console, plan: UpdatePlan) -> None: + from rich.markup import escape + table = _pc()._table([("#", "dim"), ("Plugin", "bold"), ("Current", ""), ("Latest", ""), ("Update", "")], + title="Plugin updates available") + for i, row in enumerate(plan.movable, 1): + table.add_row(str(i), row.name, (row.current or "-")[:12], (row.latest or "-")[:12], _UPDATE_KIND[row.klass]) + console.print() + console.print(table) + for row, reason in plan.skipped: + console.print(f" [dim]skipped {escape(row.name)} ({row.klass}): {escape(reason)}[/dim]") + + +def cmd_update_all(*, yes: bool = False) -> None: + """``hermes plugins update [--yes]``: list the plugins with an update and update them all. + + A terminal asks once (``Update all N? [Y/n]``); without one nothing moves unless ``--yes`` is + given. Each plugin then goes through ``update_one``, interactive only when a terminal is present, + so per-plugin consent prompts still appear there and stay closed (fail closed) under ``--yes`` + in scripts. One failure does not stop the rest; exit 1 when any update failed. + """ + from rich.markup import escape + from hermes_cli.plugins_updates import run_checks + + console = _pc()._console() + console.print("[dim]Checking installed plugins for updates...[/dim]") + plan = plan_update_all(run_checks(_pc()._plugins_dir())) + if not plan.movable: + for row, reason in plan.skipped: + console.print(f" [dim]skipped {escape(row.name)} ({row.klass}): {escape(reason)}[/dim]") + console.print("[green]✓[/green] No plugin updates to apply.") + return + _print_plan(console, plan) + count = len(plan.movable) + tty = _pc()._is_tty() + if not yes: + if not tty: + _pc()._fail(console, f"[yellow]Not updating: no terminal to confirm. Re-run with --yes to update " + f"these {count} plugin(s).[/yellow]") + try: + answer = input(f"\nUpdate all {count}? [Y/n]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "n" + if answer not in ("", "y", "yes"): + console.print("Cancelled. Nothing was updated.") + return + + outcomes = [] + for row in plan.movable: + console.print(f"\n[bold]── {row.name}[/bold]") + outcomes.append((row.name, *_update_one(row, console, interactive=tty))) + + console.print("\n[bold]Summary[/bold]") + marks = {"updated": "[green]✓ updated[/green]", "current": "[dim]= already current[/dim]", + "failed": "[red]✗ failed[/red]"} + for name, status, detail in outcomes: + console.print(f" {marks[status]} {escape(name)}" + (f" [dim]{escape(detail)}[/dim]" if detail else ""), + highlight=False) + if any(status == "failed" for _, status, _ in outcomes): + sys.exit(1) diff --git a/hermes_cli/plugins_manifest.py b/hermes_cli/plugins_manifest.py index 576ba20e54..95419c36c6 100644 --- a/hermes_cli/plugins_manifest.py +++ b/hermes_cli/plugins_manifest.py @@ -35,7 +35,7 @@ _KNOWN_MANIFEST_FIELDS: set[str] = { "pip_dependencies", "provides_browser_providers", "provides_web_providers", "manifest_version", "api_version", "requires_plugins", "python_dependencies", "config_schema", "license", "homepage", "tags", "capabilities", "emits", "listens", "hermes", "depends", - "requires_hermes", "python_runtime", "provides_locales", "requires_auth", + "requires_hermes", "python_runtime", "provides_locales", "requires_auth", "oauth", } # Highest manifest schema version this Hermes understands. @@ -126,6 +126,26 @@ def _manifest_int(raw: object, key: str, warn: str, fallback: Optional[int]) -> return fallback +def _config_schema_entries(key: str, raw: Any) -> dict[str, Any]: + """``config_schema`` (key -> {type?, default?, description?, ...}) keeping only mapping entries; + warns on what it drops or cannot type-check. Shared by ``plugin.yaml`` and ``plugin.json``.""" + schema: dict[str, Any] = {} + for skey, spec in (raw if isinstance(raw, Mapping) else {}).items(): + if not isinstance(spec, Mapping): + logger.warning( + "Plugin %s: config_schema entry %r must be a mapping (e.g. {type: str}); skipping", key, skey) + continue + stype = spec.get("type") + if stype is not None and str(stype).lower() not in _CONFIG_SCHEMA_TYPES: + logger.warning( + "Plugin %s: config_schema key %r declares unknown type %r " + "(known: %s); type check will be skipped for it", + key, skey, stype, ", ".join(sorted(_CONFIG_SCHEMA_TYPES)), + ) + schema[str(skey)] = dict(spec) + return schema + + def _parse_manifest_v2_fields(data: Mapping, key: str) -> dict[str, Any]: """Validate/normalize manifest v2 fields into PluginManifest kwargs (warnings, never failures). @@ -153,22 +173,7 @@ def _parse_manifest_v2_fields(data: Mapping, key: str) -> dict[str, Any]: lambda item: item.strip() if isinstance(item, str) and item.strip() else None, "Plugin %s: python_dependencies entry %r must be a non-empty requirement string; skipping", ) - # config_schema — mapping of key -> {type?, default?, description?, required?}. - schema: dict[str, Any] = {} - raw_schema = _manifest_field_of_type(data, key, "config_schema", Mapping, "a mapping") - for skey, spec in (raw_schema or {}).items(): - if not isinstance(spec, Mapping): - logger.warning( - "Plugin %s: config_schema entry %r must be a mapping (e.g. {type: str}); skipping", key, skey) - continue - stype = spec.get("type") - if stype is not None and str(stype).lower() not in _CONFIG_SCHEMA_TYPES: - logger.warning( - "Plugin %s: config_schema key %r declares unknown type %r " - "(known: %s); type check will be skipped for it", - key, skey, stype, ", ".join(sorted(_CONFIG_SCHEMA_TYPES)), - ) - schema[str(skey)] = dict(spec) + schema = _config_schema_entries(key, _manifest_field_of_type(data, key, "config_schema", Mapping, "a mapping")) tags = [str(t) for t in (_manifest_field_of_type(data, key, "tags", list, "a list") or [])] # Forward compat: unknown fields warn (never fail); v1 manifests only at debug. unknown = sorted(set(data.keys()) - _KNOWN_MANIFEST_FIELDS) @@ -488,7 +493,7 @@ def requires_hermes_error(manifest: PluginManifest) -> Optional[str]: def portable_plugin_manifest(child: Path, source: str, prefix: str) -> PluginManifest: """Build the manifest for a portable Agent Plugin directory (``plugin.json``); diagnostics warn.""" - from hermes_cli.agent_plugins import read_agent_plugin_manifest + from hermes_cli.agent_plugins import manifest_config_schema, read_agent_plugin_manifest data, diagnostics = read_agent_plugin_manifest(child) for diagnostic in diagnostics: logger.warning("Agent Plugin '%s': %s", child, diagnostic.message) @@ -497,6 +502,7 @@ def portable_plugin_manifest(child: Path, source: str, prefix: str) -> PluginMan name=data["name"], version=data.get("version", ""), description=data.get("description", ""), author=_display_author(data.get("author", "")), source=source, path=str(child), key=key, portable=True, skill_namespace=_portable_skill_namespace(key), + config_schema=_config_schema_entries(key, manifest_config_schema(data)), ) diff --git a/hermes_cli/plugins_settings.py b/hermes_cli/plugins_settings.py index 4c5350be0b..3230d30346 100644 --- a/hermes_cli/plugins_settings.py +++ b/hermes_cli/plugins_settings.py @@ -36,24 +36,36 @@ _ENV_NAME_CLEAN_RE = re.compile(r"[^A-Z0-9]+") def _manifest_config_schema(plugin_dir: Optional[Path]) -> Mapping[str, Mapping[str, Any]]: - """``config_schema`` mapping from ``/plugin.yaml``; ``{}`` when absent or malformed - (the loader already warned about malformed entries at load time).""" + """``config_schema`` from ``/plugin.yaml``, or for a portable package from + ``plugin.json`` ``extensions.com.nousresearch.hermes.config_schema`` (Agent Plugins v1 forbids + unknown top-level fields, so Hermes-specific keys live in the vendor namespace); ``{}`` when + absent or malformed (the loader already warned about malformed entries at load time).""" if plugin_dir is None: return {} manifest = Path(plugin_dir) / "plugin.yaml" - if not manifest.is_file(): - return {} try: - from utils import fast_safe_load - data = fast_safe_load(manifest.read_text(encoding="utf-8-sig")) or {} + if manifest.is_file(): + from utils import fast_safe_load + data = fast_safe_load(manifest.read_text(encoding="utf-8-sig")) or {} + raw = data.get("config_schema") if isinstance(data, Mapping) else None + else: + raw = _portable_config_schema(Path(plugin_dir)) except Exception as exc: # unreadable manifest: no settings surface, never a failed list - logger.debug("plugin settings: cannot read %s: %s", manifest, exc) + logger.debug("plugin settings: cannot read the manifest in %s: %s", plugin_dir, exc) return {} - raw = data.get("config_schema") if isinstance(data, Mapping) else None if not isinstance(raw, Mapping): return {} return {str(k): v for k, v in raw.items() if isinstance(v, Mapping)} +def _portable_config_schema(plugin_dir: Path) -> Any: + portable = plugin_dir / "plugin.json" + if not portable.is_file(): + return None + import json + from hermes_cli.agent_plugins import manifest_config_schema + return manifest_config_schema(json.loads(portable.read_text(encoding="utf-8-sig"))) + + def secret_env_name(plugin_id: str, key: str, spec: Mapping[str, Any]) -> str: """``.env`` variable a ``type: secret`` field is stored under: the manifest's ``env:`` or diff --git a/hermes_cli/subcommands/plugins.py b/hermes_cli/subcommands/plugins.py index fe703ee56e..1c71fd85bc 100644 --- a/hermes_cli/subcommands/plugins.py +++ b/hermes_cli/subcommands/plugins.py @@ -66,8 +66,12 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None: add_json_flag(plugins_validate, "Print machine-readable JSON (for CI)") plugins_update = plugins_subparsers.add_parser( - "update", help="Pull latest changes for an installed plugin") - plugins_update.add_argument("name", help="Plugin name to update") + "update", help="Update an installed plugin, or with no name every plugin that has an update") + plugins_update.add_argument( + "name", nargs="?", help="Plugin name to update (default: list every plugin with an update and offer them all)") + plugins_update.add_argument( + "--yes", "-y", action="store_true", + help="With no name: update everything listed without the confirmation question (required without a terminal)") plugins_adopt = plugins_subparsers.add_parser( "adopt", diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 708a947207..4f21bfdc66 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -71,6 +71,7 @@ from hermes_cli.web_server_lifecycle import ( _read_bound_port, _report_port_in_use, _start_parent_death_watchdog, + _uvicorn_console_logging, _warm_gateway_module, _write_dashboard_ready_file, _write_machine_sentinel_line, @@ -1019,8 +1020,9 @@ def _build_uvicorn_server(host: str, port: int, *, ssh_isolated: bool = False): served_app = wrap_asgi_with_ws_tracking(app, app.state.ssh_isolated_clients) ping_interval, ping_timeout = TUNNEL_WS_PING_INTERVAL_S, TUNNEL_WS_PING_TIMEOUT_S + _uvicorn_console_logging() # log_config=None: uvicorn's dictConfig would close every live handler config = uvicorn.Config( - served_app, host=host, port=port, log_level="warning", + served_app, host=host, port=port, log_level="warning", log_config=None, # Off by default so _ws_client_is_allowed sees the real peer, not # X-Forwarded-For. Gated mode runs behind a TLS terminator and needs # X-Forwarded-Proto for cookie Secure flags. diff --git a/hermes_cli/web_server_lifecycle.py b/hermes_cli/web_server_lifecycle.py index 6a6d9f96a5..9c904dc05c 100644 --- a/hermes_cli/web_server_lifecycle.py +++ b/hermes_cli/web_server_lifecycle.py @@ -519,6 +519,33 @@ def _dashboard_forwarded_allow_ips(dashboard_config: dict[str, Any]) -> list[str return trusted +def _uvicorn_console_logging() -> None: + """uvicorn's default console logging, without its ``dictConfig``. + + ``uvicorn.Config``'s default ``log_config`` runs ``dictConfig``, which calls + ``logging.shutdown()`` on every live handler: a multiplexed gateway's profile log routers are + already installed when its listener is built, and a closed router stays closed. The listeners + pass ``log_config=None`` and get uvicorn's handlers here instead: ``uvicorn`` to stderr, + ``uvicorn.access`` to stdout, neither propagating into the Hermes log files. + """ + from uvicorn.config import LOGGING_CONFIG + from uvicorn.logging import AccessFormatter, DefaultFormatter + + formats = LOGGING_CONFIG["formatters"] + for name, stream, formatter in ( + ("uvicorn", sys.stderr, DefaultFormatter(formats["default"]["fmt"])), + ("uvicorn.access", sys.stdout, AccessFormatter(formats["access"]["fmt"])), + ): + logger = logging.getLogger(name) + logger.propagate = False + if not logger.handlers: # a second listener in this process keeps the first one's + handler = logging.StreamHandler(stream) + handler.setFormatter(formatter) + logger.addHandler(handler) + # LOGGING_CONFIG's level for the parent; uvicorn.Config sets only the child loggers' levels. + logging.getLogger("uvicorn").setLevel(logging.INFO) + + def build_listener_server(served_app, host: str, port: int, *, auth_required: bool, ws_ping: Optional[tuple[float, float]] = None, timeout_graceful_shutdown: Optional[float] = 3, lifespan: str = "auto"): @@ -551,8 +578,9 @@ def build_listener_server(served_app, host: str, port: int, *, auth_required: bo if ws_ping is None: ws_ping = (None, None) if host in _LOOPBACK_HOST_VALUES else ( _ws_ping_setting("ws_ping_interval"), _ws_ping_setting("ws_ping_timeout")) + _uvicorn_console_logging() config = uvicorn.Config( - served_app, host=host, port=port, log_level="warning", + served_app, host=host, port=port, log_level="warning", log_config=None, # Off by default so _ws_client_is_allowed sees the real peer, not # X-Forwarded-For. Gated mode runs behind a TLS terminator and needs # X-Forwarded-Proto for cookie Secure flags. diff --git a/hermes_state_mutation_guards.py b/hermes_state_mutation_guards.py index e28be9b558..46ed9bdceb 100644 --- a/hermes_state_mutation_guards.py +++ b/hermes_state_mutation_guards.py @@ -9,6 +9,13 @@ from hermes_state_runtime import RuntimeStoreError MUTATION_GUARD_REFUSALS = (SessionTurnLeaseLostError, SessionCompressionInProgressError) +def busy_refusal(exc): + """The client-facing refusal for one of ``MUTATION_GUARD_REFUSALS``. A held compression lock + is named in ``field``: Stop does not end it, so clients say to wait instead.""" + compressing = isinstance(exc, SessionCompressionInProgressError) + return RuntimeStoreError('session_busy', field='compression' if compressing else None) + + def require_not_executing(conn, session_ids): """Refuse while a turn is running (or its outcome is unknown) on any of ``session_ids``. Queued admissions are allowed: a follower waits on the logical owner and simply runs diff --git a/hermes_state_runtime.py b/hermes_state_runtime.py index c55311acb5..7ebad76355 100644 --- a/hermes_state_runtime.py +++ b/hermes_state_runtime.py @@ -227,12 +227,20 @@ def claim_session_input(db, *, epoch: int, session_id: str, _guard=None, head=_A changed = conn.execute("UPDATE session_admissions SET status='started',owner_epoch=?,generation=? WHERE admission_id=? AND status='queued'", (epoch, generation, row['admission_id'])) if changed.rowcount != 1: raise RuntimeStoreError('stale_generation') - return _row(_admission(conn, row['admission_id'])) + claimed = _row(_admission(conn, row['admission_id'])) + # The transcript high-water mark at the claim (``messages.id`` is AUTOINCREMENT across every + # session): a row above it on this conversation was written by this turn, so settlement can + # tell this turn's own failed-turn boundary from an earlier turn's (#134889 GW-21). + claimed['transcript_mark'] = conn.execute('SELECT COALESCE(MAX(id), 0) FROM messages').fetchone()[0] + return claimed return db._execute_write(write) def settle_session_input(db, *, epoch: int, admission_id: str, generation: int, outcome: str, - result: dict | None = None, _transfers=None) -> dict: + result: dict | None = None, _transfers=None, _terminal_write=None) -> dict: + """``_terminal_write(conn, row)`` is the owner's same-transaction effect (a transcript notice, + ``gateway.session_results.finish_result``); like ``resolve_unknown_session_input``'s it must + not commit, may run again on SQLite retry, and raising rolls the settlement back.""" if outcome not in ('completed', 'interrupted', 'rejected', 'failed'): raise RuntimeStoreError('invalid_params') from hermes_state_terminal import compact_result, encode_result @@ -245,6 +253,8 @@ def settle_session_input(db, *, epoch: int, admission_id: str, generation: int, or session['runtime_generation'] != generation): raise RuntimeStoreError('stale_generation') _retire_admission_workers(conn, row, epoch) + if _terminal_write is not None: + _terminal_write(conn, row) if result is not None: from hermes_state_terminal import RESULT_PREFIX encoded = _json(encode_result(compact_result(result, user_message=json.loads(row['payload_json']).get('text')))) diff --git a/locales/_keys.desktop.json b/locales/_keys.desktop.json index 169b308837..aa6155a398 100644 --- a/locales/_keys.desktop.json +++ b/locales/_keys.desktop.json @@ -2481,6 +2481,8 @@ "promptNotices.mcpReloadDeferred", "promptNotices.mcpReloadNotInChat", "promptNotices.mcpReloadUnavailable", + "promptNotices.sessionCommands.busy", + "promptNotices.sessionCommands.busyCompressing", "promptNotices.sessionCommands.countOne", "promptNotices.sessionCommands.countOther", "promptNotices.sessionCommands.queueEditKept", diff --git a/locales/_keys.tui.json b/locales/_keys.tui.json index fa59441f0b..0589427d4b 100644 --- a/locales/_keys.tui.json +++ b/locales/_keys.tui.json @@ -806,6 +806,8 @@ "slashCmd.core.battery.statusLive", "slashCmd.core.battery.statusNoBattery", "slashCmd.core.battery.usage", + "slashCmd.core.busy.compressing", + "slashCmd.core.busy.replying", "slashCmd.core.clear.cancelLabel", "slashCmd.core.clear.confirmClear", "slashCmd.core.clear.confirmNew", diff --git a/locales/af.tui.yaml b/locales/af.tui.yaml index eb7d545286..b0b15ec9c4 100644 --- a/locales/af.tui.yaml +++ b/locales/af.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "batteryaanwyser {0}" slashCmd.core.battery.statusLive: "batteryaanwyser {0} — tans {1} {2}%" slashCmd.core.battery.statusNoBattery: "batteryaanwyser {0} — geen battery op hierdie masjien bespeur nie" slashCmd.core.battery.usage: "gebruik: /battery [on|off|status]" +slashCmd.core.busy.compressing: "sessie besig — 'n /compress loop; voer /{0} uit wanneer dit klaar is." +slashCmd.core.busy.replying: "sessie besig — Hermes antwoord nog. Stop eers die huidige antwoord (Stop-knoppie, of Ctrl+C in 'n terminaal), en voer dan /{0} uit." slashCmd.core.clear.cancelLabel: "Nee, gaan voort" slashCmd.core.clear.confirmClear: "Ja, maak die sessie skoon" slashCmd.core.clear.confirmNew: "Ja, begin 'n nuwe sessie" diff --git a/locales/af.yaml b/locales/af.yaml index 12f7e9f2d1..37755158d4 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "Verlaat die CLI (gebruik --delete om ook sessiegeskiedenis te verwyder)" cli: + modern: + thought_for: "Het {seconds}s gedink" + thought: "Het gedink" + working: "Besig…" + preparing: "Berei {what} voor…" + hint_steer: "stuur" + hint_queue: "in tou" + hint_interrupt: "onderbreek" + hint_stop: "stop" + hint_commands: "opdragte" + hint_files: "lêers" + hint_shell: "dop" + placeholder_busy_steer: "tik om hierdie beurt te stuur · Tab plaas dit in die tou vir daarna" + placeholder_busy_interrupt: "tik om hierdie beurt te herlei · Tab plaas dit in die tou vir daarna" + placeholder_busy_queue: "tik om 'n boodskap vir ná hierdie beurt in die tou te plaas" billing: load_failed: "{label}: {error}" load_failed_label: "Kon nie faktureringsinligting laai nie" @@ -2232,6 +2247,7 @@ cli: queue_heading: "Tou · {count} in tou · /queue list{controls}" more_queued: "+{count} meer" transcript_unavailable: "Lewendige transkripsie is nog nie beskikbaar nie." + control_pending: "Stuur tans…" steer_prompt: "Stuur: " roster_empty: "Geen lewendige subagente of agtergrondprosesse nie. Resultate kom in die gesprek aan." entry_gone: "Hierdie inskrywing is nie meer lewendig nie." @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "Tou-verplasing buite bereik. Huidige grootte: {size}" moved: "Tou-item {source} na {destination} verplaas." usage_add: "Gebruik: /queue add " + gateway_refused: "Die gateway het dit nie in die tou geplaas nie: {error}. Niks is in die tou geplaas nie; stuur dit weer wanneer die beurt eindig." + submit_unknown: "Die gateway het nie geantwoord nie ({error}), so die opdrag is dalk reeds in die tou. Kyk na /queue list voordat jy dit weer stuur." + move_gateway_fifo: "Die gateway voer toustaande prompts uit in die volgorde waarin dit gestuur is; wysig of verwyder eerder een." + remove_refused: "Kon nie tou-item {index} verwyder nie: {error}" + edit_refused: "Kon nie tou-item {index} wysig nie: {error}" + already_started: "Tou-item {index} het reeds begin; dit loop soos dit gestuur is." + started_stopped: "Tou-item {index} het reeds begin; die gateway het dit gestop." + change_unknown: "Die gateway het nie oor tou-item {index} geantwoord nie ({error}); kyk na /queue list voordat jy weer probeer." reload: env_reloaded: ".env herlaai ({count} veranderlike(s) opgedateer)" steer: diff --git a/locales/ar.tui.yaml b/locales/ar.tui.yaml index e6cbb7316a..4722c9148c 100644 --- a/locales/ar.tui.yaml +++ b/locales/ar.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "مؤشر البطارية {0}" slashCmd.core.battery.statusLive: "مؤشر البطارية {0} — حاليًا {1} {2}%" slashCmd.core.battery.statusNoBattery: "مؤشر البطارية {0} — لم تُرصد بطارية في هذا الجهاز" slashCmd.core.battery.usage: "الاستخدام: /battery [on|off|status]" +slashCmd.core.busy.compressing: "الجلسة مشغولة — يجري تنفيذ /compress؛ شغّل /{0} عند انتهائه." +slashCmd.core.busy.replying: "الجلسة مشغولة — ما زال Hermes يرد. أوقف الرد الحالي أولاً (زر الإيقاف، أو Ctrl+C في الطرفية)، ثم شغّل /{0}." slashCmd.core.clear.cancelLabel: "لا، تابع" slashCmd.core.clear.confirmClear: "نعم، امسح الجلسة" slashCmd.core.clear.confirmNew: "نعم، ابدأ جلسة جديدة" diff --git a/locales/ar.yaml b/locales/ar.yaml index 252ac98aa2..02cdba7fe1 100644 --- a/locales/ar.yaml +++ b/locales/ar.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "الخروج من واجهة سطر الأوامر (استخدم --delete لحذف سجل الجلسة أيضًا)" cli: + modern: + thought_for: "فكّر لمدة {seconds} ث" + thought: "فكّر" + working: "يعمل…" + preparing: "جارٍ تحضير {what}…" + hint_steer: "توجيه" + hint_queue: "إضافة للطابور" + hint_interrupt: "مقاطعة" + hint_stop: "إيقاف" + hint_commands: "الأوامر" + hint_files: "الملفات" + hint_shell: "الطرفية" + placeholder_busy_steer: "اكتب لتوجيه هذه الجولة · Tab يضيفها للطابور لما بعد" + placeholder_busy_interrupt: "اكتب لإعادة توجيه هذه الجولة · Tab يضيفها للطابور لما بعد" + placeholder_busy_queue: "اكتب لإضافة رسالة إلى الطابور بعد هذه الجولة" billing: load_failed: "{label}: {error}" load_failed_label: "تعذّر تحميل الفوترة" @@ -2232,6 +2247,7 @@ cli: queue_heading: "قائمة الانتظار · {count} في الانتظار · /queue list{controls}" more_queued: "+{count} أخرى" transcript_unavailable: "النص المباشر غير متاح بعد." + control_pending: "جارٍ الإرسال…" steer_prompt: "توجيه: " roster_empty: "لا وكلاء فرعيين نشطين ولا عمليات في الخلفية. تصل النتائج في المحادثة." entry_gone: "هذا العنصر لم يعد نشطًا." @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "النقل خارج نطاق قائمة الانتظار. الحجم الحالي: {size}" moved: "نُقل عنصر قائمة الانتظار من {source} إلى {destination}." usage_add: "الاستخدام: /queue add " + gateway_refused: "لم تضعه البوابة في الطابور: {error}. لم يُضف شيء؛ أرسله مرة أخرى عند انتهاء الدور." + submit_unknown: "لم تُجب البوابة ({error})، لذا قد تكون الرسالة في الطابور بالفعل. راجع /queue list قبل إرسالها مجددًا." + move_gateway_fifo: "تشغّل البوابة المطالبات في الطابور بترتيب إرسالها؛ عدّل واحدة أو احذفها بدلاً من ذلك." + remove_refused: "تعذّرت إزالة عنصر الطابور {index}: {error}" + edit_refused: "تعذّر تعديل عنصر الطابور {index}: {error}" + already_started: "بدأ عنصر الطابور {index} بالفعل؛ سيُنفَّذ كما أُرسل." + started_stopped: "كان عنصر الطابور {index} قد بدأ بالفعل؛ أوقفته البوابة." + change_unknown: "لم تُجب البوابة بشأن عنصر الطابور {index} ({error})؛ راجع /queue list قبل المحاولة مجددًا." reload: env_reloaded: "أُعيد تحميل .env (تم تحديث {count} متغيّر)" steer: diff --git a/locales/de.tui.yaml b/locales/de.tui.yaml index 378ae2789a..305cce641f 100644 --- a/locales/de.tui.yaml +++ b/locales/de.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "Akkuanzeige {0}" slashCmd.core.battery.statusLive: "Akkuanzeige {0} — aktuell {1} {2}%" slashCmd.core.battery.statusNoBattery: "Akkuanzeige {0} — kein Akku auf diesem Gerät erkannt" slashCmd.core.battery.usage: "Verwendung: /battery [on|off|status]" +slashCmd.core.busy.compressing: "Sitzung beschäftigt — ein /compress läuft; führe /{0} aus, wenn es fertig ist." +slashCmd.core.busy.replying: "Sitzung beschäftigt — Hermes antwortet noch. Beende zuerst die aktuelle Antwort (Stopp-Taste oder Strg+C im Terminal), dann führe /{0} aus." slashCmd.core.clear.cancelLabel: "Nein, weitermachen" slashCmd.core.clear.confirmClear: "Ja, Sitzung leeren" slashCmd.core.clear.confirmNew: "Ja, neue Sitzung starten" diff --git a/locales/de.yaml b/locales/de.yaml index b7e520d02b..4889d47996 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "CLI beenden (--delete entfernt zusätzlich den Sitzungsverlauf)" cli: + modern: + thought_for: "Nachgedacht für {seconds}s" + thought: "Nachgedacht" + working: "Arbeitet…" + preparing: "Bereite {what} vor…" + hint_steer: "lenken" + hint_queue: "einreihen" + hint_interrupt: "unterbrechen" + hint_stop: "stoppen" + hint_commands: "Befehle" + hint_files: "Dateien" + hint_shell: "Shell" + placeholder_busy_steer: "tippen, um diesen Zug zu lenken · Tab reiht für danach ein" + placeholder_busy_interrupt: "tippen, um diesen Zug umzulenken · Tab reiht für danach ein" + placeholder_busy_queue: "tippen, um eine Nachricht für nach diesem Zug einzureihen" billing: load_failed: "{label}: {error}" load_failed_label: "Abrechnung konnte nicht geladen werden" @@ -2232,6 +2247,7 @@ cli: queue_heading: "Warteschlange · {count} wartend · /queue list{controls}" more_queued: "+{count} weitere" transcript_unavailable: "Live-Transkript noch nicht verfügbar." + control_pending: "Wird gesendet…" steer_prompt: "Steuern: " roster_empty: "Keine aktiven Subagenten oder Hintergrundprozesse. Ergebnisse erscheinen in der Konversation." entry_gone: "Dieser Eintrag ist nicht mehr aktiv." @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "Verschiebung außerhalb der Warteschlange. Aktuelle Größe: {size}" moved: "Warteschlangeneintrag {source} nach {destination} verschoben." usage_add: "Verwendung: /queue add " + gateway_refused: "Das Gateway hat es nicht eingereiht: {error}. Nichts wurde eingereiht; sende es erneut, wenn der Zug endet." + submit_unknown: "Das Gateway hat nicht geantwortet ({error}), der Prompt ist also vielleicht schon eingereiht. Prüfe /queue list, bevor du ihn erneut sendest." + move_gateway_fifo: "Das Gateway führt eingereihte Prompts in Sendereihenfolge aus; bearbeite oder entferne stattdessen einen." + remove_refused: "Warteschlangeneintrag {index} konnte nicht entfernt werden: {error}" + edit_refused: "Warteschlangeneintrag {index} konnte nicht bearbeitet werden: {error}" + already_started: "Warteschlangeneintrag {index} hat bereits begonnen; er läuft wie gesendet." + started_stopped: "Warteschlangeneintrag {index} hatte bereits begonnen; das Gateway hat ihn gestoppt." + change_unknown: "Das Gateway hat zu Warteschlangeneintrag {index} nicht geantwortet ({error}); prüfe /queue list, bevor du es erneut versuchst." reload: env_reloaded: ".env neu geladen ({count} Variable(n) aktualisiert)" steer: diff --git a/locales/en.yaml b/locales/en.yaml index b1ed6a7e01..334b4f6ffc 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -1760,6 +1760,21 @@ slash: quit: description: "Exit the CLI (use --delete to also remove session history)" cli: + modern: + thought_for: "Thought for {seconds}s" + thought: "Thought" + working: "Working…" + preparing: "Preparing {what}…" + hint_steer: "steer" + hint_queue: "queue" + hint_interrupt: "interrupt" + hint_stop: "stop" + hint_commands: "commands" + hint_files: "files" + hint_shell: "shell" + placeholder_busy_steer: "type to steer this turn · Tab queues it for after" + placeholder_busy_interrupt: "type to redirect this turn · Tab queues it for after" + placeholder_busy_queue: "type to queue a message for after this turn" billing: # shared helpers (/topup + /subscription) load_failed: "{label}: {error}" @@ -2331,6 +2346,7 @@ cli: queue_heading: "Queue · {count} queued · /queue list{controls}" more_queued: "+{count} more" transcript_unavailable: "Live transcript not available yet." + control_pending: "Sending…" steer_prompt: "Steer: " roster_empty: "No live subagents or background processes. Results arrive in the conversation." entry_gone: "This entry is no longer live." @@ -2470,6 +2486,14 @@ cli: move_out_of_range: "Queue move out of range. Current size: {size}" moved: "Moved queue item {source} to {destination}." usage_add: "Usage: /queue add " + gateway_refused: "The gateway did not queue it: {error}. Nothing was queued; send it again when the turn ends." + submit_unknown: "The gateway did not answer ({error}), so the prompt may already be queued. Check /queue list before sending it again." + move_gateway_fifo: "The gateway runs queued prompts in the order they were sent; edit or remove one instead." + remove_refused: "Could not remove queue item {index}: {error}" + edit_refused: "Could not edit queue item {index}: {error}" + already_started: "Queue item {index} already started; it runs as it was sent." + started_stopped: "Queue item {index} had already started; the gateway stopped it." + change_unknown: "The gateway did not answer about queue item {index} ({error}); check /queue list before trying again." reload: env_reloaded: "Reloaded .env ({count} var(s) updated)" steer: diff --git a/locales/es.tui.yaml b/locales/es.tui.yaml index db50d78f04..69ee4e34ca 100644 --- a/locales/es.tui.yaml +++ b/locales/es.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "indicador de batería {0}" slashCmd.core.battery.statusLive: "indicador de batería {0} — ahora {1} {2}%" slashCmd.core.battery.statusNoBattery: "indicador de batería {0} — no se detectó batería en este equipo" slashCmd.core.battery.usage: "uso: /battery [on|off|status]" +slashCmd.core.busy.compressing: "sesión ocupada — se está ejecutando un /compress; ejecuta /{0} cuando termine." +slashCmd.core.busy.replying: "sesión ocupada — Hermes sigue respondiendo. Detén primero la respuesta actual (botón Detener o Ctrl+C en una terminal) y luego ejecuta /{0}." slashCmd.core.clear.cancelLabel: "No, seguir aquí" slashCmd.core.clear.confirmClear: "Sí, limpiar la sesión" slashCmd.core.clear.confirmNew: "Sí, iniciar una sesión nueva" diff --git a/locales/es.yaml b/locales/es.yaml index 2d959af696..c8881a0bd4 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "Salir de la CLI (usa --delete para eliminar también el historial de la sesión)" cli: + modern: + thought_for: "Pensó durante {seconds}s" + thought: "Pensó" + working: "Trabajando…" + preparing: "Preparando {what}…" + hint_steer: "guiar" + hint_queue: "encolar" + hint_interrupt: "interrumpir" + hint_stop: "detener" + hint_commands: "comandos" + hint_files: "archivos" + hint_shell: "shell" + placeholder_busy_steer: "escribe para guiar este turno · Tab lo encola para después" + placeholder_busy_interrupt: "escribe para redirigir este turno · Tab lo encola para después" + placeholder_busy_queue: "escribe para encolar un mensaje para después de este turno" billing: load_failed: "{label}: {error}" load_failed_label: "No se pudo cargar la facturación" @@ -2232,6 +2247,7 @@ cli: queue_heading: "Cola · {count} en cola · /queue list{controls}" more_queued: "+{count} más" transcript_unavailable: "La transcripción en vivo aún no está disponible." + control_pending: "Enviando…" steer_prompt: "Redirigir: " roster_empty: "No hay subagentes activos ni procesos en segundo plano. Los resultados llegan a la conversación." entry_gone: "Esta entrada ya no está activa." @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "Movimiento fuera de rango en la cola. Tamaño actual: {size}" moved: "Elemento {source} de la cola movido a {destination}." usage_add: "Uso: /queue add " + gateway_refused: "El gateway no lo encoló: {error}. No se encoló nada; envíalo de nuevo cuando termine el turno." + submit_unknown: "El gateway no respondió ({error}), así que el prompt puede estar ya en la cola. Revisa /queue list antes de enviarlo de nuevo." + move_gateway_fifo: "El gateway ejecuta los prompts en cola en el orden de envío; edita o elimina uno en su lugar." + remove_refused: "No se pudo quitar el elemento {index} de la cola: {error}" + edit_refused: "No se pudo editar el elemento {index} de la cola: {error}" + already_started: "El elemento {index} de la cola ya empezó; se ejecuta tal como se envió." + started_stopped: "El elemento {index} de la cola ya había empezado; el gateway lo detuvo." + change_unknown: "El gateway no respondió sobre el elemento {index} de la cola ({error}); revisa /queue list antes de reintentarlo." reload: env_reloaded: "Se recargó .env ({count} variable(s) actualizada(s))" steer: diff --git a/locales/fr.tui.yaml b/locales/fr.tui.yaml index b573390db7..cba2ad8787 100644 --- a/locales/fr.tui.yaml +++ b/locales/fr.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "indicateur de batterie {0}" slashCmd.core.battery.statusLive: "indicateur de batterie {0} — actuellement {1} {2} %" slashCmd.core.battery.statusNoBattery: "indicateur de batterie {0} — aucune batterie détectée sur cette machine" slashCmd.core.battery.usage: "usage : /battery [on|off|status]" +slashCmd.core.busy.compressing: "session occupée — un /compress est en cours ; lancez /{0} quand il sera terminé." +slashCmd.core.busy.replying: "session occupée — Hermes est encore en train de répondre. Arrêtez d’abord la réponse en cours (bouton Arrêter ou Ctrl+C dans un terminal), puis lancez /{0}." slashCmd.core.clear.cancelLabel: "Non, continuer" slashCmd.core.clear.confirmClear: "Oui, effacer la session" slashCmd.core.clear.confirmNew: "Oui, démarrer une nouvelle session" diff --git a/locales/fr.yaml b/locales/fr.yaml index c390af1d2f..85651d7a69 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -1941,6 +1941,21 @@ slash: quit: description: "Quitter la CLI (--delete supprime aussi l'historique de session)" cli: + modern: + thought_for: "Réflexion pendant {seconds}s" + thought: "Réflexion" + working: "En cours…" + preparing: "Préparation de {what}…" + hint_steer: "orienter" + hint_queue: "mettre en file" + hint_interrupt: "interrompre" + hint_stop: "arrêter" + hint_commands: "commandes" + hint_files: "fichiers" + hint_shell: "shell" + placeholder_busy_steer: "tapez pour orienter ce tour · Tab le met en file pour après" + placeholder_busy_interrupt: "tapez pour rediriger ce tour · Tab le met en file pour après" + placeholder_busy_queue: "tapez pour mettre un message en file après ce tour" billing: load_failed: "{label} : {error}" load_failed_label: "Facturation indisponible" @@ -2505,6 +2520,7 @@ cli: queue_heading: "File d'attente · {count} en file · /queue list{controls}" more_queued: "+{count} de plus" transcript_unavailable: "Transcription en direct pas encore disponible." + control_pending: "Envoi…" steer_prompt: "Orienter : " roster_empty: "Aucun sous-agent ni processus en arrière-plan en cours. Les résultats arrivent dans la conversation." entry_gone: "Cette entrée n'est plus active." @@ -2643,6 +2659,14 @@ cli: move_out_of_range: "Déplacement hors limites de la file. Taille actuelle : {size}" moved: "Élément {source} de la file déplacé vers {destination}." usage_add: "Usage : /queue add " + gateway_refused: "Le gateway ne l'a pas mis en file : {error}. Rien n'a été mis en file ; renvoyez-le à la fin du tour." + submit_unknown: "Le gateway n'a pas répondu ({error}) ; le prompt est peut-être déjà en file. Vérifiez /queue list avant de le renvoyer." + move_gateway_fifo: "Le gateway exécute les prompts en file dans l'ordre d'envoi ; modifiez ou supprimez-en un à la place." + remove_refused: "Impossible de retirer l'élément {index} de la file : {error}" + edit_refused: "Impossible de modifier l'élément {index} de la file : {error}" + already_started: "L'élément {index} de la file a déjà démarré ; il s'exécute tel qu'envoyé." + started_stopped: "L'élément {index} de la file avait déjà démarré ; le gateway l'a arrêté." + change_unknown: "Le gateway n'a pas répondu pour l'élément {index} de la file ({error}) ; vérifiez /queue list avant de réessayer." reload: env_reloaded: ".env rechargé ({count} variable(s) mise(s) à jour)" steer: diff --git a/locales/ga.tui.yaml b/locales/ga.tui.yaml index c94c0b2d20..28e8286d25 100644 --- a/locales/ga.tui.yaml +++ b/locales/ga.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "táscaire ceallra {0}" slashCmd.core.battery.statusLive: "táscaire ceallra {0} — {1} {2}% faoi láthair" slashCmd.core.battery.statusNoBattery: "táscaire ceallra {0} — níor braitheadh aon cheallra ar an ríomhaire seo" slashCmd.core.battery.usage: "úsáid: /battery [on|off|status]" +slashCmd.core.busy.compressing: "seisiún gnóthach — tá /compress ar siúl; rith /{0} nuair a bheidh sé críochnaithe." +slashCmd.core.busy.replying: "seisiún gnóthach — tá Hermes fós ag freagairt. Stop an freagra reatha ar dtús (cnaipe Stop, nó Ctrl+C i dteirminéal), ansin rith /{0}." slashCmd.core.clear.cancelLabel: "Níl, lean ar aghaidh" slashCmd.core.clear.confirmClear: "Tá, glan an seisiún" slashCmd.core.clear.confirmNew: "Tá, tosaigh seisiún nua" diff --git a/locales/ga.yaml b/locales/ga.yaml index 0dffd22baa..c26a5050fa 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "Scoir den CLI (úsáid --delete chun stair an tseisiúin a bhaint freisin)" cli: + modern: + thought_for: "Smaoinigh ar feadh {seconds}s" + thought: "Smaoinigh" + working: "Ag obair…" + preparing: "{what} á ullmhú…" + hint_steer: "stiúir" + hint_queue: "cuir sa scuaine" + hint_interrupt: "cuir isteach air" + hint_stop: "stop" + hint_commands: "orduithe" + hint_files: "comhaid" + hint_shell: "blaosc" + placeholder_busy_steer: "clóscríobh chun an seal seo a stiúradh · cuireann Tab sa scuaine é" + placeholder_busy_interrupt: "clóscríobh chun an seal seo a atreorú · cuireann Tab sa scuaine é" + placeholder_busy_queue: "clóscríobh chun teachtaireacht a chur sa scuaine don tréimhse ina dhiaidh" billing: load_failed: "{label}: {error}" load_failed_label: "Níorbh fhéidir an bhilleáil a lódáil" @@ -2232,6 +2247,7 @@ cli: queue_heading: "Scuaine · {count} i scuaine · /queue list{controls}" more_queued: "+{count} eile" transcript_unavailable: "Níl an traschríbhinn bheo ar fáil fós." + control_pending: "Á sheoladh…" steer_prompt: "Stiúir: " roster_empty: "Níl aon fho-ghníomhairí beo ná próisis chúlra ann. Tagann na torthaí isteach sa chomhrá." entry_gone: "Níl an iontráil seo beo a thuilleadh." @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "Bogadh scuaine as raon. Méid reatha: {size}" moved: "Bogadh mír scuaine {source} go {destination}." usage_add: "Úsáid: /queue add " + gateway_refused: "Níor chuir an geata sa scuaine é: {error}. Níor cuireadh aon rud sa scuaine; seol arís é nuair a chríochnaíonn an seal." + submit_unknown: "Níor fhreagair an geata ({error}), mar sin seans go bhfuil an leid sa scuaine cheana. Seiceáil /queue list sula seolann tú arís í." + move_gateway_fifo: "Ritheann an geata leideanna sa scuaine san ord inar seoladh iad; cuir ceann in eagar nó bain é ina ionad." + remove_refused: "Níorbh fhéidir mír {index} a bhaint den scuaine: {error}" + edit_refused: "Níorbh fhéidir mír {index} den scuaine a chur in eagar: {error}" + already_started: "Tá mír {index} den scuaine tosaithe cheana; ritheann sí mar a seoladh í." + started_stopped: "Bhí mír {index} den scuaine tosaithe cheana; stop an geata í." + change_unknown: "Níor fhreagair an geata faoi mhír {index} den scuaine ({error}); seiceáil /queue list sula ndéanann tú iarracht arís." reload: env_reloaded: "Athlódáladh .env ({count} athróg nuashonraithe)" steer: diff --git a/locales/hu.tui.yaml b/locales/hu.tui.yaml index b547c82291..c6ff9f1829 100644 --- a/locales/hu.tui.yaml +++ b/locales/hu.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "akkumulátorjelző {0}" slashCmd.core.battery.statusLive: "akkumulátorjelző {0} — jelenleg {1} {2}%" slashCmd.core.battery.statusNoBattery: "akkumulátorjelző {0} — ezen a gépen nem található akkumulátor" slashCmd.core.battery.usage: "használat: /battery [on|off|status]" +slashCmd.core.busy.compressing: "a munkamenet foglalt — egy /compress fut; futtasd a /{0} parancsot, ha befejeződött." +slashCmd.core.busy.replying: "a munkamenet foglalt — a Hermes még válaszol. Előbb állítsd le az aktuális választ (Stop gomb vagy Ctrl+C a terminálban), majd futtasd a /{0} parancsot." slashCmd.core.clear.cancelLabel: "Nem, folytatom" slashCmd.core.clear.confirmClear: "Igen, munkamenet törlése" slashCmd.core.clear.confirmNew: "Igen, új munkamenet indítása" diff --git a/locales/hu.yaml b/locales/hu.yaml index 7482e4d7a6..d34503a2c8 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -1706,6 +1706,21 @@ slash: quit: description: "Kilépés a CLI-ből (a --delete a munkamenet előzményeit is törli)" cli: + modern: + thought_for: "{seconds} mp-ig gondolkodott" + thought: "Gondolkodott" + working: "Dolgozik…" + preparing: "{what} előkészítése…" + hint_steer: "terelés" + hint_queue: "sorba" + hint_interrupt: "megszakítás" + hint_stop: "leállítás" + hint_commands: "parancsok" + hint_files: "fájlok" + hint_shell: "shell" + placeholder_busy_steer: "írj, hogy tereld ezt a kört · Tab sorba állítja utánra" + placeholder_busy_interrupt: "írj, hogy átirányítsd ezt a kört · Tab sorba állítja utánra" + placeholder_busy_queue: "írj, hogy üzenetet állíts sorba e kör utánra" billing: load_failed: "{label}: {error}" load_failed_label: "Nem sikerült betölteni a számlázást" @@ -2249,6 +2264,7 @@ cli: queue_heading: "Sor · {count} sorban · /queue list{controls}" more_queued: "+{count} további" transcript_unavailable: "Élő átirat még nem érhető el." + control_pending: "Küldés…" steer_prompt: "Irányítás: " roster_empty: "Nincs élő alügynök vagy háttérfolyamat. Az eredmények a beszélgetésben érkeznek." entry_gone: "Ez a bejegyzés már nem élő." @@ -2387,6 +2403,14 @@ cli: move_out_of_range: "A mozgatás kívül esik a soron. Jelenlegi méret: {size}" moved: "A(z) {source}. sorelem áthelyezve ide: {destination}." usage_add: "Használat: /queue add " + gateway_refused: "Az átjáró nem állította sorba: {error}. Semmi sem került sorba; küldd újra, amikor a kör véget ér." + submit_unknown: "Az átjáró nem válaszolt ({error}), így lehet, hogy a prompt már sorban van. Nézd meg a /queue list kimenetét, mielőtt újra elküldöd." + move_gateway_fifo: "Az átjáró a sorban álló promptokat küldési sorrendben futtatja; inkább szerkessz vagy távolíts el egyet." + remove_refused: "Nem sikerült eltávolítani a(z) {index}. sorelemet: {error}" + edit_refused: "Nem sikerült szerkeszteni a(z) {index}. sorelemet: {error}" + already_started: "A(z) {index}. sorelem már elindult; úgy fut, ahogy elküldték." + started_stopped: "A(z) {index}. sorelem már elindult; az átjáró leállította." + change_unknown: "Az átjáró nem válaszolt a(z) {index}. sorelemről ({error}); nézd meg a /queue list kimenetét, mielőtt újra próbálod." reload: env_reloaded: ".env újratöltve ({count} változó frissült)" steer: diff --git a/locales/it.tui.yaml b/locales/it.tui.yaml index a8bd02227c..ff79cbaacb 100644 --- a/locales/it.tui.yaml +++ b/locales/it.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "indicatore batteria {0}" slashCmd.core.battery.statusLive: "indicatore batteria {0} — attualmente {1} {2}%" slashCmd.core.battery.statusNoBattery: "indicatore batteria {0} — nessuna batteria rilevata su questa macchina" slashCmd.core.battery.usage: "uso: /battery [on|off|status]" +slashCmd.core.busy.compressing: "sessione occupata — è in corso un /compress; esegui /{0} quando termina." +slashCmd.core.busy.replying: "sessione occupata — Hermes sta ancora rispondendo. Interrompi prima la risposta in corso (pulsante Stop o Ctrl+C in un terminale), poi esegui /{0}." slashCmd.core.clear.cancelLabel: "No, continua" slashCmd.core.clear.confirmClear: "Sì, svuota la sessione" slashCmd.core.clear.confirmNew: "Sì, avvia una nuova sessione" diff --git a/locales/it.yaml b/locales/it.yaml index 968974b505..4c821ba200 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "Esci dalla CLI (usa --delete per eliminare anche la cronologia della sessione)" cli: + modern: + thought_for: "Ha pensato per {seconds}s" + thought: "Ha pensato" + working: "Al lavoro…" + preparing: "Preparazione di {what}…" + hint_steer: "guida" + hint_queue: "accoda" + hint_interrupt: "interrompi" + hint_stop: "ferma" + hint_commands: "comandi" + hint_files: "file" + hint_shell: "shell" + placeholder_busy_steer: "scrivi per guidare questo turno · Tab lo accoda per dopo" + placeholder_busy_interrupt: "scrivi per reindirizzare questo turno · Tab lo accoda per dopo" + placeholder_busy_queue: "scrivi per accodare un messaggio dopo questo turno" billing: load_failed: "{label}: {error}" load_failed_label: "Impossibile caricare la fatturazione" @@ -2232,6 +2247,7 @@ cli: queue_heading: "Coda · {count} in coda · /queue list{controls}" more_queued: "+{count} altri" transcript_unavailable: "Trascrizione in tempo reale non ancora disponibile." + control_pending: "Invio in corso…" steer_prompt: "Indirizza: " roster_empty: "Nessun subagente attivo né processo in background. I risultati arrivano nella conversazione." entry_gone: "Questa voce non è più attiva." @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "Spostamento fuori dai limiti della coda. Dimensione attuale: {size}" moved: "Elemento {source} della coda spostato in posizione {destination}." usage_add: "Uso: /queue add " + gateway_refused: "Il gateway non l'ha accodato: {error}. Nulla è stato accodato; invialo di nuovo a fine turno." + submit_unknown: "Il gateway non ha risposto ({error}), quindi il prompt potrebbe essere già in coda. Controlla /queue list prima di inviarlo di nuovo." + move_gateway_fifo: "Il gateway esegue i prompt in coda nell'ordine di invio; modificane o rimuovine uno invece." + remove_refused: "Impossibile rimuovere l'elemento {index} della coda: {error}" + edit_refused: "Impossibile modificare l'elemento {index} della coda: {error}" + already_started: "L'elemento {index} della coda è già partito; viene eseguito così come è stato inviato." + started_stopped: "L'elemento {index} della coda era già partito; il gateway lo ha fermato." + change_unknown: "Il gateway non ha risposto sull'elemento {index} della coda ({error}); controlla /queue list prima di riprovare." reload: env_reloaded: "Ricaricato .env (variabili aggiornate: {count})" steer: diff --git a/locales/ja.tui.yaml b/locales/ja.tui.yaml index 151680eae5..92db6d7ac5 100644 --- a/locales/ja.tui.yaml +++ b/locales/ja.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "バッテリーインジケーター {0}" slashCmd.core.battery.statusLive: "バッテリーインジケーター {0} — 現在 {1} {2}%" slashCmd.core.battery.statusNoBattery: "バッテリーインジケーター {0} — このマシンにバッテリーは検出されませんでした" slashCmd.core.battery.usage: "使い方: /battery [on|off|status]" +slashCmd.core.busy.compressing: "セッションが使用中です — /compress を実行中です。完了したら /{0} を実行してください。" +slashCmd.core.busy.replying: "セッションが使用中です — Hermes はまだ応答中です。先に現在の応答を停止してから(停止ボタン、またはターミナルでは Ctrl+C)/{0} を実行してください。" slashCmd.core.clear.cancelLabel: "いいえ、続ける" slashCmd.core.clear.confirmClear: "はい、セッションをクリア" slashCmd.core.clear.confirmNew: "はい、新しいセッションを開始" diff --git a/locales/ja.yaml b/locales/ja.yaml index 0afa6b067d..30336e9ce8 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "CLI を終了(--delete でセッション履歴も削除)" cli: + modern: + thought_for: "{seconds}秒考えました" + thought: "考えました" + working: "作業中…" + preparing: "{what} を準備中…" + hint_steer: "誘導" + hint_queue: "キュー" + hint_interrupt: "中断" + hint_stop: "停止" + hint_commands: "コマンド" + hint_files: "ファイル" + hint_shell: "シェル" + placeholder_busy_steer: "入力してこのターンを誘導 · Tab で後にキュー" + placeholder_busy_interrupt: "入力してこのターンを切り替え · Tab で後にキュー" + placeholder_busy_queue: "入力してこのターンの後にメッセージをキュー" billing: load_failed: "{label}: {error}" load_failed_label: "請求情報を読み込めませんでした" @@ -2232,6 +2247,7 @@ cli: queue_heading: "キュー · {count} 件キュー待ち · /queue list{controls}" more_queued: "+{count} 件" transcript_unavailable: "ライブトランスクリプトはまだ利用できません。" + control_pending: "送信中…" steer_prompt: "ステア: " roster_empty: "稼働中のサブエージェントやバックグラウンドプロセスはありません。結果は会話内に届きます。" entry_gone: "この項目はすでに終了しています。" @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "キューの移動先が範囲外です。現在のサイズ: {size}" moved: "キュー項目 {source} を {destination} に移動しました。" usage_add: "使い方: /queue add " + gateway_refused: "ゲートウェイはキューに追加しませんでした: {error}。何も追加されていません。ターン終了後に再送してください。" + submit_unknown: "ゲートウェイが応答しませんでした ({error})。プロンプトはすでにキューにある可能性があります。再送する前に /queue list を確認してください。" + move_gateway_fifo: "ゲートウェイはキュー内のプロンプトを送信順に実行します。代わりに編集または削除してください。" + remove_refused: "キュー項目 {index} を削除できませんでした: {error}" + edit_refused: "キュー項目 {index} を編集できませんでした: {error}" + already_started: "キュー項目 {index} はすでに開始しています。送信したとおりに実行されます。" + started_stopped: "キュー項目 {index} はすでに開始していたため、ゲートウェイが停止しました。" + change_unknown: "ゲートウェイからキュー項目 {index} の応答がありません ({error})。再試行する前に /queue list を確認してください。" reload: env_reloaded: ".env を再読み込みしました({count} 件の変数を更新)" steer: diff --git a/locales/ko.tui.yaml b/locales/ko.tui.yaml index 0f4fa58c3f..bc6886bcdc 100644 --- a/locales/ko.tui.yaml +++ b/locales/ko.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "배터리 표시 {0}" slashCmd.core.battery.statusLive: "배터리 표시 {0} — 현재 {1} {2}%" slashCmd.core.battery.statusNoBattery: "배터리 표시 {0} — 이 기기에서 배터리를 찾지 못했어요" slashCmd.core.battery.usage: "사용법: /battery [on|off|status]" +slashCmd.core.busy.compressing: "세션 사용 중 — /compress가 실행 중입니다. 끝나면 /{0}을(를) 실행하세요." +slashCmd.core.busy.replying: "세션 사용 중 — Hermes가 아직 응답하고 있습니다. 먼저 현재 응답을 중지한 다음(중지 버튼 또는 터미널에서 Ctrl+C) /{0}을(를) 실행하세요." slashCmd.core.clear.cancelLabel: "아니요, 계속할게요" slashCmd.core.clear.confirmClear: "네, 세션을 지울게요" slashCmd.core.clear.confirmNew: "네, 새 세션을 시작할게요" diff --git a/locales/ko.yaml b/locales/ko.yaml index 02ca5d75d1..af35a058a7 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "CLI 종료 (--delete를 붙이면 세션 기록도 삭제)" cli: + modern: + thought_for: "{seconds}초 동안 생각함" + thought: "생각함" + working: "작업 중…" + preparing: "{what} 준비 중…" + hint_steer: "유도" + hint_queue: "대기열" + hint_interrupt: "중단" + hint_stop: "정지" + hint_commands: "명령어" + hint_files: "파일" + hint_shell: "셸" + placeholder_busy_steer: "입력해 이번 턴을 유도 · Tab 은 이후로 대기열에 추가" + placeholder_busy_interrupt: "입력해 이번 턴을 전환 · Tab 은 이후로 대기열에 추가" + placeholder_busy_queue: "입력해 이번 턴 이후 메시지를 대기열에 추가" billing: load_failed: "{label}: {error}" load_failed_label: "결제 정보를 불러올 수 없어요" @@ -2232,6 +2247,7 @@ cli: queue_heading: "대기열 · {count}개 대기 중 · /queue list{controls}" more_queued: "+{count}개 더" transcript_unavailable: "실시간 기록을 아직 볼 수 없어요." + control_pending: "보내는 중…" steer_prompt: "조정: " roster_empty: "진행 중인 서브에이전트나 백그라운드 프로세스가 없어요. 결과는 대화에 도착해요." entry_gone: "이 항목은 더 이상 진행 중이 아니에요." @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "대기열 이동 범위를 벗어났어요. 현재 크기: {size}" moved: "대기열 항목 {source}을(를) {destination}으로 이동했어요." usage_add: "사용법: /queue add " + gateway_refused: "게이트웨이가 대기열에 넣지 않았습니다: {error}. 아무것도 대기열에 없습니다. 턴이 끝나면 다시 보내세요." + submit_unknown: "게이트웨이가 응답하지 않았습니다 ({error}). 프롬프트가 이미 대기열에 있을 수 있습니다. 다시 보내기 전에 /queue list를 확인하세요." + move_gateway_fifo: "게이트웨이는 대기 중인 프롬프트를 보낸 순서대로 실행합니다. 대신 편집하거나 삭제하세요." + remove_refused: "대기열 항목 {index}을(를) 제거할 수 없습니다: {error}" + edit_refused: "대기열 항목 {index}을(를) 편집할 수 없습니다: {error}" + already_started: "대기열 항목 {index}은(는) 이미 시작되었습니다. 보낸 그대로 실행됩니다." + started_stopped: "대기열 항목 {index}은(는) 이미 시작되어 게이트웨이가 중지했습니다." + change_unknown: "게이트웨이가 대기열 항목 {index}에 응답하지 않았습니다 ({error}). 다시 시도하기 전에 /queue list를 확인하세요." reload: env_reloaded: ".env를 다시 불러왔어요 (변수 {count}개 업데이트됨)" steer: diff --git a/locales/pt.tui.yaml b/locales/pt.tui.yaml index 6a34129789..3955c44abf 100644 --- a/locales/pt.tui.yaml +++ b/locales/pt.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "indicador de bateria {0}" slashCmd.core.battery.statusLive: "indicador de bateria {0} — atualmente {1} {2}%" slashCmd.core.battery.statusNoBattery: "indicador de bateria {0} — nenhuma bateria detectada nesta máquina" slashCmd.core.battery.usage: "uso: /battery [on|off|status]" +slashCmd.core.busy.compressing: "sessão ocupada — um /compress está em execução; execute /{0} quando terminar." +slashCmd.core.busy.replying: "sessão ocupada — o Hermes ainda está respondendo. Pare primeiro a resposta atual (botão Parar ou Ctrl+C em um terminal) e depois execute /{0}." slashCmd.core.clear.cancelLabel: "Não, continuar" slashCmd.core.clear.confirmClear: "Sim, limpar a sessão" slashCmd.core.clear.confirmNew: "Sim, iniciar uma nova sessão" diff --git a/locales/pt.yaml b/locales/pt.yaml index eadf372ae1..489454fc34 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "Sair da CLI (use --delete para também remover o histórico da sessão)" cli: + modern: + thought_for: "Pensou por {seconds}s" + thought: "Pensou" + working: "Trabalhando…" + preparing: "Preparando {what}…" + hint_steer: "guiar" + hint_queue: "enfileirar" + hint_interrupt: "interromper" + hint_stop: "parar" + hint_commands: "comandos" + hint_files: "arquivos" + hint_shell: "shell" + placeholder_busy_steer: "digite para guiar este turno · Tab enfileira para depois" + placeholder_busy_interrupt: "digite para redirecionar este turno · Tab enfileira para depois" + placeholder_busy_queue: "digite para enfileirar uma mensagem para depois deste turno" billing: load_failed: "{label}: {error}" load_failed_label: "Não foi possível carregar a cobrança" @@ -2232,6 +2247,7 @@ cli: queue_heading: "Fila · {count} na fila · /queue list{controls}" more_queued: "+{count} mais" transcript_unavailable: "Transcrição ao vivo ainda não disponível." + control_pending: "Enviando…" steer_prompt: "Redirecionar: " roster_empty: "Nenhum subagente ativo nem processo em segundo plano. Os resultados chegam na conversa." entry_gone: "Esta entrada não está mais ativa." @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "Movimento na fila fora do intervalo. Tamanho atual: {size}" moved: "Item {source} da fila movido para {destination}." usage_add: "Uso: /queue add " + gateway_refused: "O gateway não o enfileirou: {error}. Nada foi enfileirado; envie novamente quando o turno terminar." + submit_unknown: "O gateway não respondeu ({error}), então o prompt pode já estar na fila. Confira /queue list antes de enviá-lo de novo." + move_gateway_fifo: "O gateway executa prompts enfileirados na ordem de envio; edite ou remova um em vez disso." + remove_refused: "Não foi possível remover o item {index} da fila: {error}" + edit_refused: "Não foi possível editar o item {index} da fila: {error}" + already_started: "O item {index} da fila já começou; ele roda como foi enviado." + started_stopped: "O item {index} da fila já tinha começado; o gateway o parou." + change_unknown: "O gateway não respondeu sobre o item {index} da fila ({error}); confira /queue list antes de tentar de novo." reload: env_reloaded: ".env recarregado ({count} variável(is) atualizada(s))" steer: diff --git a/locales/ru.tui.yaml b/locales/ru.tui.yaml index faf99edb5a..bc101e5290 100644 --- a/locales/ru.tui.yaml +++ b/locales/ru.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "индикатор батареи {0}" slashCmd.core.battery.statusLive: "индикатор батареи {0} — сейчас {1} {2}%" slashCmd.core.battery.statusNoBattery: "индикатор батареи {0} — на этой машине батарея не обнаружена" slashCmd.core.battery.usage: "использование: /battery [on|off|status]" +slashCmd.core.busy.compressing: "сеанс занят — выполняется /compress; выполните /{0}, когда он завершится." +slashCmd.core.busy.replying: "сеанс занят — Hermes ещё отвечает. Сначала остановите текущий ответ (кнопка «Стоп» или Ctrl+C в терминале), затем выполните /{0}." slashCmd.core.clear.cancelLabel: "Нет, продолжить" slashCmd.core.clear.confirmClear: "Да, очистить сеанс" slashCmd.core.clear.confirmNew: "Да, начать новый сеанс" diff --git a/locales/ru.yaml b/locales/ru.yaml index 1543672352..aa36c8fc20 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "Выйти из CLI (с --delete также удаляется история сеанса)" cli: + modern: + thought_for: "Размышлял {seconds} с" + thought: "Размышлял" + working: "Работаю…" + preparing: "Готовлю {what}…" + hint_steer: "направить" + hint_queue: "в очередь" + hint_interrupt: "прервать" + hint_stop: "стоп" + hint_commands: "команды" + hint_files: "файлы" + hint_shell: "shell" + placeholder_busy_steer: "пишите, чтобы направить этот ход · Tab ставит в очередь на потом" + placeholder_busy_interrupt: "пишите, чтобы перенаправить этот ход · Tab ставит в очередь на потом" + placeholder_busy_queue: "пишите, чтобы поставить сообщение в очередь после этого хода" billing: load_failed: "{label}: {error}" load_failed_label: "Не удалось загрузить данные биллинга" @@ -2232,6 +2247,7 @@ cli: queue_heading: "Очередь · в очереди: {count} · /queue list{controls}" more_queued: "+{count} ещё" transcript_unavailable: "Живой транскрипт пока недоступен." + control_pending: "Отправка…" steer_prompt: "Корректировка: " roster_empty: "Нет активных подагентов или фоновых процессов. Результаты появятся в разговоре." entry_gone: "Эта запись больше не активна." @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "Перемещение выходит за границы очереди. Текущий размер: {size}" moved: "Элемент очереди {source} перемещён на {destination}." usage_add: "Использование: /queue add <промпт>" + gateway_refused: "Шлюз не поставил это в очередь: {error}. Ничего не поставлено; отправьте снова, когда ход закончится." + submit_unknown: "Шлюз не ответил ({error}), поэтому запрос, возможно, уже в очереди. Проверьте /queue list, прежде чем отправлять снова." + move_gateway_fifo: "Шлюз выполняет запросы в очереди в порядке отправки; вместо этого измените или удалите один." + remove_refused: "Не удалось удалить элемент очереди {index}: {error}" + edit_refused: "Не удалось изменить элемент очереди {index}: {error}" + already_started: "Элемент очереди {index} уже запущен; он выполняется в отправленном виде." + started_stopped: "Элемент очереди {index} уже был запущен; шлюз остановил его." + change_unknown: "Шлюз не ответил по элементу очереди {index} ({error}); проверьте /queue list, прежде чем пробовать снова." reload: env_reloaded: "Файл .env перезагружен (обновлено переменных: {count})" steer: diff --git a/locales/tr.tui.yaml b/locales/tr.tui.yaml index 4876e5b168..65b66381aa 100644 --- a/locales/tr.tui.yaml +++ b/locales/tr.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "pil göstergesi {0}" slashCmd.core.battery.statusLive: "pil göstergesi {0} — şu anda {1} %{2}" slashCmd.core.battery.statusNoBattery: "pil göstergesi {0} — bu makinede pil algılanmadı" slashCmd.core.battery.usage: "kullanım: /battery [on|off|status]" +slashCmd.core.busy.compressing: "oturum meşgul — bir /compress çalışıyor; bittiğinde /{0} komutunu çalıştırın." +slashCmd.core.busy.replying: "oturum meşgul — Hermes hâlâ yanıt veriyor. Önce geçerli yanıtı durdurun (Durdur düğmesi veya terminalde Ctrl+C), ardından /{0} komutunu çalıştırın." slashCmd.core.clear.cancelLabel: "Hayır, devam et" slashCmd.core.clear.confirmClear: "Evet, oturumu temizle" slashCmd.core.clear.confirmNew: "Evet, yeni oturum başlat" diff --git a/locales/tr.yaml b/locales/tr.yaml index f097a7eebd..337caf8f35 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "CLI'dan çık (oturum geçmişini de silmek için --delete kullanın)" cli: + modern: + thought_for: "{seconds} sn düşündü" + thought: "Düşündü" + working: "Çalışıyor…" + preparing: "{what} hazırlanıyor…" + hint_steer: "yönlendir" + hint_queue: "sıraya al" + hint_interrupt: "kes" + hint_stop: "durdur" + hint_commands: "komutlar" + hint_files: "dosyalar" + hint_shell: "kabuk" + placeholder_busy_steer: "bu turu yönlendirmek için yazın · Tab sonrası için sıraya alır" + placeholder_busy_interrupt: "bu turu değiştirmek için yazın · Tab sonrası için sıraya alır" + placeholder_busy_queue: "bu turdan sonrası için mesaj sıraya almak üzere yazın" billing: load_failed: "{label}: {error}" load_failed_label: "Faturalandırma yüklenemedi" @@ -2232,6 +2247,7 @@ cli: queue_heading: "Kuyruk · {count} sırada · /queue list{controls}" more_queued: "+{count} tane daha" transcript_unavailable: "Canlı transkript henüz kullanılamıyor." + control_pending: "Gönderiliyor…" steer_prompt: "Yönlendir: " roster_empty: "Canlı alt ajan veya arka plan süreci yok. Sonuçlar konuşmaya gelir." entry_gone: "Bu kayıt artık canlı değil." @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "Kuyruk taşıma aralık dışında. Geçerli boyut: {size}" moved: "Kuyruk öğesi {source}, {destination} konumuna taşındı." usage_add: "Kullanım: /queue add " + gateway_refused: "Ağ geçidi bunu kuyruğa almadı: {error}. Hiçbir şey kuyruğa alınmadı; tur bitince yeniden gönderin." + submit_unknown: "Ağ geçidi yanıt vermedi ({error}); istem zaten kuyrukta olabilir. Yeniden göndermeden önce /queue list ile kontrol edin." + move_gateway_fifo: "Ağ geçidi kuyruktaki istemleri gönderilme sırasıyla çalıştırır; bunun yerine birini düzenleyin veya kaldırın." + remove_refused: "Kuyruk öğesi {index} kaldırılamadı: {error}" + edit_refused: "Kuyruk öğesi {index} düzenlenemedi: {error}" + already_started: "Kuyruk öğesi {index} zaten başladı; gönderildiği gibi çalışıyor." + started_stopped: "Kuyruk öğesi {index} zaten başlamıştı; ağ geçidi onu durdurdu." + change_unknown: "Ağ geçidi kuyruk öğesi {index} için yanıt vermedi ({error}); yeniden denemeden önce /queue list ile kontrol edin." reload: env_reloaded: ".env yeniden yüklendi ({count} değişken güncellendi)" steer: diff --git a/locales/uk.tui.yaml b/locales/uk.tui.yaml index 9c6c62a10e..b16ff49cb5 100644 --- a/locales/uk.tui.yaml +++ b/locales/uk.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "індикатор батареї {0}" slashCmd.core.battery.statusLive: "індикатор батареї {0} — зараз {1} {2}%" slashCmd.core.battery.statusNoBattery: "індикатор батареї {0} — батарею на цьому комп'ютері не виявлено" slashCmd.core.battery.usage: "використання: /battery [on|off|status]" +slashCmd.core.busy.compressing: "сеанс зайнятий — виконується /compress; виконайте /{0}, коли він завершиться." +slashCmd.core.busy.replying: "сеанс зайнятий — Hermes ще відповідає. Спершу зупиніть поточну відповідь (кнопка «Стоп» або Ctrl+C у терміналі), потім виконайте /{0}." slashCmd.core.clear.cancelLabel: "Ні, продовжити" slashCmd.core.clear.confirmClear: "Так, очистити сеанс" slashCmd.core.clear.confirmNew: "Так, почати новий сеанс" diff --git a/locales/uk.yaml b/locales/uk.yaml index 3405cff396..08dcf6e4af 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "Вийти з CLI (--delete також видаляє історію сеансу)" cli: + modern: + thought_for: "Міркував {seconds} с" + thought: "Міркував" + working: "Працюю…" + preparing: "Готую {what}…" + hint_steer: "спрямувати" + hint_queue: "у чергу" + hint_interrupt: "перервати" + hint_stop: "стоп" + hint_commands: "команди" + hint_files: "файли" + hint_shell: "shell" + placeholder_busy_steer: "пишіть, щоб спрямувати цей хід · Tab ставить у чергу на потім" + placeholder_busy_interrupt: "пишіть, щоб перенаправити цей хід · Tab ставить у чергу на потім" + placeholder_busy_queue: "пишіть, щоб поставити повідомлення в чергу після цього ходу" billing: load_failed: "{label}: {error}" load_failed_label: "Не вдалося завантажити дані оплати" @@ -2232,6 +2247,7 @@ cli: queue_heading: "Черга · {count} у черзі · /queue list{controls}" more_queued: "+{count} ще" transcript_unavailable: "Транскрипт у реальному часі ще недоступний." + control_pending: "Надсилання…" steer_prompt: "Скерувати: " roster_empty: "Немає активних субагентів чи фонових процесів. Результати з'являться в розмові." entry_gone: "Цей запис більше не активний." @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "Переміщення в черзі поза межами діапазону. Поточний розмір: {size}" moved: "Елемент черги {source} переміщено на {destination}." usage_add: "Використання: /queue add " + gateway_refused: "Шлюз не поставив це в чергу: {error}. Нічого не поставлено; надішліть знову, коли хід закінчиться." + submit_unknown: "Шлюз не відповів ({error}), тож запит, можливо, уже в черзі. Перевірте /queue list, перш ніж надсилати знову." + move_gateway_fifo: "Шлюз виконує запити в черзі в порядку надсилання; натомість відредагуйте або видаліть один." + remove_refused: "Не вдалося видалити елемент черги {index}: {error}" + edit_refused: "Не вдалося змінити елемент черги {index}: {error}" + already_started: "Елемент черги {index} уже запущено; він виконується в надісланому вигляді." + started_stopped: "Елемент черги {index} уже було запущено; шлюз зупинив його." + change_unknown: "Шлюз не відповів щодо елемента черги {index} ({error}); перевірте /queue list, перш ніж пробувати знову." reload: env_reloaded: "Перезавантажено .env (оновлено змінних: {count})" steer: diff --git a/locales/zh-hant.tui.yaml b/locales/zh-hant.tui.yaml index 9d9affd5cf..bdad2e6833 100644 --- a/locales/zh-hant.tui.yaml +++ b/locales/zh-hant.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "電池指示器 {0}" slashCmd.core.battery.statusLive: "電池指示器 {0} — 目前 {1} {2}%" slashCmd.core.battery.statusNoBattery: "電池指示器 {0} — 這台機器未偵測到電池" slashCmd.core.battery.usage: "用法:/battery [on|off|status]" +slashCmd.core.busy.compressing: "會話忙碌中 — 正在執行 /compress;完成後再執行 /{0}。" +slashCmd.core.busy.replying: "會話忙碌中 — Hermes 仍在回覆。請先停止目前回覆(Stop 按鈕,或在終端機中按 Ctrl+C),然後再執行 /{0}。" slashCmd.core.clear.cancelLabel: "否,繼續目前的工作階段" slashCmd.core.clear.confirmClear: "是,清除工作階段" slashCmd.core.clear.confirmNew: "是,開始新的工作階段" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index c7f14ac6a9..2fe1e1a270 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "離開 CLI(加上 --delete 同時移除工作階段歷史)" cli: + modern: + thought_for: "思考了 {seconds} 秒" + thought: "思考" + working: "工作中…" + preparing: "正在準備 {what}…" + hint_steer: "引導" + hint_queue: "排隊" + hint_interrupt: "中斷" + hint_stop: "停止" + hint_commands: "指令" + hint_files: "檔案" + hint_shell: "終端" + placeholder_busy_steer: "輸入以引導本輪 · Tab 排到之後" + placeholder_busy_interrupt: "輸入以改變本輪方向 · Tab 排到之後" + placeholder_busy_queue: "輸入以排隊一則本輪結束後的訊息" billing: load_failed: "{label}:{error}" load_failed_label: "無法載入帳務資訊" @@ -2232,6 +2247,7 @@ cli: queue_heading: "佇列 · {count} 個排隊中 · /queue list{controls}" more_queued: "還有 {count} 個" transcript_unavailable: "即時逐字稿尚未可用。" + control_pending: "傳送中…" steer_prompt: "引導:" roster_empty: "沒有進行中的子代理或背景程序。結果會出現在對話中。" entry_gone: "此項目已不再進行中。" @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "佇列移動超出範圍。目前佇列長度:{size}" moved: "已將佇列項目 {source} 移至 {destination}。" usage_add: "用法:/queue add " + gateway_refused: "閘道未將其加入佇列:{error}。未加入任何內容;請在本輪結束後重新傳送。" + submit_unknown: "閘道未回應({error}),提示可能已在佇列中。重新傳送前請先查看 /queue list。" + move_gateway_fifo: "閘道依傳送順序執行佇列中的提示;請改為編輯或刪除其中一則。" + remove_refused: "無法移除佇列項目 {index}:{error}" + edit_refused: "無法編輯佇列項目 {index}:{error}" + already_started: "佇列項目 {index} 已開始執行;將依原樣執行。" + started_stopped: "佇列項目 {index} 已開始執行;閘道已將其停止。" + change_unknown: "閘道未回應佇列項目 {index}({error});請先查看 /queue list 再重試。" reload: env_reloaded: "已重新載入 .env(更新了 {count} 個變數)" steer: diff --git a/locales/zh.tui.yaml b/locales/zh.tui.yaml index 2f39e471c7..6ba0a5b54a 100644 --- a/locales/zh.tui.yaml +++ b/locales/zh.tui.yaml @@ -826,6 +826,8 @@ slashCmd.core.battery.state: "电池指示器 {0}" slashCmd.core.battery.statusLive: "电池指示器 {0} — 当前 {1} {2}%" slashCmd.core.battery.statusNoBattery: "电池指示器 {0} — 本机未检测到电池" slashCmd.core.battery.usage: "用法:/battery [on|off|status]" +slashCmd.core.busy.compressing: "会话忙碌中 — 正在运行 /compress;完成后再运行 /{0}。" +slashCmd.core.busy.replying: "会话忙碌中 — Hermes 仍在回复。请先停止当前回复(Stop 按钮,或在终端中按 Ctrl+C),然后再运行 /{0}。" slashCmd.core.clear.cancelLabel: "不,继续当前会话" slashCmd.core.clear.confirmClear: "是,清空会话" slashCmd.core.clear.confirmNew: "是,开始新会话" diff --git a/locales/zh.yaml b/locales/zh.yaml index c4f8594d88..587471f22e 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -1689,6 +1689,21 @@ slash: quit: description: "退出 CLI(加 --delete 可同时删除会话历史)" cli: + modern: + thought_for: "思考了 {seconds} 秒" + thought: "思考" + working: "工作中…" + preparing: "正在准备 {what}…" + hint_steer: "引导" + hint_queue: "排队" + hint_interrupt: "中断" + hint_stop: "停止" + hint_commands: "命令" + hint_files: "文件" + hint_shell: "终端" + placeholder_busy_steer: "输入以引导本轮 · Tab 排到之后" + placeholder_busy_interrupt: "输入以改变本轮方向 · Tab 排到之后" + placeholder_busy_queue: "输入以排队一条本轮结束后的消息" billing: load_failed: "{label}:{error}" load_failed_label: "无法加载账单信息" @@ -2232,6 +2247,7 @@ cli: queue_heading: "队列 · {count} 个排队中 · /queue list{controls}" more_queued: "+{count} 个" transcript_unavailable: "实时记录尚不可用。" + control_pending: "发送中…" steer_prompt: "引导:" roster_empty: "没有运行中的子代理或后台进程。结果会出现在对话中。" entry_gone: "此条目已不再运行。" @@ -2370,6 +2386,14 @@ cli: move_out_of_range: "队列移动位置超出范围。当前队列长度:{size}" moved: "已将队列项 {source} 移至 {destination}。" usage_add: "用法:/queue add " + gateway_refused: "网关未将其加入队列:{error}。未加入任何内容;请在本轮结束后重新发送。" + submit_unknown: "网关未回应({error}),提示可能已在队列中。重新发送前请先查看 /queue list。" + move_gateway_fifo: "网关按发送顺序运行队列中的提示;请改为编辑或删除其中一条。" + remove_refused: "无法移除队列项 {index}:{error}" + edit_refused: "无法编辑队列项 {index}:{error}" + already_started: "队列项 {index} 已开始执行;将按原样执行。" + started_stopped: "队列项 {index} 已开始执行;网关已将其停止。" + change_unknown: "网关未回应队列项 {index}({error});请先查看 /queue list 再重试。" reload: env_reloaded: "已重新加载 .env(更新了 {count} 个变量)" steer: diff --git a/plugin-catalog/README.md b/plugin-catalog/README.md index 28345b04a8..21d2320bb0 100644 --- a/plugin-catalog/README.md +++ b/plugin-catalog/README.md @@ -48,7 +48,9 @@ meaningful: 8. **Desktop plugins stay inside the SDK surface.** A `desktop/plugin.js` runs in the Desktop renderer with the app's full authority (the loader isolates errors, not capabilities), so a listed one may only use the plugin SDK: - no prototype patching (`X.prototype.y =`, `Object.defineProperty(...prototype`), + no prototype patching of renderer or browser objects (`Storage.prototype.y =`, + `Object.defineProperty(...prototype`); a bundled library setting fields on + classes it declares itself (three.js and the like) is fine, no `eval`/`new Function`, no `import()` of anything but `@hermes/plugin-sdk` / `react` (app bundle chunks, blob or http URLs included), no script-tag injection, no reaching into the app's internal stores or its own markup @@ -87,8 +89,12 @@ meaningful: which honours the active profile (a bare `os.environ` read can pick up another profile's key under multiplexing). A subscription sign-in (Codex / ChatGPT today) is used only through `credentialed_provider_request` with - `requires_auth`, so Hermes attaches the token and the plugin never holds it. A - plugin never reads `.env` files, Hermes's `auth.json`, another tool's login (a + `requires_auth`, so Hermes attaches the token and the plugin never holds it. An + OAuth client for a third-party API (the user's own Google Cloud client, for + example) is declared under `oauth:` in `plugin.yaml`, signed in with + `hermes auth add :` and used through the same helper; Hermes + stores and refreshes that token too. Its `authorize_url`, `token_url` and + `hosts` are `https` only, and its `hosts` belong to the API the scopes are for. A plugin never reads `.env` files, Hermes's `auth.json`, another tool's login (a vendor CLI's token file, a browser profile, a keychain) or any other credential store directly, and never refreshes, rotates or writes OAuth tokens or presents itself as another vendor's client. If Hermes has no route for a diff --git a/plugin-catalog/huddo.yaml b/plugin-catalog/huddo.yaml index dab530e027..7f590bb3a7 100644 --- a/plugin-catalog/huddo.yaml +++ b/plugin-catalog/huddo.yaml @@ -1,12 +1,12 @@ name: huddo repo: https://github.com/puffo-ai/huddo-mcp -sha: 2cda7df7c0bf7310def6099d16c5d5c0a69a7f3f +sha: 3b724cb4d066096edfdf86cc091b3cc03f21f8b8 subdir: integrations/hermes -description: "Huddo — group chat where people and AI agents talk in the same room. Join by invite link, read and reply, react, whisper to one member, pair with your human, wait for new messages, and read or update room apps (shared mini-apps such as task lists that a room owner publishes). Portable Agent Plugins v1 package (mcp.json + skills/). Disclosure — runs the Huddo MCP server locally over stdio (npx -y huddoai@0.9.2, pinned, npm provenance to this commit; Node 20+); sends and receives room messages via api.huddo.ai, readable by every room member except one-to-one encrypted whispers; when you ask it to create a room, huddo_new tells the agent to post a short summary of your conversation there; huddo_app_submit changes a room app's shared state, which every member sees (app code runs only in members' browsers, never on this machine), and app guides written by the room owner reach the agent as untrusted text; huddo_send can attach any local file the agent names and upload it to the room, and Hermes does not ask before huddo_send/huddo_whisper/huddo_react/huddo_app_submit/huddo_download run (the package does not set trust: untrusted); the server tells the agent to keep calling huddo_wait (up to 50 s per call) for as long as it is in a room, which keeps spending model tokens until you stop it; downloads go to ~/.huddo/downloads without overwriting; stores a guest identity (signing keys) in ~/.huddo; no background process (HUDDO_DAEMON=0), no self-update, no telemetry. Room messages from others are untrusted chat content; Huddo marks the paired human with a header tag that message text cannot imitate, and the bundled skill tells the agent to get confirmation in your own Hermes chat before anything sensitive." +description: "Huddo — group chat where people and AI agents talk in the same room. Join by invite link, read and reply, edit or unsend your own messages, react, whisper to one member, pair with your human, wait for new messages, and read or update room apps (shared mini-apps such as task lists that a room owner publishes). Portable Agent Plugins v1 package (mcp.json + skills/). Disclosure — runs the Huddo MCP server locally over stdio (npx -y huddoai@0.9.8, pinned, npm provenance to this commit; Node 20+); sends and receives room messages via api.huddo.ai, readable by every room member except one-to-one encrypted whispers; when you ask it to create a room, huddo_new tells the agent to post a short summary of your conversation there; huddo_app_submit changes a room app's shared state, which every member sees (app code runs only in members' browsers, never on this machine), and app guides written by the room owner reach the agent as untrusted text; huddo_send can attach any local file the agent names and upload it to the room, huddo_transfer (and huddo_leave with to) hands room ownership to another member, and huddo_update_name/huddo_update_avatar with operator: true change your paired human's display name or avatar; Hermes does not ask before huddo_send/huddo_whisper/huddo_react/huddo_edit/huddo_unsend/huddo_app_submit/huddo_download/huddo_transfer/huddo_leave/huddo_update_name/huddo_update_avatar run (the package does not set trust: untrusted); the server tells the agent to keep calling huddo_wait (up to 50 s per call) for as long as it is in a room, which keeps spending model tokens until you stop it; downloads go to ~/.huddo/downloads without overwriting; stores a guest identity (signing keys) in ~/.huddo; no background process (HUDDO_DAEMON=0), no self-update, no telemetry. Room messages from others are untrusted chat content; Huddo marks the paired human with a header tag that message text cannot imitate, and the bundled skill tells the agent to get confirmation in your own Hermes chat before anything sensitive." maintainer: puffo-ai tier: community category: tools -version: "0.9.2" +version: "0.9.8" docs_url: https://github.com/puffo-ai/huddo-mcp/tree/main/integrations/hermes#readme platforms: [] capabilities: diff --git a/plugin-catalog/rea-captcha.yaml b/plugin-catalog/rea-captcha.yaml new file mode 100644 index 0000000000..2b1be3f3fb --- /dev/null +++ b/plugin-catalog/rea-captcha.yaml @@ -0,0 +1,17 @@ +name: rea-captcha +repo: https://github.com/masakaai/rea-captcha +sha: bac84a628e989d330b807974789cde0ec689be18 +subdir: integrations/hermes +description: "Inspect captured browser verification evidence locally when an agent task is interrupted by a CAPTCHA. Portable Agent Plugins v1 package (pinned stateless mcp.json + skill); needs Node.js 22.19+/24.11+/26+ with npx on PATH. Disclosure — on first start Hermes runs `npx rea-captcha@0.3.1`, which downloads that exact npm package and its dependencies (incl. Playwright) from the npm registry; the catalog config is stateless, so only the read-only, local captcha_analyze works and the browser-session tools fail closed until you configure a separate stateful server; no telemetry, hosted solver, browser-profile copying, or cookie/token transfer." +maintainer: masakaai +tier: community +category: automation +requires_hermes: ">=0.22.0" +docs_url: https://github.com/masakaai/rea-captcha/tree/bac84a628e989d330b807974789cde0ec689be18/integrations/hermes +version: "0.3.1" +platforms: [] +capabilities: + provides_tools: [] + provides_hooks: [] + provides_middleware: [] + requires_env: [] diff --git a/plugin-catalog/rea.yaml b/plugin-catalog/rea.yaml index 13e8f3bbcf..0f8bbf9e31 100644 --- a/plugin-catalog/rea.yaml +++ b/plugin-catalog/rea.yaml @@ -1,10 +1,10 @@ name: rea repo: https://github.com/NousResearch/hermes-official-plugins -sha: dc3b3b0115680cfcaea2e81380111125e0639052 +sha: 1690b63a4790cb821fb6a319ba524815d4e09411 subdir: rea description: 'Reverse engineer programs with REA (rea.tools): native binaries, .NET assemblies, Electron/JavaScript apps, Android packages, firmware and browser/process activity, through - REA''s MCP server (pinned rea-agents@6.4.0 over npx stdio) with REA''s reverse-engineer-anything + REA''s MCP server (pinned rea-agents@6.7.0 over npx stdio) with REA''s reverse-engineer-anything skill adapted for Hermes. Portable Agent Plugins v1 package (mcp.json + skills/); needs Node.js 22.19+/24.11+/26+ on PATH, plus your own Ghidra, Hopper or IDA for native decompilation. Disclosure — a few REA tools run a command under a PTY, launch or drive an application, change @@ -15,7 +15,7 @@ tier: official category: tools requires_hermes: ">=0.22.0" docs_url: https://github.com/NousResearch/hermes-official-plugins/tree/main/rea -image: https://raw.githubusercontent.com/NousResearch/hermes-official-plugins/dc3b3b0115680cfcaea2e81380111125e0639052/assets/rea.jpg +image: https://raw.githubusercontent.com/NousResearch/hermes-official-plugins/1690b63a4790cb821fb6a319ba524815d4e09411/assets/rea.jpg platforms: [] capabilities: provides_tools: [] diff --git a/run_agent.py b/run_agent.py index 6ed2df0eb4..86fa471ed1 100644 --- a/run_agent.py +++ b/run_agent.py @@ -874,7 +874,7 @@ class AIAgent( if getattr(self, "_memory_provider_shutdown", False): return self._memory_provider_shutdown = True - if self._memory_manager: + if self._memory_manager and self._memory_manager.claim_teardown(session_end=True): try: self._memory_manager.on_session_end(messages or []) except Exception as e: @@ -923,9 +923,9 @@ class AIAgent( pass def release_clients(self) -> None: - """Release LLM clients and child agents WITHOUT tearing down session tool state (gateway cache - eviction: the session may resume on the same task_id, so processes, sandbox, browser, computer-use and - memory provider are kept). Idempotent; distinct from ``close()``.""" + """Release LLM clients, child agents and memory providers WITHOUT tearing down session tool state (gateway + cache eviction: the session may resume on the same task_id, so processes, sandbox, browser and computer-use + are kept). Idempotent; distinct from ``close()``.""" self._close_active_children(soft=True) # Retire (don't hard-close) the shared client: eviction runs on the gateway memory-manager thread, # and a cross-thread close can release TLS FDs under a still-unwinding worker. @@ -934,12 +934,12 @@ class AIAgent( # The Codex app-server child is an LLM client, not session tool state: the evicted instance is popped # from the cache and a rebuilt agent spawns its own child, so an unclosed one leaks for the gateway's life. _quietly(self._close_codex_session) + if getattr(self, "_memory_manager", None) is not None: + _quietly(self._memory_manager.release_soft) def close(self) -> None: """Release every resource this agent holds (idempotent); each phase is guarded so one failure never blocks the rest.""" - # close() is the hard owner boundary; shutdown_memory_provider() is idempotent so gateway pre-calls - # never double-extract. session_messages = getattr(self, "_session_messages", None) _quietly(self.shutdown_memory_provider, session_messages if isinstance(session_messages, list) else None) self._close_task_resources(getattr(self, "session_id", None) or "") diff --git a/tests/agent/test_auxiliary_reasoning_floor.py b/tests/agent/test_auxiliary_reasoning_floor.py index 2eefef4a6c..a30cd2cf6d 100644 --- a/tests/agent/test_auxiliary_reasoning_floor.py +++ b/tests/agent/test_auxiliary_reasoning_floor.py @@ -74,3 +74,43 @@ def test_field_rejection_still_strips_instead_of_stepping_up(): retry = client.chat.completions.create.call_args_list[1].kwargs assert "reasoning_effort" not in retry assert not auxiliary_reasoning_floor._FLOORED_ROUTES + + +# Live body from the firstVDS qwencloud route (Alibaba token-plan, deepseek-v4.1-flash, 2026-09-21): +# a closed level set that starts at ``minimal``, so the thinking-off encoding ``none`` is refused by +# value rather than by field name. +_TOKENPLAN_400 = ( + 'Error code: 400 - {\'error\':{\'code\':\'invalid_parameter_error\',\'param\':None,' + '\'message\':"\'reasoning_effort\' must be one of: \'minimal\', \'low\', \'medium\', \'high\', ' + '\'xhigh\', \'max\', \'ultra\'",\'type\':\'invalid_request_error\'}}' +) + + +def test_tokenplan_level_rejection_is_a_reasoning_rejection_not_a_field_rejection(): + """The closed-set wording names the field but blames its value: it must classify as a *required* + rejection (floor step-up), not as a field rejection (strip) — the endpoint needs the field on the + wire with an accepted level.""" + from agent.error_classifier import is_reasoning_field_rejection, is_reasoning_required_rejection + + assert is_reasoning_required_rejection(_TOKENPLAN_400) + assert not is_reasoning_field_rejection(_TOKENPLAN_400) + + +def test_tokenplan_level_rejection_steps_up_to_the_floor_and_remembers_the_route(): + """Alibaba token-plan (qwencloud) on deepseek-v4.1-flash: ``none`` → 400 → retry at ``low`` → ok, + and the next thinking-off aux call on that route starts at the floor.""" + client = MagicMock() + client.base_url = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1" + client.chat.completions.create.side_effect = [ + RuntimeError(_TOKENPLAN_400), {"ok": True}, {"ok": True}, + ] + + assert _call(client) == {"ok": True} + first, retry = (c.kwargs for c in client.chat.completions.create.call_args_list[:2]) + assert first["reasoning_effort"] == "none" + assert retry["reasoning_effort"] == auxiliary_reasoning_floor.REASONING_FLOOR_EFFORT + assert retry["extra_body"]["response_format"] == {"type": "json_object"} + + assert _call(client) == {"ok": True} + upfront = client.chat.completions.create.call_args_list[2].kwargs + assert upfront["reasoning_effort"] == auxiliary_reasoning_floor.REASONING_FLOOR_EFFORT diff --git a/tests/agent/test_builtin_memory_disabled_surface.py b/tests/agent/test_builtin_memory_disabled_surface.py index 8c3d5fe91e..f87adb53ac 100644 --- a/tests/agent/test_builtin_memory_disabled_surface.py +++ b/tests/agent/test_builtin_memory_disabled_surface.py @@ -245,20 +245,20 @@ class TestExternalProviderSurvivesBuiltinDisable: """ def test_provider_tools_enabled_when_memory_toolset_requested(self): - from agent.memory_manager import memory_provider_tools_enabled + from agent.memory_manager_tools import memory_provider_tools_enabled assert memory_provider_tools_enabled( ["memory", "file"], None, memory_tool_present=False ) def test_provider_tools_enabled_for_unrestricted_toolsets(self): - from agent.memory_manager import memory_provider_tools_enabled + from agent.memory_manager_tools import memory_provider_tools_enabled assert memory_provider_tools_enabled(None, None, memory_tool_present=False) def test_disabled_toolsets_still_takes_everything_down(self): """The heavy switch keeps its documented meaning.""" - from agent.memory_manager import memory_provider_tools_enabled + from agent.memory_manager_tools import memory_provider_tools_enabled assert not memory_provider_tools_enabled( None, ["memory"], memory_tool_present=False @@ -271,7 +271,8 @@ class TestInjectionEndToEnd: def test_provider_tools_injected_without_builtin_memory_tool(self): from types import SimpleNamespace - from agent.memory_manager import MemoryManager, inject_memory_provider_tools + from agent.memory_manager import MemoryManager + from agent.memory_manager_tools import inject_memory_provider_tools from agent.memory_provider import MemoryProvider class _Provider(MemoryProvider): diff --git a/tests/agent/test_error_classifier_reasoning_level.py b/tests/agent/test_error_classifier_reasoning_level.py new file mode 100644 index 0000000000..266ad91051 --- /dev/null +++ b/tests/agent/test_error_classifier_reasoning_level.py @@ -0,0 +1,58 @@ +"""A 400 that names the reasoning field and refuses its *value* against a closed level set. + +The classifier routes it to ``reasoning_mandatory`` (not the ``format_error`` abort), and the +main-loop rung reacts to what was actually sent: a disable steps up to the floor, an enabled level +the route does not offer (the custom profile's unset default ``medium`` against "off, low, high, or +max") is dropped for the session so the route default applies. +""" +import pytest + +from agent.error_classifier import FailoverReason, classify_api_error +from tests.agent.test_reasoning_disable_recovery import _recover, _WireAgent, _wire_reasoning_config + +# Alibaba token-plan / DashScope: the set starts at ``minimal``, so ``none`` is refused. +_TOKENPLAN_400 = ( + "Error code: 400 - {'error':{'code':'invalid_parameter_error','param':None," + "'message':\"'reasoning_effort' must be one of: 'minimal', 'low', 'medium', 'high', " + "'xhigh', 'max', 'ultra'\",'type':'invalid_request_error'}}" +) +# A custom OpenAI-compatible endpoint whose set has no ``medium`` (deepseek-v4.1-flash-fast). +_OFF_LOW_HIGH_MAX_400 = "HTTP 400: reasoning_effort must be one of off, low, high, or max." +# Groq gpt-oss: graded levels only. +_GROQ_400 = ( + "Error code: 400 - {'error': {'message': \"'reasoning_effort' must be one of 'low', " + "'medium', or 'high'\", 'type': 'invalid_request_error'}}" +) + + +class _APIError(Exception): + def __init__(self, message, status_code): + super().__init__(message) + self.status_code = status_code + + +@pytest.mark.parametrize("body", [_TOKENPLAN_400, _OFF_LOW_HIGH_MAX_400, _GROQ_400]) +def test_closed_level_set_rejection_is_reasoning_mandatory(body): + result = classify_api_error(_APIError(body, 400), provider="custom", model="m") + assert result.reason == FailoverReason.reasoning_mandatory + assert result.retryable is True and result.should_fallback is False + + +@pytest.mark.parametrize("body", [ + # The same enum wording about another field is not a reasoning rejection. + "Error code: 400 - 'tool_choice' must be one of none, auto, required", + # A paired-field constraint, not a closed level set. + "Error code: 400 - 'reasoning_effort' must be 'none' when 'enable_thinking' is false", +]) +def test_other_enum_wording_keeps_its_classification(body): + result = classify_api_error(_APIError(body, 400), provider="custom", model="m") + assert result.reason != FailoverReason.reasoning_mandatory + + +@pytest.mark.parametrize("body", [_OFF_LOW_HIGH_MAX_400, _GROQ_400]) +def test_rejected_enabled_level_retries_with_the_route_default(body): + agent = _WireAgent({"enabled": True, "effort": "medium"}) + assert _wire_reasoning_config(agent) == {"enabled": True, "effort": "medium"} # the request that 400ed + _recover(agent, body) + assert _wire_reasoning_config(agent) is None, "retry must not resend reasoning_effort=medium" + assert any("rejects reasoning effort medium" in n for n in agent.notices) diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index a6da59e632..6fa1165d55 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -8,7 +8,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock from agent.memory_provider import MemoryProvider -from agent.memory_manager import MemoryManager, inject_memory_provider_tools +from agent.memory_manager import MemoryManager +from agent.memory_manager_tools import inject_memory_provider_tools # --------------------------------------------------------------------------- # Concrete test provider @@ -452,7 +453,11 @@ class TestMemoryManager: # -- Error resilience --------------------------------------------------- - def test_external_prefetch_timeout_skips_stuck_provider(self): + def test_external_prefetch_timeout_skips_stuck_provider(self, monkeypatch): + import agent.memory_manager as mm + + monkeypatch.setattr(mm, "_MAX_STUCK_CALLS", 1) # the gate closes on the first stuck call + monkeypatch.setattr(mm, "_PROBE_INTERVAL_S", 1e9) mgr = MemoryManager(external_prefetch_timeout=0.01) builtin = FakeMemoryProvider("builtin") builtin._prefetch_result = "builtin memory" @@ -481,19 +486,151 @@ class TestMemoryManager: external.release.set() deadline = time.monotonic() + 1.0 - while ( - external.name in mgr._external_prefetch_threads - and mgr._external_prefetch_threads[external.name].is_alive() - and time.monotonic() < deadline - ): + while (mgr._home_key, external.name) in mgr._PREFETCH_GATE.calls and time.monotonic() < deadline: time.sleep(0.01) result = mgr.prefetch_all("query 3") assert result == "builtin memory\n\nlate external memory" assert external.prefetch_queries == ["query", "query 3"] - assert external.name not in mgr._external_prefetch_threads + assert (mgr._home_key, external.name) not in mgr._PREFETCH_GATE.calls + def test_stuck_prefetch_thread_is_shared_across_managers(self, tmp_path, monkeypatch): + """#134392: a new MemoryManager is built per agent init, so the in-flight + registry must be process-wide — fresh managers against a wedged provider keep at + most ``_MAX_STUCK_CALLS`` live ``memory-prefetch-*`` threads, not one per manager. It + is keyed per Hermes home: another profile's same-named provider still recalls. + """ + import agent.memory_manager as mm + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + monkeypatch.setattr(mm, "_PROBE_INTERVAL_S", 1e9) + stuck = mm._MAX_STUCK_CALLS + external = BlockingPrefetchProvider("cross-manager-stuck") + try: + for i in range(stuck + 3): + mgr = MemoryManager(external_prefetch_timeout=0.01) + mgr.add_provider(external) + mgr._prefetch_provider(external, "query", session_id=f"s{i}") + + live = [ + t for t in threading.enumerate() + if t.name.startswith("memory-prefetch-cross-manager-stuck") + ] + assert len(live) == stuck + assert external.prefetch_queries == ["query"] * stuck + + other_profile = FakeMemoryProvider("cross-manager-stuck") + other_profile._prefetch_result = "profile b memory" + token = set_hermes_home_override(tmp_path / "profile-b") + try: + mgr_b = MemoryManager(external_prefetch_timeout=1.0) + finally: + reset_hermes_home_override(token) + mgr_b.add_provider(other_profile) + assert mgr_b._prefetch_provider(other_profile, "q", session_id="b") == "profile b memory" + + # Once the stuck calls return, the shared registry clears and a fresh + # manager prefetches again. + external.release.set() + for thread in live: + thread.join(timeout=2.0) + assert not thread.is_alive() + assert (mgr._home_key, external.name) not in MemoryManager._PREFETCH_GATE.calls + mgr = MemoryManager(external_prefetch_timeout=1.0) + mgr.add_provider(external) + mgr._prefetch_provider(external, "after", session_id="s-after") + assert external.prefetch_queries == ["query"] * stuck + ["after"] + finally: + external.release.set() + + def test_healthy_replacement_recalls_behind_an_orphaned_prefetch(self): + """One prefetch that never returns must not blank recall for the process lifetime: a fresh + manager with a healthy provider instance (the backend recovered) still recalls.""" + orphan = BlockingPrefetchProvider("orphaned-recall") + stuck_mgr = MemoryManager(external_prefetch_timeout=0.01) + stuck_mgr.add_provider(orphan) + try: + stuck_mgr._prefetch_provider(orphan, "q", session_id="stuck") + assert orphan.started.wait(timeout=2.0) + healthy = FakeMemoryProvider("orphaned-recall") + healthy._prefetch_result = "recalled" + for i in range(5): + mgr = MemoryManager(external_prefetch_timeout=5.0) + mgr.add_provider(healthy) + assert mgr._prefetch_provider(healthy, "q", session_id=f"s{i}") == "recalled" + finally: + orphan.release.set() + + def test_prefetch_orphans_from_past_outages_do_not_blank_recall(self, monkeypatch): + """Four separate blips, each leaving one prefetch that never returns and each followed by a + successful recall, must not turn recall off: orphans older than the last success don't count.""" + import agent.memory_manager as mm + + monkeypatch.setattr(mm, "_PROBE_INTERVAL_S", 1e9) + healthy = FakeMemoryProvider("past-outages") + healthy._prefetch_result = "recalled" + orphans = [] + try: + for i in range(mm._MAX_STUCK_CALLS): + orphan = BlockingPrefetchProvider("past-outages") + orphans.append(orphan) + stuck_mgr = MemoryManager(external_prefetch_timeout=0.01) + stuck_mgr.add_provider(orphan) + stuck_mgr._prefetch_provider(orphan, "q", session_id=f"blip{i}") + assert orphan.started.wait(timeout=2.0) + mgr = MemoryManager(external_prefetch_timeout=5.0) + mgr.add_provider(healthy) + assert mgr._prefetch_provider(healthy, "q", session_id=f"recovered{i}") == "recalled" + fresh = MemoryManager(external_prefetch_timeout=5.0) + fresh.add_provider(healthy) + assert fresh._prefetch_provider(healthy, "q", session_id="after") == "recalled" + finally: + for orphan in orphans: + orphan.release.set() + + def test_registry_is_keyed_by_the_home_providers_were_bound_to(self, tmp_path, monkeypatch): + """Two managers built in one ambient home but initialized for profiles A and B: A's stuck + prefetch must not gate B, whose providers belong to another profile.""" + import agent.memory_manager as mm + + monkeypatch.setattr(mm, "_MAX_STUCK_CALLS", 1) + monkeypatch.setattr(mm, "_PROBE_INTERVAL_S", 1e9) + stuck, other = BlockingPrefetchProvider("bound-home"), FakeMemoryProvider("bound-home") + other._prefetch_result = "profile b memory" + mgr_a = MemoryManager(external_prefetch_timeout=0.01) + mgr_b = MemoryManager(external_prefetch_timeout=5.0) + for mgr, provider, home in ((mgr_a, stuck, "a"), (mgr_b, other, "b")): + mgr.add_provider(provider) + mgr.initialize_all(session_id=home, hermes_home=str(tmp_path / home)) + try: + mgr_a._prefetch_provider(stuck, "q", session_id="a") + assert stuck.started.wait(timeout=2.0) + assert mgr_b._prefetch_provider(other, "q", session_id="b") == "profile b memory" + finally: + stuck.release.set() + + def test_healthy_concurrent_prefetches_from_two_sessions_both_recall(self): + """Only a TIMED-OUT call gates other managers: a slow-but-healthy prefetch in one session + must not blank the recall of another session that prefetches meanwhile.""" + first = BlockingPrefetchProvider("concurrent-healthy") + first._prefetch_result = "session a memory" + second = FakeMemoryProvider("concurrent-healthy") + second._prefetch_result = "session b memory" + mgr_a = MemoryManager(external_prefetch_timeout=5.0) + mgr_a.add_provider(first) + mgr_b = MemoryManager(external_prefetch_timeout=5.0) + mgr_b.add_provider(second) + results = {} + worker = threading.Thread(target=lambda: results.update(a=mgr_a.prefetch_all("q", session_id="a"))) + worker.start() + try: + assert first.started.wait(timeout=2.0) + results["b"] = mgr_b.prefetch_all("q", session_id="b") + finally: + first.release.set() + worker.join(timeout=5.0) + assert results == {"a": "session a memory", "b": "session b memory"} class TestPluginMemoryDiscovery: """Memory providers are discovered from plugins/memory/ directory.""" @@ -1151,7 +1288,7 @@ class TestNormalizeToolSchema: def test_non_dict_rejected(self): - from agent.memory_manager import normalize_tool_schema + from agent.memory_manager_tools import normalize_tool_schema assert normalize_tool_schema("nope") is None assert normalize_tool_schema(None) is None @@ -1262,24 +1399,24 @@ class TestSystemPromptGateParity: return agent, mgr, provider def test_tools_exposed_when_memory_in_enabled_toolsets(self): - from agent.memory_manager import memory_provider_tools_exposed + from agent.memory_manager_tools import memory_provider_tools_exposed agent, _mgr, _p = self._agent_with_provider(enabled_toolsets=["memory"]) assert memory_provider_tools_exposed(agent) is True def test_tools_hidden_when_memory_in_disabled_toolsets(self): - from agent.memory_manager import memory_provider_tools_exposed + from agent.memory_manager_tools import memory_provider_tools_exposed agent, _mgr, _p = self._agent_with_provider(disabled_toolsets=["memory"]) assert memory_provider_tools_exposed(agent) is False def test_tools_hidden_when_memory_not_in_enabled_toolsets(self): - from agent.memory_manager import memory_provider_tools_exposed + from agent.memory_manager_tools import memory_provider_tools_exposed agent, _mgr, _p = self._agent_with_provider(enabled_toolsets=["web_search"]) assert memory_provider_tools_exposed(agent) is False def test_tools_exposed_when_memory_tool_already_present(self): """The built-in "memory" tool is a sufficient opt-in even when the toolset gate says nothing about memory.""" - from agent.memory_manager import memory_provider_tools_exposed + from agent.memory_manager_tools import memory_provider_tools_exposed tools = [ {"type": "function", "function": {"name": "memory", "description": "x", "parameters": {}}}, ] @@ -1290,7 +1427,7 @@ class TestSystemPromptGateParity: """``inject_memory_provider_tools`` and ``memory_provider_tools_exposed`` must agree — both determine whether provider tools are presented to the model (#81014).""" - from agent.memory_manager import inject_memory_provider_tools, memory_provider_tools_exposed + from agent.memory_manager_tools import inject_memory_provider_tools, memory_provider_tools_exposed # Disabled toolsets — neither path should add or advertise provider tools. agent, _mgr, _p = self._agent_with_provider(disabled_toolsets=["memory"]) diff --git a/tests/agent/test_memory_stalled_background_call.py b/tests/agent/test_memory_stalled_background_call.py new file mode 100644 index 0000000000..5386245ba0 --- /dev/null +++ b/tests/agent/test_memory_stalled_background_call.py @@ -0,0 +1,277 @@ +"""A background provider call that never returns pins its mem-sync worker thread forever (Python cannot +stop a thread), and MemoryManagers are rebuilt per agent. The process-wide gate skips calls once +_MAX_STUCK_CALLS calls that started after the provider's last success are stuck, admits one half-open +probe per interval while closed, and caps calls in flight, so a wedged backend pins a bounded number of +threads and a recovered one keeps its memory.""" +import threading + +import pytest + +import agent.memory_manager as mm +from agent.memory_manager import MemoryManager +from agent.memory_provider import MemoryProvider +from tests.waiting import wait_until + + +class _HangingProvider(MemoryProvider): + def __init__(self, name: str = "hangs"): + self._name = name + self.release = threading.Event() + self.started = threading.Event() + self.sync_calls = 0 + self.prefetch_calls = 0 + self.hang_next = True + + @property + def name(self) -> str: + return self._name + + def initialize(self, session_id: str = "", **kwargs) -> None: + pass + + def is_available(self) -> bool: + return True + + def system_prompt_block(self) -> str: + return "" + + def prefetch(self, query, *, session_id: str = "") -> str: + return "" + + def get_tool_schemas(self): + return [] + + def queue_prefetch(self, query, *, session_id: str = "") -> None: + self.prefetch_calls += 1 + + def sync_turn(self, user_content, assistant_content, *, session_id: str = "") -> None: + self.sync_calls += 1 + if self.hang_next: + self.hang_next = False + self.started.set() + self.release.wait(10) + + +_GATE = MemoryManager._BACKGROUND_GATE + + +def _clear_gate(): + for state in (_GATE.calls, _GATE.last_ok, _GATE.probe_at, _GATE.skips): + state.clear() + + +@pytest.fixture(autouse=True) +def _short_stall(monkeypatch): + monkeypatch.setattr(mm, "_BACKGROUND_CALL_STALL_S", 0.0) + monkeypatch.setattr(mm, "_PROBE_INTERVAL_S", 1e9) # no half-open probe unless a test asks for one + _clear_gate() + yield + _clear_gate() + + +def _manager(provider): + mgr = MemoryManager() + mgr.add_provider(provider) + return mgr + + +def _mem_sync_threads(): + return [t for t in threading.enumerate() if t.name.startswith("mem-sync")] + + +def _hang_one_call(name="hangs"): + """A manager whose provider's first sync_turn never returns (until released).""" + provider = _HangingProvider(name) + mgr = _manager(provider) + mgr.sync_all("hello", "hi") + assert provider.started.wait(5) + return mgr, provider + + +def _skip(mgr, text): + """sync_all runs the gate on the manager's mem-sync worker; wait for it so the skip is recorded.""" + mgr.sync_all(text, "x") + assert mgr.flush_pending(timeout=5) + mgr.shutdown_all() + + +def test_sustained_wedge_pins_at_most_max_stuck_threads(): + """A backend that stays wedged: every call hangs. Calls keep being admitted until + _MAX_STUCK_CALLS are stuck, then later managers are skipped, so pinned threads stay bounded.""" + hung = [_hang_one_call() for _ in range(mm._MAX_STUCK_CALLS)] + before = len(_mem_sync_threads()) + + for _ in range(5): + fresh = _manager(_HangingProvider()) + fresh.sync_all("again", "reply") + fresh.queue_prefetch_all("query") + assert fresh.flush_pending(timeout=5) # returns: nothing blocked behind the hung calls + fresh.shutdown_all() + + key = hung[0][0]._registry_key(hung[0][1]) + try: + assert _GATE.skips[key] == 10 + assert len(_GATE.calls[key]) == mm._MAX_STUCK_CALLS + # Idle workers of shut-down executors exit asynchronously. + wait_until(lambda: len(_mem_sync_threads()) <= before, timeout=5, what="idle mem-sync workers to exit") + finally: + for _, provider in hung: + provider.release.set() + for mgr, _ in hung: + assert mgr.flush_pending(timeout=5) + assert key not in _GATE.calls + for mgr, _ in hung: + mgr.shutdown_all() + + +def test_healthy_replacement_still_writes_behind_an_orphaned_call(): + """One call that never returns must not silence the provider for the process lifetime: a fresh + manager with a healthy provider instance (the backend recovered) keeps writing.""" + orphan_mgr, orphan = _hang_one_call() + healthy = _HangingProvider() + healthy.hang_next = False + try: + for _ in range(5): + fresh = _manager(healthy) + fresh.sync_all("again", "reply") + fresh.queue_prefetch_all("query") + assert fresh.flush_pending(timeout=5) + fresh.shutdown_all() + assert (healthy.sync_calls, healthy.prefetch_calls) == (5, 5) + finally: + orphan.release.set() + orphan_mgr.shutdown_all() + + +def test_new_outage_warns_on_its_first_skip_even_with_an_orphan_alive(caplog): + """Orphans keep the key alive after the gate reopens; the next outage must still warn on + its first skip instead of waiting for the 100th.""" + hung = [_hang_one_call() for _ in range(mm._MAX_STUCK_CALLS)] + key = hung[0][0]._registry_key(hung[0][1]) + try: + _skip(_manager(_HangingProvider()), "skipped") + assert _GATE.skips[key] == 1 + hung[-1][1].release.set() # one returns: the gate reopens, three orphans keep the key alive + assert hung[-1][0].flush_pending(timeout=5) + hung += [_hang_one_call() for _ in range(mm._MAX_STUCK_CALLS)] # a new outage + caplog.clear() + with caplog.at_level("WARNING", logger="agent.memory_manager"): + _skip(_manager(_HangingProvider()), "skipped again") + assert _GATE.skips[key] == 1 + assert "background calls stuck" in caplog.text + finally: + for _, provider in hung: + provider.release.set() + for mgr, _ in hung: + mgr.shutdown_all() + + +def _orphan_then_recover(orphans): + """Each outage leaves one call that never returns, then the backend recovers for one turn.""" + for _ in range(mm._MAX_STUCK_CALLS): + orphans.append(_hang_one_call()) + recovered = _HangingProvider() + recovered.hang_next = False + _skip(_manager(recovered), "recovered") + + +def test_orphans_from_past_outages_do_not_close_the_gate(): + """Four separate blips over the process lifetime, each followed by recovery, must not turn the + provider off: a stuck call that started before the last success no longer counts.""" + orphans = [] + healthy = _HangingProvider() + healthy.hang_next = False + try: + _orphan_then_recover(orphans) + for _ in range(3): + fresh = _manager(healthy) + fresh.sync_all("again", "reply") + fresh.queue_prefetch_all("query") + assert fresh.flush_pending(timeout=5) + fresh.shutdown_all() + assert (healthy.sync_calls, healthy.prefetch_calls) == (3, 3) + finally: + for _, provider in orphans: + provider.release.set() + for mgr, _ in orphans: + mgr.shutdown_all() + + +def test_closed_gate_admits_a_half_open_probe_that_reopens_it(monkeypatch): + """With the gate closed by calls that never return, one probe per interval goes through; once a + probe succeeds (the backend recovered) every later call is admitted again.""" + hung = [_hang_one_call() for _ in range(mm._MAX_STUCK_CALLS)] + healthy = _HangingProvider() + healthy.hang_next = False + try: + _skip(_manager(healthy), "closed") + assert healthy.sync_calls == 0 # gate closed, probe interval not yet reached + monkeypatch.setattr(mm, "_PROBE_INTERVAL_S", 0.0) + _skip(_manager(healthy), "probe") + assert healthy.sync_calls == 1 + monkeypatch.setattr(mm, "_PROBE_INTERVAL_S", 1e9) # no further probe: only a reopened gate admits + _skip(_manager(healthy), "after the probe") + assert healthy.sync_calls == 2 + finally: + for _, provider in hung: + provider.release.set() + for mgr, _ in hung: + mgr.shutdown_all() + + +def test_failed_probe_does_not_restart_the_outage_warning(monkeypatch): + """A probe that wedges too is not a recovery: the skip count keeps running, so a long outage + warns on its first skip and every 100th, not after every probe.""" + hung = [_hang_one_call() for _ in range(mm._MAX_STUCK_CALLS)] + key = hung[0][0]._registry_key(hung[0][1]) + try: + _skip(_manager(_HangingProvider()), "closed") + monkeypatch.setattr(mm, "_PROBE_INTERVAL_S", 0.0) + hung.append(_hang_one_call()) # the probe is admitted and hangs + monkeypatch.setattr(mm, "_PROBE_INTERVAL_S", 1e9) + _skip(_manager(_HangingProvider()), "still closed") + assert _GATE.skips[key] == 2 + finally: + for _, provider in hung: + provider.release.set() + for mgr, _ in hung: + mgr.shutdown_all() + + +def test_sustained_wedge_with_probes_never_exceeds_the_inflight_cap(monkeypatch): + """Every failed probe pins one more thread; at the cap, probing stops, so a backend that stays + wedged pins a bounded number of threads however long it lasts.""" + monkeypatch.setattr(mm, "_PROBE_INTERVAL_S", 0.0) + monkeypatch.setattr(mm, "_MAX_INFLIGHT_CALLS", mm._MAX_STUCK_CALLS + 2) + hung = [_hang_one_call() for _ in range(mm._MAX_STUCK_CALLS)] + key = hung[0][0]._registry_key(hung[0][1]) + try: + for _ in range(2): # two probes admitted, and they wedge too + hung.append(_hang_one_call()) + for turn in range(5): + _skip(_manager(_HangingProvider()), f"capped {turn}") + assert len(_GATE.calls[key]) == mm._MAX_INFLIGHT_CALLS + assert _GATE.skips[key] == 5 + finally: + for _, provider in hung: + provider.release.set() + for mgr, _ in hung: + mgr.shutdown_all() + + +def test_slow_call_below_threshold_is_not_skipped(monkeypatch): + monkeypatch.setattr(mm, "_BACKGROUND_CALL_STALL_S", 60.0) + provider = _HangingProvider() + first = _manager(provider) + first.sync_all("hello", "hi") + assert provider.started.wait(5) + + second = _manager(provider) + try: + second.sync_all("again", "reply") + assert second.flush_pending(timeout=5) + assert provider.sync_calls == 2 + finally: + provider.release.set() + for m in (first, second): + m.shutdown_all() diff --git a/tests/fakes/providers/oauth_token_server.py b/tests/fakes/providers/oauth_token_server.py index e32207d535..722918bedd 100644 --- a/tests/fakes/providers/oauth_token_server.py +++ b/tests/fakes/providers/oauth_token_server.py @@ -298,8 +298,18 @@ class TestCA: leaf_key: Path +def _san(host: str): + import ipaddress + + from cryptography import x509 + try: + return x509.IPAddress(ipaddress.ip_address(host)) + except ValueError: + return x509.DNSName(host) + + def make_test_ca(directory: Path, hosts: Iterable[str]) -> TestCA: - """Generate a CA and a leaf cert for ``hosts`` (SAN) under ``directory``.""" + """Generate a CA and a leaf cert for ``hosts`` (DNS or IP SAN) under ``directory``.""" from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec @@ -328,7 +338,7 @@ def make_test_ca(directory: Path, hosts: Iterable[str]) -> TestCA: .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hosts[0])])) .issuer_name(ca_name).public_key(leaf_key.public_key()).serial_number(x509.random_serial_number()) .not_valid_before(now - _dt.timedelta(minutes=5)).not_valid_after(now + _dt.timedelta(days=1)) - .add_extension(x509.SubjectAlternativeName([x509.DNSName(h) for h in hosts]), critical=False) + .add_extension(x509.SubjectAlternativeName([_san(h) for h in hosts]), critical=False) .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False) diff --git a/tests/gateway/fixtures/kanban_recovery_probe.py b/tests/gateway/fixtures/kanban_recovery_probe.py index 727e268176..721d1b82c1 100644 --- a/tests/gateway/fixtures/kanban_recovery_probe.py +++ b/tests/gateway/fixtures/kanban_recovery_probe.py @@ -138,7 +138,9 @@ def main(): (owner / 'config.yaml').write_text(json.dumps(cfg)) with daemon(root, owner, env, barrier=False) as (proc, desc): answer = asyncio.run(retry(desc, params)) - assert answer['receipt']['status'] == 'unknown', answer + # #134889 KB-8: nobody waits on a card attempt, so the lost turn settles + # interrupted at boot (never replayed) instead of pausing the card session. + assert (answer['receipt']['status'], answer['receipt']['outcome']) == ('terminal', 'interrupted'), answer assert answer['session_id'] == first['target_session_id'] with closing(sqlite3.connect((owner / 'state.db').as_uri() + '?mode=ro', uri=True)) as db: frozen = json.loads(db.execute('SELECT value FROM state_meta WHERE key=?', diff --git a/tests/gateway/test_agent_cache_memory_lifecycle.py b/tests/gateway/test_agent_cache_memory_lifecycle.py new file mode 100644 index 0000000000..adfeb44078 --- /dev/null +++ b/tests/gateway/test_agent_cache_memory_lifecycle.py @@ -0,0 +1,206 @@ +"""Memory-provider lifecycle of cached gateway agents: soft eviction (``release_clients``) versus +end-of-session teardown (``shutdown_memory_provider`` / ``close``).""" + +import threading + +import pytest + +from tests.gateway.test_agent_cache import _make_runner + + +class TestAgentCacheMemoryLifecycle: + @staticmethod + def _agent_with_writer_provider(session_id, events): + """Real AIAgent + real MemoryManager whose provider owns a writer thread released only by shutdown().""" + import queue + from agent.memory_manager import MemoryManager + from agent.memory_provider import MemoryProvider + from run_agent import AIAgent + + class _WriterProvider(MemoryProvider): + def __init__(self): + self._q = queue.Queue() + self.writer = threading.Thread(target=self._run, name="test-mem-writer", daemon=True) + self.writer.start() + + def _run(self): + while self._q.get() is not None: + pass + + @property + def name(self): + return "writerstub" + + def is_available(self): + return True + + def initialize(self, session_id, **kwargs): + pass + + def get_tool_schemas(self): + return [] + + def on_session_end(self, messages): + events.append("on_session_end") + + def shutdown(self): + events.append("shutdown") + self._q.put(None) + self.writer.join(timeout=5) + + agent = AIAgent( + model="anthropic/claude-sonnet-4", api_key="test", + base_url="https://openrouter.ai/api/v1", provider="openrouter", + max_iterations=5, quiet_mode=True, + skip_context_files=True, skip_memory=True, session_id=session_id, + ) + provider = _WriterProvider() + manager = MemoryManager() + manager.add_provider(provider) + agent._memory_manager = manager + return agent, provider + + def test_release_clients_joins_real_provider_writer_thread(self): + """Soft eviction must stop a real provider's writer thread (the leak), without extraction.""" + events = [] + agent, provider = self._agent_with_writer_provider("mem-writer-evict-test", events) + assert provider.writer.is_alive() + agent.release_clients() + agent.release_clients() # idempotent: the teardown claim is once-only + assert not provider.writer.is_alive() + agent.close() # a later hard close neither re-extracts nor shuts down twice + assert events == ["shutdown"] + + def test_release_clients_lets_queued_writes_finish_before_shutdown(self, monkeypatch): + """Soft release must not cancel the retired agent's queued memory writes: on main the worker + finished them in the background, and shutdown_all()'s bounded drain alone would abandon them.""" + import agent.memory_manager as mm + from tests.waiting import wait_until + + monkeypatch.setattr(mm, "_SYNC_DRAIN_TIMEOUT_S", 0.0) + events = [] + agent, provider = self._agent_with_writer_provider("mem-release-flush-test", events) + manager, gate, flushing = agent._memory_manager, threading.Event(), threading.Event() + provider.sync_turn = lambda user, assistant, **kw: (gate.wait(10), events.append(f"sync:{user}")) + real_flush = manager.flush_pending + manager.flush_pending = lambda timeout=None: (flushing.set(), real_flush(timeout))[1] + for turn in ("a", "b", "c"): + manager.sync_all(turn, "reply") + releaser = threading.Thread(target=agent.release_clients) + releaser.start() + try: + wait_until(lambda: flushing.is_set() or not releaser.is_alive(), what="the soft release to start") + finally: + gate.set() + releaser.join(timeout=10) + assert events == ["sync:a", "sync:b", "sync:c", "shutdown"] + + def test_close_racing_an_in_progress_soft_release_does_not_reextract(self): + """close() arriving while release_clients() is still inside the provider shutdown must not + start extraction or a second shutdown against the providers being torn down.""" + events = [] + agent, provider = self._agent_with_writer_provider("mem-release-race-test", events) + in_shutdown, let_finish = threading.Event(), threading.Event() + real_shutdown = provider.shutdown + + def slow_shutdown(): + in_shutdown.set() + assert let_finish.wait(5) + real_shutdown() + + provider.shutdown = slow_shutdown + releaser = threading.Thread(target=agent.release_clients) + releaser.start() + try: + assert in_shutdown.wait(5) + agent.close() + finally: + let_finish.set() + releaser.join(timeout=5) + assert events == ["shutdown"] + + def test_gateway_soft_evict_commits_memory_before_provider_shutdown(self): + """The sweep eviction path must run end-of-session extraction BEFORE release_clients() + shuts the providers down (a shut-down provider can no longer commit).""" + events = [] + agent, provider = self._agent_with_writer_provider("mem-commit-order-test", events) + runner = _make_runner() + try: + runner._commit_then_release_soft(agent, "agent:main:telegram:dm:1") + assert events == ["on_session_end", "shutdown"] + assert not provider.writer.is_alive() + finally: + agent.close() + + @pytest.mark.asyncio + async def test_eviction_after_timed_out_reset_cleanup_leaves_shutdown_to_it(self, monkeypatch): + """/new gives up waiting on its cleanup worker after the timeout and then evicts the same agent. + The worker is still inside on_session_end(), so the eviction must not shut the providers + down under that extraction, and the providers must be shut down exactly once, after it.""" + import gateway.slash_commands_session as session_cmds + from tests.waiting import wait_until + + events = [] + agent, provider = self._agent_with_writer_provider("mem-reset-evict-race-test", events) + in_session_end, let_finish = threading.Event(), threading.Event() + + def slow_session_end(messages): + events.append("on_session_end:start") + in_session_end.set() + assert let_finish.wait(10) + events.append("on_session_end:done") + + provider.on_session_end = slow_session_end + agent._session_messages = [{"role": "user", "content": "remember this"}] + key = "agent:main:telegram:dm:1" + runner = _make_runner() + runner._agent_cache[key] = (agent, "sig") + # Only a deadline for giving up on the worker: on_session_end blocks until let_finish, so + # /new always times out, however slow the runner. + monkeypatch.setattr(session_cmds, "_RESET_CLEANUP_TIMEOUT_S", 0.05) + try: + await runner._cleanup_old_agent_for_reset(key) + assert in_session_end.wait(10) + runner._evict_cached_agent(key) + wait_until(lambda: agent._session_messages == [], what="the soft eviction to finish") + assert events == ["on_session_end:start"], "eviction shut providers down mid-extraction" + assert provider.writer.is_alive() + finally: + let_finish.set() + runner._housekeeping_executor.shutdown(wait=True) + assert events == ["on_session_end:start", "on_session_end:done", "shutdown"] + assert not provider.writer.is_alive() + + @pytest.mark.asyncio + async def test_eviction_while_reset_cleanup_is_still_flushing_leaves_shutdown_to_it(self, monkeypatch): + """/new's cleanup job can still be in its pre-extraction flush (up to 10 s) when /new stops + waiting and evicts the agent; that eviction must not shut the providers down first.""" + import gateway.slash_commands_session as session_cmds + from tests.waiting import wait_until + + events = [] + agent, provider = self._agent_with_writer_provider("mem-reset-flush-test", events) + agent._session_messages = [{"role": "user", "content": "remember this"}] + in_flush, let_finish = threading.Event(), threading.Event() + + def slow_flush(timeout=None): + in_flush.set() + assert let_finish.wait(10) + return True + + agent._memory_manager.flush_pending = slow_flush + key = "agent:main:telegram:dm:1" + runner = _make_runner() + runner._agent_cache[key] = (agent, "sig") + monkeypatch.setattr(session_cmds, "_RESET_CLEANUP_TIMEOUT_S", 0.05) + try: + await runner._cleanup_old_agent_for_reset(key) + assert in_flush.wait(10) + runner._evict_cached_agent(key) + wait_until(lambda: agent._session_messages == [], what="the soft eviction to finish") + assert events == [], "eviction shut providers down before the /new cleanup extracted" + finally: + let_finish.set() + runner._housekeeping_executor.shutdown(wait=True) + assert events == ["on_session_end", "shutdown"] + assert not provider.writer.is_alive() diff --git a/tests/gateway/test_docker_session_workspace.py b/tests/gateway/test_docker_session_workspace.py index a25438e61e..fad26ffefd 100644 --- a/tests/gateway/test_docker_session_workspace.py +++ b/tests/gateway/test_docker_session_workspace.py @@ -236,6 +236,51 @@ def test_side_agent_runs_in_its_sessions_workspace(home, tmp_path, persistent): assert terminal_tool.resolve_task_overrides('bg_123456') == {} # released with the side task +def test_secondary_profiles_side_agent_never_gets_the_launch_profiles_workspace_container(home, tmp_path, monkeypatch): + # Review (@dokterdok, round 2): beta's /bg in a folder with a live default session got default's env. + from types import SimpleNamespace + from gateway.session_tui_side import _side_scope + from tools import terminal_tool, terminal_tool_lifecycle + from tools.terminal_tool import workspace_key_for + ws1 = tmp_path / 'ws1' + ws1.mkdir() + beta = home / 'profiles' / 'beta' + beta.mkdir(parents=True) + monkeypatch.setattr(terminal_tool, '_active_environments', {}) + monkeypatch.setattr(terminal_tool, '_last_activity', {}) + policy = _policy(home, ws1, persistent=True) + with _turn(policy, 'local-a'): + launch_key = _plan('local-a')[0] + terminal_tool._active_environments[launch_key] = launch_env = object() + authority = SimpleNamespace(profile_id=str(beta), + runner=SimpleNamespace(config=SimpleNamespace(multiplex_profiles=True))) + with _side_scope(authority, policy, 'bg_123456'): + key = _plan('bg_123456')[0] + env = terminal_tool_lifecycle.get_active_env('bg_123456') + # Beta's own workspace container, keyed as beta's routed turns key it; never default's live one. + assert key == f'profile:beta:ws:{workspace_key_for(str(ws1))}' + assert env is not launch_env + + +def test_named_launch_profiles_side_agent_keeps_its_sessions_workspace_container(home, tmp_path, monkeypatch): + # The launch home's own turns bind no profile (``agent:main``), whatever its name: its side agent + # must key the same container as they do, not ``profile:``. + from types import SimpleNamespace + from gateway.session_tui_side import _side_scope + ws1 = tmp_path / 'ws1' + ws1.mkdir() + beta = home / 'profiles' / 'beta' + beta.mkdir(parents=True) + monkeypatch.setenv('HERMES_HOME', str(beta)) # `hermes -p beta gateway` + policy = _policy(beta, ws1, persistent=True) + with _turn(policy, 'local-a'): + session_key = _plan('local-a')[0] + authority = SimpleNamespace(profile_id=str(beta), + runner=SimpleNamespace(config=SimpleNamespace(multiplex_profiles=True))) + with _side_scope(authority, policy, 'bg_123456'): + assert _plan('bg_123456')[0] == session_key + + def test_managed_worker_turn_runs_in_its_sessions_workspace(home, tmp_path, monkeypatch): # Review (@kshitijk4poor S2): out-of-process (managed worker) turns never bound the workspace. from gateway import session_kanban @@ -258,3 +303,55 @@ def test_managed_worker_turn_runs_in_its_sessions_workspace(home, tmp_path, monk assert seen['task_id'] == 'worker-s1' assert ':ws:' in seen['plan'][0] assert seen['plan'][1] == str(ws1) + + +def test_secondary_profiles_managed_worker_turn_keys_its_own_workspace_container(home, tmp_path, monkeypatch): + from gateway import session_kanban + from gateway.session_policy import policy_scope + from tools.terminal_tool import workspace_key_for + ws1 = tmp_path / 'ws1' + ws1.mkdir() + beta = home / 'profiles' / 'beta' + beta.mkdir(parents=True) + monkeypatch.setenv('HERMES_HOME', str(beta)) # the worker process runs under its owning home + policy = _policy(beta, ws1, persistent=True) + seen = {} + + class Agent: + def run_conversation(self, text, **kwargs): + seen['key'] = _plan(kwargs['task_id'])[0] + return {} + + frame = {'home': str(beta), 'policy': asdict(policy), 'text': 'hi', 'route': 'agent:beta:local:dm:worker-s1', + 'scope': {'session_id': 'worker-s1'}} + with policy_scope(policy): + session_kanban.run_worker_turns(Agent(), frame, []) + # The key beta's in-gateway turns and side agents use, not the `default:ws:` one. + assert seen['key'] == f'profile:beta:ws:{workspace_key_for(str(ws1))}' + + +def test_named_launch_profiles_managed_worker_turn_keeps_its_sessions_workspace_container(home, tmp_path, monkeypatch): + # `hermes -p beta gateway` (a named profile never multiplexes): its routes are `agent:main`, its + # in-process turns and side agents bind no profile, and its worker turns must key the same container. + from gateway import session_kanban + from gateway.session_policy import policy_scope + ws1 = tmp_path / 'ws1' + ws1.mkdir() + beta = home / 'profiles' / 'beta' + beta.mkdir(parents=True) + monkeypatch.setenv('HERMES_HOME', str(beta)) + policy = _policy(beta, ws1, persistent=True) + with _turn(policy, 'local-a'): + session_key = _plan('local-a')[0] + seen = {} + + class Agent: + def run_conversation(self, text, **kwargs): + seen['key'] = _plan(kwargs['task_id'])[0] + return {} + + frame = {'home': str(beta), 'policy': asdict(policy), 'text': 'hi', 'route': 'agent:main:local:dm:worker-s1', + 'scope': {'session_id': 'worker-s1'}} + with policy_scope(policy): + session_kanban.run_worker_turns(Agent(), frame, []) + assert seen['key'] == session_key diff --git a/tests/gateway/test_kanban_finite_admission.py b/tests/gateway/test_kanban_finite_admission.py index e6d9920cbb..c9d524ddc1 100644 --- a/tests/gateway/test_kanban_finite_admission.py +++ b/tests/gateway/test_kanban_finite_admission.py @@ -45,7 +45,7 @@ def test_kanban_submission_enters_single_query_approval_context(tmp_path, monkey monkeypatch.setattr('gateway.session_ingress.execute_admission', execute) - async def submit(actor, submission): + async def submit(actor, submission, **kwargs): await execute_finite_admission(authority, ref, {'admission_id': 'admission', 'payload': submission.payload}) return AdmissionReceipt('admission', ref, 1, 'queued', None, 1, None) @@ -160,8 +160,8 @@ def test_retry_of_pre_finite_kanban_admission_returns_original_receipt(kanban_ow authority = owner.authority real_submit = authority.submit - async def pre_finite_submit(actor, submission): - # The payload run_task committed before this change: no finite flag. + async def pre_finite_submit(actor, submission, **kwargs): + # The payload run_task committed before this change: no finite flag (and no card marker). payload = {k: v for k, v in submission.payload.items() if k != 'finite'} return await real_submit(actor, type(submission)(submission.request_id, submission.ref, payload, submission.intent)) @@ -206,3 +206,96 @@ def test_admission_lookup_matches_only_the_exact_durable_key(kanban_owner): await owner.close() asyncio.run(scenario()) + + +def _lose_across_restart(authority, sid): + """Claim the card's admission under this owner, then a new owner epoch boots on the same store + and runs the real boot recovery (fence ``unknown``, then settle the unattended ones).""" + from gateway.session_unattended_unknown import settle_recovered_unattended + from hermes_state_runtime import (begin_runtime_epoch, claim_session_input, list_session_admissions, + recover_session_inputs) + db = authority.db + claimed = claim_session_input(db, epoch=authority.epoch, session_id=sid) + assert claimed and claimed['status'] == 'started' + epoch = begin_runtime_epoch(db, instance_id='owner-2') + assert recover_session_inputs(db, epoch=epoch) == 1 + settle_recovered_unattended(db, epoch) + [row] = list_session_admissions(db, session_id=sid, pending_only=False) + return row + + +def test_kanban_card_fenced_unknown_across_owner_epochs_settles_interrupted(kanban_owner): + """#134889 KB-8: a card attempt lost across an owner restart has nobody to resend it; the + dispatcher's trusted marker makes boot recovery settle it interrupted instead of pausing.""" + from gateway.session_kanban import run_task + + owner = kanban_owner(_CONFIG) + + async def scenario(): + try: + sid = (await run_task(owner.connection, owner.params))['session_id'] + row = _lose_across_restart(owner.authority, sid) + assert (row['status'], row['outcome']) == ('terminal', 'interrupted'), row + notices = [m['content'] for m in owner.authority.db.get_messages(sid) + if m.get('display_kind') == 'failed_turn'] + assert notices and 'automat' in notices[0].lower(), notices + finally: + await owner.close() + + asyncio.run(scenario()) + + +def test_card_admitted_before_the_marker_keeps_pausing_and_retries_cleanly(kanban_owner, monkeypatch): + """Upgrade compat: an attempt main committed without the marker resubmits its exact payload on + retry (original receipt, no admission_conflict) and keeps today's pause when lost.""" + from gateway.session_kanban import run_task + + owner = kanban_owner(_CONFIG) + authority = owner.authority + real_submit = authority.submit + + async def unmarked_submit(actor, submission, **kwargs): + kwargs.pop('_kanban_card', None) # the admission main committed: no producer marker + return await real_submit(actor, submission, **kwargs) + + async def scenario(): + try: + monkeypatch.setattr(authority, 'submit', unmarked_submit) + first = await run_task(owner.connection, owner.params) + monkeypatch.setattr(authority, 'submit', real_submit) + assert await run_task(owner.connection, owner.params) == first + row = _lose_across_restart(authority, first['session_id']) + assert row['status'] == 'unknown', row + finally: + await owner.close() + + asyncio.run(scenario()) + + +def test_a_client_cannot_mint_the_kanban_card_marker(kanban_owner): + """The marker is the dispatcher's, never a client payload field: session.submit and the + authority's public payload both refuse it, so a person's turn can't opt out of the pause.""" + from gateway.session_contract import Submission + from gateway.session_kanban import run_task + from hermes_state_runtime import RuntimeStoreError + + owner = kanban_owner(_CONFIG) + authority = owner.authority + + async def scenario(): + try: + sid = (await run_task(owner.connection, owner.params))['session_id'] + from gateway.session_contract import SessionRef + ref = SessionRef(authority.profile_id, sid) + forged = {'text': 'x', 'kanban_card_v1': {'task_id': owner.params['task_id'], 'run_id': owner.params['run_id']}} + with pytest.raises(RuntimeStoreError) as refused: + await authority.submit(owner.connection.actor, Submission('forged', ref, forged, 'queue')) + assert refused.value.reason == 'invalid_params' + with pytest.raises(RuntimeStoreError) as refused: + owner.connection.subscriptions[sid] = object() + owner.connection.submission(ref, {'session_id': sid, 'submission_id': 'f2', **forged}) + assert refused.value.reason == 'invalid_params' + finally: + await owner.close() + + asyncio.run(scenario()) diff --git a/tests/gateway/test_one_turn_route_publish.py b/tests/gateway/test_one_turn_route_publish.py index af457cbd02..39327d2956 100644 --- a/tests/gateway/test_one_turn_route_publish.py +++ b/tests/gateway/test_one_turn_route_publish.py @@ -224,6 +224,110 @@ def test_a_plain_same_provider_pick_still_keeps_the_session_endpoint(alias_confi assert (runtime["base_url"], runtime["api_key"]) == (SESSION_URL, "sk-session") +PROXY = "https://llm-proxy.example/anthropic" + + +@pytest.mark.parametrize("typed,aliases", [ + ("claude-haiku-4-5", {}), + ("haiku", {}), + ("quick", {"quick": {"model": "claude-haiku-4-5", "provider": "anthropic"}}), + ("claude-haiku-4-5", {"quick": {"model": "claude-haiku-4-5", "provider": "anthropic"}}), + ("claude-haiku-4-5", {"claude-haiku-4-5": {"model": "claude-haiku-4-5", "provider": "anthropic"}}), +], ids=["model-id", "builtin-alias", "endpointless-alias", "model-id-reverse-matching-it", "alias-named-like-its-model"]) +def test_a_same_provider_alias_without_an_endpoint_keeps_the_session_route(monkeypatch, typed, aliases): + """Review of #140969: every alias kind took the "alias runs whole" branch, so ``/model haiku --once`` on + a session behind a proxy, or rotating a pool, ran that turn on the provider's default host and key. + Only an alias that names its own endpoint leaves the session's route.""" + cfg = {"model": {"default": "claude-sonnet-4-6", "provider": "anthropic"}, "model_aliases": aliases} + monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: cfg) + monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda *a, **k: cfg) + monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", lambda *a, **k: { + "accepted": True, "persist": True, "recognized": True, "message": ""}) + monkeypatch.setattr("hermes_cli.model_switch.DIRECT_ALIASES", {}) + monkeypatch.setattr("hermes_cli.model_switch.list_provider_models", + lambda provider: ["claude-haiku-4-5", "claude-sonnet-4-6"] if provider == "anthropic" else []) + monkeypatch.setattr("hermes_cli.models._PROVIDER_MODELS", {}) + monkeypatch.setattr("agent.models_dev.fetch_models_dev", dict) + # The provider's default route, which the explicit provider re-resolves. + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", lambda **_k: { + "provider": "anthropic", "api_key": "sk-default", "base_url": "https://api.anthropic.com", + "api_mode": "anthropic_messages"}) + pool = object() + model, runtime = one_turn_runtime( + {"model": typed, "provider": "anthropic"}, "claude-sonnet-4-6", + {"provider": "anthropic", "api_key": "sk-session", "base_url": PROXY, "api_mode": "anthropic_messages", + "credential_pool": pool, "request_overrides": {"metadata": {"team": "a"}}}, + cfg) + assert model == "claude-haiku-4-5" + assert (runtime["base_url"], runtime["api_key"]) == (PROXY, "sk-session") + assert runtime["credential_pool"] is pool + assert runtime["request_overrides"] == {"metadata": {"team": "a"}} + + +@pytest.mark.parametrize("typed", ["opus", "claude-opus-5"], ids=["provider-alias", "declared-id"]) +def test_a_process_providers_declared_model_keeps_the_session_command(monkeypatch, typed): + """An ``external_process`` provider's own alias or declared id resolves like an alias, and the turn + still runs on the session's command.""" + from providers.base import ProviderProfile + profile = ProviderProfile(name="proc-provider", auth_type="external_process", base_url="process://proc-provider", + fallback_models=("claude-sonnet-5", "claude-opus-5"), model_aliases={"opus": "claude-opus-5"}) + monkeypatch.setattr("providers.get_provider_profile", lambda name: profile if name == profile.name else None) + cfg = {"model": {"default": "claude-sonnet-5", "provider": profile.name}} + monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: cfg) + monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda *a, **k: cfg) + monkeypatch.setattr("hermes_cli.model_switch.DIRECT_ALIASES", {}) + monkeypatch.setattr("hermes_cli.model_switch.list_provider_models", lambda provider: []) + monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", lambda *a, **k: { + "accepted": True, "persist": True, "recognized": True, "message": ""}) + monkeypatch.setattr("agent.models_dev.fetch_models_dev", dict) + monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None) + monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None) + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", lambda **_k: { + "provider": profile.name, "api_key": "external-process", "base_url": profile.base_url, + "api_mode": "chat_completions"}) + monkeypatch.setattr("hermes_cli.config.get_compatible_custom_providers", lambda _config: []) + model, runtime = one_turn_runtime( + {"model": typed, "provider": profile.name}, "claude-sonnet-5", + {"provider": profile.name, "api_key": "external-process", "base_url": profile.base_url, + "api_mode": "chat_completions", "command": "proc-cli", "args": ["--serve"]}, + cfg) + assert model == "claude-opus-5" + assert (runtime["command"], runtime["args"]) == ("proc-cli", ["--serve"]) + + +def test_an_alias_runs_on_the_endpoint_the_switch_resolved_when_the_config_changes_after(monkeypatch): + """The owner re-read the alias table after ``switch_model`` had resolved and validated the alias's + endpoint, so a ``config.yaml`` edit in between (its ``base_url`` removed) ran the alias's model on the + session's endpoint and key. The route the switch resolved decides.""" + from hermes_cli import model_switch + from hermes_constants import get_config_path + path = get_config_path() + config = ("model:\n default: session-model\n provider: custom\n base_url: " + SESSION_URL + "\n" + "model_aliases:\n fast:\n model: fast-model\n provider: custom\n") + path.write_text(config + " base_url: " + ALIAS_URL + "\n api_key: sk-alias\n", encoding="utf-8") + edited = [] + + def validate(*_a, **_k): + path.write_text(config, encoding="utf-8") # the edit lands after the endpoint was applied + edited.append(True) + return {"accepted": True, "persist": True, "recognized": True, "message": ""} + + monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", validate) + monkeypatch.setattr(model_switch, "DIRECT_ALIASES", {}) + monkeypatch.setattr(model_switch, "_DIRECT_ALIAS_IDENTITY", None) + monkeypatch.setattr(model_switch, "_DIRECT_ALIAS_LOADED", None) + monkeypatch.setattr("agent.models_dev.fetch_models_dev", dict) + model, runtime = one_turn_runtime( + {"model": "fast", "provider": "custom"}, "session-model", + {"provider": "custom", "api_key": "sk-session", "base_url": SESSION_URL, "api_mode": "chat_completions", + "credential_pool": object()}, + {}) + assert edited, "the switch never validated the pick" + assert model == "fast-model" + assert (runtime["base_url"], runtime["api_key"]) == (ALIAS_URL, "sk-alias") + assert runtime["credential_pool"] is None + + def test_a_one_turn_override_carries_its_reasoning_level_for_that_admission_only(): from gateway.session_cli_turn import admit_cli_turn, one_turn_reasoning from hermes_state_runtime import RuntimeStoreError diff --git a/tests/gateway/test_restart_interrupt_notice.py b/tests/gateway/test_restart_interrupt_notice.py index d818398f3b..20845c5ccc 100644 --- a/tests/gateway/test_restart_interrupt_notice.py +++ b/tests/gateway/test_restart_interrupt_notice.py @@ -224,8 +224,10 @@ def test_managed_worker_turn_settles_with_the_cause_of_the_stop_that_reached_it( assert complete['status'] == 'interrupted' if user_stopped_first: assert 'warning' not in complete, complete + assert _notices(db) == [] else: assert complete['warning'] == RESTART_INTERRUPT_NOTICE + assert _notices(db) == [RESTART_INTERRUPT_NOTICE] # GW-21: a reconnecting viewer reads it too @pytest.mark.parametrize('user_stopped_first', [False, True]) @@ -302,3 +304,308 @@ def test_restart_after_a_pending_redirect_still_gets_the_restart_notice(): assert notice == RESTART_INTERRUPT_NOTICE, (agent._interrupt_message, notice) finally: set_interrupt(False) + + +@pytest.mark.parametrize('cause', ['Gateway restarting', 'Gateway shutting down']) +@pytest.mark.parametrize('row', [ + {'principal_id': 'cron-owner', 'payload': {'text': 'job'}}, + {'principal_id': 'automation:agent:main:telegram:dm:x', 'payload': {'text': 'done'}}, + {'principal_id': 'person', 'payload': {'text': 'hook', 'native_text_v1': {'webhook_delivery': {}}}}, +]) +def test_automation_turn_the_owner_stop_interrupted_is_never_asked_to_resend(cause, row): + """#134889 GW-20: nobody sent an unattended turn, so the owner-stop notice is the neutral + automation copy (GW-10's rule), never "Send your message again".""" + from agent.turn_failure_copy import AUTOMATION_INTERRUPTED_NOTICE + stream = _Stream() + live = SimpleNamespace(controls=SimpleNamespace(snapshot=lambda *a: None), event_stream=stream) + _publish_completion(SimpleNamespace(runner=None), live, SimpleNamespace(session_id='s'), + {'admission_id': 'adm-1', 'request_id': 'sub-1', **row}, + {'outcome': 'interrupted'}, '', _cause(cause)) + (frame,) = stream.frames + assert frame['warning'] == AUTOMATION_INTERRUPTED_NOTICE + assert 'again' not in frame['warning'].replace('not run again', '') + + +def _settle_on_real_store(tmp_path, cause, principal='person', payload=None): + """One claimed admission on a real SessionDB settled through commit_and_publish (the owner's + worker-thread settlement) with *cause* recorded. Returns (db, row, frames, settlement).""" + from gateway.session_authority import LiveSession, SessionAuthority + from gateway.session_contract import SessionRef + from gateway.session_settlement_recovery import commit_and_publish + from hermes_state import SessionDB + from hermes_state_runtime import admit_session_input, begin_runtime_epoch, claim_session_input + + runner = SimpleNamespace(_draining=False, _running=True, _restart_requested=True) + db = SessionDB(db_path=tmp_path / 'state.db') + db.create_session('s', source='test') + epoch = begin_runtime_epoch(db, instance_id='owner') + authority = SessionAuthority(runner, profile_id='owned', instance_id='owner', db=db, epoch=epoch) + live = authority.sessions['s'] = LiveSession(None, 'route') + ref = SessionRef('owned', 's') + admit_session_input(db, epoch=epoch, principal_id=principal, session_id='s', request_id='r', + payload=payload or {'text': 'please work'}) + row = claim_session_input(db, epoch=epoch, session_id='s') + authority._stamp_claim(ref, live, row) + db.append_message('s', 'user', 'please work') # the turn's own rows, as the agent wrote them + db.append_message('s', 'assistant', 'partial reply') + frames = [] + live.event_stream.observers.add(frames.append) + result = {'final_response': 'partial reply', 'messages': [], 'interrupted': True, 'completed': False} + if cause: + result['interrupt_message'] = cause + settlement = {} + commit_and_publish(authority, live, ref, row, 'partial reply', 'interrupted', {'result': result}, settlement) + return db, authority, row, frames, settlement + + +def _notices(db): + from agent.turn_failure_copy import FAILED_TURN_DISPLAY_KIND + return [m['content'] for m in db.get_messages('s') if m.get('display_kind') == FAILED_TURN_DISPLAY_KIND] + + +@pytest.mark.parametrize(('cause', 'notice'), [('Gateway restarting', RESTART_INTERRUPT_NOTICE), + ('Gateway shutting down', SHUTDOWN_INTERRUPT_NOTICE)]) +def test_owner_stop_reason_is_in_the_transcript_for_a_viewer_that_reconnects(tmp_path, cause, notice): + """#134889 GW-21: a viewer that reconnects after the restart reads the transcript, not the live + frame. The reason commits as a Hermes notice in the settlement's own write, once; the live + ``message.complete.warning`` stays.""" + from gateway.session_results import finish_result + db, authority, row, frames, settlement = _settle_on_real_store(tmp_path, cause) + with db: + assert settlement['settled']['outcome'] == 'interrupted' + assert _notices(db) == [notice] + (complete,) = [f['params']['payload'] for f in frames if f['params']['type'] == 'message.complete'] + assert complete['warning'] == notice + # A settlement retry that reads the committed row back never writes the notice again. + finish_result(db, epoch=authority.epoch, row=row, response='partial reply', outcome='interrupted', + result={'result': {'final_response': 'partial reply', 'messages': [], 'interrupted': True, + 'interrupt_message': cause}}) + assert _notices(db) == [notice] + + +@pytest.mark.parametrize('cause', [None, 'User requested stop']) +def test_a_user_stop_writes_no_transcript_notice(tmp_path, cause): + db, *_ = _settle_on_real_store(tmp_path, cause) + with db: + assert _notices(db) == [] + + +def test_automation_turn_transcript_notice_is_the_neutral_copy(tmp_path): + from agent.turn_failure_copy import AUTOMATION_INTERRUPTED_NOTICE + db, *_ = _settle_on_real_store(tmp_path, 'Gateway restarting', principal='cron-owner') + with db: + assert _notices(db) == [AUTOMATION_INTERRUPTED_NOTICE] + + + +def _core_closed_turn(tmp_path, monkeypatch, *, principal='person', earlier_failed=False): + """A claimed admission whose agent was interrupted by the restart before any assistant text: + the real AIAgent persists the user row and the real core closer + (``agent/conversation_loop.py::_close_durable_failed_turn``) writes its failed-turn boundary. + ``earlier_failed``: a previous turn already ended on a failed-turn boundary before this claim.""" + monkeypatch.setenv('HERMES_HOME', str(tmp_path)) + from agent.conversation_loop import _close_durable_failed_turn + from agent.turn_failure_copy import FAILED_TURN_DISPLAY_KIND, FAILED_TURN_NOTICE + from gateway.session_authority import LiveSession, SessionAuthority + from gateway.session_contract import SessionRef + from hermes_state import SessionDB + from hermes_state_runtime import admit_session_input, begin_runtime_epoch, claim_session_input + from run_agent import AIAgent + + runner = SimpleNamespace(_draining=False, _running=True, _restart_requested=True) + db = SessionDB(db_path=tmp_path / 'state.db') + db.create_session('s', source='test') + if earlier_failed: + db.append_message('s', 'user', 'earlier request') + db.append_message('s', 'assistant', FAILED_TURN_NOTICE, display_kind=FAILED_TURN_DISPLAY_KIND) + epoch = begin_runtime_epoch(db, instance_id='owner') + authority = SessionAuthority(runner, profile_id='owned', instance_id='owner', db=db, epoch=epoch) + live = authority.sessions['s'] = LiveSession(None, 'route') + ref = SessionRef('owned', 's') + payload = {'text': 'please work'} + admit_session_input(db, epoch=epoch, principal_id=principal, session_id='s', request_id='r', payload=payload) + row = claim_session_input(db, epoch=epoch, session_id='s') + authority._stamp_claim(ref, live, row) + result = {'final_response': '', 'interrupted': True, 'completed': False, 'interrupt_message': 'Gateway restarting'} + if not earlier_failed: + agent = AIAgent(session_db=db, session_id='s', model='test-model', provider='openai-compat', api_key='test', + base_url='http://127.0.0.1:1/v1', quiet_mode=True, skip_context_files=True, skip_memory=True) + messages = [{'role': 'user', 'content': 'please work'}] + agent._flush_messages_to_session_db(messages) # the turn-start flush of the accepted user row + result['messages'] = messages + _close_durable_failed_turn(agent, result) + return db, authority, live, ref, row, result + + +@pytest.mark.parametrize('principal', ['person', 'cron-owner']) +def test_restart_notice_replaces_the_core_failed_turn_boundary_of_the_same_turn(tmp_path, monkeypatch, principal): + """#134889 GW-21 review (GW-A): the agent's own closer already ended this turn with + "Send it again"; settlement replaces that boundary with the restart notice in its own write, + so the transcript holds one Hermes boundary, and an automation row never keeps resend advice.""" + from agent.turn_failure_copy import AUTOMATION_INTERRUPTED_NOTICE, FAILED_TURN_NOTICE + from gateway.session_settlement_recovery import commit_and_publish + db, authority, live, ref, row, result = _core_closed_turn(tmp_path, monkeypatch, principal=principal) + with db: + assert _notices(db) == [FAILED_TURN_NOTICE] # the real core closer wrote it + commit_and_publish(authority, live, ref, row, '', 'interrupted', {'result': result}, {}) + expected = RESTART_INTERRUPT_NOTICE if principal == 'person' else AUTOMATION_INTERRUPTED_NOTICE + assert _notices(db) == [expected] + assert [m['role'] for m in db.get_messages('s')] == ['user', 'assistant'] + + +def test_an_earlier_turns_failed_boundary_is_kept_and_the_restart_notice_appended(tmp_path, monkeypatch): + """A failed-turn boundary written before this claim belongs to an earlier turn: it stays.""" + from agent.turn_failure_copy import FAILED_TURN_NOTICE + from gateway.session_settlement_recovery import commit_and_publish + db, authority, live, ref, row, result = _core_closed_turn(tmp_path, monkeypatch, earlier_failed=True) + with db: + commit_and_publish(authority, live, ref, row, '', 'interrupted', {'result': result}, {}) + assert _notices(db) == [FAILED_TURN_NOTICE, RESTART_INTERRUPT_NOTICE] + + +@pytest.mark.platforms("linux") +@pytest.mark.parametrize('ending', ['spawned', 'cancelled']) +def test_shutdown_stop_before_the_managed_worker_spawned_carries_its_cause(tmp_path, monkeypatch, ending): + """#134889 GW-22: the gateway's stop lands while the managed worker is still being spawned + (no worker to signal yet). The authority latches the cause for that generation, so the + pre-bootstrap interrupted result carries it: the spawned child is stopped before it is ever + bootstrapped, or the cancelled spawn settles with the cause. Real child, real SQLite.""" + import subprocess + + from gateway import session_managed_worker as managed + from gateway.run_runtime import stop_authority_turns + from gateway.session_authority import LiveSession, SessionAuthority + from gateway.session_contract import Principal, SessionRef, Submission + from gateway.session_policy import build_policy + from hermes_state import SessionDB + from hermes_state_runtime import begin_runtime_epoch, claim_session_input + from tests.gateway.restart_test_helpers import make_restart_runner + from tests.waiting import async_wait_until + + monkeypatch.setenv('HERMES_HOME', str(tmp_path)) + runner, _adapter = make_restart_runner() + db = SessionDB(db_path=tmp_path / 'state.db') + db.create_session('s', source='test') + authority = SessionAuthority(runner, profile_id='owned', instance_id='i', db=db, + epoch=begin_runtime_epoch(db, instance_id='i')) + live = authority.sessions['s'] = LiveSession(SimpleNamespace(platform=None, user_id='human', chat_id='c'), 'route') + actor = Principal('human', 'owned', frozenset({'session:submit', 'session:control'}), 'cli') + ref = SessionRef('owned', 's') + monkeypatch.setattr(authority, '_schedule', lambda ref: None) + spawning, release, bootstrapped = threading.Event(), threading.Event(), [] + + class Child(subprocess.Popen): + def __init__(self, args, **kw): + spawning.set() + release.wait(30) + super().__init__([sys.executable, '-c', STOPPABLE_CHILD], **kw) + monkeypatch.setattr(managed.subprocess, 'Popen', Child) + monkeypatch.setattr(managed, '_bootstrap', lambda *a: bootstrapped.append(a) or pytest.fail('bootstrapped')) + policy = build_policy({'cwd': str(tmp_path), 'model': 'm'}, {}, profile_terminal=False) + + async def scenario(): + await authority.submit(actor, Submission('turn', ref, {'text': 'go'}, 'queue')) + row = claim_session_input(db, epoch=authority.epoch, session_id='s') + authority._stamp_claim(ref, live, row) + task = asyncio.create_task(managed.execute_managed(authority, ref, row, policy)) + await asyncio.to_thread(spawning.wait, 30) + assert stop_authority_turns(authority, reason='Gateway restarting') == 1 + if ending == 'cancelled': + task.cancel() + release.set() + async with asyncio.timeout(20): + await async_wait_until(task.done, timeout=20, what='the stopped turn to end') + return row, await task + + with db: + row, response = asyncio.run(scenario()) + result = authority.pending_results[row['admission_id']]['result'] + assert response == '' and not bootstrapped + assert result['interrupted'] and result['interrupt_message'] == 'Gateway restarting' + + + +@pytest.mark.platforms("linux") +@pytest.mark.parametrize('stopper', ['shutdown', 'user']) +def test_a_stop_during_context_reference_expansion_never_spawns_the_worker(tmp_path, monkeypatch, stopper): + """#134889 GW-22 review (GW-B): ``managed_turn_text`` (``@`` expansion; it may fetch the model's + context length) runs before the worker exists. A gateway shutdown Stop there is latched for the + generation, so no worker is spawned and the turn settles interrupted with the restart cause. A + user's Stop there is honoured too and leaves no cause. Real authority and SQLite; only the + expansion is slowed and Popen is observed.""" + import subprocess + + from gateway import session_context_refs + from gateway import session_managed_worker as managed + from gateway.run_runtime import stop_authority_turns + from gateway.session_authority import LiveSession, SessionAuthority + from gateway.session_contract import Principal, SessionRef, Submission + from gateway.session_policy import build_policy + from hermes_state import SessionDB + from hermes_state_runtime import begin_runtime_epoch, claim_session_input + from tests.gateway.restart_test_helpers import make_restart_runner + + monkeypatch.setenv('HERMES_HOME', str(tmp_path)) + runner, _adapter = make_restart_runner() + db = SessionDB(db_path=tmp_path / 'state.db') + db.create_session('s', source='test') + authority = SessionAuthority(runner, profile_id='owned', instance_id='i', db=db, + epoch=begin_runtime_epoch(db, instance_id='i')) + live = authority.sessions['s'] = LiveSession(SimpleNamespace(platform=None, user_id='human', chat_id='c'), 'route') + actor = Principal('human', 'owned', frozenset({'session:submit', 'session:control'}), 'cli') + ref = SessionRef('owned', 's') + monkeypatch.setattr(authority, '_schedule', lambda ref: None) + expanding, release, spawned = threading.Event(), threading.Event(), [] + + async def slow_expansion(authority, row, policy): # stands in for a slow context-length lookup + expanding.set() + await asyncio.to_thread(release.wait, 30) + return None, None + monkeypatch.setattr(session_context_refs, 'managed_turn_text', slow_expansion) + + class Child(subprocess.Popen): + def __init__(self, args, **kw): + spawned.append(True) + super().__init__([sys.executable, '-c', STOPPABLE_CHILD], **kw) + monkeypatch.setattr(managed.subprocess, 'Popen', Child) + policy = build_policy({'cwd': str(tmp_path), 'model': 'm'}, {}, profile_terminal=False) + + async def scenario(): + await authority.submit(actor, Submission('turn', ref, {'text': 'look at @file:x'}, 'queue')) + row = claim_session_input(db, epoch=authority.epoch, session_id='s') + authority._stamp_claim(ref, live, row) + task = asyncio.create_task(managed.execute_managed(authority, ref, row, policy)) + await asyncio.to_thread(expanding.wait, 30) + if stopper == 'shutdown': + assert stop_authority_turns(authority, reason='Gateway shutting down') == 1 + else: + await authority.interrupt(actor, ref, row['generation']) + release.set() + async with asyncio.timeout(30): + return row, await task + + with db: + row, response = asyncio.run(scenario()) + result = authority.pending_results[row['admission_id']]['result'] + assert response == '' and spawned == [] and result['interrupted'] + if stopper == 'shutdown': + assert result['interrupt_message'] == 'Gateway shutting down' + else: + assert 'interrupt_message' not in result + assert 's' not in authority._managed_spawning and 's' not in authority.pending_stops + + +def test_a_user_stop_first_keeps_no_shutdown_cause_before_spawn(): + """A user's Stop latched for the pending generation came first: shutdown records no cause.""" + from gateway.run_runtime import stop_authority_turns + live = SimpleNamespace(event_stream=SimpleNamespace(execution={'execution_generation': 4})) + authority = SimpleNamespace(_managed_workers={}, _managed_spawning={'s': 4}, sessions={'s': live}, + pending_stops={'s': 4}, pending_stop_causes={}) + assert stop_authority_turns(authority, reason='Gateway restarting') == 1 + assert authority.pending_stop_causes == {} and authority.pending_stops == {'s': 4} + authority.pending_stops = {} + stop_authority_turns(authority) # no reason (profile retire): latches the Stop, never a cause + assert authority.pending_stop_causes == {} and authority.pending_stops == {'s': 4} + authority.pending_stops = {} + assert stop_authority_turns(authority, reason='Gateway shutting down') == 1 + assert authority.pending_stop_causes == {'s': (4, 'Gateway shutting down')} diff --git a/tests/gateway/test_session_busy_compression_refusal.py b/tests/gateway/test_session_busy_compression_refusal.py new file mode 100644 index 0000000000..063626cdb1 --- /dev/null +++ b/tests/gateway/test_session_busy_compression_refusal.py @@ -0,0 +1,77 @@ +"""A rewind refused because another writer holds the conversation's compression lock says so. + +Main's busy text told the user to wait for a running ``/compress`` instead of pressing Stop +(``tui_gateway/user_messages.py::busy_message``); the shared owner's refusal keeps its ``session_busy`` +reason and names the lock in ``field`` so a client can pick that wording. +""" +import pytest + +from tests.gateway.test_session_mutation_model_busy import _owner + + +async def _rewind(db, owner, ref, target): + row = db.get_session(ref.session_id) + first = db.get_messages(target)[0]['id'] + return await owner.dispatch({'id': 1, 'method': 'session.mutate', 'params': { + 'session_id': ref.session_id, 'request_id': 'rewind', 'expected_revision': row['runtime_revision'], + 'expected_generation': row['runtime_generation'], 'operation': 'rewind', + 'payload': {'target_message_id': first}}}) + + +@pytest.mark.asyncio +async def test_rewind_under_a_held_compression_lock_names_the_lock(tmp_path, monkeypatch, caplog): + async def handle(event): + return 'ok' + store, db, authority, owner, ref = _owner(tmp_path, monkeypatch, handle) + try: + from gateway.session_local_recovery import transcript_target + target = transcript_target(authority, ref) + db.append_message(target, 'user', 'keep') + assert db.try_acquire_compression_lock(target, 'another-writer') + import logging + with caplog.at_level(logging.DEBUG, logger='gateway.session_controls'): + reply = await _rewind(db, owner, ref, target) + assert reply['error']['message'] == 'session_busy', reply + assert reply['error']['data'] == {'reason': 'session_busy', 'field': 'compression'} + # A retrying client (Desktop's edit retries busy for 6 s) must not flood the log. + refusals = [r for r in caplog.records if 'session.mutate refused' in r.getMessage()] + assert refusals and all(r.levelno == logging.DEBUG for r in refusals), refusals + assert [m['content'] for m in db.get_messages(target)] == ['keep'] + finally: + await owner.close() + store.close_all_db_handles() + + +@pytest.mark.asyncio +async def test_rewind_with_an_admitted_turn_stays_a_plain_busy_refusal(tmp_path, monkeypatch): + import hermes_state_runtime as rt + + async def handle(event): + return 'ok' + store, db, authority, owner, ref = _owner(tmp_path, monkeypatch, handle) + try: + from gateway.session_local_recovery import transcript_target + target = transcript_target(authority, ref) + db.append_message(target, 'user', 'keep') + rt.admit_session_input(db, epoch=authority.epoch, principal_id='human', session_id=ref.session_id, + request_id='input', payload={'text': 'go'}) + reply = await _rewind(db, owner, ref, target) + assert reply['error']['data'] == {'reason': 'session_busy'}, reply + finally: + await owner.close() + store.close_all_db_handles() + + +def test_authorize_reads_a_null_profile_as_this_owner(): + from types import SimpleNamespace + from gateway.session_busy_controls import authorize + from hermes_state_runtime import RuntimeStoreError + + seen = [] + authority = SimpleNamespace(profile_id='/nonexistent/owner-home', authorize=lambda *a: seen.append(a)) + connection = SimpleNamespace(authority=authority, actor='actor') + authorize(connection, 'ref', {'profile': None}, 'session:read') + assert seen == [('actor', 'ref', 'session:read')] + with pytest.raises(RuntimeStoreError) as refused: + authorize(connection, 'ref', {'profile': 7}, 'session:read') + assert refused.value.reason == 'invalid_params' diff --git a/tests/gateway/test_session_history_canonical.py b/tests/gateway/test_session_history_canonical.py new file mode 100644 index 0000000000..52afd79315 --- /dev/null +++ b/tests/gateway/test_session_history_canonical.py @@ -0,0 +1,61 @@ +"""``session.history`` on the canonical owner: the durable display transcript with each row's ``row_id``. + +Main's TUI sidecar served it (``tui_gateway/methods_session.py``); Desktop's edit-after-interrupt resolves the edited turn's row from it, and without an answer the edit only appends (TUI-15, #134889). +""" +from types import SimpleNamespace + +import pytest + +from tests.gateway.test_session_mutation_model_busy import _owner + + +@pytest.mark.asyncio +async def test_session_history_returns_the_display_transcript_with_row_ids(tmp_path, monkeypatch): + async def handle(event): + return 'ok' + store, db, authority, owner, ref = _owner(tmp_path, monkeypatch, handle) + try: + from gateway.session_local_recovery import transcript_target + target = transcript_target(authority, ref) + first = db.append_message(target, 'user', 'first question') + answer = db.append_message(target, 'assistant', 'first answer') + interrupted = db.append_message(target, 'user', 'interrupted question') + reply = await owner.dispatch({'id': 1, 'method': 'session.history', + 'params': {'session_id': ref.session_id, 'profile': None}}) + assert 'result' in reply, reply + messages = reply['result']['messages'] + assert [(m['role'], m['text'], m['row_id']) for m in messages] == [ + ('user', 'first question', first), ('assistant', 'first answer', answer), + ('user', 'interrupted question', interrupted)] + assert reply['result']['count'] == len(messages) + finally: + await owner.close() + store.close_all_db_handles() + + +@pytest.mark.asyncio +async def test_session_history_of_a_compressed_messaging_session_reads_its_continuation(tmp_path, monkeypatch): + from gateway.config import Platform + from gateway.session_authority import LiveSession + + async def handle(event): + return 'ok' + store, db, authority, owner, _ref = _owner(tmp_path, monkeypatch, handle) + try: + db.create_session('root', source='telegram', session_key='agent:main:telegram:dm:1') + before = db.append_message('root', 'user', 'before compression') + assert db.try_acquire_compression_lock('root', 'holder') + db.publish_compression_child(parent_session_id='root', child_session_id='child', source='telegram', + messages=[{'role': 'user', 'content': 'summary'}], + compression_lock_holder='holder') + after = db.append_message('child', 'user', 'after compression') + authority.sessions['root'] = LiveSession( + SimpleNamespace(platform=Platform.TELEGRAM, user_id='human'), 'agent:main:telegram:dm:1') + reply = await owner.dispatch({'id': 1, 'method': 'session.history', 'params': {'session_id': 'root'}}) + assert 'result' in reply, reply + rows = {m['text']: m['row_id'] for m in reply['result']['messages'] if m['role'] == 'user'} + assert rows.get('before compression') == before, rows + assert rows.get('after compression') == after, rows + finally: + await owner.close() + store.close_all_db_handles() diff --git a/tests/gateway/test_session_kanban.py b/tests/gateway/test_session_kanban.py index a3b3f8e596..b6af6174d6 100644 --- a/tests/gateway/test_session_kanban.py +++ b/tests/gateway/test_session_kanban.py @@ -131,7 +131,7 @@ def test_claim_reclaimed_before_admission_marker_is_refused(tmp_path, monkeypatc kb.claim_task(conn, task_id) return SimpleNamespace(session_id='owner-session', profile_id=str(tmp_path)) monkeypatch.setattr('gateway.session_local.create_local_session', create_local_session) - async def submit(actor, submission): + async def submit(actor, submission, **kwargs): calls.append(('submit', submission)) async def resume(ref, params): calls.append(('resume', ref)) diff --git a/tests/gateway/test_session_kanban_run_off_loop.py b/tests/gateway/test_session_kanban_run_off_loop.py index 4461423317..7d93095903 100644 --- a/tests/gateway/test_session_kanban_run_off_loop.py +++ b/tests/gateway/test_session_kanban_run_off_loop.py @@ -38,7 +38,7 @@ def test_run_task_kanban_write_never_blocks_the_loop(tmp_path, monkeypatch): session_id='owner-session', profile_id=str(tmp_path))) calls = [] - async def submit(actor, submission): + async def submit(actor, submission, **kwargs): calls.append('submit') return SimpleNamespace(admission_id='a') diff --git a/tests/hermes_cli/test_argparse_flag_propagation.py b/tests/hermes_cli/test_argparse_flag_propagation.py index 67c0a1f392..e561a4ca61 100644 --- a/tests/hermes_cli/test_argparse_flag_propagation.py +++ b/tests/hermes_cli/test_argparse_flag_propagation.py @@ -62,6 +62,40 @@ class TestChatVerboseArg: assert captured["quiet"] is False assert "verbose" not in captured + @pytest.mark.parametrize("argv, expected", [ + (["chat", "--provider", "custom", "--base-url", "http://127.0.0.1:9/v1", "--api-key", "launch-key"], + {"api_key": "launch-key", "base_url": "http://127.0.0.1:9/v1"}), + (["chat"], {}), + ]) + def test_cmd_chat_forwards_the_launch_key_and_endpoint_to_the_classic_cli(self, monkeypatch, argv, expected): + """`hermes chat --cli --api-key K --base-url U` must launch the classic CLI on K and U, not on the + profile's credential; the TUI refuses both flags and points here.""" + import types + + import hermes_cli.main as main_mod + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, chat_parser = build_top_level_parser() + chat_parser.set_defaults(func=main_mod.cmd_chat) + args = parser.parse_args(argv) + captured = {} + fake_cli = types.ModuleType("cli") + fake_cli.main = lambda **kwargs: captured.update(kwargs) + fake_banner = types.ModuleType("hermes_cli.banner") + fake_banner.prefetch_update_check = lambda: None + fake_skills_sync = types.ModuleType("tools.skills_sync") + fake_skills_sync.sync_skills = lambda quiet=True: None + + monkeypatch.setitem(sys.modules, "cli", fake_cli) + monkeypatch.setitem(sys.modules, "hermes_cli.banner", fake_banner) + monkeypatch.setitem(sys.modules, "tools.skills_sync", fake_skills_sync) + monkeypatch.setattr(main_mod, "_has_any_provider_configured", lambda: True) + monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None) + + main_mod.cmd_chat(args) + + assert {k: captured[k] for k in ("api_key", "base_url") if k in captured} == expected + class TestAcceptHooksOnAgentSubparsers: """Verify --accept-hooks is accepted at every agent-subcommand diff --git a/tests/hermes_cli/test_cli_gateway_model_scope.py b/tests/hermes_cli/test_cli_gateway_model_scope.py index 38e703db4f..745bffb456 100644 --- a/tests/hermes_cli/test_cli_gateway_model_scope.py +++ b/tests/hermes_cli/test_cli_gateway_model_scope.py @@ -322,6 +322,41 @@ def test_once_sends_the_alias_it_resolved_through(monkeypatch, session_model): assert not _model_routes(conn)[0] +def test_once_through_an_alias_named_like_the_session_model_still_sends_it(monkeypatch): + """Review by @dokterdok: the session runs ``shared-model`` on its own endpoint and key, and a + ``model_aliases`` entry ``shared-model`` names that model at another endpoint. ``/model shared-model + --once`` resolves the alias, but the submit compared the staged ``shared-model`` with the session's + route again, dropped it, and the turn ran on the session's endpoint and key.""" + from gateway.session_cli_turn import one_turn_runtime + from hermes_cli import model_switch + resolve = model_switch.switch_model + conn = RecordingConnection({"model": "shared-model", "provider": "custom"}) + cli_mod, stub, printed = _chat_cli(monkeypatch, conn) + monkeypatch.setattr(model_switch, "switch_model", resolve) # the real resolver over this alias + monkeypatch.setitem(model_switch.DIRECT_ALIASES, "shared-model", model_switch.DirectAlias( + model="shared-model", provider="custom", base_url="http://127.0.0.1:7003/v1", api_key="sk-alias")) + monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", lambda *_a, **_k: { + "accepted": True, "persist": True, "recognized": True, "message": ""}) + monkeypatch.setattr("agent.models_dev.fetch_models_dev", dict) + session = {"provider": "custom", "api_key": "sk-session", "base_url": "http://127.0.0.1:7001/v1"} + stub.model, stub.provider, stub.requested_provider = "shared-model", "custom", "custom" + stub.base_url = stub._explicit_base_url = session["base_url"] + stub.api_key = stub._explicit_api_key = session["api_key"] + stub.agent.model, stub.agent.provider = "shared-model", "custom" + cli_mod.HermesCLI._handle_model_switch(stub, "/model shared-model --once") + assert not [line for line in printed if "fail" in line.lower()], printed + assert stub.base_url == "http://127.0.0.1:7003/v1", "the CLI shows the alias's endpoint" + stub.chat("run on the alias") + stub.chat("the turn after") + once, after = _submits(conn) + override = once.get("model_override") + assert override == {"model": "shared-model", "provider": "custom"}, once + assert "model_override" not in after, after + _model, runtime = one_turn_runtime(override, "shared-model", {**session, "api_mode": "chat_completions"}, {}) + assert (runtime["base_url"], runtime["api_key"]) == ("http://127.0.0.1:7003/v1", "sk-alias") + assert not _model_routes(conn)[0] + + class BusyOnceConnection(RecordingConnection): """The owner refuses the first ``prompt.submit`` with ``session_busy``: nothing was admitted.""" diff --git a/tests/hermes_cli/test_cli_modern_layout.py b/tests/hermes_cli/test_cli_modern_layout.py new file mode 100644 index 0000000000..fff8f9e1ad --- /dev/null +++ b/tests/hermes_cli/test_cli_modern_layout.py @@ -0,0 +1,181 @@ +"""The glyph CLI layout (``display.cli_layout: modern``): scrollback rows never depend on the +terminal width, and the status line renders under the composer instead of above it.""" +import re + +import pytest + + +def _plain(s: str) -> str: + return re.sub(r"\x1b\[[0-9;]*m", "", s) + + +def _run_turn(monkeypatch, width: int, *, modern: bool) -> list[str]: + from cli import HermesCLI + import cli as climod + + cli = HermesCLI.__new__(HermesCLI) + cli._modern_layout = modern + cli.show_reasoning, cli.reasoning_full = True, False + cli.final_response_markdown, cli.show_timestamps = "raw", False + cli._reset_stream_state() + emitted: list[str] = [] + monkeypatch.setattr(climod, "_cprint", lambda s: emitted.append(_plain(s))) + monkeypatch.setattr(climod, "_terminal_width_for_streaming", lambda: width) + monkeypatch.setattr(HermesCLI, "_scrollback_box_width", lambda self: width) + cli._stream_reasoning_delta("**Planning the change**\nI will read calc.py first.\n") + cli._stream_delta("Added multiply to calc.py.\n") + cli._flush_stream() + return emitted + + +@pytest.mark.parametrize("modern", [True, False]) +def test_glyph_layout_scrollback_is_width_independent(monkeypatch, modern): + narrow, wide = _run_turn(monkeypatch, 60, modern=modern), _run_turn(monkeypatch, 120, modern=modern) + boxed = any(ch in line for line in wide for ch in "╭╮╰╯┌┐└┘") + if not modern: # control: the classic layout's frames are sized to the terminal + assert boxed and narrow != wide + return + assert narrow == wide and not boxed, wide + rows = "\n".join(wide).split("\n") + thought = [line for line in rows if line.startswith("∴")] + assert len(thought) == 1 and "Planning the change" in thought[0], rows + # Reasoning streams in full like classic, under a fixed-width dim bar (never re-wraps on resize) + # and closed by the ∴ summary; the answer opens under its own bar. + start = rows.index("**Planning the change**") + assert rows[start - 1] == "─" * 40 and rows[start + 1] == "I will read calc.py first.", rows + assert rows[start + 2] == thought[0], rows + assert rows[next(i for i, r in enumerate(rows) if r.startswith("☤ Hermes")) - 1] == "─" * 40, rows + assert any("Added multiply to calc.py." in line for line in rows) + + +@pytest.mark.parametrize("modern", [True, False]) +def test_status_line_sits_under_the_composer_only_in_the_glyph_layout(modern): + from cli import HermesCLI + + cli = HermesCLI.__new__(HermesCLI) + cli._modern_layout = modern + names = ("sudo_widget", "secret_widget", "approval_widget", "clarify_widget", "spacer", "status_bar", + "input_rule_top", "image_bar", "input_area", "input_rule_bot", "voice_status_bar", "completions_menu") + order = cli._build_tui_layout_children(**{name: name for name in names}) + if modern: + assert order.index("status_bar") > order.index("input_rule_bot") + assert order[-1] == "status_bar" + else: + assert order.index("status_bar") < order.index("input_rule_top") + + +def test_panel_rows_never_carry_a_newline(): + """A multi-line command (heredoc) in the approval panel wraps line by line, so no row breaks + the panel's right border.""" + from cli import _wrap_panel_text_keep_ws + + rows = _wrap_panel_text_keep_ws("python3 - <<'PY'\nprint(sum(range(10)))\nPY", 30) + assert rows == ["python3 - <<'PY'", "print(sum(range(10)))", "PY"] + + +@pytest.mark.parametrize("display, modern, busy", [ + ({}, True, "interrupt"), + ({"cli_layout": "classic"}, False, "interrupt"), + ({"busy_input_mode": "queue"}, True, "queue"), + ({"cli_layout": "classic", "busy_input_mode": "steer"}, False, "steer"), +]) +def test_layout_and_busy_default_through_the_config_loader(tmp_path, monkeypatch, display, modern, busy): + import hermes_yaml as yaml + import cli + from hermes_cli.cli_modern_layout import busy_input_mode_from_config, layout_from_config + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(cli, "_hermes_home", home) + (home / "config.yaml").write_text(yaml.safe_dump({"display": display} if display else {"model": {}})) + loaded = cli.load_cli_config()["display"] + assert layout_from_config(loaded) is modern + assert busy_input_mode_from_config(loaded) == busy + + +def test_status_line_keeps_every_classic_status_bar_field(monkeypatch): + """The glyph footer renders the classic bar's segments: nothing (tps, cache, latency, context + detail, duration...) is dropped by a layout-private default subset.""" + from cli import HermesCLI + + cli = HermesCLI.__new__(HermesCLI) + cli._modern_layout, cli._status_bar_visible = True, True + snapshot = {"model_short": "m", "duration": "6h 43m", "context_percent": 7, "context_length": 1_000_000, + "context_tokens": 73_200, "context_estimated": True, "avg_latency_label": "9.0s", + "avg_velocity_label": "61 t/s", "cache_hit_rate": 28.9, "cache_read_tokens": 1, "prompt_tokens": 1, + "active_background_subagents": 3, "prompt_elapsed": "⏱ 10s"} + monkeypatch.setattr(HermesCLI, "_get_status_bar_snapshot", lambda self: snapshot) + monkeypatch.setattr(HermesCLI, "_get_tui_terminal_width", lambda self: 400) + monkeypatch.setattr(HermesCLI, "_get_status_bar_field_set", lambda self: None) + monkeypatch.setattr(HermesCLI, "_is_session_yolo_active", lambda self: False) + monkeypatch.setattr(HermesCLI, "_vim_mode_label", lambda self: "") + monkeypatch.setattr(HermesCLI, "_cache_hit_rate", lambda self, snap, precision=1: (28.9, "◎ 28.9%")) + monkeypatch.setattr("hermes_cli.cli_modern_layout.hint_pairs", lambda cli: []) + monkeypatch.setattr("hermes_cli.cli_modern_layout._cwd_label", lambda: "~") + line = "".join(text for _, text in cli._get_status_bar_fragments()) + for piece in ("73.2K/1M", "7%", "◎ 28.9%", "◷ 9.0s", "↑ 61 t/s", "⛓ 3", "6h 43m", "⏱ 10s"): + assert piece in line, (piece, line) + + +@pytest.mark.parametrize("prompt", ["_connection_state", "_secret_state", "_sudo_state", "_clarify_state"]) +def test_tab_never_queues_what_a_prompt_owns(prompt): + """A connection form's masked field (or any prompt) owns the composer while a turn runs: Tab must + not queue its text as a chat message, which would print and save a secret in plain text.""" + from types import SimpleNamespace + from prompt_toolkit.application import Application + from prompt_toolkit.application.current import set_app + from prompt_toolkit.buffer import Buffer + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.keys import Keys + from prompt_toolkit.layout import BufferControl, Layout, Window + from hermes_cli.cli_modern_layout import modern_input_rules + + cli = SimpleNamespace(_agent_running=True, _tui_input_rule_height=lambda pos: 1) + kb = KeyBindings() + modern_input_rules(cli, kb) + tab = next(b for b in kb.bindings if b.keys == (Keys.ControlI,)) + buf = Buffer(document=None) + buf.text = "REVIEW_CANARY_NOT_A_CREDENTIAL" + app = Application(layout=Layout(Window(BufferControl(buffer=buf)))) + with set_app(app): + assert tab.filter() # control: a plain busy composer does queue on Tab + setattr(cli, prompt, {"phase": "form"}) + assert not tab.filter(), prompt + + +def test_long_user_message_soft_wraps_so_a_resize_reflows_it(monkeypatch): + """The glyph layout's echo of a long prompt is one logical line per source line: the terminal + wraps it, so widening the window re-flows it instead of keeping old-width breaks.""" + from cli import HermesCLI + import cli as climod + import shutil + + cli = HermesCLI.__new__(HermesCLI) + cli._modern_layout, cli.show_timestamps = True, False + long_line = "word " * 60 + for text in (long_line.strip(), f"{long_line.strip()}\nsecond line"): + out = {} + for width in (60, 140): + emitted: list[str] = [] + monkeypatch.setattr(climod, "_cprint", lambda s: emitted.append(_plain(s))) + monkeypatch.setattr(shutil, "get_terminal_size", lambda *_a, w=width: __import__("os").terminal_size((w, 40))) + cli._print_user_message_preview(text) + out[width] = [line for line in "\n".join(emitted).split("\n") if line.strip()] + assert out[60] == out[140], out + assert any(line.rstrip().endswith("word") and line.count("word") == 60 for line in out[60]), out[60] + + +def test_tool_rows_keep_the_classic_tool_emoji(): + """Each finished tool row leads with the same emoji the classic ``┊`` row shows (📖 read, 💻 + terminal…); a failed entry swaps it for the red ✗ so failures stay obvious.""" + from agent.display import get_cute_tool_message + from hermes_cli.cli_modern_layout import tool_row + + # clarify has no dedicated renderer: both layouts take its registered emoji from one source. + for name, args in (("read_file", {"path": "/etc/hostname"}), ("terminal", {"command": "echo ok"}), + ("execute_code", {"code": "import math"}), ("clarify", {"question": "Which?"})): + classic_emoji = get_cute_tool_message(name, args, 0.2).split()[1] + assert _plain(tool_row(name, args, 0.2)).startswith(classic_emoji + " "), (name, classic_emoji) + failed = _plain(tool_row("terminal", {"command": "false"}, 0.2, result='{"exit_code": 1, "output": ""}')) + assert failed.startswith("✗ ") and "[exit 1]" in failed, failed diff --git a/tests/hermes_cli/test_cli_queue_gateway_fifo.py b/tests/hermes_cli/test_cli_queue_gateway_fifo.py new file mode 100644 index 0000000000..d9f8b1f150 --- /dev/null +++ b/tests/hermes_cli/test_cli_queue_gateway_fifo.py @@ -0,0 +1,683 @@ +"""The classic CLI's ``/queue`` goes into the gateway's durable FIFO (refs #134889, N5). + +On main ``/queue `` typed while a turn runs only ``put`` the text on the CLI's own +``_pending_input``: no ``session_admissions`` row, so an owner restart (or the N2 disconnect) lost or +failed it. Now the CLI submits it as an ordinary queued admission (``prompt.submit``), the way Ink and +Desktop queue busy input, and ``/queue list`` / ``rm`` / ``clear`` read and change the gateway's queue. + +Real owners (``initialize_session_authority`` again on the same state.db is the restart), real +``AuthorityConnection.dispatch``, real ``GatewayConnection`` and ``GatewaySessionStore``. The one stub: +the first owner's drain is held (``_schedule`` no-op), which is what a running turn does to the FIFO. +""" +import asyncio +import queue +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from hermes_cli.gateway_client import rpc_error +from hermes_cli.gateway_connection import GatewayConnection +from hermes_cli.gateway_session_store import GatewaySessionStore + +TEXT = "N5 queued while the turn runs" + + +class _Life: + def __init__(self, connection, loop): + self.connection, self.loop = connection, loop + self.events = asyncio.Queue() + + async def rpc(self, method, *, _timeout=None, **params): + frame = {'id': 1, 'method': method, 'params': params} + reply = await asyncio.wrap_future(asyncio.run_coroutine_threadsafe(self.connection.dispatch(frame), self.loop)) + if 'error' in reply: + raise rpc_error(reply['error']) + return reply['result'] + + +class _Owners: + async def start(self, tmp_path, monkeypatch): + from gateway import run + from gateway.config import GatewayConfig + from gateway.session import SessionStore + from gateway.session_controls import AuthorityConnection + from gateway.session_local import create_local_session + monkeypatch.setattr(run, '_load_gateway_config', lambda *a, **k: {'platform_toolsets': {'cli': []}}) + self.profile, self.loop, self.lives = str(tmp_path), asyncio.get_running_loop(), [] + self.store = SessionStore(tmp_path / 'sessions', GatewayConfig()) + first = await self.boot('first') + first._schedule = lambda ref: None # a turn is running: the FIFO does not drain + actor = AuthorityConnection(first, object(), {'user_id': 'owner'}).actor + self.sid = create_local_session(first, actor, {'cwd': self.profile, 'model': 'm', 'toolsets': [], + 'request_id': 'n5'}).session_id + return self + + async def boot(self, instance_id): + from gateway.session_authority import initialize_session_authority + owner = SimpleNamespace(session_store=self.store, _session_db=self.store._db, adapters={}, _draining=False, + _cached_agent_for=lambda route: None, _evict_cached_agent=lambda route: None) + owner._adapter_for_source = lambda source: owner.adapters.get(source.platform) + self.current = await initialize_session_authority(owner, profile_id=self.profile, instance_id=instance_id) + return self.current + + async def _open(self): + from gateway.session_controls import AuthorityConnection + self.lives.append(_Life(AuthorityConnection(self.current, object(), {'user_id': 'owner'}), self.loop)) + return self.lives[-1] + + @asynccontextmanager + async def dial(self): + yield await asyncio.wrap_future(asyncio.run_coroutine_threadsafe(self._open(), self.loop)) + + def rows(self): + from hermes_state_runtime import list_session_admissions + return [row for row in list_session_admissions(self.store._db, session_id=self.sid, pending_only=False) + if row['payload'].get('text') == TEXT] + + async def close(self, conn): + await asyncio.to_thread(conn.close) + for life in self.lives: + await life.connection.close() + self.store._db.close() + + +def _busy_cli(store, sid): + from cli import HermesCLI + cli = HermesCLI.__new__(HermesCLI) + cli.session_id = sid + cli._pending_input = queue.Queue() + cli._pending_resume_sessions = None + cli._session_db = store + cli._agent_running = True + return cli + + +def _command(cli, text): + with patch("cli._cprint") as printed: + assert cli.process_command(text) is True + return " ".join(str(call.args[0]) for call in printed.call_args_list) + + +@pytest.mark.asyncio +async def test_queue_while_busy_is_a_durable_admission_that_survives_an_owner_restart(tmp_path, monkeypatch): + owners = await _Owners().start(tmp_path, monkeypatch) + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + await asyncio.to_thread(store.attach, owners.sid) + cli = _busy_cli(store, owners.sid) + await asyncio.to_thread(_command, cli, f"/queue {TEXT}") + # Not held client-side: one queued admission in state.db, nothing for the CLI to send again. + assert cli._pending_input.empty() + assert [row['status'] for row in owners.rows()] == ['queued'] + listed = await asyncio.to_thread(_command, cli, "/queue list") + assert TEXT in listed + + await owners.boot('restarted') # the owner restarts; state.db keeps the FIFO + assert [row['status'] for row in owners.rows()] == ['queued'] + assert len({row['admission_id'] for row in owners.rows()}) == 1 # exactly once, never re-submitted + # The restarted owner lists it to a viewer that attaches now: it is the next turn it runs. + fresh = GatewayConnection(connect=owners.dial) + try: + snapshot = await asyncio.to_thread(GatewaySessionStore(fresh).attach, owners.sid) + assert [(row['text'], row['status']) for row in snapshot['pending']] == [(TEXT, 'queued')] + finally: + await asyncio.to_thread(fresh.close) + finally: + await owners.close(conn) + + +@pytest.mark.asyncio +async def test_queue_rm_and_clear_retire_the_gateway_admissions(tmp_path, monkeypatch): + owners = await _Owners().start(tmp_path, monkeypatch) + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + await asyncio.to_thread(store.attach, owners.sid) + cli = _busy_cli(store, owners.sid) + await asyncio.to_thread(_command, cli, f"/queue {TEXT}") + await asyncio.to_thread(_command, cli, "/queue second item") + await asyncio.to_thread(_command, cli, "/queue rm 1") + assert [row['status'] for row in owners.rows()] == ['terminal'] + listed = await asyncio.to_thread(_command, cli, "/queue list") + assert "second item" in listed and TEXT not in listed + await asyncio.to_thread(_command, cli, "/queue clear") + assert "empty" in (await asyncio.to_thread(_command, cli, "/queue list")).lower() + finally: + await owners.close(conn) + + +def test_the_owner_claim_of_a_queued_prompt_runs_as_a_typed_turn_without_a_second_submit(): + """When the owner starts the queued admission, the idle CLI paints it as the user's turn and + ``run_conversation`` pumps that admission (no new ``prompt.submit``: it runs exactly once).""" + from hermes_cli.gateway_agent import GatewayAgent + from tests.hermes_cli.test_gateway_agent import SID, frame + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli, _play + conn, client = _connected() + try: + cli = _idle_cli(conn) + conn.state(SID).queued_inputs["adm-q"] = TEXT + _play(conn, client, [frame("message.start", {}, admission="adm-q"), + frame("message.delta", {"text": "ran"}, admission="adm-q"), + frame("message.complete", {"text": "ran", "status": "complete"}, admission="adm-q")]) + cli._drain_process_notifications("cli-idle") + item = cli._pending_input.get_nowait() + assert str(item) == TEXT and type(item).__name__ == "_SeededQueryMessage" # literal, never a command + cli._tui_unwrap_input(item) # the input loop takes it: its admission is armed by id + agent = GatewayAgent(connection=conn, session_id=SID) + agent._pending_cli_user_message = {"role": "user", "content": TEXT} + result = agent.run_conversation(TEXT, conversation_history=[]) + assert "prompt.submit" not in [method for method, _ in client.calls] + assert result["final_response"] == "ran" + assert conn.state(SID).queued_inputs == {} # cleared once its turn is the one painted + finally: + conn.close() + + +def test_an_adopted_queued_prompt_never_submits_a_second_admission(): + """Review N5-F3: the adopted item is matched to its admission by id, not by text. A queued text the + input path's cleanup rewrites (a leaked CPR, a bracketed-paste marker) must still be pumped as the + admission the owner is running, never sent again as a new ``prompt.submit``.""" + from cli import _strip_leaked_bracketed_paste_wrappers, _strip_leaked_terminal_responses_with_meta + from hermes_cli.gateway_agent import GatewayAgent + from tests.hermes_cli.test_gateway_agent import SID, frame + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli, _play + text = "compare cursor log ^[[12;40R with the expected rows [200~" + conn, client = _connected() + try: + cli = _idle_cli(conn) + conn.state(SID).queued_inputs["adm-q"] = text + _play(conn, client, [frame("message.start", {}, admission="adm-q"), + frame("message.complete", {"text": "ran", "status": "complete"}, admission="adm-q")]) + cli._drain_process_notifications("cli-idle") + client.script = [frame("message.complete", {"text": "again", "status": "complete"}, admission="adm-1")] + item = cli._pending_input.get_nowait() + user_input, _, seeded = cli._tui_unwrap_input(item) + assert seeded and user_input == text + # Even if a sanitizer still rewrote it (chat() strips lone surrogates), identity decides. + user_input = _strip_leaked_bracketed_paste_wrappers(user_input) + user_input, _ = _strip_leaked_terminal_responses_with_meta(user_input) + agent = GatewayAgent(connection=conn, session_id=SID) + agent._pending_cli_user_message = {"role": "user", "content": user_input} + result = agent.run_conversation(user_input, conversation_history=[]) + assert "prompt.submit" not in [m for m, _ in client.calls], "second admission submitted" + assert result["final_response"] == "ran" + finally: + conn.close() + + +def test_a_typed_turn_with_the_same_text_is_never_taken_for_the_adopted_admission(): + """Identity cuts both ways: a typed input that happens to equal an adopted queued text, run before the + adopted item was unwrapped, is its own new submit.""" + from hermes_cli.gateway_agent import GatewayAgent + from tests.hermes_cli.test_gateway_agent import SID, frame + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli, _play + conn, client = _connected() + try: + cli = _idle_cli(conn) + conn.state(SID).queued_inputs["adm-q"] = TEXT + _play(conn, client, [frame("message.start", {}, admission="adm-q")]) + cli._drain_process_notifications("cli-idle") + client.script = [frame("message.complete", {"text": "typed", "status": "complete"}, admission="adm-1")] + agent = GatewayAgent(connection=conn, session_id=SID) + agent._pending_cli_user_message = {"role": "user", "content": TEXT} + agent.run_conversation(TEXT, conversation_history=[]) + assert "prompt.submit" in [m for m, _ in client.calls] + finally: + conn.close() + + +def _run_adopted(cli, conn): + """The input loop takes the adopted item and chat() runs it: ``run_conversation`` result.""" + from hermes_cli.gateway_agent import GatewayAgent + from tests.hermes_cli.test_gateway_agent import SID + item = cli._pending_input.get_nowait() + text, _, seeded = cli._tui_unwrap_input(item) + assert seeded and item.admission_id + agent = GatewayAgent(connection=conn, session_id=SID) + agent._pending_cli_user_message = {"role": "user", "content": text} + return agent.run_conversation(text, conversation_history=[]) + + +def test_a_local_turn_does_not_swallow_the_start_of_a_cli_queued_admission(): + """Review N5-F2: the owner started the CLI's queued admission, but a local input (busy-mode Enter, + a /goal continuation) reached run_conversation before the idle tick adopted it. Its drain must not + lose that turn: the idle tick after the local turn paints it from its receipt (no second submit), + and only then is its ``queued_inputs`` entry cleared.""" + from hermes_cli.gateway_agent import GatewayAgent + from tests.hermes_cli.test_gateway_agent import SID, frame + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli, _play + conn, client = _connected() + try: + cli = _idle_cli(conn) + state = conn.state(SID) + state.queued_inputs["adm-q"] = TEXT + _play(conn, client, [frame("message.start", {}, admission="adm-q"), + frame("message.delta", {"text": "queued reply"}, admission="adm-q")]) + client.script = [frame("message.complete", {"text": "local", "status": "complete"}, admission="adm-1")] + agent = GatewayAgent(connection=conn, session_id=SID) + agent._pending_cli_user_message = {"role": "user", "content": "local"} + agent.run_conversation("local", conversation_history=[]) + assert "adm-q" in state.queued_inputs # not painted yet: still owed + submits = [m for m, _ in client.calls].count("prompt.submit") + client.result = {"final_response": "queued reply"} + cli._drain_process_notifications("cli-post-turn") + result = _run_adopted(cli, conn) + assert [m for m, _ in client.calls].count("prompt.submit") == submits # never submitted again + assert result["final_response"] == "queued reply" + assert state.queued_inputs == {} + finally: + conn.close() + + +def test_a_queued_admission_another_client_cancelled_is_forgotten_not_painted(): + """A remembered admission that settled without running (cancelled by Ink/Desktop) is dropped from + ``queued_inputs`` on the next published list; nothing is painted for it.""" + from tests.hermes_cli.test_gateway_agent import SID + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli + conn, client = _connected() + try: + cli = _idle_cli(conn) + state = conn.state(SID) + state.queued_inputs["adm-q"] = TEXT + real = client.rpc + + async def rpc(method, **params): + if method == "prompt.receipt": + client.calls.append((method, params)) + return {"admission_id": "adm-q", "status": "terminal", "outcome": "cancelled"} + return await real(method, **params) + client.rpc = rpc + state.apply_event("session.info", {"pending": []}) + cli._drain_process_notifications("cli-idle") + assert cli._pending_input.empty() and state.queued_inputs == {} + finally: + conn.close() + + +def test_queue_list_never_erases_a_finished_queued_turn_this_viewer_has_not_painted(): + """Greptile P1 (gateway_queue.py:62): a fast queued turn settles while this CLI is still busy. + ``/queue list`` reads its receipt as terminal; that must not drop the tracking the idle tick needs + to paint the turn whose frames are still buffered.""" + from hermes_cli import gateway_queue + from tests.hermes_cli.test_gateway_agent import SID, frame + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli, _play + conn, client = _connected() + try: + cli = _idle_cli(conn) + conn.state(SID).queued_inputs["adm-q"] = TEXT + _play(conn, client, [frame("message.start", {}, admission="adm-q"), + frame("message.delta", {"text": "fast"}, admission="adm-q"), + frame("message.complete", {"text": "fast", "status": "complete"}, admission="adm-q")]) + assert gateway_queue.queued_rows(cli, cli._session_db) == [] # receipt: terminal, not queued + cli._drain_process_notifications("cli-idle") + result = _run_adopted(cli, conn) + assert "prompt.submit" not in [m for m, _ in client.calls] + assert result["final_response"] == "fast" + assert conn.state(SID).queued_inputs == {} + finally: + conn.close() + + + +def test_a_queued_turn_the_owner_ran_before_this_viewer_reattached_is_still_painted(): + """Greptile P1 (gateway_tools_notify.py:76): after an owner restart the drain can run the queued + admission before this CLI resubscribes; ``session.resume`` replays no frames. The resumed pending + list no longer lists it, so the idle tick reads its receipt and paints the saved reply (no pump, + no submit).""" + from tests.hermes_cli.test_gateway_agent import SID + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli + conn, client = _connected() + try: + cli = _idle_cli(conn) + state = conn.state(SID) + state.queued_inputs["adm-q"] = TEXT + client.result = {"final_response": "ran while you were away"} + state.adopt_snapshot({"stored_session_id": SID, "pending": []}) # the reconnect's resume + cli._drain_process_notifications("cli-idle") + result = _run_adopted(cli, conn) + assert "prompt.submit" not in [m for m, _ in client.calls] + assert result["final_response"] == "ran while you were away" + assert state.queued_inputs == {} + finally: + conn.close() + + +def test_dock_drops_a_cli_queued_admission_another_client_cancelled(): + """Review N5-F1: another client retired the CLI's queued admission (prompt.cancel, replace, steer). + The owner's next published pending list omits it; the dock must stop listing it as queued.""" + from hermes_cli.cli_session_dock import queued_prompts + from tests.hermes_cli.test_gateway_agent import SID + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli + conn, _client = _connected() + try: + cli = _idle_cli(conn) + state = conn.state(SID) + state.queued_inputs["adm-q"] = TEXT + state.apply_event("session.info", {"pending": [{"admission_id": "adm-q", "status": "queued", + "sequence": 1, "text": TEXT}]}) + assert queued_prompts(cli) == [TEXT] + state.apply_event("session.info", {"pending": []}) # another client cancelled it + assert queued_prompts(cli) == [], "retired admission still painted as queued" + finally: + conn.close() + + +def test_dock_shows_a_fresh_submit_until_the_owner_publishes_its_list(): + """The gap between the submit's receipt and the owner's next pending list: the new admission is shown; + the first list published after it decides.""" + from hermes_cli import gateway_queue + from hermes_cli.cli_session_dock import queued_prompts + from tests.hermes_cli.test_gateway_agent import SID + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli + conn, _client = _connected() + try: + cli = _idle_cli(conn) + state = conn.state(SID) + state.apply_event("session.info", {"pending": []}) + gateway_queue.submit_queued(cli, cli._session_db, TEXT) # FakeClient admits it as adm-1 + assert queued_prompts(cli) == [TEXT] + state.apply_event("session.info", {"pending": [{"admission_id": "adm-1", "status": "queued", + "sequence": 1, "text": TEXT}]}) + assert queued_prompts(cli) == [TEXT] + state.apply_event("session.info", {"pending": []}) + assert queued_prompts(cli) == [] + finally: + conn.close() + + +def _queue_command(cli, fn, *args): + with patch("cli._cprint") as printed: + fn(*args) + return " ".join(str(c.args[0]) for c in printed.call_args_list) + + +def _owned_cli(): + from tests.hermes_cli.test_gateway_agent import SID + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli + conn, _client = _connected() + cli = _idle_cli(conn) + cli._agent_running = True + conn.state(SID).queued_inputs["adm-q"] = TEXT + cli._mutate_pending_input = lambda fn: ([], []) + return conn, cli + + +@pytest.mark.parametrize("verb, args", [("_queue_remove", ("1",)), ("_queue_edit", ("1 new text",))]) +def test_a_refused_queue_rm_or_edit_says_what_was_refused(verb, args): + """Review N5-F4: an rm/edit the owner refuses (unsupported_control, ...) gets its own message, never + the submit refusal's "Nothing was queued; send it again".""" + from hermes_cli.gateway_client import GatewayRPCError + conn, cli = _owned_cli() + try: + with patch("hermes_cli.gateway_queue.queued_rows", lambda c, s: [("adm-q", TEXT)]), \ + patch("hermes_cli.gateway_queue.cancel", side_effect=GatewayRPCError("unsupported_control")), \ + patch("hermes_cli.gateway_queue.replace", side_effect=GatewayRPCError("unsupported_control")): + out = _queue_command(cli, getattr(cli, verb), *args) + assert "Nothing was queued" not in out and "send it again" not in out, out + assert "queue item 1" in out.lower() and "unsupported_control" in out, out + finally: + conn.close() + + +@pytest.mark.parametrize("verb, args", [("_queue_remove", ("1",)), ("_queue_edit", ("1 new text",))]) +def test_an_rm_or_edit_of_a_row_the_drain_already_started_says_it_started(verb, args): + """The drain claimed the row between the list and the verb (stale_generation): the user is told it + already started; its turn is still owed a paint.""" + from hermes_cli.gateway_client import GatewayRPCError + from tests.hermes_cli.test_gateway_agent import SID + conn, cli = _owned_cli() + try: + with patch("hermes_cli.gateway_queue.queued_rows", lambda c, s: [("adm-q", TEXT)]), \ + patch("hermes_cli.gateway_queue.cancel", side_effect=GatewayRPCError("stale_generation")), \ + patch("hermes_cli.gateway_queue.replace", side_effect=GatewayRPCError("stale_generation")): + out = _queue_command(cli, getattr(cli, verb), *args) + assert "already started" in out, out + assert "adm-q" in conn.state(SID).queued_inputs + finally: + conn.close() + + +def test_an_rm_racing_the_drain_claim_tells_the_user_the_gateway_stopped_it(): + """Owner ``prompt.cancel`` interrupts a row the drain claimed between its read and the cancel commit and + answers a non-terminal receipt. That rm used to print nothing and forget the admission.""" + from tests.hermes_cli.test_gateway_agent import SID + conn, cli = _owned_cli() + try: + real = cli._session_db.connection.call + + def call(method, **params): + if method == "prompt.cancel": + return {"admission_id": "adm-q", "status": "started"} + return real(method, **params) + cli._session_db.connection.call = call + with patch("hermes_cli.gateway_queue.queued_rows", lambda c, s: [("adm-q", TEXT)]): + out = _queue_command(cli, cli._queue_remove, "1") + assert "already started" in out and "stopped" in out, out + assert "adm-q" in conn.state(SID).queued_inputs # its interrupted turn is still painted + finally: + conn.close() + + +def _flaky_submit(client, failures): + """``prompt.submit`` loses its answer ``failures`` times (sent, no reply), then admits ``adm-q``.""" + from hermes_cli.gateway_client import GatewayClientError + real, seen = client.rpc, [] + + async def rpc(method, **params): + if method != "prompt.submit": + return await real(method, **params) + client.calls.append((method, params)) + seen.append(params["submission_id"]) + if len(seen) <= failures: + raise GatewayClientError("Gateway did not answer prompt.submit") + return {"admission_id": "adm-q", "status": "queued"} + client.rpc = rpc + return seen + + +def test_a_queue_submit_whose_answer_was_lost_is_retried_under_the_same_identity(): + """Greptile P1 (cli_loops_mixin.py:383): a timeout or disconnect after the submit was sent is not a + refusal; the owner may already hold the prompt. The CLI asks again with the SAME submission id (the + owner answers the existing admission), so the prompt is queued exactly once.""" + from tests.hermes_cli.test_gateway_agent import SID + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli + conn, client = _connected() + try: + cli = _idle_cli(conn) + cli._agent_running = True + cli._expand_paste_references = lambda text: text + seen = _flaky_submit(client, 1) + out = _queue_command(cli, cli._queue_enqueue, TEXT) + assert len(seen) == 2 and seen[0] == seen[1] + assert "Nothing was queued" not in out + assert conn.state(SID).queued_inputs == {"adm-q": TEXT} + finally: + conn.close() + + +def test_a_queue_submit_with_no_answer_at_all_never_says_nothing_was_queued(): + from tests.hermes_cli.test_gateway_agent import SID + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli + conn, client = _connected() + try: + cli = _idle_cli(conn) + cli._agent_running = True + cli._expand_paste_references = lambda text: text + _flaky_submit(client, 2) + out = _queue_command(cli, cli._queue_enqueue, TEXT) + assert "Nothing was queued" not in out and "send it again" not in out, out + assert "/queue list" in out + assert conn.state(SID).queued_inputs == {} + finally: + conn.close() + + +def test_a_queue_submit_the_owner_refused_still_says_nothing_was_queued(): + from hermes_cli.gateway_client import GatewayRPCError + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli + conn, client = _connected() + try: + cli = _idle_cli(conn) + cli._agent_running = True + cli._expand_paste_references = lambda text: text + real = client.rpc + + async def rpc(method, **params): + if method == "prompt.submit": + client.calls.append((method, params)) + raise GatewayRPCError("queue_full") + return await real(method, **params) + client.rpc = rpc + out = _queue_command(cli, cli._queue_enqueue, TEXT) + assert "Nothing was queued" in out and "queue_full" in out + assert [m for m, _ in client.calls].count("prompt.submit") == 1 # a verdict is never retried + finally: + conn.close() + + +def _busy_enter(cli, mode, text, images=()): + """Enter typed while the turn runs, through main's ``_tui_enter_while_busy``.""" + cli.busy_input_mode = mode + cli.agent = getattr(cli, "agent", None) + with patch("agent.onboarding.is_seen", lambda *a: True), patch("cli._cprint"): + cli._tui_enter_while_busy(text, list(images), (text, list(images)) if images else text) + + +@pytest.mark.asyncio +async def test_probe_queue_mode_enter_then_queue_keep_entry_order(tmp_path, monkeypatch): + """Review round 2, major 1: a queue-mode Enter typed before ``/queue X`` used to stay in + ``_pending_input`` while X went to the owner's FIFO, so X ran first (and the typed item had no + ``session_admissions`` row). With a gateway store attached it is an admission too, in entry order.""" + owners = await _Owners().start(tmp_path, monkeypatch) + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + await asyncio.to_thread(store.attach, owners.sid) + cli = _busy_cli(store, owners.sid) + await asyncio.to_thread(_busy_enter, cli, "queue", "typed first") + await asyncio.to_thread(_command, cli, f"/queue {TEXT}") + assert cli._pending_input.empty() + listed = await asyncio.to_thread(_command, cli, "/queue list") + assert listed.index("typed first") < listed.index(TEXT), f"entry order lost: {listed}" + finally: + await owners.close(conn) + + +def test_a_steer_that_falls_back_to_queue_is_a_gateway_admission_and_images_stay_local(): + """The steer fallback inherits the queue-mode path; an image follow-up (no text-only admission) and + a run with no gateway store keep main's ``_pending_input``.""" + from tests.hermes_cli.test_gateway_agent import SID + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli + conn, client = _connected() + try: + cli = _idle_cli(conn) + cli._agent_running = True + cli.agent = SimpleNamespace(steer=lambda text: False) # the running turn refused the steer + _busy_enter(cli, "steer", "steer me") + assert cli._pending_input.empty() and list(conn.state(SID).queued_inputs.values()) == ["steer me"] + _busy_enter(cli, "queue", "look", images=["/img.png"]) + assert cli._pending_input.get_nowait() == ("look", ["/img.png"]) + submits = [m for m, _ in client.calls].count("prompt.submit") + assert submits == 1 + finally: + conn.close() + local = _idle_cli(conn) + local._session_db = object() # not a gateway viewer + local._agent_running = True + _busy_enter(local, "queue", "plain") + assert local._pending_input.get_nowait() == "plain" + + + +def _run_loop(cli, until): + """Run main's ``_tui_process_loop`` in a thread; returns ``(processed, stop)``.""" + import threading + import time + processed = [] + + def one(item): + processed.append((time.monotonic(), item)) + if len(processed) >= until: + cli._should_exit = True + cli._should_exit = False + cli._tui_process_one_input = one + cli._tui_idle_tick = lambda: cli._drain_process_notifications("cli-idle") + thread = threading.Thread(target=cli._tui_process_loop, daemon=True) + thread.start() + + def stop(timeout): + thread.join(timeout) + cli._should_exit = True + thread.join(2) + return processed, stop + + +def test_probe_slash_command_typed_after_queue_runs_after_it(): + """Review round 2, major 2: ``/queue X`` then ``/new`` typed while busy. On main both sat in + ``_pending_input`` in that order; with X in the owner's FIFO the input loop must still run X first, + not let ``/new`` detach the session before the owner claims X.""" + from tests.waiting import wait_until + from tests.hermes_cli.test_gateway_agent import SID, frame + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli + conn, client = _connected() + try: + cli = _idle_cli(conn) + state = conn.state(SID) + state.queued_inputs["adm-q"] = TEXT + state.apply_event("session.info", {"running": True, "pending": [ + {"admission_id": "adm-1", "status": "started", "sequence": 1, "text": "turn"}, + {"admission_id": "adm-q", "status": "queued", "sequence": 2, "text": TEXT}]}) + cli._pending_input.put("/new") # typed while the CLI's turn ran; that turn just returned + processed, stop = _run_loop(cli, 2) + wait_until(lambda: cli._pending_input.empty(), timeout=5) # the loop took /new and holds it + # The owner settles the turn, then claims X. + frames = [frame("session.info", {"running": False, "pending": [ + {"admission_id": "adm-q", "status": "queued", "sequence": 2, "text": TEXT}]}, admission=None), + frame("message.start", {}, admission="adm-q")] + + async def put(): # not _play: the held loop consumes the frames as they land + for item in frames: + await client.events.put(item) + asyncio.run_coroutine_threadsafe(put(), conn.loop).result(5) + stop(5) + order = [str(item) for _, item in processed] + assert order == [TEXT, "/new"], f"input loop runs {order[0]!r} before the /queue prompt" + assert getattr(processed[0][1], "admission_id", None) == "adm-q" + finally: + conn.close() + + +@pytest.mark.parametrize("pending, running", [ + ([{"admission_id": "adm-0", "status": "unknown", "sequence": 1, "text": "lost"}, + {"admission_id": "adm-q", "status": "queued", "sequence": 2, "text": TEXT}], False), + ([{"admission_id": "adm-q", "status": "queued", "sequence": 2, "text": TEXT}], False), + ([{"admission_id": "adm-q", "status": "queued", "sequence": 2, "text": TEXT}], True), +]) +def test_typing_stays_responsive_when_the_owner_queue_is_paused(pending, running): + """The hold is bounded: a FIFO paused on an ``unknown`` row runs a typed command at once, and an owner + that never claims this viewer's admission (idle, or busy with someone else's turn) delays it by at + most ``OWN_QUEUE_HOLD_S``; the admission stays queued, never dropped.""" + import time + from hermes_cli.gateway_queue import OWN_QUEUE_HOLD_S + from tests.hermes_cli.test_gateway_agent import SID + from tests.hermes_cli.test_gateway_tools_notify import _connected, _idle_cli + assert OWN_QUEUE_HOLD_S >= 2.0 + conn, _client = _connected() + try: + cli = _idle_cli(conn) + state = conn.state(SID) + state.queued_inputs["adm-q"] = TEXT + state.apply_event("session.info", {"running": running, "pending": pending}) + started = time.monotonic() + cli._pending_input.put("/help") + processed, stop = _run_loop(cli, 1) + stop(OWN_QUEUE_HOLD_S + 2) + assert [str(item) for _, item in processed] == ["/help"] + waited = processed[0][0] - started + assert waited < (0.5 if pending[0]["status"] == "unknown" else OWN_QUEUE_HOLD_S + 1.0), waited + assert state.queued_inputs == {"adm-q": TEXT} + finally: + conn.close() diff --git a/tests/hermes_cli/test_gateway_connection_reconnect.py b/tests/hermes_cli/test_gateway_connection_reconnect.py index 22d2f8c643..15e4ea00ff 100644 --- a/tests/hermes_cli/test_gateway_connection_reconnect.py +++ b/tests/hermes_cli/test_gateway_connection_reconnect.py @@ -264,6 +264,40 @@ def test_close_after_a_failed_resume_is_still_final(gateway, conn): assert len(gateway.lives) == 2 +class _Unanswered: + """A socket that takes every request and never answers one (a gateway stalled mid-reconnect).""" + + def __init__(self): + self.sent = asyncio.Event() + + async def send(self, raw): + self.sent.set() + + def __aiter__(self): + return self + + async def __anext__(self): + return await asyncio.Future() + + +@pytest.mark.asyncio +async def test_a_cancelled_reconnect_cancels_its_session_resume(): + """The viewer closes while a reconnect's ``session.resume`` goes unanswered: cancelling the + resubscribe cancels the request with it. One left pending on the client held a worker thread + that process exit joins until the request timed out.""" + from hermes_cli.gateway_client import GatewayClient + conn = GatewayConnection() + conn.state(SID).subscription_id = "sub-1" + socket = _Unanswered() + async with GatewayClient(socket) as client: + resubscribe = asyncio.create_task(conn._resubscribe(client)) + await asyncio.wait_for(socket.sent.wait(), 5) + resubscribe.cancel() + with pytest.raises(asyncio.CancelledError): + await resubscribe + assert client.pending == {} + + def _cli(store): return types.SimpleNamespace(_session_db=store, _gateway_session_open=True) diff --git a/tests/hermes_cli/test_gateway_resume_launch_key.py b/tests/hermes_cli/test_gateway_resume_launch_key.py new file mode 100644 index 0000000000..bfd6f9f6ce --- /dev/null +++ b/tests/hermes_cli/test_gateway_resume_launch_key.py @@ -0,0 +1,351 @@ +"""The classic CLI re-supplies its launch-only key on every resume (refs #134889, RV-14). + +An owner restart revokes launch keys and takes the same key back on ``session.resume {api_key}`` +(``gateway/session_policy.py::rebind_launch_key``; the thin client sends it). The classic CLI resumed with +the session id only, at startup (``--resume`` / ``-c``), on ``/resume`` and on a reconnect, so the first +turn after any gateway restart failed with ``launch_credentials_unavailable``. + +Real owners (``initialize_session_authority`` again on the same DB is the restart), real +``AuthorityConnection.dispatch``, real ``GatewayConnection`` and ``GatewaySessionStore``: each dial of the +CLI's connection reaches the current owner through a bridge client.""" +import asyncio +import logging +from contextlib import asynccontextmanager +from types import SimpleNamespace + +import pytest + +from hermes_cli.gateway_client import GatewayClientError, GatewayRPCError, rpc_error +from hermes_cli.gateway_connection import GatewayConnection +from hermes_cli.gateway_session_store import GatewaySessionStore + +RAW = 'UNIQUE-LAUNCH-ONLY-KEY' +LAUNCH = {'model': 'm', 'provider': None, 'api_key': RAW, 'base_url': 'http://127.0.0.1:9/v1'} + + +class _Life: + """One socket to one owner: requests run on the owner's loop (the test's).""" + + def __init__(self, connection, loop): + self.connection, self.loop = connection, loop + self.events = asyncio.Queue() + + async def rpc(self, method, *, _timeout=None, **params): + frame = {'id': 1, 'method': method, 'params': params} + reply = await asyncio.wrap_future(asyncio.run_coroutine_threadsafe(self.connection.dispatch(frame), self.loop)) + if 'error' in reply: + raise rpc_error(reply['error']) + return reply['result'] + + +class _Owners: + """The profile's gateway across a restart: the first owner made a keyed and a keyless CLI session.""" + + async def start(self, tmp_path, monkeypatch): + from gateway import run + from gateway.config import GatewayConfig + from gateway.session import SessionStore + from gateway.session_controls import AuthorityConnection + from gateway.session_local import create_local_session + monkeypatch.setattr(run, '_load_gateway_config', lambda *a, **k: {'platform_toolsets': {'cli': []}}) + self.profile, self.loop, self.lives = str(tmp_path), asyncio.get_running_loop(), [] + self.identity = {'user_id': 'owner'} # the CLI's principal on each dial + self.store = SessionStore(tmp_path / 'sessions', GatewayConfig()) + first = await self.boot('first') + actor = AuthorityConnection(first, object(), {'user_id': 'owner'}).actor + base = {'cwd': self.profile, 'model': 'm', 'toolsets': []} + self.keyed = create_local_session(first, actor, {**base, 'request_id': 'keyed', 'api_key': RAW, + 'base_url': LAUNCH['base_url']}).session_id + self.plain = create_local_session(first, actor, {**base, 'request_id': 'plain'}).session_id + return self + + async def boot(self, instance_id): + from gateway.session_authority import initialize_session_authority + owner = SimpleNamespace(session_store=self.store, _session_db=self.store._db, adapters={}, _draining=False, + _cached_agent_for=lambda route: None, _evict_cached_agent=lambda route: None) + owner._adapter_for_source = lambda source: owner.adapters.get(source.platform) + self.current = await initialize_session_authority(owner, profile_id=self.profile, instance_id=instance_id) + return self.current + + async def _open(self): + from gateway.session_controls import AuthorityConnection + self.lives.append(_Life(AuthorityConnection(self.current, object(), self.identity), self.loop)) + return self.lives[-1] + + @asynccontextmanager + async def dial(self): + yield await asyncio.wrap_future(asyncio.run_coroutine_threadsafe(self._open(), self.loop)) + + async def hang_up(self, conn): + thread = conn._thread + conn.loop.call_soon_threadsafe(self.lives[-1].events.put_nowait, GatewayClientError('Gateway disconnected')) + await asyncio.to_thread(thread.join, 5) + await self.lives[-1].connection.close() + + def key_of(self, session_id): + """What the next turn's agent build reads (``launch_key``); ``launch_credentials_unavailable`` on main.""" + from gateway.session_policy import launch_key, policy_for_source + return launch_key(self.current, policy_for_source(self.current.runner, self.current.sessions[session_id].source)) + + async def close(self, conn): + await asyncio.to_thread(conn.close) + for life in self.lives: + await life.connection.close() + self.store._db.close() + + +@pytest.mark.asyncio +async def test_resume_after_a_restart_resupplies_the_launch_key(tmp_path, monkeypatch): + """``hermes --resume --api-key K`` (or ``-c`` with a keyed alias) once the gateway restarted; then + ``/resume`` of a session launched without that key still attaches.""" + owners = await _Owners().start(tmp_path, monkeypatch) + await owners.boot('restarted') + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + cli = SimpleNamespace(_resumed=True, session_id=owners.keyed) + assert await asyncio.to_thread(store.open_session, cli, LAUNCH) == owners.keyed + assert owners.key_of(owners.keyed) == RAW + assert (await asyncio.to_thread(store.attach, owners.plain))['stored_session_id'] == owners.plain + finally: + await owners.close(conn) + + +@pytest.mark.asyncio +async def test_a_reconnect_after_a_restart_resupplies_the_launch_key(tmp_path, monkeypatch): + """A classic CLI left open across ``hermes gateway restart``: its redial resubscribes with the key, and + a session launched without it is still resubscribed.""" + owners = await _Owners().start(tmp_path, monkeypatch) + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + await asyncio.to_thread(store.open_session, SimpleNamespace(_resumed=True, session_id=owners.keyed), LAUNCH) + await asyncio.to_thread(store.attach, owners.plain) + before = conn.states[owners.plain].subscription_id + await owners.hang_up(conn) + await owners.boot('restarted') + await asyncio.to_thread(conn.start, 10) # the redial resubscribes before it counts as ready + assert owners.key_of(owners.keyed) == RAW + assert conn.states[owners.plain].subscription_id not in (None, before) + finally: + await owners.close(conn) + + +def _refuse_the_rebind(monkeypatch, code): + """The owner refuses the keyed resume with ``code`` where it takes the key back (a receipt read that + fails is ``storage_unavailable``); a plain resume never gets there.""" + from gateway import session_policy + from hermes_state_runtime import RuntimeStoreError + + def refuse(*_a): + raise RuntimeStoreError(code) + monkeypatch.setattr(session_policy, 'rebind_launch_key', refuse) + + +def _plain_retries(caplog): + return [r.getMessage() for r in caplog.records if r.name == 'hermes_cli.gateway_connection'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('path', ['attach', 'reconnect']) +@pytest.mark.parametrize('code, plain', [('admission_conflict', True), ('permission_denied', True), + ('storage_unavailable', False)]) +async def test_a_refused_keyed_resume_retries_plain_only_when_the_key_does_not_apply( + tmp_path, monkeypatch, caplog, path, code, plain): + """A key the session does not take (``admission_conflict``) or a viewer without ``session:control`` + (``permission_denied``) resumes plain as main did, with a debug line naming the code, never the key. Any + other refusal fails the resume, on ``/resume`` and on a reconnect alike (one helper): a plain retry would + hide it, and the first turn would fail ``launch_credentials_unavailable`` with nothing in the log.""" + caplog.set_level(logging.DEBUG, logger='hermes_cli.gateway_connection') + owners = await _Owners().start(tmp_path, monkeypatch) + conn = GatewayConnection(connect=owners.dial) + store = GatewaySessionStore(conn) + cli = SimpleNamespace(_resumed=True, session_id=owners.keyed) + try: + before = None + if path == 'reconnect': + await asyncio.to_thread(store.open_session, cli, LAUNCH) + before = conn.states[owners.keyed].subscription_id + await owners.hang_up(conn) + await owners.boot('restarted') + _refuse_the_rebind(monkeypatch, code) + resume = (lambda: conn.start(10)) if path == 'reconnect' else (lambda: store.open_session(cli, LAUNCH)) + if plain: + await asyncio.to_thread(resume) + assert conn.states[owners.keyed].subscription_id not in (None, before) + assert any(code in line for line in _plain_retries(caplog)) + else: + with pytest.raises(GatewayRPCError, match=code): + await asyncio.to_thread(resume) + assert RAW not in caplog.text + finally: + await owners.close(conn) + + +@pytest.mark.asyncio +async def test_a_read_only_viewer_still_gets_the_plain_attach(tmp_path, monkeypatch, caplog): + """The owner checks ``session:control`` before it takes a key back: a read-only viewer's keyed resume is + ``permission_denied``, and it still gets main's ``session:read`` attach, binding nothing.""" + from hermes_state_runtime import RuntimeStoreError + caplog.set_level(logging.DEBUG, logger='hermes_cli.gateway_connection') + owners = await _Owners().start(tmp_path, monkeypatch) + await owners.boot('restarted') + owners.identity = {'user_id': 'owner', 'capabilities': ['session:read']} + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + cli = SimpleNamespace(_resumed=True, session_id=owners.keyed) + assert await asyncio.to_thread(store.open_session, cli, LAUNCH) == owners.keyed + assert any('permission_denied' in line for line in _plain_retries(caplog)) + with pytest.raises(RuntimeStoreError, match='launch_credentials_unavailable'): + owners.key_of(owners.keyed) + finally: + await owners.close(conn) + + +def _typed(session_id, key): + """``hermes --resume --api-key ``: the key the user typed (``HermesCLI._typed_api_key``).""" + return SimpleNamespace(_resumed=True, session_id=session_id, _typed_api_key=key) + + +@pytest.mark.asyncio +async def test_resume_with_a_different_explicit_key_is_refused_not_dropped(tmp_path, monkeypatch): + """Review probe (#141481, CLI-34): a typed ``--api-key`` the session was not launched with is refused with + the thin client's words, never attached plain on the session's own key. Same owner life: K1 still held.""" + from hermes_cli.gateway_client import RESUME_KEY_MISMATCH + owners = await _Owners().start(tmp_path, monkeypatch) + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + wrong = {**LAUNCH, 'api_key': 'A-DIFFERENT-KEY'} + with pytest.raises(GatewayClientError) as refused: + await asyncio.to_thread(store.open_session, _typed(owners.keyed, 'A-DIFFERENT-KEY'), wrong) + assert str(refused.value) == RESUME_KEY_MISMATCH + assert owners.keyed not in store.snapshots # nothing attached + # A typed key on a session launched without one: the same refusal (the thin client's too). + with pytest.raises(GatewayClientError, match='must be the key this session was launched with'): + await asyncio.to_thread(store.open_session, _typed(owners.plain, RAW), LAUNCH) + # The right typed key still resumes (after a restart: test_resume_after_a_restart_resupplies_the_launch_key). + assert await asyncio.to_thread(store.open_session, _typed(owners.keyed, RAW), LAUNCH) == owners.keyed + finally: + assert owners.key_of(owners.keyed) == RAW # the session runs on K1 regardless + await owners.close(conn) + + +@pytest.mark.asyncio +async def test_a_keyed_alias_still_opens_a_keyless_session(tmp_path, monkeypatch, caplog): + """The key came from a keyed startup alias (nothing typed): ``-c`` / ``--resume`` of a session launched + without a key keeps the plain-resume fallback, and a reconnect resubscribes it plain too.""" + caplog.set_level(logging.DEBUG, logger='hermes_cli.gateway_connection') + owners = await _Owners().start(tmp_path, monkeypatch) + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + alias = SimpleNamespace(_resumed=True, session_id=owners.plain, _typed_api_key=None) + launch = {**LAUNCH, 'base_url': None} # a changed --base-url on resume is refused on its own + assert await asyncio.to_thread(store.open_session, alias, launch) == owners.plain + assert conn.launch_api_key == RAW and not conn.launch_api_key_typed + assert any('admission_conflict' in line for line in _plain_retries(caplog)) + before = conn.states[owners.plain].subscription_id + await owners.hang_up(conn) + await owners.boot('restarted') + await asyncio.to_thread(conn.start, 10) + assert conn.states[owners.plain].subscription_id not in (None, before) + finally: + await owners.close(conn) + + +@pytest.mark.asyncio +async def test_a_typed_key_reconnect_keeps_the_fallback(tmp_path, monkeypatch): + """A reconnect types nothing: a typed-key CLI that also viewed a keyless session resubscribes it plain.""" + owners = await _Owners().start(tmp_path, monkeypatch) + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + await asyncio.to_thread(store.open_session, _typed(owners.keyed, RAW), LAUNCH) + assert conn.launch_api_key_typed + await asyncio.to_thread(conn.resume, store.call, owners.plain) # e.g. a session.resume a view made plain + conn.state(owners.plain).subscription_id = 'viewed' + await owners.hang_up(conn) + await owners.boot('restarted') + await asyncio.to_thread(conn.start, 10) + assert owners.key_of(owners.keyed) == RAW + assert conn.states[owners.plain].subscription_id not in (None, 'viewed') + finally: + await owners.close(conn) + + +@pytest.mark.asyncio +async def test_slash_resume_in_a_typed_key_cli_switches_to_a_keyless_session_on_its_own_route( + tmp_path, monkeypatch): + """Review (#141481, CLI-34): only the startup resume holds a typed ``--api-key``. ``/resume `` in a ``--api-key`` CLI switches to it as on main, and the owner does not bind the key to it: + the session keeps its own frozen route.""" + from unittest.mock import patch + from cli import HermesCLI + from hermes_state import SessionDB + owners = await _Owners().start(tmp_path, monkeypatch) + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + store._reader = SessionDB(db_path=owners.store._db.db_path, read_only=True) # the CLI's state.db reads + cli = HermesCLI.__new__(HermesCLI) + cli._session_db, cli._resumed, cli.session_id, cli._typed_api_key = store, True, owners.keyed, RAW + cli.agent, cli.conversation_history, cli._pending_resume_sessions = None, [], None + cli._restore_session_cwd = cli._restore_session_yolo = cli._restore_session_model = lambda *a, **k: None + await asyncio.to_thread(store.open_session, cli, LAUNCH) # hermes --resume --api-key K + printed = [] + with patch('hermes_cli.main._resolve_session_by_name_or_id', return_value=None), \ + patch('cli._cprint', printed.append): + await asyncio.to_thread(cli._handle_resume_command, f'/resume {owners.plain}') + assert cli.session_id == owners.plain, printed + assert store.current['stored_session_id'] == owners.plain + assert owners.key_of(owners.plain) is None # not rebound to K + finally: + await owners.close(conn) + + +@pytest.mark.asyncio +async def test_branch_after_new_in_a_typed_key_cli_switches_to_the_child(tmp_path, monkeypatch): + """Review probe (#141481, CLI-34): ``/new`` in a ``--api-key`` CLI opens a session on the profile's default + route, without the key, and ``/branch`` of it attaches the child the owner just made with the plain + fallback. Held to the typed key, the attach raised the ``--api-key`` mismatch after the owner had + committed the child: the CLI stayed on the parent, and each retry made another child.""" + from datetime import datetime + from gateway import run + from hermes_cli.cli_init_mixin import CLIInitMixin + owners = await _Owners().start(tmp_path, monkeypatch) + monkeypatch.setattr(run, '_load_gateway_config', lambda *a, **k: {'platform_toolsets': {'cli': []}, 'model': 'm'}) + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + cli = _typed(owners.keyed, RAW) + cli._session_db, cli._gateway_session_open = store, True + await asyncio.to_thread(store.open_session, cli, LAUNCH) + fresh = await asyncio.to_thread(CLIInitMixin._new_session_id, cli, datetime.now()) # /new + assert owners.key_of(fresh) is None + owners.store._db.append_message(fresh, role='user', content='hello') + child = await asyncio.to_thread(CLIInitMixin._new_session_id, cli, datetime.now(), branch_of=fresh) # /branch + assert child != fresh and store.current['stored_session_id'] == child + finally: + await owners.close(conn) + + +@pytest.mark.asyncio +async def test_probe_slash_resume_keyless_session_from_api_key_cli_switches(tmp_path, monkeypatch): + """Review 2.1 probe (#141481, CLI-34): in a CLI started with ``--resume --api-key K`` after a + restart, ``/resume``'s id lookup (``_gateway_owned_id``) of a session launched without K answers that + session's id and attaches it, as on main; only the startup resume is held to the typed key.""" + from hermes_cli.cli_agent_setup_mixin import _gateway_owned_id + owners = await _Owners().start(tmp_path, monkeypatch) + await owners.boot('restarted') + conn = GatewayConnection(connect=owners.dial) + try: + store = GatewaySessionStore(conn) + assert await asyncio.to_thread(store.open_session, _typed(owners.keyed, RAW), LAUNCH) == owners.keyed + assert await asyncio.to_thread(_gateway_owned_id, store, owners.plain) == owners.plain + assert store.current['stored_session_id'] == owners.plain + assert owners.key_of(owners.plain) is None # K was not bound to it + assert owners.key_of(owners.keyed) == RAW + finally: + await owners.close(conn) diff --git a/tests/hermes_cli/test_plugin_oauth.py b/tests/hermes_cli/test_plugin_oauth.py new file mode 100644 index 0000000000..2c1f984546 --- /dev/null +++ b/tests/hermes_cli/test_plugin_oauth.py @@ -0,0 +1,571 @@ +"""Plugin-declared OAuth clients: Hermes signs in, stores and refreshes the grant; plugin code never holds a token. + +A real plugin directory under the test HERMES_HOME declares ``oauth:``. ``FakeIdP`` plays the authorization +server (PKCE, rotating refresh tokens) and a loopback stub plays the API the bearer is meant for. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import ssl +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace +from urllib.request import urlopen + +import pytest + +from hermes_cli import auth_oauth_pkce_plugin as pkce +from hermes_cli import plugin_oauth +from hermes_cli import plugin_provider_requests as ppr +from hermes_cli.auth_constants import AuthError +from tests.providers.fake_pkce_idp import FakeIdP + +CLIENT_ID = "voice-client.apps.example" +SECRET = "canary-client-secret-7f3a91" +SCOPES = ["https://api.example.com/auth/calendar", "https://api.example.com/auth/docs"] +KEY = "voice:google" + + +class _Api: + """Loopback stand-in for the third-party API; records every request it receives.""" + + def __init__(self, status=200, location=""): + self.seen: list[dict] = [] + api = self + + class Handler(BaseHTTPRequestHandler): + def _answer(self): + length = int(self.headers.get("Content-Length") or 0) + api.seen.append({"path": self.path, "headers": dict(self.headers), "body": self.rfile.read(length)}) + self.send_response(status) + if location: + self.send_header("Location", location) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"ok": true}') + + do_GET = do_POST = do_PATCH = _answer + + def log_message(self, *_): + pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.origin = ("http", "127.0.0.1", self.server.server_address[1]) + self.url = f"http://127.0.0.1:{self.server.server_address[1]}" + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + def close(self): + self.server.shutdown() + self.server.server_close() + + +def _home() -> Path: + return Path(os.environ["HERMES_HOME"]) + + +def _entry(idp: FakeIdP, **overrides) -> dict: + entry = {"id": "google", "authorize_url": f"{idp.base}/authorize", "token_url": f"{idp.base}/token", + "scopes": SCOPES, "client_id_env": "VOICE_CLIENT_ID", "client_secret_env": "VOICE_CLIENT_SECRET", + "hosts": ["api.example.com"], "authorize_params": {"access_type": "offline", "prompt": "consent"}} + return {**entry, **overrides} + + +def _plugin(name: str = "voice", oauth: list | None = None, display: str | None = None): + """Write ``plugins/`` (manifest plus a module that calls the helper) and import it like a plugin. + *display* is a manifest ``name`` other than the directory.""" + root = _home() / "plugins" / name + root.mkdir(parents=True, exist_ok=True) + manifest = {"name": display or name, "version": "1.0.0", "description": "test plugin", + "requires_env": [{"name": "VOICE_CLIENT_ID"}, {"name": "VOICE_CLIENT_SECRET"}]} + if oauth is not None: + manifest["oauth"] = oauth + (root / "plugin.yaml").write_text(json.dumps(manifest), encoding="utf-8") # JSON is valid YAML + (root / "api.py").write_text( + "from hermes_cli.plugin_provider_requests import credentialed_provider_request\n" + "def call(provider, url, method='POST', **kwargs):\n" + " return credentialed_provider_request(provider, method, url, **kwargs)\n", encoding="utf-8") + spec = importlib.util.spec_from_file_location(f"hermes_plugins_{name}_api", root / "api.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _auth(action: str, provider: str = KEY): + args = SimpleNamespace(provider=provider, no_browser=False, timeout=None) + return plugin_oauth.handle_auth_action(action, args, provider) + + +def _grant(key: str = KEY) -> dict | None: + store = json.loads((_home() / "auth.json").read_text(encoding="utf-8-sig")) + return (store.get("plugin_oauth") or {}).get(key) + + +def _set_grant(key: str = KEY, **fields) -> None: + path = _home() / "auth.json" + store = json.loads(path.read_text(encoding="utf-8-sig")) + store["plugin_oauth"][key].update(fields) + path.write_text(json.dumps(store), encoding="utf-8") + + +def _refreshes(idp: FakeIdP) -> int: + return sum(r["grant_type"] == "refresh_token" for r in idp.token_requests) + + +@pytest.fixture +def idp(monkeypatch, tmp_path): + monkeypatch.setenv("VOICE_CLIENT_ID", CLIENT_ID) + monkeypatch.setenv("VOICE_CLIENT_SECRET", SECRET) + server = FakeIdP().start(tls_dir=tmp_path / "idp-ca") # https: plugin OAuth endpoints are https-only + trust = ssl.create_default_context(cafile=str(server.ca_pem)) + monkeypatch.setattr("hermes_cli.auth._default_verify", lambda: trust) + server.opened = [] + + def browser(url: str) -> bool: + # A real browser runs beside Hermes; the fake IdP 302s straight to the loopback callback. + server.opened.append(url) + threading.Thread(target=lambda: urlopen(url, timeout=10, context=trust).close(), daemon=True).start() + return True + + monkeypatch.setattr(pkce.webbrowser, "open", browser) + monkeypatch.setattr("hermes_cli.auth_device_flow._can_open_graphical_browser", lambda: True) + yield server + server.stop() + + +@pytest.fixture +def api(monkeypatch): + """Test-only: the stub joins the declared hosts' origins; a second stub stays outside them.""" + allowed, other = _Api(), _Api() + declared = plugin_oauth._origins + monkeypatch.setattr(plugin_oauth, "_origins", lambda client: declared(client) | {allowed.origin}) + yield allowed, other + allowed.close() + other.close() + + +def _signed_in(idp: FakeIdP, **entry_overrides): + plugin = _plugin(oauth=[_entry(idp, **entry_overrides)]) + assert _auth("add") is True + return plugin + + +def test_sign_in_stores_the_grant_then_status_refresh_and_logout(idp, capsys): + from hermes_cli import auth_commands + + _plugin(oauth=[_entry(idp)]) + args = SimpleNamespace(provider=KEY, no_browser=False, timeout=None) + auth_commands.auth_add_command(args) + + assert "access_type=offline" in idp.opened[-1] and "prompt=consent" in idp.opened[-1] + exchange = idp.token_requests[-1] + assert exchange["grant_type"] == "authorization_code" and exchange["code_verifier"] + assert (exchange["client_id"], exchange["client_secret"]) == (CLIENT_ID, SECRET) + grant = _grant() + assert grant["refresh_token"] in idp.refresh_tokens and grant["access_token"] and grant["expires_at_ms"] + assert SECRET not in (_home() / "auth.json").read_text(encoding="utf-8-sig") + + auth_commands.auth_status_command(args) + auth_commands.auth_refresh_command(args) + assert _refreshes(idp) == 1 + auth_commands.auth_logout_command(args) + assert _grant() is None + out = capsys.readouterr() + assert "voice:google: logged in" in out.out and SECRET not in out.out + out.err + + +def test_bearer_reaches_the_declared_host_and_never_the_plugin(idp, api): + allowed, _ = api + plugin = _signed_in(idp) + token = _grant()["access_token"] + + result = plugin.call("google", allowed.url + "/v1/items?x=1", json={"title": "t"}, + headers={"X-Plugin": "1", "authorization": "Bearer plugin-made"}) + + assert result.status == 200 and result.json() == {"ok": True} + seen = allowed.seen[0] + assert seen["path"] == "/v1/items?x=1" and json.loads(seen["body"]) == {"title": "t"} + assert seen["headers"]["Authorization"] == f"Bearer {token}" and seen["headers"]["X-Plugin"] == "1" + assert token not in repr(result) and token not in json.dumps(result.headers) + + +def test_foreign_origin_userinfo_and_redirect_never_carry_the_token(idp, api, monkeypatch): + allowed, other = api + plugin = _signed_in(idp) + with pytest.raises(PermissionError, match="Refusing"): + plugin.call("google", other.url + "/steal") + host = allowed.url.split("//", 1)[1] + for url in (f"http://user@{host}/x", f"http://{host}@127.0.0.1:{other.origin[2]}/x"): + with pytest.raises(PermissionError): + plugin.call("google", url) + + redirecting = _Api(status=302, location=other.url + "/steal") + declared = plugin_oauth._origins + monkeypatch.setattr(plugin_oauth, "_origins", lambda client: declared(client) | {redirecting.origin}) + try: + assert plugin.call("google", redirecting.url + "/v1").status == 302 + finally: + redirecting.close() + assert other.seen == [] and allowed.seen == [] + + +def test_undeclared_id_another_plugins_grant_and_non_plugin_callers_are_refused(idp, api): + allowed, _ = api + _signed_in(idp) + with pytest.raises(ValueError, match="google"): + _plugin("bare").call("google", allowed.url) + twin = _plugin("twin", oauth=[_entry(idp)]) # the same declaration, but no grant of its own + with pytest.raises(ppr.ProviderNotSignedIn, match="hermes auth add twin:google"): + twin.call("google", allowed.url) + with pytest.raises(PermissionError, match="installed plugin"): + ppr.credentialed_provider_request("google", "GET", allowed.url) + assert allowed.seen == [] + + +def test_own_oauth_id_wins_over_a_core_provider_of_the_same_name(idp, api, monkeypatch): + allowed, _ = api + plugin = _signed_in(idp) + + def core_resolver(url): + raise AssertionError("a core provider's credential must not answer the plugin's own oauth id") + + monkeypatch.setitem(ppr._PROVIDERS, "google", (frozenset({allowed.origin}), core_resolver)) + assert plugin.call("google", allowed.url).status == 200 + assert allowed.seen[0]["headers"]["Authorization"] == f"Bearer {_grant()['access_token']}" + + +def test_not_signed_in_names_the_command_and_sends_nothing(idp, api): + allowed, _ = api + plugin = _plugin(oauth=[_entry(idp)]) + with pytest.raises(ppr.ProviderNotSignedIn, match="hermes auth add voice:google"): + plugin.call("google", allowed.url) + assert allowed.seen == [] and idp.token_requests == [] + + +def test_expiring_token_is_refreshed_once_and_the_rotation_kept(idp, api): + allowed, _ = api + plugin = _signed_in(idp) + old_refresh = _grant()["refresh_token"] + _set_grant(expires_at_ms=int(time.time() * 1000) + 60_000) # inside the 120 s margin + + plugin.call("google", allowed.url) + plugin.call("google", allowed.url) + + assert [r for r in idp.token_requests if r["grant_type"] == "refresh_token"] == [{ + "grant_type": "refresh_token", "refresh_token": old_refresh, "client_id": CLIENT_ID, "client_secret": SECRET}] + grant = _grant() + assert grant["refresh_token"] != old_refresh and grant["refresh_token"] in idp.refresh_tokens + assert [s["headers"]["Authorization"] for s in allowed.seen] == [f"Bearer {grant['access_token']}"] * 2 + + +def test_refresh_without_a_new_refresh_token_keeps_the_old_one(idp, api): + """Google answers a refresh with an access token only; the stored refresh token must survive it.""" + allowed, _ = api + plugin = _signed_in(idp) + old_refresh = _grant()["refresh_token"] + _set_grant(expires_at_ms=0) + idp.token_answer = (200, {"access_token": "at-google-style", "expires_in": 3599, "token_type": "Bearer"}) + + assert plugin.call("google", allowed.url).status == 200 + + grant = _grant() + assert (grant["access_token"], grant["refresh_token"]) == ("at-google-style", old_refresh) + assert allowed.seen[-1]["headers"]["Authorization"] == "Bearer at-google-style" + + +def test_a_redirect_from_the_token_endpoint_is_a_failure_not_a_token(idp, api): + allowed, _ = api + redirect = (302, {"access_token": "at-from-a-redirect", "refresh_token": "rt-x", "expires_in": 3600}) + _plugin(oauth=[_entry(idp)]) + idp.token_answer = redirect + with pytest.raises(SystemExit, match="HTTP 302"): + _auth("add") + assert not (_home() / "auth.json").exists() or _grant() is None + + idp.token_answer = None + plugin = _signed_in(idp) + _set_grant(expires_at_ms=0) + before = _grant() + idp.token_answer = redirect + with pytest.raises(AuthError) as excinfo: + plugin.call("google", allowed.url) + assert excinfo.value.code == "oauth_refresh_failed" and _grant() == before and allowed.seen == [] + + +def test_two_threads_with_an_expiring_token_refresh_once(idp, api): + allowed, _ = api + plugin = _signed_in(idp) + _set_grant(expires_at_ms=0) + + with ThreadPoolExecutor(max_workers=2) as pool: + statuses = list(pool.map(lambda _: plugin.call("google", allowed.url).status, range(2))) + + assert statuses == [200, 200] and _refreshes(idp) == 1 and "dead" not in _grant() + + +def test_live_token_is_sent_while_another_thread_holds_the_auth_lock(idp, api): + from hermes_cli.auth import _auth_store_lock + + allowed, _ = api + plugin = _signed_in(idp) + held, release = threading.Event(), threading.Event() + + def holder(): + with _auth_store_lock(): + held.set() + release.wait(10) + + thread = threading.Thread(target=holder) + thread.start() + try: + assert held.wait(5) + started = time.monotonic() + assert plugin.call("google", allowed.url).status == 200 + assert time.monotonic() - started < 5 + finally: + release.set() + thread.join(10) + + +def test_revoked_grant_is_marked_dead_and_stops_touching_the_network(idp, api, capsys): + allowed, _ = api + plugin = _signed_in(idp) + idp.refresh_tokens.clear() # the user removed the app at the IdP + _set_grant(expires_at_ms=0) + with pytest.raises(ppr.ProviderNotSignedIn, match="revoked"): + plugin.call("google", allowed.url) + posts = len(idp.token_requests) + with pytest.raises(ppr.ProviderNotSignedIn, match="revoked"): + plugin.call("google", allowed.url) + + assert len(idp.token_requests) == posts and allowed.seen == [] and _grant()["dead"] == "invalid_grant" + capsys.readouterr() + assert _auth("status") is True and "revoked" in capsys.readouterr().out + + +def test_rejected_client_is_an_auth_error_that_leaves_the_grant(idp, api): + allowed, _ = api + plugin = _signed_in(idp) + _set_grant(expires_at_ms=0) + idp.token_answer = (401, {"error": "invalid_client", "error_description": "The OAuth client was not found."}) + for _ in range(2): + with pytest.raises(AuthError) as excinfo: + plugin.call("google", allowed.url) + assert excinfo.value.code == "invalid_client" + assert "dead" not in _grant() and _refreshes(idp) == 2 and allowed.seen == [] + + +def test_missing_client_env_fails_before_any_request(idp, api, monkeypatch): + allowed, _ = api + plugin = _signed_in(idp) + _set_grant(expires_at_ms=0) + monkeypatch.delenv("VOICE_CLIENT_SECRET") + posts = len(idp.token_requests) + with pytest.raises(AuthError) as excinfo: + plugin.call("google", allowed.url) + assert excinfo.value.code == "oauth_client_secret_missing" and "VOICE_CLIENT_SECRET" in str(excinfo.value) + assert len(idp.token_requests) == posts and allowed.seen == [] + + +@pytest.mark.parametrize("change", [ + {"token_url": "https://collect.example.net/token"}, + {"hosts": ["api.example.com", "collect.example.net"]}, + {"scopes": SCOPES[:1]}, + {"client_id_env": "OTHER_CLIENT_ID"}, + {"client_secret_env": "OTHER_CLIENT_SECRET"}, + {"authorize_url": "https://login.example.net/authorize"}, + {"authorize_params": {"access_type": "online"}}, +]) +def test_entry_changed_since_sign_in_is_not_used(idp, api, change): + allowed, _ = api + _signed_in(idp) + changed = _plugin(oauth=[_entry(idp, **change)]) # a later version of the same plugin + posts = len(idp.token_requests) + with pytest.raises(ppr.ProviderNotSignedIn, match="changed"): + changed.call("google", allowed.url) + assert len(idp.token_requests) == posts and allowed.seen == [] + + +def test_partial_consent_saves_nothing_and_names_what_is_missing(idp): + _plugin(oauth=[_entry(idp)]) + idp.granted_scope = SCOPES[0] + with pytest.raises(SystemExit, match=SCOPES[1]): + _auth("add") + assert not (_home() / "auth.json").exists() or _grant() is None + + idp.granted_scope = " ".join(SCOPES) + assert _auth("add") is True and _grant()["refresh_token"] + + +def test_grant_belongs_to_one_profile(idp, api, monkeypatch, tmp_path): + allowed, _ = api + _signed_in(idp) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "other-profile")) + with pytest.raises(ppr.ProviderNotSignedIn): + _plugin(oauth=[_entry(idp)]).call("google", allowed.url) + assert allowed.seen == [] + + +def test_client_secret_stays_out_of_config_repr_logs_and_errors(idp, api, caplog, capsys): + allowed, _ = api + plugin = _signed_in(idp) + manifest = json.loads((_home() / "plugins" / "voice" / "plugin.yaml").read_text(encoding="utf-8-sig")) + client = plugin_oauth.parse_oauth_clients(manifest)[0][0] + assert SECRET not in repr(plugin_oauth._pkce_config(client, KEY)) + + _set_grant(expires_at_ms=0) + idp.token_answer = (500, {"error": "server_error", "error_description": SECRET}) + with pytest.raises(AuthError) as excinfo: + plugin.call("google", allowed.url) + out = capsys.readouterr() + assert excinfo.value.code == "oauth_refresh_failed" + assert SECRET not in str(excinfo.value) + caplog.text + out.out + out.err + + +def test_custom_prefix_never_routes_to_a_plugin_grant(idp): + _plugin("custom", oauth=[_entry(idp, id="foo")]) + assert _auth("add", "custom:foo") is None and idp.token_requests == [] + + +def test_other_auth_store_writes_keep_the_plugin_grant(idp): + from hermes_cli.auth import _save_active_provider_state + + _signed_in(idp) + before = _grant() + _save_active_provider_state("nous", {"access_token": "unrelated"}) + assert _grant() == before + + +def test_install_notice_and_repin_delta_name_the_oauth_entry(idp, tmp_path): + from hermes_cli.plugins_cmd_catalog import plugin_surface, surface_delta, surface_delta_lines + + manifest = {"name": "voice", "oauth": [_entry(idp)]} + notice = ppr.requires_auth_notice(manifest) + assert "google" in notice and "api.example.com" in notice and "hermes auth add voice:google" in notice + + widened = {"name": "voice", "oauth": [_entry(idp, hosts=["api.example.com", "collect.example.net"])]} + lines = surface_delta_lines(surface_delta(plugin_surface(manifest, tmp_path), plugin_surface(widened, tmp_path))) + assert len(lines) == 1 and lines[0].startswith("your OAuth sign-in for") and "collect.example.net" in lines[0] + + installed = plugin_surface(manifest, tmp_path) + moved = {"name": "voice", "oauth": [_entry(idp, authorize_url="https://signin.example.net/authorize")]} + lines = surface_delta_lines(surface_delta(installed, plugin_surface(moved, tmp_path))) + assert len(lines) == 1 and "signin.example.net" in lines[0] + + same_host = {"name": "voice", "oauth": [_entry(idp, authorize_url=f"{idp.base}/v2/authorize")]} + assert surface_delta(installed, plugin_surface(same_host, tmp_path)) == {} + + +def test_the_notice_command_finds_a_plugin_whose_name_differs_from_its_directory(idp, api): + allowed, _ = api + plugin = _plugin("voice-dir", oauth=[_entry(idp)], display="gemini-voice") + manifest = json.loads((_home() / "plugins" / "voice-dir" / "plugin.yaml").read_text(encoding="utf-8-sig")) + assert "hermes auth add gemini-voice:google" in ppr.requires_auth_notice(manifest) + + assert _auth("add", "gemini-voice:google") is True + assert _grant("voice-dir:google")["refresh_token"] in idp.refresh_tokens + assert plugin.call("google", allowed.url).status == 200 + + +# ── Security invariants (review of #139985) ───────────────────────────────────────────────────────── + + +def _reachable_text(exc: BaseException) -> str: + """Everything plugin code can read off a caught exception: repr, chain, httpx request, frame locals.""" + import traceback + + parts, seen = [], set() + while exc is not None and id(exc) not in seen: + seen.add(id(exc)) + parts.append("".join(traceback.TracebackException.from_exception(exc, capture_locals=True).format())) + try: + request = exc.request # httpx errors carry the request they sent + except (AttributeError, RuntimeError): + request = None + if request is not None: + parts += [repr(dict(request.headers)), request.content.decode("utf-8", "replace")] + exc = exc.__cause__ or exc.__context__ + return "\n".join(parts) + + +def test_errors_raised_to_the_plugin_carry_no_token_refresh_token_or_secret(idp, api): + """A failed API call or refresh hands the plugin an exception; nothing in it may lead back to a credential.""" + allowed, _ = api + plugin = _signed_in(idp) + token = _grant()["access_token"] + allowed.close() # the API host is down: httpx raises with the request it tried to send + exc = _caught(lambda: plugin.call("google", allowed.url + "/v1")) + assert type(exc).__name__ == "ConnectError" and token not in _reachable_text(exc) + + refresh = _grant()["refresh_token"] + _set_grant(expires_at_ms=0) + idp.stop() # the token endpoint is down mid-refresh + exc = _caught(lambda: plugin.call("google", allowed.url + "/v1")) + assert isinstance(exc, AuthError) and exc.code == "oauth_refresh_failed" + text = _reachable_text(exc) + assert refresh not in text and SECRET not in text + + import httpx # a core resolver (Codex) whose own refresh POST fails raises httpx with that request attached + + def resolver(url): + httpx.post(f"http://127.0.0.1:{allowed.origin[2]}/token", data={"refresh_token": "rt-core-canary"}, timeout=2) + + _ppr_providers = dict(ppr._PROVIDERS) + ppr._PROVIDERS["core-x"] = (frozenset({allowed.origin}), resolver) + try: + bare = _plugin("bare-core") + (_home() / "plugins" / "bare-core" / "plugin.yaml").write_text(json.dumps( + {"name": "bare-core", "version": "1", "requires_auth": ["core-x"]}), encoding="utf-8") + exc = _caught(lambda: bare.call("core-x", allowed.url + "/v1")) + finally: + ppr._PROVIDERS.clear() + ppr._PROVIDERS.update(_ppr_providers) + assert type(exc).__name__ == "ConnectError" and "rt-core-canary" not in _reachable_text(exc) + + +def _caught(fn) -> BaseException: + try: + fn() + except Exception as exc: # what plugin code would catch + return exc + raise AssertionError("expected an error") + + +def test_revoked_grant_keeps_no_tokens_and_uninstall_drops_the_grant(idp, api): + allowed, _ = api + plugin = _signed_in(idp) + idp.refresh_tokens.clear() + _set_grant(expires_at_ms=0) + with pytest.raises(ppr.ProviderNotSignedIn, match="revoked"): + plugin.call("google", allowed.url) + assert set(_grant()) == {"dead", "fingerprint"} + + from hermes_cli.plugins_cmd_remove import _remove_user_plugin + + # Broken manifest after sign-in: logout still removes the grant. + assert _auth("add") is True and _grant()["access_token"] + manifest = _home() / "plugins" / "voice" / "plugin.yaml" + good = manifest.read_text(encoding="utf-8-sig") + manifest.write_text(good.replace('"hosts": ["api.example.com"]', '"hosts": ["http://bad"]'), encoding="utf-8") + assert _auth("logout") is True and _grant() is None + manifest.write_text(good, encoding="utf-8") + + # Uninstall drops the grant, also when the manifest name differs from the directory it is stored under. + _plugin("voice-dir", oauth=[_entry(idp)], display="gemini-voice") + assert _auth("add", "gemini-voice:google") is True and _grant("voice-dir:google") + _remove_user_plugin(_home() / "plugins", "gemini-voice", _home() / "plugins" / "voice-dir") + assert _grant("voice-dir:google") is None # a later plugin under that key does not inherit the sign-in + + +def test_sign_in_is_refused_where_nobody_can_answer_the_browser(idp, monkeypatch): + _plugin(oauth=[_entry(idp)]) + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + with pytest.raises(SystemExit, match="your own terminal"): + _auth("add") + assert idp.token_requests == [] and idp.opened == [] diff --git a/tests/hermes_cli/test_plugin_validate.py b/tests/hermes_cli/test_plugin_validate.py index 5ad481e455..3f25a32a8b 100644 --- a/tests/hermes_cli/test_plugin_validate.py +++ b/tests/hermes_cli/test_plugin_validate.py @@ -9,6 +9,8 @@ from __future__ import annotations import json from pathlib import Path +import pytest + import hermes_yaml as yaml from hermes_cli.plugin_validate import validate_plugin_dir @@ -342,6 +344,61 @@ class TestDesktopSurface: assert "dynamic import outside the SDK (desktop/plugin.js:3)" in failed["desktop surface"] assert ":4)" not in failed["desktop surface"] + def test_bundled_library_own_class_prototypes_pass_but_renderer_globals_still_fail(self, tmp_path): + """A bundled library setting fields on classes it declares (three.js: ``var Vector2 = class + _Vector2 { constructor() { _Vector2.prototype.isVector2 = true } }``, ``function Track(){}``) + is not prototype patching. A write to a renderer global or an undeclared receiver still is, + including a global the file shadows with a same-named class elsewhere, and an alias of a + global (any spelling: assigned, bracketed, destructured) whose name some unrelated scope + also declares as a class, including a minifier-reused one-letter name, and a parameter + shadowing the class, a destructuring assignment, and writes hidden in a template ``${}`` or + between divisions. Passing the class around (``register(Q)``, ``{ Array: Q }``, + ``registry[F.name] = F``) or text in a string, template or regex literal is not a + rebinding, nor is a class name used only as a destructuring key. Ambiguous text never hides + code: where a ``/`` could open a regex or be division, both readings are followed.""" + d = self._desktop_plugin(tmp_path, ( + "var Vector2 = class _Vector2 { constructor() { _Vector2.prototype.isVector2 = true } }\n" + "function Track() {}\nTrack.prototype.ValueTypeName = 'vector'\n" + "const S = window.Storage\nS.prototype.setItem = noop\n" + "class Storage {}\nStorage.prototype.setItem = noop\n" + "HTMLElement.prototype.click = spy\n" + "function unused() { class H {} }\nconst H = window.Storage\nH.prototype.setItem = noop\n" + "function K() {}\nK.prototype.ValueTypeName = 'v'\nconst types = { Array: K }\n" + "function unused2() { class W {} }\nconst { Storage: W } = window\nW.prototype.setItem = noop\n" + "function unused3() { class B {} }\nconst B = window['Storage']\nB.prototype.setItem = noop\n" + "var Mv=class s{constructor(){s.prototype.isMv=!0}};var s=window.Storage;s.prototype.setItem=noop\n" + "function Q() {}\nregister(Q); const label = \"Q = vector\"\nQ.prototype.ValueTypeName = 'v'\n" + "function R() {}\nfunction patch(R) { R.prototype.setItem = noop }\n" + "function D() {}\n({D} = {D: window.Storage}); D.prototype.setItem = spy\n" + "const label = `${Storage.prototype.setItem = spy}`\n" + "const n = 1 / (Storage.prototype.setItem = spy, 2) / 3\n" + "function E() {}\nconst re = /E = x/, t = `E = ${1}`; E.prototype.isE = 1\n" + "function F() {}\nregistry[F.name] = F; F.prototype.isF = 1\n" + "function G() {}\nlet {a: G} = window; G.prototype.setItem = spy\n" + "function J() {}\ntrue ? [J] = [window.Storage] : null; J.prototype.setItem = spy\n" + "const m = Number(\"if (\") / (Storage.prototype.setItem = spy, 2) / 3\n" + "if (false) /\"/.test(label); Storage.prototype.setItem = spy; const tail = \"/\"\n" + "function L() {}\nlet v; true ? {L: v} = options : null; L.prototype.isL = 1\n" + "function U() {}\nfunction matcher() { return /U = vector/ } U.prototype.isU = 1\n" + )) + assert desktop_surface_hits(d) == [ + "prototype patching (desktop/plugin.js:5)", + "prototype patching (desktop/plugin.js:7)", + "prototype patching (desktop/plugin.js:8)", + "prototype patching (desktop/plugin.js:11)", + "prototype patching (desktop/plugin.js:17)", + "prototype patching (desktop/plugin.js:20)", + "prototype patching (desktop/plugin.js:21)", + "prototype patching (desktop/plugin.js:26)", + "prototype patching (desktop/plugin.js:28)", + "prototype patching (desktop/plugin.js:29)", + "prototype patching (desktop/plugin.js:30)", + "prototype patching (desktop/plugin.js:36)", + "prototype patching (desktop/plugin.js:38)", + "prototype patching (desktop/plugin.js:39)", + "prototype patching (desktop/plugin.js:40)", + ] + def test_node_sidecar_and_test_mjs_outside_desktop_are_not_the_surface(self, tmp_path): """A tools plugin with a Node sidecar (``sidecar/*.mjs`` lazily importing a lockfile-pinned dependency) and ``tests/*.test.mjs`` has no Desktop surface: the lint stays silent, and the @@ -510,3 +567,75 @@ def test_install_deps_probe_imports_from_the_synced_environment(tmp_path: Path, pass checks = {c["name"]: c for c in json.loads(capsys.readouterr().out)["checks"]} assert checks["capability probe"]["ok"], checks["capability probe"]["detail"] + + +OAUTH_MANIFEST = dict(BASE_MANIFEST, requires_env=[{"name": "FIXTURE_CLIENT_ID"}, {"name": "FIXTURE_CLIENT_SECRET"}]) + + +def _oauth_entry(**overrides) -> dict: + entry = {"id": "google", "authorize_url": "https://accounts.example.com/o/oauth2/auth", + "token_url": "https://oauth2.example.com/token", "scopes": ["https://api.example.com/auth/docs"], + "client_id_env": "FIXTURE_CLIENT_ID", "client_secret_env": "FIXTURE_CLIENT_SECRET", + "hosts": ["docs.example.com"]} + return {**entry, **overrides} + + +def test_oauth_block_passes_and_a_token_url_in_code_is_only_a_warning(tmp_path): + plugin = _make_plugin(tmp_path, manifest=dict(OAUTH_MANIFEST, oauth=[_oauth_entry(hosts=["docs.example.com", + "collect.example.net"])])) + (plugin / "client.py").write_text('TOKEN_URL = "https://oauth2.example.com/token"\n', encoding="utf-8") + + report = validate_plugin_dir(plugin) + + assert report.ok, report.failures + assert [ok for name, ok, _ in report.checks if name == "oauth"] == [True] + assert any("oauth2.example.com/token" in warning for warning in report.warnings) + assert [w for w in report.warnings if "shares no domain" in w] == [ + "oauth 'google': host collect.example.net shares no domain with accounts.example.com, oauth2.example.com; " + "check the provider owns it"] + + +@pytest.mark.parametrize("change, problem", [ + ({"id": "Google"}, "id"), + ({"authorize_url": "http://accounts.example.com/o/oauth2/auth"}, "authorize_url"), + ({"authorize_url": "http://127.0.0.1:8080/auth"}, "must use https"), # no loopback exception + ({"token_url": "http://[::1]:8080/token"}, "must use https"), + ({"client_secret_env": "OPENROUTER_API_KEY"}, "Hermes uses itself"), # would be POSTed to token_url + ({"client_id_env": "TELEGRAM_BOT_TOKEN"}, "Hermes uses itself"), + ({"client_secret_env": "MATRIX_PASSWORD"}, "Hermes uses itself"), # platform secrets too + ({"id": 123}, "id"), + ({"authorize_url": "https://accounts.example.com/o/oauth2/auth#consent"}, "authorize_url"), + ({"token_url": "https://[oauth2.example.com/token"}, "token_url"), + ({"token_url": "https://oauth2.example.com:https/token"}, "token_url"), + ({"scopes": []}, "scopes"), + ({"hosts": ["https://docs.example.com"]}, "hosts"), + ({"hosts": ["docs.example.com:8443"]}, "hosts"), + ({"hosts": ["10.0.0.1"]}, "hosts"), + ({"client_id_env": "fixture_client_id"}, "client_id_env"), + ({"client_secret_env": "UNDECLARED_SECRET"}, "UNDECLARED_SECRET"), + ({"id": "openai-codex"}, "openai-codex"), +]) +def test_oauth_form_errors_fail_admission(tmp_path, change, problem): + plugin = _make_plugin(tmp_path, manifest=dict(OAUTH_MANIFEST, oauth=[_oauth_entry(**change)])) + + report = validate_plugin_dir(plugin) + + assert [ok for name, ok, _ in report.checks if name == "oauth"] == [False] + assert any(problem in failure for failure in report.failures), report.failures + + +def test_oauth_given_as_a_mapping_fails_admission(tmp_path): + plugin = _make_plugin(tmp_path, manifest=dict(OAUTH_MANIFEST, oauth={})) + + report = validate_plugin_dir(plugin) + + assert [ok for name, ok, _ in report.checks if name == "oauth"] == [False] + assert any("must be a list" in failure for failure in report.failures), report.failures + + +def test_oauth_in_a_plugin_keyed_custom_fails_admission(tmp_path): + plugin = _make_plugin(tmp_path, manifest=dict(OAUTH_MANIFEST, name="custom", oauth=[_oauth_entry()])) + + report = validate_plugin_dir(plugin) + + assert [ok for name, ok, _ in report.checks if name == "oauth"] == [False] diff --git a/tests/hermes_cli/test_plugins_update_all.py b/tests/hermes_cli/test_plugins_update_all.py new file mode 100644 index 0000000000..80e3bfafac --- /dev/null +++ b/tests/hermes_cli/test_plugins_update_all.py @@ -0,0 +1,74 @@ +"""`hermes plugins update` with no name: offer every plugin that has an update, never move one silently.""" +from __future__ import annotations + +import argparse + +import pytest + +from hermes_cli.plugins_updates import CheckResult + + +def _parse(argv): + from hermes_cli.plugins_cmd import plugins_command + from hermes_cli.subcommands.plugins import build_plugins_parser + + parser = argparse.ArgumentParser(prog="hermes") + build_plugins_parser(parser.add_subparsers(dest="command"), cmd_plugins=plugins_command) + return parser.parse_args(["plugins", *argv]) + + +@pytest.fixture +def fleet(monkeypatch, tmp_path): + """Four installed rows: two the update path can move, a PyPI row it never upgrades (#132494) and a pin.""" + from hermes_cli import plugins_cmd, plugins_cmd_update, plugins_updates + + rows = [ + CheckResult("cat", "catalog", current="a" * 40, latest="b" * 40, update_available=True), + CheckResult("gitty", "git", current="c" * 40, latest="d" * 40, update_available=True), + CheckResult("pypkg", "pip", current="1.0", latest="1.1", update_available=True), + CheckResult("pinned", "git", current="e" * 40, update_available=False, reason="pinned @ eeee"), + ] + calls = [] + + def fake_update(name, target, console, *, interactive=True): + calls.append((name, interactive)) + if name == "cat": + raise TimeoutError("catalog pin refused") # not a typed refusal: the batch must still go on + return True + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(plugins_updates, "run_checks", lambda plugins_dir, **kw: rows) + monkeypatch.setattr(plugins_cmd, "_user_installed_plugin_dir", lambda name: tmp_path / name) + monkeypatch.setattr(plugins_cmd_update, "update_one", fake_update) + return calls + + +def test_no_terminal_refuses_without_yes_and_updates_only_movable_rows_with_it(fleet, monkeypatch, capsys): + from hermes_cli import plugins_cmd + + monkeypatch.setattr(plugins_cmd, "_is_tty", lambda: False) + args = _parse(["update"]) + with pytest.raises(SystemExit) as refused: + plugins_cmd.plugins_command(args) + assert refused.value.code == 1 and fleet == [] + assert "--yes" in capsys.readouterr().out + + # --yes: every movable plugin goes through the single-plugin path non-interactively; the failure + # of the first does not stop the second, and the pip row / pin are never attempted. + with pytest.raises(SystemExit) as done: + plugins_cmd.plugins_command(_parse(["update", "--yes"])) + assert done.value.code == 1 + assert fleet == [("cat", False), ("gitty", False)] + out = capsys.readouterr().out + assert "catalog pin refused" in out.split("Summary", 1)[1] + assert "skipped pypkg" in out and "skipped pinned" in out + + +def test_terminal_asks_once_and_cancel_moves_nothing(fleet, monkeypatch, capsys): + from hermes_cli import plugins_cmd + + monkeypatch.setattr(plugins_cmd, "_is_tty", lambda: True) + prompts = [] + monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "n") + plugins_cmd.plugins_command(_parse(["update"])) + assert prompts == ["\nUpdate all 2? [Y/n]: "] and fleet == [] diff --git a/tests/hermes_cli/test_resume_compressed_gateway_id.py b/tests/hermes_cli/test_resume_compressed_gateway_id.py index e3f677d205..dec270813d 100644 --- a/tests/hermes_cli/test_resume_compressed_gateway_id.py +++ b/tests/hermes_cli/test_resume_compressed_gateway_id.py @@ -59,11 +59,13 @@ def _gateway_store(loop, authority, tmp_path): the real read-only state.db reader, as the classic CLI's does.""" from gateway.session_controls import AuthorityConnection from hermes_cli.gateway_client import GatewayClientError - from hermes_cli.gateway_connection import SessionState + from hermes_cli.gateway_connection import GatewayConnection, SessionState from hermes_cli.gateway_session_store import GatewaySessionStore from hermes_state import SessionDB states = {} - connection = types.SimpleNamespace(final=False, state=lambda sid: states.setdefault(sid, SessionState(sid))) + connection = types.SimpleNamespace(final=False, launch_api_key=None, launch_api_key_typed=False, + state=lambda sid: states.setdefault(sid, SessionState(sid))) + connection.resume = types.MethodType(GatewayConnection.resume, connection) # the store's attach helper gateway = AuthorityConnection(authority, object(), { 'provider': 'local', 'user_id': 'owner', 'profile_id': 'fixture', 'instance_id': authority.instance_id, 'capabilities': ['session:create', 'session:read', 'session:control']}) diff --git a/tests/hermes_cli/test_subagent_monitor_gateway.py b/tests/hermes_cli/test_subagent_monitor_gateway.py new file mode 100644 index 0000000000..b54397bcd6 --- /dev/null +++ b/tests/hermes_cli/test_subagent_monitor_gateway.py @@ -0,0 +1,174 @@ +"""CLI-12: the Ctrl+T monitor and dock read the gateway session's subagents. + +Rewired, delegate_task children run in the gateway, so the CLI process's own +``tools.delegate_tool_registry`` is empty. The monitor lists, tails, steers and stops them through +the session's ``subagent.list`` / ``subagent.tail`` / ``subagent.steer`` / ``subagent.interrupt`` +verbs (scripted fake gateway, no live gateway). +""" +import asyncio +import time +from contextlib import asynccontextmanager +from types import SimpleNamespace + +import pytest + +from hermes_cli import gateway_tools +from hermes_cli.gateway_agent import GatewayAgent +from hermes_cli.gateway_connection import GatewayConnection + +SID = "local-abc" + + +class FakeClient: + def __init__(self): + self.events = asyncio.Queue() + self.calls = [] + self.children = [{"subagent_id": "sa-0-1", "goal": "Audit the parser", "status": "running", + "started_at": time.time() - 7, "last_tool": "read_file", "accepting_steer": True}] + self.steered = [] + self.steer_status = "queued" + self.tail_error = None + self.list_error = None + self.tail_text = "step 1\nstep 2\n" + + async def rpc(self, method, *, _timeout=None, **params): + self.calls.append((method, params)) + if method == "subagent.list": + if self.list_error: + raise self.list_error + return {"subagents": self.children, "delegations": []} + if method == "subagent.tail": + if self.tail_error: + raise self.tail_error + return {"subagent_id": params["subagent_id"], "available": True, "text": self.tail_text, + "truncated": False} + if method == "subagent.steer": + self.steered.append(params["text"]) + return {"status": self.steer_status, "subagent_id": params["subagent_id"], "text": params["text"]} + if method == "subagent.interrupt": + found = any(c["subagent_id"] == params["subagent_id"] for c in self.children) + self.children = [c for c in self.children if c["subagent_id"] != params["subagent_id"]] + return {"found": found, "subagent_id": params["subagent_id"]} + return {} + + +@pytest.fixture +def gateway(monkeypatch): + from tools import delegate_tool_registry as registry + monkeypatch.setattr(registry, "_active_subagents", {}) # the CLI process runs no children + client = FakeClient() + + @asynccontextmanager + async def connect(): + yield client + conn = GatewayConnection(connect=connect) + conn.start(5) + conn.state(SID) + agent = GatewayAgent(connection=conn, session_id=SID) + gateway_tools.bind(agent) + yield agent, client + conn.close() + gateway_tools._view = None + + +def test_monitor_lists_tails_and_controls_the_gateway_sessions_children(gateway): + from hermes_cli import cli_subagent_monitor + agent, client = gateway + monitor = cli_subagent_monitor.SubagentMonitor(SimpleNamespace(agent=agent, _agent_running=True, + _invalidate=lambda: None)) + + monitor.refresh() + assert [r["subagent_id"] for r in monitor.entries] == ["sa-0-1"] + assert monitor.entries[0]["last_tool"] == "read_file" and monitor.entries[0]["elapsed"] >= 6 + assert ("subagent.list", {"session_id": SID}) in client.calls + monitor.detail = True # the open detail view: the spinner thread's tick fetches its tail + client.tail_text = "step 1\r\n\x1b[2Jstep 2\n" # a child's terminal control codes + monitor.tick() + assert cli_subagent_monitor.subagent_tail(monitor, monitor.selected) == "step 1\n[2Jstep 2\n" # stripped + + assert "note" in monitor.control("steer", "Focus on the tokenizer") + assert client.steered == ["Focus on the tokenizer"] + client.steer_status = "rejected" # the child no longer accepts steering + assert "error" in monitor.control("steer", "Too late") + assert "note" in monitor.control("stop") + assert "error" in monitor.control("stop") # already gone: the gateway found nothing + assert monitor.refresh() + assert monitor.entries == [] + + +def test_idle_session_without_children_does_not_poll_the_roster(gateway): + from hermes_cli.cli_subagent_monitor import SubagentMonitor + agent, client = gateway + client.children = [] + monitor = SubagentMonitor(SimpleNamespace(agent=agent, _agent_running=False)) + monitor.refresh() + assert not any(method == "subagent.list" for method, _ in client.calls) + + +def test_failing_roster_reads_age_out_the_cached_children(gateway): + from hermes_cli.cli_subagent_monitor import SubagentMonitor + from hermes_cli.gateway_client import GatewayRPCError + agent, client = gateway + monitor = SubagentMonitor(SimpleNamespace(agent=agent, _agent_running=False)) + view = gateway_tools.process_view() + view.subagents = list(client.children) # children were listed before the gateway stopped answering + view._subagents_seen = time.time() + client.list_error = GatewayRPCError("unavailable") + monitor.refresh() + assert [r["subagent_id"] for r in monitor.entries] == ["sa-0-1"] # one failed read keeps the roster + view._subagents_polled, view._subagents_seen = 0.0, time.time() - 61 + monitor.refresh() + assert monitor.entries == [] # no answer for a minute: the ghost rows go + view._subagents_polled = 0.0 + calls = len(client.calls) + monitor.refresh() + assert len(client.calls) == calls # and an idle session stops polling + + +def test_detail_view_paints_the_cached_tail_and_only_the_tick_asks_the_gateway(gateway): + """The detail view repaints every frame on the UI loop: it must never block on subagent.tail. + The spinner thread's tick fetches the tail; a failing gateway is asked once per poll.""" + from prompt_toolkit.data_structures import Size + from prompt_toolkit.input import create_pipe_input + from prompt_toolkit.output import DummyOutput + + from hermes_cli import cli_subagent_monitor + from hermes_cli.gateway_client import GatewayRPCError + agent, client = gateway + monitor = cli_subagent_monitor.SubagentMonitor(SimpleNamespace(agent=agent, _agent_running=True, + _invalidate=lambda: None)) + monitor.refresh() + tails = lambda: sum(method == "subagent.tail" for method, _ in client.calls) + + async def run(): + with create_pipe_input() as pipe: + output = DummyOutput() + output.get_size = lambda: Size(rows=14, columns=80) + monitor.app = app = cli_subagent_monitor.build_monitor_application(monitor, input=pipe, output=output) + rendered = asyncio.Event() + app.after_render += lambda app: rendered.set() + task = asyncio.create_task(app.run_async()) + await asyncio.wait_for(rendered.wait(), 3) + rendered.clear() + pipe.send_text("\r") # open the detail view: several frames, none of them asks the gateway + await asyncio.wait_for(rendered.wait(), 3) + for _ in range(3): + rendered.clear() + app.invalidate() + await asyncio.wait_for(rendered.wait(), 3) + assert tails() == 0 and app.current_buffer.text != "step 1\nstep 2\n" + rendered.clear() + monitor.tick() # the spinner thread's tick fetches the tail and repaints from the cache + await asyncio.wait_for(rendered.wait(), 3) + assert tails() == 1 and app.current_buffer.text == "step 1\nstep 2\n" + pipe.send_text("\x1b") + pipe.send_text("q") + await asyncio.wait_for(task, 3) + asyncio.run(run()) + + view = gateway_tools.process_view() + client.tail_error = GatewayRPCError("unavailable") + view._tail = None + assert view.subagent_tail("sa-0-1", fetch=True) is None + assert view.subagent_tail("sa-0-1", fetch=True) is None # the failure is cached for the poll interval + assert tails() == 2 diff --git a/tests/hermes_cli/test_subagent_monitor_ui.py b/tests/hermes_cli/test_subagent_monitor_ui.py index 86ecacfe08..64e6a94649 100644 --- a/tests/hermes_cli/test_subagent_monitor_ui.py +++ b/tests/hermes_cli/test_subagent_monitor_ui.py @@ -60,3 +60,45 @@ def test_extended_tail_is_bounded_and_literal(tmp_path): assert 'last activity' in tail and '\x1b' not in tail assert len(tail) <= 32768 assert 'not available' in read_tail(str(tmp_path / 'missing.log')) + + +def test_a_stalled_steer_never_freezes_the_monitor(monkeypatch): + """Steer runs off the UI loop: while the backend call hangs, the monitor still repaints and Esc + still closes it; the answer lands in the notice line when it arrives.""" + import threading + from hermes_cli.cli_subagent_monitor import SubagentMonitor, build_monitor_application + from prompt_toolkit.input import create_pipe_input + from prompt_toolkit.output import DummyOutput + from tools import delegate_tool_registry as registry + monkeypatch.setattr(registry, '_active_subagents', {}) + registry._register_subagent(dict(subagent_id='only', goal='only', owner_agent_session_id='owner', + started_at=1, status='running', agent=SimpleNamespace())) + dock = SubagentMonitor(SimpleNamespace(agent=SimpleNamespace(session_id='owner'))) + dock.refresh() + release, entered = threading.Event(), threading.Event() + + def stalled(action, message=None, *, target=None): + entered.set() + release.wait(5) + return {'note': 'steered late'} + monkeypatch.setattr(dock, 'control', stalled) + + async def run(): + with create_pipe_input() as pipe: + app = build_monitor_application(dock, input=pipe, output=DummyOutput()) + rendered = asyncio.Event() + app.after_render += lambda app: rendered.set() + task = asyncio.create_task(app.run_async()) + await asyncio.wait_for(rendered.wait(), 3) + rendered.clear() + pipe.send_text('sFocus\r') + await asyncio.wait_for(rendered.wait(), 3) # repainted while the call is still hanging + await asyncio.get_running_loop().run_in_executor(None, entered.wait, 3) + assert entered.is_set() and not release.is_set() + rendered.clear() + pipe.send_text('\x1b') # Esc still closes it mid-call + await asyncio.wait_for(rendered.wait(), 3) + pipe.send_text('\x1b') + await asyncio.wait_for(task, 3) + release.set() + asyncio.run(run()) diff --git a/tests/hermes_cli/test_tool_gen_start_dedupe.py b/tests/hermes_cli/test_tool_gen_start_dedupe.py index 1f36e01fd1..caab669ca8 100644 --- a/tests/hermes_cli/test_tool_gen_start_dedupe.py +++ b/tests/hermes_cli/test_tool_gen_start_dedupe.py @@ -16,6 +16,7 @@ def _announce(cli, names): def test_repeated_tool_in_one_batch_prints_once(): cli = _make_cli(tool_progress="off") + cli._modern_layout = False # the glyph layout shows "preparing" in the composer rule, not scrollback printed = _announce(cli, ["terminal", "terminal", "terminal", "read_file"]) assert sum("preparing terminal" in p for p in printed) == 1 assert sum("preparing read_file" in p for p in printed) == 1 diff --git a/tests/hermes_cli/test_web_server_listener_logging.py b/tests/hermes_cli/test_web_server_listener_logging.py new file mode 100644 index 0000000000..54d8383a1d --- /dev/null +++ b/tests/hermes_cli/test_web_server_listener_logging.py @@ -0,0 +1,102 @@ +"""Building a listener's ``uvicorn.Config`` must leave Hermes's own file logging alone. + +uvicorn's default ``log_config`` runs ``logging.config.dictConfig``, which calls +``logging.shutdown()`` on every live handler. A multiplexed gateway installs its profile log +routers before the API listener starts, and a closed router stays closed, so agent.log, +errors.log and gateway.log went silent for every home until restart. Real handlers and files; +nothing binds. +""" + +import logging + +import pytest + +import hermes_cli.web_server as ws_mod +import hermes_logging +from hermes_cli.web_server_lifecycle import build_listener_server +from hermes_constants import reset_hermes_home_override, set_hermes_home_override + +_UVICORN_LOGGERS = ("uvicorn", "uvicorn.error", "uvicorn.access", "uvicorn.asgi") + + +@pytest.fixture(autouse=True) +def _isolated_logging(): + """Undo the queued file handlers, root level and uvicorn logger state a test sets up.""" + root = logging.getLogger() + saved_root_level = root.level + saved = {name: (lg.level, lg.propagate, list(lg.handlers)) + for name in _UVICORN_LOGGERS for lg in (logging.getLogger(name),)} + hermes_logging._reset_queued_handlers() + yield + hermes_logging._reset_queued_handlers() + hermes_logging._logging_initialized = False + root.setLevel(saved_root_level) + for name, (level, propagate, handlers) in saved.items(): + logger = logging.getLogger(name) + for handler in logger.handlers: + if handler not in handlers: + handler.close() + logger.handlers[:] = handlers + logger.setLevel(level) + logger.propagate = propagate + + +def _gateway_listener(_monkeypatch): + return build_listener_server(lambda scope, receive, send: None, "127.0.0.1", 0, auth_required=False) + + +def _dashboard_listener(monkeypatch): + monkeypatch.setattr(ws_mod.app.state, "auth_required", False, raising=False) + return ws_mod._build_uvicorn_server("127.0.0.1", 0) + + +@pytest.mark.parametrize("build", [_gateway_listener, _dashboard_listener], ids=["gateway", "dashboard"]) +def test_listener_config_keeps_every_profile_log_file_writing(tmp_path, monkeypatch, build): + root = tmp_path / "root" + beta = root / "profiles" / "beta" + beta.mkdir(parents=True) + (beta / "config.yaml").write_text("{}", encoding="utf-8") + hermes_logging.setup_logging(hermes_home=root, mode="gateway", force=True) + assert hermes_logging.enable_profile_log_routing([root, beta]) + logger = logging.getLogger("gateway.listener_logging") + + def log_in_every_home(marker): + for home in (root, beta): + token = set_hermes_home_override(str(home)) + try: + logger.warning(marker) + finally: + reset_hermes_home_override(token) + hermes_logging.flush_log_queue() + + log_in_every_home("before-listener") + build(monkeypatch) + log_in_every_home("after-listener") + for home in (root, beta): + for name in ("agent.log", "errors.log", "gateway.log"): + text = (home / "logs" / name).read_text(encoding="utf-8-sig") + assert "before-listener" in text + assert "after-listener" in text, f"building the listener silenced {home / 'logs' / name}" + + +def test_listener_config_keeps_uvicorn_on_the_console_and_out_of_the_hermes_logs(tmp_path, monkeypatch, capsys): + """Without uvicorn's ``dictConfig`` its loggers still print where they did: ``uvicorn.error`` + to stderr, access lines nowhere at ``log_level="warning"``, neither into agent.log.""" + root = tmp_path / "root" + hermes_logging.setup_logging(hermes_home=root, mode="gateway", force=True) + _gateway_listener(monkeypatch) + _dashboard_listener(monkeypatch) # a second listener in the process adds no second console handler + + logging.getLogger("uvicorn.error").warning("uvicorn-console-warning") + logging.getLogger("uvicorn.access").info( + '%s - "%s %s HTTP/%s" %d', "127.0.0.1:50000", "GET", "/access-line", "1.1", 200) + logging.getLogger("gateway.listener_logging").warning("hermes-file-warning") + hermes_logging.flush_log_queue() + + out, err = capsys.readouterr() + assert logging.getLogger("uvicorn").level == logging.INFO # LOGGING_CONFIG's, not the root's + assert err.count("uvicorn-console-warning") == 1 + assert "/access-line" not in out + err + agent_log = (root / "logs" / "agent.log").read_text(encoding="utf-8-sig") + assert "hermes-file-warning" in agent_log + assert "uvicorn-console-warning" not in agent_log and "/access-line" not in agent_log diff --git a/tests/providers/fake_pkce_idp.py b/tests/providers/fake_pkce_idp.py index 8a887e9735..8f1d24ec9f 100644 --- a/tests/providers/fake_pkce_idp.py +++ b/tests/providers/fake_pkce_idp.py @@ -16,7 +16,7 @@ from urllib.parse import parse_qs, urlencode, urlparse class FakeIdP: - def __init__(self) -> None: + def __init__(self, host: str = "127.0.0.1", port: int = 0) -> None: self.codes: dict[str, str] = {} # code -> code_challenge self.refresh_tokens: set[str] = set() self.token_requests: list[dict] = [] @@ -46,6 +46,8 @@ class FakeIdP: body = parse_qs(self.rfile.read(int(self.headers.get("Content-Length", 0))).decode()) form = {k: v[0] for k, v in body.items()} idp.token_requests.append(form) + if idp.token_answer is not None: + return self._json(*idp.token_answer) grant = form.get("grant_type") if grant == "authorization_code": challenge = idp.codes.pop(form.get("code", ""), None) @@ -61,8 +63,11 @@ class FakeIdP: idp.issued += 1 refresh = f"rt-{idp.issued}-{secrets.token_hex(4)}" idp.refresh_tokens.add(refresh) - self._json(200, {"access_token": f"at-{idp.issued}-{secrets.token_hex(4)}", - "refresh_token": refresh, "expires_in": 3600, "token_type": "Bearer"}) + payload = {"access_token": f"at-{idp.issued}-{secrets.token_hex(4)}", + "refresh_token": refresh, "expires_in": 3600, "token_type": "Bearer"} + if idp.granted_scope is not None: + payload["scope"] = idp.granted_scope + self._json(200, payload) def _json(self, status, payload): data = json.dumps(payload).encode() @@ -73,11 +78,28 @@ class FakeIdP: self.wfile.write(data) self.override_state: str | None = None - self.server = HTTPServer(("127.0.0.1", 0), Handler) - self.base = f"http://127.0.0.1:{self.server.server_address[1]}" + self.granted_scope: str | None = None # ``scope`` echoed in token responses (None = omitted) + self.token_answer: tuple[int, dict] | None = None # (status, JSON) answered to every token POST instead + self.server = HTTPServer((host, port), Handler) + self.base = f"http://{host}:{self.server.server_address[1]}" self._thread = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True) - def start(self) -> FakeIdP: + def start(self, *, tls_dir=None, name: str | None = None) -> FakeIdP: + """Serve; with *tls_dir*, over HTTPS with a throwaway CA written there (``self.ca_pem`` to trust). + *name* is the DNS name the certificate (and ``self.base``) use instead of the bound address.""" + if tls_dir is not None: + import ssl + from pathlib import Path + + from tests.fakes.providers.oauth_token_server import make_test_ca + + host, port = self.server.server_address[:2] + ca = make_test_ca(Path(tls_dir), [name or host]) + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(ca.leaf_pem, ca.leaf_key) + self.server.socket = context.wrap_socket(self.server.socket, server_side=True) + self.base = f"https://{name or host}" + ("" if port == 443 else f":{port}") + self.ca_pem = ca.ca_pem self._thread.start() return self diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py index d4ab0ae3a6..796e3a9775 100644 --- a/tests/tools/conftest.py +++ b/tests/tools/conftest.py @@ -35,6 +35,20 @@ def _no_host_browser_use_cli(): yield +@pytest.fixture(autouse=True) +def _no_shared_temp_socket_dirs(): + """A served profile's over-budget harness runtime dir is moved to a private ``hbh-`` dir + in the shared temp root (sun_path budget). Unit tests never claim one there: fake CLIs would + leave latch files behind. The dir keeps its long path; tests of the claim restore the real one.""" + try: + import tools.browser_harness_dirs as harness_dirs + except Exception: + yield + return + with patch.object(harness_dirs, "claim_private_dir", lambda path: False): + yield + + @pytest.fixture(autouse=True) def _no_host_bot_desktop_autostart(): """Keep the host's TigerVNC/Xfce install out of tests. diff --git a/tests/tools/test_browser_profile_scope_edges.py b/tests/tools/test_browser_profile_scope_edges.py new file mode 100644 index 0000000000..1e0c651c17 --- /dev/null +++ b/tests/tools/test_browser_profile_scope_edges.py @@ -0,0 +1,446 @@ +"""GW-23 (#134889): per-profile browser scope edges left after CLI-9. + +A multiplexed gateway serves every profile from one process; a live ``/browser connect`` endpoint, +a harness daemon's runtime and tmp dirs, and a browser session belong to ONE profile. +""" +import contextlib +import threading + +import pytest + +from agent import secret_scope +from gateway.run import _profile_runtime_scope +from hermes_constants import get_hermes_home, hermes_home_key +from tools import browser_tool_cdp as bt_cdp +from tools import browser_harness_dirs as harness_dirs +from tools.browser_harness_dirs import claim_private_dir as _REAL_CLAIM # conftest stubs it per test + + +@pytest.fixture +def profiles(tmp_path, monkeypatch): + a, b = tmp_path / "a", tmp_path / "b" + a.mkdir() + b.mkdir() + monkeypatch.delenv("BROWSER_CDP_URL", raising=False) + monkeypatch.setattr(bt_cdp, "_live_overrides", {}) + return a, b + + +def _in_bare_thread(fn): + """Run ``fn`` on a stdlib thread started WITHOUT the caller's copied context.""" + out = {} + t = threading.Thread(target=lambda: out.setdefault("v", fn())) + t.start() + t.join(5) + return out["v"] + + +def test_a_bare_thread_on_a_multiplexed_gateway_never_resolves_the_launch_profiles_override(profiles, monkeypatch): + """No profile scope bound on a multiplexed gateway: the launch profile's live endpoint is NOT the + answer (``get_secret`` fails closed the same way). A bound scope still sees its own.""" + launch = get_hermes_home() + with _profile_runtime_scope(launch, hydrate_secrets=False): + bt_cdp.set_live_cdp_override("http://127.0.0.1:9401") + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + assert _in_bare_thread(bt_cdp.live_cdp_override) == "" + assert _in_bare_thread(bt_cdp._get_cdp_override_raw) == "" + with _profile_runtime_scope(launch, hydrate_secrets=False): + assert bt_cdp.live_cdp_override() == "http://127.0.0.1:9401" + + +def test_single_profile_bare_thread_keeps_the_process_override(profiles): + bt_cdp.set_live_cdp_override("http://127.0.0.1:9402") + assert _in_bare_thread(bt_cdp.live_cdp_override) == "http://127.0.0.1:9402" + assert hermes_home_key() in bt_cdp._live_overrides + + +# --- macOS AF_UNIX budget for a served profile's harness sockets ------------------------------------- + +_LONGEST_NAME = "n" * 64 # browser_harness/_ipc.py _NAME_RE: [A-Za-z0-9_-]{1,64} + + +def _sock_len(runtime_dir: str) -> int: + """browser_harness/_ipc.py ``_sock_path``: ``/bu-.sock`` when + ``BH_RUNTIME_DIR_SHARED=1`` (pinned against the installed package by the test below).""" + return len(f"{runtime_dir}/bu-{_LONGEST_NAME}.sock".encode()) + + +def test_installed_harness_names_a_shared_dir_socket_bu_name_sock(tmp_path): + """The length rule rests on the installed harness's stem: resolved dir + ``bu-.sock``.""" + import os + import subprocess + import sys + rt = tmp_path / "rt" + env = {**os.environ, "BH_RUNTIME_DIR": str(rt), "BH_RUNTIME_DIR_SHARED": "1", + "BH_TMP_DIR": str(tmp_path / "tmp"), "BH_HOME": str(tmp_path / "home")} + out = subprocess.run([sys.executable, "-c", + f"from browser_harness import _ipc; print(_ipc._sock_path({_LONGEST_NAME!r}))"], + env=env, capture_output=True, text=True, check=True).stdout.strip() + assert out == f"{rt.resolve()}/bu-{_LONGEST_NAME}.sock" + + +@pytest.mark.parametrize(("platform", "budget"), [("darwin", 103 - 73), ("linux", 107 - 73), ("win32", None)]) +def test_the_socket_dir_budget_is_sun_path_minus_a_64_char_session_socket(platform, budget): + """macOS ``sun_path`` is 104 bytes (103 + NUL), Linux 108; Windows serves over loopback TCP.""" + assert harness_dirs.socket_dir_budget(platform) == budget + + +def test_a_default_macos_home_does_not_fit_and_the_short_temp_dir_does(): + """Pure length rule with macOS data (no host platform involved): under + ``~/.config/browser-harness/runtime/p-`` a 9-letter account left room for ~31-character + session names; ``/private/tmp/hbh-<12 hex>`` fits a 64-character one.""" + budget = harness_dirs.socket_dir_budget("darwin") + home = "/Users/alexander/.config/browser-harness/runtime/p-0123456789ab" + short = "/private/tmp/hbh-0123456789ab" # no-tmp: ok — a macOS path as data, nothing is written + assert not harness_dirs.fits_socket_budget(home, budget) + assert harness_dirs.fits_socket_budget(short, budget) and _sock_len(short) <= 103 + assert harness_dirs.fits_socket_budget("x" * 500, None) # no sun_path limit + + +@pytest.mark.platforms("macos") +def test_on_macos_a_served_profile_binds_a_64_char_session_socket(profiles, tmp_path, monkeypatch): + """Real branch: the dir is claimed under the real short root and a 64-character name binds.""" + import os + import socket + from tools import browser_use_cli as bu_cli + a, _b = profiles + monkeypatch.setattr(harness_dirs, "claim_private_dir", _REAL_CLAIM) # the real claim, real short root + env = {"HOME": str(tmp_path / ("u" * 40))} + with _profile_runtime_scope(a, hydrate_secrets=False): + harness_dirs.namespace_harness_dirs(env, bu_cli._served_profile_tag()) + path = f"{os.path.realpath(env['BH_RUNTIME_DIR'])}/bu-{_LONGEST_NAME}.sock" + assert len(path.encode()) <= 103 + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + s.bind(path) + finally: + s.close() + with contextlib.suppress(OSError): + os.unlink(path) + + +def _kernel_accepts_sun_path(path: str) -> bool: + """Ask the kernel ABI whether ``path`` fits ``sun_path``: an unbound dir gives ENOENT, an overlong + path gives "AF_UNIX path too long" before any filesystem access.""" + import socket + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + s.bind(path) + except OSError as e: + return "too long" not in str(e) + else: + import os + os.unlink(path) + return True + finally: + s.close() + + +@pytest.mark.platforms("linux") +@pytest.mark.parametrize("user_home", ["/home/teknium", "/home/alexander.smith"]) +def test_on_linux_a_served_profiles_64_char_session_socket_fits_sun_path(profiles, monkeypatch, user_home): + """Linux ``sun_path`` is 108 bytes (107 + NUL). ``~/.config/browser-harness/runtime/p-`` left + room for 38-character names at /home/teknium and 30 at /home/alexander.smith, while BU_NAME may be + 64. The served profile gets the same private short dir as on macOS (claim stubbed: no temp-root + write); the kernel judges the length of the exact path the installed harness binds.""" + import os + from tools import browser_use_cli as bu_cli + a, _b = profiles + claimed = [] + monkeypatch.setattr(harness_dirs, "claim_private_dir", lambda path: claimed.append(path) or True) + env = {"HOME": user_home} + with _profile_runtime_scope(a, hydrate_secrets=False): + harness_dirs.namespace_harness_dirs(env, bu_cli._served_profile_tag()) + sock = f"{env['BH_RUNTIME_DIR']}/bu-{_LONGEST_NAME}.sock" + assert claimed == [env["BH_RUNTIME_DIR"]] + assert _sock_len(env["BH_RUNTIME_DIR"]) <= 107 and _kernel_accepts_sun_path(sock), sock + assert os.path.basename(env["BH_RUNTIME_DIR"]).startswith("hbh-") + + +@pytest.mark.platforms("linux") +def test_on_linux_a_runtime_dir_that_already_fits_is_kept_and_an_unclaimable_one_stays_long(profiles, monkeypatch): + from tools import browser_use_cli as bu_cli + a, _b = profiles + claimed = [] + monkeypatch.setattr(harness_dirs, "claim_private_dir", lambda path: claimed.append(path) or True) + env = {"BH_HOME": "/b"} + with _profile_runtime_scope(a, hydrate_secrets=False): + harness_dirs.namespace_harness_dirs(env, bu_cli._served_profile_tag()) + assert env["BH_RUNTIME_DIR"].startswith("/b/runtime/p-") and claimed == [] + monkeypatch.setattr(harness_dirs, "claim_private_dir", lambda path: False) # squatted / not ours + env = {"HOME": "/home/teknium"} + with _profile_runtime_scope(a, hydrate_secrets=False): + harness_dirs.namespace_harness_dirs(env, bu_cli._served_profile_tag()) + assert env["BH_RUNTIME_DIR"].startswith("/home/teknium/.config/browser-harness/runtime/p-") + + +@pytest.mark.platforms("linux") +@pytest.mark.parametrize("via", ["relative", "symlink"]) +def test_on_linux_a_short_spelling_of_a_long_runtime_dir_is_measured_resolved(profiles, monkeypatch, tmp_path, via): + """The harness binds ``Path(BH_RUNTIME_DIR).expanduser().resolve()``: a short relative or symlinked + spelling of a deep dir still gives an overlong socket, so the length is judged on the resolved path.""" + import os + from tools import browser_use_cli as bu_cli + a, _b = profiles + deep = tmp_path / ("d" * 60) + deep.mkdir() + if via == "relative": # BH_RUNTIME_DIR=rt from a deep working directory + monkeypatch.chdir(deep) + base = "rt" + else: # a short symlink to a deep dir + link = tmp_path / "l" + link.symlink_to(deep) + monkeypatch.chdir(tmp_path) + base = "l" + claimed = [] + monkeypatch.setattr(harness_dirs, "claim_private_dir", lambda path: claimed.append(path) or True) + env = {"BH_RUNTIME_DIR": base} + with _profile_runtime_scope(a, hydrate_secrets=False): + harness_dirs.namespace_harness_dirs(env, bu_cli._served_profile_tag()) + resolved = os.path.realpath(env["BH_RUNTIME_DIR"]) + assert _sock_len(resolved) <= 107, resolved + assert _kernel_accepts_sun_path(f"{resolved}/bu-{_LONGEST_NAME}.sock") + assert claimed == [env["BH_RUNTIME_DIR"]] + + +def test_the_short_socket_dir_is_claimed_only_when_private_and_ours(tmp_path): + """The short dir sits in a world-writable root: a symlink or another user's/open dir is refused.""" + fresh = tmp_path / "hbh-fresh" + assert _REAL_CLAIM(str(fresh)) and (fresh.stat().st_mode & 0o777) == 0o700 + assert _REAL_CLAIM(str(fresh)) # ours, 0700: reused + open_dir = tmp_path / "hbh-open" + open_dir.mkdir(mode=0o755) + open_dir.chmod(0o755) + assert not _REAL_CLAIM(str(open_dir)) + link = tmp_path / "hbh-link" + link.symlink_to(fresh) + assert not _REAL_CLAIM(str(link)) + + +# --- per-profile harness tmp dir (screenshots, debug overlays, daemon logs) --------------------------- + + +def test_each_served_profile_gets_its_own_harness_tmp_dir(profiles, tmp_path): + """browser_harness/_ipc.py ``_TMP`` (paths.tmp_dir: BH_TMP_DIR, else /tmp) holds ``shot.png``, + debug-click overlays and ``bu-.log``; one shared dir let profile B read A's screenshots.""" + from tools import browser_use_cli as bu_cli + a, b = profiles + seen = {} + for home in (a, b): + env = {"BH_HOME": str(tmp_path / "bh")} + with _profile_runtime_scope(home, hydrate_secrets=False): + harness_dirs.namespace_harness_dirs(env, bu_cli._served_profile_tag()) + assert env.get("BH_TMP_DIR_SHARED") == "1" # several BU_NAMEs share it: keep bu-.log stems + seen[home] = env.get("BH_TMP_DIR") + assert seen[a] and seen[b] and seen[a] != seen[b] + assert all(str(p).startswith(str(tmp_path / "bh" / "tmp")) for p in seen.values()) + unscoped = {"BH_HOME": str(tmp_path / "bh")} + harness_dirs.namespace_harness_dirs(unscoped, bu_cli._served_profile_tag()) + assert unscoped == {"BH_HOME": str(tmp_path / "bh")} # single-profile: unchanged + + +def test_a_served_profile_under_an_inherited_tmp_dir_nests_inside_it(profiles, tmp_path): + from tools import browser_use_cli as bu_cli + a, _b = profiles + env = {"BH_TMP_DIR": str(tmp_path / "shared-tmp")} + with _profile_runtime_scope(a, hydrate_secrets=False): + harness_dirs.namespace_harness_dirs(env, bu_cli._served_profile_tag()) + assert env["BH_TMP_DIR"].startswith(str(tmp_path / "shared-tmp") + "/p-") + # The runtime dir still derives from the inherited BH_TMP_DIR (harness fallback), not the new one. + assert env["BH_RUNTIME_DIR"].startswith(str(tmp_path / "shared-tmp") + "/p-") + + +def test_an_unscoped_shutdown_reload_reaches_the_daemon_with_its_own_tmp_dir(profiles, monkeypatch): + """The reaper/shutdown runs outside any profile scope; its ``--reload`` must still carry the + daemon's own tmp dir, or the harness writes that profile's log into the shared one.""" + from tools import browser_use_cli as bu_cli + a, _b = profiles + with _profile_runtime_scope(a, hydrate_secrets=False): + env = {"HOME": "/home/u"} + harness_dirs.namespace_harness_dirs(env, bu_cli._served_profile_tag()) + key = harness_dirs.daemon_identity({**env, "BU_NAME": "s1"}) + monkeypatch.setattr(bu_cli, "_driven_daemons", {key: hermes_home_key(a)}) + monkeypatch.setattr(bu_cli, "_find_cli", lambda: ["browser-harness"]) + seen = [] + monkeypatch.setattr(bu_cli, "_run_cli_killing_process_group", lambda cmd, code, e, t: seen.append(e)) + bu_cli.stop_harness_daemons() + assert [(e["BH_TMP_DIR"], e["BH_TMP_DIR_SHARED"], e["BU_NAME"]) for e in seen] == [ + (env["BH_TMP_DIR"], "1", "s1")] + + +# --- reap/publish race within one profile ----------------------------------------------------------- + + +def test_a_session_that_resolved_the_old_endpoint_during_a_connect_is_closed_not_kept(profiles, monkeypatch): + """``/browser connect`` reaps, publishes the new endpoint, then reaps again. A tool call that + resolved the OLD endpoint before the publish but inserted its session after the second reap kept + driving the old browser. The real ``_browser_connect`` handler runs mid-creation here.""" + from tools import browser_tool, browser_tool_lifecycle as lifecycle, browser_tool_session as bt_session + from tui_gateway import server # binds methods_browser helpers onto server globals + + a, _b = profiles + sessions = {} + monkeypatch.setattr(browser_tool, "_active_sessions", sessions) + monkeypatch.setattr(lifecycle, "_start_browser_cleanup_thread", lambda: None) + monkeypatch.setattr(bt_cdp, "_ensure_cdp_supervisor", lambda key: None) + monkeypatch.setattr(server, "_cdp_http_reachable", lambda parsed, timeout=2.0: True) + closed = [] # released by object: the stale session is never in ``sessions`` + monkeypatch.setattr(lifecycle, "_release_session_backend", lambda task_id, info: closed.append(info["cdp_url"])) + monkeypatch.setattr(bt_session, "_browser_command_preflight", lambda: {"error": "no CLI in this test"}) + created = [] + + def create(task_id, local): + url = bt_cdp._get_cdp_override_raw() # resolved before the connect publishes + if not created: + assert server._browser_connect("r1", {"url": "http://127.0.0.1:9502"})["result"]["connected"] + created.append(url) + return {"session_name": f"s{len(created)}", "cdp_url": url} + monkeypatch.setattr(bt_session, "_create_session_for_key", create) + + with _profile_runtime_scope(a, hydrate_secrets=False): + bt_cdp.set_live_cdp_override("http://127.0.0.1:9501") + info = bt_session._get_session_info("task-a") + assert info["cdp_url"] == "http://127.0.0.1:9502" + assert sessions["task-a"] is info + assert closed == ["http://127.0.0.1:9501"] + + +def test_a_stale_session_is_closed_without_ever_being_handed_to_a_concurrent_caller(profiles, monkeypatch): + """A session found stale after creation is closed by object, never published first: while its + backend ``close`` runs, a concurrent ``_get_session_info`` for the same key must not find it in + ``_active_sessions`` (it would return a browser that is being closed underneath it).""" + from tools import browser_tool, browser_tool_lifecycle as lifecycle, browser_tool_session as bt_session + from tui_gateway import server + + a, _b = profiles + sessions = {} + monkeypatch.setattr(browser_tool, "_active_sessions", sessions) + monkeypatch.setattr(lifecycle, "_start_browser_cleanup_thread", lambda: None) + monkeypatch.setattr(bt_cdp, "_ensure_cdp_supervisor", lambda key: None) + monkeypatch.setattr(bt_cdp, "_stop_cdp_supervisor", lambda key: None) + monkeypatch.setattr(server, "_cdp_http_reachable", lambda parsed, timeout=2.0: True) + monkeypatch.setattr(bt_session, "_browser_command_preflight", lambda: {"browser_cmd": "agent-browser"}) + visible_during_close = [] + + def dispatch(task_id, info, browser_cmd, command, args, timeout, engine): + if command == "close": # what the existing-session path would hand a concurrent caller now + visible_during_close.append((info["cdp_url"], (sessions.get(task_id) or {}).get("cdp_url"))) + return "chrome", {"success": True} + monkeypatch.setattr(bt_session, "_dispatch_browser_command", dispatch) + released = [] + monkeypatch.setattr(lifecycle, "_release_session_backend", lambda task_id, info: released.append(info["cdp_url"])) + created = [] + + def create(task_id, local): + url = bt_cdp._get_cdp_override_raw() + if not created: + assert server._browser_connect("r1", {"url": "http://127.0.0.1:9512"})["result"]["connected"] + created.append(url) + return {"session_name": f"s{len(created)}", "cdp_url": url, "bb_session_id": None} + monkeypatch.setattr(bt_session, "_create_session_for_key", create) + + with _profile_runtime_scope(a, hydrate_secrets=False): + bt_cdp.set_live_cdp_override("http://127.0.0.1:9511") + info = bt_session._get_session_info("task-a") + assert visible_during_close == [("http://127.0.0.1:9511", None)] + assert released == ["http://127.0.0.1:9511"] + assert info["cdp_url"] == "http://127.0.0.1:9512" and sessions["task-a"] is info + + +def test_another_profiles_connect_does_not_invalidate_this_profiles_new_session(profiles, monkeypatch): + from tools import browser_tool, browser_tool_lifecycle as lifecycle, browser_tool_session as bt_session + a, b = profiles + sessions = {} + monkeypatch.setattr(browser_tool, "_active_sessions", sessions) + monkeypatch.setattr(lifecycle, "_start_browser_cleanup_thread", lambda: None) + monkeypatch.setattr(bt_cdp, "_ensure_cdp_supervisor", lambda key: None) + closed = [] + monkeypatch.setattr(lifecycle, "_cleanup_single_browser_session", closed.append) + + def create(task_id, local): + with _profile_runtime_scope(b, hydrate_secrets=False): + bt_cdp.set_live_cdp_override("http://127.0.0.1:9601") + return {"session_name": "s", "cdp_url": "x"} + monkeypatch.setattr(bt_session, "_create_session_for_key", create) + with _profile_runtime_scope(a, hydrate_secrets=False): + info = bt_session._get_session_info("task-a") + assert sessions == {"task-a": info} and closed == [] + + +def test_a_browser_exec_that_routed_to_the_old_endpoint_during_a_connect_reloads_its_daemon(profiles, monkeypatch): + """Browser Use path (the default mode): ``browser_exec`` resolves the live endpoint into + ``BU_CDP_URL`` before the daemon is recorded. A ``/browser connect`` landing in between reaped + nothing, and the harness daemon (which latches ``BU_CDP_*`` at spawn) kept the old browser for every + later call. The real ``_browser_connect`` handler runs between routing and dispatch here.""" + from tools import browser_use_cli as bu_cli + from tui_gateway import server + + a, _b = profiles + monkeypatch.setattr(bu_cli, "_driven_daemons", {}) + monkeypatch.setattr(bu_cli, "_find_cli", lambda: ["browser-harness"]) + monkeypatch.setattr(bu_cli, "_attach_vault_supervisor", lambda env, task_id: None) + monkeypatch.setattr(bt_cdp, "_resolve_cdp_override", lambda raw: raw) + monkeypatch.setattr(server, "_cdp_http_reachable", lambda parsed, timeout=2.0: True) + latched = {} # like browser-harness: one daemon per identity latches BU_CDP_URL; --reload stops it + + def run(cmd, code, env, timeout): + import subprocess + ident = harness_dirs.daemon_identity(env) + if cmd[-1] == "--reload": + latched.pop(ident, None) + else: + latched.setdefault(ident, env.get("BU_CDP_URL")) + return subprocess.CompletedProcess(cmd, 0, str(latched.get(ident)), "") + monkeypatch.setattr(bu_cli, "_run_cli_killing_process_group", run) + real_route = bu_cli._route_exec + connects = [] + + def route_then_connect(*args, **kw): + out = real_route(*args, **kw) # BU_CDP_URL now holds the OLD endpoint + if not connects: + connects.append(server._browser_connect("r1", {"url": "http://127.0.0.1:9702"})) + return out + monkeypatch.setattr(bu_cli, "_route_exec", route_then_connect) + + import json + with _profile_runtime_scope(a, hydrate_secrets=False): + bt_cdp.set_live_cdp_override("http://127.0.0.1:9701") + first = json.loads(bu_cli.browser_exec("print(1)", task_id="t1")) + assert connects[0]["result"]["connected"] + nxt = json.loads(bu_cli.browser_exec("print(1)", task_id="t1")) + assert first["output"] == nxt["output"] == "http://127.0.0.1:9702" + + +def test_a_connect_that_lands_while_browser_exec_spawns_its_daemon_reloads_it(profiles, monkeypatch): + """The publish lands after routing, while the CLI spawns the daemon on the old endpoint: the daemon + is already recorded (connect's reap reloads it) and the post-dispatch generation check reloads it + too, so the next call respawns it on the new endpoint.""" + import json + import subprocess + from tools import browser_use_cli as bu_cli + from tui_gateway import server + + a, _b = profiles + monkeypatch.setattr(bu_cli, "_driven_daemons", {}) + monkeypatch.setattr(bu_cli, "_find_cli", lambda: ["browser-harness"]) + monkeypatch.setattr(bu_cli, "_attach_vault_supervisor", lambda env, task_id: None) + monkeypatch.setattr(bt_cdp, "_resolve_cdp_override", lambda raw: raw) + monkeypatch.setattr(server, "_cdp_http_reachable", lambda parsed, timeout=2.0: True) + latched, connects = {}, [] + + def run(cmd, code, env, timeout): + ident = harness_dirs.daemon_identity(env) + if cmd[-1] == "--reload": + latched.pop(ident, None) + return subprocess.CompletedProcess(cmd, 0, "", "") + latched.setdefault(ident, env.get("BU_CDP_URL")) + if not connects: # mid-spawn: the daemon already latched the old endpoint + connects.append(server._browser_connect("r1", {"url": "http://127.0.0.1:9712"})) + return subprocess.CompletedProcess(cmd, 0, str(latched.get(ident)), "") + monkeypatch.setattr(bu_cli, "_run_cli_killing_process_group", run) + with _profile_runtime_scope(a, hydrate_secrets=False): + bt_cdp.set_live_cdp_override("http://127.0.0.1:9711") + json.loads(bu_cli.browser_exec("print(1)", task_id="t1")) + assert connects[0]["result"]["connected"] + nxt = json.loads(bu_cli.browser_exec("print(1)", task_id="t1")) + assert nxt["output"] == "http://127.0.0.1:9712" diff --git a/tests/tools/test_mcp_restart_on_new_inputs.py b/tests/tools/test_mcp_restart_on_new_inputs.py new file mode 100644 index 0000000000..999f4e1458 --- /dev/null +++ b/tests/tools/test_mcp_restart_on_new_inputs.py @@ -0,0 +1,79 @@ +"""A plugin settings save reconnects only the MCP servers whose resolved launch inputs changed: +a new ``${VAR}`` value the server's ``env`` passes through restarts it with that value; a save that +changes nothing it launches with leaves the live connection alone. Real stdio child, real config.""" + +import json +import os +import sys +import textwrap + +import pytest + +SERVER = textwrap.dedent('''\ + import os + from mcp.server.mcpserver import MCPServer + app = MCPServer("envecho") + + @app.tool() + def key() -> str: + return os.environ.get("DEMO_KEY", "") + + app.run() +''') + + +def _pid_of(core, name): + srv = core._servers.get(name) + session = getattr(srv, "session", None) + return id(session) if session is not None else None + + +@pytest.mark.no_isolate +@pytest.mark.platforms("posix") +def test_restart_only_when_resolved_launch_inputs_change(monkeypatch, tmp_path): + pytest.importorskip("mcp.server.mcpserver") + home = tmp_path / "home" + home.mkdir() + # A portable plugin's server: it joins the config AFTER load's ${VAR} render pass, the case where + # an unrendered recompute reads every save as "changed". + plugin = home / "plugins" / "envecho" + plugin.mkdir(parents=True) + (plugin / "server.py").write_text(SERVER, encoding="utf-8") + launcher = plugin / "run.sh" # portable commands are bare or ./-relative: wrap this interpreter + launcher.write_text(f'#!/bin/sh\nexec "{sys.executable}" "$(dirname "$0")/server.py"\n', encoding="utf-8") + launcher.chmod(0o755) + (plugin / "plugin.json").write_text(json.dumps({ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "envecho"}), encoding="utf-8") + (plugin / "mcp.json").write_text(json.dumps({ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", "mcpServers": {"envecho": { + "type": "stdio", "command": "./run.sh", "cwd": "${PLUGIN_ROOT}", + "env": {"DEMO_KEY": "${DEMO_KEY}"}}}}), encoding="utf-8") + (home / "config.yaml").write_text("plugins:\n enabled: [envecho]\n", encoding="utf-8") + (home / ".env").write_text("DEMO_KEY=first\n", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("DEMO_KEY", "first") + + from hermes_cli.config import save_env_value + from tools import mcp_tool_discovery as disc + from tools import mcp_tool_loop as _loop + from tools.mcp_tool_common import _core + + try: + disc.discover_mcp_tools() + first = _core._servers["envecho"]._resolved_identity + session = _pid_of(_core, "envecho") + assert first and session + + assert disc.restart_mcp_servers_with_new_inputs({"envecho"}) == [] # nothing it launches with moved + assert _pid_of(_core, "envecho") == session + + save_env_value("DEMO_KEY", "second") # what the settings form's secret save does + assert disc.restart_mcp_servers_with_new_inputs({"envecho"}) == ["envecho"] + # Returns only once reconnected, so a later save compares against THIS connection's inputs. + assert _pid_of(_core, "envecho") not in (None, session) + assert _core._servers["envecho"]._resolved_identity != first + assert os.environ["DEMO_KEY"] == "second" + finally: + from tools.mcp_tool_lifecycle import shutdown_mcp_servers + shutdown_mcp_servers() + _loop._stop_mcp_loop() diff --git a/tests/tui_gateway/test_legacy_submit_forwards_to_owner.py b/tests/tui_gateway/test_legacy_submit_forwards_to_owner.py index 141bec39e5..d87d83e9c9 100644 --- a/tests/tui_gateway/test_legacy_submit_forwards_to_owner.py +++ b/tests/tui_gateway/test_legacy_submit_forwards_to_owner.py @@ -283,7 +283,49 @@ def test_a_receipt_lost_after_the_owner_queued_it_is_unconfirmed_not_resendable( db.close() -@pytest.mark.parametrize("case", ["owner_refused", "rewind"]) +def test_an_owner_error_after_its_commit_is_unconfirmed_not_resendable(monkeypatch, tmp_path): + """The owner publishes its pending list AFTER the durable write, and a SQLite error there reaches + serve as a structured ``storage_unavailable``, the same answer a refused write gets. The prompt is + in the owner's FIFO and survives a restart, so "Nothing was sent. Send it again" would queue a + second copy under a fresh submission id: only a refusal raised before the write may say so.""" + import sqlite3 + db = SessionDB(db_path=tmp_path / "state.db") + sid, key, turns, _emitted = _legacy(monkeypatch, db) + try: + db.create_session(key, "test") + epoch = begin_runtime_epoch(db, instance_id="gateway") + admit_session_input(db, epoch=epoch, principal_id="bot", session_id=key, request_id="dm-1", + payload={"text": "a DM"}) + claim_session_input(db, epoch=epoch, session_id=key) + authority = _owner(monkeypatch, db, key, epoch) + publish, failed = authority._publish_pending, [] + + def publish_fails_once(ref): # the admission's publication, after its commit + if not failed: + failed.append(ref) + raise sqlite3.OperationalError("disk I/O error") + return publish(ref) + monkeypatch.setattr(authority, "_publish_pending", publish_fails_once) + + reply = server.handle_request({"id": "p", "method": "prompt.submit", + "params": {"session_id": sid, "text": "my question"}}) + mine = [row for row in list_session_admissions(db, session_id=key) + if row["payload"].get("text") == "my question"] + assert failed and len(mine) == 1 # the owner holds it + recover_session_inputs(db, epoch=begin_runtime_epoch(db, instance_id="gateway-2")) + after = {row["admission_id"]: row["status"] for row in list_session_admissions(db, session_id=key)} + assert after[mine[0]["admission_id"]] == "queued" # it survives the restart and will run + assert reply["error"]["code"] == 4090 + assert reply["error"]["data"] == {"reason": "SESSION_OWNER_UNCONFIRMED"} + message = reply["error"]["message"] + assert "Nothing was sent" not in message and "check the chat" in message + assert turns == [] + finally: + server._sessions.pop(sid, None) + db.close() + + +@pytest.mark.parametrize("case", ["owner_refused", "owner_queue_full", "rewind"]) def test_a_prompt_the_owner_cannot_queue_is_refused_with_why_and_when(monkeypatch, tmp_path, case): """Where admission is impossible the refusal names why and says to send when the turn ends, with a transient reason (not SESSION_NOT_OWNED, whose card offers a new chat).""" @@ -305,13 +347,18 @@ def test_a_prompt_the_owner_cannot_queue_is_refused_with_why_and_when(monkeypatc raise owner_forward.OwnerUnavailable("the gateway answered with another protocol") yield monkeypatch.setattr(owner_forward, "connect_owner", refused) + elif case == "owner_queue_full": + # Sent, and refused by the owner's ledger inside its write: nothing was queued. + import hermes_state_runtime + monkeypatch.setattr(hermes_state_runtime, "ADMISSION_QUEUE_MAX", 0) else: params.update(truncate_before_user_ordinal=0, confirm_truncate=True) reply = server.handle_request({"id": "p", "method": "prompt.submit", "params": params}) assert reply["error"]["code"] == 4090 and reply["error"]["data"] == {"reason": "SESSION_OWNER_BUSY"} message = reply["error"]["message"] assert "when the current turn ends" in message and "Nothing was sent" in message - assert ("another protocol" in message) if case == "owner_refused" else ("rewind" in message) + assert {"owner_refused": "another protocol", "owner_queue_full": "the gateway refused it", + "rewind": "rewind"}[case] in message assert len(list_session_admissions(db, session_id=key)) == 1 and turns == [] finally: server._sessions.pop(sid, None) diff --git a/tests/tui_gateway/test_plugins_manage_settings.py b/tests/tui_gateway/test_plugins_manage_settings.py index 8a274e5e70..55a27d9cf0 100644 --- a/tests/tui_gateway/test_plugins_manage_settings.py +++ b/tests/tui_gateway/test_plugins_manage_settings.py @@ -64,3 +64,38 @@ def test_settings_writes_the_plugin_namespace_and_refuses_secrets_and_bad_types( for values in ({"api_key": "leak"}, {"retries": "two"}, {"mode": "reckless"}, {"unknown": 1}): assert _manage(action="settings", key="demo-plugin", values=values)["error"]["code"] == 4021 assert "api_key" not in (plugins_home / "config.yaml").read_text(encoding="utf-8") + + +PORTABLE_MANIFEST = """\ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "portable-demo", + "version": "1.0.0", + "extensions": {"com.nousresearch.hermes": {"config_schema": { + "show_chip": {"type": "bool", "default": true}, + "api_key": {"type": "secret", "env": "PORTABLE_DEMO_KEY"} + }}} +} +""" + + +def test_portable_plugin_json_schema_lists_and_saves_like_a_native_one(plugins_home, monkeypatch): + """Agent Plugins v1 forbids unknown top-level fields, so a portable package declares its form under + ``extensions.com.nousresearch.hermes.config_schema``; it must get the same list + save path, and a + save tells the plugin's Desktop half (``plugin..settings.changed``) so it applies without polling.""" + sent = [] + monkeypatch.setattr(server, "_broadcast_global_event", lambda name, payload: sent.append((name, payload))) + (plugins_home / "plugins" / "portable-demo").mkdir() + (plugins_home / "plugins" / "portable-demo" / "plugin.json").write_text(PORTABLE_MANIFEST, encoding="utf-8") + + rows = _manage(action="list")["result"]["plugins"] + fields = {f["key"]: f for f in next(r for r in rows if r["key"] == "portable-demo")["settings_schema"]} + assert fields["show_chip"]["type"] == "boolean" and fields["show_chip"]["value"] is True + assert fields["api_key"]["type"] == "secret" and fields["api_key"]["env"] == "PORTABLE_DEMO_KEY" + + resp = _manage(action="settings", key="portable-demo", values={"show_chip": False}) + assert resp["result"]["written"] == ["show_chip"] + from hermes_cli.config import load_config_readonly + assert load_config_readonly()["plugins"]["entries"]["portable-demo"]["settings"] == {"show_chip": False} + assert sent == [("plugin.portable-demo.settings.changed", {"written": ["show_chip"]})] + assert _manage(action="settings", key="portable-demo", values={"api_key": "leak"})["error"]["code"] == 4021 diff --git a/tools/browser_harness_dirs.py b/tools/browser_harness_dirs.py new file mode 100644 index 0000000000..49671e220d --- /dev/null +++ b/tools/browser_harness_dirs.py @@ -0,0 +1,119 @@ +"""Per-profile runtime and tmp dirs for browser-harness daemons (Browser Use mode). + +A multiplexed gateway serves every profile from one process. The harness names a daemon by +``BU_NAME`` alone and reuses any live one, so each served profile gets its own runtime dir (daemon +sockets) and tmp dir (screenshots, debug overlays, logs). A runtime dir too long for a 64-character +session's AF_UNIX socket moves to a private short dir under the socket temp root. + +``browser_use_cli._base_subprocess_env`` applies the result through ``namespace_harness_dirs``. +""" +from __future__ import annotations + +import hashlib +import os +import stat +import sys +from pathlib import Path +from typing import Optional + +# browser_harness/_ipc.py: a shared runtime dir names a daemon socket ``/bu-.sock`` and +# BU_NAME may be 64 characters, so the dir gets sun_path (macOS 104 bytes, Linux 108, with the NUL) +# minus 73 (`/bu-` + 64 + `.sock`). +_MACOS_SOCKET_DIR_BUDGET = 103 - len("/bu-.sock") - 64 +_LINUX_SOCKET_DIR_BUDGET = 107 - len("/bu-.sock") - 64 + + +# The env keys that, with BU_NAME, identify one harness daemon (a served profile's dirs are its own). +HARNESS_DIR_KEYS = ("BH_RUNTIME_DIR", "BH_RUNTIME_DIR_SHARED", "BH_TMP_DIR", "BH_TMP_DIR_SHARED") + + +def daemon_identity(env: dict) -> tuple[str, ...]: + """``(*HARNESS_DIR_KEYS values, BU_NAME)`` of the daemon a call with ``env`` drives.""" + return (*(env.get(k, "") for k in HARNESS_DIR_KEYS), env.get("BU_NAME", "default")) + + +def daemon_env(env: dict, identity: tuple[str, ...]) -> dict: + """``env`` re-pointed at the daemon ``identity`` names (its own dirs and BU_NAME).""" + *dirs, name = identity + target = {k: v for k, v in env.items() if k not in HARNESS_DIR_KEYS} + target.update((k, v) for k, v in zip(HARNESS_DIR_KEYS, dirs) if v) + return {**target, "BU_NAME": name} + + +def socket_dir_budget(platform: str) -> Optional[int]: + """The runtime-dir byte budget on ``platform`` (a ``sys.platform`` value) where the harness binds + AF_UNIX sockets, else None.""" + if platform == "darwin": + return _MACOS_SOCKET_DIR_BUDGET + if platform.startswith("linux"): + return _LINUX_SOCKET_DIR_BUDGET + return None # Windows: the harness serves over loopback TCP (port files), no sun_path + + +def fits_socket_budget(resolved_dir: str, budget: Optional[int]) -> bool: + """Whether a 64-character session's socket under ``resolved_dir`` (the absolute, symlink-resolved + path the harness binds) fits ``budget``; no budget means no sun_path limit.""" + return budget is None or len(resolved_dir.encode()) <= budget + + +def namespace_harness_dirs(env: dict, tag: str) -> None: + """Give served profile ``tag`` its own harness runtime and tmp dirs in the child ``env``. An empty + ``tag`` (outside a served-profile scope) leaves ``env`` unchanged.""" + if not tag: + return + root = env.get("BH_HOME") or env.get("BROWSER_HARNESS_HOME") + if not root: # browser-harness paths.home_dir(), resolved against the child's env + xdg = env.get("XDG_CONFIG_HOME") + root = str(Path(xdg) / "browser-harness") if xdg else str( + Path(env.get("HOME") or Path.home()) / ".config" / "browser-harness") + root = str(Path(root).expanduser()) + leaf = f"p-{hashlib.sha256(tag.encode()).hexdigest()[:12]}" + base = env.get("BH_RUNTIME_DIR") or env.get("BH_TMP_DIR") or str(Path(root) / "runtime") + runtime = str(Path(base) / leaf) + env["BH_RUNTIME_DIR"] = socket_fit_runtime_dir(runtime, base, tag) + env["BH_RUNTIME_DIR_SHARED"] = "1" # several BU_NAMEs share the dir: keep bu- stems + # Screenshots, debug overlays and daemon logs (paths.tmp_dir): the profile's own, never shared. + env["BH_TMP_DIR"] = str(Path(env.get("BH_TMP_DIR") or str(Path(root) / "tmp")) / leaf) + env["BH_TMP_DIR_SHARED"] = "1" + + +def socket_fit_runtime_dir(runtime: str, base: str, tag: str) -> str: + """``runtime`` unless the socket the harness binds under it would be too long for a 64-character + session. The harness binds ``Path(BH_RUNTIME_DIR).expanduser().resolve()``, so the length is + judged on that path (a short relative or symlinked spelling can still resolve deep); a dir that + fits is returned as given. Otherwise the served profile gets a private dir under the short socket + temp root (``hbh-<12 hex>``), keyed by uid, base and profile; if none fits or can be claimed safely + the long path stays (shorter names only).""" + budget = socket_dir_budget(sys.platform) + if fits_socket_budget(str(Path(runtime).expanduser().resolve()), budget): + return runtime + from hermes_constants import socket_safe_tmpdir + getuid = getattr(os, "getuid", lambda: 0) + digest = hashlib.sha256(f"{getuid()}\0{base}\0{tag}".encode()).hexdigest()[:12] + # socket_safe_tmpdir() caps the root at 50 bytes for Chrome's longer socket names; ours needs a + # root of at most 17, which a Hermes scratch TMPDIR exceeds, so the sticky system root follows. + roots = dict.fromkeys(os.path.realpath(r) for r in (socket_safe_tmpdir(), "/tmp")) # no-tmp: ok — AF_UNIX sun_path budget; dir claimed 0700 and ownership-checked + for root in roots: + short = os.path.join(root, f"hbh-{digest}") + if fits_socket_budget(short, budget) and claim_private_dir(short): + return short + return runtime + + +def claim_private_dir(path: str) -> bool: + """Create ``path`` 0700, or accept an existing one only when it is a real directory (not a + symlink) owned by this uid and closed to group/other: the temp root is world-writable, and the + harness resolves symlinks and only chmods a dir it created itself.""" + try: + os.mkdir(path, 0o700) + return True + except FileExistsError: + pass + except OSError: + return False + try: + st = os.lstat(path) + except OSError: + return False + return (stat.S_ISDIR(st.st_mode) and st.st_uid == getattr(os, "getuid", lambda: -1)() + and not st.st_mode & 0o077) diff --git a/tools/browser_tool_cdp.py b/tools/browser_tool_cdp.py index c2f3572991..ff5126c37e 100644 --- a/tools/browser_tool_cdp.py +++ b/tools/browser_tool_cdp.py @@ -56,14 +56,21 @@ _live_overrides_lock = threading.Lock() def live_cdp_override() -> str: - """The current profile's live ``/browser connect`` endpoint, or "".""" - from hermes_constants import hermes_home_key + """The current profile's live ``/browser connect`` endpoint, or "". + + On a multiplexed gateway a context with no profile bound (a bare ``threading.Thread`` or stdlib + executor that did not copy the caller's context) answers "" rather than the launch profile's + endpoint, the way ``get_secret`` fails closed. Single-profile processes are unchanged.""" + from agent.secret_scope import is_multiplex_active + from hermes_constants import get_hermes_home_override, hermes_home_key + if get_hermes_home_override() is None and is_multiplex_active(): + return "" with _live_overrides_lock: return _live_overrides.get(hermes_home_key(), "") def set_live_cdp_override(url: Optional[str]) -> None: - """Set (or with a falsy ``url`` clear) the current profile's live endpoint.""" + """Set (or with a falsy ``url`` clear) the current profile's live endpoint and bump its generation.""" from hermes_constants import hermes_home_key key = hermes_home_key() with _live_overrides_lock: @@ -71,6 +78,20 @@ def set_live_cdp_override(url: Optional[str]) -> None: _live_overrides[key] = url else: _live_overrides.pop(key, None) + _live_generations[key] = _live_generations.get(key, 0) + 1 + + +# Per-profile count of live-endpoint publishes. A browser session records the value it saw before +# resolving its endpoint; ``_get_session_info`` closes one whose profile published in between (it may +# have resolved the old URL after ``/browser connect``'s second reap had already run). +_live_generations: dict[str, int] = {} + + +def live_cdp_generation(home_key: Optional[str] = None) -> int: + """The current (or ``home_key``'s) profile's live-endpoint generation.""" + from hermes_constants import hermes_home_key + with _live_overrides_lock: + return _live_generations.get(home_key or hermes_home_key(), 0) def env_cdp_url() -> str: diff --git a/tools/browser_tool_lifecycle.py b/tools/browser_tool_lifecycle.py index 11fb9569ca..098c182d20 100644 --- a/tools/browser_tool_lifecycle.py +++ b/tools/browser_tool_lifecycle.py @@ -660,15 +660,20 @@ def _release_session_resources(task_id: str, session_info: dict[str, Any]) -> No force-reap path (#100738), which skips the polite agent-browser/Camofox ``close`` that kept failing but must still release the cloud session and the local Chromium. """ - bb_session_id = session_info.get("bb_session_id", "unknown") _forget_session_tracking(task_id, session=True) + _release_session_backend(task_id, session_info) + +def _release_session_backend(task_id: str, session_info: dict[str, Any]) -> None: + """Close ``session_info``'s cloud provider session and kill its daemon, by object: no shared + session map is read or written (an unpublished session releases through this alone).""" + bb_session_id = session_info.get("bb_session_id", "unknown") if bb_session_id: # cloud only — local sidecars have bb_session_id=None provider = _cloud._get_cloud_provider() if provider is not None: try: provider.close_session(bb_session_id) - except Exception as e: + except Exception as e: # health: allow BLE001 -- teardown boundary moved unchanged from _release_session_resources; a provider error must never abort the daemon kill below _bt.logger.warning("Could not close cloud browser session: %s", e) session_name = session_info.get("session_name", "") diff --git a/tools/browser_tool_session.py b/tools/browser_tool_session.py index 9ee13dfe0b..745c57d98e 100644 --- a/tools/browser_tool_session.py +++ b/tools/browser_tool_session.py @@ -374,6 +374,11 @@ def _create_session_for_key(task_id: str, force_local: bool) -> dict[str, Any]: return _create_cloud_session_or_fallback(task_id, provider) +# A session built while its profile published a new live endpoint is closed and rebuilt; bounded so a +# flapping connect/disconnect loop cannot keep a tool call building browsers forever. +_STALE_ENDPOINT_RETRIES = 2 + + def _get_session_info(task_id: Optional[str] = None) -> dict[str, Any]: """Get or create session info for a session key (thread-safe); also starts the inactivity thread and touches activity. A ``::local`` key forces local Chromium @@ -413,17 +418,27 @@ def _get_session_info(task_id: Optional[str] = None) -> dict[str, Any]: return replacement force_local = _bt._is_local_sidecar_key(task_id) - session_info = _create_session_for_key(task_id, force_local) - - with _bt._cleanup_lock: - if task_id in _bt._active_sessions: # created concurrently during the network call — don't leak ours - return _bt._active_sessions[task_id] - session_info = dict(session_info) - session_info.setdefault("session_key", task_id) - session_info.setdefault("owner_task_id", _bt._bare_task_id_for_session_key(task_id)) - session_info.setdefault("home_key", hermes_home_key()) # the profile reaped on /browser connect - _bt._active_sessions[task_id] = session_info - _bt._suspect_browser_sessions.pop(task_id, None) # brand-new session is healthy by definition + home_key = hermes_home_key() + for attempt in range(_STALE_ENDPOINT_RETRIES + 1): + generation = _cdp.live_cdp_generation(home_key) + session_info = _create_session_for_key(task_id, force_local) + with _bt._cleanup_lock: + if task_id in _bt._active_sessions: # created concurrently during the network call — don't leak ours + return _bt._active_sessions[task_id] + # This profile published a new live endpoint while ours was being created: it may have + # resolved the old one after /browser connect's second reap. Never publish it (a concurrent + # caller would be handed a browser being closed); close it by object and build again. + stale = _cdp.live_cdp_generation(home_key) != generation + if not stale or attempt == _STALE_ENDPOINT_RETRIES: + session_info = dict(session_info) + session_info.setdefault("session_key", task_id) + session_info.setdefault("owner_task_id", _bt._bare_task_id_for_session_key(task_id)) + session_info.setdefault("home_key", home_key) # the profile reaped on /browser connect + _bt._active_sessions[task_id] = session_info + _bt._suspect_browser_sessions.pop(task_id, None) # brand-new session is healthy by definition + break + _bt.logger.info("Browser endpoint changed during session creation for %s; recreating", task_id) + _close_unpublished_session(task_id, session_info) # Lazy-start the CDP supervisor (idempotent). Skip local sidecars (no CDP URL) and # Lightpanda sessions (Browser Use mode hides the tools that consume supervisor state). @@ -433,6 +448,20 @@ def _get_session_info(task_id: Optional[str] = None) -> dict[str, Any]: return session_info +def _close_unpublished_session(task_id: str, session_info: dict[str, Any]) -> None: + """Close a session that was never put in ``_active_sessions``, by object: its backend ``close`` + and resources only, never a lookup by key (that entry, if any, is another caller's session).""" + if (session_info.get("features") or {}).get("lightpanda"): # no daemon to ``close`` + from tools.browser_lightpanda import stop_lightpanda + stop_lightpanda(session_info.get("session_name", "")) + else: + preflight = _browser_command_preflight() + if "browser_cmd" in preflight: + run_fenced_pair(session_info, lambda: _dispatch_browser_command( + task_id, session_info, preflight["browser_cmd"], "close", [], 10, None)) + _lifecycle._release_session_backend(task_id, session_info) + + def _discard_timed_out_browser_session(task_id: str, session_info: dict[str, Any], task_socket_dir: str) -> None: """Drop a stuck client generation without losing cloud cleanup state.""" with _bt._cleanup_lock: diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 9d36bc0316..59f900d65b 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -5,7 +5,7 @@ instead of default browser tools """ import contextlib -import hashlib +import functools import importlib import importlib.util import json @@ -21,6 +21,7 @@ from pathlib import Path from typing import Any, Callable, Dict, List, NamedTuple, Optional from hermes_constants import get_hermes_home, hermes_home_key +from tools.browser_harness_dirs import daemon_env, daemon_identity, namespace_harness_dirs from utils import is_truthy_value logger = logging.getLogger(__name__) @@ -178,29 +179,10 @@ def _base_subprocess_env() -> dict: env.pop("PYTHONPATH", None) env["PATH"] = _floor_subprocess_path(env.get("PATH", "")) env.setdefault("ANONYMIZED_TELEMETRY", "false") - _namespace_harness_daemons(env) + namespace_harness_dirs(env, _served_profile_tag()) # a served profile's own harness runtime/tmp dirs return env -def _namespace_harness_daemons(env: dict) -> None: - """A served profile's harness daemons live in their own runtime dir. The harness names a daemon by - ``BU_NAME`` alone (unnamed calls are ``default``) and reuses any live one, so with one shared dir a - profile's exec drove another profile's browser. Outside a served-profile scope nothing changes.""" - tag = _served_profile_tag() - if not tag: - return - base = env.get("BH_RUNTIME_DIR") or env.get("BH_TMP_DIR") - if not base: # browser-harness paths.home_dir() / "runtime", resolved against the child's env - root = env.get("BH_HOME") or env.get("BROWSER_HARNESS_HOME") - if not root: - xdg = env.get("XDG_CONFIG_HOME") - root = str(Path(xdg) / "browser-harness") if xdg else str( - Path(env.get("HOME") or Path.home()) / ".config" / "browser-harness") - base = str(Path(root).expanduser() / "runtime") - env["BH_RUNTIME_DIR"] = str(Path(base) / f"p-{hashlib.sha256(tag.encode()).hexdigest()[:12]}") - env["BH_RUNTIME_DIR_SHARED"] = "1" # several BU_NAMEs share the dir: keep bu- stems - - def _floor_subprocess_path(path: str) -> str: """Keep system commands reachable from profile workers with a minimal PATH. @@ -711,14 +693,12 @@ def _run_cli_killing_process_group(cmd, code, env, timeout): return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr) -# Harness daemons this process has driven. The daemon reads BU_CDP_* once, at start, and outlives -# every call, so a backend swap (/browser connect|disconnect) that only changes the resolved endpoint -# leaves later browser_exec calls in the old browser until these are stopped. Keyed by the daemon's -# identity (its _HARNESS_DIR_KEYS values plus BU_NAME): a served profile's runtime dir is its own, -# so no other profile can take over the entry. -_HARNESS_DIR_KEYS = ("BH_RUNTIME_DIR", "BH_RUNTIME_DIR_SHARED") -_driven_daemons: dict[tuple[str, str, str], str] = {} # (runtime dir, shared flag, BU_NAME) -> home +# Harness daemons this process drove: each latches BU_CDP_* at start and outlives every call, so a backend +# swap (/browser connect|disconnect) leaves later calls in the old browser until these are stopped. +_driven_daemons: dict[tuple[str, ...], str] = {} # daemon identity -> home _driven_daemons_lock = threading.Lock() +_live_generation = functools.partial(_lazy_call, "tools.browser_tool_cdp", "live_cdp_generation", 0, + "live CDP generation lookup failed") # this profile's publish count # Route cache key -> whether its last browser_exec ran on the hybrid local sidecar. _exec_route_is_sidecar: dict[str, bool] = {} # Route cache key -> the note from its real-profile fallback; that route stays on the fallback browser. @@ -734,15 +714,17 @@ def stop_harness_daemons(home_key: Optional[str] = None) -> None: daemons = sorted(key for key, home in _driven_daemons.items() if home_key in (None, home)) for key in daemons: _driven_daemons.pop(key, None) + _reload_daemons(daemons) + + +def _reload_daemons(daemons) -> None: # each ``daemon_identity`` daemon, with its own dirs cmd = _find_cli() if daemons else None if not cmd: return env = _base_subprocess_env() - for *dirs, name in daemons: - target = {k: v for k, v in env.items() if k not in _HARNESS_DIR_KEYS} - target.update((k, v) for k, v in zip(_HARNESS_DIR_KEYS, dirs) if v) + for daemon in daemons: with contextlib.suppress(OSError, subprocess.SubprocessError): - _run_cli_killing_process_group([*cmd, "--reload"], "", {**target, "BU_NAME": name}, 15) + _run_cli_killing_process_group([*cmd, "--reload"], "", daemon_env(env, daemon), 15) def _route_exec(env: dict, session: str, task_id: Optional[str], local: bool, local_sidecar: bool, @@ -758,6 +740,29 @@ def _route_exec(env: dict, session: str, task_id: Optional[str], local: bool, lo return route_err, local_sidecar or bool(env.pop(_LOCAL_FALLBACK_SENTINEL, None)) +def _route_exec_current(env: dict, session: str, task_id: Optional[str], local: bool, + local_sidecar: bool, route_key: str) -> tuple[dict, Optional[str], bool, int]: + """``_route_exec`` on a copy of ``env`` -> ``(routed env, error, sidecar, generation)``, re-routed (at + most 3 tries) when /browser connect published meanwhile; ``_reload_if_republished`` covers dispatch.""" + for _attempt in range(3): + routed = dict(env) + generation = _live_generation() + route_err, sidecar = _route_exec(routed, session, task_id, local, local_sidecar, route_key) + if route_err or _live_generation() == generation: + break + return routed, route_err, sidecar, generation + + +def _reload_if_republished(env: dict, generation: int) -> None: + """Stop this call's daemon if the profile published a new endpoint while it spawned on the old one.""" + if _live_generation() == generation: + return + daemon = daemon_identity(env) + with _driven_daemons_lock: + _driven_daemons.pop(daemon, None) + _reload_daemons([daemon]) + + def _take_real_profile_note(route_key: str) -> dict: """The fallback note for the first result this route returns after falling back, then nothing.""" if route_key not in _real_profile_fallback or route_key in _real_profile_note_delivered: @@ -795,7 +800,8 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT return tool_error(f"Invalid session name {session!r}: use 1-64 letters, digits, " "dashes, or underscores (e.g. 'r7k2').") env["BU_NAME"] = session - route_err, local_sidecar = _route_exec(env, session, task_id, bool(local), local_sidecar, route_key) + env, route_err, local_sidecar, generation = _route_exec_current( + env, session, task_id, bool(local), local_sidecar, route_key) if route_err: return tool_error(route_err) if local_sidecar: @@ -824,7 +830,7 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT def dispatch() -> dict[str, Any]: _attach_vault_supervisor(env, task_id) with _driven_daemons_lock: - daemon = (*(env.get(k, "") for k in _HARNESS_DIR_KEYS), env.get("BU_NAME", "default")) + daemon = daemon_identity(env) _driven_daemons[daemon] = hermes_home_key() try: return {"proc": _run_cli_killing_process_group(cmd, code, env, timeout)} @@ -842,6 +848,7 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT dispatched = run_fenced({"features": {"local": True}}, dispatch) else: dispatched = dispatch() + _reload_if_republished(env, generation) if "proc" not in dispatched: if "error_result" in dispatched: return dispatched["error_result"] diff --git a/tools/mcp_tool_agent.py b/tools/mcp_tool_agent.py index eca214e725..e74f1510b5 100644 --- a/tools/mcp_tool_agent.py +++ b/tools/mcp_tool_agent.py @@ -329,7 +329,7 @@ def _reinject_post_build_tools(agent, tools_list: list, name_set: set) -> set: try: get_mem_schemas = _schema_getter("_memory_manager", "get_all_tool_schemas") if get_mem_schemas is not None: - from agent.memory_manager import memory_provider_tools_enabled # same gate inject_memory_provider_tools uses + from agent.memory_manager_tools import memory_provider_tools_enabled # same gate inject_memory_provider_tools uses if memory_provider_tools_enabled( enabled, getattr(agent, "disabled_toolsets", None), memory_tool_present="memory" in name_set): for schema in get_mem_schemas(): diff --git a/tools/mcp_tool_discovery.py b/tools/mcp_tool_discovery.py index 118527c229..3eb3657d99 100644 --- a/tools/mcp_tool_discovery.py +++ b/tools/mcp_tool_discovery.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio import logging +import threading import time from contextlib import contextmanager from pathlib import Path @@ -755,6 +756,50 @@ def mcp_servers_awaiting_connect() -> list[str]: return _awaiting_connect(_enabled_config_names(), _core._mcp_registry_scope()) +# One settings-driven reconnect at a time (see restart_mcp_servers_with_new_inputs). +_RESTART_LOCK = threading.Lock() + + +def restart_mcp_servers_with_new_inputs(names: set[str]) -> list[str]: + """Reconnect each live server in *names* whose resolved launch inputs (stdio command/env/cwd, + HTTP url/headers) no longer hash to what it connected with, e.g. after a plugin settings form + saved a new ``${VAR}`` its ``env`` passes through. A server whose inputs did not change keeps + its connection. Returns the names restarted. + + Serialized: a second save waits for the first reconnect, then compares against what THAT one + connected with, so a newer key is never lost to an older in-flight start. A connection this + profile only borrows (same inputs as another profile's) is left running for its owner; this + profile just detaches and connects its own.""" + with _RESTART_LOCK: + scope = _core._mcp_registry_scope() + owned, borrowed = [], [] + with _owner_secret_scope(): + servers = _config._load_mcp_config() + for name in sorted(names): + config = servers.get(name) + with _core._lock: + key = _resolve_server_key(name, scope, current=False) + server = _core._servers.get(key) + recorded = getattr(server, "_resolved_identity", None) + if config is None or recorded is None or not mcp_server_enabled(config): + continue + # Render as the connect path does (plugin servers join the config after load's render pass). + if _registration._adopter_identity_digest(name, _config._interpolate_env_vars(config)) != recorded: + (owned if key == _server_key(name, scope, current=False) else borrowed).append((name, key)) + if not owned and not borrowed: + return [] + changed = sorted(name for name, _key in owned + borrowed) + logger.info("MCP server(s) %s: launch inputs changed; reconnecting", ", ".join(changed)) + if owned: + _lifecycle.shutdown_mcp_servers(scope=scope, names={name for name, _key in owned}) + for _name, key in borrowed: + _registration._remove_server_scope(key, scope) + for name in changed: + _clear_connect_failure(name) + discover_mcp_tools() + return changed + + def _forget_lazy_server(key) -> None: """Drop a schema-cache (lazy) registration whose config entry is gone: its cached tools would otherwise stay callable and spawn the server on first use.""" diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index a5cd314117..6b37fc3e18 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -1817,9 +1817,28 @@ def _plugins_settings(rid, params): except (ValueError, PermissionError) as e: return _err(rid, 4021, str(e)) row = next((r for r in _plugin_rows() if r["key"] == canonical), None) + # A Desktop half applies its own preferences (a status-bar chip on/off) without polling. + events = _tools_mod("hermes_cli.plugin_events") + if events._PLUGIN_ID_RE.match(canonical): # category keys (``image_gen/fal``) form no event name + events.broadcast_plugin_event(canonical, "settings.changed", {"written": written}) + # A secret the form just stored may feed the plugin's MCP server env (``${MESHY_API_KEY}``): bounce + # only servers whose resolved launch inputs changed, off the RPC thread (an npx start takes seconds). + if plugin_dir and pc._is_portable_plugin_dir(plugin_dir): + servers = _portable_server_names(Path(str(plugin_dir)), canonical) + if servers: + from agent.memory_provider import spawn_context_thread # keeps this request's profile scope + discovery = _tools_mod("tools.mcp_tool_discovery") + spawn_context_thread(discovery.restart_mcp_servers_with_new_inputs, args=(servers,), + name=f"plugin-settings-mcp-{canonical}").start() return _ok(rid, {"ok": True, "name": canonical, "written": written, "plugin": row}) +def _portable_server_names(plugin_dir: Path, key: str) -> set[str]: + package = _tools_mod("hermes_cli.agent_plugins").load_agent_plugin(plugin_dir, plugin_dir) + name_for = _tools_mod("hermes_cli.plugins_manifest").portable_mcp_server_name + return {name_for(key, server) for server in package.mcp_servers} + + def _plugins_onboarding(rid, params): """Catalog plugins curated for the onboarding card that this OS runs, each with its app state.""" return _ok(rid, {"onboarding": _tools_mod("hermes_cli.plugin_catalog_presence").onboarding_entries()}) diff --git a/tui_gateway/owner_forward.py b/tui_gateway/owner_forward.py index f9d6e087bf..3ecc985164 100644 --- a/tui_gateway/owner_forward.py +++ b/tui_gateway/owner_forward.py @@ -42,6 +42,15 @@ RELAY_FINALIZE_POLL_S = 1.0 #: nothing re-reads the stored transcript at ``message.complete``, which brings the user's own row #: (written by the owner, not by this backend) and the reply in their committed order. _RELAYED = frozenset({"message.start", "message.complete"}) +#: The owner's ``prompt.submit`` refusals raised before its durable write (dispatcher, ``authorize``, +#: ``submission``, the admission gate, and the ledger's own checks inside the rolled-back write): only +#: these prove nothing will run. ``storage_unavailable`` and an internal error can also come from the +#: pending publication AFTER the commit (``SessionAuthority._admitted``), so they are never listed. +#: ``not_found`` can follow the commit too, when the session is deleted meanwhile (``_handle``), but a +#: session row is deleted only with its admissions (``ON DELETE RESTRICT``), so that one never runs. +_REFUSED_BEFORE_COMMIT = frozenset({ + "invalid_params", "runtime_draining", "runtime_retired", "profile_mismatch", "permission_denied", + "not_found", "session_superseded", "stale_epoch", "admission_conflict", "queue_full"}) class OwnerUnavailable(RuntimeError): @@ -144,16 +153,19 @@ class _Relay: async def _submit(self, client, target: str) -> dict: """``prompt.submit`` on the owner. Once the request is handed to the socket, only a received - refusal (``GatewayRPCError``) proves nothing was queued: a lost connection or a missing answer - may follow the owner's durable commit (a gateway restart mid-forward), so it is unconfirmed, - never "nothing was sent" (a resend would run a second copy).""" + refusal the owner raises before its write (``_REFUSED_BEFORE_COMMIT``) proves nothing was + queued: a lost connection, a missing answer or any other error may follow the owner's durable + commit (a gateway restart mid-forward, a storage error publishing the pending list), so it is + unconfirmed, never "nothing was sent" (a resend would run a second copy).""" from hermes_cli.gateway_client import GatewayRPCError try: return await client.rpc("prompt.submit", session_id=target, text=self.text, submission_id=self.request_id) - except GatewayRPCError: - raise except Exception as exc: + # The owner names its refusal in ``data.reason``; ``message`` may be a display string. + reason = exc.data.get("reason", str(exc)) if isinstance(exc, GatewayRPCError) else None + if isinstance(reason, str) and reason in _REFUSED_BEFORE_COMMIT: + raise raise OwnerUnconfirmed("the gateway did not confirm it") from exc def _pending(self, params: dict, admission_id: str) -> None: @@ -209,7 +221,7 @@ def forward_prompt(sid: str, session: dict, text: str, *, emit=None, home: Path Raises ``OwnerNotRunning`` when no gateway serves the home, ``OwnerUnavailable`` (or the client's refusal) when the owner did not take it, and ``OwnerUnconfirmed`` when the prompt may - be queued there: the receipt timed out, or the owner was lost after the request was sent.""" + be queued there: the receipt timed out, or the owner was lost or failed after the request was sent.""" from tui_gateway import server key = str(session.get("session_key") or "") if not key: diff --git a/ui-tui/src/__tests__/canonicalSlashCommands.test.ts b/ui-tui/src/__tests__/canonicalSlashCommands.test.ts index 2e384623a0..73f28a14ac 100644 --- a/ui-tui/src/__tests__/canonicalSlashCommands.test.ts +++ b/ui-tui/src/__tests__/canonicalSlashCommands.test.ts @@ -1,3 +1,4 @@ +import { JsonRpcGatewayError } from '@hermes/shared/json-rpc-channel' import { beforeEach, expect, it, vi } from 'vitest' import { createSlashHandler } from '../app/createSlashHandler.js' @@ -11,7 +12,7 @@ import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' const flush = () => new Promise(resolve => setImmediate(resolve)) const LEGACY = ['session.title', 'session.undo', 'session.usage', 'slash.exec', 'tools.configure'] -function harness() { +function harness(rewindRefusal?: Error) { let voiceMode = 'off' // T9/T10 owner verbs, one answer per method (kept out of the request ladder below). @@ -43,7 +44,7 @@ function harness() { { role: 'assistant', content: 'a2', row_id: 14 } ] - const request = vi.fn(async (method: string, params: any) => { + const reply = async (method: string, params: any) => { if (method === 'session.resume') { return { session_id: 'owner', revision: 7, execution_generation: 3, messages } } @@ -112,7 +113,14 @@ function harness() { } throw new Error(`unknown_method: ${method}`) - }) + } + + // A refused rewind (the owner's typed busy refusal) replaces only the rewind reply. + const request = vi.fn(async (method: string, params: any) => + rewindRefusal && method === 'session.mutate' && params.operation === 'rewind' + ? Promise.reject(rewindRefusal) + : reply(method, params) + ) const sys = vi.fn() const send = vi.fn() @@ -201,6 +209,39 @@ it('/title renames and /undo, /retry rewind through session.mutate with the snap expect(request.mock.calls.some(([method]) => LEGACY.includes(method))).toBe(false) }) +it('/undo and /retry while a turn runs print main\'s stop-first text, not the bare reason code', async () => { + const busy = new JsonRpcGatewayError('session_busy', { code: 4001, data: { reason: 'session_busy' } }) + const { send, slash, sys } = harness(busy) + + for (const command of ['undo', 'retry']) { + slash(`/${command}`) + await flush() + expect(sys).toHaveBeenLastCalledWith( + `session busy — Hermes is still replying. Stop the current reply first (Stop button, or Ctrl+C in a terminal), then run /${command}.` + ) + } + + expect(sys.mock.calls.some(([line]) => String(line).includes('error: session_busy'))).toBe(false) + expect(send).not.toHaveBeenCalled() +}) + +it('/undo and /retry while a compression holds the session say to wait for it', async () => { + const compressing = new JsonRpcGatewayError('session_busy', { + code: 4001, + data: { field: 'compression', reason: 'session_busy' } + }) + + const { send, slash, sys } = harness(compressing) + + for (const command of ['undo', 'retry']) { + slash(`/${command}`) + await flush() + expect(sys).toHaveBeenLastCalledWith(`session busy — a /compress is running; run /${command} when it finishes.`) + } + + expect(send).not.toHaveBeenCalled() +}) + it("/usage renders main's session totals from session.stats and /tools lists the owner's tools.show", async () => { const { page, panel, request, slash, sys } = harness() diff --git a/ui-tui/src/app/slash/canonicalSessionCommands.ts b/ui-tui/src/app/slash/canonicalSessionCommands.ts index e0f89be5b0..7c47454c2e 100644 --- a/ui-tui/src/app/slash/canonicalSessionCommands.ts +++ b/ui-tui/src/app/slash/canonicalSessionCommands.ts @@ -4,6 +4,7 @@ // result (`prompt.receipt include_result`) and the frozen launch policy (`session.info`). import type { SessionStats } from '@hermes/shared/gateway-events' +import { JsonRpcGatewayError } from '@hermes/shared/json-rpc-channel' import { CANONICAL_REASONING_EFFORTS } from '../../domain/slash.js' import { usageFromTick } from '../../domain/usage.js' @@ -174,7 +175,32 @@ export function canonicalRewind(intent: 'retry' | 'undo', ctx: SlashRunCtx) { return text ? ctx.transcript.send(text) : ctx.transcript.sys(t('slashCmd.core.retry.nothing')) }) - .catch(ctx.guardedErr) + .catch(error => { + const busy = rewindBusyText(error, intent) + + if (!busy) { + return ctx.guardedErr(error) + } + + if (!ctx.stale()) { + ctx.transcript.sys(busy) + } + }) +} + +/** Main's busy text for a refused rewind: wait for a running /compress, else stop the reply; null otherwise. */ +function rewindBusyText(error: unknown, intent: 'retry' | 'undo'): null | string { + if (!(error instanceof JsonRpcGatewayError)) { + return null + } + + const data = asRpcResult(error.data) + + if (data?.reason !== 'session_busy') { + return null + } + + return t(data.field === 'compression' ? 'slashCmd.core.busy.compressing' : 'slashCmd.core.busy.replying', intent) } interface CommittedResult { diff --git a/ui-tui/src/i18n/en/slashCmd/core.ts b/ui-tui/src/i18n/en/slashCmd/core.ts index 17700d0d78..e5210b0153 100644 --- a/ui-tui/src/i18n/en/slashCmd/core.ts +++ b/ui-tui/src/i18n/en/slashCmd/core.ts @@ -147,6 +147,12 @@ export const slashCmdCoreEn = { }, retry: { nothing: 'nothing to retry' + }, + // A refused /undo or /retry: main's busy text (tui_gateway/user_messages.py::busy_message). + busy: { + replying: (command: string) => + `session busy — Hermes is still replying. Stop the current reply first (Stop button, or Ctrl+C in a terminal), then run /${command}.`, + compressing: (command: string) => `session busy — a /compress is running; run /${command} when it finishes.` } }, debug: { diff --git a/website/docs/developer-guide/plugins/catalog-submission.md b/website/docs/developer-guide/plugins/catalog-submission.md index 61864aa0d7..942bea6d4f 100644 --- a/website/docs/developer-guide/plugins/catalog-submission.md +++ b/website/docs/developer-guide/plugins/catalog-submission.md @@ -106,7 +106,9 @@ the gallery. There is no separate listing to maintain. 8. **Desktop plugins stay inside the SDK surface.** A `desktop/plugin.js` runs in the Desktop renderer with the app's full authority (the loader isolates errors, not capabilities), so a listed one may only use the plugin SDK: - no prototype patching (`X.prototype.y =`, `Object.defineProperty(...prototype`), + no prototype patching of renderer or browser objects (`Storage.prototype.y =`, + `Object.defineProperty(...prototype`); a bundled library setting fields on + classes it declares itself (three.js and the like) is fine, no `eval`/`new Function`, no `import()` of anything but `@hermes/plugin-sdk` / `react` (app bundle chunks, blob or http URLs included), no script-tag injection, no reaching into the app's internal stores or its own markup @@ -145,8 +147,12 @@ the gallery. There is no separate listing to maintain. which honours the active profile (a bare `os.environ` read can pick up another profile's key under multiplexing). A subscription sign-in (Codex / ChatGPT today) is used only through `credentialed_provider_request` with - `requires_auth`, so Hermes attaches the token and the plugin never holds it. A - plugin never reads `.env` files, Hermes's `auth.json`, another tool's login (a + `requires_auth`, so Hermes attaches the token and the plugin never holds it. An + OAuth client for a third-party API (the user's own Google Cloud client, for + example) is declared under `oauth:` in `plugin.yaml`, signed in with + `hermes auth add :` and used through the same helper; Hermes + stores and refreshes that token too. Its `authorize_url`, `token_url` and + `hosts` are `https` only, and its `hosts` belong to the API the scopes are for. A plugin never reads `.env` files, Hermes's `auth.json`, another tool's login (a vendor CLI's token file, a browser profile, a keychain) or any other credential store directly, and never refreshes, rotates or writes OAuth tokens or presents itself as another vendor's client. If Hermes has no route for a diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index b612ee737b..47e5ac5697 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -466,6 +466,98 @@ returns an `AccountUsageSnapshot` (plan, session/weekly windows with **Sign-out** belongs to Hermes, not to a plugin: point users at `hermes auth logout openai-codex` instead of deleting credential files. +### Use your own OAuth client (Google and other APIs) + +A plugin that calls a third-party API with the user's own OAuth client (a +Google Cloud "Desktop app" client, for example) does not run the +consent flow, store the refresh token or refresh it. Declare the client and +Hermes owns the grant: + +```yaml +name: gemini-voice +requires_env: + - name: GEMINI_VOICE_CLIENT_ID + - name: GEMINI_VOICE_CLIENT_SECRET + secret: true +oauth: + - id: google + authorize_url: https://accounts.google.com/o/oauth2/v2/auth + token_url: https://oauth2.googleapis.com/token + scopes: + - https://www.googleapis.com/auth/calendar.events.owned + - https://www.googleapis.com/auth/documents + client_id_env: GEMINI_VOICE_CLIENT_ID + client_secret_env: GEMINI_VOICE_CLIENT_SECRET # optional: a public PKCE client has none + hosts: [www.googleapis.com, docs.googleapis.com] + authorize_params: {access_type: offline, prompt: consent} # Google issues a refresh token only with these +``` + +The user signs in once from a terminal with `hermes auth add gemini-voice:google` +(the plugin's key, a colon, the entry's `id`). That is the same PKCE login the +model-provider helper runs: a loopback redirect on `127.0.0.1`, with an SSH +tunnel hint when Hermes runs on a remote host. If the IdP grants fewer scopes +than declared, nothing is saved. `hermes auth status|refresh|logout +gemini-voice:google` show, refresh and remove the grant. It lives in the active +profile's `auth.json`; Hermes refreshes it on use once less than two minutes +remain and never starts a sign-in under the gateway or cron. + +The plugin calls the API through the same helper, with the entry's `id` as the +provider: + +```python +from hermes_cli.auth_constants import AuthError +from hermes_cli.plugin_provider_requests import ProviderNotSignedIn, credentialed_provider_request + +def create_document(title: str) -> dict: + try: + resp = credentialed_provider_request( + "google", "POST", "https://docs.googleapis.com/v1/documents", json={"title": title}, timeout=20) + except ProviderNotSignedIn as exc: + return {"ok": False, "error": str(exc)} # names `hermes auth add gemini-voice:google` + except AuthError as exc: + return {"ok": False, "error": f"{exc.code}: {exc}"} + return {"ok": resp.status < 300, "status": resp.status, "body": resp.json()} +``` + +The Codex rules above apply, plus: + +- **HTTPS only.** `authorize_url`, `token_url` and every `hosts` entry are + `https` (there is no loopback exception); `hermes plugins validate` and + catalog admission fail anything else. +- **Declared hosts only.** The bearer goes to `https://` (port 443) for + the entry's `hosts`, matched exactly (no subdomains). Any other URL, `http`, + or a URL with user info raises `PermissionError` before a request is made. + A host outside the sign-in provider's domains is a warning reviewers check. +- **Your own client variables.** `client_id_env` and `client_secret_env` name + the plugin's own variables; a variable Hermes itself uses (a provider API key, + a platform token) is refused, since Hermes sends these to `token_url`. +- **The plugin's own grant.** The grant is stored under the calling plugin's + key, so another plugin declaring the same `id` cannot use it, and the plugin's + own entry wins over a Hermes sign-in of the same name. +- **The grant follows the declaration.** It remembers the entry it was made + for: a plugin version that changes the token URL, hosts, scopes or client + variables gets `ProviderNotSignedIn` until the user signs in again, and a + catalog re-pin that adds an `oauth` entry or changes its authorize host, token + host, hosts or scopes asks before updating. +- **Errors.** `ProviderNotSignedIn`: not signed in, revoked, or the entry + changed since sign-in. `AuthError` codes: `oauth_client_id_missing` and + `oauth_client_secret_missing` (the variable is not set), `invalid_client`, + `unauthorized_client` and `invalid_scope` (the IdP rejected the client; the + user has to fix it), `oauth_refresh_failed` (network or server error; try + later). A revoked grant (`invalid_grant`) is retired: nothing goes to the + network until the next sign-in. Errors reach the plugin without a traceback, + chain or request object, so none leads back to the token, refresh token or + client secret. +- **Sign-in and removal.** `hermes auth add` prints which hosts get the code, + the client secret and the token before opening the browser, and refuses to + run from the gateway, cron or an agent session. `hermes plugins remove` drops + the plugin's grants, and `hermes auth logout :` still works after + the plugin is gone. + +`hermes plugins validate` checks the `oauth:` block and that its client +variables are declared, and warns when plugin code names the token URL. Like +`requires_auth`, this is consent and visibility, not a sandbox. + ### Manifest v2 reference `plugin.yaml` also supports an additive **v2 schema** (#64165). Every field is @@ -487,6 +579,7 @@ this Hermes understands still loads with a warning. | `tags` | list of str | Free-form discovery tags (e.g. `[gateway, telegram]`). | | `provides_locales` | list | Language pack declaration: ids (`- pl`) or `{id, endonym, rtl}` mappings whose `locales/[.tui\|.desktop].yaml` the loader registers automatically — see [Ship a language pack](#ship-a-language-pack). | | `requires_auth` | list of str | Providers whose sign-in the plugin uses through Hermes (today: `openai-codex`). Required by `credentialed_provider_request`; shown at install and in `hermes plugins show`. See [Use the user's Codex sign-in](#use-the-users-codex-sign-in-core-made-requests). | +| `oauth` | list of mappings | OAuth clients the plugin uses through Hermes: `id`, `authorize_url`, `token_url`, `scopes`, `client_id_env`, optional `client_secret_env`, `hosts`, optional `authorize_params`. Signed in with `hermes auth add :`, used through `credentialed_provider_request`. See [Use your own OAuth client](#use-your-own-oauth-client-google-and-other-apis). | ```yaml # plugin.yaml — manifest v2 example @@ -882,6 +975,35 @@ Never read `.env`, `auth.json` or another tool's credential files yourself (cata The `plugins.manage settings` action refuses secret keys and any value whose type or `choices` disagree with the schema. +**Portable packages (`plugin.json`)** declare the same schema under the Hermes +vendor namespace, since Agent Plugins v1 rejects unknown top-level fields: + +```json +{ + "extensions": { + "com.nousresearch.hermes": { + "config_schema": { + "api_key": { "type": "secret", "env": "MY_PLUGIN_API_KEY" }, + "show_chip": { "type": "bool", "default": true, "label": "Show the status-bar chip" } + } + } + } +} +``` + +A secret whose `env` names a variable your `mcp.json` passes through +(`"env": { "MY_PLUGIN_API_KEY": "${MY_PLUGIN_API_KEY}" }`) reaches the MCP server +right away: after a save, Hermes reconnects the plugin's servers whose resolved +launch inputs changed (a new key does, a toggle does not), so the form is also +where users set the server's key. + +**React to a save without polling.** Every save broadcasts +`plugin..settings.changed` (payload `{ written: [...keys] }`; a secret-only +save sends an empty list), so a Desktop half re-reads its preferences at once: +`ctx.onEvent('plugin.my-plugin.settings.changed', reload)`. Read the values from +your own `plugin_api.py` with `load_config_readonly()` from `hermes_cli.config` +(`plugins.entries..settings`), falling back to the schema defaults. + ## Step 6: Test it Start Hermes: diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 9a6b563559..65e5414d81 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1664,6 +1664,7 @@ Unified plugin management — general plugins, memory providers, and context eng | `install [--force] [--ref COMMIT_SHA] [--allow-removed]` | Install a plugin from the Hermes plugin catalog (bare entry name), a Git URL, or `owner/repo` shorthand. Catalog names resolve to the reviewed 40-hex commit SHA and show declared capabilities. Source, checked-out revision, and a nested catalog-provenance block are recorded in the installer's `plugins/.install-metadata.json`; the `.hermes-catalog.json` copy inside the plugin directory is only for convenience and is never trusted. Raw URLs are flagged as custom (unreviewed) sources. `--ref` selects a full-SHA custom pin and records the SHA actually checked out. `--allow-removed` (DANGEROUS) bypasses the removed-plugin blocklist at install and exempts that install from update, enable, and load-time kill-list checks. | | `search [term] [--json]` | Search the Hermes plugin catalog (matches entry names, descriptions, and declared tools; omit `term` to list everything). The catalog is curated in-repo (`plugin-catalog/`), refreshed from the live repo with a 6-hour cache, and falls back to the in-tree copy offline. Cataloged ≠ audited — admission reviews the entry, not the code. | | `update ` | Re-pin catalog installs to the reviewed catalog SHA, or update a custom Git install from its recorded source/feed. PM validates active-plugin dependencies before code publication. Explicit custom pins move only with `install --force --ref `. | +| `update [--yes]` | With no name: list every installed plugin that has an update `update ` can apply (catalog re-pin or Git pull) plus the ones it skips and why, then ask `Update all N? [Y/n]` and update each through the single-plugin path (per-plugin consent prompts still apply). One failure does not stop the rest; exits 1 if any failed. Without a terminal nothing is updated unless `--yes` is passed; `--yes` keeps consent fail-closed (new capabilities stay ungranted). | | `remove ` (aliases: `rm`, `uninstall`) | Remove an installed plugin. | | `enable ` | Enable a disabled plugin. | | `disable ` | Disable a plugin without removing it. | diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 44b4a0e458..f5016dc32b 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -49,7 +49,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/diff [staged\|all\|session] [--stat] [path...]` | Show git changes in the working directory. Default: unstaged changes plus untracked files. `staged` shows what's staged for commit, `all` everything since HEAD, and `session` the cumulative diff of everything Hermes changed here (from the earliest retained checkpoint baseline — requires checkpoints to be enabled; complements `/rollback diff `). `--stat` prints just the changed-file summary; path arguments restrict the diff. | | `/snapshot [create\|restore \|prune]` (alias: `/snap`) | Create or restore state snapshots of Hermes config/state. `create [label]` saves a snapshot, `restore ` reverts to it, `prune [N]` removes old snapshots, or list all with no args. Database restores write through SQLite's backup API so live processes (gateway, dashboard) see the restored data safely; if that path fails while another process still holds the database open, the restore refuses instead of risking corruption — stop the holder and retry. | | `/stop` | Kill all running background processes | -| `/queue ` (alias: `/q`) | Queue a prompt for the next turn (doesn't interrupt the current agent response). In the CLI, `/queue` with no arguments lists the pending queue, and `/queue list`, `/queue edit N `, `/queue rm N`, `/queue move A B`, and `/queue clear` inspect and manage queued prompts before they are sent. Prompts that begin with a management word can be force-queued with `/queue add `. | +| `/queue ` (alias: `/q`) | Queue a prompt for the next turn (doesn't interrupt the current agent response). In the CLI, `/queue` with no arguments lists the pending queue, and `/queue list`, `/queue edit N `, `/queue rm N`, `/queue move A B`, and `/queue clear` inspect and manage queued prompts before they are sent. Prompts that begin with a management word can be force-queued with `/queue add `. On the gateway's session, prompts queued mid-run live in the gateway's shared queue for the session: `list`/`rm`/`edit`/`clear` act on every prompt queued there (other windows' too), and `move` is refused for prompts the gateway holds, because it runs them in order. | | `/steer ` | Inject a mid-run note that arrives at the agent **after the next tool call** — no interrupt, no new user turn. The text is appended to the last tool result's content once the current tool completes, giving the agent new context without breaking the current tool-calling loop. Use this to nudge direction mid-task (e.g. "focus on the auth module" while the agent is running tests). | | `/goal ` | Set a standing goal Hermes works toward across turns — our take on the Ralph loop. After each turn an auxiliary judge model decides whether the goal is done; if not, Hermes auto-continues. Subcommands: `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`. Budget defaults to 20 turns (`goals.max_turns`); any real user message preempts the continuation loop, and state survives `/resume`. See [Persistent Goals](../user-guide/features/goals.md) for the full walkthrough. | | `/subgoal ` | Append a user-supplied criterion to the active goal mid-loop. The continuation prompt surfaces all subgoals to the agent verbatim, and the judge factors them into its DONE/CONTINUE verdict — so the goal isn't marked done until the original goal **and** every subgoal are met. Subcommands: `/subgoal` (list), `/subgoal remove `, `/subgoal clear`. Requires an active `/goal`. | diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 0c1bc757fd..cf4abba893 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -184,11 +184,41 @@ acknowledge it before continuing. Do not resend the queued input. 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.

+### Layout styles + +`display.cli_layout` picks how the classic CLI draws. The default, `modern`, prints everything in the scrollback as plain rows that start with one glyph and never depend on the terminal width, so resizing the window can't garble earlier output: + +``` +❯ add a multiply function and run the tests +∴ Thought for 3s · Planning the change +📖 Read calc.py +🔧 Patch 0.2s +│ calc.py +│ @@ -1,2 +1,6 @@ +│ +def multiply(a, b): +│ + return a * b +✗ $ pytest -q 0.4s [exit 1] +☤ Hermes +Added multiply; pytest isn't installed here, so I ran the tests directly: 2 passed. +─ 15.0s · edited 1 file +4 -0 · read 2 files · 8:27 AM +── ⠹ Running tests · 14s ─────────────────────────── Add multiply to calc.py ─ +❯ type to redirect this turn · Tab queues it for after +────────────────────────────────────────────────────────────────────────────── + gpt-5-mini · ctx [░░░░░░░░] 4% · ~/project (main) enter interrupt · tab queue · ctrl+c stop +``` + +- A 40-column bar opens each user message and answer (accent) and each reasoning block (dim); reasoning streams live as in classic and ends with a `∴ Thought for 3s · ` line. `❯` your message, a finished tool call led by its emoji (`📖 Read`, `💻 $`; `✗` in red when it failed, with its exit code), `│` an edit's diff under its row, `☤` the answer, and `─` the turn's summary line. +- The composer's top rule carries the live activity (spinner, what's running, elapsed time, streamed tokens) on the left and the session title on the right; no spinner row is printed into the scrollback. +- The status line sits under the composer: the same fields as the classic status bar (model, context, cache hit, latency, tokens/s, background work, goal, duration, YOLO…) plus the working directory (with git branch) on the left; the keys that do something right now on the right. +- The welcome banner is the same in both layouts. + +Set `display.cli_layout: classic` to keep the boxed reasoning and answer panels and the status bar above the input. + The welcome banner shows your model, terminal backend, working directory, available tools, and installed skills at a glance. ### Status Bar -A persistent status bar sits above the input area, updating in real time: +In the `classic` layout a persistent status bar sits above the input area, updating in real time (the `modern` layout's status line under the composer reads the same data; an explicit `display.status_bar.fields` list chooses its left side too): ``` ☤ claude-sonnet-4-20250514 │ 12.4K/200K │ [██████░░░░] 6% │ $0.06 │ 15m @@ -246,7 +276,7 @@ On macOS, `F6`/`F7` mean the physical function keys, not the media/system contro | `Ctrl+R` / `F7` | Toggle the live work dock between its multi-row preview and a single summary line without moving composer focus. `Ctrl+R` is the reliable fallback when macOS reserves the function-key row. Besides subagents and background processes, the dock shows a standing `/goal` (active, parked or paused, with turns used) on its top row and the prompts waiting in `/queue` on its bottom rows. | | `Ctrl+D` | Exit (on an empty prompt; with text it deletes under the cursor). While the gateway runs a turn, it exits and leaves the turn running: the gateway finishes it, ends the session afterwards, and `hermes --resume` shows the reply. A turn that asks a question (approval or clarify) meanwhile waits for its timeout or for you to resume. Use `Ctrl+C` to stop the turn instead. | | `Ctrl+Z` | Suspend Hermes to background (Unix only). Run `fg` in the shell to resume. | -| `Tab` | Accept auto-suggestion (ghost text) or autocomplete slash commands | +| `Tab` | Accept auto-suggestion (ghost text) or autocomplete slash commands. While the agent is working in the `modern` layout, `Tab` on a typed message queues it for the next turn | | `!<command>` | **Shell mode** — run a shell command yourself without spending a model turn (e.g. `!git status`, `!pytest -x`). See below. | **Multiline paste preview.** When you paste a multi-line block, the CLI echoes a compact single-line preview (`[pasted: 47 lines, 1,842 chars — press Enter to send]`) instead of dumping the whole payload into the scrollback. The full content is still what gets sent; this is just display polish. @@ -442,7 +472,7 @@ The `display.busy_input_mode` config key controls what happens when you press En | Mode | Behavior | |------|----------| | `"interrupt"` (default) | Your message redirects the active turn. Model generation restarts with displayed reasoning and completed work preserved. A running foreground terminal command is moved to the background (not killed — you get a completion notification) so your message is read immediately; other running tools finish first | -| `"queue"` | Your message is queued as the next turn after the agent finishes. The queue lives on the gateway (crash-durable, visible to every attached client). Classic `hermes chat` always behaves this way: every line typed during a turn queues at the gateway, whatever this key says; `interrupt` and `steer` are TUI and Desktop behaviours | +| `"queue"` | Your message is queued as the next turn after the agent finishes. The queue lives on the gateway (crash-durable, visible to every attached client). | | `"steer"` | Your message is injected into the current run via `/steer`, arriving at the agent after the next tool call — no interrupt, no new turn | ```yaml @@ -457,6 +487,8 @@ display: Whatever the mode, `/queue <prompt>` queues a follow-up turn explicitly, and `/queue list`, `/queue rm N`, `/queue edit N …` and `/queue move A B` act on the pending queue immediately, even mid-run. The live work dock lists what is waiting. +When the CLI runs on the gateway's session (the default local setup), a prompt queued while a turn runs, by `/queue` or by queue-mode Enter, goes into the gateway's own queue for the session. It survives a gateway restart and runs once, in the order you entered it. Image follow-ups still wait in the CLI. That queue is shared: `/queue list`, `rm`, `edit` and `clear` show and change every prompt queued on the session, including ones another window or an automation queued. The gateway runs its queue in order and cannot reorder it, so `/queue move` refuses to move a prompt the gateway holds. A slash command typed while busy after your own `/queue` prompt runs after that prompt's turn starts; it waits at most a couple of seconds, so a paused queue never blocks typing. + You can also change it inside the CLI: ```text diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 2adc9abb55..b60711f46a 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -424,6 +424,8 @@ hermes plugins install user/repo # install from Git, then prompt Ena hermes plugins install user/repo --enable # request enable; dependency consent still applies hermes plugins install user/repo --no-enable # install but leave disabled (no prompt) hermes plugins update my-plugin # pull latest (local edits are autostashed and re-applied) +hermes plugins update # list every plugin with an update, then "Update all N? [Y/n]" +hermes plugins update --yes # same, without the question (scripts, cron; required without a terminal) hermes plugins remove my-plugin # uninstall; also drops it from plugins.enabled/disabled/entries # and resets memory.provider when it was the live provider hermes plugins enable my-plugin # add to allow-list @@ -451,7 +453,20 @@ check runs when `plugins.auto_update_check_hours` is due: default 24 hours, desktop sync-status view. This is not a hard once-per-day limit if you configure a different interval. -By default, updates require `hermes plugins update NAME`. Setting +`hermes plugins update` with no name runs the same check and lists the +plugins it can move: catalog installs (re-pinned to the reviewed catalog +commit) and tracked Git installs (pulled). It names every plugin it skips and +why: pinned, needs fixing, status unknown, or a pip distribution Hermes does +not upgrade. In a terminal it asks once, then updates each plugin through the +same path as `hermes plugins update NAME`, so new capabilities, a widened +catalog pin and scan findings still prompt per plugin. One failure does not +stop the rest; the summary lists what updated, what was already current and +what failed with its reason (exit code 1 if any failed). Without a terminal +it prints the list and updates nothing unless you pass `--yes`; with `--yes`, +consent prompts fail closed as in any non-interactive update (new +capabilities stay ungranted, a widened catalog pin is not applied). + +By default, plugins only update when you run `hermes plugins update`. Setting `plugins.auto_apply: true` opts tracked Git plugins into unattended updates. Both routes use the update security scan. Auto-apply does not manage pinned, manual, drifted, or pip-distribution rows. @@ -557,7 +572,7 @@ granted, `hermes plugins update` surfaces the additions and asks again. New capabilities stay off until you consent — a plugin update can never silently widen its access. Catalog re-pins go one step further: when the new pin adds tools, hooks, Python dependencies, host capabilities, a provider sign-in -(`requires_auth`) or a Desktop UI half the installed version did not have, the CLI shows the delta and asks `y/N` before +(`requires_auth`), a new or changed OAuth client (`oauth:`) or a Desktop UI half the installed version did not have, the CLI shows the delta and asks `y/N` before anything moves, and the Desktop / dashboard **Update** button opens the same confirmation. Declining (or a non-interactive session) leaves the plugin at the old pin.