Merge remote-tracking branch 'origin/main' into ethie/pm-clean

# Conflicts:
#	.gitignore
#	Dockerfile
#	agent/onboarding.py
#	apps/desktop/electron/main.ts
#	apps/desktop/electron/pool-stop.ts
#	apps/desktop/src/components/model-picker.test.tsx
#	apps/desktop/src/store/updates.ts
#	apps/desktop/vite.config.ts
#	datagen-config-examples/run_browser_tasks.sh
#	docs/rca-ssl-cacert-post-git-pull.md
#	gateway/run.py
#	hermes_cli/backup.py
#	hermes_cli/credential_lifecycle.py
#	hermes_cli/dashboard_procs.py
#	hermes_cli/doctor_state.py
#	hermes_cli/env_loader.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/psutil_android.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_cmd_windows.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_server_config.py
#	hermes_cli/web_server_cron.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/holographic/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/mem0/__init__.py
#	plugins/platforms/google_chat/oauth.py
#	plugins/platforms/photon/adapter.py
#	scripts/ci/list_os_marked_tests.py
#	scripts/run_tests.sh
#	tests/agent/test_compression_stall_fallback.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/gateway/test_google_chat_oauth_dependencies.py
#	tests/hermes_cli/conftest.py
#	tests/hermes_cli/test_cli_init.py
#	tests/hermes_cli/test_gateway_migrate_multiplex.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_relaunch.py
#	tests/hermes_cli/test_update_check.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_worktree_gc.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_autostash_conflict_recovery.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_commit_pin_rollback.py
#	tests/scripts/install/test_install_diverged_update.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_macos_launcher.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_ps1_ascii_only.py
#	tests/scripts/install/test_install_ps1_browser_install.py
#	tests/scripts/install/test_install_ps1_managed_node_swap.py
#	tests/scripts/install/test_install_ps1_native_stderr_eap.py
#	tests/scripts/install/test_install_ps1_node_path_for_npm.py
#	tests/scripts/install/test_install_ps1_python_fallback_venv.py
#	tests/scripts/install/test_install_ps1_resolver_strictmode.py
#	tests/scripts/install/test_install_ps1_uv_install_fallback.py
#	tests/scripts/install/test_install_ps1_uv_powershell_host.py
#	tests/scripts/install/test_install_ps1_venv_process_tree.py
#	tests/scripts/install/test_install_ps1_venv_recreate_safety.py
#	tests/scripts/install/test_install_ps1_venv_rename_abort.py
#	tests/scripts/install/test_install_ps1_venv_transaction_boundary.py
#	tests/scripts/install/test_install_ps1_web_server_syntax_probe.py
#	tests/scripts/install/test_install_scripts_computer_use.py
#	tests/scripts/install/test_install_sh_acp_launcher.py
#	tests/scripts/install/test_install_sh_bootstrap_marker.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_install_method_stamp.py
#	tests/scripts/install/test_install_sh_node_deps_failure.py
#	tests/scripts/install/test_install_sh_node_deps_workspaces.py
#	tests/scripts/install/test_install_sh_node_global_prefix.py
#	tests/scripts/install/test_install_sh_node_npm_check.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_node_probe.py
#	tests/scripts/install/test_install_sh_node_tarball_without_xz.py
#	tests/scripts/install/test_install_sh_pythonpath_sanitization.py
#	tests/scripts/install/test_install_sh_reuse_supported_python.py
#	tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py
#	tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_termux_network_prereqs.py
#	tests/scripts/install/test_install_sh_termux_python_bounds.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_project_metadata.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_tts_pythonpath_fallback.py
#	tests/tui_gateway/test_hosted_room_driver_runtime.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	tools/lazy_deps.py
#	tools/voice_mode.py
#	uv.lock
#	website/docs/developer-guide/macos-bundle-updates.md
#	website/docs/developer-guide/pm-audit-status.md
#	website/docs/developer-guide/shared-bundle-builds.md
#	website/docs/developer-guide/source-update-completion.md
#	website/docs/developer-guide/stable-releases.md
This commit is contained in:
ethernet
2026-09-14 15:38:34 -04:00
2096 changed files with 115964 additions and 30003 deletions
+1 -2
View File
@@ -86,9 +86,8 @@ apps/
# Test suite — not shipped in production images
tests/
# Documentation site (Docusaurus) and supplementary docs
# Documentation site (Docusaurus)
website/
docs/
# Keep the source artwork needed by web's icon-generation prebuild.
assets/
+5
View File
@@ -58,6 +58,11 @@ jobs:
emit-status:
name: Emit review status
runs-on: ubuntu-latest
# Downloads one small SARIF artifact and runs two inline python snippets —
# minutes of work. Bound it so a wedged artifact download can't hold a
# runner for GitHub's 6-hour default (the only unbounded job left in
# .github/workflows; every other workflow already sets timeout-minutes).
timeout-minutes: 10
needs: scan
if: always()
outputs:
+2 -2
View File
@@ -32,7 +32,7 @@ on:
desktop_updater:
description: >-
Run the Windows desktop-update hand-off integration tests
(tests/test_desktop_update_windows_*.py). These spawn the real
(tests/scripts/desktop_update/test_desktop_update_windows_*.py). These spawn the real
scripts/desktop-update/windows.ps1 and poll its loopback server, so
they carry process-timing noise a shared runner amplifies; the
caller gates them on the classifier's desktop_updater lane so a PR
@@ -126,7 +126,7 @@ jobs:
# an unbound-variable error, hence the ``${arr[@]+...}`` idiom.)
EXTRA_ARGS=()
if [ "${{ inputs.desktop_updater }}" != "true" ]; then
echo "desktop_updater lane off: skipping tests/test_desktop_update_windows_*.py"
echo "desktop_updater lane off: skipping tests/scripts/desktop_update/test_desktop_update_windows_*.py"
EXTRA_ARGS+=(--ignore-glob='*test_desktop_update_windows_*.py')
fi
+1
View File
@@ -325,6 +325,7 @@ install-stamp.json
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/
evals/tool_search/out*/
# Per-release changelog drafts. These exist only transiently during a release
# cut (passed to `gh release create --notes-file`); the GitHub Release itself
+7 -2
View File
@@ -257,7 +257,7 @@ families: `hermes_state.py` (21), `gateway/run.py` (15), `tools/mcp_tool.py` (15
(`_SLASH_DISPATCH` in `cli.py`, `_command_handler_table` in the gateway are the shape).
- **No re-export shims for internal moves** ("keep the old name importable"). Internal paths
are not API; external compat is handled ONCE by the compat layer, not per PR.
- **Moving a symbol means fixing its docs in the same PR:** grep `website/docs`, `docs/`,
- **Moving a symbol means fixing its docs in the same PR:** grep `website/docs`,
`skills/`, and every `AGENTS.md` for the old `path.py` + symbol (23 doc files went stale
after the refactor). `evals/codebase_navigability/static_metrics.py <tree> <label>` measures
file/function/CC/elif distributions before/after a large PR in ~2 min.
@@ -354,7 +354,12 @@ scripts/run_tests.sh -v --tb=long # pytest flags pass thro
`HERMES_TEST_FILE_RETRIES=0` disables). Pass-on-retry is green but printed under `⚠ FLAKY`
with both outputs — a bug to fix, not noise. Timing tests must not assume a quiet runner:
wall-clock bounds ≥ 2s, event-based sync, no `assert not _wait_until(...)` races.
- **Placement:** `scripts/ci/classify_changes.py` picks jobs by changed files. A Python test
- **Placement mirrors the source tree.** A test lives in `tests/<top-level source dir>/` (`tests/hermes_cli/`,
`tests/agent/`, `tests/hermes_state/`, `tests/gateway/relay/`, ...); installer/updater script tests
under `tests/scripts/{install,desktop_update}/`. Only tests of root-level modules (`batch_runner`,
`utils`, `hermes_constants`, packaging) sit directly in `tests/`. No issue numbers in filenames —
cite the issue in the module docstring (`test_89315_x.py` → `test_x.py`, "Regression for #89315").
- **Placement (CI lanes):** `scripts/ci/classify_changes.py` picks jobs by changed files. A Python test
asserting about `package.json`, `package-lock.json`, `tsconfig.json`, or `.ts/.tsx/.js/
.mjs/.cjs` sources will not run on a JS-only PR (green on PR, red on `main` where the
classifier fails open). Such tests belong in the vitest suite, not `tests/*.py`.
+7 -3
View File
@@ -262,7 +262,7 @@ RUN cd plugins/platforms/photon/sidecar && \
# frontend stats the readme path during dep resolution, so we `touch` an
# empty placeholder — the real README is restored by `COPY . .` below.
#
# `uv sync --frozen --no-install-project --extra all --extra messaging --extra otlp`
# `pm.build_env --no-install-project --extra all --extra messaging --extra otlp`
# installs the deps reachable through the composite `[all]` extra
# (handpicked set intended for the production image — excludes `[dev]`),
# plus gateway messaging adapters that should work in the published image
@@ -293,13 +293,17 @@ RUN cd plugins/platforms/photon/sidecar && \
# avoids the cross-platform failures that kept [matrix] out of [all]
# while still making Matrix work in the published container. Fixes #30399.
#
# Google Chat's [google-chat] extra (google-cloud-pubsub + Chat API clients)
# is baked so hosted/immutable images can enable the adapter without writing
# the sealed venv.
#
# Source binding is created after the source copy below.
COPY pyproject.toml uv.lock ./
RUN touch ./README.md
RUN python3 -m pm.build_env --source /opt/hermes --python /usr/local/bin/python3 \
--out /opt/hermes/.venv --no-install-project --sealed \
--extra all --extra messaging --extra otlp --extra anthropic --extra bedrock \
--extra azure-identity --extra hindsight --extra matrix
--extra azure-identity --extra hindsight --extra matrix --extra google-chat
# Shared product outputs are independent of application dependency assembly.
COPY --from=frontend_build /opt/products/tui /opt/hermes/ui-tui
@@ -319,7 +323,7 @@ COPY --link --chmod=a+rX,go-w . .
RUN /opt/hermes/.venv/bin/python -m docker.build_agent
# Wire the exec shim and install-method stamp. Files under /opt/hermes are
# already root-owned (COPY, uv sync, npm install all run as root) and
# already root-owned (COPY, dep assembly, npm install all run as root) and
# read-only for the hermes user (go-w from the --chmod above).
USER root
+30 -35
View File
@@ -95,7 +95,7 @@ class SlashCommandsMixin:
return None
handler = getattr(self, f"_cmd_{cmd}")
# Handlers run on the loop thread, outside the per-turn cwd-pinning context. ``/compress``
# Handlers run outside the per-turn cwd-pinning context. ``/compress``
# and ``/model`` REBUILD the system prompt, so unpinned they'd bake the Hermes install tree
# into the persisted cached prompt. Pin inside a fresh context: no leak, no teardown.
def _dispatch() -> str | None:
@@ -226,45 +226,40 @@ class SlashCommandsMixin:
return "Conversation history cleared."
def _cmd_compress(self, args: str, state: SessionState) -> str:
"""``/compress [here [N] | <focus>] [--preview] [--aggressive]`` through the shared core."""
from agent.conversation_compression import finalize_context_engine_compression_notification
from agent.conversation_compression_manual import (
AGGRESSIVE_UNSUPPORTED, compress_now, parse_compress_args, render_compress_result)
if not state.history:
return "Nothing to compress — conversation is empty."
agent = state.agent
# No compression_enabled gate: it only disables *automatic* compaction (CLI/gateway parity).
if not hasattr(agent, "_compress_context"):
return "Context compression not available for this agent."
request = parse_compress_args(args)
if request.aggressive:
return AGGRESSIVE_UNSUPPORTED
original_session_db = getattr(agent, "_session_db", None)
try:
agent = state.agent
# No compression_enabled gate: it only disables *automatic* compaction (CLI/gateway parity).
if not hasattr(agent, "_compress_context"):
return "Context compression not available for this agent."
original_count = len(state.history)
# Include system prompt + tool schemas so the figure reflects real request pressure.
# See #6217.
# See #6217.
_sys_prompt = getattr(agent, "_cached_system_prompt", "") or ""
_tools = getattr(agent, "tools", None) or None
approx_tokens = _estimate_tokens(state.history, agent, _sys_prompt, _tools)
original_session_db = getattr(agent, "_session_db", None)
try:
# Stable ACP session id: suppress _compress_context's SQLite session split.
agent._session_db = None
compressed, _ = agent._compress_context(
state.history, _sys_prompt, approx_tokens=approx_tokens, task_id=state.session_id, force=True,
)
finally:
agent._session_db = original_session_db
state.history = compressed
self.session_manager.save_session(state.session_id)
new_tokens = _estimate_tokens(
state.history, agent, getattr(agent, "_cached_system_prompt", "") or _sys_prompt,
getattr(agent, "tools", None) or _tools,
)
return (
f"Context compressed: {original_count} -> {len(state.history)} messages\n"
f"~{approx_tokens:,} -> ~{new_tokens:,} tokens"
)
# Stable ACP session id: suppress _compress_context's SQLite session split.
agent._session_db = None
result = compress_now(
agent, state.history, request, system_message=getattr(agent, "_cached_system_prompt", "") or "",
task_id=state.session_id)
except Exception as e:
return f"Compression failed: {e}"
finally:
agent._session_db = original_session_db
if result.status != "compressed":
return "\n".join(render_compress_result(result))
state.history = result.after_messages
self.session_manager.save_session(state.session_id)
finalize_context_engine_compression_notification(agent, committed=True)
return (
f"Context compressed: {len(result.before_messages)} -> {len(state.history)} messages\n"
f"~{result.before_tokens:,} -> ~{result.after_tokens:,} tokens"
)
def _cmd_steer(self, args: str, state: SessionState) -> str:
steer_text = args.strip()
+65 -7
View File
@@ -8,6 +8,7 @@ thread-safely onto the loop.
import asyncio
import logging
import uuid
from collections import deque
from typing import Any, Callable, Deque, Dict
@@ -121,22 +122,79 @@ def make_tool_progress_cb(
return _tool_progress
def _make_text_cb(conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, wrap: Callable[[str], Any]) -> Callable:
def _cb(text: str) -> None:
# ------------------------------------------------------------------
# Assistant message identity
# ------------------------------------------------------------------
class AssistantMessageIdAllocator:
"""Allocates stable per-message ids for streamed assistant chunks.
ACP clients group streamed ``agent_message_chunk`` / ``agent_thought_chunk``
deltas into one assistant reply by ``messageId`` and use a NEW id to start
the next reply (root-reply replacement semantics). Without ids, a client
that replaces "the current assistant message" on each chunk collapses
separate autonomous turns into one bubble.
One allocator lives per ACP session; a contiguous run of deltas shares
``current()`` and ``close()`` marks the message finished so the next delta
allocates a fresh id. Ids are UUID4 strings because the ACP schema requires
UUID-format message ids, and a fresh UUID can never collide with an earlier
turn's id.
"""
def __init__(self) -> None:
self._active: str | None = None
self._last: str | None = None
def current(self) -> str:
"""Return the active message id, allocating one if none is open."""
if self._active is None:
self._active = self._last = str(uuid.uuid4())
return self._active
def last(self) -> str | None:
"""Return the most recently allocated id (open or closed)."""
return self._last
def close(self) -> None:
"""End the active message; the next chunk starts a new id."""
self._active = None
def _make_text_cb(
conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop, wrap: Callable[[str], Any],
message_ids: AssistantMessageIdAllocator | None = None,
) -> Callable:
# ``None`` is the flush sentinel Hermes core sends between assistant messages
# (before tool execution / at end of stream): it closes the active messageId so
# the next delta opens a new bubble instead of merging into the previous one.
def _cb(text: str | None) -> None:
if text:
_send_update(conn, session_id, loop, wrap(text))
update = wrap(text)
if message_ids is not None:
update.message_id = message_ids.current()
_send_update(conn, session_id, loop, update)
elif text is None and message_ids is not None:
message_ids.close()
return _cb
def make_thinking_cb(conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop) -> Callable:
def make_thinking_cb(
conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop,
message_ids: AssistantMessageIdAllocator | None = None,
) -> Callable:
"""Create a ``thinking_callback`` for AIAgent."""
return _make_text_cb(conn, session_id, loop, acp.update_agent_thought_text)
return _make_text_cb(conn, session_id, loop, acp.update_agent_thought_text, message_ids)
def make_message_cb(conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop) -> Callable:
def make_message_cb(
conn: acp.Client, session_id: str, loop: asyncio.AbstractEventLoop,
message_ids: AssistantMessageIdAllocator | None = None,
) -> Callable:
"""Create a callback that streams agent response text to the editor."""
return _make_text_cb(conn, session_id, loop, acp.update_agent_message_text)
return _make_text_cb(conn, session_id, loop, acp.update_agent_message_text, message_ids)
def make_step_cb(
+48 -27
View File
@@ -28,7 +28,8 @@ from acp_adapter.auth import TERMINAL_SETUP_AUTH_METHOD_ID, build_auth_methods,
from acp_adapter.commands import HERMES_VERSION, SlashCommandsMixin, _estimate_tokens
from acp_adapter.content import PromptBlock, _content_blocks_to_openai_user_content, _extract_text
from acp_adapter.events import (
_build_plan_update_from_todo_result, make_message_cb, make_step_cb, make_thinking_cb, make_tool_progress_cb,
AssistantMessageIdAllocator, _build_plan_update_from_todo_result, make_message_cb, make_step_cb,
make_thinking_cb, make_tool_progress_cb,
)
from acp_adapter.model_catalog import build_model_state, encode_model_choice
from acp_adapter.permissions import make_approval_callback
@@ -305,32 +306,33 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
choice = encode_model_choice(provider, model)
return SessionModelState(available_models=[ModelInfo(model_id=choice, name=model)], current_model_id=choice)
@staticmethod
def _resolve_model_selection(raw_model: str, current_provider: str) -> tuple[str, str]:
"""Resolve ``provider:model`` input into the provider and normalized model id."""
target_provider, new_model = current_provider, raw_model.strip()
try:
from hermes_cli.models import detect_provider_for_model, parse_model_input
raw = new_model
target_provider, new_model = parse_model_input(new_model, current_provider)
# An explicit ``provider:model`` prefix is a selection; detection is a fallback for bare
# names only and must not second-guess it (#59089).
if target_provider == current_provider and new_model == raw:
detected = detect_provider_for_model(new_model, current_provider)
if detected:
target_provider, new_model = detected
except Exception:
logger.debug("Provider detection failed, using model as-is", exc_info=True)
return target_provider, new_model
def _switch_model(
self, state: SessionState, raw_model: str, *, keep_endpoint: bool = False
) -> tuple[str | None, str, str]:
"""Rebuild the session agent on a new model -> (old provider, new provider, model).
``keep_endpoint`` carries base_url/api_mode over when the provider is unchanged."""
Resolution goes through ``hermes_cli.model_switch.switch_model`` seeded with the live
agent route — the same catalog/alias/credential validation as CLI/gateway/TUI ``/model``
— so ACP never hands the session a model no provider can serve. ``provider:model`` picker
ids become ``--provider``. ACP never persists. ``keep_endpoint`` carries base_url/api_mode
over when the provider is unchanged."""
from hermes_cli.config import get_compatible_custom_providers, load_config
from hermes_cli.model_switch import switch_model
from hermes_cli.models import parse_model_input
current_provider = getattr(state.agent, "provider", None)
target_provider, new_model = self._resolve_model_selection(raw_model, current_provider or "openrouter")
explicit_provider, model_input = parse_model_input(raw_model, "")
cfg = load_config()
result = switch_model(
raw_input=model_input, explicit_provider=explicit_provider,
current_provider=current_provider or "openrouter", current_model=str(state.model or ""),
current_base_url=str(getattr(state.agent, "base_url", "") or ""),
current_api_key=str(getattr(state.agent, "api_key", "") or ""),
user_providers=cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {},
custom_providers=get_compatible_custom_providers(cfg))
if not result.success:
raise ValueError(result.error_message or f"Cannot switch to {raw_model}")
target_provider, new_model = result.target_provider, result.new_model
state.model = new_model
endpoint: dict[str, Any] = {}
if keep_endpoint and not (current_provider and target_provider != current_provider):
@@ -793,7 +795,9 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
# Slash commands are text-only; a prompt with media goes to the agent even if it starts with "/".
if text_only_prompt and isinstance(user_content, str) and user_text.startswith("/"):
response_text = self._handle_slash_command(user_text, state)
# Off the loop: /model validates through switch_model (network I/O) and /compress
# calls the LLM; handlers are sync and hold no loop-bound state.
response_text = await asyncio.to_thread(self._handle_slash_command, user_text, state)
if response_text is not None:
if self._conn:
await self._conn.session_update(session_id, acp.update_agent_message_text(response_text))
@@ -846,9 +850,14 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
cbs.tool_progress_cb = make_tool_progress_cb(
conn, session_id, loop, tool_call_ids, tool_call_meta, edit_approval_policy_getter=policy_getter
)
cbs.reasoning_cb = make_thinking_cb(conn, session_id, loop)
# Per-session allocator: a new turn must never reuse a previous turn's
# assistant messageId (ACP clients replace the bubble with that id).
if state.message_ids is None:
state.message_ids = AssistantMessageIdAllocator()
state.message_ids.close() # new turn -> next chunk opens a fresh id
cbs.reasoning_cb = make_thinking_cb(conn, session_id, loop, state.message_ids)
cbs.step_cb = make_step_cb(conn, session_id, loop, tool_call_ids, tool_call_meta)
message_cb = make_message_cb(conn, session_id, loop)
message_cb = make_message_cb(conn, session_id, loop, state.message_ids)
def stream_delta_cb(text: str) -> None:
cbs.streamed = cbs.streamed or bool(text)
@@ -904,7 +913,16 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
suppress = interrupted and final_response.startswith(INTERRUPT_WAITING_FOR_MODEL_PREFIX)
# Send the final text unless already streamed — or if a plugin hook transformed it after.
if final_response and conn and not suppress and (not streamed_message or result.get("response_transformed")):
await conn.session_update(session_id, acp.update_agent_message_text(final_response))
update = acp.update_agent_message_text(final_response)
if state.message_ids is not None:
# A plugin-rewritten reply replaces the streamed bubble (same id); an
# unstreamed final response opens its own.
if streamed_message and result.get("response_transformed"):
update.message_id = state.message_ids.last() or state.message_ids.current()
else:
update.message_id = state.message_ids.current()
state.message_ids.close()
await conn.session_update(session_id, update)
# Go idle before draining so recursive prompt() calls can acquire the session.
with state.runtime_lock:
@@ -935,7 +953,10 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
"""Switch the model for a session (called by ACP protocol)."""
state = self.session_manager.get_session(session_id)
if state:
_old, requested_provider, resolved_model = self._switch_model(state, model_id, keep_endpoint=True)
# switch_model() does synchronous network I/O (models.dev, custom-endpoint probes,
# ~10 s cold) — off the loop, like the gateway, so other ACP sessions keep flowing.
_old, requested_provider, resolved_model = await asyncio.to_thread(
self._switch_model, state, model_id, keep_endpoint=True)
logger.info(
"Session %s: model switched to %s via provider %s", session_id, resolved_model, requested_provider
)
+10 -5
View File
@@ -143,6 +143,9 @@ class SessionState:
runtime_lock: Any = field(default_factory=threading.Lock)
current_prompt_text: str = ""
interrupted_prompt_text: str = ""
# Per-session allocator for ACP assistant messageIds (lazily created by
# the server so streamed chunks group into distinct assistant replies).
message_ids: Any = None
class SessionManager:
@@ -266,13 +269,15 @@ class SessionManager:
return state
def _get_db(self):
"""Lazily initialise the SessionDB; ``None`` if unavailable (e.g. import error in a
minimal test env). ``HERMES_HOME`` is resolved here, not via the import-time
``DEFAULT_DB_PATH``, so test fixtures that change the env var later are honoured."""
"""Lazily acquire the process-shared SessionDB; ``None`` if unavailable (e.g. import
error in a minimal test env). ``HERMES_HOME`` is resolved here, not via the import-time
``DEFAULT_DB_PATH``, so test fixtures that change the env var later are honoured. The
registry handle is the one in-process tools (delegation, session_search, goals) also
acquire, so the ACP server holds ONE writer on state.db instead of two (#100896)."""
if self._db_instance is None:
try:
from hermes_state import SessionDB
self._db_instance = SessionDB(db_path=get_hermes_home() / "state.db")
from hermes_state_registry import acquire
self._db_instance = acquire(get_hermes_home() / "state.db")
except Exception:
logger.debug("SessionDB unavailable for ACP persistence", exc_info=True)
return self._db_instance
+7 -66
View File
@@ -10,6 +10,8 @@ from typing import Any, Callable, Dict, List, Optional
import acp
from acp.schema import ToolCallLocation, ToolCallProgress, ToolCallStart, ToolKind
from agent.display import build_tool_preview
logger = logging.getLogger(__name__)
# Hermes tool name -> ACP ToolKind (anything unlisted is "other").
@@ -84,11 +86,6 @@ def _first(data: Args, *keys: str, default: Any = "") -> Any:
return next((data[k] for k in keys if data.get(k)), default)
def _clip(text: str, limit: int) -> str:
"""Hard-truncate to ``limit`` chars with a trailing ellipsis."""
return text if len(text) <= limit else text[: limit - 3] + "..."
def _fmt(value: Any, template: str, fallback: str) -> str:
"""``template.format(value)`` when value is truthy, else ``fallback``."""
return template.format(value) if value else fallback
@@ -193,68 +190,12 @@ def _tool_result_failed(result: Optional[str], tool_name: str | None = None) ->
# --- tool-call titles -------------------------------------------------------
def _title_web_extract(args: Args) -> str:
urls = args.get("urls", [])
if not urls:
return "web extract"
first = urls[0]
if isinstance(first, dict):
first = first.get("url") or first.get("href") or "?"
elif not isinstance(first, str):
first = "?"
return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
def _title_delegate(args: Args) -> str:
if isinstance(tasks := args.get("tasks"), list) and tasks:
return f"delegate batch ({len(tasks)} tasks)"
return f"delegate: {_clip(goal, 60)}" if (goal := args.get("goal", "")) else "delegate task"
def _title_execute_code(args: Args) -> str:
first_line = next((line.strip() for line in _arg(args, "code").splitlines() if line.strip()), "")
return _fmt(_clip(first_line, 70), "python: {}", "python code")
def _title_skill_manage(args: Args) -> str:
name, file_path = _arg(args, "name", default="?"), _arg(args, "file_path")
target = _clip(f"{name}/{file_path}" if file_path else name, 64)
return f"skill {_arg(args, 'action', default='manage')}: {target}"
_TITLE_BUILDERS: Dict[str, Callable[[Args], str]] = {
"terminal": lambda a: f"terminal: {_clip(a.get('command', ''), 80)}",
"read_file": lambda a: f"read: {a.get('path', '?')}",
"write_file": lambda a: f"write: {a.get('path', '?')}",
"patch": lambda a: f"patch ({a.get('mode', 'replace')}): {a.get('path', '?')}",
"search_files": lambda a: f"search: {a.get('pattern', '?')}",
"web_search": lambda a: f"web search: {a.get('query', '?')}",
"web_extract": _title_web_extract,
"process": lambda a: _fmt(_arg(a, "session_id"), f"process {_arg(a, 'action', default='manage')}: {{}}",
f"process {_arg(a, 'action', default='manage')}"),
"delegate_task": _title_delegate,
"session_search": lambda a: _fmt(_arg(a, "query"), "session search: {}", "recent sessions"),
"memory": lambda a: f"memory {_arg(a, 'action', default='manage')}: {_arg(a, 'target', default='memory')}",
"execute_code": _title_execute_code,
"todo": lambda a: f"todo ({_plural(len(a['todos']), 'item')})" if isinstance(a.get("todos"), list) else "todo",
"skill_view": lambda a: f"skill view ({_arg(a, 'name', default='?')}{_fmt(_arg(a, 'file_path'), '/{}', '')})",
"skills_list": lambda a: _fmt(_arg(a, "category"), "skills list ({})", "skills list"),
"skill_manage": _title_skill_manage,
"browser_navigate": lambda a: f"navigate: {a.get('url', '?')}",
"browser_snapshot": lambda a: "browser snapshot",
"browser_vision": lambda a: f"browser vision: {str(a.get('question', '?'))[:50]}",
"browser_get_images": lambda a: "browser images",
"vision_analyze": lambda a: f"analyze image: {str(a.get('question', '?'))[:50]}",
"image_generate": lambda a: _fmt(_arg(a, "prompt", "description")[:50], "generate image: {}", "generate image"),
"cronjob": lambda a: _fmt(_arg(a, "job_id", "id"), f"cron {_arg(a, 'action', default='manage')}: {{}}",
f"cron {_arg(a, 'action', default='manage')}"),
}
def build_tool_title(tool_name: str, args: Args) -> str:
"""Build a human-readable title for a tool call (defaults to the tool name)."""
builder = _TITLE_BUILDERS.get(tool_name)
return builder(args) if builder is not None else tool_name
"""``<tool_name>: <preview>`` using the same per-tool preview (and argument redaction) as
every other Hermes surface, so ACP clients never show a different summary than the CLI/TUI;
bare tool name when the arguments yield no preview."""
preview = build_tool_preview(tool_name, args, max_len=80)
return f"{tool_name}: {preview}" if preview else tool_name
# --- completion formatters; all share the signature (tool_name, result, args) --
+4
View File
@@ -73,6 +73,10 @@ Adding one: register in that table (no `if name == ...` chain); `tools/todo_tool
## Compression (`agent/compression_facade.py`, `conversation_compression.py`, `turn_context_compaction.py`)
Manual `/compress` on every surface (CLI, gateway, TUI, ACP) runs through
`agent/conversation_compression_manual.py::compress_now` (one parser for `here [N]` / focus /
`--preview` / `--aggressive`; surfaces only parse their own argv, install `after_messages` and render).
Two layers: gateway session hygiene (85% threshold) and the agent `ContextCompressor` (50%,
configurable; per-model overrides; failure cooldown after provider-proven overflow). The algorithm
prunes old tool results first (no LLM call), then picks boundaries, then generates a structured
+1 -1
View File
@@ -49,7 +49,7 @@ class ActivityTrackingMixin:
kanban heartbeat when this is a dispatcher-spawned worker, and to the durable SessionDB activity
projection. ``provenance`` names special writers (compression); ``force_persist`` bypasses the
SessionDB rate limit. Module-level lock helper, not ``self._liveness_activity_lock()``: doubles bind
only ``_touch_activity`` (tests/run_agent/test_session_activity_persist.py).
only ``_touch_activity`` (tests/agent/test_session_activity_persist.py).
Bridge is rate-limited (60s) and best-effort — it never raises into the agent loop. See #31752.
See #72016, #72039.
+6 -9
View File
@@ -15,7 +15,6 @@ import re
import sys
import threading
import time
import uuid
from collections import deque
from contextlib import suppress
from datetime import datetime
@@ -41,6 +40,7 @@ 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 hermes_state_ids import new_session_id
from utils import base_url_host_matches, is_truthy_value
# Same logger name as run_agent so caplog/patches on "run_agent" see our records.
@@ -877,20 +877,19 @@ def _routed_client_kwargs(agent, fallback_model, _provider_timeout) -> Dict[str,
)
_FINE_GRAINED_BETA = "fine-grained-tool-streaming-2025-05-14"
def _apply_openai_header_policy(agent, client_kwargs: Dict[str, Any]) -> None:
"""Mutate ``client_kwargs`` (== ``agent._client_kwargs``) with header/TLS policy, in order:
OpenRouter Claude beta header → model.default_headers → custom-provider TLS/extra_headers."""
# Fine-grained tool streaming for Claude on OpenRouter: without the beta header
# Anthropic buffers the whole tool call and OpenRouter's proxy times out.
from agent.anthropic_adapter import _TOOL_STREAMING_BETA
_effective_base = str(client_kwargs.get("base_url", "")).lower()
if base_url_host_matches(_effective_base, "openrouter.ai") and "claude" in (agent.model or "").lower():
headers = client_kwargs.get("default_headers") or {}
existing_beta = headers.get("x-anthropic-beta", "")
if _FINE_GRAINED_BETA not in existing_beta:
headers["x-anthropic-beta"] = ",".join(filter(None, (existing_beta, _FINE_GRAINED_BETA)))
if _TOOL_STREAMING_BETA not in existing_beta:
headers["x-anthropic-beta"] = ",".join(filter(None, (existing_beta, _TOOL_STREAMING_BETA)))
client_kwargs["default_headers"] = headers
# model.default_headers override provider/SDK defaults (WAFs rejecting SDK headers).
agent._apply_user_default_headers()
@@ -1120,9 +1119,7 @@ def _publish_session_id(session_id: str) -> None:
def _init_session_state(agent, session_id, session_db, parent_session_id, reasoning_config, max_tokens,
checkpoints_enabled, checkpoint_max_snapshots, checkpoint_max_total_size_mb, checkpoint_max_file_size_mb):
agent.session_start = datetime.now()
agent.session_id = session_id or (
f"{agent.session_start.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
)
agent.session_id = session_id or new_session_id(agent.session_start)
_publish_session_id(agent.session_id)
# ~/.hermes/sessions/ — kept unconditionally for request_dump_*.json debug breadcrumbs.
+14 -35
View File
@@ -21,21 +21,22 @@ from agent.message_sanitization import (
)
from agent.prompt_builder import STEER_DISPLAY_KIND, steer_user_row
from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message
from agent.think_scrubber import THINK_TAG_NAMES
from agent.trajectory import convert_scratchpad_to_think
from agent.credential_pool import (
STATUS_EXHAUSTED, credential_pool_matches_provider, resolve_runtime_pool_key
)
from agent.error_classifier import FailoverReason
from agent.retry_utils import parse_retry_after_seconds, reset_delay_from_message
from agent.turn_context import drop_stale_api_content
from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write
logger = logging.getLogger(__name__)
# Cap same-entry OAuth refreshes on a persistent auth failure, else a single-entry pool re-mints forever.
_MAX_AUTH_REFRESH_ATTEMPTS = 2
_REASONING_TAG_NAMES = ("think", "thinking", "reasoning", "REASONING_SCRATCHPAD", "thought")
_TOOL_CALL_TAG_NAMES = ("tool_call", "tool_calls", "tool_result", "function_call", "function_calls")
_REASONING_BLOCK_PATTERNS = tuple(
re.compile(rf"<{name}>.*?</{name}>", re.DOTALL | re.IGNORECASE) for name in _REASONING_TAG_NAMES
re.compile(rf"<{name}>.*?</{name}>", re.DOTALL | re.IGNORECASE) for name in THINK_TAG_NAMES
)
_TOOL_CALL_BLOCK_PATTERNS = tuple(
re.compile(rf"<{name}\b[^>]*>.*?</{name}>", re.DOTALL | re.IGNORECASE)
@@ -49,10 +50,10 @@ _NAMED_FUNCTION_BLOCK_PATTERN = re.compile(
r'(?:(?:(?!</function>).)*)</function>', re.DOTALL | re.IGNORECASE,
)
_UNTERMINATED_REASONING_BLOCK_PATTERN = re.compile(
rf'(?:^|\n)[ \t]*<(?:{"|".join(_REASONING_TAG_NAMES)})\b[^>]*>.*$', re.DOTALL | re.IGNORECASE
rf'(?:^|\n)[ \t]*<(?:{"|".join(THINK_TAG_NAMES)})\b[^>]*>.*$', re.DOTALL | re.IGNORECASE
)
_ORPHAN_REASONING_TAG_PATTERN = re.compile(
rf'</?(?:{"|".join(_REASONING_TAG_NAMES)})>\s*', re.IGNORECASE
rf'</?(?:{"|".join(THINK_TAG_NAMES)})>\s*', re.IGNORECASE
)
_STRAY_TOOL_CALL_CLOSER_PATTERN = re.compile(
rf'</(?:{"|".join(_TOOL_CALL_TAG_NAMES)}|function)>\s*', re.IGNORECASE
@@ -1206,7 +1207,7 @@ _TRANSIENT_TRANSPORT_ERRORS = frozenset({
})
_INLINE_REASONING_PATTERNS = tuple(
re.compile(rf"<{tag}>(.*?)</{tag}>", re.DOTALL | re.IGNORECASE)
for tag in ("think", "thinking", "thought", "reasoning", "REASONING_SCRATCHPAD")
for tag in THINK_TAG_NAMES
)
@@ -1734,7 +1735,7 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
return client
# TCP keepalives so dead provider connections are detected (~60s) instead of hanging in
# CLOSE-WAIT. Injected into the local copy only, so each client gets its own httpx.Client;
# pinned by tests/run_agent/test_create_openai_client_reuse.py and
# pinned by tests/agent/test_create_openai_client_reuse.py and
# test_sequential_chats_live.py. What IS shared across those per-client wrappers is the
# connection pool: ``build_keepalive_http_client`` mounts a process-shared ``HTTPTransport``
# behind a per-client view whose ``close()`` is a no-op for the pool, so a closed wrapper
@@ -2319,7 +2320,7 @@ def repair_tool_call(agent, tool_name: str) -> str | None:
# character so the rest of the repair pipeline (lowercase / snake_case / fuzzy match) can resolve the
# cleaned name to a real tool. Crucially we DO NOT split on whitespace: legitimate inputs like "write
# file" must keep flowing through ``_norm`` -> ``write_file`` (covered by test_space_to_underscore in
# tests/run_agent/test_repair_tool_call_name.py). See #33007.
# tests/agent/test_repair_tool_call_name.py). See #33007.
for _xml_sep in ('"', "'", "<", ">"):
_idx = tool_name.find(_xml_sep)
if _idx > 0:
@@ -3104,34 +3105,12 @@ def cleanup_dead_connections(agent) -> bool:
return False
_QUOTA_RESET_DELAY_RE = re.compile(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", re.IGNORECASE)
_RESETS_IN_RE = re.compile(
r"resets?\s+in\s+"
r"(?:(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b\s*)?"
r"(?:(\d+(?:\.\d+)?)\s*(?:m|min|mins|minute|minutes)\b\s*)?"
r"(?:(\d+(?:\.\d+)?)\s*(?:s|sec|secs|second|seconds)\b)?", re.IGNORECASE,
)
_RETRY_AFTER_SECONDS_RE = re.compile(r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", re.IGNORECASE)
def _reset_delay_from_message(message: str) -> Optional[float]:
"""Seconds-until-reset parsed from free-text provider messages, or None."""
m = _QUOTA_RESET_DELAY_RE.search(message)
if m:
value = float(m.group(1))
return value / 1000.0 if m.group(2).lower() == "ms" else value
m = _RESETS_IN_RE.search(message)
if m and any(m.groups()):
return float(m.group(1) or 0) * 3600 + float(m.group(2) or 0) * 60 + float(m.group(3) or 0)
m = _RETRY_AFTER_SECONDS_RE.search(message)
return float(m.group(1)) if m else None
def _set_reset_from_retry_after(context: Dict[str, Any], retry_after: Any) -> None:
if retry_after in {None, ""} or "reset_at" in context:
if "reset_at" in context:
return
with contextlib.suppress(TypeError, ValueError):
context["reset_at"] = time.time() + float(retry_after)
seconds = parse_retry_after_seconds(retry_after)
if seconds is not None:
context["reset_at"] = time.time() + seconds
def extract_api_error_context(error: Exception) -> Dict[str, Any]:
@@ -3155,14 +3134,14 @@ def extract_api_error_context(error: Exception) -> Dict[str, Any]:
_set_reset_from_retry_after(context, payload.get("retry_after"))
headers = getattr(getattr(error, "response", None), "headers", None)
if headers:
_set_reset_from_retry_after(context, headers.get("retry-after") or headers.get("Retry-After") or None)
_set_reset_from_retry_after(context, headers)
ratelimit_reset = headers.get("x-ratelimit-reset")
if ratelimit_reset and "reset_at" not in context:
context["reset_at"] = ratelimit_reset
if "message" not in context and str(error).strip():
context["message"] = str(error).strip()[:500]
if "reset_at" not in context and isinstance(context.get("message") or "", str):
delay = _reset_delay_from_message(context.get("message") or "")
delay = reset_delay_from_message(context.get("message") or "")
if delay is not None:
context["reset_at"] = time.time() + delay
return context
+15
View File
@@ -630,6 +630,21 @@ def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any:
return api_kwargs
def buffer_anthropic_tool_input(api_kwargs: dict[str, Any], base_url: str | None) -> None:
"""Retry knob for a malformed fine-grained tool-JSON stream (#107830): the beta streams tool
args unvalidated, so a model that emits ``{"names": cronjob_manage}`` breaks the SDK parser
and an identical retry breaks identically. ``eager_input_streaming: false`` per tool restores
Anthropic's buffered, validated args for the rest of this turn (the flag lives on the turn's
kwargs, so a later retry of the same turn keeps it; the changed ``tools`` block costs one
prompt-cache miss, cheaper than a dead turn). Off the happy path on purpose:
buffering a large payload is a zero-event gap the stale-stream detector kills. No-op on
endpoints that never get the beta (MiniMax) rather than sending them an unknown field."""
if _TOOL_STREAMING_BETA not in _common_betas_for_base_url(base_url):
return
for tool in api_kwargs.get("tools") or ():
tool["eager_input_streaming"] = False
def _is_stream_unavailable_error(exc: Exception) -> bool:
"""True when an Anthropic stream call should fall back to create()."""
err_lower = str(exc).lower()
+4 -17
View File
@@ -18,7 +18,6 @@ import logging
import os
import platform
import secrets
import stat
import subprocess
import threading
import time
@@ -27,6 +26,7 @@ from pathlib import Path
from typing import Any, Dict, Optional
from hermes_constants import get_hermes_home
from utils import atomic_json_write
from agent.secret_scope import get_secret as _get_secret
logger = logging.getLogger(__name__)
@@ -82,22 +82,9 @@ def _load_json_if_exists(path: Path, what: str) -> Optional[Any]:
def _atomic_write_private_json(path: Path, payload: Any) -> None:
"""Write *payload* via a 0o600 O_EXCL temp file + fsync + os.replace: the token is never briefly umask-readable
(write_text + chmod had a TOCTOU window); the random suffix avoids collisions with concurrent writers and
crashed leftovers. The parent dir's mode is left alone (~/.claude/ is owned by Claude Code)."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
try:
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, stat.S_IRUSR | stat.S_IWUSR)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except OSError:
with contextlib.suppress(OSError):
tmp.unlink(missing_ok=True)
raise
"""0600-from-creation temp file + fsync + atomic replace (the token is never briefly umask-readable).
The parent dir's mode is left alone (~/.claude/ is owned by Claude Code)."""
atomic_json_write(path, payload, mode=0o600)
def _commit_private_json(path: Path, payload: Any, what: str) -> None:
+6 -1
View File
@@ -10,6 +10,11 @@ from typing import Any, Dict, Optional
from agent.redact import redact_sensitive_text
# Substrings of the plain ``ValueError`` the Anthropic SDK raises for a malformed event-stream
# frame (wire trouble, not local validation). Read by ``AIAgent._is_provider_stream_parse_error``.
PROVIDER_STREAM_PARSE_MARKERS = ("expected ident at line", "expected value at line")
# Offline DNS failures are wrapped in a generic "Connection error" by SDKs — inspect the chain.
_NETWORK_RESOLUTION_MARKERS = (
"temporary failure in name resolution",
@@ -142,7 +147,7 @@ class ApiErrorSummaryMixin:
)
current = current.__cause__ or current.__context__
if isinstance(error, ValueError) and "expected ident at line" in raw.lower():
if isinstance(error, ValueError) and any(marker in raw.lower() for marker in PROVIDER_STREAM_PARSE_MARKERS):
return f"Malformed provider streaming response: {raw[:300]}"
prefix = _http_prefix(error)
+27 -4
View File
@@ -4548,7 +4548,17 @@ def _wrap_transport(req: _ResolveRequest, client_obj: Any, final_model_str: str,
"(api_mode=%s, model=%s, base_url=%s)",
req.api_mode or "auto-detected", final_model_str, base_url_str[:60] if base_url_str else "")
return CodexAuxiliaryClient(client_obj, final_model_str)
return _maybe_wrap_anthropic(client_obj, final_model_str, api_key_str, base_url_str, req.api_mode)
# A profile that declares the Messages wire (commandcode-anthropic) is on it whatever the URL
# looks like; the same declaration gates ``_reasoning_config`` in _build_call_kwargs.
api_mode = req.api_mode or _profile_declared_messages_wire(req.provider)
return _maybe_wrap_anthropic(client_obj, final_model_str, api_key_str, base_url_str, api_mode)
def _profile_declared_messages_wire(provider: str) -> Optional[str]:
"""``"anthropic_messages"`` when the registered profile declares that api_mode, else None."""
from providers import get_provider_profile
profile = get_provider_profile(str(provider or "").strip().lower())
return "anthropic_messages" if profile is not None and profile.api_mode == "anthropic_messages" else None
def _route_client(req: _ResolveRequest, client_obj: Any, final_model_str: Optional[str]) -> _ResolveResult:
@@ -5559,6 +5569,13 @@ def _get_cached_client(
provider, model, async_mode, explicit_base_url=base_url, explicit_api_key=effective_api_key,
api_mode=api_mode, main_runtime=runtime, is_vision=is_vision, task=task,
)
if client is not None and _aux_probe_active():
# Availability probes answer "resolvable?" and must leave the cache untouched: the
# probe stub (bare, or wrapped in a Codex/Anthropic adapter whose leaf is the stub)
# shares the runtime key, and a cached one is served to every later caller — the
# next probe dies in _compat_model() on stub attribute access, so check_fns flip to
# False and vision tools vanish for the process lifetime (#87654).
return client, model or default_model
if client is not None:
with _client_cache_lock:
if cache_key not in _client_cache:
@@ -6052,6 +6069,7 @@ class _ProfileProjection(NamedTuple):
reasoning_extra: Dict[str, Any]
top_level: Dict[str, Any]
handles_reasoning: bool
messages_wire: bool = False
def _project_provider_profile(
@@ -6062,11 +6080,13 @@ def _project_provider_profile(
reasoning_extra: Dict[str, Any] = {}
top_level: Dict[str, Any] = {}
handles_reasoning = False
messages_wire = False
try:
from providers import get_provider_profile
from providers.base import ProviderProfile
profile = get_provider_profile(provider_norm)
if profile is not None:
messages_wire = profile.api_mode == "anthropic_messages"
body = profile.build_extra_body(model=model, base_url=effective_base, reasoning_config=reasoning_config) or {}
reasoning_extra, top_level = profile.build_api_kwargs_extras(
reasoning_config=reasoning_config, supports_reasoning=reasoning_config is not None,
@@ -6082,7 +6102,7 @@ def _project_provider_profile(
)
except Exception as exc:
logger.debug("_build_call_kwargs: provider profile projection failed for %s: %s", provider, exc)
return _ProfileProjection(body, reasoning_extra, top_level, handles_reasoning)
return _ProfileProjection(body, reasoning_extra, top_level, handles_reasoning, messages_wire)
def _merge_aux_extra_body(
@@ -6148,11 +6168,14 @@ def _build_call_kwargs(
kwargs["extra_body"] = merged_extra
# Anthropic Messages adapters take reasoning via a private kwarg that plain OpenAI SDK clients
# would reject; Portal Claude is dual-wire, so include it only when the catalog id selects
# /v1/messages.
# /v1/messages. A profile declaring api_mode=anthropic_messages (commandcode-anthropic) is on
# that wire regardless of URL shape — once it overrides build_api_kwargs_extras the generic
# ``extra_body.reasoning`` fallback the adapter used to read is gone, so this is the adapter's
# only path. _wrap_transport wraps such providers on the same declaration.
if reasoning_config and isinstance(reasoning_config, dict):
raw_base = base_url or ""
if (
provider_norm == "anthropic" or _nous_on_messages_wire(provider_norm, model)
provider_norm == "anthropic" or projection.messages_wire or _nous_on_messages_wire(provider_norm, model)
or _endpoint_speaks_anthropic_messages(raw_base) or _is_anthropic_compat_endpoint(provider_norm, raw_base)
):
kwargs["_reasoning_config"] = dict(reasoning_config)
+5 -6
View File
@@ -214,12 +214,11 @@ def _env(name: str) -> str:
def _scoped_env(name: str) -> str:
"""Credential-bearing env read via the profile secret scope so a multiplexed profile never reports
another profile's env-bridged credentials; unscoped CLI probes fall back to plain env."""
try:
from agent.secret_scope import get_secret
return (get_secret(name) or "").strip()
except Exception: # UnscopedSecretError, import failure, or any scope error
return _env(name)
another profile's env-bridged credentials. Unscoped CLI probes (multiplex off) read the process
env through ``get_secret`` itself; a scope-less multiplex caller raises — spawn-site bug."""
from agent.secret_scope import get_secret
return (get_secret(name) or "").strip()
# (label, predicate) for env-var-driven credential sources, in chain order.
+70 -2
View File
@@ -16,6 +16,7 @@ from contextlib import contextmanager, suppress
from dataclasses import dataclass, field
from typing import Any, Dict, Iterator, List, Optional, Tuple
from agent.prompt_cache_scope import resolve_prompt_cache_scope_safe
from agent.thread_scoped_output import thread_scoped_silence
logger = logging.getLogger(__name__)
@@ -789,6 +790,30 @@ def _same_model_parity_kwargs(agent: Any) -> Dict[str, Any]:
return kwargs
def _warn_ignored_reasoning_effort(agent: Any, task_cfg: Optional[Dict[str, Any]] = None) -> None:
"""One-shot user-visible notice: ``auxiliary.background_review.reasoning_effort`` is IGNORED on
the same-model path (#104116). The fork inherits the parent's ``reasoning_config`` verbatim so
its request bytes keep the parent's prompt-cache prefix (#30532: a diverged ``thinking`` field
on the fork-birth request re-created a large share of the cache); the no-op used to be silent,
so a user who set the key saw no feedback at all. Gated on the parent so a nudge-per-turn
session warns once, not per fork."""
effort = str(_background_review_task_config(task_cfg).get("reasoning_effort") or "").strip()
if not effort or getattr(agent, "_warned_bg_review_reasoning_effort", False):
return
agent._warned_bg_review_reasoning_effort = True
message = (
f"⚠ auxiliary.background_review.reasoning_effort='{effort}' has no effect while the review "
"runs on the main model: the fork inherits the conversation's reasoning effort to keep the "
"parent's prompt-cache prefix (see memory docs, same-model review reasoning). Route the "
"review elsewhere via auxiliary.background_review.provider/model to use a different effort."
)
emit = getattr(agent, "_emit_warning", None)
if callable(emit):
with suppress(Exception):
emit(message)
logger.warning("%s", message)
def _detach_fork_compression(review_agent: Any) -> None:
"""Detached in-memory compaction for a fork sharing the parent's session_id. Disabling
compression (the old guard against compacting the parent's live session) removed the only
@@ -820,7 +845,27 @@ def _detach_fork_compression(review_agent: Any) -> None:
review_agent._review_defer_compaction_before_first_response = True
def _fork_init_kwargs(agent: Any, rt: Dict[str, Any], routed: bool, max_iterations: int) -> Dict[str, Any]:
def _routed_reasoning_config(task_cfg: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""``reasoning_config`` for a ROUTED fork from ``auxiliary.background_review.reasoning_effort``
(#94825). The routed branch never inherits the parent's effort (its vocabulary may be invalid for
the routed provider), but an explicit per-task pin is the user's choice for THAT model and must
win over provider defaults, as every other aux task already does via ``_get_task_extra_body``.
None = unset (provider default); an unknown level warns and falls through to the default."""
effort = _background_review_task_config(task_cfg).get("reasoning_effort")
if effort is None or effort == "":
return None
from hermes_constants import VALID_REASONING_EFFORTS, parse_reasoning_effort
parsed = parse_reasoning_effort(effort)
if parsed is None:
logger.warning(
"auxiliary.background_review.reasoning_effort %r is not a valid level (none, %s) — using "
"the routed provider's default", effort, ", ".join(VALID_REASONING_EFFORTS),
)
return parsed
def _fork_init_kwargs(agent: Any, rt: Dict[str, Any], routed: bool, max_iterations: int,
task_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""AIAgent constructor kwargs for the review fork. skip_memory=True: an external memory plugin
scoped to the parent's session_id would leak the harness prompt into the user's real memory
namespace; built-in MEMORY.md/USER.md state is re-bound by the caller. Toolsets match the
@@ -841,6 +886,8 @@ def _fork_init_kwargs(agent: Any, rt: Dict[str, Any], routed: bool, max_iteratio
kwargs.update(acp_command=rt["command"], acp_args=rt.get("args") or [])
if not routed:
kwargs.update(_same_model_parity_kwargs(agent))
elif (routed_cfg := _routed_reasoning_config(task_cfg)) is not None:
kwargs["reasoning_config"] = routed_cfg
return kwargs
@@ -877,7 +924,12 @@ def build_cache_parity_fork(
# OAuth-only providers, session-scoped creds and credential pools.
_rt = _resolve_review_runtime(agent, task_cfg)
_routed = bool(_rt.get("routed"))
review_agent = AIAgent(**_fork_init_kwargs(agent, _rt, _routed, max_iterations))
# A configured effort is dropped on the same-model path (cache parity) — say so once, visible,
# instead of leaving the set-but-ignored key invisible (#104116). Routed forks honor it
# (_routed_reasoning_config).
if not _routed and write_origin == "background_review":
_warn_ignored_reasoning_effort(agent, task_cfg)
review_agent = AIAgent(**_fork_init_kwargs(agent, _rt, _routed, max_iterations, task_cfg))
review_agent._memory_write_origin = review_agent._memory_write_context = write_origin
review_agent._memory_store = agent._memory_store
review_agent._memory_enabled = agent._memory_enabled
@@ -906,6 +958,22 @@ def build_cache_parity_fork(
if not _routed:
review_agent._cached_system_prompt = agent._cached_system_prompt
review_agent.session_start = agent.session_start
# Cache-scope parity (#109964): the fork shares the parent's physical session_id and
# byte-identical prefix, but is _persist_disabled (declared scope fails closed) and
# _session_db=None (lineage walk skipped) — so BOTH cache-identity resolvers keyed it
# into a different bucket than the gateway parent, costing one cold ~full-context
# request per review. Inherit the parent's ALREADY-RESOLVED scope once, here: no DB
# access from the fork, persistence stays fully detached, and both consumers (the
# affinity header via set_affinity_scope and the body prompt_cache_key via
# cache_scope_id) resolve the parent's bucket together. Routed (different-model)
# forks do NOT inherit: their prefix is cache-cold anyway.
inherited_scope = resolve_prompt_cache_scope_safe(agent)
if inherited_scope:
review_agent._inherited_cache_scope = inherited_scope
# Same reason for the Portal ``conversation=`` tag: with no DB the fork's own
# _conversation_root_id() falls back to the parent's PHYSICAL id, so after a compression
# rotation the review's usage was attributed to a different conversation than its parent.
review_agent._cached_conversation_root = agent._conversation_root_id()
_inherit_parent_tool_surface(review_agent, agent)
_detach_fork_compression(review_agent)
# Compaction bounds a single request; this bounds the WHOLE review (checked in
+50 -69
View File
@@ -37,8 +37,11 @@ from agent.gemini_native_adapter import is_native_gemini_base_url
from agent.model_metadata import is_local_endpoint
from agent.message_content import flatten_message_text
from agent.message_metadata import append_message, stamp_message_timestamp
from agent.message_sanitization import (_sanitize_surrogates, _repair_tool_call_arguments)
from agent.reasoning_summaries import separate_glued_reasoning_blocks
from agent.message_sanitization import (
_sanitize_surrogates, _repair_tool_call_arguments, normalize_finish_reason as _normalize_finish_reason,
sanitize_outbound_kwargs,
)
from agent.reasoning_summaries import append_streamed_reasoning_detail, separate_glued_reasoning_blocks
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
from tools.terminal_tool_lifecycle import is_persistent_env
from utils import base_url_host_matches, base_url_hostname, env_float, env_int
@@ -2009,66 +2012,13 @@ def _managed_summary_call(agent, api_request_id: str, request, callback, *, retr
)
def _iteration_summary_chat_kwargs(agent, api_messages: list) -> dict:
"""chat.completions.create kwargs for the summary, mirroring ChatCompletionsTransport.build_kwargs()."""
try:
from agent.auxiliary_client import _fixed_temperature_for_model, OMIT_TEMPERATURE as _OMIT_TEMP
except Exception:
_fixed_temperature_for_model = _OMIT_TEMP = None
raw_temp = _fixed_temperature_for_model(agent.model, agent.base_url) if _fixed_temperature_for_model is not None else None
temperature = None if raw_temp is _OMIT_TEMP else raw_temp
provider_name = (agent.provider or "").strip().lower()
# LM Studio uses top-level `reasoning_effort` (not extra_body.reasoning).
is_lmstudio = provider_name == "lmstudio" and agent._supports_reasoning_extra_body()
lm_reasoning_effort = agent._resolve_lmstudio_summary_reasoning_effort() if is_lmstudio else None
extra_body = {}
if not is_lmstudio and agent._supports_reasoning_extra_body():
extra_body["reasoning"] = agent.reasoning_config if agent.reasoning_config is not None else {"enabled": True, "effort": "medium"}
if "nousresearch" in agent._base_url_lower:
from agent.portal_tags import nous_portal_tags
extra_body["tags"] = nous_portal_tags()
summary_kwargs = {"model": agent.model, "messages": api_messages}
if temperature is not None:
summary_kwargs["temperature"] = temperature
if agent.max_tokens is not None:
summary_kwargs.update(agent._max_tokens_param(agent.max_tokens))
if lm_reasoning_effort is not None:
summary_kwargs["reasoning_effort"] = lm_reasoning_effort
# Merge the profile's canonical body even when routing is unset (e.g. required Portal tags).
provider_preferences = _provider_preferences_for_agent(agent)
profile_extra_body = {}
with contextlib.suppress(Exception):
from providers import get_provider_profile
provider_profile = get_provider_profile(agent.provider)
if provider_profile is not None:
profile_extra_body = provider_profile.build_extra_body(
session_id=getattr(agent, "session_id", None), provider_preferences=provider_preferences or None,
model=agent.model, base_url=agent.base_url, reasoning_config=agent.reasoning_config)
if profile_extra_body:
extra_body.update(profile_extra_body)
def _is_openrouter() -> bool:
return provider_name == "openrouter" or agent._is_openrouter_url()
if provider_preferences and "provider" not in profile_extra_body and _is_openrouter():
extra_body["provider"] = provider_preferences
# Pareto Code router plugin — model-gated, same shape as the main-loop emission.
_score = agent.openrouter_min_coding_score
if agent.model == "openrouter/pareto-code" and _is_openrouter() and _score is not None and _score != "":
with contextlib.suppress(TypeError, ValueError):
_ps = float(_score)
if 0.0 <= _ps <= 1.0:
extra_body["plugins"] = [{"id": "pareto-router", "min_coding_score": _ps}]
if extra_body:
summary_kwargs["extra_body"] = extra_body
return summary_kwargs
def _summary_text(agent, response, **normalize_kwargs) -> str:
return (agent._get_transport().normalize_response(response, **normalize_kwargs).content or "").strip()
normalized = agent._get_transport().normalize_response(response, **normalize_kwargs)
if normalized.tool_calls:
# No summary path executes tool calls; log so a tool-only response that falls into the
# empty-summary retry is diagnosable.
logger.warning("Iteration summary emitted tool calls; discarding them")
return (normalized.content or "").strip()
def _codex_summary_attempt(agent, api_messages: list, api_request_id: str):
@@ -2096,7 +2046,14 @@ def _anthropic_summary_attempt(agent, api_messages: list, api_request_id: str):
def _chat_summary_attempt(agent, api_messages: list, api_request_id: str):
summary_kwargs = _iteration_summary_chat_kwargs(agent, api_messages)
# Same kwargs builder as the main loop so the summary keeps the cached prefix (tools,
# prompt_cache_key, xAI alias, Moonshot sanitization). Do not omit tools or force
# tool_choice="none" here: SGLang renders the prompt with tools=None in that mode and the KV
# prefix diverges. (cache_control breakpoint decoration is not re-applied on this path.)
summary_kwargs = agent._build_api_kwargs(api_messages)
# The summary now carries ``tools``; on cache-planned routes the main loop scrubbed a deep
# copy, so ``agent.tools`` may still hold bytes the provider 400s on.
sanitize_outbound_kwargs(agent, summary_kwargs)
def _attempt(retry_count: int) -> str:
summary_client = agent._ensure_primary_openai_client(reason="iteration_limit_summary_retry" if retry_count else "iteration_limit_summary")
@@ -2582,6 +2539,9 @@ class _StreamingCall(StreamingWaitMonitor):
self.stream_attempt_state["current"] += 1
attempt_id = int(self.stream_attempt_state["current"])
self.provider_tool_in_flight["yes"] = False
# Attempt-local like provider_tool_in_flight: a tool name from a stream that died
# before any text must not label a later attempt's partial stub or its retry decision.
self.result["partial_tool_names"] = []
return attempt_id
def _cancel_current_stream_attempt(self, reason: str) -> None:
@@ -2768,6 +2728,7 @@ class _StreamingCall(StreamingWaitMonitor):
base_timeout, read_timeout, conn_cap = self._stream_timeouts()
content_parts: list = []
reasoning_parts: list = []
reasoning_details: list = [] # OpenRouter replay data (signatures, encrypted blocks)
pending_text_parts: list[str] = []
tool_calls = _ToolCallAccumulator()
tool_calls_acc = tool_calls.acc
@@ -2831,7 +2792,7 @@ class _StreamingCall(StreamingWaitMonitor):
delta = choice.delta
# Read finish_reason/usage BEFORE any content-shape `continue`: the SSE-echo
# guard can swallow a merged finish chunk (vLLM standalone ':' tokens).
finish_reason = getattr(choice, "finish_reason", None) or finish_reason
finish_reason = _normalize_finish_reason(getattr(choice, "finish_reason", None)) or finish_reason
if hasattr(chunk, "usage") and chunk.usage:
usage_obj = chunk.usage
@@ -2842,6 +2803,15 @@ class _StreamingCall(StreamingWaitMonitor):
reasoning_parts[-1] if reasoning_parts else "", reasoning_text)
reasoning_parts.append(reasoning_text)
self._emit_reasoning(reasoning_text)
# Structured reasoning_details deltas carry the provider's replay data; the
# non-streaming path already keeps them, so dropping them here lost
# reasoning continuity on nearly every turn. Pydantic parks unknown fields
# in ``model_extra``.
rd_delta = getattr(delta, "reasoning_details", None)
if rd_delta is None and isinstance(getattr(delta, "model_extra", None), dict):
rd_delta = delta.model_extra.get("reasoning_details")
for rd in rd_delta if isinstance(rd_delta, (list, tuple)) else ():
append_streamed_reasoning_detail(reasoning_details, rd)
# Text (list-of-blocks deltas flattened once); possible echoed SSE is
# buffered until it can be judged.
@@ -2877,7 +2847,7 @@ class _StreamingCall(StreamingWaitMonitor):
return self._adopt_final_response(stream.final_response)
return self._finish_chat_stream(stream, role, content_parts, reasoning_parts, tool_calls_acc,
finish_reason, model_name, usage_obj, flush_pending=_flush_pending_stream_text,
response_id=response_id, upstream_provider=upstream_provider)
response_id=response_id, upstream_provider=upstream_provider, reasoning_details=reasoning_details)
def _adopt_final_response(self, final_response):
"""Adapter returned a completed response for ``stream=True``: switch the
@@ -2926,7 +2896,7 @@ class _StreamingCall(StreamingWaitMonitor):
return mock_tool_calls or None, has_truncated_tool_args
def _finish_chat_stream(self, stream, role, content_parts, reasoning_parts, tool_calls_acc, finish_reason,
model_name, usage_obj, *, flush_pending, response_id=None, upstream_provider=None):
model_name, usage_obj, *, flush_pending, response_id=None, upstream_provider=None, reasoning_details=None):
"""Assemble the non-streaming-shaped response after the chunk loop. A
stream ending with no finish_reason is a drop, not a completion: return a
partial-stream stub so the loop fails fast instead of executing empty
@@ -2948,9 +2918,11 @@ class _StreamingCall(StreamingWaitMonitor):
_dropped_names)
return _build_partial_stream_stub(
role, full_content, full_reasoning, model_name, usage_obj, dropped_tool_names=_dropped_names or None)
if finish_reason is None and content_parts and not tool_calls_acc and usage_obj is None:
# Text-only drop: otherwise the partial text is stamped "stop" and the next step is
# lost. A usage object proves the provider finished (include_usage's final chunk).
if finish_reason is None and (content_parts or reasoning_parts) and not tool_calls_acc and usage_obj is None:
# Text-only (or reasoning-only) drop: otherwise the partial text is stamped "stop"
# and the next step is lost — for reasoning-only, the clean-stop promotion in
# finish_text_response would then surface a truncated thought as the answer.
# A usage object proves the provider finished (include_usage's final chunk).
logger.warning(
"Stream ended with no finish_reason after delivering text with no tool calls; treating as a mid-stream drop.")
return _build_partial_stream_stub(role, full_content, full_reasoning, model_name, usage_obj)
@@ -2961,6 +2933,10 @@ class _StreamingCall(StreamingWaitMonitor):
raise provider_stream_error
flush_pending()
message = SimpleNamespace(role=role, content=full_content, tool_calls=mock_tool_calls, reasoning_content=full_reasoning)
if reasoning_details:
# Only when present: _build_assistant_message's passthrough persists them
# for replay, and non-reasoning providers keep the attribute absent.
message.reasoning_details = reasoning_details
# The provider's id when the chunks carried one (chatcmpl-/gen-...): it is what a provider needs to
# look a request up. Fabricated only when the stream never sent one.
return SimpleNamespace(id=response_id or ("stream-" + str(uuid.uuid4())), model=model_name, usage=usage_obj,
@@ -3038,6 +3014,9 @@ class _StreamingCall(StreamingWaitMonitor):
has_tool_use = True
if getattr(block, "name", None):
self._emit_tool_started(block.name)
# Same as the chat_completions wire: a stream that dies inside the
# tool args is retried (no tool has run yet) instead of stubbed.
self.result["partial_tool_names"].append(block.name)
elif event_type == "content_block_delta":
delta = getattr(event, "delta", None)
delta_type = getattr(delta, "type", None) if delta else None
@@ -3080,6 +3059,9 @@ class _StreamingCall(StreamingWaitMonitor):
OpenAI primary is replaced lazily."""
self.agent._emit_stream_drop(
error=e, attempt=attempt + 2, max_attempts=max_retries + 1, mid_tool_call=mid_tool_call, diag=self.clients.diag)
if self.agent._is_provider_stream_parse_error(e):
from agent.anthropic_adapter import buffer_anthropic_tool_input
buffer_anthropic_tool_input(self.api_kwargs, getattr(self.agent, "_anthropic_base_url", None))
self._cancel_current_stream_attempt(reason)
self.clients.close_once(reason)
@@ -3144,7 +3126,6 @@ class _StreamingCall(StreamingWaitMonitor):
# reset the streamed-text buffer so it isn't double-recorded; fresh accumulators.
self._quiet(self.agent._fire_stream_delta, "\n\n⚠ Connection dropped mid tool-call; reconnecting…\n\n")
self._quiet(self.agent._reset_stream_delivery_tracking)
self.result["partial_tool_names"] = []
self.deltas_were_sent["yes"] = False
self.first_delta_fired["done"] = False
self._retry_after_drop(e, attempt, max_retries, mid_tool_call=True, reason="stream_mid_tool_retry_cleanup")
+4 -2
View File
@@ -32,7 +32,8 @@ _DETAILS_TABLE_LIMIT = 15 # display cap only; the underlying data keeps everyth
def _chars_to_tokens(text: str) -> int:
return (len(text) + 3) // 4
from agent.model_metadata import estimate_tokens_rough
return estimate_tokens_rough(text)
def _json_tokens(value: Any) -> int:
@@ -40,7 +41,8 @@ def _json_tokens(value: Any) -> int:
def _bytes_to_tokens(size: Optional[int]) -> Optional[int]:
return None if size is None else (int(size) + 3) // 4
from agent.model_metadata import CHARS_PER_TOKEN
return None if size is None else (int(size) + 3) // CHARS_PER_TOKEN
def _skills_block(stable: str) -> str:
+2 -2
View File
@@ -27,7 +27,7 @@ from agent.error_classifier import FailoverReason, classify_api_error
from agent.micro_compaction import MicroCompactionMixin
from agent.prompt_builder import STEER_DISPLAY_KIND
from agent.model_metadata import (
MINIMUM_CONTEXT_LENGTH, get_model_context_length, estimate_messages_tokens_rough, estimate_tokens_rough,
CHARS_PER_TOKEN, MINIMUM_CONTEXT_LENGTH, get_model_context_length, estimate_messages_tokens_rough, estimate_tokens_rough,
strip_opaque_replay_items,
)
from agent.redact import redact_sensitive_text
@@ -955,7 +955,7 @@ def _collect_protected_skill_names(messages: List[Dict[str, Any]], prune_boundar
}
_CHARS_PER_TOKEN = 4
_CHARS_PER_TOKEN = CHARS_PER_TOKEN
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
# Fallback handoff preserves continuity anchors only, not a transcript copy.
+10 -4
View File
@@ -33,6 +33,7 @@ from agent.memory_provider import PRE_COMPRESS_CHECKPOINT_API_VERSION
from agent.model_metadata import estimate_messages_tokens_rough, estimate_request_tokens_rough
from agent.session_activity import ActivityProvenance, normalize_activity_provenance
from agent.usage_anchor import set_usage_anchor
from hermes_state_ids import new_session_id as mint_session_id
logger = logging.getLogger(__name__)
@@ -2980,7 +2981,7 @@ def _publish_rotated_compaction(
if _profile_for_child == "default":
_profile_for_child = None
old_title = agent._session_db.get_session_title(agent.session_id)
new_session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
new_session_id = mint_session_id()
from agent.context_compressor import _DB_PERSISTED_MARKER
agent._session_db.publish_compression_child(
parent_session_id=old_session_id, child_session_id=new_session_id,
@@ -3039,14 +3040,19 @@ def _warn_summary_or_aux_fallback(agent: Any) -> None:
)
def _reset_read_dedup_caches(task_id: str, *, skills: bool = True) -> None:
def _reset_read_dedup_caches(task_id: str, *, session_id: str = "", skills: bool = True) -> None:
"""Advance the file-read (and skill_view) repeat-read dedup to a fresh generation after a boundary.
The mtime map is kept: the first read of each unchanged key returns full content compaction may have
omitted; later reads return stubs, and stub-hit counters restart at the same boundary (#84857).
The computer_use screenshot dedup is session-keyed and forgets its last frame for the same reason.
"""
with contextlib.suppress(Exception):
from tools.file_tools_read_tracking import reset_file_dedup
reset_file_dedup(task_id)
if session_id:
with contextlib.suppress(Exception):
from tools.computer_use.tool import reset_screenshot_dedup
reset_screenshot_dedup(session_id)
if not skills:
return
with contextlib.suppress(Exception):
@@ -3144,7 +3150,7 @@ def _finish_compaction_boundary(
)
else:
compressor._verify_compaction_cleared_threshold = True
_reset_read_dedup_caches(task_id)
_reset_read_dedup_caches(task_id, session_id=agent.session_id or "")
return _compressed_est
@@ -3831,7 +3837,7 @@ def _compress_context_via_codex_app_server(
# armed until a later turn; minimal test engines may lack update_from_response.
if hasattr(agent.context_compressor, "update_from_response"):
_record_codex_app_server_usage(agent, result, messages=messages)
_reset_read_dedup_caches(task_id, skills=False)
_reset_read_dedup_caches(task_id, session_id=agent.session_id or "", skills=False)
logger.info(
"codex app-server compaction done: session=%s thread=%s turn=%s", _sid,
getattr(result, "thread_id", None) or "", getattr(result, "turn_id", None) or "",
+137
View File
@@ -0,0 +1,137 @@
"""Manual ``/compress`` core shared by the CLI, gateway, TUI and ACP surfaces.
Manual compression is the ONE sanctioned history mutation (prompt-cache invariant): each surface parses
its own flags and renders its own text, but the sequence — split for ``here [N]``, estimate, run
``agent._compress_context(force=True)``, detect a lock-skip, rejoin the verbatim tail, summarize — lives
here so ``--preview`` / ``--aggressive`` and the lock-skip wording cannot drift per surface again.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence
#: Every surface renders the same refusal; hard truncation has no persistence path outside the guarded
#: ``_compress_context`` rotation, so ``--aggressive`` is refused rather than mis-parsed as a focus topic.
AGGRESSIVE_UNSUPPORTED = (
"--aggressive is not supported; use '/compress here [N]' to keep only recent exchanges, "
"or /undo to drop turns.")
MIN_MESSAGES = 4
@dataclass
class CompressRequest:
"""Parsed ``/compress`` arguments (``extract_compress_flags`` + ``parse_partial_compress_args``)."""
preview: bool = False
aggressive: bool = False
partial: bool = False
keep_last: int = 2
focus_topic: Optional[str] = None
@dataclass
class CompressResult:
status: str # "preview" | "compressed" | "lock_skipped" | "nothing_to_do"
before_messages: List[Dict[str, Any]]
after_messages: List[Dict[str, Any]]
before_tokens: int
after_tokens: int
request: CompressRequest
lines: List[str] = field(default_factory=list) # preview report lines (status == "preview")
lock_holder: Any = None
summary: Optional[Dict[str, Any]] = None # ``summarize_manual_compression`` payload when compressed
@property
def removed(self) -> int:
return len(self.before_messages) - len(self.after_messages)
def parse_compress_args(raw_args: str) -> CompressRequest:
"""One parser for every surface: flags anywhere, then the boundary-aware / focus positional forms."""
from hermes_cli.partial_compress import extract_compress_flags, parse_partial_compress_args
rest, preview, aggressive = extract_compress_flags((raw_args or "").strip())
partial, keep_last, focus_topic = parse_partial_compress_args(rest)
return CompressRequest(preview=preview, aggressive=aggressive, partial=partial, keep_last=keep_last,
focus_topic=focus_topic or None)
def estimate_request_tokens(agent: Any, messages: Sequence[Dict[str, Any]]) -> int:
"""Transcript + system prompt + tool schemas: a transcript-only figure understates real request pressure
and can even appear to grow after a dense handoff summary replaces many short turns (#6217)."""
from agent.model_metadata import estimate_request_tokens_rough
if not messages:
return 0
return estimate_request_tokens_rough(
list(messages), system_prompt=getattr(agent, "_cached_system_prompt", "") or "",
tools=getattr(agent, "tools", None) or None)
def compress_now(
agent: Any, history: Sequence[Dict[str, Any]], request: CompressRequest, *,
system_message: Any = None, task_id: str = "default", skip_without_window: bool = False,
) -> CompressResult:
"""Run one manual compression of ``history`` on ``agent`` and return the outcome; the caller installs
``after_messages`` (and re-anchors session ids) — history is never mutated here.
``preview=True`` performs no compression and leaves ``agent`` untouched. A held compression lock
yields ``lock_skipped`` with the agent's signal cleared and the deferred context-engine notification
discarded; otherwise the caller must call ``finalize_context_engine_compression_notification(agent,
committed=True)`` once its own history transaction commits (``committed=False`` on failure).
``system_message=None`` makes ``_compress_context`` rebuild the prompt; passing the cached prompt
duplicated the identity block (#15281). ``skip_without_window`` (gateway) answers ``nothing_to_do``
when the local compressor sees no summarizable middle; the in-process surfaces leave it off because
``_compress_context`` still does useful work there — codex_app_server native compaction, and the
phase-1 tool-result prune / blank-echo drop that ``ContextCompressor.compress`` commits even when no
summary window exists."""
from agent.conversation_compression import finalize_context_engine_compression_notification
from agent.manual_compression_feedback import summarize_manual_compression
from hermes_cli.partial_compress import (
rejoin_compressed_head_and_tail, split_history_for_partial_compress, summarize_compress_preview)
before = list(history)
before_tokens = estimate_request_tokens(agent, before)
head, tail = before, []
if request.partial:
head, tail = split_history_for_partial_compress(before, request.keep_last)
if not tail: # degenerate split: nothing to keep verbatim → full compression
head = before
if request.preview:
report = summarize_compress_preview(before, request.partial, request.keep_last, request.focus_topic, before_tokens)
return CompressResult("preview", before, before, before_tokens, before_tokens, request, lines=report["lines"])
compressor = getattr(agent, "context_compressor", None)
has_content = getattr(compressor, "has_content_to_compress", None)
if skip_without_window and callable(has_content) and has_content(head) is False:
return CompressResult("nothing_to_do", before, before, before_tokens, before_tokens, request)
try:
compressed, _ = agent._compress_context(
head, system_message, approx_tokens=before_tokens, focus_topic=request.focus_topic, force=True,
defer_context_engine_notification=True, **({"task_id": task_id} if task_id != "default" else {}))
except Exception:
finalize_context_engine_compression_notification(agent, committed=False)
raise
# Type-pinned (is True / str): bare truthiness is fooled by MagicMock auto-attributes on test doubles.
lock_signal = getattr(agent, "_compression_skipped_due_to_lock", None)
if lock_signal is True or isinstance(lock_signal, str):
agent._compression_skipped_due_to_lock = None
finalize_context_engine_compression_notification(agent, committed=False)
return CompressResult("lock_skipped", before, before, before_tokens, before_tokens, request,
lock_holder=lock_signal if isinstance(lock_signal, str) else None)
if tail:
compressed = rejoin_compressed_head_and_tail(compressed, tail)
after_tokens = estimate_request_tokens(agent, compressed)
summary = summarize_manual_compression(before, compressed, before_tokens, after_tokens, compression_state=compressor)
return CompressResult("compressed", before, list(compressed), before_tokens, after_tokens, request, summary=summary)
def render_compress_result(result: CompressResult, *, prefix: str = "") -> List[str]:
"""Surface-neutral text lines for a result (each surface may add its own icon/prefix)."""
if result.status == "preview":
return [f"{prefix}{line}" for line in result.lines]
if result.status == "lock_skipped":
from agent.manual_compression_feedback import describe_compression_lock_skip
return [f"{prefix}{describe_compression_lock_skip(result.lock_holder or True)}"]
if result.status == "nothing_to_do":
return [f"{prefix}Nothing to compress yet."]
summary = result.summary or {}
return [f"{prefix}{line}" for line in (summary.get("headline"), summary.get("token_line"), summary.get("note")) if line]
+73 -24
View File
@@ -17,6 +17,7 @@ import subprocess
import threading
import time
from collections import deque
from collections.abc import Callable, Iterator
from pathlib import Path
from types import SimpleNamespace
from typing import Any
@@ -139,10 +140,36 @@ def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": message_id, "error": {"code": code, "message": message}}
def _enabled_ids(entries: Any, key: str) -> set[str]:
"""Ids of ``entries`` (dicts) whose ``_meta.copilotEnablement`` is not ``disabled``."""
return {str(e.get(key) or "").strip() for e in (entries or []) if isinstance(e, dict)
and str((e.get("_meta") or {}).get("copilotEnablement") or "").strip().lower() != "disabled"}
def _enabled_id_list(entries: Any, key: str) -> list[str]:
"""Ordered ids whose ``_meta.copilotEnablement`` is not ``disabled``."""
seen: set[str] = set()
result: list[str] = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
value = str(entry.get(key) or "").strip()
if (not value or value in seen
or str((entry.get("_meta") or {}).get("copilotEnablement") or "").strip().lower() == "disabled"):
continue
seen.add(value)
result.append(value)
return result
def _model_config_option(session: dict[str, Any]) -> dict[str, Any] | None:
return next((option for option in (session.get("configOptions") or []) if isinstance(option, dict)
and "model" in (option.get("category"), option.get("id"))), None)
def _session_model_ids(session: dict[str, Any]) -> list[str]:
"""Account-authorized model ids advertised by ``session/new`` in ACP v1 or its legacy extension."""
if option := _model_config_option(session):
return _enabled_id_list(option.get("options"), "value")
return _legacy_session_model_ids(session)
def _legacy_session_model_ids(session: dict[str, Any]) -> list[str]:
return _enabled_id_list((session.get("models") or {}).get("availableModels"), "modelId")
def _model_selection_request(session: dict[str, Any], requested_model: str) -> tuple[str, dict[str, str]] | None:
@@ -154,12 +181,12 @@ def _model_selection_request(session: dict[str, Any], requested_model: str) -> t
requested_model = str(requested_model or "").strip()
if not session_id or not requested_model or requested_model == "copilot-acp":
return None
options = [o for o in (session.get("configOptions") or []) if isinstance(o, dict) and "model" in (o.get("category"), o.get("id"))]
if options:
if requested_model not in _enabled_ids(options[0].get("options"), "value"):
option = _model_config_option(session)
if option:
if requested_model not in _enabled_id_list(option.get("options"), "value"):
return None
return "session/set_config_option", {"sessionId": session_id, "configId": str(options[0].get("id") or "model"), "value": requested_model}
available = _enabled_ids((session.get("models") or {}).get("availableModels"), "modelId")
return "session/set_config_option", {"sessionId": session_id, "configId": str(option.get("id") or "model"), "value": requested_model}
available = _legacy_session_model_ids(session)
return None if available and requested_model not in available else ("session/set_model", {"sessionId": session_id, "modelId": requested_model})
@@ -327,10 +354,11 @@ class CopilotACPClient:
self._active_process = proc
return proc
def _run_prompt(self, prompt_text: str, *, timeout_seconds: float, model: str | None = None) -> tuple[str, str]:
# The CLI's `--model` spawn flag is deliberately NOT used: `copilot --acp` validates it (unknown id
# aborts the spawn) but ignores it for the session; the model is applied after session/new instead.
requested_model = str(model or "").strip()
@contextlib.contextmanager
def _session(
self, timeout_seconds: float, *, allow_file_requests: bool = True
) -> Iterator[tuple[dict[str, Any], Callable[..., Any]]]:
"""Start one ACP process and yield its ``session/new`` result plus request callable."""
proc = self._spawn()
inbox: queue.Queue[dict[str, Any]] = queue.Queue()
stderr_tail: deque[str] = deque(maxlen=40)
@@ -348,19 +376,24 @@ class CopilotACPClient:
threading.Thread(target=_pump, args=(proc.stdout, lambda line: inbox.put(_decode(line))), daemon=True).start()
threading.Thread(target=_pump, args=(proc.stderr, lambda line: stderr_tail.append(line.rstrip("\n"))), daemon=True).start()
request_ids = iter(range(1, 1 << 62))
# One budget for the WHOLE session (initialize + session/new + any prompt), not per
# request: a hung CLI must not get 2x the caller's timeout on the foreground /model path.
session_deadline = time.monotonic() + timeout_seconds
def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | None = None, reasoning_parts: list[str] | None = None) -> Any:
def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | None = None,
reasoning_parts: list[str] | None = None) -> Any:
request_id = next(request_ids)
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + "\n")
proc.stdin.flush()
deadline = time.monotonic() + timeout_seconds
deadline = session_deadline
while time.monotonic() < deadline and proc.poll() is None:
try:
msg = inbox.get(timeout=0.1)
except queue.Empty:
continue
if self._handle_server_message(
msg, process=proc, cwd=self._acp_cwd, text_parts=text_parts, reasoning_parts=reasoning_parts
msg, process=proc, cwd=self._acp_cwd, text_parts=text_parts,
reasoning_parts=reasoning_parts, allow_file_requests=allow_file_requests,
) or msg.get("id") != request_id:
continue
if "error" in msg:
@@ -377,9 +410,23 @@ class CopilotACPClient:
try:
_request("initialize", _INITIALIZE_PARAMS)
session = _request("session/new", {"cwd": self._acp_cwd, "mcpServers": []}) or {}
session_id = str(session.get("sessionId") or "").strip()
if not session_id:
if not str(session.get("sessionId") or "").strip():
raise RuntimeError("Copilot ACP did not return a sessionId.")
yield session, _request
finally:
self.close()
def list_models(self, *, timeout_seconds: float = 15.0) -> list[str]:
"""Return the enabled models advertised by a short-lived authenticated ACP session."""
with self._session(timeout_seconds, allow_file_requests=False) as (session, _):
return _session_model_ids(session)
def _run_prompt(self, prompt_text: str, *, timeout_seconds: float, model: str | None = None) -> tuple[str, str]:
# The CLI's `--model` spawn flag is deliberately NOT used: `copilot --acp` validates it (unknown id
# aborts the spawn) but ignores it for the session; the model is applied after session/new instead.
requested_model = str(model or "").strip()
with self._session(timeout_seconds) as (session, _request):
session_id = str(session.get("sessionId") or "").strip()
if requested_model and requested_model != "copilot-acp":
try:
if (selection := _model_selection_request(session, requested_model)) is not None:
@@ -393,11 +440,10 @@ class CopilotACPClient:
prompt = {"sessionId": session_id, "prompt": [{"type": "text", "text": prompt_text}]}
_request("session/prompt", prompt, text_parts=text_parts, reasoning_parts=reasoning_parts)
return "".join(text_parts), "".join(reasoning_parts)
finally:
self.close()
def _handle_server_message(
self, msg: dict[str, Any], *, process: subprocess.Popen[str], cwd: str, text_parts: list[str] | None, reasoning_parts: list[str] | None,
allow_file_requests: bool = True,
) -> bool:
"""Consume a server->client message; True when handled (notification or request answered)."""
method = msg.get("method")
@@ -417,10 +463,13 @@ class CopilotACPClient:
if method == "session/request_permission":
response = _jsonrpc_result(message_id, {"outcome": {"outcome": "cancelled"}})
elif method in _FS_HANDLERS:
try:
response = _jsonrpc_result(message_id, _FS_HANDLERS[method](msg.get("params") or {}, cwd))
except Exception as exc:
response = _jsonrpc_error(message_id, -32602, str(exc))
if not allow_file_requests:
response = _jsonrpc_error(message_id, -32601, "File access is unavailable during model discovery.")
else:
try:
response = _jsonrpc_result(message_id, _FS_HANDLERS[method](msg.get("params") or {}, cwd))
except Exception as exc:
response = _jsonrpc_error(message_id, -32602, str(exc))
else:
response = _jsonrpc_error(message_id, -32601, f"ACP client method '{method}' is not supported by Hermes yet.")
process.stdin.write(json.dumps(response) + "\n")
+2 -31
View File
@@ -19,6 +19,7 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL
from hermes_cli.config import load_env
from agent.secret_scope import get_secret as _get_secret
from agent.retry_utils import reset_delay_from_message
from agent.credential_persistence import (
fingerprint_secret_value,
is_borrowed_credential_source,
@@ -367,36 +368,6 @@ def _parse_absolute_timestamp(value: Any) -> Optional[float]:
return None
# (regex, seconds-from-match) pairs tried in order against provider error text.
_RETRY_DELAY_PATTERNS: Tuple[Tuple[re.Pattern, Callable[[re.Match], float]], ...] = (
(
re.compile(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", re.IGNORECASE),
lambda m: float(m.group(1)) / 1000.0 if m.group(2).lower() == "ms" else float(m.group(1)),
),
(
re.compile(r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", re.IGNORECASE),
lambda m: float(m.group(1)),
),
# "Resets in 4hr 5min" format used by OpenCode Go weekly usage limits
(
re.compile(r"resets?\s+in\s+(\d+)\s*hr\s+(\d+)\s*min", re.IGNORECASE),
lambda m: int(m.group(1)) * 3600 + int(m.group(2)) * 60,
),
(re.compile(r"resets?\s+in\s+(\d+)\s*hr\b", re.IGNORECASE), lambda m: int(m.group(1)) * 3600),
(re.compile(r"resets?\s+in\s+(\d+)\s*min\b", re.IGNORECASE), lambda m: int(m.group(1)) * 60),
)
def _extract_retry_delay_seconds(message: str) -> Optional[float]:
if not message:
return None
for pattern, to_seconds in _RETRY_DELAY_PATTERNS:
match = pattern.search(message)
if match:
return to_seconds(match)
return None
def _normalize_error_context(error_context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(error_context, dict):
return {}
@@ -413,7 +384,7 @@ def _normalize_error_context(error_context: Optional[Dict[str, Any]]) -> Dict[st
parsed_reset_at = _parse_absolute_timestamp(reset_at)
message = error_context.get("message")
if parsed_reset_at is None and isinstance(message, str):
retry_delay_seconds = _extract_retry_delay_seconds(message)
retry_delay_seconds = reset_delay_from_message(message)
if retry_delay_seconds is not None:
parsed_reset_at = time.time() + retry_delay_seconds
if parsed_reset_at is not None:
+16 -1
View File
@@ -92,6 +92,11 @@ _BILLING_PATTERNS = (
"billing hard limit", "exceeded your current quota", "account is deactivated", "plan does not include",
"out of extra usage", "out of funds", "run out of funds", "balance_depleted",
"model_not_supported_on_free_tier", "not available on the free tier",
# LiteLLM proxies word a hard cap as "hard billing limit" (structured twin:
# ``terminal_quota_exhausted`` in _BILLING_ERROR_CODES). "terminal billing
# limit" free text is NOT matched: substring rules can't negate the
# "non-terminal billing limit" wording, and the structured code covers it.
"hard billing limit",
)
# Not proof of exhaustion: Anthropic returns the same "out of extra usage" body
@@ -107,7 +112,12 @@ _XAI_SPENDING_LIMIT_ERROR_CODE = "personal-team-blocked:spending-limit"
_BILLING_ERROR_CODES = frozenset({
"insufficient_quota", "billing_not_active", "payment_required", "insufficient_credits",
"no_usable_credits", "balance_depleted", "model_not_supported_on_free_tier",
"member_spend_cap_exceeded", _XAI_SPENDING_LIMIT_ERROR_CODE,
"member_spend_cap_exceeded", "terminal_quota_exhausted", _XAI_SPENDING_LIMIT_ERROR_CODE,
# OpenAI (and OpenAI-compatible aggregators) spend/usage-limit family:
# a credit balance or an org/project spend or usage cap is exhausted —
# terminal for this credential until limits are raised.
"credit_balance_exhausted", "organization_spend_limit_exceeded",
"organization_usage_limit_exceeded", "project_spend_limit_exceeded",
})
# Transient rate limiting. Bedrock "Throttling error: Too many tokens" also
@@ -725,6 +735,11 @@ def _status_404(c: _Ctx) -> Verdict:
def _status_429(c: _Ctx) -> Verdict:
# A structured billing code is decisive: LiteLLM stamps
# ``terminal_quota_exhausted`` (a hard cap, not throttling) on 429s, and
# this handler always returns, so _by_error_code never sees the code.
if c.code in _BILLING_ERROR_CODES:
return _V_BILLING
# Z.AI/Zhipu reuse 429 for server-wide overload: back off on the same
# key instead of burning the pool (#14038).
if any(p in c.msg for p in _OVERLOADED_PATTERNS):
+51 -21
View File
@@ -20,7 +20,8 @@ from typing import Any, Dict, Iterator, List, Optional
import httpx
from agent.bounded_response import read_streaming_error_body
from agent.gemini_schema import sanitize_gemini_tool_parameters
from agent.retry_utils import parse_retry_after_seconds
from agent.gemini_schema import prepare_gemini_tool_parameters, sanitize_gemini_tool_parameters
logger = logging.getLogger(__name__)
@@ -101,6 +102,13 @@ def is_native_gemini_base_url(base_url: str) -> bool:
return "generativelanguage.googleapis.com" in normalized and not normalized.endswith("/openai")
def gemini_accepts_parameters_json_schema(base_url: str) -> bool:
"""``FunctionDeclaration.parametersJsonSchema`` exists only in the ``v1beta`` surface of
generativelanguage (absent from ``v1`` / ``v1alpha`` content.proto); other versions and
unknown proxies get the legacy ``parameters`` subset."""
return str(base_url or "").strip().rstrip("/").lower().endswith("/v1beta")
def probe_gemini_tier(
api_key: str, base_url: str = DEFAULT_GEMINI_BASE_URL, *, model: str = "gemini-3.7-flash", timeout: float = 10.0
) -> str:
@@ -328,7 +336,7 @@ def _build_gemini_contents(
return _merge_alternating(contents), ({"role": "system", "parts": [{"text": joined_system}]} if joined_system else None)
def _function_declaration(tool: Any) -> Optional[Dict[str, Any]]:
def _function_declaration(tool: Any, *, json_schema: bool = False) -> Optional[Dict[str, Any]]:
fn = (tool.get("function") or {}) if isinstance(tool, dict) else None
if not isinstance(fn, dict) or not (isinstance(fn.get("name"), str) and fn["name"]):
return None
@@ -336,12 +344,18 @@ def _function_declaration(tool: Any) -> Optional[Dict[str, Any]]:
if isinstance(fn.get("description"), str) and fn["description"]:
decl["description"] = fn["description"]
if isinstance(fn.get("parameters"), dict):
decl["parameters"] = sanitize_gemini_tool_parameters(fn["parameters"])
# Full JSON Schema where the API version has the field (unions, bare arrays,
# $ref survive); the lossy OpenAPI subset elsewhere. Mutually exclusive on the wire.
if json_schema:
decl["parametersJsonSchema"] = prepare_gemini_tool_parameters(fn["parameters"])
else:
decl["parameters"] = sanitize_gemini_tool_parameters(fn["parameters"])
return decl
def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]:
declarations = [d for d in map(_function_declaration, tools if isinstance(tools, list) else []) if d]
def _translate_tools_to_gemini(tools: Any, *, json_schema: bool = False) -> List[Dict[str, Any]]:
declarations = [d for d in (_function_declaration(t, json_schema=json_schema)
for t in (tools if isinstance(tools, list) else [])) if d]
return [{"functionDeclarations": declarations}] if declarations else []
@@ -395,13 +409,14 @@ def _effective_gemini_max_output_tokens(max_tokens: Optional[int], thinking_conf
def build_gemini_request(
*, messages: List[Dict[str, Any]], tools: Any = None, tool_choice: Any = None, temperature: Optional[float] = None,
max_tokens: Optional[int] = None, top_p: Optional[float] = None, stop: Any = None, thinking_config: Any = None,
model: str = "",
model: str = "", tools_as_json_schema: bool = False,
) -> Dict[str, Any]:
# Gemini 3+ both requires tool-call ids and accepts multimodal functionResponse parts.
is_gemini3 = gemini_requires_tool_call_ids(model)
contents, system_instruction = _build_gemini_contents(messages, include_tool_call_ids=is_gemini3, is_gemini3=is_gemini3)
optional = (
("systemInstruction", system_instruction), ("tools", _translate_tools_to_gemini(tools)),
("systemInstruction", system_instruction),
("tools", _translate_tools_to_gemini(tools, json_schema=tools_as_json_schema)),
("toolConfig", _translate_tool_choice_to_gemini(tool_choice)),
)
request: Dict[str, Any] = {"contents": contents, **{k: v for k, v in optional if v}}
@@ -506,24 +521,41 @@ def _make_stream_chunk(
return _envelope(model, "chat.completion.chunk", choice, None, cls=_GeminiStreamChunk)
_SSE_DONE = object() # sentinel: terminal [DONE] frame
def _parse_sse_line(line: str) -> Any:
"""One SSE line → payload dict, ``_SSE_DONE`` for the terminal frame, or None."""
line = line.rstrip("\r")
if not line.startswith("data: "):
return None
if (data := line[6:]) == "[DONE]":
return _SSE_DONE
try:
payload = json.loads(data)
except json.JSONDecodeError:
logger.debug("Non-JSON Gemini SSE line: %s", data[:200])
return None
return payload if isinstance(payload, dict) else None
def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
buffer = ""
for chunk in response.iter_text():
buffer += chunk or ""
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.rstrip("\r")
if not line.startswith("data: "):
continue
if (data := line[6:]) == "[DONE]":
payload = _parse_sse_line(line)
if payload is _SSE_DONE:
return
try:
payload = json.loads(data)
except json.JSONDecodeError:
logger.debug("Non-JSON Gemini SSE line: %s", data[:200])
continue
if isinstance(payload, dict):
if payload is not None:
yield payload
# The final frame may not be newline-terminated: flush the residual buffer
# after EOF instead of silently dropping its content (pi#8997 bug class).
if buffer:
payload = _parse_sse_line(buffer)
if payload is not None and payload is not _SSE_DONE:
yield payload
def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: Dict[str, Dict[str, Any]]) -> List[_GeminiStreamChunk]:
@@ -591,10 +623,7 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No
err_obj = _error_object(body_text)
err_status, err_message = (str(err_obj.get(k) or "").strip() for k in ("status", "message"))
reason, metadata = _error_info(err_obj)
try:
retry_after: Optional[float] = float(response.headers.get("Retry-After") or response.headers.get("retry-after"))
except (TypeError, ValueError):
retry_after = None
retry_after = parse_retry_after_seconds(response.headers)
message = (
f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}" if err_message
else f"Gemini returned HTTP {status}: {body_text[:500]}"
@@ -660,6 +689,7 @@ class GeminiNativeClient:
request = build_gemini_request(
messages=messages or [], tools=tools, tool_choice=tool_choice, temperature=temperature, max_tokens=max_tokens,
top_p=top_p, stop=stop, thinking_config=extra.get("thinking_config") or extra.get("thinkingConfig"), model=model,
tools_as_json_schema=gemini_accepts_parameters_json_schema(self.base_url),
)
model = bare_gemini_model_id(model)
url = f"{self.base_url}/models/{model}:"
+86 -2
View File
@@ -1,12 +1,22 @@
"""Helpers for translating OpenAI-style tool schemas to Gemini's schema subset."""
"""Tool-schema preparation for Gemini's native API.
Two wire shapes: ``parametersJsonSchema`` (plain JSON Schema, v1beta only) gets a light
normalizer (``prepare_gemini_tool_parameters``); the legacy ``parameters`` field accepts
only the OpenAPI ``Schema`` subset and keeps the lossy translator
(``sanitize_gemini_tool_parameters``) for API versions without the JSON Schema field.
"""
from __future__ import annotations
import copy
import logging
import math
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from tools.schema_sanitizer import _normalize_type_array
logger = logging.getLogger(__name__)
# Gemini's ``FunctionDeclaration.parameters`` accepts only a subset of OpenAPI 3.0 /
# JSON Schema (the ``Schema`` object); everything else is stripped.
_GEMINI_SCHEMA_ALLOWED_KEYS = {
@@ -112,3 +122,77 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
def sanitize_gemini_tool_parameters(parameters: Any) -> Dict[str, Any]:
"""Normalize tool parameters to a valid Gemini object schema."""
return sanitize_gemini_schema(parameters) or {"type": "object", "properties": {}}
# ── parametersJsonSchema (full JSON Schema) ─────────────────────────────────
#
# The legacy translator is lossy: anyOf unions without an outer type, bare arrays,
# $ref/$defs and additionalProperties had to be stripped or repaired, and one
# unrepresentable construct 400s the ENTIRE request. Through parametersJsonSchema the
# schema goes as-is; only same-document $refs are inlined (MCP pydantic / zod emit
# them and Google rejects reference indirection) and root ``$schema`` is dropped.
_EMPTY_OBJECT_SCHEMA: Dict[str, Any] = {"type": "object", "properties": {}}
# Real tool schemas hold a handful of refs; the cap stops circular pydantic models
# from expanding forever.
_MAX_REF_EXPANSIONS = 256
def _resolve_local_ref(root: Dict[str, Any], ref: str) -> Optional[Dict[str, Any]]:
"""Resolve a same-document JSON pointer (``#/$defs/Foo``) against *root*."""
if not isinstance(ref, str) or not ref.startswith("#/"):
return None
node: Any = root
for raw_part in ref[2:].split("/"):
part = raw_part.replace("~1", "/").replace("~0", "~")
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
return node if isinstance(node, dict) else None
def _inline_refs(node: Any, root: Dict[str, Any], budget: List[int], stack: tuple = ()) -> Any:
"""Recursively inline same-document ``$ref`` nodes; ``ValueError`` on an unresolvable
or circular reference or an exhausted budget (the caller then keeps the original)."""
if isinstance(node, list):
return [_inline_refs(item, root, budget, stack) for item in node]
if not isinstance(node, dict):
return node
ref = node.get("$ref")
if not isinstance(ref, str):
return {key: _inline_refs(value, root, budget, stack) for key, value in node.items()}
if ref in stack:
raise ValueError(f"circular $ref {ref!r}")
budget[0] -= 1
if budget[0] < 0:
raise ValueError("$ref expansion budget exhausted")
target = _resolve_local_ref(root, ref)
if target is None:
raise ValueError(f"unresolvable $ref {ref!r}")
inlined = _inline_refs(target, root, budget, stack + (ref,))
# JSON Schema: siblings of $ref (description, default, ...) apply alongside the
# referenced schema and win over it.
siblings = {k: v for k, v in node.items() if k != "$ref"}
return {**inlined, **_inline_refs(siblings, root, budget, stack)} if siblings else inlined
def prepare_gemini_tool_parameters(parameters: Any) -> Dict[str, Any]:
"""Full JSON Schema for ``parametersJsonSchema``: deep-copied, root ``$schema`` dropped,
same-document ``$ref`` inlined, object root guaranteed. A schema whose references
cannot all be resolved is sent untouched so the provider names the real problem."""
if not isinstance(parameters, dict) or not parameters:
return dict(_EMPTY_OBJECT_SCHEMA)
schema = copy.deepcopy(parameters)
schema.pop("$schema", None)
try:
schema = _inline_refs(schema, schema, [_MAX_REF_EXPANSIONS])
except ValueError as exc:
logger.debug("Gemini tool schema kept as-is ($ref inlining skipped): %s", exc)
return schema
schema.pop("$defs", None)
schema.pop("definitions", None)
if not schema:
return dict(_EMPTY_OBJECT_SCHEMA)
if schema.get("type") == "object" and "properties" not in schema:
schema["properties"] = {}
return schema
+4 -10
View File
@@ -16,7 +16,7 @@ from concurrent.futures import Future, ThreadPoolExecutor, wait
from functools import partial
from typing import Any, Callable, Dict, List, Optional
from agent.memory_provider import MemoryProvider, PRE_COMPRESS_CHECKPOINT_API_VERSION
from agent.memory_provider import MemoryProvider, PRE_COMPRESS_CHECKPOINT_API_VERSION, ctx_bound, spawn_context_thread
from agent.skill_commands import extract_user_instruction_from_skill_message
from tools.hook_output_spill import get_spill_config, spill_if_oversized
from tools.registry import tool_error
@@ -59,12 +59,6 @@ def _accepts_require_checkpoint(fn: Callable[..., Any]) -> bool:
return _has_var_kwargs(params) or kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
def _ctx_bound(fn: Callable[[], Any]) -> Callable[[], Any]:
"""Bind ``fn`` to the CALLER's contextvars for another thread: profile isolation is a
ContextVar-scoped HERMES_HOME override, and an unbound worker would silently use the default profile."""
return partial(contextvars.copy_context().run, fn)
# -- Tool-schema plumbing -----------------------------------------------------
def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
@@ -421,7 +415,7 @@ class MemoryManager:
except Exception as exc: # pragma: no cover - re-raised by caller
result_box["error"] = exc
thread = threading.Thread(target=_ctx_bound(_run), daemon=True, name=f"memory-prefetch-{provider.name}")
thread = spawn_context_thread(_run, name=f"memory-prefetch-{provider.name}")
with self._external_prefetch_lock:
existing = self._external_prefetch_threads.get(provider.name)
if existing is not None and existing.is_alive():
@@ -511,9 +505,9 @@ class MemoryManager:
def _submit_background(self, fn, *, kind: str = "write") -> None:
"""Queue ``fn`` on the serialized worker (created lazily; None once shutting down) and track its
durability class. Runs under the caller's contextvars (``_ctx_bound``). If the executor is
durability class. Runs under the caller's contextvars (``ctx_bound``). If the executor is
unavailable outside shutdown, run inline — the historical fail-safe."""
fn = _ctx_bound(fn)
fn = ctx_bound(fn)
executor = None if self._shutting_down else self._sync_executor
if executor is None and not self._shutting_down:
with self._sync_executor_lock:
+18 -1
View File
@@ -7,14 +7,31 @@ prefetch / sync_turn per turn -> tool dispatch -> shutdown, plus optional ``on_*
from __future__ import annotations
import contextvars
import logging
import re
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
def ctx_bound(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Bind ``fn`` to the CALLER's contextvars for another thread/executor. Profile isolation
is a ContextVar-scoped HERMES_HOME override plus the per-turn secret scope; a worker started
with an empty context silently lands on the default profile (or fails closed on secrets)."""
ctx = contextvars.copy_context()
return lambda *args, **kwargs: ctx.run(fn, *args, **kwargs)
def spawn_context_thread(target: Callable[..., Any], *, name: str, daemon: bool = True,
args: tuple = ()) -> threading.Thread:
"""Unstarted thread running *target* under the spawner's contextvars (see :func:`ctx_bound`).
Every memory-provider background job (prefetch, sync, writer loops) must go through this."""
return threading.Thread(target=ctx_bound(target), args=args, name=name, daemon=daemon)
# v1 = best-effort on_pre_compress() with the raw message list; v2 = opt-in fail-closed
# checkpoint (normalized evidence handoff + strict-mode failure propagation).
PRE_COMPRESS_CHECKPOINT_API_VERSION = 2
+40 -1
View File
@@ -95,6 +95,19 @@ _sanitize_messages_non_ascii = partial(_sanitize_messages, fix=_strip_non_ascii,
_sanitize_tools_non_ascii = _sanitize_structure_non_ascii
def sanitize_outbound_kwargs(agent: Any, api_kwargs: dict) -> None:
"""Outbound-request chokepoint for every built kwargs dict (main loop and iteration summary).
Tool descriptions, extra_body and kwargs strings can carry invalid code points that
providers reject with a non-retryable 400 (#50959); one in-place walk makes the whole
payload json.dumps()-safe. The ASCII strip is opt-in via the recovery flag set after an
ASCII-codec rejection.
"""
_sanitize_structure_surrogates(api_kwargs)
if agent._force_ascii_payload:
_sanitize_structure_non_ascii(api_kwargs)
def _escape_invalid_chars_in_json_strings(raw: str) -> str:
"""Escape literal control chars (0x00-0x1F) inside JSON string values as ``\\uXXXX``
(for llama.cpp-style output mixing control chars with other malformations)."""
@@ -197,6 +210,32 @@ def close_interrupted_tool_sequence(messages: list, final_response: Any = None)
return True
# finish_reason wire normalization. Some OpenAI-compatible gateways fronting
# Gemini backends emit the native uppercase reasons (STOP, MAX_TOKENS); every
# downstream comparison uses the lowercase OpenAI literals, so an uppercase
# reason silently skips stop handling and length recovery. Single owner —
# call at wire intake (transport normalize_response, stream chunk capture),
# never re-fold at comparison sites.
_FINISH_REASON_ALIASES = {
"max_tokens": "length", # Gemini-native / Anthropic-style cap reason
"end": "stop", # some gateways' clean-completion spelling
"function_call": "tool_calls", # OpenAI legacy pre-tools spelling
}
def normalize_finish_reason(raw: Any) -> Any:
"""Fold a wire ``finish_reason`` to the lowercase OpenAI contract value.
Non-string and empty values pass through unchanged (callers keep their
``or "stop"`` defaults and the Poolside int-reason path); contract values
are returned byte-identical.
"""
if not isinstance(raw, str) or not raw:
return raw
lowered = raw.lower()
return _FINISH_REASON_ALIASES.get(lowered, lowered)
def serialized_messages_bytes(messages: list) -> int:
"""Exact serialized byte size of ``messages`` (HTTP 413 is a BYTE-size error the token
estimator, pricing images flat, cannot score). Non-serializable values fall back to
@@ -289,7 +328,7 @@ __all__ = [
"_sanitize_surrogates", "_sanitize_structure_surrogates", "_sanitize_messages_surrogates",
"_escape_invalid_chars_in_json_strings", "_repair_tool_call_arguments",
"_strip_non_ascii", "_sanitize_messages_non_ascii", "_sanitize_tools_non_ascii",
"_strip_images_from_messages", "_sanitize_structure_non_ascii",
"_strip_images_from_messages", "_sanitize_structure_non_ascii", "sanitize_outbound_kwargs",
# call_id policy owners
"deterministic_call_id", "coalesce_tool_call_id", "tool_call_id_variants",
"tool_result_id_variants", "uniquify_tool_call_ids",
+15 -3
View File
@@ -1546,6 +1546,13 @@ def _verified_codex_ctx_for_slug(model_bare: str) -> Optional[int]:
_codex_oauth_context_cache: Dict[str, Tuple[Dict[str, int], float]] = {}
_CODEX_OAUTH_CONTEXT_CACHE_TTL = 3600 # 1 hour
# The Codex models endpoint reads ``client_version`` as a Codex CLI compatibility version and
# hides models whose ``minimal_client_version`` is newer, so a made-up version (the old
# "1.0.0") silently drops future models. "0.0.0" is the backend's ungated sentinel returning
# the full account catalog; other out-of-sequence values return an empty catalog and omitting
# the parameter is HTTP 400.
CODEX_UNGATED_CLIENT_VERSION = "0.0.0"
CODEX_MODELS_CATALOG_URL = f"https://chatgpt.com/backend-api/codex/models?client_version={CODEX_UNGATED_CLIENT_VERSION}"
def _codex_oauth_token_fingerprint(access_token: str) -> str:
@@ -1580,7 +1587,8 @@ def _fetch_codex_oauth_context_lengths_with_source(access_token: str) -> Tuple[D
if acct_id:
headers["ChatGPT-Account-Id"] = acct_id
try:
resp = model_metadata_http.get("https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", headers=headers, timeout=(5, 10), verify=model_metadata_http.resolve_verify())
resp = model_metadata_http.get(CODEX_MODELS_CATALOG_URL, headers=headers, timeout=(5, 10), verify=model_metadata_http.resolve_verify())
if resp.status_code != 200:
logger.debug("Codex /models probe returned HTTP %s; falling back to hardcoded defaults", resp.status_code)
return {}, False
@@ -1987,6 +1995,10 @@ async def get_model_context_length_async(model: str, base_url: str = "", api_key
# CJK/Hangul/Kana codepoints (~1 token each), counted in one C-level regex pass: Hangul
# Jamo (+Ext-A), CJK radicals/ideographs (+compat), Hangul syllables, fullwidth/halfwidth.
# Rough chars-per-token ratio for ASCII text; the single source for every "N tokens ≈ N*4 chars"
# budget conversion (context files, tool-output budgets, whisper prompt cap, compressor metadata).
CHARS_PER_TOKEN = 4
_CJK_DENSE_RE = re.compile("[\u1100-\u11ff\u2e80-\u9fff\ua960-\ua97f\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]")
@@ -2008,10 +2020,10 @@ def estimate_tokens_rough(text: str) -> int:
return 0
text = str(text)
if text.isascii(): # flag check on CPython; ASCII cannot contain token-dense CJK
return (len(text) + 3) // 4
return (len(text) + 3) // CHARS_PER_TOKEN
stripped = _CJK_DENSE_RE.sub("", text)
dense = len(text) - len(stripped)
return dense + ((len(stripped.encode("utf-8", "replace")) + 3) // 4)
return dense + ((len(stripped.encode("utf-8", "replace")) + 3) // CHARS_PER_TOKEN)
def estimate_messages_tokens_rough(messages: List[Dict[str, Any]], *, charge_stale_thinking: bool = True) -> int:
+5 -24
View File
@@ -1,9 +1,9 @@
"""Redaction applied to monitoring data before egress.
One unconditional scrub, no modes, no knobs. Every string that leaves the process passes
through ``redact_for_export``: secrets first (``agent/redact.py::redact_sensitive_text(force=True)``
plus bearer/token shapes, failing CLOSED so a broken redactor never emits the raw string), then
PII (e-mail, phone, UUID-shaped ids -> ``[email]`` / ``[phone]`` / ``[id]``).
through ``redact_for_export``: secrets via ``agent/redact.py::redact_for_egress`` (the single
pattern source; fails CLOSED so a broken redactor never emits the raw string), then PII
(e-mail, phone, UUID-shaped ids -> ``[email]`` / ``[phone]`` / ``[id]``).
"""
from __future__ import annotations
@@ -11,11 +11,7 @@ from __future__ import annotations
import re
from typing import Any, Optional
# ── secret shapes (belt-and-suspenders on top of agent/redact.py) ───────────
_BEARER_RE = re.compile(r"\bBearer\s+[A-Za-z0-9._~+\-/]+=*", re.IGNORECASE)
_TOKEN_RE = re.compile(r"\b(xox[baprs]-[A-Za-z0-9-]+|sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9_]{8,})\b")
_SECRET_LITERAL_RE = re.compile(r"\*{3,}")
_BEARER_RESIDUE_RE = re.compile(r"\bBearer\s+\[[^\]]+\]", re.IGNORECASE)
from agent.redact import REDACTION_UNAVAILABLE as UNAVAILABLE, redact_for_egress
# ── PII shapes ───────────────────────────────────────────────────────────────
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
@@ -25,27 +21,12 @@ _PHONE_RE = re.compile(
)
_UUID_RE = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b")
UNAVAILABLE = "[redaction-unavailable]"
def _secret_redact(text: str) -> str:
"""Always-on secret redaction. force=True so user config can't disable it."""
try:
from agent.redact import redact_sensitive_text
out = redact_sensitive_text(text, force=True)
except Exception:
# Fail CLOSED: if the redactor can't run, do not emit the raw string.
return UNAVAILABLE
for pattern in (_BEARER_RE, _TOKEN_RE, _SECRET_LITERAL_RE, _BEARER_RESIDUE_RE):
out = pattern.sub("[redacted]", out)
return out
def redact_for_export(text: Optional[str]) -> Optional[str]:
"""Scrub a string for egress: secrets, then PII. Unconditional."""
if text is None:
return None
out = _secret_redact(str(text))
out = redact_for_egress(str(text))
out = _EMAIL_RE.sub("[email]", out)
out = _UUID_RE.sub("[id]", out)
out = _PHONE_RE.sub("[phone]", out)
+3 -2
View File
@@ -135,8 +135,9 @@ RETAINED_SUMMARY_TOKEN_BUDGET = 32_000
def _approx_tokens(text: str) -> int:
"""Cheap chars//4 token estimate — same shape Codex uses for retention."""
return max(1, len(text) // 4)
"""Retention cost of one carried-over text; never 0 so an empty item still consumes budget."""
from agent.model_metadata import estimate_tokens_rough
return max(1, estimate_tokens_rough(text))
def _extract_item_text(item: Any) -> Optional[str]:
+4 -2
View File
@@ -15,6 +15,7 @@ import os
import time
from typing import Any, Mapping, Optional
from utils import atomic_write_text
from agent.retry_utils import parse_retry_after_seconds
from agent.rate_limit_tracker import (
_BUCKET_TAGS, _fmt_seconds, _safe_float, _safe_int, has_rate_limit_headers, lower_headers,
)
@@ -41,11 +42,12 @@ def _state_path() -> str:
def _parse_reset_seconds(headers: Optional[Mapping[str, str]]) -> Optional[float]:
"""Best reset estimate (seconds from now) from hourly, per-minute, then retry-after headers."""
lowered = lower_headers(headers)
for key in ("x-ratelimit-reset-requests-1h", "x-ratelimit-reset-requests", "retry-after"):
for key in ("x-ratelimit-reset-requests-1h", "x-ratelimit-reset-requests"):
val = _safe_float(lowered.get(key), 0.0)
if val > 0:
return val
return None
retry_after = parse_retry_after_seconds(lowered.get("retry-after"))
return retry_after if retry_after else None
def record_nous_rate_limit(
+3 -7
View File
@@ -153,16 +153,12 @@ def is_seen(config: Mapping[str, Any], flag: str) -> bool:
def mark_seen(config_path: Path, flag: str) -> bool:
"""Persist ``onboarding.seen.<flag> = True`` atomically; False on any error (best-effort)."""
try:
import hermes_yaml as yaml
from hermes_cli.config import atomic_config_write
from hermes_cli.config import atomic_config_write, read_user_config_raw
except Exception as e: # pragma: no cover — dependency issue
logger.debug("onboarding: failed to import hermes_yaml/utils: %s", e)
logger.debug("onboarding: failed to import config helpers: %s", e)
return False
try:
cfg: dict = {}
if config_path.exists():
with open(config_path, encoding="utf-8-sig") as f:
cfg = yaml.safe_load(f) or {}
cfg: dict = read_user_config_raw(config_path)
if not isinstance(cfg.get("onboarding"), dict):
cfg["onboarding"] = {}
seen = cfg["onboarding"].get("seen")
+8 -8
View File
@@ -15,10 +15,10 @@ import socket
import sys
import threading
import time
import urllib.request
from typing import Any, Optional
from utils import base_url_hostname, normalize_proxy_url
from agent.proxy_bypass import first_proxy_env_value, should_bypass_proxy
_OPENAI_CLS_CACHE = None
@@ -288,18 +288,18 @@ class _SafeWriter:
def _get_proxy_from_env() -> Optional[str]:
"""First configured proxy URL from HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (any case), or None."""
keys = ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy")
return next((normalize_proxy_url(v) for k in keys if (v := os.environ.get(k, "").strip())), None)
value = first_proxy_env_value()
return normalize_proxy_url(value) if value else None
def _get_proxy_for_base_url(base_url: Optional[str]) -> Optional[str]:
"""Env-configured proxy unless NO_PROXY excludes this base URL."""
"""Env-configured proxy unless NO_PROXY excludes this base URL (same matcher as the
gateway adapters: CIDR, ``*.`` wildcards and host:port entries all count)."""
proxy = _get_proxy_from_env()
host = base_url_hostname(base_url) if proxy and base_url else ""
try:
return None if host and urllib.request.proxy_bypass_environment(host) else proxy
except Exception:
if not (proxy and base_url):
return proxy
raw = base_url.strip()
return None if should_bypass_proxy(raw if "://" in raw else f"//{raw}") else proxy
def _shared_transport_cls():
+4 -4
View File
@@ -19,6 +19,7 @@ from hermes_constants import (
get_hermes_home, get_skills_dir, is_wsl, reset_hermes_home_override, set_hermes_home_override,
)
from agent.model_metadata import CHARS_PER_TOKEN
from agent.runtime_cwd import resolve_agent_cwd
from agent.skill_utils import (
EXCLUDED_SKILL_DIRS, ORG_ACTIVE_MARKER, ORG_MIRROR_DIR_NAME, ORG_PROVENANCE_FILE, SKILL_SUPPORT_DIRS,
@@ -1028,9 +1029,8 @@ CONTEXT_FILE_MAX_CHARS = 20_000
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
# Dynamic cap (no explicit context_file_max_chars): ~4 chars/token, a small slice of the window since
# context files share the cached prefix; small models stay at the floor.
_CONTEXT_FILE_CHARS_PER_TOKEN = 4
# Dynamic cap (no explicit context_file_max_chars): a small slice of the window since context files
# share the cached prefix; small models stay at the floor.
_CONTEXT_FILE_WINDOW_FRACTION = 0.06
_CONTEXT_FILE_DYNAMIC_CEILING = 500_000
@@ -1039,7 +1039,7 @@ def _dynamic_context_file_max_chars(context_length: Optional[int]) -> int:
"""Char cap from the model's window, clamped to [20K floor, 500K ceiling]; flat default when unknown."""
if not isinstance(context_length, int) or context_length <= 0:
return CONTEXT_FILE_MAX_CHARS
budget = int(context_length * _CONTEXT_FILE_CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION)
budget = int(context_length * CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION)
return max(CONTEXT_FILE_MAX_CHARS, min(budget, _CONTEXT_FILE_DYNAMIC_CEILING))
+17 -2
View File
@@ -90,7 +90,18 @@ def declared_conversation_scope(agent: Any) -> Optional[str]:
when no key is declared, for a background-review fork (``_persist_disabled``), for an
explicit fork child, and on any DB error (fail closed rather than merge a fork onto its
parent's key).
The one sanctioned exception is a same-model cache-parity fork (#109964): its whole purpose
is prefix parity with the parent, yet ``_persist_disabled`` + ``_session_db=None`` made both
resolvers key it into a different bucket (one cold ~full-context request per review).
``build_cache_parity_fork`` stamps the parent's ALREADY-RESOLVED scope as
``_inherited_cache_scope`` (no DB access from the fork). Only a ``gwk_`` value is a declared
scope; a physical lineage root stays out of the affinity header so the fork publishes
exactly what its parent publishes (None → consumers fall back to the conversation root).
"""
inherited = getattr(agent, "_inherited_cache_scope", None)
if isinstance(inherited, str) and inherited.startswith(_DECLARED_SCOPE_PREFIX):
return inherited
key = str(getattr(agent, "_gateway_session_key", "") or "").strip()
if not key or getattr(agent, "_persist_disabled", False):
return None
@@ -130,8 +141,12 @@ def declared_conversation_scope(agent: Any) -> Optional[str]:
def resolve_prompt_cache_scope(agent: Any) -> str:
"""Rotation-stable cache-scope id: declared scope, else the compression-lineage root of
``agent.session_id`` (the physical id without ancestry/DB). Memoized on the agent."""
"""Rotation-stable cache-scope id: the inherited parent scope of a same-model cache-parity
fork, else the declared scope, else the compression-lineage root of ``agent.session_id``
(the physical id without ancestry/DB). Memoized on the agent."""
inherited = getattr(agent, "_inherited_cache_scope", None)
if isinstance(inherited, str) and inherited:
return inherited
sid = str(getattr(agent, "session_id", None) or "")
if not sid:
return ""
+99
View File
@@ -0,0 +1,99 @@
"""NO_PROXY matching shared by the LLM transport (``agent/process_bootstrap.py``) and the
gateway platform adapters (``gateway/platforms/base.py``).
One matcher so "is this host in NO_PROXY" has one answer everywhere: exact hosts, domain
suffixes (``example.com``, ``.example.com``, ``*.example.com``), IP literals, CIDR ranges,
optional ``host:port`` entries and ``*``. The stdlib ``proxy_bypass_environment`` understands
none of the CIDR / ``*.`` forms, which is why the LLM path used to route ``10.x`` endpoints
through the corporate proxy while Telegram/Discord bypassed it.
Leaf module: stdlib only, importable during early boot.
"""
from __future__ import annotations
import ipaddress
import os
import re
from urllib.parse import urlsplit
PROXY_ENV_KEYS = ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy")
def first_proxy_env_value() -> str:
"""First non-empty HTTPS_PROXY / HTTP_PROXY / ALL_PROXY value (any case), or ''."""
return next((v for k in PROXY_ENV_KEYS if (v := (os.environ.get(k) or "").strip())), "")
def split_host_port(value: str) -> tuple[str, int | None]:
"""``(host, port)`` from a URL (scheme optional: ``//host/path``), ``[v6]:port``,
``host:port`` or bare host; host lowercased. A malformed URL port (``host:abc``,
``host:99999``) yields ``(host, None)`` rather than raising."""
raw = str(value or "").strip()
if not raw:
return "", None
if "://" in raw or raw.startswith("//"):
parsed = urlsplit(raw)
host = parsed.hostname or ""
try:
port = parsed.port
except ValueError: # ``host:abc`` / ``host:99999``: keep the host, drop the port
port = None
elif raw.startswith("[") and "]" in raw:
host, _, rest = raw[1:].partition("]")
port = int(rest[1:]) if rest.startswith(":") and rest[1:].isdigit() else None
elif raw.count(":") == 1 and raw.rpartition(":")[2].isdigit():
host, _, port_s = raw.rpartition(":")
port = int(port_s)
else:
host, port = raw.strip("[]"), None
return host.lower().rstrip("."), port
def no_proxy_entries(no_proxy_value: str | None = None) -> list[str]:
"""Comma/whitespace-separated NO_PROXY entries; from the environment (both casings) when
``no_proxy_value`` is None."""
if no_proxy_value is None:
no_proxy_value = ",".join(os.environ.get(key, "") for key in ("NO_PROXY", "no_proxy"))
return [part for part in re.split(r"[\s,]+", no_proxy_value.strip()) if part]
def _ip_or_none(value: str, parse=ipaddress.ip_address):
"""``parse(value)`` or None on ``ValueError`` (``parse`` is ip_address / ip_network)."""
try:
return parse(value)
except ValueError:
return None
def no_proxy_entry_matches(entry: str, host: str, port: int | None = None) -> bool:
token = str(entry or "").strip().lower()
if not token:
return False
if token == "*":
return True
token_host, token_port = split_host_port(token)
if not token_host or (token_port is not None and (port is None or token_port != port)):
return False
host_ip = _ip_or_none(host)
network = _ip_or_none(token_host, lambda v: ipaddress.ip_network(v, strict=False))
if network is not None: # CIDR or bare IP literal (a /32 / /128 network)
return host_ip is not None and host_ip in network
# ``*.example.com`` and ``.example.com`` both mean apex + subdomains (curl/requests
# convention, and what is_host_excluded_by_no_proxy promised the Slack adapter).
suffix = token_host.removeprefix("*").removeprefix(".")
return host == suffix or host.endswith(f".{suffix}")
def should_bypass_proxy(
target_hosts: str | list[str] | tuple[str, ...] | set[str] | None, *, no_proxy_value: str | None = None,
) -> bool:
"""True when NO_PROXY (the environment, or ``no_proxy_value``) matches at least one target
host (a URL, ``host:port`` or bare host)."""
entries = no_proxy_entries(no_proxy_value)
if not entries or not target_hosts:
return False
candidates = [target_hosts] if isinstance(target_hosts, str) else list(target_hosts)
return any(
host and any(no_proxy_entry_matches(entry, host, port) for entry in entries)
for host, port in map(split_host_port, map(str, candidates)))
+11 -13
View File
@@ -28,6 +28,8 @@ from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from utils import atomic_json_write, atomic_write_text
logger = logging.getLogger(__name__)
_RUN_TIMEOUT = 30
@@ -499,21 +501,15 @@ def ensure_audit_log(audit_path: Path) -> None:
) from exc
def _write_state_file_atomic(state: Path, name: str, dump) -> Path:
"""0600 temp file + atomic replace: the file holds proxy tokens; chmod-after-replace would be a world-readable TOCTOU window."""
tmp_path = state / f".{name}.tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
dump(f)
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, state / name)
return state / name
def write_proxy_config(config: Dict) -> Path:
"""Serialize the config dict to ``<hermes_home>/proxy/proxy.yaml`` (safe_dump, no Python tags)."""
"""Serialize the config dict to ``<hermes_home>/proxy/proxy.yaml`` (safe_dump, no Python tags).
The file holds proxy tokens: written 0600 from creation, never at process umask."""
if (yaml := _yaml()) is None:
raise RuntimeError("ruamel.yaml is required to write the iron-proxy config but is not installed.")
return _write_state_file_atomic(_proxy_state_dir(), "proxy.yaml", lambda f: yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False))
path = _proxy_state_dir() / "proxy.yaml"
atomic_write_text(path, yaml.safe_dump(config, default_flow_style=False, sort_keys=False), mode=0o600)
return path
def write_mappings(mappings: List[TokenMapping]) -> Path:
@@ -522,7 +518,9 @@ def write_mappings(mappings: List[TokenMapping]) -> Path:
"proxy_token": m.proxy_token, "env_name": m.real_env_name, "upstream_hosts": list(m.upstream_hosts),
"match_headers": list(m.match_headers), "alias_env_names": list(m.alias_env_names),
} for m in mappings]}
return _write_state_file_atomic(_proxy_state_dir(), "mappings.json", lambda f: json.dump(payload, f, indent=2))
path = _proxy_state_dir() / "mappings.json"
atomic_json_write(path, payload, mode=0o600)
return path
def load_mappings() -> List[TokenMapping]:
+35
View File
@@ -156,6 +156,41 @@ def requested_effort(reasoning_config: Optional[dict]) -> Optional[str]:
return str(reasoning_config.get("effort") or "").strip().lower() or None
def thinking_toggle_extras(
reasoning_config: Optional[dict],
efforts: Sequence[str],
overrides: Optional[dict[str, str]] = None,
*,
always_emit_toggle: bool = False,
) -> tuple[dict, dict]:
"""Translate a reasoning config onto the Moonshot/DeepSeek chat_completions wire:
``extra_body.thinking`` toggle and top-level ``reasoning_effort``.
Moonshot 400s when both are sent, so by default the effort (when it lands in
``efforts``) replaces the toggle. DeepSeek instead requires the toggle on every
request (an omitted toggle defaults thinking on and then demands
``reasoning_content`` echoes), hence ``always_emit_toggle``. A requested effort of
``none`` is not a level on these wires; it falls back to the plain toggle.
"""
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False:
return {"thinking": {"type": "disabled"}}, {}
effort = requested_effort(reasoning_config)
clamped = clamp_effort(None if effort == "none" else effort, efforts, overrides)
if clamped in efforts:
return ({"thinking": {"type": "enabled"}} if always_emit_toggle else {}), {"reasoning_effort": clamped}
return {"thinking": {"type": "enabled"}}, {}
def ox_alpha_reasoning_extras(reasoning_config: Optional[dict], model: Optional[str]) -> tuple[dict, dict]:
"""Ox Alpha (``x-preview-f-free``) ``reasoning_effort`` translation, shared by the
opencode-zen and opencode-free profiles (low/high/max only; anything else 400s)."""
if (model or "").strip().rsplit("/", 1)[-1].lower() != "x-preview-f-free":
return {}, {}
effort = requested_effort(reasoning_config)
clamped = clamp_effort(None if effort == "none" else effort, OX_ALPHA_EFFORTS, OX_ALPHA_OVERRIDES)
return ({}, {"reasoning_effort": clamped}) if clamped in OX_ALPHA_EFFORTS else ({}, {})
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
-6
View File
@@ -5,7 +5,6 @@ When ``reasoning`` extra_body is safe to send, LM Studio / Ollama / GitHub Model
Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged.
"""
import time
from typing import Optional
from agent.lazy_forward import forward as _forward, forward_static as _forward_static
from agent.message_sanitization import matches_reasoning_echo_family
@@ -98,11 +97,6 @@ class ReasoningParamsMixin:
return False
return bool(_cached_probe(self, "_ollama_thinking_cache", ollama_model_supports_thinking, None, lambda v: v is not None))
def _resolve_lmstudio_summary_reasoning_effort(self) -> Optional[str]:
"""Safe top-level ``reasoning_effort`` for LM Studio; shared with the iteration-limit summary call."""
from agent.lmstudio_reasoning import resolve_lmstudio_effort
return resolve_lmstudio_effort(self.reasoning_config, self._lmstudio_reasoning_options_cached())
def _github_models_reasoning_extra_body(self) -> dict | None:
"""Format reasoning payload for GitHub Models/OpenAI-compatible routes."""
try:
+35 -1
View File
@@ -14,7 +14,7 @@ from typing import Any
from agent.message_content import flatten_message_text
__all__ = ["separate_glued_reasoning_blocks"]
__all__ = ["append_streamed_reasoning_detail", "separate_glued_reasoning_blocks"]
def separate_glued_reasoning_blocks(previous: str, delta: Any) -> str:
@@ -29,3 +29,37 @@ def separate_glued_reasoning_blocks(previous: str, delta: Any) -> str:
delta = flatten_message_text(delta, sep="")
glued = previous and delta and not previous[-1].isspace() and delta.startswith("**") and "**" in delta[2:]
return f"\n\n{delta}" if glued else delta
# reasoning_details entry types whose consecutive fragments are ONE logical block.
_MERGEABLE_DETAIL_TEXT_KEYS = {"reasoning.text": "text", "reasoning.summary": "summary"}
_BACKFILL_DETAIL_KEYS = ("signature", "id", "format", "index")
def append_streamed_reasoning_detail(details_acc: list, detail: Any) -> None:
"""Accumulate one streamed ``reasoning_details`` delta entry into *details_acc*.
OpenRouter streams ``reasoning_details`` as word-level deltas: consecutive
``reasoning.text`` / ``reasoning.summary`` entries are fragments of one logical
block and are merged (later fragments backfill ``signature``/``id`` the first
omitted); encrypted/opaque entries stay discrete. Unmerged, a long thought
replays as hundreds of one-word entries and providers that validate the
sequence shape on the next turn reject it. SDK objects are normalized to dicts.
"""
if not isinstance(detail, dict):
if hasattr(detail, "model_dump"):
detail = detail.model_dump(warnings=False)
elif hasattr(detail, "__dict__"):
detail = dict(detail.__dict__)
else:
return
dtype = detail.get("type")
merge_key = _MERGEABLE_DETAIL_TEXT_KEYS.get(dtype)
last = details_acc[-1] if details_acc else None
if last is not None and merge_key and last.get("type") == dtype and isinstance(detail.get(merge_key), str):
last[merge_key] = (last.get(merge_key) or "") + detail[merge_key]
for k in _BACKFILL_DETAIL_KEYS:
if last.get(k) in (None, "") and detail.get(k) not in (None, ""):
last[k] = detail[k]
return
details_acc.append(dict(detail))
+264 -22
View File
@@ -367,6 +367,72 @@ def _should_redact_assignment(key: str, value: str, *, check_keyword: bool) -> b
_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(rf'("{_JSON_KEY_NAMES}")\s*:\s*"([^"]+)"', re.IGNORECASE)
# Python ``repr`` uses single-quoted mapping fields, so opaque credentials in
# tracebacks and pytest failure introspection bypass the double-quoted JSON rule
# above: ``{'BRAVE_API_KEY': 'opaque-value'}``. Capture identifier-shaped keys
# here, then apply the canonical high-confidence key policy in the callback.
_PYTHON_REPR_SECRET_KEYS = frozenset({
"access_token",
"refresh_token",
"id_token",
"auth_token",
"token",
"api_key",
"apikey",
"client_secret",
"secret",
"password",
"passwd",
"private_key",
"credential",
"credentials",
"authorization",
"bearer",
"secret_value",
"raw_secret",
"secret_input",
"key_material",
})
_PYTHON_REPR_ENV_SUFFIXES = (
"_API_KEY",
"_TOKEN",
"_SECRET",
"_PASSWORD",
"_PASSWD",
"_CREDENTIAL",
"_CREDENTIALS",
)
# Casefolded credential suffixes for mixed/camel-case key names
# (``UserPassword``, ``sessionToken``, ``clientApiKey``). Suffix-only so
# ``token_count`` / ``password_policy`` metadata keys never match. Widened per
# OpenHands/software-agent-sdk#4508.
_PYTHON_REPR_CREDENTIAL_SUFFIXES = (
"apikey",
"api_key",
"token",
"secret",
"password",
"passwd",
"credential",
"credentials",
)
_PYTHON_REPR_FIELD_RE = re.compile(
r"'(?P<key>[A-Za-z_][A-Za-z0-9_]*)'(?P<sep>\s*:\s*)"
r"(?:"
r"(?P<single_prefix>[bB]?)'(?P<single_value>(?:\\.|[^'\\])+)'"
r"|(?P<double_prefix>[bB]?)\"(?P<double_value>(?:\\.|[^\"\\])+)\""
r")"
)
# Terminal/process output normally uses ``code_file=True`` to preserve source.
# Add repr masking only to high-confidence diagnostic lines: pytest assertion
# introspection (``E ...``) and final Python exception lines.
_PYTEST_DIAGNOSTIC_LINE_RE = re.compile(r"^(?P<prefix>[ \t]*E[ \t]{2,})(?P<body>.*)$")
_PYTHON_EXCEPTION_LINE_RE = re.compile(
r"^(?P<prefix>(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*"
r"(?:Error|Exception|Warning):[ \t]*)(?P<body>.*)$"
)
# Authorization / Proxy-Authorization, any scheme or bare credential; header
# name and scheme word preserved. The credential class excludes quotes: pulling
# a closing quote into the mask turns value corruption into SYNTAX corruption
@@ -515,6 +581,79 @@ def _mask_token(token: str) -> str:
return mask_secret(token, head=6, tail=4, floor=18)
def _is_python_repr_secret_key(key: str) -> bool:
"""Return True for exact secret keys or credential-suffixed key names."""
folded = key.casefold()
if folded in _PYTHON_REPR_SECRET_KEYS:
return True
if key.isupper() and key.endswith(_PYTHON_REPR_ENV_SUFFIXES):
return True
# Mixed/camel-case keys ending in a credential word (``UserPassword``,
# ``sessionToken``, ``clientApiKey``) — the exact-set and uppercase-suffix
# rules above miss these. Suffix-only matching keeps metadata names like
# ``TOKEN_COUNT`` / ``PASSWORD_POLICY`` / ``SECRET_NAME`` untouched.
# Class widened per OpenHands/software-agent-sdk#4508 (their dict-entry
# redaction was uppercase-only and leaked mixed-case keys).
return folded.endswith(_PYTHON_REPR_CREDENTIAL_SUFFIXES)
def _redact_python_repr_fields(text: str) -> str:
"""Fully mask credential fields in Python mapping ``repr`` output."""
def _sub(match: re.Match) -> str:
key = match.group("key")
if not _is_python_repr_secret_key(key):
return match.group(0)
single_value = match.group("single_value")
if single_value is not None:
prefix = match.group("single_prefix") or ""
quote = "'"
value = single_value
else:
prefix = match.group("double_prefix") or ""
quote = '"'
value = match.group("double_value")
# Mapping repr can contain code-shaped fixture values too. Preserve
# programmatic env lookups just like the ENV/JSON/YAML passes do.
if _ENV_LOOKUP_VALUE_RE.match(value):
return match.group(0)
# An upstream pass (MCP probe header scrub, _mask_token) already masked this
# value; re-masking would erase the scheme word it deliberately kept
# (``'Authorization': 'Digest ***'`` → ``'***'``).
if "***" in value or value.startswith("«redacted:"):
return match.group(0)
# Do not retain head/tail characters here: escaped repr atoms can cross
# a slicing boundary and leave an unescaped quote behind. A full mask is
# parseable for both str and bytes values and leaks no opaque bytes.
return f"'{key}'{match.group('sep')}{prefix}{quote}***{quote}"
return _PYTHON_REPR_FIELD_RE.sub(_sub, text)
def _redact_python_diagnostic_repr_fields(text: str) -> str:
"""Mask repr fields only on pytest/error lines in source-preserving output."""
lines = text.splitlines(keepends=True)
for index, line in enumerate(lines):
ending = ""
body_line = line
if line.endswith("\r\n"):
body_line, ending = line[:-2], "\r\n"
elif line.endswith("\n") or line.endswith("\r"):
body_line, ending = line[:-1], line[-1:]
match = _PYTEST_DIAGNOSTIC_LINE_RE.match(body_line)
if match is None:
match = _PYTHON_EXCEPTION_LINE_RE.match(body_line)
if match is not None:
lines[index] = (
match.group("prefix")
+ _redact_python_repr_fields(match.group("body"))
+ ending
)
return "".join(lines)
def _redact_query_string(query: str) -> str:
"""Replace values of sensitive ``k=v&k=v`` params with ``***``; others pass through."""
if not query:
@@ -624,6 +763,11 @@ def _redact_assignments(text: str) -> str:
text = _JSON_FIELD_RE.sub(
_assignment_sub(lambda g: f'{g[0]}: "{_mask_token(g[1])}"', check_keyword=False), text)
# Python mapping repr fields ({'API_KEY': '…'}): single-quoted, so the JSON rule
# above never sees them — the traceback / pytest-introspection leak shape.
if ":" in text and "'" in text:
text = _redact_python_repr_fields(text)
# YAML after JSON: quoted values are handled there (_YAML_ASSIGN_RE skips quotes).
if ":" in text and "://" not in text:
text = _YAML_ASSIGN_RE.sub(
@@ -670,6 +814,10 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
Every regex sits behind a cheap substring gate that its pattern requires,
so the gates are never false-negative.
Set code_file=True to also skip the Python-repr mapping pass (``{'API_KEY': '…'}``
fixtures in source); pytest/exception diagnostic lines get a narrow pass in
redact_terminal_output instead.
Set file_read=True for file *content* returned to the agent (read_file / search_files / cat). The old
mask looked like a real-but-truncated key, so an agent reading it from config.yaml and writing it back
silently corrupted the stored credential into a dead 13-char value → 401 (issue #35519). The sentinel is
@@ -750,35 +898,100 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
# ``postgresql://{user}`` f-string templates). See issue #43025.
_ENV_DUMP_COMMANDS = frozenset({"env", "printenv", "set", "export", "declare"})
# Commands that read file contents to stdout. A ``.env`` target is a credential
# dump (per AGENTS.md ``.env`` holds only secrets), so the ENV pass must run.
# Commands that read file contents to stdout, plus the filter readers (``grep``/``awk``/``sed``)
# the model reaches for on config files. A secret-bearing target (``.env`` per AGENTS.md,
# a shell rc/profile, Hermes' own ``config.yaml`` where ``hermes mcp add --env`` writes
# tokens) is a credential dump, so the ENV/YAML assignment pass must run. Arbitrary
# ``config.yaml`` / source files stay on the code_file path (``MAX_TOKENS: 100``).
_FILE_READ_COMMANDS = frozenset({
"cat", "head", "tail", "type", "bat", "less", "more", "nl",
"zcat", "tac", "view", "batcat",
"zcat", "tac", "view", "batcat", "grep", "awk", "sed",
})
_SHELL_RC_BASENAMES = frozenset({
".bashrc", ".bash_profile", ".bash_login", ".profile",
".zshrc", ".zprofile", ".zlogin", ".zshenv",
})
# Filter readers take a PATTERN/program as their first positional; only the operands after
# it are files, so ``grep .bashrc app.py`` must not gate on the pattern.
_PATTERN_FIRST_COMMANDS = frozenset({"grep", "awk", "sed"})
_HERMES_HOME_PREFIXES = ("$HERMES_HOME/", "${HERMES_HOME}/")
# ``$HOME/.hermes/config.yaml`` keeps the ``.hermes`` segment, so stripping the prefix is
# enough to gate it; ``~/`` already survives the ``$``-bearing-path bail-out.
_HOME_PREFIXES = ("$HOME/", "${HOME}/")
def _command_segments(command: str) -> list[str]:
"""Pipeline/sequence segments of a shell command, stripped, empties dropped."""
return [seg.strip() for seg in re.split(r"[|;&]+", command) if seg.strip()]
"""Pipeline/sequence segments, split only on unquoted ``| ; &`` so an
``awk '{print $1; print $2}'`` program or ``grep 'foo|bar'`` pattern stays one
segment. Backslash is not an escape (Windows ``C:\\Users\\...``)."""
segments: list[str] = []
buf: list[str] = []
quote: str | None = None
for ch in command:
if quote:
buf.append(ch)
if ch == quote:
quote = None
continue
if ch in "'\"":
quote = ch
buf.append(ch)
continue
if ch in "|;&":
seg = "".join(buf).strip()
if seg:
segments.append(seg)
buf = []
continue
buf.append(ch)
seg = "".join(buf).strip()
if seg:
segments.append(seg)
return segments
def _command_reads_env_file(command: str | None) -> bool:
"""True if ``command`` reads a ``.env``-style file (by basename) to stdout.
Defense-in-depth, not a boundary: indirect reads (``sudo cat .env``, ``$(cat
.env)``, ``sed``/``awk``) are not detected, matching ``is_env_dump_command``."""
if not command:
def _is_secret_file_arg(arg: str) -> bool:
"""``.env``-style or shell rc basename anywhere; ``config.yaml`` only under a
``.hermes`` directory or ``$HERMES_HOME`` (never arbitrary YAML)."""
path = arg.strip("\"'").replace("\\", "/")
hermes_home = False
for prefix in _HERMES_HOME_PREFIXES:
if path.startswith(prefix):
path = path[len(prefix):]
hermes_home = True
break
for prefix in _HOME_PREFIXES:
if path.startswith(prefix):
path = path[len(prefix):]
break
if "$" in path:
return False
parts = [part.lower() for part in path.split("/") if part]
if not parts:
return False
if parts[-1] in _ENV_FILE_BASENAMES or parts[-1] in _SHELL_RC_BASENAMES:
return True
return parts[-1] == "config.yaml" and (hermes_home or ".hermes" in parts[:-1])
def _command_reads_secret_file(command: str | None) -> bool:
"""True if ``command`` reads a secret-bearing file (see ``_is_secret_file_arg``) to
stdout. Defense-in-depth, not a boundary: indirect reads (``sudo cat .env``, ``$(cat
.env)``, unresolved variable paths) are not detected, matching ``is_env_dump_command``."""
if not command or not isinstance(command, str):
return False
for seg in _command_segments(command):
tokens = seg.split() # not shlex: it mangles Windows paths (``C:\Users\...\.env``)
if not tokens or tokens[0] not in _FILE_READ_COMMANDS:
if not tokens:
continue
for arg in tokens[1:]:
if arg.startswith("-"):
continue
basename = arg.strip("\"'").rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if basename.lower() in _ENV_FILE_BASENAMES:
return True
reader = tokens[0].rsplit("/", 1)[-1].lower()
if reader not in _FILE_READ_COMMANDS:
continue
positional = [arg for arg in tokens[1:] if not arg.startswith("-")]
if reader in _PATTERN_FIRST_COMMANDS:
positional = positional[1:]
if any(_is_secret_file_arg(arg) for arg in positional):
return True
return False
@@ -797,14 +1010,43 @@ def is_env_dump_command(command: str | None) -> bool:
return False
REDACTION_UNAVAILABLE = "[redaction-unavailable]"
# The opaque branch needs a 20-char floor (the floor the gateway/A2A sweeps always had): without it the
# English word "bearer" turns "the bearer of bad news" into "Bearer [redacted] bad news" on every chat
# reply. The bracket branch folds an already-masked residue ("Bearer [redacted-jwt]") to one marker.
_BEARER_RESIDUE_RE = re.compile(r"\bBearer\s+(?:\[[^\]]+\]|[A-Za-z0-9._~+/-]{20,}=*)", re.IGNORECASE)
def redact_for_egress(text: str) -> str:
"""The one scrub for text leaving the process for a remote reader (chat platforms, A2A peers,
telemetry). ``redact_sensitive_text(force=True)`` — the only secret-pattern list — plus a bearer
sweep, because a ``Bearer <opaque>`` value with no vendor prefix carries no shape the prefix
matcher can key on. Fails CLOSED: if the redactor raises, the raw text is never returned."""
text = str(text or "")
try:
text = redact_sensitive_text(text, force=True)
except Exception:
return REDACTION_UNAVAILABLE
if "earer" in text:
text = _BEARER_RESIDUE_RE.sub("Bearer [redacted]", text)
return text
def redact_terminal_output(output: str, command: str | None = None, *, force: bool = False) -> str:
"""Single redaction policy for ALL terminal-output surfaces: the ENV-assignment
pass runs only when ``command`` is an env dump or reads a ``.env`` file
(otherwise code_file=True avoids false positives on source/config dumps)."""
"""Single redaction policy for ALL terminal-output surfaces: the ENV/YAML-assignment
pass runs only when ``command`` is an env dump or reads a secret-bearing file (``.env``,
shell rc, Hermes ``config.yaml``); otherwise code_file=True avoids false positives on
source/config dumps."""
if not output:
return output
code_file = not (is_env_dump_command(command) or _command_reads_env_file(command))
return redact_sensitive_text(output, force=force, code_file=code_file)
code_file = not (is_env_dump_command(command) or _command_reads_secret_file(command))
redacted = redact_sensitive_text(output, force=force, code_file=code_file)
# Source-preserving output still gets the Python-repr pass on high-confidence
# diagnostic lines (pytest ``E `` introspection, final exception lines): that is
# where {'BRAVE_API_KEY': '…'} leaks, not in source dumps.
if code_file and (force or _redact_enabled()) and ":" in redacted and "'" in redacted:
redacted = _redact_python_diagnostic_repr_fields(redacted)
return redacted
# --- Prefix pre-screen: derived from _PREFIX_PATTERNS so a new prefix can't
+63 -7
View File
@@ -6,12 +6,17 @@ re-issues the unanswered call → endless "thinking"/reboot loop. These pure hel
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List
import math
import re
import time
from typing import Any, Dict, List, Optional
from agent.tool_dispatch_helpers import make_tool_result_message
from agent.tool_result_classification import tool_may_have_side_effect
from agent.turn_context import drop_stale_api_content
from hermes_cli.timefmt import coerce_epoch
logger = logging.getLogger(__name__)
@@ -26,12 +31,32 @@ _DANGLING_NOTICES = (
)
# Every executor ends the killed run's ``output`` with a bracketed marker line: "[Command
# interrupted]" (tools/environments/, exit 130), "[Command interrupted - Modal ...]"
# (managed_modal.py, exit 130), "[execution interrupted ...]" (code_execution_tool.py, exit -1).
_INTERRUPT_MARKER_LINE = re.compile(r"^\[(?:command|execution) interrupted\b[^\n]*\]\s*$", re.IGNORECASE)
def is_interrupted_tool_result(content: Any) -> bool:
"""Return True if a tool result indicates the tool was interrupted."""
"""True only when the result has the executor's interrupt SHAPE: the marker is the last
line of the output (JSON envelope with a non-zero exit code, or a bare text result). A
marker quoted inside successful output — a grep hit, a doc example — is ordinary data;
this runs on every live request, so a false positive rewrites real tool output."""
if not isinstance(content, str):
return False
lowered = content.lower()
return "[command interrupted]" in lowered or ("exit_code" in lowered and ("130" in lowered or "-1" in lowered) and "interrupt" in lowered)
output = content
if content.lstrip().startswith("{"):
try:
envelope = json.loads(content)
except ValueError:
return False
if not isinstance(envelope, dict) or envelope.get("exit_code") in (0, None):
return False
output = envelope.get("output")
if not isinstance(output, str):
return False
last_line = output.rstrip().rsplit("\n", 1)[-1]
return _INTERRUPT_MARKER_LINE.match(last_line) is not None
def _call_name(call: Dict[str, Any]) -> str:
@@ -126,6 +151,26 @@ def sanitize_replay_history(agent_history: List[Dict[str, Any]]) -> List[Dict[st
return strip_dangling_tool_call_tail(strip_interrupted_tool_tails(agent_history))
def canonicalize_replay_history(
agent_history: List[Dict[str, Any]], *, now: Optional[float] = None
) -> List[Dict[str, Any]]:
"""Apply every destructive replay transform in the shared, fixed order.
Resume surfaces and the send path must serialize the same history bytes, or a
resumed request diverges in the middle of the cached prefix.
The input is never modified. ``now`` is the expiry clock; the send path passes the
turn's admission time so every request in one turn sees the same bytes.
"""
if not agent_history:
return agent_history
if now is None:
now = time.time()
cleaned = strip_interrupted_tool_tails(agent_history)
cleaned = strip_dangling_tool_call_tail(cleaned)
return strip_stale_dangerous_confirmations(cleaned, now=now)
# --- Stale dangerous-confirmation text expiry ---
# Short on purpose: a dangerous confirmation must not survive any restart or resume gap.
@@ -151,7 +196,10 @@ _EXPIRED_CONFIRMATION_SENTINEL = (
def is_dangerous_confirmation(content: Any) -> bool:
"""True if user-message text contains a known dangerous confirmation phrase."""
return isinstance(content, str) and any(pattern in content.strip().lower() for pattern in _DANGEROUS_CONFIRMATION_PATTERNS)
if not isinstance(content, str):
return False
lowered = content.strip().lower()
return any(pattern in lowered for pattern in _DANGEROUS_CONFIRMATION_PATTERNS)
def strip_stale_dangerous_confirmations(
@@ -174,12 +222,20 @@ def strip_stale_dangerous_confirmations(
cleaned: List[Dict[str, Any]] = []
for msg in agent_history:
ts = msg.get("timestamp") if isinstance(msg, dict) and msg.get("role") == "user" else None
if ts is None or not is_dangerous_confirmation(msg.get("content", "")) or (now - float(ts)) <= expiry_seconds:
if ts is None or not is_dangerous_confirmation(msg.get("content", "")):
cleaned.append(msg)
continue
# A present-but-untrustworthy stamp (corrupt, or issued in the future relative to
# the admission clock) is treated as expired: its age is unknowable, and keeping the
# text (plus its api_content sidecar) would replay a live confirmation.
ts_f = coerce_epoch(ts, field="message timestamp")
age = math.inf if ts_f is None else now - ts_f
if 0 <= age <= expiry_seconds:
cleaned.append(msg)
continue
logger.debug(
"Redacting stale dangerous-confirmation text in user message (age=%.1fs, expiry=%.1fs): %r",
now - float(ts), expiry_seconds, (msg.get("content") or "")[:80],
age, expiry_seconds, (msg.get("content") or "")[:80],
)
redacted = dict(msg)
redacted["content"] = _EXPIRED_CONFIRMATION_SENTINEL
+46
View File
@@ -5,6 +5,7 @@ when many sessions hit the same rate-limited provider concurrently.
"""
import random
import re
import threading
import time
from datetime import datetime, timezone
@@ -63,6 +64,51 @@ def parse_retry_after_seconds(value_or_headers: Any) -> Optional[float]:
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
# Free-text "reset" grammars providers put in error bodies, tried in order. One table so the
# conversation loop's error context and the credential pool's cooldown agree on the same wait.
_QUOTA_RESET_DELAY_RE = re.compile(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", re.IGNORECASE)
# "Resets in 4hr 5min" (weekly usage limits), "resets in 2 hours 5 minutes", "resets in 30s".
_RESETS_IN_RE = re.compile(
r"resets?\s+in\s+"
r"(?:(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b\s*)?"
r"(?:(\d+(?:\.\d+)?)\s*(?:m|min|mins|minute|minutes)\b\s*)?"
r"(?:(\d+(?:\.\d+)?)\s*(?:s|sec|secs|second|seconds)\b)?", re.IGNORECASE,
)
_RETRY_AFTER_SECONDS_RE = re.compile(r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", re.IGNORECASE)
def _quota_reset_seconds(m: "re.Match[str]") -> float:
value = float(m.group(1))
return value / 1000.0 if m.group(2).lower() == "ms" else value
def _resets_in_seconds(m: "re.Match[str]") -> Optional[float]:
if not any(m.groups()): # "resets in" with no unit-bearing number: not this grammar
return None
return float(m.group(1) or 0) * 3600 + float(m.group(2) or 0) * 60 + float(m.group(3) or 0)
# An explicit "retry after N s" wins over "resets in ..." (the credential pool's precedence):
# a body carrying both describes a short throttle inside a long quota window, and the
# shorter explicit wait is the one the provider actually asks for.
RETRY_DELAY_PATTERNS = (
(_QUOTA_RESET_DELAY_RE, _quota_reset_seconds),
(_RETRY_AFTER_SECONDS_RE, lambda m: float(m.group(1))),
(_RESETS_IN_RE, _resets_in_seconds),
)
def reset_delay_from_message(message: str) -> Optional[float]:
"""Seconds-until-reset parsed from free-text provider error messages, or None."""
if not message:
return None
for pattern, to_seconds in RETRY_DELAY_PATTERNS:
m = pattern.search(message)
if m and (seconds := to_seconds(m)) is not None:
return seconds
return None
def jittered_backoff(attempt: int, *, base_delay: float = 5.0, max_delay: float = 120.0, jitter_ratio: float = 0.5) -> float:
"""min(base * 2^(attempt-1), max_delay) + uniform jitter in
[0, jitter_ratio * delay]. ``attempt`` is 1-based."""
+42 -9
View File
@@ -7,10 +7,11 @@ context-local secret scope: ``set_secret_scope(mapping)`` installs the active
profile's secrets for the current task (a contextvar, so it propagates into the
agent's worker thread via ``copy_context()``); ``get_secret(name)`` reads from
it and, when multiplexing is active with no scope set, RAISES rather than
falling back to ``os.environ``. Design: ``docs/design/multiplexing-gateway.md``.
falling back to ``os.environ``. Design: ``website/docs/developer-guide/multiplexing-gateway.md``.
"""
from __future__ import annotations
import codecs
import os
import re
from contextvars import ContextVar, Token
@@ -131,12 +132,19 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
f"while multiplexing is on. This credential read must run inside a "
f"set_secret_scope(...) block (the per-turn / per-adapter profile "
f"scope). Reading os.environ here would risk leaking another "
f"profile's value. See docs/design/multiplexing-gateway.md "
f"profile's value. See website/docs/developer-guide/multiplexing-gateway.md "
f"(Workstream A)."
)
return _environ_or(name, default)
def get_secret_str(name: str, default: str = "") -> str:
"""``get_secret`` for callers that want a ``str``: ``default`` only when the secret is genuinely
unset. Still raises ``UnscopedSecretError`` — swallowing it hides a spawn-site bug."""
val = get_secret(name, default)
return default if val is None else val
def _strip_inline_comment(value: str) -> str:
"""Strip a dotenv-style inline comment (python-dotenv semantics): quoted values
scan to the matching close quote (backslash-aware for double quotes) and drop a
@@ -160,17 +168,42 @@ def _strip_inline_comment(value: str) -> str:
return re.split(r"\s+#", value, maxsplit=1)[0].strip()
def _parse_env_value(raw_value: str) -> str:
"""Parse the small .env value subset Hermes writes itself (bare, 'single', or "double" with
``\\"`` / ``\\\\`` escapes)."""
value = raw_value.strip()
if len(value) >= 2 and value[0] == value[-1] == '"':
quoted = value[1:-1]
parsed: list[str] = []
i = 0
while i < len(quoted):
escaped = quoted[i] == "\\" and quoted[i + 1:i + 2] in ('"', "\\")
parsed.append(quoted[i + 1] if escaped else quoted[i])
i += 2 if escaped else 1
return "".join(parsed)
if len(value) >= 2 and value[0] == value[-1] == "'":
return value[1:-1]
return value
def load_env_file(env_path: Path) -> Dict[str, str]:
"""Parse a ``.env`` file into a dict WITHOUT touching ``os.environ``: ``export``
prefix, ``#`` comments, and the writer's quote escapes reversed via the canonical
``_parse_env_value``. ``utf-8-sig`` so a BOM doesn't prefix the first key."""
"""THE ``.env`` tokenizer: every reader (profile scope, ``hermes_cli.config.load_env``, the dashboard
scrub, skill secret capture, managed .env, setup prompts) parses through here so no two boundaries
disagree on which keys/values a file defines. Dict only — never touches ``os.environ``. ``export``
prefix, ``#`` comments, quote escapes reversed; ``utf-8-sig`` so a BOM doesn't prefix the first key.
Invalid UTF-8 decodes as latin-1, exactly like ``env_loader._load_dotenv_with_fallback`` installs it
into ``os.environ``. Absent/unreadable → ``{}``."""
secrets: Dict[str, str] = {}
try:
text = env_path.read_text(encoding="utf-8-sig")
except (FileNotFoundError, OSError, UnicodeDecodeError):
raw = env_path.read_bytes()
except OSError:
return secrets
from hermes_cli.config import _parse_env_value
if raw.startswith(codecs.BOM_UTF8):
raw = raw[len(codecs.BOM_UTF8):]
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
text = raw.decode("latin-1")
for raw in text.splitlines():
line = raw.strip()
+11 -31
View File
@@ -11,13 +11,14 @@ from __future__ import annotations
import hashlib
import json
import os
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, Generic, Optional, TypeVar
from hermes_constants import secure_parent_dir
from utils import atomic_json_write
__all__ = [
"CachedFetch",
"DiskCache",
@@ -68,32 +69,13 @@ def entry_from_payload(payload: object) -> Optional[CachedFetch]:
return CachedFetch(secrets=typed, fetched_at=float(fetched_at))
def atomic_write_json(path: Path, payload: dict, *, tmp_prefix: str) -> None:
"""Write ``payload`` to ``path`` via mkstemp → chmod 0600 → os.replace.
The containing dir is forced to ``0700`` (``mkdir``'s mode is umask-subject,
so the chmod is the reliable form). Raises ``OSError`` on failure; callers
decide whether that is best-effort.
"""
cache_dir = path.parent
cache_dir.mkdir(parents=True, exist_ok=True)
try:
os.chmod(cache_dir, 0o700)
except OSError:
pass
# tempfile honours os.umask, so chmod 0600 explicitly before the rename.
fd, tmp = tempfile.mkstemp(prefix=tmp_prefix, suffix=".tmp", dir=str(cache_dir))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def atomic_write_json(path: Path, payload: dict) -> None:
"""Secret cache entry at 0600 from creation; the containing dir is tightened to 0700
(``secure_parent_dir`` refuses ``/``, top-level dirs and the install tree). Raises ``OSError``
on failure; callers decide whether that is best-effort."""
path.parent.mkdir(parents=True, exist_ok=True)
secure_parent_dir(path)
atomic_json_write(path, payload, indent=None, mode=0o600)
K = TypeVar("K")
@@ -116,8 +98,6 @@ class DiskCache(Generic[K]):
def __init__(self, basename: str, *, key_serializer: Callable[[K], str]) -> None:
self._basename = basename
self._key_serializer = key_serializer
# Per-backend temp prefix so concurrent writers in one dir never collide.
self._tmp_prefix = f".{basename.split('.', 1)[0]}_"
def path(self, home_path: Optional[Path] = None) -> Path:
return resolve_cache_home(home_path) / "cache" / self._basename
@@ -142,7 +122,7 @@ class DiskCache(Generic[K]):
return
payload = {"key": self._key_serializer(key), "secrets": entry.secrets, "fetched_at": entry.fetched_at}
try:
atomic_write_json(self.path(home_path), payload, tmp_prefix=self._tmp_prefix)
atomic_write_json(self.path(home_path), payload)
except OSError:
pass # best-effort — a disk-cache miss next invocation is fine
+1 -1
View File
@@ -139,7 +139,7 @@ def _write_encrypted_disk_cache(*, cache_key: _CacheKey, access_token: str, entr
ciphertext = AESGCM(key).encrypt(nonce, plaintext, serialized_key.encode("utf-8"))
payload = {"version": _ENCRYPTED_CACHE_VERSION, "key": serialized_key,
"salt": _b64e(salt), "nonce": _b64e(nonce), "ciphertext": _b64e(ciphertext)}
atomic_write_json(_encrypted_disk_cache_path(home_path), payload, tmp_prefix=".bws_cache_enc_")
atomic_write_json(_encrypted_disk_cache_path(home_path), payload)
_STORE.disk.clear(home_path)
except Exception: # noqa: BLE001 — best-effort cache only
return
+2 -12
View File
@@ -13,7 +13,6 @@ import os
import re
import subprocess
import sys
import tempfile
import threading
import time
from contextlib import ExitStack, contextmanager, suppress
@@ -31,7 +30,7 @@ except ImportError: # pragma: no cover
fcntl = None # type: ignore[assignment]
from hermes_constants import get_hermes_home
from utils import atomic_replace
from utils import atomic_json_write
logger = logging.getLogger(__name__)
@@ -469,16 +468,7 @@ def save_allowlist(data: Dict[str, Any]) -> None:
"""Atomic write; on OSError log and keep the in-process approval."""
p = allowlist_path()
try:
p.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=f"{p.name}.", suffix=".tmp", dir=str(p.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(json.dumps(data, indent=2, sort_keys=True))
atomic_replace(tmp_path, p)
except Exception:
with suppress(OSError):
os.unlink(tmp_path)
raise
atomic_json_write(p, data, sort_keys=True, mode=0o600)
except OSError as exc:
logger.warning("Failed to persist shell hook allowlist to %s: %s. The approval is in-memory for this run, "
"but the next startup will re-prompt (or skip registration on non-TTY runs without "
+12 -6
View File
@@ -13,7 +13,15 @@ from __future__ import annotations
import re
from typing import Tuple
__all__ = ["StreamingThinkScrubber"]
__all__ = ["StreamingThinkScrubber", "THINK_TAG_NAMES", "THINK_OPEN_TAGS", "THINK_CLOSE_TAGS"]
# The one list of model reasoning tag names. Every surface that hides reasoning (this scrubber,
# the CLI stream filter, the gateway stream filter, the final-response regex stripper) binds to
# these; a tag added here is covered everywhere. Consumers match case-insensitively, so the
# literal tags are lowercase.
THINK_TAG_NAMES: Tuple[str, ...] = ("think", "thinking", "reasoning", "thought", "REASONING_SCRATCHPAD")
THINK_OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name.lower()}>" for name in THINK_TAG_NAMES)
THINK_CLOSE_TAGS: Tuple[str, ...] = tuple(f"</{name.lower()}>" for name in THINK_TAG_NAMES)
class StreamingThinkScrubber:
@@ -24,11 +32,9 @@ class StreamingThinkScrubber:
was emitted yet — decides whether an open tag at buffer position 0 sits at a block boundary).
"""
_OPEN_TAG_NAMES: Tuple[str, ...] = ("think", "thinking", "reasoning", "thought", "REASONING_SCRATCHPAD")
# Lowercased literal tags so the hot path does string ops, not regex per feed().
_OPEN_TAGS: Tuple[str, ...] = tuple(f"<{name.lower()}>" for name in _OPEN_TAG_NAMES)
_CLOSE_TAGS: Tuple[str, ...] = tuple(f"</{name.lower()}>" for name in _OPEN_TAG_NAMES)
# Literal tags so the hot path does string ops, not regex per feed().
_OPEN_TAGS: Tuple[str, ...] = THINK_OPEN_TAGS
_CLOSE_TAGS: Tuple[str, ...] = THINK_CLOSE_TAGS
_ALL_TAGS: Tuple[str, ...] = _OPEN_TAGS + _CLOSE_TAGS
_MAX_TAG_LEN: int = max(len(tag) for tag in _ALL_TAGS)
# Orphan close tag plus trailing whitespace (matches _strip_think_blocks case 3).
+55 -4
View File
@@ -39,6 +39,29 @@ MAX_DERIVED_TITLE_CHARS = 48
# legitimate wordy titles while excluding full-sentence answers.
_MAX_TITLE_WORDS = 12
# The example titles shown to the model in the prompt, and the echo-guard
# set: when the opening message carries little topical signal, a small model
# sometimes takes the cheapest schema-valid answer and parrots one of these
# back verbatim — most visibly "Fix login button on mobile" naming sessions
# that have nothing to do with a login button. The prompt's example lines are
# rendered from these constants so the guard set and the prompt cannot drift
# apart. Port of QwenLM/qwen-code#9709.
_PROMPT_GOOD_EXAMPLES = (
"Fix login button on mobile",
"Postgres connection pool exhaustion",
"Friendly greeting",
)
_PROMPT_VAGUE_EXAMPLE = "Code changes"
# "Friendly greeting" is deliberately NOT in the reject set: the prompt
# instructs the model to produce it for bare greetings, so it is a legitimate
# output, not an echo failure. The too-vague example is rejected too — a model
# repeating the counter-example says nothing about the session, and the
# derived title the guard falls back to is strictly more informative.
_EXAMPLE_ECHO_REJECT = frozenset(
t.lower() for t in _PROMPT_GOOD_EXAMPLES if t != "Friendly greeting"
) | {_PROMPT_VAGUE_EXAMPLE.lower()}
_TITLE_PROMPT_TEMPLATE = (
"You name chat sessions. Given the user's opening message, write a title "
"that lets them find this conversation again in a list.\n\n"
@@ -51,10 +74,8 @@ _TITLE_PROMPT_TEMPLATE = (
"- Never answer the message. Name it.\n"
"- Always produce something, even for a bare greeting.\n"
"__LANGUAGE_RULE__\n"
'Good: {"title": "Fix login button on mobile"}\n'
'Good: {"title": "Postgres connection pool exhaustion"}\n'
'Good: {"title": "Friendly greeting"}\n'
'Too vague: {"title": "Code changes"}\n'
+ "".join(f'Good: {{"title": "{t}"}}\n' for t in _PROMPT_GOOD_EXAMPLES)
+ f'Too vague: {{"title": "{_PROMPT_VAGUE_EXAMPLE}"}}\n'
'Too long: {"title": "Investigate and fix the issue where the login button '
'does not respond on mobile devices"}\n\n'
'Reply with JSON only: {"title": "..."}'
@@ -229,6 +250,18 @@ def _notify_title(title_callback: Optional[TitleCallback], title: str, source: s
_safe_callback(title_callback, (title, source), "%s callback failed", label)
def _is_prompt_example_echo(title: str) -> bool:
"""Return True when *title* is one of the prompt's own example titles.
Comparison is case-insensitive after stripping any leading/trailing run of
non-letter/non-digit characters, so bracket/quote wrappers cannot bypass
the guard — while ``_clean_title`` keeps brackets for real titles like
"(WIP) Fix build". Unicode-aware so full-width wrappers are covered too.
"""
normalized = re.sub(r"^[\W_]+|[\W_]+$", "", title.strip(), flags=re.UNICODE).lower()
return normalized in _EXAMPLE_ECHO_REJECT
def generate_title(
user_message: str,
timeout: Optional[float] = None,
@@ -267,6 +300,13 @@ def generate_title(
# A title is a handful of tokens; a larger ceiling let chatty models burn seconds.
max_tokens=64, temperature=0.3, timeout=timeout, main_runtime=main_runtime,
extra_body={"response_format": _TITLE_RESPONSE_FORMAT},
# The module contract above promises thinking-disabled operation,
# but nothing enforced it: with the aux default reasoning_effort
# "" (provider default), Gemini enables internal thinking and
# bills thought tokens against max_tokens=64 — the JSON payload
# never lands, and the prose fallback stores the opening fence
# ("```json") as the session title (#91927).
reasoning_config={"enabled": False},
)
title = _clean_title(_extract_title_text(response.choices[0].message.content or ""))
# Answer-shaped output guard: titling is a 3-7 word task, so a title with many words is a model that
@@ -278,6 +318,17 @@ def generate_title(
# Answer-shaped output: reject (not truncate) so the caller retries next exchange.
logger.debug("Rejecting answer-shaped title output (%d words > %d)", len(title.split()), _MAX_TITLE_WORDS)
return None
# Example-echo guard: a title that parrots one of the prompt's own
# examples back verbatim says nothing about the session — reject it so
# the instant derived title (a slice of the user's actual words)
# survives instead. Exact match after wrapper-stripping, deliberately
# not fuzzy, so a genuinely topical title that merely resembles an
# example still passes. Wrappers are stripped for the comparison only
# ("(Fix login button on mobile)" is the same canned echo as the bare
# example). Port of QwenLM/qwen-code#9709.
if title is not None and _is_prompt_example_echo(title):
logger.debug("Rejecting prompt-example echo title: %r", title)
return None
return title
except Exception as e:
# WARNING so it shows in agent.log without debug mode; stack at debug.
+74
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import hashlib
import json
from collections import deque
from dataclasses import asdict, dataclass, field, fields
from typing import Any, Mapping
@@ -35,6 +36,15 @@ STALL_GUARD_REPEATABLE_TOOLS = frozenset({"process_manage"})
_STALL_GUARD_REPEATABLE_SUFFIXES = ("_get_result", "_poll") # generated / MCP poller conventions
# Nth consecutive identical (tool, args, result) call that fires the notice; 3 tolerates one double-check.
STALL_GUARD_IDENTICAL_CALL_THRESHOLD = 3
# Repeating multi-call cycles (A,B,A,B,... with identical args AND results) defeat the
# consecutive streak above — every alternation resets it, so a model replaying the same
# 2–4 call batch each iteration ran to the budget unflagged (port of can1357/oh-my-pi#10521,
# which widened their loop guard from single-call turns to whole tool-call batches).
# Longest cycle period detected; laps reuse the streak thresholds (notice at
# STALL_GUARD_IDENTICAL_CALL_THRESHOLD laps, halt at no_progress_block_after laps).
_STALL_GUARD_MAX_CYCLE_PERIOD = 4
# History window: enough for block_after laps of the longest cycle plus slack.
_STALL_GUARD_CYCLE_HISTORY = 64
# From the 2nd byte-identical repeat the duplicate payload becomes a reference stub; smaller results
# aren't worth it, errors never are. The args preview keeps WHAT was called if compression evicts the original.
IDENTICAL_RESULT_STUB_MIN_CHARS = 512
@@ -249,6 +259,11 @@ _DECISION_MESSAGES: dict[str, str] = {
"Stopped {tool_name}: the same call with identical arguments returned the same result "
"{count} times in a row. Stop repeating it unchanged; use the result already provided or change strategy."
),
"identical_cycle_halt": (
"Stopped {tool_name}: the same repeating cycle of tool calls (period {period}) with identical "
"arguments and identical results has run {count} times. Repeating the batch unchanged is not "
"progress; use the results already provided or change strategy."
),
"loop_web_search_cap": (
"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."
@@ -266,6 +281,13 @@ _IDENTICAL_CALL_NOTICE = (
"proceed with what you have.]"
)
_IDENTICAL_CYCLE_NOTICE = (
"[hermes note: the last {count} rounds repeated the same cycle of {period} tool calls "
"(ending with {tool_name}) with identical arguments and identical results. "
"Do not repeat the batch — change arguments, use a different tool, or "
"proceed with what you have.]"
)
# tool -> (LoopCapConfig field, controller counter attribute, decision code)
_LOOP_CAPS: dict[str, tuple[str, str, str]] = {
"web_search": ("max_web_searches", "_turn_web_search_count", "loop_web_search_cap"),
@@ -298,6 +320,10 @@ class ToolCallGuardrailController:
self._identical_streak_result_hash: str = ""
self._identical_streak_count: int = 0
self._identical_streak_first_call_id: str = ""
# Batch-cycle loop breaker (port of can1357/oh-my-pi#10521): sequence of
# (signature, result_hash, repeatable) for every observed call this turn, so a repeating
# multi-call cycle (A,B,A,B,...) is caught even though it resets the consecutive streak above.
self._call_history: deque[tuple[ToolCallSignature, str, bool]] = deque(maxlen=_STALL_GUARD_CYCLE_HISTORY)
# tool_call_id -> spillover path, so a stub referencing a persisted-output preview can't dangle.
self._persisted_result_paths: dict[str, str] = {}
self._turn_web_search_count = 0
@@ -434,11 +460,59 @@ class ToolCallGuardrailController:
if self.config.hard_stop_enabled and count >= self.config.no_progress_block_after and self._halt_decision is None:
self._decide("halt", "identical_call_streak_halt", tool_name, count, signature)
# Batch-cycle detection (oh-my-pi#10521): a repeating multi-call cycle resets the
# consecutive streak on every alternation, so check the call history for a period-p lap.
if is_plain_str:
self._call_history.append((signature, result_hash, is_stall_guard_repeatable(tool_name)))
else:
self._call_history.clear()
if notice is None and is_plain_str:
cycle = self._detect_identical_cycle()
if cycle is not None:
period, laps = cycle
notice = _IDENTICAL_CYCLE_NOTICE.format(count=laps, period=period, tool_name=tool_name)
if self.config.hard_stop_enabled and laps >= self.config.no_progress_block_after and self._halt_decision is None:
self._decide("halt", "identical_cycle_halt", tool_name, laps, signature, period=period)
stub = None
if is_plain_str and count >= 2 and not failed and len(result) >= IDENTICAL_RESULT_STUB_MIN_CHARS:
stub = self._build_result_reference_stub(tool_name, args)
return IdenticalCallObservation(notice=notice, stub=stub)
def _detect_identical_cycle(self) -> tuple[int, int] | None:
"""Detect a repeating identical-call cycle ending at the latest observed call.
Returns ``(period, laps)`` for the smallest period 2..max whose trailing laps
(identical signature AND result per position) reach the notice threshold, else None.
Period 1 is the consecutive streak's job. A cycle made ONLY of poller-exempt tools
is exempt (an unchanged poll loop is legitimate waiting); one non-exempt call in
the cycle keeps the guard armed, matching the single-call exemption semantics.
"""
history = self._call_history
for period in range(2, _STALL_GUARD_MAX_CYCLE_PERIOD + 1):
if len(history) < period * STALL_GUARD_IDENTICAL_CALL_THRESHOLD:
continue
laps = 1
# Count how many consecutive trailing laps equal the final lap.
while True:
base = len(history) - period * (laps + 1)
if base < 0:
break
lap_equal = all(
history[base + i][:2] == history[len(history) - period + i][:2]
for i in range(period)
)
if not lap_equal:
break
laps += 1
if laps >= STALL_GUARD_IDENTICAL_CALL_THRESHOLD:
tail = [history[len(history) - period + i] for i in range(period)]
if all(repeatable for _, _, repeatable in tail):
continue
# A constant sub-cycle would already have fired at a smaller period.
return period, laps
return None
def record_persisted_result(self, tool_call_id: str, file_path: str) -> None:
"""Remember the spillover path a persisted result was saved to."""
if tool_call_id and file_path:
+15 -2
View File
@@ -13,6 +13,7 @@ from agent.reasoning_effort import (
KIMI_K3_EFFORTS, KIMI_K3_OVERRIDES, OPENAI_COMPAT_WIRE_EFFORTS, TOKENHUB_EFFORTS, clamp_effort,
kimi_supported_efforts, requested_effort,
)
from agent.message_sanitization import normalize_finish_reason as _normalize_finish_reason
from agent.moonshot_schema import is_moonshot_model, sanitize_moonshot_tools
from agent.prompt_builder import DEVELOPER_ROLE_MODELS
from agent.transports.base import ProviderTransport
@@ -139,7 +140,17 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
return None
effort = str(reasoning_config.get("effort", "medium") or "medium").strip().lower()
if reasoning_config.get("enabled") is False or effort == "none":
return {"includeThoughts": False}
# ``includeThoughts: False`` only omits thought parts from the returned
# response; the model may still reason internally and bill thought
# tokens against maxOutputTokens, starving small budgets (title
# generation's 64 tokens). Set thinkingBudget to 0 to actually disable
# thinking on families that document it: Gemini 2.5 and 3+ (plus the
# ``gemini-flash-latest`` alias); future majors are added only when the
# API documents thinkingBudget for them. (#91927)
config: dict[str, Any] = {"includeThoughts": False}
if normalized_model == "gemini-flash-latest" or normalized_model.startswith(("gemini-2.5-", "gemini-3")):
config["thinkingBudget"] = 0
return config
thinking_config: dict[str, Any] = {"includeThoughts": True}
# Gemini 2.5 takes thinkingBudget; don't guess one from coarse effort levels.
if normalized_model.startswith("gemini-2.5-"):
@@ -502,7 +513,9 @@ class ChatCompletionsTransport(ProviderTransport):
choice = response.choices[0]
msg = getattr(choice, "message", None)
_fr = getattr(choice, "finish_reason", None)
finish_reason = (str(_fr) if isinstance(_fr, int) else _fr) or "stop" # Poolside returns int finish_reason
# Poolside returns int finish_reason; Gemini-fronting gateways return
# uppercase STOP / MAX_TOKENS — fold to the OpenAI contract here.
finish_reason = _normalize_finish_reason(str(_fr) if isinstance(_fr, int) else _fr) or "stop"
tool_calls = None
if getattr(msg, "tool_calls", None):
+4 -14
View File
@@ -12,9 +12,7 @@ from dataclasses import dataclass
import logging
from typing import Any
from agent.message_sanitization import (
_sanitize_structure_non_ascii, _sanitize_structure_surrogates
)
from agent.message_sanitization import sanitize_outbound_kwargs
from utils import env_var_enabled
logger = logging.getLogger("agent.conversation_loop")
@@ -120,17 +118,9 @@ def build_api_request(
api_kwargs = agent._build_api_kwargs(api_messages)
else:
api_kwargs = agent._build_api_kwargs(api_messages, tools_for_api=tools_for_api)
# Surrogate chokepoint: tool descriptions, extra_body and kwargs strings can carry
# invalid code points (HTTP 400). One walk makes the payload json.dumps()-safe.
# Outbound-request surrogate chokepoint (#50959): the messages were scrubbed above, but the rest of the
# request body — tool/function descriptions (session_search's ±-heavy text is the recorded repro),
# extra_body, system strings routed via kwargs — can still carry invalid code points that providers
# reject with a non-retryable HTTP 400 ("invalid unicode code point"). One in-place walk here guarantees
# the entire payload json.dumps()-safe regardless of which leaf produced the string. Fast no-op when the
# payload is clean.
_sanitize_structure_surrogates(api_kwargs)
if agent._force_ascii_payload:
_sanitize_structure_non_ascii(api_kwargs)
# Messages were scrubbed above; this walk covers the rest of the payload (tool descriptions,
# extra_body, kwargs strings) — see sanitize_outbound_kwargs for the #50959 rationale.
sanitize_outbound_kwargs(agent, api_kwargs)
if agent.api_mode == "codex_responses":
api_kwargs = agent._get_transport().preflight_kwargs(
api_kwargs, allow_stream=False, is_github_responses=agent._is_copilot_url(),
+25 -3
View File
@@ -508,6 +508,11 @@ def _reset_per_turn_agent_state(agent: Any) -> None:
_reset_consol = getattr(agent._memory_store, "reset_consolidation_failures", None)
if callable(_reset_consol):
_reset_consol()
# Expiry clock for build_api_messages: admission time (not the input's platform-event
# stamp, which can predate admission by minutes), frozen so every request this turn
# sends identical bytes. Distinct from note_turn_start's _inflight_turn_started, a
# tripwire slot cleared at persist.
agent._current_turn_timestamp = time.time()
# Pre-turn connection health check: clean up dead TCP connections.
if agent.api_mode != "anthropic_messages":
@@ -1047,9 +1052,26 @@ def build_api_messages(
replayed verbatim."""
from agent.agent_runtime_helpers import fill_empty_non_final_wire_payload
from agent.conversation_loop import _clone_message_for_send
from agent.replay_cleanup import canonicalize_replay_history
has_current = isinstance(current_turn_user_idx, int) and 0 <= current_turn_user_idx < len(messages)
current_turn_message = messages[current_turn_user_idx] if has_current else None
# Replay consumers canonicalize the persisted prefix on read; the request copy must
# carry the same bytes or a resume diverges mid-prefix. Only the rows BEFORE this
# turn's user message are the replayed prefix — rows this turn appended (its tool
# calls/results) are live and must never be rewritten between iterations. The
# expiry clock is the turn's admission time, frozen in _reset_per_turn_agent_state.
# Without an anchor (compaction found no surviving user row) there is no provable
# persisted prefix, so nothing is canonicalized. The clock is stamped once per turn in
# _reset_per_turn_agent_state; a caller that skipped the prologue fails loudly here
# rather than silently un-freezing it.
turn_now = agent._current_turn_timestamp
split = current_turn_user_idx if has_current else 0
canonical_messages = canonicalize_replay_history(messages[:split], now=turn_now) + messages[split:]
api_messages = []
for idx, msg in enumerate(messages):
for idx, msg in enumerate(canonical_messages):
# Structural clone, NOT msg.copy(): in-place transforms below must not reach
# persisted history via nested containers; see _clone_message_for_send.
api_msg = _clone_message_for_send(msg)
@@ -1063,7 +1085,7 @@ def build_api_messages(
# Inject ephemeral context (memory prefetch + pre_llm_call user hooks)
# at API time only; `messages` is untouched beyond the api_content stamp.
if idx == current_turn_user_idx and msg.get("role") == "user":
if msg is current_turn_message and msg.get("role") == "user":
if isinstance(_api_content, str) and _api_content:
# Reuse the prologue's stamp so sidecar and wire cannot drift
# and every pass this turn sends identical bytes.
@@ -1094,7 +1116,7 @@ def build_api_messages(
# Fill empty non-final user/assistant wire copies so the pre-call sanitizer
# stops re-healing and flooding errors.log; durable history is untouched.
# After the reasoning copy so thinking-only turns keep payload.
fill_empty_non_final_wire_payload(api_msg, is_final=(idx == len(messages) - 1))
fill_empty_non_final_wire_payload(api_msg, is_final=(idx == len(canonical_messages) - 1))
# _thinking_prefill survives intentionally: the drop pass below needs it.
# Strip length-continuation marks; some transports keep underscore keys.
api_msg.pop("_length_continuation_fragment", None)
+20
View File
@@ -72,6 +72,26 @@ def finish_text_response(
result=result,
)
# Reasoning-only clean stop: some reasoning parsers (vLLM nemotron_v3 past ~500K
# prompt tokens) file the whole answer as reasoning when the model omits the closing
# delimiter. ``finish_reason == "stop"`` means the provider considers generation
# complete, so the empty-response ladder would only re-bill the same input to arrive
# at a truncated preview of this text; promote the reasoning to the visible answer
# BEFORE the ladder. ``length`` (cut off mid-thought) stays on the continuation path,
# and the promoted text is persisted as ordinary content so the next turn replays it.
_content = assistant_message.content
if (
finish_reason == "stop"
and not assistant_message.tool_calls
and (_content is None or (isinstance(_content, str) and not _content.strip()))
):
_promoted = agent._extract_reasoning(assistant_message)
if _promoted:
logger.info(
"Reasoning-only clean stop (%d chars) — using reasoning as the final response",
len(_promoted),
)
assistant_message.content = _promoted
final_response = assistant_message.content or ""
# Unmute: _mute_post_response from a housekeeping tool turn must not silence
# empty-response warnings on the final response path.
+3 -18
View File
@@ -30,6 +30,7 @@ from typing import Any, Dict, List, Optional
from urllib.parse import urlsplit
from hermes_constants import get_hermes_home
from utils import atomic_write_bytes
VAULT_KINDS = ("login", "payment", "address")
@@ -282,24 +283,8 @@ class VaultStore:
self._ensure_dir()
payload = json.dumps({"version": 1, "items": items}).encode("utf-8")
blob = self._fernet().encrypt(payload)
tmp = self._vault_path.with_suffix(".enc.tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, blob)
os.fsync(fd) # the blob must be on disk before the rename makes it THE vault
finally:
os.close(fd)
os.replace(tmp, self._vault_path)
with suppress(OSError): # directory entry durable too (power loss between rename and next sync)
dfd = os.open(self._base, os.O_RDONLY)
try:
os.fsync(dfd)
finally:
os.close(dfd)
try:
os.chmod(self._vault_path, 0o600)
except OSError:
pass
# fsync_dir: the directory entry must be durable too (power loss between rename and next sync).
atomic_write_bytes(self._vault_path, blob, mode=0o600, fsync_dir=True)
# -- public API ----------------------------------------------------------
+6 -32
View File
@@ -10,11 +10,10 @@ 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, Iterator, Optional
from typing import Any, Optional
from hermes_constants import get_hermes_home
@@ -120,40 +119,15 @@ def _ledger_enabled() -> bool:
def _connect() -> sqlite3.Connection:
from hermes_state_wal import apply_wal_with_fallback
from hermes_cli.sqlite_util import open_db
path = _db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
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 connect() must not leak the open connection.
conn.close()
raise
return conn
return open_db(_db_path(), db_label="verification_evidence.db", initialize=_ensure_schema)
@contextmanager
def _transaction() -> Iterator[sqlite3.Connection]:
"""Open a connection, commit/rollback on exit, and ALWAYS close it.
def _transaction():
from hermes_cli.sqlite_util import transaction
``sqlite3.Connection`` as a context manager only commits/rolls back; without
the close, each call leaks a connection (and WAL/SHM fds) until GC runs.
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()
return transaction(_connect())
def _ensure_schema(conn: sqlite3.Connection) -> None:
+4
View File
@@ -272,6 +272,10 @@ so glass and message-bubble transparency do not reveal scrolling text.
from the chip to the floating pill; leaving both dismisses it.
- A tool result may expose an inline action that opens a preview. It must not
open the rail automatically.
- Tool rows reserve destructive red for explicit failures. Missing read paths and
ambiguous exit-1 results use neutral notices, with details still available.
Errors described inside returned data are not tool failures. Expanded failures
show the actual explanation; supporting output keeps its normal text color.
- Composer status groups start collapsed except todos. Progress updates and queue
pause/resume preserve the user's disclosure choice. Error banners meet the
stack's top edge without a blank padding strip. File and preview links remain
@@ -56,7 +56,8 @@ test.skipIf(process.platform === 'win32')(
const deps = {
pool: new Map([['profile', { process: child }]]),
stopChild: lifecycle.stop
stopChild: (c: unknown): void => { void lifecycle.stop(c as ChildProcess) },
waitForExit: (c: unknown): Promise<void> => lifecycle.stop(c as ChildProcess)
}
const pool = createPoolStopper(deps)
@@ -136,7 +137,11 @@ test.skipIf(process.platform === 'win32')(
child.once('exit', (): boolean => lifecycle.release(child))
const entry = { process: child, releaseLocalBackendSlot: release }
const entries = new Map([['claiming', entry]])
const pool = createPoolStopper({ pool: entries, stopChild: lifecycle.stop })
const pool = createPoolStopper({
pool: entries,
stopChild: (c: unknown): void => { void lifecycle.stop(c as ChildProcess) },
waitForExit: (c: unknown): Promise<void> => lifecycle.stop(c as ChildProcess)
})
let finishClaim!: () => void
const claim = new Promise<void>((resolve: () => void): void => {
+30 -2
View File
@@ -403,6 +403,7 @@ import { createSourcePythonBackend, resolveSourceInstallationBackend, type Sourc
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
import { collectSshConfigHosts, parseSshGOutput } from './ssh-config'
import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection'
import { createSshIsolatedKeepaliveRegistry } from './ssh-isolated-keepalive'
import { createSshTeardownTracker } from './ssh-teardown'
import { createStreamThrottle } from './stream-throttle'
import { registerTerminalIpc } from './terminal-ipc'
@@ -9123,6 +9124,9 @@ async function buildRemoteConnection(
}
const sshConnections = new Map<string, any>()
const sshIsolatedKeepalives = createSshIsolatedKeepaliveRegistry({
log: chunk => sshRememberLog(chunk)
})
const desktopInstallationId = loadOrCreateInstallationId(DESKTOP_INSTALLATION_PATH)
// Managed SSH update lifecycle (#93042): while an update owns a registered
@@ -9334,6 +9338,7 @@ async function sshProbeReuseProof(baseUrl, token, spawnNonce) {
async function teardownSshConnection(profile) {
const scope = sshScopeKey(profile)
sshIsolatedKeepalives.stop(scope)
const state = sshConnections.get(scope)
if (!state) {
@@ -9605,6 +9610,7 @@ async function rollbackSshBootstrapResult(ssh, result, profile, sshConfig, bound
}
if (sshConnections.get(scope)?.ssh === ssh) {
sshIsolatedKeepalives.stop(scope)
sshConnections.delete(scope)
}
@@ -9639,6 +9645,7 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc
}
ssh = null
sshIsolatedKeepalives.stop(scope)
sshConnections.delete(scope)
}
@@ -9762,6 +9769,7 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc
// site may label a registry-qualified SSH scope as the primary backend.
primaryRegistryScope: metadata.primaryRegistryScope === true
})
sshIsolatedKeepalives.start(scope, { baseUrl: result.baseUrl, token: result.token })
},
rollback: error => rollbackSshBootstrapResult(ssh, result, profile, sshConfig, error)
})
@@ -11136,6 +11144,7 @@ async function drainManagedSshScope(scope) {
}
if (state && sshConnections.get(scope.key) === state) {
sshIsolatedKeepalives.stop(scope.key)
sshConnections.delete(scope.key)
}
}
@@ -11672,9 +11681,27 @@ async function spawnPoolBackend(
// physical shutdown promise resolves. Previously
// SIGTERM + immediate entry delete dropped the handle and a slow child
// survived detached under PID 1.
const poolStopper = createPoolStopper<ChildProcess>({
const poolStopper = createPoolStopper({
pool: backendPool,
stopChild: localBackendLifecycle.stop
// localBackendLifecycle is the single owner of local child stop semantics:
// stopChild triggers its stop (stop + bounded exit wait + slot release);
// waitForExit resolves when that same stop promise settles. Remote /
// SSH-isolated pool entries keep `process: null` — child exit is immediate
// there, so the same in-flight fence carries only the bootstrap drain +
// SSH teardown below (#106935).
stopChild: child => {
void localBackendLifecycle.stop(child as ChildProcess | null | undefined)
},
waitForExit: child => localBackendLifecycle.stop(child as ChildProcess | null | undefined),
afterStop: async key => {
try {
await sshBootstrapCoordinator.cancelAndWait(key, () => teardownSshConnection(key))
} catch (err) {
// The idle reaper calls stopPoolBackend un-awaited; a failed SSH teardown
// must not surface as an unhandled rejection or block the pool fence.
sshRememberLog(`[ssh-teardown] ${key}: ${String(err)}`)
}
}
})
async function stopPoolBackend(profile: string) {
@@ -16164,6 +16191,7 @@ app.on('before-quit', () => {
// Close the pooled keep-alive sockets on quit so lingering connections can't
// hold the event loop open or leak FDs past app teardown.
app.on('will-quit', () => {
sshIsolatedKeepalives.stopAll()
destroyKeepaliveAgents()
})
+86 -53
View File
@@ -13,31 +13,25 @@ interface Child {
killed: boolean
}
function harness(): {
addChild: (key: string) => Child
events: string[]
exitResolvers: Map<Child, () => void>
pool: Map<string, PoolStopEntry<Child>>
stopper: ReturnType<typeof createPoolStopper<Child>>
} {
const pool = new Map<string, PoolStopEntry<Child>>()
function harness() {
const pool = new Map<string, PoolStopEntry>()
const events: string[] = []
const exitResolvers = new Map<Child, () => void>()
const stopper = createPoolStopper({
pool,
stopChild: (child: Child | undefined): Promise<void> => {
stopChild: child => {
;(child as Child).killed = true
events.push('stop')
return new Promise<void>((resolve: () => void): void => {
exitResolvers.set(child as Child, (): void => {
},
waitForExit: child =>
new Promise<void>(resolve => {
exitResolvers.set(child as Child, () => {
;(child as Child).exited = true
events.push('exit')
resolve()
})
})
}
})
function addChild(key: string): Child {
@@ -103,11 +97,10 @@ test('a remote pooled descriptor without a local child does not require quit def
assert.equal(stopper.hasPending(), false)
})
test('stopAll waits for current and already-stopping backends', async () => {
test('stopAll stops every pooled backend and resolves after all exits', async () => {
const { addChild, exitResolvers, pool, stopper } = harness()
const a = addChild('a')
const b = addChild('b')
const priorStop = stopper.stop('a')
let settled = false
@@ -116,21 +109,94 @@ test('stopAll waits for current and already-stopping backends', async () => {
})
assert.equal(pool.size, 0)
assert.equal(stopper.hasPending(), true)
assert.equal(a.killed, true)
assert.equal(b.killed, true)
exitResolvers.get(b)?.()
exitResolvers.get(a)?.()
await Promise.resolve()
await new Promise(setImmediate)
assert.equal(settled, false, 'must wait for EVERY child, not the first')
exitResolvers.get(a)?.()
await Promise.all([all, priorStop])
exitResolvers.get(b)?.()
await all
assert.equal(settled, true)
})
test('stopAll joins a stop whose pool entry was already evicted', async () => {
const { addChild, exitResolvers, pool, stopper } = harness()
const child = addChild('already-stopping')
const first = stopper.stop('already-stopping')
assert.equal(pool.size, 0)
assert.equal(stopper.hasPending(), true)
let settled = false
const all = stopper.stopAll().then(() => {
settled = true
})
await Promise.resolve()
assert.equal(settled, false)
exitResolvers.get(child)?.()
await Promise.all([first, all])
assert.equal(settled, true)
assert.equal(stopper.hasPending(), false)
})
test('afterStop holds inFlight until extra teardown finishes (process-less SSH)', async () => {
const pool = new Map<string, PoolStopEntry>()
const events: string[] = []
let releaseAfter: (() => void) | undefined
const afterGate = new Promise<void>(resolve => {
releaseAfter = resolve
})
const stopper = createPoolStopper({
pool,
stopChild: () => {
events.push('stop')
},
waitForExit: async () => {
events.push('exit')
},
afterStop: async () => {
events.push('after-start')
await afterGate
events.push('after-done')
}
})
pool.set('ssh', { process: null })
const stop = stopper.stop('ssh')
await Promise.resolve()
await Promise.resolve()
assert.equal(stopper.inFlight('ssh'), stop)
assert.deepEqual(events, ['stop', 'exit', 'after-start'])
let spawned = false
const respawn = (async () => {
const dying = stopper.inFlight('ssh')
if (dying) {
await dying
}
spawned = true
})()
await Promise.resolve()
assert.equal(spawned, false, 'reconnect must wait for SSH teardown, not just child exit')
releaseAfter?.()
await stop
await respawn
assert.equal(spawned, true)
assert.deepEqual(events, ['stop', 'exit', 'after-start', 'after-done'])
assert.equal(stopper.inFlight('ssh'), undefined)
})
test('a respawn can await the in-flight stop before reusing the key', async () => {
const { addChild, exitResolvers, stopper } = harness()
const child = addChild('selena')
@@ -154,36 +220,3 @@ test('a respawn can await the in-flight stop before reusing the key', async () =
assert.deepEqual(order, ['exit-signal', 'spawn'])
})
test('failed stops block respawn and retain the child for a later stop retry', async () => {
const child: Child = { exited: false, killed: false }
const pool = new Map([['profile', { process: child }]])
const failure = new Error('child is still alive')
const attempts: Child[] = []
let refuses = true
const stopper = createPoolStopper({
pool,
stopChild: async (current: Child | undefined): Promise<void> => {
attempts.push(current!)
if (refuses) {
throw failure
}
current!.exited = true
}
})
const failed = stopper.stop('profile')
await assert.rejects(failed, error => error === failure)
assert.equal(pool.has('profile'), false)
assert.equal(stopper.inFlight('profile'), failed)
await assert.rejects(stopper.inFlight('profile')!, error => error === failure)
refuses = false
await stopper.stopAll()
assert.deepEqual(attempts, [child, child])
assert.equal(child.exited, true)
assert.equal(stopper.inFlight('profile'), undefined)
})
+32 -42
View File
@@ -22,15 +22,22 @@
* directly instead of grepping main.ts source text.
*/
export interface PoolStopEntry<Process = unknown> {
process?: Process
export interface PoolStopEntry {
process?: unknown
}
export interface PoolStopperDeps<Process> {
export interface PoolStopperDeps {
/** The live backend pool. Entries are evicted synchronously on stop. */
pool: Map<string, PoolStopEntry<Process>>
/** The physical lifecycle owns signalling, escalation and confirmed exit. */
stopChild: (child: Process | undefined) => Promise<void>
pool: Map<string, PoolStopEntry>
/** Signal the child (tree/group kill per platform). Synchronous. */
stopChild: (child: unknown) => void
/** Bounded wait: resolves when the child exits, escalating to SIGKILL. */
waitForExit: (child: unknown) => Promise<void>
/**
* Extra per-key work that must finish before a replacement may spawn.
* Held on the same in-flight promise as child exit (SSH teardown, etc.).
*/
afterStop?: (key: string) => Promise<void>
}
export interface PoolStopper {
@@ -44,23 +51,17 @@ export interface PoolStopper {
stopAll: () => Promise<void>
}
interface PendingStop<Process> {
entry: PoolStopEntry<Process>
completion: Promise<void>
failed: boolean
}
export function createPoolStopper<Process>(deps: PoolStopperDeps<Process>): PoolStopper {
const stops = new Map<string, PendingStop<Process>>()
export function createPoolStopper(deps: PoolStopperDeps): PoolStopper {
const stops = new Map<string, Promise<void>>()
function stop(key: string): Promise<void> {
const inFlight = stops.get(key)
if (inFlight && !inFlight.failed) {
return inFlight.completion
if (inFlight) {
return inFlight
}
const entry = inFlight?.entry ?? deps.pool.get(key)
const entry = deps.pool.get(key)
if (!entry) {
return Promise.resolve()
@@ -70,40 +71,29 @@ export function createPoolStopper<Process>(deps: PoolStopperDeps<Process>): Pool
// below retains the process handle until the bounded exit completes.
deps.pool.delete(key)
const stopping = (async (): Promise<void> => {
await deps.stopChild(entry.process)
})().then(
(): void => {
stops.delete(key)
},
(error: unknown): never => {
pending.failed = true
throw error
const stopping = (async () => {
deps.stopChild(entry.process)
await deps.waitForExit(entry.process)
if (deps.afterStop) {
await deps.afterStop(key)
}
)
})().finally(() => {
stops.delete(key)
})
const pending: PendingStop<Process> = { entry, completion: stopping, failed: false }
stops.set(key, pending)
stops.set(key, stopping)
return stopping
}
return {
inFlight: (key: string): Promise<void> | undefined => stops.get(key)?.completion,
hasPending: (): boolean =>
stops.size > 0 || [...deps.pool.values()].some((entry: PoolStopEntry<Process>): boolean => entry.process != null),
inFlight: key => stops.get(key),
hasPending: () => stops.size > 0 || [...deps.pool.values()].some(entry => entry.process != null),
stop,
stopAll: async (): Promise<void> => {
const pending = new Set([...deps.pool.keys(), ...stops.keys()])
const results = await Promise.allSettled([...pending].map(stop))
stopAll: async () => {
const currentStops = [...deps.pool.keys()].map(stop)
const errors = results
.filter((result: PromiseSettledResult<void>): result is PromiseRejectedResult => result.status === 'rejected')
.map((result: PromiseRejectedResult): unknown => result.reason)
if (errors.length) {
throw new AggregateError(errors, 'Backend pool shutdown failed')
}
await Promise.all(new Set([...stops.values(), ...currentStops]))
}
}
}
@@ -208,6 +208,40 @@ test('cancelAndWait force-cleans pending resources before awaiting rollback', as
assert.equal(cleaned, 1)
})
test('cancelAndWait keeps the drain up through afterCancel teardown', async () => {
const coordinator = createBootstrapCoordinator()
const events: string[] = []
let releaseAfter: (() => void) | undefined
const afterGate = new Promise<void>(resolve => {
releaseAfter = resolve
})
let teardownStarted: (() => void) | undefined
const started = new Promise<void>(resolve => {
teardownStarted = resolve
})
const drain = coordinator.cancelAndWait('scope', async () => {
events.push('teardown-start')
teardownStarted?.()
await afterGate
events.push('teardown-done')
})
await started
const next = coordinator.start('scope', 'new', async () => {
events.push('new-start')
return 'new'
})
await Promise.resolve()
assert.deepEqual(events, ['teardown-start'])
releaseAfter?.()
await drain
assert.equal(await next, 'new')
assert.deepEqual(events, ['teardown-start', 'teardown-done', 'new-start'])
})
test('a generation started during cancelAndWait cannot run before the drain completes', async () => {
const coordinator = createBootstrapCoordinator()
const oldGate = deferred()
@@ -236,3 +270,39 @@ test('a generation started during cancelAndWait cannot run before the drain comp
assert.equal(await next, 'new')
assert.deepEqual(events, ['old-start', 'new-start'])
})
test('a second cancelAndWait on the same scope composes with the teardown still in flight', async () => {
// Pool stop is blocked in SSH teardown; a connection apply cancels the same
// scope with no bootstrap left to drain. The apply's drain must not replace
// and clear the barrier, or start() runs before the first teardown finishes.
const coordinator = createBootstrapCoordinator()
const events: string[] = []
const teardownGate = deferred()
const teardownStarted = deferred()
const poolStop = coordinator.cancelAndWait('scope', async () => {
events.push('teardown-start')
teardownStarted.resolve()
await teardownGate.promise
events.push('teardown-done')
})
await teardownStarted.promise
const apply = coordinator.cancelAndWait('scope').then(() => events.push('apply-drained'))
const next = coordinator.start('scope', 'new', async () => {
events.push('new-start')
return 'new'
})
// A macrotask, not a microtask tick: nothing may run before the first teardown finishes.
await new Promise(resolve => setTimeout(resolve, 0))
assert.deepEqual(events, ['teardown-start'])
teardownGate.resolve()
await Promise.all([poolStop, apply])
assert.equal(await next, 'new')
// Both the apply and the new bootstrap wake on the same drained barrier; only
// their position after teardown-done is the contract.
assert.deepEqual(events.slice(0, 2), ['teardown-start', 'teardown-done'])
assert.deepEqual(events.slice(2).sort(), ['apply-drained', 'new-start'])
})
@@ -93,14 +93,26 @@ function createBootstrapCoordinator() {
pending.get(scope)?.controller.abort()
}
async function cancelAndWait(scope) {
async function cancelAndWait(scope, afterCancel?: () => Promise<void>) {
let release
const barrier = new Promise<void>(resolve => {
const own = new Promise<void>(resolve => {
release = resolve
})
// Compose with any drain already in flight for this scope (a pool stop
// still tearing down SSH while a connection apply cancels the same scope):
// start() must wait for every active teardown, and the map entry is
// cleared only once the composed barrier settles.
const prior = drains.get(scope)
// Drain barriers never reject, so chaining is equivalent to allSettled.
const barrier = prior ? prior.then(() => own) : own
drains.set(scope, barrier)
void barrier.finally(() => {
if (drains.get(scope) === barrier) {
drains.delete(scope)
}
})
const entries = [...active].filter(entry => entry.scope === scope)
for (const entry of entries) {
@@ -114,13 +126,18 @@ function createBootstrapCoordinator() {
// drain barrier still prevents stale resurrection.
await Promise.allSettled(entries.flatMap(entry => [...entry.forceCleanups]).map(cleanup => cleanup()))
await Promise.allSettled(entries.map(entry => entry.promise))
} finally {
if (drains.get(scope) === barrier) {
drains.delete(scope)
// Keep the drain up through caller teardown (SSH keepalive / tunnel)
// so a replacement start() cannot publish before the old scope is gone.
if (afterCancel) {
await afterCancel()
}
} finally {
release()
}
// "Cancel and wait" means the scope is drained: callers tear down SSH right
// after this returns, so wait for the composed barrier, not just our own.
await barrier
}
function cancelAll() {
@@ -0,0 +1,118 @@
/**
* #106935: Desktop main must hold a long-lived keep-alive WebSocket for every
* published SSH-isolated backend. Idle-exit (#101626) treats accepted WS as
* ownership liveness; renderer sockets can drop while sshConnections still owns
* the scope. Sticky artifacts (nonce / token file / lockfile) are NOT liveness.
*
* The pool-stop teardown fence that closes this socket is asserted in
* pool-stop.test.ts (afterStop) — AGENTS.md forbids reading `.ts` source from tests.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } {
const instances: any[] = []
class FakeWs {
url: string
closed = false
listeners: Record<string, Array<(event?: any) => void>> = {}
constructor(url: string) {
this.url = url
instances.push(this)
}
addEventListener(type: string, fn: (event?: any) => void) {
;(this.listeners[type] ||= []).push(fn)
}
close() {
this.closed = true
}
emit(type: string, event?: any) {
for (const fn of this.listeners[type] || []) {
fn(event)
}
}
}
return { FakeWs, instances }
}
describe('ssh-isolated keep-alive registry (#106935)', () => {
afterEach(() => {
vi.useRealTimers()
})
it('holds an open WebSocket per scope until that scope stops, then never reconnects it', async () => {
const { createSshIsolatedKeepaliveRegistry } = await import('./ssh-isolated-keepalive')
const { FakeWs, instances } = makeFakeWs()
const registry = createSshIsolatedKeepaliveRegistry({
WebSocketImpl: FakeWs,
reconnectDelayMs: 25
})
registry.start('conn:office::work', {
baseUrl: 'http://127.0.0.1:53101',
token: 'sess-work'
})
registry.start('conn:office::less', { baseUrl: 'http://127.0.0.1:53102', token: 'sess-less' })
expect(instances).toHaveLength(2)
expect(instances[0].url).toBe('ws://127.0.0.1:53101/api/ws?token=sess-work')
expect(registry.isArmed('conn:office::work')).toBe(true)
instances[0].emit('open')
expect(registry.openUrl('conn:office::work')).toBe('ws://127.0.0.1:53101/api/ws?token=sess-work')
// A dropped socket is redialled with backoff (25 → 50 ms), so a dead tunnel is not
// hammered every interval until the scope is torn down.
vi.useFakeTimers()
instances[1].emit('close', { code: 1006 })
await vi.advanceTimersByTimeAsync(25)
expect(instances).toHaveLength(3)
instances[2].emit('close', { code: 1006 })
await vi.advanceTimersByTimeAsync(25)
expect(instances).toHaveLength(3)
await vi.advanceTimersByTimeAsync(25)
expect(instances).toHaveLength(4)
vi.useRealTimers()
registry.stop('conn:office::work')
expect(instances[0].closed).toBe(true)
expect(registry.isArmed('conn:office::work')).toBe(false)
// Tearing down one sibling must not drop the other owned scope.
expect(instances[3].closed).toBe(false)
expect(registry.isArmed('conn:office::less')).toBe(true)
instances[0].emit('close', { code: 1006 })
await new Promise(resolve => setTimeout(resolve, 50))
expect(instances).toHaveLength(4)
expect(registry.openUrl('conn:office::work')).toBeNull()
})
it('treats empty-string scope as the v1/global SSH primary but still requires baseUrl and token', async () => {
const { createSshIsolatedKeepaliveRegistry } = await import('./ssh-isolated-keepalive')
const { FakeWs, instances } = makeFakeWs()
const registry = createSshIsolatedKeepaliveRegistry({ WebSocketImpl: FakeWs })
registry.start('', { baseUrl: '', token: 'tok' })
registry.start('', { baseUrl: 'http://127.0.0.1:9', token: '' })
expect(instances).toHaveLength(0)
expect(registry.isArmed('')).toBe(false)
registry.start('', { baseUrl: 'http://127.0.0.1:53100', token: 'primary' })
expect(instances).toHaveLength(1)
expect(instances[0].url).toBe('ws://127.0.0.1:53100/api/ws?token=primary')
expect(registry.isArmed('')).toBe(true)
instances[0].emit('open')
expect(registry.openUrl('')).toBe('ws://127.0.0.1:53100/api/ws?token=primary')
registry.stop('')
expect(instances[0].closed).toBe(true)
expect(registry.isArmed('')).toBe(false)
})
})
@@ -0,0 +1,192 @@
/**
* Long-lived keep-alive WebSocket from Electron main for every published
* SSH-isolated backend (#106935).
*
* `web_server_idle_exit` (#101626) treats accepted WebSockets as ownership
* liveness. Renderer sockets can vanish (`disposeSecondary` /
* `pruneSecondaryGateways`) while Desktop still owns the backend via
* `sshConnections`. Sticky spawn artifacts (owner-nonce, token file, lockfile)
* are NOT liveness and must not suppress idle-exit.
*
* If the socket drops the registry reconnects with capped exponential backoff;
* while it is down the backend may idle-exit as before. This module never consults
* nonce/lock/token files.
*/
import { buildGatewayWsUrl } from './connection-config'
export type SshIsolatedKeepaliveTarget = {
baseUrl: string
token: string
}
export type SshIsolatedKeepaliveOptions = {
WebSocketImpl?: any
buildWsUrl?: (baseUrl: string, token: string) => string
log?: (message: string) => void
reconnectDelayMs?: number
}
type KeepaliveEntry = {
failures: number
reconnectTimer: ReturnType<typeof setTimeout> | null
scope: string
socket: { close?: () => void; url?: string } | null
target: SshIsolatedKeepaliveTarget
}
const DEFAULT_RECONNECT_DELAY_MS = 2_000
const MAX_RECONNECT_DELAY_MS = 30_000
function addListener(socket: any, type: string, handler: (event?: any) => void) {
if (typeof socket?.addEventListener === 'function') {
socket.addEventListener(type, handler)
return
}
if (typeof socket?.on === 'function') {
socket.on(type, handler)
}
}
export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepaliveOptions = {}) {
const WebSocketImpl =
'WebSocketImpl' in options ? options.WebSocketImpl : (globalThis as { WebSocket?: unknown }).WebSocket
const buildWsUrl = options.buildWsUrl ?? buildGatewayWsUrl
const log = options.log
const reconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS
const entries = new Map<string, KeepaliveEntry>()
function clearTimer(entry: KeepaliveEntry) {
if (entry.reconnectTimer == null) {
return
}
clearTimeout(entry.reconnectTimer)
entry.reconnectTimer = null
}
function closeSocket(entry: KeepaliveEntry) {
const socket = entry.socket
entry.socket = null
if (!socket) {
return
}
try {
socket.close?.()
} catch {
// Best-effort teardown; a dead socket must not block scope cleanup.
}
}
function scheduleReconnect(entry: KeepaliveEntry) {
if (entries.get(entry.scope) !== entry || entry.reconnectTimer != null) {
return
}
// A dead tunnel would otherwise be redialled every 2 s until the scope is torn down.
const delay = Math.min(reconnectDelayMs * 2 ** entry.failures, MAX_RECONNECT_DELAY_MS)
entry.failures += 1
entry.reconnectTimer = setTimeout(() => {
entry.reconnectTimer = null
connect(entry)
}, delay)
}
function connect(entry: KeepaliveEntry) {
if (entries.get(entry.scope) !== entry) {
return
}
clearTimer(entry)
closeSocket(entry)
let socket: any
let url: string
try {
url = buildWsUrl(entry.target.baseUrl, entry.target.token)
socket = new WebSocketImpl(url)
} catch (error) {
log?.(`[ssh] keep-alive WS failed to open for ${entry.scope}: ${error instanceof Error ? error.message : error}`)
scheduleReconnect(entry)
return
}
entry.socket = socket
// Staleness is derivable: stop() removes the entry, connect() replaces entry.socket.
const abandonIfStale = () => {
if (entries.get(entry.scope) !== entry || entry.socket !== socket) {
return
}
entry.socket = null
scheduleReconnect(entry)
}
addListener(socket, 'open', () => {
if (entry.socket === socket) {
entry.failures = 0
}
})
addListener(socket, 'close', abandonIfStale)
addListener(socket, 'error', abandonIfStale)
}
function start(scope: string, target: SshIsolatedKeepaliveTarget) {
// '' is a real published key: sshScopeKey(null) for the v1/global SSH primary.
if (typeof scope !== 'string') {
return
}
const baseUrl = typeof target?.baseUrl === 'string' ? target.baseUrl : ''
const token = typeof target?.token === 'string' ? target.token : ''
if (!baseUrl || !token) {
return
}
stop(scope)
const entry: KeepaliveEntry = {
failures: 0,
reconnectTimer: null,
scope,
socket: null,
target: { baseUrl, token }
}
entries.set(scope, entry)
connect(entry)
}
function stop(scope: string) {
const entry = entries.get(scope)
if (!entry) {
return
}
entries.delete(scope)
clearTimer(entry)
closeSocket(entry)
}
function stopAll() {
for (const scope of [...entries.keys()]) {
stop(scope)
}
}
function isArmed(scope: string) {
return entries.has(scope)
}
function openUrl(scope: string) {
const url = entries.get(scope)?.socket?.url
return typeof url === 'string' ? url : null
}
return { isArmed, openUrl, start, stop, stopAll }
}
+8 -2
View File
@@ -28,8 +28,14 @@ via `/api/gateway/*`). Never re-parent the gateway under the backend — `gatewa
- The backend already provides everything: `commands.catalog` and `complete.slash` include built-ins,
user `quick_commands`, AND skill-derived commands. No new RPC is needed to see skills.
- `src/lib/desktop-slash-commands.ts` is the load-bearing file: `DESKTOP_COMMAND_SPECS` (built-ins
and their desktop surfaces) + `NO_DESKTOP_SURFACE` block-lists (terminal-only / messaging-only /
picker-owned / settings-owned / advanced). `isDesktopSlashCommand(name)` gates **execution** (true
and their desktop surfaces) + the block-list. A command's desktop disposition (terminal-only /
messaging-only / settings-owned / advanced / hidden) is authored ONCE, as `desktop=` on its
`CommandDef` in `hermes_cli/commands.py`; the live `commands.catalog` carries it, and
`src/lib/desktop-slash-registry.json` (regenerate with `scripts/dump_desktop_slash_registry.py`;
`tests/hermes_cli/test_desktop_slash_registry.py` + the vitest file fail on drift) is the offline
fallback. Only names the Python registry has never heard of (`/density`, `/details`, `/logs`,
`/mouse` — Ink-local; `/pets`) live in `TS_ONLY_NO_DESKTOP_SURFACE`. `isDesktopSlashCommand(name)`
gates **execution** (true
for built-ins AND any non-built-in so typed skill/quick commands run);
`isDesktopSlashSuggestion(name)` gates **discovery** — used by BOTH completion paths in
`app/chat/composer/hooks/use-slash-completions.ts` and by `filterDesktopCommandsCatalog`;
+5 -4
View File
@@ -1,11 +1,12 @@
import type { ModelOptionsResult } from '@hermes/shared'
import type {
AnalyticsResponse,
AuxiliaryModelsResponse,
MoaConfigResponse,
ModelAssignmentRequest,
ModelAssignmentResponse,
ModelInfoResponse,
ModelOptionsResponse
ModelInfoResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
@@ -32,7 +33,7 @@ export function getGlobalModelOptions(
explicitOnly?: boolean
},
profile?: null | string
): Promise<ModelOptionsResponse> {
): Promise<ModelOptionsResult> {
const params = new URLSearchParams()
if (opts?.refresh) {
@@ -47,7 +48,7 @@ export function getGlobalModelOptions(
params.set('explicit_only', '1')
}
return hermesApi<ModelOptionsResponse>({
return hermesApi<ModelOptionsResult>({
...profileScoped(profile),
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
+2 -1
View File
@@ -1,5 +1,6 @@
import { reconnectBackoffDelayMs } from '@hermes/shared'
import type { HermesConnection } from '@/global'
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client'
+1 -1
View File
@@ -1,3 +1,4 @@
import { compactNumber } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useMemo, useState } from 'react'
@@ -8,7 +9,6 @@ import { Codicon } from '@/components/ui/codicon'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
import { compactNumber } from '@/lib/format'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
@@ -13,6 +13,7 @@ import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
import { ReasoningPill } from './reasoning-pill'
import { StartVoiceButton } from './start-voice-button'
import type { ChatBarState, VoiceStatus } from './types'
import { VoiceMenu } from './voice-menu'
@@ -110,7 +111,12 @@ export function ComposerControls({
<div className="ml-auto flex min-w-0 shrink items-center gap-(--composer-control-gap)">
{minimal ? null : (
<>
{hideModelPill ? null : <ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />}
{hideModelPill ? null : (
<>
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
{compactModelPill ? null : <ReasoningPill disabled={disabled} model={state.model} />}
</>
)}
{voiceControls}
</>
)}
@@ -7,10 +7,10 @@ import '@/store/suggestion-providers/mcp'
import '@/store/suggestion-providers/skill'
import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
import { SLASH_COMMAND_RE } from '@hermes/shared'
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import {
type ComposerAttachment,
@@ -6,7 +6,6 @@ import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'
import { $clarifyRequests } from '@/store/clarify'
import type { ComposerAttachment } from '@/store/composer'
import { clearQueuedPrompts, getQueuedPrompts } from '@/store/composer-queue'
import { $gateway } from '@/store/gateway'
import {
clearAllPrompts,
hasBlockingPromptRequest,
@@ -14,6 +13,7 @@ import {
setSecretRequest,
setSudoRequest
} from '@/store/prompts'
import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'
import { type ComposerTarget, requestComposerSubmit } from '../focus'
import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope'
@@ -457,26 +457,30 @@ describe('useComposerSubmit busy-turn routing', () => {
})
describe('useComposerSubmit with a clarify parked on the session', () => {
const gatewayRequest = vi.fn(async () => ({ ok: true }))
// The clarify is a live server→client request: skipping it answers that
// request frame (`{ answer: '' }`), not a `clarify.respond` RPC.
const respond = vi.fn()
const parkClarify = (sessionId: string) => {
const requestId = `req-${sessionId}`
rememberServerRequest({ fail: vi.fn(), id: requestId, method: 'clarify', params: {}, respond })
$clarifyRequests.set({
[sessionId]: {
requestId: `req-${sessionId}`,
requestId,
question: 'which one?',
choices: ['a', 'b'],
multiSelect: false,
sessionId
}
})
$gateway.set({ request: gatewayRequest } as unknown as ReturnType<typeof $gateway.get>)
}
afterEach(() => {
cleanup()
gatewayRequest.mockClear()
respond.mockClear()
resetServerRequestsForTests()
$clarifyRequests.set({})
$gateway.set(null)
vi.restoreAllMocks()
})
@@ -488,16 +492,12 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
hook.result.current.submitDraft()
})
await waitFor(() =>
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', {
request_id: 'req-runtime-session',
answer: ''
})
)
await waitFor(() => expect(respond).toHaveBeenCalledWith({ answer: '' }))
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith('actually do this instead', expect.objectContaining({ attachments: [] }))
)
expect($clarifyRequests.get()['runtime-session']).toBeUndefined()
expect(hasOpenServerRequest('req-runtime-session')).toBe(false)
})
it('skips the question before steering a busy turn', async () => {
@@ -509,7 +509,7 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
})
await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-runtime-session', answer: '' })
expect(respond).toHaveBeenCalledWith({ answer: '' })
})
it('leaves the question alone for an empty Enter (Stop, not an answer)', () => {
@@ -520,7 +520,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
hook.result.current.submitDraft()
})
expect(gatewayRequest).not.toHaveBeenCalled()
expect(respond).not.toHaveBeenCalled()
expect(hasOpenServerRequest('req-runtime-session')).toBe(true)
expect($clarifyRequests.get()['runtime-session']).toBeDefined()
expect(onCancel).toHaveBeenCalledTimes(1)
})
@@ -534,7 +535,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
})
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
expect(gatewayRequest).not.toHaveBeenCalled()
expect(respond).not.toHaveBeenCalled()
expect(hasOpenServerRequest('req-other-session')).toBe(true)
expect($clarifyRequests.get()['other-session']).toBeDefined()
})
})
@@ -1,7 +1,7 @@
import { SLASH_COMMAND_RE } from '@hermes/shared'
import { type RefObject, useLayoutEffect, useRef } from 'react'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
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'
+25 -1
View File
@@ -88,7 +88,13 @@ 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 {
chipTypedUrlOnSpace,
linkifyUrls,
markdownLinkFor,
resolveExactLinkPaste,
selectionLinkLabel
} from './url-refs'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
export function ChatBar({
@@ -564,6 +570,24 @@ export function ChatBar({
event.preventDefault()
// Pasting exactly one link while composer text is selected turns that text
// into a markdown link instead of replacing it — the behavior every rich
// editor ships (ported from block/buzz#6684). Selections that span chips
// or lines fall through to the normal replace-with-chip path.
const exactLink = resolveExactLinkPaste(pastedText)
if (exactLink) {
const label = selectionLinkLabel(event.currentTarget)
if (label) {
recordUndoPoint()
insertComposerContentsAtCaret(event.currentTarget, markdownLinkFor(label, exactLink))
scheduleFlushEditorToDraft(event.currentTarget)
return
}
}
// A paste past the large-paste threshold becomes a `.txt` attachment chip
// instead of flooding the composer.
// The instruction the user types stays in the input; the pasted source
@@ -158,7 +158,7 @@ describe('ModelPill per-surface model label', () => {
</SessionViewProvider>
)
expect(screen.getByText('Sonnet · High')).toBeTruthy()
expect(screen.getByText('Sonnet')).toBeTruthy()
expect(screen.queryByText(/primary/i)).toBeNull()
})
})
@@ -12,9 +12,9 @@ import { releaseTypingFocus } from '@/components/ui/keyboard-first'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { formatModelPillLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session'
import { $currentModelSource, setModelPickerOpen } from '@/store/session'
import { onComposerModelMenuRequest } from './focus'
import { RICH_INPUT_SLOT } from './rich-editor'
@@ -57,9 +57,7 @@ export function ModelPill({
const currentModel = model.model || viewModel
const currentProvider = model.provider || viewProvider
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)
const restoreSelection = useRef<(() => void) | null>(null)
@@ -131,9 +129,7 @@ export function ModelPill({
) : (
<>
{currentModel.trim() ? (
<span className="truncate">
{formatModelStatusLabel(currentModel, { defaultEffort, fastMode, reasoningEffort })}
</span>
<span className="truncate">{formatModelPillLabel(currentModel, { fastMode })}</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
@@ -0,0 +1,81 @@
import { cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { $defaultReasoningEffort } from '@/store/session'
import { ReasoningPill } from './reasoning-pill'
const modelState = (over: Partial<ChatBarState['model']> = {}): ChatBarState['model'] => ({
canSwitch: true,
model: 'gpt-6',
provider: 'openai',
reasoningMenuContent: <div>menu</div>,
...over
})
const tileView = (reasoningEffort: string): SessionView => ({
kind: 'tile',
$awaitingResponse: atom(false),
$busy: atom(false),
$cwd: atom(''),
$fast: atom(false),
$lastVisibleIsUser: atom(false),
$messages: atom([]),
$messagesEmpty: atom(true),
$model: atom('tile/claude-sonnet'),
$provider: atom('anthropic'),
$reasoningEffort: atom(reasoningEffort),
$runtimeId: atom('tile-runtime'),
$storedId: atom('stored-tile'),
$turnStartedAt: atom<number | null>(null)
})
afterEach(() => {
cleanup()
$defaultReasoningEffort.set('')
})
describe('ReasoningPill', () => {
it("shows THIS surface's live effort, falling back to the profile default when the session has none", () => {
$defaultReasoningEffort.set('high')
const { unmount } = render(
<SessionViewProvider value={tileView('low')}>
<ReasoningPill disabled={false} model={modelState()} />
</SessionViewProvider>
)
expect(screen.getByTestId('reasoning-pill').textContent).toBe('Low')
unmount()
render(
<SessionViewProvider value={tileView('')}>
<ReasoningPill disabled={false} model={modelState()} />
</SessionViewProvider>
)
expect(screen.getByTestId('reasoning-pill').textContent).toBe('High')
})
it('hides when the catalog says the model has no reasoning control, but not while that is unknown', () => {
const { unmount } = render(
<SessionViewProvider value={tileView('medium')}>
<ReasoningPill disabled={false} model={modelState({ supportsReasoning: false })} />
</SessionViewProvider>
)
expect(screen.queryByTestId('reasoning-pill')).toBeNull()
unmount()
render(
<SessionViewProvider value={tileView('medium')}>
<ReasoningPill disabled={false} model={modelState({ supportsReasoning: undefined })} />
</SessionViewProvider>
)
expect(screen.getByTestId('reasoning-pill')).toBeTruthy()
})
})
@@ -0,0 +1,82 @@
import { DEFAULT_REASONING_EFFORT } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { releaseTypingFocus } from '@/components/ui/keyboard-first'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { reasoningEffortLabel } from '@/lib/reasoning-effort'
import { cn } from '@/lib/utils'
import { $defaultReasoningEffort } from '@/store/session'
import type { ChatBarState } from './types'
const PILL = cn(
'h-(--composer-control-size) shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
/**
* Composer reasoning selector: the active model's effort level as its own
* pill next to the model pill, opening the same Thinking / Fast / Effort rows
* the catalog offers per model — without having to find the model's row and
* hover its submenu. Hidden when the catalog says the model has no reasoning
* control, and while there is no live menu (gateway closed).
*
* Reads THIS surface's SessionView (primary or tile), like the model pill.
*/
export function ReasoningPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
const copy = useI18n().t.shell.modelOptions
const view = useSessionView()
const reasoningEffort = useStore(view.$reasoningEffort)
const defaultEffort = useStore($defaultReasoningEffort)
const [open, setOpen] = useState(false)
if (!model.reasoningMenuContent || model.supportsReasoning === false) {
return null
}
const label = reasoningEffortLabel(reasoningEffort || defaultEffort || DEFAULT_REASONING_EFFORT)
const title = `${copy.effort}: ${label}`
// Closing the menu ends its claim on the keyboard: Radix restores focus to
// this pill (a toolbar button), so without the release the Enter that
// committed a level also swallows whatever you type next.
const setMenuOpen = (next: boolean) => {
setOpen(next)
if (!next) {
releaseTypingFocus()
}
}
return (
<DropdownMenu onOpenChange={setMenuOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={title}
className={PILL}
data-testid="reasoning-pill"
disabled={disabled}
type="button"
variant="ghost"
>
<span>{label}</span>
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end" className="w-52 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setMenuOpen(false)}>
{model.reasoningMenuContent}
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -26,6 +26,11 @@ export interface ChatBarState {
quickModels?: QuickModelOption[]
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
modelMenuContent?: ReactNode
/** The reasoning pill's dropdown (same host + controller as the model menu). */
reasoningMenuContent?: ReactNode
/** False when the catalog says the active model has no reasoning control;
* undefined while unknown (loading) so the pill stays put. */
supportsReasoning?: boolean
}
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
voice: { enabled: boolean; active: boolean }
@@ -1,8 +1,14 @@
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'
import { composerPlainText, refChipElement, RICH_INPUT_SLOT } from './rich-editor'
import {
chipTypedUrlOnSpace,
linkifyUrls,
markdownLinkFor,
resolveExactLinkPaste,
selectionLinkLabel
} from './url-refs'
/** An editor holding `text` with a collapsed caret at `caret`, plus the space
* keydown the composer would hand `chipTypedUrlOnSpace`. */
@@ -51,6 +57,95 @@ describe('linkifyUrls', () => {
})
})
describe('resolveExactLinkPaste', () => {
it('accepts a lone bare link', () => {
expect(resolveExactLinkPaste('https://example.dev/a/b')).toBe('https://example.dev/a/b')
})
it('accepts a wrapped <link> and surrounding whitespace', () => {
expect(resolveExactLinkPaste(' <https://example.dev/a> ')).toBe('https://example.dev/a')
})
it('rejects prose around the link', () => {
expect(resolveExactLinkPaste('see https://example.dev')).toBeNull()
expect(resolveExactLinkPaste('https://example.dev is nice')).toBeNull()
})
it('rejects multiple links', () => {
expect(resolveExactLinkPaste('https://a.dev https://b.dev')).toBeNull()
})
it('rejects trailing sentence punctuation and hostless schemes', () => {
expect(resolveExactLinkPaste('https://example.dev.')).toBeNull()
expect(resolveExactLinkPaste('https://')).toBeNull()
})
})
describe('selectionLinkLabel', () => {
const selectAll = (build: (editor: HTMLElement) => void) => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
build(editor)
document.body.append(editor)
const selection = window.getSelection()!
const range = document.createRange()
range.selectNodeContents(editor)
selection.removeAllRanges()
selection.addRange(range)
return editor
}
it('returns the selected text', () => {
const editor = selectAll(node => {
node.textContent = 'the docs'
})
expect(selectionLinkLabel(editor)).toBe('the docs')
editor.remove()
})
it('rejects a collapsed selection', () => {
const editor = document.createElement('div')
editor.textContent = 'text'
document.body.append(editor)
window.getSelection()?.removeAllRanges()
expect(selectionLinkLabel(editor)).toBeNull()
editor.remove()
})
it('rejects a selection containing a chip', () => {
const editor = selectAll(node => {
node.append(document.createTextNode('see '), refChipElement('url', '`https://a.dev`'))
})
expect(selectionLinkLabel(editor)).toBeNull()
editor.remove()
})
it('rejects a multi-line selection', () => {
const editor = selectAll(node => {
node.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two'))
})
expect(selectionLinkLabel(editor)).toBeNull()
editor.remove()
})
})
describe('markdownLinkFor', () => {
it('builds a markdown link', () => {
expect(markdownLinkFor('the docs', 'https://example.dev')).toBe('[the docs](https://example.dev)')
})
it('escapes square brackets in the label', () => {
expect(markdownLinkFor('a [b] c', 'https://example.dev')).toBe('[a \\[b\\] c](https://example.dev)')
})
})
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)
@@ -59,6 +59,71 @@ export function linkifyUrls(text: string) {
return out + text.slice(cursor)
}
/** The href to apply when a clipboard payload is exactly ONE supported link —
* a bare or `<…>`-wrapped `http(s)` URL with a host and nothing else. Null for
* anything that isn't a lone link (prose, multiple links, trailing text), so
* callers fall through to the normal paste pipeline. Ported from
* block/buzz#6684's `resolveExactLinkPaste`. */
export function resolveExactLinkPaste(raw: string): string | null {
const text = raw.trim()
const unwrapped = text.startsWith('<') && text.endsWith('>') && text.length > 2 ? text.slice(1, -1).trim() : text
URL_RE.lastIndex = 0
const match = URL_RE.exec(unwrapped)
if (!match || match.index !== 0 || match[0].length !== unwrapped.length) {
return null
}
const { trailing, url } = splitUrlTail(unwrapped)
// Trailing sentence punctuation means the user copied prose, not a link.
if (trailing || !hasHost(url)) {
return null
}
return url
}
/** The selected composer text a link paste should hyperlink, or null when the
* selection can't take a link mark: collapsed, outside `editor`, spanning
* chips or line breaks, or whitespace-only. */
export function selectionLinkLabel(editor: HTMLElement): string | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
return null
}
const range = selection.getRangeAt(0)
if (!editor.contains(range.commonAncestorContainer)) {
return null
}
const probe = document.createElement('div')
probe.append(range.cloneContents())
// A chip inside the selection is a directive, not prose — linking over it
// would destroy the reference. Multi-line selections don't read as a label.
if (probe.querySelector('[data-ref-text], br')) {
return null
}
const label = probe.textContent?.replace(/\s+/g, ' ').trim() ?? ''
return label || null
}
/** Markdown link for a paste-over-selection: the label the user selected, the
* URL they pasted. Square brackets in the label are escaped so the link
* survives markdown parsing downstream. */
export function markdownLinkFor(label: string, url: string): string {
return `[${label.replace(/([[\]])/g, '\\$1')}](${url})`
}
/** 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. */
+1
View File
@@ -43,6 +43,7 @@ vi.mock('@/components/Backdrop', async () => {
vi.mock('@/components/prompt-overlays', () => ({ PromptOverlays: () => null }))
vi.mock('@/components/chat/vibe-hearts', () => ({ COMPOSER_HEART_CONFIG: {}, HeartField: () => null }))
vi.mock('@/lib/model-options', () => ({
currentModelCapabilities: () => undefined,
modelOptionsQueryKey: (...parts: unknown[]) => ['model-options', ...parts],
requestModelOptions: vi.fn(async () => ({ models: [] }))
}))
+34 -7
View File
@@ -1,4 +1,5 @@
import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react'
import type { ModelOptionsResult } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type { ReadableAtom } from 'nanostores'
@@ -23,7 +24,7 @@ import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { NEW_SESSION_TITLE, quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { currentModelCapabilities, modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { migrateSessionDraft } from '@/store/composer'
@@ -48,10 +49,9 @@ import {
sessionPinId,
shouldMigrateComposerScope
} from '@/store/session'
import { $focusedStoredSessionId, sessionTileDelegate } from '@/store/session-states'
import { $focusedStoredSessionId, $sessionStates, sessionTileDelegate } from '@/store/session-states'
import { $transcriptTailBySessionId, transcriptTailState } from '@/store/transcript-tail'
import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
import { primaryRouteSelectedSessionId, routeSessionId } from '../routes'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar'
@@ -85,6 +85,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
modelOptionsOwnerConnectionId?: string
modelOptionsProfile?: string
modelMenuContent?: React.ReactNode
reasoningMenuContent?: React.ReactNode
requestModelOptionsForOwner?: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void
@@ -381,6 +382,7 @@ const ChatViewContent = memo(function ChatViewContent({
modelOptionsOwnerConnectionId,
modelOptionsProfile,
modelMenuContent,
reasoningMenuContent,
requestModelOptionsForOwner,
onToggleSelectedPin,
onDeleteSelectedSession,
@@ -418,6 +420,11 @@ const ChatViewContent = memo(function ChatViewContent({
const composerSurfaceId = useComposerSurfaceId()
const isPrimary = view.kind === 'primary'
const activeSessionId = useStore(view.$runtimeId)
const transcriptStoredSessionId = useStoreSelector($sessionStates, states =>
activeSessionId ? (states[activeSessionId]?.storedSessionId ?? null) : null
)
const storedId = useStore(view.$storedId)
// Multi-pane dimming: only the focused surface paints at full strength, so
// two sessions side by side read as "this one, and that one over there".
@@ -511,7 +518,14 @@ const ChatViewContent = memo(function ChatViewContent({
// direct nav). Derived in render so the swap reads instantly: the same frame
// the id changes we drop the old transcript and show the loader, instead of
// waiting for the resume effect (which paints a frame later) to clear them.
const routeSessionMismatch = isPrimary ? isRouteSessionMismatch(routedSessionId, selectedSessionId, sessions) : false
const routeSessionMismatch = isPrimary
? isRouteSessionMismatch(routedSessionId, selectedSessionId, sessions, {
activeRuntimeId: activeSessionId,
contextSwitching: Boolean(gatewaySwapTarget),
messagesEmpty,
transcriptStoredSessionId
})
: false
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state. The Appearance toggle
@@ -562,7 +576,7 @@ const ChatViewContent = memo(function ChatViewContent({
const showChatBar = !loadingSession && !resumeExhausted && !isWatchWindow()
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
const modelOptionsQuery = useQuery<ModelOptionsResult>({
queryKey: modelOptionsQueryKey(
modelOptionsProfile || activeGatewayProfile,
activeSessionId,
@@ -583,6 +597,8 @@ const ChatViewContent = memo(function ChatViewContent({
[currentModel, currentProvider, modelOptionsQuery.data]
)
const supportsReasoning = currentModelCapabilities(modelOptionsQuery.data, currentProvider, currentModel)?.reasoning
const chatBarState = useMemo<ChatBarState>(
() => ({
model: {
@@ -591,7 +607,9 @@ const ChatViewContent = memo(function ChatViewContent({
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
modelMenuContent,
quickModels
quickModels,
reasoningMenuContent,
supportsReasoning
},
tools: {
enabled: true,
@@ -603,7 +621,16 @@ const ChatViewContent = memo(function ChatViewContent({
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
[
contextSuggestions,
currentModel,
currentProvider,
gatewayOpen,
modelMenuContent,
quickModels,
reasoningMenuContent,
supportsReasoning
]
)
// Drop files anywhere in the conversation area, not just on the composer
@@ -21,6 +21,7 @@ import { CopyButton } from '@/components/ui/copy-button'
import { Input } from '@/components/ui/input'
import { PaneStripGlyph } from '@/components/ui/pane-tab'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import { ANNOTATE_BLUE } from '@/lib/preview-annotate'
import { cn } from '@/lib/utils'
@@ -191,7 +192,7 @@ export function PreviewBrowserBar({
event.currentTarget.select()
}}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
commit(event.currentTarget.value)
event.currentTarget.blur()
}
@@ -84,17 +84,14 @@ describe('RealProfileConsentDialog', () => {
fireEvent.click(screen.getByRole('button', { name: promptCopy.enable }))
})
// Saves the WHOLE merged record with only use_real_profile added — the
// same shape the Capabilities toggle writes, through the same cache, so
// the existing toggle flips on without a refetch.
expect(mocks.save).toHaveBeenCalledWith(
{
browser: { allow_private_urls: false, use_real_profile: true },
model: { provider: 'nous' }
},
undefined
)
expect(mocks.cache).toHaveBeenCalledWith(mocks.save.mock.calls[0][0])
// Saves ONLY the toggled key (PUT deep-merges) — the same shape the
// Capabilities toggle writes — while the shared cache gets the merged
// record so the existing toggle flips on without a refetch.
expect(mocks.save).toHaveBeenCalledWith({ browser: { use_real_profile: true } }, undefined)
expect(mocks.cache).toHaveBeenCalledWith({
browser: { allow_private_urls: false, use_real_profile: true },
model: { provider: 'nous' }
})
expect(mocks.notify).toHaveBeenCalled()
})
@@ -78,7 +78,9 @@ export function RealProfileConsentDialog({ tabId }: RealProfileConsentDialogProp
setConfig(next)
try {
await saveHermesConfigRecord(next)
// Sparse patch: PUT /api/config deep-merges, and echoing the cached
// snapshot would overwrite keys other surfaces changed since it loaded.
await saveHermesConfigRecord({ browser: { use_real_profile: true } })
notify({ kind: 'info', title: copy.enabledTitle, message: copy.enabledMessage })
} catch (err) {
setConfig(config)
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import { routeSessionId, sessionRoute } from '../routes'
import { isRouteSessionMismatch } from './route-session-state'
describe('isRouteSessionMismatch', () => {
@@ -21,4 +23,58 @@ describe('isRouteSessionMismatch', () => {
expect(isRouteSessionMismatch('same', 'same', [])).toBe(false)
expect(isRouteSessionMismatch(null, 'same', [])).toBe(false)
})
it('keeps the same-session route visible while a context switch is in flight', () => {
const sessions = [{ id: 'a', _lineage_root_id: null }]
expect(
isRouteSessionMismatch('a', 'a', sessions, {
activeRuntimeId: 'r',
contextSwitching: true,
messagesEmpty: false,
transcriptStoredSessionId: 'a'
}),
'a profile swap while route == selected must not blank the chat to the splash'
).toBe(false)
})
it('keeps only the routed chat whose active view owns an existing transcript during selection churn', () => {
const routedSessionId = routeSessionId(sessionRoute('session-a'))
const sessions = [
{ id: 'session-a', _lineage_root_id: null },
{ id: 'session-b', _lineage_root_id: null }
]
const activeTranscript = {
activeRuntimeId: 'runtime-a',
contextSwitching: false,
messagesEmpty: false,
transcriptStoredSessionId: 'session-a'
}
expect(isRouteSessionMismatch(routedSessionId, null, sessions, activeTranscript)).toBe(false)
expect(isRouteSessionMismatch(routedSessionId, 'session-b', sessions, activeTranscript)).toBe(false)
expect(
isRouteSessionMismatch('session-b', 'session-a', sessions, activeTranscript),
'genuine navigation must suppress session A'
).toBe(true)
expect(
isRouteSessionMismatch(routedSessionId, null, sessions, { ...activeTranscript, contextSwitching: true }),
'profile or connection switches must not retain the prior context'
).toBe(true)
expect(
isRouteSessionMismatch(routedSessionId, null, sessions, { ...activeTranscript, messagesEmpty: true }),
'a route with no prior transcript must keep loading'
).toBe(true)
expect(
isRouteSessionMismatch(routedSessionId, null, sessions, {
...activeTranscript,
activeRuntimeId: 'runtime-b',
transcriptStoredSessionId: 'session-b'
}),
'a background chat must never publish into the routed foreground'
).toBe(true)
})
})
@@ -1,6 +1,13 @@
import { sessionMatchesStoredId } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
interface ActiveTranscriptState {
activeRuntimeId: null | string
contextSwitching: boolean
messagesEmpty: boolean
transcriptStoredSessionId: null | string
}
/**
* Whether the route points at a different conversation than the selected view.
*
@@ -14,17 +21,37 @@ import type { SessionInfo } from '@/types/hermes'
export function isRouteSessionMismatch(
routedSessionId: null | string,
selectedSessionId: null | string,
sessions: readonly Pick<SessionInfo, '_lineage_root_id' | 'id'>[]
sessions: readonly Pick<SessionInfo, '_lineage_root_id' | 'id'>[],
activeTranscript?: ActiveTranscriptState
): boolean {
if (!routedSessionId || routedSessionId === selectedSessionId) {
if (!routedSessionId) {
return false
}
if (!selectedSessionId) {
const matchesRoute = (storedSessionId: null | string) =>
storedSessionId === routedSessionId ||
Boolean(
storedSessionId &&
sessions.some(
session => sessionMatchesStoredId(session, routedSessionId) && sessionMatchesStoredId(session, storedSessionId)
)
)
// The selected view already owns the routed conversation: a profile or
// connection switch must not blank it to the splash.
if (matchesRoute(selectedSessionId)) {
return false
}
// Only the transcript-retention fallback below must be denied while a
// context switch is in flight; the prior context must not be retained.
if (activeTranscript?.contextSwitching) {
return true
}
return !sessions.some(
session => sessionMatchesStoredId(session, routedSessionId) && sessionMatchesStoredId(session, selectedSessionId)
return !(
activeTranscript?.activeRuntimeId &&
!activeTranscript.messagesEmpty &&
matchesRoute(activeTranscript.transcriptStoredSessionId)
)
}
@@ -9,12 +9,12 @@
*/
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { SLASH_COMMAND_RE } from '@hermes/shared'
import { useCallback, useMemo, useRef } from 'react'
import type { ClientSessionState } from '@/app/types'
import { useI18n } from '@/i18n'
import { textPart } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { clearClarifyRequest } from '@/store/clarify'
import type { ComposerAttachment } from '@/store/composer'
@@ -24,6 +24,7 @@ import { useModelControls } from '@/app/session/hooks/use-model-controls'
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
import { resolveStoredSession } from '@/app/session/hooks/use-session-actions/utils'
import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
import { ReasoningMenuPanel } from '@/app/shell/reasoning-menu-panel'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
@@ -295,6 +296,27 @@ function TileChat({
]
)
const reasoningMenuContent = useMemo(
() =>
gatewayOpen ? (
<ReasoningMenuPanel
onSelectModel={selectModel}
ownerConnectionId={ownerRoute?.connectionId || undefined}
profile={ownerRoute?.targetProfile || ownerRoute?.profile || activeGatewayProfile}
requestGateway={requestTileGateway}
/>
) : null,
[
activeGatewayProfile,
gatewayOpen,
ownerRoute?.connectionId,
ownerRoute?.profile,
ownerRoute?.targetProfile,
requestTileGateway,
selectModel
]
)
return (
<SessionViewProvider value={view}>
<ComposerScopeProvider value={scope}>
@@ -327,6 +349,7 @@ function TileChat({
onThreadMessagesChange={actions.handleThreadMessagesChange}
onToggleSelectedPin={noop}
onTranscribeAudio={tileTranscribeAudio}
reasoningMenuContent={reasoningMenuContent}
requestModelOptionsForOwner={requestTileGateway}
/>
</ComposerScopeProvider>
+1 -1
View File
@@ -1,3 +1,4 @@
import { compactNumber } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
@@ -7,7 +8,6 @@ import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { RowButton } from '@/components/ui/row-button'
import { Tip } from '@/components/ui/tooltip'
import { compactNumber } from '@/lib/format'
import { cn } from '@/lib/utils'
import { $sidebarRowMeta } from '@/store/layout'
import type { TileDock } from '@/store/session-states'
@@ -13,6 +13,7 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import { notify, notifyError } from '@/store/notifications'
import {
$remoteOverrideDialogProfile,
@@ -242,7 +243,7 @@ export function ProfileRemoteOverrideDialog({ profileNames }: { profileNames: st
<Input
autoCorrect="off"
onChange={event => setUrl(event.target.value)}
onKeyDown={event => event.key === 'Enter' && submit()}
onKeyDown={event => isSubmitEnter(event) && submit()}
placeholder={p.urlPlaceholder}
ref={urlRef}
spellCheck={false}
@@ -257,7 +258,7 @@ export function ProfileRemoteOverrideDialog({ profileNames }: { profileNames: st
<Input
autoComplete="off"
onChange={event => setToken(event.target.value)}
onKeyDown={event => event.key === 'Enter' && submit()}
onKeyDown={event => isSubmitEnter(event) && submit()}
placeholder={p.tokenPlaceholder}
type="password"
value={token}
@@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-idea-templates'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
@@ -191,7 +192,7 @@ export function ProjectDialog() {
disabled={submitting}
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {

Some files were not shown because too many files have changed in this diff Show More