diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1ded0ff541..3441d7bb6db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,11 @@ jobs: needs: detect uses: ./.github/workflows/uv-lockfile-check.yml + infographic-check: + name: Check no committed infographics + needs: detect + uses: ./.github/workflows/infographic-check.yml + lockfile-diff: name: package-lock.json diff needs: detect diff --git a/.github/workflows/infographic-check.yml b/.github/workflows/infographic-check.yml new file mode 100644 index 00000000000..288f6f493a0 --- /dev/null +++ b/.github/workflows/infographic-check.yml @@ -0,0 +1,78 @@ +name: Infographic Check + +# Rejects PRs that commit PR-infographic images into the repo. +# +# PR infographics are rendered to an image-provider URL (fal.media) and +# embedded in the PR *description*. The PR body is the archive; the binary +# never belongs in git history. +# +# This has now leaked twice. PR #48261 removed the first batch, PR #54564 +# removed a second batch and added `infographic/` to `.gitignore` — but +# `.gitignore` only stops *accidental* `git add`. It does nothing against +# `git add -f`, and it does nothing for a path that does not literally match +# the ignore pattern. Nine more PNGs (~14MB) were committed in the four +# weeks AFTER that rule landed, plus PR #70552 caught an `infograficos/` +# spelling that sidestepped the pattern entirely. +# +# A passive ignore rule cannot enforce a policy. This check can. + +on: + workflow_call: + outputs: + review_status: + description: "JSON array of review_status objects for the synthesizer." + value: ${{ jobs.check-no-committed-infographics.outputs.review_status }} + +permissions: + contents: read + +jobs: + check-no-committed-infographics: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + review_status: ${{ steps.infographic-check.outputs.review_status }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - id: infographic-check + name: Reject committed PR-infographic images + run: | + # Match on the IMAGE, not on a directory name. Keying this to + # `infographic/` is what let `infograficos/` through in #70552 — + # any localized or typo'd directory would sidestep it again. + # Instead: find tracked raster images whose path contains an + # infographic-ish segment, in any spelling, at any depth. + # + # `docs/assets` and `website/` legitimately hold product imagery + # and are excluded; those are referenced from shipped docs pages. + OFFENDERS=$(git ls-files -z \ + | tr '\0' '\n' \ + | grep -iE '(^|/)(infograph|infograf)[^/]*/' \ + | grep -iE '\.(png|jpe?g|webp|gif)$' \ + || true) + + if [ -n "$OFFENDERS" ]; then + COUNT=$(printf '%s\n' "$OFFENDERS" | wc -l | tr -d ' ') + STATUS='[{"source":"committed infographics","results":[{"kind":"action_required","title":"PR infographic committed to the repo","summary":"Infographic images belong in the PR description, never in git.","detail":"","how_to_fix":"Untrack the image and reference the provider URL from the PR body instead:\n```\ngit rm --cached \n```\nThen put it in the PR description:\n```\n## Infographic\n\n![slug](https://)\n```\n"}]}]' + echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT" + echo "" + echo "::error::${COUNT} PR-infographic image(s) are tracked in git." + echo "" + printf '%s\n' "$OFFENDERS" | sed 's/^/ /' + echo "" + echo "PR infographics are rendered to an image-provider URL and" + echo "embedded in the PR DESCRIPTION. The PR body is the archive —" + echo "the binary never enters git history." + echo "" + echo "This rule has been re-established twice already (#48261," + echo "#54564) and leaked both times, because .gitignore cannot stop" + echo "'git add -f' or a differently-spelled directory (#70552)." + echo "" + echo "To fix:" + echo " git rm --cached # keeps your local copy" + echo " # then embed the provider URL in the PR description" + exit 1 + fi + echo "::notice::No committed PR-infographic images." + echo "review_status=[]" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 29489633104..cd05306af00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .DS_Store /venv/ /venv.old/ +/venv.stale.runtime-*/ +/.hermes-runtime/ /_pycache/ *.pyc* __pycache__/ @@ -150,6 +152,11 @@ docs/superpowers/* .update-incomplete .update-incomplete.lock +# Checkout fingerprint the __pycache__ tree was last validated against +# (launch-time stale-bytecode sweep). Runtime state, never a code change. +.bytecode-fingerprint +.bytecode-fingerprint.tmp + # Installer-written method stamp in the managed checkout root (scripts/install.sh). # Runtime metadata only — never a code change. Ignore so `git status` stays clean # and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855). @@ -173,5 +180,13 @@ apps/desktop/demo/ # image-provider (fal.media) URL — they are NEVER committed to the repo. The # PR body is the archive. See the hermes-agent-dev skill's # pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1). +# +# Spelling variants are listed because a single `infographic/` pattern was +# sidestepped by an `infograficos/` directory (#70552). .gitignore is only +# the first line of defence and cannot stop `git add -f` at all — the +# infographic-check CI job is what actually enforces this. infographic/ +infographics/ +infograficos/ +infografico/ native/fts5_cjk/*.so diff --git a/AGENTS.md b/AGENTS.md index cb53e95eb0b..d623ba59bbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -325,7 +325,7 @@ class AIAgent: provider: str = None, api_mode: str = None, # "chat_completions" | "codex_responses" | ... model: str = "", # empty → resolved from config/provider later - max_iterations: int = 90, # tool-calling iterations (shared with subagents) + max_iterations: int = 500, # tool-calling iterations (shared with subagents) enabled_toolsets: list = None, disabled_toolsets: list = None, quiet_mode: bool = False, diff --git a/Dockerfile b/Dockerfile index 4d2c0f1972f..6b76075e980 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,45 @@ +# Debian 13 still ships SQLite 3.46.1, which contains the upstream WAL-reset +# corruption bug. Build a pinned shared library for the runtime image instead +# of relying on a distro backport that trixie does not currently provide. +# See #70480 and https://sqlite.org/wal.html#walresetbug. +FROM debian:13.4 AS sqlite_build +ARG SQLITE_AUTOCONF_VERSION=3530400 +ARG SQLITE_SHA256=0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c +RUN apt-get -o Acquire::Retries=3 update && \ + apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ + build-essential ca-certificates curl && \ + rm -rf /var/lib/apt/lists/* && \ + (curl -fsSL --retry 1 --retry-all-errors --connect-timeout 15 --max-time 60 \ + -o /tmp/sqlite.tar.gz \ + "https://sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" || \ + curl -fsSL --retry 3 --retry-all-errors --connect-timeout 15 --max-time 120 \ + -o /tmp/sqlite.tar.gz \ + "https://sources.buildroot.net/sqlite/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz") && \ + printf '%s %s\n' "${SQLITE_SHA256}" /tmp/sqlite.tar.gz > /tmp/sqlite.sha256 && \ + sha256sum -c /tmp/sqlite.sha256 && \ + tar -xzf /tmp/sqlite.tar.gz -C /tmp && \ + cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" && \ + CFLAGS="-O2 \ + -DSQLITE_ENABLE_FTS3 \ + -DSQLITE_ENABLE_FTS3_PARENTHESIS \ + -DSQLITE_ENABLE_FTS4 \ + -DSQLITE_ENABLE_FTS5 \ + -DSQLITE_ENABLE_RTREE \ + -DSQLITE_ENABLE_GEOPOLY \ + -DSQLITE_ENABLE_COLUMN_METADATA \ + -DSQLITE_ENABLE_UNLOCK_NOTIFY \ + -DSQLITE_ENABLE_DBSTAT_VTAB \ + -DSQLITE_ENABLE_DBPAGE_VTAB \ + -DSQLITE_ENABLE_MATH_FUNCTIONS \ + -DSQLITE_ENABLE_PREUPDATE_HOOK \ + -DSQLITE_ENABLE_SESSION \ + -DSQLITE_SECURE_DELETE \ + -DSQLITE_THREADSAFE=1 \ + -DSQLITE_MAX_VARIABLE_NUMBER=250000" \ + ./configure --prefix=/opt/sqlite-fixed --disable-static && \ + make -j"$(nproc)" && \ + make install + FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source # Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x # which reached EOL in April 2026 — we copy node + npm + corepack from the @@ -31,6 +73,23 @@ RUN apt-get -o Acquire::Retries=3 update && \ ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \ rm -rf /var/lib/apt/lists/* +# Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the +# public library name stable so both the system interpreter and the uv-created +# venv resolve the replacement without changing Python import paths. +COPY --from=sqlite_build /opt/sqlite-fixed/lib/libsqlite3.so.3.53.4 /usr/local/lib/ +RUN ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so.0 && \ + ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so && \ + printf '/usr/local/lib\n' > /etc/ld.so.conf.d/000-sqlite-fixed.conf && \ + ldconfig && \ + python3 -c "import sqlite3, sys; \ +v = sqlite3.sqlite_version_info; \ +sys.exit(f'linked SQLite {sqlite3.sqlite_version} still has the WAL-reset bug') if v < (3, 51, 3) else None; \ +db = sqlite3.connect(':memory:'); \ +db.execute(\"CREATE VIRTUAL TABLE docs USING fts5(content, tokenize='trigram')\"); \ +db.execute(\"INSERT INTO docs VALUES ('hermes')\"); \ +sys.exit('SQLite FTS5 trigram self-test failed') if db.execute(\"SELECT count(*) FROM docs WHERE docs MATCH 'erm'\").fetchone()[0] != 1 else None; \ +db.close()" + # ---------- s6-overlay install ---------- # s6-overlay provides supervision for the main hermes process, the dashboard, # and per-profile gateways. /init becomes PID 1 below — see ENTRYPOINT. diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 55773536122..fb9ed95450c 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -32,6 +32,7 @@ else: import argparse import asyncio import logging +import os import sys from pathlib import Path from hermes_constants import get_hermes_home @@ -251,11 +252,13 @@ def main(argv: list[str] | None = None) -> None: # MCP servers dynamically via asyncio.to_thread inside the event # loop; that path is unaffected.) Moved from model_tools.py module # scope to avoid freezing the gateway's loop on lazy import (#16856). - try: - from tools.mcp_tool import discover_mcp_tools - discover_mcp_tools() - except Exception: - logger.debug("MCP tool discovery failed at ACP startup", exc_info=True) + # Metadata-only hosts can opt out of unrelated global MCP startup. + if os.environ.get("HERMES_ACP_SKIP_CONFIGURED_MCP", "").strip() != "1": + try: + from tools.mcp_tool import discover_mcp_tools + discover_mcp_tools() + except Exception: + logger.debug("MCP tool discovery failed at ACP startup", exc_info=True) agent = HermesACPAgent() try: diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 3e79bdcd38a..7fee2d932f8 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -85,6 +85,110 @@ from tools.approval import ( logger = logging.getLogger(__name__) + +def _named_custom_provider_catalogs() -> list[tuple[str, str, list[tuple[str, str]]]]: + """Return ``(slug, label, [(model_id, description), ...])`` for named endpoints. + + Covers both the v12 ``providers:`` mapping and the legacy + ``custom_providers:`` list. These endpoints never appear in canonical + provider enumeration, so without this the ACP model selector hides every + named endpoint that the TUI ``/model`` picker already renders (#47039 + implemented named-endpoint rows for the TUI surface only). + + Model lists come from the entry's declared models (``default_model`` + + ``models``), refreshed from the endpoint's live ``/models`` listing when a + credential is available and ``discover_models`` is not disabled. Declared + models are kept even when live discovery fails — some OpenAI-compatible + endpoints (e.g. Bedrock Mantle Responses) expose no ``/models`` route at + all yet serve the declared models fine. + + Slugs use the ``custom:`` shape that ``parse_model_input`` and + ``resolve_runtime_provider`` already resolve, so encoded choice ids + (``custom::``) round-trip through ``set_session_model`` + unchanged. + """ + try: + from hermes_cli.config import ( + get_compatible_custom_providers, + is_provider_enabled, + load_config, + ) + from hermes_cli.models import fetch_api_models + except ImportError: + return [] + + try: + cfg = load_config() + entries = get_compatible_custom_providers(cfg) + except Exception: + logger.debug("Could not load named custom providers", exc_info=True) + return [] + + # ``get_compatible_custom_providers`` drops the ``enabled`` flag during + # normalization, so collect explicitly disabled provider keys from the + # raw config and skip their entries below. + disabled_keys: set[str] = set() + raw_providers = cfg.get("providers") if isinstance(cfg, dict) else None + if isinstance(raw_providers, dict): + for raw_key, raw_entry in raw_providers.items(): + if isinstance(raw_entry, dict) and not is_provider_enabled(raw_entry): + disabled_keys.add(str(raw_key).strip().lower()) + + catalogs: list[tuple[str, str, list[tuple[str, str]]]] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + provider_key = str(entry.get("provider_key", "") or "").strip() + if provider_key.lower() in disabled_keys: + continue + name = str(entry.get("name", "") or "").strip() + base_url = str(entry.get("base_url", "") or "").strip() + if not name or not base_url: + continue + slug_source = provider_key or name + slug = "custom:" + slug_source.strip().lower().replace(" ", "-") + + api_key = str(entry.get("api_key", "") or "").strip() + if not api_key: + key_env = str(entry.get("key_env", "") or "").strip() + api_key = os.environ.get(key_env, "").strip() if key_env else "" + + declared: list[str] = [] + default_model = str(entry.get("model", "") or "").strip() + if default_model: + declared.append(default_model) + models_cfg = entry.get("models") + if isinstance(models_cfg, dict): + for mid in models_cfg: + mid = str(mid or "").strip() + if mid and mid not in declared: + declared.append(mid) + + if not api_key and not declared: + # No credential to discover with and nothing declared: + # not addressable from the selector. + continue + + model_ids = list(declared) + discover = entry.get("discover_models", True) + if isinstance(discover, str): + discover = discover.lower() not in {"false", "no", "0"} + if discover and api_key: + try: + live = fetch_api_models( + api_key, base_url, api_mode=entry.get("api_mode") + ) + except Exception: + live = None + if live: + model_ids = declared + [m for m in live if m not in declared] + + if not model_ids: + continue + catalogs.append((slug, name, [(mid, "") for mid in model_ids])) + + return catalogs + try: from hermes_cli import __version__ as HERMES_VERSION except Exception: @@ -97,6 +201,13 @@ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="acp-agent") # does not expose a client-side limit, so this is a fixed cap that clients # paginate against using `cursor` / `next_cursor`. _LIST_SESSIONS_PAGE_SIZE = 50 +# Per-provider cap for the ACP model selector. ACP clients (Zed, Buzz) render +# the whole `availableModels` array in one dropdown, so an unbounded +# cross-provider catalog degrades the picker. Mirrors the cap the MoA picker +# already uses (`hermes_cli/moa_cmd.py`). This bounds each provider's row, not +# the total; aggregator providers stay intentionally uncapped inside the shared +# inventory, and the current model is always kept via the fallback insert below. +ACP_MAX_MODELS_PER_PROVIDER = 200 _MAX_ACP_RESOURCE_BYTES = 512 * 1024 _TEXT_RESOURCE_MIME_PREFIXES = ("text/",) _TEXT_RESOURCE_MIME_TYPES = { @@ -585,46 +696,108 @@ class HermesACPAgent(acp.Agent): return f"{raw_provider}:{raw_model}" def _build_model_state(self, state: SessionState) -> SessionModelState | None: - """Return the ACP model selector payload for editors like Zed.""" + """Return authenticated providers and their models for ACP clients. + + The shared Hermes inventory is also used by ``hermes model``, the TUI, + and the dashboard. Keeping ACP on that substrate prevents its selector + from silently collapsing to the current provider's curated list. + """ model = str(state.model or getattr(state.agent, "model", "") or "").strip() provider = getattr(state.agent, "provider", None) or detect_provider() or "openrouter" try: - from hermes_cli.models import curated_models_for_provider, normalize_provider, provider_label + from hermes_cli.inventory import build_models_payload, load_picker_context + from hermes_cli.models import normalize_provider, provider_label normalized_provider = normalize_provider(provider) - provider_name = provider_label(normalized_provider) + context = load_picker_context().with_overrides( + current_provider=normalized_provider, + current_model=model, + current_base_url=str(getattr(state.agent, "base_url", "") or ""), + ) + payload = build_models_payload( + context, + explicit_only=True, + include_unconfigured=False, + picker_hints=False, + canonical_order=True, + pricing=False, + capabilities=False, + refresh=False, + probe_custom_providers=False, + probe_current_custom_provider=False, + max_models=ACP_MAX_MODELS_PER_PROVIDER, + ) + available_models: list[ModelInfo] = [] seen_ids: set[str] = set() - - for model_id, description in curated_models_for_provider(normalized_provider): - rendered_model = str(model_id or "").strip() - if not rendered_model: + for row in payload.get("providers") or []: + row_provider = normalize_provider(str(row.get("slug") or "").strip()) + if not row_provider: continue - choice_id = self._encode_model_choice(normalized_provider, rendered_model) - if choice_id in seen_ids: - continue - desc_parts = [f"Provider: {provider_name}"] - if description: - desc_parts.append(str(description).strip()) - if rendered_model == model: - desc_parts.append("current") - available_models.append( - ModelInfo( - model_id=choice_id, - name=rendered_model, - description=" • ".join(part for part in desc_parts if part), - ) + provider_name = str(row.get("name") or "").strip() or provider_label( + row_provider ) - seen_ids.add(choice_id) + for model_entry in row.get("models") or []: + if isinstance(model_entry, dict): + rendered_model = str( + model_entry.get("id") + or model_entry.get("model") + or model_entry.get("name") + or "" + ).strip() + else: + rendered_model = str(model_entry or "").strip() + if not rendered_model: + continue + choice_id = self._encode_model_choice(row_provider, rendered_model) + if choice_id in seen_ids: + continue + is_current = ( + row_provider == normalized_provider and rendered_model == model + ) + description = f"Provider: {provider_name}" + if is_current: + description += " • current" + available_models.append( + ModelInfo( + model_id=choice_id, + name=f"{provider_name} · {rendered_model}", + description=description, + ) + ) + seen_ids.add(choice_id) + + # Named user-defined endpoints (providers: / custom_providers:) + # are invisible to canonical provider enumeration — append them + # so editor clients can select them like the TUI /model picker. + for named_slug, named_label, named_catalog in _named_custom_provider_catalogs(): + for named_model, named_desc in named_catalog: + named_choice = self._encode_model_choice(named_slug, named_model) + if not named_choice or named_choice in seen_ids: + continue + named_parts = [f"Provider: {named_label}"] + if named_desc: + named_parts.append(str(named_desc).strip()) + if named_slug == normalized_provider and named_model == model: + named_parts.append("current") + available_models.append( + ModelInfo( + model_id=named_choice, + name=named_model, + description=" • ".join(part for part in named_parts if part), + ) + ) + seen_ids.add(named_choice) current_model_id = self._encode_model_choice(normalized_provider, model) if current_model_id and current_model_id not in seen_ids: + provider_name = provider_label(normalized_provider) available_models.insert( 0, ModelInfo( model_id=current_model_id, - name=model, + name=f"{provider_name} · {model}", description=f"Provider: {provider_name} • current", ), ) @@ -1588,7 +1761,16 @@ class HermesACPAgent(acp.Agent): clear_session_vars, set_session_vars, ) - session_tokens = set_session_vars(session_key=session_id) + # ``cwd`` pins the logical working directory for this context, + # which is what the system prompt's "Current working directory" + # line reports (agent/prompt_builder.py -> resolve_agent_cwd). + # Without it the prompt advertises the global Hermes workspace + # while the tools are rooted at the client's project, so the + # model emits absolute paths under ~/.hermes/workspace and the + # edit silently lands outside the editor's workspace. + session_tokens = set_session_vars( + session_key=session_id, cwd=state.cwd, + ) except Exception: session_tokens = None clear_session_vars = None # type: ignore[assignment] @@ -1875,8 +2057,26 @@ class HermesACPAgent(acp.Agent): if handler is None: return None # not a known command — let the LLM handle it - try: + # Slash handlers run on the event-loop thread, OUTSIDE the per-turn + # contextvars.copy_context() that pins the session cwd for the agent + # call. ``/compress`` and ``/model`` reach code that REBUILDS the + # system prompt (agent._build_system_prompt -> resolve_agent_cwd), so + # an unpinned handler bakes the Hermes install tree into the session's + # cached prompt — persisted, and therefore poisoning every later turn + # even though the turn itself is pinned. Pin inside a fresh context so + # the write can't leak into other concurrent ACP sessions and needs no + # teardown. + def _dispatch() -> str | None: + try: + from agent.runtime_cwd import set_session_cwd + + set_session_cwd(state.cwd) + except Exception: + logger.debug("Could not pin ACP session cwd for slash command", exc_info=True) return handler(args, state) + + try: + return contextvars.copy_context().run(_dispatch) except Exception as e: logger.error("Slash command /%s error: %s", cmd, e, exc_info=True) return f"Error executing /{cmd}: {e}" diff --git a/agent/agent_init.py b/agent/agent_init.py index 7373f80f0bf..a99a0de8991 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -455,7 +455,7 @@ def init_agent( command: str = None, args: list[str] | None = None, model: str = "", - max_iterations: int = 90, # Default tool-calling iterations (shared with subagents) + max_iterations: int = 500, # Default tool-calling iterations (shared with subagents) tool_delay: float = 1.0, enabled_toolsets: List[str] = None, disabled_toolsets: List[str] = None, @@ -529,7 +529,7 @@ def init_agent( requested_provider (str): Original provider identity before runtime canonicalization api_mode (str): API mode override: "chat_completions" or "codex_responses" model (str): Model name to use (default: "anthropic/claude-opus-4.6") - max_iterations (int): Maximum number of tool calling iterations (default: 90) + max_iterations (int): Maximum number of tool calling iterations (default: 500) tool_delay (float): Delay between tool calls in seconds (default: 1.0) enabled_toolsets (List[str]): Only enable tools from these toolsets (optional) disabled_toolsets (List[str]): Disable tools from these toolsets (optional) @@ -645,6 +645,13 @@ def init_agent( # AWS Bedrock — auto-detect from provider name or base URL # (bedrock-runtime..amazonaws.com). agent.api_mode = "bedrock_converse" + elif agent.provider in {"nous", "nous-portal", "nousresearch"}: + # Portal is dual-wire: anthropic/* → Messages, everything else → + # chat_completions. Callers that already pass api_mode win above; + # this covers direct AIAgent construction without a resolved runtime. + from hermes_cli.providers import nous_api_mode + + agent.api_mode = nous_api_mode(agent.model) else: agent.api_mode = "chat_completions" @@ -823,9 +830,10 @@ def init_agent( # Anthropic prompt caching: auto-enabled for Claude models on native # Anthropic, OpenRouter, and third-party gateways that speak the # Anthropic protocol (``api_mode == 'anthropic_messages'``). Reduces - # input costs by ~75% on multi-turn conversations. Uses system_and_3 - # strategy (4 breakpoints). See ``_anthropic_prompt_cache_policy`` - # for the layout-vs-transport decision. + # input costs by ~75% on multi-turn conversations. Uses four breakpoints: + # the static system prefix, full system prompt, and last two messages + # (falling back to system-and-3 when no static prefix is available). See + # ``_anthropic_prompt_cache_policy`` for the layout-vs-transport decision. agent._use_prompt_caching, agent._use_native_cache_layout = ( agent._anthropic_prompt_cache_policy() ) @@ -950,12 +958,6 @@ def init_agent( agent._stream_writer_tls = threading.local() agent._stream_writer_dropped = 0 - # Displayed reasoning text streamed during the current model response, - # captured only when a surface consumed it via a reasoning callback. Used - # by active-turn redirect to checkpoint what the user actually saw without - # ever persisting hidden provider reasoning. - agent._current_streamed_reasoning_text = "" - # Optional current-turn user-message override used when the API-facing # user message intentionally differs from the persisted transcript # (e.g. CLI voice mode adds a temporary prefix for the live call only). @@ -1154,6 +1156,10 @@ def init_agent( elif base_url_host_matches(effective_base, "chatgpt.com"): from agent.auxiliary_client import _codex_cloudflare_headers client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key) + elif base_url_host_matches(effective_base, "x.ai"): + from tools.xai_http import hermes_xai_default_headers + + client_kwargs["default_headers"] = hermes_xai_default_headers() elif "default_headers" not in client_kwargs: # Fall back to profile.default_headers for providers that # declare custom headers (e.g. Kimi User-Agent on non-kimi.com @@ -1494,6 +1500,9 @@ def init_agent( # Cached system prompt -- built once per session, only rebuilt on compression agent._cached_system_prompt: Optional[str] = None + # Cross-session-stable prefix of the cached prompt. It remains separate + # from the persisted string and is used only to place an early cache marker. + agent._cached_system_prompt_static: Optional[str] = None # Filesystem checkpoint manager (transparent — not a tool) from tools.checkpoint_manager import CheckpointManager @@ -1948,8 +1957,12 @@ def init_agent( # parent_session_id chain, no `name #N` renumber). See #38763 and # agent/conversation_compression.py. Consumed by compress_context(), not the # compressor, so it rides on the agent. + # Default True must match DEFAULT_CONFIG["compression"]["in_place"] + # (#38763). default=False here previously flipped agents into rotation + # mode whenever the merged config omitted the key (partial configs, + # load_config failure → {}), re-arming the pre-lease drift abort. compression_in_place = is_truthy_value( - _compression_cfg.get("in_place"), default=False + _compression_cfg.get("in_place"), default=True ) codex_app_server_auto_compaction = str( _compression_cfg.get("codex_app_server_auto", "native") or "native" diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 5da612b9258..d6522d07f48 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1221,15 +1221,29 @@ def try_recover_primary_transport( if agent._is_openrouter_url(): return False provider_lower = (agent.provider or "").strip().lower() - if provider_lower in {"nous", "nous-research"}: + # Portal OpenAI-wire traffic still rides aggregator retry infra, so one + # more rebuilt OpenAI client won't help. Portal Claude on the native + # Messages route holds a local Anthropic SDK client whose connection + # pool *does* need the rebuild every other anthropic_messages provider + # already gets — don't blanket-skip the dual-wire path. + if ( + provider_lower in {"nous", "nous-portal", "nousresearch"} + and getattr(agent, "api_mode", None) != "anthropic_messages" + ): return False try: - # Close existing client to release stale connections + # Retire the existing client to release stale connections. #70773: + # never hard-close the shared client here — this runs on the + # conversation-loop thread while workers from stale-killed streaming + # attempts may still be unwinding their SSL BIOs on the old pool. + # ``_retire_shared_openai_client`` shuts the sockets down (FD-safe + # from any thread) and defers the FD release to GC, which cannot + # complete until every borrowing thread has unwound. if getattr(agent, "client", None) is not None: try: - agent._close_openai_client( - agent.client, reason="primary_recovery", shared=True, + agent._retire_shared_openai_client( + agent.client, reason="primary_recovery", ) except Exception: pass @@ -1889,7 +1903,15 @@ def anthropic_prompt_cache_policy( if is_native_anthropic: return True, True - if (is_openrouter or is_nous_portal) and (is_claude or is_kimi): + # Envelope layout is an OpenAI-wire construct. Portal Claude on the native + # Messages route must fall through to the third-party anthropic_messages + # branch below, which emits inner-block cache_control breakpoints; the + # envelope form would be dropped and serve 0% cache hits. + if ( + (is_openrouter or is_nous_portal) + and (is_claude or is_kimi) + and not is_anthropic_wire + ): 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 @@ -2053,8 +2075,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo from hermes_cli.providers import determine_api_mode # ── Determine api_mode if not provided ── + # Pass model so dual-wire providers (Nous Portal anthropic/* → Messages) + # resolve correctly; without it determine_api_mode falls back to the + # openai_chat overlay default. if not api_mode: - api_mode = determine_api_mode(new_provider, base_url) + api_mode = determine_api_mode(new_provider, base_url, model=new_model) # Defense-in-depth: ensure OpenCode base_url doesn't carry a trailing # /v1 into the anthropic_messages client, which would cause the SDK to @@ -2608,6 +2633,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i _clarify_tool( question=next_args.get("question", ""), choices=next_args.get("choices"), + multi_select=next_args.get("multi_select", False), callback=agent.clarify_callback, ), next_args, @@ -2753,6 +2779,129 @@ def repair_tool_call(agent, tool_name: str) -> str | None: +# Placeholder substituted for an empty non-final message that would otherwise +# make the provider reject the whole request. Kept identical to the stub- +# creation placeholder in chat_completion_helpers so a healed transcript reads +# consistently whether the empty turn was caught at write time or send time. +_INTERRUPTED_PLACEHOLDER = "[response interrupted]" + + +def _msg_has_payload(msg: Dict[str, Any]) -> bool: + """True if ``msg`` carries anything the API treats as non-empty content. + + Covers string content, non-empty multimodal content lists, tool_calls, + tool_call_id linkage (tool results), and reasoning payloads. Mirrors the + emptiness checks used by ``AIAgent._is_thinking_only_assistant`` but is + role-agnostic so it can vet user/assistant/tool turns uniformly. + """ + content = msg.get("content") + if isinstance(content, str): + if content.strip(): + return True + elif isinstance(content, list): + for block in content: + if isinstance(block, dict): + # any typed block (text/image/tool_use/document/...) counts, + # as long as a text block is not itself blank + if block.get("type") == "text": + if isinstance(block.get("text"), str) and block["text"].strip(): + return True + continue + return True + elif block: + return True + elif content not in (None, ""): + return True + # Structural payloads that make an "empty-content" message still valid. + if msg.get("tool_calls"): + return True + if isinstance(msg.get("reasoning_content"), str) and msg["reasoning_content"].strip(): + return True + if msg.get("reasoning") or msg.get("reasoning_details"): + return True + # Codex Responses item carriers: a commentary-phase assistant turn + # persists with content:"" by DESIGN — its text lives in + # ``codex_message_items`` (delivered via the interim callback) and the + # structured items are replayed for prefix-cache hits. Same for + # ``codex_reasoning_items``. These turns are never wire-empty on any + # api_mode: the codex transport replays the items, and the + # chat-completions transport strips the carriers only after this repair + # pass has already run. Treat them as payload so the repair never + # rewrites a designed-empty codex turn (July 2026: a write-time pad that + # ignored this broke codex commentary replay in CI). + if msg.get("codex_message_items") or msg.get("codex_reasoning_items"): + return True + return False + + +def repair_empty_non_final_messages( + messages: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Heal empty-content non-final messages before they reach the provider. + + Root-cause context: a stream that dies with 0 recovered characters (peer + reset, stall-kill) could persist an assistant turn with ``content=None`` + and no tool_calls. The Anthropic message schema — and the litellm/Bedrock + proxies in front of it — reject ANY request whose transcript contains an + empty non-final message: + + "all messages must have non-empty content except for the optional + final assistant message" (HTTP 400 INVALID_REQUEST_BODY) + + Once such a message lands mid-transcript it poisons EVERY subsequent turn + of that session until it scrolls out of context. The write-time guard in + ``chat_completion_helpers`` stops NEW stubs, but sessions already carrying + one (persisted before the guard, or fed in from a host history) stay stuck + and previously needed a manual DB edit + gateway restart to recover. + + This pass is the self-healing counterpart: it runs unconditionally on the + per-call ``api_messages`` copy, so a poisoned transcript repairs itself + IN MEMORY on the very next send — no restart, no DB surgery. The final + message is left untouched (an empty final assistant turn is legal). The + stored conversation history is never mutated; only the wire copy is + repaired, so the UI/session trace stays faithful. + + Repair strategy is substitution, not deletion: dropping a mid-transcript + turn can break role alternation and tool-call pairing, whereas an honest + minimal placeholder keeps the sequence intact and reads correctly as an + interrupted turn on replay. + """ + if not messages or len(messages) < 2: + return messages + + repaired: List[Dict[str, Any]] = [] + healed = 0 + last_idx = len(messages) - 1 + for idx, msg in enumerate(messages): + if ( + idx != last_idx + and isinstance(msg, dict) + # tool results are validated by their own orphan/pairing pass; an + # empty tool result is a separate (and rarer) concern. + and msg.get("role") in ("assistant", "user") + and not _msg_has_payload(msg) + ): + # Shallow-copy so stored history / prompt caching stays byte-stable. + fixed = dict(msg) + fixed["content"] = _INTERRUPTED_PLACEHOLDER + repaired.append(fixed) + healed += 1 + else: + repaired.append(msg) + + if healed: + _ra().logger.warning( + "Pre-call sanitizer: healed %d empty non-final message(s) by " + "substituting placeholder content — an empty-content turn was in " + "the transcript and would 400 the request ('messages must have " + "non-empty content' / INVALID_REQUEST_BODY). Self-recovering the " + "poisoned transcript in memory; no restart needed.", + healed, + ) + return repaired + return messages + + def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Fix orphaned tool_call / tool_result pairs before every LLM call. @@ -2773,6 +2922,15 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] filtered.append(msg) messages = filtered + # --- Heal empty-content non-final messages (self-recovery) --- + # A dead stream can leave an empty assistant stub (or an empty user turn) + # mid-transcript; the provider then 400s EVERY subsequent request until it + # scrolls out. Repair it here, on the per-call copy, so a poisoned session + # recovers itself in memory on the next send — no restart, no DB edit. + # Done first so a substituted turn participates normally in the tool-pair + # and dedup passes below. + messages = repair_empty_non_final_messages(messages) + # --- Drop empty / malformed tool_calls arrays on assistant messages --- # An assistant message carrying ``tool_calls: []`` (an empty array) — or a # non-list value under the key — is semantically identical to an assistant @@ -3232,75 +3390,139 @@ def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int: return changed +def _iter_httpx_pool_objects(http_client: Any): + """Yield httpcore pool objects reachable from an httpx client. + + Hermes' keepalive client (#10324 / ``_build_keepalive_http_client``) and + any ``HTTP(S)_PROXY`` configuration put live connections on *mounted* + transports (``client._mounts``), not only on the default + ``client._transport``. Walking the default transport alone makes + ``force_close_tcp_sockets`` return 0 while a stream is still mid-recv — + the interrupt logs success and the provider keeps burning the slot + (#72975). + """ + seen_pools: set[int] = set() + + def _emit(pool: Any): + if pool is None: + return + marker = id(pool) + if marker in seen_pools: + return + seen_pools.add(marker) + yield pool + + def _pools_for_transport(transport: Any): + if transport is None: + return + # Normal httpx.HTTPTransport / HTTPProxy-as-transport: connections + # live under ``_pool``. HTTPProxy itself *is* a ConnectionPool and + # may be mounted directly — then ``_connections`` is on the + # transport. + pool = getattr(transport, "_pool", None) + if pool is not None: + yield from _emit(pool) + return + if getattr(transport, "_connections", None) is not None: + yield from _emit(transport) + + try: + yield from _pools_for_transport(getattr(http_client, "_transport", None)) + mounts = getattr(http_client, "_mounts", None) or {} + for _pattern, mounted in list(mounts.items()): + yield from _pools_for_transport(mounted) + except Exception: + return + + +def _connection_candidates(conn: Any): + """Walk nested ``_connection`` wrappers (proxy tunnel → HTTP11/2).""" + seen: set[int] = set() + stack = [conn] + while stack: + candidate = stack.pop() + if candidate is None: + continue + marker = id(candidate) + if marker in seen: + continue + seen.add(marker) + yield candidate + inner = getattr(candidate, "_connection", None) + if inner is not None and id(inner) not in seen: + stack.append(inner) + + def _iter_pool_sockets(client: Any): """Yield raw sockets reachable from an OpenAI/httpx client pool. httpcore 1.x stores the concrete HTTP11/HTTP2 connection under ``conn._connection``; older versions exposed stream attributes directly - on the pool entry. Keep the traversal defensive because these are private - transport internals and vary across httpx/httpcore releases. + on the pool entry. Proxy tunnels wrap another layer + (``TunnelHTTPConnection`` / ``ForwardHTTPConnection``). Keep the + traversal defensive because these are private transport internals and + vary across httpx/httpcore releases. + + Also walks ``httpx`` mount transports — see ``_iter_httpx_pool_objects``. """ try: http_client = getattr(client, "_client", None) if http_client is None: - return - transport = getattr(http_client, "_transport", None) - if transport is None: - return - pool = getattr(transport, "_pool", None) - if pool is None: - return + # Some SDK wrappers *are* the httpx client (or expose the pool + # directly). Fall through so mount-aware discovery still runs. + http_client = client + pools = list(_iter_httpx_pool_objects(http_client)) + except Exception: + return + + if not pools: + return + + seen: set[int] = set() + for pool in pools: connections = ( getattr(pool, "_connections", None) or getattr(pool, "_pool", None) or [] ) - except Exception: - return - - seen: set[int] = set() - for conn in list(connections): - candidates = [conn] - inner = getattr(conn, "_connection", None) - if inner is not None: - candidates.append(inner) - for candidate in candidates: - stream = ( - getattr(candidate, "_network_stream", None) - or getattr(candidate, "_stream", None) - ) - if stream is None: - continue - sock = getattr(stream, "_sock", None) - if sock is None: - get_extra_info = getattr(stream, "get_extra_info", None) - if callable(get_extra_info): - try: - sock = get_extra_info("socket") - except Exception: - sock = None - if sock is None: - wrapped = getattr(stream, "stream", None) - if wrapped is not None: - sock = getattr(wrapped, "_sock", None) - if sock is None: - # anyio-backed streams expose the raw socket through - # SocketAttribute.raw_socket when available. - wrapped = getattr(stream, "_stream", None) - extra = getattr(wrapped, "extra", None) - if callable(extra): - try: - from anyio.abc import SocketAttribute - sock = extra(SocketAttribute.raw_socket) - except Exception: - sock = None - if sock is None: - continue - marker = id(sock) - if marker in seen: - continue - seen.add(marker) - yield sock + for conn in list(connections): + for candidate in _connection_candidates(conn): + stream = ( + getattr(candidate, "_network_stream", None) + or getattr(candidate, "_stream", None) + ) + if stream is None: + continue + sock = getattr(stream, "_sock", None) + if sock is None: + get_extra_info = getattr(stream, "get_extra_info", None) + if callable(get_extra_info): + try: + sock = get_extra_info("socket") + except Exception: + sock = None + if sock is None: + wrapped = getattr(stream, "stream", None) + if wrapped is not None: + sock = getattr(wrapped, "_sock", None) + if sock is None: + # anyio-backed streams expose the raw socket through + # SocketAttribute.raw_socket when available. + wrapped = getattr(stream, "_stream", None) + extra = getattr(wrapped, "extra", None) + if callable(extra): + try: + from anyio.abc import SocketAttribute + sock = extra(SocketAttribute.raw_socket) + except Exception: + sock = None + if sock is None: + continue + marker = id(sock) + if marker in seen: + continue + seen.add(marker) + yield sock def cleanup_dead_connections(agent) -> bool: diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 6d6be638fc2..0d59d94c9c3 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -23,7 +23,7 @@ from urllib.parse import urlparse from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple -from utils import base_url_host_matches, normalize_proxy_env_vars +from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars # NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls # ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.) @@ -546,15 +546,49 @@ def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool: return "/anthropic" in normalized.rstrip("/").lower() +def _is_nous_portal_endpoint(base_url: str | None) -> bool: + """Return True for Nous Portal's Anthropic Messages route. + + Portal serves its ``anthropic/*`` catalog natively at + ``https://inference-api.nousresearch.com/v1/messages``. Portal-specific + behaviours key off this: Bearer JWT auth, verbatim catalog model ids, + and native thinking-signature replay. + + Trusted hosts only: + + 1. Prod hostname ``inference-api.nousresearch.com`` + 2. The operator-set ``NOUS_INFERENCE_BASE_URL`` hostname (staging/preview) + + Lookalikes such as ``inference-api.nousresearch.com.attacker.test`` are + rejected (hostname match, not substring). + """ + if base_url_host_matches(base_url or "", "inference-api.nousresearch.com"): + return True + try: + from hermes_cli.auth import _nous_inference_env_override + + override = _nous_inference_env_override() + except Exception: + return False + if not override: + return False + # Exact host equality (not subdomain) so the env override can't broaden + # into sibling hosts the operator did not set. + override_host = base_url_hostname(override) + return bool(override_host) and base_url_hostname(base_url or "") == override_host + + def _requires_bearer_auth(base_url: str | None) -> bool: """Return True for Anthropic-compatible providers that require Bearer auth. Some third-party /anthropic endpoints implement Anthropic's Messages API but require Authorization: Bearer instead of Anthropic's native x-api-key header. MiniMax's global and China Anthropic-compatible endpoints, Azure AI - Foundry's Anthropic-style endpoint, and Palantir Foundry's LLM proxy - follow this pattern. + Foundry's Anthropic-style endpoint, Palantir Foundry's LLM proxy, and Nous + Portal's Messages route follow this pattern. """ + if _is_nous_portal_endpoint(base_url): + return True normalized = _normalize_base_url_text(base_url) if not normalized: return False @@ -721,7 +755,11 @@ def _build_anthropic_client_with_bearer_hook( if common_betas: kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} - return _anthropic_sdk.Anthropic(**kwargs) + client = _anthropic_sdk.Anthropic(**kwargs) + # Same env-inference trap as build_anthropic_client: auth_token-only + # construction would otherwise also send ANTHROPIC_API_KEY as X-Api-Key. + client.api_key = None + return client def build_anthropic_client( @@ -850,7 +888,16 @@ def build_anthropic_client( if common_betas: kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} - return _anthropic_sdk.Anthropic(**kwargs) + client = _anthropic_sdk.Anthropic(**kwargs) + # Bearer-only construction leaves ``api_key`` unset, so the SDK fills it + # from ``ANTHROPIC_API_KEY`` (Hermes loads that into the process env from + # ``~/.hermes/.env``). The result is dual auth — + # ``X-Api-Key: sk-ant-…`` *and* ``Authorization: Bearer `` — + # on every Portal / MiniMax / OAuth Messages request. Clear the env-filled + # key whenever we intentionally authenticated via auth_token alone. + if "auth_token" in kwargs and "api_key" not in kwargs: + client.api_key = None + return client def build_anthropic_bedrock_client(region: str): @@ -1920,10 +1967,18 @@ def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: return None btype = b.get("type") if btype == "text": - # Coerce empty/whitespace-only text to a non-whitespace placeholder; - # the Messages input schema rejects blank text blocks (#69512), and a - # blank block stored in history replays on every turn → permanent 400. - out: Dict[str, Any] = {"type": "text", "text": _safe_text(b.get("text", ""))} + text_val = b.get("text", "") + # Bedrock and strict Anthropic-compatible endpoints reject text + # blocks where "text" is empty or whitespace-only (#69512). Drop the + # blank block (the caller relocates any cache_control it carried and + # falls back to a non-whitespace placeholder when nothing survives) + # rather than coercing in place — a coerced "(empty)" block would be + # model-visible noise next to surviving thinking/tool_use blocks. + # Type-safe: captured blocks can carry text=None from an invalid + # upstream payload, which a bare .strip() would crash on. + if not isinstance(text_val, str) or not text_val.strip(): + return None + out: Dict[str, Any] = {"type": "text", "text": text_val} # citations is input-valid ONLY when it's a non-empty list; the SDK # emits citations=None on responses, which the input schema rejects. cits = b.get("citations") @@ -2011,9 +2066,17 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: parsed_args = {} redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args replayed: List[Dict[str, Any]] = [] + _relocated_replay_cache_control = None + _dropped_blank_text = False for b in ordered_blocks: clean = _sanitize_replay_block(b) if clean is None: + if isinstance(b, dict) and b.get("type") == "text": + _dropped_blank_text = True + if isinstance(b, dict) and isinstance(b.get("cache_control"), dict): + # A dropped blank text block can still carry the cache + # breakpoint marker -- relocate it rather than losing it. + _relocated_replay_cache_control = b["cache_control"] continue if clean.get("type") == "tool_use": # Override raw (un-redacted) input with the redacted copy when @@ -2023,20 +2086,90 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: if redacted is not None: clean["input"] = redacted replayed.append(clean) + # When every text block was blank and nothing cacheable survived + # (e.g. signed thinking + a blank text block, or a SOLE blank + # cache-marked block), emit the non-whitespace placeholder so the + # replayed message stays schema-valid (#69512) and a relocated cache + # marker still has a carrier instead of being silently lost. + _has_cacheable_replay = any( + isinstance(b, dict) and b.get("type") in {"text", "tool_use"} + for b in replayed + ) + if not _has_cacheable_replay and ( + _dropped_blank_text or _relocated_replay_cache_control is not None + ): + replayed.append({"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}) if replayed: + if _relocated_replay_cache_control is not None: + _apply_assistant_cache_control_to_last_cacheable_block( + replayed, _relocated_replay_cache_control + ) _apply_assistant_cache_control_to_last_cacheable_block( replayed, m.get("cache_control") ) + # apply_anthropic_cache_control marks an assistant turn with + # non-empty text by writing cache_control INTO ``content`` (see + # _apply_cache_marker's list branch), not at the top level. This + # branch rebuilds the message from ordered_blocks and never reads + # ``content``, so that marker would be dropped -- and because + # _can_carry_marker already counted this message as a carrier, the + # breakpoint is burned rather than relocated. #56195 covered the + # complementary shape (blank content -> top-level marker); this is + # the interleaved thinking + preamble-text + tool_use shape. + _inline_cc = None + _msg_content = m.get("content") + if isinstance(_msg_content, list): + for _blk in _msg_content: + if isinstance(_blk, dict) and isinstance( + _blk.get("cache_control"), dict + ): + _inline_cc = _blk["cache_control"] + break + if _inline_cc is not None: + _apply_assistant_cache_control_to_last_cacheable_block( + replayed, _inline_cc + ) return {"role": "assistant", "content": replayed} blocks = _extract_preserved_thinking_blocks(m) + # Cache markers dropped along with a blank block are relocated onto the + # last surviving cacheable block below (via + # _apply_assistant_cache_control_to_last_cacheable_block), rather than + # lost -- prompt_caching.py's _apply_cache_marker() sets cache_control + # directly on content[-1] for list content, so if that last part happens + # to be blank text, dropping it silently would lose the breakpoint. + _relocated_cache_control = None if content: if isinstance(content, list): converted_content = _convert_content_to_anthropic(content) if isinstance(converted_content, list): - blocks.extend(converted_content) + # Bedrock and strict Anthropic-compatible endpoints reject + # text blocks where "text" is empty or whitespace-only. The + # ordered-replay path enforces the same invariant via + # _sanitize_replay_block(). Type-safe against ANY invalid + # "text" value from an upstream payload -- None, or a + # truthy non-string like an int -- not just None: checking + # isinstance() first (rather than `blk.get("text") or ""`) + # means a non-string value is treated as blank/invalid + # instead of reaching .strip() and raising AttributeError. + for blk in converted_content: + _blk_text = blk.get("text") if isinstance(blk, dict) else None + if ( + isinstance(blk, dict) + and blk.get("type") == "text" + and (not isinstance(_blk_text, str) or not _blk_text.strip()) + ): + if isinstance(blk.get("cache_control"), dict): + _relocated_cache_control = blk["cache_control"] + continue + blocks.append(blk) else: - blocks.append({"type": "text", "text": str(content)}) + # Scalar (non-list) content: a whitespace-only string is the + # same invalid-payload case as an empty list block -- drop it + # rather than emitting a blank text block. + text_str = str(content) + if text_str.strip(): + blocks.append({"type": "text", "text": text_str}) for tc in m.get("tool_calls", []): if not tc or not isinstance(tc, dict): continue @@ -2052,9 +2185,6 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: "name": fn.get("name", ""), "input": parsed_args, }) - _apply_assistant_cache_control_to_last_cacheable_block( - blocks, m.get("cache_control") - ) # Kimi's /coding endpoint (Anthropic protocol) requires assistant # tool-call messages to carry reasoning_content when thinking is # enabled server-side. Preserve it as a thinking block so Kimi @@ -2080,19 +2210,26 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: ) if isinstance(reasoning_content, str) and not _already_has_thinking: blocks.insert(0, {"type": "thinking", "thinking": reasoning_content}) - # Anthropic rejects empty assistant content - effective = blocks or content - if not effective or effective == "": - effective = [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}] - elif isinstance(effective, list): - # The all-empty guard above misses a list that still contains a - # whitespace-only text block (e.g. from a content array of blank parts, - # or compression). Those also trip "text content blocks must contain - # non-whitespace text" (#69512). Coerce text blocks in place; other - # block types (thinking/tool_use/image) are left untouched. - for blk in effective: - if isinstance(blk, dict) and blk.get("type") == "text": - blk["text"] = _safe_text(blk.get("text", "")) + # Anthropic rejects empty assistant content. IMPORTANT: fall back only + # to the placeholder, never to the raw `content` variable -- `content` + # is the UNFILTERED original message content, and can itself be exactly + # the blank/whitespace-only payload the filtering above just removed + # (a sole blank text block, or scalar whitespace with no tool_calls). + # `blocks or content` there would silently restore the invalid provider + # payload this function exists to prevent (#69512). + effective = blocks if blocks else [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}] + # Applied here (after the empty-fallback resolution) rather than + # earlier against `blocks` directly, so a cache_control relocated from + # a dropped blank block that was the ONLY block still lands on the + # (empty) placeholder instead of being silently lost when blocks was + # empty at the point the marker would otherwise have been applied. + if _relocated_cache_control is not None: + _apply_assistant_cache_control_to_last_cacheable_block( + effective, _relocated_cache_control + ) + _apply_assistant_cache_control_to_last_cacheable_block( + effective, m.get("cache_control") + ) return {"role": "assistant", "content": effective} @@ -2326,10 +2463,22 @@ def _manage_thinking_signatures( replayed assistant tool-call messages. See hermes-agent#13848 (Kimi) and hermes-agent#16748 (DeepSeek). + Nous Portal's ``/v1/messages`` route is the exception among third-party + hosts: it proxies Claude to Anthropic/Vertex/Bedrock and validates the + same signed thinking blocks. Sticky ``session_id`` keeps a conversation + on one upstream instance so those signatures stay warm — stripping them + here would 400 the first tool-loop turn ("thinking must be passed back"). + Portal therefore takes the native Anthropic replay path below. + Mutates ``result`` in place. """ _THINKING_TYPES = frozenset(("thinking", "redacted_thinking")) - _is_third_party = _is_third_party_anthropic_endpoint(base_url) + # Portal speaks Anthropic's thinking contract end-to-end; do not treat it + # as a signature-blind proxy even though the host is not anthropic.com. + _is_third_party = ( + _is_third_party_anthropic_endpoint(base_url) + and not _is_nous_portal_endpoint(base_url) + ) last_assistant_idx = None for i in range(len(result) - 1, -1, -1): @@ -2586,7 +2735,12 @@ def build_anthropic_kwargs( ) anthropic_tools = convert_tools_to_anthropic(tools) if tools else [] - model = normalize_model_name(model, preserve_dots=preserve_dots) + # Nous Portal routes on its own catalog ids (``anthropic/claude-opus-4.8``); + # normalizing to the bare Anthropic slug would make the model unresolvable + # there. Skipping the call preserves the prefix AND the dots, so + # ``preserve_dots`` stays irrelevant for Portal. + if not _is_nous_portal_endpoint(base_url): + model = normalize_model_name(model, preserve_dots=preserve_dots) # effective_max_tokens = output cap for this call (≠ total context window) # Use the resolver helper so non-positive values (negative ints, # fractional floats, NaN, non-numeric) fail locally with a clear error @@ -2825,6 +2979,8 @@ def create_anthropic_message( *, log_prefix: str = "", prefer_stream: bool = True, + on_stream_event=None, + on_response=None, ) -> Any: """Create an Anthropic message, aggregating via stream when available. @@ -2834,6 +2990,20 @@ def create_anthropic_message( crash on ``.content``. Prefer ``messages.stream().get_final_message()`` to match the main turn path, falling back to ``create()`` only for providers that explicitly do not support streaming, such as restricted Bedrock roles. + + ``on_stream_event``: optional callable invoked once per streamed event + (best-effort, exceptions swallowed). Lets callers report forward progress + to liveness watchdogs — e.g. the auxiliary compression path ticking its + progress hook so a slow-but-generating summary model isn't treated as + hung. Only fires on the streaming path; the ``create()`` fallback has no + events to report. + + ``on_response``: optional callable invoked once with the underlying httpx + response before the message is aggregated (best-effort, exceptions + swallowed). Response *headers* carry out-of-band provider state that the + parsed ``Message`` drops — Nous Portal's ``x-nous-credits-*`` balance family + in particular. Only fires on the streaming path, which is the one the main + turn loop takes. """ sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix) @@ -2844,6 +3014,26 @@ def create_anthropic_message( stream_kwargs.pop("stream", None) try: with stream_fn(**stream_kwargs) as stream: + if callable(on_response): + try: + on_response(getattr(stream, "response", None)) + except Exception: + logger.debug( + "%son_response callback failed", + log_prefix, exc_info=True, + ) + if callable(on_stream_event): + # Consume the event stream manually so each event can + # tick the caller's progress callback; get_final_message + # then returns the accumulated snapshot. + for _event in stream: + try: + on_stream_event(_event) + except Exception: + logger.debug( + "%son_stream_event callback failed", + log_prefix, exc_info=True, + ) return stream.get_final_message() except Exception as exc: if not _is_stream_unavailable_error(exc): diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index dc5c2648f7a..fa5bc6b80d7 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -247,6 +247,50 @@ def aux_interrupt_protection(active: bool = True): _aux_interrupt_protection.active = prev +# ── Forward-progress hook for streamed auxiliary calls ─────────────────── +# Long auxiliary calls (context compression is the prime case) are watched by +# wall-clock deadlines in their hosts (gateway session hygiene). A fixed +# deadline punishes SLOW summary models exactly as hard as HUNG ones: a +# reasoning model happily streaming a large summary is killed mid-generation. +# This thread-local hook lets the host observe liveness instead: the wire +# consumers below tick it on every streamed token/SSE event, and the host +# extends its deadline while tokens are moving (see gateway/run.py session +# hygiene + CompressionCommitFence.touch_progress). Thread-local matches the +# call topology — the aux call and its stream consumption run synchronously +# on the thread that installed the hook. +_aux_progress = threading.local() + + +def _notify_aux_progress() -> None: + """Tick the installed forward-progress hook, if any. Never raises.""" + hook = getattr(_aux_progress, "hook", None) + if hook is None: + return + try: + hook() + except Exception: + logger.debug("aux progress hook failed", exc_info=True) + + +def _aux_progress_active() -> bool: + return getattr(_aux_progress, "hook", None) is not None + + +@contextlib.contextmanager +def aux_progress_hook(hook): + """Install *hook* as the current thread's aux forward-progress callback. + + ``hook=None`` is a no-op passthrough so callers can wire it + unconditionally. Re-entrant-safe: restores the previous hook on exit. + """ + prev = getattr(_aux_progress, "hook", None) + _aux_progress.hook = hook if callable(hook) else prev + try: + yield + finally: + _aux_progress.hook = prev + + def _safe_isinstance(obj: Any, maybe_type: Any) -> bool: """Return False instead of raising when a patched symbol is not a type.""" try: @@ -1058,16 +1102,29 @@ class _CodexCompletionsAdapter: # key in extra_body (not top-level) and GitHub/Copilot Responses opts # out of cache-key routing entirely — for those hosts, skip it here. try: - from agent.transports.codex import _content_cache_key + from agent.transports.codex import ( + _content_cache_key, + _default_prompt_cache_retention_for_request, + ) from utils import base_url_host_matches _host_src = str(getattr(self._client, "base_url", "") or "") _is_xai = base_url_host_matches(_host_src, "x.ai") or base_url_host_matches(_host_src, "api.x.ai") - _is_github = base_url_host_matches(_host_src, "githubcopilot.com") + _is_github = ( + base_url_host_matches(_host_src, "githubcopilot.com") + or base_url_host_matches(_host_src, "models.github.ai") + ) if not _is_xai and not _is_github and "prompt_cache_key" not in resp_kwargs: _cache_key = _content_cache_key(instructions, resp_kwargs.get("tools")) if _cache_key: resp_kwargs["prompt_cache_key"] = _cache_key + if "prompt_cache_retention" not in resp_kwargs: + _cache_retention = _default_prompt_cache_retention_for_request( + model, + _host_src, + ) + if _cache_retention: + resp_kwargs["prompt_cache_retention"] = _cache_retention except Exception: logger.debug( "Codex auxiliary: prompt_cache_key derivation skipped", exc_info=True @@ -1149,6 +1206,10 @@ class _CodexCompletionsAdapter: def _on_each_event(_event: Any) -> None: # Re-check timeout/cancellation per event, matching the # cadence the old in-line ``_check_cancelled()`` used. + # Each SSE event is also forward progress for hosts watching + # a progress hook (gateway session hygiene): a reasoning + # model streaming a long summary must not look hung. + _notify_aux_progress() _check_cancelled() event_stream = self._client.responses.create(**stream_kwargs) @@ -1301,10 +1362,32 @@ class AsyncCodexAuxiliaryClient: class _AnthropicCompletionsAdapter: """OpenAI-client-compatible adapter for Anthropic Messages API.""" - def __init__(self, real_client: Any, model: str, is_oauth: bool = False): + def __init__( + self, + real_client: Any, + model: str, + is_oauth: bool = False, + base_url: str | None = None, + ): self._client = real_client self._model = model self._is_oauth = is_oauth + # Prefer the caller-supplied URL (AnthropicAuxiliaryClient keeps the + # pre-strip Portal ``.../v1`` form). Only fall back to the SDK + # client's host for Nous Portal — a blanket fallback would flip + # MiniMax/Zhipu/etc. aux adapters from "unknown host = native + # Anthropic" to third-party (stripping thinking signatures). + self._base_url = base_url or None + if not self._base_url: + candidate = str(getattr(real_client, "base_url", "") or "") or None + if candidate: + try: + from agent.anthropic_adapter import _is_nous_portal_endpoint + + if _is_nous_portal_endpoint(candidate): + self._base_url = candidate + except Exception: + pass def create(self, **kwargs) -> Any: from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message @@ -1356,6 +1439,11 @@ class _AnthropicCompletionsAdapter: reasoning_config=_reasoning_cfg, tool_choice=normalized_tool_choice, is_oauth=self._is_oauth, + # Portal routes on ``anthropic/`` catalog ids and replays + # signed thinking like native Anthropic; both carve-outs key off + # base_url. Omitting it normalizes the id to a bare Anthropic + # slug and the Portal Messages route cannot resolve it. + base_url=self._base_url, ) # Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set # temperature for models that still accept it. build_anthropic_kwargs @@ -1390,7 +1478,18 @@ class _AnthropicCompletionsAdapter: existing = {} anthropic_kwargs["extra_body"] = {**existing, **passthrough} - response = create_anthropic_message(self._client, anthropic_kwargs) + response = create_anthropic_message( + self._client, + anthropic_kwargs, + # Tick the aux forward-progress hook per streamed event so hosts + # watching liveness (gateway session hygiene) don't kill a + # slow-but-generating summary model. No-op when no hook is + # installed (None keeps the fast get_final_message path). + on_stream_event=( + (lambda _event: _notify_aux_progress()) + if _aux_progress_active() else None + ), + ) _transport = get_transport("anthropic_messages") _nr = _transport.normalize_response( response, strip_tool_prefix=self._is_oauth @@ -1438,7 +1537,9 @@ class AnthropicAuxiliaryClient: def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False): self._real_client = real_client - adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth) + adapter = _AnthropicCompletionsAdapter( + real_client, model, is_oauth=is_oauth, base_url=base_url, + ) self.chat = _AnthropicChatShim(adapter) self.api_key = api_key self.base_url = base_url @@ -1703,7 +1804,7 @@ def _read_nous_auth() -> Optional[dict]: try: if not _AUTH_JSON_PATH.is_file(): return None - data = json.loads(_AUTH_JSON_PATH.read_text()) + data = json.loads(_AUTH_JSON_PATH.read_text(encoding="utf-8")) if data.get("active_provider") != "nous": return None provider = data.get("providers", {}).get("nous", {}) @@ -2693,7 +2794,13 @@ def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str return None, None api_key, base_url = resolved logger.debug("Auxiliary client: xAI OAuth (%s via Responses API)", model) - real_client = _create_openai_client(api_key=api_key, base_url=base_url) + from tools.xai_http import hermes_xai_default_headers + + real_client = _create_openai_client( + api_key=api_key, + base_url=base_url, + default_headers=hermes_xai_default_headers(), + ) return CodexAuxiliaryClient(real_client, model), model @@ -4116,6 +4223,7 @@ def _try_main_agent_model_fallback( failed_provider: str, task: str = None, reason: str = "error", + failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Last-resort fallback to the user's main agent provider + model. @@ -4124,8 +4232,23 @@ def _try_main_agent_model_fallback( layer: if nothing the user asked for can serve the request, try the main chat model before giving up. - Skips when the failed provider already IS the main provider (no point - retrying the same backend that just failed). + ``failed_model`` narrows the same-provider skip to the exact + (provider, model) pair that just failed, mirroring + :func:`_try_configured_fallback_chain`. This matters for self-hosted / + custom endpoints serving several models behind one provider label: the + aux compression model timing out says nothing about the health of the + main agent model deployed on the same URL (real incident: aux + ``glm-5.2`` hung and timed out while main ``macaron-v1-venti`` on the + identical endpoint was serving 448K-token turns fine — the + provider-label skip discarded the one fallback that would have worked). + + - Model-specific runtime failures (timeout, connection, rate limit, + model-incompatible, invalid response) pass ``failed_model``: skip the + main model only when it IS the exact model that failed. + - Provider-wide failures (auth 401, payment 402) and legacy callers + leave ``failed_model`` as None, keeping the whole-provider skip — + the shared credentials/account are broken, so the main model on the + same provider cannot help either. Returns: (client, model, provider_label) or (None, None, "") if no fallback. @@ -4142,9 +4265,23 @@ def _try_main_agent_model_fallback( if not main_provider or not main_model or main_provider.lower() in {"auto", ""}: return None, None, "" - skip = (failed_provider or "").lower().strip() - if main_provider.lower() == skip: - # The thing that failed IS the main model — nothing to fall back to. + # Identity + scope semantics owned by agent.backend_identity (#72468): + # model-scoped failures skip only the exact deployment that failed; + # provider-wide failures (no failed_model) skip the credential surface. + from agent.backend_identity import ( + BackendIdentity, + FailureScope, + should_skip_candidate, + ) + + skip_model = (failed_model or "").strip().lower() or None + if should_skip_candidate( + BackendIdentity.build(provider=main_provider, model=main_model), + BackendIdentity.build(provider=failed_provider, model=skip_model), + FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL, + ): + # The thing that failed IS the main model (or the failure was + # provider-wide) — nothing to fall back to. return None, None, "" if _is_provider_unhealthy(main_provider): _log_skip_unhealthy(main_provider, task) @@ -4254,6 +4391,7 @@ def _try_configured_fallback_chain( task: str, failed_provider: str, reason: str = "error", + failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Try user-configured fallback_chain for a specific auxiliary task. @@ -4261,6 +4399,25 @@ def _try_configured_fallback_chain( entry in order. Each entry must have at least ``provider``; ``model``, ``base_url``, and ``api_key`` are optional. + ``failed_model`` narrows the skip check to the exact (provider, model) + pair that just failed, rather than the whole provider. Without it every + entry sharing the failed provider is skipped (the original behaviour). + Callers pass it only when a sibling model on the same provider could + plausibly recover: + + - Model-specific runtime failures (timeout, connection, rate limit, + model-incompatible, invalid response) pass ``failed_model`` so a + chain that intentionally lists several models under the same provider + — e.g. two more NVIDIA NIM models after the primary NIM model times + out — is not skipped wholesale. Only the exact model that failed is + skipped; the siblings still run instead of jumping straight to the + main-agent-model safety net. + - Provider-wide failures (auth 401, payment 402) and "no client could + be built" callers leave ``failed_model`` as None, keeping the whole + provider skipped — the shared credentials/account behind every model + on that provider are broken, so a sibling can't help and the + main-agent-model safety net should be reached instead. + Returns: (client, model, provider_label) or (None, None, "") if no fallback. """ @@ -4272,7 +4429,24 @@ def _try_configured_fallback_chain( if not chain or not isinstance(chain, list): return None, None, "" - skip = failed_provider.lower().strip() + skip_model = (failed_model or "").strip().lower() or None + # Identity + scope semantics owned by agent.backend_identity (#59561, + # #72468): a failed_model means the failure was model-scoped (timeout / + # connection / rate limit) — only the exact deployment is skipped; no + # failed_model means provider-wide (auth/payment) — the whole credential + # surface is skipped. + from agent.backend_identity import ( + BackendIdentity, + FailureScope, + should_skip_candidate, + ) + + failed_ident = BackendIdentity.build( + provider=failed_provider, model=skip_model, + ) + failure_scope = ( + FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL + ) tried = [] min_ctx = _task_minimum_context_length(task) @@ -4280,9 +4454,20 @@ def _try_configured_fallback_chain( if not isinstance(entry, dict): continue fb_provider = str(entry.get("provider", "")).strip() - if not fb_provider or fb_provider.lower() == skip: + if not fb_provider: continue - fb_model = str(entry.get("model", "")).strip() or None + fb_model_raw = str(entry.get("model", "")).strip() + if should_skip_candidate( + BackendIdentity.build( + provider=fb_provider, + model=fb_model_raw, + base_url=str(entry.get("base_url") or ""), + ), + failed_ident, + failure_scope, + ): + continue + fb_model = fb_model_raw or None label = f"fallback_chain[{i}]({fb_provider})" @@ -4713,6 +4898,10 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} elif base_url_host_matches(sync_base_url, "integrate.api.nvidia.com"): async_kwargs["default_headers"] = build_nvidia_nim_headers(sync_base_url) + elif base_url_host_matches(sync_base_url, "x.ai"): + from tools.xai_http import hermes_xai_default_headers + + async_kwargs["default_headers"] = hermes_xai_default_headers() else: # Fall back to profile.default_headers for providers that declare # client-level headers on their ProviderProfile (e.g. attribution @@ -4949,10 +5138,11 @@ def resolve_provider_client( # ── Nous Portal (OAuth) ────────────────────────────────────────── if provider == "nous": - # Detect vision tasks: either explicit model override from - # _PROVIDER_VISION_MODELS, or caller passed a known vision model. + # Detect vision tasks: caller flag (strict vision backend), explicit + # model override from _PROVIDER_VISION_MODELS, or a known vision id. _is_vision = ( - model in _PROVIDER_VISION_MODELS.values() + is_vision + or model in _PROVIDER_VISION_MODELS.values() or (model or "").strip().lower() == "mimo-v2-omni" ) client, default = _try_nous(vision=_is_vision) @@ -4961,6 +5151,17 @@ def resolve_provider_client( "but Nous Portal not configured (run: hermes auth)") return None, None final_model = _normalize_resolved_model(model or default, provider) + # Dual-wire: anthropic/* → /v1/messages, everything else stays on + # /chat/completions. Derive from the catalog id (not a stale + # api_mode=chat_completions) so aux matches the main agent. + from hermes_cli.providers import nous_api_mode + + portal_mode = nous_api_mode(final_model) + api_key_str = str(getattr(client, "api_key", "") or "") + base_url_str = str(getattr(client, "base_url", "") or "") + client = _maybe_wrap_anthropic( + client, final_model, api_key_str, base_url_str, portal_mode, + ) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) @@ -5324,6 +5525,10 @@ def resolve_provider_client( )) elif base_url_host_matches(base_url, "integrate.api.nvidia.com"): headers.update(build_nvidia_nim_headers(base_url)) + elif base_url_host_matches(base_url, "x.ai"): + from tools.xai_http import hermes_xai_default_headers + + headers.update(hermes_xai_default_headers()) else: # Fall back to profile.default_headers for providers that declare # client-level attribution headers on their profile (e.g. GMI @@ -5617,7 +5822,10 @@ def _resolve_strict_vision_backend( if provider == "openrouter": return _try_openrouter(model=model) if provider == "nous": - return _try_nous(vision=True) + # Must go through resolve_provider_client so anthropic/* vision + # recommendations wrap onto /v1/messages — _try_nous alone returns + # a bare OpenAI client and the call 404s. + return resolve_provider_client("nous", model, is_vision=True) if provider == "openai-codex": # Route through resolve_provider_client so the caller's explicit # model is used. There is no safe default Codex model (shifting @@ -6878,8 +7086,14 @@ def _build_call_kwargs( _is_gemini_native = is_native_gemini_base_url(_effective_base) except Exception: pass + _nous_on_messages = False + if _provider_norm in {"nous", "nous-portal", "nousresearch"}: + from hermes_cli.providers import nous_api_mode + + _nous_on_messages = nous_api_mode(model) == "anthropic_messages" if ( _is_anthropic_compat_endpoint(provider, _effective_base) + or _nous_on_messages or _is_nvidia_nim or _is_moa or _is_gemini_native @@ -6974,21 +7188,43 @@ def _build_call_kwargs( else: effort = reasoning_config.get("effort") or "medium" merged_extra["reasoning"] = {"enabled": True, "effort": effort} - if provider == "nous" and "tags" not in merged_extra: - merged_extra["tags"] = _nous_portal_tags() + # Portal product tags + sticky session_id. The provider profile usually + # supplies both; this fallback covers profile-load failures and alias + # spellings the profile lookup might miss. session_id keeps aux + # compression/title/vision calls on the same upstream instance as the + # main turn (cache warmth) — tags alone are not enough on /v1/messages. + _provider_for_portal = str(provider or "").strip().lower() + if _provider_for_portal in {"nous", "nous-portal", "nousresearch"}: + if "tags" not in merged_extra: + merged_extra["tags"] = _nous_portal_tags() + if "session_id" not in merged_extra: + try: + from agent.portal_tags import get_conversation_context + + sticky_key = get_conversation_context() + except Exception: + sticky_key = None + if sticky_key: + merged_extra["session_id"] = sticky_key if merged_extra: kwargs["extra_body"] = merged_extra - # Native Anthropic Messages adapters do not consume ``extra_body``. Carry - # the normalized Hermes reasoning config through a private kwarg so the - # adapter can pass it into build_anthropic_kwargs(), where provider-aware - # thinking/output_config projection lives. Do not expose this private kwarg - # to ordinary OpenAI-compatible SDK clients, which would reject it. + # Anthropic Messages adapters translate Hermes reasoning into native + # ``thinking`` via a private kwarg (and strip OpenAI-shaped + # ``extra_body.reasoning``). Do not expose this private kwarg to ordinary + # OpenAI-compatible SDK clients, which would reject it. Portal Claude is + # dual-wire — include it when the catalog id selects /v1/messages. if reasoning_config and isinstance(reasoning_config, dict): provider_norm = str(provider or "").strip().lower() effective_base = base_url or "" + _nous_on_messages = False + if provider_norm in {"nous", "nous-portal", "nousresearch"}: + from hermes_cli.providers import nous_api_mode + + _nous_on_messages = nous_api_mode(model) == "anthropic_messages" if ( provider_norm == "anthropic" + or _nous_on_messages or _endpoint_speaks_anthropic_messages(effective_base) or _is_anthropic_compat_endpoint(provider_norm, effective_base) ): @@ -7104,6 +7340,346 @@ def _obj_get(obj: Any, key: str, default: Any = None) -> Any: return value +# ── Streamed aggregation for progress-hooked auxiliary calls ───────────── +# When a forward-progress hook is installed (aux_progress_hook — today only +# by context compression), the primary chat.completions attempt is upgraded +# to a streamed request that is aggregated back into a complete response. +# Two effects, both deliberate: +# 1. The configured ``timeout`` becomes an INTER-CHUNK idle timeout instead +# of a total budget (httpx applies the read timeout per stream read), so +# a slow-but-generating summary model is never killed mid-generation +# while tokens are moving — only a genuinely silent connection dies. +# 2. Every arriving chunk ticks the progress hook, letting outer watchdogs +# (gateway session hygiene) extend their deadlines on liveness instead +# of guessing with a fixed wall clock. +# A total ceiling still bounds the pathological 1-token-per-idle-window +# stream; see _aux_stream_total_ceiling(). + +_AUX_STREAM_CEILING_FLOOR_SECONDS = 600.0 +_AUX_STREAM_CEILING_MULTIPLIER = 4.0 + + +def _aux_stream_total_ceiling(effective_timeout: Optional[float]) -> float: + """Absolute wall-clock bound for a progress-hooked streamed aux call. + + Generous by design — the idle timeout is the real guard; this only stops + a degenerate stream that trickles one token per idle window forever. + """ + try: + timeout = float(effective_timeout) if effective_timeout is not None else 0.0 + except (TypeError, ValueError): + timeout = 0.0 + return max(_AUX_STREAM_CEILING_FLOOR_SECONDS, + _AUX_STREAM_CEILING_MULTIPLIER * timeout) + + +def _client_streams_internally(client: Any) -> bool: + """Wire adapters that consume a stream inside .create() already tick the + progress hook themselves (Codex per SSE event, Anthropic per stream + event); Bedrock's Converse shim cannot stream at all. None of them + accept chat-completions ``stream=True`` semantics from us.""" + return isinstance(client, ( + CodexAuxiliaryClient, + AnthropicAuxiliaryClient, + BedrockAuxiliaryClient, + )) + + +def _is_streaming_rejected_error(exc: Exception) -> bool: + """Provider explicitly refused a streamed chat.completions request.""" + err = str(exc).lower() + if "stream_options" in err: + return True + return "stream" in err and ( + "not supported" in err + or "unsupported" in err + or "not allowed" in err + or "disabled" in err + ) + + +def _provider_requires_stream(provider: str, base_url: Optional[str]) -> bool: + """Detect providers that only accept streaming (non-stream = HTTP 400). + + Some OpenAI-compatible endpoints reject non-streaming chat requests + outright — e.g. Tencent Copilot returns + ``{"code": 11101, "msg": "Non-stream chat request is currently not + supported"}``. The main conversation loop already streams, so interactive + chat works; auxiliary tasks (title generation, compression, web extract) + used the non-streaming path and failed on every call. When this returns + True the auxiliary client sends ``stream=True`` and aggregates the chunks + itself (see :func:`_aggregate_chat_stream`). Credit @kudi88 (PR #60686). + + Beyond the known-host list, users can mark ANY custom endpoint as + stream-only via ``auxiliary.stream_only_base_urls`` in config.yaml + (list of substrings matched against the endpoint URL). + """ + _url = str(base_url or "").lower() + if not _url: + return False + # Tencent Copilot — "Non-stream chat request is currently not supported" + if base_url_host_matches(_url, "copilot.tencent.com"): + return True + try: + from hermes_cli.config import load_config + aux_cfg = (load_config() or {}).get("auxiliary", {}) + markers = aux_cfg.get("stream_only_base_urls") or [] + if isinstance(markers, (list, tuple)): + for marker in markers: + if isinstance(marker, str) and marker.strip() and marker.strip().lower() in _url: + return True + except Exception: + # Config read is best-effort; never break an aux call over it. + pass + return False + + +def _create_with_progress( + client: Any, + kwargs: Dict[str, Any], + task: Optional[str] = None, + *, + force_stream: bool = False, +) -> Any: + """chat.completions.create() that streams when a progress hook is active + or the provider only accepts streamed requests. + + Behavior is byte-for-byte identical to a plain ``create(**kwargs)`` when + neither trigger applies (every existing caller/task) or when the client's + wire adapter streams internally. With a hook + a chunk-capable client, + the request is sent with ``stream=True`` and aggregated, ticking the hook + per chunk — so the configured ``timeout`` acts per stream read (idle) + rather than as a total budget, and outer liveness watchdogs see tokens + moving. ``force_stream=True`` (stream-only providers such as Tencent + Copilot — credit @kudi88, PR #60686) takes the same streamed path even + without a hook. Providers that reject the streamed request fall back to + the plain non-streaming call — except under ``force_stream``, where a + stream-only provider rejects the plain call by definition, so the + original error is surfaced to the normal recovery chains instead. + """ + _notify_aux_progress() # request dispatched counts as progress + if (not _aux_progress_active() and not force_stream) or _client_streams_internally(client): + return client.chat.completions.create(**kwargs) + + total_ceiling = _aux_stream_total_ceiling(kwargs.get("timeout")) + stream_kwargs = dict(kwargs) + stream_kwargs["stream"] = True + stream_kwargs["stream_options"] = {"include_usage": True} + try: + chunks = client.chat.completions.create(**stream_kwargs) + except Exception as exc: + # Genuine provider failures (auth, credit, rate limit, network) are + # not streaming's fault — surface them unchanged so the existing + # recovery chains (credential refresh, pool rotation, provider + # fallback) see the same error they would on a plain call. + if ( + force_stream + or _is_transient_transport_error(exc) + or _is_auth_error(exc) + or _is_payment_error(exc) + or _is_rate_limit_error(exc) + ): + raise + # Anything else may be a streaming-specific rejection (explicit + # "stream not supported", stream_options 400, or an idiosyncratic + # 4xx). Retry non-streaming once; if the request itself is bad the + # plain call reproduces the real error for the normal except-chains. + logger.debug( + "Auxiliary %s: streamed request failed (%s); retrying " + "non-streaming", task or "call", exc, + ) + return client.chat.completions.create(**kwargs) + + # Some shims (MoA virtual provider under quiet mode, defensive adapters) + # return a complete response even when stream=True was requested. + if hasattr(chunks, "choices"): + _notify_aux_progress() + return chunks + return _aggregate_chat_stream( + chunks, model=str(kwargs.get("model") or ""), total_ceiling=total_ceiling, + ) + + +def _aggregate_chat_stream( + chunks: Any, + *, + model: str = "", + total_ceiling: Optional[float] = None, +) -> Any: + """Consume a chat.completions chunk stream into a complete response. + + Ticks the thread-local aux progress hook on every chunk. Raises + TimeoutError when *total_ceiling* seconds elapse before the stream + finishes — phrased with "timed out" so existing timeout classification + (``_is_timeout_error``) treats it exactly like a request timeout. + Accumulation is shared with the async mirror via + :class:`_ChatStreamAccumulator`. + """ + acc = _ChatStreamAccumulator(model=model, total_ceiling=total_ceiling) + try: + for chunk in chunks: + acc.feed(chunk) + finally: + close_fn = getattr(chunks, "close", None) + if callable(close_fn): + try: + close_fn() + except Exception: + pass + return acc.finish() + + +class _ChatStreamAccumulator: + """Shared per-chunk accumulation for sync and async stream aggregation. + + Mirrors :func:`_aggregate_chat_stream`'s chunk handling so the async + consumer below cannot drift from the sync one (same content/reasoning/ + tool-call delta reassembly, same "timed out" ceiling phrasing). + """ + + def __init__(self, model: str = "", total_ceiling: Optional[float] = None): + self._started = time.monotonic() + self._total_ceiling = total_ceiling + self.content_parts: List[str] = [] + self.reasoning_parts: List[str] = [] + self.tool_calls_acc: Dict[int, Dict[str, Any]] = {} + self.finish_reason = None + self.usage = None + self.resp_id = "" + self.resp_model = model or "" + + def feed(self, chunk: Any) -> None: + _notify_aux_progress() + if ( + self._total_ceiling is not None + and (time.monotonic() - self._started) >= self._total_ceiling + ): + raise TimeoutError( + f"Auxiliary streamed call timed out after {self._total_ceiling:.0f}s " + "total ceiling (stream still open but over budget)" + ) + self.resp_id = getattr(chunk, "id", None) or self.resp_id + self.resp_model = getattr(chunk, "model", None) or self.resp_model + chunk_usage = getattr(chunk, "usage", None) + if chunk_usage: + self.usage = chunk_usage + choices = getattr(chunk, "choices", None) or [] + if not choices: + return + choice = choices[0] + self.finish_reason = getattr(choice, "finish_reason", None) or self.finish_reason + delta = getattr(choice, "delta", None) + if delta is None: + return + piece = getattr(delta, "content", None) + if piece: + self.content_parts.append(piece) + reasoning_piece = ( + getattr(delta, "reasoning", None) + or getattr(delta, "reasoning_content", None) + ) + if reasoning_piece and isinstance(reasoning_piece, str): + self.reasoning_parts.append(reasoning_piece) + for tc in (getattr(delta, "tool_calls", None) or []): + idx = getattr(tc, "index", 0) or 0 + acc = self.tool_calls_acc.setdefault( + idx, {"id": "", "name": "", "arguments": []} + ) + if getattr(tc, "id", None): + acc["id"] = tc.id + fn = getattr(tc, "function", None) + if fn is not None: + if getattr(fn, "name", None): + acc["name"] = fn.name + if getattr(fn, "arguments", None): + acc["arguments"].append(fn.arguments) + + def finish(self) -> Any: + tool_calls = None + if self.tool_calls_acc: + tool_calls = [ + SimpleNamespace( + id=acc["id"], + type="function", + function=SimpleNamespace( + name=acc["name"], + arguments="".join(acc["arguments"]), + ), + ) + for _idx, acc in sorted(self.tool_calls_acc.items()) + ] + message = SimpleNamespace( + role="assistant", + content="".join(self.content_parts), + tool_calls=tool_calls, + reasoning="".join(self.reasoning_parts) or None, + ) + choice = SimpleNamespace( + index=0, + message=message, + finish_reason=self.finish_reason or "stop", + ) + return SimpleNamespace( + id=self.resp_id, + model=self.resp_model, + object="chat.completion", + choices=[choice], + usage=self.usage, + ) + + +async def _aggregate_chat_stream_async( + chunks: Any, + *, + model: str = "", + total_ceiling: Optional[float] = None, +) -> Any: + """Async mirror of :func:`_aggregate_chat_stream` (``async for`` consumer). + + The AsyncOpenAI stream contract is an async iterator — consuming it with + the sync helper raises. Same accumulation and ceiling semantics via + :class:`_ChatStreamAccumulator`. + """ + acc = _ChatStreamAccumulator(model=model, total_ceiling=total_ceiling) + try: + async for chunk in chunks: + acc.feed(chunk) + finally: + close_fn = getattr(chunks, "close", None) or getattr(chunks, "aclose", None) + if callable(close_fn): + try: + result = close_fn() + if inspect.isawaitable(result): + await result + except Exception: + pass + return acc.finish() + + +async def _acreate_with_stream( + client: Any, + kwargs: Dict[str, Any], + task: Optional[str] = None, +) -> Any: + """Async chat.completions.create() for stream-only providers. + + Sends ``stream=True`` and aggregates the async chunk stream into a + complete response (credit @kudi88, PR #60686 — async contract fixed to + ``async for`` and tool-call deltas preserved per sweeper review). + """ + total_ceiling = _aux_stream_total_ceiling(kwargs.get("timeout")) + stream_kwargs = dict(kwargs) + stream_kwargs["stream"] = True + stream_kwargs["stream_options"] = {"include_usage": True} + chunks = await client.chat.completions.create(**stream_kwargs) + # Defensive: shims may hand back a complete response despite stream=True. + if hasattr(chunks, "choices"): + return chunks + return await _aggregate_chat_stream_async( + chunks, model=str(kwargs.get("model") or ""), total_ceiling=total_ceiling, + ) + + def call_llm( task: str = None, *, @@ -7304,7 +7880,13 @@ def call_llm( # for the transient retry every auxiliary task shares. (PR #16587) try: return _validate_llm_response( - client.chat.completions.create(**kwargs), task, + _create_with_progress( + client, kwargs, task, + force_stream=_provider_requires_stream( + resolved_provider, _base_info or resolved_base_url, + ), + ), + task, provider=resolved_provider, base_url=_base_info) except Exception as transient_err: if not _is_transient_transport_error(transient_err): @@ -7337,7 +7919,13 @@ def call_llm( time.sleep(_backoff) try: return _validate_llm_response( - client.chat.completions.create(**kwargs), task) + _create_with_progress( + client, kwargs, task, + force_stream=_provider_requires_stream( + resolved_provider, _base_info or resolved_base_url, + ), + ), + task) except Exception as retry_transient: if not _is_transient_transport_error(retry_transient): raise @@ -7654,6 +8242,15 @@ def call_llm( logger.info("Auxiliary %s: %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) + # Narrow the configured-chain skip to the exact model that + # failed ONLY for model-specific failures. Auth (401) and + # payment (402) errors are provider-wide — the credentials or + # account behind every model on that provider are the same — so + # a sibling model can't recover; keep skipping the whole + # provider so the main-agent-model safety net is still reached. + _chain_failed_model = ( + None if reason in ("auth error", "payment error") else final_model + ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -7662,7 +8259,8 @@ def call_llm( fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -7671,10 +8269,12 @@ def call_llm( resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason) + resolved_provider, task, reason=reason, + failed_model=_chain_failed_model) if fb_client is not None: fb_resp = _call_fallback_candidate_sync( @@ -7890,9 +8490,25 @@ async def async_call_llm( # Retry ONCE on the same provider for a transient transport blip # before the except-chain escalates to fallback — see call_llm() # for the rationale. (PR #16587) + _force_stream_async = ( + _provider_requires_stream( + resolved_provider, _client_base or resolved_base_url, + ) + and not isinstance(client, ( + AsyncCodexAuxiliaryClient, + AsyncAnthropicAuxiliaryClient, + AsyncBedrockAuxiliaryClient, + )) + ) + + async def _acreate(_kwargs: Dict[str, Any]) -> Any: + if _force_stream_async: + return await _acreate_with_stream(client, _kwargs, task) + return await client.chat.completions.create(**_kwargs) + try: return _validate_llm_response( - await client.chat.completions.create(**kwargs), task, + await _acreate(kwargs), task, provider=resolved_provider, base_url=_client_base) except Exception as transient_err: if not _is_transient_transport_error(transient_err): @@ -7913,7 +8529,7 @@ async def async_call_llm( task or "call", transient_err, ) return _validate_llm_response( - await client.chat.completions.create(**kwargs), task) + await _acreate(kwargs), task) except Exception as first_err: if "temperature" in kwargs and _is_unsupported_temperature_error(first_err): retry_kwargs = dict(kwargs) @@ -8172,6 +8788,15 @@ async def async_call_llm( logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) + # Narrow the configured-chain skip to the exact model that + # failed ONLY for model-specific failures. Auth (401) and + # payment (402) errors are provider-wide — the credentials or + # account behind every model on that provider are the same — so + # a sibling model can't recover; keep skipping the whole + # provider so the main-agent-model safety net is still reached. + _chain_failed_model = ( + None if reason in ("auth error", "payment error") else final_model + ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -8180,7 +8805,8 @@ async def async_call_llm( fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -8189,10 +8815,12 @@ async def async_call_llm( resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason) + resolved_provider, task, reason=reason, + failed_model=_chain_failed_model) if fb_client is not None: # Convert sync fallback client to async diff --git a/agent/backend_identity.py b/agent/backend_identity.py new file mode 100644 index 00000000000..7a7e9efb6bf --- /dev/null +++ b/agent/backend_identity.py @@ -0,0 +1,204 @@ +"""Single owner for backend identity and failure-scoped skip decisions. + +Every fallback / dedup / skip / quarantine decision in Hermes ultimately asks +one question: **"is this candidate the same backend as the one that failed, +along the axis that failure invalidated?"** Before this module, that +question was re-implemented inline at six call sites across four subsystems, +each comparing whatever string was locally convenient (provider label, +provider+model, base_url+model, ...). Each incident fixed one site while the +others kept the bug: #22548 (same-shim aliases), #70893 (xai-oauth vs xai — +same host, distinct credential), #59561 (aux chain skipped sibling models), +#72468 (aux main-model safety net, same bug three weeks later), #62984 / +#54250 / #57584 (dedup ignoring base_url strands multi-endpoint pools). + +The root insight: "provider" conflates three independent identity axes, and +each failure class invalidates a different one: + +* **credential surface** — auth 401 / payment 402 kill everything sharing the + credential (every model, every host reached with that key/token). +* **endpoint** — DNS failure / connection refused kill everything behind the + URL, regardless of model or credential. +* **model deployment** — timeout / overload / rate limit / model-incompatible + kill ONE model's deployment. A sibling model behind the same URL is an + independent deployment (real incident: aux ``glm-5.2`` hung and timed out + while main ``macaron-v1-venti`` on the identical endpoint was serving + 448K-token turns). + +Call sites should build :class:`BackendIdentity` values, classify the failure +with :func:`classify_failure_scope`, and ask :func:`should_skip_candidate`. +Do not re-implement any comparison inline — extend THIS module instead. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +logger = logging.getLogger(__name__) + + +class FailureScope(Enum): + """Which identity axis a failure invalidates.""" + + #: Timeout, overload/429, connection blip, model-incompatible, invalid + #: response: evidence against ONE model deployment only. + MODEL = "model" + #: Auth 401 / payment 402: evidence against the shared credential — + #: every model reached with it is equally dead. + CREDENTIAL = "credential" + #: DNS / connection-refused / unreachable host: evidence against the + #: endpoint — every model behind the URL is equally dead. + ENDPOINT = "endpoint" + + +#: Reason strings already used by auxiliary_client's except-chain, mapped to +#: scopes. Unknown reasons default to MODEL — the least-invalidating scope — +#: so an unrecognized failure never over-skips viable candidates. +_REASON_SCOPES = { + "auth error": FailureScope.CREDENTIAL, + "payment error": FailureScope.CREDENTIAL, + "rate limit": FailureScope.MODEL, + "model incompatible with route": FailureScope.MODEL, + "invalid provider response": FailureScope.MODEL, + "connection error": FailureScope.MODEL, + "timeout": FailureScope.MODEL, +} + + +def classify_failure_scope(reason: Optional[str]) -> FailureScope: + """Map a human-readable failure reason to the identity axis it kills.""" + return _REASON_SCOPES.get((reason or "").strip().lower(), FailureScope.MODEL) + + +def _norm_provider(value: Optional[str]) -> str: + return (value or "").strip().lower() + + +def _norm_model(value: Optional[str]) -> str: + return (value or "").strip().lower() + + +def _norm_base_url(value: Optional[str]) -> str: + return (value or "").strip().rstrip("/").lower() + + +@dataclass(frozen=True) +class BackendIdentity: + """Normalized identity of one (provider, model, endpoint) deployment. + + Empty fields mean "unknown" — comparisons treat an unknown axis as + non-distinguishing (it can neither prove sameness nor difference on its + own; the remaining axes decide). + """ + + provider: str = "" + model: str = "" + base_url: str = "" + + @classmethod + def build( + cls, + provider: Optional[str] = None, + model: Optional[str] = None, + base_url: Optional[str] = None, + ) -> "BackendIdentity": + return cls( + provider=_norm_provider(provider), + model=_norm_model(model), + base_url=_norm_base_url(base_url), + ) + + +def _both_first_class(a: BackendIdentity, b: BackendIdentity) -> bool: + """True when both providers are distinct registered first-class providers. + + Two different registry providers have distinct credential surfaces even + when they share an inference host (xai-oauth vs xai, openai-codex vs + openai-api) — #70893. Custom/shim aliases are NOT in the registry, so + two aliases pointing at one URL still count as the same backend (#22548). + """ + if not a.provider or not b.provider or a.provider == b.provider: + return False + try: + from hermes_cli.auth import PROVIDER_REGISTRY + + return a.provider in PROVIDER_REGISTRY and b.provider in PROVIDER_REGISTRY + except Exception: + return False + + +def same_credential_surface(a: BackendIdentity, b: BackendIdentity) -> bool: + """Do two identities share the credential a 401/402 just invalidated? + + Conservative on purpose: an unprovable axis must answer "different" + (try the candidate — worst case one wasted RTT) rather than "same" + (skip — worst case stranded failover). Two distinct custom labels at + one URL may carry different per-entry api_keys, so a shared URL alone + never proves a shared credential; it is only used as a weak signal + when a provider label is missing entirely. + """ + if a.provider and b.provider: + # Same label = same configured credential. Different labels = + # different credential config (first-class registry providers + # explicitly so — #70893; custom entries can each carry their own + # api_key, so sameness is unprovable and we must not skip). + return a.provider == b.provider + # Provider unknown on a side: same explicit URL is the best signal left. + return bool(a.base_url and a.base_url == b.base_url) + + +def same_endpoint(a: BackendIdentity, b: BackendIdentity) -> bool: + """Do two identities sit behind the endpoint that just went unreachable?""" + if a.base_url and b.base_url: + return a.base_url == b.base_url + # An unknown base_url inherits the provider default → same provider + # label implies the same default endpoint. + return bool(a.provider and a.provider == b.provider) + + +def same_deployment(a: BackendIdentity, b: BackendIdentity) -> bool: + """Are these the exact same model deployment (the thing a timeout kills)? + + Provider+model must match; the base_url axis distinguishes only when BOTH + sides carry an explicit URL (#62984: same provider+model on two different + explicit URLs is two deployments — a pool). A side with an unknown URL + inherits the provider default and cannot prove difference. + """ + if not (a.provider and b.provider and a.provider == b.provider): + # Same-host different-label shims: same URL + same model IS the same + # deployment even when the alias labels differ (#22548) — unless both + # labels are first-class registry providers (#70893). + if ( + a.base_url + and a.base_url == b.base_url + and a.model + and a.model == b.model + and not _both_first_class(a, b) + ): + return True + return False + if not (a.model and b.model and a.model == b.model): + return False + if a.base_url and b.base_url and a.base_url != b.base_url: + return False # distinct explicit endpoints — a pool, not a dup + return True + + +def should_skip_candidate( + candidate: BackendIdentity, + failed: BackendIdentity, + scope: FailureScope = FailureScope.MODEL, +) -> bool: + """THE skip predicate: would trying ``candidate`` just repeat the failure? + + True when the candidate is the same backend as ``failed`` along the axis + ``scope`` says the failure invalidated. Every fallback/dedup/skip site + must call this instead of comparing labels inline. + """ + if scope is FailureScope.CREDENTIAL: + return same_credential_surface(candidate, failed) + if scope is FailureScope.ENDPOINT: + return same_endpoint(candidate, failed) + return same_deployment(candidate, failed) diff --git a/agent/background_review.py b/agent/background_review.py index c2ea87bd94e..a0dbd4a99e2 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -209,7 +209,10 @@ _SKILL_REVIEW_PROMPT = ( "conversation for skills the user loaded via /skill-name or you " "read via skill_view. If any of them covers the territory of the " "new learning, PATCH that one first. It is the skill that was in " - "play, so it's the right one to extend.\n" + "play, so it's the right one to extend — but only if it is " + "curator-managed. Bundled, hub, pinned, and user-owned skills are " + "off-limits to you no matter how relevant (see Protected skills " + "below); for those, fall through to the next option.\n" " 2. UPDATE AN EXISTING UMBRELLA (via skills_list + skill_view). " "If no loaded skill fits but an existing class-level skill does, " "patch it. Add a subsection, a pitfall, or broaden a trigger.\n" @@ -251,10 +254,18 @@ _SKILL_REVIEW_PROMPT = ( "Protected skills (DO NOT edit these):\n" " • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n" " • Hub-installed skills (installed via 'hermes skills install').\n" - "Pinned skills (marked via 'hermes curator pin') CAN be improved — " - "pin only blocks deletion/archive/consolidation by the curator, not " - "content updates. Patch them when a pitfall or missing step turns up, " - "same as any other agent-created skill.\n" + " • Skills in skills.external_dirs (externally owned).\n" + " • PINNED skills (marked via 'hermes curator pin'). You are an " + "autonomous no-user-present actor, so pin blocks your writes too — " + "content updates included. Only the user, in a foreground session, " + "can change a pinned skill.\n" + " • USER-OWNED skills — anything not curator-managed. A skill the " + "user hand-wrote, installed by URL, or asked a foreground agent to " + "create is theirs, not yours; your writes to it WILL be refused. " + "This includes skills that were loaded or consulted this session: " + "being in play does not make one yours to edit. If such a skill is " + "wrong or outdated, say so in your reply and recommend " + "'hermes curator adopt ' — do not try to patch it.\n" "If the only skills that need updating are protected, say\n" "'Nothing to save.' and stop.\n\n" "Do NOT capture (these become persistent self-imposed constraints " @@ -309,7 +320,9 @@ _COMBINED_REVIEW_PROMPT = ( " 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were " "loaded via /skill-name or skill_view in the conversation. If one " "of them covers the learning, PATCH it first. It was in play; " - "it's the right place.\n" + "it's the right place — provided it is curator-managed. Protected " + "and user-owned skills are off-limits however relevant; fall " + "through when one of those is the best fit.\n" " 2. UPDATE AN EXISTING UMBRELLA (skills_list + skill_view to " "find the right one). Patch it.\n" " 3. ADD A SUPPORT FILE under an existing umbrella via " @@ -337,10 +350,15 @@ _COMBINED_REVIEW_PROMPT = ( "Protected skills (DO NOT edit these):\n" " • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n" " • Hub-installed skills (installed via 'hermes skills install').\n" - "Pinned skills (marked via 'hermes curator pin') CAN be improved — " - "pin only blocks deletion/archive/consolidation by the curator, not " - "content updates. Patch them when a pitfall or missing step turns up, " - "same as any other agent-created skill.\n" + " • Skills in skills.external_dirs (externally owned).\n" + " • PINNED skills (marked via 'hermes curator pin'). Pin blocks " + "autonomous writes entirely — content updates included — because no " + "user is present to consent. Only a foreground session can change one.\n" + " • USER-OWNED skills — anything not curator-managed (hand-written, " + "URL-installed, or created by a foreground agent at the user's " + "request). Your writes to these WILL be refused, including to skills " + "loaded or consulted this session. If one is wrong, say so in your " + "reply and recommend 'hermes curator adopt ' instead.\n" "If the only skills that need updating are protected, say\n" "'Nothing to save.' and stop.\n\n" "Do NOT capture as skills (these become persistent self-imposed " diff --git a/agent/billing_view.py b/agent/billing_view.py index a535aee1f14..29e8068c227 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -107,6 +107,22 @@ class CardInfo: return f"{self.masked} — {label}" if label else self.masked +@dataclass(frozen=True) +class PaymentMethodInfo: + """The payment method on file. `kind` is "card", "link", or "unknown" + — anything else is normalised to "unknown" at parse time, so consumers + only ever see fields that belong to the kind they are looking at.""" + + kind: str + brand: Optional[str] = None + last4: Optional[str] = None + wallet: Optional[str] = None + email: Optional[str] = None + resolved_via: Optional[str] = None + #: What the server called it, when we did not recognise the kind. + raw_kind: Optional[str] = None + + @dataclass(frozen=True) class MonthlyCap: limit_usd: Optional[Decimal] = None @@ -150,6 +166,7 @@ class BillingState: min_usd: Optional[Decimal] = None max_usd: Optional[Decimal] = None card: Optional[CardInfo] = None + payment_method: Optional[PaymentMethodInfo] = None monthly_cap: Optional[MonthlyCap] = None auto_reload: Optional[AutoReload] = None portal_url: Optional[str] = None @@ -201,6 +218,41 @@ def _parse_card(raw: Any) -> Optional[CardInfo]: return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via) +def _parse_payment_method(raw: Any) -> Optional[PaymentMethodInfo]: + if not isinstance(raw, dict): + return None + kind = raw.get("kind") + if not isinstance(kind, str): + return None + + def _optional_string(key: str) -> Optional[str]: + value = raw.get(key) + return value if isinstance(value, str) else None + + resolved_via = _optional_string("resolvedVia") + brand = _optional_string("brand") + last4 = _optional_string("last4") + # Settle the kind here, the way _parse_card settles a card, so nothing + # downstream has to re-check which fields this kind is allowed to have. + if kind == "card" and brand and last4: + return PaymentMethodInfo( + kind="card", + brand=brand, + last4=last4, + wallet=_optional_string("wallet"), + resolved_via=resolved_via, + ) + if kind == "link": + return PaymentMethodInfo( + kind="link", + email=_optional_string("email"), + resolved_via=resolved_via, + ) + return PaymentMethodInfo( + kind="unknown", raw_kind=kind, resolved_via=resolved_via + ) + + def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]: if not isinstance(raw, dict): return None @@ -274,6 +326,7 @@ def billing_state_from_payload( min_usd=parse_money(bounds.get("minUsd")), max_usd=parse_money(bounds.get("maxUsd")), card=_parse_card(payload.get("card")), + payment_method=_parse_payment_method(payload.get("paymentMethod")), monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")), auto_reload=_parse_auto_reload(payload.get("autoReload")), portal_url=portal_url, diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 70e4ea002a5..5fecd0e26cf 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -188,6 +188,31 @@ def _provider_preferences_for_agent(agent) -> Dict[str, Any]: return preferences +def _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs: dict) -> dict: + """Merge Portal ``tags`` / ``session_id`` onto an Anthropic Messages kwargs dict. + + The Nous provider profile is only consulted by the OpenAI-wire transport; + anthropic_messages callers must merge it themselves. Passes ``session_id`` + only — not ``provider_preferences`` (those become a top-level ``provider`` + routing object on the OpenAI wire). Never blocks a turn on tagging. + """ + if getattr(agent, "provider", None) not in {"nous", "nous-portal", "nousresearch"}: + return anthropic_kwargs + try: + from providers import get_provider_profile + + nous_profile = get_provider_profile("nous") + if nous_profile is not None: + anthropic_kwargs.setdefault("extra_body", {}).update( + nous_profile.build_extra_body( + session_id=getattr(agent, "session_id", None) + ) + ) + except Exception as exc: # noqa: BLE001 — never block a turn on tagging + logger.debug("Nous Portal extra_body merge failed: %s", exc) + return anthropic_kwargs + + def _env_float(name: str, default: float) -> float: try: return float(os.getenv(name, str(default))) @@ -433,26 +458,56 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client): def should_use_direct_api_call(agent) -> bool: - """Whether a cron OpenAI-wire request should skip the interrupt worker. + """Whether an 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. + Two nested-pool contexts wedge before the socket opens when the request + is pushed onto yet another daemon worker thread: + + - Gateway cron turns (#62151): gateway asyncio loop → cron thread → + interrupt worker. Fixed by running inline. + - Delegated children (#60203): gateway loop → async-delegation executor + (module-lifetime daemon pool) → per-child timeout executor → interrupt + worker. Same fingerprint after multi-day gateway uptime — children hang + at their FIRST API call with zero stale-detector output (the worker + never reaches dispatch), all providers, restart cures it. The cron fix + originally excluded delegation "for lack of evidence"; #60203 is that + evidence. + + Running inline drops the deepest thread layer (whose only job is + interactive-interrupt responsiveness). Interrupts still work: the inline + path registers ``agent._active_request_abort``, which ``interrupt()`` + invokes cross-thread to shut the active sockets — the same mechanism the + async-delegation stall monitor (#72227) relies on. + + Keep native/Codex/Bedrock/MoA transports on their established workers: + their cancellation and client ownership differ. """ - return ( - getattr(agent, "platform", None) == "cron" - and getattr(agent, "api_mode", None) == "chat_completions" - and getattr(agent, "provider", None) != "moa" - ) + if getattr(agent, "api_mode", None) != "chat_completions": + return False + if getattr(agent, "provider", None) == "moa": + return False + if getattr(agent, "platform", None) == "cron": + return True + # Delegated child (delegate_task sync or background) — detected via the + # execution ContextVar set by _run_single_child, with the agent's own + # platform stamp as a fallback for callers that bypass the runner. + try: + from agent.delegation_context import is_delegated_child_context + + if is_delegated_child_context(): + return True + except Exception: + pass + return getattr(agent, "platform", None) == "subagent" 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 + Used when ``should_use_direct_api_call`` is True (cron turns and + delegated children). Skips the interrupt worker (whose only job is + interactive-interrupt responsiveness, which these contexts do not have) + so the nested-pool deadlock (#62151, #60203) 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. @@ -463,7 +518,7 @@ def direct_api_call(agent, api_kwargs: dict): request_client_lock = threading.Lock() def _abort_active_request(reason: str) -> None: - """Abort the inline request from cron's watchdog/interrupt thread.""" + """Abort the inline request from a watchdog/interrupt thread.""" with request_client_lock: request_client = request_client_holder["client"] if request_client is not None: @@ -993,7 +1048,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: ephemeral_out = getattr(agent, "_ephemeral_max_output_tokens", None) if ephemeral_out is not None: agent._ephemeral_max_output_tokens = None # consume immediately - return _transport.build_kwargs( + anthropic_kwargs = _transport.build_kwargs( model=agent.model, messages=anthropic_messages, tools=tools_for_api, @@ -1006,6 +1061,12 @@ def build_api_kwargs(agent, api_messages: list) -> dict: fast_mode=(agent.request_overrides or {}).get("speed") == "fast", drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False)), ) + # Nous Portal reads ``tags`` and ``session_id`` as top-level body fields + # on its Messages route the same way it does on /chat/completions, but + # the profile hook that produces them is only consulted by the + # OpenAI-wire transport. Merge them here so Messages traffic keeps + # product attribution and sticky routing. + return _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs) # AWS Bedrock native Converse API — bypasses the OpenAI client entirely. # The adapter handles message/tool conversion and boto3 calls directly. @@ -1076,6 +1137,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: tools=tools_for_api, reasoning_config=agent.reasoning_config, session_id=getattr(agent, "session_id", None), + base_url=agent.base_url, max_tokens=agent.max_tokens, timeout=agent._resolved_api_call_timeout(), request_overrides=agent.request_overrides, @@ -1311,6 +1373,17 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic from agent.redact import redact_sensitive_text _san_content = redact_sensitive_text(_san_content) + # NOTE (empty-content class fix): textless assistant turns are NOT padded + # here. The single owner for "never send a turn strict wire validation + # rejects as empty" is ``repair_empty_non_final_messages`` in + # agent_runtime_helpers, which runs inside ``sanitize_api_messages`` — the + # unconditional pre-send chokepoint for both the main loop and the summary + # path. Padding at write time was tried (a single-space pad, later a + # placeholder) and rejected: it forked the concept across three sites, + # broke codex commentary turns (content:'' is a designed state there), and + # a DB-side pad can't survive ``_rows_to_conversation``'s whitespace strip + # anyway. Repair belongs at the send boundary, once. + msg = { "role": "assistant", "content": _san_content, @@ -1513,45 +1586,6 @@ def _fallback_entry_key(fb: dict) -> tuple[str, str, str]: ) -def _fallback_entry_is_same_backend_by_base_url( - *, - current_provider: str, - fb_provider: str, - current_base_url: str, - fb_base_url: str, - current_model: str, - fb_model: str, -) -> bool: - """True when base_url+model identity means the fallback is the same backend. - - Issue #22548: two ``custom_providers`` aliases that point at the same shim - URL with the same model must be skipped, or failover loops on the dead - backend. First-class providers that share a host while using different - auth (``xai-oauth`` vs ``xai``, ``openai-codex`` vs ``openai-api``) are - distinct credential surfaces — skipping them strands configured failover - when primary and fallback reuse the same model slug on that host. - """ - if not ( - fb_base_url - and current_base_url - and fb_base_url == current_base_url - and fb_model == current_model - ): - return False - if fb_provider == current_provider: - return True - try: - from hermes_cli.auth import PROVIDER_REGISTRY - - # Both sides are registered first-class providers → different auth - # identities even when the inference host matches. Allow failover. - if current_provider in PROVIDER_REGISTRY and fb_provider in PROVIDER_REGISTRY: - return False - except Exception: - pass - return True - - def _fallback_entry_unavailable_without_network(agent, fb: dict) -> Optional[str]: """Return a skip reason for fallback entries known to be unusable locally.""" fb_provider = (fb.get("provider") or "").strip().lower() @@ -1637,33 +1671,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool ) return agent._try_activate_fallback(reason) - # Skip entries that resolve to the current (provider, model) — falling - # back to the same backend that just failed loops the failure. Compare - # base_url too so two distinct custom_providers entries pointing at the - # same shim/proxy URL also dedup. See issue #22548. Do NOT treat - # first-class providers that share a host (xai-oauth vs xai) as the same - # backend — they use different credentials. - current_provider = (getattr(agent, "provider", "") or "").strip().lower() - current_model = (getattr(agent, "model", "") or "").strip() - current_base_url = str(getattr(agent, "base_url", "") or "").rstrip("/").lower() - fb_base_url_for_dedup = (fb.get("base_url") or "").strip().rstrip("/").lower() - if fb_provider == current_provider and fb_model == current_model: + # Skip entries that resolve to the same backend that just failed — + # falling back to it loops the failure. Identity semantics (which axes + # distinguish two backends, shim aliases, first-class credential + # surfaces, multi-endpoint pools) are owned by agent.backend_identity — + # see #22548, #70893, #62984. Do not re-implement comparisons here. + from agent.backend_identity import BackendIdentity, should_skip_candidate + + current_ident = BackendIdentity.build( + provider=getattr(agent, "provider", ""), + model=getattr(agent, "model", ""), + base_url=str(getattr(agent, "base_url", "") or ""), + ) + fb_ident = BackendIdentity.build( + provider=fb_provider, + model=fb_model, + base_url=(fb.get("base_url") or ""), + ) + if should_skip_candidate(fb_ident, current_ident): logger.warning( - "Fallback skip: chain entry %s/%s matches current provider/model", - fb_provider, fb_model, - ) - return agent._try_activate_fallback(reason) - if _fallback_entry_is_same_backend_by_base_url( - current_provider=current_provider, - fb_provider=fb_provider, - current_base_url=current_base_url, - fb_base_url=fb_base_url_for_dedup, - current_model=current_model, - fb_model=fb_model, - ): - logger.warning( - "Fallback skip: chain entry base_url %s matches current backend", - fb_base_url_for_dedup, + "Fallback skip: chain entry %s/%s resolves to the same backend " + "as the current one (%s)", + fb_provider, fb_model, current_ident.base_url or current_ident.provider, ) return agent._try_activate_fallback(reason) @@ -1714,6 +1743,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool _fb_is_azure = agent._is_azure_openai_url(fb_base_url) if fb_provider == "openai-codex": fb_api_mode = "codex_responses" + elif fb_provider in {"nous", "nous-portal", "nousresearch"}: + # Portal is dual-wire: anthropic/* must land on /v1/messages. + # resolve_provider_client still returns an OpenAI client for + # Nous; the anthropic_messages branch below rebuilds the native + # client from that credential + base_url. + from hermes_cli.providers import nous_api_mode + + fb_api_mode = nous_api_mode(fb_model) elif ( fb_provider == "anthropic" or fb_base_url.rstrip("/").lower().endswith("/anthropic") @@ -2140,7 +2177,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: _ant_kw = _tsum.build_kwargs(model=agent.model, messages=api_messages, tools=None, max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, is_oauth=agent._is_anthropic_oauth, - preserve_dots=agent._anthropic_preserve_dots()) + preserve_dots=agent._anthropic_preserve_dots(), + base_url=getattr(agent, "_anthropic_base_url", None)) + _ant_kw = _merge_nous_portal_messages_extra_body(agent, _ant_kw) summary_response = agent._anthropic_messages_create(_ant_kw) _summary_result = _tsum.normalize_response(summary_response, strip_tool_prefix=agent._is_anthropic_oauth) final_response = (_summary_result.content or "").strip() @@ -2170,7 +2209,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: _ant_kw2 = _tretry.build_kwargs(model=agent.model, messages=api_messages, tools=None, is_oauth=agent._is_anthropic_oauth, max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, - preserve_dots=agent._anthropic_preserve_dots()) + preserve_dots=agent._anthropic_preserve_dots(), + base_url=getattr(agent, "_anthropic_base_url", None)) + _ant_kw2 = _merge_nous_portal_messages_extra_body(agent, _ant_kw2) retry_response = agent._anthropic_messages_create(_ant_kw2) _retry_result = _tretry.normalize_response(retry_response, strip_tool_prefix=agent._is_anthropic_oauth) final_response = (_retry_result.content or "").strip() @@ -3491,13 +3532,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # already worker-owned-closed by _close_request_client_once # above; the next attempt builds a fresh one. The shared # _anthropic_client is never closed from inside a request. - if agent.api_mode != "anthropic_messages": - try: - agent._replace_primary_openai_client( - reason="stream_mid_tool_retry_pool_cleanup" - ) - except Exception: - pass + # #70773: same FD-recycle corruption vector for OpenAI. + # The shared client will be replaced lazily by + # _ensure_primary_openai_client on the next attempt. continue # SSE error events from proxies (e.g. OpenRouter sends @@ -3556,13 +3593,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # above; next attempt builds fresh), so the shared # _anthropic_client is never closed from inside a # request — only the OpenAI-wire primary is refreshed. - if agent.api_mode != "anthropic_messages": - try: - agent._replace_primary_openai_client( - reason="stream_retry_pool_cleanup" - ) - except Exception: - pass + # #70773: same FD-recycle corruption vector for OpenAI. + # The shared client will be replaced lazily by + # _ensure_primary_openai_client on the next attempt. continue # Retries exhausted. Log the final failure with # full diagnostic detail (chain, headers, @@ -3805,10 +3838,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # FD-recycle corruption vector. Nothing further is needed. pass else: - try: - agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup") - except Exception: - pass + # #70773: same FD-recycle corruption vector as #67142. + # The shared OpenAI client's connection pool must NOT be + # closed from this watchdog/poll thread — worker threads + # from previous stale-killed attempts may still be + # unwinding their SSL BIOs. The request-local client is + # already closed above via _close_request_client_once. + # The shared client will be replaced lazily by + # _ensure_primary_openai_client on the next request. + pass # Reset the timer so we don't kill repeatedly while # the inner thread processes the closure. last_chunk_time["t"] = time.time() @@ -3891,6 +3929,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= result["error"], ) _stub_finish_reason = FINISH_REASON_LENGTH + # NOTE (empty-content class fix): the stub is deliberately allowed + # to carry empty content here. The conversation loop's truncation + # path detects an EMPTY partial-stream stub (PARTIAL_STREAM_STUB_ID + # + no content) and skips appending it to history entirely — only + # the continuation nudge is sent. Substituting placeholder text at + # this site was tried and reverted: it defeats that guard (the stub + # no longer looks empty), gets appended to history, and the + # placeholder leaks into the stitched final response via + # truncated_response_parts. Transcripts that already carry a + # persisted empty turn are healed at the send boundary by + # ``repair_empty_non_final_messages`` (the single owner). _stub_msg = SimpleNamespace( role="assistant", content=_partial_text, tool_calls=None, reasoning_content=None, diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index bce372ebb5d..ee75f4190e6 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -912,7 +912,8 @@ def _preflight_codex_api_kwargs( allowed_keys = { "model", "instructions", "input", "tools", "store", "reasoning", "include", "max_output_tokens", "temperature", - "tool_choice", "parallel_tool_calls", "prompt_cache_key", "service_tier", + "tool_choice", "parallel_tool_calls", "prompt_cache_key", + "prompt_cache_retention", "service_tier", "extra_headers", "extra_body", "timeout", } normalized: Dict[str, Any] = { @@ -950,8 +951,13 @@ def _preflight_codex_api_kwargs( if isinstance(temperature, (int, float)): normalized["temperature"] = float(temperature) - # Pass through tool_choice, parallel_tool_calls, prompt_cache_key - for passthrough_key in ("tool_choice", "parallel_tool_calls", "prompt_cache_key"): + # Pass through cache routing/retention and tool-dispatch hints. + for passthrough_key in ( + "tool_choice", + "parallel_tool_calls", + "prompt_cache_key", + "prompt_cache_retention", + ): val = api_kwargs.get(passthrough_key) if val is not None: normalized[passthrough_key] = val diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 59f9bac25a2..da3bc4f9569 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -778,12 +778,27 @@ def run_codex_app_server_turn( # the already-flushed user turn). See gateway/run.py agent_persisted. if getattr(agent, "_session_db", None) is not None: try: - agent._flush_messages_to_session_db(messages) + _codex_flush_ok = agent._flush_messages_to_session_db(messages) except Exception: - logger.debug( + _codex_flush_ok = False + logger.warning( "codex app-server projected-message flush failed", exc_info=True, ) + if _codex_flush_ok is False: + # Unlike the chat-completions loop (which fails closed BEFORE + # projection — see conversation_loop session_persistence_failed), + # codex output has already streamed to the user by the time this + # flush runs, so there is nothing left to withhold. We cannot + # flip agent_persisted=False either: the gateway fallback write + # would re-INSERT the already-flushed user turn (#860/#42039). + # Surface the durability gap loudly instead of a silent debug. + logger.warning( + "codex app-server turn was delivered but could NOT be " + "persisted to the session DB (session=%s) — this turn " + "will be missing after restart/resume", + getattr(agent, "session_id", None), + ) # Counter ticks for the agent-improvement loop. diff --git a/agent/coding_context.py b/agent/coding_context.py index 4a0cb841030..fabbdb48e07 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -520,30 +520,46 @@ class RuntimeMode: return None return [self.profile.toolset, *_enabled_mcp_servers(config)] - def system_blocks(self) -> list[str]: - """Stable system-prompt blocks for this posture (brief + workspace). + def system_prompt_parts(self) -> tuple[list[str], list[str], list[str]]: + """Return prefix, workspace, and trailing posture blocks separately. The operating brief carries a model-family edit-format nudge appended to it (one cached string, not a separate block) so the model is steered toward the `patch` mode it handles best — see ``_edit_format_line``. + + The three lists preserve the historical flat prompt order: the brief, + the live workspace snapshot, then configured operator instructions. + Prompt assembly can therefore put a cache boundary before the snapshot + without changing the persisted system-prompt bytes. """ if not self.is_coding: - return [] - blocks: list[str] = [] + return [], [], [] + prefix: list[str] = [] + workspace_parts: list[str] = [] + trailing: list[str] = [] if self.profile.guidance: brief = self.profile.guidance edit_line = _edit_format_line(self.model) if edit_line: brief = f"{brief}\n{edit_line}" - blocks.append(brief) + prefix.append(brief) workspace = build_coding_workspace_block(self.cwd) if workspace: - blocks.append(workspace) + workspace_parts.append(workspace) # Operator instructions ride their own block so the brief (block 0) stays # byte-stable and cache-keyed independently of user config. if self.instructions: - blocks.append(f"Operator instructions (from config):\n{self.instructions}") - return blocks + trailing.append(f"Operator instructions (from config):\n{self.instructions}") + return prefix, workspace_parts, trailing + + def system_blocks(self) -> list[str]: + """Return posture blocks in their historical display order. + + ``system_prompt_parts`` is the cache-aware API. This compatibility + helper retains the public flat list for callers outside prompt assembly. + """ + prefix, workspace, trailing = self.system_prompt_parts() + return [*prefix, *workspace, *trailing] def compact_skill_categories(self) -> frozenset[str]: """Skill categories to demote to names-only in the prompt's skill index. @@ -644,6 +660,19 @@ def coding_system_blocks( ).system_blocks() +def coding_system_prompt_parts( + *, + platform: Optional[str] = None, + cwd: Optional[str | Path] = None, + config: Optional[dict[str, Any]] = None, + model: Optional[str] = None, +) -> tuple[list[str], list[str], list[str]]: + """Return coding prefix, workspace snapshot, and trailing guidance.""" + return resolve_runtime_mode( + platform=platform, cwd=cwd, config=config, model=model + ).system_prompt_parts() + + def coding_compact_skill_categories( *, platform: Optional[str] = None, diff --git a/agent/context_breakdown.py b/agent/context_breakdown.py index 0e2eb772f2f..4527c5dca22 100644 --- a/agent/context_breakdown.py +++ b/agent/context_breakdown.py @@ -154,3 +154,207 @@ def compute_session_context_breakdown( "estimated_total": estimated_total, "model": getattr(agent, "model", "") or "", } + + +# ── /context rendering (CLI + gateway) ────────────────────────────────────── +# +# Pure text renderers over the payload above. The CLI shows a glyph block-grid +# plus a category table; the gateway uses the same table without the grid +# (proportional monospace is not guaranteed on messaging platforms). + +_CATEGORY_GLYPHS = { + "system_prompt": "■", + "tool_definitions": "▣", + "rules": "▩", + "skills": "▤", + "mcp": "▥", + "subagent_definitions": "▦", + "memory": "▧", + "conversation": "▨", +} +_FREE_GLYPH = "·" +_GRID_COLUMNS = 20 +_GRID_ROWS = 5 # 100 cells → 1 cell per percent of the context window + +# Human-readable tables cap the expanded listings; nothing is dropped from +# the underlying data. +_DETAILS_TABLE_LIMIT = 15 + + +def _bytes_to_tokens(size: Optional[int]) -> Optional[int]: + if size is None: + return None + return (int(size) + 3) // 4 + + +def compute_context_details(agent: Any) -> Dict[str, Any]: + """Expanded per-skill / per-toolset cost listing for ``/context all``. + + Reuses the ``hermes prompt-size`` attribution mechanism (PR #66656): + per-skill index-line bytes parsed from the live ```` + block, and per-toolset schema bytes attributed via the tool registry's + canonical tool→toolset map. Byte figures are converted to the same + chars/4 token heuristic the categories above use. + """ + from hermes_cli.prompt_size import ( + _compute_skills_breakdown, + _compute_toolsets_breakdown, + ) + from agent.system_prompt import build_system_prompt_parts + + parts = build_system_prompt_parts(agent) + stable = parts.get("stable", "") or "" + skills_match = _SKILLS_BLOCK_RE.search(stable) + skills_block = skills_match.group(0) if skills_match else "" + + skills: List[Dict[str, Any]] = [] + if skills_block: + for entry in _compute_skills_breakdown(skills_block): + skills.append({ + "name": entry.get("name", ""), + "index_tokens": _bytes_to_tokens(entry.get("index_line_bytes")) or 0, + "skill_md_tokens": _bytes_to_tokens(entry.get("skill_md_bytes")), + }) + + toolsets: List[Dict[str, Any]] = [] + tools = list(getattr(agent, "tools", None) or []) + if tools: + for group in _compute_toolsets_breakdown(tools): + toolsets.append({ + "toolset": group.get("toolset", ""), + "tool_count": int(group.get("tool_count", 0) or 0), + "schema_tokens": _bytes_to_tokens(group.get("json_bytes")) or 0, + }) + + return {"skills": skills, "toolsets": toolsets} + + +def render_context_grid(payload: Dict[str, Any]) -> List[str]: + """Render the payload as a Claude Code-style glyph block grid. + + 100 cells (5×20), each one percent of the model context window. Categories + fill in declaration order; the remainder renders as free space. + """ + context_max = int(payload.get("context_max") or 0) + categories = payload.get("categories") or [] + total_cells = _GRID_COLUMNS * _GRID_ROWS + + cells: List[str] = [] + if context_max > 0: + for cat in categories: + tokens = int(cat.get("tokens") or 0) + n = round(tokens / context_max * total_cells) + if tokens > 0 and n == 0: + n = 1 # never render a nonzero category as invisible + glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪") + cells.extend([glyph] * n) + cells = cells[:total_cells] + cells.extend([_FREE_GLYPH] * (total_cells - len(cells))) + + return [ + " ".join(cells[row * _GRID_COLUMNS:(row + 1) * _GRID_COLUMNS]) + for row in range(_GRID_ROWS) + ] + + +def render_context_category_lines(payload: Dict[str, Any]) -> List[str]: + """Render the 'Estimated usage by category' table as plain-text lines.""" + categories = payload.get("categories") or [] + context_max = int(payload.get("context_max") or 0) + estimated_total = int(payload.get("estimated_total") or 0) + denom = context_max or estimated_total + + lines = ["Estimated usage by category"] + if not categories: + lines.append(" (no data yet — send a message first)") + return lines + + width = max(len(str(cat.get("label") or "")) for cat in categories) + width = max(width, len("Free space")) + for cat in categories: + tokens = int(cat.get("tokens") or 0) + glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪") + pct = tokens / denom * 100 if denom else 0.0 + label = str(cat.get("label") or cat.get("id") or "") + lines.append(f"{glyph} {label:<{width}} {tokens:>9,} tokens {pct:>5.1f}%") + if context_max > 0: + free = max(0, context_max - estimated_total) + pct = free / context_max * 100 + lines.append(f"{_FREE_GLYPH} {'Free space':<{width}} {free:>9,} tokens {pct:>5.1f}%") + return lines + + +def render_context_details_lines(details: Dict[str, Any]) -> List[str]: + """Render the expanded ``/context all`` per-skill / per-toolset tables.""" + lines: List[str] = [] + + toolsets = details.get("toolsets") or [] + if toolsets: + lines.append("Toolsets by schema cost (largest first)") + for group in toolsets[:_DETAILS_TABLE_LIMIT]: + lines.append( + f" {group['toolset']:<24} {group['tool_count']:>3} tools" + f" {group['schema_tokens']:>8,} tokens" + ) + remaining = len(toolsets) - _DETAILS_TABLE_LIMIT + if remaining > 0: + lines.append(f" … and {remaining} more") + + skills = details.get("skills") or [] + if skills: + if lines: + lines.append("") + lines.append("Skills by cost (index = always-on; SKILL.md = cost when loaded)") + for entry in skills[:_DETAILS_TABLE_LIMIT]: + name = str(entry.get("name") or "") + if len(name) > 28: + name = name[:27] + "…" + md = entry.get("skill_md_tokens") + md_str = f"{md:>8,}" if md is not None else f"{'n/a':>8}" + lines.append( + f" {name:<28} index {entry['index_tokens']:>6,}" + f" SKILL.md {md_str} tokens" + ) + remaining = len(skills) - _DETAILS_TABLE_LIMIT + if remaining > 0: + lines.append(f" … and {remaining} more") + + return lines + + +def render_context_breakdown_lines( + payload: Dict[str, Any], + *, + details: Optional[Dict[str, Any]] = None, + grid: bool = True, +) -> List[str]: + """Render the full /context view as plain-text lines. + + ``grid=True`` (CLI) prepends the glyph block grid; the gateway passes + ``grid=False`` and keeps its own gauge. ``details`` (from + :func:`compute_context_details`) appends the expanded listings. + """ + lines: List[str] = [] + if grid: + lines.extend(render_context_grid(payload)) + lines.append("") + lines.extend(render_context_category_lines(payload)) + + context_max = int(payload.get("context_max") or 0) + context_used = int(payload.get("context_used") or 0) + if context_max > 0: + pct = int(payload.get("context_percent") or 0) + lines.append("") + lines.append( + f"Context window: {context_used:,} / {context_max:,} tokens ({pct}%)" + ) + + if details is not None: + detail_lines = render_context_details_lines(details) + if detail_lines: + lines.append("") + lines.extend(detail_lines) + else: + lines.append("") + lines.append("Use /context all for per-skill and per-toolset costs.") + return lines diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 65684ced454..73fa1e36f21 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -90,9 +90,6 @@ def _is_summary_access_or_quota_error(exc: Exception) -> bool: HISTORICAL_TASK_HEADING = "## Historical Task Snapshot" -HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State" -HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks" -HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work" SUMMARY_PREFIX = ( @@ -107,9 +104,7 @@ SUMMARY_PREFIX = ( "Topic overlap with the summary does NOT mean you should resume its " "task: even on similar topics, the latest user message WINS. Treat ONLY " "the latest message as the active task and discard stale items from " - f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / " - f"'{HISTORICAL_PENDING_ASKS_HEADING}' / " - f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or " + f"'{HISTORICAL_TASK_HEADING}' entirely — do not 'wrap up' or " "'finish' work described there unless the latest message explicitly " "asks for it. " "Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll " @@ -219,8 +214,45 @@ _MERGED_SUMMARY_DELIMITER = "[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW] # stale directive it carried (e.g. "resume exactly from Active Task") survives # embedded in the body and keeps hijacking replies. Keep newest-first; entries # are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes. +# NEVER mutate or reorder an existing entry — each one is the exact wire text a +# shipped build persisted, so editing it silently un-normalizes every summary +# written by that build generation; prepend only. tests/agent/ +# test_summary_prefix_semantics.py byte-pins every entry to enforce this. _HISTORICAL_SUMMARY_PREFIXES = ( - # Jul 2026 (#65848 class): identical to the current prefix except it + # Pre-#69619: identical to the current prefix except the stale-item + # discard clause named all four historical headings (the three + # section headers removed by #69619 were still in the template). + # Summaries persisted by builds immediately before #69619 carry this + # exact text and must remain detectable/strippable on resume. + "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted " + "into the summary below. This is a handoff from a previous context " + "window — treat it as background reference, NOT as active instructions. " + "Do NOT answer questions or fulfill requests mentioned in this summary; " + "they were already addressed. " + "Respond ONLY to the latest user message that appears AFTER this " + "summary — that message is the single source of truth for what to do " + "right now. " + "Topic overlap with the summary does NOT mean you should resume its " + "task: even on similar topics, the latest user message WINS. Treat ONLY " + "the latest message as the active task and discard stale items from " + "'## Historical Task Snapshot' / '## Historical In-Progress State' / " + "'## Historical Pending User Asks' / " + "'## Historical Remaining Work' entirely — do not 'wrap up' or " + "'finish' work described there unless the latest message explicitly " + "asks for it. " + "Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll " + "back', 'just verify', 'don't do that anymore', 'never mind', a new " + "topic) must immediately end any in-flight work described in the " + "summary; do not re-surface it in later turns. " + "IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system " + "prompt is ALWAYS authoritative and active — never ignore or deprioritize " + "memory content due to this compaction note. " + "None of the above restricts HOW you work: your tools remain fully " + "active — keep calling them normally for the active task (edit files, " + "run commands, search) instead of merely narrating what you would do. " + "The current session state (files, config, etc.) may reflect work " + "described here — avoid repeating it:", + # Jul 2026 (#65848 class): identical to the pre-#69619 prefix except it # lacked the explicit "tools remain fully active" clause — the strong # REFERENCE ONLY framing bled into general tool-use suppression # (observed: 7 consecutive narration-only turns immediately after a @@ -236,9 +268,9 @@ _HISTORICAL_SUMMARY_PREFIXES = ( "Topic overlap with the summary does NOT mean you should resume its " "task: even on similar topics, the latest user message WINS. Treat ONLY " "the latest message as the active task and discard stale items from " - f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / " - f"'{HISTORICAL_PENDING_ASKS_HEADING}' / " - f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or " + "'## Historical Task Snapshot' / '## Historical In-Progress State' / " + "'## Historical Pending User Asks' / " + "'## Historical Remaining Work' entirely — do not 'wrap up' or " "'finish' work described there unless the latest message explicitly " "asks for it. " "Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll " @@ -1213,6 +1245,7 @@ class ContextCompressor(ContextEngine): self._last_aux_model_failure_model = None self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 + self._anti_thrash_recovery_deadline = 0.0 self._fallback_compression_streak = 0 self._verify_compaction_cleared_threshold = False self._last_compression_made_progress = False @@ -1350,6 +1383,7 @@ class ContextCompressor(ContextEngine): self._last_aux_model_failure_model = None self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 + self._anti_thrash_recovery_deadline = 0.0 self._fallback_compression_streak = 0 self._verify_compaction_cleared_threshold = False self._last_compression_made_progress = False @@ -1376,6 +1410,7 @@ class ContextCompressor(ContextEngine): self._consecutive_timeout_failures = 0 self._fallback_compression_streak = 0 self._ineffective_compression_count = 0 + self._anti_thrash_recovery_deadline = 0.0 self.get_active_compression_failure_cooldown() self._load_fallback_compression_streak() self._load_ineffective_compression_count() @@ -1747,6 +1782,15 @@ class ContextCompressor(ContextEngine): # rationale as the gpt-5.5/Codex 85% autoraise. _MIN_CTX_TRIGGER_RATIO = 0.85 + # Anti-thrash recovery window (#14694): once the ineffective/fallback + # breaker trips, automatic compaction stays blocked for this long, then + # ONE probe attempt is allowed (counters drop to 1 strike, so another + # ineffective pass re-trips immediately). Long enough that a genuinely + # incompressible session isn't compacting in a loop; short enough that a + # session which has since grown real compressible material recovers well + # before it rides into the provider's hard context limit. + _ANTI_THRASH_RECOVERY_SECONDS = 300.0 + @staticmethod def _coerce_max_tokens(value: Any) -> int | None: """Normalize a max_tokens value to a positive int or None. @@ -2016,6 +2060,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 + # Monotonic deadline after which a tripped anti-thrash guard grants + # one probation probe (#14694). 0.0 = clock not armed. Armed lazily on + # the first blocked evaluation; deliberately NOT durable, so a process + # restart with a persisted tripped counter (#69872) waits a full fresh + # window before probing (#54923: restart must never disarm a guard). + self._anti_thrash_recovery_deadline: float = 0.0 # Consecutive completed deterministic-fallback boundaries. Unlike the # real-usage effectiveness counter, ordinary fitting responses must not # reset this breaker; only a healthy completed summary does. @@ -2306,21 +2356,66 @@ class ContextCompressor(ContextEngine): _cooldown_remaining, ) return True - # Anti-thrashing: back off if recent compressions were ineffective + # Anti-thrashing: back off if recent compressions were ineffective. + # The back-off must not be permanent (#14694): the tripped state was + # judged against the transcript as it existed THEN (e.g. a middle + # region too small to matter), but the conversation keeps growing and + # can accumulate plenty of compressible material later. Without a + # recovery path the session never auto-compacts again and rides into + # the provider's hard context limit. Recovery is a probation probe: + # after _ANTI_THRASH_RECOVERY_SECONDS of continuous block, allow ONE + # attempt by dropping the tripped counter(s) to 1 strike (persisted, + # so sibling agents on the same session row unblock too). If the probe + # is ineffective again the very next verdict re-trips the guard, so + # the worst case in the truly-incompressible state is one compaction + # attempt per recovery window — bounded, not thrash. + # + # The clock is armed lazily on the first BLOCKED evaluation rather + # than persisted at trip time: a fresh process that loads a durable + # tripped counter (#69872) therefore starts a full window blocked, + # preserving the restart-must-not-disarm contract (#54923). if ( self._ineffective_compression_count >= 2 or self._fallback_compression_streak >= 2 ): + _now = time.monotonic() + if self._anti_thrash_recovery_deadline <= 0.0: + self._anti_thrash_recovery_deadline = ( + _now + self._ANTI_THRASH_RECOVERY_SECONDS + ) + elif _now >= self._anti_thrash_recovery_deadline: + self._anti_thrash_recovery_deadline = 0.0 + if self._ineffective_compression_count >= 2: + self._record_ineffective_compression_verdict(1) + if self._fallback_compression_streak >= 2: + self._fallback_compression_streak = 1 + self._persist_fallback_compression_streak() + if not self.quiet_mode: + logger.info( + "Anti-thrashing recovery: %.0fs elapsed since the " + "guard tripped — allowing one compaction probe " + "(ineffective=%d fallback=%d).", + self._ANTI_THRASH_RECOVERY_SECONDS, + self._ineffective_compression_count, + self._fallback_compression_streak, + ) + return False if not self.quiet_mode: logger.warning( "Compression skipped — repeated compaction attempts did not " "restore healthy context. ineffective=%d fallback=%d. " - "Consider /new to start fresh, or /compress for " - "focused compression.", + "Auto-compaction will retry once in %.0fs. Consider /new " + "to start fresh, or /compress for focused " + "compression.", self._ineffective_compression_count, self._fallback_compression_streak, + max(0.0, self._anti_thrash_recovery_deadline - _now), ) return True + # Guard not tripped (counters were cleared by an effective compaction + # or a fitting real-usage reading) — disarm any pending recovery clock + # so a LATER trip starts its own full window. + self._anti_thrash_recovery_deadline = 0.0 return False # ------------------------------------------------------------------ @@ -2968,12 +3063,6 @@ Recovered from a deterministic fallback because the LLM context summarizer was u ## Active State Unknown from deterministic fallback. Inspect current repository/session state if needed. -{HISTORICAL_IN_PROGRESS_HEADING} -Unknown from deterministic fallback — the latest user ask is recorded once under -"{HISTORICAL_TASK_HEADING}" above as historical context only. Do NOT treat it as an -unfulfilled instruction to re-answer; verify current state and continue from the -protected recent messages after this summary. - ## Blocked {_bullets(blockers, limit=5)} @@ -2983,17 +3072,9 @@ None recoverable from deterministic fallback. ## Resolved Questions None recoverable from deterministic fallback. -{HISTORICAL_PENDING_ASKS_HEADING} -None recoverable from deterministic fallback. (The latest user ask is preserved once -under "{HISTORICAL_TASK_HEADING}" as historical context — it is NOT necessarily -outstanding.) - ## Relevant Files {_bullets(relevant_files, limit=12)} -{HISTORICAL_REMAINING_WORK_HEADING} -Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims. - ## Last Dropped Turns {_bullets(last_dropped_turns, limit=8)} @@ -3304,9 +3385,6 @@ Be specific with file paths, commands, line numbers, and results.] - Any running processes or servers - Environment details that matter] -{HISTORICAL_IN_PROGRESS_HEADING} -[Work currently underway — what was being done when compaction fired] - ## Blocked [Any blockers, errors, or issues not yet resolved. Include exact error messages.] @@ -3316,15 +3394,9 @@ Be specific with file paths, commands, line numbers, and results.] ## Resolved Questions {_resolved_questions_instructions} -{HISTORICAL_PENDING_ASKS_HEADING} -{_pending_asks_instructions} - ## Relevant Files [Files read, modified, or created — with brief note on each] -{HISTORICAL_REMAINING_WORK_HEADING} -[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.] - ## Critical Context [Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.] diff --git a/agent/context_references.py b/agent/context_references.py index dea5fd4c3a5..ab370a5a592 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -19,6 +19,7 @@ REFERENCE_PATTERN = re.compile( rf"(?diff|staged)\b|(?Pfile|folder|git|url):(?P{_QUOTED_REFERENCE_VALUE}(?::\d+(?:-\d+)?)?|\S+))" ) TRAILING_PUNCTUATION = ",.;!?" +_NEEDS_QUOTING = re.compile(r"""[\s()\[\]{}<>"'`]""") _SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure", ".config/gh") _SENSITIVE_HERMES_DIRS = (Path("skills") / ".hub",) _SENSITIVE_HOME_FILES = ( @@ -60,6 +61,21 @@ class ContextReferenceResult: blocked: bool = False +def format_reference_value(value: str) -> str: + """Quote a reference value so ``REFERENCE_PATTERN`` reads it back whole. + + The unquoted alternative in the pattern is ``\\S+``, so a path containing a + space parses as a truncated ref with the tail left behind as loose text. + Mirrors ``formatRefValue`` in the desktop's directive-text.tsx. + """ + if not _NEEDS_QUOTING.search(value): + return value + for quote in ("`", '"', "'"): + if quote not in value: + return f"{quote}{value}{quote}" + return value + + def parse_context_references(message: str) -> list[ContextReference]: refs: list[ContextReference] = [] if not message: @@ -197,8 +213,12 @@ async def preprocess_context_references_async( f"@ context injection warning: {injected_tokens} tokens exceeds the 25% soft limit ({soft_limit})." ) - stripped = _remove_reference_tokens(message, refs) - final = stripped + # Leave the `@file:`/`@folder:` tokens where the user typed them. The token + # IS the reference, not scaffolding around it: clients render each one as an + # inline chip, so stripping them left a sentence with a hole in it ("review + # and ship") and made the desktop re-derive the refs from the attached block + # to show them as a detached list above the prose. + final = message if warnings: final = f"{final}\n\n--- Context Warnings ---\n" + "\n".join(f"- {warning}" for warning in warnings) if blocks: @@ -457,19 +477,6 @@ def _parse_file_reference_value(value: str) -> tuple[str, int | None, int | None return _strip_reference_wrappers(value), None, None -def _remove_reference_tokens(message: str, refs: list[ContextReference]) -> str: - pieces: list[str] = [] - cursor = 0 - for ref in refs: - pieces.append(message[cursor:ref.start]) - cursor = ref.end - pieces.append(message[cursor:]) - text = "".join(pieces) - text = re.sub(r"\s{2,}", " ", text) - text = re.sub(r"\s+([,.;:!?])", r"\1", text) - return text.strip() - - def _is_binary_file(path: Path) -> bool: mime, _ = mimetypes.guess_type(path.name) if mime and not mime.startswith("text/") and not any( diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 88a5f4d7ab8..506181f2d39 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -40,7 +40,7 @@ import uuid import threading from datetime import datetime from pathlib import Path -from typing import Any, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from agent.context_engine import ( automatic_compaction_status_message, @@ -221,6 +221,25 @@ class CompressionCommitFence: self._lock = threading.Lock() self._cancelled = False self._commit_started = False + # Forward-progress telemetry: the compression worker touches this + # whenever the streamed summary call produces a token (see + # ContextCompressor._call_summary_llm). Waiters use it to distinguish + # a SLOW-but-alive summary model from a HUNG one, so slow models are + # not killed by a fixed wall-clock deadline while tokens are moving. + self._last_progress = time.monotonic() + + def touch_progress(self) -> None: + """Record forward progress (e.g. a streamed summary token arriving). + + Called from the compression worker thread; read by async waiters via + :meth:`seconds_since_progress`. A bare float store is atomic in + CPython, so no lock is needed. + """ + self._last_progress = time.monotonic() + + def seconds_since_progress(self) -> float: + """Seconds since the worker last reported forward progress.""" + return max(0.0, time.monotonic() - self._last_progress) def cancel_before_commit(self) -> bool: """Cancel a pending commit, or wait for an active commit to finish. @@ -373,6 +392,125 @@ def compression_skipped_due_to_lock(agent: Any) -> bool: return _sig is True or isinstance(_sig, str) +def _adopt_live_compression_child( + agent: Any, + session_db: Any, + parent_session_id: str, +) -> Optional[List[Dict[str, Any]]]: + """Move a stale compression contender onto the unique durable child. + + Resolve and load first, then mutate the live agent. This ordering keeps the + stale contender fail-closed when lineage is ambiguous or the compacted + handoff cannot be read. + """ + finder = getattr(type(session_db), "find_live_compression_child", None) + loader = getattr(type(session_db), "get_messages_as_conversation", None) + if not callable(finder) or not callable(loader): + return None + child = finder(session_db, parent_session_id) + if not child or not child.get("id"): + return None + child_session_id = str(child["id"]) + recovered = loader(session_db, child_session_id) + if not isinstance(recovered, list) or not recovered: + return None + # Revalidate after loading: the child may have rotated or a competing + # continuation may have appeared between the two DB reads. + confirmed = finder(session_db, parent_session_id) + if not confirmed or str(confirmed.get("id") or "") != child_session_id: + return None + + agent.session_id = child_session_id + try: + from gateway.session_context import set_current_session_id + + set_current_session_id(child_session_id) + except Exception: + os.environ["HERMES_SESSION_ID"] = child_session_id + try: + from hermes_logging import set_session_context + + set_session_context(child_session_id) + except Exception: + pass + + agent._session_db_created = True + if child.get("system_prompt"): + agent._cached_system_prompt = child["system_prompt"] + agent._last_flushed_db_idx = len(recovered) + agent._flushed_db_message_session_id = child_session_id + agent._flushed_db_message_ids = { + id(message) for message in recovered if isinstance(message, dict) + } + + on_session_start = getattr(agent.context_compressor, "on_session_start", None) + if callable(on_session_start): + try: + on_session_start( + child_session_id, + boundary_reason="compression", + old_session_id=parent_session_id, + session_db=session_db, + platform=getattr(agent, "platform", None) or "cli", + conversation_id=getattr(agent, "_gateway_session_key", None), + ) + except Exception as exc: + logger.debug("context engine compression-child adoption failed: %s", exc) + else: + bind_state = getattr(agent.context_compressor, "bind_session_state", None) + if callable(bind_state): + try: + bind_state(session_db=session_db, session_id=child_session_id) + except Exception: + pass + try: + if agent._memory_manager: + agent._memory_manager.on_session_switch( + child_session_id, + parent_session_id=parent_session_id, + reset=False, + reason="compression", + ) + except Exception as exc: + logger.debug("memory manager compression-child adoption failed: %s", exc) + + return recovered + + +def recover_rotated_compression_session( + agent: Any, +) -> Optional[List[Dict[str, Any]]]: + """Recover a stale live agent before a new turn writes to its old parent.""" + session_db = getattr(agent, "_session_db", None) + session_id = getattr(agent, "session_id", None) or "" + if session_db is None or not session_id: + return None + try: + if not _session_was_rotated_by_compression(session_db, session_id): + return None + # Rotation publication holds the parent compression lease until the + # child handoff is durable. A concurrent turn waits briefly rather than + # observing the intentional parent-ended/child-empty intermediate state. + holder_getter = getattr(session_db, "get_compression_lock_holder", None) + for attempt in range(21): + recovered = _adopt_live_compression_child(agent, session_db, session_id) + if recovered is not None: + return recovered + holder = holder_getter(session_id) if callable(holder_getter) else None + if not holder or attempt == 20: + return None + time.sleep(0.05) + return None + except Exception as exc: + logger.warning( + "compression session recovery failed for session=%s (%s: %s)", + session_id, + type(exc).__name__, + exc, + ) + return None + + def _compression_lock_holder(agent: Any) -> str: """Build a unique holder id for the lock: pid:tid:agent-instance:uuid. @@ -533,7 +671,17 @@ class _CompressionLockLeaseRefresher: # by the TTL the acquirer set — the lock can never be held past its TTL # by a stuck refresher. consecutive_failures = 0 - while not self._stop.wait(self._refresh_interval_seconds): + # First refresh happens immediately, not one interval late. Everything + # between try_acquire() and start() (the rotation-ownership lookup, the + # durable-breaker re-read, thread startup) is charged against the very + # first lease, so on a short TTL under load the lock could already be + # expired — and reclaimable by a competing path — before tick #1. + first = True + while first or not self._stop.wait(self._refresh_interval_seconds): + if first: + first = False + if self._stop.is_set(): + break try: refreshed = self._db.refresh_compression_lock( self._session_id, @@ -892,6 +1040,7 @@ _SYNTHETIC_USER_FLAGS = ( "_empty_recovery_synthetic", "_verification_stop_synthetic", "_pre_verify_synthetic", + "_dropped_toolcall_nudge", ) @@ -1252,8 +1401,11 @@ def compress_context( # parent_session_id child, no # `name #N` renumber, no contextvar/env/logging re-sync, no memory/context- # engine session-switch. The conversation keeps one durable id for life, - # eliminating the session-rotation bug cluster. Default False during rollout. - in_place = bool(getattr(agent, "compression_in_place", False)) + # eliminating the session-rotation bug cluster. Default True (2107b86024). + # Default True matches DEFAULT_CONFIG / #38763. A missing attribute must + # NOT fall back to rotation mode — that re-enables the pre-lease drift + # path and can wedge busy sessions that never set the flag. + in_place = bool(getattr(agent, "compression_in_place", True)) # Set True once the in-place DB write actually completes (the DB block can # raise and skip it). Surfaced to the gateway via agent._last_compaction_in_place. compacted_in_place = False @@ -1460,6 +1612,8 @@ def compress_context( if _lock_released: return _lock_released = True + if getattr(agent, "_active_compression_lock_holder", None) == _lock_holder: + agent._active_compression_lock_holder = None if _lock_refresher is not None: try: _lock_refresher.stop() @@ -1471,6 +1625,9 @@ def compress_context( except Exception as _rel_err: logger.debug("compression lock release failed: %s", _rel_err) + if _lock_holder is not None: + agent._active_compression_lock_holder = _lock_holder + # A delayed contender can acquire the parent lock after the winning path # has released it and completed rotation. The lock serializes work but does # not by itself prove that this stale agent still owns a live parent. @@ -1493,15 +1650,25 @@ def compress_context( _existing_sp = agent._build_system_prompt(system_message) return messages, _existing_sp if _parent_already_rotated: - logger.info( - "compression skipped: session=%s was already rotated by " - "another compression path", - _lock_sid, + recovered_messages = _adopt_live_compression_child( + agent, _lock_db, _lock_sid ) _release_lock() _existing_sp = getattr(agent, "_cached_system_prompt", None) if not _existing_sp: _existing_sp = agent._build_system_prompt(system_message) + if recovered_messages is not None: + logger.warning( + "compression recovery: stale session=%s adopted live child=%s", + _lock_sid, + agent.session_id, + ) + return recovered_messages, _existing_sp + logger.warning( + "compression skipped: session=%s was already rotated by " + "another compression path, but no unique live child could be adopted", + _lock_sid, + ) return messages, _existing_sp # The agent may have been constructed before another path completed an @@ -1535,6 +1702,47 @@ def compress_context( ) _lock_refresher.start() + # The caller's history snapshot predates lease acquisition. Reload the + # durable parent after the lease is live; MORE durable rows than the + # snapshot carries means a frontend/background writer committed a turn + # in that window, so publishing from this snapshot would omit it. + # Deliberately a LENGTH check, not content equality: in-memory + # mutation of past turns is legal (multimodal compression, retry + # history replacement, think-tag stripping), and a content-equality + # abort would permanently wedge compression on such sessions — the + # #14694 failure shape. + # Rotation-only: in-place compaction (archive_and_compact) is + # non-destructive — pre-compaction rows are soft-archived (active=0, + # compacted=1), stay searchable and recoverable, so snapshot/durable + # drift cannot lose data there and must not abort compaction. + # + # When durable DID grow, ADOPT it and continue rather than aborting. + # Aborting returned the stale snapshot unchanged, so busy sessions + # (memory review / shared session_id writers) stayed permanently + # behind the DB: every /compress and auto-compress saw + # "changed before lease acquisition", surfaced as the misleading + # "No changes from compression", and never reclaimed tokens. + if not in_place and _lock_db is not None and _lock_sid: + durable_loader = getattr( + type(_lock_db), "get_messages_as_conversation", None + ) + if callable(durable_loader): + durable_parent = durable_loader(_lock_db, _lock_sid) + if isinstance(durable_parent, list) and len(durable_parent) > len(messages): + logger.info( + "compression: session=%s grew before lease " + "(%d → %d msgs); adopting durable snapshot", + _lock_sid, + len(messages), + len(durable_parent), + ) + messages = durable_parent + _pre_msg_count = len(messages) + # Token estimate was for the stale snapshot; clear it so + # the compressor re-derives from the adopted transcript + # instead of under-counting the newly visible rows. + approx_tokens = 0 + # Notify external memory provider before compression discards context. # The provider's on_pre_compress() may return a string of insights it # wants surfaced inside the compression summary; capture and forward it @@ -1575,7 +1783,29 @@ def compress_context( messages_before_compression = copy.deepcopy(messages) _activity_heartbeat = _CompressionActivityHeartbeat(agent).start() - compressed = compress_fn(messages, **compress_kwargs) + # Publish forward progress to the commit fence while the summary LLM + # call streams. Async hosts (gateway session hygiene) poll + # ``commit_fence.seconds_since_progress()`` to extend their deadline + # while tokens are moving — so a SLOW summary model is only killed + # when it is actually silent, not merely thorough. The hook is + # thread-local and the compress call is synchronous on this thread, + # so it cannot leak into unrelated auxiliary calls. + # + # Fenceless callers (CLI /compress, in-loop auto-compress) install a + # no-op hook: nobody polls their progress, but an ACTIVE hook is what + # switches the summary call onto the streamed path — giving every + # compression path the same two guarantees: the configured timeout + # acts on inactivity (slow models finish), and a byte-trickling + # provider that keeps the connection alive forever is cut off at the + # streamed total ceiling (see _aux_stream_total_ceiling) instead of + # outliving the SDK's inactivity timeout indefinitely. + from agent.auxiliary_client import aux_progress_hook + _progress_hook = ( + commit_fence.touch_progress if commit_fence is not None + else (lambda: None) + ) + with aux_progress_hook(_progress_hook): + compressed = compress_fn(messages, **compress_kwargs) except BaseException as _compress_exc: # ANY exception after lock acquisition — memory hook, capability # inspection, engine lookup, or compress() — must release the lock so @@ -1803,6 +2033,20 @@ def compress_context( ): new_system_prompt = cached_system_prompt agent._cached_system_prompt = cached_system_prompt + # _invalidate_system_prompt() above also cleared the + # cross-session-stable prefix marker boundary. The kept prompt + # is byte-identical, so reconstruct the stable tier and reuse + # it ONLY when the kept prompt still literally starts with it + # (same startswith gate as the restore path); otherwise the + # request layer falls back to the legacy single-breakpoint + # layout with the prompt bytes untouched. + from agent.system_prompt import reconstruct_static_prefix + + reconstruct_static_prefix( + agent, + system_message=system_message, + log_label="compression keep-prompt", + ) else: new_system_prompt = agent._build_system_prompt(system_message) agent._cached_system_prompt = new_system_prompt @@ -1879,40 +2123,13 @@ def compress_context( ) except Exception: pass # best-effort — don't block compression on a flush error - # Propagate title to the new session with auto-numbering - old_title = agent._session_db.get_session_title(agent.session_id) - agent._session_db.end_session(agent.session_id, "compression") - old_session_id = agent.session_id - agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" - # Ordering contract: the agent thread updates the contextvar here; - # the gateway propagates to SessionEntry after run_in_executor returns. - try: - from gateway.session_context import set_current_session_id - - set_current_session_id(agent.session_id) - except Exception: - os.environ["HERMES_SESSION_ID"] = agent.session_id - # The gateway/tools session context (ContextVar + env) and the - # logging session context are SEPARATE mechanisms. The call above - # moves the former; the ``[session_id]`` tag on log lines comes - # from ``hermes_logging._session_context`` (set once per turn in - # conversation_loop.py). Without this, post-rotation log lines in - # the same turn keep the STALE old id while the message/DB/gateway - # state carry the new one — breaking log correlation exactly at the - # compaction boundary (see #34089). Guarded separately so a logging - # failure can never regress the routing update above. - try: - from hermes_logging import set_session_context - - set_session_context(agent.session_id) - except Exception: - pass - agent._session_db_created = False + # Publish parent closure + child row + compacted handoff in + # one transaction. No reader can observe a missing/empty child. # The rotation child must stay on the parent's profile — # mirror _ensure_db_session's stamp ("default" persists as - # NULL). _insert_session_row's parent backfill additionally - # COALESCEs from the parent row, covering app-global remote - # sessions whose thread lacks the HERMES_HOME context. + # NULL). publish_compression_child additionally COALESCEs + # from the parent row, covering app-global remote sessions + # whose thread lacks the HERMES_HOME context. try: from hermes_cli.profiles import get_active_profile_name @@ -1921,53 +2138,39 @@ def compress_context( _profile_for_child = None except Exception: _profile_for_child = None + old_title = agent._session_db.get_session_title(agent.session_id) + old_session_id = agent.session_id + new_session_id = ( + f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_" + f"{uuid.uuid4().hex[:6]}" + ) + agent._session_db.publish_compression_child( + parent_session_id=old_session_id, + child_session_id=new_session_id, + source=agent.platform + or os.environ.get("HERMES_SESSION_SOURCE", "cli"), + model=agent.model, + model_config=agent._session_init_model_config, + system_prompt=new_system_prompt, + messages=compressed, + cwd=getattr(agent, "working_directory", None), + profile_name=_profile_for_child, + compression_lock_holder=_lock_holder, + require_compression_lease=_lock_holder is not None, + ) + agent.session_id = new_session_id try: - agent._session_db.create_session( - session_id=agent.session_id, - source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), - model=agent.model, - model_config=agent._session_init_model_config, - parent_session_id=old_session_id, - profile_name=_profile_for_child, - ) - except Exception as _cs_err: - # The child row could not be created (e.g. FK constraint, - # contended write). Previously the outer handler simply - # warned and let the agent continue on the NEW id — which - # has no row in state.db, producing an orphan: the parent - # is ended, the child is never indexed, and every - # subsequent message is attributed to a session that - # doesn't exist (#33906/#33907). Roll the live id back to - # the parent so the conversation stays attached to a real, - # indexed session instead of a phantom. - logger.warning( - "Compression child session create failed (%s) — " - "rolling back to parent session %s to avoid an orphan.", - _cs_err, old_session_id, - ) - agent.session_id = old_session_id - try: - from gateway.session_context import set_current_session_id - set_current_session_id(agent.session_id) - except Exception: - os.environ["HERMES_SESSION_ID"] = agent.session_id - try: - from hermes_logging import set_session_context - set_session_context(agent.session_id) - except Exception: - pass - # Re-open the parent: it was ended above, but we're - # continuing on it, so it must not stay closed. - try: - agent._session_db.reopen_session(old_session_id) - except Exception: - pass - old_session_id = None # no rotation happened - # The parent row already exists in state.db, so mark the - # session as created — _ensure_db_session would otherwise - # retry a (harmless INSERT OR IGNORE) create next turn. - agent._session_db_created = True - raise + from gateway.session_context import set_current_session_id + + set_current_session_id(agent.session_id) + except Exception: + os.environ["HERMES_SESSION_ID"] = agent.session_id + try: + from hermes_logging import set_session_context + + set_session_context(agent.session_id) + except Exception: + pass agent._session_db_created = True split_status = "rotated_committed" # Carry a persistent /goal onto the continuation session. @@ -1987,18 +2190,14 @@ def compress_context( except (ValueError, Exception) as e: logger.debug("Could not propagate title on compression: %s", e) - # Shared post-write steps (both modes target agent.session_id, which - # in-place keeps and rotation has already reassigned to the new id): - # refresh the stored system prompt and reset the flush cursor so the - # next turn re-bases its append diff. - agent._session_db.update_system_prompt(agent.session_id, new_system_prompt) + # In-place mode still updates/replaces the current row here. + # Rotation already published prompt + compacted handoff atomically. if in_place: + agent._session_db.update_system_prompt( + agent.session_id, new_system_prompt + ) agent._last_flushed_db_idx = 0 else: - # A headless turn can be killed before its finalizer. Persist - # the rotated child's compacted handoff at the boundary so - # the new session is immediately resumable. - agent._session_db.replace_messages(agent.session_id, compressed) agent._last_flushed_db_idx = len(compressed) agent._flushed_db_message_session_id = agent.session_id agent._flushed_db_message_ids = { @@ -2008,7 +2207,22 @@ def compress_context( } _session_commit_succeeded = True except Exception as e: - split_status = "aborted" if locals().get("old_session_id") is None and not in_place else "failed_not_indexed" + if ( + not in_place + and locals().get("old_session_id") + and agent.session_id == old_session_id + ): + # Atomic publication failed (including lease loss): keep the + # parent live and discard the stale compacted snapshot. + old_session_id = None + messages[:] = copy.deepcopy(messages_before_compression) + compressed = messages + _compression_made_progress = False + split_status = ( + "aborted" + if locals().get("old_session_id") is None and not in_place + else "failed_not_indexed" + ) # If the rotation rolled back to the parent (orphan-avoidance # above), agent.session_id is the still-indexed parent and # old_session_id was cleared — so this is recovery, not an diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index a234213e32c..7df0e44db8e 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -48,6 +48,7 @@ from agent.turn_context import ( reanchor_current_turn_user_idx, ) from agent.turn_retry_state import TurnRetryState +from agent.runtime_cwd import resolve_agent_cwd from agent.message_sanitization import ( close_interrupted_tool_sequence, _repair_tool_call_arguments, @@ -71,7 +72,10 @@ from agent.model_metadata import ( save_context_length, ) from agent.process_bootstrap import _install_safe_stdio -from agent.prompt_caching import apply_anthropic_cache_control +from agent.prompt_caching import ( + apply_anthropic_cache_control, + strip_anthropic_cache_control, +) from agent.retry_utils import ( adaptive_rate_limit_backoff, is_zai_coding_overload_error, @@ -117,22 +121,44 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text Incomplete provider reasoning blocks are not valid replay items (Anthropic signs them; Responses reasoning items require their following output). - Preserve only what Hermes actually displayed, demoted to ordinary text, - then add the correction as a real user message. This keeps role alternation + Preserve only the *visible* response text, demoted to ordinary text, then + add the correction as a real user message. This keeps role alternation valid and leaves every previously cached message byte-for-byte unchanged. + + INVARIANT — raw chain-of-thought must never be serialized into replayable + message content. Streamed reasoning is display-only state: it may be shown + live, but it does not re-enter the transcript as assistant (or user) text. + An assistant turn whose content inlines its own chain-of-thought reads to + Anthropic's output classifier as reasoning-injection/prefill jailbreak, + and because the poisoned checkpoint is persisted and replayed on every + subsequent call, the session dies permanently with deterministic + "Provider returned an empty response" storms that no retry, nudge, or + empty-recovery branch can escape (July 2026: four sessions bricked this + way; every reasoning-free checkpoint that week was untouched — same + mechanism as the ~/.hermes/prefill.json incident, 20/20 blocked with + assistant-exposed CoT vs 0/20 without). The interrupted reasoning was + incomplete by definition; the model regenerates it on the retried turn. + If a future path needs to preserve interrupted thinking, carry it in a + provider-gated reasoning *field*, never in content. + INVARIANT — the scaffolding is provider-replay text, not transcript text. + ``[This response was interrupted by a user correction.]`` and its + ``Visible response before the interruption:`` header exist so the MODEL + understands its own reply was cut off. They are not prose the user wrote + or the agent said. Persisting them into ``content`` painted the raw + machinery as an assistant bubble on every reload (and merged it into the + preceding tool-call bubble), which is what made a steered transcript + unreadable. Carry the scaffolded form in the ``api_content`` sidecar -- + the exact bytes replayed to the provider -- and keep ``content`` clean. + When nothing was on screen there is no clean form at all, so the row is + marked ``display_kind="hidden"``: still replayed to the model, dropped by + every transcript surface (desktop, TUI, CLI resume), exactly like the + compaction-reference rows. """ - reasoning = str( - getattr(agent, "_current_streamed_reasoning_text", "") or "" - ).strip() visible = agent._strip_think_blocks( getattr(agent, "_current_streamed_assistant_text", "") or "" ).strip() checkpoint_parts = ["[This response was interrupted by a user correction.]"] - if reasoning: - checkpoint_parts.extend( - ["Reasoning shown before the interruption:", reasoning] - ) if visible: checkpoint_parts.extend( ["Visible response before the interruption:", visible] @@ -149,13 +175,25 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text f"{checkpoint}\n\n" f"{text}" ) - messages.append({"role": "user", "content": correction}) + # Transcript shows the user's own words; the provider replays the + # scaffolded form so it still sees the interrupted context. + messages.append( + {"role": "user", "content": text, "api_content": correction} + ) else: - messages.append({"role": "assistant", "content": checkpoint}) + entry: Dict[str, Any] = { + "role": "assistant", + "content": visible or checkpoint, + "api_content": checkpoint, + } + if not visible: + # Nothing reached the screen — this row carries no assistant prose + # at all, only the cut-off notice for the model. + entry["display_kind"] = "hidden" + messages.append(entry) messages.append({"role": "user", "content": text}) agent._current_streamed_assistant_text = "" - agent._current_streamed_reasoning_text = "" agent._stream_needs_break = True @@ -436,6 +474,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) # Continuing session — reuse the exact system prompt from the # previous turn so the Anthropic cache prefix matches. agent._cached_system_prompt = stored_prompt + # Reconstruct the cross-session-stable prefix for the early cache + # breakpoint. The static prefix is not persisted (only the full + # prompt is), so gateway surfaces that build a fresh AIAgent per + # turn would otherwise lose the two-block system layout after the + # first turn — flip-flopping the wire shape mid-conversation and + # silently degrading to the legacy single-breakpoint layout. + # + # ``reconstruct_static_prefix`` gates on ``_use_prompt_caching`` (so + # non-Anthropic routes skip the rebuild), applies the startswith + # safety gate (stored prompt bytes are never rewritten), and + # fails open to the legacy cache layout. + from agent.system_prompt import reconstruct_static_prefix + + reconstruct_static_prefix(agent, system_message=system_message) return if stored_prompt: stored_state = "stale_runtime" @@ -508,9 +560,17 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: - """Return False when the persisted Model/Provider lines are stale.""" + """Return False when the persisted runtime-identity lines are stale.""" def line_value(label: str) -> str: + """Last matching line wins. + + Safe ONLY for fields emitted in the volatile tier at the very END of + the prompt (Model / Provider / Platform). User-supplied project + context (AGENTS.md / CLAUDE.md / .cursorrules) is embedded in the + middle context tier, so a last-match scan lets project prose shadow + any field emitted EARLIER — see ``host_info_value``. + """ prefix = f"{label}:" value = "" for line in prompt.splitlines(): @@ -518,6 +578,32 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: value = line[len(prefix):].strip() return value + def host_info_value(label: str) -> str: + """Read a field from the prompt's own host-info block. + + The host-info block (``build_environment_hints``) sits in the STABLE + tier, ahead of the embedded project context files. A bare scan of the + whole prompt would therefore match a user's ``AGENTS.md`` that merely + contains a line starting with the same label, comparing runtime state + against project prose. That mismatch never clears, so the check would + reject the stored prompt on EVERY turn — rebuilding the system prompt + each message and destroying the prefix cache for the whole session, + which is far worse than the staleness this function guards against. + + Anchor on the ``User home directory:`` line that immediately precedes + the working-directory line in that block, and take the FIRST such + occurrence, so only Hermes' own emitted block can satisfy the read. + """ + prefix = f"{label}:" + lines = prompt.splitlines() + for idx, line in enumerate(lines): + if not line.startswith("User home directory:"): + continue + for candidate in lines[idx + 1: idx + 4]: + if candidate.startswith(prefix): + return candidate[len(prefix):].strip() + return "" + stored_model = line_value("Model") current_model = str(getattr(agent, "model", "") or "").strip() if stored_model and current_model and stored_model != current_model: @@ -528,6 +614,24 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: if stored_provider and current_provider and stored_provider != current_provider: return False + # Detect cwd drift: if the stored prompt was built in a different working + # directory, reuse would silently inject a stale path into the prefix cache. + # Compare against resolve_agent_cwd() — the SAME resolver used to build the + # prompt — so gateway/TUI sessions that set TERMINAL_CWD are not falsely + # rejected (they would always differ from the launch dir's os.getcwd()). + stored_cwd = host_info_value("Current working directory") + if stored_cwd: + if stored_cwd != str(resolve_agent_cwd()): + return False + + # Detect runtime-surface drift: the stored prompt records which platform it + # was built for (e.g. "desktop" vs "cli"). Reusing a desktop-built prompt on + # a terminal session (or vice versa) would inject the wrong runtime hints. + stored_platform = line_value("Platform") + current_platform = str(getattr(agent, "platform", "") or "").strip() + if stored_platform and current_platform and stored_platform != current_platform: + return False + return True @@ -690,6 +794,41 @@ def _compression_deferred_result( } +def _rewrite_system_content_blocks(system_message: dict, effective: str) -> bool: + """Rewrite a cache-decorated system message in place, keeping its blocks. + + ``apply_anthropic_cache_control`` runs once per call block, *before* the + retry loop, and splits the system prompt into ``[static prefix, volatile + tail]`` text blocks carrying the cache_control breakpoints. Assigning a bare + string over that list drops both breakpoints, so the failover retry ships + the whole system prompt uncached and re-bills it in full. + + ``rewrite_prompt_model_identity`` only touches the LAST ``Model:`` / + ``Provider:`` lines, and those live in the volatile tail — so the static + prefix stays byte-identical and its cache entry keeps matching. Returns + False when the shape is not one we can safely patch, so the caller falls + back to the plain-string assignment. + """ + content = system_message.get("content") + if not isinstance(content, list) or not content: + return False + if not all( + isinstance(part, dict) and part.get("type") == "text" for part in content + ): + return False + if len(content) == 1: + content[0]["text"] = effective + return True + if len(content) == 2: + head = content[0].get("text") or "" + if head and effective.startswith(head): + tail = effective[len(head):] + if tail: + content[1]["text"] = tail + return True + return False + + def _sync_failover_system_message(agent, api_messages, active_system_prompt): """Refresh the in-flight system message after a provider failover. @@ -712,10 +851,109 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt): effective = sp if agent.ephemeral_system_prompt: effective = (effective + "\n\n" + agent.ephemeral_system_prompt).strip() - api_messages[0]["content"] = effective + if not _rewrite_system_content_blocks(api_messages[0], effective): + api_messages[0]["content"] = effective return sp +def _ensure_cached_system_prompt_static(agent, system_message=None) -> None: + """Rebuild ``_cached_system_prompt_static`` when caching becomes active. + + Sessions restored under a cache-off primary skip the static-prefix rebuild + (gated on ``_use_prompt_caching`` at restore time). A later failover to a + cache-on provider would otherwise redecorate with ``static_system_prefix= + None`` and silently fall back to the legacy system-plus-3 layout (#72626). + + Thin wrapper over :func:`agent.system_prompt.reconstruct_static_prefix`, + which memoizes failed rebuilds so this stays cheap on the retry-loop hot + path (it runs at the top of every attempt). + """ + from agent.system_prompt import reconstruct_static_prefix + + reconstruct_static_prefix( + agent, system_message=system_message, log_label="failover redecoration" + ) + + +def _peel_moa_guidance( + messages: List[Dict[str, Any]], + guidance: Any, +) -> List[Dict[str, Any]]: + """Remove MoA reference guidance previously attached by ``_attach_reference_guidance``. + + Thin wrapper over :func:`agent.moa_loop.peel_reference_guidance` (kept + adjacent to the attach so the forward/inverse shapes evolve together). + Lazy import mirrors the module's other moa_loop touchpoints. + """ + from agent.moa_loop import peel_reference_guidance + + return peel_reference_guidance(messages, guidance) + + +def _redecorate_prompt_cache_for_provider( + agent, + api_messages: List[Dict[str, Any]], + *, + system_message=None, + moa_prepared: Optional[Dict[str, Any]] = None, +) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]: + """Strip and re-apply cache_control for the *current* provider policy. + + Decoration runs once per call block before the retry loop for the primary + provider. ``try_activate_fallback`` refreshes ``_use_prompt_caching`` / + ``_use_native_cache_layout`` but the nine failover ``continue`` paths reused + the old ``api_messages`` (#72626). Mirror ``_reapply_reasoning_echo_for_provider`` + by reshaping at the top of each retry attempt. + + The source list is the mutated in-flight request (image shrink / ASCII / + reasoning_details recoveries already applied) — never a pristine + pre-decoration snapshot. MoA guidance is peeled, the base is redecorated, + then ``rebase_prepared_request`` re-attaches guidance outside the cached + span. + """ + messages: List[Dict[str, Any]] = [ + dict(m) if isinstance(m, dict) else m for m in (api_messages or []) + ] + prepared = moa_prepared + guidance = prepared.get("guidance") if isinstance(prepared, dict) else None + if guidance: + messages = _peel_moa_guidance(messages, guidance) + + strip_anthropic_cache_control(messages) + + # Direct attribute access matches the call-block decoration site — the + # flags are unconditionally initialized on AIAgent, and a getattr + # default here would mask a real init bug as silent cache-off. + if agent._use_prompt_caching: + _ensure_cached_system_prompt_static(agent, system_message=system_message) + static = getattr(agent, "_cached_system_prompt_static", None) + messages = apply_anthropic_cache_control( + messages, + cache_ttl=agent._cache_ttl, + native_anthropic=agent._use_native_cache_layout, + static_system_prefix=static if isinstance(static, str) else None, + ) + + if ( + prepared is not None + and getattr(agent, "provider", None) == "moa" + ): + # No `and guidance` here: guidance=None is a real prepared shape + # (all-references-failed / silent degraded policy builds the + # prepared request without attaching guidance), and the MoA facade + # sends prepared["messages"] — not api_kwargs["messages"] — so the + # rebase must refresh the prepared object even when there is no + # guidance to re-attach. rebase_prepared_request handles falsy + # guidance by copying the messages and skipping the attach. + completions = getattr(getattr(agent.client, "chat", None), "completions", None) + rebase = getattr(completions, "rebase_prepared_request", None) + if callable(rebase): + prepared = rebase(prepared, messages) + messages = prepared["messages"] + + return messages, prepared + + def _apply_context_engine_selection( agent: Any, api_messages: List[Dict[str, Any]], @@ -855,6 +1093,8 @@ def run_conversation( stream_callback: Optional[callable] = None, persist_user_message: Optional[Any] = None, persist_user_timestamp: Optional[float] = None, + persist_user_display_kind: Optional[str] = None, + persist_user_display_metadata: Optional[Dict[str, Any]] = None, moa_config: Optional[dict[str, Any]] = None, ) -> Dict[str, Any]: """ @@ -873,6 +1113,13 @@ def run_conversation( synthetic prefixes. persist_user_timestamp: Optional platform event timestamp to store as metadata on that persisted user message. + persist_user_display_kind: Optional presentation type for a + synthesized user turn (``auto_continue``, ``model_switch``, …). + Display-only: transcript surfaces render the row as a timeline + event instead of a user bubble, while the model still receives + the message unchanged. + persist_user_display_metadata: Optional payload for that event + (e.g. a delegation's task count). or queuing follow-up prefetch work. Returns: @@ -915,6 +1162,8 @@ def run_conversation( stream_callback, persist_user_message, persist_user_timestamp, + persist_user_display_kind=persist_user_display_kind, + persist_user_display_metadata=persist_user_display_metadata, restore_or_build_system_prompt=_restore_or_build_system_prompt, install_safe_stdio=_install_safe_stdio, sanitize_surrogates=_sanitize_surrogates, @@ -941,6 +1190,9 @@ def run_conversation( # Commentary deduplication spans all provider continuations and tool calls # within one user turn, but must not suppress the same phrase next turn. agent._delivered_interim_texts = set() + # A configured SessionDB append failure halts only the affected turn. A + # cached gateway agent must recover on the next message if storage did. + agent._incremental_persistence_failed = False # Main conversation loop counters (pure locals consumed by the loop below). api_call_count = 0 @@ -1278,9 +1530,9 @@ def run_conversation( # # Hermes invariant: the system prompt is built ONCE per session # (cached on ``_cached_system_prompt``) and replayed verbatim on - # every turn. We send it as a single content string so the - # bytes are byte-stable across turns and upstream prompt caches - # stay warm. + # every turn. ``apply_anthropic_cache_control`` may split its stable + # prefix into content blocks on the wire, but the stored string and + # its byte-stability remain unchanged. effective_system = active_system_prompt or "" if agent.ephemeral_system_prompt: effective_system = (effective_system + "\n\n" + agent.ephemeral_system_prompt).strip() @@ -1365,19 +1617,6 @@ def run_conversation( logger=request_logger, ) - # Apply Anthropic prompt caching for Claude models on native - # Anthropic, OpenRouter, and third-party Anthropic-compatible - # gateways. Auto-detected: if ``_use_prompt_caching`` is set, - # inject cache_control breakpoints (system + last 3 messages) - # to reduce input token costs by ~75% on multi-turn - # conversations. - if agent._use_prompt_caching: - api_messages = apply_anthropic_cache_control( - api_messages, - cache_ttl=agent._cache_ttl, - native_anthropic=agent._use_native_cache_layout, - ) - # Safety net: strip orphaned tool results / add stubs for missing # results before sending to the API. Runs unconditionally — not # gated on context_compressor — so orphans from session loading or @@ -1436,6 +1675,48 @@ def run_conversation( # the OpenAI SDK. Sanitizing here prevents the 3-retry cycle. _sanitize_messages_surrogates(api_messages) + # NOTE (empty-content class fix): no send-time pad loop here. The + # single owner for "never send a turn strict wire validation rejects + # as empty" is ``repair_empty_non_final_messages``, which runs inside + # ``_sanitize_api_messages`` above — the unconditional pre-send + # chokepoint shared with the summary path. Its placeholder is + # non-whitespace, so it survives the whitespace-normalization pass + # regardless of ordering (a single-space pad here previously had to + # be sequenced after normalization to survive, forking the concept). + + # Apply Anthropic prompt caching for Claude models on native + # Anthropic, OpenRouter, and third-party Anthropic-compatible + # gateways. Auto-detected: if ``_use_prompt_caching`` is set, inject + # cache_control breakpoints for the static system prefix, full system + # prompt, and last two messages (or the legacy system-and-3 layout + # when no static prefix is available). + # + # Runs LAST, after every message mutation above. Marking earlier + # defeats the prefix stability the mutations exist to create: + # ``_apply_cache_marker`` rewrites ``content`` from a plain string + # into a ``[{"type": "text", ...}]`` block, so the marked messages + # no longer match the ``isinstance(content, str)`` test in the + # whitespace-normalization pass and silently keep their raw + # leading/trailing whitespace. A tool result ending in "\n" is + # therefore sent unstripped while it sits in the last-3 window and + # stripped once it rolls out of it — the same message, different + # bytes on consecutive turns, which breaks the prefix match at + # exactly the point the breakpoints were meant to protect. Marking + # last also keeps breakpoints off messages that the orphan sweep or + # the thinking-only drop is about to remove or merge away. + if agent._use_prompt_caching: + _static_system_prefix = getattr(agent, "_cached_system_prompt_static", None) + api_messages = apply_anthropic_cache_control( + api_messages, + cache_ttl=agent._cache_ttl, + native_anthropic=agent._use_native_cache_layout, + static_system_prefix=( + _static_system_prefix + if isinstance(_static_system_prefix, str) + else None + ), + ) + # Build a persistent-MoA request before measuring compression pressure. # MoA reference output is injected into the aggregator prompt, but it # is deliberately ephemeral and therefore absent from ``messages``. @@ -1766,6 +2047,18 @@ def run_conversation( # unless the active provider needs it) so the fallback request # isn't sent with stale, primary-shaped reasoning fields. agent._reapply_reasoning_echo_for_provider(api_messages) + # Same story for prompt-cache decoration (#72626): try_activate_ + # fallback refreshes the policy flags, but the decorated list + # still carries the primary's breakpoints (or none). Strip and + # re-render for the current provider before building kwargs. + api_messages, _moa_prepared_request = ( + _redecorate_prompt_cache_for_provider( + agent, + api_messages, + system_message=system_message, + moa_prepared=_moa_prepared_request, + ) + ) api_kwargs = agent._build_api_kwargs(api_messages) if agent._force_ascii_payload: _sanitize_structure_non_ascii(api_kwargs) @@ -2231,6 +2524,17 @@ def run_conversation( _backoff_touch_counter = 0 while time.time() < sleep_end: if agent._interrupt_requested: + # A redirect uses the interrupt machinery to cancel + # only the live request. Aborting the retry here + # with clear_interrupt() would DESTROY the pending + # correction and kill the turn with "Operation + # interrupted" — the exact mid-stream steer loss + # users hit when a redirect lands during provider + # backoff. Rebuild from the correction instead, + # mirroring the InterruptedError handler. + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + break agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True) _interrupt_text = f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries})." close_interrupted_tool_sequence(messages, _interrupt_text) @@ -2252,6 +2556,8 @@ def run_conversation( f"retry backoff ({retry_count}/{max_retries}), " f"{int(sleep_end - time.time())}s remaining" ) + if _retry.restart_with_redirected_messages: + break # rebuild this iteration from the correction continue # Retry the API call agent._turn_received_provider_response = True @@ -2557,10 +2863,27 @@ def run_conversation( ) if assistant_message is not None and not _trunc_has_tool_calls: length_continue_retries += 1 - interim_msg = agent._build_assistant_message(assistant_message, finish_reason) - messages.append(interim_msg) - if assistant_message.content: - truncated_response_parts.append(assistant_message.content) + # An EMPTY partial-stream stub (stream dropped + # mid tool-call before any text was delivered) + # must not be appended as an interim assistant + # message: it would serialize as + # {"role": "assistant", "content": ""}, and + # strict providers (Moonshot/Kimi via OpenRouter) + # reject empty assistant content with HTTP 400 + # ("message ... with role 'assistant' must not be + # empty") on the very next replay — permanently + # poisoning the session history. There is no + # partial text to continue from anyway, so only + # the continuation user-message is appended. + _is_empty_partial_stub = ( + getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID + and not getattr(assistant_message, "content", None) + ) + if not _is_empty_partial_stub: + interim_msg = agent._build_assistant_message(assistant_message, finish_reason) + messages.append(interim_msg) + if assistant_message.content: + truncated_response_parts.append(assistant_message.content) if length_continue_retries < 4: _is_partial_stream_stub = ( @@ -3455,7 +3778,7 @@ def run_conversation( agent._buffer_vprint("🔐 Vertex AI token refreshed after 401. Retrying request...") continue if ( - agent.api_mode == "chat_completions" + agent.api_mode in ("chat_completions", "anthropic_messages") and agent.provider == "nous" and status_code == 401 and not _retry.nous_auth_retry_attempted @@ -3727,6 +4050,12 @@ def run_conversation( # Check for interrupt before deciding to retry if agent._interrupt_requested: + # Preserve a pending redirect (mid-stream correction): the + # user is steering, not stopping. Rebuild the turn from the + # correction instead of aborting with a dead-end interrupt. + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + break agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True) _interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})." close_interrupted_tool_sequence(messages, _interrupt_text) @@ -4963,6 +5292,12 @@ def run_conversation( _backoff_touch_counter = 0 while time.time() < sleep_end: if agent._interrupt_requested: + # Same preserve-redirect rule as the retry-wait above: + # a steering correction must survive backoff, not die + # as "Operation interrupted". + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + break agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True) _interrupt_text = f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries})." close_interrupted_tool_sequence(messages, _interrupt_text) @@ -4984,6 +5319,11 @@ def run_conversation( f"error retry backoff ({retry_count}/{max_retries}), " f"{int(sleep_end - time.time())}s remaining" ) + if _retry.restart_with_redirected_messages: + # Leave the retry loop — the check right below rebuilds this + # iteration from the correction instead of re-firing the + # stale request. + break if _retry.restart_with_redirected_messages: # The cancelled request produced no valid assistant item. Reuse the @@ -5329,6 +5669,14 @@ def run_conversation( args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200] logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview) + # Uniquify duplicate tool-call ids BEFORE any downstream + # consumer (validation error paths, dispatch, history build, + # Responses item-id derivation). Models that reuse one id for + # different calls in a batch otherwise lose the later call's + # result: the pre-API sanitizer keeps only the first + # call/result pair per id. See _uniquify_tool_call_ids. + agent._uniquify_tool_call_ids(assistant_message.tool_calls) + # Validate tool call names - detect model hallucinations # Repair mismatched tool names before validating for tc in assistant_message.tool_calls: @@ -5617,6 +5965,10 @@ def run_conversation( # flag so it can fire again if the model goes empty on # a LATER tool round. agent._post_tool_empty_retried = False + # A landed tool call means any earlier dropped-tool-call stall + # was recovered — refresh that budget too so it guards each + # stall independently rather than capping the whole run. + agent._dropped_toolcall_retries = 0 previous_msg = messages[-1] if messages else None current_interim_visible = agent._interim_assistant_visible_text(assistant_msg) @@ -5633,8 +5985,6 @@ def run_conversation( and previous_interim_visible == current_interim_visible ) messages.append(assistant_msg) - if not duplicate_previous_interim: - agent._emit_interim_assistant_message(assistant_msg) # Mixed batch: error-result the invalid calls and strip them # from the execution set. The assistant message above keeps @@ -5656,13 +6006,17 @@ def run_conversation( if tc.function.name in agent.valid_tool_names ] + _tool_turn_persisted = None try: # Persist the assistant tool-call turn before any tool # side effects run. If a destructive tool restarts or # terminates Hermes mid-turn, resume logic still sees the # exact tool-call block that already executed. - agent._flush_messages_to_session_db(messages, conversation_history) + _tool_turn_persisted = agent._flush_messages_to_session_db( + messages, conversation_history + ) except Exception as exc: + _tool_turn_persisted = False logger.warning( "Incremental tool-call persistence failed before execution " "(session=%s): %s", @@ -5670,6 +6024,22 @@ def run_conversation( exc, ) + if _tool_turn_persisted is False: + # The canonical append failed. Do not project the row or + # run side-effecting tools from state that exists only in + # this process. Breaking also avoids retrying the same + # unpersisted turn until the iteration budget is exhausted. + _turn_exit_reason = "session_persistence_failed" + final_response = "" + failed = True + break + + # A UI must never observe an assistant/tool-call row that is + # still only an ephemeral in-memory projection. Emit interim + # commentary only after the canonical SessionDB append above. + if not duplicate_previous_interim: + agent._emit_interim_assistant_message(assistant_msg) + # Close any open streaming display (response box, reasoning # box) before tool execution begins. Intermediate turns may # have streamed early content that opened the response box; @@ -5684,6 +6054,15 @@ def run_conversation( agent._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count) + if getattr(agent, "_incremental_persistence_failed", False): + # A tool result could not be made canonical. Do not send + # the in-memory result back to the model or project any + # later events from this turn. + _turn_exit_reason = "session_persistence_failed" + final_response = "" + failed = True + break + if agent._tool_guardrail_halt_decision is not None: decision = agent._tool_guardrail_halt_decision _turn_exit_reason = "guardrail_halt" @@ -5865,11 +6244,26 @@ def run_conversation( # Save session log incrementally (so progress is visible even if interrupted) agent._session_messages = messages + # Touch activity before continuing so the gateway's + # inactivity monitor never sees a stale timestamp + # between tool completion and the start of the next + # API call. Without this, a tool-call result (which + # takes ~0s to process) followed by slow post-tool + # processing (compression, persist) and a slow + # follow-up API call can exceed the gateway inactivity + # timeout (HERMES_AGENT_TIMEOUT, default 1800s) and the + # gateway kills the session before the next activity + # touch fires (#69559, #69131). + agent._touch_activity(f"tool results posted, continuing iteration #{api_call_count}") # Continue loop for next response continue else: - # No tool calls - this is the final response + # No tool calls - this is the final response. + # (Dropped tool-call recovery — finish_reason=="tool_calls" with + # an empty tool_calls array — is handled at the finalization + # chokepoint below, after final_msg is built, so it catches + # every path that reaches turn finalization, not just this one.) final_response = assistant_message.content or "" # Fix: unmute output when entering the no-tool-call branch @@ -6141,7 +6535,28 @@ def run_conversation( ". No fallback providers configured.") ) - final_response = "(empty)" + # Deliver a labeled reasoning excerpt instead of a bare + # "(empty)" when the model DID think but never produced + # visible text. This is delivery-only: the persisted + # assistant message above keeps the "(empty)" sentinel + # (its replay semantics prevent empty-response loops), + # and raw chain-of-thought is never promoted to a normal + # answer earlier in the ladder — prefill continuation, + # empty-content retries, and provider fallback all run + # first. Only at this terminal, where the alternative is + # returning nothing, is showing the model's own reasoning + # (clearly labeled as such) strictly more useful. + # Idea credit: PR #48795 (@ligl0325). + if reasoning_text: + final_response = ( + "⚠️ The model produced only internal reasoning and " + "no final answer, despite retries" + + (" and fallback" if agent._fallback_chain else "") + + ". Its last reasoning, which may contain the " + "answer:\n\n" + reasoning_preview + ) + else: + final_response = "(empty)" break # Reset retry counter/signature on successful content @@ -6201,6 +6616,64 @@ def run_conversation( final_msg = agent._build_assistant_message(assistant_message, finish_reason) + # ── Dropped tool-call recovery (copilot/Claude) ──────── + # Some providers (observed: claude-opus-4.8 / claude-sonnet-4.5 + # on GitHub Copilot, ~2026-07) return finish_reason="tool_calls" + # while the parsed tool_calls array is empty — the model + # signalled it wanted to act but the payload shipped no call. + # Reaching finalization with that mismatch means the turn is + # about to end with the task unstarted (the narration, which may + # be in content or only in the reasoning field, gets treated as + # the final answer). Re-prompt (bounded to 3 CONSECUTIVE stalls; + # the budget resets after any successful tool round) to make the + # model emit the call instead of exiting. finish_reason="stop" + # text finishes never enter this guard. + if ( + finish_reason == "tool_calls" + and not assistant_message.tool_calls + and getattr(agent, "_dropped_toolcall_retries", 0) < 3 + ): + agent._dropped_toolcall_retries = getattr(agent, "_dropped_toolcall_retries", 0) + 1 + logger.warning( + "finish_reason=tool_calls with empty tool_calls array " + "(narration only) — re-prompting to emit the call " + "(retry %d/3, model=%s provider=%s)", + agent._dropped_toolcall_retries, agent.model, agent.provider, + ) + agent._emit_status( + "↻ Model signaled a tool call but sent none — " + f"re-prompting ({agent._dropped_toolcall_retries}/3)" + ) + # Both halves of the re-prompt pair are ephemeral recovery + # scaffolding (mirrors the empty-response nudge pattern): + # the interim narration-only assistant turn exists solely to + # keep role alternation valid for the nudge, and the nudge + # exists solely to drive the retry. Flag both so the + # persistence layer never writes them to the durable + # transcript and the finalization pop below can strip an + # unanswered tail pair. A recovered (answered) pair stays + # buried mid-list in live memory but is skipped by the + # flush regardless of position. + final_msg["_dropped_toolcall_nudge"] = True + messages.append(final_msg) + messages.append({ + "role": "user", + "content": ( + "Your previous turn indicated a tool call but none was " + "included. Do not narrate a plan or restate intent — issue " + "the actual tool call now to continue the task." + ), + "_dropped_toolcall_nudge": True, + }) + agent._session_messages = messages + final_response = None + continue + + # Reached finalization without the dropped-tool-call mismatch — + # a genuine turn end. Clear the consecutive-stall budget so the + # next turn starts fresh. + agent._dropped_toolcall_retries = 0 + # Pop thinking-only prefill and empty-response retry # scaffolding before appending either a final response or a # verification-stop follow-up. These internal turns are only @@ -6213,6 +6686,7 @@ def run_conversation( messages[-1].get("_thinking_prefill") or messages[-1].get("_empty_recovery_synthetic") or messages[-1].get("_empty_terminal_sentinel") + or messages[-1].get("_dropped_toolcall_nudge") ) ): messages.pop() diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index e4990fdf5e7..9cbdcd34944 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -708,7 +708,7 @@ class CopilotACPClient: if block_error: raise PermissionError(block_error) try: - content = path.read_text() + content = path.read_text(encoding="utf-8") except FileNotFoundError: content = "" line = params.get("line") @@ -736,7 +736,7 @@ class CopilotACPClient: if denied: raise PermissionError(denied) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(str(params.get("content") or "")) + path.write_text(str(params.get("content") or ""), encoding="utf-8") response = { "jsonrpc": "2.0", "id": message_id, diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 516e7f61db6..08b0c0ea6b9 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -594,6 +594,14 @@ class CredentialPool: # Re-armed to None on every successful selection so a recover→re-exhaust # transition logs promptly instead of being swallowed by a stale window. self._last_no_entries_log_at: Optional[float] = None + # #70401: consecutive mark_exhausted_and_rotate() calls whose supplied + # credential identity matched no pool entry (OAuth wrappers whose + # runtime key rotates, entries pruned by another process, ...). These + # rotations mark nothing exhausted, so without a cap the pool can + # never converge to "no available entries" and the caller's 401 retry + # loop runs unbounded and non-interruptible. Reset whenever a real + # entry is identified or an escape path returns None. + self._unmatched_rotation_streak: int = 0 def has_credentials(self) -> bool: with self._lock: @@ -1584,7 +1592,13 @@ class CredentialPool: def select(self) -> Optional[PooledCredential]: with self._lock: - return self._select_unlocked() + entry = self._select_unlocked() + if entry is not None: + # A normal (non-recovery) selection starts a fresh episode — + # don't let a leftover unmatched-rotation streak from an old + # failure trip the #70401 bound early next time. + self._unmatched_rotation_streak = 0 + return entry def _available_entries(self, *, clear_expired: bool = False, refresh: bool = False) -> List[PooledCredential]: """Return entries not currently in exhaustion cooldown. @@ -1809,6 +1823,35 @@ class CredentialPool: # (rotated away, or a wrapper whose runtime key differs). # Falling through to current()/_select_unlocked() would mark an # innocent healthy key exhausted for the full cooldown TTL. + # + # #70401: this branch must still be BOUNDED. With OAuth-token + # auth the upstream 401's key hint never matches any entry's + # ``runtime_api_key``, so every retry lands here, nothing is + # ever marked exhausted, and the pool can never reach the + # "no available entries" state — the caller retries the same + # dead token forever (~6/sec, starving the event loop so chat + # interrupts are never processed). The single-entry case + # below already escapes; multi-entry pools could still + # ping-pong A→B→A indefinitely without marking anything. + # Cap consecutive no-mark rotations at one full lap of the + # available entries: past that, every candidate has been + # handed back at least once without recovery, so stop + # guessing and surface the error (no cooldown is written for + # anybody — healthy keys stay available for the next turn). + self._unmatched_rotation_streak += 1 + available_count = len(self._available_entries()) + if self._unmatched_rotation_streak > max(available_count, 1): + logger.warning( + "credential pool: failed credential identity matched no " + "%s entry for %d consecutive rotations (pool size %d) — " + "surfacing the error instead of rotating again", + self.provider, + self._unmatched_rotation_streak, + available_count, + ) + self._unmatched_rotation_streak = 0 + self._current_id = None + return None logger.info( "credential pool: failed credential identity matched no %s " "entry; rotating without marking any credential exhausted", @@ -1821,9 +1864,13 @@ class CredentialPool: # entry reports a successful recovery without changing # the credential, so the caller retries the same 401 # indefinitely. Let fallback/error propagation proceed. + self._unmatched_rotation_streak = 0 self._current_id = None return None return next_entry + # A real entry was identified — any prior unmatched-rotation + # streak is stale (this mark WILL advance pool state). + self._unmatched_rotation_streak = 0 if entry is None: entry = self._current_unlocked() or self._select_unlocked() if entry is None: diff --git a/agent/credential_sources.py b/agent/credential_sources.py index 18f0823ba84..32cd5e01a80 100644 --- a/agent/credential_sources.py +++ b/agent/credential_sources.py @@ -164,7 +164,7 @@ def _remove_env_source(provider: str, removed) -> RemovalResult: if env_path.exists(): env_in_dotenv = any( line.strip().startswith(f"{env_var}=") - for line in env_path.read_text(errors="replace").splitlines() + for line in env_path.read_text(errors="replace", encoding="utf-8").splitlines() ) except OSError: pass diff --git a/agent/curator.py b/agent/curator.py index dc908fc3593..975ed102de0 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -325,7 +325,7 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0, "seeded": 0} - for row in _u.agent_created_report(): + for row in _u.curated_report(): counts["checked"] += 1 name = row["name"] if row.get("pinned"): @@ -1472,15 +1472,16 @@ def _render_report_markdown(p: Dict[str, Any]) -> str: # --------------------------------------------------------------------------- def _render_candidate_list() -> str: - """Human/agent-readable list of agent-created skills with usage stats.""" - rows = skill_usage.agent_created_report() + """Human/agent-readable list of curator-managed skills with usage stats.""" + rows = skill_usage.curated_report() if not rows: - return "No agent-created skills to review." + return "No curator-managed skills to review." cron_referenced = _cron_referenced_skills() - lines = [f"Agent-created skills ({len(rows)}):\n"] + lines = [f"Curator-managed skills ({len(rows)}):\n"] for r in rows: lines.append( f"- {r['name']} " + f"provenance={r.get('provenance', 'agent')} " f"state={r['state']} " f"pinned={'yes' if r.get('pinned') else 'no'} " f"cron={'yes' if r['name'] in cron_referenced else 'no'} " @@ -1533,7 +1534,7 @@ def run_curator_review( if dry_run: # Count candidates without mutating state. try: - report = skill_usage.agent_created_report() + report = skill_usage.curated_report() counts = { "checked": len(report), "marked_stale": 0, @@ -1586,7 +1587,7 @@ def run_curator_review( nonlocal auto_summary # Snapshot skill state BEFORE the LLM pass so the report can diff. try: - before_report = skill_usage.agent_created_report() + before_report = skill_usage.curated_report() except Exception: before_report = [] before_names = {r.get("name") for r in before_report if isinstance(r, dict)} @@ -1612,7 +1613,7 @@ def run_curator_review( state2["last_run_duration_seconds"] = elapsed state2["last_run_summary"] = final_summary try: - after_report = skill_usage.agent_created_report() + after_report = skill_usage.curated_report() except Exception: after_report = [] try: @@ -1699,7 +1700,7 @@ def run_curator_review( try: rename_lines = _build_rename_summary( before_names=before_names, - after_report=skill_usage.agent_created_report(), + after_report=skill_usage.curated_report(), tool_calls=llm_meta.get("tool_calls", []) or [], model_final=llm_meta.get("final", "") or "", ) @@ -1717,7 +1718,7 @@ def run_curator_review( # reporting bug never breaks the curator itself. Report path is # recorded in state so `hermes curator status` can point at it. try: - after_report = skill_usage.agent_created_report() + after_report = skill_usage.curated_report() except Exception: after_report = [] try: diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 33c2f545856..27df08f58ea 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -159,6 +159,14 @@ _RATE_LIMIT_PATTERNS = [ "throttlingexception", "too many concurrent requests", "servicequotaexceededexception", + # Generic throttle prefix — Bedrock (and some proxies) surface throttling + # as "Throttling error: Too many tokens, please wait before trying + # again." Without this entry the message falls through to the + # context-overflow list (which contains "too many tokens") and the retry + # loop compresses a healthy session instead of backing off. Matched + # BEFORE _CONTEXT_OVERFLOW_PATTERNS in the message-only path, so the + # throttle wins. (port of anomalyco/opencode#37848's exclusion guard) + "throttling", ] # Patterns that indicate provider-side overload, NOT a per-credential rate @@ -212,6 +220,12 @@ _PAYLOAD_TOO_LARGE_PATTERNS = [ "request entity too large", "payload too large", "error code: 413", + # Anthropic's structured 413 error type. Normally arrives with an HTTP + # 413 status (handled by the status path), but aggregators/proxies can + # re-wrap it into a plain message with no status attribute — route it to + # the same compression recovery. (port of anomalyco/opencode#37848) + "request_too_large", + "request exceeds the maximum size", ] # Image-size patterns. Matched against 400 bodies (not 413) because most @@ -298,6 +312,10 @@ _CONTEXT_OVERFLOW_PATTERNS = [ "max input token", "input token", "exceeds the maximum number of input tokens", + # Together/Fireworks-style: "Input length 131393 exceeds the maximum + # allowed input length of 131040 tokens." No other pattern in this list + # matches that wording. (port of anomalyco/opencode#37848) + "maximum allowed input length", ] # Model not found patterns @@ -321,6 +339,30 @@ _MODEL_NOT_FOUND_PATTERNS = [ "no endpoints found that support tool use", ] +# Malformed-message-array 400s. Deterministic request-shape rejections that +# describe the *transcript* being invalid, not a parameter. The canonical +# case: a stream dies mid-response and Hermes persists a content-less +# assistant stub; on the next turn the Anthropic message schema (and the +# litellm/Bedrock proxies in front of it) reject the whole request with +# "all messages must have non-empty content except for the optional final +# assistant message" / errorCode INVALID_REQUEST_BODY +# These are NOT context overflow — the input may be tiny — but a large +# session used to mis-route them into the compression loop via the generic +# "400 + large session" heuristic below, ending in "Cannot compress further" +# every retry (the input is unchanged, so compression cannot help). Match +# the message-shape signals explicitly and fail fast as a format_error so the +# loop stops looping. The empty-stub creation is the root cause (fixed in +# chat_completion_helpers); this pattern stops the misclassification symptom +# for transcripts that already contain a poisoned stub. +_INVALID_MESSAGE_BODY_PATTERNS = [ + "must have non-empty content", + "messages must have non-empty", + "invalid_request_body", + "text content blocks must be non-empty", + "content field is required", + "messages: at least one message is required", +] + # Request-validation patterns — the request is malformed and will fail # identically on every retry. Some OpenAI-compatible gateways (notably # codex.nekos.me) return these as 5xx instead of the standard 4xx, which @@ -1271,6 +1313,33 @@ def _classify_400( should_fallback=True, ) + # Malformed message array (empty-content assistant stub, etc.). Must be + # checked BEFORE context_overflow: the input can be tiny, so the generic + # "400 + large session" heuristic would otherwise mis-route it into the + # compression loop and thrash until "Cannot compress further" on every + # retry (the request is unchanged, so compression cannot fix it). This is + # a deterministic request-shape rejection — fail fast as a non-retryable + # format_error and fall back. Checked against the message text AND the + # structured error code, since proxies (litellm/Bedrock) surface the + # signal in errorCode=INVALID_REQUEST_BODY. + if ( + any(p in error_msg for p in _INVALID_MESSAGE_BODY_PATTERNS) + or error_code_lower == "invalid_request_body" + ): + logger.warning( + "Malformed message array 400 (invalid request body) classified as " + "format_error, NOT context overflow — failing fast + falling back " + "instead of entering the compression loop. This usually means an " + "empty-content assistant stub is in the transcript; num_messages=%s " + "approx_tokens=%s. error=%.200s", + num_messages, approx_tokens, error_msg, + ) + return result_fn( + FailoverReason.format_error, + retryable=False, + should_fallback=True, + ) + # Empty-provider-response advisories must not enter compression. They # often mention "max_tokens" as a possible cause and used to match the # bare overflow pattern, then thrash compress until "Cannot compress @@ -1331,6 +1400,18 @@ def _classify_400( # Responses API (and some providers) use flat body: {"message": "..."} if not err_body_msg: err_body_msg = str(body.get("message") or "").strip().lower() + # litellm / Bedrock proxies use a custom shape: {"errorMessage": "...", + # "errorCode": "...", "errorArgs": {"reason": "..."}}. Without these + # keys err_body_msg stays "" and a long, descriptive rejection is + # wrongly treated as a "generic" (bare) error below, which — on a + # large session — mis-routes into the compression loop. Recognize + # them so the is_generic heuristic sees the real message length. + if not err_body_msg: + err_body_msg = str(body.get("errorMessage") or "").strip().lower() + if not err_body_msg: + _args = body.get("errorArgs") + if isinstance(_args, dict): + err_body_msg = str(_args.get("reason") or "").strip().lower() is_generic = len(err_body_msg) < 30 or err_body_msg in {"error", ""} # Absolute token/message-count thresholds are only a proxy for smaller # context windows. Large-context sessions can have many messages while @@ -1629,7 +1710,7 @@ def _extract_error_code(body: dict) -> str: return nested_code # Top-level code - code = body.get("code") or body.get("error_code") or "" + code = body.get("code") or body.get("error_code") or body.get("errorCode") or "" if isinstance(code, (str, int)): text = str(code).strip() if text and text != "400": @@ -1649,6 +1730,16 @@ def _extract_message(error: Exception, body: dict) -> str: msg = body.get("message", "") if isinstance(msg, str) and msg.strip(): return msg.strip()[:500] + # litellm / Bedrock proxy shape: {"errorMessage": "...", + # "errorArgs": {"reason": "..."}}. + msg = body.get("errorMessage", "") + if isinstance(msg, str) and msg.strip(): + return msg.strip()[:500] + args = body.get("errorArgs") + if isinstance(args, dict): + reason = args.get("reason", "") + if isinstance(reason, str) and reason.strip(): + return reason.strip()[:500] # Fallback to str(error) return str(error)[:500] diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index b4f6e6386e7..bb53e32b2ce 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -163,6 +163,42 @@ _FREE_TIER_GUIDANCE = ( ) +def is_standard_key_auth_error( + status: int, error_message: str, reason: str = "" +) -> bool: + """Return True when a Gemini 401 indicates Google rejected the key TYPE. + + Google began rejecting unrestricted legacy "Standard" Google Cloud API + keys on the Gemini API on June 19, 2026, and ALL Standard keys stop + working in September 2026. The rejection surfaces as a misleading 401 + telling the user to supply an OAuth 2 access token ("Request had invalid + authentication credentials. Expected OAuth 2 access token, login cookie + or other valid authentication credential."), optionally carrying + ``google.rpc.ErrorInfo`` reason ``ACCESS_TOKEN_TYPE_UNSUPPORTED``. + + Scoped narrowly so a plain bad key (reason ``API_KEY_INVALID``, + "API key not valid") keeps its existing message. + """ + if status != 401: + return False + if reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED": + return True + return "expected oauth 2 access token" in (error_message or "").lower() + + +_STANDARD_KEY_GUIDANCE = ( + "\n\nGoogle Gemini rejected this API key's type — you do NOT need OAuth. " + "Google began rejecting legacy 'Standard' Google Cloud keys for the " + "Gemini API on June 19, 2026, and all Standard keys stop working in " + "September 2026. Open https://aistudio.google.com/api-keys, check the " + "key's type and status, and create a replacement Gemini API key (or, as " + "a temporary bridge, restrict the Standard key to " + "generativelanguage.googleapis.com). Then update GEMINI_API_KEY / " + "GOOGLE_API_KEY in ~/.hermes/.env and restart your session. " + "Details: https://ai.google.dev/gemini-api/docs/api-key" +) + + class GeminiAPIError(Exception): """Error shape compatible with Hermes retry/error classification.""" @@ -824,6 +860,12 @@ def gemini_http_error( if status == 429 and is_free_tier_quota_error(err_message or body_text): message = message + _FREE_TIER_GUIDANCE + # Legacy "Standard" Google Cloud key rejection (June 19, 2026 onward) -> + # Google's raw 401 misleadingly tells the user to use OAuth. Append the + # actual fix (mint a new Gemini API key in AI Studio). + if is_standard_key_auth_error(status, err_message or body_text, reason): + message = message + _STANDARD_KEY_GUIDANCE + return GeminiAPIError( message, code=code, diff --git a/agent/gemini_schema.py b/agent/gemini_schema.py index b0985422fbb..665fd79a37e 100644 --- a/agent/gemini_schema.py +++ b/agent/gemini_schema.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from typing import Any, Dict # Gemini's ``FunctionDeclaration.parameters`` field accepts the ``Schema`` @@ -76,15 +77,31 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]: # Gemini's Schema validator requires every ``enum`` entry to be a string, # even when the parent ``type`` is ``integer`` / ``number`` / ``boolean``. - # OpenAI / OpenRouter / Anthropic accept typed enums (e.g. Discord's - # ``auto_archive_duration: {type: integer, enum: [60, 1440, 4320, 10080]}``), - # so we only drop the ``enum`` when it would collide with Gemini's rule. - # Keeping ``type: integer`` plus the human-readable description gives the - # model enough guidance; the tool handler still validates the value. + # Preserve those constraints by stringifying scalar values while keeping + # the declared type intact; Gemini uses the strings as schema metadata and + # still emits typed tool arguments at runtime. enum_val = cleaned.get("enum") type_val = cleaned.get("type") if isinstance(enum_val, list) and type_val in {"integer", "number", "boolean"}: - if any(not isinstance(item, str) for item in enum_val): + stringified = [] + for item in enum_val: + if isinstance(item, str): + value = item + elif isinstance(item, bool): + value = "true" if item else "false" + elif ( + isinstance(item, (int, float)) + and not isinstance(item, bool) + and math.isfinite(item) + ): + value = str(item) + else: + continue + if value not in stringified: + stringified.append(value) + if stringified: + cleaned["enum"] = stringified + else: cleaned.pop("enum", None) # Gemini validates ``required`` strictly against the same node's diff --git a/agent/iteration_budget.py b/agent/iteration_budget.py index 213b97c0226..7d50026c170 100644 --- a/agent/iteration_budget.py +++ b/agent/iteration_budget.py @@ -2,7 +2,7 @@ Extracted from ``run_agent.py``. Each ``AIAgent`` instance (parent or subagent) holds an :class:`IterationBudget`; the parent's cap comes from -``max_iterations`` (default 90), each subagent's cap comes from +``max_iterations`` (default 500), each subagent's cap comes from ``delegation.max_iterations`` (default 50). ``run_agent`` re-exports ``IterationBudget`` so existing @@ -18,7 +18,7 @@ class IterationBudget: """Thread-safe iteration counter for an agent. Each agent (parent or subagent) gets its own ``IterationBudget``. - The parent's budget is capped at ``max_iterations`` (default 90). + The parent's budget is capped at ``max_iterations`` (default 500). Each subagent gets an independent budget capped at ``delegation.max_iterations`` (default 50) — this means total iterations across parent + subagents can exceed the parent's cap. diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 21fae396d9d..173816c8cbd 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -388,10 +388,10 @@ def _maybe_apply_moa_cache_control( Reuses the SAME policy function as the main agent loop (``anthropic_prompt_cache_policy``) resolved against the slot's own - provider/base_url/api_mode/model, and the SAME breakpoint layout - (``apply_anthropic_cache_control``, system_and_3). This keeps advisor and - aggregator calls decorated exactly like an acting agent on that provider - would be — no MoA-specific caching logic to drift. + provider/base_url/api_mode/model and shared marker helper + (``apply_anthropic_cache_control``). MoA has no per-session static prefix, + so it uses the helper's legacy system-and-3 fallback without carrying a + separate caching strategy. Returns the messages unchanged on any resolution error or when the policy says the route doesn't honor markers. @@ -480,10 +480,11 @@ def _run_reference( reserve_output_tokens=max_tokens, context_length_cache=context_length_cache, ) - # Apply the same Anthropic-style prompt-caching decoration the main - # agent loop applies (system_and_3 breakpoints). The advisory view is - # append-only across iterations (new turns append before the trailing - # synthetic marker), so on cache-honoring routes (Claude via + # Apply the Anthropic-style prompt-caching decoration used by the main + # agent loop. This fixed reference prompt has no session-specific + # prefix split, so the helper uses its legacy system-and-3 fallback. + # The advisory view is append-only across iterations (new turns append + # before the trailing synthetic marker), so on cache-honoring routes (Claude via # OpenRouter/native, MiniMax, Qwen/DashScope) iteration N+1's prefix # replays iteration N's cached prefix. Without this, Claude advisors # served ZERO cache reads across an entire benchmark run (measured: @@ -1336,6 +1337,63 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str agg_messages.append({"role": "user", "content": guidance}) +def peel_reference_guidance( + messages: list[dict[str, Any]], + guidance: Any, +) -> list[dict[str, Any]]: + """Remove reference guidance previously attached by ``_attach_reference_guidance``. + + Exact inverse of the three attach shapes above (string merge, trailing + text part, appended user message) — kept adjacent so the two evolve + together; a drifting separator or shape would make the peel silently + no-op and let a cache breakpoint land on the turn-varying guidance + block (the bug class #72626 fixes). + + Used by the failover redecoration chokepoint: redecoration must run on + the base transcript so the last cache breakpoint does not land on the + guidance; callers then rebase via ``rebase_prepared_request``. + + Returns a new list (input list and its messages are not mutated). + """ + if not guidance or not messages: + return messages + guidance_text = str(guidance) + last = messages[-1] + if not isinstance(last, dict) or last.get("role") != "user": + return messages + content = last.get("content") + if content == guidance_text: + # Attach shape (c): guidance was appended as its own user message. + return list(messages[:-1]) + suffix = "\n\n" + guidance_text + if isinstance(content, str) and content.endswith(suffix): + # Attach shape (a): merged into a trailing string user turn. + peeled = dict(last) + peeled["content"] = content[: -len(suffix)] + return [*messages[:-1], peeled] + if isinstance(content, list) and content: + last_part = content[-1] + if isinstance(last_part, dict) and last_part.get("type", "text") == "text": + text = last_part.get("text") or "" + if text == suffix or text == guidance_text: + # Attach shape (b): guidance rode as its own trailing part. + peeled = dict(last) + peeled["content"] = list(content[:-1]) + if not peeled["content"]: + # The guidance part was the only content — mirror the + # string shape (c) and drop the whole message rather + # than leaving an empty-content user turn behind. + return list(messages[:-1]) + return [*messages[:-1], peeled] + if text.endswith(suffix): + new_part = dict(last_part) + new_part["text"] = text[: -len(suffix)] + peeled = dict(last) + peeled["content"] = [*content[:-1], new_part] + return [*messages[:-1], peeled] + return messages + + class MoAChatCompletions: """OpenAI-chat-compatible facade where the aggregator is the acting model.""" diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 288083628e0..296fe0aedca 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -215,6 +215,7 @@ DEFAULT_CONTEXT_LENGTHS = { # OpenRouter-prefixed models resolve via OpenRouter live API or models.dev. "claude-fable-5": 1000000, "claude-fable": 1000000, + "claude-opus-5": 1000000, "claude-sonnet-5": 1000000, "claude-opus-4-8": 1000000, "claude-opus-4.8": 1000000, diff --git a/agent/nous_rate_guard.py b/agent/nous_rate_guard.py index 415d367ca17..0234eef2ea2 100644 --- a/agent/nous_rate_guard.py +++ b/agent/nous_rate_guard.py @@ -117,7 +117,7 @@ def record_nous_rate_limit( # Atomic write: write to temp file + rename fd, tmp_path = tempfile.mkstemp(dir=state_dir, suffix=".tmp") try: - with os.fdopen(fd, "w") as f: + with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(state, f) atomic_replace(tmp_path, path) except Exception: diff --git a/agent/prompt_caching.py b/agent/prompt_caching.py index 9a2fdf4ccce..2e606cd377c 100644 --- a/agent/prompt_caching.py +++ b/agent/prompt_caching.py @@ -1,9 +1,11 @@ """Anthropic prompt caching strategy. -Single layout: ``system_and_3``. 4 cache_control breakpoints — system -prompt + last 3 non-system messages, all at the same TTL (5m or 1h). -Reduces input token costs by ~75% on multi-turn conversations within a -single session. +The default layout uses 4 cache_control breakpoints: the static system +prefix, the end of the system prompt, and the last 2 non-system messages. +When a static system prefix is unavailable, it falls back to one system +breakpoint plus the last 3 messages. All markers use the same TTL (5m or 1h). +This preserves intra-session caching while allowing new sessions to reuse the +stable system-prompt prefix. Pure functions -- no class state, no AIAgent dependency. """ @@ -81,15 +83,108 @@ def _build_marker(ttl: str) -> Dict[str, str]: return marker +def _apply_system_cache_markers( + message: dict, + cache_marker: dict, + static_system_prefix: str | None, + *, + native_anthropic: bool, +) -> int: + """Mark the static system prefix and full prompt when they can be split. + + The system prompt remains one stored string. Splitting it only in the + outgoing request keeps session persistence and non-Anthropic transports + unchanged while making the stable prefix independently cacheable. + """ + content = message.get("content") + if ( + isinstance(static_system_prefix, str) + and static_system_prefix + and isinstance(content, str) + and content.startswith(static_system_prefix) + ): + suffix = content[len(static_system_prefix):] + if suffix: + message["content"] = [ + { + "type": "text", + "text": static_system_prefix, + "cache_control": cache_marker, + }, + {"type": "text", "text": suffix, "cache_control": cache_marker}, + ] + return 2 + + _apply_cache_marker(message, cache_marker, native_anthropic=native_anthropic) + return 1 + + +def strip_anthropic_cache_control( + api_messages: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Remove ``cache_control`` markers and undo decoration-produced list shapes. + + Used before re-applying decoration after a mid-turn provider failover so + the mutated, undecorated shape (image shrink / ASCII cleanup / etc.) is + preserved while markers match the *new* provider's cache policy (#72626). + + Flattening back to a plain string is restricted to the exact shapes + :func:`apply_anthropic_cache_control` produces from string content — + a single ``{"type": "text"}`` part, or the two-part ``[static, volatile]`` + system split — so the ``""``-join is provably byte-exact. Organic + multi-part text (merged user turns, imported transcripts) and parts + carrying extra keys (``citations`` etc.) keep their structure; only + per-part markers are removed. Marker removal is copy-on-write on the + part dicts: content parts may alias the persistent conversation history + (the per-call copy is shallow), and stripping must never rewrite the + stored transcript. + + Mutates the top-level message dicts of ``api_messages`` in place and + returns the same list. + """ + for msg in api_messages: + if not isinstance(msg, dict): + continue + msg.pop("cache_control", None) + content = msg.get("content") + if not isinstance(content, list): + continue + if any(isinstance(part, dict) and "cache_control" in part for part in content): + content = [ + {k: v for k, v in part.items() if k != "cache_control"} + if isinstance(part, dict) and "cache_control" in part + else part + for part in content + ] + msg["content"] = content + decoration_shape = content and all( + isinstance(part, dict) + and part.get("type", "text") == "text" + and isinstance(part.get("text"), str) + and set(part.keys()) <= {"type", "text"} + for part in content + ) and ( + len(content) == 1 + or (msg.get("role") == "system" and len(content) == 2) + ) + if decoration_shape: + msg["content"] = "".join(part["text"] for part in content) + return api_messages + + def apply_anthropic_cache_control( api_messages: List[Dict[str, Any]], cache_ttl: str = "5m", native_anthropic: bool = False, + static_system_prefix: str | None = None, ) -> List[Dict[str, Any]]: - """Apply system_and_3 caching strategy to messages for Anthropic models. + """Apply Anthropic cache-control markers to API messages. - Places up to 4 cache_control breakpoints: system prompt + last 3 non-system - messages, all at the same TTL. + When ``static_system_prefix`` exactly matches the beginning of a string + system prompt, it receives an early marker and the full system prompt gets + a trailing marker. The remaining two markers target the latest cacheable + non-system messages. Without that prefix, the legacy system-and-3 layout + is retained. Returns: Deep copy of messages with cache_control breakpoints injected. @@ -103,8 +198,12 @@ def apply_anthropic_cache_control( breakpoints_used = 0 if messages[0].get("role") == "system": - _apply_cache_marker(messages[0], marker, native_anthropic=native_anthropic) - breakpoints_used += 1 + breakpoints_used = _apply_system_cache_markers( + messages[0], + marker, + static_system_prefix, + native_anthropic=native_anthropic, + ) remaining = 4 - breakpoints_used non_sys = [ diff --git a/agent/reasoning_timeouts.py b/agent/reasoning_timeouts.py index 13df836b110..9c5fc202015 100644 --- a/agent/reasoning_timeouts.py +++ b/agent/reasoning_timeouts.py @@ -102,9 +102,18 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = ( # ``claude-opus-4`` so non-thinking Claude 3.x or future # non-reasoning Claude variants don't match. ("claude-opus-4", 240), + ("claude-opus-5", 240), ("claude-sonnet-5", 180), ("claude-sonnet-4.5", 180), ("claude-sonnet-4.6", 180), + # Anthropic Mythos-class named reasoning models (claude-fable-5, …). + # 1M context + 128K output — heavier thinking phase than the + # numbered Claude line, so the floor is in the deep-reasoning tier + # alongside o1 / deepseek-r1 / nemotron-3-ultra. Without this + # entry the stale-stream detector kills fable-5's thinking phase + # at the default 180s (300s with context scaling), tripping the + # cross-turn circuit breaker after 5 consecutive stale kills. + ("claude-fable", 600), # xAI Grok reasoning variants. Explicit reasoning-only keys # plus one for the ``non-reasoning`` variant so users picking # the fast variant don't get the 300s floor. Bare ``grok-3``, @@ -206,6 +215,8 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]: 300.0 >>> get_reasoning_stale_timeout_floor("anthropic/claude-opus-4-6") 240.0 + >>> get_reasoning_stale_timeout_floor("anthropic/claude-fable-5") + 600.0 >>> get_reasoning_stale_timeout_floor("gpt-4o") is None True >>> get_reasoning_stale_timeout_floor("olmo-1") is None diff --git a/agent/redact.py b/agent/redact.py index ebca1ae75f1..bff23934da6 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -175,6 +175,85 @@ _YAML_ASSIGN_RE = re.compile( re.IGNORECASE | re.MULTILINE, ) +# Word-boundary validation for the mixed/lowercase key patterns above +# (_CFG_DOTTED_RE, _CFG_ANCHORED_RE, _YAML_ASSIGN_RE). +# +# Those key classes allow arbitrary alphanumeric affixes around the secret +# keyword so real key names like ``client_secret``, ``clientSecret``, and +# ``s3.secret-key`` match. The side effect: ordinary prose/document words that +# merely CONTAIN a keyword also matched — ``Secretary: J.Smith`` (secret), +# ``tokenizer: cl100k_base`` (token), ``author=Smith`` (auth) — mangling +# legitimate content on the surfaces that run these passes (browser snapshots, +# log lines, kanban summaries, CLI-echoed command output). Ported from +# nearai/ironclaw#6129, where the same substring false positive ("Secretary of +# the Treasury" matching the ``secret`` marker) scrubbed legitimate tool +# results from the replayed transcript and sent the model into a re-fetch +# loop. +# +# A keyword occurrence only counts when it sits at a word boundary within the +# key: at the key's edge, next to a non-letter (``_ - . 3``), or at a +# camelCase transition (``clientSecret``, ``secretKey``, ``APIToken``). A +# trailing plural ``s`` is treated as part of the keyword (``secrets:``, +# ``tokens:``). Common concatenated compounds keep matching via explicit +# alternatives (``authtoken`` ngrok, ``authkey`` tailscale, ``secretkey`` +# minio, ``apikey``). Embedded occurrences inside a larger word +# (``secretary``, ``tokenizer``, ``authored``, ``credentialing``) no longer +# match. ALL-CAPS keys keep the legacy embedded matching (``MYTOKEN=…``) — an +# all-caps key is almost never prose, the same rationale as _ENV_ASSIGN_RE. +_KEY_KEYWORD_RE = re.compile( + r"(?:api|auth|access|refresh|session|secret)[ _.\-]?(?:key|token)" + r"|token|secret|passwd|password|credential|auth", + re.IGNORECASE, +) + + +def _is_word_start(s: str, i: int) -> bool: + """True if position ``i`` in ``s`` begins a word (not mid-word).""" + if i == 0: + return True + prev, cur = s[i - 1], s[i] + if not prev.isalpha(): + return True + if cur.isupper() and prev.islower(): + return True # camelCase: clientSecret + # Acronym run ending: APIToken — the 'T' begins a new word when it is + # followed by lowercase while the preceding run is uppercase. + if cur.isupper() and prev.isupper() and i + 1 < len(s) and s[i + 1].islower(): + return True + return False + + +def _is_word_end(s: str, j: int, *, allow_plural: bool = True) -> bool: + """True if position ``j`` (exclusive end) in ``s`` ends a word.""" + if j >= len(s): + return True + cur = s[j] + if not cur.isalpha(): + return True + if cur.isupper() and s[j - 1].islower(): + return True # camelCase continuation: secretKey + if allow_plural and cur in "sS": + return _is_word_end(s, j + 1, allow_plural=False) + return False + + +def _key_has_secret_keyword(key: str) -> bool: + """True if ``key`` contains a secret keyword at a word boundary. + + Post-match validator for _CFG_DOTTED_RE / _CFG_ANCHORED_RE / + _YAML_ASSIGN_RE hits — rejects prose words that merely embed a keyword + (``secretary``, ``tokenizer``, ``authored``). Safe to call with the + _ENV_ASSIGN_RE key too: all-caps keys short-circuit to the legacy + embedded-match behavior. + """ + letters = [c for c in key if c.isalpha()] + if letters and all(c.isupper() for c in letters): + return True # legacy all-caps behavior (MYTOKEN=…) + for m in _KEY_KEYWORD_RE.finditer(key): + if _is_word_start(key, m.start()) and _is_word_end(key, m.end()): + return True + return False + # JSON field patterns: "apiKey": "value", "token": "value", etc. _JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)" _JSON_FIELD_RE = re.compile( @@ -614,6 +693,13 @@ def redact_sensitive_text( # prose/log contexts (issue #2852): ``KEY=os.getenv('X')``. if _ENV_LOOKUP_VALUE_RE.match(value): return m.group(0) + # Keyword must sit at a word boundary within the key — + # ``author=Smith`` / ``press.secretary=…`` are prose, not + # credentials (ported from nearai/ironclaw#6129). All-caps + # keys (the _ENV_ASSIGN_RE shape) short-circuit to legacy + # embedded matching inside the helper. + if not _key_has_secret_keyword(name): + return m.group(0) return f"{name}={quote}{_mask_token(value)}{quote}" text = _ENV_ASSIGN_RE.sub(_redact_env, text) # Lowercase/dotted config keys (issue #16413). Skip URLs entirely — @@ -647,6 +733,11 @@ def redact_sensitive_text( # not a leaked secret value. if _ENV_LOOKUP_VALUE_RE.match(value): return m.group(0) + # Keyword must sit at a word boundary within the key — + # ``Secretary: J.Smith`` / ``tokenizer: cl100k_base`` are + # document text, not credentials (nearai/ironclaw#6129). + if not _key_has_secret_keyword(key): + return m.group(0) return f"{key}{sep}{_mask_token(value)}" text = _YAML_ASSIGN_RE.sub(_redact_yaml, text) diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 45772d0578f..5699e4d9eb6 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -100,6 +100,7 @@ emitted by each built-in hook site. child_role – role string of the child agent child_summary – summary of the child's work child_status – exit status string (e.g. "success", "error") + tool_call_history – redacted tool name/input summary/byte counts/status list duration_ms – wall-clock time of the child run in milliseconds """ @@ -632,7 +633,7 @@ def allowlist_path() -> Path: def load_allowlist() -> Dict[str, Any]: """Return the parsed allowlist, or an empty skeleton if absent.""" try: - raw = json.loads(allowlist_path().read_text()) + raw = json.loads(allowlist_path().read_text(encoding="utf-8")) except (FileNotFoundError, json.JSONDecodeError, OSError): return {"approvals": []} if not isinstance(raw, dict): diff --git a/agent/skill_commands.py b/agent/skill_commands.py index fa1b4044a7a..3f1156a8592 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -54,6 +54,21 @@ _BUNDLE_MARKER = " skill bundle," _BUNDLE_USER_INSTRUCTION = "\nUser instruction: " _BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the " +# The skill name sits in the first quoted span of the activation note, for both +# the single-skill and the bundle header ("work" / "/clean /work"). +_SKILL_NAME_RE = re.compile(re.escape(_SKILL_INVOCATION_PREFIX) + r'"([^"]*)"') + +# SQL LIKE pattern matching a skill-expanded turn, for listing queries that +# have to recognize scaffolding before the row reaches Python. The prefix +# contains no LIKE wildcards (`%`, `_`), so it needs no ESCAPE clause. +SKILL_SCAFFOLD_SQL_LIKE = _SKILL_INVOCATION_PREFIX + "%" + +# Marks where a preview query joined the head and tail of a long scaffolded +# message. ``describe_skill_invocation`` may hand back a span that runs across +# the joint (a bundle instruction cut off by the head window); callers cut the +# description there rather than show the skill body on the far side. +SKILL_EXCERPT_JOINT = "\x1e" + def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]: """Recover the user's instruction from a slash-skill-expanded turn. @@ -82,6 +97,45 @@ def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]: return None +def describe_skill_invocation(content: Any, separator: str = " — ") -> Optional[str]: + """Render a slash-skill-expanded turn the way the user typed it. + + The expanded message embeds the whole skill body, so any surface that + summarizes a user turn from its raw content — session titles, sidebar + previews, the ``/rewind`` picker — otherwise shows the skill's own prose + as if the user had written it. That is how a skill's opening line ends up + as a session title. + + Returns ``"/work — fix the title leak"``, or ``"/work"`` for a bare + invocation, or ``None`` when *content* is not skill scaffolding (the + caller should then summarize it as an ordinary message). + + *separator* joins the command and the instruction. Previews use the + default em dash; pass ``" "`` for the literal invocation the user typed, + which is what chat transcripts render. + """ + if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX): + return None + + match = _SKILL_NAME_RE.match(content) + name = (match.group(1) if match else "").strip() + # Bundle headers already carry their typed "/a /b" keys; a single skill is + # a bare name. + label = name if name.startswith("/") else f"/{name}" + + instruction = extract_user_instruction_from_skill_message(content) + if instruction and instruction is not content: + # An excerpted message (head + tail, joined by SKILL_EXCERPT_JOINT) can + # put the joint inside the matched span — keep only the side the + # instruction marker was found on. + instruction = instruction.split(SKILL_EXCERPT_JOINT)[0] + instruction = " ".join(instruction.split()) + if instruction: + return f"{label}{separator}{instruction}" if name else instruction + + return label if name else None + + def _extract_single_skill_user_instruction(message: str) -> Optional[str]: # Single-skill format appends the user instruction after the skill body, so # the last occurrence is the user-provided one; the body may quote this text. diff --git a/agent/skill_utils.py b/agent/skill_utils.py index df0f933317f..eea78d6a07c 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -50,7 +50,7 @@ EXCLUDED_SKILL_DIRS = frozenset( SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts")) -def is_excluded_skill_path(path) -> bool: +def is_excluded_skill_path(path, *, root: Optional[Path] = None) -> bool: """True if *path* should be skipped by active skill scanners. Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to @@ -66,11 +66,11 @@ def is_excluded_skill_path(path) -> bool: from pathlib import PurePath parts = PurePath(str(path)).parts return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path( - path + path, root=root ) -def is_skill_support_path(path) -> bool: +def is_skill_support_path(path, *, root: Optional[Path] = None) -> bool: """True if *path* is under a support dir of an actual skill root. ``references/``, ``templates/``, ``assets/``, and ``scripts/`` are @@ -92,6 +92,8 @@ def is_skill_support_path(path) -> bool: if part not in SKILL_SUPPORT_DIRS or idx == 0: continue skill_root = Path(*parts[:idx]) + if root is not None and not path_obj.is_absolute(): + skill_root = root / skill_root if (skill_root / "SKILL.md").exists(): return True return False diff --git a/agent/ssl_guard.py b/agent/ssl_guard.py index 557f8566c32..ac1b7841b2d 100644 --- a/agent/ssl_guard.py +++ b/agent/ssl_guard.py @@ -31,7 +31,8 @@ def _skip_ssl_guard_enabled() -> bool: def _repair_hint() -> str: return ( - "Repair: python -m pip install --force-reinstall certifi openai httpx\n" + "Repair: run `hermes doctor --fix` (auto-reinstalls certifi), or " + "manually: python -m pip install --force-reinstall certifi openai httpx\n" "If you configured a custom corporate CA bundle, fix or unset the " "broken CA bundle environment variable." ) diff --git a/agent/subagent_lifecycle.py b/agent/subagent_lifecycle.py new file mode 100644 index 00000000000..b85d84d9e19 --- /dev/null +++ b/agent/subagent_lifecycle.py @@ -0,0 +1,533 @@ +"""Public, plugin-safe lifecycle API for delegated Hermes subagents. + +This module deliberately exposes immutable contracts, not ``AIAgent`` objects. +It is the supported boundary for plugins that need to supervise fresh child +sessions; plugins must obtain it from ``PluginContext.subagent_lifecycle``. +""" + +from __future__ import annotations + +import contextvars +import dataclasses +import enum +import hashlib +import hmac +import json +import math +import secrets +import threading +import time +from contextlib import contextmanager +from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError +from typing import Any, Callable, Mapping, Optional + + +PUBLIC_CONTRACT_VERSION = 1 +_MAX_GOAL_CHARS = 16_000 +_MAX_CONTEXT_CHARS = 32_000 +_MAX_METADATA_BYTES = 8_192 +_MAX_RESULT_CHARS = 32_000 +_TERMINAL_RETENTION_SECONDS = 3_600 + + +class SubagentLifecycleError(ValueError): + """A request cannot be safely accepted by the public lifecycle API.""" + + +class SubagentState(str, enum.Enum): + PENDING = "PENDING" + STARTING = "STARTING" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + INTERRUPTED = "INTERRUPTED" + CANCEL_REQUESTED = "CANCEL_REQUESTED" + CANCELLED = "CANCELLED" + UNKNOWN = "UNKNOWN" + + +@dataclasses.dataclass(frozen=True) +class SubagentLaunchRequest: + goal: str + context: Optional[str] = None + role: str = "leaf" + model: Optional[str] = None + allowed_toolsets: Optional[tuple[str, ...]] = None + blocked_tools: tuple[str, ...] = () + working_directory: Optional[str] = None + parent_session_id: Optional[str] = None + correlation_id: Optional[str] = None + metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict) + timeout_seconds: Optional[float] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentHandle: + contract_version: int + subagent_id: str + parent_session_id: Optional[str] + correlation_id: Optional[str] + created_at: float + provider: Optional[str] + model: Optional[str] + role: str + depth: int + capability: str + + def to_dict(self) -> dict[str, Any]: + return dataclasses.asdict(self) + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> "SubagentHandle": + try: + return cls(**dict(value)) + except (TypeError, ValueError) as exc: + raise SubagentLifecycleError("Malformed subagent handle.") from exc + + +@dataclasses.dataclass(frozen=True) +class SubagentStatus: + handle: SubagentHandle + state: SubagentState + updated_at: float + diagnostic: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentTerminalState: + handle: SubagentHandle + state: SubagentState + completed: bool + timed_out: bool = False + diagnostic: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentCancelResult: + accepted: bool + already_terminal: bool = False + unknown_handle: bool = False + unsupported: bool = False + state: SubagentState = SubagentState.UNKNOWN + + +@dataclasses.dataclass(frozen=True) +class SubagentResult: + handle: SubagentHandle + terminal_state: SubagentState + ready: bool + summary: Optional[str] = None + structured_payload: Optional[Mapping[str, Any]] = None + started_at: Optional[float] = None + completed_at: Optional[float] = None + error_classification: Optional[str] = None + error_message: Optional[str] = None + usage_metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict) + tool_execution_summary: Mapping[str, Any] = dataclasses.field(default_factory=dict) + result_hash: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentReconnectResult: + connected: bool + state: SubagentState + diagnostic: Optional[str] = None + + +@dataclasses.dataclass +class _Record: + handle: SubagentHandle + state: SubagentState + updated_at: float + agent: Any = None + future: Optional[Future] = None + started_at: Optional[float] = None + completed_at: Optional[float] = None + result: Optional[SubagentResult] = None + + +class _Registry: + """Thread-safe terminal-retention registry; never returns live records.""" + + def __init__(self) -> None: + self.lock = threading.RLock() + self.records: dict[str, _Record] = {} + self.correlations: dict[tuple[Optional[str], str], str] = {} + + +_REGISTRY = _Registry() +# Daemon worker pool: a wedged/abandoned child must never block interpreter +# exit at atexit-join time (same rationale as _run_single_child's timeout +# executor and the async-delegation registry pool). +from tools.daemon_pool import DaemonThreadPoolExecutor as _DaemonExecutor + +_EXECUTOR = _DaemonExecutor(max_workers=8, thread_name_prefix="hermes-lifecycle") +_SECRET = secrets.token_bytes(32) +_ACTIVE_PARENT_AGENT: contextvars.ContextVar[Any] = contextvars.ContextVar( + "hermes_subagent_lifecycle_parent", default=None +) + + +@contextmanager +def bind_subagent_parent(parent_agent: Any): + """Bind the host-owned parent for the current agent turn.""" + token = _ACTIVE_PARENT_AGENT.set(parent_agent) + try: + yield + finally: + _ACTIVE_PARENT_AGENT.reset(token) + + +def get_active_subagent_parent() -> Any: + """Return the parent bound to this execution context, if any.""" + return _ACTIVE_PARENT_AGENT.get() + + +class SubagentLifecycleService: + """Stable public service returned by :attr:`PluginContext.subagent_lifecycle`. + + Running children are in-process only. Completed results remain available + until process exit; ``reconnect`` accurately reports that a serialized + handle cannot reconnect after a restart instead of launching work again. + """ + + def __init__(self, parent_agent_resolver: Callable[[], Any]) -> None: + self._parent_agent_resolver = parent_agent_resolver + + def launch(self, request: SubagentLaunchRequest) -> SubagentHandle: + parent = self._parent_agent_resolver() + if parent is None: + raise SubagentLifecycleError( + "No active Hermes parent session is available." + ) + self._validate_request(request, parent) + parent_session_id = str(getattr(parent, "session_id", "") or "") or None + if request.parent_session_id and request.parent_session_id != parent_session_id: + raise SubagentLifecycleError( + "parent_session_id does not match the active session." + ) + correlation_key = (parent_session_id, request.correlation_id or "") + with _REGISTRY.lock: + self._cleanup_locked() + if request.correlation_id and correlation_key in _REGISTRY.correlations: + raise SubagentLifecycleError( + "Duplicate correlation_id for this parent session." + ) + + # Delegate construction remains internal so plugin code never imports + # private delegation helpers or manipulates the active-child registry. + from tools.delegate_tool import ( + _build_child_preserving_parent_tools, + DEFAULT_MAX_ITERATIONS, + ) + + child = _build_child_preserving_parent_tools( + task_index=0, + goal=request.goal, + context=request.context, + toolsets=list(request.allowed_toolsets) + if request.allowed_toolsets + else None, + model=request.model, + max_iterations=DEFAULT_MAX_ITERATIONS, + task_count=1, + parent_agent=parent, + role=request.role, + ) + subagent_id = str(getattr(child, "_subagent_id", "") or "") + if not subagent_id: + raise SubagentLifecycleError("Hermes failed to assign a child identity.") + created = time.time() + handle = SubagentHandle( + PUBLIC_CONTRACT_VERSION, + subagent_id, + parent_session_id, + request.correlation_id, + created, + getattr(child, "provider", None), + getattr(child, "model", None), + getattr(child, "_delegate_role", request.role), + int(getattr(child, "_delegate_depth", 1) or 1), + self._capability(subagent_id, parent_session_id, created), + ) + record = _Record(handle, SubagentState.PENDING, created, agent=child) + with _REGISTRY.lock: + _REGISTRY.records[subagent_id] = record + if request.correlation_id: + _REGISTRY.correlations[correlation_key] = subagent_id + record.future = _EXECUTOR.submit(self._run, record, request.goal, parent) + return handle + + def status(self, handle: SubagentHandle) -> SubagentStatus: + record = self._record(handle) + if record is None: + return SubagentStatus( + handle, SubagentState.UNKNOWN, time.time(), "UNKNOWN_HANDLE" + ) + with _REGISTRY.lock: + return SubagentStatus(record.handle, record.state, record.updated_at) + + def wait( + self, handle: SubagentHandle, *, timeout_seconds: Optional[float] = None + ) -> SubagentTerminalState: + record = self._record(handle) + if record is None: + return SubagentTerminalState( + handle, SubagentState.UNKNOWN, True, diagnostic="UNKNOWN_HANDLE" + ) + future = record.future + if future is not None: + try: + future.result(timeout=timeout_seconds) + except TimeoutError: + return SubagentTerminalState(record.handle, record.state, False, True) + except Exception: + pass + with _REGISTRY.lock: + return SubagentTerminalState( + record.handle, record.state, record.result is not None + ) + + def cancel(self, handle: SubagentHandle, *, reason: str) -> SubagentCancelResult: + record = self._record(handle) + if record is None: + return SubagentCancelResult(False, unknown_handle=True) + with _REGISTRY.lock: + if record.result is not None: + return SubagentCancelResult( + False, already_terminal=True, state=record.state + ) + agent = record.agent + record.state = SubagentState.CANCEL_REQUESTED + record.updated_at = time.time() + if agent is None or not hasattr(agent, "interrupt"): + return SubagentCancelResult( + False, unsupported=True, state=SubagentState.CANCEL_REQUESTED + ) + try: + agent.interrupt(f"Lifecycle cancellation requested: {reason[:500]}") + except Exception: + return SubagentCancelResult( + False, unsupported=True, state=SubagentState.CANCEL_REQUESTED + ) + return SubagentCancelResult(True, state=SubagentState.CANCEL_REQUESTED) + + def result(self, handle: SubagentHandle) -> SubagentResult: + record = self._record(handle) + if record is None: + return SubagentResult( + handle, + SubagentState.UNKNOWN, + False, + error_classification="UNKNOWN_HANDLE", + ) + with _REGISTRY.lock: + if record.result is not None: + return record.result + return SubagentResult( + record.handle, record.state, False, error_classification="NOT_READY" + ) + + def reconnect(self, handle: SubagentHandle) -> SubagentReconnectResult: + record = self._record(handle) + if record is None: + return SubagentReconnectResult( + False, SubagentState.UNKNOWN, "RECONNECT_UNAVAILABLE" + ) + with _REGISTRY.lock: + return SubagentReconnectResult(True, record.state) + + def _record(self, handle: SubagentHandle) -> Optional[_Record]: + if ( + not isinstance(handle, SubagentHandle) + or type(handle.contract_version) is not int + or handle.contract_version != PUBLIC_CONTRACT_VERSION + ): + return None + if ( + not isinstance(handle.subagent_id, str) + or not handle.subagent_id + or ( + handle.parent_session_id is not None + and not isinstance(handle.parent_session_id, str) + ) + or ( + handle.correlation_id is not None + and not isinstance(handle.correlation_id, str) + ) + or isinstance(handle.created_at, bool) + or not isinstance(handle.created_at, (int, float)) + or not math.isfinite(handle.created_at) + or (handle.provider is not None and not isinstance(handle.provider, str)) + or (handle.model is not None and not isinstance(handle.model, str)) + or not isinstance(handle.role, str) + or type(handle.depth) is not int + or not isinstance(handle.capability, str) + ): + return None + if not hmac.compare_digest( + handle.capability, + self._capability( + handle.subagent_id, handle.parent_session_id, handle.created_at + ), + ): + return None + parent = self._parent_agent_resolver() + active_parent_id = str(getattr(parent, "session_id", "") or "") or None + if active_parent_id != handle.parent_session_id: + return None + with _REGISTRY.lock: + return _REGISTRY.records.get(handle.subagent_id) + + @staticmethod + def _cleanup_locked() -> None: + """Retain terminal snapshots for a bounded period, never live work.""" + cutoff = time.time() - _TERMINAL_RETENTION_SECONDS + expired = [ + subagent_id + for subagent_id, record in _REGISTRY.records.items() + if record.result is not None + and record.completed_at is not None + and record.completed_at < cutoff + ] + for subagent_id in expired: + record = _REGISTRY.records.pop(subagent_id) + if record.handle.correlation_id: + _REGISTRY.correlations.pop( + (record.handle.parent_session_id, record.handle.correlation_id), + None, + ) + + def _run(self, record: _Record, goal: str, parent: Any) -> None: + with _REGISTRY.lock: + if record.state is not SubagentState.CANCEL_REQUESTED: + record.state = SubagentState.RUNNING + record.started_at = time.time() + record.updated_at = record.started_at + try: + from tools.delegate_tool import _run_child_lifecycle + + raw = _run_child_lifecycle(0, goal, record.agent, parent) + status = ( + str(raw.get("status", "error")) if isinstance(raw, dict) else "error" + ) + if status == "completed": + state = SubagentState.SUCCEEDED + elif status == "interrupted": + state = ( + SubagentState.CANCELLED + if record.state == SubagentState.CANCEL_REQUESTED + else SubagentState.INTERRUPTED + ) + else: + state = SubagentState.FAILED + summary = raw.get("summary") if isinstance(raw, dict) else None + summary = str(summary)[:_MAX_RESULT_CHARS] if summary is not None else None + error = raw.get("error") if isinstance(raw, dict) else None + result = SubagentResult( + record.handle, + state, + True, + summary=summary, + completed_at=time.time(), + started_at=record.started_at, + error_classification=None + if state == SubagentState.SUCCEEDED + else status.upper(), + error_message=str(error)[:_MAX_RESULT_CHARS] if error else None, + usage_metadata={"api_calls": raw.get("api_calls", 0)} + if isinstance(raw, dict) + else {}, + tool_execution_summary={ + "duration_seconds": raw.get("duration_seconds", 0) + } + if isinstance(raw, dict) + else {}, + ) + except Exception as exc: + result = SubagentResult( + record.handle, + SubagentState.FAILED, + True, + started_at=record.started_at, + completed_at=time.time(), + error_classification=type(exc).__name__, + error_message=str(exc)[:_MAX_RESULT_CHARS], + ) + payload = dataclasses.asdict(result) + payload.pop("result_hash", None) + result = dataclasses.replace( + result, + result_hash=hashlib.sha256( + json.dumps(payload, sort_keys=True, default=str).encode() + ).hexdigest(), + ) + with _REGISTRY.lock: + record.agent = None + record.result = result + record.state = result.terminal_state + record.completed_at = result.completed_at + record.updated_at = result.completed_at or time.time() + + @staticmethod + def _capability( + subagent_id: str, parent_session_id: Optional[str], created_at: float + ) -> str: + value = f"{subagent_id}|{parent_session_id or ''}|{created_at:.6f}".encode() + return hmac.new(_SECRET, value, hashlib.sha256).hexdigest() + + @staticmethod + def _validate_request(request: SubagentLaunchRequest, parent: Any) -> None: + if ( + not isinstance(request, SubagentLaunchRequest) + or not isinstance(request.goal, str) + or not request.goal.strip() + or len(request.goal) > _MAX_GOAL_CHARS + ): + raise SubagentLifecycleError( + "goal must be a non-empty string of at most 16000 characters." + ) + if request.context is not None and ( + not isinstance(request.context, str) + or len(request.context) > _MAX_CONTEXT_CHARS + ): + raise SubagentLifecycleError( + "context must be a string of at most 32000 characters." + ) + if request.role not in {"leaf", "orchestrator"}: + raise SubagentLifecycleError("role must be 'leaf' or 'orchestrator'.") + if request.timeout_seconds is not None: + raise SubagentLifecycleError( + "Per-launch timeout is not supported; configure delegation timeout explicitly." + ) + if request.working_directory is not None: + raise SubagentLifecycleError( + "working_directory is not supported because Hermes delegates use isolated task environments." + ) + if request.blocked_tools: + raise SubagentLifecycleError( + "Per-tool blocking is not supported; use allowed_toolsets. Hermes always blocks unsafe child tools." + ) + try: + metadata_bytes = len( + json.dumps(dict(request.metadata), sort_keys=True).encode() + ) + except (TypeError, ValueError) as exc: + raise SubagentLifecycleError("metadata must be JSON-serializable.") from exc + if metadata_bytes > _MAX_METADATA_BYTES: + raise SubagentLifecycleError("metadata exceeds 8192 bytes.") + if request.allowed_toolsets: + from toolsets import TOOLSETS + + unknown = set(request.allowed_toolsets) - set(TOOLSETS) + if unknown: + raise SubagentLifecycleError( + f"Unknown toolsets: {', '.join(sorted(unknown))}." + ) + enabled = getattr(parent, "enabled_toolsets", None) + if enabled is not None and not set(request.allowed_toolsets).issubset( + set(enabled) + ): + raise SubagentLifecycleError( + "Requested toolsets would broaden parent permissions." + ) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index aab34cb799e..8b8832ca280 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -12,9 +12,11 @@ Three tiers are joined with ``\\n\\n``: * ``stable`` — identity (SOUL.md or DEFAULT_AGENT_IDENTITY), tool guidance, computer-use guidance, nous subscription block, tool-use enforcement guidance + per-model operational guidance, skills prompt, - alibaba model-name workaround, environment hints, platform hints. + alibaba model-name workaround, environment hints, coding guidance, + platform hints. * ``context`` — caller-supplied ``system_message`` plus context files - (AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``. + (AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``, + plus the session's coding-workspace snapshot. * ``volatile`` — memory snapshot, USER.md profile, external memory provider block, timestamp/session/model/provider line. @@ -24,6 +26,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders. from __future__ import annotations import json +import logging import os from typing import Any, Dict, List, Optional @@ -49,6 +52,8 @@ from agent.runtime_cwd import resolve_context_cwd from hermes_constants import get_hermes_home from utils import is_truthy_value +logger = logging.getLogger(__name__) + def _ra(): """Lazy reference to the ``run_agent`` module. @@ -145,14 +150,14 @@ def _tui_embedded_pane_clarifier(hint: str) -> str: def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]: - """Assemble the system prompt as three ordered parts. + """Assemble the system prompt as three ordered cache tiers. Returns a dict with three keys: - * ``stable`` — identity, tool guidance, skills prompt, - environment hints, platform hints, model-family operational - guidance. - * ``context`` — context files (AGENTS.md, .cursorrules, etc.) - and caller-supplied system_message. + * ``stable`` — the cross-session-stable prefix, through the coding + operating brief when a workspace snapshot follows. + * ``context`` — the workspace snapshot followed by the remaining + session-stable guidance, context files, and caller-supplied + system_message. * ``volatile`` — memory snapshot, user profile, external memory provider block, timestamp line. @@ -345,25 +350,35 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) stable_parts.append(_env_hints) # Coding posture (base Hermes, any interactive coding surface in a code - # workspace — see agent/coding_context.py). The operating brief + the live - # git/workspace snapshot are built once here and cached for the session; - # the snapshot is never re-probed per turn (that would break the prompt - # cache), so the brief tells the model to re-check git before relying on it. + # workspace — see agent/coding_context.py). Keep the operating brief in + # the cross-session-stable prefix, while placing the live git/workspace + # snapshot behind its own cache boundary. The post-snapshot blocks must + # stay in their historical position after the workspace snapshot. + coding_workspace_parts: List[str] = [] + coding_trailing_parts: List[str] = [] if agent.valid_tool_names: try: - from agent.coding_context import coding_system_blocks + from agent.coding_context import coding_system_prompt_parts - stable_parts.extend( - coding_system_blocks( - platform=agent.platform, - cwd=resolve_context_cwd(), - model=agent.model, - ) + coding_prefix_parts, coding_workspace_parts, coding_trailing_parts = coding_system_prompt_parts( + platform=agent.platform, + cwd=resolve_context_cwd(), + model=agent.model, ) + stable_parts.extend(coding_prefix_parts) except Exception: # Coding-context probing must never block prompt build. pass + # Guidance assembled after the coding posture historically followed the + # workspace snapshot. With no snapshot, the coding tail instead remains + # directly after the coding prefix in the cacheable prefix. + if coding_workspace_parts: + post_workspace_parts: List[str] = [] + else: + stable_parts.extend(coding_trailing_parts) + post_workspace_parts = stable_parts + # Local Python toolchain probe — names python/pip/uv/PEP-668 state when # something is non-default so the model can pick the right install # strategy without discovering by failure. Emits a single line; emits @@ -376,7 +391,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) from tools.env_probe import get_environment_probe_line _probe_line = get_environment_probe_line() if _probe_line: - stable_parts.append(_probe_line) + post_workspace_parts.append(_probe_line) except Exception: # Probe failure must never block prompt build. pass @@ -394,7 +409,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) except Exception: active_profile = "default" if active_profile == "default": - stable_parts.append( + post_workspace_parts.append( "Active Hermes profile: default. Other profiles (if any) live " "under " + str(get_hermes_home()) + "/profiles//. Each profile has its own " "skills/, plugins/, cron/, and memories/ that affect a different " @@ -403,7 +418,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) "you to." ) else: - stable_parts.append( + post_workspace_parts.append( f"Active Hermes profile: {active_profile}. This session reads " f"and writes {get_hermes_home()}/profiles/{active_profile}/. The default " f"profile's data lives at {get_hermes_home()}/skills/, {get_hermes_home()}/plugins/, " @@ -449,11 +464,16 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) if platform_key == "tui" and _effective_hint: _effective_hint = _tui_embedded_pane_clarifier(_effective_hint) if _effective_hint: - stable_parts.append(_effective_hint) + post_workspace_parts.append(_effective_hint) # ── Context tier (cwd-dependent, may change between sessions) ─ context_parts: List[str] = [] + if coding_workspace_parts: + context_parts.extend(coding_workspace_parts) + context_parts.extend(coding_trailing_parts) + context_parts.extend(post_workspace_parts) + # Note: ephemeral_system_prompt is NOT included here. It's injected at # API-call time only so it stays out of the cached/stored system prompt. if system_message is not None: @@ -515,6 +535,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) timestamp_line += f"\nModel: {agent.model}" if agent.provider: timestamp_line += f"\nProvider: {agent.provider}" + if agent.platform: + timestamp_line += f"\nPlatform: {agent.platform}" volatile_parts.append(timestamp_line) return { @@ -541,6 +563,7 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str """ parts = build_system_prompt_parts(agent, system_message=system_message) joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p) + agent._cached_system_prompt_static = parts["stable"] # Surface context-file truncation warnings through the normal agent status # channel so gateway/CLI users see them in chat instead of only in logs. @@ -557,10 +580,65 @@ def invalidate_system_prompt(agent: Any) -> None: so the rebuilt prompt captures any writes from this session. """ agent._cached_system_prompt = None + agent._cached_system_prompt_static = None if agent._memory_store: agent._memory_store.load_from_disk() +def reconstruct_static_prefix( + agent: Any, + system_message: Optional[str] = None, + *, + log_label: str = "restore", +) -> None: + """Reconstruct ``_cached_system_prompt_static`` for a stored prompt. + + The static prefix is not persisted (only the full prompt is), so any + path that adopts a stored/kept ``_cached_system_prompt`` — session + restore, the compression keep-prompt path, or a failover to a cache-on + provider mid-turn (#72626) — must rebuild the stable tier to regain the + two-block ``[static, volatile]`` system layout. + + Safety: the rebuilt stable tier is used ONLY when the stored prompt + literally starts with it (checked here AND re-checked by + ``_apply_system_cache_markers``'s ``startswith`` gate). If any + stable-tier input changed since the prompt was persisted (skills + edited, identity changed), the prefix mismatches, the static stays + None, and requests fall back to the legacy layout with the stored + prompt bytes untouched — never a rewritten prompt. + + A failed reconstruction is memoized per stored prompt + (``_static_rebuild_failed_for``): ``build_system_prompt_parts`` does + real file I/O (SOUL.md, context files, memory), and callers on the + retry-loop hot path must not re-run it every attempt when the inputs + haven't changed. A legitimately changed stored prompt retries once. + """ + if not getattr(agent, "_use_prompt_caching", False): + return + stored = getattr(agent, "_cached_system_prompt", None) + if not isinstance(stored, str) or not stored: + return + existing = getattr(agent, "_cached_system_prompt_static", None) + if isinstance(existing, str) and existing and stored.startswith(existing): + return + if getattr(agent, "_static_rebuild_failed_for", None) == stored: + return + try: + static = build_system_prompt_parts(agent, system_message=system_message)["stable"] + if static and stored.startswith(static): + agent._cached_system_prompt_static = static + agent._static_rebuild_failed_for = None + return + except Exception: + logger.debug( + "static system-prefix reconstruction failed on %s", + log_label, + exc_info=True, + ) + agent._cached_system_prompt_static = None + agent._static_rebuild_failed_for = stored + + def format_tools_for_system_message(agent: Any) -> str: """Format tool definitions for the system message in the trajectory format. diff --git a/agent/title_generator.py b/agent/title_generator.py index 7469a665bfc..bb44da276ec 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -71,6 +71,27 @@ def _auto_title_enabled() -> bool: return True +def _summarize_user_message(user_message: str) -> str: + """Collapse a slash-skill-expanded turn back to what the user typed. + + A ``/skill`` invocation expands into a message that embeds the whole skill + body, so feeding it to the titler verbatim titles the session after the + *skill's* prose — "Kick off a task in a fresh isolated git worktree" — not + after the user's request. Reuse the canonical scaffolding parser so the + model sees ``/work — fix the title leak`` instead. + """ + if not user_message: + return "" + try: + from agent.skill_commands import describe_skill_invocation + + described = describe_skill_invocation(user_message) + except Exception: + logger.debug("Skill-scaffolding summary failed; titling raw", exc_info=True) + return user_message + return described if described is not None else user_message + + def generate_title( user_message: str, assistant_response: str, @@ -110,7 +131,7 @@ def generate_title( logger.debug("Title runtime validator raised; proceeding", exc_info=True) # Truncate long messages to keep the request small - user_snippet = user_message[:500] if user_message else "" + user_snippet = _summarize_user_message(user_message)[:500] assistant_snippet = assistant_response[:500] if assistant_response else "" language = _title_language() @@ -143,6 +164,11 @@ def generate_title( title = title.strip('"\'') if title.lower().startswith("title:"): title = title[6:].strip() + # A title is one line. A model that ignores "return ONLY the title" and + # answers the prompt instead (a shell transcript, a bulleted plan) would + # otherwise be stored verbatim and truncated mid-command. Keep the first + # non-empty line — the closest thing to a title in that response. + title = next((line.strip() for line in title.splitlines() if line.strip()), "") # Enforce reasonable length if len(title) > 80: title = title[:77] + "..." diff --git a/agent/tool_executor.py b/agent/tool_executor.py index d235de36c03..d32fe99c0c5 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -140,8 +140,8 @@ def _flush_session_db_after_tool_progress( messages: list, *, stage: str, -) -> None: - """Best-effort incremental SessionDB flush for tool-call progress. +) -> bool: + """Flush tool-call progress before projecting it to any UI surface. Tool execution can perform side effects that terminate or restart the current Hermes process before the normal turn-end persistence path runs. @@ -149,9 +149,14 @@ def _flush_session_db_after_tool_progress( transcript survives destructive-but-valid tool calls. """ try: - agent._flush_messages_to_session_db(messages) + persisted = agent._flush_messages_to_session_db(messages) is not False + if not persisted: + agent._incremental_persistence_failed = True + return persisted except Exception as exc: + agent._incremental_persistence_failed = True logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc) + return False def _ra(): @@ -431,8 +436,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) if not _err and _underlying: if _underlying in _tool_search_scoped_names(agent): - function_name = _underlying - function_args = _underlying_args + # Probe-validate before unwrapping (ironclaw#5149): + # missing required args return the parameter schema + # instead of dispatching into an opaque failure. + _probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args) + if _probe_err is not None: + _ts_scope_block = _probe_err + else: + function_name = _underlying + function_args = _underlying_args else: _ts_scope_block = json.dumps({ "error": ( @@ -854,6 +866,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls): r = results[i] blocked = False + is_error = True + progress_function_name = name # A worker can finish and write results[i] in the window between the # deadline snapshot (timed_out_indices, taken from not_done) and this # loop. Prefer that real result over a fabricated timeout message — the @@ -909,6 +923,7 @@ 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 + progress_function_name = function_name if blocked: effect_disposition = "none" @@ -936,43 +951,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe except Exception as _ver_err: logging.debug("file-mutation verifier record failed: %s", _ver_err) - if not blocked and agent.tool_progress_callback: - try: - agent.tool_progress_callback( - "tool.completed", function_name, None, None, - duration=tool_duration, is_error=is_error, - result=function_result, - ) - except Exception as cb_err: - logging.debug(f"Tool progress callback error: {cb_err}") - if agent.verbose_logging: logging.debug(f"Tool {function_name} completed in {tool_duration:.2f}s") logging.debug(f"Tool result ({len(function_result)} chars): {function_result}") - # Print cute message per tool - if agent._should_emit_quiet_tool_messages(): - cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result) - agent._safe_print(f" {cute_msg}") - elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": - _preview_str = _multimodal_text_summary(function_result) - if agent.verbose_logging: - print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s") - print(agent._wrap_verbose("Result: ", _preview_str)) - else: - response_preview = _preview_str[:agent.log_prefix_chars] + "..." if len(_preview_str) > agent.log_prefix_chars else _preview_str - print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}") - agent._current_tool = None - agent._touch_activity(f"tool completed: {name} ({tool_duration:.1f}s)") - - if not blocked and agent.tool_complete_callback: - try: - display_args = _redact_tool_args_for_display(name, args) or args - agent.tool_complete_callback(tc.id, name, display_args, function_result) - except Exception as cb_err: - logging.debug(f"Tool complete callback error: {cb_err}") + _status_suffix = " (error)" if is_error else "" + agent._touch_activity(f"tool completed: {name} ({tool_duration:.1f}s){_status_suffix}") + display_function_result = function_result function_result = maybe_persist_tool_result( content=function_result, tool_name=name, @@ -1007,6 +994,50 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe ) messages.append(tool_message) risk_metadata = tool_message.get("_tool_output_risk") + if not _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"tool result {name}", + ): + return + + # Every completion surface is downstream of the canonical append. If + # the UI bridge or process dies while projecting one of these events, + # resume can reconstruct the tool result that was already visible. + if not blocked and agent.tool_progress_callback: + try: + agent.tool_progress_callback( + "tool.completed", progress_function_name, None, None, + duration=tool_duration, is_error=is_error, + result=display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool progress callback error: {cb_err}") + + # Print cute message per tool + if agent._should_emit_quiet_tool_messages(): + cute_msg = _get_cute_tool_message_impl( + name, args, tool_duration, result=display_function_result, + ) + agent._safe_print(f" {cute_msg}") + elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": + _preview_str = _multimodal_text_summary(display_function_result) + if agent.verbose_logging: + print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s") + print(agent._wrap_verbose("Result: ", _preview_str)) + else: + response_preview = _preview_str[:agent.log_prefix_chars] + "..." if len(_preview_str) > agent.log_prefix_chars else _preview_str + print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}") + + if not blocked and agent.tool_complete_callback: + try: + display_args = _redact_tool_args_for_display(name, args) or args + agent.tool_complete_callback( + tc.id, name, display_args, display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool complete callback error: {cb_err}") + if ( risk_metadata is not None and risk_metadata.get("risk") != "low" @@ -1023,11 +1054,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe ) except Exception as cb_err: logging.debug("Tool output risk callback error: %s", cb_err) - _flush_session_db_after_tool_progress( - agent, - messages, - stage=f"tool result {name}", - ) # ── Per-tool /steer drain ─────────────────────────────────── # Same as the sequential path: drain between each collected @@ -1059,6 +1085,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe # Resolve the context-scaled tool-output budget once per turn. _tool_budget = _budget_for_agent(agent) for i, tool_call in enumerate(assistant_message.tool_calls, 1): + if getattr(agent, "_incremental_persistence_failed", False): + return # SAFETY: check interrupt BEFORE starting each tool. # If the user sent "stop" during a previous tool's execution, # do NOT start any more tools -- skip them all immediately. @@ -1074,11 +1102,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skipped_tc.id, effect_disposition="none", )) - _flush_session_db_after_tool_progress( + if not _flush_session_db_after_tool_progress( agent, messages, stage=f"cancelled tool result {skipped_name}", - ) + ): + return break function_name = tool_call.function.name @@ -1094,11 +1123,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe tool_call.id, ) ) - _flush_session_db_after_tool_progress( + if not _flush_session_db_after_tool_progress( agent, messages, stage=f"invalid tool arguments {function_name}", - ) + ): + return agent._apply_pending_steer_to_tool_results(messages, 1) continue @@ -1112,8 +1142,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) if not _err and _underlying: if _underlying in _tool_search_scoped_names(agent): - function_name = _underlying - function_args = _underlying_args + # Probe-validate before unwrapping (ironclaw#5149): + # missing required args return the parameter schema + # instead of dispatching into an opaque failure. + _probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args) + if _probe_err is not None: + # This path wraps _block_msg in {"error": ...} — + # flatten the probe payload to one plain string. + try: + _probe = json.loads(_probe_err) + _ts_scope_block = ( + f"{_probe.get('error', '')} Parameters schema: " + f"{json.dumps(_probe.get('parameters', {}), ensure_ascii=False)}. " + f"{_probe.get('hint', '')}" + ).strip() + except Exception: + _ts_scope_block = _probe_err + else: + function_name = _underlying + function_args = _underlying_args else: _ts_scope_block = ( f"'{_underlying}' is not available in this session. " @@ -1359,6 +1406,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe return _clarify_tool( question=next_args.get("question", ""), choices=next_args.get("choices"), + multi_select=next_args.get("multi_select", False), callback=agent.clarify_callback, ) function_result, function_args = _run_agent_tool_execution_middleware( @@ -1644,31 +1692,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe except Exception as _ver_err: logging.debug("file-mutation verifier record failed: %s", _ver_err) - if not _execution_blocked and agent.tool_progress_callback: - try: - agent.tool_progress_callback( - "tool.completed", function_name, None, None, - duration=tool_duration, is_error=_is_error_result, - result=function_result, - ) - except Exception as cb_err: - logging.debug(f"Tool progress callback error: {cb_err}") - agent._current_tool = None - agent._touch_activity(f"tool completed: {function_name} ({tool_duration:.1f}s)") + _status_suffix = " (error)" if _is_error_result else "" + agent._touch_activity(f"tool completed: {function_name} ({tool_duration:.1f}s){_status_suffix}") if agent.verbose_logging: logging.debug(f"Tool {function_name} completed in {tool_duration:.2f}s") _log_result = _multimodal_text_summary(function_result) logging.debug(f"Tool result ({len(_log_result)} chars): {_log_result}") - if not _execution_blocked and agent.tool_complete_callback: - try: - display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - agent.tool_complete_callback(tool_call.id, function_name, display_args, function_result) - except Exception as cb_err: - logging.debug(f"Tool complete callback error: {cb_err}") - + display_function_result = function_result function_result = maybe_persist_tool_result( content=function_result, tool_name=function_name, @@ -1691,6 +1724,40 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe 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 not _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"tool result {function_name}", + ): + return + + # UI completion/progress events are projections of the canonical tool + # row, never a competing in-memory authority. + if not _execution_blocked and agent.tool_progress_callback: + try: + agent.tool_progress_callback( + "tool.completed", function_name, None, None, + duration=tool_duration, is_error=_is_error_result, + result=display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool progress callback error: {cb_err}") + + if not _execution_blocked and agent.tool_complete_callback: + try: + display_args = ( + _redact_tool_args_for_display(function_name, function_args) + or function_args + ) + agent.tool_complete_callback( + tool_call.id, + function_name, + display_args, + display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool complete callback error: {cb_err}") + if ( risk_metadata is not None and risk_metadata.get("risk") != "low" @@ -1707,11 +1774,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe ) except Exception as cb_err: logging.debug("Tool output risk callback error: %s", cb_err) - _flush_session_db_after_tool_progress( - agent, - messages, - stage=f"tool result {function_name}", - ) # ── Per-tool /steer drain ─────────────────────────────────── # Drain pending steer BETWEEN individual tool calls so the @@ -1739,11 +1801,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skipped_tc.id, effect_disposition="none", )) - _flush_session_db_after_tool_progress( + if not _flush_session_db_after_tool_progress( agent, messages, stage=f"skipped tool result {skipped_name}", - ) + ): + return break if agent.tool_delay > 0 and i < len(assistant_message.tool_calls): @@ -1794,6 +1857,8 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec segments = _plan_tool_batch_segments(assistant_message.tool_calls, execution_cwd=_exec_cwd) for kind, calls in segments: + if getattr(agent, "_incremental_persistence_failed", False): + return segment_message = SimpleNamespace(tool_calls=list(calls)) if kind == "parallel": execute_tool_calls_concurrent( @@ -1806,6 +1871,9 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec finalize=False, ) + if getattr(agent, "_incremental_persistence_failed", False): + return + # ── Whole-turn finalize (budget + /steer) ───────────────────────── total_tools = len(assistant_message.tool_calls) if total_tools > 0: diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index f08f1b60478..444ce373959 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -79,6 +79,7 @@ class ToolCallGuardrailConfig: no_progress_block_after: int = 5 idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES) mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES) + loop_caps: "LoopCapConfig" = field(default_factory=lambda: LoopCapConfig()) @classmethod def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig": @@ -121,6 +122,54 @@ class ToolCallGuardrailConfig: hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")), defaults.no_progress_block_after, ), + loop_caps=LoopCapConfig.from_mapping(data.get("loop_caps")), + ) + + +# Default session-wide caps, matching Claude Code's v2.1.212 runaway-loop +# Per-turn (per-agent-loop) caps on runaway-prone tool calls. Counts reset at +# the start of every agent loop (reset_for_turn), so the limit is "within a +# single turn" rather than cumulative over the whole session. A single loop +# issuing dozens of web searches or spawning dozens of subagents is already +# pathological, so the defaults are deliberately low. +_DEFAULT_MAX_WEB_SEARCHES_PER_TURN = 50 +_DEFAULT_MAX_SUBAGENTS_PER_TURN = 50 + + +@dataclass(frozen=True) +class LoopCapConfig: + """Per-turn caps on runaway-prone tool calls. + + Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added caps on + WebSearch calls and subagent spawns to stop runaway search / delegation + loops. Here the caps count *within a single agent loop* (one turn): the + counters reset in ``reset_for_turn`` at the start of every + ``run_conversation``, so a legitimate multi-turn session is never starved, + but a single turn that spirals into an unbounded search / delegation loop + is stopped. + + Semantics differ from the per-turn loop *detector* above (which keys on + repeated identical/failing calls): these caps are a hard ceiling on the + total count of a tool within the turn and fire regardless of + ``hard_stop_enabled``. A value of ``0`` disables the cap (unlimited). + """ + + max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_TURN + max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN + + @classmethod + def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig": + """Build config from the ``tool_loop_guardrails.loop_caps`` section.""" + if not isinstance(data, Mapping): + return cls() + defaults = cls() + return cls( + max_web_searches=_non_negative_int( + data.get("max_web_searches"), defaults.max_web_searches + ), + max_subagents=_non_negative_int( + data.get("max_subagents"), defaults.max_subagents + ), ) @@ -233,6 +282,11 @@ class ToolCallGuardrailController: self._same_tool_failure_counts: dict[str, int] = {} self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {} self._halt_decision: ToolGuardrailDecision | None = None + # Per-turn runaway-loop cap counters. Reset every turn (this method + # runs at the start of each run_conversation), so the caps bound a + # single agent loop rather than accumulating across the session. + self._turn_web_search_count = 0 + self._turn_subagent_count = 0 @property def halt_decision(self) -> ToolGuardrailDecision | None: @@ -240,6 +294,17 @@ class ToolCallGuardrailController: def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision: signature = ToolCallSignature.from_call(tool_name, _coerce_args(args)) + + # ── Per-turn runaway-loop caps ────────────────────────────────── + # These are hard ceilings on how many times a runaway-prone tool may + # be called within a single agent loop (turn). They apply regardless + # of hard_stop_enabled (which only governs the per-turn loop detector). + # We block BEFORE the call runs once the count is already at the cap, + # then increment for an allowed call so the (cap+1)-th is refused. + cap_block = self._check_loop_cap(tool_name, _coerce_args(args), signature) + if cap_block is not None: + return cap_block + if not self.config.hard_stop_enabled: return ToolGuardrailDecision(tool_name=tool_name, signature=signature) @@ -379,6 +444,68 @@ class ToolCallGuardrailController: return False return tool_name in self.config.idempotent_tools + def _check_loop_cap( + self, + tool_name: str, + args: Mapping[str, Any], + signature: ToolCallSignature, + ) -> ToolGuardrailDecision | None: + """Enforce and advance the per-turn runaway-loop counters. + + Returns a ``block`` decision when the cap is already reached, otherwise + increments the relevant counter for the allowed call and returns + ``None``. A cap of 0 disables that limit entirely. Counters reset each + turn via ``reset_for_turn``. + """ + caps = self.config.loop_caps + + if tool_name == "web_search": + cap = caps.max_web_searches + if cap and self._turn_web_search_count >= cap: + decision = ToolGuardrailDecision( + action="block", + code="loop_web_search_cap", + message=( + f"Blocked web_search: this turn has already made {cap} " + "web searches, the per-turn limit. This looks like a " + "runaway search loop. Work with the results you already " + "have and give the user your answer." + ), + tool_name=tool_name, + count=self._turn_web_search_count, + signature=signature, + ) + self._halt_decision = decision + return decision + self._turn_web_search_count += 1 + return None + + if tool_name == "delegate_task": + cap = caps.max_subagents + if not cap: + return None + spawn_count = _subagent_spawn_count(args) + if self._turn_subagent_count >= cap: + decision = ToolGuardrailDecision( + action="block", + code="loop_subagent_cap", + message=( + f"Blocked delegate_task: this turn has already spawned " + f"{self._turn_subagent_count} subagents (limit {cap}). " + "This looks like a runaway delegation loop. Finish the " + "work with the results you have and answer the user." + ), + tool_name=tool_name, + count=self._turn_subagent_count, + signature=signature, + ) + self._halt_decision = decision + return decision + self._turn_subagent_count += spawn_count + return None + + return None + def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str: """Build a synthetic role=tool content string for a blocked tool call.""" @@ -471,6 +598,32 @@ def _positive_int(value: Any, default: int) -> int: return parsed if parsed >= 1 else default +def _non_negative_int(value: Any, default: int) -> int: + """Parse a session-cap value. 0 is a valid (disable) value; negatives and + junk fall back to the default.""" + if value is None: + return default + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 0 else default + + +def _subagent_spawn_count(args: Mapping[str, Any]) -> int: + """How many subagents a single delegate_task call spawns. + + delegate_task runs in one of two modes: a batch (``tasks`` is a non-empty + list, one child per item) or a single task (``goal``). Count the batch size + when present, otherwise 1, so the session subagent cap reflects real spawns + rather than delegate_task invocations. + """ + tasks = args.get("tasks") if isinstance(args, Mapping) else None + if isinstance(tasks, list) and tasks: + return len(tasks) + return 1 + + def _sha256(value: str) -> str: # surrogatepass: tool results scraped from the web can carry unpaired # UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 5855fcfe9c5..15dd3409e3f 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -7,6 +7,7 @@ streaming, or the _run_codex_stream() call path. import hashlib import json +import re from typing import Any, Dict, List, Optional from agent.transports.base import ProviderTransport @@ -27,6 +28,49 @@ def _bounded_prompt_cache_key(value: Any) -> Optional[str]: return f"pck_{digest}" +_EXTENDED_PROMPT_CACHE_MODELS = ( + "gpt-5.5-pro", + "gpt-5.5", + "gpt-5.4", + "gpt-5.2", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5.1-chat-latest", + "gpt-5.1-codex", + "gpt-5.1", + "gpt-5-codex", + "gpt-5", + "gpt-4.1", +) +_EXTENDED_PROMPT_CACHE_MODEL_RE = re.compile( + rf"(?:^|[./:])(?:{'|'.join(re.escape(name) for name in _EXTENDED_PROMPT_CACHE_MODELS)})" + r"(?:-\d{4}-\d{2}-\d{2})?$" +) + + +def _default_prompt_cache_retention_for_request( + model: str, + base_url: Any, +) -> Optional[str]: + """Return ``24h`` for supported models on Amazon Bedrock Mantle.""" + from utils import base_url_hostname + + hostname_parts = base_url_hostname(str(base_url or "")).split(".") + is_bedrock_mantle = ( + len(hostname_parts) == 4 + and hostname_parts[0] == "bedrock-mantle" + and bool(hostname_parts[1]) + and hostname_parts[2:] == ["api", "aws"] + ) + if not is_bedrock_mantle: + return None + + normalized = str(model or "").strip().lower().replace("_", "-") + if _EXTENDED_PROMPT_CACHE_MODEL_RE.search(normalized): + return "24h" + return None + + def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]: """Content-address the prompt cache key from the static request prefix. @@ -284,6 +328,13 @@ class ResponsesApiTransport(ProviderTransport): if not is_github_responses and not is_xai_responses and cache_key: kwargs["prompt_cache_key"] = cache_key + cache_retention = _default_prompt_cache_retention_for_request( + model, + params.get("base_url"), + ) + if cache_retention: + kwargs.setdefault("prompt_cache_retention", cache_retention) + if reasoning_enabled and is_xai_responses: from agent.model_metadata import grok_supports_reasoning_effort diff --git a/agent/turn_context.py b/agent/turn_context.py index 59914befe0c..94dc1660d1a 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -36,6 +36,7 @@ from agent.conversation_compression import ( PREFLIGHT_COMPRESSION_STATUS_TEMPLATE, compression_skipped_due_to_lock, conversation_history_after_compression, + recover_rotated_compression_session, ) from agent.context_engine import automatic_compaction_status_message from agent.iteration_budget import IterationBudget @@ -335,6 +336,8 @@ def build_turn_context( persist_user_message: Optional[Any], persist_user_timestamp: Optional[float] = None, *, + persist_user_display_kind: Optional[str] = None, + persist_user_display_metadata: Optional[Dict[str, Any]] = None, restore_or_build_system_prompt, install_safe_stdio, sanitize_surrogates, @@ -353,6 +356,13 @@ def build_turn_context( # Guard stdio against OSError from broken pipes (systemd/headless/daemon). install_safe_stdio() + # Recover a session rotated by another path before binding log/turn ids or + # copying client-supplied history. Everything in this turn must consistently + # belong to the canonical child, including observability metadata. + recovered_history = recover_rotated_compression_session(agent) + if recovered_history is not None: + conversation_history = recovered_history + # NOTE: the DB session row is created later, AFTER the system prompt is # restored/built (see _ensure_db_session() below the system-prompt block). # Creating it here — before _cached_system_prompt is populated — inserts a @@ -529,6 +539,19 @@ def build_turn_context( # Add the current user message after the prompt/session setup has made # close persistence safe. The handoff above preserves any marker already # stamped by an earlier close flush. + # + # A synthesized turn (auto-continue recovery note, delegation completion) + # declares how it should READ in a transcript. Stamp that on the live + # message so the crash persist below writes the row already typed. Typing + # it after the turn instead leaves the row untyped for the whole run — and + # forever if the turn crashes — so the raw system note paints as a user + # bubble. The model still receives role/content unchanged; the api_messages + # build strips both fields from every outgoing copy. + if persist_user_display_kind: + user_msg["display_kind"] = persist_user_display_kind + if persist_user_display_metadata: + user_msg["display_metadata"] = persist_user_display_metadata + messages.append(user_msg) current_turn_user_idx = len(messages) - 1 agent._persist_user_message_idx = current_turn_user_idx diff --git a/agent/turn_summary.py b/agent/turn_summary.py new file mode 100644 index 00000000000..f4440afb50a --- /dev/null +++ b/agent/turn_summary.py @@ -0,0 +1,310 @@ +"""Per-turn accounting for the interactive CLI. + +Two display-only pieces live here: + +* :class:`TurnSummaryCollector` — a tiny observer that rides the existing + ``tool_progress_callback`` feed (``tool.completed`` events already carry + the tool name and its raw result) and tallies what a turn actually did. + It holds **no** agent-loop state: the display layer already sees every + tool call, so nothing new is threaded through the conversation loop. +* :func:`format_turn_summary` — a pure formatter that turns a tally plus a + wall-clock duration into one dim line, e.g.:: + + ⋯ 12.4s · edited 2 files +18 -3 · read 4 files · ran 3 commands + + Ported from Claude Code's post-turn accounting line + ("Edited 1 file +6 -2, read 1 file … Worked for 10s"). + +:func:`format_token_flow` is the spinner-side counterpart: a cumulative +token readout appended to the live elapsed timer (``↓ 1.2k tok``). + +Everything in this module is pure/side-effect free apart from the +collector's own counters, which makes it directly unit-testable without a +terminal, an agent, or a network call. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +__all__ = [ + "TurnSummaryCollector", + "TurnTally", + "format_turn_summary", + "format_token_flow", + "format_elapsed", +] + + +# Leading glyph for the summary line. Deliberately not an emoji — the line is +# meant to read as terminal chrome, not as agent speech. +SUMMARY_PREFIX = "⋯" + +# A turn that called no tools and finished this fast has nothing worth +# reporting (plain chat reply). Below the threshold the formatter returns "". +_MIN_TOOLLESS_SECONDS = 2.0 + +# Max number of "verb + count" segments rendered before collapsing the rest +# into a "+N more" tail, so a 12-tool turn cannot blow past one line. +_MAX_SEGMENTS = 4 + + +# Tool name -> (verb, singular noun, plural noun). +# +# Verbs are past tense because the line is printed *after* the turn. Tools not +# listed here fall into a generic "called N tools" bucket rather than inventing +# phrasing for plugin/MCP tools whose semantics we don't know. +_VERB_GROUPS: dict[str, tuple[str, str, str]] = { + "write_file": ("edited", "file", "files"), + "patch": ("edited", "file", "files"), + "read_file": ("read", "file", "files"), + "web_extract": ("read", "page", "pages"), + "terminal": ("ran", "command", "commands"), + "execute_code": ("ran", "script", "scripts"), + "search_files": ("searched", "path", "paths"), + "web_search": ("searched the web", "time", "times"), + "session_search": ("searched sessions", "time", "times"), + "browser_navigate": ("browsed", "page", "pages"), + "skill_view": ("read", "skill", "skills"), + "skill_manage": ("updated", "skill", "skills"), + "skills_list": ("listed skills", "time", "times"), + "todo": ("updated", "task list", "task lists"), + "delegate_task": ("delegated", "task", "tasks"), + "memory": ("updated", "memory", "memories"), +} + +# Verb groups that carry file-edit line deltas (+X -Y) when known. +_EDIT_VERB = "edited" + +# Render order: edits first (the thing users most want confirmed), then reads, +# then commands. Anything else follows in first-seen order. +_VERB_PRIORITY: tuple[str, ...] = ("edited", "read", "ran") + +# Tools whose results may report a unified diff we can count lines from. +_DIFF_RESULT_TOOLS = frozenset({"patch"}) + + +@dataclass +class TurnTally: + """What a single turn did, as observed from the tool-progress feed.""" + + # verb -> {noun_plural: count}; keeps insertion order for stable rendering. + verbs: dict[str, dict[str, int]] = field(default_factory=dict) + # Tools with no curated verb, counted together. + other_tools: int = 0 + # Aggregated unified-diff line deltas across edit tools, when reported. + lines_added: int = 0 + lines_removed: int = 0 + # True once at least one edit tool reported a countable diff, so the + # formatter knows the difference between "+0 -0" and "unknown". + has_line_deltas: bool = False + + @property + def total_tools(self) -> int: + counted = sum(sum(nouns.values()) for nouns in self.verbs.values()) + return counted + self.other_tools + + +def _count_diff_lines(diff: str) -> tuple[int, int]: + """Count added/removed lines in unified-diff text. + + File headers (``+++``/``---``) are excluded so a one-line edit does not + read as three additions. + """ + added = removed = 0 + for line in diff.splitlines(): + if line.startswith("+++") or line.startswith("---"): + continue + if line.startswith("+"): + added += 1 + elif line.startswith("-"): + removed += 1 + return added, removed + + +def _extract_line_deltas(tool_name: str, result: Any) -> tuple[int, int] | None: + """Pull (added, removed) from a tool result, or None when unavailable. + + Only tools that already report a diff in their result payload are + inspected — we never shell out to git and never re-read files to + synthesise a delta. + """ + if tool_name not in _DIFF_RESULT_TOOLS: + return None + payload: Any = result + if isinstance(payload, str): + text = payload.strip() + if not text.startswith("{"): + return None + try: + import json + + # strict=False tolerates literal control characters inside strings + # (raw newlines in an embedded diff), which some tool serialisers + # emit. A tally line is never worth failing over formatting. + payload = json.loads(text, strict=False) + except Exception: + return None + if not isinstance(payload, dict): + return None + diff = payload.get("diff") + if not isinstance(diff, str) or not diff.strip(): + return None + added, removed = _count_diff_lines(diff) + # A diff that carries no +/- content lines (e.g. a bare hunk header) tells + # us nothing — report it as unknown rather than rendering a misleading + # "+0 -0" next to a real edit. + if added == 0 and removed == 0: + return None + return added, removed + + +class TurnSummaryCollector: + """Accumulate per-turn tool tallies from the tool-progress feed. + + Wired into the CLI's existing ``_on_tool_progress`` handler: the display + layer already receives every ``tool.completed`` event with the tool name + and raw result, so no agent-loop bookkeeping is added. + """ + + def __init__(self) -> None: + self._tally = TurnTally() + + def begin(self) -> None: + """Start a fresh turn (drops any prior tally).""" + self._tally = TurnTally() + + def record_tool( + self, + tool_name: str | None, + *, + result: Any = None, + is_error: bool = False, + ) -> None: + """Record one completed tool call. + + Failed calls are skipped: a summary claiming "edited 2 files" when one + write was denied would be exactly the over-claim the file-mutation + verifier exists to catch. + """ + if not tool_name or is_error: + return + # Internal/pseudo tools (``_thinking``) are not user-visible work. + if tool_name.startswith("_"): + return + + group = _VERB_GROUPS.get(tool_name) + if group is None: + self._tally.other_tools += 1 + return + + verb, _singular, plural = group + nouns = self._tally.verbs.setdefault(verb, {}) + nouns[plural] = nouns.get(plural, 0) + 1 + + if verb == _EDIT_VERB: + deltas = _extract_line_deltas(tool_name, result) + if deltas is not None: + added, removed = deltas + self._tally.lines_added += added + self._tally.lines_removed += removed + self._tally.has_line_deltas = True + + @property + def tally(self) -> TurnTally: + return self._tally + + def render(self, elapsed_seconds: float) -> str: + """Render this turn's summary line (see :func:`format_turn_summary`).""" + return format_turn_summary(elapsed_seconds, self._tally) + + +def format_elapsed(seconds: float) -> str: + """Format a wall-clock duration compactly (``12.4s`` / ``2m05s``).""" + if seconds < 0: + seconds = 0.0 + if seconds < 60: + return f"{seconds:.1f}s" + minutes, rest = divmod(int(round(seconds)), 60) + return f"{minutes}m{rest:02d}s" + + +def _pluralize(count: int, plural_noun: str) -> str: + """Return ``"1 file"`` / ``"3 files"`` from a plural noun form.""" + if count == 1: + singular = plural_noun + if plural_noun.endswith("ies"): + singular = plural_noun[:-3] + "y" + elif plural_noun.endswith("ses"): + singular = plural_noun[:-2] + elif plural_noun.endswith("s"): + singular = plural_noun[:-1] + return f"1 {singular}" + return f"{count} {plural_noun}" + + +def _ordered_verbs(tally: TurnTally) -> list[str]: + """Verbs in render order: priority verbs first, then first-seen order.""" + seen = list(tally.verbs.keys()) + ranked = [v for v in _VERB_PRIORITY if v in tally.verbs] + ranked += [v for v in seen if v not in _VERB_PRIORITY] + return ranked + + +def format_turn_summary( + elapsed_seconds: float, + tally: TurnTally | None, + *, + max_segments: int = _MAX_SEGMENTS, +) -> str: + """Render the per-turn accounting line, or ``""`` when there's nothing to say. + + Pure function — no config lookups, no terminal access, no I/O. Gating + (``display.turn_summary``, quiet mode, CLI-only) is the caller's job. + """ + if tally is None: + tally = TurnTally() + + segments: list[str] = [] + for verb in _ordered_verbs(tally): + nouns = tally.verbs[verb] + parts = [_pluralize(count, plural) for plural, count in nouns.items() if count] + if not parts: + continue + segment = f"{verb} {', '.join(parts)}" + if verb == _EDIT_VERB and tally.has_line_deltas: + segment += f" +{tally.lines_added} -{tally.lines_removed}" + segments.append(segment) + + if tally.other_tools: + segments.append(f"called {_pluralize(tally.other_tools, 'tools')}") + + if not segments and tally.total_tools == 0 and elapsed_seconds < _MIN_TOOLLESS_SECONDS: + return "" + + if max_segments > 0 and len(segments) > max_segments: + hidden = len(segments) - max_segments + segments = segments[:max_segments] + [f"+{hidden} more"] + + pieces = [format_elapsed(elapsed_seconds)] + segments + return f"{SUMMARY_PREFIX} " + " · ".join(pieces) + + +def format_token_flow(output_tokens: Any, *, arrow: str = "↓") -> str: + """Render cumulative turn tokens for the live spinner (``↓ 1.2k tok``). + + Returns ``""`` for a non-positive count so the spinner shows nothing + rather than a misleading ``↓ 0 tok`` before the first API response lands. + """ + try: + count = int(output_tokens) + except (TypeError, ValueError): + return "" + if count <= 0: + return "" + if count < 1000: + return f"{arrow} {count} tok" + if count < 1_000_000: + return f"{arrow} {count / 1000:.1f}k tok" + return f"{arrow} {count / 1_000_000:.1f}M tok" diff --git a/agent/verification_evidence.py b/agent/verification_evidence.py index c3154378f5e..2c8d1f85efa 100644 --- a/agent/verification_evidence.py +++ b/agent/verification_evidence.py @@ -13,10 +13,11 @@ import shlex import sqlite3 import tempfile import threading +from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Optional +from typing import Any, Iterator, Optional from hermes_constants import get_hermes_home @@ -65,13 +66,38 @@ def _connect() -> sqlite3.Connection: path = _db_path() path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path) - apply_wal_with_fallback(conn, db_label="verification_evidence.db") - conn.execute("PRAGMA busy_timeout=5000") conn.row_factory = sqlite3.Row - _ensure_schema(conn) + try: + apply_wal_with_fallback(conn, db_label="verification_evidence.db") + conn.execute("PRAGMA busy_timeout=5000") + _ensure_schema(conn) + except Exception: + # A PRAGMA/DDL failure after a successful connect() must not leak the + # just-opened connection back to the caller. + conn.close() + raise return conn +@contextmanager +def _transaction() -> Iterator[sqlite3.Connection]: + """Open a connection, commit/rollback on exit, and ALWAYS close it. + + ``sqlite3.Connection.__enter__``/``__exit__`` only commit or roll back the + transaction; they do not close the connection. Using ``with _connect()`` + alone therefore leaks a connection — and its WAL/SHM file descriptors — on + every call, deferring the close to the garbage collector, which over a + long-running process can exhaust ``RLIMIT_NOFILE`` (the cron-ledger sibling + of this bug was #69567 / PR #69594). + """ + conn = _connect() + try: + with conn: + yield conn + finally: + conn.close() + + def _ensure_schema(conn: sqlite3.Connection) -> None: conn.execute( """ @@ -454,7 +480,7 @@ def record_terminal_result( created_at = _utc_now() with _DB_LOCK: - with _connect() as conn: + with _transaction() as conn: cur = conn.execute( """ INSERT INTO verification_events( @@ -520,7 +546,7 @@ def mark_workspace_edited( edited_at = _utc_now() with _DB_LOCK: - with _connect() as conn: + with _transaction() as conn: row = conn.execute( """ SELECT changed_paths_json FROM verification_state @@ -570,7 +596,7 @@ def verification_status( sid = str(session_id or "default") root = str(facts.get("root") or Path(cwd or ".").resolve()) with _DB_LOCK: - with _connect() as conn: + with _transaction() as conn: state = conn.execute( """ SELECT last_event_id, last_edit_at, changed_paths_json diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs index 1d70ec59a61..f78b26134e4 100644 --- a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -12,11 +12,11 @@ //! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`. //! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Instant; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; use tokio::sync::{mpsc, Mutex}; @@ -260,6 +260,107 @@ pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool { && resolve_hermes_desktop_exe(install_root).is_some() } +fn resolve_marker_commit(install_root: &Path, pin: &Pin) -> Option { + if let Some(commit) = pin + .commit + .as_ref() + .filter(|commit| !commit.trim().is_empty()) + { + return Some(commit.clone()); + } + + let output = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(install_root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if commit.is_empty() { + None + } else { + Some(commit) + } +} + +fn write_bootstrap_complete_marker(install_root: &Path, pin: &Pin) -> Result { + use std::io::Write; + + let marker_path = crate::paths::likely_bootstrap_marker(install_root); + if let Some(parent) = marker_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "could not create bootstrap marker directory {}", + parent.display() + ) + })?; + } + + let completed_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + let marker = serde_json::json!({ + "schemaVersion": 1, + "pinnedCommit": resolve_marker_commit(install_root, pin), + "pinnedBranch": pin.branch.clone(), + "completedAtUnix": completed_at_unix, + }); + let mut body = serde_json::to_vec_pretty(&marker)?; + body.push(b'\n'); + + // Atomic publish (temp sibling + flush + rename), matching Electron's + // writeFileAtomic(). hermes_is_installed() only checks existence, so a + // partial direct write would incorrectly enable the launcher fast path. + let tmp_path = install_root.join(".hermes-bootstrap-complete.tmp"); + { + let mut file = std::fs::File::create(&tmp_path).with_context(|| { + format!( + "could not create temp bootstrap marker {}", + tmp_path.display() + ) + })?; + file.write_all(&body).with_context(|| { + format!( + "could not write temp bootstrap marker {}", + tmp_path.display() + ) + })?; + file.sync_all().with_context(|| { + format!( + "could not flush temp bootstrap marker {}", + tmp_path.display() + ) + })?; + } + // Windows rename fails if the destination already exists; drop any prior + // marker first so a re-run can still publish a fresh payload. + if marker_path.exists() { + std::fs::remove_file(&marker_path).with_context(|| { + format!( + "could not replace existing bootstrap marker {}", + marker_path.display() + ) + })?; + } + if let Err(err) = std::fs::rename(&tmp_path, &marker_path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(err).with_context(|| { + format!( + "could not publish bootstrap marker {} → {}", + tmp_path.display(), + marker_path.display() + ) + }); + } + + tracing::info!(path = %marker_path.display(), "bootstrap marker written"); + Ok(marker) +} + /// Spawn the already-built desktop app, detached. Returns Err if no built app /// exists or the spawn fails, so the caller can fall back to showing the /// installer UI. @@ -644,6 +745,23 @@ async fn run_bootstrap( .unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned()); let install_root = PathBuf::from(&hermes_home).join("hermes-agent"); + // Marker publish is terminal for this run: a write failure must emit Failed + // so the UI leaves the progress state (it does not poll get_bootstrap_status). + let marker = match write_bootstrap_complete_marker(&install_root, &pin) { + Ok(marker) => marker, + Err(err) => { + let msg = format!("write bootstrap marker failed: {err:#}"); + emit_event( + &app, + BootstrapEvent::Failed { + stage: None, + error: msg.clone(), + }, + ); + return Err(anyhow!(msg)); + } + }; + // Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can // re-invoke us with `--update` and shortcuts have a stable target. This is // a one-shot install concern; an `--update` re-invocation no-ops because @@ -660,10 +778,7 @@ async fn run_bootstrap( &app, BootstrapEvent::Complete { install_root: install_root.to_string_lossy().into_owned(), - marker: Some(serde_json::json!({ - "pinnedCommit": pin.commit, - "pinnedBranch": pin.branch, - })), + marker: Some(marker), }, ); @@ -903,4 +1018,103 @@ mod tests { ); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn bootstrap_complete_marker_uses_desktop_compatible_schema() { + let root = unique_tmp_dir("marker-schema"); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + let marker = + write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed"); + let marker_path = root.join(".hermes-bootstrap-complete"); + let from_disk: serde_json::Value = + serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap(); + + assert_eq!(marker, from_disk); + assert_eq!(from_disk["schemaVersion"], 1); + assert_eq!(from_disk["pinnedCommit"], "abcdef1234567890"); + assert_eq!(from_disk["pinnedBranch"], "main"); + assert!( + from_disk["completedAtUnix"].as_u64().is_some(), + "marker must carry a completion timestamp" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn bootstrap_complete_marker_is_published_atomically() { + let root = unique_tmp_dir("marker-atomic"); + make_release_tree(&root); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed"); + + let marker_path = root.join(".hermes-bootstrap-complete"); + let tmp_path = root.join(".hermes-bootstrap-complete.tmp"); + assert!( + marker_path.is_file(), + "final marker must exist after atomic publish" + ); + assert!( + !tmp_path.exists(), + "temp sibling must not remain after atomic publish" + ); + assert!( + hermes_is_installed(&root), + "atomically published marker must enable the installer fast path" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn hermes_is_installed_treats_marker_existence_as_sufficient() { + // Documents why write_bootstrap_complete_marker must publish atomically: + // the launcher predicate only checks existence, so a partial/corrupt + // final marker would still enable the fast path. + let root = unique_tmp_dir("marker-existence-only"); + make_release_tree(&root); + std::fs::write(root.join(".hermes-bootstrap-complete"), b"").unwrap(); + + assert!( + hermes_is_installed(&root), + "empty/partial marker content still counts as installed" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn marker_write_failure_leaves_no_final_marker() { + // install_root is a regular file → create_dir_all on its path fails + // before any marker bytes are published under the final name. + let base = unique_tmp_dir("marker-fail"); + let not_a_dir = base.join("not-a-dir"); + std::fs::write(¬_a_dir, b"not a directory").unwrap(); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + let err = write_bootstrap_complete_marker(¬_a_dir, &pin) + .expect_err("marker write against a non-directory root must fail"); + let msg = format!("{err:#}"); + assert!( + msg.contains("bootstrap marker"), + "error should mention the marker path: {msg}" + ); + assert!( + !not_a_dir.join(".hermes-bootstrap-complete").exists(), + "failed write must not leave a final marker that enables the fast path" + ); + assert!( + !not_a_dir.join(".hermes-bootstrap-complete.tmp").exists(), + "failed write must not leave a temp marker sibling either" + ); + let _ = std::fs::remove_dir_all(&base); + } } diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 7c64c91cf6e..0eec8ccd319 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -149,8 +149,8 @@ fn repair_macos_installer_helper(path: &Path) { #[cfg(not(target_os = "macos"))] 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.ts: +/// Where the bootstrap-complete marker lives (existence-only for the Rust +/// installer fast path; JSON schema-checked by the Electron app). 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. diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 53075c5d49b..dae529dca90 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -117,20 +117,31 @@ 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`. -**Icon-only buttons must have a tooltip.** Every button with an `icon*` size -carries no visible text label, so it must be wrapped in `` -with a descriptive label (matching the button's `aria-label`). Never use the -native HTML `title=` attribute — it's unstyled, delayed (~500ms OS default), -and visually inconsistent with the instant themed `Tip`. An enforcement test -(`src/components/ui/__tests__/no-native-title.test.ts`) fails on any ` - {onRemove && ( + <> + +
- )} -
-
+ {onRemove && ( + + )} + +
+ {lightboxSrc && ( + + )} + ) } diff --git a/apps/desktop/src/app/chat/composer/composer-utils.test.ts b/apps/desktop/src/app/chat/composer/composer-utils.test.ts index 4df8463ba2d..062b2567738 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.test.ts @@ -2,12 +2,14 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' import { describe, expect, it } from 'vitest' import { + acceptsTriggerCompletion, isPendingDraftPersistCurrent, type PendingDraftPersist, pickPlaceholder, slashArgStage, slashChipKindForItem, - slashCommandToken + slashCommandToken, + type TriggerAcceptInput } from './composer-utils' const item = (group: string): Unstable_TriggerItem => @@ -39,6 +41,53 @@ describe('slashChipKindForItem', () => { }) }) +describe('acceptsTriggerCompletion', () => { + const press = (key: string, overrides: Partial = {}) => + acceptsTriggerCompletion({ + activeExplicit: false, + freeTextArgStage: false, + key, + kind: '/', + query: 'personality alic', + ...overrides + }) + + it('accepts on Enter / Tab / Space for a finite option list', () => { + expect(press('Enter')).toBe(true) + expect(press('Tab')).toBe(true) + expect(press(' ')).toBe(true) + }) + + it('ignores keys that are neither navigation nor acceptance', () => { + expect(press('a')).toBe(false) + expect(press('Escape')).toBe(false) + }) + + it('lets an `@` mention take a literal space', () => { + expect(press(' ', { kind: '@', query: 'src/comp' })).toBe(false) + expect(press('Enter', { kind: '@', query: 'src/comp' })).toBe(true) + }) + + it('types a space on a bare `/ ` instead of accepting', () => { + expect(press(' ', { query: '' })).toBe(false) + }) + + // The `/goal ` class: the popover may be live over free-form text, so + // the keys that mean something else in prose must keep meaning it. + it('sends the prose rather than the unchosen first row', () => { + expect(press('Enter', { freeTextArgStage: true, query: 'goal ship the redesign' })).toBe(false) + expect(press(' ', { freeTextArgStage: true, query: 'goal ship the' })).toBe(false) + }) + + it('accepts on Enter once the user has arrowed to a row deliberately', () => { + expect(press('Enter', { activeExplicit: true, freeTextArgStage: true, query: 'goal stat' })).toBe(true) + }) + + it('keeps Tab as the explicit accept even over free text', () => { + expect(press('Tab', { freeTextArgStage: true, query: 'goal stat' })).toBe(true) + }) +}) + describe('pickPlaceholder', () => { it('returns a member of the pool', () => { const pool = ['a', 'b', 'c'] as const diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 7939be35b6b..21e3c1ac4a1 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -4,6 +4,8 @@ import type { SlashChipKind } from '@/components/assistant-ui/directive-text' import type { ComposerAttachment } from '@/store/composer' import { setSessionPickerOpen } from '@/store/session' +import type { TriggerState } from './text-utils' + export const COMPOSER_STACK_BREAKPOINT_PX = 320 // Above the stack breakpoint but still cramped: the model pill sheds its label @@ -50,12 +52,57 @@ export function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind return 'command' } +/** True for a skill completion — the only kind offered mid-message. */ +export const isSkillItem = (item: Unstable_TriggerItem) => slashChipKindForItem(item) === 'skill' + /** A `/` query is at its arg stage once it's past the command name. */ export const slashArgStage = (query: string) => query.includes(' ') /** The `/command` token of a slash query (`personality x` → `/personality`). */ export const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}` +export interface TriggerAcceptInput { + /** The user moved the highlight themselves (arrow keys) rather than + * inheriting the list's default first row. */ + activeExplicit: boolean + /** The trigger is a slash command whose argument is arbitrary prose. */ + freeTextArgStage: boolean + key: string + kind: TriggerState['kind'] + query: string +} + +/** + * Whether a keypress accepts the highlighted completion while the popover is + * open. Tab is always an accept — it has no other meaning in the composer. + * + * Enter and Space are conditional, because both mean something else while a + * free-text argument is being written (`/goal ship the redesign`). Space types + * a space, and Enter sends the message; letting either take the popover's + * pre-highlighted row would swap the prose the user is mid-sentence on for a + * subcommand they never chose. Enter still accepts once the user has arrowed + * to a row deliberately, so the highlight never lies about what Enter will do. + */ +export function acceptsTriggerCompletion({ + activeExplicit, + freeTextArgStage, + key, + kind, + query +}: TriggerAcceptInput): boolean { + if (key === 'Tab') { + return true + } + + if (key === 'Enter') { + return !freeTextArgStage || activeExplicit + } + + // Space is slash-only (an `@` mention takes a literal space) and gated to a + // non-empty query so a bare `/ ` still types a space. + return key === ' ' && kind === '/' && Boolean(query.trim()) && !freeTextArgStage +} + export interface QueueEditState { attachments: ComposerAttachment[] draft: string diff --git a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx index ff01bf6fd37..ad35b20faa3 100644 --- a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx +++ b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx @@ -29,7 +29,8 @@ function Harness({ onSubmit, onQueue, onCancel, - onDrain + onDrain, + onSendNow }: { busy?: boolean disabled?: boolean @@ -38,6 +39,7 @@ function Harness({ onQueue: (text: string) => void onCancel: () => void onDrain: () => void + onSendNow?: (id: string) => void }) { const editorRef = useRef(null) const draftRef = useRef('') @@ -103,6 +105,12 @@ function Harness({ } if (busy && !hasLivePayload) { + const head = queued[0] + + if (head) { + onSendNow?.(head) + } + return } @@ -167,13 +175,14 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)', expect(onCancel).not.toHaveBeenCalled() }) - it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => { + it('treats an empty Enter while busy with nothing queued as a no-op (never an accidental Stop)', async () => { const onCancel = vi.fn() const onSubmit = vi.fn() const onQueue = vi.fn() + const onSendNow = vi.fn() const { getByTestId } = render( - + ) const editor = getByTestId('editor') @@ -186,6 +195,35 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)', expect(onCancel).not.toHaveBeenCalled() expect(onSubmit).not.toHaveBeenCalled() expect(onQueue).not.toHaveBeenCalled() + expect(onSendNow).not.toHaveBeenCalled() + }) + + it('double-send: an empty Enter while busy with a queued turn sends that turn now', async () => { + const onCancel = vi.fn() + const onSendNow = vi.fn() + + const { getByTestId } = render( + + ) + + const editor = getByTestId('editor') + + await act(async () => { + editor.textContent = '' + fireEvent.keyDown(editor, { key: 'Enter' }) + }) + + // Head of the queue, and NOT a bare cancel — send-now promotes + interrupts. + expect(onSendNow).toHaveBeenCalledWith('queued-1') + expect(onCancel).not.toHaveBeenCalled() }) it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => { diff --git a/apps/desktop/src/app/chat/composer/focus.test.ts b/apps/desktop/src/app/chat/composer/focus.test.ts new file mode 100644 index 00000000000..f3987982274 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/focus.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { blurComposerInput } from './focus' +import { RICH_INPUT_SLOT } from './rich-editor' + +/** + * Inactive tabs keep their composer mounted, so an unscoped lookup can blur a + * background input and leave the one the user is typing in focused. + */ + +/** A composer input inside its own pane layer, hidden or not. */ +function mountInput(hidden = false) { + const layer = document.createElement('div') + const input = document.createElement('div') + input.dataset.slot = RICH_INPUT_SLOT + input.tabIndex = 0 + layer.toggleAttribute('data-pane-hidden', hidden) + layer.append(input) + document.body.append(layer) + + return input +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('blurComposerInput', () => { + it('blurs the foreground composer while a hidden tab matches first', () => { + const background = mountInput(true) + const foreground = mountInput() + + foreground.focus() + blurComposerInput() + + expect(document.activeElement).not.toBe(foreground) + expect(document.activeElement).not.toBe(background) + }) + + it('leaves focus alone when the composer does not hold it', () => { + const outside = document.createElement('button') + document.body.append(outside) + mountInput() + + outside.focus() + blurComposerInput() + + expect(document.activeElement).toBe(outside) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index a470edfcd95..7120fde2b1a 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -10,6 +10,8 @@ * steal focus from the composer effect. */ +import { queryVisible } from '@/components/pane-shell/pane-visibility' + import type { InlineRefInput } from './inline-refs' import { RICH_INPUT_SLOT } from './rich-editor' @@ -175,9 +177,11 @@ export const focusComposerInput = (el: HTMLElement | null) => { window.setTimeout(focus, 0) } -/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). */ +/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). + * Skips inactive tabs — they stay mounted, so an unscoped lookup can land on a + * background composer and leave the visible one focused. */ export const blurComposerInput = () => { - const el = document.querySelector(`[data-slot="${RICH_INPUT_SLOT}"]`) as HTMLElement | null + const el = queryVisible(`[data-slot="${RICH_INPUT_SLOT}"]`) if (el && document.activeElement === el) { el.blur() diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts index 949b8f1020c..aa657ca8291 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts @@ -4,10 +4,11 @@ import { useEffect, useRef } from 'react' import { playSpeechText } from '@/lib/voice-playback' import { ownsAmbientCue } from '@/store/ambient' import { notifyError } from '@/store/notifications' -import { $messages } from '@/store/session' import { $voicePlayback } from '@/store/voice-playback' import { $autoSpeakReplies } from '@/store/voice-prefs' +import { useComposerScope } from '../scope' + interface AutoSpeakReply { id: string pending: boolean @@ -40,6 +41,9 @@ export function useAutoSpeakReplies({ sessionId }: UseAutoSpeakReplies) { const enabled = useStore($autoSpeakReplies) + // Wake on THIS composer's transcript: a tile subscribed to the primary's + // would never fire on its own replies (and would fire on someone else's). + const { $messages } = useComposerScope() const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply }) latest.current = { conversationActive, failureLabel, markSpoken, pendingReply } @@ -83,5 +87,5 @@ export function useAutoSpeakReplies({ const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)] return () => stops.forEach(f => f()) - }, [enabled, sessionId]) + }, [$messages, enabled, sessionId]) } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx new file mode 100644 index 00000000000..81401482f69 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx @@ -0,0 +1,130 @@ +import { act, cleanup, render } from '@testing-library/react' +import { useLayoutEffect } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { type ComposerAttachment, mainComposerScope, stashSessionDraft } from '@/store/composer' + +import type { QueueEditState } from '../composer-utils' + +import { useComposerDraft } from './use-composer-draft' + +const mockComposerApi = { setText: vi.fn() } + +vi.mock('@assistant-ui/react', () => ({ + useAui: () => ({ composer: () => mockComposerApi }), + useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({ composer: { text: '' } }), + useComposerRuntime: () => ({ + getState: () => ({ text: '' }), + subscribe: () => () => undefined + }) +})) + +interface ProbeHarnessProps { + activeQueueSessionKey: string | null + onLayoutSnapshot: (attachments: ComposerAttachment[]) => void + sessionId: string +} + +function ProbeHarness({ activeQueueSessionKey, onLayoutSnapshot, sessionId }: ProbeHarnessProps) { + useComposerDraft({ + activeQueueSessionKey, + focusKey: null, + inputDisabled: false, + queueEditRef: { current: null as QueueEditState | null }, + sessionId + }) + + // useLayoutEffect fires synchronously right after the DOM commit, BEFORE + // the hook's per-thread scope-swap useEffect (a passive effect) has a + // chance to swap attachmentScope.$attachments over to the new session. A + // synchronous read here — the same read ChatBar's `attachments` prop + // performs at render time — observes the OUTGOING session's attachments. + useLayoutEffect(() => { + onLayoutSnapshot(mainComposerScope.$attachments.get()) + }) + + return null +} + +describe('useComposerDraft — attachment scope stays coherent with the committed session on switch (#59305)', () => { + afterEach(() => { + cleanup() + mainComposerScope.clear() + }) + + it('clears the outgoing session attachments by the layout phase right after switching sessions', () => { + const attachmentA: ComposerAttachment = { id: 'url-A', kind: 'url', label: 'A' } + stashSessionDraft('session-A', 'hi from A', [attachmentA]) + + const snapshots: ComposerAttachment[][] = [] + + const { rerender } = render( + snapshots.push(s)} sessionId="session-A" /> + ) + + // Mount loads session A's stashed attachment into the (module-level) main + // scope — confirms the fixture actually seeded the leak precondition. + expect(mainComposerScope.$attachments.get()).toEqual([attachmentA]) + + snapshots.length = 0 // drop the initial-mount snapshot; only the switch matters + + act(() => { + rerender( + snapshots.push(s)} + sessionId="session-B" + /> + ) + }) + + // By the layout phase the scope must already be B's (empty) — a submit + // fired the instant B renders must never ship session A's attachment. + expect(snapshots[0]).toEqual([]) + }) +}) + +describe('useComposerDraft — rehydrate diagnostic log stays redacted', () => { + afterEach(() => { + cleanup() + mainComposerScope.clear() + vi.restoreAllMocks() + }) + + it('logs counts/kinds/scope on restore but never the raw url, refText, or label', () => { + const secretUrl = 'https://secret.example.com/private-workspace-path' + + const attachment: ComposerAttachment = { + id: 'url-secret', + kind: 'url', + label: 'do-not-leak-label', + refText: `@url:${secretUrl}` + } + + stashSessionDraft('session-secret', '', [attachment]) + + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined) + + render( + undefined} + sessionId="session-secret" + /> + ) + + const rehydrateCalls = debugSpy.mock.calls.filter(call => call[0] === '[composer-rehydrate]') + expect(rehydrateCalls.length).toBeGreaterThan(0) + + const serialized = JSON.stringify(rehydrateCalls) + expect(serialized).not.toContain(secretUrl) + expect(serialized).not.toContain(attachment.label) + expect(serialized).not.toContain(attachment.refText) + + expect(rehydrateCalls[0]?.[1]).toMatchObject({ + attachmentCount: 1, + attachmentKinds: ['url'], + scope: 'session-secret' + }) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 11dc9534df0..43b8514cc35 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -1,5 +1,5 @@ import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react' -import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' +import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer' @@ -20,7 +20,7 @@ import { onComposerInsertRequest } from '../focus' import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs' -import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor' +import { composerPlainText, placeCaretEnd, REF_RE, renderComposerContents } from '../rich-editor' import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' @@ -189,6 +189,21 @@ export function useComposerDraft({ stashSessionDraft(scope, text, attachments) const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { + // Diagnostic breadcrumb for #59305-class reports: identifies WHAT kind of + // state got restored into the composer (session switch, queue-edit + // restore, history browse) without logging any raw content. REF_RE has the + // global flag — testing against a throwaway clone avoids mutating the + // shared instance's lastIndex, which would otherwise corrupt this check on + // the next call. + if (attachments.length > 0 || new RegExp(REF_RE.source, REF_RE.flags).test(text)) { + console.debug('[composer-rehydrate]', { + attachmentCount: attachments.length, + attachmentKinds: attachments.map(a => a.kind), + hasTextRefs: new RegExp(REF_RE.source, REF_RE.flags).test(text), + scope: activeQueueSessionKeyRef.current + }) + } + attachmentScope.$attachments.set(cloneAttachments(attachments)) paintDraft(text, false) } @@ -231,6 +246,7 @@ export function useComposerDraft({ // source otherwise), and (3) schedule the debounced per-session stash. // Browsing history / editing a queued prompt suppress the stash so recalled // text never clobbers the draft. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const sync = () => { const text = composerRuntime.getState().text @@ -318,7 +334,17 @@ export function useComposerDraft({ // Per-thread draft swap — the composer's only session coupling. Lifecycle // never clears composer state; this effect alone stashes on leave, restores // on enter. Keyed writes are idempotent, so no skip-sentinel. - useEffect(() => { + // + // MUST be a layout effect, not a passive one: it swaps attachmentScope's + // module-level $attachments atom, and a passive effect fires only after the + // browser paints the new session's view — leaving a window where the DOM + // already shows session B while $attachments (and therefore ChatBar's + // `attachments` prop) still holds session A's chips. A submit fired in that + // window (e.g. a fast session switch immediately followed by Enter) would + // ship A's attachments into B's turn (#59305). useLayoutEffect closes the + // window by running before paint. + + useLayoutEffect(() => { // A pending debounce timer from the outgoing session is now stale — its // scope was correct when scheduled, but the authoritative stash below // (and the cleanup on the way out) already covers that text. Letting it @@ -344,6 +370,7 @@ export function useComposerDraft({ // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R // inside the debounce/rAF window would drop trailing keystrokes without this. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const flushPendingDraftPersist = () => { const scope = draftScopeRef.current @@ -381,6 +408,7 @@ export function useComposerDraft({ requestMainFocus, sessionIdRef, setComposerText, - stashAt + stashAt, + syncDraftFromEditor } } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index 318802bbab8..771bf2a9dd3 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -1,10 +1,14 @@ import { useAuiState } from '@assistant-ui/react' import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' +import { + clearSurfaceVar, + COMPOSER_HEIGHT_VAR, + COMPOSER_SURFACE_HEIGHT_VAR, + setSurfaceVar +} from '@/app/chat/surface-vars' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' -import { $composerPoppedOut } from '@/store/composer-popout' -import { isSecondaryWindow } from '@/store/windows' import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils' @@ -76,6 +80,11 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, const lastBucketedSurfaceHeightRef = useRef(0) const lastTightRef = useRef(null) const lastCompactPillRef = useRef(null) + // Mirrored into a ref so `syncComposerMetrics` stays referentially stable — + // it's the shared ResizeObserver's handler, and a new identity every render + // would re-register the observation. + const poppedOutRef = useRef(poppedOut) + poppedOutRef.current = poppedOut const syncComposerMetrics = useCallback(() => { const composer = composerRef.current @@ -86,21 +95,20 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, // Floating composer is out of the thread's flow — it must not reserve any // bottom clearance. Zero the measured vars so the thread reclaims the space. - // (Read globals here so the callback stays stable; mirror the popoutAllowed - // gate since secondary windows are forced docked.) - if ($composerPoppedOut.get() && !isSecondaryWindow()) { - const root = document.documentElement + // Read through a ref so the callback stays stable, and read THIS surface's + // own state: pop-out is per layout zone, so a float in the left split must + // not zero the right split's clearance. + if (poppedOutRef.current) { lastBucketedHeightRef.current = 0 lastBucketedSurfaceHeightRef.current = 0 - root.style.setProperty('--composer-measured-height', '0px') - root.style.setProperty('--composer-surface-measured-height', '0px') + setSurfaceVar(composer, COMPOSER_HEIGHT_VAR, '0px') + setSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR, '0px') return } const { height, width } = composer.getBoundingClientRect() const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height - const root = document.documentElement if (width > 0) { const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX @@ -135,7 +143,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, if (bucket !== lastBucketedHeightRef.current) { lastBucketedHeightRef.current = bucket - root.style.setProperty('--composer-measured-height', `${bucket}px`) + setSurfaceVar(composer, COMPOSER_HEIGHT_VAR, `${bucket}px`) } } @@ -144,7 +152,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, if (bucket !== lastBucketedSurfaceHeightRef.current) { lastBucketedSurfaceHeightRef.current = bucket - root.style.setProperty('--composer-surface-measured-height', `${bucket}px`) + setSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR, `${bucket}px`) } } }, [composerRef, composerSurfaceRef, editorRef]) @@ -160,12 +168,13 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, }, [poppedOut, syncComposerMetrics]) useEffect(() => { + const composer = composerRef.current + return () => { - const root = document.documentElement - root.style.removeProperty('--composer-measured-height') - root.style.removeProperty('--composer-surface-measured-height') + clearSurfaceVar(composer, COMPOSER_HEIGHT_VAR) + clearSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR) } - }, []) + }, [composerRef]) // Pill compacts on real width (tile/pane), OR when stacked for any reason // (viewport-narrow / wrapped) so the controls row never over-runs. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts index 0c2e4b61927..8d1bf8c1ee5 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts @@ -29,6 +29,7 @@ export function useComposerPlaceholder({ disabled, reconnecting, sessionId }: Us const prevSessionIdRef = useRef(sessionId) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const prev = prevSessionIdRef.current prevSessionIdRef.current = sessionId diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts index 518aa3658a5..b326025bb95 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts @@ -1,18 +1,20 @@ import { useStore } from '@nanostores/react' -import { type RefObject, useCallback, useEffect } from 'react' +import { type RefObject, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { usePaneGroup, usePaneVisible } from '@/components/pane-shell/pane-visibility' +import { useResizeObserver } from '@/hooks/use-resize-observer' import { triggerHaptic } from '@/lib/haptics' import { - $composerPopoutPosition, - $composerPoppedOut, + $composerPopoutZone, + clampPopoutPosition, + getComposerPopoutZone, + popoutBoundsElement, + type PopoutPosition, readPopoutBounds, - setComposerPopoutPosition, setComposerPoppedOut } from '@/store/composer-popout' import { isSecondaryWindow } from '@/store/windows' -import { useComposerScope } from '../scope' - import { useComposerPopoutGestures } from './use-popout-drag' interface UseComposerPopoutOptions { @@ -20,32 +22,122 @@ interface UseComposerPopoutOptions { } /** - * Pop-out engine: the docked↔floating state (a shared, persisted atom), the - * dock/float/toggle actions, the drag gestures, and the on-screen re-clamp. - * Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) can't - * pop out — a floating composer makes no sense there and would yank the main - * window's composer out via the shared atom. + * This surface's on-screen placement, derived from its zone's drag intent. + * + * A zone stores one intent for its whole tab stack — drag the box in any tab and + * it moves in all of them — but each surface owns a different rect, so the + * intent is clamped per surface. Clamping into the store instead would have + * every keep-alive-mounted tab overwrite the others with a position bounded by + * ITS geometry, last writer winning: that's how a drag in one tab used to get + * lost in another. + * + * Re-placing is skipped while this surface drags (the gesture already clamped + * against this rect) and while it's an inactive tab (still mounted, so a live + * drag would otherwise force a reflow per background tab per frame). + */ +function usePopoutPlacement( + composerRef: RefObject, + groupId: string, + intent: PopoutPosition, + dragging: boolean, + poppedOut: boolean +): PopoutPosition { + const [placement, setPlacement] = useState(intent) + const visible = usePaneVisible() + // Re-place while this surface is the visible tab and isn't itself dragging. + const live = poppedOut && visible && !dragging + + // Resolved before the shared ResizeObserver below registers (hook order puts + // this layout effect first), so the observer always has this surface's own + // bounds element rather than a document-wide first match. + const boundsRef = useRef(null) + + useLayoutEffect(() => { + boundsRef.current = popoutBoundsElement(composerRef.current) + }) + + const reclamp = useCallback(() => { + const el = composerRef.current + + if (!el) { + return + } + + const size = { height: el.offsetHeight, width: el.offsetWidth } + const next = clampPopoutPosition(getComposerPopoutZone(groupId).position, size, readPopoutBounds(el)) + + // Bail on an unchanged placement: a sash drag resizes the surface every + // frame, and a fresh object each time re-renders the whole composer. + setPlacement(prev => (prev.bottom === next.bottom && prev.right === next.right ? prev : next)) + }, [composerRef, groupId]) + + // The surface resizing (sash drag, sidebar open, tab split) re-places the box + // against its new rect; the composer resizing (a growing draft) re-places it + // against its new height. + useResizeObserver( + useCallback(() => { + if (live) { + reclamp() + } + }, [live, reclamp]), + composerRef, + boundsRef + ) + + // useLayoutEffect, not useEffect: a tab revealed after the box was dragged in + // another one must not paint a frame at its stale placement before catching + // up. Runs before paint, and no-ops for hidden tabs (`live`). + useLayoutEffect(() => { + if (!live) { + return undefined + } + + reclamp() + // A second pass after layout settles (sidebar widths, fonts): anyone + // restored out of bounds is pulled back even if the first measure was + // premature. + const raf = requestAnimationFrame(reclamp) + window.addEventListener('resize', reclamp) + + return () => { + cancelAnimationFrame(raf) + window.removeEventListener('resize', reclamp) + } + }, [intent, live, reclamp]) + + return dragging ? intent : placement +} + +/** + * Pop-out engine: the docked↔floating state, the dock/float/toggle actions, the + * drag gestures, and this surface's placement. + * + * State is scoped to the surface's layout ZONE (its tab stack): tabs in the same + * zone share one float, so switching tabs keeps the box exactly where you put + * it, while a split zone beside them keeps its own — popping out on the left + * doesn't fling a composer out of the right. + * + * Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) stay + * docked: a floating composer makes no sense in a scratch window. */ export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) { - // The floating composer is a window-level singleton: only the main scope - // (not tiles) in a primary window may pop out. - const scope = useComposerScope() - const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed - const poppedOut = useStore($composerPoppedOut) && popoutAllowed - const popoutPosition = useStore($composerPopoutPosition) + const popoutAllowed = !isSecondaryWindow() + const groupId = usePaneGroup() + const zone = useStore(useMemo(() => $composerPopoutZone(groupId), [groupId])) + const poppedOut = zone.poppedOut && popoutAllowed const handleComposerPopOut = useCallback(() => { triggerHaptic('open') - setComposerPoppedOut(true) - }, []) + setComposerPoppedOut(groupId, true) + }, [groupId]) const handleComposerDock = useCallback(() => { triggerHaptic('success') - setComposerPoppedOut(false) - }, []) + setComposerPoppedOut(groupId, false) + }, [groupId]) // Double-click the grab area toggles dock/float. Undocking restores the last - // position (the persisted atom is never cleared on dock). + // position (a zone's stored position is never cleared on dock). const handleComposerToggle = useCallback(() => { poppedOut ? handleComposerDock() : handleComposerPopOut() }, [handleComposerDock, handleComposerPopOut, poppedOut]) @@ -56,39 +148,14 @@ export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) { onPointerDown: onComposerGesturePointerDown } = useComposerPopoutGestures({ composerRef, + groupId, onDock: handleComposerDock, onPopOut: handleComposerPopOut, poppedOut, - position: popoutPosition + position: zone.position }) - // Keep the floating box on-screen: re-clamp (with the real measured size + - // thread bounds) when it pops out and on every window resize — so a position - // persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar - // can never strand it. The rAF pass re-clamps after layout settles (sidebar - // widths, fonts), so anyone loading in out of bounds is pulled back + saved - // even if the first measure was premature. - useEffect(() => { - if (!poppedOut) { - return undefined - } - - const reclamp = (persist: boolean) => { - const el = composerRef.current - const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined - setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size }) - } - - reclamp(true) - const raf = requestAnimationFrame(() => reclamp(true)) - const onResize = () => reclamp(false) - window.addEventListener('resize', onResize) - - return () => { - cancelAnimationFrame(raf) - window.removeEventListener('resize', onResize) - } - }, [composerRef, poppedOut]) + const popoutPosition = usePopoutPlacement(composerRef, groupId, zone.position, dragging, poppedOut) return { dockProximity, diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts index 4e813f548ae..e1bb5442fef 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -106,7 +106,9 @@ export function useComposerQueue({ entryId: entry.id, sessionKey: activeQueueSessionKey }) - loadIntoComposer(entry.text, entry.attachments) + // Edit what the panel SHOWS. A queued `/skill` entry's text is the + // expanded skill body — never drop that into the composer. + loadIntoComposer(entry.displayText ?? entry.text, entry.attachments) triggerHaptic('selection') focusInput() } @@ -135,7 +137,7 @@ export function useComposerQueue({ if (next) { setQueueEditSnapshot({ ...queueEdit, entryId: next.id }) - loadIntoComposer(next.text, next.attachments) + loadIntoComposer(next.displayText ?? next.text, next.attachments) } else { setQueueEditSnapshot(null) loadIntoComposer(queueEdit.draft, queueEdit.attachments) @@ -213,6 +215,7 @@ export function useComposerQueue({ const accepted = await Promise.resolve( onSubmit(entry.text, { attachments: entry.attachments, + ...(entry.displayText ? { displayText: entry.displayText } : {}), fromQueue: true, sessionId: drainRuntimeSessionId, storedSessionId: drainQueueSessionKey @@ -322,6 +325,7 @@ export function useComposerQueue({ // never churns, so a change there is a real session switch and must NOT // migrate; only the runtime-derived key (queueSessionKey falsy → key is // sessionId) churns on a backend bounce/resume of the same conversation. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const prev = prevQueueKeyRef.current prevQueueKeyRef.current = activeQueueSessionKey diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx index a09cd10ef29..dd83d04f0c0 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx @@ -1,7 +1,9 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { $clarifyRequests } from '@/store/clarify' import type { ComposerAttachment } from '@/store/composer' +import { $gateway } from '@/store/gateway' import { useComposerSubmit } from './use-composer-submit' @@ -113,7 +115,9 @@ describe('useComposerSubmit busy-turn routing', () => { hook.result.current.submitDraft() }) - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('/compress preserve context')) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith('/compress preserve context', { composerScope: 'stored-session' }) + ) expect(clearDraft).toHaveBeenCalledTimes(1) expect(onSteer).not.toHaveBeenCalled() expect(queueCurrentDraft).not.toHaveBeenCalled() @@ -159,9 +163,103 @@ describe('useComposerSubmit busy-turn routing', () => { hook.result.current.submitDraft() }) - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('ordinary question', { attachments: [] })) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith('ordinary question', { + attachments: [], + composerScope: 'stored-session' + }) + ) expect(onSteer).not.toHaveBeenCalled() expect(queueCurrentDraft).not.toHaveBeenCalled() expect(onCancel).not.toHaveBeenCalled() }) + + it('threads the loaded composer scope through onSubmit for the #59305 submit-time guard', async () => { + const { hook, onSubmit } = renderSubmitHook({ text: 'hello' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith('hello', expect.objectContaining({ composerScope: 'stored-session' })) + ) + }) +}) + +describe('useComposerSubmit with a clarify parked on the session', () => { + const gatewayRequest = vi.fn(async () => ({ ok: true })) + + const parkClarify = (sessionId: string) => { + $clarifyRequests.set({ + [sessionId]: { requestId: `req-${sessionId}`, question: 'which one?', choices: ['a', 'b'], sessionId } + }) + $gateway.set({ request: gatewayRequest } as unknown as ReturnType) + } + + afterEach(() => { + cleanup() + gatewayRequest.mockClear() + $clarifyRequests.set({}) + $gateway.set(null) + vi.restoreAllMocks() + }) + + it('skips the question and still sends the typed message on an idle session', async () => { + parkClarify('runtime-session') + const { hook, onSubmit } = renderSubmitHook({ text: 'actually do this instead' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => + expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { + request_id: 'req-runtime-session', + answer: '' + }) + ) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith('actually do this instead', expect.objectContaining({ attachments: [] })) + ) + expect($clarifyRequests.get()['runtime-session']).toBeUndefined() + }) + + it('skips the question before steering a busy turn', async () => { + parkClarify('runtime-session') + const { hook, onSteer } = renderSubmitHook({ busy: true, text: 'change course' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course')) + expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-runtime-session', answer: '' }) + }) + + it('leaves the question alone for an empty Enter (Stop, not an answer)', () => { + parkClarify('runtime-session') + const { hook, onCancel } = renderSubmitHook({ busy: true }) + + act(() => { + hook.result.current.submitDraft() + }) + + expect(gatewayRequest).not.toHaveBeenCalled() + expect($clarifyRequests.get()['runtime-session']).toBeDefined() + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it("leaves another session's question alone", async () => { + parkClarify('other-session') + const { hook, onSubmit } = renderSubmitHook({ text: 'unrelated message' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()) + expect(gatewayRequest).not.toHaveBeenCalled() + expect($clarifyRequests.get()['other-session']).toBeDefined() + }) }) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index fb1cec2cf04..9d34ddfca3f 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -2,12 +2,14 @@ import { type RefObject, useEffect, useRef } from 'react' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' +import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify' import { clearSessionDraft, type ComposerAttachment } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue' import { cloneAttachments, type QueueEditState } from '../composer-utils' import { onComposerSubmitRequest } from '../focus' +import { pathifyRefs } from '../path-refs' import { composerPlainText } from '../rich-editor' import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' @@ -89,7 +91,11 @@ export function useComposerSubmit({ stashAt(submittedScope, text, submittedAttachments) } - void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) + void Promise.resolve( + attachments + ? onSubmit(text, { attachments, composerScope: submittedScope }) + : onSubmit(text, { composerScope: submittedScope }) + ) .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope))) .catch(restore) } @@ -134,9 +140,27 @@ export function useComposerSubmit({ } } - const text = draftRef.current + // A path that never got its committing space (`@apps/desktop/` left by a Tab + // descend, then Enter) is still the reference the user picked — promote it + // on the way out so it attaches instead of submitting as inert text. + const text = pathifyRefs(draftRef.current) const payloadPresent = text.trim().length > 0 || attachments.length > 0 + // A clarify card parked on this session owns the turn: the agent is blocked + // inside its tool batch waiting on `clarify.respond`, so a follow-up routed + // through steer/queue sits undelivered until the clarify's own timeout + // (default 5 min) — the message looks sent and nothing happens. Typing a + // real message instead of picking an option IS the answer "none of these": + // skip the question so the tool returns, then route the words normally. + // + // Fire-and-forget, not awaited: the skip clears the card synchronously and + // both RPCs ride the same socket in call order, so the gateway resolves the + // clarify before it sees the follow-up. Awaiting first would leave the draft + // live for a tick — long enough for a second Enter to send it twice. + if (payloadPresent && !queueEdit && hasClarifyRequest(sessionId)) { + void skipClarifyRequest(sessionId) + } + if (queueEdit) { exitQueuedEdit('save') } else if (busy) { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts new file mode 100644 index 00000000000..a778262d370 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts @@ -0,0 +1,225 @@ +import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { act, renderHook } from '@testing-library/react' +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from '../rich-editor' + +import { useComposerTrigger } from './use-composer-trigger' + +/** A live contentEditable seeded with `text`, caret parked at the end. */ +function mountEditor(text: string) { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.contentEditable = 'true' + document.body.append(editor) + renderComposerContents(editor, text) + + const range = document.createRange() + range.selectNodeContents(editor) + range.collapse(false) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + + return editor +} + +const item = (command: string, group = 'Skills'): Unstable_TriggerItem => ({ + id: command, + type: 'slash', + label: command.slice(1), + metadata: { command, display: command, meta: '', group, action: '', rawText: command } +}) + +function mountTrigger(editor: HTMLDivElement, items: Unstable_TriggerItem[]) { + const editorRef = createRef() as { current: HTMLDivElement | null } + editorRef.current = editor + + const draftRef = { current: composerPlainText(editor) } + + const adapter: Unstable_TriggerAdapter = { + categories: () => [], + categoryItems: () => [], + search: () => items + } + + const setComposerText = vi.fn() + + const hook = renderHook(() => + useComposerTrigger({ + at: { adapter: null, loading: false }, + draftRef, + editorRef, + requestMainFocus: vi.fn(), + setComposerText, + slash: { adapter, loading: false } + }) + ) + + return { draftRef, hook, setComposerText } +} + +describe('useComposerTrigger — slash anywhere in the prompt', () => { + it('opens the completion list for a slash typed mid-message', () => { + const editor = mountEditor('please run /cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.trigger).toMatchObject({ kind: '/', inline: true, query: 'cle' }) + expect(hook.result.current.triggerItems).toHaveLength(1) + }) + + it('inserts the picked skill inline and keeps the surrounding prose intact', () => { + const editor = mountEditor('please run /cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.replaceTriggerWithChip(item('/clean'))) + + // The `/cle` the user typed is replaced by the full command; "please run" + // in front of it survives untouched. + expect(composerPlainText(editor)).toBe('please run /clean ') + }) + + it('offers only skills mid-message, not app commands', () => { + // `/model` and `/new` act on the app — meaningless as a reference in prose. + const editor = mountEditor('please run /') + const { hook } = mountTrigger(editor, [item('/clean'), item('/model', 'Commands'), item('/new', 'Commands')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.triggerItems.map(i => i.label)).toEqual(['clean']) + }) + + it('still offers the full command set at the start of the prompt', () => { + const editor = mountEditor('/') + const { hook } = mountTrigger(editor, [item('/clean'), item('/model', 'Commands')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.triggerItems.map(i => i.label)).toEqual(['clean', 'model']) + }) + + it('still opens the list for a slash at the start of the prompt', () => { + const editor = mountEditor('/cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.trigger).toMatchObject({ kind: '/', query: 'cle' }) + expect(hook.result.current.trigger?.inline).toBeUndefined() + }) + + it('leaves a mid-message file path alone', () => { + const editor = mountEditor('open src/foo/bar') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.trigger).toBeNull() + }) + + it('opens the list for a second slash after a leading command', () => { + // `/work /cle`: the command regex's argument tail would otherwise swallow + // `/cle` as an argument to `/work`, and a no-arg command suppresses the + // popover — so every slash after the first went dead. + const editor = mountEditor('/work /cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.trigger).toMatchObject({ kind: '/', inline: true, query: 'cle' }) + expect(hook.result.current.triggerItems).toHaveLength(1) + }) + + it('inserts the second command without disturbing the first', () => { + const editor = mountEditor('/work rewrite the composer /cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.replaceTriggerWithChip(item('/clean'))) + + expect(composerPlainText(editor)).toBe('/work rewrite the composer /clean ') + }) +}) + +describe('useComposerTrigger — free-text slash arguments', () => { + it('keeps a picked /goal command as editable text while retaining subcommand completion', () => { + const editor = mountEditor('/go') + const goal = item('/goal', 'Commands') + const { hook } = mountTrigger(editor, [goal]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.replaceTriggerWithChip(goal)) + + expect(composerPlainText(editor)).toBe('/goal ') + expect(editor.querySelector('[data-slash-kind]')).toBeNull() + expect(hook.result.current.trigger).not.toBeNull() + }) + + it('does not seal a multi-word /goal into a chip when the option list runs empty', () => { + const editor = mountEditor('/goal finish the full prompt') + const { hook } = mountTrigger(editor, []) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.slashFreeTextArgStage).toBe(true) + expect(hook.result.current.commitTypedSlashDirective()).toBe(false) + expect(composerPlainText(editor)).toBe('/goal finish the full prompt') + expect(editor.querySelector('[data-slash-kind]')).toBeNull() + }) + + it('treats the default highlight as a suggestion until the user arrows to a row', () => { + const editor = mountEditor('/goal stat') + const { hook } = mountTrigger(editor, [item('/goal status', 'Options')]) + + act(() => hook.result.current.refreshTrigger()) + expect(hook.result.current.triggerActiveExplicit).toBe(false) + + act(() => hook.result.current.moveTriggerActive(1)) + expect(hook.result.current.triggerActiveExplicit).toBe(true) + }) + + it('drops a deliberate selection once the query moves on', () => { + const editor = mountEditor('/goal stat') + const { hook } = mountTrigger(editor, [item('/goal status', 'Options')]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.moveTriggerActive(1)) + + renderComposerContents(editor, '/goal start the migration') + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.triggerActiveExplicit).toBe(false) + }) + + it('keeps a multi-word /resume search typeable instead of firing the picker action', () => { + // The session list always ends in a "Browse all sessions…" action row, so + // an accept here doesn't insert a chip — it empties the composer and opens + // the overlay, taking the half-typed query with it. + const editor = mountEditor('/resume my new') + const { hook } = mountTrigger(editor, [item('/resume', 'Sessions')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.slashFreeTextArgStage).toBe(true) + expect(hook.result.current.commitTypedSlashDirective()).toBe(false) + expect(composerPlainText(editor)).toBe('/resume my new') + }) + + it('still commits a fully typed finite option as one directive chip', () => { + const editor = mountEditor('/personality creative') + const { hook } = mountTrigger(editor, []) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.slashFreeTextArgStage).toBe(false) + act(() => { + expect(hook.result.current.commitTypedSlashDirective()).toBe(true) + }) + expect(composerPlainText(editor)).toBe('/personality creative ') + expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/personality creative') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index 20abc03309f..ea968fb3643 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -2,9 +2,15 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u import { type MutableRefObject, type RefObject, useCallback, useEffect, useRef, useState } from 'react' import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' -import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' +import { desktopSlashCommandArgumentMode } from '@/lib/desktop-slash-commands' -import { COMPLETION_ACTIONS, slashArgStage, slashChipKindForItem, slashCommandToken } from '../composer-utils' +import { + COMPLETION_ACTIONS, + isSkillItem, + slashArgStage, + slashChipKindForItem, + slashCommandToken +} from '../composer-utils' import { composerPlainText, placeCaretEnd, @@ -47,6 +53,11 @@ export function useComposerTrigger({ }: UseComposerTriggerOptions) { const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) + // The list highlights its first row on open, which is a suggestion rather + // than a choice. This records that the user moved the highlight themselves, + // which is what lets Enter accept a completion in a free-text argument stage + // without stealing prose from everyone who never touched the arrows. + const [triggerActiveExplicit, setTriggerActiveExplicit] = useState(false) const [triggerItems, setTriggerItems] = useState([]) // Set synchronously in keydown when the open trigger popover consumes a // navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must @@ -57,6 +68,11 @@ export function useComposerTrigger({ // re-rendered and the handler closure sees the post-keydown state. const triggerKeyConsumedRef = useRef(false) + const resetTriggerActive = useCallback(() => { + setTriggerActive(0) + setTriggerActiveExplicit(false) + }, []) + const refreshTrigger = useCallback(() => { const editor = editorRef.current @@ -74,7 +90,7 @@ export function useComposerTrigger({ if (!rawText.includes('@') && !rawText.includes('/')) { if (trigger) { setTrigger(null) - setTriggerActive(0) + resetTriggerActive() } return @@ -83,11 +99,16 @@ export function useComposerTrigger({ const before = textBeforeCaret(editor) const found = detectTrigger(before ?? composerPlainText(editor)) - // The arg-stage popover is only useful for commands with an options screen. - // For a no-arg command it would dead-end on "No matches", so drop it — the - // directive is already complete. + // A text-only command has no completion screen once its prose begins. Mixed + // commands such as /goal stay live so their finite subcommands can still be + // suggested, while arbitrary goal text remains valid. + const argumentMode = + found?.kind === '/' && slashArgStage(found.query) + ? desktopSlashCommandArgumentMode(slashCommandToken(found.query)) + : null + const detected = - found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query)) + found?.kind === '/' && slashArgStage(found.query) && argumentMode !== 'options' && argumentMode !== 'mixed' ? null : found @@ -98,9 +119,9 @@ export function useComposerTrigger({ // caret move (mouseup) or a stray refresh — must preserve the user's // current selection instead of snapping back to the first item. if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) { - setTriggerActive(0) + resetTriggerActive() } - }, [editorRef, trigger]) + }, [editorRef, resetTriggerActive, trigger]) const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null @@ -112,7 +133,13 @@ export function useComposerTrigger({ return } - setTriggerItems(triggerAdapter.search(trigger.query)) + const items = triggerAdapter.search(trigger.query) + + // Mid-message only offers SKILLS. A built-in like `/model` or `/new` acts + // on the app, so it's meaningless as a reference inside prose — only a + // skill reads as "handle this part with X". Filtering here rather than in + // the fetcher keeps one completion source for both shapes. + setTriggerItems(trigger.inline ? items.filter(isSkillItem) : items) }, [trigger, triggerAdapter]) const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false @@ -122,10 +149,23 @@ export function useComposerTrigger({ // Space/Tab — neither should dead-end on a popover. const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length + const slashArgumentMode = + trigger?.kind === '/' && slashArgStage(trigger.query) + ? desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) + : null + + const slashFreeTextArgStage = slashArgumentMode === 'mixed' || slashArgumentMode === 'text' + const closeTrigger = () => { setTrigger(null) setTriggerItems([]) - setTriggerActive(0) + resetTriggerActive() + } + + /** Step the highlight, marking it as the user's own deliberate pick. */ + const moveTriggerActive = (delta: number) => { + setTriggerActiveExplicit(true) + setTriggerActive(idx => (idx + delta + triggerItems.length) % triggerItems.length) } useEffect(() => { @@ -136,9 +176,16 @@ export function useComposerTrigger({ // the completion list is empty because the arg is already fully typed (the // backend completer drops exact matches). Reuses the chip path via a // synthetic item whose serialized form is the verbatim text. - const commitTypedSlashDirective = () => { + const commitTypedSlashDirective = (): boolean => { if (trigger?.kind !== '/') { - return + return false + } + + // Free prose must stay ordinary contentEditable text. This guard also + // protects against a stale completion result reaching the keydown path + // before refreshTrigger has caught up with the latest DOM input. + if (desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) !== 'options') { + return false } const text = `/${trigger.query.trimEnd()}` @@ -156,9 +203,11 @@ export function useComposerTrigger({ rawText: text } }) + + return true } - const replaceTriggerWithChip = (item: Unstable_TriggerItem) => { + const replaceTriggerWithChip = (item: Unstable_TriggerItem, options?: { descend?: boolean }) => { const editor = editorRef.current if (!editor || !trigger) { @@ -188,20 +237,48 @@ export function useComposerTrigger({ const serialized = hermesDirectiveFormatter.serialize(item) const starter = serialized.endsWith(':') + // Tab on a folder walks INTO it instead of committing it: re-type the + // token as the bare path so the next `complete.path` lists that folder's + // children, exactly as typing the path by hand would. Enter still commits + // the folder itself — the two intents are distinct, so the keys are too. + // Only `@` folders descend; a slash command's arg list has no hierarchy. + const descendInto = + options?.descend && trigger.kind === '@' && item.type === 'folder' + ? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '') + : '' + + if (descendInto) { + const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/` + const current = composerPlainText(editor) + const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength)) + + renderComposerContents(editor, `${prefix}@${path}`) + placeCaretEnd(editor) + draftRef.current = composerPlainText(editor) + setComposerText(draftRef.current) + requestMainFocus() + window.setTimeout(refreshTrigger, 0) + + return + } + // Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit // it — expand to its options step so the popover shows the inline list, just // as typing `/personality ` by hand would. A serialized value with a space is - // already an arg pick (`/personality alice`), so it commits normally. + // already an arg pick (`/personality alice`), so it commits normally. An + // inline (mid-message) pick never expands: it's a reference inside prose, so + // there's no command invocation for the args to belong to. const command = (item.metadata as { command?: string } | undefined)?.command ?? '' - const expandsToArgs = trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command) + const argumentMode = desktopSlashCommandArgumentMode(command) + const expandsToArgs = trigger.kind === '/' && !trigger.inline && !serialized.includes(' ') && argumentMode !== null const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} ` const directive = !starter && serialized.match(/^@([^:]+):(.+)$/) // No pill while expanding — the bare command stays plain text until an arg // is picked, at which point a single pill is emitted for the full command. const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null - const keepTriggerOpen = starter || expandsToArgs + const keepTriggerOpen = starter || (expandsToArgs && argumentMode !== 'text') const finish = () => { draftRef.current = composerPlainText(editor) @@ -266,15 +343,47 @@ export function useComposerTrigger({ finish() } + /** Backspace inside an `@` path drops the last segment (`a/b/` → `a/`) + * instead of one character. Descending is one Tab per level, so climbing + * back out should cost one key too rather than a held delete. Returns + * false when the caret isn't in a path, so keydown falls through. */ + const ascendTriggerPath = () => { + const editor = editorRef.current + + if (!editor || trigger?.kind !== '@' || !trigger.query.includes('/')) { + return false + } + + // Trailing slash means we're listing a folder's children: drop that + // folder. Otherwise a partial segment is typed — drop just that. + const trimmed = trigger.query.replace(/\/$/, '') + const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1) + + const current = composerPlainText(editor) + const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength)) + + renderComposerContents(editor, `${prefix}@${parent}`) + placeCaretEnd(editor) + draftRef.current = composerPlainText(editor) + setComposerText(draftRef.current) + window.setTimeout(refreshTrigger, 0) + + return true + } + return { argStageEmpty, + ascendTriggerPath, closeTrigger, commitTypedSlashDirective, + moveTriggerActive, refreshTrigger, replaceTriggerWithChip, setTriggerActive, + slashFreeTextArgStage, trigger, triggerActive, + triggerActiveExplicit, triggerItems, triggerKeyConsumedRef, triggerLoading diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx new file mode 100644 index 00000000000..bbffc396ecb --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx @@ -0,0 +1,193 @@ +import { render } from '@testing-library/react' +import { createRef, type RefObject } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { useComposerUndo } from './use-composer-undo' + +/** Mount the hook against a real contentEditable, exposing its API. */ +function mountUndo(editorRef: RefObject, onSync: () => string) { + const api: { current: ReturnType | null } = { current: null } + + const Harness = () => { + // Assigned during render on purpose: the tests drive the API imperatively + // right after mount, and this is a harness, not app state. + api.current = useComposerUndo({ editorRef, syncDraftFromEditor: onSync }) + + return null + } + + const view = render() + + return { api, view } +} + +function makeEditor(text: string) { + const editor = document.createElement('div') + editor.contentEditable = 'true' + // jsdom only focuses a contentEditable div when it's explicitly focusable; + // the real editor is reachable via the composer's focus bus. + editor.tabIndex = 0 + editor.append(document.createTextNode(text)) + document.body.append(editor) + + const ref = createRef() as RefObject + ref.current = editor + + return { editor, ref } +} + +const caretAtEnd = (editor: HTMLElement) => { + const range = document.createRange() + const selection = window.getSelection()! + range.selectNodeContents(editor) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) +} + +describe('useComposerUndo', () => { + it('restores the pre-edit text, which is what a paste destroyed', () => { + const { editor, ref } = makeEditor('before') + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + // Bank, then simulate the Range-based paste that Chromium never records. + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' PASTED')) + expect(editor.textContent).toBe('before PASTED') + + api.current!.undo() + expect(editor.textContent).toBe('before') + + api.current!.redo() + expect(editor.textContent).toBe('before PASTED') + + view.unmount() + editor.remove() + }) + + it('withUndoPoint banks only when the edit actually ran', () => { + const { editor, ref } = makeEditor('text') + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + // A guard that declines must not consume an undo slot. + expect(api.current!.withUndoPoint(() => false)).toBe(false) + expect(api.current!.undo()).toBe(false) + + expect( + api.current!.withUndoPoint(() => { + editor.append(document.createTextNode('!')) + + return true + }) + ).toBe(true) + + api.current!.undo() + expect(editor.textContent).toBe('text') + + view.unmount() + editor.remove() + }) + + it('claims a native historyUndo aimed at the focused editor', () => { + const { editor, ref } = makeEditor('kept') + editor.focus() + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' extra')) + + // What Electron's Edit menu `{ role: 'undo' }` produces. + const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' }) + editor.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(true) + expect(editor.textContent).toBe('kept') + + view.unmount() + editor.remove() + }) + + it('ignores a historyUndo while another editor holds focus', () => { + const { editor, ref } = makeEditor('mine') + const { editor: other } = makeEditor('theirs') + + other.focus() + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' changed')) + + const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' }) + other.dispatchEvent(event) + + // Not ours to claim — the other surface keeps its native behavior. + expect(event.defaultPrevented).toBe(false) + expect(editor.textContent).toBe('mine changed') + + view.unmount() + editor.remove() + other.remove() + }) + + it('keeps two mounted composers independent', () => { + const { editor: main, ref: mainRef } = makeEditor('main') + const { editor: edit, ref: editRef } = makeEditor('edit') + + const mainUndo = mountUndo(mainRef, () => main.textContent || '') + const editUndo = mountUndo(editRef, () => edit.textContent || '') + + mainUndo.api.current!.recordUndoPoint() + main.append(document.createTextNode(' typed')) + + // Undoing in the edit composer must not touch the main composer's text. + editUndo.api.current!.undo() + expect(main.textContent).toBe('main typed') + + mainUndo.api.current!.undo() + expect(main.textContent).toBe('main') + expect(edit.textContent).toBe('edit') + + mainUndo.view.unmount() + editUndo.view.unmount() + main.remove() + edit.remove() + }) + + it('reset drops history so undo cannot cross a draft swap', () => { + const { editor, ref } = makeEditor('session A') + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' edited')) + api.current!.resetUndoHistory() + + expect(api.current!.undo()).toBe(false) + expect(editor.textContent).toBe('session A edited') + + view.unmount() + editor.remove() + }) + + it('is inert when the editor ref is empty', () => { + const ref = createRef() as RefObject + const sync = vi.fn(() => '') + + const { api, view } = mountUndo(ref, sync) + + api.current!.recordUndoPoint() + + expect(api.current!.undo()).toBe(false) + expect(sync).not.toHaveBeenCalled() + + view.unmount() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts new file mode 100644 index 00000000000..72f891f6dac --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts @@ -0,0 +1,119 @@ +import { type RefObject, useCallback, useEffect, useMemo } from 'react' + +import { caretOffsetInEditor, composerPlainText, placeCaretAtOffset, renderComposerContents } from '../rich-editor' +import { type ComposerSnapshot, createComposerUndoHistory } from '../undo-history' + +interface UseComposerUndoArgs { + editorRef: RefObject + /** Push a restored snapshot back into draftRef + composer state. */ + syncDraftFromEditor: () => string +} + +/** + * Undo/redo for the rich composer. + * + * The editor mutates its DOM through `Range` to dodge Chromium's O(n²) editing + * pipeline (#45812), which also dodges Chromium's undo stack — so a paste was + * invisible to ⌘Z and the keystroke undid whatever edit came before it instead. + * We own the stack outright rather than half of it: every edit path records the + * pre-edit state here, and the editor claims ⌘Z / ⌘⇧Z itself. + */ +export function useComposerUndo({ editorRef, syncDraftFromEditor }: UseComposerUndoArgs) { + const history = useMemo(() => createComposerUndoHistory(), []) + + const snapshot = useCallback((): ComposerSnapshot => { + const editor = editorRef.current + + if (!editor) { + return { caret: 0, text: '' } + } + + return { caret: caretOffsetInEditor(editor), text: composerPlainText(editor) } + }, [editorRef]) + + /** Bank the current state before mutating the editor. `coalesce` marks a + * keystroke, so a run of typing collapses into one undo step. */ + const recordUndoPoint = useCallback( + (options?: { coalesce?: boolean }) => { + if (editorRef.current) { + history.record(snapshot(), options) + } + }, + [editorRef, history, snapshot] + ) + + const applySnapshot = useCallback( + (next: ComposerSnapshot | null) => { + const editor = editorRef.current + + if (!next || !editor) { + return false + } + + renderComposerContents(editor, next.text) + placeCaretAtOffset(editor, next.caret) + syncDraftFromEditor() + + return true + }, + [editorRef, syncDraftFromEditor] + ) + + /** Run a conditional edit, banking its pre-edit state only if it actually + * ran. The snapshot has to be taken first (the edit destroys the state we'd + * be saving), but recording unconditionally would clear the redo stack on + * every Backspace that falls through to the native path. */ + const withUndoPoint = useCallback( + (edit: () => boolean) => { + const before = snapshot() + const ran = edit() + + if (ran) { + history.record(before) + } + + return ran + }, + [history, snapshot] + ) + + const undo = useCallback(() => applySnapshot(history.undo(snapshot())), [applySnapshot, history, snapshot]) + const redo = useCallback(() => applySnapshot(history.redo(snapshot())), [applySnapshot, history, snapshot]) + + // A session/draft swap makes prior history meaningless — undoing into another + // conversation's text is worse than having no history at all. + const resetUndoHistory = useCallback(() => history.reset(), [history]) + + // Electron's Edit menu ships `{ role: 'undo' }`, whose accelerator the macOS + // menu bar consumes before the web contents sees the keystroke (the same + // hazard main.ts documents for ⌘W). It fires the native editing command, + // which knows nothing about our stack. Claim it at the document level while + // the composer holds focus, so the menu item and the keystroke agree. + useEffect(() => { + const onBeforeInput = (event: Event) => { + const inputType = (event as InputEvent).inputType + + if (inputType !== 'historyUndo' && inputType !== 'historyRedo') { + return + } + + if (document.activeElement !== editorRef.current) { + return + } + + event.preventDefault() + + if (inputType === 'historyUndo') { + undo() + } else { + redo() + } + } + + document.addEventListener('beforeinput', onBeforeInput, true) + + return () => document.removeEventListener('beforeinput', onBeforeInput, true) + }, [editorRef, redo, undo]) + + return { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index f8b75183f30..8e53096f322 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -5,11 +5,11 @@ import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' import { resetBrowseState } from '@/store/composer-input-history' import { notifyError } from '@/store/notifications' -import { $messages } from '@/store/session' import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' import type { ComposerTarget } from '../focus' import { onComposerVoiceToggleRequest } from '../focus' +import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' import { useAutoSpeakReplies } from './use-auto-speak-replies' @@ -50,6 +50,8 @@ export function useComposerVoice({ target }: UseComposerVoiceArgs) { const { t } = useI18n() + // A tile's composer speaks ITS transcript, not the primary chat's. + const { $messages } = useComposerScope() const [voiceConversationActive, setVoiceConversationActive] = useState(false) const lastSpokenIdRef = useRef(null) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts index 6da699b602a..d35c78ea7b3 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts @@ -25,9 +25,18 @@ export function useLiveCompletionAdapter(options: { enabled: boolean debounceMs?: number fetcher: (query: string) => Promise + /** True when `fetcher` will answer this query from cache. Such a query skips + * both the debounce and the loading state — the debounce exists to avoid a + * request per keystroke, and a spinner over an answer we already hold reads + * as latency the user isn't actually paying. */ + isCached?: (query: string) => boolean + /** Bump to declare the held answer stale. Without it a popover left open on + * an unchanged query would keep serving what it fetched before the source + * changed, because the adapter de-dupes on the query alone. */ + epoch?: number toItem: (entry: CompletionEntry, index: number) => Unstable_TriggerItem }): { adapter: Unstable_TriggerAdapter; loading: boolean } { - const { enabled, debounceMs = 60, fetcher, toItem } = options + const { enabled, debounceMs = 60, epoch = 0, fetcher, isCached, toItem } = options const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({ query: EMPTY_QUERY, @@ -49,6 +58,7 @@ export function useLiveCompletionAdapter(options: { useEffect(() => () => cancelTimer(), [cancelTimer]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (enabled) { return @@ -61,6 +71,16 @@ export function useLiveCompletionAdapter(options: { setState({ query: EMPTY_QUERY, items: [] }) }, [cancelTimer, enabled]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) + useEffect(() => { + // Invalidate by forgetting which query the held items answer, so the next + // search() re-fetches. The items themselves stay until the new answer + // lands — an open popover must not blink empty on a background refresh. + // On mount this is already the state, so the first run is a no-op. + pendingQueryRef.current = null + setState(current => (current.query === EMPTY_QUERY ? current : { ...current, query: EMPTY_QUERY })) + }, [epoch]) + const scheduleFetch = useCallback( (query: string) => { if (!enabled) { @@ -74,9 +94,13 @@ export function useLiveCompletionAdapter(options: { pendingQueryRef.current = query cancelTimer() const token = ++tokenRef.current - setLoading(true) + const cached = isCached?.(query) ?? false - timerRef.current = window.setTimeout(() => { + if (!cached) { + setLoading(true) + } + + const run = () => { timerRef.current = null fetcher(query) @@ -102,9 +126,13 @@ export function useLiveCompletionAdapter(options: { setLoading(false) } }) - }, debounceMs) + } + + // A cached answer resolves in a microtask, so debouncing it would only + // add a frame of empty popover on every keystroke. + cached ? run() : (timerRef.current = window.setTimeout(run, debounceMs)) }, - [cancelTimer, debounceMs, enabled, fetcher, toItem] + [cancelTimer, debounceMs, enabled, fetcher, isCached, toItem] ) const adapter = useMemo( diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts index 0b71507bfd1..e4a53889e5b 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts @@ -3,6 +3,7 @@ import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, us import { POPOUT_ESTIMATED_HEIGHT, POPOUT_WIDTH_REM, + type PopoutBounds, type PopoutPosition, type PopoutSize, readPopoutBounds, @@ -35,6 +36,8 @@ interface PressState { interface ComposerPopoutGesturesOptions { composerRef: RefObject + /** Layout zone this composer belongs to — the scope its float is stored under. */ + groupId: string onDock: () => void onPopOut: () => void poppedOut: boolean @@ -67,10 +70,17 @@ function isFloatDragPlatform(target: EventTarget | null) { } /** 0 (far) → 1 (inside the dock zone). Drives both the dock glow and the - * release-to-dock test (which fires at proximity 1). */ -function dockProximityOf(rect: DOMRect) { - const horizontalDist = Math.abs(rect.left + rect.width / 2 - window.innerWidth / 2) - const verticalGap = window.innerHeight - DOCK_ZONE_BOTTOM_PX - rect.bottom + * release-to-dock test (which fires at proximity 1). + * + * Measured against THIS surface's area, not the window: the dock target is the + * docked composer, which sits at the bottom-center of its own chat surface. In + * a split (or any layout where the chat isn't the full window) the viewport's + * bottom-center is somewhere else entirely, so dragging onto the real dock + * never registered. */ +function dockProximityOf(rect: DOMRect, area?: PopoutBounds) { + const a = area ?? { bottom: window.innerHeight, left: 0, right: window.innerWidth, top: 0 } + const horizontalDist = Math.abs(rect.left + rect.width / 2 - (a.left + a.right) / 2) + const verticalGap = a.bottom - DOCK_ZONE_BOTTOM_PX - rect.bottom const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX) @@ -109,6 +119,7 @@ function popoutPositionUnderPointer( */ export function useComposerPopoutGestures({ composerRef, + groupId, onDock, onPopOut, poppedOut, @@ -142,7 +153,12 @@ export function useComposerPopoutGestures({ const beginFloatDrag = useCallback( (state: PressState, clientX: number, clientY: number, next: PopoutPosition, size?: PopoutSize) => { clearTimer() - const clamped = setComposerPopoutPosition(next, { area: readPopoutBounds(composerRef.current), size }) + + const clamped = setComposerPopoutPosition(groupId, next, { + area: readPopoutBounds(composerRef.current), + size + }) + liveRef.current = clamped state.mode = 'float' @@ -154,7 +170,7 @@ export function useComposerPopoutGestures({ setDragging(true) }, - [clearTimer, composerRef] + [clearTimer, composerRef, groupId] ) const peelOffFromDock = useCallback( @@ -231,6 +247,7 @@ export function useComposerPopoutGestures({ [clearTimer, poppedOut] ) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { // Coalesce drag updates to one per frame — pointermove can fire several times // between paints on high-Hz mice, and each update re-renders + clamps. @@ -254,17 +271,19 @@ export function useComposerPopoutGestures({ const composer = composerRef.current const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined + const area = readPopoutBounds(composer) liveRef.current = setComposerPopoutPosition( + groupId, { bottom: state.startBottom - (pending.y - state.startY), right: state.startRight - (pending.x - state.startX) }, - { area: readPopoutBounds(composer), size } + { area, size } ) if (composer) { - setDockProximity(dockProximityOf(composer.getBoundingClientRect())) + setDockProximity(dockProximityOf(composer.getBoundingClientRect(), area)) } } @@ -316,13 +335,14 @@ export function useComposerPopoutGestures({ if (state.armed && state.mode === 'float') { const composer = composerRef.current const rect = composer?.getBoundingClientRect() + const area = readPopoutBounds(composer) - if (rect && dockProximityOf(rect) >= 1) { + if (rect && dockProximityOf(rect, area) >= 1) { onDock() } else { // Persist the resting position once, on release — never per move. const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined - setComposerPopoutPosition(liveRef.current, { area: readPopoutBounds(composer), persist: true, size }) + setComposerPopoutPosition(groupId, liveRef.current, { area, persist: true, size }) } } @@ -339,7 +359,7 @@ export function useComposerPopoutGestures({ window.removeEventListener('pointerup', handleUp) window.removeEventListener('pointercancel', handleUp) } - }, [composerRef, onDock, peelOffFromDock, resetGesture]) + }, [composerRef, groupId, onDock, peelOffFromDock, resetGesture]) useEffect(() => clearTimer, [clearTimer]) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx new file mode 100644 index 00000000000..eebed60ce4d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx @@ -0,0 +1,98 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { HermesGateway } from '@/hermes' +import { queryClient } from '@/lib/query-client' +import { invalidateSlashCompletions } from '@/lib/slash-completion-cache' + +import { isSkillItem } from '../composer-utils' + +import { useSlashCompletions } from './use-slash-completions' + +const CATALOG = { + categories: [{ name: 'Session', pairs: [['/new', 'Start a new session']] }], + pairs: [ + ['/new', 'Start a new session'], + ['/work', 'Kick off a task in a fresh worktree'] + ] +} + +function harness(gateway: HermesGateway) { + const api: { search?: (query: string) => readonly Unstable_TriggerItem[] } = {} + + function Probe() { + const { adapter } = useSlashCompletions({ gateway }) + api.search = adapter.search + + return null + } + + render() + + return api as { search: (query: string) => readonly Unstable_TriggerItem[] } +} + +/** Drive the adapter until its async fetch has settled into `search`'s result. */ +async function completions(api: { search: (query: string) => readonly Unstable_TriggerItem[] }, query: string) { + await act(async () => { + api.search(query) + await Promise.resolve() + }) + + // The debounce is skipped only for cached queries; give the timer a beat. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 120)) + }) + + return api.search(query) +} + +afterEach(() => { + cleanup() + queryClient.clear() +}) + +describe('useSlashCompletions', () => { + it('serves the bare-slash catalog from cache instead of re-requesting it', async () => { + const request = vi.fn().mockResolvedValue(CATALOG) + const api = harness({ request } as unknown as HermesGateway) + + await completions(api, '') + expect(request).toHaveBeenCalledTimes(1) + + // Reopening `/` must not hit the gateway again. + queryClient.setQueryData(['unrelated'], 1) + await completions(api, '') + expect(request).toHaveBeenCalledTimes(1) + + // …until something that changes the command set invalidates it. + await act(async () => invalidateSlashCompletions()) + await completions(api, '') + expect(request).toHaveBeenCalledTimes(2) + }) + + it('offers skill commands on a bare slash, not just built-ins', async () => { + const request = vi.fn().mockResolvedValue(CATALOG) + const api = harness({ request } as unknown as HermesGateway) + + const items = await completions(api, '') + const work = items.find(item => (item.metadata as { command?: string })?.command === '/work') + + expect((work?.metadata as { group?: string })?.group).toBe('Skills') + }) + + // A `/` typed mid-message is a reference dropped into prose, so the trigger + // filters the list to skills (use-composer-trigger). A bare mid-message `/` + // resolves to the same empty query as an opening `/`, so that filter runs + // over the catalog — which listed no skill-group rows at all, leaving the + // inline popover empty. Asserted through isSkillItem, the real predicate. + it('leaves only skills for a mid-message slash', async () => { + const request = vi.fn().mockResolvedValue(CATALOG) + const api = harness({ request } as unknown as HermesGateway) + + const inline = (await completions(api, '')).filter(isSkillItem) + + expect(inline.map(item => (item.metadata as { command?: string })?.command)).toEqual(['/work']) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts index bf6e5006bea..0f023a1d3f1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -1,4 +1,5 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { useStore } from '@nanostores/react' import { useCallback } from 'react' import type { HermesGateway } from '@/hermes' @@ -12,6 +13,7 @@ import { isDesktopSlashExtensionCommand, isDesktopSlashSuggestion } from '@/lib/desktop-slash-commands' +import { $slashCompletionsEpoch, cachedSlashCompletion, hasCachedSlashCompletion } from '@/lib/slash-completion-cache' import { normalize } from '@/lib/text' import { $sessions } from '@/store/session' @@ -63,6 +65,7 @@ export function useSlashCompletions(options: { } { const { gateway, skinThemes, activeSkin } = options const enabled = Boolean(gateway) + const epoch = useStore($slashCompletionsEpoch) const fetcher = useCallback( async (query: string): Promise => { @@ -133,7 +136,9 @@ export function useSlashCompletions(options: { try { if (!query) { - const catalog = filterDesktopCommandsCatalog(await gateway.request('commands.catalog')) + const catalog = filterDesktopCommandsCatalog( + await cachedSlashCompletion('catalog', () => gateway.request('commands.catalog')) + ) // Prefer the categorized layout so the popover renders section headers // (Session, Tools & Skills, ...). Fall back to the flat list when the @@ -149,12 +154,26 @@ export function useSlashCompletions(options: { })) ) + // Skill commands reach us only through the flat `pairs` list — the + // backend categorizes registry commands but appends skills + // uncategorized, so the categorized layout alone drops every skill + // from the bare `/` list even though typing `/wo` offers them. + // Re-add the leftovers under one Skills header (which also gives them + // the skill pill accent and makes them offerable mid-message). + const categorized = new Set(items.map(item => item.text.toLowerCase())) + + for (const [command, meta] of catalog.pairs ?? []) { + if (!categorized.has(command.toLowerCase()) && isDesktopSlashExtensionCommand(command)) { + items.push({ text: command, display: command, group: 'Skills', meta }) + } + } + return { items, query } } - const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', { - text - }) + const result = await cachedSlashCompletion(`slash:${text.toLowerCase()}`, () => + gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', { text }) + ) // Arg-completion items (replace_from > 1) carry just the arg stub — // e.g. complete.slash returns `{text: "alice"}` for `/personality alic` @@ -231,5 +250,21 @@ export function useSlashCompletions(options: { } }, []) - return useLiveCompletionAdapter({ enabled, fetcher, toItem }) + // Mirrors the fetcher's branching: the `/skin` and `/resume` arg stages are + // answered from client-side state, so they never wait on the network; every + // other query is served from the completion cache when it's still warm. + const isCached = useCallback( + (query: string) => { + const text = `/${query}` + + if ((skinThemes && /^\/skin\s+/is.test(text)) || /^\/(?:resume|sessions|switch)\s+/is.test(text)) { + return true + } + + return hasCachedSlashCompletion(query ? `slash:${text.toLowerCase()}` : 'catalog') + }, + [skinThemes] + ) + + return useLiveCompletionAdapter({ enabled, epoch, fetcher, isCached, toItem }) } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 2ec43c83c0b..33285f05aa1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -60,18 +60,22 @@ export function useVoiceConversation({ const statusRef = useRef('idle') const wasEnabledRef = useRef(enabled) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { enabledRef.current = enabled }, [enabled]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { mutedRef.current = muted }, [muted]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { busyRef.current = busy }, [busy]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { statusRef.current = status }, [status]) @@ -508,6 +512,7 @@ export function useVoiceConversation({ // Drive the loop: when a voice-submitted reply appears, open a live speech // session (which feeds itself from then on). Otherwise start listening when // idle between turns. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!enabled || muted) { return @@ -542,6 +547,7 @@ export function useVoiceConversation({ } }, [busy, enabled, muted, openLiveSpeech, pendingResponse, startListening, status]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (enabled && !wasEnabledRef.current) { void start() diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 1ce110f4e09..74f9a7459f6 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,6 +1,6 @@ import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react' +import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef } from 'react' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' @@ -11,7 +11,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { $compactionActive } from '@/store/compaction' +import { sessionCompacting } from '@/store/compaction' import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history' import { POPOUT_WIDTH_REM } from '@/store/composer-popout' import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue' @@ -22,7 +22,12 @@ import { $autoSpeakReplies } from '@/store/voice-prefs' import { useTheme } from '@/themes' import { AttachmentList } from './attachments' -import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils' +import { + acceptsTriggerCompletion, + COMPOSER_FADE_BACKGROUND, + type QueueEditState, + slashArgStage +} from './composer-utils' import { ContextMenu } from './context-menu' import { COMPOSER_AREAS, runComposerMiddleware } from './contrib' import { ComposerControls } from './controls' @@ -40,16 +45,18 @@ import { useComposerPopout } from './hooks/use-composer-popout' import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerSubmit } from './hooks/use-composer-submit' import { useComposerTrigger } from './hooks/use-composer-trigger' +import { useComposerUndo } from './hooks/use-composer-undo' import { useComposerUrlDialog } from './hooks/use-composer-url-dialog' import { useComposerVoice } from './hooks/use-composer-voice' import { useSlashCompletions } from './hooks/use-slash-completions' import { useSessionStatusPresence } from './hooks/use-status-presence' +import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' import { QueuePanel } from './queue-panel' import { composerPlainText, deleteChipBeforeCaret, deleteSelectionInEditor, - insertPlainTextAtCaret, + insertComposerContentsAtCaret, normalizeComposerEditorDom, RICH_INPUT_SLOT } from './rich-editor' @@ -59,7 +66,9 @@ import { CodingStatusRow } from './status-stack/coding-row' import { extractClipboardImageBlobs } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' +import { isRedoShortcut, isUndoShortcut } from './undo-history' import { UrlDialog } from './url-dialog' +import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' export function ChatBar({ @@ -105,7 +114,7 @@ export function ChatBar({ // focus-bus key, and awaiting-input edge. Main scope = the legacy globals. const scope = useComposerScope() const attachments = useStore(scope.attachments.$attachments) - const compacting = useStore($compactionActive) + const compacting = useStore(useMemo(() => sessionCompacting(sessionId ?? null), [sessionId])) const scrolledUp = useStore($threadScrolledUp) const autoSpeak = useStore($autoSpeakReplies) // The turn is parked on the user (clarify / approval / sudo / secret). Esc must @@ -172,9 +181,24 @@ export function ChatBar({ requestMainFocus, sessionIdRef, setComposerText, - stashAt + stashAt, + syncDraftFromEditor } = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId }) + // Undo/redo. The rich editor bypasses Chromium's editing pipeline for speed, + // which also bypasses its undo stack — so we own the stack and every edit + // path below banks its pre-edit state through `recordUndoPoint`. + const { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint } = useComposerUndo({ + editorRef, + syncDraftFromEditor + }) + + // Prior history belongs to the draft that just left — undoing into another + // conversation's text is worse than having none. + useEffect(() => { + resetUndoHistory() + }, [activeQueueSessionKey, resetUndoHistory]) + // "Add URL" dialog — open/value state, autofocus, and submit (host onAddUrl or // an @url: directive into the draft). const { openUrlDialog, setUrlOpen, setUrlValue, submitUrl, urlInputRef, urlOpen, urlValue } = useComposerUrlDialog({ @@ -281,13 +305,17 @@ export function ChatBar({ // this API; keyup uses triggerKeyConsumedRef to skip its refresh. const { argStageEmpty, + ascendTriggerPath, closeTrigger, commitTypedSlashDirective, + moveTriggerActive, refreshTrigger, replaceTriggerWithChip, setTriggerActive, + slashFreeTextArgStage, trigger, triggerActive, + triggerActiveExplicit, triggerItems, triggerKeyConsumedRef, triggerLoading @@ -356,6 +384,23 @@ export function ChatBar({ scheduleFlushEditorToDraft(event.currentTarget) } + // Native typing/deleting mutates the DOM through Chromium's editing pipeline, + // whose undo stack we've taken over — so bank the pre-edit state here, before + // the change lands. `beforeinput` is the only hook that still sees the old + // text. Consecutive keystrokes coalesce into one entry, so ⌘Z steps back by a + // burst rather than a character. + const handleEditorBeforeInput = (event: FormEvent) => { + const inputType = (event.nativeEvent as InputEvent).inputType + + // Undo/redo are ours (handled in useComposerUndo + keydown), and IME preedit + // is not a committed edit — compositionend is where that text becomes real. + if (inputType === 'historyUndo' || inputType === 'historyRedo' || composingRef.current) { + return + } + + recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' }) + } + const handlePaste = (event: ClipboardEvent) => { const imageBlobs = extractClipboardImageBlobs(event.clipboardData) @@ -402,7 +447,12 @@ export function ChatBar({ } event.preventDefault() - insertPlainTextAtCaret(event.currentTarget, pastedText) + + // Links in the paste land as `@url:` chips rather than a wall of URL text — + // the same reference the "Add URL" dialog inserts, parsed in place so a link + // mid-sentence keeps its position. Bare `@path` tokens promote the same way. + recordUndoPoint() + insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText))) scheduleFlushEditorToDraft(event.currentTarget) } @@ -416,6 +466,23 @@ export function ChatBar({ return } + // Undo/redo before anything else — we own the stack (see useComposerUndo), + // so these never reach Chromium's native history, which has no record of + // the Range-based edits the rich editor makes. + if (isUndoShortcut(event.nativeEvent)) { + event.preventDefault() + undo() + + return + } + + if (isRedoShortcut(event.nativeEvent)) { + event.preventDefault() + redo() + + return + } + // Plain Backspace right after a directive chip: remove the chip + its // auto-inserted trailing space as one unit, so deleting a directive never // leaves an orphaned space. (Modified backspaces stay native.) @@ -424,7 +491,7 @@ export function ChatBar({ !event.metaKey && !event.ctrlKey && !event.altKey && - deleteChipBeforeCaret(event.currentTarget) + withUndoPoint(() => deleteChipBeforeCaret(event.currentTarget)) ) { event.preventDefault() flushEditorToDraft(event.currentTarget) @@ -434,7 +501,29 @@ export function ChatBar({ // Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large // drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through. - if ((event.key === 'Backspace' || event.key === 'Delete') && deleteSelectionInEditor(event.currentTarget)) { + if ( + (event.key === 'Backspace' || event.key === 'Delete') && + withUndoPoint(() => deleteSelectionInEditor(event.currentTarget)) + ) { + event.preventDefault() + flushEditorToDraft(event.currentTarget) + + return + } + + // A typed link finished with a space chips like a pasted one — the space + // itself rides along inside the insert. + if (withUndoPoint(() => chipTypedUrlOnSpace(event))) { + event.preventDefault() + flushEditorToDraft(event.currentTarget) + + return + } + + // Same for a bare `@path` — a hand-typed or Tab-descended path chips into + // the `@file:`/`@folder:` ref it means, instead of submitting as plain text + // the backend never resolves. + if (withUndoPoint(() => chipTypedPathOnSpace(event))) { event.preventDefault() flushEditorToDraft(event.currentTarget) @@ -457,7 +546,7 @@ export function ChatBar({ if (event.key === 'ArrowDown') { event.preventDefault() triggerKeyConsumedRef.current = true - setTriggerActive(idx => (idx + 1) % triggerItems.length) + moveTriggerActive(1) return } @@ -465,18 +554,21 @@ export function ChatBar({ if (event.key === 'ArrowUp') { event.preventDefault() triggerKeyConsumedRef.current = true - setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length) + moveTriggerActive(-1) return } - // Enter / Tab / Space all accept the highlighted item: a no-arg command - // commits its directive chip, an arg-taking command expands to its - // options step, and an arg option commits the full `/cmd arg` chip. Space - // is slash-only (an `@` mention takes a literal space) and gated to a - // non-empty query so a bare `/ ` still types a space. - const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim()) - const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace + // Accepting the highlighted item: a no-arg command commits its directive + // chip, an arg-taking command expands to its options step, and an arg + // option commits the full `/cmd arg` chip. + const accept = acceptsTriggerCompletion({ + activeExplicit: triggerActiveExplicit, + freeTextArgStage: slashFreeTextArgStage, + key: event.key, + kind: trigger.kind, + query: trigger.query + }) if (accept) { event.preventDefault() @@ -484,12 +576,24 @@ export function ChatBar({ const item = triggerItems[triggerActive] if (item) { - replaceTriggerWithChip(item) + // Tab means "go deeper" on a folder; Enter means "I want this one". + // Everything else treats them alike. + replaceTriggerWithChip(item, { descend: event.key === 'Tab' }) } return } + // Backspace climbs out of an `@` path one segment at a time, mirroring + // Tab's one-key descent. Only when the caret sits at the end of the + // token — mid-token editing keeps normal character deletion. + if (event.key === 'Backspace' && !event.metaKey && !event.altKey && ascendTriggerPath()) { + event.preventDefault() + triggerKeyConsumedRef.current = true + + return + } + if (event.key === 'Escape') { event.preventDefault() triggerKeyConsumedRef.current = true @@ -510,11 +614,12 @@ export function ChatBar({ slashArgStage(trigger.query) && trigger.query.trim() ) { - event.preventDefault() - triggerKeyConsumedRef.current = true - commitTypedSlashDirective() + if (commitTypedSlashDirective()) { + event.preventDefault() + triggerKeyConsumedRef.current = true - return + return + } } // ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in @@ -551,7 +656,7 @@ export function ChatBar({ // $messages is read imperatively (not subscribed) so the composer // doesn't re-render on every streaming delta flush. - const history = deriveUserHistory(scope.readMessages(), chatMessageText) + const history = deriveUserHistory(scope.$messages.get(), chatMessageText) const entry = browseBackward(sessionId, currentDraft, history) if (entry !== null) { @@ -576,7 +681,7 @@ export function ChatBar({ event.preventDefault() triggerKeyConsumedRef.current = true - const history = deriveUserHistory(scope.readMessages(), chatMessageText) + const history = deriveUserHistory(scope.$messages.get(), chatMessageText) const result = browseForward(sessionId, history) if (result !== null) { @@ -630,12 +735,21 @@ export function ChatBar({ return } - // Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc), - // never a stray Enter after sending. With a payload, submitDraft queues it. - // Gate on the live DOM payload (not the render-lagged composer state) so a - // message typed fast / via IME while busy still reaches submitDraft() and - // gets queued instead of being mistaken for an empty Enter. + // Empty Enter while busy. With prompts queued this is the double-send: + // the first Enter put the words in the queue, a second sends them now + // (promote + interrupt + drain on settle), mirroring the idle empty-Enter + // drain above. With nothing queued it stays a no-op — interrupting is + // explicit (Stop/Esc), never a stray Enter after sending. Gate on the live + // DOM payload (not the render-lagged composer state) so a message typed + // fast / via IME while busy still reaches submitDraft() and gets queued + // instead of being mistaken for an empty Enter. if (busy && !hasLivePayload) { + const head = queuedPrompts.find(entry => entry.id !== queueEdit?.entryId) + + if (head) { + sendQueuedNow(head.id) + } + return } @@ -777,6 +891,7 @@ export function ChatBar({ contentEditable={!inputDisabled} data-placeholder={placeholder} data-slot={RICH_INPUT_SLOT} + onBeforeInput={handleEditorBeforeInput} onBlur={() => window.setTimeout(closeTrigger, 80)} onCompositionEnd={event => { composingRef.current = false diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts index 5fd62f4cc94..5e282f6a040 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -4,7 +4,13 @@ import { contextPath } from '@/lib/chat-runtime' import type { DroppedFile } from '../hooks/use-composer-actions' -import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor' +import { + composerPlainText, + normalizeComposerEditorDom, + placeCaretEnd, + refChipElement, + RICH_INPUT_SLOT +} from './rich-editor' /** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */ export type InlineRefInput = string | { kind: string; label?: string; value: string } @@ -92,7 +98,12 @@ function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | slice.setStart(range.endContainer, range.endOffset) } + // Carry the editor's slot marker: composerPlainText appends a trailing "\n" + // to any other block element, so a bare
made `beforeText` always look + // like it ended in whitespace and the separating space was never inserted — + // a chip dropped after a word came out glued to it (`review@file:...`). const container = document.createElement('div') + container.dataset.slot = RICH_INPUT_SLOT container.appendChild(slice.cloneContents()) return composerPlainText(container) diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index b5a6e976805..2a26db4094c 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -11,7 +11,7 @@ import { useI18n } from '@/i18n' import { ChevronDown } from '@/lib/icons' import { formatModelStatusLabel } from '@/lib/model-status-label' import { cn } from '@/lib/utils' -import { $currentModelSource, setModelPickerOpen } from '@/store/session' +import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session' import type { ChatBarState } from './types' @@ -48,6 +48,7 @@ export function ModelPill({ const fastMode = useStore(view.$fast) const reasoningEffort = useStore(view.$reasoningEffort) const modelSource = useStore($currentModelSource) + const defaultEffort = useStore($defaultReasoningEffort) const runtimeId = useStore(view.$runtimeId) const [open, setOpen] = useState(false) @@ -68,7 +69,9 @@ export function ModelPill({ ) : ( <> {currentModel.trim() ? ( - {formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })} + + {formatModelStatusLabel(currentModel, { defaultEffort, fastMode, reasoningEffort })} + ) : ( )} diff --git a/apps/desktop/src/app/chat/composer/path-refs.test.ts b/apps/desktop/src/app/chat/composer/path-refs.test.ts new file mode 100644 index 00000000000..da7cf1dc6f1 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/path-refs.test.ts @@ -0,0 +1,103 @@ +import type { KeyboardEvent } from 'react' +import { describe, expect, it } from 'vitest' + +import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' +import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor' + +/** + * Tab-descending into a folder from the `@` popover leaves a bare + * `@apps/desktop/` in the editor. That is not a reference — the backend's + * REFERENCE_PATTERN only matches `@kind:value` — so it used to submit as plain + * text and silently attach nothing. + */ + +/** An editor holding `text` with a collapsed caret at its end, plus the space + * keydown the composer would hand `chipTypedPathOnSpace`. */ +const spaceOn = (text: string) => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = text + document.body.append(editor) + + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(editor.firstChild!, text.length) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + return { editor, event: { currentTarget: editor, key: ' ' } as KeyboardEvent } +} + +describe('pathifyRefs', () => { + it('promotes a Tab-descended folder token', () => { + expect(pathifyRefs('look at @apps/desktop/')).toBe('look at @folder:`apps/desktop`') + }) + + it('promotes a typed file path', () => { + expect(pathifyRefs('read @src/main.ts please')).toBe('read @file:`src/main.ts` please') + }) + + it('promotes every bare path in one message', () => { + expect(pathifyRefs('@apps/desktop/ and @src/main.ts')).toBe('@folder:`apps/desktop` and @file:`src/main.ts`') + }) + + it('leaves an already-typed directive alone', () => { + const text = 'look at @folder:`apps/desktop` ok' + + expect(pathifyRefs(text)).toBe(text) + }) + + it('leaves a handle alone', () => { + expect(pathifyRefs('cc @teknium1 on this')).toBe('cc @teknium1 on this') + }) + + it('leaves simple refs alone', () => { + expect(pathifyRefs('check @diff and @staged')).toBe('check @diff and @staged') + }) + + it('leaves an email alone', () => { + expect(pathifyRefs('mail me at brooklyn@nous.dev/x')).toBe('mail me at brooklyn@nous.dev/x') + }) + + it('is a no-op without an @', () => { + expect(pathifyRefs('nothing here')).toBe('nothing here') + }) +}) + +describe('chipTypedPathOnSpace', () => { + it('chips a Tab-descended folder token into a real @folder: chip', () => { + const { editor, event } = spaceOn('look at @apps/desktop/') + + expect(chipTypedPathOnSpace(event)).toBe(true) + + const chip = editor.querySelector('[data-ref-text]') + + expect(chip?.getAttribute('data-ref-kind')).toBe('folder') + expect(chip?.getAttribute('data-ref-id')).toBe('apps/desktop') + expect(chip?.textContent).toContain('desktop') + expect(composerPlainText(editor)).toBe('look at @folder:`apps/desktop` ') + }) + + it('chips a typed file path', () => { + const { editor, event } = spaceOn('read @src/main.ts') + + expect(chipTypedPathOnSpace(event)).toBe(true) + expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull() + expect(composerPlainText(editor)).toBe('read @file:`src/main.ts` ') + }) + + it('leaves a handle alone', () => { + const { editor, event } = spaceOn('cc @teknium1') + + expect(chipTypedPathOnSpace(event)).toBe(false) + expect(editor.querySelector('[data-ref-text]')).toBeNull() + }) + + it('leaves an already-typed ref alone', () => { + const { event } = spaceOn('look at @folder:`apps/desktop`') + + expect(chipTypedPathOnSpace(event)).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/path-refs.ts b/apps/desktop/src/app/chat/composer/path-refs.ts new file mode 100644 index 00000000000..c326727afa2 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/path-refs.ts @@ -0,0 +1,103 @@ +/** + * Bare-path recognition for the composer. Walking into a folder from the `@` + * popover (Tab) re-types the token as a bare `@apps/desktop/` so the next + * `complete.path` lists that folder's children. That's right while typing, but + * a bare token is not a reference: `REFERENCE_PATTERN` only matches + * `@kind:value`, so a draft sent with one attaches nothing and renders as + * plain text instead of a chip. + * + * So the bare token is promoted to its typed form on the way out — the same + * shape `url-refs.ts` uses for bare links. + */ +import type { KeyboardEvent } from 'react' + +import { quoteRefValue, REF_RE, refChipElement, replaceBeforeCaret } from './rich-editor' +import { textBeforeCaret } from './text-utils' + +// A `/` is required, exactly like URL_RE requires an explicit scheme: `@teknium1` +// and `@diff` are a handle and a simple ref, not paths, and guessing wrong turns +// someone's name into a file reference. A separator is the cheap signal that the +// user meant a path. The token also can't start with a `:` kind prefix — that is +// already a typed ref and REF_RE owns it. +const BARE_PATH_RE = /(? { + const start = match.index ?? 0 + + return { end: start + match[0].length, start } + }) + + let out = '' + let cursor = 0 + + for (const match of text.matchAll(BARE_PATH_RE)) { + const start = match.index ?? 0 + const ref = barePathRef(match[1] || '') + + if (!ref || fenced.some(span => start >= span.start && start < span.end)) { + continue + } + + out += `${text.slice(cursor, start)}${ref}` + cursor = start + match[0].length + } + + return out + text.slice(cursor) +} + +/** A plain space finishing a typed `@path` commits it as a chip, so a hand-typed + * path chips the same way a picked one does. Returns whether it ran, so a + * keydown handler can fall through on anything else. */ +export function chipTypedPathOnSpace(event: KeyboardEvent) { + if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) { + return false + } + + const editor = event.currentTarget + + // Runs on every space, so bail on the cheap native read before paying for the + // caret range walk (same guard shape as the trigger detector). + if (!editor.textContent?.includes('@')) { + return false + } + + const before = textBeforeCaret(editor) + const match = before ? TYPED_BARE_PATH_RE.exec(before) : null + const token = match?.[0] + const ref = match?.[1] ? barePathRef(match[1]) : null + + if (!token || !ref) { + return false + } + + const directive = ref.match(/^@([^:]+):(.+)$/) + + if (!directive) { + return false + } + + const fragment = document.createDocumentFragment() + + fragment.append(refChipElement(directive[1], directive[2]), document.createTextNode(' ')) + + return replaceBeforeCaret(editor, token.length, fragment) +} diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index 6bb04ec23c4..2f5612efd13 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' -import { ArrowUp, iconSize, Pencil, Trash2 } from '@/lib/icons' +import { CornerDownLeft, iconSize, Pencil, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' import type { QueuedPromptEntry } from '@/store/composer-queue' @@ -22,7 +22,7 @@ interface QueuePanelProps { } const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) => - entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn) + (entry.displayText ?? entry.text).trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn) export function QueuePanel({ busy, @@ -103,7 +103,7 @@ export function QueuePanel({ type="button" variant="ghost" > - + diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index 12e3e9613ef..842765388ff 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -4,10 +4,11 @@ import { insertInlineRefsIntoEditor } from './inline-refs' import { composerPlainText, deleteSelectionInEditor, - insertPlainTextAtCaret, + insertComposerContentsAtCaret, normalizeComposerEditorDom, refChipElement, renderComposerContents, + replaceBeforeCaret, RICH_INPUT_SLOT } from './rich-editor' @@ -70,16 +71,40 @@ describe('insertInlineRefsIntoEditor', () => { expect(editor.querySelector(':scope > div')).toBeNull() expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ') }) + + it('separates a chip from the word the caret sits after', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.append(document.createTextNode('review')) + document.body.append(editor) + caretIn(editor) + + expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ') + + editor.remove() + }) + + it('does not double the space when one is already there', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.append(document.createTextNode('review ')) + document.body.append(editor) + caretIn(editor) + + expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ') + + editor.remove() + }) }) -describe('insertPlainTextAtCaret', () => { +describe('insertComposerContentsAtCaret', () => { it('inserts multiline text as text nodes + br', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) caretIn(editor) - insertPlainTextAtCaret(editor, 'one\ntwo\nthree') + insertComposerContentsAtCaret(editor, 'one\ntwo\nthree') expect(editor.querySelectorAll('br').length).toBe(2) expect(composerPlainText(editor)).toBe('one\ntwo\nthree') @@ -102,12 +127,76 @@ describe('insertPlainTextAtCaret', () => { selection.removeAllRanges() selection.addRange(range) - insertPlainTextAtCaret(editor, 'cd') + insertComposerContentsAtCaret(editor, 'cd') expect(composerPlainText(editor)).toBe('abcdef') editor.remove() }) + + it('lands directives in the text as chips', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + caretIn(editor) + + insertComposerContentsAtCaret(editor, 'read @url:`https://example.dev/a` now') + + expect(editor.querySelectorAll('[data-ref-kind="url"]').length).toBe(1) + expect(composerPlainText(editor)).toBe('read @url:`https://example.dev/a` now') + + editor.remove() + }) +}) + +describe('replaceBeforeCaret', () => { + it('swaps the token before the caret and leaves the caret after the insert', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = 'see foo' + document.body.append(editor) + + const text = editor.firstChild! + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(text, 7) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + const fragment = document.createDocumentFragment() + fragment.append(refChipElement('file', '`src/foo.ts`'), document.createTextNode(' ')) + + expect(replaceBeforeCaret(editor, 3, fragment)).toBe(true) + expect(composerPlainText(editor)).toBe('see @file:`src/foo.ts` ') + expect(selection.getRangeAt(0).collapsed).toBe(true) + + editor.remove() + }) + + it('leaves the editor alone when the caret has no room for the token', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = 'hi' + document.body.append(editor) + + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(editor.firstChild!, 2) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + const fragment = document.createDocumentFragment() + fragment.append(document.createTextNode('x')) + + expect(replaceBeforeCaret(editor, 20, fragment)).toBe(false) + expect(composerPlainText(editor)).toBe('hi') + + editor.remove() + }) }) describe('deleteSelectionInEditor', () => { diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 71491b87496..7bf770eda17 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -11,6 +11,7 @@ import { directiveIconElement, directiveIconSvg, formatRefValue, + refChipLabel, slashChipClass, type SlashChipKind, slashIconElement @@ -34,10 +35,6 @@ export function unquoteRef(raw: string) { return quoted ? raw.slice(1, -1) : raw.replace(/[,.;!?]+$/, '') } -export function refLabel(id: string) { - return id.split(/[\\/]/).filter(Boolean).pop() || id -} - /** Always-quote variant of formatRefValue — chips need a fence even for safe values. */ export function quoteRefValue(value: string) { if (!value.includes('`')) { @@ -59,7 +56,9 @@ export function refChipHtml(kind: string, rawValue: string, displayLabel?: strin const id = unquoteRef(rawValue) const text = `@${kind}:${quoteRefValue(id)}` - return `${directiveIconSvg(kind)}${escapeHtml(displayLabel || refLabel(id))}` + const label = displayLabel || refChipLabel(kind, id) + + return `${directiveIconSvg(kind)}${escapeHtml(label)}` } export function refChipElement(kind: string, rawValue: string, displayLabel?: string) { @@ -69,12 +68,13 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st const label = document.createElement('span') chip.contentEditable = 'false' + chip.title = id chip.dataset.refText = text chip.dataset.refId = id chip.dataset.refKind = kind chip.className = DIRECTIVE_CHIP_CLASS label.className = 'truncate' - label.textContent = displayLabel || refLabel(id) + label.textContent = displayLabel || refChipLabel(kind, id) chip.append(directiveIconElement(kind), label) return chip @@ -144,14 +144,15 @@ function composerSelectionRange(editor: HTMLElement) { return { range, selection } } -/** Insert plain text at the caret (replacing any selection). Pastes use this - * instead of `execCommand('insertText')` — Chromium's editing pipeline is - * ~O(n²) on large multiline blobs. */ -export function insertPlainTextAtCaret(editor: HTMLElement, text: string) { +/** Insert text at the caret (replacing any selection), with any `@kind:value` + * directives in it landing as chips. Pastes use this instead of + * `execCommand('insertText')` — Chromium's editing pipeline is ~O(n²) on large + * multiline blobs. */ +export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) { const hit = composerSelectionRange(editor) const fragment = document.createDocumentFragment() - appendTextWithBreaks(fragment, text) + appendComposerContents(fragment, text) const tail = fragment.lastChild @@ -172,6 +173,41 @@ export function insertPlainTextAtCaret(editor: HTMLElement, text: string) { } } +/** Swap the `length` characters immediately before a collapsed caret for + * `fragment`, leaving the caret after it. Returns whether it ran — a caret that + * isn't inside a text node holding the whole token is left alone. */ +export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment: DocumentFragment) { + const hit = composerSelectionRange(editor) + + if (!hit?.range.collapsed) { + return false + } + + const { startContainer, startOffset } = hit.range + + if (startContainer.nodeType !== Node.TEXT_NODE || startOffset < length) { + return false + } + + const range = document.createRange() + const tail = fragment.lastChild + + range.setStart(startContainer, startOffset - length) + range.setEnd(startContainer, startOffset) + range.deleteContents() + range.insertNode(fragment) + + if (tail) { + range.setStartAfter(tail) + } + + range.collapse(true) + hit.selection.removeAllRanges() + hit.selection.addRange(range) + + return true +} + /** Backspace at a collapsed caret immediately after a chip: delete the chip AND * the single trailing space we auto-insert after it, atomically — so removing a * directive never strands an orphaned space (the contenteditable-driven cleanup @@ -296,6 +332,106 @@ export function placeCaretEnd(element: HTMLElement) { selection?.addRange(range) } +/** The caret's offset in `composerPlainText` coordinates, so it can be restored + * after the editor is re-rendered from text (undo/redo). A chip counts as its + * whole `@kind:value` text — the same units the snapshot measures. */ +export function caretOffsetInEditor(editor: HTMLElement): number { + const selection = window.getSelection() + const range = selection?.rangeCount ? selection.getRangeAt(0) : null + + if (!range || !editor.contains(range.commonAncestorContainer)) { + return composerPlainText(editor).length + } + + const before = range.cloneRange() + before.selectNodeContents(editor) + before.setEnd(range.startContainer, range.startOffset) + + // The scratch container must carry the editor's slot marker: composerPlainText + // appends a trailing "\n" to any other block element, which would inflate + // every offset by one and land the restored caret a character late. + const container = document.createElement('div') + container.dataset.slot = RICH_INPUT_SLOT + container.append(before.cloneContents()) + + return composerPlainText(container).length +} + +/** Place the caret `offset` characters into the editor, in the same + * `composerPlainText` coordinates `caretOffsetInEditor` reports. Lands after a + * chip it would otherwise split, since a chip is a single atomic unit. */ +export function placeCaretAtOffset(editor: HTMLElement, offset: number) { + const selection = window.getSelection() + + if (!selection) { + return + } + + let remaining = offset + + const walk = (node: Node): Range | null => { + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const length = (child.textContent || '').length + + if (remaining <= length) { + const range = document.createRange() + range.setStart(child, remaining) + range.collapse(true) + + return range + } + + remaining -= length + + continue + } + + if (child.nodeType !== Node.ELEMENT_NODE) { + continue + } + + const el = child as HTMLElement + + // Chips and
are atomic: consume their serialized length whole. + if (el.dataset.refText || el.tagName === 'BR') { + const length = el.dataset.refText ? el.dataset.refText.length : 1 + + if (remaining < length) { + const range = document.createRange() + range.setStartBefore(el) + range.collapse(true) + + return range + } + + remaining -= length + + continue + } + + const hit = walk(el) + + if (hit) { + return hit + } + } + + return null + } + + const range = walk(editor) + + if (range) { + selection.removeAllRanges() + selection.addRange(range) + + return + } + + placeCaretEnd(editor) +} + /** Nothing but a break / whitespace (recursively) — i.e. no real text or chip. */ function isBlankNode(node: ChildNode | null): boolean { if (!node) { diff --git a/apps/desktop/src/app/chat/composer/scope.tsx b/apps/desktop/src/app/chat/composer/scope.tsx index 67581a39e30..e7e7aff880e 100644 --- a/apps/desktop/src/app/chat/composer/scope.tsx +++ b/apps/desktop/src/app/chat/composer/scope.tsx @@ -23,20 +23,18 @@ export interface ComposerScope { /** This scope's "turn parked on user input" edge — gates Esc-to-stop. */ $awaitingInput: ReadableAtom attachments: ComposerAttachmentScope - /** Only the main scope may pop out (the floating composer is a singleton). */ - popoutAllowed: boolean - /** Imperative read of this scope's transcript (input-history browse) — - * never subscribed, so streaming stays out of the composer's renders. */ - readMessages: () => ChatMessage[] + /** This scope's transcript. Read it imperatively (input-history browse) to + * keep streaming out of the composer's renders; subscribe only off-render + * (auto-speak) where the reply edge is the whole point. */ + $messages: ReadableAtom /** Focus-bus routing key (`'main'` | `'tile:'`). */ target: ComposerTarget } export const MAIN_COMPOSER_SCOPE: ComposerScope = { $awaitingInput: $activeSessionAwaitingInput, + $messages, attachments: mainComposerScope, - popoutAllowed: true, - readMessages: () => $messages.get(), target: 'main' } diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx index aee735e4aea..14cb89d3ddc 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx @@ -3,17 +3,16 @@ import { memo, useEffect, useRef, useState } from 'react' import { WorktreeDialog } from '@/app/chat/sidebar/projects/worktree-dialog' import { StatusRow } from '@/components/chat/status-row' +import { + type ActionItemSpec, + ActionsContextMenu, + ActionsMenu, + type MenuKit, + renderActionItem +} from '@/components/ui/actions-menu' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DiffCount } from '@/components/ui/diff-count' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import type { HermesGitBranch } from '@/global' import { useI18n } from '@/i18n' import { $repoStatus, $repoWorktrees } from '@/store/coding-status' @@ -91,6 +90,7 @@ export const CodingStatusRow = memo(function CodingStatusRow({ const worktreeReq = useStore($newWorktreeRequest) const lastWorktreeReqRef = useRef(worktreeReq) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (worktreeReq === lastWorktreeReqRef.current) { return @@ -152,34 +152,87 @@ export const CodingStatusRow = memo(function CodingStatusRow({ // they're the only change (otherwise +/- tells the story). const untrackedOnly = !hasLineDelta && status.untracked > 0 + // The branch actions, rendered identically by the kebab dropdown and the + // row's right-click menu so the two never drift. `onBranchOff` gates the + // whole menu (omitted = remote backend), matching the kebab. + const renderBranchItems = (kit: MenuKit) => { + const branchItems: ActionItemSpec[] = branchTargets.map(target => ({ + key: target.base ?? '__head__', + label: {target.label}, + onSelect: () => startBranch(target.base) + })) + + const worktreeItems: ActionItemSpec[] = otherWorktrees.map(worktree => ({ + key: worktree.path, + label: {worktree.branch}, + onSelect: () => onOpenWorktree?.(worktree.path) + })) + + return ( + <> + {s.newBranch} + {branchItems.map(item => renderActionItem(kit, item))} + {switchTarget && + renderActionItem(kit, { + key: '__switch__', + label: {s.switchTo(switchTarget)}, + onSelect: () => void switchToBranch(switchTarget) + })} + + {s.worktrees} + {worktreeItems.map(item => renderActionItem(kit, item))} + {/* Create a fresh worktree off the current HEAD (the generic "spin up a + worktree here", mirroring the sidebar's + button). */} + {renderActionItem(kit, { + key: '__start__', + label: {p.startWork}, + onSelect: () => startBranch(undefined) + })} + {onConvertBranch && + renderActionItem(kit, { + key: '__convert__', + label: {p.convertBranch}, + onSelect: () => startBranch(undefined) + })} + + ) + } + return ( <> - } - onActivate={onOpen} - > -
- - {branchLabel} - + + } + onActivate={onOpen} + > +
+ + {branchLabel} + - {/* Branch actions kebab — same pattern as the session/worktree rows. - ALWAYS laid out; only its opacity flips on hover/focus/open, so - revealing it never reflows the row (no layout shift). pointer-events - follow opacity so the invisible trigger isn't clickable at rest. */} - {onBranchOff && ( - - + {/* Branch actions kebab — same pattern as the session/worktree rows. + ALWAYS laid out; only its opacity flips on hover/focus/open, so + revealing it never reflows the row (no layout shift). pointer-events + follow opacity so the invisible trigger isn't clickable at rest. */} + {onBranchOff && ( + - - {/* The row sits at the bottom of the screen (above the composer), - so the menu opens upward. */} - - {s.newBranch} - {branchTargets.map(target => ( - startBranch(target.base)}> - {target.label} - - ))} + + )} +
- {switchTarget && ( - void switchToBranch(switchTarget)}> - {s.switchTo(switchTarget)} - - )} - - - {s.worktrees} - {otherWorktrees.map(worktree => ( - onOpenWorktree?.(worktree.path)}> - {worktree.branch} - - ))} - {/* Create a fresh worktree off the current HEAD (the generic - "spin up a worktree here", mirroring the sidebar's + button). */} - startBranch(undefined)}> - {p.startWork} - - {/* Create a fresh worktree off the current HEAD (the generic - "spin up a worktree here", mirroring the sidebar's + button). */} - {onConvertBranch && ( - startBranch(undefined)}> - {p.convertBranch} - - )} - - + {(status.ahead > 0 || status.behind > 0) && ( + + {status.ahead > 0 && ( + + + {status.ahead} + + )} + {status.behind > 0 && ( + + + {status.behind} + + )} + )} -
- {(status.ahead > 0 || status.behind > 0) && ( - - {status.ahead > 0 && ( - - - {status.ahead} - - )} - {status.behind > 0 && ( - - - {status.behind} - - )} - - )} - - {hasLineDelta ? ( - - ) : untrackedOnly ? ( - - {s.changed(status.untracked)} - - ) : null} -
+ {hasLineDelta ? ( + + ) : untrackedOnly ? ( + + {s.changed(status.untracked)} + + ) : null} + + {resolvedRepoPath && onOpenWorktree && ( ({ + detail, + status, + title, + updatedAt: Date.now() +}) + +function renderStack(sessionId: null | string = SID) { + return render( + + + + + + ) +} + +describe('ComposerStatusStack goal indicator', () => { + beforeEach(() => { + $goalsBySession.set({}) + }) + + afterEach(() => { + cleanup() + $goalsBySession.set({}) + }) + + it('renders nothing when the session has no goal', () => { + const view = renderStack() + + expect(view.container.firstChild).toBeNull() + }) + + it('shows an active goal with its title', () => { + $goalsBySession.set({ [SID]: goal('active') }) + + renderStack() + + expect(screen.getByText('Goal active')).toBeTruthy() + expect(screen.getByText('ship the feature')).toBeTruthy() + }) + + it('labels a paused goal as paused', () => { + $goalsBySession.set({ [SID]: goal('paused') }) + + renderStack() + + expect(screen.getByText('Goal paused')).toBeTruthy() + expect(screen.getByText('ship the feature')).toBeTruthy() + }) + + it('shows the continuation detail line for an active goal', () => { + $goalsBySession.set({ [SID]: goal('active', 'ship it', 'Continuing toward goal (3/20)') }) + + renderStack() + + expect(screen.getByText('Continuing toward goal (3/20)')).toBeTruthy() + }) + + it('scopes the indicator to the goal-owning session', () => { + $goalsBySession.set({ 'other-session': goal('active') }) + + const view = renderStack() + + expect(view.container.firstChild).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index 739080be8b8..fbbd2b94d5a 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'rea import { useNavigate } from 'react-router-dom' import { blurComposerInput } from '@/app/chat/composer/focus' +import { clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from '@/app/chat/surface-vars' import { AGENTS_ROUTE } from '@/app/routes' import { BillingBanner } from '@/components/billing-banner' import { composerDockCard } from '@/components/chat/composer-dock' @@ -11,6 +12,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' +import { useSessionSlice } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { $billingBlock } from '@/store/billing-block' import { @@ -22,6 +24,7 @@ import { type StatusGroup, stopBackgroundProcess } from '@/store/composer-status' +import { refreshSessionGoal } from '@/store/goals' import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status' import { $threadScrolledUp } from '@/store/thread-scroll' import { openSessionInNewWindow } from '@/store/windows' @@ -41,12 +44,25 @@ const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\. // Real codicons per group (no sparkles): a checklist for todos, the agent glyph // for subagents, a background process glyph for background tasks. const GROUP_ICON: Record = { + goal: 'target', todo: 'checklist', subagent: 'agent', background: 'server-process' } const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => { + if (group.type === 'goal') { + const status = group.items[0]?.goalStatus + + return status === 'paused' + ? s.goalPaused + : status === 'waiting' + ? s.goalWaiting + : status === 'done' + ? s.goalDone + : s.goalActive + } + if (group.type === 'todo') { return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length) } @@ -69,23 +85,25 @@ interface ComposerStatusStackProps { export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) { const { t } = useI18n() const navigate = useNavigate() - const itemsBySession = useStore($statusItemsBySession) - const previewsBySession = useStore($previewStatusBySession) + // Subscribe to THIS session's slice only. Both maps churn on other + // sessions' activity (subagent ticks, background polls, preview updates in + // any tile); a whole-map `useStore` re-rendered every mounted stack — one + // per open tile — on all of it. The per-key arrays are referentially stable + // across unrelated writes, so the slice hook bails out unless OUR session's + // items actually changed. + const items = useSessionSlice($statusItemsBySession, sessionId) + const previews = useSessionSlice($previewStatusBySession, sessionId) const scrolledUp = useStore($threadScrolledUp) const billing = useStore($billingBlock) - const groups = useMemo( - () => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []), - [itemsBySession, sessionId] - ) - - const previews = sessionId ? (previewsBySession[sessionId] ?? []) : [] + const groups = useMemo(() => groupStatusItems(items), [items]) // Seed from the registry on session open; event-driven refreshes (terminal / // process tool completions) live in use-message-stream. useEffect(() => { if (sessionId) { void refreshBackgroundProcesses(sessionId) + void refreshSessionGoal(sessionId) } }, [sessionId]) @@ -153,7 +171,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
) : undefined } - defaultCollapsed={group.type !== 'todo'} + defaultCollapsed={group.type !== 'todo' && group.type !== 'goal'} icon={} label={groupLabel(group, t.statusStack)} > @@ -197,12 +215,12 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro // height never sees it. Publish our own measured height — bucketed like the // composer's, to avoid style invalidation churn — so the thread's // last-message clearance can add it and the stack never hides messages. + // Scoped to THIS surface: tiles render their own stack (see surface-vars.ts). useLayoutEffect(() => { - const root = document.documentElement const el = stackRef.current if (!visible || !el) { - root.style.removeProperty('--status-stack-measured-height') + clearSurfaceVar(el, STATUS_STACK_VAR) return } @@ -214,7 +232,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro if (bucket !== last) { last = bucket - root.style.setProperty('--status-stack-measured-height', `${bucket}px`) + setSurfaceVar(el, STATUS_STACK_VAR, `${bucket}px`) } } @@ -224,7 +242,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro return () => { observer.disconnect() - root.style.removeProperty('--status-stack-measured-height') + clearSurfaceVar(el, STATUS_STACK_VAR) } }, [visible]) diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx index cf721d2ae93..dc40c31de2e 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -11,7 +11,7 @@ import { cn } from '@/lib/utils' import { PREVIEW_PANE_ID } from '@/store/layout' import { notifyError } from '@/store/notifications' import { $paneOpen } from '@/store/panes' -import { $previewTarget, dismissPreviewTarget, setCurrentSessionPreviewTarget } from '@/store/preview' +import { $previewTabSources, closePreviewForSource, openPreview } from '@/store/preview' import { type PreviewArtifact } from '@/store/preview-status' interface PreviewStatusRowProps { @@ -22,10 +22,10 @@ interface PreviewStatusRowProps { /** One detected artifact, single line, always visible: filename + open + close. */ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss }: PreviewStatusRowProps) { const { t } = useI18n() - const activePreview = useStore($previewTarget) + const openSources = useStore($previewTabSources) const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) const [opening, setOpening] = useState(false) - const isOpen = activePreview?.source === item.target && previewPaneOpen + const isOpen = openSources.includes(item.target) && previewPaneOpen const resolveTarget = async () => { const target = await normalizeOrLocalPreviewTarget(item.target, item.cwd || undefined) @@ -43,7 +43,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss } if (isOpen) { - dismissPreviewTarget() + closePreviewForSource(item.target) return } @@ -51,7 +51,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss setOpening(true) try { - setCurrentSessionPreviewTarget(await resolveTarget(), 'tool-result', item.target) + openPreview(await resolveTarget(), 'tool-result') } catch (error) { notifyError(error, t.preview.unavailable) } finally { diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx index 6857be46ccf..2c43743c40c 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -25,6 +25,24 @@ const TODO_GLYPHS: Record, { icon // Left slot: braille spinner while running, otherwise a small status dot // (green = done, red = failed) so the slot is always filled and rows align. function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode { + if (item.type === 'goal') { + if (item.goalStatus === 'paused') { + return + } + + if (item.goalStatus === 'done') { + return + } + + return ( + + ) + } + if (item.todoStatus === 'pending') { return ( )} + {item.type === 'goal' && item.currentTool && ( + + {item.currentTool} + + )} {failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && ( {s.exit(item.exitCode)} diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index 6c6a20780f6..2a350593f45 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -44,14 +44,58 @@ describe('detectTrigger', () => { it('does not treat file-style paths as slash triggers', () => { expect(detectTrigger('src/foo/bar')).toBeNull() expect(detectTrigger('/path/to/file')).toBeNull() + // Mid-message paths stay excluded too: a path keeps going past the command + // token, so the trailing-anchored inline trigger never matches it. + expect(detectTrigger('check src/foo/bar')).toBeNull() + expect(detectTrigger('look at /usr/local/bin')).toBeNull() + expect(detectTrigger('and/or')).toBeNull() }) - it('does not trigger slash popover mid-message', () => { - expect(detectTrigger('hello /')).toBeNull() - expect(detectTrigger('hello /skill')).toBeNull() + it('keeps the at-mention live while walking into subfolders', () => { + // A `/` inside the query is path navigation, not the end of the token — + // the popover has to stay open so the next directory level can load. + expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3 }) + expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6 }) + expect(detectTrigger('@~/Desktop/')).toEqual({ kind: '@', query: '~/Desktop/', tokenLength: 11 }) + expect(detectTrigger('@/usr/local')).toEqual({ kind: '@', query: '/usr/local', tokenLength: 11 }) + expect(detectTrigger('@apps/desktop/src')).toEqual({ + kind: '@', + query: 'apps/desktop/src', + tokenLength: 17 + }) + }) + + it('keeps the at-mention live for a typed ref kind with a path', () => { + expect(detectTrigger('@file:src/main.tsx')).toEqual({ + kind: '@', + query: 'file:src/main.tsx', + tokenLength: 18 + }) + expect(detectTrigger('@folder:apps/')).toEqual({ kind: '@', query: 'folder:apps/', tokenLength: 13 }) + }) + + it('still ends the at-mention token at whitespace', () => { + // The token is whitespace-delimited; a path doesn't change that. + expect(detectTrigger('@./src and more')).toBeNull() + expect(detectTrigger('look at @apps/desktop')).toEqual({ + kind: '@', + query: 'apps/desktop', + tokenLength: 13 + }) + }) + + it('treats a mid-message slash as an inline reference', () => { + // Skills have to be reachable anywhere in a prompt, not just at position 0. + expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1 }) + expect(detectTrigger('hello /clean')).toEqual({ kind: '/', inline: true, query: 'clean', tokenLength: 6 }) + expect(detectTrigger('text\n/skill')).toEqual({ kind: '/', inline: true, query: 'skill', tokenLength: 6 }) + }) + + it('does not carry arg completion into an inline slash reference', () => { + // Only a position-0 slash is a real invocation, so `/personality alic` + // mid-message is prose — the trigger ends at the command token. expect(detectTrigger('hello there /personality alic')).toBeNull() - expect(detectTrigger('text\n/skill')).toBeNull() - expect(detectTrigger('multi word message /')).toBeNull() + expect(detectTrigger('run /tools enable foo')).toBeNull() }) it('still anchors at-mention triggers strictly at the token edge', () => { diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index b9b6adc07f1..bf91f1db6a4 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -1,22 +1,44 @@ import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images' export interface TriggerState { + /** True for a `/` typed mid-message — an inline skill/command reference in + * prose rather than a command invocation. Arg completion doesn't apply. */ + inline?: boolean kind: '@' | '/' query: string tokenLength: number } // `@` triggers stop at the first whitespace — `@file:path` and `@diff` are -// single tokens. `/` triggers keep going so the popover stays live while the -// user types args (`/personality alic` → arg completer suggests `alice`). -// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file -// paths like `src/foo/bar`. +// single tokens, and a path is part of that token: `@./src/`, `@~/Desktop/`, +// and `@file:src/foo` all have to keep the popover live while the user walks +// into subdirectories. Excluding `/` from the query class would end the token +// at the first separator, which is exactly the "can't browse into a folder" +// bug. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids +// matching file paths like `src/foo/bar`. // -// Slash commands only execute at the beginning of a message, so the `/` -// trigger is anchored strictly at position 0 — not after whitespace — to -// avoid opening the popover mid-message (e.g. `hello /`). -const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/ -const SLASH_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ +// `/` triggers fire in two shapes, because a slash means two different things +// depending on where it sits: +// +// - At position 0 it's a COMMAND invocation the app executes (SLASH_COMMAND_RE +// is `^`-anchored, and so is the backend's). The popover stays live past the +// command name so arg completion works (`/personality alic` → `alice`). +// - After whitespace it's an inline REFERENCE the user is dropping into prose +// ("clean this up with /clean"). The text submits as an ordinary message, so +// there are no args to complete — the trigger is a single token that ends at +// the next space, exactly like `@`. +// +// Only the FIRST slash can be an invocation, so the inline shape is tested +// first: the command regex's argument tail (`(?:\s+\S*)*`) happily swallows a +// later `/skill` as if it were an argument, which killed completion for every +// slash after a leading command (`/work /cle` → nothing). +// +// The inline shape is what makes skills reachable anywhere in a prompt. Both +// shapes need the trailing `$`: detection runs against the text BEFORE the +// caret, so the match must end where the user is typing. +const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@]*)$/ +const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ +const SLASH_INLINE_TRIGGER_RE = /[\s](\/)([a-zA-Z][\w-]*)?$/ /** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */ export function blobDedupeKey(blob: Blob): string { @@ -107,10 +129,22 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null { } export function detectTrigger(textBefore: string): TriggerState | null { - const slash = SLASH_TRIGGER_RE.exec(textBefore) + // An inline `/skill` is a reference dropped into prose, so it carries no args + // and the whole match is the token the chip replaces. Checked before the + // anchored command shape so a second slash isn't mistaken for the first + // command's argument. + const inline = SLASH_INLINE_TRIGGER_RE.exec(textBefore) - if (slash) { - return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length } + if (inline) { + const query = inline[2] ?? '' + + return { inline: true, kind: '/', query, tokenLength: 1 + query.length } + } + + const command = SLASH_COMMAND_TRIGGER_RE.exec(textBefore) + + if (command) { + return { kind: '/', query: command[2], tokenLength: 1 + command[2].length } } const at = AT_TRIGGER_RE.exec(textBefore) diff --git a/apps/desktop/src/app/chat/composer/undo-history.test.ts b/apps/desktop/src/app/chat/composer/undo-history.test.ts new file mode 100644 index 00000000000..5ce477a8020 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/undo-history.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + caretOffsetInEditor, + composerPlainText, + placeCaretAtOffset, + refChipElement, + renderComposerContents, + RICH_INPUT_SLOT +} from './rich-editor' +import { createComposerUndoHistory, isRedoShortcut, isUndoShortcut } from './undo-history' + +const key = (over: Partial = {}) => + ({ altKey: false, ctrlKey: false, key: 'z', metaKey: false, shiftKey: false, ...over }) as KeyboardEvent + +describe('undo/redo shortcut recognition', () => { + it('claims Cmd+Z and Ctrl+Z as undo, but not with Shift or Alt', () => { + expect(isUndoShortcut(key({ metaKey: true }))).toBe(true) + expect(isUndoShortcut(key({ ctrlKey: true }))).toBe(true) + expect(isUndoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(false) + expect(isUndoShortcut(key({ altKey: true, metaKey: true }))).toBe(false) + expect(isUndoShortcut(key({ key: 'a', metaKey: true }))).toBe(false) + expect(isUndoShortcut(key())).toBe(false) + }) + + it('claims Cmd+Shift+Z everywhere and Ctrl+Y as redo', () => { + expect(isRedoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(true) + expect(isRedoShortcut(key({ ctrlKey: true, shiftKey: true }))).toBe(true) + expect(isRedoShortcut(key({ ctrlKey: true, key: 'y' }))).toBe(true) + expect(isRedoShortcut(key({ metaKey: true }))).toBe(false) + expect(isRedoShortcut(key({ altKey: true, ctrlKey: true, key: 'y' }))).toBe(false) + }) + + it('never treats one keystroke as both undo and redo', () => { + const chords = [ + key({ metaKey: true }), + key({ ctrlKey: true }), + key({ metaKey: true, shiftKey: true }), + key({ ctrlKey: true, shiftKey: true }), + key({ ctrlKey: true, key: 'y' }) + ] + + for (const chord of chords) { + expect(isUndoShortcut(chord) && isRedoShortcut(chord)).toBe(false) + } + }) +}) + +describe('composer undo history', () => { + const snap = (text: string, caret = text.length) => ({ caret, text }) + + it('steps back through discrete edits, newest first', () => { + const history = createComposerUndoHistory() + + history.record(snap('')) + history.record(snap('a')) + history.record(snap('ab')) + + expect(history.undo(snap('abc'))?.text).toBe('ab') + expect(history.undo(snap('ab'))?.text).toBe('a') + expect(history.undo(snap('a'))?.text).toBe('') + expect(history.undo(snap(''))).toBeNull() + }) + + it('redoes back to the state undo left, and stops at the newest', () => { + const history = createComposerUndoHistory() + + history.record(snap('before')) + + const undone = history.undo(snap('before + pasted')) + + expect(undone?.text).toBe('before') + expect(history.redo(snap('before'))?.text).toBe('before + pasted') + expect(history.redo(snap('before + pasted'))).toBeNull() + }) + + it('restores the caret along with the text', () => { + const history = createComposerUndoHistory() + + history.record({ caret: 3, text: 'hello world' }) + + expect(history.undo({ caret: 0, text: 'changed' })).toEqual({ caret: 3, text: 'hello world' }) + }) + + it('collapses a run of typing into one entry, so undo steps back by a burst', () => { + let now = 1_000 + const history = createComposerUndoHistory(200, () => now) + + history.record(snap(''), { coalesce: true }) + now += 50 + history.record(snap('h'), { coalesce: true }) + now += 50 + history.record(snap('he'), { coalesce: true }) + + // One burst → one entry, back to the state before the burst started. + expect(history.undo(snap('hel'))?.text).toBe('') + expect(history.undo(snap(''))).toBeNull() + }) + + it('starts a new entry once the typing pause exceeds the coalesce window', () => { + let now = 1_000 + const history = createComposerUndoHistory(200, () => now) + + history.record(snap(''), { coalesce: true }) + now += 5_000 + history.record(snap('word one'), { coalesce: true }) + + expect(history.undo(snap('word one two'))?.text).toBe('word one') + expect(history.undo(snap('word one'))?.text).toBe('') + }) + + it('does not coalesce a paste into the typing burst that preceded it', () => { + let now = 1_000 + const history = createComposerUndoHistory(200, () => now) + + history.record(snap(''), { coalesce: true }) + now += 20 + // A paste is a discrete edit — no coalesce flag. + history.record(snap('typed ')) + + expect(history.undo(snap('typed PASTED'))?.text).toBe('typed ') + expect(history.undo(snap('typed '))?.text).toBe('') + }) + + it('drops the redo stack once a new edit lands', () => { + const history = createComposerUndoHistory() + + history.record(snap('one')) + history.undo(snap('one two')) + history.record(snap('one')) + + expect(history.redo(snap('one three'))).toBeNull() + }) + + it('ignores a no-op edit so undo never looks stuck for a press', () => { + const history = createComposerUndoHistory() + + history.record(snap('same')) + history.record(snap('same')) + + expect(history.undo(snap('same'))?.text).toBe('same') + expect(history.undo(snap('same'))).toBeNull() + }) + + it('bounds the stack, discarding the oldest entries', () => { + const history = createComposerUndoHistory(3) + + for (const text of ['a', 'b', 'c', 'd', 'e']) { + history.record(snap(text)) + } + + expect(history.undo(snap('f'))?.text).toBe('e') + expect(history.undo(snap('e'))?.text).toBe('d') + expect(history.undo(snap('d'))?.text).toBe('c') + expect(history.undo(snap('c'))).toBeNull() + }) + + it('reset clears both directions', () => { + const history = createComposerUndoHistory() + + history.record(snap('a')) + history.undo(snap('ab')) + history.reset() + + expect(history.undo(snap('ab'))).toBeNull() + expect(history.redo(snap('ab'))).toBeNull() + }) +}) + +describe('caret offsets in composerPlainText coordinates', () => { + let editor: HTMLDivElement + + beforeEach(() => { + editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + }) + + const caretAfter = (node: Node, offset: number) => { + const range = document.createRange() + const selection = window.getSelection()! + range.setStart(node, offset) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + it('round-trips a caret in plain text', () => { + renderComposerContents(editor, 'hello world') + placeCaretAtOffset(editor, 5) + + expect(caretOffsetInEditor(editor)).toBe(5) + }) + + it('counts a chip as its whole @kind:value text', () => { + editor.append( + document.createTextNode('see '), + refChipElement('file', '`src/a.ts`'), + document.createTextNode(' now') + ) + + const chipText = '@file:`src/a.ts`' + // Caret at the very end = everything before it. + placeCaretAtOffset(editor, composerPlainText(editor).length) + + expect(caretOffsetInEditor(editor)).toBe(4 + chipText.length + 4) + }) + + it('lands the caret before a chip rather than splitting it', () => { + editor.append(document.createTextNode('a '), refChipElement('file', '`x.ts`')) + + // An offset that falls midway through the chip's serialized text. + placeCaretAtOffset(editor, 2 + 3) + + expect(caretOffsetInEditor(editor)).toBe(2) + }) + + it('counts a line break as one character', () => { + renderComposerContents(editor, 'one\ntwo') + placeCaretAtOffset(editor, 5) + + expect(caretOffsetInEditor(editor)).toBe(5) + expect(composerPlainText(editor)).toBe('one\ntwo') + }) + + it('clamps past-the-end offsets to the end instead of throwing', () => { + renderComposerContents(editor, 'short') + placeCaretAtOffset(editor, 999) + + expect(caretOffsetInEditor(editor)).toBe(5) + }) + + it('reports the end when the selection is outside the editor', () => { + renderComposerContents(editor, 'hello') + + const outside = document.createElement('div') + outside.textContent = 'elsewhere' + document.body.append(outside) + caretAfter(outside.firstChild!, 3) + + expect(caretOffsetInEditor(editor)).toBe(5) + + outside.remove() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/undo-history.ts b/apps/desktop/src/app/chat/composer/undo-history.ts new file mode 100644 index 00000000000..62bed71be83 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/undo-history.ts @@ -0,0 +1,126 @@ +/** + * The composer's own undo stack. + * + * The rich editor mutates its DOM through `Range` rather than the browser's + * editing commands — `execCommand('insertText')` is ~O(n²) on large multiline + * blobs and froze the composer for seconds on a big paste (#45812). The cost of + * that bypass is that those mutations never reach Chromium's undo stack, so + * ⌘Z skipped straight past a paste and undid whatever came *before* it, leaving + * the pasted text stranded. + * + * Owning the whole stack is the only coherent fix: a half-owned one interleaves + * our snapshots with Chromium's own typing entries and undoes them out of order. + * So every composer edit — typed or programmatic — records here, and the editor + * intercepts ⌘Z / ⌘⇧Z instead of letting the native command run. + * + * Snapshots are plain text + a caret offset, not DOM: the editor already + * round-trips losslessly through `composerPlainText`/`renderComposerContents`, + * so text is the smallest thing that fully restores a state. + */ + +export interface ComposerSnapshot { + caret: number + text: string +} + +/** Consecutive typing inside this window collapses into one undo entry, so ⌘Z + * steps back by a burst the way a native editor does — not one character. */ +const COALESCE_WINDOW_MS = 600 + +const DEFAULT_LIMIT = 200 + +export interface ComposerUndoHistory { + /** Drop all history and start over from `snapshot`'s state (session swap). */ + reset: () => void + /** Redo one step. `current` is the live state, banked for a subsequent undo. */ + redo: (current: ComposerSnapshot) => ComposerSnapshot | null + /** Bank the state that existed *before* an edit. `coalesce` merges this into + * the previous entry when it lands inside the typing window. */ + record: (previous: ComposerSnapshot, options?: { coalesce?: boolean }) => void + /** Undo one step. `current` is the live state, banked for a subsequent redo. */ + undo: (current: ComposerSnapshot) => ComposerSnapshot | null +} + +export function createComposerUndoHistory( + limit = DEFAULT_LIMIT, + now: () => number = () => Date.now() +): ComposerUndoHistory { + let past: ComposerSnapshot[] = [] + let future: ComposerSnapshot[] = [] + let lastRecordedAt = 0 + let lastWasCoalescable = false + + const record: ComposerUndoHistory['record'] = (previous, options) => { + const coalesce = options?.coalesce ?? false + const at = now() + const merges = coalesce && lastWasCoalescable && past.length > 0 && at - lastRecordedAt < COALESCE_WINDOW_MS + + lastRecordedAt = at + lastWasCoalescable = coalesce + // A fresh edit invalidates anything the user had redone past. + future = [] + + // Merging keeps the OLDER snapshot — the entry already holds the state from + // the start of the burst, which is what ⌘Z should step back to. + if (merges) { + return + } + + // A no-op edit (same text) would make ⌘Z look broken for one press. + if (past[past.length - 1]?.text === previous.text) { + return + } + + past.push(previous) + + if (past.length > limit) { + past = past.slice(past.length - limit) + } + } + + const step = (from: ComposerSnapshot[], to: ComposerSnapshot[], current: ComposerSnapshot) => { + const next = from.pop() + + if (!next) { + return null + } + + to.push(current) + // Any traversal ends the typing burst, so the next keystroke opens a new entry. + lastWasCoalescable = false + + return next + } + + return { + record, + redo: current => step(future, past, current), + reset: () => { + past = [] + future = [] + lastRecordedAt = 0 + lastWasCoalescable = false + }, + undo: current => step(past, future, current) + } +} + +/** True for the keystroke that means "undo" (⌘Z / Ctrl+Z, without Shift). */ +export function isUndoShortcut(event: Pick) { + return (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'z' +} + +/** True for "redo" — ⌘⇧Z everywhere, plus Ctrl+Y on Windows/Linux. */ +export function isRedoShortcut(event: Pick) { + if (event.altKey) { + return false + } + + const key = event.key.toLowerCase() + + if ((event.metaKey || event.ctrlKey) && event.shiftKey && key === 'z') { + return true + } + + return event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'y' +} diff --git a/apps/desktop/src/app/chat/composer/url-refs.test.ts b/apps/desktop/src/app/chat/composer/url-refs.test.ts new file mode 100644 index 00000000000..febbd533ce4 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/url-refs.test.ts @@ -0,0 +1,98 @@ +import type { KeyboardEvent } from 'react' +import { describe, expect, it } from 'vitest' + +import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor' +import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs' + +/** An editor holding `text` with a collapsed caret at `caret`, plus the space + * keydown the composer would hand `chipTypedUrlOnSpace`. */ +const spaceOn = (text: string, caret: number) => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = text + document.body.append(editor) + + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(editor.firstChild!, caret) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + return { editor, event: { currentTarget: editor, key: ' ' } as KeyboardEvent } +} + +describe('linkifyUrls', () => { + it('rewrites a bare link as a url directive', () => { + expect(linkifyUrls('https://example.dev/a/b')).toBe('@url:`https://example.dev/a/b`') + }) + + it('keeps the link in place mid-sentence and leaves its punctuation behind', () => { + expect(linkifyUrls('read https://example.dev/a. then stop')).toBe('read @url:`https://example.dev/a`. then stop') + }) + + it('keeps balanced parens but drops the one that closed the sentence', () => { + expect(linkifyUrls('(see https://en.wikipedia.org/wiki/A_(b))')).toBe( + '(see @url:`https://en.wikipedia.org/wiki/A_(b)`)' + ) + }) + + it('rewrites every link in a multi-link paste', () => { + expect(linkifyUrls('http://a.dev and https://b.dev')).toBe('@url:`http://a.dev` and @url:`https://b.dev`') + }) + + it('leaves a link that is already a directive alone', () => { + expect(linkifyUrls('@url:`https://example.dev`')).toBe('@url:`https://example.dev`') + }) + + it('leaves text without a scheme alone', () => { + expect(linkifyUrls('example.dev/a and src/foo.ts')).toBe('example.dev/a and src/foo.ts') + }) +}) + +describe('chipTypedUrlOnSpace', () => { + it('chips a link typed right before the caret and adds the space', () => { + const { editor, event } = spaceOn('see https://example.dev/a', 25) + + expect(chipTypedUrlOnSpace(event)).toBe(true) + expect(composerPlainText(editor)).toBe('see @url:`https://example.dev/a` ') + + editor.remove() + }) + + it('keeps sentence punctuation outside the chip', () => { + const { editor, event } = spaceOn('https://example.dev.', 20) + + expect(chipTypedUrlOnSpace(event)).toBe(true) + expect(composerPlainText(editor)).toBe('@url:`https://example.dev`. ') + + editor.remove() + }) + + it('ignores a caret that is not sitting on a link', () => { + const { editor, event } = spaceOn('https://example.dev is nice', 27) + + expect(chipTypedUrlOnSpace(event)).toBe(false) + expect(composerPlainText(editor)).toBe('https://example.dev is nice') + + editor.remove() + }) + + it('ignores a scheme with no host yet', () => { + const { editor, event } = spaceOn('https://', 8) + + expect(chipTypedUrlOnSpace(event)).toBe(false) + + editor.remove() + }) + + it('leaves a modified space alone', () => { + const { editor, event } = spaceOn('https://example.dev', 19) + + expect(chipTypedUrlOnSpace({ ...event, altKey: true })).toBe(false) + expect(composerPlainText(editor)).toBe('https://example.dev') + + editor.remove() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/url-refs.ts b/apps/desktop/src/app/chat/composer/url-refs.ts new file mode 100644 index 00000000000..580abdc1c6f --- /dev/null +++ b/apps/desktop/src/app/chat/composer/url-refs.ts @@ -0,0 +1,103 @@ +/** + * Bare-link recognition for the composer. A link the user pastes or types is the + * same thing the "+ → Add URL" dialog inserts, so it becomes an `@url:` + * directive: a chip that truncates instead of a wall of URL text, and a + * reference the gateway resolves. + */ +import type { KeyboardEvent } from 'react' + +import { quoteRefValue, REF_RE, refChipElement, replaceBeforeCaret } from './rich-editor' +import { textBeforeCaret } from './text-utils' + +// An explicit scheme only — `example.com` bare is too easy to hit by accident +// (a filename, a version, a sentence). Brackets and quotes fence a URL in prose; +// parens don't, so they stay in and an unbalanced tail is trimmed below. +const URL_RE = /https?:\/\/[^\s<>[\]{}"'`]+/gi +const TYPED_URL_RE = /(?:^|\s)(https?:\/\/[^\s<>[\]{}"'`]+)$/i + +/** A URL at the end of a sentence carries the punctuation that ended it. */ +function splitUrlTail(raw: string) { + let url = raw.replace(/[,.;:!?]+$/, '') + + while (url.endsWith(')') && url.split(')').length > url.split('(').length) { + url = url.slice(0, -1) + } + + return { trailing: raw.slice(url.length), url } +} + +/** A URL needs a host past the scheme to be worth chipping. */ +const hasHost = (url: string) => /^https?:\/\/[^/\s]/i.test(url) + +/** Rewrite bare links in `text` as `@url:` directives, leaving links that are + * already part of a directive alone. Returns `text` unchanged when there are + * none. */ +export function linkifyUrls(text: string) { + REF_RE.lastIndex = 0 + + const fenced = Array.from(text.matchAll(REF_RE)).map(match => { + const start = match.index ?? 0 + + return { end: start + match[0].length, start } + }) + + let out = '' + let cursor = 0 + + for (const match of text.matchAll(URL_RE)) { + const start = match.index ?? 0 + const { url } = splitUrlTail(match[0]) + + if (!hasHost(url) || fenced.some(span => start >= span.start && start < span.end)) { + continue + } + + out += `${text.slice(cursor, start)}@url:${quoteRefValue(url)}` + cursor = start + url.length + } + + return out + text.slice(cursor) +} + +/** A plain space finishing a typed link commits it as a chip (followed by + * whatever punctuation ended it, then the space). Returns whether it ran, so a + * keydown handler can fall through on anything else. */ +export function chipTypedUrlOnSpace(event: KeyboardEvent) { + if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) { + return false + } + + const editor = event.currentTarget + + // Runs on every space, so bail on the cheap native read before paying for the + // caret range walk (same guard shape as the trigger detector). + if (!editor.textContent?.includes('://')) { + return false + } + + const before = textBeforeCaret(editor) + const match = before ? TYPED_URL_RE.exec(before) : null + const token = match?.[1] + + if (!token) { + return false + } + + const { trailing, url } = splitUrlTail(token) + + if (!hasHost(url)) { + return false + } + + const fragment = document.createDocumentFragment() + + fragment.append(refChipElement('url', quoteRefValue(url))) + + if (trailing) { + fragment.append(document.createTextNode(trailing)) + } + + fragment.append(document.createTextNode(' ')) + + return replaceBeforeCaret(editor, token.length, fragment) +} diff --git a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts index 42e4554b992..186c226b84a 100644 --- a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +++ b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts @@ -44,6 +44,7 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt // DnD can't be cancelled at the OS level, so we drop the overlay and arm a // guard that swallows the trailing drop instead. Top escape layer + capture // stop so it doesn't also fire a handler behind the drag (see drag-session). + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (dragKind === null) { return diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index bd18be0c483..40a8922d453 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -1,14 +1,16 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' +import type { ReadableAtom } from 'nanostores' import type * as React from 'react' -import { Suspense, useCallback, useEffect, useMemo } from 'react' +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts' +import { usePaneVisible } from '@/components/pane-shell/pane-visibility' import { $sessionTileDragging, $sessionTileEdgeHover } from '@/components/pane-shell/tree/store' import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' @@ -42,7 +44,7 @@ import { import { isSecondaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' -import { routeSessionId } from '../routes' +import { primaryRouteSelectedSessionId, routeSessionId } from '../routes' import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar' import { ChatDropOverlay } from './chat-drop-overlay' @@ -69,7 +71,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onCancel: () => Promise | void onAddContextRef: (refText: string, label?: string, detail?: string) => void onAddUrl: (url: string) => void - onBranchInNewChat: (messageId: string) => void + onBranchInNewChat?: (messageId: string) => void maxVoiceRecordingSeconds?: number onAttachImageBlob: (blob: Blob) => Promise | boolean | void onAttachDroppedItems: (candidates: DroppedFile[]) => Promise | boolean | void @@ -174,6 +176,30 @@ interface ChatRuntimeBoundaryProps { const NO_MESSAGES: ChatMessage[] = [] +/** + * The view's $messages, live only while this surface is the VISIBLE tab. + * + * Keep-alive keeps every ever-active tab MOUNTED (tree-group.tsx), so without + * this gate a hidden tab re-renders its entire thread on every streaming + * delta flush (~30×/s) — five busy tabs quintuple the per-token render cost + * and the app crawls. Hidden tabs freeze their transcript instead (status + * dots stay live through the separate status atoms) and catch up in one + * commit on reveal — the subscribe fires immediately with the current value. + */ +function useMessagesWhileVisible($messages: ReadableAtom): ChatMessage[] { + const visible = usePaneVisible() + const [messages, setMessages] = useState(() => $messages.get()) + + // nanostores types the listener value ReadonlyIfObject; the store publishes + // a fresh array per flush, so the cast is safe and avoids a per-token clone. + useEffect( + () => (visible ? $messages.subscribe(value => setMessages(value as ChatMessage[])) : undefined), + [$messages, visible] + ) + + return messages +} + /** * Owns the $messages subscription and the assistant-ui external-store runtime. * @@ -193,7 +219,7 @@ function ChatRuntimeBoundary({ onThreadMessagesChange, suppressMessages }: ChatRuntimeBoundaryProps) { - const storeMessages = useStore(useSessionView().$messages) + const storeMessages = useMessagesWhileVisible(useSessionView().$messages) const messages = suppressMessages ? NO_MESSAGES : storeMessages const runtimeMessageRepository = useRuntimeMessageRepository(messages) @@ -285,11 +311,18 @@ export function ChatView({ const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) // Durable composer/queue scope (lineage root) so auto-compression tip rotation - // does not wipe an in-progress draft or orphan /queue entries. - const queueSessionKey = useMemo( - () => resolveComposerSessionKey(selectedSessionId, sessions), - [selectedSessionId, sessions] - ) + // does not wipe an in-progress draft or orphan /queue entries. For the + // primary view, the route is authoritative over the store selection — the + // latter can be momentarily null/stale mid-switch, which used to leak into + // the composer's scope key (#59305). A tile has no route, so it always uses + // its own selection directly. + const queueSessionKey = useMemo(() => { + const effectiveSelectedSessionId = isPrimary + ? primaryRouteSelectedSessionId(location.pathname, selectedSessionId) + : selectedSessionId + + return resolveComposerSessionKey(effectiveSelectedSessionId, sessions) + }, [isPrimary, location.pathname, selectedSessionId, sessions]) // When the tip row arrives after compression, migrate any tip-keyed stash onto // the durable lineage key before the composer remounts onto that key. @@ -437,6 +470,7 @@ export function ChatView({ 'relative isolate flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className )} + data-chat-surface="" data-composer-target={composerScope.target} data-session-anchor={sessionAnchor} > diff --git a/apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx new file mode 100644 index 00000000000..5b1a84e2098 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx @@ -0,0 +1,112 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { $artifactRegistry, $artifactVersionSelection, artifactPreviewTarget, upsertArtifact } from '@/store/artifacts' + +import { ArtifactPreview } from './preview-artifact' + +function register(title: string, kind: 'code' | 'html' | 'svg', content: string) { + const result = upsertArtifact('session-1', { kind, language: kind === 'code' ? 'python' : kind, title }, content) + + if (!result) { + throw new Error('artifact did not register') + } + + return result +} + +async function renderArtifact(artifactId: string) { + const record = $artifactRegistry.get()['session-1']!.find(item => item.id === artifactId)! + + await act(async () => { + render() + }) +} + +describe('ArtifactPreview', () => { + afterEach(() => { + cleanup() + $artifactRegistry.set({}) + $artifactVersionSelection.set({}) + }) + + it('renders html in a scripts-only sandboxed frame the parent app is unreachable from', async () => { + const { artifactId } = register('Dashboard', 'html', '

Hi

') + await renderArtifact(artifactId) + + const frame = screen.getByTitle('Dashboard') as HTMLIFrameElement + + expect(frame.getAttribute('sandbox')).toBe('allow-scripts') + expect(frame.srcdoc).toContain('

Hi

') + // No allow-same-origin: scripts inside cannot reach the renderer's origin. + expect(frame.getAttribute('sandbox')).not.toContain('same-origin') + }) + + it('strips scripts out of svg before it renders inline', async () => { + const { artifactId } = register( + 'Logo', + 'svg', + '' + ) + + await renderArtifact(artifactId) + + expect(document.querySelector('svg')).not.toBeNull() + expect(document.querySelector('svg script')).toBeNull() + }) + + it('offers only the source view for code, which has nothing to render', async () => { + const { artifactId } = register('Solver', 'code', 'print("hi")') + await renderArtifact(artifactId) + + expect(screen.queryByRole('button', { name: /rendered/i })).toBeNull() + }) + + it('shows the version stepper once an artifact has history, and follows the selection', async () => { + register('Dashboard', 'html', '

v1

') + const { artifactId } = register('Dashboard', 'html', '

v2

') + await renderArtifact(artifactId) + + expect(screen.getByText('v2 of 2')).toBeTruthy() + expect((screen.getByTitle('Dashboard') as HTMLIFrameElement).srcdoc).toContain('v2') + + await act(async () => { + $artifactVersionSelection.set({ [artifactId]: 0 }) + }) + + expect(screen.getByText('v1 of 2')).toBeTruthy() + expect((screen.getByTitle('Dashboard') as HTMLIFrameElement).srcdoc).toContain('v1') + }) + + it('hides the stepper for a single-version artifact', async () => { + const { artifactId } = register('Dashboard', 'html', '

only

') + await renderArtifact(artifactId) + + expect(screen.queryByText('v1 of 1')).toBeNull() + }) + + it('picks up a new version in an already-open tab', async () => { + const { artifactId } = register('Dashboard', 'html', '

v1

') + await renderArtifact(artifactId) + + await act(async () => { + register('Dashboard', 'html', '

v2

') + }) + + expect((screen.getByTitle('Dashboard') as HTMLIFrameElement).srcdoc).toContain('v2') + }) + + it('falls back to an empty state when the registry no longer has the artifact', async () => { + const { artifactId } = register('Dashboard', 'html', '

gone

') + const record = $artifactRegistry.get()['session-1']!.find(item => item.id === artifactId)! + const target = artifactPreviewTarget(record) + + $artifactRegistry.set({}) + + await act(async () => { + render() + }) + + expect(screen.queryByTitle('Dashboard')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/chat/right-rail/preview-artifact.tsx b/apps/desktop/src/app/chat/right-rail/preview-artifact.tsx new file mode 100644 index 00000000000..07d60393a8e --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-artifact.tsx @@ -0,0 +1,263 @@ +import { useStore } from '@nanostores/react' +import DOMPurify from 'dompurify' +import { useEffect, useMemo, useState } from 'react' + +import { CopyButton } from '@/components/ui/copy-button' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { artifactDownloadName, type ArtifactKind } from '@/lib/artifact-detect' +import { downloadTextFile } from '@/lib/download-text' +import { ChevronLeft, ChevronRight, Download, ExternalLink } from '@/lib/icons' +import { $artifactRegistry, $artifactVersionSelection, findArtifact, selectArtifactVersion } from '@/store/artifacts' +import { notifyError } from '@/store/notifications' +import type { PreviewTarget } from '@/store/preview' + +import { PreviewEmptyState, PreviewModeSwitcher, type PreviewViewMode, SourceView } from './preview-file' + +const MIME_BY_KIND = { code: 'text/plain', html: 'text/html', svg: 'image/svg+xml' } as const + +// Shiki has no `svg` grammar; code artifacts keep their detected fence language. +const SOURCE_LANGUAGE_BY_KIND: Record = { + code: undefined, + html: 'html', + svg: 'xml' +} + +const HEADER_BUTTON_CLASS = + 'flex items-center gap-1 rounded-md px-1.5 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40' + +/** Wrap an HTML fragment in a minimal document shell; full documents pass + * through untouched. Keeps generated fragments (no /) rendering + * with sane defaults instead of quirks-mode soup. */ +function composeArtifactHtml(content: string): string { + if (/]|', + '', + '', + content, + '' + ].join('\n') +} + +/** Write the composed document to a real temp file through the existing + * buffer-save IPC, then hand it to the OS browser. A blob/data URL can't + * cross into the OS default browser, so a file on disk is the honest path. */ +async function openHtmlInBrowser(content: string): Promise { + const bridge = window.hermesDesktop + + if (!bridge?.saveImageBuffer || !bridge.openExternal) { + throw new Error('Desktop bridge unavailable') + } + + const bytes = new TextEncoder().encode(composeArtifactHtml(content)) + const path = await bridge.saveImageBuffer(bytes, '.html') + + if (!path) { + throw new Error('Could not write artifact file') + } + + const fileUrl = `file://${path.startsWith('/') ? '' : '/'}${path.replace(/\\/g, '/')}` + + if (bridge.openPreviewInBrowser) { + await bridge.openPreviewInBrowser(fileUrl) + + return + } + + await bridge.openExternal(fileUrl) +} + +/** + * Live view for renderable artifact content. + * + * HTML runs in an `