diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cf15a1a6e02..6932fff39cca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,12 +158,13 @@ jobs: review-labels: name: Review label gate - needs: detect - if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true') + needs: [detect, supply-chain] + if: always() && needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true' || needs.supply-chain.outputs.critical_findings == 'true') uses: ./.github/workflows/review-labels.yml with: ci_review: ${{ needs.detect.outputs.ci_review == 'true' }} mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }} + supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }} secrets: inherit osv-scanner: diff --git a/.github/workflows/review-labels.yml b/.github/workflows/review-labels.yml index f0167b486742..737348d1c559 100644 --- a/.github/workflows/review-labels.yml +++ b/.github/workflows/review-labels.yml @@ -27,6 +27,10 @@ on: description: Whether the MCP catalog / installer changed. type: boolean default: false + supply_chain: + description: Whether the critical supply-chain scan found a risk requiring review. + type: boolean + default: false outputs: ci_reviewed: description: Whether the ci-reviewed label is present. Empty when neither input was true. @@ -42,7 +46,7 @@ permissions: jobs: check: name: Review label gate - if: inputs.ci_review || inputs.mcp_catalog + if: inputs.ci_review || inputs.mcp_catalog || inputs.supply_chain runs-on: ubuntu-latest timeout-minutes: 2 outputs: @@ -75,15 +79,17 @@ jobs: env: CI_REVIEW: ${{ inputs.ci_review }} MCP_CATALOG: ${{ inputs.mcp_catalog }} + SUPPLY_CHAIN: ${{ inputs.supply_chain }} LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }} run: | set -euo pipefail - ARGS="" - if [ "$CI_REVIEW" = "true" ]; then ARGS="$ARGS --ci-review"; fi - if [ "$MCP_CATALOG" = "true" ]; then ARGS="$ARGS --mcp-catalog"; fi - if [ "$LABEL_PRESENT" = "true" ]; then ARGS="$ARGS --label-present"; fi + args=() + if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi + if [ "$MCP_CATALOG" = "true" ]; then args+=(--mcp-catalog); fi + if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi + if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi - python3 scripts/ci/emit_review_status.py $ARGS --output "$GITHUB_OUTPUT" + python3 scripts/ci/emit_review_status.py "${args[@]}" --output "$GITHUB_OUTPUT" - name: Fail on missing label if: steps.label-check.outputs.ci_reviewed != 'true' diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 1b8a35cb301c..47114b306546 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -16,11 +16,12 @@ name: Supply Chain Audit # ``review-labels.yml`` so it can be rerun independently. # # Outputs: -# review_status — JSON array of status objects consumed by the review -# comment assembler (scripts/ci/assemble_review_comment.py). -# Each job (``scan``, ``dep-bounds``) emits its own -# array; an ``aggregate`` job merges them into the -# workflow-level output. +# review_status — JSON array of status objects consumed by the review +# comment assembler (scripts/ci/assemble_review_comment.py). +# critical_findings — "true" when the narrow critical-pattern scan found +# something. The review-label gate consumes this and +# owns the action-required result, so adding +# ``ci-reviewed`` can heal the run on rerun. on: workflow_call: @@ -41,6 +42,9 @@ on: review_status: description: JSON array of review status objects for the review comment assembler. value: ${{ jobs.aggregate.outputs.review_status }} + critical_findings: + description: Whether the critical-pattern scan found a risk requiring maintainer review. + value: ${{ jobs.aggregate.outputs.critical_findings }} permissions: pull-requests: write @@ -54,6 +58,7 @@ jobs: timeout-minutes: 15 outputs: review_status: ${{ steps.emit-status.outputs.review_status }} + critical_findings: ${{ steps.scan.outputs.found }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -165,33 +170,15 @@ jobs: python3 - <<'PYEOF' import json, os - found = os.environ.get("FOUND", "") == "true" - - if found: - with open("/tmp/findings.md", encoding="utf-8") as f: - detail = f.read() - status = [{ - "source": "supply chain", - "results": [{ - "kind": "error", - "title": "Critical supply chain risk", - "summary": "Critical supply chain risk patterns detected in this PR.", - "detail": detail, - "how_to_fix": "Review the flagged code carefully. If intentional, add the `ci-reviewed` label." - }] - }] - else: - status = [] + # The review-label gate renders and blocks critical findings. Keep + # this scan a fact-finder so adding ci-reviewed can rerun the gate + # without requiring the scanner itself to fail again. + status = [] with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: f.write(f"review_status={json.dumps(status)}\n") PYEOF - - name: Fail on critical findings - if: steps.scan.outputs.found == 'true' - run: | - echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the review comment for details." - exit 1 dep-bounds: name: Check PyPI dependency upper bounds @@ -279,12 +266,14 @@ jobs: timeout-minutes: 15 outputs: review_status: ${{ steps.merge.outputs.review_status }} + critical_findings: ${{ steps.merge.outputs.critical_findings }} steps: - name: Merge review statuses id: merge env: SCAN_STATUS: ${{ needs.scan.outputs.review_status }} DEP_STATUS: ${{ needs.dep-bounds.outputs.review_status }} + CRITICAL_FINDINGS: ${{ needs.scan.outputs.critical_findings }} run: | python3 - <<'PYEOF' import json, os @@ -303,4 +292,5 @@ jobs: with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: f.write(f"review_status={json.dumps(merged)}\n") + f.write("critical_findings=" + os.environ.get("CRITICAL_FINDINGS", "false") + "\n") PYEOF diff --git a/.gitignore b/.gitignore index c4fb20049ea4..5b3c3b1c1572 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,10 @@ run_datagen_sonnet.sh source-data/* run_datagen_megascience_glm4-6.sh data/* -node_modules/ +# No trailing slash: also matches node_modules SYMLINKS (worktrees often +# symlink node_modules to the main checkout; the dir-only pattern let one +# slip into a commit and break `npm ci` on CI with ENOTDIR). +node_modules browser-use/ agent-browser/ # Private keys @@ -72,6 +75,8 @@ environments/benchmarks/evals/ # Web UI build output hermes_cli/web_dist/ +# Cross-process web UI build lock (flock target, always empty) +.web_ui_build.lock apps/desktop/build/ apps/desktop/dist/ @@ -164,3 +169,4 @@ apps/desktop/demo/ # PR body is the archive. See the hermes-agent-dev skill's # pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1). infographic/ +native/fts5_cjk/*.so diff --git a/AGENTS.md b/AGENTS.md index 49596b9b41be..cb53e95eb0b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -998,7 +998,8 @@ Two shapes: Roles: - `role="leaf"` (default) — focused worker. Cannot call `delegate_task`, - `clarify`, `memory`, `send_message`, `execute_code`. + `clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code` + (programmatic tool calling). - `role="orchestrator"` — retains `delegate_task` so it can spawn its own workers. Gated by `delegation.orchestrator_enabled` (default true) and bounded by `delegation.max_spawn_depth` (default 2). diff --git a/MANIFEST.in b/MANIFEST.in index cd224167a459..4827e3470adf 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ graft skills graft optional-skills graft optional-mcps +graft hermes_cli/web_dist graft locales # Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the # PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs diff --git a/acp_adapter/server.py b/acp_adapter/server.py index d86e40651869..266d587b0743 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -456,7 +456,7 @@ class HermesACPAgent(acp.Agent): "tools": "List available tools", "context": "Show conversation context info", "reset": "Clear conversation history", - "compact": "Compress conversation context", + "compress": "Compress conversation context", "steer": "Inject guidance into the currently running agent turn", "queue": "Queue a prompt to run after the current turn finishes", "version": "Show Hermes version", @@ -485,7 +485,7 @@ class HermesACPAgent(acp.Agent): "description": "Clear conversation history", }, { - "name": "compact", + "name": "compress", "description": "Compress conversation context", }, { @@ -1756,7 +1756,7 @@ class HermesACPAgent(acp.Agent): "tools": self._cmd_tools, "context": self._cmd_context, "reset": self._cmd_reset, - "compact": self._cmd_compact, + "compress": self._cmd_compress, "steer": self._cmd_steer, "queue": self._cmd_queue, "version": self._cmd_version, @@ -1898,7 +1898,7 @@ class HermesACPAgent(acp.Agent): lines.append( f"Compression: due now (threshold ~{threshold_tokens:,}" + (f", {threshold_pct:.0f}%" if threshold_pct else "") - + "). Run /compact." + + "). Run /compress." ) else: lines.append( @@ -1913,7 +1913,7 @@ class HermesACPAgent(acp.Agent): if getattr(agent, "compression_enabled", True) is False: lines.append("Compression is disabled for this agent.") else: - lines.append("Tip: run /compact to compress manually before the threshold.") + lines.append("Tip: run /compress to compress manually before the threshold.") return "\n".join(lines) @@ -1933,7 +1933,7 @@ class HermesACPAgent(acp.Agent): return "Conversation history cleared. Agent session state reset failed; see logs." return "Conversation history cleared." - def _cmd_compact(self, args: str, state: SessionState) -> str: + def _cmd_compress(self, args: str, state: SessionState) -> str: if not state.history: return "Nothing to compress — conversation is empty." try: diff --git a/agent/agent_init.py b/agent/agent_init.py index 210743f92ac8..c268c37d505c 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -28,7 +28,7 @@ import time import uuid from datetime import datetime from typing import Any, Callable, Dict, List, Optional -from urllib.parse import urlparse, parse_qs, urlunparse +from urllib.parse import parse_qs, urlparse, urlunparse from agent.context_compressor import ContextCompressor from agent.iteration_budget import IterationBudget @@ -48,6 +48,7 @@ from agent.tool_guardrails import ( ToolGuardrailDecision, ) from hermes_cli.config import cfg_get +from hermes_cli.route_identity import normalize_route_base_url from hermes_cli.timeouts import get_provider_request_timeout from hermes_constants import get_hermes_home from utils import base_url_host_matches, is_truthy_value @@ -68,6 +69,129 @@ def _ra(): return run_agent +def _normalize_route_base_url(base_url: Any) -> str: + """Canonicalize an endpoint URL for model-route identity comparisons.""" + return normalize_route_base_url(base_url) + + +def _provider_default_routes(provider: str) -> set[str]: + """Return known exact default routes for a canonical provider id.""" + routes: set[str] = set() + try: + from hermes_cli.providers import HERMES_OVERLAYS, get_provider + + overlay = HERMES_OVERLAYS.get(provider) + provider_def = get_provider(provider) + for value in ( + getattr(overlay, "base_url_override", ""), + getattr(provider_def, "base_url", ""), + ): + route = _normalize_route_base_url(value) + if route: + routes.add(route) + except Exception: + pass + + try: + from providers import get_provider_profile + + profile = get_provider_profile(provider) + route = _normalize_route_base_url( + getattr(profile, "base_url", "") + ) + if route: + routes.add(route) + except Exception: + pass + + try: + from hermes_cli.auth import PROVIDER_REGISTRY + from hermes_cli.models import normalize_provider as normalize_model_provider + from hermes_cli.providers import normalize_provider as normalize_registry_provider + + for provider_id, config in PROVIDER_REGISTRY.items(): + canonical_id = normalize_registry_provider( + normalize_model_provider(provider_id) + ) + if canonical_id != provider: + continue + route = _normalize_route_base_url( + getattr(config, "inference_base_url", "") + ) + if route: + routes.add(route) + except Exception: + pass + + if provider == "gemini": + routes.update( + f"{route.rstrip('/')}/openai" + for route in list(routes) + ) + return routes + + +def _context_route_mismatch( + configured_base_url: Any, + active_base_url: Any, + configured_provider: Any, + active_provider: Any, + *, + already_normalized: bool = False, +) -> bool: + """Return whether a context pin's configured route differs from runtime.""" + if already_normalized: + configured_route = str(configured_base_url or "") + active_route = str(active_base_url or "") + else: + configured_route = _normalize_route_base_url(configured_base_url) + active_route = _normalize_route_base_url(active_base_url) + if configured_route: + return configured_route != active_route + + configured_provider = str(configured_provider or "").strip() + active_provider = str(active_provider or "").strip() + if not configured_provider: + return False + try: + from hermes_cli.models import normalize_provider as normalize_model_provider + + configured_provider = normalize_model_provider(configured_provider) + active_provider = normalize_model_provider(active_provider) + except Exception: + configured_provider = configured_provider.lower() + active_provider = active_provider.lower() + try: + from hermes_cli.providers import normalize_provider as normalize_registry_provider + + configured_provider = normalize_registry_provider(configured_provider) + active_provider = normalize_registry_provider(active_provider) + except Exception: + pass + + if active_route: + configured_routes = _provider_default_routes(configured_provider) + return not configured_routes or active_route not in configured_routes + return bool( + configured_provider + and active_provider + and configured_provider != active_provider + ) + + +def _normalize_custom_provider_name(value: Any) -> str: + """Mirror runtime normalization for a requested custom-provider identity.""" + return str(value or "").strip().lower().replace(" ", "-") + + +def _custom_provider_runtime_ids(value: Any) -> set[str]: + """Return raw/menu identities that runtime accepts for a configured name.""" + normalized = _normalize_custom_provider_name(value) + if not normalized: + return set() + return {normalized, f"custom:{normalized}"} + + def _build_codex_gpt5_autoraise_notice( autoraise: Dict[str, Any], context_length: Optional[int] = None ) -> str: @@ -1437,7 +1561,14 @@ def init_agent( agent._memory_nudge_interval = 10 agent._turns_since_memory = 0 agent._iters_since_skill = 0 - if not skip_memory: + # A flush/background agent may pass skip_memory=True to avoid spinning up an + # external memory *provider*, but if the caller also explicitly enables the + # "memory" toolset it still needs the built-in file-backed store — otherwise + # the memory tool dispatches with store=None and every call fails (#65429). + # So the built-in store is created unless memory is globally disabled, while + # the external-provider block below stays gated on skip_memory. + _memory_toolset_requested = "memory" in (agent.enabled_toolsets or []) + if not skip_memory or _memory_toolset_requested: try: mem_config = _agent_cfg.get("memory", {}) agent._memory_enabled = mem_config.get("memory_enabled", False) @@ -1657,6 +1788,34 @@ def init_agent( compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"} compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20)) compression_protect_last = int(_compression_cfg.get("protect_last_n", 20)) + # Cap on compression retry rounds before a turn gives up with "max + # compression attempts reached" (compression.max_attempts). Hardcoding 3 + # strands sessions that legitimately need more rounds — e.g. a restart + # history reload whose incompressible tool schemas keep the request + # estimate above the threshold even though the messages compress fine + # (the #62605 failure class). Default 3 preserves current behavior, so + # an unset key is behavior-neutral; validated >= 1, hard-capped at 10, + # and any non-int-like value falls back to 3. Booleans are rejected + # (bool subclasses int, so int(True) would silently become 1) and + # fractional floats are rejected rather than truncated — "4.7 attempts" + # is a config mistake, not a request for 4. + _raw_max_attempts = _compression_cfg.get("max_attempts", 3) + if isinstance(_raw_max_attempts, bool): + compression_max_attempts = 3 + elif isinstance(_raw_max_attempts, int): + compression_max_attempts = _raw_max_attempts + elif isinstance(_raw_max_attempts, float): + compression_max_attempts = ( + int(_raw_max_attempts) if _raw_max_attempts.is_integer() else 3 + ) + else: + try: + compression_max_attempts = int(str(_raw_max_attempts).strip()) + except (TypeError, ValueError): + compression_max_attempts = 3 + if compression_max_attempts < 1: + compression_max_attempts = 3 + compression_max_attempts = min(compression_max_attempts, 10) # protect_first_n is the number of non-system messages to protect at # the head, in addition to the system prompt (which is always # implicitly protected by the compressor). Floor at 0 — a value of @@ -1669,6 +1828,29 @@ def init_agent( compression_abort_on_summary_failure = str( _compression_cfg.get("abort_on_summary_failure", False) ).lower() in {"true", "1", "yes"} + # Per-model threshold overrides: keys are substring-matched against the + # model name (longest match wins). Empty dict = use the global threshold + # for all models (backward compatible). + _raw_model_thresholds = _compression_cfg.get("model_thresholds", {}) + if isinstance(_raw_model_thresholds, dict): + compression_model_thresholds = { + str(k): float(v) for k, v in _raw_model_thresholds.items() + if isinstance(v, (int, float)) and not isinstance(v, bool) + } + else: + compression_model_thresholds = {} + # Absolute token cap: when set, compression triggers at the lower of + # the ratio-based threshold and this absolute count. Clamped to the + # model's context length at apply-time so a cap above the window is + # a no-op (ratio-based threshold wins). + compression_threshold_tokens = _compression_cfg.get("threshold_tokens") + if compression_threshold_tokens is not None: + try: + compression_threshold_tokens = int(compression_threshold_tokens) + if compression_threshold_tokens <= 0: + compression_threshold_tokens = None + except (TypeError, ValueError): + compression_threshold_tokens = None # In-place compaction: when True, compress_context() rewrites the message # list + rebuilds the system prompt WITHOUT rotating the session id (no # parent_session_id chain, no `name #N` renumber). See #38763 and @@ -1757,8 +1939,9 @@ def init_agent( ) _config_context_length = None - # Resolve custom_providers list once for reuse below (startup - # context-length override and plugin context-engine init). + # Resolve custom_providers once before route-scoping a global context pin: + # a named custom provider may keep its base URL only in this list rather + # than repeating it under ``model``. try: from hermes_cli.config import get_compatible_custom_providers _custom_providers = get_compatible_custom_providers(_agent_cfg) @@ -1767,6 +1950,163 @@ def init_agent( if not isinstance(_custom_providers, list): _custom_providers = [] + # ``model.context_length`` describes the configured default model. A + # process launched directly with ``--model`` / ``-m`` has already replaced + # ``agent.model`` before this initializer loads config, so carrying the + # default model's explicit window into that different runtime is stale. The + # live switch/fallback paths already clear this override; keep direct-start + # overrides consistent with them and let provider metadata resolve the + # active model's window instead. + if _config_context_length is not None and isinstance(_model_cfg, dict): + _configured_default_model = str(_model_cfg.get("default") or "").strip() + _configured_default_runtime_model = _configured_default_model + _active_runtime_model = agent.model + if _configured_default_model: + try: + from hermes_cli.model_normalize import normalize_model_for_provider + + _configured_default_runtime_model = normalize_model_for_provider( + _configured_default_model, agent.provider + ) + _active_runtime_model = normalize_model_for_provider( + agent.model, agent.provider + ) + except Exception: + pass + _configured_provider = str(_model_cfg.get("provider") or "").strip() + _configured_base_url = _normalize_route_base_url( + _model_cfg.get("base_url") + ) + _configured_provider_norm = _normalize_custom_provider_name( + _configured_provider + ) + _custom_provider_candidate = bool(_configured_provider_norm) + _runtime_first_provider_ids = { + "auto", + "moa", + "vertex", + "google-vertex", + "vertex-ai", + "gcp-vertex", + "vertexai", + } + if _configured_provider_norm in _runtime_first_provider_ids: + _custom_provider_candidate = False + elif ( + _custom_provider_candidate + and _configured_provider_norm != "custom" + and not _configured_provider_norm.startswith("custom:") + ): + try: + from hermes_cli.auth import resolve_provider as resolve_auth_provider + + _resolved_auth_provider = resolve_auth_provider( + _configured_provider_norm + ) + _custom_provider_candidate = ( + str(_resolved_auth_provider or "").strip().lower() + != _configured_provider_norm + ) + except Exception: + pass + if not _configured_base_url and _custom_provider_candidate: + _configured_custom_provider = _normalize_custom_provider_name( + _configured_provider + ) + _user_providers = _agent_cfg.get("providers") + _disabled_custom_provider_ids: set[str] = set() + if isinstance(_user_providers, dict): + from hermes_cli.config import is_provider_enabled + + for _provider_key, _provider_entry in _user_providers.items(): + if not isinstance(_provider_entry, dict): + continue + _entry_name = str( + _provider_entry.get("name") or "" + ).strip() + _entry_provider_ids = _custom_provider_runtime_ids( + _provider_key + ) | _custom_provider_runtime_ids(_entry_name) + if not is_provider_enabled(_provider_entry): + _disabled_custom_provider_ids.update( + provider_id + for provider_id in _entry_provider_ids + if provider_id + ) + continue + if _configured_custom_provider not in _entry_provider_ids: + continue + _configured_base_url = _normalize_route_base_url( + _provider_entry.get("api") + or _provider_entry.get("url") + or _provider_entry.get("base_url") + ) + if _configured_base_url: + break + if not _configured_base_url: + for _provider_entry in _custom_providers: + if not isinstance(_provider_entry, dict): + continue + _entry_name = str( + _provider_entry.get("name") or "" + ).strip() + _entry_provider_key = str( + _provider_entry.get("provider_key") or "" + ).strip().lower() + _entry_provider_ids = _custom_provider_runtime_ids( + _entry_name + ) | _custom_provider_runtime_ids(_entry_provider_key) + if ( + _entry_provider_key + and _custom_provider_runtime_ids(_entry_provider_key) + & _disabled_custom_provider_ids + ): + continue + if _configured_custom_provider not in _entry_provider_ids: + continue + _configured_base_url = _normalize_route_base_url( + _provider_entry.get("base_url") + ) + if _configured_base_url: + break + _active_route_url = str(agent.base_url or "") + _requested_route_url = str(base_url or "") + if "?" in _requested_route_url.split("#", 1)[0]: + try: + _requested_parts = urlparse(_requested_route_url) + _requested_without_query = urlunparse( + _requested_parts._replace(query="") + ) + if _normalize_route_base_url( + _requested_without_query + ) == _normalize_route_base_url(_active_route_url): + _active_route_url = _requested_route_url + except (TypeError, ValueError): + pass + _active_base_url = _normalize_route_base_url(_active_route_url) + _route_mismatch = _context_route_mismatch( + _configured_base_url, + _active_base_url, + _configured_provider, + agent.provider, + already_normalized=True, + ) + _model_mismatch = bool( + _configured_default_runtime_model + and _configured_default_runtime_model != _active_runtime_model + ) + if _model_mismatch or _route_mismatch: + _ra().logger.debug( + "Ignoring model.context_length=%s for startup runtime %s at %s " + "(configured default is %s at %s)", + _config_context_length, + agent.model, + _active_base_url or agent.provider, + _configured_default_model, + _configured_base_url or _model_cfg.get("provider"), + ) + _config_context_length = None + # Store for reuse by _check_compression_model_feasibility (auxiliary # compression model context-length detection needs the same list). agent._custom_providers = _custom_providers @@ -1789,11 +2129,11 @@ def init_agent( # Surface a clear warning if the user set a context_length but it # wasn't a valid positive int — the helper silently skips those. if _config_context_length is None: - _target = agent.base_url.rstrip("/") if agent.base_url else "" + _target = _normalize_route_base_url(agent.base_url) for _cp_entry in _custom_providers: if not isinstance(_cp_entry, dict): continue - _cp_url = (_cp_entry.get("base_url") or "").rstrip("/") + _cp_url = _normalize_route_base_url(_cp_entry.get("base_url")) if _target and _cp_url == _target: _cp_models = _cp_entry.get("models", {}) if isinstance(_cp_models, dict): @@ -1906,6 +2246,16 @@ def init_agent( provider=agent.provider, custom_providers=_custom_providers, ) + # Per-model threshold overrides are part of the explicit + # context-engine contract: assign them BEFORE the initial + # update_model() call so the first resolution (which derives + # threshold_percent/threshold_tokens for the initial model) already + # sees the overrides. Assigning after update_model() left the initial + # model on the engine's global threshold until the first /model + # switch. Engines that override update_model() own their own policy + # and may ignore the attribute. + if compression_model_thresholds: + agent.context_compressor.model_thresholds = compression_model_thresholds agent.context_compressor.update_model( model=agent.model, context_length=_plugin_ctx_len, @@ -1932,6 +2282,8 @@ def init_agent( api_mode=agent.api_mode, abort_on_summary_failure=compression_abort_on_summary_failure, max_tokens=agent.max_tokens, + model_thresholds=compression_model_thresholds, + threshold_tokens_cap=compression_threshold_tokens, ) _bind_session_state = getattr(agent.context_compressor, "bind_session_state", None) if callable(_bind_session_state): @@ -1942,6 +2294,7 @@ def init_agent( agent.compression_enabled = compression_enabled agent.compression_in_place = compression_in_place agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction + agent.max_compression_attempts = compression_max_attempts # Reject models whose context window is below the minimum required # for reliable tool-calling workflows (64K tokens). @@ -2142,7 +2495,11 @@ def init_agent( _active_threshold_pct = getattr( agent.context_compressor, "threshold_percent", compression_threshold ) - print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})") + _cap_note = "" + _cap = getattr(agent.context_compressor, "threshold_tokens_cap", None) + if _cap and _cap > 0: + _cap_note = f" (capped at {_cap:,} tokens)" + print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,}{_cap_note})") else: print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)") # Notice with the exact opt-back-out command. Printed inline at startup diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index a7f13ba2d777..fd7596e7e3bf 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -2412,6 +2412,24 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None: ] +def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None: + """Anthropic requires messages[0] to have role=user. + + After a second context compaction on the auto path the summary can be + emitted as role=assistant with nothing in front of it (the system prompt + lives outside messages[] or is extracted into the separate ``system`` + param), so messages[0] ends up assistant and the Messages API rejects + the request with HTTP 400 — often masked by a misleading + "tool_use ids were found without tool_result blocks" error (#52160). + + Mirror the Bedrock Converse adapter, which unconditionally prepends a + minimal user turn when the first message is not user + (convert_messages_to_converse). + """ + if result and result[0].get("role") != "user": + result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]}) + + def convert_messages_to_anthropic( messages: List[Dict], base_url: str | None = None, @@ -2470,6 +2488,7 @@ def convert_messages_to_anthropic( _strip_orphaned_tool_blocks(result) result = _merge_consecutive_roles(result) + _ensure_leading_user_turn(result) _manage_thinking_signatures(result, base_url, model) _evict_old_screenshots(result) diff --git a/agent/battery.py b/agent/battery.py new file mode 100644 index 000000000000..a1c0f32fa4d1 --- /dev/null +++ b/agent/battery.py @@ -0,0 +1,131 @@ +"""System-battery read-out for the CLI/TUI status bar. + +Reads the host battery through ``psutil`` (already a Hermes dependency) and +exposes a compact, colour-coded label. Everything degrades to "unavailable" +when there is no battery (desktops, servers, VMs) or when the read fails, so +callers can render the result unconditionally and simply show nothing. + +The status bar repaints often (every keystroke and on a ~1s idle refresh), so +:func:`read_battery` memoises the last reading for a few seconds instead of +hitting ``psutil`` on every frame. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class BatteryStatus: + """A single battery reading. + + ``available`` is False on machines without a battery (or when the read + failed). ``percent`` is clamped to 0-100. ``plugged`` is True when on AC + power, False on battery, and None when the platform can't tell. + """ + + available: bool + percent: Optional[int] = None + plugged: Optional[bool] = None + + @property + def charging(self) -> bool: + return bool(self.plugged) + + +UNAVAILABLE = BatteryStatus(available=False) + +# Colour buckets, mirroring the status-bar context styles but inverted (a full +# battery is "good", an empty one is "critical"). +CATEGORY_GOOD = "good" +CATEGORY_WARN = "warn" +CATEGORY_BAD = "bad" +CATEGORY_CRITICAL = "critical" +CATEGORY_DIM = "dim" + +_CACHE_TTL_SECONDS = 8.0 +_cache: Optional[tuple[float, BatteryStatus]] = None + + +def _read_battery_uncached() -> BatteryStatus: + try: + import psutil + except Exception: + return UNAVAILABLE + + # ``sensors_battery`` is missing on some platforms/builds of psutil. + reader = getattr(psutil, "sensors_battery", None) + if reader is None: + return UNAVAILABLE + + try: + batt = reader() + except Exception: + return UNAVAILABLE + + if batt is None: + return UNAVAILABLE + + percent: Optional[int] = None + raw_percent = getattr(batt, "percent", None) + if raw_percent is not None: + try: + percent = max(0, min(100, int(round(float(raw_percent))))) + except (TypeError, ValueError): + percent = None + + plugged = getattr(batt, "power_plugged", None) + if plugged is not None: + plugged = bool(plugged) + + return BatteryStatus(available=True, percent=percent, plugged=plugged) + + +def read_battery(use_cache: bool = True) -> BatteryStatus: + """Return the current battery status (cached for a few seconds).""" + global _cache + if use_cache and _cache is not None: + ts, cached = _cache + if time.monotonic() - ts < _CACHE_TTL_SECONDS: + return cached + + status = _read_battery_uncached() + _cache = (time.monotonic(), status) + return status + + +def clear_cache() -> None: + """Drop the memoised reading (used by tests).""" + global _cache + _cache = None + + +def battery_category(status: BatteryStatus) -> str: + """Bucket a reading into a colour category: good/warn/bad/critical/dim.""" + if not status.available or status.percent is None: + return CATEGORY_DIM + # On AC power the level isn't a concern — always read as healthy. + if status.charging: + return CATEGORY_GOOD + pct = status.percent + if pct <= 10: + return CATEGORY_CRITICAL + if pct <= 20: + return CATEGORY_BAD + if pct <= 50: + return CATEGORY_WARN + return CATEGORY_GOOD + + +def battery_glyph(status: BatteryStatus) -> str: + """Return the leading glyph: a bolt while charging, else a battery.""" + return "\u26a1" if status.charging else "\U0001f50b" # ⚡ / 🔋 + + +def format_battery(status: BatteryStatus) -> str: + """Return a compact label like ``🔋 82%`` / ``⚡ 82%`` (empty if N/A).""" + if not status.available or status.percent is None: + return "" + return f"{battery_glyph(status)} {status.percent}%" diff --git a/agent/coding_context.py b/agent/coding_context.py index db38ab3daa8a..4a0cb8410308 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -55,13 +55,12 @@ import json import logging import os import re -import subprocess import tempfile from dataclasses import dataclass from pathlib import Path from typing import Any, Optional -from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags +from hermes_cli._subprocess_compat import bounded_git_probe logger = logging.getLogger("hermes.coding_context") @@ -689,18 +688,14 @@ def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]: def _git(cwd: Path, *args: str) -> str: - _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {} - try: - out = subprocess.run( - ["git", "-C", str(cwd), *args], - capture_output=True, - text=True, - timeout=_GIT_TIMEOUT, - **_popen_kwargs, - ) - except (OSError, subprocess.SubprocessError): - return "" - return out.stdout.strip() if out.returncode == 0 else "" + """``git -C `` → stripped stdout, or ``""`` on any failure. + + Uses the shared :func:`bounded_git_probe` so the post-kill cleanup is bounded + on Windows — a plain ``subprocess.run(timeout=...)`` here deadlocked the agent + turn inside ``build_coding_workspace_block`` when a killed git left a suspended + descendant holding the pipe handles (issue #66037). + """ + return bounded_git_probe(["git", "-C", str(cwd), *args], timeout=_GIT_TIMEOUT) def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]: diff --git a/agent/context_compressor.py b/agent/context_compressor.py index a16ec913461d..9597d9fbc2a3 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -22,6 +22,7 @@ import logging import sqlite3 import re import time +import uuid from typing import Any, Dict, List, Optional from agent.auxiliary_client import call_llm, _is_connection_error, aux_interrupt_protection @@ -31,13 +32,23 @@ from agent.model_metadata import ( MINIMUM_CONTEXT_LENGTH, get_model_context_length, estimate_messages_tokens_rough, + estimate_tokens_rough, ) from agent.redact import redact_sensitive_text from agent.turn_context import drop_stale_api_content +from tools.todo_tool import TODO_INJECTION_HEADER logger = logging.getLogger(__name__) +def _safe_int(value: Any) -> int | None: + """Best-effort integer coercion for telemetry fields.""" + try: + return int(value) + except (TypeError, ValueError): + return None + + _SUMMARY_PERMANENT_QUOTA_MARKERS: tuple[str, ...] = ( "insufficient_quota", "quota exceeded", @@ -130,8 +141,20 @@ LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:" # poisoning every subsequent request in the session — a bare key like # "is_compressed_summary" would reach the wire and trip exactly that. COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary" +COMPRESSED_SUMMARY_HAS_USER_TURN_KEY = "_compressed_summary_has_user_turn" _DB_PERSISTED_MARKER = "_db_persisted" +_NO_USER_TASK_SENTINEL = "None. This session contains no user-authored turns." +COMPRESSION_CONTINUATION_USER_CONTENT = ( + "Continue from the compressed conversation context above. " + "This marker exists because no human user turn was available." +) +_LEGACY_COMPRESSION_CONTINUATION_USER_CONTENT = ( + "Continue from the compressed conversation context above. " + "This marker exists because the compacted transcript contained " + "no preserved user turn." +) + def _fresh_compaction_message_copy(msg: Dict[str, Any]) -> Dict[str, Any]: """Copy a message for compaction assembly without persistence markers. @@ -328,6 +351,27 @@ _HISTORICAL_TASK_SECTION_RE = re.compile( ) +def _redact_compaction_text(text: Any) -> str: + """Redact text that crosses a compaction summary boundary. + + Compaction summaries persist across sessions and are re-injected into + every subsequent summarizer prompt, so this boundary uses strict mode: + + - ``force=True`` — deliberately overrides ``security.redact_secrets: + false``. That opt-out targets *live tool output* (e.g. working on the + redactor itself); a summary is a persistence boundary where a leaked + credential keeps re-entering prompts indefinitely. + - ``redact_url_credentials=True`` — OAuth callback codes, magic-link + tokens, and URL userinfo never need to survive summarization the way + they must survive live navigation flows. + """ + return redact_sensitive_text( + text or "", + force=True, + redact_url_credentials=True, + ) + + def _dedupe_append(items: list[str], value: str, *, limit: int) -> None: value = value.strip() if value and value not in items and len(items) < limit: @@ -436,11 +480,15 @@ def _estimate_msg_budget_tokens(msg: dict) -> int: compaction re-fires continuously (#55572). Accounting-only: replay fields are never mutated or pruned here. """ - content_len = _content_length_for_budget(msg.get("content") or "") - tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/key overhead + content = msg.get("content") or "" + if isinstance(content, str): + tokens = estimate_tokens_rough(content) + 10 # +10 for role/key overhead + else: + content_len = _content_length_for_budget(content) + tokens = content_len // _CHARS_PER_TOKEN + 10 for tc in msg.get("tool_calls") or []: if isinstance(tc, dict): - tokens += len(str(tc)) // _CHARS_PER_TOKEN + tokens += estimate_tokens_rough(str(tc)) for key in _REPLAY_BUDGET_KEYS: tokens += _serialized_length_for_budget(msg.get(key)) // _CHARS_PER_TOKEN return tokens @@ -856,6 +904,32 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten return f"[{tool_name}]{first_arg} ({content_len:,} chars result)" +def resolve_model_threshold( + model: str, + model_thresholds: dict[str, float] | None, + default: float, +) -> float: + """Resolve the effective compression threshold for a given model. + + ``model_thresholds`` maps substring keys to override fractions. The + longest matching key wins (so ``glm-5.2-1M`` beats ``glm-5.2`` when the + model is ``glm-5.2-1M``). When no override matches, or when + ``model_thresholds`` is empty/None, ``default`` is returned unchanged. + + This is a module-level helper so plugin context engines (e.g. LCM) can + import and reuse the same resolution logic as the built-in compressor. + """ + if not model_thresholds or not model: + return default + best_key = "" + for key in model_thresholds: + if key in model and len(key) > len(best_key): + best_key = key + if best_key: + return float(model_thresholds[best_key]) + return default + + class ContextCompressor(ContextEngine): """Default context engine — compresses conversation context via lossy summarization. @@ -877,6 +951,7 @@ class ContextCompressor(ContextEngine): self._context_probed = False self._context_probe_persistable = False self._previous_summary = None + self._summary_has_user_turn = None self._last_summary_error = None self._consecutive_timeout_failures = 0 self._last_summary_dropped_count = 0 @@ -896,6 +971,102 @@ class ContextCompressor(ContextEngine): self.last_compression_rough_tokens = 0 self.last_rough_tokens_when_real_prompt_fit = 0 self.awaiting_real_usage_after_compression = False + self._last_compression_telemetry = None + self._active_compression_telemetry = None + self._compression_telemetry_seed = None + + def _begin_compression_telemetry( + self, + *, + current_tokens: int | None, + attempt_id: str | None = None, + session_id: str | None = None, + trigger_source: str | None = None, + ) -> Dict[str, Any]: + """Initialize content-free per-attempt compression telemetry.""" + seed = getattr(self, "_compression_telemetry_seed", None) + if isinstance(seed, dict): + attempt_id = attempt_id or seed.get("attempt_id") + session_id = session_id or seed.get("session_id") + trigger_source = trigger_source or seed.get("trigger_source") + telemetry: Dict[str, Any] = { + "event": "compression_attempt", + "attempt_id": attempt_id or uuid.uuid4().hex, + "session_id": session_id or "", + "trigger_source": trigger_source or "unknown", + "main_provider": self.provider or "", + "main_model": self.model or "", + "main_context_limit": _safe_int(self.context_length), + "current_estimated_tokens": _safe_int(current_tokens), + "effective_threshold": _safe_int(self.threshold_tokens), + "protected_head_tokens": None, + "protected_tail_tokens": None, + "middle_window_tokens": None, + "aux_prompt_tokens": None, + "aux_output_reservation": None, + "aux_provider": "", + "aux_model": "", + "effective_aux_context": None, + "fit_margin": None, + "chunking": False, + "chunk_count": 0, + "total_duration_ms": None, + "aux_call_duration_ms": None, + "fallback_used": False, + "commit_status": "unknown", + "split_status": "unknown", + "failure_class": None, + } + self._active_compression_telemetry = telemetry + self._last_compression_telemetry = telemetry + return telemetry + + def _record_compression_regions( + self, + *, + head_messages: List[Dict[str, Any]], + middle_messages: List[Dict[str, Any]], + tail_messages: List[Dict[str, Any]], + ) -> None: + telemetry = getattr(self, "_active_compression_telemetry", None) + if not isinstance(telemetry, dict): + return + telemetry["protected_head_tokens"] = estimate_messages_tokens_rough(head_messages) + telemetry["middle_window_tokens"] = estimate_messages_tokens_rough(middle_messages) + telemetry["protected_tail_tokens"] = estimate_messages_tokens_rough(tail_messages) + + def _record_aux_compression_call( + self, + *, + prompt_messages: List[Dict[str, Any]], + max_tokens: int | None, + duration_ms: int, + aux_provider: str | None = None, + aux_model: str | None = None, + effective_aux_context: int | None = None, + ) -> None: + telemetry = getattr(self, "_active_compression_telemetry", None) + if not isinstance(telemetry, dict): + return + telemetry["aux_prompt_tokens"] = estimate_messages_tokens_rough(prompt_messages) + telemetry["aux_output_reservation"] = _safe_int(max_tokens) + if aux_provider: + telemetry["aux_provider"] = aux_provider + if aux_model: + telemetry["aux_model"] = aux_model + if effective_aux_context is not None: + telemetry["effective_aux_context"] = _safe_int(effective_aux_context) + if ( + telemetry["effective_aux_context"] is not None + and telemetry["aux_prompt_tokens"] is not None + ): + telemetry["fit_margin"] = ( + telemetry["effective_aux_context"] + - telemetry["aux_prompt_tokens"] + - (telemetry["aux_output_reservation"] or 0) + ) + previous = telemetry.get("aux_call_duration_ms") or 0 + telemetry["aux_call_duration_ms"] = previous + max(0, int(duration_ms)) def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None: """Clear all per-session compaction state at a real session boundary. @@ -917,6 +1088,7 @@ class ContextCompressor(ContextEngine): surface the moment the owning session ends. """ self._previous_summary = None + self._summary_has_user_turn = None self._last_summary_error = None self._consecutive_timeout_failures = 0 self._last_summary_dropped_count = 0 @@ -937,6 +1109,9 @@ class ContextCompressor(ContextEngine): self.last_compression_rough_tokens = 0 self.last_rough_tokens_when_real_prompt_fit = 0 self.awaiting_real_usage_after_compression = False + self._last_compression_telemetry = None + self._active_compression_telemetry = None + self._compression_telemetry_seed = None def bind_session_state(self, session_db: Any = None, session_id: str = "") -> None: """Bind the current session row so durable cooldowns can round-trip.""" @@ -1163,17 +1338,19 @@ class ContextCompressor(ContextEngine): self.provider = provider self.api_mode = api_mode self.context_length = context_length - # Re-apply the small-context threshold floor for the NEW window, - # starting from the originally-configured percent (not the possibly - # floored live value) so a small -> large switch drops back to the - # configured threshold and a large -> small switch gains the floor. - # Guard with getattr: compressors unpickled/constructed before this - # attribute existed fall back to the live value. - _configured_pct = getattr( - self, "_configured_threshold_percent", self.threshold_percent, + # Re-resolve per-model threshold for the NEW model, then re-apply the + # small-context threshold floor. Starting from _config_threshold_percent + # (the raw config value) so a switch from a model with an override to + # one without correctly falls back to the global threshold. + _config_pct = getattr( + self, "_config_threshold_percent", self.threshold_percent, ) + _new_base = resolve_model_threshold( + model, self.model_thresholds, _config_pct, + ) + self._base_threshold_percent = _new_base self.threshold_percent = self._effective_threshold_percent( - context_length, _configured_pct, + context_length, _new_base, ) # max_tokens=None here means "caller didn't specify" → keep the existing # output reservation. A switch that genuinely changes the output budget @@ -1183,6 +1360,11 @@ class ContextCompressor(ContextEngine): self.threshold_tokens = self._compute_threshold_tokens( context_length, self.threshold_percent, self.max_tokens, ) + # Re-apply the absolute token cap so it survives model switches + # and fallback activations. The cap is a first-class config value + # stored on the compressor instance, not a one-time post-construction + # patch — this is why update_model() must re-apply it. + self._apply_threshold_tokens_cap() # Recalculate token budgets for the new context length so the # compressor stays calibrated after a model switch (e.g. 200K → 32K). target_tokens = int(self.threshold_tokens * self.summary_target_ratio) @@ -1245,6 +1427,36 @@ class ContextCompressor(ContextEngine): return None return ivalue if ivalue > 0 else None + @staticmethod + def _coerce_threshold_tokens_cap(value: Any) -> int | None: + """Normalize a threshold_tokens cap to a positive int or None. + + None means "no absolute cap — use the ratio-based threshold only". + Non-numeric or non-positive values are treated as None so a bad + config value never silently caps the threshold at zero. + """ + if value is None: + return None + try: + ivalue = int(value) + except (TypeError, ValueError): + return None + return ivalue if ivalue > 0 else None + + def _apply_threshold_tokens_cap(self) -> None: + """Apply the absolute token cap if configured. + + After ``threshold_tokens`` is (re)computed from the ratio-based + percent, clamp it to the cap so compression never fires later + than the user's preferred absolute token count. The cap itself + is clamped to the current context length so a cap larger than + the model's window is a no-op (the ratio-based threshold wins). + """ + if self.threshold_tokens_cap is not None and self.threshold_tokens_cap > 0: + _effective_cap = min(self.threshold_tokens_cap, self.context_length) + if _effective_cap < self.threshold_tokens: + self.threshold_tokens = _effective_cap + @staticmethod def _effective_threshold_percent( context_length: int, threshold_percent: float, @@ -1319,13 +1531,35 @@ class ContextCompressor(ContextEngine): api_mode: str = "", abort_on_summary_failure: bool = False, max_tokens: int | None = None, + model_thresholds: dict[str, float] | None = None, + threshold_tokens_cap: Any = None, ): self.model = model self.base_url = base_url self.api_key = api_key self.provider = provider self.api_mode = api_mode - self.threshold_percent = threshold_percent + # Per-model threshold overrides (longest substring match wins). + # Stored as a plain dict; resolved in _resolve_threshold(), then the + # small-context floor is applied on top. + self.model_thresholds = model_thresholds or {} + # _config_threshold_percent is the raw config value (before per-model + # override or small-context floor). Used as the fallback when switching + # to a model with no matching override. + self._config_threshold_percent = threshold_percent + # Resolve per-model override first, then apply the small-context floor. + self._base_threshold_percent = resolve_model_threshold( + model, self.model_thresholds, threshold_percent, + ) + self.threshold_percent = self._base_threshold_percent + # Absolute token cap from config (compression.threshold_tokens). When + # set, the effective trigger point is min(ratio-based threshold, cap) + # so compression never fires later than the user's preferred token + # count regardless of which model is active. Applied in __init__ and + # re-applied in update_model() so it survives model switches/fallbacks. + self.threshold_tokens_cap = self._coerce_threshold_tokens_cap( + threshold_tokens_cap, + ) self.protect_first_n = protect_first_n self.protect_last_n = protect_last_n self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80)) @@ -1355,9 +1589,11 @@ class ContextCompressor(ContextEngine): # resolved and BEFORE threshold_tokens is derived. The pre-floor # value is kept so update_model() can re-derive for a new window # (switching small -> large must drop back to the configured value). + # Note: _base_threshold_percent already has the per-model override + # applied, so the floor stacks on top of any model-specific threshold. self._configured_threshold_percent = self.threshold_percent self.threshold_percent = self._effective_threshold_percent( - self.context_length, self.threshold_percent, + self.context_length, self._base_threshold_percent, ) threshold_percent = self.threshold_percent # Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if @@ -1369,6 +1605,9 @@ class ContextCompressor(ContextEngine): self.threshold_tokens = self._compute_threshold_tokens( self.context_length, threshold_percent, self.max_tokens, ) + # Apply absolute token cap (compression.threshold_tokens) — takes + # the lower of the ratio-based threshold and the cap. + self._apply_threshold_tokens_cap() self.compression_count = 0 # Derive token budgets: ratio is relative to the threshold, not total context @@ -1403,6 +1642,10 @@ class ContextCompressor(ContextEngine): # Stores the previous compaction summary for iterative updates self._previous_summary: Optional[str] = None + # Provenance for the rolling summary. A compaction handoff can carry + # role="user" solely to satisfy provider alternation, so role alone + # cannot prove that a human-authored turn ever existed. + self._summary_has_user_turn: Optional[bool] = None # Anti-thrashing: track whether last compression was effective self._last_compression_savings_pct: float = 100.0 self._ineffective_compression_count: int = 0 @@ -1455,6 +1698,9 @@ class ContextCompressor(ContextEngine): # succeeded. Silent recovery would hide the broken config. self._last_aux_model_failure_error: Optional[str] = None self._last_aux_model_failure_model: Optional[str] = None + self._last_compression_telemetry: Optional[Dict[str, Any]] = None + self._active_compression_telemetry: Optional[Dict[str, Any]] = None + self._compression_telemetry_seed: Optional[Dict[str, Any]] = None def update_from_response(self, usage: Dict[str, Any]): """Update tracked token usage from API response.""" @@ -1867,7 +2113,7 @@ class ContextCompressor(ContextEngine): elif isinstance(part, str): text_parts.append(part) content = "\n".join(text_parts) - content = redact_sensitive_text(content or "") + content = _redact_compaction_text(content or "") content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content) # Strip inline reasoning blocks (, , etc.) from # assistant content before it reaches the summarizer. Reasoning @@ -1900,7 +2146,7 @@ class ContextCompressor(ContextEngine): if isinstance(tc, dict): fn = tc.get("function", {}) name = fn.get("name", "?") - args = redact_sensitive_text(fn.get("arguments", "")) + args = _redact_compaction_text(fn.get("arguments", "")) # Truncate long arguments but keep enough for context if len(args) > self._TOOL_ARGS_MAX: args = args[:self._TOOL_ARGS_HEAD] + "..." @@ -1943,7 +2189,7 @@ class ContextCompressor(ContextEngine): last_dropped_turns: list[str] = [] def _compact_fallback_turn(value: Any) -> str: - text = redact_sensitive_text(_content_text_for_contains(value)) + text = _redact_compaction_text(_content_text_for_contains(value)) text = re.sub(r"\bgh[pousr]_[A-Za-z0-9_]{8,}\b", "[REDACTED]", text) text = re.sub(r"\s+", " ", text).strip() if len(text) > _FALLBACK_TURN_MAX_CHARS: @@ -1975,7 +2221,7 @@ class ContextCompressor(ContextEngine): if msg.get("role") == "assistant" and msg.get("tool_calls"): for tc in msg.get("tool_calls") or []: name, raw_args = _extract_tool_call_name_and_args(tc) - args = redact_sensitive_text(raw_args) + args = _redact_compaction_text(raw_args) call_id = _extract_tool_call_id(tc) if call_id: call_id_to_tool[call_id] = (name, args) @@ -1990,6 +2236,9 @@ class ContextCompressor(ContextEngine): role = msg.get("role", "unknown") text = _compact_fallback_turn(msg.get("content")) _collect_path_mentions(text, relevant_files) + synthetic_user = ( + role == "user" and self._is_synthetic_compression_user_turn(msg) + ) turn_text = text turn_tool_names: list[str] = [] @@ -2000,12 +2249,13 @@ class ContextCompressor(ContextEngine): if turn_tool_names: prefix = "tool calls: " + ", ".join(turn_tool_names[:6]) turn_text = f"{prefix}; {turn_text}" if turn_text else prefix - _remember_dropped_turn(str(role).upper(), turn_text) + turn_label = "INTERNAL CONTEXT" if synthetic_user else str(role).upper() + _remember_dropped_turn(turn_label, turn_text) if len(text) > 600: text = text[:420].rstrip() + " ... " + text[-160:].lstrip() - if role == "user" and text: + if role == "user" and text and not synthetic_user: user_asks.append(text) elif role == "assistant": tool_names: list[str] = [] @@ -2051,7 +2301,7 @@ class ContextCompressor(ContextEngine): active_task = ( f"User asked: {user_asks[-1]!r}" if user_asks - else "Unknown from deterministic fallback." + else _NO_USER_TASK_SENTINEL ) previous_summary_note = "" if self._previous_summary: @@ -2109,7 +2359,7 @@ Continue from the most recent unfulfilled user ask and protected tail messages. ## Critical Context Summary generation was unavailable, so this is a best-effort deterministic fallback for {len(turns_to_summarize)} compacted message(s).{reason_text}""" - summary = self._with_summary_prefix(redact_sensitive_text(body.strip())) + summary = self._with_summary_prefix(_redact_compaction_text(body.strip())) if len(summary) > _FALLBACK_SUMMARY_MAX_CHARS: summary = summary[: _FALLBACK_SUMMARY_MAX_CHARS - 42].rstrip() + "\n...[fallback summary truncated]" return summary @@ -2138,6 +2388,10 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb _err_text = _err_text[:217].rstrip() + "..." self._last_aux_model_failure_error = _err_text self._last_aux_model_failure_model = self.summary_model + telemetry = getattr(self, "_active_compression_telemetry", None) + if isinstance(telemetry, dict): + telemetry["fallback_used"] = True + telemetry["failure_class"] = telemetry.get("failure_class") or "aux_model_fallback" self.summary_model = "" # empty = use main model self._clear_compression_failure_cooldown() # no cooldown — retry immediately @@ -2172,6 +2426,15 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb ) return None + # Strict-redact prompt inputs that bypass _serialize_for_summary: + # a manual `/compress ` string, and a previous summary that + # may predate compaction redaction (resumed from a persisted + # handoff message written before this boundary existed). + if focus_topic: + focus_topic = _redact_compaction_text(focus_topic) + if self._previous_summary: + self._previous_summary = _redact_compaction_text(self._previous_summary) + summary_budget = self._compute_summary_budget(turns_to_summarize) content_to_summarize = self._serialize_for_summary(turns_to_summarize) _sanitized_memory_context = sanitize_memory_context(memory_context) @@ -2194,6 +2457,9 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb if _sanitized_memory_context else "" ) + has_user_turn = getattr(self, "_summary_has_user_turn", None) + if has_user_turn is None: + has_user_turn = self._transcript_has_real_user_turn(turns_to_summarize) # Current date for temporal anchoring (see ## Temporal Anchoring below). # Date-only granularity matches system_prompt.py:337 (PR #20451) and the @@ -2211,18 +2477,86 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb # Preamble shared by both first-compaction and iterative-update prompts. # Keep the wording deliberately plain: Azure/OpenAI-compatible content # filters have flagged stronger "injection" / "do not respond" framing. + if has_user_turn: + _language_and_provenance_rule = ( + "Write the summary in the same language the user was using in the " + "conversation — do not translate or switch to English. " + ) + _historical_task_instructions = """[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled +input verbatim — the exact words they used. This includes: +- Explicit task assignments ("") +- Questions awaiting an answer ("") +- Decisions awaiting input ("