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:
+1
-2
@@ -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/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) --
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)))
|
||||
@@ -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]:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 ----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: [] }))
|
||||
}))
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user