mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
Merge origin/main into bbednarski/nemo-relay-upgrade
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
This commit is contained in:
commit
2d2bed5891
891 changed files with 67676 additions and 7194 deletions
|
|
@ -1,6 +1,13 @@
|
|||
# Hermes Agent Environment Configuration
|
||||
# Copy this file to .env and fill in your API keys
|
||||
|
||||
# =============================================================================
|
||||
# LLM PROVIDER (Fireworks AI)
|
||||
# =============================================================================
|
||||
# Get your key at: https://app.fireworks.ai/settings/users/api-keys
|
||||
# Address models directly by catalog ID, e.g.
|
||||
# accounts/fireworks/models/kimi-k2p6, accounts/fireworks/models/glm-5p2
|
||||
# FIREWORKS_API_KEY=
|
||||
# =============================================================================
|
||||
# LLM PROVIDER (OpenRouter)
|
||||
# =============================================================================
|
||||
|
|
|
|||
2
.envrc
2
.envrc
|
|
@ -1,4 +1,4 @@
|
|||
watch_file pyproject.toml uv.lock
|
||||
watch_file pyproject.toml uv.lock hermes
|
||||
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
|
||||
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix
|
||||
|
||||
|
|
|
|||
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -221,10 +221,17 @@ jobs:
|
|||
|
||||
- name: Save baseline cache (main only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: cp ci-timings.json ci-timings-baseline.json
|
||||
run: |
|
||||
# Degraded runs (API rate-limited) produce no ci-timings.json —
|
||||
# skip rather than fail, and never cache an empty baseline.
|
||||
if [ -f ci-timings.json ]; then
|
||||
cp ci-timings.json ci-timings-baseline.json
|
||||
else
|
||||
echo "No timings JSON this run — skipping baseline update"
|
||||
fi
|
||||
|
||||
- name: Upload baseline to cache (main only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('ci-timings-baseline.json') != ''
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ci-timings-baseline.json
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -119,6 +119,9 @@ docs/superpowers/*
|
|||
# treat it as a local edit and autostash it on every run (#38529).
|
||||
.hermes-bootstrap-complete
|
||||
|
||||
# Persistent dev sandbox dir (scripts/dev-sandbox.sh --persistent)
|
||||
.hermes-sandbox/
|
||||
|
||||
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
|
||||
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
|
||||
# — ignore so `git status` stays clean and update's autostash skips them.
|
||||
|
|
|
|||
|
|
@ -491,18 +491,18 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
|
|||
|
||||
### Electron Desktop Chat App (`apps/desktop/`)
|
||||
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.ts` + `backendSupportsServe()` in `electron/main.ts`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. For scoped Desktop architecture, state, resolver, transport, and testing rules, read `apps/desktop/AGENTS.md`.
|
||||
|
||||
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
|
||||
|
||||
- **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills.
|
||||
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMANDS` (the ~19 built-ins shown in the palette) plus block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
|
||||
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMAND_SPECS` (the built-ins and their Desktop surfaces) plus `NO_DESKTOP_SURFACE` block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
|
||||
- `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
|
||||
- `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`.
|
||||
- `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.)
|
||||
- **Dispatch** lives in `app/session/hooks/use-prompt-actions.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
|
||||
- **Dispatch** lives in `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
|
||||
|
||||
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: `apps/desktop/src/lib/desktop-slash-commands.test.ts` (run via the repo-root `vitest`, since `apps/desktop` resolves deps from the root workspace install).
|
||||
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: from `apps/desktop`, run `npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace dependencies are installed at the repo root).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimi
|
|||
| Requisito | Notas |
|
||||
|-----------|-------|
|
||||
| **Git** | Con la extensión `git-lfs` instalada |
|
||||
| **Python 3.11+** | uv lo instalará si falta |
|
||||
| **Python 3.11–3.13** | uv lo instalará si falta |
|
||||
| **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) |
|
||||
|
||||
|
|
|
|||
|
|
@ -38,19 +38,22 @@ def _permission_option_supports_kind(kind: str) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]:
|
||||
def _build_permission_options(
|
||||
*, allow_permanent: bool, smart_denied: bool = False,
|
||||
) -> list[PermissionOption]:
|
||||
"""Return ACP options that match Hermes approval semantics."""
|
||||
options = [
|
||||
PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"),
|
||||
PermissionOption(
|
||||
options = [PermissionOption(
|
||||
option_id="allow_once", kind="allow_once", name="Allow once",
|
||||
)]
|
||||
if not smart_denied:
|
||||
options.append(PermissionOption(
|
||||
option_id="allow_session",
|
||||
# ACP has no session-scoped kind, so use the closest persistent
|
||||
# hint while keeping Hermes semantics in the option id.
|
||||
kind="allow_always",
|
||||
name="Allow for session",
|
||||
),
|
||||
]
|
||||
if allow_permanent:
|
||||
))
|
||||
if allow_permanent and not smart_denied:
|
||||
options.append(
|
||||
PermissionOption(
|
||||
option_id="allow_always",
|
||||
|
|
@ -59,7 +62,7 @@ def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption
|
|||
),
|
||||
)
|
||||
options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny"))
|
||||
if _permission_option_supports_kind("reject_always"):
|
||||
if not smart_denied and _permission_option_supports_kind("reject_always"):
|
||||
options.append(
|
||||
PermissionOption(
|
||||
option_id="deny_always",
|
||||
|
|
@ -129,11 +132,15 @@ def make_approval_callback(
|
|||
description: str,
|
||||
*,
|
||||
allow_permanent: bool = True,
|
||||
smart_denied: bool = False,
|
||||
**_: object,
|
||||
) -> str:
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
|
||||
options = _build_permission_options(allow_permanent=allow_permanent)
|
||||
options = _build_permission_options(
|
||||
allow_permanent=allow_permanent,
|
||||
smart_denied=smart_denied,
|
||||
)
|
||||
|
||||
tool_call = _build_permission_tool_call(command, description)
|
||||
coro = request_permission_fn(
|
||||
|
|
|
|||
|
|
@ -26,31 +26,18 @@ from typing import Any, Dict, List, Optional
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _win_path_to_wsl(path: str) -> str | None:
|
||||
"""Convert a Windows drive path to its WSL /mnt/<drive>/... equivalent."""
|
||||
match = re.match(r"^([A-Za-z]):[\\/](.*)$", path)
|
||||
if not match:
|
||||
return None
|
||||
drive = match.group(1).lower()
|
||||
tail = match.group(2).replace("\\", "/")
|
||||
return f"/mnt/{drive}/{tail}"
|
||||
|
||||
|
||||
def _translate_acp_cwd(cwd: str) -> str:
|
||||
"""Translate Windows ACP cwd values when Hermes itself is running in WSL.
|
||||
|
||||
Windows ACP clients can launch ``hermes acp`` inside WSL while still sending
|
||||
editor workspaces as Windows drive paths such as ``E:\\Projects``. Store
|
||||
and execute against the WSL mount path so agents, tools, and persisted ACP
|
||||
sessions all agree on the usable workspace. Native Linux/macOS keeps the
|
||||
original cwd unchanged.
|
||||
editor workspaces as Windows drive paths (``E:\\Projects``) or
|
||||
``\\\\wsl.localhost\\`` UNC paths. Store and execute against the POSIX form so
|
||||
agents, tools, and persisted ACP sessions all agree on the usable workspace.
|
||||
Native Linux/macOS keeps the original cwd unchanged.
|
||||
"""
|
||||
from hermes_constants import is_wsl
|
||||
from hermes_constants import translate_cwd_for_wsl_backend
|
||||
|
||||
if not is_wsl():
|
||||
return cwd
|
||||
translated = _win_path_to_wsl(str(cwd))
|
||||
return translated if translated is not None else cwd
|
||||
return translate_cwd_for_wsl_backend(str(cwd))
|
||||
|
||||
|
||||
def _normalize_cwd_for_compare(cwd: str | None) -> str:
|
||||
|
|
@ -61,7 +48,9 @@ def _normalize_cwd_for_compare(cwd: str | None) -> str:
|
|||
|
||||
# Normalize Windows drive paths into the equivalent WSL mount form so
|
||||
# ACP history filters match the same workspace across Windows and WSL.
|
||||
translated = _win_path_to_wsl(expanded)
|
||||
from hermes_constants import windows_path_to_wsl
|
||||
|
||||
translated = windows_path_to_wsl(expanded)
|
||||
if translated is not None:
|
||||
expanded = translated
|
||||
elif re.match(r"^/mnt/[A-Za-z]/", expanded):
|
||||
|
|
|
|||
|
|
@ -110,7 +110,12 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:
|
|||
if tool_name == "web_extract":
|
||||
urls = args.get("urls", [])
|
||||
if urls:
|
||||
return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
|
||||
first = urls[0]
|
||||
if isinstance(first, dict):
|
||||
first = first.get("url") or first.get("href") or "?"
|
||||
elif not isinstance(first, str):
|
||||
first = "?"
|
||||
return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
|
||||
return "web extract"
|
||||
if tool_name == "process":
|
||||
action = str(args.get("action") or "").strip() or "manage"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.18.0",
|
||||
"version": "0.18.2",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.18.0",
|
||||
"package": "hermes-agent[acp]==0.18.2",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Optional
|
|||
import httpx
|
||||
|
||||
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
|
||||
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
|
||||
from hermes_cli.auth import AuthError, _read_codex_tokens, resolve_codex_runtime_credentials
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -436,20 +436,78 @@ def _resolve_codex_usage_url(base_url: str) -> str:
|
|||
return normalized + "/api/codex/usage"
|
||||
|
||||
|
||||
def _fetch_codex_account_usage() -> Optional[AccountUsageSnapshot]:
|
||||
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
|
||||
token_data = _read_codex_tokens()
|
||||
tokens = token_data.get("tokens") or {}
|
||||
account_id = str(tokens.get("account_id", "") or "").strip() or None
|
||||
def _resolve_codex_usage_credentials(
|
||||
base_url: Optional[str],
|
||||
api_key: Optional[str],
|
||||
) -> tuple[str, str, Optional[str]]:
|
||||
"""Resolve Codex quota credentials from the native runtime path.
|
||||
|
||||
Prefer explicit live-agent credentials, then the legacy singleton OAuth
|
||||
state, then the credential pool. Hermes's native OAuth setup now stores
|
||||
device-code logins in the pool, so quota diagnostics must not depend only
|
||||
on the older singleton store.
|
||||
"""
|
||||
explicit_key = str(api_key or "").strip()
|
||||
if explicit_key:
|
||||
return explicit_key, str(base_url or "").strip(), None
|
||||
|
||||
# Tier 2: the native runtime resolver. It ALREADY falls back to the
|
||||
# credential pool when the singleton is empty (see
|
||||
# ``resolve_codex_runtime_credentials`` — issue #32992), so in a pool-only
|
||||
# setup this returns a usable ``source="credential_pool"`` token.
|
||||
#
|
||||
# Only ``AuthError`` ("no creds" / rate-limited) is caught so tier 3 can
|
||||
# run: a broad ``except Exception`` would (a) mask a transient refresh /
|
||||
# network failure and silently hand back a DIFFERENT pool account's usage,
|
||||
# and (b) hide genuine programming errors. A refresh/network error must
|
||||
# propagate — the outer ``fetch_account_usage`` guard fails open (shows
|
||||
# nothing this turn) rather than reporting the wrong account.
|
||||
#
|
||||
# The ``account_id`` (for the ``ChatGPT-Account-Id`` header) is read
|
||||
# best-effort: a partial/missing singleton token store must not sink an
|
||||
# otherwise-usable resolver credential and force a header-less pool fallback.
|
||||
try:
|
||||
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
|
||||
account_id: Optional[str] = None
|
||||
try:
|
||||
token_data = _read_codex_tokens()
|
||||
tokens = token_data.get("tokens") or {}
|
||||
account_id = str(tokens.get("account_id", "") or "").strip() or None
|
||||
except AuthError:
|
||||
# Pool-only creds carry no singleton account_id; header is optional.
|
||||
logger.debug("codex ▸ /usage account_id read failed (best-effort)", exc_info=True)
|
||||
return creds["api_key"], str(creds.get("base_url", "") or "").strip(), account_id
|
||||
except AuthError:
|
||||
logger.debug("codex ▸ /usage runtime resolver returned no creds; trying pool", exc_info=True)
|
||||
|
||||
# Tier 3: direct pool select. Reached only when the resolver itself raises
|
||||
# AuthError (e.g. singleton missing AND its own pool read found nothing at
|
||||
# resolve time, but a pool entry is usable now). Pool credentials have no
|
||||
# account_id concept, so the ChatGPT-Account-Id header is intentionally
|
||||
# omitted here.
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openai-codex")
|
||||
entry = pool.select()
|
||||
if entry is None:
|
||||
raise RuntimeError("No available openai-codex credential in credential pool")
|
||||
return entry.runtime_api_key, str(entry.runtime_base_url or base_url or "").strip(), None
|
||||
|
||||
|
||||
def _fetch_codex_account_usage(
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> Optional[AccountUsageSnapshot]:
|
||||
token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {creds['api_key']}",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "codex-cli",
|
||||
}
|
||||
if account_id:
|
||||
headers["ChatGPT-Account-Id"] = account_id
|
||||
with httpx.Client(timeout=15.0) as client:
|
||||
response = client.get(_resolve_codex_usage_url(creds.get("base_url", "")), headers=headers)
|
||||
response = client.get(_resolve_codex_usage_url(resolved_base_url), headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json() or {}
|
||||
rate_limit = payload.get("rate_limit") or {}
|
||||
|
|
@ -628,7 +686,7 @@ def fetch_account_usage(
|
|||
return None
|
||||
try:
|
||||
if normalized == "openai-codex":
|
||||
return _fetch_codex_account_usage()
|
||||
return _fetch_codex_account_usage(base_url=base_url, api_key=api_key)
|
||||
if normalized == "anthropic":
|
||||
return _fetch_anthropic_account_usage()
|
||||
if normalized == "openrouter":
|
||||
|
|
|
|||
|
|
@ -68,24 +68,118 @@ def _ra():
|
|||
return run_agent
|
||||
|
||||
|
||||
def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str:
|
||||
"""Build the one-time notice shown when Codex gpt-5.5 raises compaction.
|
||||
def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
|
||||
"""Build the one-time notice shown when Codex gpt-5.x raises compaction.
|
||||
|
||||
``autoraise`` is ``{"from": <old_ratio>, "to": <new_ratio>}``. The same
|
||||
text is printed inline for CLI users and replayed via ``status_callback``
|
||||
for gateway users, so it must be self-contained and include the exact
|
||||
opt-back-out command.
|
||||
``autoraise`` is ``{"model": <slug>, "from": <old_ratio>, "to": <new_ratio>}``.
|
||||
The same text is printed inline for CLI users and replayed via
|
||||
``status_callback`` for gateway users, so it must be self-contained and
|
||||
include the exact opt-back-out command.
|
||||
"""
|
||||
model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
|
||||
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 family
|
||||
# is capped at 272K by the Codex OAuth backend.
|
||||
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
|
||||
from_pct = int(round(autoraise["from"] * 100))
|
||||
to_pct = int(round(autoraise["to"] * 100))
|
||||
return (
|
||||
f"ℹ Codex gpt-5.5 caps context at 272K, so auto-compaction was raised "
|
||||
f"ℹ Codex {model} caps context at {cap}, so auto-compaction was raised "
|
||||
f"to {to_pct}% (from {from_pct}%) to use more of the window before "
|
||||
f"summarizing.\n"
|
||||
f" Opt back out: hermes config set compression.codex_gpt55_autoraise false"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_compression_threshold(
|
||||
global_threshold: float,
|
||||
model_cthresh: Optional[float],
|
||||
*,
|
||||
model: Optional[str] = None,
|
||||
is_codex_autoraise: bool,
|
||||
) -> tuple[float, Optional[Dict[str, Any]]]:
|
||||
"""Combine the user's global compaction threshold with a per-model override.
|
||||
|
||||
Returns ``(effective_threshold, autoraise_notice)``. ``autoraise_notice`` is
|
||||
``{"model": <slug>, "from": <old>, "to": <new>}`` only when a Codex
|
||||
autoraise (gpt-5.4/5.5 272K family or gpt-5.3-codex-spark) actually raises
|
||||
the threshold, otherwise ``None``.
|
||||
|
||||
The Codex overrides are *autoraises*: they must never LOWER a higher
|
||||
user-configured threshold. A user who already set ``compression.threshold``
|
||||
above the raised value deliberately keeps more raw context, and silently
|
||||
dropping them would both waste usable window and contradict the feature's
|
||||
purpose (use more of the window). Other overrides (e.g. Arcee Trinity)
|
||||
keep their existing unconditional behaviour.
|
||||
"""
|
||||
if model_cthresh is None:
|
||||
return global_threshold, None
|
||||
if is_codex_autoraise:
|
||||
if model_cthresh <= global_threshold + 1e-9:
|
||||
# Autoraise never lowers; keep the user's higher/equal threshold.
|
||||
return global_threshold, None
|
||||
return model_cthresh, {
|
||||
"model": model,
|
||||
"from": global_threshold,
|
||||
"to": model_cthresh,
|
||||
}
|
||||
return model_cthresh, None
|
||||
|
||||
|
||||
def _codex_gpt55_autoraise_notice_marker():
|
||||
"""Path to the per-profile marker recording that the autoraise notice ran.
|
||||
|
||||
Lives under ``$HERMES_HOME`` (which is profile-scoped) alongside the other
|
||||
internal markers like ``.container-mode`` — so it is not a user-facing config
|
||||
key, and every profile tracks its own notice state independently.
|
||||
"""
|
||||
return get_hermes_home() / ".codex_gpt55_autoraise_notice"
|
||||
|
||||
|
||||
def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str:
|
||||
"""Stable identity for one autoraise notice, keyed on what it displays.
|
||||
|
||||
Uses the model slug plus the same from→to percentages the notice text
|
||||
shows, so an unchanged threshold stays silent across restarts while a
|
||||
later change (the user edits their global ``threshold``, or switches to a
|
||||
different autoraised Codex model) re-notifies once.
|
||||
"""
|
||||
model = str(autoraise.get("model") or "").strip().lower().rsplit("/", 1)[-1]
|
||||
from_pct = int(round(float(autoraise["from"]) * 100))
|
||||
to_pct = int(round(float(autoraise["to"]) * 100))
|
||||
return f"{model}:{from_pct}:{to_pct}"
|
||||
|
||||
|
||||
def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool:
|
||||
"""True if this exact autoraise notice was already shown for this profile.
|
||||
|
||||
A missing/unreadable marker (or one recording a different threshold) reads
|
||||
as unseen, so the notice shows.
|
||||
"""
|
||||
try:
|
||||
current = _codex_gpt55_autoraise_notice_state(autoraise)
|
||||
return _codex_gpt55_autoraise_notice_marker().read_text(
|
||||
encoding="utf-8"
|
||||
).strip() == current
|
||||
except (OSError, KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _record_codex_gpt55_autoraise_notice(autoraise: Dict[str, Any]) -> None:
|
||||
"""Persist that the autoraise notice was shown for this profile/config state.
|
||||
|
||||
Best-effort: a read-only or missing ``$HERMES_HOME`` just means the notice
|
||||
may show again next init, which is preferable to breaking agent init.
|
||||
"""
|
||||
try:
|
||||
marker = _codex_gpt55_autoraise_notice_marker()
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text(
|
||||
_codex_gpt55_autoraise_notice_state(autoraise), encoding="utf-8"
|
||||
)
|
||||
except (OSError, KeyError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _normalized_custom_base_url(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
|
|
@ -93,10 +187,26 @@ def _normalized_custom_base_url(value: Any) -> str:
|
|||
|
||||
|
||||
def _custom_provider_model_matches(agent_model: str, entry: Dict[str, Any]) -> bool:
|
||||
provider_model = str(entry.get("model", "") or "").strip().lower()
|
||||
if not provider_model:
|
||||
agent_model_norm = str(agent_model or "").strip().lower()
|
||||
# Multi-model entries (v12+ `providers.<name>.models` mapping / legacy
|
||||
# `models:` list): the agent's model matching ANY catalog entry counts.
|
||||
# Without this, a provider whose `model`/`default_model` differs from the
|
||||
# session model silently fails to match and per-provider request settings
|
||||
# (extra_body, e.g. OpenAI service_tier) are dropped — billing the whole
|
||||
# session at the wrong tier (July 2026 sweeper incident: flex config
|
||||
# ignored, ~2.3x overbilling).
|
||||
models = entry.get("models")
|
||||
catalog: List[str] = []
|
||||
if isinstance(models, dict):
|
||||
catalog = [str(k).strip().lower() for k in models.keys()]
|
||||
elif isinstance(models, (list, tuple)):
|
||||
catalog = [str(m).strip().lower() for m in models]
|
||||
if catalog and agent_model_norm in catalog:
|
||||
return True
|
||||
return provider_model == str(agent_model or "").strip().lower()
|
||||
provider_model = str(entry.get("model", "") or "").strip().lower()
|
||||
if not provider_model and not catalog:
|
||||
return True
|
||||
return provider_model == agent_model_norm
|
||||
|
||||
|
||||
def _custom_provider_extra_body_for_agent(
|
||||
|
|
@ -208,6 +318,7 @@ def init_agent(
|
|||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
reaction_callback: Optional[Callable[[str], None]] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
|
|
@ -317,13 +428,25 @@ def init_agent(
|
|||
agent.skip_context_files = skip_context_files
|
||||
agent.load_soul_identity = load_soul_identity
|
||||
agent.pass_session_id = pass_session_id
|
||||
agent._credential_pool = credential_pool
|
||||
agent.log_prefix_chars = log_prefix_chars
|
||||
agent.log_prefix = f"{log_prefix} " if log_prefix else ""
|
||||
# Store effective base URL for feature detection (prompt caching, reasoning, etc.)
|
||||
agent.base_url = base_url or ""
|
||||
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
|
||||
agent.provider = provider_name or ""
|
||||
if credential_pool is not None:
|
||||
try:
|
||||
from agent.credential_pool import credential_pool_matches_provider
|
||||
|
||||
if not credential_pool_matches_provider(
|
||||
credential_pool,
|
||||
agent.provider,
|
||||
base_url=agent.base_url,
|
||||
):
|
||||
credential_pool = None
|
||||
except Exception:
|
||||
credential_pool = None
|
||||
agent._credential_pool = credential_pool
|
||||
agent.acp_command = acp_command or command
|
||||
agent.acp_args = list(acp_args or args or [])
|
||||
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server"}:
|
||||
|
|
@ -441,6 +564,7 @@ def init_agent(
|
|||
agent.notice_callback = notice_callback
|
||||
agent.notice_clear_callback = notice_clear_callback
|
||||
agent.event_callback = event_callback
|
||||
agent.reaction_callback = reaction_callback
|
||||
agent.tool_gen_callback = tool_gen_callback
|
||||
|
||||
|
||||
|
|
@ -1409,14 +1533,14 @@ def init_agent(
|
|||
if not isinstance(_compression_cfg, dict):
|
||||
_compression_cfg = {}
|
||||
compression_threshold = float(_compression_cfg.get("threshold", 0.50))
|
||||
# Per-model/route compaction-threshold override. Codex gpt-5.5 raises to
|
||||
# 85% (the Codex backend caps the window at 272K, so the default 50% would
|
||||
# compact at ~136K — half the usable context). Gated by an opt-out config
|
||||
# flag so the user can fall back to the global threshold; when the override
|
||||
# fires we stash a one-time notification (replayed on the first turn) that
|
||||
# tells the user what changed and how to revert. The notice has its own
|
||||
# display gate so users can keep the threshold autoraise without getting
|
||||
# the banner on gateway turns.
|
||||
# Per-model/route compaction-threshold override. Codex gpt-5.4 / gpt-5.5
|
||||
# raise to 85% (the Codex backend caps both families at 272K, so the
|
||||
# default 50% would compact at ~136K — half the usable context). Gated by
|
||||
# an opt-out config flag so the user can fall back to the global threshold;
|
||||
# when the override fires we stash a one-time notification (replayed on the
|
||||
# first turn) that tells the user what changed and how to revert. The
|
||||
# notice has its own display gate so users can keep the threshold
|
||||
# autoraise without getting the banner on gateway turns.
|
||||
_codex_gpt55_autoraise = str(
|
||||
_compression_cfg.get("codex_gpt55_autoraise", True)
|
||||
).lower() in {"true", "1", "yes"}
|
||||
|
|
@ -1427,28 +1551,30 @@ def init_agent(
|
|||
try:
|
||||
from agent.auxiliary_client import (
|
||||
_compression_threshold_for_model as _cthresh_fn,
|
||||
_is_codex_gpt55 as _is_codex_gpt55_fn,
|
||||
_is_codex_gpt54_or_gpt55 as _is_codex_gpt54_or_gpt55_fn,
|
||||
_is_codex_spark as _is_codex_spark_fn,
|
||||
)
|
||||
_model_cthresh = _cthresh_fn(
|
||||
agent.model,
|
||||
agent.provider,
|
||||
allow_codex_gpt55_autoraise=_codex_gpt55_autoraise,
|
||||
)
|
||||
if _model_cthresh is not None:
|
||||
_prev_threshold = compression_threshold
|
||||
compression_threshold = _model_cthresh
|
||||
# Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity
|
||||
# override is a long-standing silent default). Skip the notice when
|
||||
# the user's global threshold already meets/exceeds the raised
|
||||
# value, since nothing actually changed for them.
|
||||
if (
|
||||
_is_codex_gpt55_fn(agent.model, agent.provider)
|
||||
and _model_cthresh > _prev_threshold + 1e-9
|
||||
):
|
||||
agent._compression_threshold_autoraised = {
|
||||
"from": _prev_threshold,
|
||||
"to": _model_cthresh,
|
||||
}
|
||||
# The Codex autoraises (gpt-5.4/5.5 272K family and gpt-5.3-codex-spark)
|
||||
# apply only when they RAISE (never lower a user's higher global
|
||||
# threshold). The notice is populated only when it actually fires, and
|
||||
# carries the model slug so the banner names the right family. Arcee
|
||||
# Trinity keeps its long-standing unconditional behaviour.
|
||||
compression_threshold, agent._compression_threshold_autoraised = (
|
||||
_resolve_compression_threshold(
|
||||
compression_threshold,
|
||||
_model_cthresh,
|
||||
model=agent.model,
|
||||
is_codex_autoraise=(
|
||||
_is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider)
|
||||
or _is_codex_spark_fn(agent.model, agent.provider)
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
|
||||
|
|
@ -1474,6 +1600,16 @@ def init_agent(
|
|||
compression_in_place = is_truthy_value(
|
||||
_compression_cfg.get("in_place"), default=False
|
||||
)
|
||||
codex_app_server_auto_compaction = str(
|
||||
_compression_cfg.get("codex_app_server_auto", "native") or "native"
|
||||
).lower()
|
||||
if codex_app_server_auto_compaction not in {"native", "hermes", "off"}:
|
||||
_ra().logger.warning(
|
||||
"Invalid compression.codex_app_server_auto=%r; using 'native'. "
|
||||
"Valid values are: native, hermes, off.",
|
||||
codex_app_server_auto_compaction,
|
||||
)
|
||||
codex_app_server_auto_compaction = "native"
|
||||
|
||||
# Read optional explicit context_length override for the auxiliary
|
||||
# compression model. Custom endpoints often cannot report this via
|
||||
|
|
@ -1677,6 +1813,12 @@ def init_agent(
|
|||
|
||||
if _selected_engine is not None:
|
||||
agent.context_compressor = _selected_engine
|
||||
# External engines own compaction policy: the host compression
|
||||
# threshold (including the Codex gpt-5.5 autoraise above) only
|
||||
# configures the built-in ContextCompressor and never reaches the
|
||||
# plugin, so the autoraise notice would announce a change that does
|
||||
# not apply. Drop it. (#44439)
|
||||
agent._compression_threshold_autoraised = None
|
||||
# Resolve context_length for plugin engines — mirrors switch_model() path
|
||||
from agent.model_metadata import get_model_context_length
|
||||
_plugin_ctx_len = get_model_context_length(
|
||||
|
|
@ -1722,6 +1864,7 @@ def init_agent(
|
|||
pass
|
||||
agent.compression_enabled = compression_enabled
|
||||
agent.compression_in_place = compression_in_place
|
||||
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
|
||||
|
||||
# Reject models whose context window is below the minimum required
|
||||
# for reliable tool-calling workflows (64K tokens).
|
||||
|
|
@ -1899,29 +2042,53 @@ def init_agent(
|
|||
agent._ollama_num_ctx,
|
||||
)
|
||||
|
||||
# Codex gpt-5.x autoraise notice: show at most once per profile/config
|
||||
# state. Without the persisted marker the notice re-fires on every agent
|
||||
# init — and the gateway rebuilds the agent per inbound message, so Discord
|
||||
# etc. saw it repeatedly (#54432). A change in the raised threshold (or the
|
||||
# autoraised model) updates the marker state and re-notifies once. The
|
||||
# config display gate (compression.codex_gpt55_autoraise_notice) still
|
||||
# suppresses the banner entirely without disabling the threshold autoraise.
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
|
||||
_show_autoraise_notice = (
|
||||
bool(_autoraise)
|
||||
and compression_enabled
|
||||
and _codex_gpt55_autoraise_notice
|
||||
and not _codex_gpt55_autoraise_notice_seen(_autoraise)
|
||||
)
|
||||
|
||||
if not agent.quiet_mode:
|
||||
if compression_enabled:
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})")
|
||||
# Report the active engine's own threshold — for a plugin engine
|
||||
# the host compression_threshold is not in effect, and mixing the
|
||||
# two printed a percent that contradicted the token count. (#44439)
|
||||
_active_threshold_pct = getattr(
|
||||
agent.context_compressor, "threshold_percent", compression_threshold
|
||||
)
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})")
|
||||
else:
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
|
||||
# One-time notice when the Codex gpt-5.5 autoraise kicked in, with the
|
||||
# exact opt-back-out command. Printed inline at startup for CLI users;
|
||||
# gateway users get the same text replayed via _compression_warning on
|
||||
# turn 1 (set below, after the warning slot is initialized).
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
|
||||
if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice:
|
||||
print(_build_codex_gpt55_autoraise_notice(_autoraise))
|
||||
# Notice with the exact opt-back-out command. Printed inline at startup
|
||||
# for CLI users; gateway users get the same text replayed via
|
||||
# _compression_warning on turn 1 (set below).
|
||||
if _show_autoraise_notice:
|
||||
print(_build_codex_gpt5_autoraise_notice(_autoraise))
|
||||
|
||||
# Check immediately so CLI users see the warning at startup.
|
||||
# Gateway status_callback is not yet wired, so any warning is stored
|
||||
# in _compression_warning and replayed in the first run_conversation().
|
||||
agent._compression_warning = None
|
||||
# Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print
|
||||
# Gateway parity for the Codex gpt-5.x autoraise notice: the startup print
|
||||
# above only reaches the CLI, so stash the same text here to be replayed
|
||||
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
|
||||
if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice:
|
||||
agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise)
|
||||
if _show_autoraise_notice:
|
||||
agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise)
|
||||
|
||||
# Mark shown so repeated inits in this profile (e.g. every gateway message)
|
||||
# stay silent. Recorded once, whether the notice went to the CLI print or
|
||||
# the gateway replay slot.
|
||||
if _show_autoraise_notice:
|
||||
_record_codex_gpt55_autoraise_notice(_autoraise)
|
||||
# Lazy feasibility check: deferred to the first turn that approaches the
|
||||
# compression threshold. Running it eagerly here costs ~400ms cold (network
|
||||
# probe of the auxiliary provider chain + /models lookup) on every agent
|
||||
|
|
|
|||
|
|
@ -729,7 +729,14 @@ def recover_with_credential_pool(
|
|||
# that seeded the pool.
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
|
||||
if current_provider and pool_provider and current_provider != pool_provider:
|
||||
# Guard: skip credential pool recovery when the pool is scoped to a
|
||||
# different provider than the agent. Only guard when the pool has a
|
||||
# known provider — an empty pool provider means "unscoped" (applies to
|
||||
# any provider). An empty agent provider is treated as a mismatch
|
||||
# because swapping the pool's credentials would set base_url/api_key
|
||||
# without fixing the empty provider field, leaving the agent in a
|
||||
# corrupted state (provider="" model="").
|
||||
if pool_provider and current_provider != pool_provider:
|
||||
# Custom endpoints use two naming conventions for the SAME provider:
|
||||
# the agent carries the generic ``custom`` label while the pool is
|
||||
# keyed ``custom:<name>`` (see CUSTOM_POOL_PREFIX). A literal string
|
||||
|
|
@ -1201,7 +1208,42 @@ def restore_primary_runtime(agent) -> bool:
|
|||
api_mode=rt.get("compressor_api_mode", ""),
|
||||
)
|
||||
|
||||
# ── Re-select from the credential pool if one is available ──
|
||||
# ── Rebind and re-select the primary credential pool ──
|
||||
# A cross-provider fallback attaches the fallback provider's pool. The
|
||||
# runtime fields above restore the primary, but leaving that pool in
|
||||
# place makes the next primary 401/429 hit the provider-mismatch guard
|
||||
# and disables credential rotation. Reload the primary pool first; if
|
||||
# auth storage is temporarily unreadable, clear the mismatched pool.
|
||||
primary_provider = str(rt.get("provider") or "").strip().lower()
|
||||
pool = getattr(agent, "_credential_pool", None)
|
||||
pool_provider = str(getattr(pool, "provider", "") or "").strip().lower()
|
||||
pool_matches_primary = pool_provider == primary_provider
|
||||
if (
|
||||
primary_provider == "custom"
|
||||
and pool_provider.startswith("custom:")
|
||||
):
|
||||
try:
|
||||
from agent.credential_pool import get_custom_provider_pool_key
|
||||
|
||||
primary_key = (
|
||||
get_custom_provider_pool_key(str(rt.get("base_url") or "")) or ""
|
||||
).strip().lower()
|
||||
pool_matches_primary = bool(primary_key) and primary_key == pool_provider
|
||||
except Exception:
|
||||
pool_matches_primary = False
|
||||
if pool is not None and pool_provider and not pool_matches_primary:
|
||||
agent._credential_pool = None
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
agent._credential_pool = load_pool(primary_provider)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Restore could not reload primary credential pool for %s: %s",
|
||||
primary_provider,
|
||||
exc,
|
||||
)
|
||||
|
||||
# The snapshot's api_key was captured at construction time. Across
|
||||
# turns the pool may have rotated (token revocation, billing/rate-limit
|
||||
# exhaustion, cooldown), leaving the snapshot key stale. Restoring it
|
||||
|
|
@ -1215,7 +1257,6 @@ def restore_primary_runtime(agent) -> bool:
|
|||
entry = pool.select()
|
||||
if entry is not None:
|
||||
entry_provider = str(getattr(entry, "provider", "") or "").strip().lower()
|
||||
primary_provider = str(rt.get("provider") or "").strip().lower()
|
||||
entry_matches_primary = entry_provider == primary_provider
|
||||
# Custom endpoints all carry the generic ``custom`` provider on
|
||||
# the agent while the pool entry is keyed ``custom:<name>`` (see
|
||||
|
|
@ -1268,6 +1309,12 @@ def restore_primary_runtime(agent) -> bool:
|
|||
agent._fallback_activated = False
|
||||
agent._fallback_index = 0
|
||||
|
||||
# Reset the stale-call circuit breaker (#58962): the streak measured
|
||||
# the FALLBACK provider we're leaving; the restored primary deserves
|
||||
# a fresh stream attempt before the breaker can trip again.
|
||||
from agent.chat_completion_helpers import _reset_stale_streak
|
||||
_reset_stale_streak(agent)
|
||||
|
||||
# Undo the fallback's identity rewrite so the prompt is
|
||||
# byte-identical to the stored copy again (prefix cache match).
|
||||
from agent.chat_completion_helpers import rewrite_prompt_model_identity
|
||||
|
|
@ -1551,6 +1598,17 @@ def anthropic_prompt_cache_policy(
|
|||
model_lower = eff_model.lower()
|
||||
provider_lower = eff_provider.lower()
|
||||
is_claude = "claude" in model_lower
|
||||
# Kimi / Moonshot family via OpenRouter: same cache_control wire format
|
||||
# as Claude on OpenRouter (envelope layout). Without this branch
|
||||
# moonshotai/kimi-k2.6 falls through to (False, False), serving ~1%
|
||||
# cache hits on 64K-token prompts and re-billing the full prompt on
|
||||
# every turn. Observed within-turn progression with cache enabled:
|
||||
# 1% → 67% → 84% → 97% (#25970). Reuses the canonical family matcher
|
||||
# (covers bare k1./k2./k25 release slugs the substring check missed).
|
||||
from agent.anthropic_adapter import _model_name_is_kimi_family
|
||||
is_kimi = (
|
||||
_model_name_is_kimi_family(eff_model) or "moonshot" in model_lower
|
||||
)
|
||||
is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai")
|
||||
# Nous Portal proxies to OpenRouter behind the scenes — identical
|
||||
# OpenAI-wire envelope cache_control semantics. Treat it as an
|
||||
|
|
@ -1564,7 +1622,7 @@ def anthropic_prompt_cache_policy(
|
|||
|
||||
if is_native_anthropic:
|
||||
return True, True
|
||||
if (is_openrouter or is_nous_portal) and is_claude:
|
||||
if (is_openrouter or is_nous_portal) and (is_claude or is_kimi):
|
||||
return True, False
|
||||
# Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout
|
||||
# cache_control path as Portal Claude. Portal proxies to OpenRouter
|
||||
|
|
@ -1792,13 +1850,30 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
|||
# ── Swap core runtime fields ──
|
||||
agent.model = new_model
|
||||
agent.provider = new_provider
|
||||
# Use new base_url when provided; only fall back to current when the
|
||||
# new provider genuinely has no endpoint (e.g. native SDK providers).
|
||||
# Without this guard the old provider's URL (e.g. Ollama's localhost
|
||||
# address) would persist silently after switching to a cloud provider
|
||||
# that returns an empty base_url string.
|
||||
# Use the new base_url when provided. When it's empty AND the
|
||||
# provider is actually changing, do NOT fall back to the current
|
||||
# (old provider's) URL — that silently pairs the new provider label
|
||||
# with the previous provider's endpoint (e.g. new_provider=minimax
|
||||
# paired with the leftover api.githubcopilot.com URL), and every
|
||||
# request after the switch 400s at the wrong host. This mismatched
|
||||
# pair also gets snapshotted into _primary_runtime below, so it
|
||||
# keeps re-applying on every subsequent turn until a full restart.
|
||||
# Fail loud instead: the caller (model_switch.switch_model())
|
||||
# already resolves base_url for every real provider, so an empty
|
||||
# value here means resolution failed upstream, not that the
|
||||
# provider genuinely has none. Re-selecting the SAME provider with
|
||||
# an empty base_url (e.g. a credential-only refresh) is still fine
|
||||
# to keep the current URL. See #47828.
|
||||
old_norm_provider = (old_provider or "").strip().lower()
|
||||
new_norm_provider = (new_provider or "").strip().lower()
|
||||
if base_url:
|
||||
agent.base_url = base_url
|
||||
elif old_norm_provider != new_norm_provider:
|
||||
raise ValueError(
|
||||
f"switch_model: no base_url resolved for provider "
|
||||
f"'{new_provider}' (switching from '{old_provider}'); "
|
||||
"refusing to keep the previous provider's endpoint"
|
||||
)
|
||||
agent.api_mode = api_mode
|
||||
# Invalidate transport cache — new api_mode may need a different transport
|
||||
if hasattr(agent, "_transport_cache"):
|
||||
|
|
@ -1817,6 +1892,9 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
|||
old_norm = (old_provider or "").strip().lower()
|
||||
new_norm = (new_provider or "").strip().lower()
|
||||
if old_norm != new_norm or getattr(agent, "_credential_pool", None) is None:
|
||||
# A pool bound to the old provider is worse than no pool: the
|
||||
# recovery guard rejects it and every later 401/429 skips rotation.
|
||||
agent._credential_pool = None
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
agent._credential_pool = load_pool(new_provider)
|
||||
|
|
@ -1912,6 +1990,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
|||
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
|
||||
if _sm_timeout is not None:
|
||||
agent._client_kwargs["timeout"] = _sm_timeout
|
||||
# Reapply provider-specific headers (e.g. OpenRouter HTTP-Referer,
|
||||
# X-Title) that were lost when _client_kwargs was rebuilt from
|
||||
# scratch. Without this, model switches clear attribution headers
|
||||
# and OpenRouter logs show "Unknown" for subsequent requests.
|
||||
agent._apply_client_headers_for_base_url(effective_base)
|
||||
agent.client = agent._create_openai_client(
|
||||
dict(agent._client_kwargs),
|
||||
reason="switch_model",
|
||||
|
|
@ -1985,6 +2068,14 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
|||
# ── Invalidate cached system prompt so it rebuilds next turn ──
|
||||
agent._cached_system_prompt = None
|
||||
|
||||
# ── Reset the cross-turn stale-call circuit breaker (#58962) ──
|
||||
# The breaker's error text tells the user to "switch models ... then
|
||||
# retry"; without this reset the streak stays latched and the freshly
|
||||
# selected (healthy) provider would keep short-circuiting before any
|
||||
# stream is even attempted.
|
||||
from agent.chat_completion_helpers import _reset_stale_streak
|
||||
_reset_stale_streak(agent)
|
||||
|
||||
# ── Update _primary_runtime so the change persists across turns ──
|
||||
_cc = agent.context_compressor if hasattr(agent, "context_compressor") and agent.context_compressor else None
|
||||
agent._primary_runtime = {
|
||||
|
|
@ -2094,12 +2185,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||
except Exception as _mw_err:
|
||||
logger.debug("tool_request middleware error: %s", _mw_err)
|
||||
|
||||
# Check plugin hooks for a block directive before executing anything.
|
||||
# Check plugin hooks for a block or approval directive before executing.
|
||||
block_message: Optional[str] = None
|
||||
if not pre_tool_block_checked:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
block_message = resolve_pre_tool_block(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
|
|
@ -2110,7 +2201,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||
middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
block_message = None
|
||||
if block_message is not None:
|
||||
result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ THINKING_BUDGET = {"xhigh": 32000, "high": 16000, "medium": 8000, "low": 4000}
|
|||
# maps to low on every model. See:
|
||||
# https://platform.claude.com/docs/en/about-claude/models/migration-guide
|
||||
ADAPTIVE_EFFORT_MAP = {
|
||||
"ultra": "max",
|
||||
"max": "max",
|
||||
"xhigh": "xhigh",
|
||||
"high": "high",
|
||||
|
|
@ -1390,7 +1391,8 @@ _OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
|
|||
_OAUTH_TOKEN_USER_AGENT = "axios/1.7.9"
|
||||
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
||||
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
|
||||
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
|
||||
def _get_hermes_oauth_file() -> Path:
|
||||
return get_hermes_home() / ".anthropic_oauth.json"
|
||||
|
||||
|
||||
def _generate_pkce() -> tuple:
|
||||
|
|
@ -1538,9 +1540,10 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
|||
|
||||
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
|
||||
if _HERMES_OAUTH_FILE.exists():
|
||||
oauth_file = _get_hermes_oauth_file()
|
||||
if oauth_file.exists():
|
||||
try:
|
||||
data = json.loads(_HERMES_OAUTH_FILE.read_text(encoding="utf-8"))
|
||||
data = json.loads(oauth_file.read_text(encoding="utf-8"))
|
||||
if data.get("accessToken"):
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
|
|
@ -2100,7 +2103,7 @@ def _convert_user_message(content: Any) -> Dict[str, Any]:
|
|||
if isinstance(content, list):
|
||||
converted_blocks = _convert_content_to_anthropic(content)
|
||||
if not converted_blocks or all(
|
||||
b.get("text", "").strip() == ""
|
||||
(b.get("text") or "").strip() == ""
|
||||
for b in converted_blocks
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
):
|
||||
|
|
|
|||
|
|
@ -314,33 +314,72 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
|
|||
return bare == "trinity-large-thinking"
|
||||
|
||||
|
||||
# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.5.
|
||||
# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the
|
||||
# Codex backend hard-caps at 272K (verified live: a ~330K-token request to
|
||||
# Context window enforced by ChatGPT's Codex OAuth backend for the
|
||||
# gpt-5.4 / gpt-5.5 / gpt-5.6 families. The raw OpenAI API and OpenRouter
|
||||
# expose 1.05M for the same slugs, but the Codex backend hard-caps at 272K
|
||||
# (verified live for 5.4/5.5: a ~330K-token request to
|
||||
# chatgpt.com/backend-api/codex/responses is rejected with
|
||||
# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the
|
||||
# default 50% compaction trigger fires at ~136K — wasteful, since the model
|
||||
# can hold far more raw context before summarization actually buys anything.
|
||||
# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.5
|
||||
# sessions use the window they actually have.
|
||||
_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85
|
||||
# ``context_length_exceeded`` while ~250K succeeds; gpt-5.6 shares the same
|
||||
# 272K Codex cap — see _CODEX_OAUTH_CONTEXT_FALLBACK in model_metadata.py).
|
||||
# With a 272K ceiling the default 50% compaction trigger fires at ~136K —
|
||||
# wasteful, since the model can hold far more raw context before
|
||||
# summarization actually buys anything. We raise the trigger to 85% (~231K)
|
||||
# on this exact route so Codex gpt-5.4 / gpt-5.5 / gpt-5.6 sessions use the
|
||||
# window they actually have.
|
||||
_CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85
|
||||
|
||||
# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a
|
||||
# native 128K context window. The default 50% compaction trigger fires at
|
||||
# ~64K — wasting half the usable window, often before the session has enough
|
||||
# turns to summarize meaningfully. We raise the trigger to 70% (~90K) so
|
||||
# spark sessions use more of the window before summarization, while still
|
||||
# leaving ~38K headroom for the summary and continued conversation before
|
||||
# the 128K hard limit.
|
||||
_CODEX_SPARK_COMPACTION_THRESHOLD = 0.70
|
||||
|
||||
|
||||
def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
|
||||
"""True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend.
|
||||
def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
|
||||
"""True for gpt-5.4 / gpt-5.5 / gpt-5.6 on the ChatGPT Codex OAuth backend.
|
||||
|
||||
Matches only the Codex OAuth route (provider ``openai-codex``), not the
|
||||
direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a
|
||||
larger context window for the same slug and must keep the user's default
|
||||
compaction threshold. ``gpt-5.5-pro`` and dated snapshots
|
||||
(``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the
|
||||
family without re-listing every variant.
|
||||
compaction threshold. ``-pro`` variants and dated snapshots are matched
|
||||
via prefix so the override tracks every 272K-capped family (5.4, 5.5,
|
||||
5.6 sol/terra/luna incl. their ``-pro`` modes) without re-listing every
|
||||
variant. (Name kept for backward compatibility with the
|
||||
``compression.codex_gpt55_autoraise`` config key.)
|
||||
"""
|
||||
prov = (provider or "").strip().lower()
|
||||
if prov != "openai-codex":
|
||||
return False
|
||||
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
|
||||
return bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.")
|
||||
return (
|
||||
bare == "gpt-5.4"
|
||||
or bare.startswith("gpt-5.4-")
|
||||
or bare.startswith("gpt-5.4.")
|
||||
or bare == "gpt-5.5"
|
||||
or bare.startswith("gpt-5.5-")
|
||||
or bare.startswith("gpt-5.5.")
|
||||
or bare == "gpt-5.6"
|
||||
or bare.startswith("gpt-5.6-")
|
||||
or bare.startswith("gpt-5.6.")
|
||||
)
|
||||
|
||||
|
||||
def _is_codex_spark(model: Optional[str], provider: Optional[str] = None) -> bool:
|
||||
"""True for ``gpt-5.3-codex-spark`` on the ChatGPT Codex OAuth backend.
|
||||
|
||||
The model is Codex-OAuth-only (ChatGPT Pro entitlement) with a native
|
||||
128K context window. Only the Codex OAuth route (provider
|
||||
``openai-codex``) is matched — the slug is not available on other
|
||||
routes.
|
||||
"""
|
||||
prov = (provider or "").strip().lower()
|
||||
if prov != "openai-codex":
|
||||
return False
|
||||
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
|
||||
return bare == "gpt-5.3-codex-spark"
|
||||
|
||||
|
||||
def _fixed_temperature_for_model(
|
||||
|
|
@ -379,18 +418,27 @@ def _compression_threshold_for_model(
|
|||
|
||||
Per-model/route overrides:
|
||||
- Arcee Trinity Large Thinking → 0.75 (preserve reasoning context).
|
||||
- gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps the window
|
||||
at 272K and the default 50% trigger would compact at ~136K. Gated by
|
||||
``allow_codex_gpt55_autoraise`` so the user can opt back down to the
|
||||
global default (the caller passes the config flag through here).
|
||||
- gpt-5.4 / gpt-5.5 / gpt-5.6 on the Codex OAuth route → 0.85, because
|
||||
Codex caps all three families at 272K and the default 50% trigger
|
||||
would compact at ~136K. Gated by ``allow_codex_gpt55_autoraise``
|
||||
(historical config-key name kept for backward compatibility) so the
|
||||
user can opt back down to the global default (the caller passes the
|
||||
config flag through here).
|
||||
- gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model
|
||||
has a native 128K window and the default 50% trigger would compact at
|
||||
~64K — wasting half the usable context. Not gated by the gpt-5.5
|
||||
opt-out flag: 128K is the model's native window, so the raise is
|
||||
unambiguously correct.
|
||||
|
||||
Returns a float in (0, 1] to override the global ``compression.threshold``
|
||||
config value, or ``None`` to leave the user's config value unchanged.
|
||||
"""
|
||||
if _is_arcee_trinity_thinking(model):
|
||||
return 0.75
|
||||
if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider):
|
||||
return _CODEX_GPT55_COMPACTION_THRESHOLD
|
||||
if allow_codex_gpt55_autoraise and _is_codex_gpt54_or_gpt55(model, provider):
|
||||
return _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD
|
||||
if _is_codex_spark(model, provider):
|
||||
return _CODEX_SPARK_COMPACTION_THRESHOLD
|
||||
return None
|
||||
|
||||
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
|
||||
|
|
@ -824,6 +872,7 @@ class _CodexCompletionsAdapter:
|
|||
# `function_call_output` items with a valid call_id, so every
|
||||
# Responses path normalizes tool history identically and cannot drift.
|
||||
from agent.codex_responses_adapter import _chat_messages_to_responses_input
|
||||
from utils import base_url_host_matches
|
||||
|
||||
instructions = "You are a helpful assistant."
|
||||
replay_messages: List[Dict[str, Any]] = []
|
||||
|
|
@ -835,7 +884,18 @@ class _CodexCompletionsAdapter:
|
|||
else:
|
||||
replay_messages.append(msg)
|
||||
|
||||
input_items = _chat_messages_to_responses_input(replay_messages)
|
||||
# Copilot (githubcopilot.com) binds replayed codex_message_items ids
|
||||
# to a backend "connection" that doesn't survive credential
|
||||
# rotation/gateway restarts — replaying one gets HTTP 401 "input
|
||||
# item ID does not belong to this connection" (#32716). Auxiliary
|
||||
# calls (context compression, flush_memories, MoA aggregation) go
|
||||
# through this adapter instead of agent/transports/codex.py's
|
||||
# build_kwargs, so they need the same guard applied independently.
|
||||
_host_for_input = str(getattr(self._client, "base_url", "") or "")
|
||||
_is_github_for_input = base_url_host_matches(_host_for_input, "githubcopilot.com")
|
||||
input_items = _chat_messages_to_responses_input(
|
||||
replay_messages, is_github_responses=_is_github_for_input,
|
||||
)
|
||||
|
||||
resp_kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
|
|
@ -1317,6 +1377,96 @@ class AsyncAnthropicAuxiliaryClient:
|
|||
self._real_client = sync_wrapper._real_client
|
||||
|
||||
|
||||
class _BedrockCompletionsAdapter:
|
||||
"""Translates ``chat.completions.create(**kwargs)`` into Bedrock Converse."""
|
||||
|
||||
def __init__(self, region: str, model: str):
|
||||
self._region = region
|
||||
self._model = model
|
||||
|
||||
def create(self, **kwargs) -> Any:
|
||||
from agent.bedrock_adapter import call_converse
|
||||
|
||||
messages = kwargs.get("messages", [])
|
||||
model = kwargs.get("model", self._model)
|
||||
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens")
|
||||
# OpenAI accepts ``stop`` as str or list; Converse requires a list.
|
||||
stop = kwargs.get("stop")
|
||||
if isinstance(stop, str):
|
||||
stop = [stop]
|
||||
if kwargs.get("tool_choice") is not None:
|
||||
# Converse's toolChoice isn't wired through call_converse();
|
||||
# no in-tree auxiliary caller passes tool_choice today. Surface
|
||||
# the drop instead of silently ignoring it.
|
||||
logger.debug(
|
||||
"BedrockAuxiliaryClient: tool_choice=%r not supported by the "
|
||||
"Converse shim — ignored.", kwargs.get("tool_choice"),
|
||||
)
|
||||
if kwargs.get("stream"):
|
||||
# Converse streaming isn't wired through this shim. Return a
|
||||
# complete response instead — call_llm's streaming consumer
|
||||
# detects a final object and downgrades to non-live output.
|
||||
logger.debug(
|
||||
"BedrockAuxiliaryClient: stream=True requested for %s — "
|
||||
"returning a complete response (Converse shim does not "
|
||||
"stream); caller downgrades to non-streaming.",
|
||||
model,
|
||||
)
|
||||
return call_converse(
|
||||
region=self._region,
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=kwargs.get("tools"),
|
||||
max_tokens=int(max_tokens) if max_tokens else 4096,
|
||||
temperature=kwargs.get("temperature"),
|
||||
top_p=kwargs.get("top_p"),
|
||||
stop_sequences=stop,
|
||||
)
|
||||
|
||||
|
||||
class _BedrockChatShim:
|
||||
def __init__(self, adapter: "_BedrockCompletionsAdapter"):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class BedrockAuxiliaryClient:
|
||||
"""OpenAI-client-compatible wrapper over AWS Bedrock Converse API."""
|
||||
|
||||
def __init__(self, region: str, model: str):
|
||||
self._region = region
|
||||
self._model = model
|
||||
adapter = _BedrockCompletionsAdapter(region, model)
|
||||
self.chat = _BedrockChatShim(adapter)
|
||||
self.api_key = "aws-sdk"
|
||||
self.base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _AsyncBedrockCompletionsAdapter:
|
||||
def __init__(self, sync_adapter: _BedrockCompletionsAdapter):
|
||||
self._sync = sync_adapter
|
||||
|
||||
async def create(self, **kwargs) -> Any:
|
||||
import asyncio
|
||||
return await asyncio.to_thread(self._sync.create, **kwargs)
|
||||
|
||||
|
||||
class _AsyncBedrockChatShim:
|
||||
def __init__(self, adapter: _AsyncBedrockCompletionsAdapter):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class AsyncBedrockAuxiliaryClient:
|
||||
def __init__(self, sync_wrapper: "BedrockAuxiliaryClient"):
|
||||
sync_adapter = sync_wrapper.chat.completions
|
||||
async_adapter = _AsyncBedrockCompletionsAdapter(sync_adapter)
|
||||
self.chat = _AsyncBedrockChatShim(async_adapter)
|
||||
self.api_key = sync_wrapper.api_key
|
||||
self.base_url = sync_wrapper.base_url
|
||||
|
||||
|
||||
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
|
||||
protocol instead of OpenAI chat.completions.
|
||||
|
|
@ -1372,6 +1522,8 @@ def _maybe_wrap_anthropic(
|
|||
# Already wrapped — don't double-wrap.
|
||||
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
|
||||
return client_obj
|
||||
if _safe_isinstance(client_obj, BedrockAuxiliaryClient):
|
||||
return client_obj
|
||||
# Other specialized adapters we should never re-dispatch.
|
||||
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
|
||||
return client_obj
|
||||
|
|
@ -3349,6 +3501,21 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
|||
"""Refresh short-lived credentials for OAuth-backed auxiliary providers."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
try:
|
||||
if normalized == "copilot":
|
||||
from hermes_cli.copilot_auth import (
|
||||
_jwt_cache,
|
||||
_token_fingerprint,
|
||||
exchange_copilot_token,
|
||||
resolve_copilot_token,
|
||||
)
|
||||
|
||||
raw_token, _source = resolve_copilot_token()
|
||||
if not str(raw_token or "").strip():
|
||||
return False
|
||||
_jwt_cache.pop(_token_fingerprint(raw_token), None)
|
||||
exchange_copilot_token(raw_token)
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
if normalized == "openai-codex":
|
||||
from hermes_cli.auth import resolve_codex_runtime_credentials
|
||||
|
||||
|
|
@ -3403,6 +3570,152 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _auth_refresh_provider_for_route(
|
||||
resolved_provider: Optional[str],
|
||||
client_base_url: str,
|
||||
) -> str:
|
||||
"""Return the provider whose short-lived credentials should be refreshed.
|
||||
|
||||
Auto-routed auxiliary calls keep ``resolved_provider == "auto"`` even
|
||||
after _get_cached_client() selects a concrete backend. Infer the backend
|
||||
from the selected client's base URL so auth refresh works for auto →
|
||||
Copilot/Codex/Anthropic/Nous routes too. (#20832)
|
||||
"""
|
||||
normalized = _normalize_aux_provider(resolved_provider)
|
||||
if normalized and normalized != "auto":
|
||||
return normalized
|
||||
if base_url_host_matches(client_base_url, "api.githubcopilot.com"):
|
||||
return "copilot"
|
||||
if base_url_host_matches(client_base_url, "chatgpt.com"):
|
||||
return "openai-codex"
|
||||
if base_url_host_matches(client_base_url, "api.anthropic.com"):
|
||||
return "anthropic"
|
||||
if base_url_host_matches(client_base_url, "inference-api.nousresearch.com"):
|
||||
return "nous"
|
||||
return normalized
|
||||
|
||||
|
||||
def _call_fallback_candidate_sync(
|
||||
fb_client: Any,
|
||||
fb_model: Optional[str],
|
||||
fb_label: str,
|
||||
*,
|
||||
task: Optional[str],
|
||||
messages: list,
|
||||
temperature: Optional[float],
|
||||
max_tokens: Optional[int],
|
||||
tools: Optional[list],
|
||||
effective_timeout: float,
|
||||
effective_extra_body: dict,
|
||||
) -> Optional[Any]:
|
||||
"""Call one fallback candidate with stale-credential recovery.
|
||||
|
||||
A fallback candidate can itself carry a stale credential (e.g. an expired
|
||||
``ANTHROPIC_TOKEN`` picked up by ``_try_anthropic``). Before this helper,
|
||||
such a 401 propagated out of the fallback site and aborted the auxiliary
|
||||
task (for compression: a 60s cooldown + context marker) even though other
|
||||
healthy candidates remained. Live case: a Codex-timeout → Anthropic
|
||||
fallback 401-looped five times in one session (mattalachia debug dump,
|
||||
Jul 2026).
|
||||
|
||||
On an auth error: refresh the candidate's provider credentials and retry
|
||||
once with a rebuilt client; if the retry also auth-fails (non-refreshable
|
||||
expired token), mark the provider unhealthy and return ``None`` so the
|
||||
caller can continue to the next fallback layer. Non-auth errors raise.
|
||||
"""
|
||||
fb_base = str(getattr(fb_client, "base_url", "") or "")
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body, base_url=fb_base)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
except Exception as fb_err:
|
||||
if not _is_auth_error(fb_err):
|
||||
raise
|
||||
fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)
|
||||
if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider):
|
||||
retry_client, retry_model = _get_cached_client(fb_provider, fb_model)
|
||||
if retry_client is not None:
|
||||
retry_kwargs = _build_call_kwargs(
|
||||
fb_provider, retry_model or fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
except Exception as retry_err:
|
||||
if not _is_auth_error(retry_err):
|
||||
raise
|
||||
# Refresh unavailable or the refreshed credential still 401s —
|
||||
# the token is dead (expired setup token with no refresh token).
|
||||
# Quarantine the candidate so subsequent chain walks skip it, and
|
||||
# let the caller move on instead of aborting the whole task.
|
||||
_mark_provider_unhealthy(fb_provider or fb_label)
|
||||
logger.warning(
|
||||
"Auxiliary %s: fallback candidate %s has a stale/unrefreshable "
|
||||
"credential (%s) — skipping to next fallback",
|
||||
task or "call", fb_label, fb_err,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _call_fallback_candidate_async(
|
||||
fb_client: Any,
|
||||
fb_model: Optional[str],
|
||||
fb_label: str,
|
||||
*,
|
||||
task: Optional[str],
|
||||
messages: list,
|
||||
temperature: Optional[float],
|
||||
max_tokens: Optional[int],
|
||||
tools: Optional[list],
|
||||
effective_timeout: float,
|
||||
effective_extra_body: dict,
|
||||
) -> Optional[Any]:
|
||||
"""Async mirror of :func:`_call_fallback_candidate_sync`."""
|
||||
fb_base = str(getattr(fb_client, "base_url", "") or "")
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body, base_url=fb_base)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
except Exception as fb_err:
|
||||
if not _is_auth_error(fb_err):
|
||||
raise
|
||||
fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)
|
||||
if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider):
|
||||
retry_client, retry_model = _get_cached_client(
|
||||
fb_provider, fb_model, async_mode=True)
|
||||
if retry_client is not None:
|
||||
retry_kwargs = _build_call_kwargs(
|
||||
fb_provider, retry_model or fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
except Exception as retry_err:
|
||||
if not _is_auth_error(retry_err):
|
||||
raise
|
||||
_mark_provider_unhealthy(fb_provider or fb_label)
|
||||
logger.warning(
|
||||
"Auxiliary %s (async): fallback candidate %s has a stale/unrefreshable "
|
||||
"credential (%s) — skipping to next fallback",
|
||||
task or "call", fb_label, fb_err,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _try_payment_fallback(
|
||||
failed_provider: str,
|
||||
task: str = None,
|
||||
|
|
@ -4004,6 +4317,8 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
|||
return AsyncCodexAuxiliaryClient(sync_client), model
|
||||
if isinstance(sync_client, AnthropicAuxiliaryClient):
|
||||
return AsyncAnthropicAuxiliaryClient(sync_client), model
|
||||
if isinstance(sync_client, BedrockAuxiliaryClient):
|
||||
return AsyncBedrockAuxiliaryClient(sync_client), model
|
||||
try:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
|
||||
|
||||
|
|
@ -4151,7 +4466,15 @@ def resolve_provider_client(
|
|||
# main_model also empty), the branches still hit their own
|
||||
# missing-credentials returns and ``_resolve_auto`` falls through to
|
||||
# the Step-2 chain as before.
|
||||
if not model:
|
||||
#
|
||||
# Prefer explicit caller model, then provider-scoped aux model, then main model.
|
||||
# Do NOT pre-fill a blank ``auto`` request from the config/main default here.
|
||||
# ``auto`` has its own main-runtime resolver below; pre-filling first can pair
|
||||
# a stale configured model with a live fallback provider (e.g. Claude model
|
||||
# sent to Codex after the main lane fell back to gpt-5.5). Let _resolve_auto()
|
||||
# return the actual current runtime model when the caller did not explicitly
|
||||
# request one. (# compression-current-model)
|
||||
if not model and provider != "auto":
|
||||
model = _get_aux_model_for_provider(provider) or _read_main_model() or model
|
||||
|
||||
def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
|
||||
|
|
@ -4405,8 +4728,8 @@ def resolve_provider_client(
|
|||
if custom_entry is None:
|
||||
custom_entry = _get_named_custom_provider(provider)
|
||||
if custom_entry:
|
||||
custom_base = custom_entry.get("base_url", "").strip()
|
||||
custom_key = custom_entry.get("api_key", "").strip()
|
||||
custom_base = (custom_entry.get("base_url") or "").strip()
|
||||
custom_key = (custom_entry.get("api_key") or "").strip()
|
||||
custom_key_env = (custom_entry.get("key_env") or custom_entry.get("api_key_env") or "").strip()
|
||||
if not custom_key and custom_key_env:
|
||||
custom_key = os.getenv(custom_key_env, "").strip()
|
||||
|
|
@ -4735,10 +5058,14 @@ def resolve_provider_client(
|
|||
else (client, final_model))
|
||||
|
||||
elif pconfig.auth_type == "aws_sdk":
|
||||
# AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
|
||||
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
|
||||
# AWS SDK providers (Bedrock) — Claude models use the Anthropic Bedrock
|
||||
# SDK (prompt caching, thinking); non-Claude models use Converse API.
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
is_anthropic_bedrock_model,
|
||||
resolve_bedrock_region,
|
||||
)
|
||||
from agent.anthropic_adapter import build_anthropic_bedrock_client
|
||||
except ImportError:
|
||||
logger.warning("resolve_provider_client: bedrock requested but "
|
||||
|
|
@ -4753,17 +5080,26 @@ def resolve_provider_client(
|
|||
region = resolve_bedrock_region()
|
||||
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
try:
|
||||
real_client = build_anthropic_bedrock_client(region)
|
||||
except ImportError as exc:
|
||||
logger.warning("resolve_provider_client: cannot create Bedrock "
|
||||
"client: %s", exc)
|
||||
return None, None
|
||||
client = AnthropicAuxiliaryClient(
|
||||
real_client, final_model, api_key="aws-sdk",
|
||||
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
|
||||
)
|
||||
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
|
||||
base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
|
||||
|
||||
if is_anthropic_bedrock_model(final_model):
|
||||
try:
|
||||
real_client = build_anthropic_bedrock_client(region)
|
||||
except ImportError as exc:
|
||||
logger.warning("resolve_provider_client: cannot create Bedrock "
|
||||
"client: %s", exc)
|
||||
return None, None
|
||||
client = AnthropicAuxiliaryClient(
|
||||
real_client, final_model, api_key="aws-sdk",
|
||||
base_url=base_url,
|
||||
)
|
||||
logger.debug("resolve_provider_client: bedrock anthropic (%s, %s)",
|
||||
final_model, region)
|
||||
else:
|
||||
client = BedrockAuxiliaryClient(region, final_model)
|
||||
logger.debug("resolve_provider_client: bedrock converse (%s, %s)",
|
||||
final_model, region)
|
||||
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
|
|
@ -5657,6 +5993,17 @@ def _resolve_task_provider_model(
|
|||
|
||||
_DEFAULT_AUX_TIMEOUT = 30.0
|
||||
|
||||
# Compression summarises large conversation histories; a reasoning auxiliary
|
||||
# model (e.g. Codex / GPT-5.5) can legitimately take longer than the default
|
||||
# ``auxiliary.compression.timeout`` (120 s), causing the stream to time out and
|
||||
# the compressor to fall back to the deterministic context marker (#54915).
|
||||
# This is a bounded *floor* applied only to config-derived compression timeouts
|
||||
# — it does not affect other auxiliary tasks and does not override an explicit
|
||||
# per-call ``timeout=``. A floor is harmless for fast compression models
|
||||
# (they finish before the deadline) and is a minimum, so a higher config value
|
||||
# is kept unchanged.
|
||||
_COMPRESSION_TIMEOUT_FLOOR_SECONDS = 300.0
|
||||
|
||||
|
||||
def _get_auxiliary_task_config(task: str) -> Dict[str, Any]:
|
||||
"""Return the config dict for auxiliary.<task>, or {} when unavailable.
|
||||
|
|
@ -5716,6 +6063,23 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float
|
|||
return default
|
||||
|
||||
|
||||
def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float:
|
||||
"""Resolve the effective timeout for an auxiliary LLM call.
|
||||
|
||||
Uses the caller-provided ``timeout`` when given; otherwise reads
|
||||
``auxiliary.{task}.timeout`` from config via :func:`_get_task_timeout`.
|
||||
For the ``compression`` task only, applies a bounded floor so a reasoning
|
||||
model summarising a large context is not cut off by the default timeout
|
||||
(#54915). The floor is intentionally skipped when the caller passes an
|
||||
explicit ``timeout=`` — explicit per-call deadlines are always honoured —
|
||||
and it is a minimum (``max``), so a config value already above it is kept.
|
||||
"""
|
||||
effective = timeout if timeout is not None else _get_task_timeout(task)
|
||||
if timeout is None and task == "compression":
|
||||
effective = max(effective, _COMPRESSION_TIMEOUT_FLOOR_SECONDS)
|
||||
return effective
|
||||
|
||||
|
||||
def _get_task_extra_body(task: str) -> Dict[str, Any]:
|
||||
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid."""
|
||||
task_config = _get_auxiliary_task_config(task)
|
||||
|
|
@ -6150,7 +6514,7 @@ def call_llm(
|
|||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
f"Run: hermes setup")
|
||||
|
||||
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
|
||||
effective_timeout = _effective_aux_timeout(task, timeout)
|
||||
|
||||
# Log what we're about to do — makes auxiliary operations visible
|
||||
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
|
||||
|
|
@ -6389,18 +6753,24 @@ def call_llm(
|
|||
refreshed_client.chat.completions.create(**kwargs), task)
|
||||
|
||||
# ── Auth refresh retry ───────────────────────────────────────
|
||||
auth_refresh_provider = _auth_refresh_provider_for_route(
|
||||
resolved_provider, _base_info)
|
||||
if (_is_auth_error(first_err)
|
||||
and resolved_provider not in {"auto", "", None}
|
||||
and auth_refresh_provider not in {"auto", "", None}
|
||||
and not client_is_nous):
|
||||
if _refresh_provider_credentials(resolved_provider):
|
||||
if _refresh_provider_credentials(auth_refresh_provider):
|
||||
if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
|
||||
# The stale client is cached under the route label
|
||||
# (e.g. "auto"), not the concrete backend we refreshed.
|
||||
_evict_cached_clients(resolved_provider)
|
||||
logger.info(
|
||||
"Auxiliary %s: refreshed %s credentials after auth error, retrying",
|
||||
task or "call", resolved_provider,
|
||||
task or "call", auth_refresh_provider,
|
||||
)
|
||||
return _retry_same_provider_sync(
|
||||
task=task,
|
||||
resolved_provider=resolved_provider,
|
||||
resolved_model=resolved_model,
|
||||
resolved_provider=auth_refresh_provider,
|
||||
resolved_model=resolved_model or final_model,
|
||||
resolved_base_url=resolved_base_url,
|
||||
resolved_api_key=resolved_api_key,
|
||||
resolved_api_mode=resolved_api_mode,
|
||||
|
|
@ -6570,14 +6940,28 @@ def call_llm(
|
|||
resolved_provider, task, reason=reason)
|
||||
|
||||
if fb_client is not None:
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
fb_resp = _call_fallback_candidate_sync(
|
||||
fb_client, fb_model, fb_label,
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=str(getattr(fb_client, "base_url", "") or ""))
|
||||
return _validate_llm_response(
|
||||
fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# The candidate had a stale/unrefreshable credential and was
|
||||
# quarantined — walk the discovery chain once more; unhealthy
|
||||
# entries are skipped so the next viable candidate serves.
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason="stale fallback credential")
|
||||
if fb_client is not None:
|
||||
fb_resp = _call_fallback_candidate_sync(
|
||||
fb_client, fb_model, fb_label,
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# All fallback layers exhausted — emit a single user-visible
|
||||
# warning so the operator knows aux task is about to fail.
|
||||
# (#26882) The error itself is re-raised below.
|
||||
|
|
@ -6739,7 +7123,7 @@ async def async_call_llm(
|
|||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
f"Run: hermes setup")
|
||||
|
||||
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
|
||||
effective_timeout = _effective_aux_timeout(task, timeout)
|
||||
|
||||
# Pass the client's actual base_url (not just resolved_base_url) so
|
||||
# endpoint-specific temperature overrides can distinguish
|
||||
|
|
@ -6917,18 +7301,24 @@ async def async_call_llm(
|
|||
await refreshed_client.chat.completions.create(**kwargs), task)
|
||||
|
||||
# ── Auth refresh retry (mirrors sync call_llm) ───────────────
|
||||
auth_refresh_provider = _auth_refresh_provider_for_route(
|
||||
resolved_provider, _client_base)
|
||||
if (_is_auth_error(first_err)
|
||||
and resolved_provider not in {"auto", "", None}
|
||||
and auth_refresh_provider not in {"auto", "", None}
|
||||
and not client_is_nous):
|
||||
if _refresh_provider_credentials(resolved_provider):
|
||||
if _refresh_provider_credentials(auth_refresh_provider):
|
||||
if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
|
||||
# The stale client is cached under the route label
|
||||
# (e.g. "auto"), not the concrete backend we refreshed.
|
||||
_evict_cached_clients(resolved_provider)
|
||||
logger.info(
|
||||
"Auxiliary %s (async): refreshed %s credentials after auth error, retrying",
|
||||
task or "call", resolved_provider,
|
||||
task or "call", auth_refresh_provider,
|
||||
)
|
||||
return await _retry_same_provider_async(
|
||||
task=task,
|
||||
resolved_provider=resolved_provider,
|
||||
resolved_model=resolved_model,
|
||||
resolved_provider=auth_refresh_provider,
|
||||
resolved_model=resolved_model or final_model,
|
||||
resolved_base_url=resolved_base_url,
|
||||
resolved_api_key=resolved_api_key,
|
||||
resolved_api_mode=resolved_api_mode,
|
||||
|
|
@ -7055,20 +7445,34 @@ async def async_call_llm(
|
|||
resolved_provider, task, reason=reason)
|
||||
|
||||
if fb_client is not None:
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
base_url=str(getattr(fb_client, "base_url", "") or ""))
|
||||
# Convert sync fallback client to async
|
||||
async_fb, async_fb_model = _to_async_client(
|
||||
fb_client, fb_model or "", is_vision=(task == "vision")
|
||||
)
|
||||
if async_fb_model and async_fb_model != fb_kwargs.get("model"):
|
||||
fb_kwargs["model"] = async_fb_model
|
||||
return _validate_llm_response(
|
||||
await async_fb.chat.completions.create(**fb_kwargs), task)
|
||||
fb_resp = await _call_fallback_candidate_async(
|
||||
async_fb, async_fb_model or fb_model, fb_label,
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# Stale/unrefreshable candidate credential — quarantined; walk
|
||||
# the discovery chain once more (unhealthy entries skipped).
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason="stale fallback credential")
|
||||
if fb_client is not None:
|
||||
async_fb, async_fb_model = _to_async_client(
|
||||
fb_client, fb_model or "", is_vision=(task == "vision")
|
||||
)
|
||||
fb_resp = await _call_fallback_candidate_async(
|
||||
async_fb, async_fb_model or fb_model, fb_label,
|
||||
task=task, messages=messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, effective_timeout=effective_timeout,
|
||||
effective_extra_body=effective_extra_body)
|
||||
if fb_resp is not None:
|
||||
return fb_resp
|
||||
# All fallback layers exhausted — warn before re-raising. (#26882)
|
||||
logger.warning(
|
||||
"Auxiliary %s (async): %s on %s and all fallbacks exhausted "
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
|
|||
"api_key": parent_runtime.get("api_key") or None,
|
||||
"base_url": parent_runtime.get("base_url") or None,
|
||||
"api_mode": parent_api_mode,
|
||||
"credential_pool": getattr(agent, "_credential_pool", None),
|
||||
"request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
|
||||
"max_tokens": getattr(agent, "max_tokens", None),
|
||||
"command": getattr(agent, "acp_command", None),
|
||||
"args": list(getattr(agent, "acp_args", []) or []),
|
||||
"routed": False,
|
||||
}
|
||||
try:
|
||||
|
|
@ -89,10 +94,15 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
|
|||
)
|
||||
return {
|
||||
"provider": rp.get("provider") or task_provider,
|
||||
"model": task_model,
|
||||
"model": rp.get("model") or task_model,
|
||||
"api_key": rp.get("api_key"),
|
||||
"base_url": rp.get("base_url"),
|
||||
"api_mode": rp.get("api_mode"),
|
||||
"credential_pool": rp.get("credential_pool"),
|
||||
"request_overrides": dict(rp.get("request_overrides") or {}),
|
||||
"max_tokens": rp.get("max_output_tokens"),
|
||||
"command": rp.get("command"),
|
||||
"args": list(rp.get("args") or []),
|
||||
"routed": True,
|
||||
}
|
||||
except Exception as e:
|
||||
|
|
@ -680,6 +690,12 @@ def _run_review_in_thread(
|
|||
# Match parent's toolset config so ``tools[]`` is byte-identical
|
||||
# in the request body — Anthropic's cache key includes it.
|
||||
# (The runtime whitelist below still restricts dispatch.)
|
||||
_fork_kwargs: Dict[str, Any] = {}
|
||||
if isinstance(_rt.get("max_tokens"), int):
|
||||
_fork_kwargs["max_tokens"] = _rt["max_tokens"]
|
||||
if isinstance(_rt.get("command"), str) and _rt["command"]:
|
||||
_fork_kwargs["acp_command"] = _rt["command"]
|
||||
_fork_kwargs["acp_args"] = _rt.get("args") or []
|
||||
review_agent = AIAgent(
|
||||
model=_rt.get("model") or agent.model,
|
||||
max_iterations=16,
|
||||
|
|
@ -689,11 +705,13 @@ def _run_review_in_thread(
|
|||
api_mode=_rt.get("api_mode"),
|
||||
base_url=_rt.get("base_url") or None,
|
||||
api_key=_rt.get("api_key") or None,
|
||||
credential_pool=getattr(agent, "_credential_pool", None),
|
||||
credential_pool=_rt.get("credential_pool"),
|
||||
request_overrides=_rt.get("request_overrides") or {},
|
||||
parent_session_id=agent.session_id,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
skip_memory=True,
|
||||
**_fork_kwargs,
|
||||
)
|
||||
review_agent._memory_write_origin = "background_review"
|
||||
review_agent._memory_write_context = "background_review"
|
||||
|
|
|
|||
|
|
@ -164,6 +164,25 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _provider_preferences_for_agent(agent) -> Dict[str, Any]:
|
||||
"""Build the validated provider-routing object shared by request paths."""
|
||||
preferences: Dict[str, Any] = {}
|
||||
if agent.providers_allowed:
|
||||
preferences["only"] = agent.providers_allowed
|
||||
if agent.providers_ignored:
|
||||
preferences["ignore"] = agent.providers_ignored
|
||||
if agent.providers_order:
|
||||
preferences["order"] = agent.providers_order
|
||||
provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
|
||||
if provider_sort:
|
||||
preferences["sort"] = provider_sort
|
||||
if agent.provider_require_parameters:
|
||||
preferences["require_parameters"] = True
|
||||
if agent.provider_data_collection:
|
||||
preferences["data_collection"] = agent.provider_data_collection
|
||||
return preferences
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
|
|
@ -171,6 +190,175 @@ def _env_float(name: str, default: float) -> float:
|
|||
return default
|
||||
|
||||
|
||||
# ── Cross-turn stale-call circuit breaker (#58962) ─────────────────────
|
||||
# A session wedged against an unresponsive provider hits the stale detector
|
||||
# on every call and loops forever (observed: 494 consecutive failures over
|
||||
# 3+ days, each burning the full stale timeout × retries with no response).
|
||||
# The agent carries ``_consecutive_stale_streams``: incremented on every
|
||||
# stale kill, reset only when a call actually completes (or when the
|
||||
# provider is swapped — switch_model / try_activate_fallback /
|
||||
# restore_primary_runtime — since the streak measured the OLD provider).
|
||||
# Past the give-up threshold, calls abort immediately with an actionable
|
||||
# error instead of re-waiting out the stale timeout.
|
||||
|
||||
def _stale_streak(agent) -> int:
|
||||
try:
|
||||
return int(getattr(agent, "_consecutive_stale_streams", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _bump_stale_streak(agent) -> None:
|
||||
try:
|
||||
agent._consecutive_stale_streams = _stale_streak(agent) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _reset_stale_streak(agent) -> None:
|
||||
try:
|
||||
agent._consecutive_stale_streams = 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _check_stale_giveup(agent) -> None:
|
||||
"""Raise immediately when the consecutive-stale streak is past the
|
||||
give-up threshold — no network attempt, no stale-timeout wait."""
|
||||
_giveup = env_int("HERMES_STREAM_STALE_GIVEUP", 5)
|
||||
_streak = _stale_streak(agent)
|
||||
if _giveup > 0 and _streak >= _giveup:
|
||||
raise RuntimeError(
|
||||
"Provider has been unresponsive (no response received) for "
|
||||
f"{_streak} consecutive stale attempts — aborting this call to "
|
||||
"avoid an indefinite stall. Switch models or start a new "
|
||||
"session, then retry."
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
|
||||
"""Run one non-streaming LLM request for the active api_mode and return it.
|
||||
|
||||
Shared by the interrupt-worker path (``interruptible_api_call``) and the
|
||||
inline path (``direct_api_call``) so the per-api_mode dispatch — codex /
|
||||
anthropic / bedrock / MoA / OpenAI-compatible — lives in exactly one place.
|
||||
|
||||
``make_client(reason)`` builds the per-request OpenAI client for the codex
|
||||
and OpenAI-compatible branches; the worker path uses it to register the
|
||||
client with its stranger-thread abort machinery, the inline path uses it to
|
||||
capture the client for its own ``finally`` close. The anthropic / bedrock /
|
||||
MoA branches manage their own clients and never call it. All interrupt,
|
||||
abort, cancellation, and close semantics stay in the callers — this helper
|
||||
only issues the request.
|
||||
"""
|
||||
if agent.api_mode == "codex_responses":
|
||||
request_client = make_client("codex_stream_request")
|
||||
return agent._run_codex_stream(
|
||||
api_kwargs,
|
||||
client=request_client,
|
||||
on_first_delta=getattr(agent, "_codex_on_first_delta", None),
|
||||
)
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
return agent._anthropic_messages_create(api_kwargs)
|
||||
if agent.api_mode == "bedrock_converse":
|
||||
# Bedrock uses boto3 directly — no OpenAI client needed.
|
||||
# normalize_converse_response produces an OpenAI-compatible
|
||||
# SimpleNamespace so the rest of the agent loop can treat
|
||||
# bedrock responses like chat_completions responses.
|
||||
from agent.bedrock_adapter import (
|
||||
_get_bedrock_runtime_client,
|
||||
invalidate_runtime_client,
|
||||
is_stale_connection_error,
|
||||
normalize_converse_response,
|
||||
)
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
api_kwargs.pop("__bedrock_converse__", None)
|
||||
client = _get_bedrock_runtime_client(region)
|
||||
try:
|
||||
raw_response = client.converse(**api_kwargs)
|
||||
except Exception as _bedrock_exc:
|
||||
# Evict the cached client on stale-connection failures
|
||||
# so the outer retry loop builds a fresh client/pool.
|
||||
if is_stale_connection_error(_bedrock_exc):
|
||||
invalidate_runtime_client(region)
|
||||
raise
|
||||
return normalize_converse_response(raw_response)
|
||||
if agent.provider == "moa":
|
||||
# MoA is a virtual chat-completions provider backed by the
|
||||
# in-process MoAClient facade. Do not rebuild a request-local
|
||||
# OpenAI client from the virtual runtime metadata.
|
||||
return agent.client.chat.completions.create(**api_kwargs)
|
||||
request_client = make_client("chat_completion_request")
|
||||
return request_client.chat.completions.create(**api_kwargs)
|
||||
|
||||
|
||||
def should_use_direct_api_call(agent) -> bool:
|
||||
"""Whether a cron OpenAI-wire request should skip the interrupt worker.
|
||||
|
||||
Issue #62151 is specific to OpenRouter's chat-completions path inside the
|
||||
gateway cron thread stack. Keep native/Codex/Bedrock/MoA transports on their
|
||||
established workers: their cancellation and client ownership differ, and
|
||||
the report provides no evidence that those paths share the pre-HTTP wedge.
|
||||
"""
|
||||
return (
|
||||
getattr(agent, "platform", None) == "cron"
|
||||
and getattr(agent, "api_mode", None) == "chat_completions"
|
||||
and getattr(agent, "provider", None) != "moa"
|
||||
)
|
||||
|
||||
|
||||
def direct_api_call(agent, api_kwargs: dict):
|
||||
"""Run a non-streaming LLM call inline on the conversation thread.
|
||||
|
||||
Used when ``should_use_direct_api_call`` is True. Skips the interrupt worker
|
||||
(whose only job is interactive-interrupt responsiveness, which this context
|
||||
does not have) so the nested-pool deadlock (#62151) cannot occur. Because the
|
||||
request runs in-flight normally, the per-request OpenAI client's own httpx
|
||||
timeout (provider ``request_timeout_seconds`` / ``HERMES_API_TIMEOUT``) bounds
|
||||
a genuinely hung provider — the same bound interactive calls already rely on.
|
||||
"""
|
||||
_check_stale_giveup(agent)
|
||||
agent._touch_activity("waiting for non-streaming API response")
|
||||
request_client_holder = {"client": None}
|
||||
request_client_lock = threading.Lock()
|
||||
|
||||
def _abort_active_request(reason: str) -> None:
|
||||
"""Abort the inline request from cron's watchdog/interrupt thread."""
|
||||
with request_client_lock:
|
||||
request_client = request_client_holder["client"]
|
||||
if request_client is not None:
|
||||
agent._abort_request_openai_client(request_client, reason=reason)
|
||||
|
||||
def _make_client(reason: str):
|
||||
client = agent._create_request_openai_client(reason=reason, api_kwargs=api_kwargs)
|
||||
with request_client_lock:
|
||||
request_client_holder["client"] = client
|
||||
agent._active_request_abort = _abort_active_request
|
||||
return client
|
||||
|
||||
try:
|
||||
response = _dispatch_nonstreaming_api_request(
|
||||
agent, api_kwargs, make_client=_make_client
|
||||
)
|
||||
except Exception:
|
||||
if getattr(agent, "_interrupt_requested", False):
|
||||
raise InterruptedError("Agent interrupted during API call") from None
|
||||
raise
|
||||
else:
|
||||
if getattr(agent, "_interrupt_requested", False):
|
||||
raise InterruptedError("Agent interrupted during API call")
|
||||
_reset_stale_streak(agent)
|
||||
return response
|
||||
finally:
|
||||
if getattr(agent, "_active_request_abort", None) is _abort_active_request:
|
||||
agent._active_request_abort = None
|
||||
with request_client_lock:
|
||||
request_client = request_client_holder["client"]
|
||||
request_client_holder["client"] = None
|
||||
if request_client is not None:
|
||||
agent._close_request_openai_client(request_client, reason="request_complete")
|
||||
|
||||
|
||||
def interruptible_api_call(agent, api_kwargs: dict):
|
||||
"""
|
||||
Run the API call in a background thread so the main conversation loop
|
||||
|
|
@ -185,7 +373,20 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
|||
the main retry loop can try again with backoff / credential rotation /
|
||||
provider fallback.
|
||||
"""
|
||||
# Cron and other non-interactive, nested-pool contexts must not spawn the
|
||||
# interrupt worker — it wedges before the socket opens on the 2nd+ call
|
||||
# (#62151). Run inline instead. See should_use_direct_api_call.
|
||||
if should_use_direct_api_call(agent):
|
||||
return direct_api_call(agent, api_kwargs)
|
||||
|
||||
result = {"response": None, "error": None}
|
||||
|
||||
# Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling
|
||||
# of the guard in interruptible_streaming_api_call. Quiet-mode /
|
||||
# subagent / no-stream-consumer sessions take THIS path, and a wedged
|
||||
# unattended session here has the same infinite stale-retry class.
|
||||
_check_stale_giveup(agent)
|
||||
|
||||
request_client_holder = {"client": None, "owner_tid": None}
|
||||
request_client_lock = threading.Lock()
|
||||
# Request-local cancellation flag. Distinct from agent._interrupt_requested
|
||||
|
|
@ -241,56 +442,19 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
|||
|
||||
def _call():
|
||||
try:
|
||||
if agent.api_mode == "codex_responses":
|
||||
request_client = _set_request_client(
|
||||
# _set_request_client registers each per-request OpenAI client with
|
||||
# the stranger-thread abort machinery above; the shared dispatch
|
||||
# helper builds it via this callback so the interrupt / stale-call
|
||||
# detectors can force-close the worker's connection.
|
||||
result["response"] = _dispatch_nonstreaming_api_request(
|
||||
agent,
|
||||
api_kwargs,
|
||||
make_client=lambda reason: _set_request_client(
|
||||
agent._create_request_openai_client(
|
||||
reason="codex_stream_request",
|
||||
api_kwargs=api_kwargs,
|
||||
reason=reason, api_kwargs=api_kwargs
|
||||
)
|
||||
)
|
||||
result["response"] = agent._run_codex_stream(
|
||||
api_kwargs,
|
||||
client=request_client,
|
||||
on_first_delta=getattr(agent, "_codex_on_first_delta", None),
|
||||
)
|
||||
elif agent.api_mode == "anthropic_messages":
|
||||
result["response"] = agent._anthropic_messages_create(api_kwargs)
|
||||
elif agent.api_mode == "bedrock_converse":
|
||||
# Bedrock uses boto3 directly — no OpenAI client needed.
|
||||
# normalize_converse_response produces an OpenAI-compatible
|
||||
# SimpleNamespace so the rest of the agent loop can treat
|
||||
# bedrock responses like chat_completions responses.
|
||||
from agent.bedrock_adapter import (
|
||||
_get_bedrock_runtime_client,
|
||||
invalidate_runtime_client,
|
||||
is_stale_connection_error,
|
||||
normalize_converse_response,
|
||||
)
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
api_kwargs.pop("__bedrock_converse__", None)
|
||||
client = _get_bedrock_runtime_client(region)
|
||||
try:
|
||||
raw_response = client.converse(**api_kwargs)
|
||||
except Exception as _bedrock_exc:
|
||||
# Evict the cached client on stale-connection failures
|
||||
# so the outer retry loop builds a fresh client/pool.
|
||||
if is_stale_connection_error(_bedrock_exc):
|
||||
invalidate_runtime_client(region)
|
||||
raise
|
||||
result["response"] = normalize_converse_response(raw_response)
|
||||
elif agent.provider == "moa":
|
||||
# MoA is a virtual chat-completions provider backed by the
|
||||
# in-process MoAClient facade. Do not rebuild a request-local
|
||||
# OpenAI client from the virtual runtime metadata.
|
||||
result["response"] = agent.client.chat.completions.create(**api_kwargs)
|
||||
else:
|
||||
request_client = _set_request_client(
|
||||
agent._create_request_openai_client(
|
||||
reason="chat_completion_request",
|
||||
api_kwargs=api_kwargs,
|
||||
)
|
||||
)
|
||||
result["response"] = request_client.chat.completions.create(**api_kwargs)
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
# If the request was cancelled by the main thread's interrupt
|
||||
# handler, the transport error is the expected consequence of our
|
||||
|
|
@ -557,6 +721,9 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
|||
_close_request_client_once("stale_call_kill")
|
||||
except Exception:
|
||||
pass
|
||||
# Circuit breaker (#58962): count the stale kill. See the
|
||||
# canonical comment block above ``_stale_streak()``.
|
||||
_bump_stale_streak(agent)
|
||||
agent._touch_activity(
|
||||
f"stale non-streaming call killed after {int(_elapsed)}s"
|
||||
)
|
||||
|
|
@ -599,6 +766,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
|||
raise InterruptedError("Agent interrupted during API call")
|
||||
if result["error"] is not None:
|
||||
raise result["error"]
|
||||
# Success — clear the circuit breaker (#58962): the provider proved
|
||||
# responsive. See the canonical comment block above ``_stale_streak()``.
|
||||
if result["response"] is not None:
|
||||
_reset_stale_streak(agent)
|
||||
return result["response"]
|
||||
|
||||
|
||||
|
|
@ -741,21 +912,8 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
|||
_omit_temp = False
|
||||
_fixed_temp = None
|
||||
|
||||
# Provider preferences (OpenRouter-style)
|
||||
_prefs: Dict[str, Any] = {}
|
||||
if agent.providers_allowed:
|
||||
_prefs["only"] = agent.providers_allowed
|
||||
if agent.providers_ignored:
|
||||
_prefs["ignore"] = agent.providers_ignored
|
||||
if agent.providers_order:
|
||||
_prefs["order"] = agent.providers_order
|
||||
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
|
||||
if _provider_sort:
|
||||
_prefs["sort"] = _provider_sort
|
||||
if agent.provider_require_parameters:
|
||||
_prefs["require_parameters"] = True
|
||||
if agent.provider_data_collection:
|
||||
_prefs["data_collection"] = agent.provider_data_collection
|
||||
# Provider preferences (aggregator profile decides whether to emit them).
|
||||
_prefs = _provider_preferences_for_agent(agent)
|
||||
|
||||
# Anthropic-compatible max-output fallback (last resort only — applied in
|
||||
# build_kwargs *after* ephemeral/user/profile max_tokens, never overriding
|
||||
|
|
@ -1339,6 +1497,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
|||
fb_api_mode = "bedrock_converse"
|
||||
|
||||
old_model = agent.model
|
||||
old_provider = agent.provider
|
||||
|
||||
# Clear the per-config context_length override so the fallback
|
||||
# model's actual context window is resolved instead of inheriting
|
||||
|
|
@ -1484,10 +1643,25 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
|||
f"🔄 Primary model failed — switching to fallback: "
|
||||
f"{fb_model} via {fb_provider}"
|
||||
)
|
||||
# The buffered line above is dropped on successful recovery, but a
|
||||
# provider/model switch is a durable state change operators must see
|
||||
# even when the fallback succeeds. Record a one-shot notice that the
|
||||
# success path surfaces exactly once via _emit_pending_fallback_notice
|
||||
# (see run_agent.py); it is discarded on terminal failure since the
|
||||
# buffered line is flushed instead. See fallback-observability fix.
|
||||
agent._pending_fallback_notice = (
|
||||
f"🔄 Switched to fallback model: {old_model} via {old_provider} "
|
||||
f"→ {fb_model} via {fb_provider}"
|
||||
)
|
||||
logger.info(
|
||||
"Fallback activated: %s → %s (%s)",
|
||||
old_model, fb_model, fb_provider,
|
||||
)
|
||||
# Reset the stale-call circuit breaker (#58962): the streak measured
|
||||
# the OLD provider's unresponsiveness. Carrying it over would
|
||||
# short-circuit the freshly activated fallback before it gets a
|
||||
# single stream attempt.
|
||||
_reset_stale_streak(agent)
|
||||
return True
|
||||
except Exception as e:
|
||||
if fb_provider == "nous":
|
||||
|
|
@ -1525,8 +1699,10 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
|||
# hand-builds messages and calls chat.completions.create() directly,
|
||||
# bypassing the transport — so mirror that sanitization here:
|
||||
# tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers,
|
||||
# timestamp (preserved on gateway user replay entries for the
|
||||
# stale-confirmation expiry check — #47868 rejection class),
|
||||
# and every Hermes-internal underscore-prefixed scaffolding key.
|
||||
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"):
|
||||
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items", "timestamp"):
|
||||
api_msg.pop(schema_foreign, None)
|
||||
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
|
||||
api_msg.pop(internal_key, None)
|
||||
|
|
@ -1612,18 +1788,28 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
|||
if _lm_reasoning_effort is not None:
|
||||
summary_kwargs["reasoning_effort"] = _lm_reasoning_effort
|
||||
|
||||
# Include provider routing preferences
|
||||
provider_preferences = {}
|
||||
if agent.providers_allowed:
|
||||
provider_preferences["only"] = agent.providers_allowed
|
||||
if agent.providers_ignored:
|
||||
provider_preferences["ignore"] = agent.providers_ignored
|
||||
if agent.providers_order:
|
||||
provider_preferences["order"] = agent.providers_order
|
||||
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
|
||||
if _provider_sort:
|
||||
provider_preferences["sort"] = _provider_sort
|
||||
if provider_preferences and (
|
||||
# Merge the profile's canonical body even when routing is unset:
|
||||
# profiles may always emit required metadata such as Portal tags.
|
||||
provider_preferences = _provider_preferences_for_agent(agent)
|
||||
profile_extra_body = {}
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
|
||||
provider_profile = get_provider_profile(agent.provider)
|
||||
if provider_profile is not None:
|
||||
profile_extra_body = provider_profile.build_extra_body(
|
||||
session_id=getattr(agent, "session_id", None),
|
||||
provider_preferences=provider_preferences or None,
|
||||
model=agent.model,
|
||||
base_url=agent.base_url,
|
||||
reasoning_config=agent.reasoning_config,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if profile_extra_body:
|
||||
summary_extra_body.update(profile_extra_body)
|
||||
if provider_preferences and "provider" not in profile_extra_body and (
|
||||
(agent.provider or "").strip().lower() == "openrouter"
|
||||
or agent._is_openrouter_url()
|
||||
):
|
||||
|
|
@ -1779,6 +1965,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
if agent._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted before streaming API call")
|
||||
|
||||
# Cron and other non-interactive, nested-pool contexts deadlock on the
|
||||
# spawned worker thread (#62151). They also have no stream consumer, so the
|
||||
# deltas this path produces go nowhere. Delegate to the non-streaming entry
|
||||
# (which runs inline via should_use_direct_api_call) exactly like the codex
|
||||
# branch below — routing through the _interruptible_api_call method keeps the
|
||||
# outer loop's per-request retry/refresh seam intact.
|
||||
if should_use_direct_api_call(agent):
|
||||
return agent._interruptible_api_call(api_kwargs)
|
||||
|
||||
if agent.api_mode == "codex_responses":
|
||||
# Codex streams internally via _run_codex_stream. The main dispatch
|
||||
# in _interruptible_api_call already calls it; we just need to
|
||||
|
|
@ -1879,11 +2074,27 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
t.join(timeout=0.3)
|
||||
if agent._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted during Bedrock API call")
|
||||
# Worker exited before the poll loop observed the interrupt flag. The
|
||||
# Bedrock stream callback breaks out and returns a PARTIAL response
|
||||
# without raising on interrupt (see bedrock_adapter.py
|
||||
# stream_converse_with_callbacks / on_interrupt_check), so result[
|
||||
# "response"] is populated with error=None and the in-loop raise above
|
||||
# never fires. Re-check here so /stop is not silently swallowed on the
|
||||
# Bedrock path — mirrors the post-worker guard on the main streaming
|
||||
# loop. (#59999 area)
|
||||
if agent._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted during Bedrock API call (post-worker)")
|
||||
if result["error"] is not None:
|
||||
raise result["error"]
|
||||
return result["response"]
|
||||
|
||||
result = {"response": None, "error": None, "partial_tool_names": []}
|
||||
|
||||
# Cross-turn stale-stream circuit breaker (#58962) — see the canonical
|
||||
# comment block above ``_stale_streak()``. Raises past the give-up
|
||||
# threshold instead of burning another stale-timeout×retries cycle.
|
||||
_check_stale_giveup(agent)
|
||||
|
||||
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
|
||||
request_client_lock = threading.Lock()
|
||||
# Request-local cancellation flag — see interruptible_api_call for the full
|
||||
|
|
@ -2861,6 +3072,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
_close_request_client_once("stale_stream_kill")
|
||||
except Exception:
|
||||
pass
|
||||
# Circuit breaker (#58962): count the stale kill. See the
|
||||
# canonical comment block above ``_stale_streak()``.
|
||||
_bump_stale_streak(agent)
|
||||
# Rebuild the primary client too — its connection pool
|
||||
# may hold dead sockets from the same provider outage.
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
|
|
@ -2900,6 +3114,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
except Exception:
|
||||
pass
|
||||
raise InterruptedError("Agent interrupted during streaming API call")
|
||||
# Worker thread exited before the main thread's poll loop could check
|
||||
# the interrupt flag. If the worker returned early due to an interrupt
|
||||
# (e.g. _call_anthropic() detected _interrupt_requested and returned
|
||||
# None), the InterruptedError above was never raised. Re-check the
|
||||
# flag here so /stop is not silently swallowed. (#59999 area)
|
||||
if agent._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted during streaming API call (post-worker)")
|
||||
if result["error"] is not None:
|
||||
if deltas_were_sent["yes"]:
|
||||
# Streaming failed AFTER some tokens were already delivered to
|
||||
|
|
@ -2983,8 +3204,16 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
)
|
||||
if _content_filter_terminated:
|
||||
_stub._content_filter_terminated = True
|
||||
# Partial-stream stub: chunks WERE received (deltas fired), so
|
||||
# the provider is demonstrably responsive — clear the circuit
|
||||
# breaker (#58962) just like the full-success return below.
|
||||
_reset_stale_streak(agent)
|
||||
return _stub
|
||||
raise result["error"]
|
||||
# Success — clear the circuit breaker (#58962): the provider proved
|
||||
# responsive. See the canonical comment block above ``_stale_streak()``.
|
||||
if result["response"] is not None:
|
||||
_reset_stale_streak(agent)
|
||||
return result["response"]
|
||||
|
||||
# ── Provider fallback ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -288,6 +288,13 @@ _RESPONSES_BUILTIN_TOOL_TYPES = {
|
|||
|
||||
_RESPONSE_MESSAGE_STATUSES = {"completed", "incomplete", "in_progress"}
|
||||
|
||||
# The Responses API rejects input[].id longer than this with a non-retryable
|
||||
# HTTP 400 ("string too long"). Codex-issued assistant message ids are
|
||||
# server-assigned base64 blobs that can run 400+ chars, while Hermes-minted
|
||||
# ids (msg_...) stay well under this cap and are worth keeping for
|
||||
# prefix-cache hits. Drop only the oversized ones on replay.
|
||||
_MAX_RESPONSES_ITEM_ID_LENGTH = 64
|
||||
|
||||
|
||||
def _normalize_responses_message_status(value: Any, *, default: str = "completed") -> str:
|
||||
"""Normalize a Responses assistant message status for replay.
|
||||
|
|
@ -307,6 +314,7 @@ def _chat_messages_to_responses_input(
|
|||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
is_xai_responses: bool = False,
|
||||
is_github_responses: bool = False,
|
||||
replay_encrypted_reasoning: bool = True,
|
||||
current_issuer_kind: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
|
|
@ -331,6 +339,16 @@ def _chat_messages_to_responses_input(
|
|||
items from the conversation history and threads ``replay_enabled=False``
|
||||
through this converter so subsequent turns send no reasoning items.
|
||||
|
||||
``is_github_responses`` drops the ``id`` field from replayed
|
||||
``codex_message_items`` regardless of length. The Copilot backend
|
||||
(api.githubcopilot.com/responses) binds these ids to a specific
|
||||
backend "connection" — credential-pool rotation, a gateway restart,
|
||||
or routine load-balancer churn between turns all invalidate it — and
|
||||
rejects a stale id with HTTP 401 "input item ID does not belong to
|
||||
this connection" even for short ids (see #32716). ``phase``/
|
||||
``status``/``content`` are still replayed; only ``id`` is unsafe to
|
||||
reuse across a Copilot connection.
|
||||
|
||||
``current_issuer_kind`` enables a per-item cross-issuer guard. The
|
||||
Responses API's ``encrypted_content`` blob is decryptable only by the
|
||||
endpoint that minted it — replaying a Codex-issued blob against xAI
|
||||
|
|
@ -463,8 +481,14 @@ def _chat_messages_to_responses_input(
|
|||
"content": normalized_content_parts,
|
||||
}
|
||||
item_id = raw_item.get("id")
|
||||
if isinstance(item_id, str) and item_id.strip():
|
||||
replay_item["id"] = item_id.strip()
|
||||
if (
|
||||
not is_github_responses
|
||||
and isinstance(item_id, str)
|
||||
and item_id.strip()
|
||||
):
|
||||
stripped_id = item_id.strip()
|
||||
if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
|
||||
replay_item["id"] = stripped_id
|
||||
phase = raw_item.get("phase")
|
||||
if isinstance(phase, str) and phase.strip():
|
||||
replay_item["phase"] = phase.strip()
|
||||
|
|
@ -576,7 +600,11 @@ def _chat_messages_to_responses_input(
|
|||
# Input preflight / validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]:
|
||||
def _preflight_codex_input_items(
|
||||
raw_items: Any,
|
||||
*,
|
||||
is_github_responses: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError("Codex Responses input must be a list of input items.")
|
||||
|
||||
|
|
@ -717,8 +745,14 @@ def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]:
|
|||
"content": normalized_content,
|
||||
}
|
||||
item_id = item.get("id")
|
||||
if isinstance(item_id, str) and item_id.strip():
|
||||
normalized_item["id"] = item_id.strip()
|
||||
if (
|
||||
not is_github_responses
|
||||
and isinstance(item_id, str)
|
||||
and item_id.strip()
|
||||
):
|
||||
stripped_id = item_id.strip()
|
||||
if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
|
||||
normalized_item["id"] = stripped_id
|
||||
phase = item.get("phase")
|
||||
if isinstance(phase, str) and phase.strip():
|
||||
normalized_item["phase"] = phase.strip()
|
||||
|
|
@ -790,6 +824,7 @@ def _preflight_codex_api_kwargs(
|
|||
api_kwargs: Any,
|
||||
*,
|
||||
allow_stream: bool = False,
|
||||
is_github_responses: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
if not isinstance(api_kwargs, dict):
|
||||
raise ValueError("Codex Responses request must be a dict.")
|
||||
|
|
@ -811,7 +846,10 @@ def _preflight_codex_api_kwargs(
|
|||
instructions = str(instructions)
|
||||
instructions = instructions.strip() or DEFAULT_AGENT_IDENTITY
|
||||
|
||||
normalized_input = _preflight_codex_input_items(api_kwargs.get("input"))
|
||||
normalized_input = _preflight_codex_input_items(
|
||||
api_kwargs.get("input"),
|
||||
is_github_responses=is_github_responses,
|
||||
)
|
||||
|
||||
tools = api_kwargs.get("tools")
|
||||
normalized_tools = None
|
||||
|
|
|
|||
|
|
@ -113,6 +113,15 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
|
|||
|
||||
usage = getattr(turn, "token_usage_last", None)
|
||||
if not isinstance(usage, dict) or not usage:
|
||||
compressor = getattr(agent, "context_compressor", None)
|
||||
if (
|
||||
compressor is not None
|
||||
and getattr(compressor, "awaiting_real_usage_after_compression", False)
|
||||
):
|
||||
# No usage means this turn cannot adjudicate the pending compaction.
|
||||
# Consume the marker so a later unrelated reading is not charged to
|
||||
# it and preflight deferral cannot stay latched indefinitely.
|
||||
compressor.update_from_response({})
|
||||
if agent._session_db and agent.session_id:
|
||||
try:
|
||||
if not agent._session_db_created:
|
||||
|
|
@ -120,6 +129,9 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
|
|||
agent._session_db.update_token_counts(
|
||||
agent.session_id,
|
||||
model=agent.model,
|
||||
billing_provider=agent.provider,
|
||||
billing_base_url=agent.base_url,
|
||||
billing_mode="subscription_included",
|
||||
api_call_count=1,
|
||||
)
|
||||
except Exception as exc:
|
||||
|
|
@ -228,6 +240,81 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def _record_codex_app_server_compaction(
|
||||
agent,
|
||||
turn,
|
||||
*,
|
||||
approx_tokens: int | None = None,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
"""Record a Codex-native context compaction boundary in Hermes state.
|
||||
|
||||
The app-server owns the compacted thread context, so Hermes should not
|
||||
rewrite local transcript rows here; state.db records the boundary via the
|
||||
session event/usage counters while preserving the visible transcript.
|
||||
"""
|
||||
if not force and not getattr(turn, "compacted", False):
|
||||
return False
|
||||
|
||||
thread_id = getattr(turn, "thread_id", None) or ""
|
||||
turn_id = getattr(turn, "turn_id", None) or ""
|
||||
logger.info(
|
||||
"codex app-server compaction observed: session=%s thread=%s turn=%s force=%s",
|
||||
getattr(agent, "session_id", None) or "none",
|
||||
thread_id,
|
||||
turn_id,
|
||||
force,
|
||||
)
|
||||
if not force:
|
||||
try:
|
||||
from agent.conversation_compression import COMPACTION_STATUS
|
||||
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
compressor = getattr(agent, "context_compressor", None)
|
||||
if compressor is not None:
|
||||
compressor.compression_count = getattr(
|
||||
compressor, "compression_count", 0
|
||||
) + 1
|
||||
compressor.last_compression_rough_tokens = approx_tokens or 0
|
||||
# The app server has already completed a real compaction boundary. Its
|
||||
# usage update (when supplied) is therefore the same real-vs-real
|
||||
# effectiveness verdict used by the normal compression path.
|
||||
if hasattr(compressor, "_verify_compaction_cleared_threshold"):
|
||||
compressor._verify_compaction_cleared_threshold = True
|
||||
if not getattr(turn, "token_usage_last", None):
|
||||
compressor.last_prompt_tokens = -1
|
||||
compressor.last_completion_tokens = 0
|
||||
compressor.awaiting_real_usage_after_compression = True
|
||||
|
||||
agent._last_compaction_in_place = False
|
||||
try:
|
||||
if getattr(agent, "event_callback", None):
|
||||
agent.event_callback(
|
||||
"session:compress",
|
||||
{
|
||||
"platform": getattr(agent, "platform", None) or "",
|
||||
"session_id": getattr(agent, "session_id", None) or "",
|
||||
"old_session_id": "",
|
||||
"in_place": False,
|
||||
"compression_count": getattr(
|
||||
compressor, "compression_count", 0
|
||||
)
|
||||
if compressor is not None
|
||||
else 0,
|
||||
"runtime": "codex_app_server",
|
||||
"thread_id": thread_id,
|
||||
"turn_id": turn_id,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("event_callback error on codex session:compress", exc_info=True)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def run_codex_app_server_turn(
|
||||
agent,
|
||||
*,
|
||||
|
|
@ -393,6 +480,7 @@ def run_codex_app_server_turn(
|
|||
agent._iters_since_skill = (
|
||||
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
|
||||
)
|
||||
_record_codex_app_server_compaction(agent, turn)
|
||||
usage_result = _record_codex_app_server_usage(agent, turn)
|
||||
api_calls = 1
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import logging
|
|||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
|
@ -412,10 +413,18 @@ def _marker_root(cwd: Path) -> Optional[Path]:
|
|||
"""
|
||||
current = cwd.resolve()
|
||||
home = _home()
|
||||
# Shared world-writable temp roots are never project roots: a stray
|
||||
# manifest in /tmp (left by any process) must not flip every session
|
||||
# whose cwd lives under the temp dir into the coding posture. Same
|
||||
# reasoning as the $HOME skip below.
|
||||
try:
|
||||
temp_root = Path(tempfile.gettempdir()).resolve()
|
||||
except Exception:
|
||||
temp_root = None
|
||||
for depth, parent in enumerate([current, *current.parents]):
|
||||
if depth > 6:
|
||||
break
|
||||
if parent == home:
|
||||
if parent == home or (temp_root is not None and parent == temp_root):
|
||||
continue
|
||||
for marker in _PROJECT_MARKERS:
|
||||
if (parent / marker).exists():
|
||||
|
|
|
|||
|
|
@ -193,8 +193,10 @@ _HISTORICAL_SUMMARY_PREFIXES = (
|
|||
_MIN_SUMMARY_TOKENS = 2000
|
||||
# Proportion of compressed content to allocate for summary
|
||||
_SUMMARY_RATIO = 0.20
|
||||
# Absolute ceiling for summary tokens (even on very large context windows)
|
||||
_SUMMARY_TOKENS_CEILING = 12_000
|
||||
# Absolute ceiling for summary tokens (even on very large context windows).
|
||||
# Summaries must stay within a 1K-10K token envelope — anything larger is
|
||||
# itself a context-pressure source and slows every compaction.
|
||||
_SUMMARY_TOKENS_CEILING = 10_000
|
||||
|
||||
# Placeholder used when pruning old tool results
|
||||
_PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]"
|
||||
|
|
@ -227,6 +229,16 @@ _AUTO_FOCUS_MAX_CHARS = 700
|
|||
# back the old large-tool-output case where nothing can be compacted.
|
||||
_MAX_TAIL_MESSAGE_FLOOR = 8
|
||||
|
||||
# Models with context windows below this get their compression threshold
|
||||
# floored at ``_SMALL_CTX_THRESHOLD_PERCENT`` (raise-only — an explicitly
|
||||
# higher user/model threshold always wins). At the default 50% trigger a
|
||||
# 128K-262K model compacts with only ~64-131K consumed; the incompressible
|
||||
# floor (system prompt + tool schemas + protected tail + rolling summary)
|
||||
# eats most of the reclaimed headroom, so compaction re-fires every 1-2
|
||||
# turns and the session spends most of its wall-clock summarizing.
|
||||
_SMALL_CTX_WINDOW_LIMIT = 512_000
|
||||
_SMALL_CTX_THRESHOLD_PERCENT = 0.75
|
||||
|
||||
|
||||
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
|
||||
|
||||
|
|
@ -298,6 +310,32 @@ def _content_length_for_budget(raw_content: Any) -> int:
|
|||
return total
|
||||
|
||||
|
||||
def _serialized_length_for_budget(value: Any) -> int:
|
||||
"""Return a stable char-length for non-content replay/metadata fields."""
|
||||
if value is None or value == "":
|
||||
return 0
|
||||
if isinstance(value, str):
|
||||
return len(value)
|
||||
try:
|
||||
return len(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str))
|
||||
except (TypeError, ValueError):
|
||||
return len(str(value))
|
||||
|
||||
|
||||
# Provider replay/metadata fields that ride the wire on every request but are
|
||||
# invisible to ``msg["content"]``/``msg["tool_calls"]`` accounting. Codex
|
||||
# Responses sessions in particular carry ``codex_reasoning_items`` blobs of
|
||||
# ``encrypted_content`` that can dominate the serialized session (a measured
|
||||
# 214-turn session held ~115K tokens / 27% of its payload there — #55572).
|
||||
_REPLAY_BUDGET_KEYS = (
|
||||
"reasoning",
|
||||
"reasoning_content",
|
||||
"reasoning_details",
|
||||
"codex_reasoning_items",
|
||||
"codex_message_items",
|
||||
)
|
||||
|
||||
|
||||
def _estimate_msg_budget_tokens(msg: dict) -> int:
|
||||
"""Token estimate for one message in the tail-protection budget walks.
|
||||
|
||||
|
|
@ -308,12 +346,23 @@ def _estimate_msg_budget_tokens(msg: dict) -> int:
|
|||
4-tool-call turn measures ~73 vs ~1,090 real tokens), so the protected
|
||||
tail overshot ``tail_token_budget`` and compression became ineffective.
|
||||
See issue #28053.
|
||||
|
||||
Also counts provider replay fields (``codex_reasoning_items`` etc. —
|
||||
see ``_REPLAY_BUDGET_KEYS``). The preflight "should I compress?"
|
||||
estimator sees the full message shape, so the tail walk must use the
|
||||
same size class; otherwise an assistant message with tiny visible
|
||||
content but large hidden replay blobs is protected as if it were small,
|
||||
the post-compression session stays near the context limit, and
|
||||
compaction re-fires continuously (#55572). Accounting-only: replay
|
||||
fields are never mutated or pruned here.
|
||||
"""
|
||||
content_len = _content_length_for_budget(msg.get("content") or "")
|
||||
tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/key overhead
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict):
|
||||
tokens += len(str(tc)) // _CHARS_PER_TOKEN
|
||||
for key in _REPLAY_BUDGET_KEYS:
|
||||
tokens += _serialized_length_for_budget(msg.get(key)) // _CHARS_PER_TOKEN
|
||||
return tokens
|
||||
|
||||
|
||||
|
|
@ -687,6 +736,8 @@ class ContextCompressor(ContextEngine):
|
|||
self._last_aux_model_failure_model = None
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
self._verify_compaction_cleared_threshold = False
|
||||
self._last_compression_made_progress = False
|
||||
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
|
||||
self._last_summary_error = None
|
||||
self._last_compress_aborted = False
|
||||
|
|
@ -722,6 +773,8 @@ class ContextCompressor(ContextEngine):
|
|||
self._last_aux_model_failure_model = None
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
self._verify_compaction_cleared_threshold = False
|
||||
self._last_compression_made_progress = False
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
self._last_compress_aborted = False
|
||||
self._context_probed = False
|
||||
|
|
@ -846,6 +899,18 @@ class ContextCompressor(ContextEngine):
|
|||
self.provider = provider
|
||||
self.api_mode = api_mode
|
||||
self.context_length = context_length
|
||||
# Re-apply the small-context threshold floor for the NEW window,
|
||||
# starting from the originally-configured percent (not the possibly
|
||||
# floored live value) so a small -> large switch drops back to the
|
||||
# configured threshold and a large -> small switch gains the floor.
|
||||
# Guard with getattr: compressors unpickled/constructed before this
|
||||
# attribute existed fall back to the live value.
|
||||
_configured_pct = getattr(
|
||||
self, "_configured_threshold_percent", self.threshold_percent,
|
||||
)
|
||||
self.threshold_percent = self._effective_threshold_percent(
|
||||
context_length, _configured_pct,
|
||||
)
|
||||
# max_tokens=None here means "caller didn't specify" → keep the existing
|
||||
# output reservation. A switch that genuinely changes the output budget
|
||||
# passes the new value explicitly. (#43547)
|
||||
|
|
@ -883,6 +948,8 @@ class ContextCompressor(ContextEngine):
|
|||
self.last_compression_rough_tokens = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
self._ineffective_compression_count = 0
|
||||
self._verify_compaction_cleared_threshold = False
|
||||
self._last_compression_made_progress = False
|
||||
|
||||
# When the MINIMUM_CONTEXT_LENGTH floor meets/exceeds a small context
|
||||
# window, compacting at the percentage (50% → 32K of a 64K window) wastes
|
||||
|
|
@ -908,6 +975,23 @@ class ContextCompressor(ContextEngine):
|
|||
return None
|
||||
return ivalue if ivalue > 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _effective_threshold_percent(
|
||||
context_length: int, threshold_percent: float,
|
||||
) -> float:
|
||||
"""Apply the small-context threshold floor (raise-only).
|
||||
|
||||
Models under ``_SMALL_CTX_WINDOW_LIMIT`` (512K) trigger at no less
|
||||
than ``_SMALL_CTX_THRESHOLD_PERCENT`` (75%) of the window. An
|
||||
explicitly higher threshold (user config or per-model autoraise,
|
||||
e.g. Codex gpt-5.5's 85%) always wins; only lower values are raised.
|
||||
Large-context models keep the configured value — at 512K+ the default
|
||||
50% trigger already leaves ample post-compaction headroom.
|
||||
"""
|
||||
if context_length and context_length < _SMALL_CTX_WINDOW_LIMIT:
|
||||
return max(threshold_percent, _SMALL_CTX_THRESHOLD_PERCENT)
|
||||
return threshold_percent
|
||||
|
||||
@staticmethod
|
||||
def _compute_threshold_tokens(
|
||||
context_length: int, threshold_percent: float, max_tokens: int | None = None,
|
||||
|
|
@ -995,6 +1079,18 @@ class ContextCompressor(ContextEngine):
|
|||
config_context_length=config_context_length,
|
||||
provider=provider,
|
||||
)
|
||||
# Small-context threshold floor: models under 512K trigger at >=75%
|
||||
# so compaction doesn't fire with half the window still free (the
|
||||
# incompressible floor makes 50%-triggered compaction thrash on
|
||||
# 128K-262K models). Raise-only; must run AFTER context_length is
|
||||
# resolved and BEFORE threshold_tokens is derived. The pre-floor
|
||||
# value is kept so update_model() can re-derive for a new window
|
||||
# (switching small -> large must drop back to the configured value).
|
||||
self._configured_threshold_percent = self.threshold_percent
|
||||
self.threshold_percent = self._effective_threshold_percent(
|
||||
self.context_length, self.threshold_percent,
|
||||
)
|
||||
threshold_percent = self.threshold_percent
|
||||
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if
|
||||
# the percentage would suggest a lower value. This prevents premature
|
||||
# compression on large-context models at 50% while keeping the % sane
|
||||
|
|
@ -1041,6 +1137,12 @@ class ContextCompressor(ContextEngine):
|
|||
# Anti-thrashing: track whether last compression was effective
|
||||
self._last_compression_savings_pct: float = 100.0
|
||||
self._ineffective_compression_count: int = 0
|
||||
# Set after a completed compression boundary; consumed by the next
|
||||
# provider-reported prompt count in update_from_response().
|
||||
self._verify_compaction_cleared_threshold: bool = False
|
||||
# Lets the boundary wrapper distinguish a completed rewrite from a
|
||||
# no-op/abort without inferring progress from message-list length.
|
||||
self._last_compression_made_progress: bool = False
|
||||
self._summary_failure_cooldown_until: float = 0.0
|
||||
self._last_summary_error: Optional[str] = None
|
||||
# When summary generation fails and a static fallback is inserted,
|
||||
|
|
@ -1087,8 +1189,48 @@ class ContextCompressor(ContextEngine):
|
|||
if self.last_prompt_tokens < self.threshold_tokens:
|
||||
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
|
||||
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
|
||||
# Any real provider reading below the trigger proves the prompt
|
||||
# fits again. Clear the episode latch even when this response was
|
||||
# not the one immediately following compaction.
|
||||
self._ineffective_compression_count = 0
|
||||
else:
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
|
||||
# Anti-thrashing verdict, judged HERE because this is the only place
|
||||
# that sees the provider's real prompt count for the just-compacted
|
||||
# conversation. Effectiveness is "did the prompt get under the
|
||||
# threshold?", not "did the message list shrink?": compaction can
|
||||
# only shrink messages, while the system prompt and tool schemas are
|
||||
# an incompressible floor (with 50+ tools, 20-30K tokens — see
|
||||
# #14695). When that floor alone meets the threshold, every pass
|
||||
# shrinks messages by a healthy margin yet leaves the prompt over the
|
||||
# line, so the next turn compacts again, forever.
|
||||
#
|
||||
# It must NOT live in should_compress(): that runs twice per turn
|
||||
# with two different measures (a rough preflight estimate and the
|
||||
# real post-response count, #36718), and the rough one can dip below
|
||||
# the threshold and reset the strike every turn, re-opening the loop.
|
||||
# Keying on real usage compares like with like and fires exactly once
|
||||
# per compaction.
|
||||
if self._verify_compaction_cleared_threshold:
|
||||
if self.last_prompt_tokens >= self.threshold_tokens:
|
||||
self._ineffective_compression_count += 1
|
||||
if not self.quiet_mode:
|
||||
logger.warning(
|
||||
"Compaction did not clear the threshold: %d real "
|
||||
"tokens still >= %d. The incompressible prompt "
|
||||
"(system prompt + tool schemas) may already exceed "
|
||||
"it, in which case shrinking messages cannot help. "
|
||||
"ineffective_compression_count=%d",
|
||||
self.last_prompt_tokens, self.threshold_tokens,
|
||||
self._ineffective_compression_count,
|
||||
)
|
||||
else:
|
||||
self._ineffective_compression_count = 0
|
||||
# Consume the pending-verification flag once real usage arrives, whether
|
||||
# or not prompt_tokens was reported, so a usage-less response can't leave
|
||||
# it armed for a later, unrelated reading.
|
||||
self._verify_compaction_cleared_threshold = False
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
|
||||
|
|
@ -1164,9 +1306,9 @@ class ContextCompressor(ContextEngine):
|
|||
if self._ineffective_compression_count >= 2:
|
||||
if not self.quiet_mode:
|
||||
logger.warning(
|
||||
"Compression skipped — last %d compressions saved <10%% each. "
|
||||
"Consider /new to start a fresh session, or /compress <topic> "
|
||||
"for focused compression.",
|
||||
"Compression skipped — last %d compaction attempts did not "
|
||||
"restore enough context headroom. Consider /new to start a "
|
||||
"fresh session, or /compress <topic> for focused compression.",
|
||||
self._ineffective_compression_count,
|
||||
)
|
||||
return False
|
||||
|
|
@ -1373,11 +1515,26 @@ class ContextCompressor(ContextEngine):
|
|||
(API keys, tokens, passwords) from leaking into the summary that
|
||||
gets sent to the auxiliary model and persisted across compactions.
|
||||
"""
|
||||
# Lazy import (matches title_generator.py) — agent_runtime_helpers
|
||||
# pulls in heavy transitive imports we don't want at module load.
|
||||
from agent.agent_runtime_helpers import strip_think_blocks
|
||||
|
||||
parts = []
|
||||
for msg in turns:
|
||||
role = msg.get("role", "unknown")
|
||||
content = redact_sensitive_text(msg.get("content") or "")
|
||||
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
|
||||
# Strip inline reasoning blocks (<think>, <reasoning>, etc.) from
|
||||
# assistant content before it reaches the summarizer. Reasoning
|
||||
# traces are transient scratch work — feeding them to the aux
|
||||
# model wastes summarizer context and risks scratch-work
|
||||
# conclusions being preserved as facts in the summary. The native
|
||||
# ``reasoning`` message field is already excluded (only
|
||||
# ``content`` is serialized); this closes the inline-tag path
|
||||
# used when native thinking is disabled or the provider inlines
|
||||
# traces into content.
|
||||
if role == "assistant" and content:
|
||||
content = strip_think_blocks(None, content)
|
||||
|
||||
# Tool results: keep enough content for the summarizer
|
||||
if role == "tool":
|
||||
|
|
@ -1843,7 +2000,15 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
"api_mode": self.api_mode,
|
||||
},
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": int(summary_budget * 1.3),
|
||||
# NO max_tokens: the output cap must never truncate a summary.
|
||||
# ``summary_budget`` is prompt-level guidance only ("Target ~N
|
||||
# tokens" above). Most OpenAI-compatible wires already omit the
|
||||
# param (see _build_call_kwargs), but the Anthropic Messages
|
||||
# wire and NVIDIA NIM forward it — a hard cap there cut
|
||||
# summaries mid-section (thinking models burn the cap on
|
||||
# reasoning first), producing truncated/thinking-only
|
||||
# summaries and compaction loops. Omitting lets the adapter
|
||||
# fall back to the model's native output ceiling.
|
||||
# timeout resolved from auxiliary.compression.timeout config by call_llm
|
||||
}
|
||||
if self.summary_model:
|
||||
|
|
@ -1883,6 +2048,16 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
f"(provider={self.provider or 'auto'} "
|
||||
f"model={self.summary_model or self.model})"
|
||||
)
|
||||
# Strip reasoning blocks the summarizer model may have emitted
|
||||
# (<think>...</think> etc. from thinking models like MiniMax,
|
||||
# DeepSeek, QwQ). Without this the trace is stored in
|
||||
# _previous_summary, injected into the conversation, AND fed back
|
||||
# into every subsequent iterative-update prompt — compounding
|
||||
# token bloat across compactions. Mirrors title_generator.py.
|
||||
from agent.agent_runtime_helpers import strip_think_blocks
|
||||
stripped = strip_think_blocks(None, content).strip()
|
||||
if stripped:
|
||||
content = stripped
|
||||
# Redact the summary output as well — the summarizer LLM may
|
||||
# ignore prompt instructions and echo back secrets verbatim.
|
||||
summary = redact_sensitive_text(content.strip())
|
||||
|
|
@ -2697,6 +2872,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
self._last_aux_model_failure_error = None
|
||||
self._last_aux_model_failure_model = None
|
||||
self._last_compress_aborted = False
|
||||
self._last_compression_made_progress = False
|
||||
# NOTE: do NOT reset _last_summary_auth_failure or
|
||||
# _last_summary_network_failure here. These flags are set by
|
||||
# _generate_summary() on a terminal failure and are already cleared on
|
||||
|
|
@ -2717,10 +2893,21 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
# Only need head + 3 tail messages minimum (token budget decides the real tail size)
|
||||
_min_for_compress = self._protect_head_size(messages) + 3 + 1
|
||||
if n_messages <= _min_for_compress:
|
||||
# Record the no-op, exactly as the sibling "no compressable window"
|
||||
# branch below does (#40803). Returning without touching the
|
||||
# anti-thrashing counter leaves should_compress() saying True on a
|
||||
# transcript that can never shrink: when the prompt sits above the
|
||||
# threshold because of the incompressible floor (system prompt +
|
||||
# tool schemas), every subsequent turn re-fires a compaction that
|
||||
# returns here unchanged, and the CLI appears frozen.
|
||||
self._ineffective_compression_count += 1
|
||||
self._last_compression_savings_pct = 0.0
|
||||
if not self.quiet_mode:
|
||||
logger.warning(
|
||||
"Cannot compress: only %d messages (need > %d)",
|
||||
"Cannot compress: only %d messages (need > %d). "
|
||||
"ineffective_compression_count=%d",
|
||||
n_messages, _min_for_compress,
|
||||
self._ineffective_compression_count,
|
||||
)
|
||||
return messages
|
||||
|
||||
|
|
@ -3016,15 +3203,26 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
compressed = _strip_historical_media(compressed)
|
||||
|
||||
new_estimate = estimate_messages_tokens_rough(compressed)
|
||||
saved_estimate = display_tokens - new_estimate
|
||||
|
||||
# Anti-thrashing: track compression effectiveness
|
||||
savings_pct = (saved_estimate / display_tokens * 100) if display_tokens > 0 else 0
|
||||
# Anti-thrashing: measure effectiveness on a like-for-like basis.
|
||||
#
|
||||
# ``display_tokens`` is usually ``current_tokens`` — the provider's real
|
||||
# prompt count, which includes the system prompt and tool schemas.
|
||||
# ``new_estimate`` covers the messages ONLY. Comparing the two makes a
|
||||
# compaction that freed almost nothing look like it saved ~96%, so the
|
||||
# counter below resets every pass and the anti-thrashing guard is dead
|
||||
# code. Compaction can only shrink messages, so score it against the
|
||||
# messages it was given.
|
||||
pre_estimate = estimate_messages_tokens_rough(messages)
|
||||
saved_estimate = pre_estimate - new_estimate
|
||||
savings_pct = (saved_estimate / pre_estimate * 100) if pre_estimate > 0 else 0
|
||||
self._last_compression_savings_pct = savings_pct
|
||||
if savings_pct < 10:
|
||||
self._ineffective_compression_count += 1
|
||||
else:
|
||||
self._ineffective_compression_count = 0
|
||||
|
||||
# Message-only savings are diagnostic. The anti-thrashing verdict is
|
||||
# owned by the next provider-reported prompt count, which answers the
|
||||
# actual question: did this completed boundary get under the threshold?
|
||||
# Counting a low message-savings estimate here as well would give one
|
||||
# compaction two strikes when that real reading remains over threshold.
|
||||
|
||||
if not self.quiet_mode:
|
||||
logger.info(
|
||||
|
|
@ -3041,5 +3239,6 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
# are positional; this single terminal sweep makes it structural so a
|
||||
# future copy site cannot re-leak the marker into the child-session flush.
|
||||
_strip_persistence_markers(compressed)
|
||||
self._last_compression_made_progress = True
|
||||
|
||||
return compressed
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ these paths see no behavioural change.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
|
|
@ -52,6 +53,29 @@ COMPACTION_STATUS = (
|
|||
)
|
||||
|
||||
|
||||
def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool:
|
||||
"""Whether the live in-memory SessionDB class structurally predates locks.
|
||||
|
||||
In the supported hot-reload skew, this module is new while the already
|
||||
imported ``hermes_state.SessionDB`` class (and its live instances) is old.
|
||||
Only that exact class identity may fail open. Proxies, nominal lookalikes,
|
||||
non-callables, and descriptor failures must fail closed. Static lookup
|
||||
avoids invoking a present-but-broken descriptor.
|
||||
"""
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
missing = object()
|
||||
return (
|
||||
type(lock_db) is SessionDB
|
||||
and inspect.getattr_static(
|
||||
SessionDB, "try_acquire_compression_lock", missing
|
||||
) is missing
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
|
||||
|
|
@ -465,6 +489,21 @@ def compress_context(
|
|||
prompt — the session is NOT rotated. Callers should detect the
|
||||
no-op via ``len(returned) == len(input)`` and stop the retry loop.
|
||||
"""
|
||||
# Codex app-server sessions: the codex agent owns the real thread context;
|
||||
# Hermes' summarizer would only rewrite a local mirror without shrinking
|
||||
# the actual thread (#36801). Route compaction to the app server's own
|
||||
# thread/compact mechanism. Behavior is controlled by
|
||||
# ``compression.codex_app_server_auto`` (native|hermes|off).
|
||||
if getattr(agent, "api_mode", None) == "codex_app_server":
|
||||
return _compress_context_via_codex_app_server(
|
||||
agent,
|
||||
messages,
|
||||
system_message,
|
||||
approx_tokens=approx_tokens,
|
||||
task_id=task_id,
|
||||
force=force,
|
||||
)
|
||||
|
||||
# Lazy feasibility check — run the auxiliary-provider probe + context
|
||||
# length lookup just-in-time on the first compression attempt instead of
|
||||
# at AIAgent.__init__. Saves ~400ms cold off every short session that
|
||||
|
|
@ -526,21 +565,31 @@ def compress_context(
|
|||
_lock_sid = agent.session_id or ""
|
||||
_lock_holder: Optional[str] = None
|
||||
# Probe whether the lock subsystem is actually available on this
|
||||
# SessionDB instance. A process running mismatched module versions
|
||||
# (e.g. ``conversation_compression.py`` reloaded after a pull but the
|
||||
# long-lived ``hermes_state.SessionDB`` class still bound to the
|
||||
# pre-#34351 version in memory) has the call site but not the method.
|
||||
# In that case ``try_acquire_compression_lock`` raises AttributeError —
|
||||
# NOT a ``sqlite3.Error`` — so the method's own fail-open guard never
|
||||
# runs and the exception propagates to the outer agent loop, which
|
||||
# prints the error and retries. Because compression never succeeds,
|
||||
# the token count never drops and the loop re-triggers compaction
|
||||
# forever (the "API call #47/#48/#49 ... has no attribute
|
||||
# try_acquire_compression_lock" spin). Fail OPEN here: if the lock
|
||||
# subsystem is missing or broken in any unexpected way, skip locking
|
||||
# and proceed with compression. Skipping the lock risks a rare
|
||||
# concurrent-compression session fork; an infinite no-progress loop
|
||||
# that never compresses at all is strictly worse.
|
||||
# SessionDB instance. A process running mismatched module versions can have
|
||||
# this call site while its long-lived SessionDB instance predates the lock
|
||||
# API. Only that structural absence is safe to fail open for: compression
|
||||
# must make progress rather than spin forever after an update. Once the
|
||||
# method has been resolved, every exception from its implementation fails
|
||||
# closed because proceeding without a lock can fork the session lineage.
|
||||
_try_acquire_lock = None
|
||||
_lock_lookup_error: Optional[Exception] = None
|
||||
_legacy_session_db_without_lock_api = False
|
||||
if _lock_db is not None:
|
||||
try:
|
||||
_legacy_session_db_without_lock_api = _lock_api_is_absent_on_session_db(
|
||||
_lock_db
|
||||
)
|
||||
except Exception as exc:
|
||||
_lock_lookup_error = exc
|
||||
if _lock_lookup_error is None and not _legacy_session_db_without_lock_api:
|
||||
try:
|
||||
_try_acquire_lock = _lock_db.try_acquire_compression_lock
|
||||
if not callable(_try_acquire_lock):
|
||||
_lock_lookup_error = TypeError(
|
||||
"compression lock API is present but not callable"
|
||||
)
|
||||
except Exception as exc:
|
||||
_lock_lookup_error = exc
|
||||
try:
|
||||
_lock_ttl = float(getattr(agent, "_compression_lock_ttl_seconds", 300.0) or 300.0)
|
||||
except (TypeError, ValueError):
|
||||
|
|
@ -549,25 +598,58 @@ def compress_context(
|
|||
_lock_refresher: Optional[_CompressionLockLeaseRefresher] = None
|
||||
if _lock_db is not None and _lock_sid:
|
||||
_lock_holder = _compression_lock_holder(agent)
|
||||
try:
|
||||
_lock_acquired = _lock_db.try_acquire_compression_lock(
|
||||
_lock_sid, _lock_holder, ttl_seconds=_lock_ttl
|
||||
if _lock_lookup_error is not None:
|
||||
# Attribute lookup itself failed for a reason other than a missing
|
||||
# lock API. It is unsafe to proceed without a lock in that case.
|
||||
_lock_holder = None
|
||||
logger.warning(
|
||||
"compression lock lookup raised unexpectedly for session=%s "
|
||||
"(%s: %s) — skipping compression this cycle",
|
||||
_lock_sid, type(_lock_lookup_error).__name__, _lock_lookup_error,
|
||||
)
|
||||
except Exception as _lock_err:
|
||||
# Broken/absent lock subsystem (version skew, etc.). Log once
|
||||
# per session and proceed WITHOUT the lock rather than letting
|
||||
# the exception spin the outer loop.
|
||||
_lock_holder = None # we don't own anything to release
|
||||
_lock_acquired = False
|
||||
elif _try_acquire_lock is None:
|
||||
# The lock API itself is absent on this in-memory instance. Log once
|
||||
# and proceed unlocked so an update-version skew cannot leave the
|
||||
# outer auto-compression loop making no progress forever.
|
||||
_lock_holder = None
|
||||
if getattr(agent, "_last_compression_lock_error_sid", None) != _lock_sid:
|
||||
agent._last_compression_lock_error_sid = _lock_sid
|
||||
logger.warning(
|
||||
"compression lock subsystem unavailable for session=%s "
|
||||
"(%s: %s) — proceeding without lock. This usually means a "
|
||||
"stale in-memory module after an update; restart the "
|
||||
"process (or `hermes update`) to resync.",
|
||||
"— proceeding without lock. This usually means a stale "
|
||||
"in-memory module after an update; restart the process "
|
||||
"(or `hermes update`) to resync.",
|
||||
_lock_sid,
|
||||
)
|
||||
_lock_acquired = True # acquired-but-unlocked compatibility path
|
||||
else:
|
||||
try:
|
||||
_lock_acquired = _try_acquire_lock(
|
||||
_lock_sid, _lock_holder, ttl_seconds=_lock_ttl
|
||||
)
|
||||
except Exception as _lock_err:
|
||||
# The method exists and entered its implementation but failed.
|
||||
# Do not mistake an internal AttributeError or TypeError for
|
||||
# version skew: fail closed and preserve session lineage. A
|
||||
# failure after SQLite committed the acquire can leave our
|
||||
# holder row behind, so release it best-effort before returning
|
||||
# unchanged messages; release is holder-qualified and safe when
|
||||
# acquisition never succeeded.
|
||||
try:
|
||||
_lock_db.release_compression_lock(_lock_sid, _lock_holder)
|
||||
except Exception as _release_err:
|
||||
logger.debug(
|
||||
"compression lock cleanup after failed acquire failed: %s",
|
||||
_release_err,
|
||||
)
|
||||
_lock_holder = None
|
||||
logger.warning(
|
||||
"compression lock acquisition raised unexpectedly for "
|
||||
"session=%s (%s: %s) — skipping compression this cycle",
|
||||
_lock_sid, type(_lock_err).__name__, _lock_err,
|
||||
)
|
||||
_lock_acquired = True # treat as acquired-but-unlocked; proceed
|
||||
_lock_acquired = False
|
||||
if not _lock_acquired:
|
||||
try:
|
||||
existing = _lock_db.get_compression_lock_holder(_lock_sid)
|
||||
|
|
@ -658,6 +740,20 @@ def compress_context(
|
|||
finally:
|
||||
_release_lock()
|
||||
|
||||
# A compressor that returns the exact input object made no structural
|
||||
# progress. Do not rotate/rewrite the session or arm post-compression
|
||||
# deferral in that case; its own anti-thrash counter records the no-op.
|
||||
if compressed is messages:
|
||||
logger.info(
|
||||
"Compression made no progress (session=%s) — skipping boundary rewrite.",
|
||||
agent.session_id or "none",
|
||||
)
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
_release_lock()
|
||||
return messages, _existing_sp
|
||||
|
||||
try:
|
||||
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
|
||||
if summary_error:
|
||||
|
|
@ -946,6 +1042,12 @@ def compress_context(
|
|||
agent.context_compressor.last_prompt_tokens = -1
|
||||
agent.context_compressor.last_completion_tokens = 0
|
||||
agent.context_compressor.awaiting_real_usage_after_compression = True
|
||||
# Arm the effectiveness verdict only after a completed rewrite crosses
|
||||
# the full compaction boundary. Exceptions, aborts, and no-op attempts
|
||||
# leave this false, so unrelated later usage cannot be charged to an
|
||||
# attempt that never changed the transcript.
|
||||
if getattr(agent.context_compressor, "_last_compression_made_progress", False):
|
||||
agent.context_compressor._verify_compaction_cleared_threshold = True
|
||||
|
||||
# Clear the file-read dedup cache. After compression the original
|
||||
# read content is summarised away — if the model re-reads the same
|
||||
|
|
@ -971,6 +1073,126 @@ def compress_context(
|
|||
_release_lock()
|
||||
|
||||
|
||||
def _compress_context_via_codex_app_server(
|
||||
agent: Any,
|
||||
messages: list,
|
||||
system_message: Optional[str],
|
||||
*,
|
||||
approx_tokens: Optional[int] = None,
|
||||
task_id: str = "default",
|
||||
force: bool = False,
|
||||
) -> Tuple[list, str]:
|
||||
"""Route compaction to Codex app-server for Codex-owned threads.
|
||||
|
||||
Hermes' normal compressor rewrites the local OpenAI-style transcript.
|
||||
That does not shrink the actual Codex app-server thread context. For this
|
||||
runtime, ask Codex to compact its own thread and keep Hermes' transcript
|
||||
unchanged.
|
||||
"""
|
||||
auto_mode = str(
|
||||
getattr(agent, "codex_app_server_auto_compaction", "native") or "native"
|
||||
).lower()
|
||||
if auto_mode not in {"native", "hermes", "off"}:
|
||||
auto_mode = "native"
|
||||
if not force and auto_mode != "hermes":
|
||||
logger.info(
|
||||
"codex app-server compaction skipped: mode=%s force=false "
|
||||
"(session=%s messages=%d tokens=~%s)",
|
||||
auto_mode,
|
||||
getattr(agent, "session_id", None) or "none",
|
||||
len(messages),
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown",
|
||||
)
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
|
||||
codex_session = getattr(agent, "_codex_session", None)
|
||||
if codex_session is None:
|
||||
logger.info(
|
||||
"codex app-server compaction skipped: no active codex thread "
|
||||
"(session=%s messages=%d tokens=~%s)",
|
||||
getattr(agent, "session_id", None) or "none",
|
||||
len(messages),
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown",
|
||||
)
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
|
||||
logger.info(
|
||||
"codex app-server compaction started: session=%s messages=%d tokens=~%s",
|
||||
getattr(agent, "session_id", None) or "none",
|
||||
len(messages),
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown",
|
||||
)
|
||||
try:
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = codex_session.compact_thread()
|
||||
if getattr(result, "should_retire", False):
|
||||
try:
|
||||
codex_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
agent._codex_session = None
|
||||
|
||||
if getattr(result, "interrupted", False) or getattr(result, "error", None):
|
||||
try:
|
||||
agent._emit_warning(
|
||||
f"⚠ Codex app-server compaction failed: {result.error}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
|
||||
try:
|
||||
from agent.codex_runtime import (
|
||||
_record_codex_app_server_compaction,
|
||||
_record_codex_app_server_usage,
|
||||
)
|
||||
|
||||
_record_codex_app_server_compaction(
|
||||
agent,
|
||||
result,
|
||||
approx_tokens=approx_tokens,
|
||||
force=True,
|
||||
)
|
||||
# An empty usage report must consume the pending post-compaction verdict
|
||||
# rather than leaving preflight deferral armed until some unrelated later
|
||||
# Codex turn supplies usage. Minimal external test engines may not expose
|
||||
# the ContextEngine update hook; preserve their existing bookkeeping.
|
||||
if hasattr(agent.context_compressor, "update_from_response"):
|
||||
_record_codex_app_server_usage(agent, result)
|
||||
except Exception:
|
||||
logger.debug("codex compaction bookkeeping failed", exc_info=True)
|
||||
|
||||
try:
|
||||
from tools.file_tools import reset_file_dedup
|
||||
|
||||
reset_file_dedup(task_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"codex app-server compaction done: session=%s thread=%s turn=%s",
|
||||
getattr(agent, "session_id", None) or "none",
|
||||
getattr(result, "thread_id", None) or "",
|
||||
getattr(result, "turn_id", None) or "",
|
||||
)
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
|
||||
|
||||
def try_shrink_image_parts_in_messages(
|
||||
api_messages: list,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,12 @@ from agent.model_metadata import (
|
|||
)
|
||||
from agent.process_bootstrap import _install_safe_stdio
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff
|
||||
from agent.retry_utils import (
|
||||
adaptive_rate_limit_backoff,
|
||||
is_zai_coding_overload_error,
|
||||
jittered_backoff,
|
||||
zai_coding_overload_retry_ceiling,
|
||||
)
|
||||
from agent.trajectory import has_incomplete_scratchpad
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID
|
||||
|
|
@ -608,6 +613,11 @@ def run_conversation(
|
|||
truncated_response_parts: List[str] = []
|
||||
compression_attempts = 0
|
||||
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
|
||||
# Last composed answer intentionally held back by a verification gate. If
|
||||
# that continuation consumes the remaining budget, this is the best
|
||||
# user-facing result available; it must not be confused with error or
|
||||
# recovery text produced by unrelated exit paths.
|
||||
_pending_verification_response = None
|
||||
|
||||
# Per-turn tally of consecutive successful credential-pool token refreshes,
|
||||
# keyed by (provider, pool-entry-id). A persistent upstream 401 lets
|
||||
|
|
@ -1157,7 +1167,11 @@ def run_conversation(
|
|||
if agent._force_ascii_payload:
|
||||
_sanitize_structure_non_ascii(api_kwargs)
|
||||
if agent.api_mode == "codex_responses":
|
||||
api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False)
|
||||
api_kwargs = agent._get_transport().preflight_kwargs(
|
||||
api_kwargs,
|
||||
allow_stream=False,
|
||||
is_github_responses=agent._is_copilot_url(),
|
||||
)
|
||||
# Copilot x-initiator: the first API call of a user turn is
|
||||
# marked "user" so Copilot bills a premium request; tool-loop
|
||||
# follow-ups keep the default "agent" header (#3040).
|
||||
|
|
@ -1304,6 +1318,12 @@ def run_conversation(
|
|||
_use_streaming = False
|
||||
|
||||
def _perform_api_call(next_api_kwargs):
|
||||
if agent.api_mode == "codex_responses":
|
||||
next_api_kwargs = agent._get_transport().preflight_kwargs(
|
||||
next_api_kwargs,
|
||||
allow_stream=False,
|
||||
is_github_responses=agent._is_copilot_url(),
|
||||
)
|
||||
if _use_streaming:
|
||||
return agent._interruptible_streaming_api_call(
|
||||
next_api_kwargs, on_first_delta=_stop_spinner
|
||||
|
|
@ -2099,7 +2119,19 @@ def run_conversation(
|
|||
"reasoning_tokens": canonical_usage.reasoning_tokens,
|
||||
}
|
||||
agent.context_compressor.update_from_response(usage_dict)
|
||||
elif getattr(
|
||||
agent.context_compressor,
|
||||
"awaiting_real_usage_after_compression",
|
||||
False,
|
||||
):
|
||||
# A response with no usage cannot adjudicate whether the
|
||||
# prior compaction cleared the threshold. Consume the pending
|
||||
# verdict now so a much later, unrelated reading is not
|
||||
# charged to that old compaction, and so preflight deferral
|
||||
# does not remain latched indefinitely.
|
||||
agent.context_compressor.update_from_response({})
|
||||
|
||||
if hasattr(response, 'usage') and response.usage:
|
||||
# Cache discovered context length after successful call.
|
||||
# Only persist limits confirmed by the provider (parsed
|
||||
# from the error message), not guessed probe tiers.
|
||||
|
|
@ -3142,6 +3174,18 @@ def run_conversation(
|
|||
FailoverReason.timeout,
|
||||
FailoverReason.overloaded,
|
||||
}
|
||||
# Z.AI Coding Plan GLM-5.2 overload 429s classify as
|
||||
# `overloaded` (to spare the credential pool), but `overloaded`
|
||||
# is excluded from `is_rate_limited` — the gate for the adaptive
|
||||
# Z.AI backoff below. Detect the overload directly so its
|
||||
# long-backoff schedule runs, and raise the retry ceiling so the
|
||||
# long tier (30/60/90/120s) is reachable. See
|
||||
# zai_coding_overload_retry_ceiling() for the ceiling rationale.
|
||||
_is_zai_coding_overload = is_zai_coding_overload_error(
|
||||
base_url=str(_base), model=_model, error=api_error
|
||||
)
|
||||
if _is_zai_coding_overload:
|
||||
max_retries = max(max_retries, zai_coding_overload_retry_ceiling())
|
||||
_should_fallback = (
|
||||
is_rate_limited
|
||||
or (_is_transport_failure and retry_count >= 2)
|
||||
|
|
@ -4092,7 +4136,7 @@ def run_conversation(
|
|||
pass
|
||||
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
|
||||
_backoff_policy = None
|
||||
if is_rate_limited and not _retry_after:
|
||||
if (is_rate_limited or _is_zai_coding_overload) and not _retry_after:
|
||||
wait_time, _backoff_policy = adaptive_rate_limit_backoff(
|
||||
retry_count,
|
||||
base_url=str(_base),
|
||||
|
|
@ -4100,13 +4144,14 @@ def run_conversation(
|
|||
error=api_error,
|
||||
default_wait=wait_time,
|
||||
)
|
||||
if is_rate_limited:
|
||||
if is_rate_limited or _is_zai_coding_overload:
|
||||
_policy_note = ""
|
||||
if _backoff_policy == "zai_coding_overload_long":
|
||||
_policy_note = " (Z.AI Coding overload adaptive long backoff)"
|
||||
elif _backoff_policy == "zai_coding_overload_short":
|
||||
_policy_note = " (Z.AI Coding overload short retry)"
|
||||
_rate_limit_status = f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
|
||||
_wait_reason = "Provider overloaded" if _is_zai_coding_overload and not is_rate_limited else "Rate limited"
|
||||
_rate_limit_status = f"⏱️ {_wait_reason}. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
|
||||
# Normal retries are buffered to avoid noisy transient chatter. Long
|
||||
# Z.AI Coding waits are different: they can last minutes, so surface
|
||||
# progress immediately instead of making the TUI look frozen.
|
||||
|
|
@ -5044,8 +5089,11 @@ def run_conversation(
|
|||
# Reset retry counter/signature on successful content
|
||||
agent._empty_content_retries = 0
|
||||
agent._thinking_prefill_retries = 0
|
||||
# Successful content reached — drop any buffered retry
|
||||
# status from earlier failed attempts in this turn.
|
||||
# Successful content reached — surface the one-shot fallback
|
||||
# switch notice (if a fallback activated this turn) before
|
||||
# dropping the noisy retry buffer, so a provider/model switch
|
||||
# stays visible even when the fallback succeeds.
|
||||
agent._emit_pending_fallback_notice()
|
||||
agent._clear_status_buffer()
|
||||
|
||||
from agent.agent_runtime_helpers import (
|
||||
|
|
@ -5078,6 +5126,10 @@ def run_conversation(
|
|||
}
|
||||
messages.append(continue_msg)
|
||||
agent._session_messages = messages
|
||||
# An acknowledgment is explicitly non-final. Do not let its
|
||||
# text suppress iteration-limit summarization if this
|
||||
# continuation consumes the remaining budget.
|
||||
final_response = None
|
||||
continue
|
||||
|
||||
codex_ack_continuations = 0
|
||||
|
|
@ -5152,6 +5204,12 @@ def run_conversation(
|
|||
# terminal. Keep a debug breadcrumb in agent.log for tracing.
|
||||
logger.debug("verification stop-loop nudge issued (attempt %d)",
|
||||
agent._verification_stop_nudges)
|
||||
# Keep the attempted answer only as an explicit fallback for
|
||||
# continuation-budget exhaustion. ``final_response`` itself
|
||||
# must be cleared so the finalizer can distinguish this gate
|
||||
# from unrelated error/recovery exits. (#61631)
|
||||
_pending_verification_response = final_response
|
||||
final_response = None
|
||||
continue
|
||||
|
||||
# User verification-loop gate: when the agent edited code this
|
||||
|
|
@ -5203,6 +5261,8 @@ def run_conversation(
|
|||
agent._session_messages = messages
|
||||
logger.debug("pre_verify nudge issued (attempt %d)",
|
||||
agent._pre_verify_nudges)
|
||||
_pending_verification_response = final_response
|
||||
final_response = None
|
||||
continue
|
||||
|
||||
messages.append(final_msg)
|
||||
|
|
@ -5287,6 +5347,7 @@ def run_conversation(
|
|||
original_user_message=original_user_message,
|
||||
_should_review_memory=_should_review_memory,
|
||||
_turn_exit_reason=_turn_exit_reason,
|
||||
_pending_verification_response=_pending_verification_response,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,17 @@ _EXTRA_KEYS = frozenset({
|
|||
})
|
||||
|
||||
|
||||
def _normalize_pool_auth_type(provider: str, token: Any, auth_type: Any) -> str:
|
||||
"""Infer pool auth metadata for token formats with one unambiguous meaning."""
|
||||
if (
|
||||
provider == "anthropic"
|
||||
and isinstance(token, str)
|
||||
and token.startswith("sk-ant-oat")
|
||||
):
|
||||
return AUTH_TYPE_OAUTH
|
||||
return str(auth_type or AUTH_TYPE_API_KEY)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PooledCredential:
|
||||
provider: str
|
||||
|
|
@ -157,6 +168,11 @@ class PooledCredential:
|
|||
def __post_init__(self):
|
||||
if self.extra is None:
|
||||
self.extra = {}
|
||||
self.auth_type = _normalize_pool_auth_type(
|
||||
self.provider,
|
||||
self.access_token,
|
||||
self.auth_type,
|
||||
)
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
if name in _EXTRA_KEYS:
|
||||
|
|
@ -445,6 +461,44 @@ def get_pool_strategy(provider: str) -> str:
|
|||
return STRATEGY_FILL_FIRST
|
||||
|
||||
|
||||
def credential_pool_matches_provider(
|
||||
pool_or_provider: Any,
|
||||
provider: Optional[str],
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Return whether a pool belongs to the requested runtime provider.
|
||||
|
||||
Named custom endpoints intentionally use two identities: the live agent is
|
||||
``custom`` while its pool is keyed ``custom:<name>``. Accept that pair only
|
||||
when the runtime base URL resolves to the exact same custom pool key.
|
||||
Empty string identities fail closed. Legacy pool adapters without a
|
||||
``provider`` attribute remain compatible; production pools are scoped.
|
||||
"""
|
||||
raw_pool_provider = getattr(pool_or_provider, "provider", None)
|
||||
if raw_pool_provider is None:
|
||||
if isinstance(pool_or_provider, str):
|
||||
raw_pool_provider = pool_or_provider
|
||||
else:
|
||||
# Backward compatibility for lightweight/unscoped pool adapters.
|
||||
# Production CredentialPool instances always carry ``provider``;
|
||||
# old plugins and tests may expose only select()/has_credentials().
|
||||
return True
|
||||
pool_provider = str(raw_pool_provider or "").strip().lower()
|
||||
provider_norm = str(provider or "").strip().lower()
|
||||
if not pool_provider or not provider_norm:
|
||||
return False
|
||||
if pool_provider == provider_norm:
|
||||
return True
|
||||
if provider_norm != "custom" or not pool_provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
return False
|
||||
try:
|
||||
matched_pool = get_custom_provider_pool_key(base_url or "")
|
||||
except Exception:
|
||||
return False
|
||||
return str(matched_pool or "").strip().lower() == pool_provider
|
||||
|
||||
|
||||
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
|
||||
|
||||
|
||||
|
|
@ -1378,6 +1432,11 @@ class CredentialPool:
|
|||
entries_to_prune: List[str] = []
|
||||
available: List[PooledCredential] = []
|
||||
for entry in self._entries:
|
||||
# Borrowed credentials persist as metadata-only references and are
|
||||
# hydrated from their live source on load. A stale duplicate row
|
||||
# can remain unhydrated; never lease or select it as an empty key.
|
||||
if entry.auth_type == AUTH_TYPE_API_KEY and not entry.runtime_api_key:
|
||||
continue
|
||||
# For anthropic claude_code entries, sync from the credentials file
|
||||
# before any status/refresh checks. This picks up tokens refreshed
|
||||
# by other processes (Claude Code CLI, other Hermes profiles).
|
||||
|
|
@ -1694,11 +1753,15 @@ class CredentialPool:
|
|||
|
||||
|
||||
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
|
||||
existing_idx = None
|
||||
matching_indices = []
|
||||
for idx, entry in enumerate(entries):
|
||||
if entry.source == source:
|
||||
existing_idx = idx
|
||||
break
|
||||
matching_indices.append(idx)
|
||||
|
||||
existing_idx = matching_indices[0] if matching_indices else None
|
||||
duplicate_indices = set(matching_indices[1:])
|
||||
if duplicate_indices:
|
||||
entries[:] = [entry for idx, entry in enumerate(entries) if idx not in duplicate_indices]
|
||||
|
||||
if existing_idx is None:
|
||||
payload.setdefault("id", uuid.uuid4().hex[:6])
|
||||
|
|
@ -1730,8 +1793,8 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
|
|||
# Runtime-only borrowed secret updates should refresh the in-memory
|
||||
# entry without forcing auth.json churn when the disk-safe payload is
|
||||
# unchanged (for example env keys with the same fingerprint).
|
||||
return existing.to_dict() != updated.to_dict()
|
||||
return False
|
||||
return bool(duplicate_indices) or existing.to_dict() != updated.to_dict()
|
||||
return bool(duplicate_indices)
|
||||
|
||||
|
||||
def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool:
|
||||
|
|
@ -2205,12 +2268,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
|||
if _is_source_suppressed(provider, source):
|
||||
continue
|
||||
active_sources.add(source)
|
||||
# Claude Code OAuth tokens are the only Anthropic credentials that should flow into the OAuth refresh path.
|
||||
auth_type = (
|
||||
AUTH_TYPE_OAUTH
|
||||
if provider == "anthropic" and token.startswith("sk-ant-oat")
|
||||
else AUTH_TYPE_API_KEY
|
||||
)
|
||||
base_url = env_url or pconfig.inference_base_url
|
||||
if provider == "kimi-coding":
|
||||
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
|
||||
|
|
@ -2225,7 +2282,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
|||
env_var=env_var,
|
||||
token=token,
|
||||
base_url=base_url,
|
||||
auth_type=auth_type,
|
||||
),
|
||||
)
|
||||
return changed, active_sources
|
||||
|
|
@ -2353,16 +2409,37 @@ def load_pool(provider: str) -> CredentialPool:
|
|||
for payload in raw_entries
|
||||
)
|
||||
entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]
|
||||
raw_needs_auth_normalization = any(
|
||||
isinstance(payload, dict)
|
||||
and _normalize_pool_auth_type(
|
||||
provider,
|
||||
payload.get("access_token"),
|
||||
payload.get("auth_type", AUTH_TYPE_API_KEY),
|
||||
) != payload.get("auth_type", AUTH_TYPE_API_KEY)
|
||||
for payload in raw_entries
|
||||
)
|
||||
if raw_needs_auth_normalization:
|
||||
# A profile may be reading this provider from the global-root fallback.
|
||||
# Keep that fallback read-only: only the store that owns these rows may
|
||||
# rewrite them. Loading the default/root profile will heal global rows.
|
||||
active_pool = _load_auth_store().get("credential_pool")
|
||||
active_entries = active_pool.get(provider) if isinstance(active_pool, dict) else None
|
||||
raw_needs_auth_normalization = bool(active_entries)
|
||||
|
||||
if provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
# Custom endpoint pool — seed from custom_providers config and model config
|
||||
custom_changed, custom_sources = _seed_custom_pool(provider, entries)
|
||||
changed = raw_needs_sanitization or custom_changed
|
||||
changed = raw_needs_sanitization or raw_needs_auth_normalization or custom_changed
|
||||
changed |= _prune_stale_seeded_entries(entries, custom_sources)
|
||||
else:
|
||||
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
|
||||
env_changed, env_sources = _seed_from_env(provider, entries)
|
||||
changed = raw_needs_sanitization or singleton_changed or env_changed
|
||||
changed = (
|
||||
raw_needs_sanitization
|
||||
or raw_needs_auth_normalization
|
||||
or singleton_changed
|
||||
or env_changed
|
||||
)
|
||||
# ``load_pool()`` is a non-destructive read for env-seeded entries: a
|
||||
# process missing a provider env var must not delete the persisted
|
||||
# pool entry for every other process (#9331). File-backed singletons
|
||||
|
|
|
|||
|
|
@ -45,12 +45,26 @@ def _strip_aux_credential(value: Any) -> Optional[str]:
|
|||
|
||||
|
||||
class _ReviewRuntimeBinding(NamedTuple):
|
||||
"""Provider/model for the curator review fork plus optional per-slot overrides."""
|
||||
"""Provider/model for the curator review fork plus per-slot overrides."""
|
||||
|
||||
provider: str
|
||||
model: str
|
||||
explicit_api_key: Optional[str]
|
||||
explicit_base_url: Optional[str]
|
||||
request_overrides: Dict[str, Any]
|
||||
|
||||
|
||||
def _merge_request_overrides(
|
||||
runtime_overrides: Any,
|
||||
slot_extra_body: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Merge resolver metadata with task-local request body fields."""
|
||||
merged = dict(runtime_overrides or {})
|
||||
if isinstance(slot_extra_body, dict) and slot_extra_body:
|
||||
extra_body = dict(merged.get("extra_body") or {})
|
||||
extra_body.update(slot_extra_body)
|
||||
merged["extra_body"] = extra_body
|
||||
return merged
|
||||
|
||||
|
||||
DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
|
||||
|
|
@ -1764,6 +1778,7 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
|
|||
_task_model,
|
||||
_strip_aux_credential(_cur_task.get("api_key")),
|
||||
_strip_aux_credential(_cur_task.get("base_url")),
|
||||
_merge_request_overrides({}, _cur_task.get("extra_body")),
|
||||
)
|
||||
|
||||
# 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification)
|
||||
|
|
@ -1781,10 +1796,11 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
|
|||
str(_legacy_model),
|
||||
_strip_aux_credential(_legacy.get("api_key")),
|
||||
_strip_aux_credential(_legacy.get("base_url")),
|
||||
_merge_request_overrides({}, _legacy.get("extra_body")),
|
||||
)
|
||||
|
||||
# 3. Fall through to the main chat model
|
||||
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None)
|
||||
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None, {})
|
||||
|
||||
|
||||
def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:
|
||||
|
|
@ -1850,6 +1866,11 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
|||
_base_url = None
|
||||
_api_mode = None
|
||||
_resolved_provider = None
|
||||
_credential_pool = None
|
||||
_request_overrides: Dict[str, Any] = {}
|
||||
_max_tokens = None
|
||||
_acp_command = None
|
||||
_acp_args = None
|
||||
_model_name = ""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
|
@ -1867,6 +1888,16 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
|||
_base_url = _rp.get("base_url")
|
||||
_api_mode = _rp.get("api_mode")
|
||||
_resolved_provider = _rp.get("provider") or _provider
|
||||
_credential_pool = _rp.get("credential_pool")
|
||||
_request_overrides = _merge_request_overrides(
|
||||
_rp.get("request_overrides"),
|
||||
_binding.request_overrides.get("extra_body"),
|
||||
)
|
||||
_max_tokens = _rp.get("max_output_tokens")
|
||||
_acp_command = _rp.get("command")
|
||||
_acp_args = list(_rp.get("args") or [])
|
||||
if isinstance(_rp.get("model"), str) and _rp["model"].strip():
|
||||
_model_name = _rp["model"].strip()
|
||||
except Exception as e:
|
||||
logger.debug("Curator provider resolution failed: %s", e, exc_info=True)
|
||||
|
||||
|
|
@ -1875,12 +1906,21 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
|||
|
||||
review_agent = None
|
||||
try:
|
||||
_agent_kwargs: Dict[str, Any] = {}
|
||||
if isinstance(_max_tokens, int):
|
||||
_agent_kwargs["max_tokens"] = _max_tokens
|
||||
if isinstance(_acp_command, str) and _acp_command:
|
||||
_agent_kwargs["acp_command"] = _acp_command
|
||||
_agent_kwargs["acp_args"] = _acp_args or []
|
||||
review_agent = AIAgent(
|
||||
model=_model_name,
|
||||
provider=_resolved_provider,
|
||||
api_key=_api_key,
|
||||
base_url=_base_url,
|
||||
api_mode=_api_mode,
|
||||
credential_pool=_credential_pool,
|
||||
request_overrides=_request_overrides,
|
||||
**_agent_kwargs,
|
||||
# Umbrella-building over a large skill collection is worth a
|
||||
# high iteration ceiling — the pass typically takes 50-100
|
||||
# API calls against hundreds of candidate skills. The
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
_ANSI_RESET = "\033[0m"
|
||||
|
||||
|
||||
def _display_url(value: Any) -> str:
|
||||
"""Extract a display-only URL without assuming model argument types."""
|
||||
if isinstance(value, dict):
|
||||
value = value.get("url") or value.get("href")
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
# Diff colors — resolved lazily from the skin engine so they adapt
|
||||
# to light/dark themes. Falls back to sensible defaults on import
|
||||
# failure. We cache after first resolution for performance.
|
||||
|
|
@ -515,6 +523,16 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
|||
msg = msg[:17] + "..."
|
||||
return f"to {target}: \"{msg}\""
|
||||
|
||||
if tool_name == "skill_view":
|
||||
name = _oneline(str(args.get("name") or ""))
|
||||
file_path = args.get("file_path")
|
||||
if file_path:
|
||||
file_path = _oneline(str(file_path))
|
||||
preview = f"{name} → {file_path}" if name else file_path
|
||||
else:
|
||||
preview = name
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
key = primary_args.get(tool_name)
|
||||
if not key:
|
||||
for fallback_key in ("query", "text", "command", "path", "name", "prompt", "code", "goal"):
|
||||
|
|
@ -1249,7 +1267,7 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
|
|||
return False, ""
|
||||
|
||||
|
||||
def get_cute_tool_message(
|
||||
def _get_cute_tool_message(
|
||||
tool_name: str, args: dict, duration: float, result: str | None = None,
|
||||
) -> str:
|
||||
"""Generate a formatted tool completion line for CLI quiet mode.
|
||||
|
|
@ -1291,9 +1309,11 @@ def get_cute_tool_message(
|
|||
if tool_name == "web_extract":
|
||||
urls = args.get("urls", [])
|
||||
if urls:
|
||||
url = urls[0] if isinstance(urls, list) else str(urls)
|
||||
url = _display_url(urls[0] if isinstance(urls, list) else urls)
|
||||
if not url:
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
|
||||
extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else ""
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
|
|
@ -1384,7 +1404,11 @@ def get_cute_tool_message(
|
|||
if tool_name == "skills_list":
|
||||
return _wrap(f"┊ 📚 skills list {args.get('category', 'all')} {dur}")
|
||||
if tool_name == "skill_view":
|
||||
return _wrap(f"┊ 📚 skill {_trunc(args.get('name', ''), 30)} {dur}")
|
||||
label = args.get("name", "")
|
||||
file_path = args.get("file_path")
|
||||
if file_path:
|
||||
label = f"{label} → {file_path}" if label else str(file_path)
|
||||
return _wrap(f"┊ 📚 skill {_trunc(label, 44)} {dur}")
|
||||
if tool_name == "image_generate":
|
||||
return _wrap(f"┊ 🎨 create {_trunc(args.get('prompt', ''), 35)} {dur}")
|
||||
if tool_name == "text_to_speech":
|
||||
|
|
@ -1419,6 +1443,19 @@ def get_cute_tool_message(
|
|||
return _wrap(f"┊ ⚡ {tool_name[:9]:9} {_trunc(preview, 35)} {dur}")
|
||||
|
||||
|
||||
def get_cute_tool_message(
|
||||
tool_name: str, args: dict, duration: float, result: str | None = None,
|
||||
) -> str:
|
||||
"""Render a completion label without letting cosmetic failures escape."""
|
||||
try:
|
||||
return _get_cute_tool_message(tool_name, args, duration, result=result)
|
||||
except Exception as exc: # noqa: BLE001 — display must never abort a turn
|
||||
logger.debug("Tool completion label failed for %s: %s", tool_name, exc)
|
||||
safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else "tool"
|
||||
safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else "done"
|
||||
return f"┊ ⚡ {safe_name:9} completed {safe_duration}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Honcho session line (one-liner with clickable OSC 8 hyperlink)
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -1147,6 +1147,7 @@ def _classify_400(
|
|||
"encrypted content for item" in error_msg
|
||||
and "could not be verified" in error_msg
|
||||
)
|
||||
or "could not decrypt the provided encrypted_content" in error_msg
|
||||
):
|
||||
return result_fn(
|
||||
FailoverReason.invalid_encrypted_content,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ Usage:
|
|||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime
|
||||
|
|
@ -141,8 +142,8 @@ class InsightsEngine:
|
|||
}
|
||||
|
||||
# Compute insights
|
||||
overview = self._compute_overview(sessions, message_stats)
|
||||
models = self._compute_model_breakdown(sessions)
|
||||
models = self._compute_model_breakdown(sessions, cutoff, source)
|
||||
overview = self._compute_overview(sessions, message_stats, models)
|
||||
platforms = self._compute_platform_breakdown(sessions)
|
||||
tools = self._compute_tool_breakdown(tool_usage)
|
||||
skills = self._compute_skill_breakdown(skill_usage)
|
||||
|
|
@ -172,7 +173,7 @@ class InsightsEngine:
|
|||
"message_count, tool_call_count, input_tokens, output_tokens, "
|
||||
"cache_read_tokens, cache_write_tokens, billing_provider, "
|
||||
"billing_base_url, billing_mode, estimated_cost_usd, "
|
||||
"actual_cost_usd, cost_status, cost_source")
|
||||
"actual_cost_usd, cost_status, cost_source, api_call_count")
|
||||
|
||||
# Pre-computed query strings — f-string evaluated once at class definition,
|
||||
# not at runtime, so no user-controlled value can alter the query structure.
|
||||
|
|
@ -399,7 +400,12 @@ class InsightsEngine:
|
|||
# Computation
|
||||
# =========================================================================
|
||||
|
||||
def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict:
|
||||
def _compute_overview(
|
||||
self,
|
||||
sessions: List[Dict],
|
||||
message_stats: Dict,
|
||||
models: Optional[List[Dict]] = None,
|
||||
) -> Dict:
|
||||
"""Compute high-level overview statistics."""
|
||||
total_input = sum(s.get("input_tokens") or 0 for s in sessions)
|
||||
total_output = sum(s.get("output_tokens") or 0 for s in sessions)
|
||||
|
|
@ -431,6 +437,9 @@ class InsightsEngine:
|
|||
else:
|
||||
models_without_pricing.add(display)
|
||||
|
||||
if models:
|
||||
total_cost = sum(float(m.get("cost") or 0.0) for m in models)
|
||||
|
||||
# Session duration stats (guard against negative durations from clock drift)
|
||||
durations = []
|
||||
for s in sessions:
|
||||
|
|
@ -473,39 +482,189 @@ class InsightsEngine:
|
|||
"included_cost_sessions": included_cost_sessions,
|
||||
}
|
||||
|
||||
def _compute_model_breakdown(self, sessions: List[Dict]) -> List[Dict]:
|
||||
"""Break down usage by model."""
|
||||
_GET_MODEL_USAGE_WITH_SOURCE = (
|
||||
"SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url,"
|
||||
" u.api_call_count, u.input_tokens, u.output_tokens,"
|
||||
" u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens,"
|
||||
" u.estimated_cost_usd, u.actual_cost_usd, u.cost_status,"
|
||||
" u.cost_source, u.billing_mode"
|
||||
" FROM session_model_usage u"
|
||||
" JOIN sessions s ON s.id = u.session_id"
|
||||
" WHERE s.started_at >= ? AND s.source = ?"
|
||||
)
|
||||
_GET_MODEL_USAGE_ALL = (
|
||||
"SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url,"
|
||||
" u.api_call_count, u.input_tokens, u.output_tokens,"
|
||||
" u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens,"
|
||||
" u.estimated_cost_usd, u.actual_cost_usd, u.cost_status,"
|
||||
" u.cost_source, u.billing_mode"
|
||||
" FROM session_model_usage u"
|
||||
" JOIN sessions s ON s.id = u.session_id"
|
||||
" WHERE s.started_at >= ?"
|
||||
)
|
||||
|
||||
def _get_model_usage(self, cutoff: float, source: str = None) -> List[Dict]:
|
||||
"""Fetch per-model usage rows within the window (issue #51607).
|
||||
|
||||
Returns an empty list when the table is missing (e.g. a DB opened by
|
||||
older code that never created it) so the caller can fall back to the
|
||||
per-session aggregate.
|
||||
"""
|
||||
try:
|
||||
if source:
|
||||
cursor = self._conn.execute(
|
||||
self._GET_MODEL_USAGE_WITH_SOURCE, (cutoff, source)
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute(self._GET_MODEL_USAGE_ALL, (cutoff,))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
except sqlite3.OperationalError:
|
||||
return []
|
||||
|
||||
def _compute_model_breakdown(
|
||||
self, sessions: List[Dict], cutoff: float, source: str = None
|
||||
) -> List[Dict]:
|
||||
"""Break down token usage and cost by model.
|
||||
|
||||
Tokens and cost are attributed per model from session_model_usage, so a
|
||||
session that switched models mid-flight (via ``/model``) splits across
|
||||
every model it used instead of dumping everything on the initial model
|
||||
(issue #51607). Sessions without per-model rows — e.g. data written
|
||||
before this table existed and not yet backfilled — fall back to their
|
||||
single recorded (model, billing_provider) aggregate so nothing is lost.
|
||||
|
||||
Tool calls aren't tied to a specific API invocation, so they stay
|
||||
attributed to the session's recorded model.
|
||||
"""
|
||||
model_data = defaultdict(lambda: {
|
||||
"sessions": 0, "input_tokens": 0, "output_tokens": 0,
|
||||
"sessions": set(), "input_tokens": 0, "output_tokens": 0,
|
||||
"cache_read_tokens": 0, "cache_write_tokens": 0,
|
||||
"total_tokens": 0, "tool_calls": 0, "cost": 0.0,
|
||||
"reasoning_tokens": 0, "total_tokens": 0, "api_calls": 0,
|
||||
"tool_calls": 0, "cost": 0.0, "actual_cost": 0.0,
|
||||
})
|
||||
|
||||
for s in sessions:
|
||||
model = s.get("model") or "unknown"
|
||||
def _accumulate(model, provider, base_url, session_id, inp, out,
|
||||
cache_read, cache_write, reasoning, *,
|
||||
stored_cost=None, actual_cost=None, cost_status=None):
|
||||
model = model or "unknown"
|
||||
# Normalize: strip provider prefix for display
|
||||
display_model = model.split("/")[-1] if "/" in model else model
|
||||
d = model_data[display_model]
|
||||
d["sessions"] += 1
|
||||
inp = s.get("input_tokens") or 0
|
||||
out = s.get("output_tokens") or 0
|
||||
cache_read = s.get("cache_read_tokens") or 0
|
||||
cache_write = s.get("cache_write_tokens") or 0
|
||||
d: Dict[str, Any] = model_data[display_model]
|
||||
d["sessions"].add(session_id)
|
||||
d["input_tokens"] += inp
|
||||
d["output_tokens"] += out
|
||||
d["cache_read_tokens"] += cache_read
|
||||
d["cache_write_tokens"] += cache_write
|
||||
d["reasoning_tokens"] += reasoning
|
||||
d["total_tokens"] += inp + out + cache_read + cache_write
|
||||
d["tool_calls"] += s.get("tool_call_count") or 0
|
||||
estimate, status = _estimate_cost(s)
|
||||
if stored_cost is None:
|
||||
estimate, status = _estimate_cost(
|
||||
model, inp, out,
|
||||
cache_read_tokens=cache_read, cache_write_tokens=cache_write,
|
||||
provider=provider or None, base_url=base_url,
|
||||
)
|
||||
else:
|
||||
estimate = float(stored_cost or 0.0)
|
||||
status = cost_status or "unknown"
|
||||
d["cost"] += estimate
|
||||
d["has_pricing"] = has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url"))
|
||||
d["actual_cost"] += float(actual_cost or 0.0)
|
||||
d["cost_status"] = status
|
||||
if has_known_pricing(model, provider or None, base_url):
|
||||
d["has_pricing"] = True
|
||||
else:
|
||||
d.setdefault("has_pricing", False)
|
||||
return display_model
|
||||
|
||||
result = [
|
||||
{"model": model, **data}
|
||||
for model, data in model_data.items()
|
||||
]
|
||||
usage_rows = self._get_model_usage(cutoff, source)
|
||||
usage_totals = defaultdict(lambda: {
|
||||
"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0,
|
||||
"cache_write_tokens": 0, "reasoning_tokens": 0,
|
||||
"api_call_count": 0, "estimated_cost_usd": 0.0,
|
||||
"actual_cost_usd": 0.0,
|
||||
})
|
||||
for r in usage_rows:
|
||||
totals: Dict[str, Any] = usage_totals[r["session_id"]]
|
||||
for key in (
|
||||
"input_tokens", "output_tokens", "cache_read_tokens",
|
||||
"cache_write_tokens", "reasoning_tokens", "api_call_count",
|
||||
):
|
||||
totals[key] += r[key] or 0
|
||||
totals["estimated_cost_usd"] += r["estimated_cost_usd"] or 0.0
|
||||
totals["actual_cost_usd"] += r["actual_cost_usd"] or 0.0
|
||||
d = _accumulate(
|
||||
r["model"], r["billing_provider"], r.get("billing_base_url"),
|
||||
r["session_id"], r["input_tokens"] or 0, r["output_tokens"] or 0,
|
||||
r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0,
|
||||
r["reasoning_tokens"] or 0,
|
||||
stored_cost=(
|
||||
r["estimated_cost_usd"]
|
||||
if r.get("cost_status") or r.get("cost_source")
|
||||
else None
|
||||
),
|
||||
actual_cost=r["actual_cost_usd"],
|
||||
cost_status=r.get("cost_status"),
|
||||
)
|
||||
model_data[d]["api_calls"] += r["api_call_count"] or 0
|
||||
|
||||
# Reconcile against the aggregate row. This covers legacy sessions,
|
||||
# interrupted migrations, and absolute cumulative updates without
|
||||
# double-counting already-attributed route deltas.
|
||||
for s in sessions:
|
||||
totals = usage_totals[s["id"]]
|
||||
inp = max(0, (s.get("input_tokens") or 0) - totals["input_tokens"])
|
||||
out = max(0, (s.get("output_tokens") or 0) - totals["output_tokens"])
|
||||
cache_read = max(
|
||||
0, (s.get("cache_read_tokens") or 0) - totals["cache_read_tokens"]
|
||||
)
|
||||
cache_write = max(
|
||||
0, (s.get("cache_write_tokens") or 0) - totals["cache_write_tokens"]
|
||||
)
|
||||
residual_cost = max(
|
||||
0.0, float(s.get("estimated_cost_usd") or 0.0)
|
||||
- totals["estimated_cost_usd"],
|
||||
)
|
||||
residual_actual = max(
|
||||
0.0, float(s.get("actual_cost_usd") or 0.0)
|
||||
- totals["actual_cost_usd"],
|
||||
)
|
||||
residual_calls = max(
|
||||
0, (s.get("api_call_count") or 0) - totals["api_call_count"]
|
||||
)
|
||||
if not (
|
||||
inp or out or cache_read or cache_write or residual_cost
|
||||
or residual_actual or residual_calls
|
||||
):
|
||||
continue
|
||||
d = _accumulate(
|
||||
s.get("model"), s.get("billing_provider"),
|
||||
s.get("billing_base_url"), s["id"],
|
||||
inp, out, cache_read, cache_write, 0,
|
||||
stored_cost=residual_cost,
|
||||
actual_cost=residual_actual,
|
||||
cost_status=s.get("cost_status"),
|
||||
)
|
||||
residual_bucket: Dict[str, Any] = model_data[d]
|
||||
residual_bucket["api_calls"] += residual_calls
|
||||
|
||||
# Tool calls are attributed by the session's recorded model.
|
||||
for s in sessions:
|
||||
tool_calls = s.get("tool_call_count") or 0
|
||||
if not tool_calls:
|
||||
continue
|
||||
model = s.get("model") or "unknown"
|
||||
display_model = model.split("/")[-1] if "/" in model else model
|
||||
model_data[display_model]["tool_calls"] += tool_calls
|
||||
|
||||
result = []
|
||||
for model, data in model_data.items():
|
||||
entry = {"model": model, **data}
|
||||
entry["sessions"] = len(data["sessions"])
|
||||
# Models that surfaced only via tool-call attribution (no token
|
||||
# rows) won't have these set by _accumulate — default them so the
|
||||
# output shape is uniform for downstream/JSON consumers.
|
||||
entry.setdefault("has_pricing", False)
|
||||
entry.setdefault("cost_status", "unknown")
|
||||
result.append(entry)
|
||||
# Sort by tokens first, fall back to session count when tokens are 0
|
||||
result.sort(key=lambda x: (x["total_tokens"], x["sessions"]), reverse=True)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -348,17 +348,15 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
|||
pip_target.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
logger.info("[install] pip install --target %s %s", pip_target, pkg)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
proc = _pip_install(
|
||||
["--target", str(pip_target), "--quiet", pkg],
|
||||
timeout=300,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"[install] pip install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
||||
"[install] pip install failed for %s: %s", pkg, (proc.stderr or "").strip()[:500]
|
||||
)
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
|
|
|
|||
|
|
@ -783,6 +783,55 @@ class MemoryManager:
|
|||
exc_info=True,
|
||||
)
|
||||
|
||||
def commit_session_boundary_async(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
new_session_id: str,
|
||||
parent_session_id: str = "",
|
||||
reason: str = "new_session",
|
||||
) -> None:
|
||||
"""Queue old-session extraction + provider rebinding as ONE serialized task.
|
||||
|
||||
Session rotation (/new) must deliver ``on_session_end`` (end-of-session
|
||||
extraction — an LLM-bound call that can take seconds) strictly BEFORE
|
||||
``on_session_switch`` (which rebinds provider-internal ``_session_id`` /
|
||||
turn buffers to the new session). Running extraction inline blocked the
|
||||
/new command for the whole LLM round-trip (#16454); running it on an
|
||||
ad-hoc thread raced the inline switch — providers key off internal
|
||||
state, so a late ``on_session_end`` ran against post-switch bindings
|
||||
(transcript misattributed to the new session id, double-ingest of the
|
||||
old turn buffer, new-session buffers cleared).
|
||||
|
||||
Submitting BOTH hooks as one task on the manager's single background
|
||||
worker gives both properties at a single chokepoint: the caller returns
|
||||
immediately, and the worker's FIFO order serializes end→switch against
|
||||
every other provider write (per-turn ``sync_all``, prefetches), which
|
||||
already share the same worker. If the executor is unavailable,
|
||||
``_submit_background`` degrades to inline execution — the pre-#16454
|
||||
synchronous behavior, slow but correct.
|
||||
"""
|
||||
if not self._providers:
|
||||
return
|
||||
snapshot = list(messages or [])
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
self.on_session_end(snapshot)
|
||||
except Exception as e: # pragma: no cover - on_session_end guards per-provider
|
||||
logger.warning("Session-boundary extraction failed: %s", e)
|
||||
try:
|
||||
self.on_session_switch(
|
||||
new_session_id,
|
||||
parent_session_id=parent_session_id,
|
||||
reset=True,
|
||||
reason=reason,
|
||||
)
|
||||
except Exception as e: # pragma: no cover - on_session_switch guards per-provider
|
||||
logger.warning("Session-boundary switch failed: %s", e)
|
||||
|
||||
self._submit_background(_run)
|
||||
|
||||
def on_session_switch(
|
||||
self,
|
||||
new_session_id: str,
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ _REFERENCE_SYSTEM_PROMPT = (
|
|||
|
||||
|
||||
def _slot_label(slot: dict[str, str]) -> str:
|
||||
return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}"
|
||||
return f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}"
|
||||
|
||||
|
||||
def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -111,6 +111,15 @@ _MODEL_CACHE_TTL = 3600
|
|||
_endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||||
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
|
||||
_ENDPOINT_MODEL_CACHE_TTL = 300
|
||||
# Bounded-lifetime cache: after the first successful probe we remember the
|
||||
# server type so subsequent refreshes skip the full waterfall (no more 404
|
||||
# spam every 5 minutes on non-matching endpoints like /api/v1/models on vllm).
|
||||
# Entries expire after _ENDPOINT_PROBE_TTL_SECONDS so a server swap on the
|
||||
# same port (stop Ollama, start LM Studio) is eventually re-detected instead
|
||||
# of being pinned to the stale type for the whole process lifetime.
|
||||
# Values are (server_type, monotonic_timestamp).
|
||||
_ENDPOINT_PROBE_TTL_SECONDS = 3600.0
|
||||
_endpoint_probe_path_cache: Dict[str, tuple] = {}
|
||||
|
||||
|
||||
def _get_model_metadata_cache_path() -> Path:
|
||||
|
|
@ -220,6 +229,12 @@ DEFAULT_CONTEXT_LENGTHS = {
|
|||
# ChatGPT Codex OAuth caps it at 272K; both paths resolve via their own
|
||||
# provider-aware branches (_resolve_codex_oauth_context_length + models.dev).
|
||||
# This hardcoded value is only reached when every probe misses.
|
||||
# GPT-5.6 series (Sol/Terra/Luna, GA 2026-07-09) — 1.05M on the direct
|
||||
# OpenAI API (same as gpt-5.5). Codex OAuth caps these at 272K.
|
||||
# (Lookups length-sort keys at match time, so dict order is cosmetic.)
|
||||
"gpt-5.6-luna": 1050000,
|
||||
"gpt-5.6-terra": 1050000,
|
||||
"gpt-5.6-sol": 1050000,
|
||||
"gpt-5.5": 1050000,
|
||||
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
|
||||
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
|
||||
|
|
@ -289,11 +304,13 @@ DEFAULT_CONTEXT_LENGTHS = {
|
|||
# Premium+); /v1/responses additionally enforces a ~262144 input+output
|
||||
# budget, but the usable context (what we track here) is 200k.
|
||||
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
|
||||
"grok-build-latest": 500000, # alias of grok-4.5 (early access)
|
||||
"grok-build": 256000, # grok-build-0.1
|
||||
"grok-code-fast": 256000, # grok-code-fast-1
|
||||
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
|
||||
"grok-4-fast": 2000000, # grok-4-fast-(non-)reasoning, also matches -reasoning
|
||||
"grok-4.20": 2000000, # grok-4.20-0309-(non-)reasoning, -multi-agent-0309
|
||||
"grok-4.5": 500000, # grok-4.5, grok-4.5-latest — 500K context per docs.x.ai
|
||||
"grok-4.3": 1000000, # grok-4.3, grok-4.3-latest — 1M context per docs.x.ai
|
||||
"grok-4": 256000, # grok-4, grok-4-0709
|
||||
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
|
||||
|
|
@ -305,6 +322,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
|||
# OpenRouter live metadata reports 262144 (256 × 1024); align the
|
||||
# static fallback so cache and offline both agree (issue #22268).
|
||||
"hy3-preview": 262144,
|
||||
# Tencent — Hy3 (GA successor to Hy3 Preview), same 256K window.
|
||||
"hy3": 262144,
|
||||
# Nemotron — NVIDIA's open-weights series (128K context across all sizes)
|
||||
"nemotron": 131072,
|
||||
# Arcee
|
||||
|
|
@ -345,6 +364,11 @@ _GROK_EFFORT_CAPABLE_PREFIXES = (
|
|||
"grok-3-mini",
|
||||
"grok-4.20-multi-agent",
|
||||
"grok-4.3",
|
||||
# grok-4.5: verified live against /v1/responses 2026-07-08 — accepts
|
||||
# effort low/medium/high (default: high when omitted) but REJECTS
|
||||
# "none" ("This model does not support `reasoning_effort` value `none`"),
|
||||
# unlike grok-4.3. models.dev agrees: effort values [low, medium, high].
|
||||
"grok-4.5",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -623,66 +647,109 @@ def is_local_endpoint(base_url: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _localhost_to_ipv4(url: str) -> str:
|
||||
"""Rewrite a ``localhost`` HOST to ``127.0.0.1`` in a probe URL.
|
||||
|
||||
On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1``
|
||||
first and pays a ~2s IPv6 connect timeout before falling back to IPv4
|
||||
when the local server only listens on IPv4 (LM Studio, Ollama defaults).
|
||||
Probing the IPv4 loopback directly skips that penalty.
|
||||
|
||||
Only the URL's own host component is rewritten (anchored at the scheme),
|
||||
so a non-localhost URL whose path or query merely embeds the substring
|
||||
``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``)
|
||||
passes through untouched.
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
return re.sub(
|
||||
r"^(https?://)localhost(?=[:/]|$)",
|
||||
r"\g<1>127.0.0.1",
|
||||
url,
|
||||
count=1,
|
||||
)
|
||||
|
||||
|
||||
def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
|
||||
"""Detect which local server is running at base_url by probing known endpoints.
|
||||
|
||||
Returns one of: "ollama", "lm-studio", "vllm", "llamacpp", or None.
|
||||
|
||||
The result is cached for the lifetime of the process so that repeated
|
||||
calls (e.g. every 5-minute metadata refresh) never re-run the waterfall
|
||||
and never spray 404s at endpoints the server does not expose.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
normalized = _normalize_base_url(base_url)
|
||||
|
||||
# Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack.
|
||||
# Applied to ``normalized`` before deriving server/LM Studio URLs AND
|
||||
# before the cache lookup, so localhost and 127.0.0.1 share a cache entry.
|
||||
normalized = _localhost_to_ipv4(normalized)
|
||||
|
||||
server_url = normalized
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
lmstudio_url = _lmstudio_server_root(base_url)
|
||||
lmstudio_url = _lmstudio_server_root(normalized)
|
||||
|
||||
cached = _endpoint_probe_path_cache.get(server_url)
|
||||
if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS:
|
||||
return cached[0]
|
||||
|
||||
headers = _auth_headers(api_key)
|
||||
|
||||
result: Optional[str] = None
|
||||
try:
|
||||
with httpx.Client(timeout=2.0, headers=headers) as client:
|
||||
# LM Studio exposes /api/v1/models — check first (most specific)
|
||||
try:
|
||||
r = client.get(f"{lmstudio_url}/api/v1/models")
|
||||
if r.status_code == 200:
|
||||
return "lm-studio"
|
||||
result = "lm-studio"
|
||||
except Exception:
|
||||
pass
|
||||
# Ollama exposes /api/tags and responds with {"models": [...]}
|
||||
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
|
||||
# on this path, so we must verify the response contains "models".
|
||||
try:
|
||||
r = client.get(f"{server_url}/api/tags")
|
||||
if r.status_code == 200:
|
||||
try:
|
||||
if result is None:
|
||||
# Ollama exposes /api/tags and responds with {"models": [...]}
|
||||
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
|
||||
# on this path, so we must verify the response contains "models".
|
||||
try:
|
||||
r = client.get(f"{server_url}/api/tags")
|
||||
if r.status_code == 200:
|
||||
try:
|
||||
data = r.json()
|
||||
if "models" in data:
|
||||
result = "ollama"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
if result is None:
|
||||
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
|
||||
try:
|
||||
r = client.get(f"{server_url}/v1/props")
|
||||
if r.status_code != 200:
|
||||
r = client.get(f"{server_url}/props") # fallback for older builds
|
||||
if r.status_code == 200 and "default_generation_settings" in r.text:
|
||||
result = "llamacpp"
|
||||
except Exception:
|
||||
pass
|
||||
if result is None:
|
||||
# vLLM: /version
|
||||
try:
|
||||
r = client.get(f"{server_url}/version")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
if "models" in data:
|
||||
return "ollama"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
|
||||
try:
|
||||
r = client.get(f"{server_url}/v1/props")
|
||||
if r.status_code != 200:
|
||||
r = client.get(f"{server_url}/props") # fallback for older builds
|
||||
if r.status_code == 200 and "default_generation_settings" in r.text:
|
||||
return "llamacpp"
|
||||
except Exception:
|
||||
pass
|
||||
# vLLM: /version
|
||||
try:
|
||||
r = client.get(f"{server_url}/version")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
if "version" in data:
|
||||
return "vllm"
|
||||
except Exception:
|
||||
pass
|
||||
if "version" in data:
|
||||
result = "vllm"
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
if result is not None:
|
||||
_endpoint_probe_path_cache[server_url] = (result, time.monotonic())
|
||||
return result
|
||||
|
||||
|
||||
def _iter_nested_dicts(value: Any):
|
||||
|
|
@ -786,7 +853,10 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
|
|||
return _model_metadata_cache
|
||||
|
||||
try:
|
||||
response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify())
|
||||
# Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per
|
||||
# retry stage through proxies that 403 CONNECT, ballooning to minutes
|
||||
# (#46620). 5s connect / 10s read fails fast on unreachable hosts.
|
||||
response = requests.get(OPENROUTER_MODELS_URL, timeout=(5, 10), verify=_resolve_requests_verify())
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
|
@ -864,7 +934,7 @@ def fetch_endpoint_model_metadata(
|
|||
response = requests.get(
|
||||
server_url.rstrip("/") + "/api/v1/models",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
timeout=(5, 10),
|
||||
verify=_resolve_requests_verify(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
|
@ -912,7 +982,7 @@ def fetch_endpoint_model_metadata(
|
|||
for candidate in candidates:
|
||||
url = candidate.rstrip("/") + "/models"
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
|
||||
response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
|
@ -1007,19 +1077,29 @@ def _load_context_cache() -> Dict[str, int]:
|
|||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return data.get("context_lengths", {})
|
||||
return data.get("context_lengths") or {}
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load context length cache: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _context_cache_key(model: str, base_url: str) -> str:
|
||||
"""Canonical ``model@base_url`` key for the persistent context cache.
|
||||
|
||||
Trailing slashes are stripped so ``http://host/v1`` and
|
||||
``http://host/v1/`` share one entry instead of creating duplicates
|
||||
that can go stale independently.
|
||||
"""
|
||||
return f"{model}@{(base_url or '').rstrip('/')}"
|
||||
|
||||
|
||||
def save_context_length(model: str, base_url: str, length: int) -> None:
|
||||
"""Persist a discovered context length for a model+provider combo.
|
||||
|
||||
Cache key is ``model@base_url`` so the same model name served from
|
||||
different providers can have different limits.
|
||||
"""
|
||||
key = f"{model}@{base_url}"
|
||||
key = _context_cache_key(model, base_url)
|
||||
cache = _load_context_cache()
|
||||
if cache.get(key) == length:
|
||||
return # already stored
|
||||
|
|
@ -1036,18 +1116,43 @@ def save_context_length(model: str, base_url: str, length: int) -> None:
|
|||
|
||||
def get_cached_context_length(model: str, base_url: str) -> Optional[int]:
|
||||
"""Look up a previously discovered context length for model+provider."""
|
||||
key = f"{model}@{base_url}"
|
||||
key = _context_cache_key(model, base_url)
|
||||
cache = _load_context_cache()
|
||||
return cache.get(key)
|
||||
hit = cache.get(key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
# Legacy rows written before key normalization may carry a trailing
|
||||
# slash — honor them rather than re-probing. Checked regardless of the
|
||||
# caller's slash form: the row's shape and the caller's shape can differ
|
||||
# in either direction (old slashed row + new normalized config, or the
|
||||
# reverse), so probe the literal form and the slashed canonical form.
|
||||
for legacy_key in (f"{model}@{base_url}", f"{key}/"):
|
||||
if legacy_key != key:
|
||||
hit = cache.get(legacy_key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
def _invalidate_cached_context_length(model: str, base_url: str) -> None:
|
||||
"""Drop a stale cache entry so it gets re-resolved on the next lookup."""
|
||||
key = f"{model}@{base_url}"
|
||||
key = _context_cache_key(model, base_url)
|
||||
cache = _load_context_cache()
|
||||
if key not in cache:
|
||||
# Invalidation must also drop the in-memory TTL probe entries for this
|
||||
# pair — otherwise the next resolution inside the TTL window reuses the
|
||||
# very value we just declared stale and re-persists it.
|
||||
bare = _strip_provider_prefix(model)
|
||||
stripped = (base_url or "").rstrip("/")
|
||||
_LOCAL_CTX_PROBE_CACHE.pop((bare, stripped), None)
|
||||
_LOCAL_CTX_PROBE_CACHE.pop(("ollama_show", bare, stripped), None)
|
||||
# Clear every key shape for this pair: canonical, the caller's literal
|
||||
# form, and the slashed legacy form — same set get_cached_context_length
|
||||
# consults, so a lookup can never resurrect a row invalidation missed.
|
||||
stale_keys = {key, f"{model}@{base_url}", f"{key}/"}
|
||||
if not any(k in cache for k in stale_keys):
|
||||
return
|
||||
del cache[key]
|
||||
for k in stale_keys:
|
||||
cache.pop(k, None)
|
||||
path = _get_context_cache_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -1334,7 +1439,7 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option
|
|||
import httpx
|
||||
|
||||
bare_model = _strip_provider_prefix(model)
|
||||
server_url = base_url.rstrip("/")
|
||||
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
|
||||
|
|
@ -1395,7 +1500,7 @@ def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") -
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
server_url = base_url.rstrip("/")
|
||||
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
|
||||
|
|
@ -1434,6 +1539,12 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
|
|||
hosting behind a reverse proxy, etc. For non-Ollama servers the POST
|
||||
returns 404/405 quickly; the function handles errors gracefully.
|
||||
|
||||
Results are cached in ``_LOCAL_CTX_PROBE_CACHE`` (same 30s TTL,
|
||||
positive-only — see ``_query_local_context_length``) so back-to-back
|
||||
resolutions during one startup issue a single POST instead of one per
|
||||
call site. Failures are never memoized: a server that isn't up yet must
|
||||
be re-probed once it comes up.
|
||||
|
||||
For hosted servers the GGUF ``model_info.*.context_length`` is the
|
||||
authoritative source: the user can't set their own ``num_ctx``, and the
|
||||
OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length``
|
||||
|
|
@ -1445,9 +1556,28 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
|
|||
The order is flipped vs ``query_ollama_num_ctx()`` because local users
|
||||
control ``num_ctx`` themselves; hosted users can't.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
# Namespaced cache key: shares the TTL store with
|
||||
# _query_local_context_length but never collides with its (model, url)
|
||||
# keys — the two probes can return different values for the same pair.
|
||||
cache_key = ("ollama_show", _strip_provider_prefix(model), base_url.rstrip("/"))
|
||||
now = _time.monotonic()
|
||||
cached = _LOCAL_CTX_PROBE_CACHE.get(cache_key)
|
||||
if cached is not None and (now - cached[1]) < _LOCAL_CTX_PROBE_TTL_SECONDS:
|
||||
return cached[0]
|
||||
|
||||
result = _query_ollama_api_show_uncached(model, base_url, api_key=api_key)
|
||||
if result: # positive-only — never memoize a failed probe
|
||||
_LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now)
|
||||
return result
|
||||
|
||||
|
||||
def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]:
|
||||
"""Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``."""
|
||||
import httpx
|
||||
|
||||
server_url = base_url.rstrip("/")
|
||||
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
|
||||
|
|
@ -1566,10 +1696,10 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str
|
|||
model = _strip_provider_prefix(model)
|
||||
|
||||
# Strip /v1 suffix to get the server root
|
||||
server_url = base_url.rstrip("/")
|
||||
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
lmstudio_url = _lmstudio_server_root(base_url)
|
||||
lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url))
|
||||
|
||||
headers = _auth_headers(api_key)
|
||||
|
||||
|
|
@ -1679,7 +1809,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) ->
|
|||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
|
||||
resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
|
|
@ -1713,6 +1843,9 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
|
|||
"gpt-5.3-codex-spark": 128_000,
|
||||
"gpt-5.2-codex": 272_000,
|
||||
"gpt-5.4-mini": 272_000,
|
||||
"gpt-5.6-sol": 272_000,
|
||||
"gpt-5.6-terra": 272_000,
|
||||
"gpt-5.6-luna": 272_000,
|
||||
"gpt-5.5": 272_000,
|
||||
"gpt-5.4": 272_000,
|
||||
"gpt-5.2": 272_000,
|
||||
|
|
@ -1746,7 +1879,7 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
|
|||
resp = requests.get(
|
||||
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=10,
|
||||
timeout=(5, 10),
|
||||
verify=_resolve_requests_verify(),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
|
|
@ -2430,5 +2563,82 @@ def estimate_request_tokens_rough(
|
|||
if messages:
|
||||
total += estimate_messages_tokens_rough(messages)
|
||||
if tools:
|
||||
total += (len(str(tools)) + 3) // 4
|
||||
total += _estimate_tools_tokens_rough(tools)
|
||||
return total
|
||||
|
||||
|
||||
# NOTE: tool schemas can be large. Avoid repeated `str(tools)` conversions,
|
||||
# which are CPU-heavy and can stall GUI event loops under GIL pressure.
|
||||
#
|
||||
# Keyed by ``id(tools)``. A long-lived gateway/desktop backend builds many
|
||||
# transient tool lists over its lifetime, so the cache is bounded and evicts
|
||||
# oldest-first (insertion-ordered dict) once it exceeds the cap. The cap is
|
||||
# generous relative to how rarely toolsets are rebuilt within a process.
|
||||
_TOOLS_TOKENS_CACHE: dict[int, Tuple[int, str, str, int]] = {}
|
||||
_TOOLS_TOKENS_CACHE_MAX = 256
|
||||
|
||||
|
||||
def _tool_name_for_cache(tool: Any) -> str:
|
||||
if not isinstance(tool, dict):
|
||||
return ""
|
||||
fn = tool.get("function")
|
||||
if isinstance(fn, dict):
|
||||
name = fn.get("name")
|
||||
if isinstance(name, str):
|
||||
return name
|
||||
name = tool.get("name")
|
||||
return name if isinstance(name, str) else ""
|
||||
|
||||
|
||||
def _estimate_tools_tokens_rough(tools: List[Dict[str, Any]]) -> int:
|
||||
if not tools:
|
||||
return 0
|
||||
|
||||
# Cache by list identity. Tools are rebuilt rarely (toolset changes),
|
||||
# but token estimates are requested frequently (preflight, compaction).
|
||||
key = id(tools)
|
||||
n = len(tools)
|
||||
first = _tool_name_for_cache(tools[0]) if n else ""
|
||||
last = _tool_name_for_cache(tools[-1]) if n else ""
|
||||
|
||||
cached = _TOOLS_TOKENS_CACHE.get(key)
|
||||
if cached is not None:
|
||||
cached_n, cached_first, cached_last, cached_tokens = cached
|
||||
if cached_n == n and cached_first == first and cached_last == last:
|
||||
return cached_tokens
|
||||
|
||||
# Fast, stable rough estimate: sum lengths of the major schema fields.
|
||||
# This avoids the pathological `str(tools)` path while still scaling with
|
||||
# schema size (descriptions + parameters dominate).
|
||||
total_chars = 0
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
fn = tool.get("function")
|
||||
if isinstance(fn, dict):
|
||||
name = fn.get("name") or ""
|
||||
desc = fn.get("description") or ""
|
||||
params = fn.get("parameters") or {}
|
||||
else:
|
||||
name = tool.get("name") or ""
|
||||
desc = tool.get("description") or ""
|
||||
params = tool.get("parameters") or {}
|
||||
|
||||
if isinstance(name, str):
|
||||
total_chars += len(name)
|
||||
if isinstance(desc, str):
|
||||
total_chars += len(desc)
|
||||
# Parameters can be nested; JSON is closer to over-the-wire size than repr().
|
||||
try:
|
||||
total_chars += len(json.dumps(params, ensure_ascii=False, separators=(",", ":")))
|
||||
except Exception:
|
||||
total_chars += len(str(params))
|
||||
|
||||
tokens = (total_chars + 3) // 4
|
||||
# Bound the cache: drop the oldest entry when the cap is exceeded so a
|
||||
# long-running process can't accumulate an unbounded number of stale
|
||||
# ``id(tools)`` entries (id values are recycled after GC anyway).
|
||||
if len(_TOOLS_TOKENS_CACHE) >= _TOOLS_TOKENS_CACHE_MAX:
|
||||
_TOOLS_TOKENS_CACHE.pop(next(iter(_TOOLS_TOKENS_CACHE)), None)
|
||||
_TOOLS_TOKENS_CACHE[key] = (n, first, last, tokens)
|
||||
return tokens
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ assemble pieces, then combines them with memory and ephemeral prompts.
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import contextvars
|
||||
from collections import OrderedDict
|
||||
|
|
@ -17,6 +18,8 @@ from typing import Optional
|
|||
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
from agent.skill_utils import (
|
||||
EXCLUDED_SKILL_DIRS,
|
||||
SKILL_SUPPORT_DIRS,
|
||||
extract_skill_conditions,
|
||||
extract_skill_description,
|
||||
get_all_skills_dirs,
|
||||
|
|
@ -25,6 +28,7 @@ from agent.skill_utils import (
|
|||
parse_frontmatter,
|
||||
skill_matches_environment,
|
||||
skill_matches_platform,
|
||||
skill_matches_platform_list,
|
||||
)
|
||||
from utils import atomic_json_write
|
||||
|
||||
|
|
@ -743,6 +747,17 @@ PLATFORM_HINTS = {
|
|||
"or 'all'). Do not promise the user that a deliver='origin' or "
|
||||
"default-deliver cron job will message them in this session."
|
||||
),
|
||||
"desktop": (
|
||||
"You are chatting inside the Hermes desktop app — a graphical chat "
|
||||
"surface, not a terminal. Use markdown freely: it renders with full "
|
||||
"GitHub flavor (tables, code blocks with syntax highlighting, math "
|
||||
"via $...$, task lists, blockquote callouts). "
|
||||
"You can deliver files natively — include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.png, .jpg, .webp) appear inline, audio and "
|
||||
"video play inline, and other files arrive as download links. You can "
|
||||
"also include image URLs in markdown format  and they "
|
||||
"render inline as photos."
|
||||
),
|
||||
"sms": (
|
||||
"You are communicating via SMS. Keep responses concise and use plain text "
|
||||
"only — no markdown, no formatting. SMS messages are limited to ~1600 "
|
||||
|
|
@ -1127,22 +1142,6 @@ def build_environment_hints() -> str:
|
|||
f"`uname -a && whoami && pwd`."
|
||||
)
|
||||
|
||||
# Hermes desktop GUI — any agent running under the desktop app should know
|
||||
# it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
|
||||
# marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
|
||||
_truthy = ("1", "true", "yes")
|
||||
_in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
|
||||
_in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
|
||||
if _in_desktop or _in_desktop_term:
|
||||
_desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
|
||||
if _in_desktop_term:
|
||||
_desktop_hint += (
|
||||
" You're in its embedded terminal pane, beside the GUI chat — the user can "
|
||||
"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
|
||||
"⌘/Ctrl+L to send it to the chat composer."
|
||||
)
|
||||
hints.append(_desktop_hint)
|
||||
|
||||
if is_wsl():
|
||||
hints.append(WSL_ENVIRONMENT_HINT)
|
||||
|
||||
|
|
@ -1276,13 +1275,26 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
|
|||
def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
|
||||
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
|
||||
manifest: dict[str, list[int]] = {}
|
||||
for filename in ("SKILL.md", "DESCRIPTION.md"):
|
||||
for path in iter_skill_index_files(skills_dir, filename):
|
||||
skills_dir_str = str(skills_dir)
|
||||
base = os.path.join(skills_dir_str, "")
|
||||
prefix_len = len(base)
|
||||
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
|
||||
has_skill_md = "SKILL.md" in files
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if d not in EXCLUDED_SKILL_DIRS
|
||||
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
|
||||
]
|
||||
for filename in ("SKILL.md", "DESCRIPTION.md"):
|
||||
if filename not in files:
|
||||
continue
|
||||
path = os.path.join(root, filename)
|
||||
try:
|
||||
st = path.stat()
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size]
|
||||
manifest[path[prefix_len:]] = [st.st_mtime_ns, st.st_size]
|
||||
return manifest
|
||||
|
||||
|
||||
|
|
@ -1414,6 +1426,22 @@ def _skill_should_show(
|
|||
return True
|
||||
|
||||
|
||||
def _current_session_platform_hint() -> str:
|
||||
"""Return the active platform without importing the gateway package on CLI startup."""
|
||||
platform = os.environ.get("HERMES_PLATFORM") or os.environ.get("HERMES_SESSION_PLATFORM")
|
||||
if platform:
|
||||
return platform
|
||||
|
||||
session_context = sys.modules.get("gateway.session_context")
|
||||
get_session_env = getattr(session_context, "get_session_env", None) if session_context else None
|
||||
if get_session_env is None:
|
||||
return ""
|
||||
try:
|
||||
return get_session_env("HERMES_SESSION_PLATFORM") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def build_skills_system_prompt(
|
||||
available_tools: "set[str] | None" = None,
|
||||
available_toolsets: "set[str] | None" = None,
|
||||
|
|
@ -1448,15 +1476,10 @@ def build_skills_system_prompt(
|
|||
# ── Layer 1: in-process LRU cache ─────────────────────────────────
|
||||
# Include the resolved platform so per-platform disabled-skill lists
|
||||
# produce distinct cache entries (gateway serves multiple platforms).
|
||||
from gateway.session_context import get_session_env
|
||||
_platform_hint = (
|
||||
os.environ.get("HERMES_PLATFORM")
|
||||
or get_session_env("HERMES_SESSION_PLATFORM")
|
||||
or ""
|
||||
)
|
||||
_platform_hint = _current_session_platform_hint()
|
||||
disabled = get_disabled_skill_names(_platform_hint or None)
|
||||
cache_key = (
|
||||
str(skills_dir.resolve()),
|
||||
str(skills_dir),
|
||||
tuple(str(d) for d in external_dirs),
|
||||
tuple(sorted(str(t) for t in (available_tools or set()))),
|
||||
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
||||
|
|
@ -1485,7 +1508,7 @@ def build_skills_system_prompt(
|
|||
category = entry.get("category") or "general"
|
||||
frontmatter_name = entry.get("frontmatter_name") or skill_name
|
||||
platforms = entry.get("platforms") or []
|
||||
if not skill_matches_platform({"platforms": platforms}):
|
||||
if not skill_matches_platform_list(platforms):
|
||||
continue
|
||||
if frontmatter_name in disabled or skill_name in disabled:
|
||||
continue
|
||||
|
|
|
|||
56
agent/reactions.py
Normal file
56
agent/reactions.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Token-free detection of user *reactions* to the agent.
|
||||
|
||||
Currently the only reaction is ``vibe`` — an expression of affection or
|
||||
gratitude toward the agent (``ily``, ``<3``, ``love you``, ``good bot``, a heart
|
||||
emoji, …). Detection is a curated regex/lexicon: **no model call, no tokens**.
|
||||
|
||||
This is the single source of truth shared by every surface — the CLI pet, the
|
||||
TUI heart, and the desktop floating hearts all react off the same signal,
|
||||
delivered via ``AIAgent.reaction_callback`` (wired per interactive host).
|
||||
|
||||
Generalized on purpose: :func:`detect_reaction` returns a reaction *kind*
|
||||
string, so new kinds (other emoji reactions, etc.) can be added here without
|
||||
touching any caller. We match affection specifically — not general positive
|
||||
sentiment — so "this is great" does NOT fire, but "good bot" / "❤️" do.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
#: The affection/gratitude reaction — the only kind today.
|
||||
VIBE = "vibe"
|
||||
|
||||
# Curated affection lexicon. Kept deliberately narrow: gratitude + love aimed at
|
||||
# the agent, heart emoji, and ``<3`` (but not the broken heart ``</3``).
|
||||
_VIBE_RE = re.compile(
|
||||
"|".join(
|
||||
(
|
||||
r"\bgood\s*bot\b",
|
||||
r"\bi\s*(?:love|luv)\s*(?:you|u|ya)\b",
|
||||
r"\b(?:love|luv)\s*(?:you|u|ya)\b",
|
||||
r"\bily(?:sm)?\b",
|
||||
r"\bthank\s*(?:you|u)\b",
|
||||
r"\b(?:thanks|thx|tysm|ty)\b",
|
||||
r"<3+", # <3, <33 … but not </3
|
||||
# Hearts + affection faces (❤ ♥ 🥰 😍 😘 💕 💖 💗 💞 💛 💜 💚 💙 💓 💘 💝 🩷).
|
||||
r"[\u2764\u2665"
|
||||
r"\U0001F970\U0001F60D\U0001F618"
|
||||
r"\U0001F495\U0001F496\U0001F497\U0001F49E"
|
||||
r"\U0001F49B\U0001F49C\U0001F49A\U0001F499"
|
||||
r"\U0001F493\U0001F498\U0001F49D\U0001FA77]",
|
||||
)
|
||||
),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def detect_reaction(text: str | None) -> str | None:
|
||||
"""Return the reaction kind for *text* (currently :data:`VIBE`), or ``None``.
|
||||
|
||||
Pure, token-free, and safe to call on every user turn.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
return VIBE if _VIBE_RE.search(text) else None
|
||||
|
|
@ -66,9 +66,13 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
|
|||
("nemotron-3-ultra", 600),
|
||||
("nemotron-3-super", 600),
|
||||
("nemotron-3-nano", 300),
|
||||
# DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct.
|
||||
# DeepSeek — R1 and V4 reasoning models on hosted NIM / DeepSeek direct.
|
||||
# V4 series emits reasoning_content in a separate delta field before
|
||||
# final content, requiring the same extended stale timeout floor.
|
||||
("deepseek-r1", 600),
|
||||
("deepseek-reasoner", 600),
|
||||
("deepseek-v4-flash", 600),
|
||||
("deepseek-v4-pro", 600),
|
||||
# Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B
|
||||
# preview is the stable slug; ``qwen3`` covers the family of
|
||||
# thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.)
|
||||
|
|
@ -190,6 +194,10 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:
|
|||
300.0
|
||||
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-r1")
|
||||
600.0
|
||||
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-flash")
|
||||
600.0
|
||||
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-pro")
|
||||
600.0
|
||||
>>> get_reasoning_stale_timeout_floor("qwen/qwen3-235b-a22b-thinking")
|
||||
180.0
|
||||
>>> get_reasoning_stale_timeout_floor("x-ai/grok-4-fast-reasoning")
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ from __future__ import annotations
|
|||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from agent.tool_dispatch_helpers import make_tool_result_message
|
||||
from agent.tool_result_classification import tool_may_have_side_effect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -64,8 +67,40 @@ def strip_interrupted_tool_tails(
|
|||
is_interrupted_tool_result(m.get("content", ""))
|
||||
for m in tool_results
|
||||
):
|
||||
calls = msg.get("tool_calls") or []
|
||||
if any(
|
||||
tool_may_have_side_effect(
|
||||
str((call.get("function") or {}).get("name") or "")
|
||||
)
|
||||
for call in calls
|
||||
):
|
||||
call_names = {
|
||||
str(call.get("id") or call.get("call_id") or ""): str(
|
||||
(call.get("function") or {}).get("name") or ""
|
||||
)
|
||||
for call in calls
|
||||
}
|
||||
cleaned.append(msg)
|
||||
for tool_result in tool_results:
|
||||
if not is_interrupted_tool_result(tool_result.get("content", "")):
|
||||
cleaned.append(tool_result)
|
||||
continue
|
||||
recovered = dict(tool_result)
|
||||
name = call_names.get(str(tool_result.get("tool_call_id") or ""), "")
|
||||
recovered["effect_disposition"] = (
|
||||
"unknown" if tool_may_have_side_effect(name) else "none"
|
||||
)
|
||||
recovered["content"] = (
|
||||
"[Orphan recovery: interrupted side-effecting tool may have "
|
||||
"executed; its effect is UNKNOWN. Inspect state before retrying.]"
|
||||
if recovered["effect_disposition"] == "unknown"
|
||||
else "[Orphan recovery: interrupted read-only tool did not complete.]"
|
||||
)
|
||||
cleaned.append(recovered)
|
||||
i = j
|
||||
continue
|
||||
logger.debug(
|
||||
"Stripping interrupted assistant→tool replay block "
|
||||
"Stripping interrupted read-only assistant→tool replay block "
|
||||
"(indices %d–%d, tool_results=%d)",
|
||||
i, j - 1, len(tool_results),
|
||||
)
|
||||
|
|
@ -116,11 +151,36 @@ def strip_dangling_tool_call_tail(
|
|||
):
|
||||
return agent_history
|
||||
|
||||
tool_calls = last.get("tool_calls") or []
|
||||
if any(
|
||||
tool_may_have_side_effect(
|
||||
str((call.get("function") or {}).get("name") or "")
|
||||
)
|
||||
for call in tool_calls
|
||||
):
|
||||
recovered = list(agent_history)
|
||||
for call in tool_calls:
|
||||
function = call.get("function") or {}
|
||||
name = str(function.get("name") or "unknown")
|
||||
call_id = str(call.get("id") or call.get("call_id") or "")
|
||||
disposition = "unknown" if tool_may_have_side_effect(name) else "none"
|
||||
content = (
|
||||
"[Orphan recovery: this tool may have executed before Hermes stopped; "
|
||||
"its effect is UNKNOWN. Inspect current state before retrying.]"
|
||||
if disposition == "unknown"
|
||||
else "[Orphan recovery: this read-only tool did not complete and had no effect.]"
|
||||
)
|
||||
recovered.append(make_tool_result_message(
|
||||
name, content, call_id, effect_disposition=disposition,
|
||||
))
|
||||
logger.warning(
|
||||
"Recovered dangling side-effecting tool call(s) as UNKNOWN instead of erasing them"
|
||||
)
|
||||
return recovered
|
||||
|
||||
logger.debug(
|
||||
"Stripping dangling unanswered assistant(tool_calls) tail "
|
||||
"(%d call(s)) — process likely killed mid-tool-call by a "
|
||||
"restart/shutdown command (#49201)",
|
||||
len(last.get("tool_calls") or []),
|
||||
"Stripping dangling unanswered read-only assistant(tool_calls) tail (%d call(s))",
|
||||
len(tool_calls),
|
||||
)
|
||||
return agent_history[:-1]
|
||||
|
||||
|
|
@ -138,3 +198,120 @@ def sanitize_replay_history(
|
|||
if not agent_history:
|
||||
return agent_history
|
||||
return strip_dangling_tool_call_tail(strip_interrupted_tool_tails(agent_history))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Stale dangerous-confirmation text expiry (#59607)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# How long a high-risk confirmation phrase remains valid.
|
||||
# Short on purpose: dangerous side effects should not survive any restart
|
||||
# or session resumption gap. The user can always re-confirm if needed.
|
||||
_DANGEROUS_CONFIRMATION_EXPIRY_SECONDS = 60.0
|
||||
|
||||
# Confirmation phrases that unlock destructive host actions.
|
||||
# Substring match (case-insensitive) so that user variants (e.g. trailing
|
||||
# punctuation, additional context) still match. Add new patterns here when
|
||||
# new high-risk actions are introduced.
|
||||
_DANGEROUS_CONFIRMATION_PATTERNS: tuple = (
|
||||
"confirm forced restart",
|
||||
"confirm forced reboot",
|
||||
"confirm shutdown",
|
||||
"confirm reboot",
|
||||
"confirm power off",
|
||||
"yes, delete everything",
|
||||
"confirm wipe",
|
||||
"confirm factory reset",
|
||||
# i18n variants observed in the original incident
|
||||
"確認強制重開機",
|
||||
"確認強制重開",
|
||||
"確認重啟",
|
||||
)
|
||||
|
||||
# Replacement text for an expired confirmation. Redacting in place (rather
|
||||
# than deleting the message) preserves strict user/assistant role
|
||||
# alternation in the replayed history.
|
||||
_EXPIRED_CONFIRMATION_SENTINEL = (
|
||||
"[A high-risk confirmation previously given here has EXPIRED and must "
|
||||
"not be acted on. Ask the user to re-confirm explicitly before "
|
||||
"performing any destructive action.]"
|
||||
)
|
||||
|
||||
|
||||
def is_dangerous_confirmation(content: Any) -> bool:
|
||||
"""Return True if a user-message text matches a known dangerous confirmation.
|
||||
|
||||
Used by ``strip_stale_dangerous_confirmations`` to decide which
|
||||
transcript rows to expire. Substring + case-insensitive so that
|
||||
``"Please confirm forced restart, the host is critical"`` still matches.
|
||||
"""
|
||||
if not isinstance(content, str):
|
||||
return False
|
||||
text = content.strip().lower()
|
||||
return any(pattern in text for pattern in _DANGEROUS_CONFIRMATION_PATTERNS)
|
||||
|
||||
|
||||
def strip_stale_dangerous_confirmations(
|
||||
agent_history: List[Dict[str, Any]],
|
||||
*,
|
||||
now: float,
|
||||
expiry_seconds: float = _DANGEROUS_CONFIRMATION_EXPIRY_SECONDS,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Expire stale dangerous-confirmation text in user messages (#59607).
|
||||
|
||||
When a high-risk side effect (e.g. host restart via ``shutdown.exe``)
|
||||
runs, the user's plain-text confirmation phrase is persisted in the
|
||||
conversation transcript. If the host restart killed the gateway
|
||||
process before the assistant's tool result was written, the
|
||||
transcript tail ends on the assistant's text response — and the
|
||||
dangerous confirmation text remains in the user role.
|
||||
|
||||
On the next inbound message — possibly a casual "are you there?" from
|
||||
the user minutes later — the LLM sees the stale confirmation and may
|
||||
interpret the new turn as a fresh re-confirmation, re-executing the
|
||||
destructive action. This is the failure mode reported in #59607.
|
||||
|
||||
Expired confirmations are REDACTED IN PLACE, not removed: deleting a
|
||||
user message from the incident tail (``user(confirm) →
|
||||
assistant("OK, restarting")``) would leave two consecutive assistant
|
||||
messages, violating the strict role-alternation invariant providers
|
||||
enforce. The message survives with its role intact; only the trigger
|
||||
text is replaced by a sentinel that tells the model the confirmation
|
||||
has expired.
|
||||
|
||||
Messages without a timestamp are left untouched (backward
|
||||
compatibility: legacy transcripts and in-memory test scaffolding have
|
||||
no timestamps). User messages that contain dangerous confirmation
|
||||
text but are within the expiry window are also left untouched — they
|
||||
represent a fresh confirmation that has not yet been acted on.
|
||||
|
||||
Complements 75ed07ace (which strips the *assistant* side of the
|
||||
broken tail) by handling the *user* side: a stale plain-text
|
||||
confirmation that the assistant has not yet responded to in a way
|
||||
the resume logic recognises.
|
||||
"""
|
||||
if not agent_history:
|
||||
return agent_history
|
||||
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
for msg in agent_history:
|
||||
if (
|
||||
isinstance(msg, dict)
|
||||
and msg.get("role") == "user"
|
||||
and is_dangerous_confirmation(msg.get("content", ""))
|
||||
):
|
||||
ts = msg.get("timestamp")
|
||||
if ts is not None and (now - float(ts)) > expiry_seconds:
|
||||
logger.debug(
|
||||
"Redacting stale dangerous-confirmation text in user "
|
||||
"message (age=%.1fs, expiry=%.1fs): %r",
|
||||
now - float(ts),
|
||||
expiry_seconds,
|
||||
(msg.get("content") or "")[:80],
|
||||
)
|
||||
redacted = dict(msg)
|
||||
redacted["content"] = _EXPIRED_CONFIRMATION_SENTINEL
|
||||
cleaned.append(redacted)
|
||||
continue
|
||||
cleaned.append(msg)
|
||||
return cleaned
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ _jitter_lock = threading.Lock()
|
|||
# not sit silent for 20+ minutes.
|
||||
_ZAI_CODING_OVERLOAD_LONG_BACKOFF = (30.0, 60.0, 90.0, 120.0)
|
||||
|
||||
# Number of initial short retries before the adaptive long-backoff tier kicks
|
||||
# in. Shared by ``adaptive_rate_limit_backoff`` (which walks the long table
|
||||
# starting at attempt ``short_attempts + 1``) and
|
||||
# ``zai_coding_overload_retry_ceiling`` (which sizes the retry loop so every
|
||||
# long-tier entry is reachable). Keeping it a single module constant prevents
|
||||
# the two from silently desyncing if the short-retry count is ever tuned.
|
||||
_ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS = 3
|
||||
|
||||
|
||||
def jittered_backoff(
|
||||
attempt: int,
|
||||
|
|
@ -104,7 +112,7 @@ def adaptive_rate_limit_backoff(
|
|||
model: str | None,
|
||||
error: Any,
|
||||
default_wait: float,
|
||||
short_attempts: int = 3,
|
||||
short_attempts: int = _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS,
|
||||
) -> tuple[float, str | None]:
|
||||
"""Provider-aware rate-limit backoff.
|
||||
|
||||
|
|
@ -127,3 +135,20 @@ def adaptive_rate_limit_backoff(
|
|||
# A smaller jitter ratio keeps long waits readable while still avoiding
|
||||
# synchronized retry storms across concurrent Hermes sessions.
|
||||
return jittered_backoff(1, base_delay=base_delay, max_delay=base_delay, jitter_ratio=0.2), "zai_coding_overload_long"
|
||||
|
||||
|
||||
def zai_coding_overload_retry_ceiling(short_attempts: int = _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS) -> int:
|
||||
"""Retry-loop ceiling needed for the full Z.AI overload backoff schedule.
|
||||
|
||||
The adaptive policy runs ``short_attempts`` short retries, then walks the
|
||||
long-backoff table one entry per subsequent attempt. The retry loop gives
|
||||
up as soon as ``retry_count >= ceiling`` — and that check runs *before* the
|
||||
attempt's backoff is computed — so the ceiling must sit one past the final
|
||||
long-backoff entry for every long tier to actually execute.
|
||||
|
||||
With the default ``api_max_retries`` (3) equal to ``short_attempts`` (3),
|
||||
the loop always gave up before reaching the long tier, leaving the whole
|
||||
long-backoff schedule as dead code. Callers extend the ceiling to this
|
||||
value for Z.AI Coding overload 429s so the 30/60/90/120s waits run.
|
||||
"""
|
||||
return short_attempts + len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) + 1
|
||||
|
|
|
|||
|
|
@ -224,6 +224,15 @@ def register_from_config(
|
|||
if not isinstance(cfg, dict):
|
||||
return []
|
||||
|
||||
# Safe mode (--safe-mode / HERMES_SAFE_MODE=1): shell hooks are user
|
||||
# customizations too — skip registration entirely so a troubleshooting
|
||||
# run fires zero user-configured code (plugins, MCP, AND hooks).
|
||||
from utils import env_var_enabled
|
||||
|
||||
if env_var_enabled("HERMES_SAFE_MODE"):
|
||||
logger.info("HERMES_SAFE_MODE=1 — shell-hook registration skipped")
|
||||
return []
|
||||
|
||||
effective_accept = _resolve_effective_accept(cfg, accept_hooks)
|
||||
|
||||
specs = _parse_hooks_block(cfg.get("hooks"))
|
||||
|
|
|
|||
|
|
@ -143,37 +143,9 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu
|
|||
|
||||
try:
|
||||
from tools.skills_tool import SKILLS_DIR, skill_view
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
from agent.skill_utils import normalize_skill_lookup_name
|
||||
|
||||
identifier_path = Path(raw_identifier).expanduser()
|
||||
if identifier_path.is_absolute():
|
||||
normalized = None
|
||||
trusted_roots = [SKILLS_DIR]
|
||||
try:
|
||||
trusted_roots.extend(get_external_skills_dirs())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Prefer the lexical path under a trusted skill root before
|
||||
# resolving symlinks. Slash-command discovery can legitimately
|
||||
# find a skill via ~/.hermes/skills/<name> where <name> is a
|
||||
# symlink to a checked-out skill elsewhere. Resolving first turns
|
||||
# that trusted visible path into an arbitrary absolute path that
|
||||
# skill_view() refuses to load.
|
||||
for root in trusted_roots:
|
||||
try:
|
||||
normalized = str(identifier_path.relative_to(root))
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if normalized is None:
|
||||
try:
|
||||
normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve()))
|
||||
except Exception:
|
||||
normalized = raw_identifier
|
||||
else:
|
||||
normalized = raw_identifier.lstrip("/")
|
||||
normalized = normalize_skill_lookup_name(raw_identifier)
|
||||
|
||||
loaded_skill = json.loads(
|
||||
skill_view(normalized, task_id=task_id, preprocess=False)
|
||||
|
|
|
|||
|
|
@ -160,27 +160,8 @@ def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
|
|||
# ── Platform matching ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
|
||||
"""Return True when the skill is compatible with the current OS.
|
||||
|
||||
Skills declare platform requirements via a top-level ``platforms`` list
|
||||
in their YAML frontmatter::
|
||||
|
||||
platforms: [macos] # macOS only
|
||||
platforms: [macos, linux] # macOS and Linux
|
||||
|
||||
If the field is absent or empty the skill is compatible with **all**
|
||||
platforms (backward-compatible default).
|
||||
|
||||
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
|
||||
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
|
||||
Linux userland riding on the Android kernel, so skills tagged
|
||||
``linux`` are treated as compatible in Termux regardless of which
|
||||
``sys.platform`` value Python reports. Individual Linux commands
|
||||
inside a skill may still misbehave (no systemd, BusyBox utils, no
|
||||
apt/dnf, etc.) but that is on the skill, not on platform gating.
|
||||
"""
|
||||
platforms = frontmatter.get("platforms")
|
||||
def skill_matches_platform_list(platforms: Any) -> bool:
|
||||
"""Return True when *platforms* is compatible with the current OS."""
|
||||
if not platforms:
|
||||
return True
|
||||
if not isinstance(platforms, list):
|
||||
|
|
@ -204,6 +185,29 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
|
||||
"""Return True when the skill is compatible with the current OS.
|
||||
|
||||
Skills declare platform requirements via a top-level ``platforms`` list
|
||||
in their YAML frontmatter::
|
||||
|
||||
platforms: [macos] # macOS only
|
||||
platforms: [macos, linux] # macOS and Linux
|
||||
|
||||
If the field is absent or empty the skill is compatible with **all**
|
||||
platforms (backward-compatible default).
|
||||
|
||||
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
|
||||
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
|
||||
Linux userland riding on the Android kernel, so skills tagged
|
||||
``linux`` are treated as compatible in Termux regardless of which
|
||||
``sys.platform`` value Python reports. Individual Linux commands
|
||||
inside a skill may still misbehave (no systemd, BusyBox utils, no
|
||||
apt/dnf, etc.) but that is on the skill, not on platform gating.
|
||||
"""
|
||||
return skill_matches_platform_list(frontmatter.get("platforms"))
|
||||
|
||||
|
||||
# ── Environment matching ──────────────────────────────────────────────────
|
||||
|
||||
# Recognized environment tags and how each is detected. An environment tag is
|
||||
|
|
@ -507,6 +511,63 @@ def get_all_skills_dirs() -> List[Path]:
|
|||
return dirs
|
||||
|
||||
|
||||
def normalize_skill_lookup_name(identifier: str) -> str:
|
||||
"""Normalize a skill identifier to a ``skill_view()``-safe relative path.
|
||||
|
||||
Slash commands and cron jobs may store absolute paths to skills that live
|
||||
under ``~/.hermes/skills/`` (including via symlinks) or configured
|
||||
``skills.external_dirs``. ``skill_view()`` rejects absolute names for
|
||||
security, so callers must translate trusted absolute paths to their
|
||||
relative form first.
|
||||
"""
|
||||
raw_identifier = (identifier or "").strip()
|
||||
if not raw_identifier:
|
||||
return raw_identifier
|
||||
|
||||
identifier_path = Path(raw_identifier).expanduser()
|
||||
if not identifier_path.is_absolute():
|
||||
return raw_identifier.lstrip("/")
|
||||
|
||||
# Look the primary skills root up on tools.skills_tool at CALL time
|
||||
# (not via get_skills_dir()): callers and tests patch
|
||||
# ``tools.skills_tool.SKILLS_DIR`` and skill_view() itself resolves
|
||||
# against that module attribute, so normalization must agree with the
|
||||
# exact root skill_view() will enforce. Import deferred to avoid a
|
||||
# module cycle (tools.skills_tool imports agent.skill_utils).
|
||||
try:
|
||||
from tools import skills_tool as _skills_tool
|
||||
primary_root = Path(_skills_tool.SKILLS_DIR)
|
||||
except Exception:
|
||||
primary_root = get_skills_dir()
|
||||
|
||||
trusted_roots = [primary_root]
|
||||
try:
|
||||
trusted_roots.extend(get_external_skills_dirs())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Prefer the lexical path under a trusted skill root before resolving
|
||||
# symlinks. Slash-command discovery can legitimately find a skill via
|
||||
# ~/.hermes/skills/<name> where <name> is a symlink to a checked-out
|
||||
# skill elsewhere. Resolving first turns that trusted visible path into
|
||||
# an arbitrary absolute path that skill_view() refuses to load.
|
||||
for root in trusted_roots:
|
||||
try:
|
||||
return str(identifier_path.relative_to(root))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
try:
|
||||
return str(identifier_path.resolve().relative_to(primary_root.resolve()))
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Skill identifier %r is an absolute path outside trusted skills "
|
||||
"roots — passing through unchanged (skill_view will reject it)",
|
||||
raw_identifier,
|
||||
)
|
||||
return raw_identifier
|
||||
|
||||
|
||||
def _resolve_for_skill_ownership(path) -> Path:
|
||||
path_obj = path if isinstance(path, Path) else Path(str(path))
|
||||
try:
|
||||
|
|
@ -730,8 +791,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
|
|||
``SKILL.md`` files, but they are progressive-disclosure data loaded through
|
||||
``skill_view(..., file_path=...)`` rather than active skill roots.
|
||||
"""
|
||||
matches = []
|
||||
for root, dirs, files in os.walk(skills_dir, followlinks=True):
|
||||
skills_dir_str = str(skills_dir)
|
||||
matches: list[str] = []
|
||||
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
|
||||
has_skill_md = "SKILL.md" in files
|
||||
dirs[:] = [
|
||||
d
|
||||
|
|
@ -740,9 +802,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
|
|||
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
|
||||
]
|
||||
if filename in files:
|
||||
matches.append(Path(root) / filename)
|
||||
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
|
||||
yield path
|
||||
matches.append(os.path.join(root, filename))
|
||||
for path in sorted(matches):
|
||||
yield Path(path)
|
||||
|
||||
|
||||
# ── Namespace helpers for plugin-provided skills ───────────────────────────
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.prompt_builder import (
|
||||
|
|
@ -44,6 +45,7 @@ from agent.prompt_builder import (
|
|||
drain_truncation_warnings,
|
||||
)
|
||||
from agent.runtime_cwd import resolve_context_cwd
|
||||
from utils import is_truthy_value
|
||||
|
||||
|
||||
def _ra():
|
||||
|
|
@ -110,6 +112,36 @@ def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) ->
|
|||
return base
|
||||
|
||||
|
||||
_TUI_EMBEDDED_PANE_CLARIFIER = (
|
||||
" You're in its embedded terminal pane, beside the GUI chat — the user can "
|
||||
"select your output (Option-drag on macOS, Shift-drag elsewhere) and press "
|
||||
"Cmd/Ctrl+L to send it to the chat composer."
|
||||
)
|
||||
|
||||
|
||||
def _tui_embedded_pane_clarifier(hint: str) -> str:
|
||||
"""Append the desktop-embedded-terminal-pane clarifier to a tui hint.
|
||||
|
||||
Triggered by ``HERMES_DESKTOP_TERMINAL=1`` (set by ``main.cjs`` only on the
|
||||
shell env of the desktop's embedded TUI PTY — never on the chat backend).
|
||||
This is a runtime-surface qualifier, not a config override, so it lives at
|
||||
the resolution site rather than inside ``_resolve_platform_hint`` (which
|
||||
is purely the config-platform_hints override applier). Byte-stable for the
|
||||
cache: called once per session build, deterministically from env state.
|
||||
|
||||
Idempotent and empty-safe: re-applying on an already-augmented hint is a
|
||||
no-op, and an empty input returns empty (we never synthesize the
|
||||
clarifier without its tui framing).
|
||||
"""
|
||||
if not hint:
|
||||
return hint
|
||||
if _TUI_EMBEDDED_PANE_CLARIFIER in hint:
|
||||
return hint
|
||||
if not is_truthy_value(os.getenv("HERMES_DESKTOP_TERMINAL")):
|
||||
return hint
|
||||
return hint + _TUI_EMBEDDED_PANE_CLARIFIER
|
||||
|
||||
|
||||
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
|
||||
"""Assemble the system prompt as three ordered parts.
|
||||
|
||||
|
|
@ -398,6 +430,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||
pass
|
||||
|
||||
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
|
||||
if platform_key == "tui" and _effective_hint:
|
||||
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)
|
||||
if _effective_hint:
|
||||
stable_parts.append(_effective_hint)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from typing import Any, Dict, List, Optional
|
|||
from agent.tool_result_classification import (
|
||||
FILE_MUTATING_TOOL_NAMES as _FILE_MUTATING_TOOLS,
|
||||
)
|
||||
from tools.threat_patterns import scan_for_threats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -358,7 +359,13 @@ def _trajectory_normalize_msg(msg: Dict[str, Any]) -> Dict[str, Any]:
|
|||
return msg
|
||||
|
||||
|
||||
def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict:
|
||||
def make_tool_result_message(
|
||||
name: str,
|
||||
content: Any,
|
||||
tool_call_id: str,
|
||||
*,
|
||||
effect_disposition: str | None = None,
|
||||
) -> dict:
|
||||
"""Build a tool-result message dict with both the OpenAI-format ``name``
|
||||
field (required by the wire format and provider adapters) and the internal
|
||||
``tool_name`` field (written to the session DB messages table).
|
||||
|
|
@ -379,13 +386,23 @@ def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict
|
|||
callers should compare by value, not by ``is``.
|
||||
"""
|
||||
wrapped = _maybe_wrap_untrusted(name, content)
|
||||
return {
|
||||
message = {
|
||||
"role": "tool",
|
||||
"name": name,
|
||||
"tool_name": name,
|
||||
"content": wrapped,
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
try:
|
||||
risk_metadata = _tool_output_risk_metadata(name, content)
|
||||
except Exception as exc:
|
||||
logger.debug("Tool output risk scan failed for %s: %s", name, exc)
|
||||
else:
|
||||
if risk_metadata is not None:
|
||||
message["_tool_output_risk"] = risk_metadata
|
||||
if effect_disposition is not None:
|
||||
message["effect_disposition"] = effect_disposition
|
||||
return message
|
||||
|
||||
|
||||
# Tools whose results carry attacker-controllable content. Wrapping their
|
||||
|
|
@ -419,6 +436,42 @@ def _is_untrusted_tool(name: Optional[str]) -> bool:
|
|||
return any(name.startswith(p) for p in _UNTRUSTED_TOOL_PREFIXES)
|
||||
|
||||
|
||||
def _tool_output_risk_metadata(name: str, content: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Classify textual attacker-controlled output without retaining a copy.
|
||||
|
||||
The advisory metadata is internal-only. It records deterministic finding
|
||||
identifiers, never blocks or redacts the normal result, and deliberately
|
||||
omits raw scanned text.
|
||||
"""
|
||||
if not _is_untrusted_tool(name):
|
||||
return None
|
||||
if isinstance(content, str):
|
||||
text_parts = [content]
|
||||
elif isinstance(content, list):
|
||||
text_parts = [
|
||||
item["text"]
|
||||
for item in content
|
||||
if isinstance(item, dict)
|
||||
and item.get("type") == "text"
|
||||
and isinstance(item.get("text"), str)
|
||||
]
|
||||
if not text_parts:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
findings: List[str] = []
|
||||
for text in text_parts:
|
||||
for finding in scan_for_threats(text, scope="context"):
|
||||
if finding not in findings:
|
||||
findings.append(finding)
|
||||
return {
|
||||
"risk": "high" if findings else "low",
|
||||
"findings": findings,
|
||||
"redacted": False,
|
||||
}
|
||||
|
||||
|
||||
def _neutralize_delimiters(content: str) -> str:
|
||||
"""Defang any literal ``untrusted_tool_result`` delimiter embedded in
|
||||
attacker-controlled content so it can't break out of the wrapper.
|
||||
|
|
|
|||
|
|
@ -74,6 +74,25 @@ _MAX_TOOL_WORKERS = 8
|
|||
_DEFAULT_CONCURRENT_TOOL_TIMEOUT_S = 420.0
|
||||
|
||||
|
||||
def _parse_tool_arguments(raw_arguments: Any) -> tuple[dict, Optional[str]]:
|
||||
"""Parse model-emitted arguments without repairing or coercing them."""
|
||||
try:
|
||||
arguments = json.loads(raw_arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
arguments = None
|
||||
if isinstance(arguments, dict):
|
||||
return arguments, None
|
||||
return {}, json.dumps(
|
||||
{
|
||||
"error": "Invalid tool arguments",
|
||||
"message": (
|
||||
"Tool arguments must be a valid JSON object; tool was not executed."
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_concurrent_tool_timeout() -> float | None:
|
||||
raw = os.getenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "").strip()
|
||||
if not raw:
|
||||
|
|
@ -324,6 +343,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
tc.function.name,
|
||||
f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
|
||||
tc.id,
|
||||
effect_disposition="none",
|
||||
))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
|
|
@ -337,19 +357,29 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
for tool_call in tool_calls:
|
||||
function_name = tool_call.function.name
|
||||
|
||||
# Reset nudge counters
|
||||
function_args, malformed_args_result = _parse_tool_arguments(
|
||||
tool_call.function.arguments
|
||||
)
|
||||
|
||||
if malformed_args_result is not None:
|
||||
parsed_calls.append(
|
||||
(
|
||||
tool_call,
|
||||
function_name,
|
||||
function_args,
|
||||
[],
|
||||
malformed_args_result,
|
||||
False,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Reset nudge counters only for a structurally valid invocation.
|
||||
if function_name == "memory":
|
||||
agent._turns_since_memory = 0
|
||||
elif function_name == "skill_manage":
|
||||
agent._iters_since_skill = 0
|
||||
|
||||
try:
|
||||
function_args = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError:
|
||||
function_args = {}
|
||||
if not isinstance(function_args, dict):
|
||||
function_args = {}
|
||||
|
||||
# ── Tool Search unwrap ────────────────────────────────────────
|
||||
# When the model invokes the tool_call bridge, peel it open so
|
||||
# every downstream check (checkpointing, guardrails, plugin
|
||||
|
|
@ -415,8 +445,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
)
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
block_message = resolve_pre_tool_block(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
|
|
@ -798,9 +828,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
# deadline snapshot (timed_out_indices, taken from not_done) and this
|
||||
# loop. Prefer that real result over a fabricated timeout message — the
|
||||
# tool genuinely succeeded, just slightly late.
|
||||
effect_disposition = None
|
||||
if i in timed_out_indices and r is None:
|
||||
suffix = f"{timeout_s:.1f}s" if timeout_s is not None else "the configured timeout"
|
||||
function_result = f"Error executing tool '{name}': timed out after {suffix}"
|
||||
effect_disposition = "unknown"
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=name,
|
||||
|
|
@ -847,6 +879,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
tool_duration = 0.0
|
||||
else:
|
||||
function_name, function_args, function_result, tool_duration, is_error, blocked, middleware_trace = r
|
||||
if blocked:
|
||||
effect_disposition = "none"
|
||||
|
||||
if not blocked:
|
||||
function_result = agent._append_guardrail_observation(
|
||||
|
|
@ -935,7 +969,30 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
# image tool result never poisons canonical session history.
|
||||
# String results pass through unchanged.
|
||||
_tool_content = agent._tool_result_content_for_active_model(name, function_result)
|
||||
messages.append(make_tool_result_message(name, _tool_content, tc.id))
|
||||
tool_message = make_tool_result_message(
|
||||
name,
|
||||
_tool_content,
|
||||
tc.id,
|
||||
effect_disposition=effect_disposition,
|
||||
)
|
||||
messages.append(tool_message)
|
||||
risk_metadata = tool_message.get("_tool_output_risk")
|
||||
if (
|
||||
risk_metadata is not None
|
||||
and risk_metadata.get("risk") != "low"
|
||||
and agent.tool_progress_callback
|
||||
):
|
||||
try:
|
||||
agent.tool_progress_callback(
|
||||
"tool.output_risk",
|
||||
name,
|
||||
None,
|
||||
None,
|
||||
tool_call_id=tc.id,
|
||||
risk_metadata=risk_metadata,
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug("Tool output risk callback error: %s", cb_err)
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
|
|
@ -980,6 +1037,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
skipped_name,
|
||||
f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
|
||||
skipped_tc.id,
|
||||
effect_disposition="none",
|
||||
))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
|
|
@ -990,13 +1048,24 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
|
||||
function_name = tool_call.function.name
|
||||
|
||||
try:
|
||||
function_args = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Unexpected JSON error after validation: {e}")
|
||||
function_args = {}
|
||||
if not isinstance(function_args, dict):
|
||||
function_args = {}
|
||||
function_args, malformed_args_result = _parse_tool_arguments(
|
||||
tool_call.function.arguments
|
||||
)
|
||||
if malformed_args_result is not None:
|
||||
messages.append(
|
||||
make_tool_result_message(
|
||||
function_name,
|
||||
malformed_args_result,
|
||||
tool_call.id,
|
||||
)
|
||||
)
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"invalid tool arguments {function_name}",
|
||||
)
|
||||
agent._apply_pending_steer_to_tool_results(messages, 1)
|
||||
continue
|
||||
|
||||
# Tool Search unwrap — see execute_tool_calls_concurrent for full
|
||||
# rationale, including the scope gate (the unwrap dispatches the
|
||||
|
|
@ -1034,8 +1103,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
_block_error_type = "tool_scope_block"
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
_block_msg = get_pre_tool_call_block_message(
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
_block_msg = resolve_pre_tool_block(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
|
|
@ -1584,7 +1653,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
# Unwrap _multimodal dicts to an OpenAI-style content list
|
||||
# (see parallel path for rationale). String results pass through.
|
||||
_tool_content = agent._tool_result_content_for_active_model(function_name, function_result)
|
||||
messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id))
|
||||
tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id)
|
||||
messages.append(tool_message)
|
||||
risk_metadata = tool_message.get("_tool_output_risk")
|
||||
if (
|
||||
risk_metadata is not None
|
||||
and risk_metadata.get("risk") != "low"
|
||||
and agent.tool_progress_callback
|
||||
):
|
||||
try:
|
||||
agent.tool_progress_callback(
|
||||
"tool.output_risk",
|
||||
function_name,
|
||||
None,
|
||||
None,
|
||||
tool_call_id=tool_call.id,
|
||||
risk_metadata=risk_metadata,
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logging.debug("Tool output risk callback error: %s", cb_err)
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
|
|
@ -1615,6 +1702,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
skipped_name,
|
||||
f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
|
||||
skipped_tc.id,
|
||||
effect_disposition="none",
|
||||
))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,20 @@ from typing import Any
|
|||
FILE_MUTATING_TOOL_NAMES = frozenset({"write_file", "patch"})
|
||||
|
||||
|
||||
# Tools whose interrupted/dangling execution is safe to discard because they
|
||||
# cannot mutate either external state or Hermes session state. Unknown/plugin/
|
||||
# MCP tools stay effect-capable by default.
|
||||
NO_EFFECT_TOOL_NAMES = frozenset({
|
||||
"read_file", "search_files", "session_search", "skill_view", "skills_list",
|
||||
"web_extract", "web_search", "vision_analyze", "browser_snapshot",
|
||||
"browser_get_images", "browser_console", "read_terminal",
|
||||
})
|
||||
|
||||
|
||||
def tool_may_have_side_effect(tool_name: str) -> bool:
|
||||
return tool_name not in NO_EFFECT_TOOL_NAMES
|
||||
|
||||
|
||||
def file_mutation_result_landed(tool_name: str, result: Any) -> bool:
|
||||
"""Return True when a file mutation result proves the write landed."""
|
||||
if tool_name not in FILE_MUTATING_TOOL_NAMES or not isinstance(result, str):
|
||||
|
|
|
|||
398
agent/trace_upload.py
Normal file
398
agent/trace_upload.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
"""Upload a Hermes session transcript to Hugging Face as an agent trace.
|
||||
|
||||
Hermes stores sessions in its own SQLite store (``hermes_state.SessionDB``),
|
||||
so we reconstruct the conversation and emit it in the **Claude Code JSONL**
|
||||
shape — one of the three formats the Hugging Face Agent Trace Viewer
|
||||
auto-detects (Claude Code / Codex / Pi). No dataset-side preprocessing is
|
||||
needed; the Hub tags the dataset ``agent-traces`` and opens it in the viewer.
|
||||
|
||||
Docs: https://huggingface.co/docs/hub/agent-traces
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* **Zero LLM turn.** This is a deterministic export — it never spends a
|
||||
model call. The ``hermes trace upload`` subcommand calls
|
||||
:func:`upload_session_trace` directly.
|
||||
* **Private by default.** Traces can contain prompts, tool output, local
|
||||
paths, and secrets. The dataset is created private and every text body
|
||||
is passed through Hermes' secret redactor (``force=True``) unless the
|
||||
caller explicitly opts out with ``redact=False``.
|
||||
* **Never raises.** Returns a user-facing status string so command
|
||||
handlers can echo it straight back to the user. Programmatic callers
|
||||
that need the URL can use :func:`build_trace_jsonl` + :func:`_do_upload`
|
||||
directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DATASET_NAME = "hermes-traces"
|
||||
_HERMES_VERSION = "hermes-agent"
|
||||
_REDACTION_BLOCKED_MESSAGE = (
|
||||
"Trace upload blocked: secret redaction failed, so the transcript may "
|
||||
"still contain credentials or other sensitive data. Fix the redactor or "
|
||||
"rerun with --no-redact only after manually reviewing the transcript."
|
||||
)
|
||||
|
||||
|
||||
class TraceRedactionError(RuntimeError):
|
||||
"""Raised when a trace cannot be safely redacted before upload."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversion: Hermes OpenAI-format messages -> Claude Code JSONL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
def _redact(text: Any, enabled: bool) -> Any:
|
||||
"""Redact secrets from a string body when redaction is enabled.
|
||||
|
||||
Non-strings pass through untouched. Uses Hermes' shared redactor with
|
||||
``force=True`` so an upload always scrubs known secret shapes even if
|
||||
the user disabled log redaction globally.
|
||||
"""
|
||||
if not enabled or not isinstance(text, str) or not text:
|
||||
return text
|
||||
try:
|
||||
from agent.redact import redact_sensitive_text
|
||||
return redact_sensitive_text(text, force=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Trace upload redaction failed; refusing upload", exc_info=True)
|
||||
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE) from exc
|
||||
|
||||
|
||||
def _content_to_blocks(content: Any, redact: bool) -> List[Dict[str, Any]]:
|
||||
"""Normalize a message ``content`` field into Anthropic content blocks."""
|
||||
if content is None:
|
||||
return []
|
||||
if isinstance(content, str):
|
||||
return [{"type": "text", "text": _redact(content, redact)}]
|
||||
if isinstance(content, list):
|
||||
blocks: List[Dict[str, Any]] = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
ptype = part.get("type")
|
||||
if ptype == "text":
|
||||
blocks.append({"type": "text", "text": _redact(part.get("text", ""), redact)})
|
||||
elif ptype in ("image_url", "image"):
|
||||
# Keep a placeholder; the viewer renders text turns and we
|
||||
# don't want to inline base64 blobs into a trace.
|
||||
blocks.append({"type": "text", "text": "[image omitted]"})
|
||||
else:
|
||||
blocks.append({"type": "text", "text": _redact(json.dumps(part), redact)})
|
||||
else:
|
||||
blocks.append({"type": "text", "text": _redact(str(part), redact)})
|
||||
return blocks
|
||||
return [{"type": "text", "text": _redact(json.dumps(content), redact)}]
|
||||
|
||||
|
||||
def _tool_calls_to_blocks(tool_calls: Any, redact: bool) -> List[Dict[str, Any]]:
|
||||
"""Convert OpenAI tool_calls into Anthropic ``tool_use`` content blocks."""
|
||||
blocks: List[Dict[str, Any]] = []
|
||||
if not isinstance(tool_calls, list):
|
||||
return blocks
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function") or {}
|
||||
name = fn.get("name") or tc.get("name") or "tool"
|
||||
raw_args = fn.get("arguments")
|
||||
if isinstance(raw_args, str):
|
||||
try:
|
||||
parsed = json.loads(raw_args) if raw_args.strip() else {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed = {"_raw": raw_args}
|
||||
elif isinstance(raw_args, dict):
|
||||
parsed = raw_args
|
||||
else:
|
||||
parsed = {}
|
||||
if redact:
|
||||
try:
|
||||
parsed = json.loads(_redact(json.dumps(parsed), redact))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
logger.warning("Trace upload redacted tool arguments are not valid JSON; refusing upload")
|
||||
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE)
|
||||
blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": tc.get("id") or f"toolu_{uuid.uuid4().hex[:16]}",
|
||||
"name": name,
|
||||
"input": parsed,
|
||||
})
|
||||
return blocks
|
||||
|
||||
|
||||
def build_trace_jsonl(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
session_id: str,
|
||||
model: str = "",
|
||||
cwd: str = "",
|
||||
redact: bool = True,
|
||||
) -> str:
|
||||
"""Render Hermes conversation messages as Claude Code JSONL text.
|
||||
|
||||
Each non-system message becomes one JSONL line in the Claude Code
|
||||
transcript shape the HF Agent Trace Viewer auto-detects:
|
||||
|
||||
* ``user`` / ``tool`` -> ``{"type": "user", "message": {...}}``
|
||||
* ``assistant`` -> ``{"type": "assistant", "message": {...}}``
|
||||
with ``content`` blocks (text + ``tool_use``).
|
||||
|
||||
Tool results are emitted as user turns carrying a ``tool_result``
|
||||
block keyed by ``tool_call_id`` — the same way Claude Code records
|
||||
them. Turns are linked via ``uuid`` / ``parentUuid``.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
parent: Optional[str] = None
|
||||
base_ts = _now_iso()
|
||||
git_branch = ""
|
||||
try:
|
||||
import subprocess
|
||||
if cwd:
|
||||
r = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True, text=True, timeout=3, cwd=cwd,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
git_branch = r.stdout.strip()
|
||||
except Exception:
|
||||
git_branch = ""
|
||||
|
||||
def _common(turn_uuid: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"parentUuid": parent,
|
||||
"isSidechain": False,
|
||||
"userType": "external",
|
||||
"cwd": cwd or os.getcwd(),
|
||||
"sessionId": session_id,
|
||||
"version": _HERMES_VERSION,
|
||||
"gitBranch": git_branch,
|
||||
"uuid": turn_uuid,
|
||||
"timestamp": base_ts,
|
||||
}
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
if role == "system":
|
||||
continue
|
||||
turn_uuid = str(uuid.uuid4())
|
||||
|
||||
if role == "assistant":
|
||||
blocks = _content_to_blocks(msg.get("content"), redact)
|
||||
blocks.extend(_tool_calls_to_blocks(msg.get("tool_calls"), redact))
|
||||
if not blocks:
|
||||
blocks = [{"type": "text", "text": ""}]
|
||||
entry = _common(turn_uuid)
|
||||
entry["type"] = "assistant"
|
||||
entry["message"] = {
|
||||
"role": "assistant",
|
||||
"model": model or "unknown",
|
||||
"content": blocks,
|
||||
}
|
||||
lines.append(json.dumps(entry, ensure_ascii=False))
|
||||
parent = turn_uuid
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
tool_use_id = msg.get("tool_call_id") or msg.get("tool_name") or "tool"
|
||||
result_content = _redact(
|
||||
msg.get("content") if isinstance(msg.get("content"), str)
|
||||
else json.dumps(msg.get("content")),
|
||||
redact,
|
||||
)
|
||||
entry = _common(turn_uuid)
|
||||
entry["type"] = "user"
|
||||
entry["message"] = {
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": result_content,
|
||||
}],
|
||||
}
|
||||
lines.append(json.dumps(entry, ensure_ascii=False))
|
||||
parent = turn_uuid
|
||||
continue
|
||||
|
||||
# Default: user (and any unknown role) -> user turn.
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
message_content: Any = _redact(content, redact)
|
||||
else:
|
||||
message_content = _content_to_blocks(content, redact)
|
||||
entry = _common(turn_uuid)
|
||||
entry["type"] = "user"
|
||||
entry["message"] = {"role": "user", "content": message_content}
|
||||
lines.append(json.dumps(entry, ensure_ascii=False))
|
||||
parent = turn_uuid
|
||||
|
||||
return "\n".join(lines) + ("\n" if lines else "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_hf_token() -> Optional[str]:
|
||||
"""Return the user's Hugging Face token from the usual env vars."""
|
||||
for var in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"):
|
||||
val = os.getenv(var)
|
||||
if val and val.strip():
|
||||
return val.strip()
|
||||
return None
|
||||
|
||||
|
||||
_NO_TOKEN_MESSAGE = (
|
||||
"Can't upload — no Hugging Face token is available. To set it up:\n"
|
||||
"\n"
|
||||
"1. Create a token with WRITE access at https://huggingface.co/settings/tokens\n"
|
||||
" (New token -> type \"Write\" -> copy it).\n"
|
||||
"2. Add it to your environment as HF_TOKEN (e.g. in ~/.hermes/.env):\n"
|
||||
" HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx\n"
|
||||
"3. Run /upload-trace again (or `hermes trace upload`)."
|
||||
)
|
||||
|
||||
|
||||
def _do_upload(
|
||||
jsonl: str,
|
||||
*,
|
||||
token: str,
|
||||
session_id: str,
|
||||
dataset_name: str = DEFAULT_DATASET_NAME,
|
||||
private: bool = True,
|
||||
) -> str:
|
||||
"""Create (idempotently) the private dataset and push the trace file.
|
||||
|
||||
Returns a user-facing status string. Never raises.
|
||||
"""
|
||||
try:
|
||||
from tools import lazy_deps
|
||||
lazy_deps.ensure("tool.trace_upload", prompt=False)
|
||||
except Exception:
|
||||
# lazy-install unavailable/declined — fall through to the import,
|
||||
# which surfaces the install hint below if the package is missing.
|
||||
pass
|
||||
try:
|
||||
from huggingface_hub import HfApi
|
||||
except ImportError:
|
||||
return ("Hugging Face upload needs the `huggingface_hub` package "
|
||||
"(`pip install huggingface_hub`).")
|
||||
|
||||
api = HfApi(token=token)
|
||||
try:
|
||||
who = api.whoami()
|
||||
user = who.get("name") if isinstance(who, dict) else None
|
||||
except Exception as e:
|
||||
logger.warning("HF whoami failed: %s", e)
|
||||
return ("Your Hugging Face token was rejected (whoami failed). "
|
||||
"Make sure it has WRITE access and isn't expired.")
|
||||
if not user:
|
||||
return "Could not resolve your Hugging Face username from the token."
|
||||
|
||||
repo_id = f"{user}/{dataset_name}"
|
||||
try:
|
||||
api.create_repo(
|
||||
repo_id=repo_id, repo_type="dataset", private=private, exist_ok=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("HF create_repo failed for %s: %s", repo_id, e)
|
||||
return f"Could not create/access dataset {repo_id}: {e}"
|
||||
|
||||
path_in_repo = f"sessions/{session_id}.jsonl"
|
||||
try:
|
||||
api.upload_file(
|
||||
path_or_fileobj=jsonl.encode("utf-8"),
|
||||
path_in_repo=path_in_repo,
|
||||
repo_id=repo_id,
|
||||
repo_type="dataset",
|
||||
commit_message=f"add session trace {session_id}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("HF upload_file failed for %s: %s", repo_id, e)
|
||||
return f"Upload to Hugging Face failed: {e}"
|
||||
|
||||
return (f"Uploaded -> https://huggingface.co/datasets/{repo_id}/blob/main/{path_in_repo}\n"
|
||||
f"View in the trace viewer: https://huggingface.co/datasets/{repo_id}")
|
||||
|
||||
|
||||
def load_session_messages(
|
||||
session_id: str, db_path=None
|
||||
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||||
"""Load a session's conversation + metadata from the SQLite store.
|
||||
|
||||
Returns ``(messages, meta)``. ``meta`` is ``{}`` when the session row is
|
||||
missing (messages may still be present for a live, untitled session).
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB(db_path=db_path) if db_path else SessionDB()
|
||||
resolved = db.resolve_session_id(session_id) or session_id
|
||||
meta = db.get_session(resolved) or {}
|
||||
messages = db.get_messages_as_conversation(resolved)
|
||||
return messages, meta
|
||||
|
||||
|
||||
def upload_session_trace(
|
||||
session_id: str,
|
||||
*,
|
||||
model: str = "",
|
||||
cwd: str = "",
|
||||
redact: bool = True,
|
||||
private: bool = True,
|
||||
dataset_name: str = DEFAULT_DATASET_NAME,
|
||||
db_path=None,
|
||||
token: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Top-level entry point used by the CLI/gateway/subcommand.
|
||||
|
||||
Loads the session, converts it to Claude Code JSONL, and uploads it to
|
||||
the user's private ``{user}/hermes-traces`` dataset. Returns a
|
||||
user-facing status string and never raises.
|
||||
"""
|
||||
if not session_id:
|
||||
return "No active session to upload."
|
||||
|
||||
token = token or _resolve_hf_token()
|
||||
if not token:
|
||||
return _NO_TOKEN_MESSAGE
|
||||
|
||||
try:
|
||||
messages, meta = load_session_messages(session_id, db_path=db_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load session %s for trace upload: %s", session_id, e)
|
||||
return f"Could not load session {session_id}: {e}"
|
||||
|
||||
if not messages:
|
||||
return "No transcript to upload for this session yet."
|
||||
|
||||
resolved_model = model or meta.get("model") or ""
|
||||
try:
|
||||
jsonl = build_trace_jsonl(
|
||||
messages,
|
||||
session_id=session_id,
|
||||
model=resolved_model,
|
||||
cwd=cwd,
|
||||
redact=redact,
|
||||
)
|
||||
except TraceRedactionError:
|
||||
return _REDACTION_BLOCKED_MESSAGE
|
||||
if not jsonl.strip():
|
||||
return "No transcript content to upload for this session."
|
||||
|
||||
return _do_upload(
|
||||
jsonl,
|
||||
token=token,
|
||||
session_id=session_id,
|
||||
dataset_name=dataset_name,
|
||||
private=private,
|
||||
)
|
||||
|
|
@ -9,7 +9,6 @@ which has provider-specific conditionals for max_tokens defaults,
|
|||
reasoning configuration, temperature handling, and extra_body assembly.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict
|
||||
|
||||
from agent.lmstudio_reasoning import resolve_lmstudio_effort
|
||||
|
|
@ -19,6 +18,20 @@ from agent.transports.base import ProviderTransport
|
|||
from agent.transports.types import NormalizedResponse, ToolCall, Usage
|
||||
|
||||
|
||||
def _reasoning_config_for_model(model: str, reasoning_config: dict | None) -> dict | None:
|
||||
"""Return the model's wire-compatible reasoning config."""
|
||||
if not isinstance(reasoning_config, dict):
|
||||
return reasoning_config
|
||||
if (
|
||||
"gpt-5.6" in (model or "").lower()
|
||||
and str(reasoning_config.get("effort") or "").strip().lower() == "ultra"
|
||||
):
|
||||
normalized = dict(reasoning_config)
|
||||
normalized["effort"] = "max"
|
||||
return normalized
|
||||
return reasoning_config
|
||||
|
||||
|
||||
def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> dict | None:
|
||||
"""Translate Hermes/OpenRouter-style reasoning config to Gemini thinkingConfig."""
|
||||
if reasoning_config is None or not isinstance(reasoning_config, dict):
|
||||
|
|
@ -53,7 +66,7 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
|
|||
if normalized_model.startswith("gemini-2.5-"):
|
||||
return thinking_config
|
||||
|
||||
if effort not in {"minimal", "low", "medium", "high", "xhigh"}:
|
||||
if effort not in {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"}:
|
||||
effort = "medium"
|
||||
|
||||
# Gemini 3 Flash documents low/medium/high thinking levels; Gemini 3 Pro
|
||||
|
|
@ -63,13 +76,13 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
|
|||
if "flash" in normalized_model:
|
||||
if effort in {"minimal", "low"}:
|
||||
thinking_config["thinkingLevel"] = "low"
|
||||
elif effort in {"high", "xhigh"}:
|
||||
elif effort in {"high", "xhigh", "max", "ultra"}:
|
||||
thinking_config["thinkingLevel"] = "high"
|
||||
else:
|
||||
thinking_config["thinkingLevel"] = "medium"
|
||||
elif "pro" in normalized_model:
|
||||
thinking_config["thinkingLevel"] = (
|
||||
"high" if effort in {"high", "xhigh"} else "low"
|
||||
"high" if effort in {"high", "xhigh", "max", "ultra"} else "low"
|
||||
)
|
||||
|
||||
return thinking_config
|
||||
|
|
@ -172,6 +185,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||
"codex_reasoning_items" in msg
|
||||
or "codex_message_items" in msg
|
||||
or "tool_name" in msg
|
||||
or "effect_disposition" in msg
|
||||
or "timestamp" in msg # #47868 — strict providers reject this
|
||||
):
|
||||
needs_sanitize = True
|
||||
|
|
@ -195,27 +209,65 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||
if not needs_sanitize:
|
||||
return messages
|
||||
|
||||
sanitized = copy.deepcopy(messages)
|
||||
for msg in sanitized:
|
||||
sanitized = list(messages)
|
||||
for msg_idx, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg.pop("codex_reasoning_items", None)
|
||||
msg.pop("codex_message_items", None)
|
||||
msg.pop("tool_name", None)
|
||||
msg.pop("timestamp", None) # #47868 — leak into strict providers
|
||||
|
||||
copied_msg: dict[str, Any] | None = None
|
||||
|
||||
def mutable_msg() -> dict[str, Any]:
|
||||
nonlocal copied_msg
|
||||
if copied_msg is None:
|
||||
copied_msg = dict(msg)
|
||||
sanitized[msg_idx] = copied_msg
|
||||
return copied_msg
|
||||
|
||||
if (
|
||||
"codex_reasoning_items" in msg
|
||||
or "codex_message_items" in msg
|
||||
or "tool_name" in msg
|
||||
or "effect_disposition" in msg
|
||||
or "timestamp" in msg # #47868 — leak into strict providers
|
||||
):
|
||||
out_msg = mutable_msg()
|
||||
out_msg.pop("codex_reasoning_items", None)
|
||||
out_msg.pop("codex_message_items", None)
|
||||
out_msg.pop("tool_name", None)
|
||||
out_msg.pop("effect_disposition", None)
|
||||
out_msg.pop("timestamp", None) # #47868 — leak into strict providers
|
||||
|
||||
|
||||
# Drop all Hermes-internal scaffolding markers (``_``-prefixed).
|
||||
# OpenAI's message schema has no ``_``-prefixed fields, so this
|
||||
# is safe and future-proofs against new markers being added.
|
||||
for key in [k for k in msg if isinstance(k, str) and k.startswith("_")]:
|
||||
msg.pop(key, None)
|
||||
internal_keys = [k for k in msg if isinstance(k, str) and k.startswith("_")]
|
||||
if internal_keys:
|
||||
out_msg = mutable_msg()
|
||||
for key in internal_keys:
|
||||
out_msg.pop(key, None)
|
||||
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list):
|
||||
for tc in tool_calls:
|
||||
copied_tool_calls: list[Any] | None = None
|
||||
for tc_idx, tc in enumerate(tool_calls):
|
||||
if isinstance(tc, dict):
|
||||
tc.pop("call_id", None)
|
||||
tc.pop("response_item_id", None)
|
||||
if strip_extra_content:
|
||||
tc.pop("extra_content", None)
|
||||
should_copy_tc = (
|
||||
"call_id" in tc
|
||||
or "response_item_id" in tc
|
||||
or (strip_extra_content and "extra_content" in tc)
|
||||
)
|
||||
if should_copy_tc:
|
||||
if copied_tool_calls is None:
|
||||
copied_tool_calls = list(tool_calls)
|
||||
copied_tc = dict(tc)
|
||||
copied_tc.pop("call_id", None)
|
||||
copied_tc.pop("response_item_id", None)
|
||||
if strip_extra_content:
|
||||
copied_tc.pop("extra_content", None)
|
||||
copied_tool_calls[tc_idx] = copied_tc
|
||||
if copied_tool_calls is not None:
|
||||
mutable_msg()["tool_calls"] = copied_tool_calls
|
||||
return sanitized
|
||||
|
||||
def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
|
@ -326,7 +378,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||
is_nvidia_nim = params.get("is_nvidia_nim", False)
|
||||
is_kimi = params.get("is_kimi", False)
|
||||
is_tokenhub = params.get("is_tokenhub", False)
|
||||
reasoning_config = params.get("reasoning_config")
|
||||
reasoning_config = _reasoning_config_for_model(model, params.get("reasoning_config"))
|
||||
|
||||
if ephemeral is not None and max_tokens_fn:
|
||||
api_kwargs.update(max_tokens_fn(ephemeral))
|
||||
|
|
@ -525,7 +577,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||
api_kwargs["max_tokens"] = anthropic_max
|
||||
|
||||
# Provider-specific api_kwargs extras (reasoning_effort, metadata, etc.)
|
||||
reasoning_config = params.get("reasoning_config")
|
||||
reasoning_config = _reasoning_config_for_model(model, params.get("reasoning_config"))
|
||||
extra_body_from_profile, top_level_from_profile = (
|
||||
profile.build_api_kwargs_extras(
|
||||
reasoning_config=reasoning_config,
|
||||
|
|
|
|||
|
|
@ -67,9 +67,9 @@ class ResponsesApiTransport(ProviderTransport):
|
|||
"""Classify the current Responses endpoint from transport params."""
|
||||
from agent.codex_responses_adapter import _classify_responses_issuer
|
||||
return _classify_responses_issuer(
|
||||
is_xai_responses=bool(params.get("is_xai_responses")),
|
||||
is_github_responses=bool(params.get("is_github_responses")),
|
||||
is_codex_backend=bool(params.get("is_codex_backend")),
|
||||
is_xai_responses=params.get("is_xai_responses") is True,
|
||||
is_github_responses=params.get("is_github_responses") is True,
|
||||
is_codex_backend=params.get("is_codex_backend") is True,
|
||||
base_url=params.get("base_url"),
|
||||
)
|
||||
|
||||
|
|
@ -80,7 +80,8 @@ class ResponsesApiTransport(ProviderTransport):
|
|||
self._last_issuer_kind = issuer
|
||||
return _chat_messages_to_responses_input(
|
||||
messages,
|
||||
is_xai_responses=bool(kwargs.get("is_xai_responses")),
|
||||
is_xai_responses=kwargs.get("is_xai_responses") is True,
|
||||
is_github_responses=kwargs.get("is_github_responses") is True,
|
||||
replay_encrypted_reasoning=bool(
|
||||
kwargs.get("replay_encrypted_reasoning", True)
|
||||
),
|
||||
|
|
@ -137,9 +138,9 @@ class ResponsesApiTransport(ProviderTransport):
|
|||
if not instructions:
|
||||
instructions = DEFAULT_AGENT_IDENTITY
|
||||
|
||||
is_github_responses = params.get("is_github_responses", False)
|
||||
is_codex_backend = params.get("is_codex_backend", False)
|
||||
is_xai_responses = params.get("is_xai_responses", False)
|
||||
is_github_responses = params.get("is_github_responses") is True
|
||||
is_codex_backend = params.get("is_codex_backend") is True
|
||||
is_xai_responses = params.get("is_xai_responses") is True
|
||||
replay_encrypted_reasoning = bool(
|
||||
params.get("replay_encrypted_reasoning", True)
|
||||
)
|
||||
|
|
@ -163,6 +164,12 @@ class ResponsesApiTransport(ProviderTransport):
|
|||
reasoning_effort = reasoning_config["effort"]
|
||||
|
||||
_effort_clamp = {"minimal": "low"}
|
||||
if "gpt-5.6" in (model or "").lower():
|
||||
# Ultra is the Codex product tier; the Responses API wire value is max.
|
||||
_effort_clamp["ultra"] = "max"
|
||||
if params.get("is_xai_responses", False):
|
||||
# xAI Responses tops out at high; keep generic stronger values usable.
|
||||
_effort_clamp.update({"xhigh": "high", "max": "high", "ultra": "high"})
|
||||
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
|
||||
|
||||
response_tools = _responses_tools(tools)
|
||||
|
|
@ -239,6 +246,7 @@ class ResponsesApiTransport(ProviderTransport):
|
|||
"input": _chat_messages_to_responses_input(
|
||||
payload_messages,
|
||||
is_xai_responses=is_xai_responses,
|
||||
is_github_responses=is_github_responses,
|
||||
replay_encrypted_reasoning=replay_encrypted_reasoning,
|
||||
current_issuer_kind=issuer_kind,
|
||||
),
|
||||
|
|
@ -438,13 +446,23 @@ class ResponsesApiTransport(ProviderTransport):
|
|||
return False
|
||||
return True
|
||||
|
||||
def preflight_kwargs(self, api_kwargs: Any, *, allow_stream: bool = False) -> dict:
|
||||
def preflight_kwargs(
|
||||
self,
|
||||
api_kwargs: Any,
|
||||
*,
|
||||
allow_stream: bool = False,
|
||||
is_github_responses: bool = False,
|
||||
) -> dict:
|
||||
"""Validate and sanitize Codex API kwargs before the call.
|
||||
|
||||
Normalizes input items, strips unsupported fields, validates structure.
|
||||
"""
|
||||
from agent.codex_responses_adapter import _preflight_codex_api_kwargs
|
||||
return _preflight_codex_api_kwargs(api_kwargs, allow_stream=allow_stream)
|
||||
return _preflight_codex_api_kwargs(
|
||||
api_kwargs,
|
||||
allow_stream=allow_stream,
|
||||
is_github_responses=is_github_responses,
|
||||
)
|
||||
|
||||
def map_finish_reason(self, raw_reason: str) -> str:
|
||||
"""Map Codex response.status to OpenAI finish_reason.
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class TurnResult:
|
|||
token_usage_last: Optional[dict[str, Any]] = None
|
||||
token_usage_total: Optional[dict[str, Any]] = None
|
||||
model_context_window: Optional[int] = None
|
||||
compacted: bool = False
|
||||
# Hint to the caller that the underlying codex subprocess is likely
|
||||
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
|
||||
# token-refresh failure killed the child). The caller should retire
|
||||
|
|
@ -505,6 +506,7 @@ class CodexAppServerSession:
|
|||
if pending is None:
|
||||
break
|
||||
_apply_token_usage_notification(result, pending)
|
||||
_apply_compaction_notification(result, pending)
|
||||
self._track_pending_file_change(pending)
|
||||
proj = projector.project(pending)
|
||||
if proj.messages:
|
||||
|
|
@ -541,6 +543,7 @@ class CodexAppServerSession:
|
|||
logger.debug("on_event callback raised", exc_info=True)
|
||||
|
||||
_apply_token_usage_notification(result, note)
|
||||
_apply_compaction_notification(result, note)
|
||||
|
||||
# Track in-progress fileChange items so the approval bridge
|
||||
# can surface a real change summary when codex requests
|
||||
|
|
@ -632,6 +635,154 @@ class CodexAppServerSession:
|
|||
|
||||
return result
|
||||
|
||||
def compact_thread(
|
||||
self,
|
||||
*,
|
||||
turn_timeout: float = 600.0,
|
||||
notification_poll_timeout: float = 0.25,
|
||||
) -> TurnResult:
|
||||
"""Trigger Codex-native history compaction for the current thread.
|
||||
|
||||
`thread/compact/start` returns immediately; the actual compaction
|
||||
progress streams through the same turn/item notifications as a normal
|
||||
turn. We wait for the matching `turn/completed` so callers can treat a
|
||||
successful return as a completed compaction boundary.
|
||||
"""
|
||||
result = TurnResult()
|
||||
try:
|
||||
self.ensure_started()
|
||||
except (CodexAppServerError, TimeoutError) as exc:
|
||||
result.error = self._format_error_with_stderr(
|
||||
"codex app-server startup failed", exc
|
||||
)
|
||||
result.should_retire = True
|
||||
return result
|
||||
|
||||
assert self._client is not None and self._thread_id is not None
|
||||
result.thread_id = self._thread_id
|
||||
self._interrupt_event.clear()
|
||||
projector = CodexEventProjector()
|
||||
|
||||
try:
|
||||
self._client.request(
|
||||
"thread/compact/start",
|
||||
{"threadId": self._thread_id},
|
||||
timeout=10,
|
||||
)
|
||||
except CodexAppServerError as exc:
|
||||
stderr_blob = "\n".join(self._client.stderr_tail(40))
|
||||
hint = _classify_oauth_failure(exc.message, stderr_blob)
|
||||
if hint is not None:
|
||||
result.error = hint
|
||||
result.should_retire = True
|
||||
else:
|
||||
result.error = self._format_error_with_stderr(
|
||||
"thread/compact/start failed", exc
|
||||
)
|
||||
return result
|
||||
except TimeoutError as exc:
|
||||
stderr_blob = "\n".join(self._client.stderr_tail(40))
|
||||
hint = _classify_oauth_failure(stderr_blob)
|
||||
result.error = hint or self._format_error_with_stderr(
|
||||
"thread/compact/start timed out", exc
|
||||
)
|
||||
result.should_retire = True
|
||||
return result
|
||||
|
||||
deadline = time.monotonic() + turn_timeout
|
||||
turn_complete = False
|
||||
|
||||
while time.monotonic() < deadline and not turn_complete:
|
||||
if self._interrupt_event.is_set():
|
||||
self._issue_interrupt(result.turn_id)
|
||||
result.interrupted = True
|
||||
break
|
||||
|
||||
if not self._client.is_alive():
|
||||
stderr_blob = "\n".join(self._client.stderr_tail(60))
|
||||
hint = _classify_oauth_failure(stderr_blob)
|
||||
if hint is not None:
|
||||
result.error = hint
|
||||
else:
|
||||
result.error = self._format_error_with_stderr(
|
||||
"codex app-server subprocess exited unexpectedly",
|
||||
tail_lines=20,
|
||||
)
|
||||
result.should_retire = True
|
||||
break
|
||||
|
||||
sreq = self._client.take_server_request(timeout=0)
|
||||
if sreq is not None:
|
||||
self._handle_server_request(sreq)
|
||||
continue
|
||||
|
||||
note = self._client.take_notification(
|
||||
timeout=notification_poll_timeout
|
||||
)
|
||||
if note is None:
|
||||
continue
|
||||
|
||||
method = note.get("method", "")
|
||||
if self._on_event is not None:
|
||||
try:
|
||||
self._on_event(note)
|
||||
except Exception: # pragma: no cover - display callback
|
||||
logger.debug("on_event callback raised", exc_info=True)
|
||||
|
||||
_apply_token_usage_notification(result, note)
|
||||
_apply_compaction_notification(result, note)
|
||||
self._track_pending_file_change(note)
|
||||
|
||||
projection = projector.project(note)
|
||||
if projection.messages:
|
||||
result.projected_messages.extend(projection.messages)
|
||||
if projection.is_tool_iteration:
|
||||
result.tool_iterations += 1
|
||||
if projection.final_text is not None:
|
||||
result.final_text = projection.final_text
|
||||
if _has_turn_aborted_marker(projection.final_text):
|
||||
turn_complete = True
|
||||
result.interrupted = True
|
||||
result.error = (
|
||||
result.error or "codex reported turn_aborted"
|
||||
)
|
||||
|
||||
if method == "turn/started":
|
||||
turn_obj = (note.get("params") or {}).get("turn") or {}
|
||||
result.turn_id = turn_obj.get("id") or result.turn_id
|
||||
elif method == "turn/completed":
|
||||
turn_complete = True
|
||||
turn_obj = (note.get("params") or {}).get("turn") or {}
|
||||
result.turn_id = turn_obj.get("id") or result.turn_id
|
||||
turn_status = turn_obj.get("status")
|
||||
if turn_status == "interrupted":
|
||||
result.interrupted = True
|
||||
result.error = result.error or "compact turn interrupted"
|
||||
elif turn_status and turn_status != "completed":
|
||||
err_obj = turn_obj.get("error")
|
||||
err_msg = _format_responses_error(err_obj, str(turn_status))
|
||||
stderr_blob = "\n".join(self._client.stderr_tail(40))
|
||||
hint = _classify_oauth_failure(err_msg, stderr_blob)
|
||||
if hint is not None:
|
||||
result.error = hint
|
||||
result.should_retire = True
|
||||
else:
|
||||
result.error = self._format_error_with_stderr(
|
||||
f"compact turn ended status={turn_status}",
|
||||
err_msg,
|
||||
)
|
||||
|
||||
if not turn_complete and not result.interrupted:
|
||||
self._issue_interrupt(result.turn_id)
|
||||
result.interrupted = True
|
||||
if not result.error:
|
||||
result.error = self._format_error_with_stderr(
|
||||
f"compact turn timed out after {turn_timeout}s"
|
||||
)
|
||||
result.should_retire = True
|
||||
|
||||
return result
|
||||
|
||||
# ---------- internals ----------
|
||||
|
||||
def _issue_interrupt(self, turn_id: Optional[str]) -> None:
|
||||
|
|
@ -845,6 +996,38 @@ def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
|
|||
result.model_context_window = window
|
||||
|
||||
|
||||
def _apply_compaction_notification(result: TurnResult, note: dict) -> None:
|
||||
"""Capture Codex-native context compaction boundaries.
|
||||
|
||||
Recent app-server builds expose compaction as a ContextCompaction item.
|
||||
Older builds also emit the deprecated thread/compacted notification. Both
|
||||
mean the underlying Codex thread history has been compacted.
|
||||
"""
|
||||
if not isinstance(note, dict):
|
||||
return
|
||||
method = note.get("method") or ""
|
||||
params = note.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
return
|
||||
|
||||
if method == "thread/compacted":
|
||||
result.compacted = True
|
||||
result.thread_id = params.get("threadId") or result.thread_id
|
||||
result.turn_id = params.get("turnId") or result.turn_id
|
||||
return
|
||||
|
||||
if method not in {"item/started", "item/completed"}:
|
||||
return
|
||||
|
||||
item = params.get("item") or {}
|
||||
if not isinstance(item, dict) or item.get("type") != "contextCompaction":
|
||||
return
|
||||
|
||||
result.compacted = True
|
||||
result.thread_id = params.get("threadId") or result.thread_id
|
||||
result.turn_id = params.get("turnId") or result.turn_id
|
||||
|
||||
|
||||
def _approval_choice_to_codex_decision(choice: str) -> str:
|
||||
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
|
||||
/ FileChangeApprovalDecision wire values.
|
||||
|
|
|
|||
|
|
@ -319,6 +319,20 @@ def build_turn_context(
|
|||
current_turn_user_idx = len(messages) - 1
|
||||
agent._persist_user_message_idx = current_turn_user_idx
|
||||
|
||||
# Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot)
|
||||
# and notify the host so it can play hearts. Token-free, never touches the
|
||||
# conversation, and never fatal — a purely optional UI beat.
|
||||
reaction_callback = getattr(agent, "reaction_callback", None)
|
||||
if reaction_callback is not None:
|
||||
try:
|
||||
from agent.reactions import detect_reaction
|
||||
|
||||
kind = detect_reaction(original_user_message)
|
||||
if kind:
|
||||
reaction_callback(kind)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not agent.quiet_mode:
|
||||
_print_preview = summarize_user_message_for_log(user_message)
|
||||
agent._safe_print(
|
||||
|
|
@ -369,6 +383,20 @@ def build_turn_context(
|
|||
lambda _tokens: False,
|
||||
)
|
||||
_preflight_deferred = _defer_preflight(_preflight_tokens)
|
||||
# Codex app-server threads are compacted by the codex agent itself;
|
||||
# Hermes only initiates compaction in "hermes" mode (#36801).
|
||||
_codex_native_auto = (
|
||||
getattr(agent, "api_mode", None) == "codex_app_server"
|
||||
and str(
|
||||
getattr(
|
||||
agent,
|
||||
"codex_app_server_auto_compaction",
|
||||
"native",
|
||||
)
|
||||
or "native"
|
||||
).lower()
|
||||
in {"native", "off"}
|
||||
)
|
||||
|
||||
if not _preflight_deferred:
|
||||
_last = _compressor.last_prompt_tokens
|
||||
|
|
@ -397,6 +425,12 @@ def build_turn_context(
|
|||
int(_compression_cooldown.get("remaining_seconds", 0.0)),
|
||||
agent.session_id or "none",
|
||||
)
|
||||
elif _codex_native_auto:
|
||||
logger.info(
|
||||
"Skipping Hermes preflight compression for codex app-server "
|
||||
"(mode=%s); Hermes will not start thread compaction here.",
|
||||
getattr(agent, "codex_app_server_auto_compaction", "native"),
|
||||
)
|
||||
elif _compressor.should_compress(_preflight_tokens):
|
||||
logger.info(
|
||||
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ def finalize_turn(
|
|||
original_user_message,
|
||||
_should_review_memory,
|
||||
_turn_exit_reason,
|
||||
_pending_verification_response=None,
|
||||
):
|
||||
"""Run the post-loop finalization and return the turn ``result`` dict.
|
||||
|
||||
|
|
@ -50,10 +51,35 @@ def finalize_turn(
|
|||
"""
|
||||
from agent.conversation_loop import logger
|
||||
|
||||
if final_response is None and (
|
||||
budget_exhausted = (
|
||||
api_call_count >= agent.max_iterations
|
||||
or agent.iteration_budget.remaining <= 0
|
||||
):
|
||||
)
|
||||
budget_fallback_eligible = (
|
||||
budget_exhausted
|
||||
and not interrupted
|
||||
and not failed
|
||||
and str(_turn_exit_reason) in {"unknown", "budget_exhausted"}
|
||||
)
|
||||
continuation_budget_exhausted = (
|
||||
final_response is None
|
||||
and bool(_pending_verification_response)
|
||||
and budget_fallback_eligible
|
||||
)
|
||||
|
||||
iteration_limit_fallback = False
|
||||
preserved_verification_fallback = False
|
||||
if continuation_budget_exhausted:
|
||||
# A verification/continuation gate deliberately withheld a composed
|
||||
# answer, then consumed the remaining budget before producing a newer
|
||||
# one. Preserve that exact answer instead of replacing it with another
|
||||
# fallible model call. The explicit pending value is the provenance
|
||||
# guard: unrelated error/recovery exits can never enter this branch.
|
||||
final_response = _pending_verification_response
|
||||
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
|
||||
iteration_limit_fallback = True
|
||||
preserved_verification_fallback = True
|
||||
elif final_response is None and budget_fallback_eligible:
|
||||
# Budget exhausted — ask the model for a summary via one extra
|
||||
# API call with tools stripped. _handle_max_iterations injects a
|
||||
# user message and makes a single toolless request.
|
||||
|
|
@ -68,20 +94,18 @@ def finalize_turn(
|
|||
"— requesting summary..."
|
||||
)
|
||||
final_response = agent._handle_max_iterations(messages, api_call_count)
|
||||
iteration_limit_fallback = True
|
||||
|
||||
if iteration_limit_fallback:
|
||||
# If running as a kanban worker, signal the dispatcher that the
|
||||
# worker could not complete (rather than treating it as a
|
||||
# protocol violation). The agent loop strips tools before calling
|
||||
# _handle_max_iterations, so the model cannot call kanban_block
|
||||
# itself — we must do it on its behalf.
|
||||
# protocol violation). This applies whether the user-facing fallback
|
||||
# came from the summary call or an explicitly pending continuation;
|
||||
# both exhausted the task budget and must advance the failure circuit.
|
||||
#
|
||||
# We route through ``_record_task_failure(outcome="timed_out")``
|
||||
# rather than ``kanban_block`` so this counts toward the
|
||||
# ``consecutive_failures`` counter and the dispatcher's
|
||||
# ``failure_limit`` circuit breaker (#29747 gap 2). Without this,
|
||||
# a task whose worker keeps exhausting its budget would block
|
||||
# silently each run, get auto-promoted by the operator (or never
|
||||
# surface), and re-block in an endless loop with no signal.
|
||||
# rather than ``kanban_block`` so this counts toward the dispatcher's
|
||||
# consecutive-failure circuit breaker (#29747 gap 2).
|
||||
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
|
||||
if _kanban_task:
|
||||
try:
|
||||
|
|
@ -304,6 +328,7 @@ def finalize_turn(
|
|||
# truncated partial (the "The" case from #34452).
|
||||
_is_partial_fragment = (
|
||||
not _is_empty_terminal
|
||||
and not preserved_verification_fallback
|
||||
and not str(_turn_exit_reason).startswith("text_response")
|
||||
and len(_stripped) <= 24
|
||||
and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"}
|
||||
|
|
@ -424,6 +449,11 @@ def finalize_turn(
|
|||
"estimated_cost_usd": agent.session_estimated_cost_usd,
|
||||
"cost_status": agent.session_cost_status,
|
||||
"cost_source": agent.session_cost_source,
|
||||
# Requested service tier (from request_overrides.extra_body), for
|
||||
# billing audits by callers like `hermes -z --usage-file`.
|
||||
"service_tier": (
|
||||
(getattr(agent, "request_overrides", {}) or {}).get("extra_body") or {}
|
||||
).get("service_tier"),
|
||||
"session_id": agent.session_id,
|
||||
}
|
||||
if agent._tool_guardrail_halt_decision is not None:
|
||||
|
|
|
|||
|
|
@ -103,6 +103,54 @@ _UTC_NOW = lambda: datetime.now(timezone.utc)
|
|||
# Official docs snapshot entries. Models whose published pricing and cache
|
||||
# semantics are stable enough to encode exactly.
|
||||
_OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
# ── OpenAI GPT-5.6 series (Sol/Terra/Luna) ───────────────────────────
|
||||
# Announced in limited preview 2026-06-26; GA 2026-07-09 at the same
|
||||
# rates (Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per 1M in/out). Cache
|
||||
# writes are billed at 1.25x the uncached input rate; cache reads get the
|
||||
# standard 90% discount (0.10x input, confirmed: Sol $0.50/M cached).
|
||||
# Note: "Sol Fast mode" ($12.5/$75, up to 750 tok/s via Cerebras) is a
|
||||
# separate serving tier, not covered by these entries. The "-pro"
|
||||
# variants (high-effort modes, GA alongside base tiers) bill at the
|
||||
# SAME per-token rates and are aliased onto these entries below the
|
||||
# dict (they cost more per task by consuming more tokens, not by a
|
||||
# higher rate — verified against OpenRouter's live pricing 2026-07-09).
|
||||
# Source: https://openai.com/index/previewing-gpt-5-6-sol/
|
||||
(
|
||||
"openai",
|
||||
"gpt-5.6-sol",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("30.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
|
||||
pricing_version="openai-gpt-5.6-2026-07",
|
||||
),
|
||||
(
|
||||
"openai",
|
||||
"gpt-5.6-terra",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("2.50"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.25"),
|
||||
cache_write_cost_per_million=Decimal("3.125"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
|
||||
pricing_version="openai-gpt-5.6-2026-07",
|
||||
),
|
||||
(
|
||||
"openai",
|
||||
"gpt-5.6-luna",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("1.00"),
|
||||
output_cost_per_million=Decimal("6.00"),
|
||||
cache_read_cost_per_million=Decimal("0.10"),
|
||||
cache_write_cost_per_million=Decimal("1.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
|
||||
pricing_version="openai-gpt-5.6-2026-07",
|
||||
),
|
||||
# ── Anthropic Claude 4.8 ─────────────────────────────────────────────
|
||||
# Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate
|
||||
# model ID with 2x premium (vs the 6x premium on older Opus generations).
|
||||
|
|
@ -563,6 +611,15 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
|||
),
|
||||
}
|
||||
|
||||
# GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as
|
||||
# their base tiers (more tokens per task, not a higher rate). Alias them
|
||||
# onto the base entries so the snapshot stays single-source.
|
||||
for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"):
|
||||
_OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[
|
||||
("openai", _base_56)
|
||||
]
|
||||
del _base_56
|
||||
|
||||
|
||||
def _to_decimal(value: Any) -> Optional[Decimal]:
|
||||
if value is None:
|
||||
|
|
@ -602,7 +659,11 @@ def resolve_billing_route(
|
|||
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
|
||||
if provider_name == "anthropic":
|
||||
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name == "openai":
|
||||
# "openai-api" is the picker/registry slug for direct api.openai.com; it
|
||||
# bills identically to bare "openai", so normalize it here — otherwise the
|
||||
# ("openai", <model>) _OFFICIAL_DOCS_PRICING keys are unreachable from the
|
||||
# openai-api provider path.
|
||||
if provider_name in {"openai", "openai-api"}:
|
||||
return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name in {"minimax", "minimax-cn"}:
|
||||
return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ def build_verify_on_stop_nudge(
|
|||
+ "), read any failure, repair the code, and summarize what passed."
|
||||
)
|
||||
else:
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_dir = os.path.realpath(tempfile.gettempdir())
|
||||
command_instruction = (
|
||||
"No canonical test/lint/build command was detected. Create a focused "
|
||||
f"temporary verification script under `{temp_dir}` using an OS-safe "
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Bootstrap orchestration.
|
||||
//!
|
||||
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`.
|
||||
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`.
|
||||
//! Drives install.ps1 / install.sh stage-by-stage, emits progress events
|
||||
//! over the Tauri `bootstrap` channel, writes a forensic log to
|
||||
//! HERMES_HOME/logs/bootstrap-<timestamp>.log.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Event types streamed from Rust → React.
|
||||
//!
|
||||
//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape
|
||||
//! These mirror `apps/desktop/electron/bootstrap-runner.ts`'s event shape
|
||||
//! 1:1 so the React installer code can be roughly identical to the Electron
|
||||
//! install-overlay we'll replace.
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
//! 3. Network: download from GitHub raw at a pinned commit or branch.
|
||||
//! Commit pins are immutable; branch pins are HEAD-tracking.
|
||||
//!
|
||||
//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`,
|
||||
//! Mirrors `apps/desktop/electron/bootstrap-runner.ts`'s `resolveInstallScript`,
|
||||
//! but the dev-checkout resolution is driven by an env var rather than the
|
||||
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
|
||||
//! to live OUTSIDE any repo checkout.
|
||||
|
|
@ -64,7 +64,7 @@ impl ScriptKind {
|
|||
}
|
||||
|
||||
/// Validates a string looks like a git SHA (7+ hex chars). Mirrors
|
||||
/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs.
|
||||
/// `STAMP_COMMIT_RE` from bootstrap-runner.ts.
|
||||
fn is_valid_commit(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ fn repair_macos_installer_helper(path: &Path) {
|
|||
fn repair_macos_installer_helper(_path: &Path) {}
|
||||
|
||||
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
|
||||
/// the Electron app also checks). Per main.cjs:
|
||||
/// the Electron app also checks). Per main.ts:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
|
||||
/// this is a probe helper, not a definitive path.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh.
|
||||
//!
|
||||
//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same
|
||||
//! Port of `spawnPowerShell` from bootstrap-runner.ts, with the same
|
||||
//! line-buffered stdout/stderr streaming + cancellation semantics.
|
||||
//!
|
||||
//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`.
|
||||
|
|
@ -19,7 +19,7 @@ pub struct StreamSink {
|
|||
pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Outcome of a script invocation. Mirrors bootstrap-runner.cjs's
|
||||
/// Outcome of a script invocation. Mirrors bootstrap-runner.ts's
|
||||
/// `{stdout, stderr, code, signal, killed}` shape.
|
||||
#[derive(Debug)]
|
||||
pub struct ScriptResult {
|
||||
|
|
@ -258,7 +258,7 @@ fn interpreter_label() -> String {
|
|||
/// Parses the LAST line of stdout that looks like a JSON object matching
|
||||
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
|
||||
///
|
||||
/// Mirrors `parseStageResult` from bootstrap-runner.cjs. install.ps1 may
|
||||
/// Mirrors `parseStageResult` from bootstrap-runner.ts. install.ps1 may
|
||||
/// print info/banner lines before the result frame; we scan from the end.
|
||||
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
|
||||
for line in stdout.lines().rev() {
|
||||
|
|
|
|||
200
apps/desktop/AGENTS.md
Normal file
200
apps/desktop/AGENTS.md
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# Desktop Engineering Guide
|
||||
|
||||
How to build Hermes Desktop well. This is a judgment guide, not an inventory —
|
||||
it teaches the invariants and the reasoning behind them so a change fits the app
|
||||
even as files move. Read it with the repository `AGENTS.md` (root rules still
|
||||
apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract.
|
||||
|
||||
When a rule here and the code disagree, trust the code and fix whichever is
|
||||
wrong — but never break an invariant to make a change easier.
|
||||
|
||||
## What this app is
|
||||
|
||||
Desktop is its own native chat surface. It is not the browser dashboard and it
|
||||
does not embed the TUI. Three parties, each authoritative for one thing:
|
||||
|
||||
- **Electron** owns the machine: process lifecycle, native filesystem/git/
|
||||
windows, install/update, and a narrow, typed capability bridge.
|
||||
- **The renderer** owns the experience: navigation, presentation, and ephemeral
|
||||
interaction state.
|
||||
- **The agent backend** owns the work: sessions, tools, model calls, streaming.
|
||||
|
||||
Keep the seams clean. The renderer never reaches for Node or Electron directly;
|
||||
native power arrives through a deliberate capability, not a general escape hatch.
|
||||
Agent behavior lives behind the gateway, never reimplemented in React. When a
|
||||
change blurs a seam, that is the smell — fix the seam, don't widen it.
|
||||
|
||||
## Decide state by authority
|
||||
|
||||
The first question for any piece of state is *who is allowed to be right about
|
||||
it*, not where it is convenient to store it. Put state with its authority:
|
||||
|
||||
- The **backend** is authoritative for anything another Hermes surface can also
|
||||
change. Treat the renderer's copy as a cache of that truth.
|
||||
- **Electron** is authoritative for machine and runtime facts.
|
||||
- The **renderer** owns only what is purely about this window's presentation.
|
||||
|
||||
From that, everything else follows: shared renderer state lives in small stores
|
||||
owned by the feature that owns the concern; request-shaped server data that wants
|
||||
invalidation lives in the query layer; short-lived interaction detail stays in
|
||||
the component; hot coordination that must not paint stays in a ref. Reach for the
|
||||
narrowest home that still lets the state be correct. A new global store is a
|
||||
claim that many distant surfaces need it — earn that claim.
|
||||
|
||||
Persisted state must declare its scope in its own key: is this global, or does it
|
||||
belong to a connection, a profile, a stored session, a project, or a window?
|
||||
Getting the scope wrong is how one profile's setting bleeds into another.
|
||||
|
||||
## Identity is not incidental
|
||||
|
||||
Sessions have more than one identity, and conflating them is a recurring source
|
||||
of "session not found" and vanishing history. Reason about which identity a
|
||||
surface needs: durable navigation and anything the user pins or persists key off
|
||||
the stable/durable identity; live streaming keys off the runtime identity; state
|
||||
that must outlive compression keys off the lineage root. Keep the mapping between
|
||||
them explicit and translate at the boundary rather than passing the wrong id
|
||||
inward.
|
||||
|
||||
## Server truth is cached, not owned
|
||||
|
||||
The renderer paints from a cache of backend truth, so it must reconcile, not
|
||||
assume:
|
||||
|
||||
- **Merge, don't clobber.** A refresh is new information layered over what you
|
||||
already know, not a replacement that can drop live or pinned rows.
|
||||
- **Be optimistic, then honest.** Direct manipulation should paint immediately
|
||||
from a snapshot; a failed write rolls back visibly and an authoritative
|
||||
refresh gets the last word.
|
||||
- **Guard against the past.** Async results can arrive out of order; a stale
|
||||
response must never overwrite newer intent. Generation counters and request
|
||||
tokens exist for this.
|
||||
- **Isolate the foreground.** Only the surface the user is looking at may publish
|
||||
into the shared view; background work updates its own cache quietly.
|
||||
- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but
|
||||
let terminal transitions (a turn finishing, needing input, failing) reach the
|
||||
user immediately.
|
||||
- **Preserve reference identity on no-ops.** Handing React a fresh array that
|
||||
contains the same data re-renders expensive trees for nothing.
|
||||
|
||||
## Switching context is a re-home, not a reboot
|
||||
|
||||
Changing profile, connection, or mode is a workspace switch, not a cold start.
|
||||
The shell and whatever the user was doing stay put; only the gateway-bound view
|
||||
is cleared and repopulated, and the previous context must not leak into the next
|
||||
one. Reserve the full-screen boot/connecting experience for a genuinely unusable
|
||||
backend.
|
||||
|
||||
There are three distinct switch shapes, and conflating them is the classic bug:
|
||||
|
||||
- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home:
|
||||
shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query
|
||||
invalidation alone cannot evict live session stores — wipe them.
|
||||
- A **runtime home change** (switching the underlying `HERMES_HOME` profile) is
|
||||
a hard re-home: the window legitimately reloads and state resets by remount.
|
||||
- A **live profile swap** in the same window activates another profile's socket
|
||||
while background profiles keep streaming; lists merge rather than wipe, and
|
||||
only an explicit user selection starts a fresh foreground draft.
|
||||
|
||||
Treating a soft switch as hard flickers the app; treating a hard one as soft
|
||||
strands stale rows. After any swap, the active socket, active profile, and
|
||||
connection atoms must agree, or REST and filesystem calls route to the wrong
|
||||
backend.
|
||||
|
||||
## Cross everything as an observable ladder
|
||||
|
||||
Desktop lives at the seams: versions, profiles, local vs remote vs cloud,
|
||||
partially installed runtimes, stale caches, older backends. The durable technique
|
||||
for all of it is the same — an ordered ladder of candidates:
|
||||
|
||||
1. Precedence is written down, in one place, as data or a pure function.
|
||||
2. A candidate is trusted only after it is validated at the right boundary.
|
||||
Existence is not proof; probe what you're about to rely on.
|
||||
3. A failed *read* falls to the next rung; a failed *authoritative write*
|
||||
surfaces or rolls back rather than silently retargeting.
|
||||
4. A missing capability and a transient failure are different: the first may
|
||||
enable a compatibility path or a disabled state; the second should retry.
|
||||
5. Retries are bounded and end in a real recovery affordance — never an infinite
|
||||
spinner or a hot loop.
|
||||
6. One resolver owns each policy so every caller gets the same answer. Scatter is
|
||||
how two call sites drift apart.
|
||||
|
||||
This is the shape of backend discovery, command/version fallbacks, connection and
|
||||
auth resolution, workspace-cwd selection, capability detection, and preview
|
||||
normalization alike. Learn the shape, not a snapshot of the current rungs.
|
||||
|
||||
Two auth-flavored corollaries worth naming because they are easy to get wrong:
|
||||
|
||||
- **One-time credentials are never reused.** An OAuth gateway connection mints a
|
||||
fresh WebSocket ticket on every dial; a mint failure means reauthentication,
|
||||
not "fall back to the cached URL." Only long-lived token/local auth may reuse
|
||||
a cached URL as a lower rung.
|
||||
- **A connection test must exercise the leg you'll actually use.** An HTTP
|
||||
status probe passing while the WebSocket/auth leg fails is a false positive
|
||||
that ships as "it said connected but nothing works."
|
||||
|
||||
## Compatibility without carrying the past forever
|
||||
|
||||
Desktop and its runtime update on separate clocks, so a change can meet an older
|
||||
backend. Keep those users working: preserve the current feature, keep the
|
||||
fallback narrow and tied to an identified older runtime, and cover it with a
|
||||
test. A fallback that quietly degrades the feature it's meant to protect is worse
|
||||
than the crash it replaced.
|
||||
|
||||
## Keep the waist narrow, grow at the edges
|
||||
|
||||
The root contribution rubric governs here too. New capability should arrive at
|
||||
the smallest surface that solves it: extend what exists, add a feature locally,
|
||||
lean on an existing seam — before you invent a framework. The shell's internal
|
||||
registries are composition seams, not a public plugin ABI; do not build a
|
||||
universal extension system, a manifest, or a plugin adapter for a single
|
||||
consumer. Design a shared contract only once more than one real consumer proves
|
||||
its shape. "Plugin" means several unrelated things across Hermes — do not assume
|
||||
one surface's extension model runs in another.
|
||||
|
||||
## Respect the person using it
|
||||
|
||||
Design and engineering meet at intent. The user's attention and context are
|
||||
sacred:
|
||||
|
||||
- Never navigate, move focus, or open a surface because something *happened* in
|
||||
the background. Offer; don't hijack.
|
||||
- The states around loading are distinct experiences — empty, loading,
|
||||
reconnecting, degraded/stale, and exhausted-recovery each deserve their own
|
||||
honest copy and their own way out.
|
||||
- Keyboard ownership follows focus. The focused surface wins its keys; one
|
||||
cancel gesture does exactly one thing.
|
||||
- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden.
|
||||
Visibility is not lifecycle.
|
||||
|
||||
## Make it feel instant
|
||||
|
||||
Performance is a feature the user feels, especially in drag, resize, scroll,
|
||||
typing, streaming, and terminals. The principles are timeless even as the code
|
||||
changes: keep hot-path state local or narrowly derived; don't subscribe heavy
|
||||
trees to per-frame updates; coalesce pointer work; avoid reading layout right
|
||||
after writing style; and don't mount expensive content mid-gesture. Prove speed
|
||||
against realistic content — a fast empty demo proves nothing about a long
|
||||
transcript. If motion is masking latency, remove the motion, don't tune it.
|
||||
|
||||
## Testing as a habit of proof
|
||||
|
||||
Test the behavior that would actually break a user, not a snapshot of today's
|
||||
data. Favor invariants over frozen values. Exercise the real path for anything
|
||||
at a seam — resolver precedence and its failure rungs, identity and scope
|
||||
boundaries, optimistic rollback and stale-response ordering, and both sides of a
|
||||
local/remote adapter with its profile routing intact. Match how the suite is
|
||||
actually run rather than inventing a command; when in doubt, read the scripts.
|
||||
|
||||
## The taste test before you hand off
|
||||
|
||||
- Does every piece of state live with its authority, at the narrowest scope?
|
||||
- Would a background event ever steal the foreground or the user's focus?
|
||||
- Does each resolver have one home, a validated ladder, and a bounded, recoverable
|
||||
end?
|
||||
- Do local, remote, and profile routing still agree?
|
||||
- Does async failure leave a usable UI and a way forward?
|
||||
- Do hot interactions stay cheap under realistic load?
|
||||
- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all
|
||||
locales?
|
||||
|
||||
If any answer is "not sure," that's the part to go verify.
|
||||
|
|
@ -6,12 +6,29 @@ concern, tokens over literals, flat over boxed.** If you reach for a raw color,
|
|||
a one-off shadow, a bespoke button, or a hardcoded `px-*` on a control — stop,
|
||||
there's already a primitive for it.
|
||||
|
||||
This file owns the visual and interaction contract. Read
|
||||
[`AGENTS.md`](./AGENTS.md) for architecture, state, resolver, transport, and
|
||||
testing rules.
|
||||
|
||||
This doc contains two kinds of content, maintained differently:
|
||||
|
||||
- **Principles** (flatness, intent, feedback, motion, cancellation) are durable.
|
||||
They hold as components come and go.
|
||||
- **Named contracts** (tokens, `Button` variants, primitive names) are the
|
||||
design system's current API. They are maintained *with* the code: if you
|
||||
change a primitive, token, or variant, update its entry here **in the same
|
||||
change** — a stale name in this file is a bug, exactly like a stale type.
|
||||
|
||||
When a rule and the code disagree, fix whichever is wrong rather than forking a
|
||||
one-off at the call site.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Flat, not boxed.** No card-in-card, no divider borders inside a panel.
|
||||
Group with whitespace and a single hairline, never nested rounded boxes.
|
||||
2. **Borderless + shadow for elevation.** Overlays float on `shadow-nous` + a
|
||||
`--stroke-nous` hairline, not hard borders.
|
||||
2. **Borderless elevation for floating panels.** Overlays float on
|
||||
`shadow-nous` + a `--stroke-nous` hairline, not thick framed boxes. In-panel
|
||||
structure may use token hairlines sparingly.
|
||||
3. **One primitive per concern.** One `Button`, one set of control variants,
|
||||
one `SearchField`, one `Loader`, one `ErrorState`. Migrate onto them; don't
|
||||
fork.
|
||||
|
|
@ -20,11 +37,40 @@ there's already a primitive for it.
|
|||
5. **Style lives in the primitive.** Variants and sizes own padding, radius,
|
||||
color, chrome. Call sites pass a `variant`/`size`, not `className` overrides
|
||||
that re-specify those.
|
||||
6. **Intent before automation.** Surface useful actions and previews, but do not
|
||||
open panes, move focus, or navigate because a tool happened to produce
|
||||
something.
|
||||
7. **Immediate feedback.** Direct manipulation updates the view first. Network
|
||||
or disk persistence reconciles afterward and rolls back visibly on failure.
|
||||
|
||||
## Information architecture
|
||||
|
||||
- **Chat is the home surface.** The transcript and composer stay primary; tools,
|
||||
previews, files, review, and terminal complement the conversation.
|
||||
- **Pages are durable destinations.** Chat, Skills, Messaging, and Artifacts
|
||||
remain in shell chrome. Do not hide a distinct product noun inside an
|
||||
unrelated page.
|
||||
- **Route overlays are short tasks.** Settings, Command Center, Cron, Profiles,
|
||||
Agents, and Starmap render as `OverlayView` cards and return to the previous
|
||||
route on close. Model/session pickers and dialogs layer above the current
|
||||
surface; they are not navigation stacks.
|
||||
- **Panes are working context.** Preview, files, review, and terminal remain
|
||||
attached to the current task. Their state survives temporary hiding and chat
|
||||
switches where the underlying tool is meant to persist.
|
||||
- **One action, one home.** A command may have keyboard, palette, and visible
|
||||
affordances, but they invoke the same action and state. Do not fork behavior
|
||||
per entry point.
|
||||
- **Projects own workspace cwd.** Use Sidebar → Projects for local folders and
|
||||
worktrees; do not reintroduce a per-session/right-sidebar folder-picker flow.
|
||||
|
||||
Navigation must preserve context. A background session finishing, a tool result
|
||||
arriving, or a project refresh may update badges and cached data; it must not
|
||||
replace the foreground transcript or steal focus.
|
||||
|
||||
## Surfaces & elevation
|
||||
|
||||
Every overlay / dialog / toast (boot-failure, install, notifications,
|
||||
model-picker, onboarding, prompt-overlays, updates, base `Dialog`) uses:
|
||||
Floating panels (base `Dialog`, route overlays, boot/install/update surfaces,
|
||||
model-picker, onboarding, prompt overlays, notifications) use:
|
||||
|
||||
```
|
||||
shadow-nous /* downward-weighted, layered contact→ambient falloff */
|
||||
|
|
@ -35,6 +81,11 @@ Both are CSS vars in `src/styles.css` — tune in one place, everything inherits
|
|||
Don't add per-overlay `shadow-[…]` or `border-(--ui-stroke-secondary)`
|
||||
one-offs; if elevation needs to change, change the token.
|
||||
|
||||
Menus and popovers use their own shared `shadow-md` +
|
||||
`--ui-stroke-secondary` primitive treatment. Drag affordances may use tokenized
|
||||
dashed targets and local blur. These are semantic surface classes, not licenses
|
||||
for call-site shadow or border inventions.
|
||||
|
||||
## Stroke & color tokens
|
||||
|
||||
| Token | Use |
|
||||
|
|
@ -62,8 +113,9 @@ fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel",
|
|||
"Open logs").
|
||||
|
||||
**Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons
|
||||
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), and the icon
|
||||
family `icon` / `icon-xs` / `icon-sm` / `icon-lg` / `icon-titlebar`.
|
||||
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro`
|
||||
(status-stack/table-footers), and the icon family `icon` / `icon-xs` /
|
||||
`icon-sm` / `icon-lg` / `icon-titlebar`.
|
||||
|
||||
Notes:
|
||||
- Text buttons are square (no radius) and sized by padding + line-height (no
|
||||
|
|
@ -107,11 +159,36 @@ Notes:
|
|||
through for a11y.
|
||||
- **Logs:** `LogView` — no bg, hairline border, tight padding, small mono.
|
||||
Every place we surface raw logs uses it.
|
||||
- **Empty:** `EmptyState` / `EmptyPanel` — don't hand-roll centered empties.
|
||||
- **Empty:** `EmptyState` for plain page bodies; `PanelEmpty` for overlay
|
||||
master/detail empties with an icon and action. Don't hand-roll a third
|
||||
centered empty.
|
||||
|
||||
## Chat, tools & boot surfaces
|
||||
|
||||
- The transcript and composer are built on `@assistant-ui/react`. Extend the
|
||||
existing components under `src/components/assistant-ui` and
|
||||
`src/app/chat/composer`; do not fork a second markdown, message, tool-call, or
|
||||
approval renderer for one feature.
|
||||
- A tool result may expose an inline action that opens a preview. It must not
|
||||
open the rail automatically.
|
||||
- Install, onboarding, connecting, boot failure, and reauthentication are
|
||||
distinct states with shared visual primitives. Preserve their recovery
|
||||
semantics when unifying appearance.
|
||||
- Respect `AppShell` overlay ownership. Persistent terminal/content layers,
|
||||
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
|
||||
z-index literals.
|
||||
|
||||
## Iconography & brand
|
||||
|
||||
- **`Codicon`** is the icon set. No mixing icon libraries inline.
|
||||
- **Tabler** is the default component/chrome set. Import its curated aliases and
|
||||
`iconSize` scale from `src/lib/icons.ts`; do not import icon packages directly
|
||||
in feature code.
|
||||
- **`Codicon`** is the compact editor/tool/status vocabulary. Use
|
||||
`src/components/ui/codicon.tsx`, including `codiconIcon()` where a
|
||||
Tabler-shaped component is required.
|
||||
- Pick the vocabulary by semantic context and reuse the existing icon for an
|
||||
action. Do not introduce a third icon set or mix styles within one control
|
||||
group.
|
||||
- **`BrandMark`** (`src/components/brand-mark.tsx`) is the brand glyph — the
|
||||
`nous-girl` mark on a white tile, softly rounded, identical in light/dark.
|
||||
It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it
|
||||
|
|
@ -124,6 +201,43 @@ Notes:
|
|||
- Choreographed exits (e.g. onboarding's "matrix" fade-down) stagger per-element
|
||||
then settle the surface — the outer container's fade is *delayed* so it
|
||||
doesn't swallow the inner animation. Don't let a global fade race the detail.
|
||||
- Motion follows state; it never delays state. Selection, drag targets, cancel,
|
||||
and pressed feedback paint in the current frame.
|
||||
- Do not animate layout geometry with `transition-all` on a hot interaction.
|
||||
Name the properties, avoid backdrop-filter repaints during movement, and
|
||||
remove animation before masking a performance problem.
|
||||
|
||||
## Direct manipulation & performance
|
||||
|
||||
The app should feel instant under real load — long transcripts, several panes,
|
||||
live streams. Design toward that:
|
||||
|
||||
- Direct manipulation paints first; persistence reconciles after and rolls back
|
||||
visibly on failure.
|
||||
- Keep interaction feedback cheap: hot-path state stays local or narrowly
|
||||
derived, not wired into heavy trees; pointer work coalesces per frame.
|
||||
- One drop region has one visual owner, and drop targets speak one affordance
|
||||
language across files, sessions, tabs, and panes. Overlapping targets resolve
|
||||
to the active one instead of stacking overlays.
|
||||
- Forgiving geometry beats pixel-perfect triggers; edge actions live near their
|
||||
edge, not clustered in the center.
|
||||
- Expensive stateful surfaces stay mounted when hidden. Visibility is not
|
||||
lifecycle.
|
||||
|
||||
Prove speed with realistic content. A fast empty-state demo says nothing about a
|
||||
long transcript or a busy terminal.
|
||||
|
||||
## Keyboard & cancellation
|
||||
|
||||
- Keyboard ownership follows focus. The focused surface wins its keys; shell
|
||||
shortcuts must not steal a terminal's or editor's bindings.
|
||||
- Register global shortcuts through the shared layer, not ad-hoc listeners.
|
||||
- One cancel gesture does one thing: cancel the active interaction, or close the
|
||||
topmost dismissable surface — never both, never the control underneath.
|
||||
- Cancellation is synchronous in the UI even if cleanup is async: overlays,
|
||||
cursors, and pending gesture state clear at once.
|
||||
- Flows that deliberately cannot be dismissed (install/onboarding, destructive
|
||||
confirmation) must make that explicit.
|
||||
|
||||
## i18n
|
||||
|
||||
|
|
@ -135,12 +249,15 @@ Notes:
|
|||
|
||||
## State (TypeScript)
|
||||
|
||||
Mirrors the repo TS style (see root `AGENTS.md`):
|
||||
The detailed state contract lives in the scoped
|
||||
[`AGENTS.md`](./AGENTS.md). Visual code follows these essentials:
|
||||
|
||||
- Shared/cross-component state → small **nanostores**, not prop-drilling.
|
||||
Each feature owns its atoms; shared atoms live in `src/store`.
|
||||
- Rendering components subscribe with `useStore`; non-render actions read with
|
||||
`$atom.get()`.
|
||||
- Subscribe to derived coarse facts instead of high-frequency source atoms when
|
||||
the component does not render the full value.
|
||||
- Colocated action modules over god hooks. A hook owns one narrow job.
|
||||
- Keep persistence beside the atom that owns it. Route roots stay thin.
|
||||
- Prefer `interface` for public props; extend React primitives
|
||||
|
|
@ -163,5 +280,13 @@ Mirrors the repo TS style (see root `AGENTS.md`):
|
|||
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
|
||||
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
|
||||
- [ ] Flat — no card-in-card, no gratuitous row dividers?
|
||||
- [ ] No automatic navigation, focus steal, or pane opening from background
|
||||
events?
|
||||
- [ ] Direct manipulation paints immediately and rolls back cleanly on failure?
|
||||
- [ ] Hot interactions avoid broad subscriptions, layout thrash, and
|
||||
`transition-all`?
|
||||
- [ ] Keyboard ownership and single-action `Esc` behavior are correct?
|
||||
- [ ] All four locales updated for any new/changed string?
|
||||
- [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave?
|
||||
- [ ] Touched a primitive, token, or variant? Its named-contract entry in this
|
||||
file is updated in the same change.
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ npm run dev # Vite renderer + Electron, which boots the Python backend
|
|||
Point the app at a specific source checkout, or sandbox it away from your real config:
|
||||
|
||||
```bash
|
||||
# throwaway HERMES_HOME, separate Electron userData, distinct app name to avoid the single-instance lock
|
||||
../scripts/dev-sandbox.sh npm run dev
|
||||
HERMES_DESKTOP_HERMES_ROOT=/path/to/clone npm run dev
|
||||
HERMES_HOME=/tmp/throwaway npm run dev
|
||||
npm run dev:fake-boot # exercise the startup overlay with deterministic delays
|
||||
|
|
@ -85,7 +87,63 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
|
|||
|
||||
### How it works
|
||||
|
||||
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.cjs` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
|
||||
The packaged app ships the Electron shell and a native React chat surface. On
|
||||
first launch it can install the Hermes Agent runtime into `HERMES_HOME`
|
||||
(`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows), using the same layout as a
|
||||
CLI install.
|
||||
|
||||
The app has three boundaries:
|
||||
|
||||
- **Electron** resolves and validates a runnable backend, owns native
|
||||
filesystem/git/window capabilities, and exposes a narrow preload bridge.
|
||||
- **React** owns the Desktop routes, panes, interaction state, and
|
||||
`@assistant-ui/react` transcript.
|
||||
- **Hermes Agent** runs as a headless `hermes serve` process and exposes the
|
||||
`tui_gateway` JSON-RPC/WebSocket API. The renderer connects through
|
||||
[`apps/shared`](../shared/), which is also used by the browser dashboard.
|
||||
|
||||
Backend resolution is an ordered ladder:
|
||||
|
||||
1. `HERMES_DESKTOP_HERMES_ROOT`
|
||||
2. the current source checkout during development
|
||||
3. a completed managed install
|
||||
4. `HERMES_DESKTOP_HERMES`, or `hermes` on `PATH`
|
||||
5. a system Python that can import the Hermes runtime
|
||||
6. the first-launch bootstrap installer
|
||||
|
||||
Candidates are probed before use; an existing shim or interpreter is not enough.
|
||||
A runtime that predates `serve` falls back to headless
|
||||
`dashboard --no-open`. This is compatibility for the backend command only and
|
||||
does not launch or embed the dashboard UI.
|
||||
|
||||
The Electron orchestration entry point is `electron/main.ts`; pure resolution,
|
||||
probe, hardening, and platform policies live in focused modules beside it. The
|
||||
renderer is under `src/`, with shared atoms in `src/store` and transport/native
|
||||
adapters in `src/lib`.
|
||||
|
||||
Before changing the app, read:
|
||||
|
||||
- [`AGENTS.md`](./AGENTS.md): architecture, state ownership, resolver/fallback,
|
||||
transport, performance, and testing rules.
|
||||
- [`DESIGN.md`](./DESIGN.md): visual system, information architecture, motion,
|
||||
direct manipulation, and keyboard behavior.
|
||||
|
||||
### Connections, projects, and switching
|
||||
|
||||
Desktop supports a managed local backend, explicit remote gateways, and Hermes
|
||||
Cloud connections. Remote and cloud modes use the same remote-capability path;
|
||||
authentication and discovery differ, not the renderer feature model.
|
||||
|
||||
Projects are the workspace abstraction. A project may own multiple folders,
|
||||
repositories, worktrees, and sessions; a bare new chat remains detached unless
|
||||
the user enters a project or configures a default project directory. Use the
|
||||
Projects UI rather than adding a second per-session folder-picker workflow.
|
||||
|
||||
Changing profiles or connection modes is a soft workspace switch, not another
|
||||
cold boot. The shell and current management overlay remain mounted while
|
||||
gateway-bound nanostores are wiped, query-backed data is invalidated, and the
|
||||
new connection repopulates skeletons. This prevents rows or transcripts from
|
||||
the previous gateway bleeding into the next one.
|
||||
|
||||
### Verification
|
||||
|
||||
|
|
@ -95,9 +153,13 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl
|
|||
npm run fix
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run test:desktop:all
|
||||
npm run test:ui
|
||||
npm run test:desktop:platforms
|
||||
```
|
||||
|
||||
Run `npm run test:desktop:all` for install, boot, update, packaging, or other
|
||||
release-path changes.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const { serveBackendArgs, dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs')
|
||||
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
|
||||
|
||||
test('serveBackendArgs builds a headless serve invocation', () => {
|
||||
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0'])
|
||||
|
|
@ -61,5 +61,6 @@ test('sourceDeclaresServe does not false-positive on the substring "server"', ()
|
|||
dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard")
|
||||
from hermes_cli.web_server import start_server # web server
|
||||
`
|
||||
|
||||
assert.equal(sourceDeclaresServe(oldSource), false)
|
||||
})
|
||||
|
|
@ -17,8 +17,9 @@
|
|||
* Build the canonical headless backend argv (always `serve`).
|
||||
* @param {string} [profile] optional Hermes profile to pin via `--profile`.
|
||||
*/
|
||||
function serveBackendArgs(profile) {
|
||||
export function serveBackendArgs(profile?: string) {
|
||||
const head = profile ? ['--profile', profile] : []
|
||||
|
||||
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
}
|
||||
|
||||
|
|
@ -28,9 +29,13 @@ function serveBackendArgs(profile) {
|
|||
* `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is
|
||||
* no `serve` token the argv is returned unchanged.
|
||||
*/
|
||||
function dashboardFallbackArgs(args) {
|
||||
export function dashboardFallbackArgs(args) {
|
||||
const i = args.indexOf('serve')
|
||||
if (i === -1) return args.slice()
|
||||
|
||||
if (i === -1) {
|
||||
return args.slice()
|
||||
}
|
||||
|
||||
return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
|
||||
}
|
||||
|
||||
|
|
@ -40,12 +45,6 @@ function dashboardFallbackArgs(args) {
|
|||
* specifically so the substring "server" (e.g. "start_server", "web server")
|
||||
* never produces a false positive.
|
||||
*/
|
||||
function sourceDeclaresServe(dashboardPySource) {
|
||||
export function sourceDeclaresServe(dashboardPySource) {
|
||||
return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serveBackendArgs,
|
||||
dashboardFallbackArgs,
|
||||
sourceDeclaresServe
|
||||
}
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const path = require('node:path')
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
import {
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
} = require('./backend-env.cjs')
|
||||
pathEnvKey,
|
||||
POSIX_SANE_PATH_ENTRIES
|
||||
} from './backend-env'
|
||||
|
||||
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
const path = require('node:path')
|
||||
import path from 'node:path'
|
||||
|
||||
// Match the POSIX fallback surface used by the Python terminal environment.
|
||||
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
|
||||
|
|
@ -23,12 +23,16 @@ function pathModuleForPlatform(platform = process.platform) {
|
|||
}
|
||||
|
||||
function pathEnvKey(env = process.env, platform = process.platform) {
|
||||
if (platform !== 'win32') return 'PATH'
|
||||
if (platform !== 'win32') {
|
||||
return 'PATH'
|
||||
}
|
||||
|
||||
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
|
||||
}
|
||||
|
||||
function currentPathValue(env = process.env, platform = process.platform) {
|
||||
const key = pathEnvKey(env, platform)
|
||||
|
||||
return env?.[key] || ''
|
||||
}
|
||||
|
||||
|
|
@ -37,10 +41,15 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
|
|||
const ordered = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry) continue
|
||||
if (!entry) {
|
||||
continue
|
||||
}
|
||||
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part || seen.has(part)) continue
|
||||
if (!part || seen.has(part)) {
|
||||
continue
|
||||
}
|
||||
seen.add(part)
|
||||
ordered.push(part)
|
||||
}
|
||||
|
|
@ -55,7 +64,7 @@ function buildDesktopBackendPath({
|
|||
currentPath = '',
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
}: any = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
|
||||
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
|
||||
|
|
@ -64,13 +73,17 @@ function buildDesktopBackendPath({
|
|||
return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter })
|
||||
}
|
||||
|
||||
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
|
||||
if (!hermesHome) return hermesHome
|
||||
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) {
|
||||
if (!hermesHome) {
|
||||
return hermesHome
|
||||
}
|
||||
const resolved = pathModule.resolve(String(hermesHome))
|
||||
const parent = pathModule.dirname(resolved)
|
||||
|
||||
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
|
||||
return pathModule.dirname(parent)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +94,7 @@ function buildDesktopBackendEnv({
|
|||
currentEnv = process.env,
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
}: any = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const currentPythonPath = currentEnv?.PYTHONPATH || ''
|
||||
const key = pathEnvKey(currentEnv, platform)
|
||||
|
|
@ -98,12 +111,12 @@ function buildDesktopBackendEnv({
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
export {
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
pathEnvKey,
|
||||
POSIX_SANE_PATH_ENTRIES
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
/**
|
||||
* Tests for electron/backend-probes.cjs.
|
||||
* Tests for electron/backend-probes.ts.
|
||||
*
|
||||
* Run with: node --test electron/backend-probes.test.cjs
|
||||
* Run with: node --test electron/backend-probes.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
|
||||
|
||||
// Resolve the host's own Node binary -- guaranteed to be on disk and
|
||||
// runnable. We use it as both a stand-in for "a python that doesn't
|
||||
|
|
@ -67,6 +67,7 @@ test('verifyHermesCli returns true when --version exits 0', () => {
|
|||
// verifyHermesCli only cares about the exit code.
|
||||
const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`)
|
||||
fs.writeFileSync(scriptPath, 'process.exit(0)\n')
|
||||
|
||||
try {
|
||||
// Use node as the launcher and our script as the "command". Pass
|
||||
// shell:false (default) -- node is a real binary, no shim.
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* backend-probes.cjs
|
||||
* backend-probes.ts
|
||||
*
|
||||
* Cheap "does this candidate backend actually work" checks used by
|
||||
* resolveHermesBackend (main.cjs). The resolver walks a ladder of
|
||||
* resolveHermesBackend (main.ts). The resolver walks a ladder of
|
||||
* candidates -- bootstrap marker, `hermes` on PATH, system Python with
|
||||
* hermes_cli installed -- and historically returned the first candidate
|
||||
* whose binary existed on disk. That assumption breaks when a user has
|
||||
|
|
@ -27,12 +27,12 @@
|
|||
* via the caller's catch block if it chooses)
|
||||
* - any throw -> false (never propagate -- resolver wants a boolean)
|
||||
*
|
||||
* Kept in a standalone cjs module so it can be unit-tested with
|
||||
* Kept in a standalone ts module so it can be unit-tested with
|
||||
* `node --test` without dragging in the electron runtime (same pattern
|
||||
* as bootstrap-platform.cjs and hardening.cjs).
|
||||
* as bootstrap-platform.ts and hardening.ts).
|
||||
*/
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
const PROBE_TIMEOUT_MS = 5000
|
||||
|
||||
|
|
@ -62,12 +62,14 @@ function hermesRuntimeImportProbe() {
|
|||
* through PYTHONPATH but lack PyYAML, then die on the first real CLI import.
|
||||
*
|
||||
* @param {string} pythonPath - Absolute path to a python.exe / python.
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.env] - Additional environment for the probe.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function canImportHermesCli(pythonPath, opts = {}) {
|
||||
if (!pythonPath) return false
|
||||
function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) {
|
||||
if (!pythonPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
|
||||
env: { ...process.env, ...(opts.env || {}) },
|
||||
|
|
@ -75,6 +77,7 @@ function canImportHermesCli(pythonPath, opts = {}) {
|
|||
timeout: PROBE_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
|
|
@ -95,31 +98,29 @@ function canImportHermesCli(pythonPath, opts = {}) {
|
|||
*
|
||||
* @param {string} hermesCommand - Resolved absolute path to a hermes
|
||||
* executable (or an interpreter+script wrapper).
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.shell] - Whether to run through a shell. For
|
||||
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
|
||||
* the cmd interpreter; mirrors the same flag isCommandScript() drives
|
||||
* in resolveHermesBackend.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function verifyHermesCli(hermesCommand, opts = {}) {
|
||||
if (!hermesCommand) return false
|
||||
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
||||
if (!hermesCommand) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(hermesCommand, ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
shell: Boolean(opts.shell),
|
||||
shell: Boolean(opts?.shell),
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canImportHermesCli,
|
||||
hermesRuntimeImportProbe,
|
||||
verifyHermesCli,
|
||||
PROBE_TIMEOUT_MS
|
||||
}
|
||||
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, verifyHermesCli }
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Tests for electron/backend-ready.cjs.
|
||||
* Tests for electron/backend-ready.ts.
|
||||
*
|
||||
* Run with: node --test electron/backend-ready.test.cjs
|
||||
* Run with: node --test electron/backend-ready.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Covers the cold-start port-announcement deadline (issue #50209): the clock
|
||||
|
|
@ -11,29 +11,34 @@
|
|||
* HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
import {
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
readDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
waitForDashboardPort,
|
||||
waitForDashboardPortAnnouncement,
|
||||
waitForDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS
|
||||
} = require('./backend-ready.cjs')
|
||||
waitForDashboardReadyFile
|
||||
} from './backend-ready'
|
||||
|
||||
type FakeChildProcess = EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
}
|
||||
|
||||
// A minimal stand-in for a spawned child process: an EventEmitter with a
|
||||
// stdout EventEmitter, matching the surface waitForDashboardPort consumes
|
||||
// (child.stdout.on('data'), child.on('exit'|'error') + the .off() teardown).
|
||||
function makeFakeChild() {
|
||||
const child = new EventEmitter()
|
||||
function makeFakeChild(): FakeChildProcess {
|
||||
const child = new EventEmitter() as FakeChildProcess
|
||||
child.stdout = new EventEmitter()
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +91,13 @@ test('resolves with the announced port', async () => {
|
|||
assert.equal(await p, 54321)
|
||||
})
|
||||
|
||||
test('resolves with a HERMES_BACKEND_READY port (headless `serve`)', async () => {
|
||||
const child = makeFakeChild()
|
||||
const p = waitForDashboardPort(child, 1000)
|
||||
child.stdout.emit('data', 'HERMES_BACKEND_READY port=43210\n')
|
||||
assert.equal(await p, 43210)
|
||||
})
|
||||
|
||||
test('parses the port even when the line arrives split across chunks', async () => {
|
||||
const child = makeFakeChild()
|
||||
const p = waitForDashboardPort(child, 1000)
|
||||
|
|
@ -132,6 +144,7 @@ test('a late announcement after timeout does not throw (listeners torn down)', a
|
|||
|
||||
function mkTmpReadyFile() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
|
||||
|
||||
return {
|
||||
dir,
|
||||
file: path.join(dir, 'ready.json'),
|
||||
|
|
@ -141,6 +154,7 @@ function mkTmpReadyFile() {
|
|||
|
||||
test('readDashboardReadyFile returns a valid port from JSON', () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
|
||||
assert.equal(readDashboardReadyFile(tmp.file), 4567)
|
||||
|
|
@ -151,6 +165,7 @@ test('readDashboardReadyFile returns a valid port from JSON', () => {
|
|||
|
||||
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
|
||||
try {
|
||||
assert.equal(readDashboardReadyFile(tmp.file), null)
|
||||
fs.writeFileSync(tmp.file, '{')
|
||||
|
|
@ -165,6 +180,7 @@ test('readDashboardReadyFile ignores missing, malformed, or invalid files', () =
|
|||
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
|
||||
try {
|
||||
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
|
||||
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
|
||||
|
|
@ -177,6 +193,7 @@ test('waitForDashboardReadyFile resolves when the ready file appears', async ()
|
|||
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
|
||||
try {
|
||||
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
|
||||
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
|
||||
|
|
@ -189,6 +206,7 @@ test('waitForDashboardPortAnnouncement uses ready file when provided', async ()
|
|||
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
|
||||
try {
|
||||
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
|
||||
child.emit('exit', 1, null)
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
const fs = require('node:fs')
|
||||
import fs from 'node:fs'
|
||||
|
||||
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
|
||||
// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard`
|
||||
// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn
|
||||
// works against both the headless backend and old/dashboard runtimes.
|
||||
const _READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m
|
||||
|
||||
// The announcement clock starts the instant the backend process is spawned —
|
||||
// before uvicorn binds its socket. On a cold install the child must first
|
||||
|
|
@ -23,15 +26,17 @@ const MIN_PORT_ANNOUNCE_TIMEOUT_MS = 45_000
|
|||
*/
|
||||
function resolvePortAnnounceTimeoutMs(env = process.env) {
|
||||
const parsed = Number(env.HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS)
|
||||
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.max(MIN_PORT_ANNOUNCE_TIMEOUT_MS, Math.round(parsed))
|
||||
}
|
||||
|
||||
return DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=<N>`
|
||||
* line that web_server.py prints after uvicorn binds its socket.
|
||||
* Watch a child process's stdout for the `HERMES_(BACKEND|DASHBOARD)_READY
|
||||
* port=<N>` line that web_server.py prints after uvicorn binds its socket.
|
||||
*
|
||||
* Returns the parsed port. Rejects if:
|
||||
* - the child exits before emitting the line
|
||||
|
|
@ -52,7 +57,9 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
|
|||
let done = false
|
||||
|
||||
function cleanup() {
|
||||
if (done) return
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
child.stdout.off('data', onData)
|
||||
|
|
@ -63,13 +70,16 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
|
|||
function onData(chunk) {
|
||||
buf += chunk.toString()
|
||||
let nl
|
||||
|
||||
while ((nl = buf.indexOf('\n')) !== -1) {
|
||||
const line = buf.slice(0, nl)
|
||||
buf = buf.slice(nl + 1)
|
||||
const m = line.match(_READY_RE)
|
||||
|
||||
if (m) {
|
||||
cleanup()
|
||||
resolve(parseInt(m[1], 10))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -96,11 +106,15 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
|
|||
})
|
||||
}
|
||||
|
||||
function readDashboardReadyFile(readyFile) {
|
||||
if (!readyFile) return null
|
||||
function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) {
|
||||
if (!readyFile) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
|
||||
const port = Number(parsed?.port)
|
||||
|
||||
return Number.isInteger(port) && port > 0 ? port : null
|
||||
} catch {
|
||||
return null
|
||||
|
|
@ -113,16 +127,22 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
|
|||
let interval = null
|
||||
|
||||
function cleanup() {
|
||||
if (done) return
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
if (interval) clearInterval(interval)
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
|
||||
function check() {
|
||||
const port = readDashboardReadyFile(readyFile)
|
||||
|
||||
if (port) {
|
||||
cleanup()
|
||||
resolve(port)
|
||||
|
|
@ -147,25 +167,36 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
|
|||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
interval = setInterval(check, 50)
|
||||
if (typeof interval.unref === 'function') interval.unref()
|
||||
|
||||
if (typeof interval.unref === 'function') {
|
||||
interval.unref()
|
||||
}
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
function waitForDashboardPortAnnouncement(child, options = {}) {
|
||||
function waitForDashboardPortAnnouncement(
|
||||
child,
|
||||
options: {
|
||||
readyFile?: fs.PathOrFileDescriptor
|
||||
timeoutMs?: number
|
||||
} = {}
|
||||
) {
|
||||
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
|
||||
|
||||
if (options.readyFile) {
|
||||
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs)
|
||||
}
|
||||
|
||||
return waitForDashboardPort(child, timeoutMs)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
waitForDashboardPort,
|
||||
waitForDashboardPortAnnouncement,
|
||||
waitForDashboardReadyFile,
|
||||
export {
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
readDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS
|
||||
waitForDashboardPort,
|
||||
waitForDashboardPortAnnouncement,
|
||||
waitForDashboardReadyFile
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
import {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
} = require('./bootstrap-platform.cjs')
|
||||
} from './bootstrap-platform'
|
||||
|
||||
test('isWslEnvironment detects WSL2 env vars on linux', () => {
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
|
||||
|
|
@ -85,27 +83,3 @@ test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both wa
|
|||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
|
||||
const electronDir = __dirname
|
||||
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
|
||||
// - electron: provided by the electron runtime, always resolvable in packaged builds.
|
||||
// - node-pty: hoisted by workspace dedup AND shipped via extraResources to
|
||||
// resources/native-deps/node-pty (see scripts/stage-native-deps.cjs). main.cjs
|
||||
// has a try/catch fallback at line ~38 that resolves the staged copy when the
|
||||
// bare require fails in the packaged asar, so the bare require itself is by
|
||||
// design rather than an oversight.
|
||||
const allowedBareRequires = new Set(['electron', 'node-pty'])
|
||||
const requirePattern = /require\(['"]([^'"]+)['"]\)/g
|
||||
|
||||
for (const entrypoint of entrypoints) {
|
||||
const source = fs.readFileSync(path.join(electronDir, entrypoint), 'utf8')
|
||||
const bareRequires = Array.from(source.matchAll(requirePattern))
|
||||
.map(match => match[1])
|
||||
.filter(specifier => !specifier.startsWith('node:'))
|
||||
.filter(specifier => !specifier.startsWith('.'))
|
||||
.filter(specifier => !allowedBareRequires.has(specifier))
|
||||
|
||||
assert.deepEqual(bareRequires, [], `${entrypoint} has unpackaged runtime requires`)
|
||||
}
|
||||
})
|
||||
|
|
@ -1,20 +1,32 @@
|
|||
const fs = require('node:fs')
|
||||
import fs from 'node:fs'
|
||||
|
||||
function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) {
|
||||
if (platform !== 'linux') return false
|
||||
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true
|
||||
if (platform !== 'linux') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
|
||||
|
||||
return /microsoft|wsl/i.test(release)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowsBinaryPathInWsl(filePath, options = {}) {
|
||||
function isWindowsBinaryPathInWsl(
|
||||
filePath,
|
||||
options: { isWsl?: boolean; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}
|
||||
) {
|
||||
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
|
||||
if (!isWsl) return false
|
||||
|
||||
if (!isWsl) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalized = String(filePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
|
|
@ -48,19 +60,27 @@ const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
|
|||
*
|
||||
* Pure + dependency-free so it can be unit-tested and called before app ready.
|
||||
*/
|
||||
function detectRemoteDisplay(options = {}) {
|
||||
function detectRemoteDisplay(options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}) {
|
||||
const env = options.env ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
|
||||
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
|
||||
if (GPU_OVERRIDE_OFF.has(override)) return null
|
||||
|
||||
if (GPU_OVERRIDE_ON.has(override)) {
|
||||
return 'override (HERMES_DESKTOP_DISABLE_GPU)'
|
||||
}
|
||||
|
||||
if (GPU_OVERRIDE_OFF.has(override)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Launched from an SSH session → the display is X11-forwarded or otherwise
|
||||
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
|
||||
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session'
|
||||
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
|
||||
return 'ssh-session'
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
|
||||
|
|
@ -68,6 +88,7 @@ function detectRemoteDisplay(options = {}) {
|
|||
// NB: WSLg deliberately isn't treated as remote — it reports
|
||||
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
|
||||
const display = String(env.DISPLAY || '')
|
||||
|
||||
if (display.includes(':') && display.split(':')[0]) {
|
||||
return `x11-forwarding (DISPLAY=${display})`
|
||||
}
|
||||
|
|
@ -77,15 +98,13 @@ function detectRemoteDisplay(options = {}) {
|
|||
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
|
||||
// "Console".
|
||||
const sessionName = String(env.SESSIONNAME || '')
|
||||
if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})`
|
||||
|
||||
if (/^rdp-/i.test(sessionName)) {
|
||||
return `rdp (SESSIONNAME=${sessionName})`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
}
|
||||
export { bundledRuntimeImportCheck, detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment }
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
runBootstrap,
|
||||
resolveInstallScript,
|
||||
import {
|
||||
buildPinArgs,
|
||||
buildPosixPinArgs,
|
||||
cachedScriptPath,
|
||||
hasExistingGitCheckout,
|
||||
installedAgentInstallScript,
|
||||
cachedScriptPath
|
||||
} = require('./bootstrap-runner.cjs')
|
||||
resolveInstallScript,
|
||||
runBootstrap
|
||||
} from './bootstrap-runner'
|
||||
|
||||
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
|
||||
|
||||
|
|
@ -22,6 +25,7 @@ test('runBootstrap bails immediately when the signal is already aborted', async
|
|||
controller.abort()
|
||||
|
||||
const events = []
|
||||
|
||||
const result = await runBootstrap({
|
||||
installStamp: null,
|
||||
activeRoot: '/tmp/hermes-runner-test',
|
||||
|
|
@ -42,6 +46,7 @@ test('runBootstrap bails immediately when the signal is already aborted', async
|
|||
|
||||
test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
|
||||
|
||||
|
|
@ -57,8 +62,61 @@ test('installedAgentInstallScript resolves the installer in the agent checkout',
|
|||
}
|
||||
})
|
||||
|
||||
test('existing checkout detection requires git metadata', () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const activeRoot = path.join(home, 'hermes-agent')
|
||||
assert.equal(hasExistingGitCheckout(activeRoot), false)
|
||||
|
||||
fs.mkdirSync(path.join(activeRoot, '.git'), { recursive: true })
|
||||
assert.equal(hasExistingGitCheckout(activeRoot), true)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('fresh bootstrap args include the packaged commit pin', () => {
|
||||
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
|
||||
|
||||
assert.deepEqual(buildPinArgs(installStamp), ['-Commit', installStamp.commit, '-Branch', 'main'])
|
||||
assert.deepEqual(
|
||||
buildPosixPinArgs({
|
||||
installStamp,
|
||||
activeRoot: '/tmp/hermes-agent',
|
||||
hermesHome: '/tmp/hermes'
|
||||
}),
|
||||
[
|
||||
'--dir',
|
||||
'/tmp/hermes-agent',
|
||||
'--hermes-home',
|
||||
'/tmp/hermes',
|
||||
'--branch',
|
||||
'main',
|
||||
'--commit',
|
||||
installStamp.commit
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('existing-checkout bootstrap args keep branch but skip the packaged commit pin', () => {
|
||||
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
|
||||
|
||||
assert.deepEqual(buildPinArgs(installStamp, { pinCommit: false }), ['-Branch', 'main'])
|
||||
assert.deepEqual(
|
||||
buildPosixPinArgs({
|
||||
installStamp,
|
||||
activeRoot: '/tmp/hermes-agent',
|
||||
hermesHome: '/tmp/hermes',
|
||||
pinCommit: false
|
||||
}),
|
||||
['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main']
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveInstallScript prefers a cached script without touching the network', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
const cached = cachedScriptPath(home, commit)
|
||||
|
|
@ -66,6 +124,7 @@ test('resolveInstallScript prefers a cached script without touching the network'
|
|||
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
|
||||
|
||||
const logs = []
|
||||
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
|
|
@ -82,6 +141,7 @@ test('resolveInstallScript prefers a cached script without touching the network'
|
|||
|
||||
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
// Seed the installed agent checkout so the fallback has something to resolve.
|
||||
|
|
@ -91,6 +151,7 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
|
|||
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
|
||||
|
||||
const logs = []
|
||||
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
|
|
@ -117,6 +178,7 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
|
|||
|
||||
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
// No installed agent checkout seeded -> nothing to fall back to.
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
'use strict'
|
||||
|
||||
/**
|
||||
* bootstrap-runner.cjs
|
||||
* bootstrap-runner.ts
|
||||
*
|
||||
* Drives apps/desktop's first-launch install of Hermes Agent by spawning
|
||||
* scripts/install.ps1 stage-by-stage and streaming progress events back to
|
||||
* the renderer.
|
||||
*
|
||||
* Wired from electron/main.cjs:
|
||||
* const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
* Wired from electron/main.ts:
|
||||
* import { runBootstrap }from './bootstrap-runner.ts'
|
||||
* const result = await runBootstrap({
|
||||
* installStamp, // INSTALL_STAMP from main.cjs (may be null in dev)
|
||||
* installStamp, // INSTALL_STAMP from main.ts (may be null in dev)
|
||||
* activeRoot, // ACTIVE_HERMES_ROOT
|
||||
* sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup)
|
||||
* hermesHome, // HERMES_HOME
|
||||
|
|
@ -34,11 +32,11 @@
|
|||
* no UI consumes them yet)
|
||||
*/
|
||||
|
||||
const fs = require('node:fs')
|
||||
const fsp = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const https = require('node:https')
|
||||
const { spawn } = require('node:child_process')
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import https from 'node:https'
|
||||
import path from 'node:path'
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
|
|
@ -46,6 +44,7 @@ function hiddenWindowsChildOptions(options = {}) {
|
|||
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||
return options
|
||||
}
|
||||
|
||||
return { ...options, windowsHide: true }
|
||||
}
|
||||
|
||||
|
|
@ -71,10 +70,14 @@ function installScriptKind() {
|
|||
}
|
||||
|
||||
function resolveLocalInstallScript(sourceRepoRoot) {
|
||||
if (!sourceRepoRoot) return null
|
||||
if (!sourceRepoRoot) {
|
||||
return null
|
||||
}
|
||||
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
|
||||
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK)
|
||||
|
||||
return candidate
|
||||
} catch {
|
||||
return null
|
||||
|
|
@ -90,16 +93,32 @@ function bootstrapCacheDir(hermesHome) {
|
|||
// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop
|
||||
// app stamped to an unpushed HEAD).
|
||||
function installedAgentInstallScript(hermesHome) {
|
||||
if (!hermesHome) return null
|
||||
if (!hermesHome) {
|
||||
return null
|
||||
}
|
||||
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
|
||||
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK)
|
||||
|
||||
return candidate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function hasExistingGitCheckout(activeRoot) {
|
||||
if (!activeRoot) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.existsSync(path.join(activeRoot, '.git'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function cachedScriptPath(hermesHome, commit) {
|
||||
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
|
||||
}
|
||||
|
|
@ -110,6 +129,7 @@ function downloadInstallScript(commit, destPath) {
|
|||
// verification beyond "did the file we wrote pass a syntax probe."
|
||||
const scriptName = installScriptName()
|
||||
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}`
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
const tmpPath = destPath + '.tmp'
|
||||
|
|
@ -129,8 +149,10 @@ function downloadInstallScript(commit, destPath) {
|
|||
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
|
||||
)
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const out2 = fs.createWriteStream(tmpPath)
|
||||
res2.pipe(out2)
|
||||
out2.on('finish', () => {
|
||||
|
|
@ -141,18 +163,24 @@ function downloadInstallScript(commit, destPath) {
|
|||
out2.on('error', reject)
|
||||
})
|
||||
.on('error', reject)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
out.close()
|
||||
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
res.pipe(out)
|
||||
out.on('finish', () => {
|
||||
out.close()
|
||||
|
|
@ -165,6 +193,7 @@ function downloadInstallScript(commit, destPath) {
|
|||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
|
|
@ -174,6 +203,7 @@ function downloadInstallScript(commit, destPath) {
|
|||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
|
|
@ -187,11 +217,13 @@ async function resolveInstallScript({
|
|||
_download = downloadInstallScript
|
||||
}) {
|
||||
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
|
||||
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
|
||||
// without pushing. SOURCE_REPO_ROOT comes from main.ts (path.resolve
|
||||
// of APP_ROOT/../..).
|
||||
const localScript = resolveLocalInstallScript(sourceRepoRoot)
|
||||
|
||||
if (localScript) {
|
||||
emit({ type: 'log', line: `[bootstrap] using local ${installScriptName()} at ${localScript}` })
|
||||
|
||||
return { path: localScript, source: 'local', kind: installScriptKind() }
|
||||
}
|
||||
|
||||
|
|
@ -204,12 +236,14 @@ async function resolveInstallScript({
|
|||
}
|
||||
|
||||
const cached = cachedScriptPath(hermesHome, installStamp.commit)
|
||||
|
||||
try {
|
||||
await fsp.access(cached, fs.constants.R_OK)
|
||||
emit({
|
||||
type: 'log',
|
||||
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
|
||||
})
|
||||
|
||||
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch {
|
||||
// not cached; download
|
||||
|
|
@ -219,17 +253,20 @@ async function resolveInstallScript({
|
|||
type: 'log',
|
||||
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
|
||||
})
|
||||
|
||||
try {
|
||||
await _download(installStamp.commit, cached)
|
||||
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
|
||||
|
||||
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch (err) {
|
||||
// The pinned commit may not be fetchable from GitHub -- most commonly a
|
||||
// locally-built desktop app stamped to an unpushed HEAD (see
|
||||
// write-build-stamp.cjs fromLocalGit). Fall back to the installer that
|
||||
// write-build-stamp.mjs fromLocalGit). Fall back to the installer that
|
||||
// ships inside the already-installed agent checkout so dev/self-builds can
|
||||
// still bootstrap instead of dying with a fatal 404.
|
||||
const installed = installedAgentInstallScript(hermesHome)
|
||||
|
||||
if (installed) {
|
||||
emit({
|
||||
type: 'log',
|
||||
|
|
@ -237,15 +274,18 @@ async function resolveInstallScript({
|
|||
`[bootstrap] GitHub fetch failed (${err.message}); ` +
|
||||
`falling back to installed agent ${installScriptName()} at ${installed}`
|
||||
})
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cached), { recursive: true })
|
||||
fs.copyFileSync(installed, cached)
|
||||
|
||||
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch {
|
||||
// Cache copy failed (read-only FS, etc.) -- use the source path directly.
|
||||
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
|
||||
}
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
|
@ -271,31 +311,41 @@ function powershellUnderRoot(root) {
|
|||
function resolveWindowsPowerShell() {
|
||||
for (const v of ['SystemRoot', 'windir']) {
|
||||
const root = process.env[v]
|
||||
|
||||
if (root) {
|
||||
const candidate = powershellUnderRoot(root)
|
||||
|
||||
try {
|
||||
if (fs.statSync(candidate).isFile()) return candidate
|
||||
if (fs.statSync(candidate).isFile()) {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathDirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)
|
||||
|
||||
for (const exe of ['powershell.exe', 'pwsh.exe']) {
|
||||
for (const dir of pathDirs) {
|
||||
const candidate = path.join(dir, exe)
|
||||
|
||||
try {
|
||||
if (fs.statSync(candidate).isFile()) return candidate
|
||||
if (fs.statSync(candidate).isFile()) {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'powershell.exe'
|
||||
}
|
||||
|
||||
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
|
||||
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
||||
|
||||
|
|
@ -319,12 +369,14 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
|||
|
||||
const onAbort = () => {
|
||||
killed = true
|
||||
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) {
|
||||
onAbort()
|
||||
|
|
@ -342,10 +394,14 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
|||
stdout += chunk
|
||||
stdoutBuf += chunk
|
||||
let nl
|
||||
|
||||
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stdoutBuf = stdoutBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
|
||||
|
||||
if (line) {
|
||||
emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -354,30 +410,44 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
|||
stderr += chunk
|
||||
stderrBuf += chunk
|
||||
let nl
|
||||
|
||||
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
|
||||
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stderrBuf = stderrBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
|
||||
|
||||
if (line) {
|
||||
emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', err => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
reject(err)
|
||||
})
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
// Flush any trailing bytes
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
if (stdoutBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' } as any)
|
||||
}
|
||||
|
||||
if (stderrBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any)
|
||||
}
|
||||
resolve({ stdout, stderr, code, signal, killed } as any)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const child = spawn('bash', [scriptPath, ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
|
|
@ -392,12 +462,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
|||
|
||||
const onAbort = () => {
|
||||
killed = true
|
||||
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) {
|
||||
onAbort()
|
||||
|
|
@ -414,10 +486,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
|||
stdout += chunk
|
||||
stdoutBuf += chunk
|
||||
let nl
|
||||
|
||||
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stdoutBuf = stdoutBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
|
||||
|
||||
if (line) {
|
||||
emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -426,22 +502,36 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
|||
stderr += chunk
|
||||
stderrBuf += chunk
|
||||
let nl
|
||||
|
||||
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
|
||||
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stderrBuf = stderrBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
|
||||
|
||||
if (line) {
|
||||
emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', err => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
reject(err)
|
||||
})
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
if (stdoutBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
|
||||
}
|
||||
|
||||
if (stderrBuf) {
|
||||
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
}
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
})
|
||||
})
|
||||
|
|
@ -451,53 +541,74 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
|||
// Manifest + stage dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build the install.ps1 pin args (-Commit / -Branch) from the install-stamp
|
||||
// so the repository stage clones the exact SHA the .exe was tested with
|
||||
// instead of falling back to install.ps1's default ($Branch = "main").
|
||||
function buildPinArgs(installStamp) {
|
||||
// Build the installer branch/pin args from the install stamp. The commit pin
|
||||
// is fresh-install only: once a managed checkout already exists, bootstrap is
|
||||
// a repair/update path and must not let an old packaged app detach the checkout
|
||||
// back to the commit baked into that app.
|
||||
function buildPinArgs(installStamp, { pinCommit = true } = {}) {
|
||||
const args = []
|
||||
if (installStamp && installStamp.commit) {
|
||||
|
||||
if (pinCommit && installStamp && installStamp.commit) {
|
||||
args.push('-Commit', installStamp.commit)
|
||||
}
|
||||
|
||||
if (installStamp && installStamp.branch) {
|
||||
args.push('-Branch', installStamp.branch)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) {
|
||||
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit = true }) {
|
||||
const args = ['--dir', activeRoot, '--hermes-home', hermesHome]
|
||||
|
||||
if (installStamp && installStamp.branch) {
|
||||
args.push('--branch', installStamp.branch)
|
||||
}
|
||||
if (installStamp && installStamp.commit) {
|
||||
|
||||
if (pinCommit && installStamp && installStamp.commit) {
|
||||
args.push('--commit', installStamp.commit)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) {
|
||||
async function fetchManifest({
|
||||
scriptPath,
|
||||
installerKind,
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
installStamp,
|
||||
pinCommit
|
||||
}) {
|
||||
const isPosix = installerKind === 'posix'
|
||||
|
||||
const args = isPosix
|
||||
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
|
||||
: ['-Manifest', ...buildPinArgs(installStamp)]
|
||||
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit })]
|
||||
: ['-Manifest', ...buildPinArgs(installStamp, { pinCommit })]
|
||||
|
||||
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
|
||||
emit,
|
||||
stageName: '__manifest__',
|
||||
hermesHome
|
||||
})
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
|
||||
// The manifest is the LAST JSON line on stdout (install.ps1 may print
|
||||
// banner / info lines first depending on Console.OutputEncoding effects).
|
||||
// Find the last line that parses as JSON with a `stages` field.
|
||||
const lines = result.stdout.split(/\r?\n/).filter(Boolean)
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const parsed = JSON.parse(lines[i])
|
||||
|
||||
if (parsed && Array.isArray(parsed.stages)) {
|
||||
return parsed
|
||||
}
|
||||
|
|
@ -505,6 +616,7 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
|
|||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`
|
||||
)
|
||||
|
|
@ -515,9 +627,11 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
|
|||
// for the double-emit bug we addressed in the install.ps1 PR).
|
||||
function parseStageResult(stdout) {
|
||||
const lines = stdout.split(/\r?\n/).filter(Boolean)
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const parsed = JSON.parse(lines[i])
|
||||
|
||||
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
|
||||
return parsed
|
||||
}
|
||||
|
|
@ -525,23 +639,36 @@ function parseStageResult(stdout) {
|
|||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, activeRoot, abortSignal, installStamp }) {
|
||||
async function runStage({
|
||||
scriptPath,
|
||||
installerKind,
|
||||
stage,
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
abortSignal,
|
||||
installStamp,
|
||||
pinCommit
|
||||
}) {
|
||||
const startedAt = Date.now()
|
||||
emit({ type: 'stage', name: stage.name, state: 'running' })
|
||||
|
||||
const isPosix = installerKind === 'posix'
|
||||
|
||||
const args = isPosix
|
||||
? [
|
||||
'--stage',
|
||||
stage.name,
|
||||
'--non-interactive',
|
||||
'--json',
|
||||
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
|
||||
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit })
|
||||
]
|
||||
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
|
||||
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp, { pinCommit })]
|
||||
|
||||
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
|
||||
emit,
|
||||
stageName: stage.name,
|
||||
|
|
@ -554,6 +681,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
|||
if (result.killed) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' }
|
||||
emit(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
|
|
@ -568,20 +696,26 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
|||
error: `${isPosix ? 'install.sh --stage' : 'install.ps1 -Stage'} ${stage.name} produced no JSON result frame (exit=${result.code})`,
|
||||
json: null
|
||||
}
|
||||
|
||||
emit(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
if (json.ok && json.skipped) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json }
|
||||
emit(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
if (json.ok) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json }
|
||||
emit(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
const ev = {
|
||||
type: 'stage',
|
||||
name: stage.name,
|
||||
|
|
@ -590,7 +724,9 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
|||
json,
|
||||
error: json.reason || `exit code ${result.code}`
|
||||
}
|
||||
|
||||
emit(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
|
|
@ -603,6 +739,7 @@ function openRunLog(logRoot) {
|
|||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const logPath = path.join(logRoot, `bootstrap-${ts}.log`)
|
||||
const stream = fs.createWriteStream(logPath, { flags: 'a' })
|
||||
|
||||
return { path: logPath, stream }
|
||||
}
|
||||
|
||||
|
|
@ -619,7 +756,7 @@ async function runBootstrap(opts) {
|
|||
logRoot,
|
||||
onEvent,
|
||||
abortSignal,
|
||||
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
|
||||
writeMarker // callback to write the bootstrap-complete marker; main.ts provides
|
||||
} = opts
|
||||
|
||||
// Bail before spawning anything if the user already cancelled — otherwise an
|
||||
|
|
@ -633,6 +770,7 @@ async function runBootstrap(opts) {
|
|||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
|
||||
|
|
@ -646,8 +784,11 @@ async function runBootstrap(opts) {
|
|||
} catch {
|
||||
void 0
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof onEvent === 'function') onEvent(ev)
|
||||
if (typeof onEvent === 'function') {
|
||||
onEvent(ev)
|
||||
}
|
||||
} catch (err) {
|
||||
// Don't let a subscriber bug crash the bootstrap
|
||||
runLog.stream.write(`emit error: ${err && err.message}\n`)
|
||||
|
|
@ -664,6 +805,18 @@ async function runBootstrap(opts) {
|
|||
})
|
||||
|
||||
try {
|
||||
const existingCheckout = hasExistingGitCheckout(activeRoot)
|
||||
const pinCommit = !existingCheckout
|
||||
|
||||
if (existingCheckout && installStamp && installStamp.commit) {
|
||||
emit({
|
||||
type: 'log',
|
||||
line:
|
||||
`[bootstrap] existing checkout detected at ${activeRoot}; ` +
|
||||
`not pinning to packaged install stamp ${installStamp.commit.slice(0, 12)}`
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Resolve the platform installer.
|
||||
const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit })
|
||||
const installerKind = scriptInfo.kind || 'powershell'
|
||||
|
|
@ -675,8 +828,10 @@ async function runBootstrap(opts) {
|
|||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
installStamp
|
||||
installStamp,
|
||||
pinCommit
|
||||
})
|
||||
|
||||
emit({
|
||||
type: 'manifest',
|
||||
stages: manifest.stages,
|
||||
|
|
@ -690,8 +845,10 @@ async function runBootstrap(opts) {
|
|||
for (const stage of manifest.stages) {
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
emit({ type: 'failed', error: 'bootstrap cancelled by user' })
|
||||
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
|
||||
const ev = await runStage({
|
||||
scriptPath: scriptInfo.path,
|
||||
installerKind,
|
||||
|
|
@ -700,11 +857,14 @@ async function runBootstrap(opts) {
|
|||
hermesHome,
|
||||
activeRoot,
|
||||
abortSignal,
|
||||
installStamp
|
||||
installStamp,
|
||||
pinCommit
|
||||
})
|
||||
|
||||
if (ev.state === 'failed') {
|
||||
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' })
|
||||
return { ok: false, failedStage: stage.name, error: ev.error }
|
||||
emit({ type: 'failed', stage: stage.name, error: (ev as any).error || 'stage failed' })
|
||||
|
||||
return { ok: false, failedStage: stage.name, error: (ev as any).error }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -713,11 +873,14 @@ async function runBootstrap(opts) {
|
|||
pinnedCommit: installStamp ? installStamp.commit : null,
|
||||
pinnedBranch: installStamp ? installStamp.branch : null
|
||||
}
|
||||
|
||||
const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload
|
||||
emit({ type: 'complete', marker })
|
||||
|
||||
return { ok: true, marker }
|
||||
} catch (err) {
|
||||
emit({ type: 'failed', error: err.message || String(err) })
|
||||
|
||||
return { ok: false, error: err.message || String(err) }
|
||||
} finally {
|
||||
try {
|
||||
|
|
@ -728,12 +891,15 @@ async function runBootstrap(opts) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runBootstrap,
|
||||
export {
|
||||
buildPinArgs,
|
||||
buildPosixPinArgs,
|
||||
cachedScriptPath,
|
||||
hasExistingGitCheckout,
|
||||
installedAgentInstallScript,
|
||||
// Exposed for testability
|
||||
parseStageResult,
|
||||
resolveLocalInstallScript,
|
||||
resolveInstallScript,
|
||||
installedAgentInstallScript,
|
||||
cachedScriptPath
|
||||
resolveLocalInstallScript,
|
||||
runBootstrap
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Tests for electron/connection-config.cjs.
|
||||
* Tests for electron/connection-config.ts.
|
||||
*
|
||||
* Run with: node --test electron/connection-config.test.cjs
|
||||
* Run with: node --test electron/connection-config.test.ts
|
||||
* (Wire into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* These are the pure helpers behind the remote-gateway connection settings:
|
||||
|
|
@ -10,26 +10,28 @@
|
|||
* and the OAuth session-cookie detector.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
import {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
tokenPreview
|
||||
} = require('./connection-config.cjs')
|
||||
} from './connection-config'
|
||||
|
||||
// --- connectionScopeKey / normAuthMode ---
|
||||
|
||||
|
|
@ -47,6 +49,19 @@ test('normAuthMode coerces to token unless explicitly oauth', () => {
|
|||
assert.equal(normAuthMode('weird'), 'token')
|
||||
})
|
||||
|
||||
// --- modeIsRemoteLike ---
|
||||
|
||||
test('modeIsRemoteLike is true for remote and cloud, false otherwise', () => {
|
||||
// cloud resolves to a remote backend under the hood (Q6), so every resolution
|
||||
// site treats it like remote.
|
||||
assert.equal(modeIsRemoteLike('remote'), true)
|
||||
assert.equal(modeIsRemoteLike('cloud'), true)
|
||||
assert.equal(modeIsRemoteLike('local'), false)
|
||||
assert.equal(modeIsRemoteLike(undefined), false)
|
||||
assert.equal(modeIsRemoteLike(null), false)
|
||||
assert.equal(modeIsRemoteLike('weird'), false)
|
||||
})
|
||||
|
||||
// --- profileRemoteOverride ---
|
||||
|
||||
test('profileRemoteOverride returns null when no profile is given', () => {
|
||||
|
|
@ -73,6 +88,7 @@ test('profileRemoteOverride returns the per-profile remote with defaulted auth m
|
|||
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
|
||||
url: 'https://coder.example.com/hermes',
|
||||
authMode: 'token',
|
||||
|
|
@ -85,6 +101,21 @@ test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
|
|||
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
|
||||
})
|
||||
|
||||
test('profileRemoteOverride treats a cloud entry as a remote override', () => {
|
||||
// A 'cloud' per-profile entry resolves to the same remote backend a 'remote'
|
||||
// entry would (Q6) — the override must be returned, not dropped.
|
||||
const config = {
|
||||
profiles: {
|
||||
coder: { mode: 'cloud', url: 'https://agent-1.agents.nousresearch.com', authMode: 'oauth' }
|
||||
}
|
||||
}
|
||||
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
|
||||
url: 'https://agent-1.agents.nousresearch.com',
|
||||
authMode: 'oauth',
|
||||
token: undefined
|
||||
})
|
||||
})
|
||||
|
||||
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
|
||||
assert.equal(profileRemoteOverride({}, 'coder'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
|
||||
|
|
@ -331,6 +362,35 @@ test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', ()
|
|||
assert.equal(cookiesHaveLiveSession([]), false)
|
||||
})
|
||||
|
||||
// --- cookiesHavePrivySession (Nous portal / Privy auth, NOT gateway cookies) ---
|
||||
|
||||
test('cookiesHavePrivySession detects the privy-token access cookie', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: 'jwt' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession detects __Host-/__Secure- prefixes and the legacy privy-session name', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: '__Host-privy-token', value: 'x' }]), true)
|
||||
assert.equal(cookiesHavePrivySession([{ name: '__Secure-privy-token', value: 'x' }]), true)
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'privy-session', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession is false for an empty value', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: '' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession does NOT treat hermes gateway cookies as a portal session', () => {
|
||||
// The whole point of Q7: a gateway session cookie is NOT a portal sign-in.
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'hermes_session_at', value: 'x' }]), false)
|
||||
assert.equal(cookiesHavePrivySession([{ name: '__Host-hermes_session_rt', value: 'x' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession is false for unrelated cookies and non-arrays', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'other', value: 'x' }]), false)
|
||||
assert.equal(cookiesHavePrivySession(null), false)
|
||||
assert.equal(cookiesHavePrivySession(undefined), false)
|
||||
assert.equal(cookiesHavePrivySession([]), false)
|
||||
})
|
||||
|
||||
// --- tokenPreview ---
|
||||
|
||||
test('tokenPreview returns null for empty', () => {
|
||||
|
|
@ -365,6 +425,7 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
|
|||
const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
||||
mintTicket: async () => 'tkt-9'
|
||||
})
|
||||
|
||||
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
|
||||
})
|
||||
|
||||
|
|
@ -376,13 +437,14 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio
|
|||
throw new Error('401 ticket mint failed')
|
||||
}
|
||||
}),
|
||||
err => {
|
||||
(err: any) => {
|
||||
// Actionable, points the user at re-auth, and preserves the cause + flag
|
||||
// the boot overlay uses to offer a sign-in prompt.
|
||||
assert.match(err.message, /WebSocket ticket/i)
|
||||
assert.match(err.message, /sign in again/i)
|
||||
assert.equal(err.needsOauthLogin, true)
|
||||
assert.ok(err.cause instanceof Error)
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
/**
|
||||
* connection-config.cjs
|
||||
* connection-config.ts
|
||||
*
|
||||
* Pure, electron-free helpers for the desktop's remote-gateway connection
|
||||
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
|
||||
* auth-mode classification, and the auth-mode coercion rules.
|
||||
*
|
||||
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as backend-probes.cjs / bootstrap-platform.cjs.
|
||||
* main.cjs requires these and wires them into the electron-coupled IPC layer.
|
||||
* Kept standalone (no `import 'electron'`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as backend-probes.ts / bootstrap-platform.ts.
|
||||
* main.ts requires these and wires them into the electron-coupled IPC layer.
|
||||
*
|
||||
* Background on the two auth models a remote gateway can use:
|
||||
* - 'token': legacy static dashboard session token. REST uses an
|
||||
|
|
@ -37,6 +37,15 @@
|
|||
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
|
||||
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
|
||||
|
||||
// The Nous portal (NAS) does NOT use Hermes gateway session cookies — it is a
|
||||
// Privy-authed Next.js app. NAS `auth()` (src/server/auth/session.ts) reads the
|
||||
// `privy-token` access-token cookie (with `privy-id-token` alongside), which is
|
||||
// also exactly what the `/api/agents` cookie-auth path validates. So portal
|
||||
// sign-in / discovery liveness must look for the Privy cookie, NOT the gateway
|
||||
// cookies above. `privy-token` is the access token (the required signal);
|
||||
// variants cover the secured-prefix forms and the older `privy-session` name.
|
||||
const PRIVY_SESSION_COOKIE_VARIANTS = ['__Host-privy-token', '__Secure-privy-token', 'privy-token', 'privy-session']
|
||||
|
||||
function normalizeRemoteBaseUrl(rawUrl) {
|
||||
const value = String(rawUrl || '').trim()
|
||||
|
||||
|
|
@ -45,6 +54,7 @@ function normalizeRemoteBaseUrl(rawUrl) {
|
|||
}
|
||||
|
||||
let parsed
|
||||
|
||||
try {
|
||||
parsed = new URL(value)
|
||||
} catch (error) {
|
||||
|
|
@ -83,7 +93,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
|||
* exercise the same transport the app actually uses.
|
||||
*
|
||||
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
|
||||
* so this stays electron-free and unit-testable; main.cjs passes the real
|
||||
* so this stays electron-free and unit-testable; main.ts passes the real
|
||||
* `mintGatewayWsTicket`.
|
||||
*
|
||||
* Return semantics:
|
||||
|
|
@ -93,7 +103,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
|||
* - oauth, mint fails → THROWS (NOT a skip)
|
||||
*
|
||||
* The oauth-mint-failure throw is the important case: the real boot path
|
||||
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard
|
||||
* (resolveRemoteBackend in main.ts) treats a mint failure as a hard
|
||||
* "session expired" auth error and refuses to connect. Swallowing it here
|
||||
* would re-introduce the exact false-positive this test exists to catch —
|
||||
* HTTP /api/status passes, the test reports "reachable", then the renderer
|
||||
|
|
@ -105,13 +115,16 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
|||
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
|
||||
async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
|
||||
if (authMode === 'oauth') {
|
||||
const mintTicket = deps.mintTicket
|
||||
|
||||
if (typeof mintTicket !== 'function') {
|
||||
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
|
||||
}
|
||||
|
||||
let ticket
|
||||
|
||||
try {
|
||||
ticket = await mintTicket(baseUrl)
|
||||
} catch (error) {
|
||||
|
|
@ -119,15 +132,19 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
|
|||
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
|
||||
'(it may have expired). Open Settings → Gateway and sign in again.'
|
||||
)
|
||||
err.needsOauthLogin = true
|
||||
|
||||
;(err as any).needsOauthLogin = true
|
||||
err.cause = error
|
||||
throw err
|
||||
}
|
||||
|
||||
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
|
||||
|
|
@ -142,23 +159,36 @@ function normAuthMode(mode) {
|
|||
return mode === 'oauth' ? 'oauth' : 'token'
|
||||
}
|
||||
|
||||
// True for connection modes that resolve to a REMOTE backend. 'cloud' is a
|
||||
// Hermes Cloud connection (cloud-auto-discovery Q3/Q6): it carries a
|
||||
// remote-shaped block and reuses the entire remote connect/probe/reconnect
|
||||
// path, so every resolution site treats it exactly like 'remote'. The only
|
||||
// places that distinguish cloud from remote are the settings UI (which card to
|
||||
// show) and config persistence (remembering the provenance). Centralized here
|
||||
// so no resolution site forgets the third arm.
|
||||
function modeIsRemoteLike(mode) {
|
||||
return mode === 'remote' || mode === 'cloud'
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a profile's explicit remote override from a connection config, or null
|
||||
* when it has none (so the caller falls back to env → global remote → local).
|
||||
*
|
||||
* The config may carry a `profiles` map keyed by name; an entry counts as an
|
||||
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
|
||||
* is the raw stored secret; main.cjs decrypts it. Returns
|
||||
* override only with a remote-like `mode` (remote or cloud) and a non-empty
|
||||
* `url`. Pure: `token` is the raw stored secret; main.ts decrypts it. Returns
|
||||
* `{ url, authMode, token } | null`.
|
||||
*/
|
||||
function profileRemoteOverride(config, profile) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const entry = key ? config?.profiles?.[key] : null
|
||||
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
|
||||
|
||||
if (!entry || typeof entry !== 'object' || !modeIsRemoteLike(entry.mode)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const url = String(entry.url || '').trim()
|
||||
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -172,18 +202,21 @@ function profileRemoteOverride(config, profile) {
|
|||
* query parameter. Local pooled backends and per-profile remote overrides do not
|
||||
* need this: they already run against a backend scoped to the target profile.
|
||||
*/
|
||||
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
|
||||
function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) {
|
||||
const scopedProfile = connectionScopeKey(profile)
|
||||
|
||||
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
|
||||
return path
|
||||
}
|
||||
|
||||
const rawPath = String(path || '')
|
||||
|
||||
if (!rawPath) {
|
||||
return path
|
||||
}
|
||||
|
||||
let parsed
|
||||
|
||||
try {
|
||||
parsed = new URL(rawPath, 'http://hermes.local')
|
||||
} catch {
|
||||
|
|
@ -224,9 +257,18 @@ function authModeFromStatus(statusBody) {
|
|||
* Returns 'oauth' | 'token'.
|
||||
*/
|
||||
function resolveAuthMode(inputAuthMode, existingAuthMode) {
|
||||
if (inputAuthMode === 'oauth') return 'oauth'
|
||||
if (inputAuthMode === 'token') return 'token'
|
||||
if (existingAuthMode === 'oauth') return 'oauth'
|
||||
if (inputAuthMode === 'oauth') {
|
||||
return 'oauth'
|
||||
}
|
||||
|
||||
if (inputAuthMode === 'token') {
|
||||
return 'token'
|
||||
}
|
||||
|
||||
if (existingAuthMode === 'oauth') {
|
||||
return 'oauth'
|
||||
}
|
||||
|
||||
return 'token'
|
||||
}
|
||||
|
||||
|
|
@ -242,7 +284,10 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
|
|||
* need to know whether an unexpired access token is present right now.
|
||||
*/
|
||||
function cookiesHaveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
if (!Array.isArray(cookies)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
|
||||
}
|
||||
|
||||
|
|
@ -260,24 +305,46 @@ function cookiesHaveSession(cookies) {
|
|||
* the RT is also dead/revoked).
|
||||
*/
|
||||
function cookiesHaveLiveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
if (!Array.isArray(cookies)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* True if the cookie jar holds a live Nous PORTAL (Privy) session — a non-empty
|
||||
* `privy-token` (access-token) cookie, or a variant. This is the portal
|
||||
* analogue of `cookiesHaveLiveSession`: the portal authenticates via Privy, not
|
||||
* the Hermes gateway session cookies, so cloud sign-in / discovery liveness
|
||||
* must check THIS, not the gateway helpers. (NAS `auth()` and the `/api/agents`
|
||||
* cookie path both key off `privy-token`.)
|
||||
*/
|
||||
function cookiesHavePrivySession(cookies) {
|
||||
if (!Array.isArray(cookies)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return cookies.some(c => c && c.value && PRIVY_SESSION_COOKIE_VARIANTS.includes(c.name))
|
||||
}
|
||||
|
||||
export {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
PRIVY_SESSION_COOKIE_VARIANTS,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
RT_COOKIE_VARIANTS,
|
||||
tokenPreview
|
||||
}
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
/**
|
||||
* Tests for electron/dashboard-token.cjs.
|
||||
* Tests for electron/dashboard-token.ts.
|
||||
*
|
||||
* Run with: node --test electron/dashboard-token.test.cjs
|
||||
* Run with: node --test electron/dashboard-token.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
import {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
} = require('./dashboard-token.cjs')
|
||||
} from './dashboard-token'
|
||||
|
||||
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
|
||||
|
|
@ -39,9 +39,11 @@ test('dashboardIndexUrl preserves dashboard path prefixes', () => {
|
|||
|
||||
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
|
||||
const logs = []
|
||||
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async url => {
|
||||
assert.equal(url, 'http://127.0.0.1:9120/')
|
||||
|
||||
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
|
|
@ -100,8 +102,9 @@ test('isForeignBackendToken only flags a mismatched token from a dead child', ()
|
|||
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
|
||||
]
|
||||
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
|
||||
assert.equal(isForeignBackendToken(input as any), expected, JSON.stringify(input))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -128,6 +131,7 @@ test('adoptServedDashboardToken refuses a foreign token when our child is dead',
|
|||
|
||||
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
|
||||
const logs = []
|
||||
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => {
|
||||
|
|
@ -9,29 +9,39 @@
|
|||
|
||||
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
|
||||
|
||||
async function fetchPublicText(url, options = {}) {
|
||||
async function fetchPublicText(url, options: any = {}) {
|
||||
const { protocol } = new URL(url)
|
||||
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
throw error
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
|
||||
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function extractInjectedDashboardToken(html) {
|
||||
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
|
||||
if (!match) return null
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch {
|
||||
|
|
@ -43,11 +53,13 @@ function dashboardIndexUrl(baseUrl) {
|
|||
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
|
||||
}
|
||||
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) {
|
||||
const fetchText = options.fetchText || fetchPublicText
|
||||
|
||||
const html = await fetchText(dashboardIndexUrl(baseUrl), {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
})
|
||||
|
||||
const servedToken = extractInjectedDashboardToken(html)
|
||||
|
||||
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
|
||||
|
|
@ -76,6 +88,7 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
|
|||
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
|
||||
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
|
||||
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
|
||||
|
||||
return spawnToken
|
||||
})
|
||||
|
||||
|
|
@ -88,10 +101,10 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe
|
|||
return servedToken
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
export {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Tests for electron/desktop-uninstall.cjs.
|
||||
* Tests for electron/desktop-uninstall.ts.
|
||||
*
|
||||
* Run with: node --test electron/desktop-uninstall.test.cjs
|
||||
* Run with: node --test electron/desktop-uninstall.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
|
||||
|
|
@ -9,19 +9,19 @@
|
|||
* cleanup-script builders (POSIX + Windows).
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
UNINSTALL_MODES,
|
||||
import {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
UNINSTALL_MODES,
|
||||
uninstallArgsForMode
|
||||
} = require('./desktop-uninstall.cjs')
|
||||
} from './desktop-uninstall'
|
||||
|
||||
// --- uninstallArgsForMode ---
|
||||
|
||||
|
|
@ -132,6 +132,7 @@ test('buildPosixCleanupScript waits for the PID, runs the uninstall module, remo
|
|||
appPath: '/opt/hermes/linux-unpacked',
|
||||
hermesHome: '/home/x/.hermes'
|
||||
})
|
||||
|
||||
assert.match(script, /^#!\/bin\/bash/)
|
||||
assert.match(script, /pid=4321/)
|
||||
assert.match(script, /kill -0 "\$pid"/)
|
||||
|
|
@ -152,6 +153,7 @@ test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/fu
|
|||
appPath: null,
|
||||
hermesHome: '/home/x/.hermes'
|
||||
})
|
||||
|
||||
// System python + source on PYTHONPATH so import hermes_cli works while the
|
||||
// venv is torn down.
|
||||
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
|
||||
|
|
@ -168,6 +170,7 @@ test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', (
|
|||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
|
||||
assert.doesNotMatch(script, /export PYTHONPATH/)
|
||||
})
|
||||
|
||||
|
|
@ -181,6 +184,7 @@ test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
|
|||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
|
||||
assert.doesNotMatch(script, /rm -rf '\//)
|
||||
// Still runs the uninstall.
|
||||
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
|
||||
|
|
@ -196,6 +200,7 @@ test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () =
|
|||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
|
||||
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
|
||||
assert.match(script, /'\/home\/o'\\''brien\/python'/)
|
||||
})
|
||||
|
|
@ -212,6 +217,7 @@ test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir b
|
|||
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
|
||||
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
|
||||
})
|
||||
|
||||
assert.match(script, /@echo off/)
|
||||
assert.match(script, /set "PID=9988"/)
|
||||
// PYTHONPATH set so a system python can import hermes_cli from source.
|
||||
|
|
@ -238,6 +244,7 @@ test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, n
|
|||
appPath: null,
|
||||
hermesHome: 'C:\\h'
|
||||
})
|
||||
|
||||
assert.doesNotMatch(script, /rmdir/)
|
||||
assert.doesNotMatch(script, /set "PYTHONPATH=/)
|
||||
})
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
/**
|
||||
* desktop-uninstall.cjs
|
||||
* desktop-uninstall.ts
|
||||
*
|
||||
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
|
||||
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
|
||||
* resolve the running app bundle/exe so a detached cleanup script can remove
|
||||
* it after the app quits, and build that cleanup script for each OS.
|
||||
*
|
||||
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as connection-config.cjs / backend-probes.cjs.
|
||||
* main.cjs requires these and wires them into the electron-coupled IPC layer.
|
||||
* Kept standalone (no ` import 'electron'`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as connection-config.ts / backend-probes.ts.
|
||||
* main.ts requires these and wires them into the electron-coupled IPC layer.
|
||||
*
|
||||
* The three modes mirror the CLI's options exactly:
|
||||
* - 'gui' → remove ONLY the Chat GUI, keep the agent + all user data.
|
||||
|
|
@ -23,10 +23,10 @@
|
|||
* app bundle (locked on macOS/Windows while the process is alive). So we hand
|
||||
* the work to a detached child that waits for this app's PID to exit, runs the
|
||||
* Python uninstall, then removes the app bundle — then the app quits. Same
|
||||
* shape as the self-update swap-and-relaunch flow already in main.cjs.
|
||||
* shape as the self-update swap-and-relaunch flow already in main.ts.
|
||||
*/
|
||||
|
||||
const path = require('node:path')
|
||||
import path from 'node:path'
|
||||
|
||||
const UNINSTALL_MODES = ['gui', 'lite', 'full']
|
||||
|
||||
|
|
@ -41,6 +41,7 @@ function uninstallArgsForMode(mode) {
|
|||
if (!UNINSTALL_MODES.includes(mode)) {
|
||||
throw new Error(`Unknown uninstall mode: ${mode}`)
|
||||
}
|
||||
|
||||
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
|
||||
}
|
||||
|
||||
|
|
@ -65,9 +66,12 @@ function modeRemovesUserData(mode) {
|
|||
* Returns null when we can't confidently identify a removable bundle (e.g.
|
||||
* running from a dev checkout, or a system-package install we must not rmtree).
|
||||
*/
|
||||
function resolveRemovableAppPath(execPath, platform, env = {}) {
|
||||
function resolveRemovableAppPath(execPath, platform, env: any = {}) {
|
||||
const exe = String(execPath || '')
|
||||
if (!exe) return null
|
||||
|
||||
if (!exe) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the path flavor that matches the TARGET platform, not the host running
|
||||
// this code — so the Windows branch parses backslash paths correctly even
|
||||
|
|
@ -79,22 +83,36 @@ function resolveRemovableAppPath(execPath, platform, env = {}) {
|
|||
const macOsDir = p.dirname(exe) // …/Contents/MacOS
|
||||
const contents = p.dirname(macOsDir) // …/Contents
|
||||
const appBundle = p.dirname(contents) // …/Hermes.app
|
||||
if (appBundle.endsWith('.app')) return appBundle
|
||||
|
||||
if (appBundle.endsWith('.app')) {
|
||||
return appBundle
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// NSIS per-user installs Hermes.exe directly in the install dir.
|
||||
const dir = p.dirname(exe)
|
||||
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir
|
||||
|
||||
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) {
|
||||
return dir
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Linux: an AppImage exposes its own path via the APPIMAGE env var.
|
||||
if (env.APPIMAGE) return env.APPIMAGE
|
||||
if (env.APPIMAGE) {
|
||||
return env.APPIMAGE
|
||||
}
|
||||
// Unpacked electron-builder tree: …/linux-unpacked/hermes
|
||||
const dir = p.dirname(exe)
|
||||
if (/-unpacked$/.test(dir)) return dir
|
||||
|
||||
if (/-unpacked$/.test(dir)) {
|
||||
return dir
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +139,7 @@ function shouldRemoveAppBundle(isPackaged, appPath) {
|
|||
*/
|
||||
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
|
||||
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
|
||||
|
||||
const lines = [
|
||||
'#!/bin/bash',
|
||||
'set -u',
|
||||
|
|
@ -135,16 +154,21 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
|
|||
'fi',
|
||||
`export HERMES_HOME=${q(hermesHome)}`
|
||||
]
|
||||
|
||||
if (pythonPath) {
|
||||
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
|
||||
}
|
||||
|
||||
lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
|
||||
|
||||
if (appPath) {
|
||||
lines.push(`rm -rf ${q(appPath)} || true`)
|
||||
}
|
||||
|
||||
// Self-delete the script.
|
||||
lines.push('rm -f "$0" 2>/dev/null || true')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
|
|
@ -180,15 +204,18 @@ function buildWindowsCleanupScript({
|
|||
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
|
||||
// a problem, but Hermes install paths don't use them.
|
||||
const q = s => `"${String(s).replace(/"/g, '')}"`
|
||||
|
||||
const lines = [
|
||||
'@echo off',
|
||||
'setlocal enableextensions',
|
||||
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
|
||||
`set "PID=${pid}"`
|
||||
]
|
||||
|
||||
if (pythonPath) {
|
||||
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'set /a waited=0',
|
||||
':waitloop',
|
||||
|
|
@ -206,6 +233,7 @@ function buildWindowsCleanupScript({
|
|||
`cd /d ${q(agentRoot)}`,
|
||||
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
|
||||
)
|
||||
|
||||
if (appPath) {
|
||||
lines.push(
|
||||
'set /a tries=0',
|
||||
|
|
@ -220,18 +248,20 @@ function buildWindowsCleanupScript({
|
|||
':rmdone'
|
||||
)
|
||||
}
|
||||
|
||||
lines.push('del "%~f0"')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UNINSTALL_MODES,
|
||||
export {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
UNINSTALL_MODES,
|
||||
uninstallArgsForMode
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
'use strict'
|
||||
|
||||
const { session } = require('electron')
|
||||
import { session } from 'electron'
|
||||
|
||||
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
|
||||
const EMBED_REFERER = 'https://www.youtube.com/'
|
||||
|
||||
const YOUTUBE_REFERER_HOST_RE =
|
||||
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
|
||||
|
||||
|
|
@ -23,6 +22,7 @@ function installEmbedRefererForSession(embedSession) {
|
|||
|
||||
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -45,4 +45,4 @@ function installEmbedReferer() {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { installEmbedReferer }
|
||||
export { installEmbedReferer }
|
||||
|
|
@ -1,19 +1,17 @@
|
|||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
|
||||
}
|
||||
|
||||
function fakeDirent(name, flags = {}) {
|
||||
function fakeDirent(name, flags: any = {}) {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => Boolean(flags.directory),
|
||||
|
|
@ -109,10 +107,12 @@ test('readDirForIpc accepts file URLs for directories', async () => {
|
|||
|
||||
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
|
||||
let readdirCalls = 0
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
@ -126,10 +126,12 @@ test('readDirForIpc returns invalid-path for blank or non-string input', async (
|
|||
|
||||
test('readDirForIpc rejects Windows device paths before readdir', async () => {
|
||||
let readdirCalls = 0
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
@ -224,6 +226,7 @@ test('readDirForIpc allows expanding symlink or junction directories outside the
|
|||
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
|
||||
|
||||
const linkPath = path.join(root, 'outside-link')
|
||||
|
||||
try {
|
||||
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
|
|
@ -252,6 +255,7 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
|
|||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const statCalls = []
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
|
|
@ -266,9 +270,11 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
|
|||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
|
||||
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
}
|
||||
|
|
@ -301,12 +307,15 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
|
|||
let peak = 0
|
||||
let releaseStats
|
||||
let markFirstStatStarted
|
||||
|
||||
const statsReleased = new Promise(resolve => {
|
||||
releaseStats = resolve
|
||||
})
|
||||
|
||||
const firstStatStarted = new Promise(resolve => {
|
||||
markFirstStatStarted = resolve
|
||||
})
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
|
|
@ -326,6 +335,7 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
|
|||
active -= 1
|
||||
|
||||
const name = path.basename(fullPath)
|
||||
|
||||
if (name === failedName) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
'use strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveDirectoryForIpc } = require('./hardening.cjs')
|
||||
import { resolveDirectoryForIpc } from './hardening'
|
||||
import { resolveLocalReadPath } from './wsl-path-bridge'
|
||||
|
||||
const FS_READDIR_STAT_CONCURRENCY = 16
|
||||
|
||||
|
|
@ -37,7 +37,9 @@ function direntIsSymbolicLink(dirent) {
|
|||
}
|
||||
|
||||
function shouldStatDirent(dirent) {
|
||||
if (direntIsDirectory(dirent)) return false
|
||||
if (direntIsDirectory(dirent)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
|
||||
}
|
||||
|
|
@ -70,18 +72,22 @@ async function mapWithStatConcurrency(items, mapper) {
|
|||
}
|
||||
|
||||
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
|
||||
const workers = Array.from({ length: workerCount }, () => runWorker())
|
||||
const workers = Array.from({ length: workerCount } as any, () => runWorker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function readDirForIpc(dirPath, options = {}) {
|
||||
async function readDirForIpc(dirPath, options: any = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
// On a Windows host with a WSL backend, a WSL/POSIX cwd (`/home/...`,
|
||||
// `/mnt/c/...`) isn't readable as-is; bridge it to a UNC/drive form first.
|
||||
const readPath = resolveLocalReadPath(String(dirPath ?? ''))
|
||||
|
||||
try {
|
||||
;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, {
|
||||
;({ resolvedPath: resolved } = await resolveDirectoryForIpc(readPath, {
|
||||
fs: fsImpl,
|
||||
purpose: 'Directory read'
|
||||
}))
|
||||
|
|
@ -102,6 +108,4 @@ async function readDirForIpc(dirPath, options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readDirForIpc
|
||||
}
|
||||
export { readDirForIpc }
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Tests for electron/gateway-ws-probe.cjs.
|
||||
* Tests for electron/gateway-ws-probe.ts.
|
||||
*
|
||||
* Run with: node --test electron/gateway-ws-probe.test.cjs
|
||||
* Run with: node --test electron/gateway-ws-probe.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* The probe drives a real WebSocket handshake for the "Test remote" button.
|
||||
|
|
@ -9,16 +9,20 @@
|
|||
* outcome (open, frame, error, early close, never-opens) without a network.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
|
||||
// Minimal WebSocket double: records listeners synchronously (the probe attaches
|
||||
// them in its executor) and exposes emit() so the test can replay events.
|
||||
function makeFakeWs() {
|
||||
function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } {
|
||||
const instances = []
|
||||
|
||||
class FakeWs {
|
||||
url: string
|
||||
closed = false
|
||||
listeners: Record<string, any[]> = {}
|
||||
constructor(url) {
|
||||
this.url = url
|
||||
this.listeners = {}
|
||||
|
|
@ -32,9 +36,12 @@ function makeFakeWs() {
|
|||
this.closed = true
|
||||
}
|
||||
emit(type, event) {
|
||||
for (const fn of this.listeners[type] || []) fn(event)
|
||||
for (const fn of this.listeners[type] || []) {
|
||||
fn(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { FakeWs, instances }
|
||||
}
|
||||
|
||||
|
|
@ -51,11 +58,13 @@ test('probe resolves ok when the socket opens and stays open', async () => {
|
|||
|
||||
test('probe resolves ok immediately when a frame arrives', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', {
|
||||
WebSocketImpl: FakeWs,
|
||||
connectTimeoutMs: 1_000,
|
||||
readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer
|
||||
})
|
||||
|
||||
instances[0].emit('open')
|
||||
instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' })
|
||||
const result = await promise
|
||||
|
|
@ -95,11 +104,13 @@ test('probe fails when the gateway accepts then immediately closes (auth rejecte
|
|||
|
||||
test('probe times out when the socket never opens', async () => {
|
||||
const { FakeWs } = makeFakeWs()
|
||||
|
||||
const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', {
|
||||
WebSocketImpl: FakeWs,
|
||||
connectTimeoutMs: 20,
|
||||
readyGraceMs: 10
|
||||
})
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /Timed out/)
|
||||
})
|
||||
|
|
@ -36,13 +36,16 @@ const DEFAULT_READY_GRACE_MS = 750
|
|||
* Attempt a live WebSocket connection and classify the outcome.
|
||||
*
|
||||
* @param {string} wsUrl - Fully-formed ws(s):// URL including the credential.
|
||||
* @param {object} [options]
|
||||
* @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor.
|
||||
* @param {number} [options.connectTimeoutMs]
|
||||
* @param {number} [options.readyGraceMs]
|
||||
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
||||
*/
|
||||
function probeGatewayWebSocket(wsUrl, options = {}) {
|
||||
function probeGatewayWebSocket<T>(
|
||||
wsUrl: string,
|
||||
options: {
|
||||
WebSocketImpl?: any
|
||||
connectTimeoutMs?: number
|
||||
readyGraceMs?: number
|
||||
} = {}
|
||||
) {
|
||||
const WebSocketImpl = options.WebSocketImpl
|
||||
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
||||
const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS
|
||||
|
|
@ -54,7 +57,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
return new Promise<any>(resolve => {
|
||||
let settled = false
|
||||
let opened = false
|
||||
let connectTimer = null
|
||||
|
|
@ -66,6 +69,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
|||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
if (graceTimer !== null) {
|
||||
clearTimeout(graceTimer)
|
||||
graceTimer = null
|
||||
|
|
@ -73,14 +77,18 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
|||
}
|
||||
|
||||
const finish = result => {
|
||||
if (settled) return
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimers()
|
||||
|
||||
try {
|
||||
socket?.close?.()
|
||||
} catch {
|
||||
// ignore — best effort teardown
|
||||
}
|
||||
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
|
|
@ -91,11 +99,14 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
|||
ok: false,
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const onOpen = () => {
|
||||
if (settled) return
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
opened = true
|
||||
// Upgrade accepted. Give the server a brief window to reject the
|
||||
// credential post-handshake (early close) before declaring success.
|
||||
|
|
@ -118,7 +129,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
|||
}
|
||||
|
||||
const onClose = event => {
|
||||
if (settled) return
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (opened) {
|
||||
// Opened, then closed inside the grace window: the upgrade was accepted
|
||||
// but the session was refused (e.g. ws-ticket/token rejected, or a
|
||||
|
|
@ -127,8 +141,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
|||
ok: false,
|
||||
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
finish({
|
||||
ok: false,
|
||||
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
|
||||
|
|
@ -154,8 +170,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
|
|||
function addListener(socket, type, handler) {
|
||||
if (typeof socket.addEventListener === 'function') {
|
||||
socket.addEventListener(type, handler)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Node's global WebSocket implements addEventListener; this fallback keeps the
|
||||
// helper usable with the `ws` package's EventEmitter shape too.
|
||||
if (typeof socket.on === 'function') {
|
||||
|
|
@ -164,25 +182,43 @@ function addListener(socket, type, handler) {
|
|||
}
|
||||
|
||||
function extractErrorReason(event) {
|
||||
if (!event) return ''
|
||||
if (event instanceof Error) return event.message
|
||||
if (!event) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (event instanceof Error) {
|
||||
return event.message
|
||||
}
|
||||
const err = event.error || event.message
|
||||
if (err instanceof Error) return err.message
|
||||
if (typeof err === 'string') return err
|
||||
|
||||
if (err instanceof Error) {
|
||||
return err.message
|
||||
}
|
||||
|
||||
if (typeof err === 'string') {
|
||||
return err
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function closeReason(event, fallback) {
|
||||
const code = event && typeof event.code === 'number' ? event.code : null
|
||||
const reason = event && typeof event.reason === 'string' ? event.reason.trim() : ''
|
||||
if (code && reason) return `${fallback} (code ${code}: ${reason})`
|
||||
if (code) return `${fallback} (code ${code})`
|
||||
if (reason) return `${fallback} (${reason})`
|
||||
|
||||
if (code && reason) {
|
||||
return `${fallback} (code ${code}: ${reason})`
|
||||
}
|
||||
|
||||
if (code) {
|
||||
return `${fallback} (code ${code})`
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
return `${fallback} (${reason})`
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
DEFAULT_READY_GRACE_MS,
|
||||
probeGatewayWebSocket
|
||||
}
|
||||
export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READY_GRACE_MS, probeGatewayWebSocket }
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
'use strict'
|
||||
|
||||
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
|
||||
// — no native addon, so it just works for anyone who pulls main (no
|
||||
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
|
||||
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
|
||||
// first scan stays fast. Results are cached by the backend after the first run.
|
||||
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const fsp = fs.promises
|
||||
|
||||
|
|
@ -36,14 +34,14 @@ async function mapLimit(items, limit, fn) {
|
|||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker))
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
|
||||
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
|
||||
*/
|
||||
async function scanGitRepos(roots, options = {}) {
|
||||
async function scanGitRepos(roots, options: any = {}) {
|
||||
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
|
||||
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
|
||||
const found = new Map()
|
||||
|
|
@ -54,6 +52,7 @@ async function scanGitRepos(roots, options = {}) {
|
|||
}
|
||||
|
||||
let entries
|
||||
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
|
|
@ -73,6 +72,7 @@ async function scanGitRepos(roots, options = {}) {
|
|||
}
|
||||
|
||||
const subdirs = []
|
||||
|
||||
for (const entry of entries) {
|
||||
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
|
||||
// known heavy trees.
|
||||
|
|
@ -93,4 +93,4 @@ async function scanGitRepos(roots, options = {}) {
|
|||
return [...found.entries()].map(([root, label]) => ({ label, root }))
|
||||
}
|
||||
|
||||
module.exports = { scanGitRepos }
|
||||
export { scanGitRepos }
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { resolveRenamePath } = require('./git-review-ops.cjs')
|
||||
import { resolveRenamePath } from './git-review-ops'
|
||||
|
||||
test('resolveRenamePath: plain path is unchanged', () => {
|
||||
assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts')
|
||||
|
|
@ -1,37 +1,16 @@
|
|||
'use strict'
|
||||
|
||||
// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git`
|
||||
// (a maintained wrapper around the system git binary — same git the rest of the
|
||||
// app shells to, no native build) so we read structured status()/diffSummary()
|
||||
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a
|
||||
// non-repo / remote backend; mutations reject so the renderer can toast.
|
||||
|
||||
const { execFile } = require('node:child_process')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
// `simple-git` is a pure-JS runtime dep that workspace dedup hoists into the
|
||||
// repo-root node_modules. Packaged builds set `files:` in package.json, which
|
||||
// excludes node_modules from the asar, so the normal require() fails at launch
|
||||
// (issue #52735: "Cannot find module 'simple-git'"). We ship the dep's
|
||||
// closure under resources/native-deps/vendor/node_modules/ via extraResources
|
||||
// + scripts/stage-native-deps.cjs, and resolve from there when the hoisted
|
||||
// require() isn't reachable. The `vendor/` nesting matters: electron-builder
|
||||
// drops a node_modules dir at the root of an extraResources copy but keeps a
|
||||
// nested one. Dev mode never hits the fallback -- Node's normal lookup finds
|
||||
// the hoisted copy.
|
||||
let simpleGit
|
||||
try {
|
||||
simpleGit = require('simple-git')
|
||||
} catch {
|
||||
const resourcesPath = process.resourcesPath
|
||||
if (!resourcesPath) {
|
||||
throw new Error("git-review IPC: 'simple-git' not found and no resourcesPath to fall back to")
|
||||
}
|
||||
simpleGit = require(path.join(resourcesPath, 'native-deps', 'vendor', 'node_modules', 'simple-git'))
|
||||
}
|
||||
import simpleGit from 'simple-git'
|
||||
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
import { resolveRequestedPathForIpc } from './hardening'
|
||||
|
||||
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
|
||||
const COMMIT_CONTEXT_UNTRACKED_MAX = 80
|
||||
|
|
@ -52,7 +31,7 @@ function ghEnv(ghBin) {
|
|||
|
||||
// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on
|
||||
// availability/auth without a throw. gh missing/unauthed → ok:false.
|
||||
function runGh(args, cwd, ghBin) {
|
||||
function runGh(args, cwd, ghBin): Promise<{ ok: boolean; stdout: string }> {
|
||||
return new Promise(resolve => {
|
||||
execFile(
|
||||
ghBin || 'gh',
|
||||
|
|
@ -260,10 +239,11 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
|
|||
|
||||
const range = scope === 'branch' ? `${base}...HEAD` : base
|
||||
const summary = await git.diffSummary([range])
|
||||
|
||||
const files = summary.files.map(file => ({
|
||||
path: resolveRenamePath(file.file),
|
||||
added: file.binary ? 0 : file.insertions,
|
||||
removed: file.binary ? 0 : file.deletions,
|
||||
added: 'insertions' in file ? file.insertions : 0,
|
||||
removed: 'deletions' in file ? file.deletions : 0,
|
||||
status: 'M',
|
||||
staged: false
|
||||
}))
|
||||
|
|
@ -291,6 +271,7 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
|
|||
git.diffSummary(['--cached']),
|
||||
git.diffSummary([])
|
||||
])
|
||||
|
||||
const stagedCounts = countsByPath(staged)
|
||||
const unstagedCounts = countsByPath(unstaged)
|
||||
|
||||
|
|
@ -495,6 +476,7 @@ async function reviewCommitContext(repoPath, gitBin) {
|
|||
const safe = args => git.diff(args).catch(() => '')
|
||||
|
||||
let status
|
||||
|
||||
try {
|
||||
status = await git.status()
|
||||
} catch {
|
||||
|
|
@ -510,9 +492,11 @@ async function reviewCommitContext(repoPath, gitBin) {
|
|||
|
||||
// Untracked files have no diff — list them so new files aren't invisible.
|
||||
const untracked = status.not_added || []
|
||||
|
||||
if (untracked.length > 0) {
|
||||
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
|
||||
const omitted = untracked.length - visible.length
|
||||
|
||||
const note =
|
||||
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` +
|
||||
(omitted > 0 ? `# ... ${omitted} more omitted\n` : '')
|
||||
|
|
@ -607,6 +591,7 @@ async function repoStatus(repoPath, gitBin) {
|
|||
// fail soft and hide the coding rail instead of spamming IPC handler errors.
|
||||
try {
|
||||
const stat = await fs.stat(cwd)
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -615,11 +600,13 @@ async function repoStatus(repoPath, gitBin) {
|
|||
}
|
||||
|
||||
let git
|
||||
|
||||
try {
|
||||
git = gitFor(cwd, gitBin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
let status
|
||||
|
||||
try {
|
||||
|
|
@ -630,6 +617,7 @@ async function repoStatus(repoPath, gitBin) {
|
|||
}
|
||||
|
||||
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
|
||||
|
||||
const files = status.files.map(file => ({
|
||||
path: file.path,
|
||||
staged: isStaged(file),
|
||||
|
|
@ -671,10 +659,12 @@ async function repoStatus(repoPath, gitBin) {
|
|||
// can't stall the probe.
|
||||
try {
|
||||
const untracked = status.not_added.slice(0, 500)
|
||||
|
||||
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
|
||||
const batch = await Promise.all(
|
||||
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
|
||||
)
|
||||
|
||||
result.added += batch.reduce((sum, n) => sum + n, 0)
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -684,7 +674,7 @@ async function repoStatus(repoPath, gitBin) {
|
|||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
branchBase,
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
|
|
@ -695,8 +685,8 @@ module.exports = {
|
|||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewRevParse,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
import { gitRootForIpc } from './git-root'
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
'use strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
import { resolveRequestedPathForIpc } from './hardening'
|
||||
|
||||
function findGitRoot(start, fsImpl = fs) {
|
||||
let dir = start
|
||||
|
|
@ -28,7 +27,7 @@ function findGitRoot(start, fsImpl = fs) {
|
|||
return null
|
||||
}
|
||||
|
||||
async function gitRootForIpc(startPath, options = {}) {
|
||||
async function gitRootForIpc(startPath, options: { fs?: typeof fs } = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
|
|
@ -48,7 +47,4 @@ async function gitRootForIpc(startPath, options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findGitRoot,
|
||||
gitRootForIpc
|
||||
}
|
||||
export { findGitRoot, gitRootForIpc }
|
||||
|
|
@ -1,20 +1,19 @@
|
|||
'use strict'
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const {
|
||||
import {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
parseWorktrees,
|
||||
sanitizeBranch,
|
||||
switchBranch
|
||||
} = require('./git-worktree-ops.cjs')
|
||||
} from './git-worktree-ops'
|
||||
|
||||
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
|
||||
assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes')
|
||||
|
|
@ -212,3 +211,53 @@ test('addWorktree: existing default branch switches the main checkout, not .work
|
|||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBaseBranches: lists local branches and flags the default', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-branches-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
const trunk = git('branch', '--show-current')
|
||||
execFileSync('git', ['branch', 'feature'], { cwd: dir })
|
||||
|
||||
const branches = await listBaseBranches(dir, 'git')
|
||||
const names = branches.map(b => b.name).sort()
|
||||
|
||||
assert.deepEqual(names, [trunk, 'feature'].sort())
|
||||
// No remote → all local.
|
||||
assert.equal(branches.every(b => !b.isRemote), true)
|
||||
// The trunk is flagged as the default.
|
||||
assert.equal(branches.find(b => b.name === trunk).isDefault, true)
|
||||
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBaseBranches: empty on a non-repo path', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-nonrepo-'))
|
||||
|
||||
try {
|
||||
assert.deepEqual(await listBaseBranches(dir, 'git'), [])
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('addWorktree: base param branches off a specified local branch', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-add-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
execFileSync('git', ['branch', 'staging'], { cwd: dir })
|
||||
|
||||
const result = await addWorktree(dir, { base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' }, 'git')
|
||||
|
||||
assert.equal(result.branch, 'new-from-staging')
|
||||
assert.equal(git('-C', result.path, 'merge-base', 'HEAD', 'staging').length > 0, true)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
'use strict'
|
||||
|
||||
// Git-driven worktree operations for the desktop "Start work" flow: spin up a
|
||||
// fresh worktree the lightest way (`git worktree add -b`), list real worktrees,
|
||||
// and remove them. Git is the source of truth; the renderer just drives these.
|
||||
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const { execFile } = require('node:child_process')
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
import { resolveRequestedPathForIpc } from './hardening'
|
||||
|
||||
function runGit(gitBin, args, cwd) {
|
||||
function runGit(gitBin, args, cwd): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
gitBin,
|
||||
|
|
@ -253,7 +251,25 @@ async function addWorktree(repoPath, options, gitBin) {
|
|||
const args = ['worktree', 'add', '-b', branch, dir]
|
||||
|
||||
if (opts.base) {
|
||||
args.push(String(opts.base))
|
||||
// Remote-tracking branches may be stale or missing if the user hasn't
|
||||
// fetched recently. When the base is an `origin/…` ref, fetch just that
|
||||
// branch so `git worktree add -b new origin/main` works against the
|
||||
// latest remote commit. Local branches are used as-is.
|
||||
const base = String(opts.base)
|
||||
|
||||
if (base.startsWith('origin/')) {
|
||||
const remoteBranch = base.slice('origin/'.length)
|
||||
|
||||
try {
|
||||
await runGit(gitBin, ['fetch', 'origin', remoteBranch], root)
|
||||
} catch {
|
||||
// The fetch isn't mandatory, but it would be nice to do if possible.
|
||||
// If it's not possible, just use the local ref of the remote branch.
|
||||
// If it doesn't exist locally, we'll get an error anyways
|
||||
}
|
||||
}
|
||||
|
||||
args.push(base)
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -306,6 +322,7 @@ async function listBranches(repoPath, gitBin) {
|
|||
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
|
||||
resolved
|
||||
)
|
||||
|
||||
const trees = await listWorktrees(resolved, gitBin)
|
||||
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
|
||||
const trunk = await defaultBranch(gitBin, resolved)
|
||||
|
|
@ -338,9 +355,56 @@ async function switchBranch(repoPath, branch, gitBin) {
|
|||
return { branch: target }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Branches the new worktree can be based on: local heads + remote-tracking
|
||||
// refs. Listed most-recently-committed first; the remote's default branch
|
||||
// (origin/HEAD) is flagged so the UI can preselect it. Empty on a non-repo /
|
||||
// remote backend where the probe can't run.
|
||||
async function listBaseBranches(repoPath, gitBin) {
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Base branch list' })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await runGit(
|
||||
gitBin,
|
||||
['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes'],
|
||||
resolved
|
||||
)
|
||||
|
||||
const remoteDefault = await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved)
|
||||
const localDefault = await defaultBranch(gitBin, resolved)
|
||||
|
||||
return out
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [name] = line.split('\t')
|
||||
|
||||
return {
|
||||
name,
|
||||
isRemote: name.startsWith('origin/'),
|
||||
// origin/HEAD when a remote exists; otherwise the local default
|
||||
// (main/master/init.defaultBranch) so a no-remote repo still flags
|
||||
// its trunk.
|
||||
isDefault: Boolean(
|
||||
(remoteDefault && name === remoteDefault) || (!remoteDefault && localDefault && name === localDefault)
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBaseBranches,
|
||||
listBranches,
|
||||
listWorktrees,
|
||||
parseWorktrees,
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const {
|
||||
import {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveDirectoryForIpc,
|
||||
|
|
@ -13,11 +13,12 @@ const {
|
|||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
} = require('./hardening.cjs')
|
||||
} from './hardening'
|
||||
|
||||
async function rejectsWithCode(promise, code) {
|
||||
await assert.rejects(promise, error => {
|
||||
async function rejectsWithCode(promise, code: string) {
|
||||
await assert.rejects(promise, (error: any) => {
|
||||
assert.equal(error?.code, code)
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
|
@ -76,8 +77,9 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async
|
|||
for (const devicePath of devicePaths) {
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
|
||||
error => {
|
||||
(error: any) => {
|
||||
assert.equal(error?.code, 'device-path')
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
|
@ -86,8 +88,9 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async
|
|||
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
|
||||
error => {
|
||||
(error: any) => {
|
||||
assert.equal(error?.code, 'invalid-path')
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
|
@ -131,19 +134,23 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
|||
maxBytes: 256,
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromRelative.resolvedPath, textPath)
|
||||
assert.equal(fromRelative.stat.size, 11)
|
||||
|
||||
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
|
||||
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
|
||||
|
||||
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
|
||||
|
||||
await assert.rejects(
|
||||
|
|
@ -184,9 +191,11 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
|||
|
||||
const envTemplatePath = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
|
||||
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
})
|
||||
|
||||
|
|
@ -229,8 +238,10 @@ test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', as
|
|||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
|
|
@ -268,8 +279,10 @@ test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t =
|
|||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath } = require('node:url')
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
|
||||
|
|
@ -13,6 +13,7 @@ const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
|
|||
function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) {
|
||||
const fallback =
|
||||
Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS
|
||||
|
||||
const parsed = Number(timeoutMs)
|
||||
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
|
|
@ -62,6 +63,7 @@ function sensitiveFileBlockReason(filePath) {
|
|||
const normalized = String(filePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.toLowerCase()
|
||||
|
||||
const basename = path.basename(normalized)
|
||||
const ext = path.extname(basename)
|
||||
|
||||
|
|
@ -87,6 +89,7 @@ function sensitiveFileBlockReason(filePath) {
|
|||
|
||||
if (basename.startsWith('.env.')) {
|
||||
const suffix = basename.slice('.env.'.length)
|
||||
|
||||
if (!SAFE_ENV_SUFFIXES.has(suffix)) {
|
||||
return `${basename} is blocked because it appears to contain environment secrets.`
|
||||
}
|
||||
|
|
@ -107,9 +110,10 @@ function sensitiveFileBlockReason(filePath) {
|
|||
return null
|
||||
}
|
||||
|
||||
function ipcPathError(code, message) {
|
||||
const error = new Error(message)
|
||||
error.code = code
|
||||
function ipcPathError(code: any, message: string): Error & { code: any } {
|
||||
const error = new Error(message) as Error & { code: any }
|
||||
;(error as any).code = code
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
|
|
@ -129,6 +133,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
|||
}
|
||||
|
||||
const normalized = raw.replace(/\\/g, '/').toLowerCase()
|
||||
|
||||
if (
|
||||
normalized.startsWith('//?/') ||
|
||||
normalized.startsWith('//./') ||
|
||||
|
|
@ -141,7 +146,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
|||
return raw
|
||||
}
|
||||
|
||||
function resolveRequestedPathForIpc(filePath, options = {}) {
|
||||
function resolveRequestedPathForIpc(filePath, options: { purpose?: string; baseDir?: fs.PathOrFileDescriptor } = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
let raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
|
||||
|
|
@ -154,17 +159,21 @@ function resolveRequestedPathForIpc(filePath, options = {}) {
|
|||
|
||||
if (/^file:/i.test(raw)) {
|
||||
let resolvedPath
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw)
|
||||
|
||||
if (parsed.protocol !== 'file:') {
|
||||
throw new Error('not a file URL')
|
||||
}
|
||||
|
||||
resolvedPath = fileURLToPath(parsed)
|
||||
} catch {
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
|
||||
}
|
||||
|
||||
rejectUnsafePathSyntax(resolvedPath, purpose)
|
||||
|
||||
return path.resolve(resolvedPath)
|
||||
}
|
||||
|
||||
|
|
@ -178,14 +187,16 @@ function resolveRequestedPathForIpc(filePath, options = {}) {
|
|||
return resolvedPath
|
||||
}
|
||||
|
||||
async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
|
||||
async function statForIpc(fsImpl: { promises: { stat: typeof fs.promises.stat } }, resolvedPath, purpose, typeLabel) {
|
||||
try {
|
||||
return await fsImpl.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
|
||||
}
|
||||
|
||||
throw ipcPathError(
|
||||
code || 'read-error',
|
||||
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
|
@ -201,6 +212,7 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
|
|||
try {
|
||||
const realPath = await fsImpl.promises.realpath(resolvedPath)
|
||||
rejectUnsafePathSyntax(realPath, purpose)
|
||||
|
||||
return realPath
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
|
|
@ -213,12 +225,20 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
|
|||
|
||||
function rejectSensitiveFilePath(filePath, purpose) {
|
||||
const blockReason = sensitiveFileBlockReason(filePath)
|
||||
|
||||
if (blockReason) {
|
||||
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectoryForIpc(dirPath, options = {}) {
|
||||
async function resolveDirectoryForIpc(
|
||||
dirPath,
|
||||
options: {
|
||||
purpose?: string
|
||||
baseDir?: fs.PathOrFileDescriptor
|
||||
fs?: { promises: { stat: typeof fs.promises.stat } }
|
||||
} = {}
|
||||
) {
|
||||
const purpose = String(options.purpose || 'Directory read')
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
|
||||
|
|
@ -233,7 +253,16 @@ async function resolveDirectoryForIpc(dirPath, options = {}) {
|
|||
return { realPath, resolvedPath, stat }
|
||||
}
|
||||
|
||||
async function resolveReadableFileForIpc(filePath, options = {}) {
|
||||
async function resolveReadableFileForIpc(
|
||||
filePath,
|
||||
options: {
|
||||
purpose?: string
|
||||
baseDir?: fs.PathOrFileDescriptor
|
||||
fs?: typeof fs
|
||||
blockSensitive?: boolean
|
||||
maxBytes?: number
|
||||
} = {}
|
||||
) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
|
||||
|
|
@ -253,11 +282,13 @@ async function resolveReadableFileForIpc(filePath, options = {}) {
|
|||
}
|
||||
|
||||
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
||||
|
||||
if (options.blockSensitive !== false) {
|
||||
rejectSensitiveFilePath(realPath, purpose)
|
||||
}
|
||||
|
||||
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
|
||||
|
||||
if (maxBytes && stat.size > maxBytes) {
|
||||
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
}
|
||||
|
|
@ -271,15 +302,15 @@ async function resolveReadableFileForIpc(filePath, options = {}) {
|
|||
return { realPath, resolvedPath, stat }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret,
|
||||
rejectUnsafePathSyntax,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
sensitiveFileBlockReason,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const {
|
||||
import {
|
||||
createLinkTitleWindow,
|
||||
guardLinkTitleSession,
|
||||
linkTitleWindowOptions,
|
||||
readLinkTitleWindowTitle
|
||||
} = require('./link-title-window.cjs')
|
||||
} from './link-title-window'
|
||||
|
||||
function makeFakeBrowserWindow() {
|
||||
const calls = { audioMuted: [] }
|
||||
|
||||
const FakeBrowserWindow = function (options) {
|
||||
this.options = options
|
||||
this.webContents = {
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
'use strict'
|
||||
|
||||
// Hidden BrowserWindow used by tier-2 link-title resolution: when curl can't
|
||||
// read a page <title> (bot walls, JS-rendered pages), we briefly load the URL
|
||||
// in an offscreen window and read its title. That window loads arbitrary
|
||||
// user-linked pages, so it must never emit sound or trigger real downloads.
|
||||
|
||||
function linkTitleWindowOptions(partitionSession) {
|
||||
export function linkTitleWindowOptions(partitionSession) {
|
||||
return {
|
||||
show: false,
|
||||
width: 1280,
|
||||
|
|
@ -25,7 +23,7 @@ function linkTitleWindowOptions(partitionSession) {
|
|||
// Create the offscreen title-fetch window and immediately mute it. Without the
|
||||
// mute, autoplaying media on the loaded page (e.g. a YouTube link) leaks ~2s of
|
||||
// audio every time a session containing such links is re-rendered. See #49505.
|
||||
function createLinkTitleWindow(BrowserWindow, partitionSession) {
|
||||
export function createLinkTitleWindow(BrowserWindow, partitionSession) {
|
||||
const window = new BrowserWindow(linkTitleWindowOptions(partitionSession))
|
||||
|
||||
try {
|
||||
|
|
@ -41,7 +39,7 @@ function createLinkTitleWindow(BrowserWindow, partitionSession) {
|
|||
// Cancel any download the title-fetch window triggers. Without this, a link
|
||||
// artifact URL served with Content-Disposition: attachment auto-downloads every
|
||||
// time the Artifacts page renders and fetchLinkTitle loads it.
|
||||
function guardLinkTitleSession(partitionSession) {
|
||||
export function guardLinkTitleSession(partitionSession) {
|
||||
try {
|
||||
partitionSession.on('will-download', (_event, item) => item.cancel())
|
||||
} catch {
|
||||
|
|
@ -52,20 +50,19 @@ function guardLinkTitleSession(partitionSession) {
|
|||
// Read the page title from a title-fetch window. Callers schedule this from
|
||||
// timers that can fire after finish() destroys the window, so every access must
|
||||
// guard isDestroyed and swallow Electron's "Object has been destroyed" throws.
|
||||
function readLinkTitleWindowTitle(window) {
|
||||
export function readLinkTitleWindowTitle(window) {
|
||||
try {
|
||||
if (!window || window.isDestroyed()) return ''
|
||||
if (!window || window.isDestroyed()) {
|
||||
return ''
|
||||
}
|
||||
const contents = window.webContents
|
||||
if (!contents || contents.isDestroyed()) return ''
|
||||
|
||||
if (!contents || contents.isDestroyed()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return contents.getTitle() || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createLinkTitleWindow,
|
||||
guardLinkTitleSession,
|
||||
linkTitleWindowOptions,
|
||||
readLinkTitleWindowTitle
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,13 @@
|
|||
/**
|
||||
* Tests for OAuth-session Electron net.request helpers.
|
||||
*
|
||||
* Run with: node --test electron/oauth-net-request.test.cjs
|
||||
* Run with: node --test electron/oauth-net-request.test.ts
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
|
||||
test('serializeJsonBody returns undefined for absent bodies', () => {
|
||||
assert.equal(serializeJsonBody(undefined), undefined)
|
||||
|
|
@ -21,6 +21,7 @@ test('serializeJsonBody JSON-encodes request bodies', () => {
|
|||
|
||||
test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => {
|
||||
const headers = []
|
||||
|
||||
const request = {
|
||||
setHeader(name, value) {
|
||||
headers.push([name, value])
|
||||
|
|
@ -14,7 +14,4 @@ function setJsonRequestHeaders(request) {
|
|||
request.setHeader('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serializeJsonBody,
|
||||
setJsonRequestHeaders
|
||||
}
|
||||
export { serializeJsonBody, setJsonRequestHeaders }
|
||||
33
apps/desktop/electron/oauth-session-request.test.ts
Normal file
33
apps/desktop/electron/oauth-session-request.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Regression coverage for the OAuth-session Electron net.request path.
|
||||
*
|
||||
* Electron net rejects manual Content-Length/Host headers with
|
||||
* net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length;
|
||||
* this guard is scoped to fetchJsonViaOauthSession only.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
|
||||
|
||||
function extractFetchJsonViaOauthSession() {
|
||||
const start = source.indexOf('function fetchJsonViaOauthSession')
|
||||
const end = source.indexOf('// Mint a single-use WS ticket', start)
|
||||
assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist')
|
||||
assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist')
|
||||
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
test('OAuth Electron net request does not set forbidden Content-Length header', () => {
|
||||
const fn = extractFetchJsonViaOauthSession()
|
||||
|
||||
assert.match(fn, /electronNet\.request/)
|
||||
assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/)
|
||||
assert.match(fn, /request\.write\(body\)/)
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
const { contextBridge, ipcRenderer, webUtils } = require('electron')
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
|
||||
|
|
@ -24,12 +24,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
onState: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:pet-overlay:state', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
|
||||
},
|
||||
// Main renderer subscribes to overlay control messages.
|
||||
onControl: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:pet-overlay:control', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
|
||||
}
|
||||
},
|
||||
|
|
@ -41,6 +43,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
|
||||
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
|
||||
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
|
||||
// Hermes Cloud: one portal login powers discovery + silent per-agent sign-in
|
||||
// (cloud-auto-discovery Phase 3).
|
||||
cloud: {
|
||||
status: () => ipcRenderer.invoke('hermes:cloud:status'),
|
||||
login: () => ipcRenderer.invoke('hermes:cloud:login'),
|
||||
logout: () => ipcRenderer.invoke('hermes:cloud:logout'),
|
||||
discover: org => ipcRenderer.invoke('hermes:cloud:discover', org),
|
||||
agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl)
|
||||
},
|
||||
profile: {
|
||||
get: () => ipcRenderer.invoke('hermes:profile:get'),
|
||||
set: name => ipcRenderer.invoke('hermes:profile:set', name)
|
||||
|
|
@ -78,6 +89,19 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
||||
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
|
||||
},
|
||||
zoom: {
|
||||
// Current zoom of this window, as { level, percent }.
|
||||
get: () => ipcRenderer.invoke('hermes:zoom:get'),
|
||||
setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent),
|
||||
// Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts,
|
||||
// so the settings UI can stay in sync with the keyboard.
|
||||
onChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:zoom:changed', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)
|
||||
}
|
||||
},
|
||||
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
|
|
@ -93,6 +117,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
|
||||
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
|
||||
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
|
||||
baseBranchList: repoPath => ipcRenderer.invoke('hermes:git:baseBranchList', repoPath),
|
||||
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
|
||||
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
|
||||
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
|
||||
|
|
@ -112,6 +137,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
}
|
||||
},
|
||||
terminal: {
|
||||
cwd: id => ipcRenderer.invoke('hermes:terminal:cwd', id),
|
||||
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
|
||||
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
|
||||
start: options => ipcRenderer.invoke('hermes:terminal:start', options),
|
||||
|
|
@ -120,68 +146,88 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
const channel = `hermes:terminal:${id}:data`
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on(channel, listener)
|
||||
|
||||
return () => ipcRenderer.removeListener(channel, listener)
|
||||
},
|
||||
onExit: (id, callback) => {
|
||||
const channel = `hermes:terminal:${id}:exit`
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on(channel, listener)
|
||||
|
||||
return () => ipcRenderer.removeListener(channel, listener)
|
||||
}
|
||||
},
|
||||
onClosePreviewRequested: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:close-preview-requested', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
|
||||
},
|
||||
onOpenUpdatesRequested: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:open-updates', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
|
||||
},
|
||||
onDeepLink: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:deep-link', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
|
||||
},
|
||||
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
|
||||
onWindowStateChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:window-state-changed', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
|
||||
},
|
||||
onFocusSession: callback => {
|
||||
const listener = (_event, sessionId) => callback(sessionId)
|
||||
ipcRenderer.on('hermes:focus-session', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
|
||||
},
|
||||
onNotificationAction: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:notification-action', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
|
||||
},
|
||||
onPreviewFileChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:preview-file-changed', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener)
|
||||
},
|
||||
onBackendExit: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:backend-exit', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
|
||||
},
|
||||
// Soft gateway-mode apply finished tearing down the primary backend. Renderer
|
||||
// should wipe session lists + re-dial without a window reload.
|
||||
onConnectionApplied: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:connection:applied', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:connection:applied', listener)
|
||||
},
|
||||
onPowerResume: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:power-resume', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:power-resume', listener)
|
||||
},
|
||||
onBootProgress: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:boot-progress', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
|
||||
},
|
||||
// First-launch bootstrap progress -- emitted by the install.ps1 stage
|
||||
// runner in main.cjs (apps/desktop/electron/bootstrap-runner.cjs).
|
||||
// runner in main.ts (apps/desktop/electron/bootstrap-runner.ts).
|
||||
// Renderer's install overlay subscribes to live events and queries the
|
||||
// current snapshot via getBootstrapState() to recover after a devtools
|
||||
// reload mid-bootstrap.
|
||||
|
|
@ -192,6 +238,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
onBootstrapEvent: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:bootstrap:event', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
|
||||
},
|
||||
getVersion: () => ipcRenderer.invoke('hermes:version'),
|
||||
|
|
@ -208,6 +255,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
onProgress: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:updates:progress', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
|
||||
}
|
||||
},
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const ELECTRON_DIR = __dirname
|
||||
const ELECTRON_DIR = import.meta.dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
|
|
@ -17,7 +17,7 @@ function readElectronFile(name) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// Locate the function definition and its closing brace.
|
||||
const fnStart = source.indexOf('async function prepareProfileDeleteRequest(')
|
||||
|
|
@ -36,7 +36,7 @@ test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
|
|||
})
|
||||
|
||||
test('hermes:api handler routes profile-delete requests to the primary backend', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
const source = readElectronFile('main.ts')
|
||||
|
||||
// The handler must capture prepareProfileDeleteRequest's return value.
|
||||
assert.match(
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue