mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Merge origin/main into fix/pkg13-ink-leftovers
Resolves the hermes_cli/cli_tui_mixin.py conflict from #141501 landing: main moved _Panel to hermes_cli/cli_panel.py and this branch moved the wrap helpers to hermes_cli/cli_tui_wrap.py. Keep both moves: import _Panel and the wrap helpers, define neither here. Refs #134889 (N8)
This commit is contained in:
commit
85188e61c3
199 files changed
+9910
-648
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+53
-2
@@ -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.<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 "")
|
||||
|
||||
+42
-11
@@ -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.
|
||||
|
||||
+156
-116
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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<void>(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<void>(resolve => server.close(() => resolve()))
|
||||
await fs.rm(home, { recursive: true, force: true })
|
||||
await fs.rm(resources, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
@@ -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<typeof ChildProcessAPI>()
|
||||
|
||||
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 })
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<ResolvedHerm
|
||||
command: payload.shim,
|
||||
args: [...backendArgs],
|
||||
env: { ...buildDesktopBackendEnv(), HERMES_RUNTIME_DIR: payload.toolsDir },
|
||||
...payloadTicketRuntime(payload),
|
||||
root: payload.repoDir,
|
||||
bootstrap: false,
|
||||
shell: false,
|
||||
@@ -5209,6 +5202,8 @@ interface ResolvedHermesBackend {
|
||||
shell: boolean
|
||||
local?: string
|
||||
root?: string
|
||||
ticketCommand?: string
|
||||
ticketEnv?: NodeJS.ProcessEnv
|
||||
installStamp?: Readonly<InstallStamp> | null
|
||||
activeRoot?: string
|
||||
isPackaged?: boolean
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -46,6 +46,33 @@ export function payloadPythonPath(payload: Pick<PayloadInfo, 'repoDir' | 'sitePa
|
||||
return [payload.repoDir, payload.sitePackages].join(path.delimiter)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundled shim is the Hermes CLI, not an interpreter, so it rejects the
|
||||
* ticket bridge's `-P -u -c`. Ticket minting runs the payload python against
|
||||
* the same import closure the shim gives the CLI.
|
||||
*/
|
||||
export function payloadTicketRuntime(payload: Pick<PayloadInfo, 'storePython' | 'repoDir' | 'sitePackages'>): {
|
||||
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
|
||||
|
||||
@@ -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<string, unknown>, withGeneration: boolean): Record<string, unknown> {
|
||||
// `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<string, unknown>, withGeneration: boolean, derived = false): Record<string, unknown> {
|
||||
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<string, unknown>, fenced: { operation: string; payload: Record<string, unknown> }): Record<string, unknown> {
|
||||
// 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<string, unknown>): Record<string, unknown> | 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<string, unknown>) {
|
||||
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<string, () => { operation: string; payload: Record<string, unknown> }>)[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<string, unknown>), ...(typeof params.confirm === 'string' ? { confirm: params.confirm } : {}) }
|
||||
@@ -625,7 +665,7 @@ export class CanonicalDesktopProtocol {
|
||||
private mutationReceipt(method: string, params: Record<string, unknown>, 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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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<void>(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<void>(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<void>(resolve => server.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<boolean>) | null = null
|
||||
let redirect: ((text: string, mode?: 'interrupt' | 'steer') => Promise<boolean>) | null = null
|
||||
let states: Map<string, ClientSessionState>
|
||||
|
||||
/** 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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 extends LiveStreamState>(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 extends LiveStreamState>(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 extends LiveStreamState>(
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+428
-7
@@ -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<string, unknown>) => 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<string, unknown>) => (respond(method, params) ?? {}) as never)
|
||||
return vi.fn(
|
||||
async (method: string, params?: Record<string, unknown>) =>
|
||||
(method === 'session.rewind' && params?.replay_only
|
||||
? { status: 'nothing', rewound_count: 0 }
|
||||
: (respond(method, params) ?? {})) as never
|
||||
)
|
||||
}
|
||||
|
||||
async function mount(requestGateway: ReturnType<typeof gateway>) {
|
||||
@@ -58,8 +69,12 @@ async function mount(requestGateway: ReturnType<typeof gateway>) {
|
||||
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<typeof gateway>, 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<unknown, Record<string, unknown>>()
|
||||
let revision = 4
|
||||
let loseReply = true
|
||||
|
||||
const owner = (wire: string, params: Record<string, unknown>) => {
|
||||
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<string, unknown> = {}) => {
|
||||
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<typeof gateway>, seedMessages: unknown[]) {
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (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(
|
||||
<Harness
|
||||
onReady={h => (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(
|
||||
<Harness
|
||||
onReady={h => (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))
|
||||
|
||||
@@ -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<C extends { arg: string }> {
|
||||
|
||||
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<string, number>()
|
||||
|
||||
/** 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<C extends { arg: string }>(env: SessionCo
|
||||
): Promise<undefined | { count: number; content: unknown }> {
|
||||
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<RewindReceipt>('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<RewindReceipt | undefined> {
|
||||
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<C extends { arg: string }>(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<RewindReceipt>('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<C extends { arg: string }>(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<C extends { arg: string }>(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<C extends { arg: string }>(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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"`,
|
||||
|
||||
@@ -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 <Prompt>',
|
||||
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}“`,
|
||||
|
||||
@@ -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 <prompt>',
|
||||
steerQueued: (preview: string) => `steer queued — arrives after next tool call: "${preview}"`,
|
||||
steerNoActiveTurn: (preview: string) => `no active turn — queued for next: "${preview}"`,
|
||||
|
||||
@@ -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 <prompt>',
|
||||
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}"`,
|
||||
|
||||
@@ -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 <prompt>',
|
||||
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} »`,
|
||||
|
||||
@@ -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}」`,
|
||||
|
||||
@@ -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}»`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}」`,
|
||||
|
||||
@@ -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}”`,
|
||||
|
||||
@@ -25,6 +25,7 @@ export {
|
||||
spliceOlderPreservedRows
|
||||
} from './reconciliation'
|
||||
export {
|
||||
hasUnresolvedToolCall,
|
||||
QUESTION_CARD_TOOLS,
|
||||
restorePendingBlockingToolCall,
|
||||
restorePendingClarifyToolCall,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -118,6 +118,25 @@ describe('saveAgentPluginSettings (#46600, #87934)', () => {
|
||||
expect($agentPlugins.get()[0].settings_schema).toEqual([])
|
||||
})
|
||||
|
||||
// The backend's settings save is what announces `plugin.<id>.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 () => {
|
||||
|
||||
@@ -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.<id>.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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
bablobanov
|
||||
@@ -0,0 +1,2 @@
|
||||
Neckyserg
|
||||
# PR #117987 author (commit 904a695 on Neckyserg/hermes-agent)
|
||||
@@ -0,0 +1,2 @@
|
||||
austinpickett
|
||||
# PR #139174
|
||||
+24
-2
@@ -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))
|
||||
|
||||
+8
-11
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:<name>:``); 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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``).
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:<route>``,
|
||||
``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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
``<plugin>:<id>`` 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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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 <prompt>`` enqueues; a leading management verb whose arguments fit
|
||||
|
||||
@@ -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/<abs> → b/<abs>`` 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 [""]
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
|
||||
+16
-32
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 <prompt>`` 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
-2
@@ -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}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 <plugin>:<id>`` 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(<id>, ...)``: 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 "<plugin>")
|
||||
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:<name>` 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 <plugin>:<id> ────────────────────────────────────────────
|
||||
|
||||
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 <plugin>:<id>`` 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 <plugin>:<id>``; None when *provider* is not a plugin's
|
||||
``oauth:`` entry (``custom:<name>`` 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",
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"(?<![\w$.]){0}\s*=\s*(?:class\b[^{{]*|function\s*\*?\s*[\w$]*\s*\([^)]*\)\s*)\{{")
|
||||
# What can make ``X`` name something else, read liberally (a false hit only refuses a bundle for
|
||||
# review; a miss would excuse a renderer patch): any assignment operator on ``X``; a declaration;
|
||||
# ``X`` inside a destructuring pattern that is assigned or iterated (``({X} = o)``, ``for ([X] of
|
||||
# a)``); a parameter (function, method, arrow, catch); ``for (X of/in …)``; an import binding.
|
||||
# A reference (``register(X)``, ``[X, Y]``, ``{ Array: X }``) is not a binding.
|
||||
_REBINDING = (
|
||||
r"(?<![\w$.]){0}\s*(?:&&|\|\||\?\?|\*\*|<<|>>>?|[-+*/%&|^])?=(?![=>])"
|
||||
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*[\[{{][^;=()]*?(?<![\w$.]){0}(?![\w$.]|\s*:)[^;=()]*?[}}\]][\s}}\]]*"
|
||||
r"(?:=(?![=>])|\b(?:of|in)\b)"
|
||||
r"|(?:\bfunction\s*\*?\s*[\w$]*|(?<![\w$.])(?!(?:if|for|while|switch|with|return|typeof|await)\b)[\w$]+)"
|
||||
r"\s*\([^()]*?(?<![\w$.]){0}(?![\w$])[^()]*\)\s*\{{"
|
||||
r"|\(([^()]*?(?<![\w$.]){0}(?![\w$])[^()]*)\)\s*=>|(?<![\w$.]){0}\s*=>"
|
||||
r"|\bcatch\s*\([^)]*(?<![\w$.]){0}(?![\w$])"
|
||||
r"|(?<![\w$.]){0}\s+(?:of|in)\b|\bas\s+{0}(?![\w$])|\bimport\s+{0}(?![\w$])")
|
||||
|
||||
|
||||
_REGEX_AFTER_WORD = re.compile(r"(?:^|[^\w$.])(?:return|typeof|case|do|else|in|of|void|delete|throw|new|yield|await)\s*$")
|
||||
_MAX_READINGS = 32
|
||||
|
||||
|
||||
def _slash_readings(source: str, at: int) -> 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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
|
||||
@@ -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', '<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', '<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 <name>``: :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 <name>[/dim]")
|
||||
console.print("[dim]Check-only. Apply with: hermes plugins update <name>, or "
|
||||
"hermes plugins update to update them all[/dim]")
|
||||
|
||||
|
||||
def dashboard_update_user_plugin(name: str, *, accept_capabilities: bool = False) -> dict[str, Any]:
|
||||
|
||||
@@ -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 <name>` 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 <name>`` 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)
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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_dir>/plugin.yaml``; ``{}`` when absent or malformed
|
||||
(the loader already warned about malformed entries at load time)."""
|
||||
"""``config_schema`` from ``<plugin_dir>/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
|
||||
|
||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user