Compare commits

..
Author SHA1 Message Date
Teknium 5f99b75536 fix(mem0): migrate legacy OSS base URL aliases
Normalize stale api_base keys to each mem0 provider's accepted URL field before Memory.from_config, without mutating the saved config.
2026-07-17 06:30:06 -07:00
TekniumandNick Taylor 582b0acba6 fix(codex): harden final cache-key boundaries
Fold #62349's broader provider-boundary handling into the header fix: bound top-level and xAI override keys again at preflight after middleware, preserve unrelated headers, and cover boundaries and collisions.

Co-authored-by: Nick Taylor <nicktaylor@TheWorldofNick-Lappy.local>
2026-07-17 06:30:06 -07:00
Teknium 7214b9ca80 fix(moa): surface stale presets without retries
Keep invalid persisted preset names fail-closed, list the valid configured choices, and classify the local lookup failure as deterministic so it reaches Desktop immediately.
2026-07-17 06:22:57 -07:00
Teknium dbd9da7185 test(codex): cover overlength cache-scope headers
Exercise the real transport path for long session ids, including stable hashing and bounded body/header cache keys.
2026-07-17 06:22:57 -07:00
webtecnica 6f795f22cc fix: cap cache-scope headers at 64 chars to avoid Codex 400 error (#66045) 2026-07-17 06:22:57 -07:00
102 changed files with 1059 additions and 4201 deletions
+12 -66
View File
@@ -14,11 +14,7 @@ name: OSV-Scanner
# code patterns in PR diffs) by covering the orthogonal "currently-pinned
# dep became known-vulnerable" case.
#
# Steps below are inlined from Google's officially-recommended reusable
# workflow (google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml),
# rather than called via `uses:` so we can set a `timeout-minutes` in the
# degenerate case where this job hangs.
# Uses Google's officially-recommended reusable workflow, pinned by SHA.
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
# fail-on-vuln is disabled so the job does not block merges on pre-existing
# vulnerabilities in pinned deps that we may need to patch deliberately.
@@ -28,11 +24,11 @@ on:
schedule:
# Weekly scan against main — catches CVEs published after merge for
# deps that haven't changed since.
- cron: '0 9 * * 1'
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
# Required to upload SARIF file to CodeQL. See: https://github.com/github/codeql-action/issues/2117
# Required by the reusable workflow to upload SARIF to the Security tab.
actions: read
contents: read
security-events: write
@@ -40,62 +36,12 @@ permissions:
jobs:
scan:
name: Scan lockfiles
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: 'Run scanner'
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
# Scan explicit lockfiles rather than recursing, so we only look at
# the three sources of truth and skip vendored / test / worktree dirs.
scan-args: |-
--output=results.json
--format=json
--lockfile=uv.lock
--lockfile=package-lock.json
--lockfile=website/package-lock.json
continue-on-error: true
- name: 'Run osv-scanner-reporter'
uses: google/osv-scanner-action/osv-reporter-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
scan-args: |-
--output=results.sarif
--new=results.json
--gh-annotations=false
--fail-on-vuln=false
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: 'Upload artifact'
id: 'upload_artifact'
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: OSV Scanner SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard.
- name: 'Upload to code-scanning'
if: ${{ !cancelled() }}
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
sarif_file: results.sarif
- name: 'Print Code Scanning URL'
if: ${{ !cancelled() }}
run: |
echo "View the OSV-Scanner results in the 'Security' tab, using the following link:"
echo "${{ github.server_url }}/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3A${GITHUB_REF_NAME}+tool%3Aosv-scanner"
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
- name: 'Error troubleshooter'
if: ${{ always() && steps.upload_artifact.outcome == 'failure' }}
run: |
echo "::error::Artifact upload failed. This is most likely caused by a error during scanning earlier in the workflow."
exit 1
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
with:
# Scan explicit lockfiles rather than recursing, so we only look at
# the three sources of truth and skip vendored / test / worktree dirs.
scan-args: |-
--lockfile=uv.lock
--lockfile=package-lock.json
--lockfile=website/package-lock.json
fail-on-vuln: false
+2 -8
View File
@@ -534,15 +534,9 @@ class SessionManager:
model = row.get("model") or None
# Load conversation history. repair_alternation: this restore feeds
# LIVE REPLAY — the loaded list becomes the resumed agent's working
# conversation. A durable ``user;user`` violation left in state.db would
# otherwise re-fire the pre-request defensive repair on every request
# for the rest of the session (see hermes_state.get_messages_as_conversation).
# Load conversation history.
try:
history = db.get_messages_as_conversation(
session_id, repair_alternation=True
)
history = db.get_messages_as_conversation(session_id)
except Exception:
logger.warning("Failed to load messages for ACP session %s", session_id, exc_info=True)
history = []
+46 -61
View File
@@ -275,71 +275,71 @@ def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, An
def init_agent(
agent,
base_url: str | None = None,
api_key: str | None = None,
provider: str | None = None,
api_mode: str | None = None,
acp_command: str | None = None,
base_url: str = None,
api_key: str = None,
provider: str = None,
api_mode: str = None,
acp_command: str = None,
acp_args: list[str] | None = None,
command: str | None = None,
command: str = None,
args: list[str] | None = None,
model: str = "",
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
tool_delay: float = 1.0,
enabled_toolsets: List[str] | None = None,
disabled_toolsets: List[str] | None = None,
enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None,
save_trajectories: bool = False,
verbose_logging: bool = False,
quiet_mode: bool = False,
tool_progress_mode: str = "all",
ephemeral_system_prompt: str | None = None,
ephemeral_system_prompt: str = None,
log_prefix_chars: int = 100,
log_prefix: str = "",
providers_allowed: List[str] | None = None,
providers_ignored: List[str] | None = None,
providers_order: List[str] | None = None,
provider_sort: str | None = None,
providers_allowed: List[str] = None,
providers_ignored: List[str] = None,
providers_order: List[str] = None,
provider_sort: str = None,
provider_require_parameters: bool = False,
provider_data_collection: str | None = None,
provider_data_collection: str = None,
openrouter_min_coding_score: Optional[float] = None,
session_id: str | None = None,
tool_progress_callback: Callable | None = None,
tool_start_callback: Callable | None = None,
tool_complete_callback: Callable | None = None,
thinking_callback: Callable | None = None,
reasoning_callback: Callable | None = None,
clarify_callback: Callable | None = None,
read_terminal_callback: Callable | None = None,
step_callback: Callable | None = None,
stream_delta_callback: Callable | None = None,
interim_assistant_callback: Callable | None = None,
tool_gen_callback: Callable | None = None,
status_callback: Callable | None = None,
notice_callback: Callable | None = None,
notice_clear_callback: Callable | None = None,
session_id: str = None,
tool_progress_callback: callable = None,
tool_start_callback: callable = None,
tool_complete_callback: callable = None,
thinking_callback: callable = None,
reasoning_callback: callable = None,
clarify_callback: callable = None,
read_terminal_callback: callable = None,
step_callback: callable = None,
stream_delta_callback: callable = None,
interim_assistant_callback: callable = None,
tool_gen_callback: callable = None,
status_callback: callable = None,
notice_callback: callable = None,
notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
reaction_callback: Optional[Callable[[str], None]] = None,
max_tokens: int | None = None,
reasoning_config: Dict[str, Any] | None = None,
service_tier: str | None = None,
request_overrides: Dict[str, Any] | None = None,
prefill_messages: List[Dict[str, Any]] | None = None,
platform: str | None = None,
user_id: str | None = None,
user_id_alt: str | None = None,
user_name: str | None = None,
chat_id: str | None = None,
chat_name: str | None = None,
chat_type: str | None = None,
thread_id: str | None = None,
gateway_session_key: str | None = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
service_tier: str = None,
request_overrides: Dict[str, Any] = None,
prefill_messages: List[Dict[str, Any]] = None,
platform: str = None,
user_id: str = None,
user_id_alt: str = None,
user_name: str = None,
chat_id: str = None,
chat_name: str = None,
chat_type: str = None,
thread_id: str = None,
gateway_session_key: str = None,
skip_context_files: bool = False,
load_soul_identity: bool = False,
skip_memory: bool = False,
session_db=None,
parent_session_id: str | None = None,
iteration_budget: Optional["IterationBudget"] = None,
fallback_model: Dict[str, Any] | None = None,
parent_session_id: str = None,
iteration_budget: "IterationBudget" = None,
fallback_model: Dict[str, Any] = None,
credential_pool=None,
checkpoints_enabled: bool = False,
checkpoint_max_snapshots: int = 20,
@@ -748,21 +748,6 @@ def init_agent(
# repeated commentary is not re-sent before normalization can deduplicate it.
agent._delivered_interim_texts: set[str] = set()
# Single-writer guard for the streaming delta sink (#65991). A stale/
# superseded stream (e.g. one the stale-stream detector reconnected past,
# whose socket abort raced and never actually stopped the old worker) must
# NOT keep writing tokens into the turn alongside the retry's stream —
# otherwise two coherent responses interleave token-by-token into one
# transcript. Every streaming attempt claims a monotonic writer token; the
# delta sink drops chunks whose calling thread holds a stale token. The
# threading.local means threads that never claimed (non-streaming callers)
# are never fenced, so the guard can only ever drop a superseded stream,
# never the single legitimate writer.
agent._stream_writer_lock = threading.Lock()
agent._stream_writer_token = 0
agent._stream_writer_tls = threading.local()
agent._stream_writer_dropped = 0
# Optional current-turn user-message override used when the API-facing
# user message intentionally differs from the persisted transcript
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
+1 -1
View File
@@ -246,7 +246,7 @@ def sanitize_tool_call_arguments(
messages: list,
*,
logger=None,
session_id: str | None = None,
session_id: str = None,
) -> int:
"""Repair corrupted assistant tool-call argument JSON in-place."""
log = logger or logging.getLogger(__name__)
+4 -4
View File
@@ -633,8 +633,8 @@ def _common_betas_for_base_url(
def _build_anthropic_client_with_bearer_hook(
token_provider,
base_url: str | None = None,
timeout: float | None = None,
base_url: str = None,
timeout: float = None,
*,
drop_context_1m_beta: bool = False,
):
@@ -709,8 +709,8 @@ def _build_anthropic_client_with_bearer_hook(
def build_anthropic_client(
api_key,
base_url: str | None = None,
timeout: float | None = None,
base_url: str = None,
timeout: float = None,
*,
drop_context_1m_beta: bool = False,
):
+119 -267
View File
@@ -41,9 +41,6 @@ Payment / credit exhaustion fallback:
"""
import contextlib
import contextvars
import hashlib
import inspect
import json
import logging
import os
@@ -2210,7 +2207,7 @@ def _read_main_model() -> str:
that gate on "the active main model" (e.g. ``vision_analyze``'s native
fast path) see the live runtime, not the persisted config default.
"""
override = _runtime_main_value("model")
override = _RUNTIME_MAIN_MODEL
if isinstance(override, str) and override.strip():
return override.strip()
try:
@@ -2237,7 +2234,7 @@ def _read_main_provider() -> str:
Runtime override: see ``_read_main_model`` — same mechanism for the
provider half of the runtime tuple.
"""
override = _runtime_main_value("provider")
override = _RUNTIME_MAIN_PROVIDER
if isinstance(override, str) and override.strip():
return override.strip().lower()
try:
@@ -2266,7 +2263,7 @@ def _read_main_api_key() -> str:
the main model's credentials instead of falling to ``no-key-required``
(issue #9318).
"""
override = _runtime_main_value("api_key")
override = _RUNTIME_MAIN_API_KEY
if isinstance(override, str) and override.strip():
return override.strip()
try:
@@ -2287,7 +2284,7 @@ def _read_main_base_url() -> str:
Same override-then-config pattern as ``_read_main_api_key``.
"""
override = _runtime_main_value("base_url")
override = _RUNTIME_MAIN_BASE_URL
if isinstance(override, str) and override.strip():
return override.strip()
try:
@@ -2323,55 +2320,13 @@ def _read_main_api_key_if_same_host(aux_base_url: str) -> str:
return _read_main_api_key()
# Compatibility mirrors for older readers/tests. The authoritative value is
# the ContextVar below: gateway sessions can overlap in one process, so a
# process-global tuple is not safe as routing or cache-key input.
# Process-local override set by AIAgent at session/turn start. Single-threaded
# per turn — no lock needed. Cleared by ``clear_runtime_main()``.
_RUNTIME_MAIN_PROVIDER: str = ""
_RUNTIME_MAIN_MODEL: str = ""
_RUNTIME_MAIN_BASE_URL: str = ""
_RUNTIME_MAIN_API_KEY: Any = ""
_RUNTIME_MAIN_API_KEY: str = ""
_RUNTIME_MAIN_API_MODE: str = ""
_RUNTIME_MAIN_AUTH_MODE: str = ""
_RUNTIME_MAIN_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = (
contextvars.ContextVar("auxiliary_runtime_main", default=None)
)
_RUNTIME_MAIN_COMPAT_SNAPSHOT: Tuple[Any, ...] = ("", "", "", "", "", "")
_RUNTIME_MAIN_COMPAT_LOCK = threading.Lock()
def _compat_runtime_main() -> Optional[Dict[str, Any]]:
"""Expose deliberately patched legacy globals in a single main context.
``set_runtime_main`` mirrors values into the old module attributes for
introspection, but those mirrors must never become runtime inputs. A direct
patch is recognized only when it differs from the mirrored snapshot and
only on the main thread, keeping concurrent session workers isolated.
"""
if threading.current_thread() is not threading.main_thread():
return None
values = (
_RUNTIME_MAIN_PROVIDER,
_RUNTIME_MAIN_MODEL,
_RUNTIME_MAIN_BASE_URL,
_RUNTIME_MAIN_API_KEY,
_RUNTIME_MAIN_API_MODE,
_RUNTIME_MAIN_AUTH_MODE,
)
if values == _RUNTIME_MAIN_COMPAT_SNAPSHOT:
return None
return dict(zip(_MAIN_RUNTIME_FIELDS, values))
def _runtime_main_value(field: str) -> Any:
"""Read one runtime field through context-local/controlled legacy state."""
runtime = _RUNTIME_MAIN_CONTEXT.get()
if runtime is None:
runtime = _compat_runtime_main()
if isinstance(runtime, dict):
value = runtime.get(field)
if value:
return value
return ""
def set_runtime_main(
@@ -2379,85 +2334,38 @@ def set_runtime_main(
model: str,
*,
base_url: str = "",
api_key: Any = "",
api_key: str = "",
api_mode: str = "",
auth_mode: str = "",
) -> contextvars.Token:
"""Record the current context's live main runtime for auxiliary routing.
) -> None:
"""Record the live runtime provider/model/credentials for the current AIAgent.
Context-local state prevents concurrent gateway sessions from overwriting
one another while retaining compatibility mirrors for legacy readers.
Called by ``run_agent.AIAgent._sync_runtime_main_for_aux_routing`` (or
equivalent setter) at the top of each turn so that
``_read_main_provider`` / ``_read_main_model`` reflect CLI/gateway
overrides instead of the stale config.yaml default.
For ``custom:`` providers, ``base_url`` and ``api_key`` must also be
recorded so that ``_resolve_auto`` can construct a valid client in
Step 1 instead of falling through to the aggregator chain.
"""
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
global _RUNTIME_MAIN_AUTH_MODE, _RUNTIME_MAIN_COMPAT_SNAPSHOT
runtime = {
"provider": (provider or "").strip().lower(),
"model": (model or "").strip(),
"base_url": (base_url or "").strip(),
"api_key": (
api_key.strip()
if isinstance(api_key, str)
else api_key if callable(api_key) else ""
),
"api_mode": (api_mode or "").strip(),
"auth_mode": (auth_mode or "").strip().lower(),
}
# Publish authoritative context before updating locked compatibility
# mirrors; concurrent sessions never read those mirrors at runtime.
token = _RUNTIME_MAIN_CONTEXT.set(runtime)
with _RUNTIME_MAIN_COMPAT_LOCK:
(
_RUNTIME_MAIN_PROVIDER,
_RUNTIME_MAIN_MODEL,
_RUNTIME_MAIN_BASE_URL,
_RUNTIME_MAIN_API_KEY,
_RUNTIME_MAIN_API_MODE,
_RUNTIME_MAIN_AUTH_MODE,
) = (runtime[field] for field in _MAIN_RUNTIME_FIELDS)
_RUNTIME_MAIN_COMPAT_SNAPSHOT = tuple(
runtime[field] for field in _MAIN_RUNTIME_FIELDS
)
return token
def reset_runtime_main(token: contextvars.Token) -> None:
"""Restore the runtime binding that preceded one scoped turn."""
if token is None:
return
try:
_RUNTIME_MAIN_CONTEXT.reset(token)
except (RuntimeError, ValueError):
# A token cannot be reset from another copied Context. Background
# workers inherit values, not ownership of the parent's token.
pass
@contextlib.contextmanager
def scoped_runtime_main(main_runtime: Optional[Dict[str, Any]]):
"""Temporarily bind an explicit runtime without touching legacy mirrors."""
runtime = _normalize_main_runtime(main_runtime)
token = _RUNTIME_MAIN_CONTEXT.set(runtime or None)
try:
yield runtime
finally:
_RUNTIME_MAIN_CONTEXT.reset(token)
_RUNTIME_MAIN_PROVIDER = (provider or "").strip().lower()
_RUNTIME_MAIN_MODEL = (model or "").strip()
_RUNTIME_MAIN_BASE_URL = (base_url or "").strip()
_RUNTIME_MAIN_API_KEY = api_key.strip() if isinstance(api_key, str) else ""
_RUNTIME_MAIN_API_MODE = (api_mode or "").strip()
def clear_runtime_main() -> None:
"""Clear the runtime override in the current context."""
"""Clear the runtime override (e.g. on session end)."""
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
global _RUNTIME_MAIN_AUTH_MODE, _RUNTIME_MAIN_COMPAT_SNAPSHOT
_RUNTIME_MAIN_CONTEXT.set(None)
with _RUNTIME_MAIN_COMPAT_LOCK:
_RUNTIME_MAIN_PROVIDER = ""
_RUNTIME_MAIN_MODEL = ""
_RUNTIME_MAIN_BASE_URL = ""
_RUNTIME_MAIN_API_KEY = ""
_RUNTIME_MAIN_API_MODE = ""
_RUNTIME_MAIN_AUTH_MODE = ""
_RUNTIME_MAIN_COMPAT_SNAPSHOT = ("", "", "", "", "", "")
_RUNTIME_MAIN_PROVIDER = ""
_RUNTIME_MAIN_MODEL = ""
_RUNTIME_MAIN_BASE_URL = ""
_RUNTIME_MAIN_API_KEY = ""
_RUNTIME_MAIN_API_MODE = ""
def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[str]]:
@@ -2876,14 +2784,6 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str,
surface as the main agent. The OpenAI SDK accepts ``Callable[[], str]``
for ``api_key`` and calls it before every request.
"""
if main_runtime is None:
# Context-local state is inherited by tool worker wrappers while
# remaining isolated across concurrent gateway sessions. Never fall
# back to compatibility mirrors here: another session may have written
# them most recently, which would leak its endpoint/key into this call.
main_runtime = _RUNTIME_MAIN_CONTEXT.get()
if main_runtime is None:
main_runtime = _compat_runtime_main()
if not isinstance(main_runtime, dict):
return {}
normalized: Dict[str, Any] = {}
@@ -3411,7 +3311,13 @@ def _evict_cached_clients(provider: str) -> None:
for key in stale_keys:
client = _client_cache.get(key, (None, None, None))[0]
if client is not None:
_close_cached_client(client)
_force_close_async_httpx(client)
try:
close_fn = getattr(client, "close", None)
if callable(close_fn):
close_fn()
except Exception:
pass
_client_cache.pop(key, None)
@@ -3972,7 +3878,7 @@ async def _call_fallback_candidate_async(
def _try_payment_fallback(
failed_provider: str,
task: str | None = None,
task: str = None,
reason: str = "payment error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try alternative providers after a payment/credit or connection error.
@@ -4023,7 +3929,7 @@ def _try_payment_fallback(
def _try_main_agent_model_fallback(
failed_provider: str,
task: str | None = None,
task: str = None,
reason: str = "error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Last-resort fallback to the user's main agent provider + model.
@@ -4402,6 +4308,17 @@ def _resolve_auto(
runtime_api_key = runtime.get("api_key", "")
runtime_api_mode = str(runtime.get("api_mode") or "")
# Fall back to process-local globals when main_runtime dict was not
# provided or was incomplete. ``set_runtime_main()`` now records
# base_url/api_key/api_mode alongside provider/model, so custom:
# providers get the full credential surface in Step 1 of the
# auto-detect chain.
if not runtime_base_url and _RUNTIME_MAIN_BASE_URL:
runtime_base_url = _RUNTIME_MAIN_BASE_URL
if not runtime_api_key and _RUNTIME_MAIN_API_KEY:
runtime_api_key = _RUNTIME_MAIN_API_KEY
if not runtime_api_mode and _RUNTIME_MAIN_API_MODE:
runtime_api_mode = _RUNTIME_MAIN_API_MODE
# ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
# provider (not 'custom'). This catches the common "env poisoning"
@@ -4665,12 +4582,12 @@ def _normalize_resolved_model(model_name: Optional[str], provider: str) -> Optio
def resolve_provider_client(
provider: str,
model: str | None = None,
model: str = None,
async_mode: bool = False,
raw_codex: bool = False,
explicit_base_url: str | None = None,
explicit_api_key: str | None = None,
api_mode: str | None = None,
explicit_base_url: str = None,
explicit_api_key: str = None,
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
@@ -4715,14 +4632,12 @@ def resolve_provider_client(
# Normalise aliases
provider = _normalize_aux_provider(provider)
# Universal model-resolution fallback for concrete providers. ``auto`` is
# intentionally excluded: `_resolve_auto(main_runtime=...)` returns the
# model paired with the provider it actually selected. Pre-filling an auto
# call from `_read_main_model()` can leak a stale process-global runtime
# into a different provider (for example Claude model slug on Codex OAuth)
# and override that correctly resolved model.
#
# Concrete provider resolution order:
# Universal model-resolution fallback chain. Callers (notably title
# generation, vision, session search, and other auxiliary tasks) can
# reach this function without an explicit model — the user picked their
# main provider, didn't bother configuring a per-task ``auxiliary.<task>.model``,
# and just expects "use my main model for side tasks too." Resolve in
# this order, stopping at the first non-empty answer:
#
# 1. ``model`` argument (caller knew what they wanted)
# 2. Provider's catalog default — cheap/fast model the provider
@@ -5567,7 +5482,6 @@ def resolve_vision_provider_client(
base_url: Optional[str] = None,
api_key: Optional[str] = None,
async_mode: bool = False,
main_runtime: Optional[Dict[str, Any]] = None,
) -> Tuple[Optional[str], Optional[Any], Optional[str]]:
"""Resolve the client actually used for vision tasks.
@@ -5576,7 +5490,6 @@ def resolve_vision_provider_client(
backends, so users can intentionally force experimental providers. Auto mode
stays conservative and only tries vision backends known to work today.
"""
runtime = _normalize_main_runtime(main_runtime)
requested, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
"vision", provider, model, base_url, api_key
)
@@ -5602,7 +5515,6 @@ def resolve_vision_provider_client(
explicit_base_url=resolved_base_url,
explicit_api_key=resolved_api_key,
api_mode=resolved_api_mode,
main_runtime=runtime,
)
if client is None:
return provider_for_base_override, None, None
@@ -5626,8 +5538,8 @@ def resolve_vision_provider_client(
# live from the catalog — tried when
# DEEPINFRA_API_KEY is set)
# 5. Stop
main_provider = str(runtime.get("provider") or _read_main_provider())
main_model = str(runtime.get("model") or _read_main_model())
main_provider = _read_main_provider()
main_model = _read_main_model()
if main_provider and main_provider not in {"auto", ""}:
# A provider-specific vision default wins over the user's chat model:
# static overrides (xiaomi/zai) and catalog-backed discovery (the
@@ -5690,15 +5602,10 @@ def resolve_vision_provider_client(
rpc_api_key = None
rpc_api_mode = resolved_api_mode
if main_provider == "custom" or main_provider.startswith("custom:"):
runtime_base_url = runtime.get("base_url")
if runtime_base_url:
rpc_base_url = runtime_base_url
rpc_api_key = runtime.get("api_key") or None
rpc_api_mode = (
resolved_api_mode
or runtime.get("api_mode")
or None
)
if _RUNTIME_MAIN_BASE_URL:
rpc_base_url = _RUNTIME_MAIN_BASE_URL
rpc_api_key = _RUNTIME_MAIN_API_KEY or None
rpc_api_mode = resolved_api_mode or _RUNTIME_MAIN_API_MODE or None
else:
# No live runtime recorded (non-gateway caller): fall
# back to resolving the configured custom endpoint.
@@ -5712,7 +5619,6 @@ def resolve_vision_provider_client(
api_mode=rpc_api_mode,
explicit_base_url=rpc_base_url,
explicit_api_key=rpc_api_key,
main_runtime=runtime,
is_vision=True)
if rpc_client is not None:
logger.info(
@@ -5755,7 +5661,6 @@ def resolve_vision_provider_client(
base_url=_zai_url,
api_key=resolved_api_key or None,
api_mode="chat_completions",
main_runtime=runtime,
is_vision=True,
)
if client is not None:
@@ -5763,7 +5668,6 @@ def resolve_vision_provider_client(
# Fallback: try without explicit base_url (old behavior)
client, final_model = _get_cached_client(requested, resolved_model, async_mode,
api_mode=resolved_api_mode,
main_runtime=runtime,
is_vision=True)
if client is None:
return requested, None, None
@@ -5771,7 +5675,6 @@ def resolve_vision_provider_client(
client, final_model = _get_cached_client(requested, resolved_model, async_mode,
api_mode=resolved_api_mode,
main_runtime=runtime,
is_vision=True)
if client is None:
return requested, None, None
@@ -5839,38 +5742,6 @@ _client_cache_lock = threading.Lock()
_CLIENT_CACHE_MAX_SIZE = 64 # safety belt — evict oldest when exceeded
class _CallableCacheDiscriminator:
"""Hash a credential callback by identity without exposing its state."""
__slots__ = ("_callback",)
def __init__(self, callback: Any) -> None:
# Retain the callback so its id cannot be reused while cached.
self._callback = callback
def __hash__(self) -> int:
return id(self._callback)
def __eq__(self, other: object) -> bool:
return (
isinstance(other, _CallableCacheDiscriminator)
and self._callback is other._callback
)
def __repr__(self) -> str:
return "<callable-api-key>"
def _runtime_cache_discriminator(field: str, value: Any) -> Any:
"""Return a hashable, secret-safe runtime cache-key component."""
if field == "api_key" and callable(value):
return _CallableCacheDiscriminator(value)
if field == "api_key" and isinstance(value, str) and value:
digest = hashlib.blake2b(value.encode("utf-8"), digest_size=16).digest()
return ("api-key-digest", digest)
return value
def _client_cache_key(
provider: str,
*,
@@ -5884,10 +5755,7 @@ def _client_cache_key(
model: Optional[str] = None,
) -> tuple:
runtime = _normalize_main_runtime(main_runtime)
runtime_key = tuple(
_runtime_cache_discriminator(field, runtime.get(field, ""))
for field in _MAIN_RUNTIME_FIELDS
) if provider == "auto" else ()
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
# `auto` can now resolve through task-specific or main fallback policy,
# so the task participates in the cache key. Non-auto providers keep the
# old cache shape because the explicit provider/model tuple is sufficient.
@@ -5902,16 +5770,21 @@ def _client_cache_key(
# APIConnectionError that fails the sibling advisor (root cause of the run2
# double-advisor "Connection error" collapse). Keying on model gives each
# model its own client, so concurrent fan-out calls never cross-close.
model_key = model or runtime.get("model", "")
api_key_key = _runtime_cache_discriminator("api_key", api_key or "")
return (provider, async_mode, base_url or "", api_key_key, api_mode or "", runtime_key, is_vision, task_key, pool_hint, model_key)
model_key = model or ""
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint, model_key)
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
with _client_cache_lock:
old_entry = _client_cache.get(cache_key)
if old_entry is not None and old_entry[0] is not client:
_close_cached_client(old_entry[0])
_force_close_async_httpx(old_entry[0])
try:
close_fn = getattr(old_entry[0], "close", None)
if callable(close_fn):
close_fn()
except Exception:
pass
_client_cache[cache_key] = (client, default_model, bound_loop)
@@ -6013,31 +5886,30 @@ def _force_close_async_httpx(client: Any) -> None:
pass
def _close_cached_client(client: Any) -> None:
"""Apply the canonical best-effort close policy to one cached client."""
if client is None:
return
_force_close_async_httpx(client)
try:
close_fn = getattr(client, "close", None)
if callable(close_fn) and not inspect.iscoroutinefunction(close_fn):
close_fn()
except Exception:
pass
def shutdown_cached_clients() -> None:
"""Close all cached clients (sync and async) to prevent event-loop errors.
Call this during CLI shutdown, *before* the event loop is closed, to
avoid ``AsyncHttpxClientWrapper.__del__`` raising on a dead loop.
"""
import inspect
with _client_cache_lock:
for key, entry in list(_client_cache.items()):
client = entry[0]
if client is None:
continue
_close_cached_client(client)
# Mark any async httpx transport as closed first (prevents __del__
# from scheduling aclose() on a dead event loop).
_force_close_async_httpx(client)
# Sync clients: close the httpx connection pool cleanly.
# Async clients: skip — we already neutered __del__ above.
try:
close_fn = getattr(client, "close", None)
if close_fn and not inspect.iscoroutinefunction(close_fn):
close_fn()
except Exception:
pass
_client_cache.clear()
@@ -6086,11 +5958,11 @@ def _compat_model(client: Any, model: Optional[str], cached_default: Optional[st
def _get_cached_client(
provider: str,
model: str | None = None,
model: str = None,
async_mode: bool = False,
base_url: str | None = None,
api_key: str | None = None,
api_mode: str | None = None,
base_url: str = None,
api_key: str = None,
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
@@ -6187,20 +6059,13 @@ def _get_cached_client(
if cache_key not in _client_cache:
# Safety belt: if the cache has grown beyond the max, evict
# the oldest entries (FIFO — dict preserves insertion order).
# Do not close an evicted client here: another caller may be
# mid-request with the object it obtained from this cache.
# Dropping the cache reference lets normal refcount/GC cleanup
# happen after in-flight users release it.
while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE:
evict_key = next(iter(_client_cache))
evict_key, evict_entry = next(iter(_client_cache.items()))
_force_close_async_httpx(evict_entry[0])
del _client_cache[evict_key]
_client_cache[cache_key] = (client, default_model, bound_loop)
else:
built_client = client
client, default_model, _ = _client_cache[cache_key]
# This concurrently built loser was never exposed to a caller,
# so it is safe to close immediately.
_close_cached_client(built_client)
return client, model or default_model
@@ -6222,11 +6087,11 @@ _AUX_DIRECT_API_BASE_URLS: Dict[str, str] = {
def _resolve_task_provider_model(
task: str | None = None,
provider: str | None = None,
model: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
task: str = None,
provider: str = None,
model: str = None,
base_url: str = None,
api_key: str = None,
) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]:
"""Determine provider + model for a call.
@@ -6900,23 +6765,23 @@ def _obj_get(obj: Any, key: str, default: Any = None) -> Any:
def call_llm(
task: str | None = None,
task: str = None,
*,
provider: str | None = None,
model: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
provider: str = None,
model: str = None,
base_url: str = None,
api_key: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
temperature: Optional[float] = None,
max_tokens: int | None = None,
tools: list | None = None,
timeout: float | None = None,
extra_body: dict | None = None,
max_tokens: int = None,
tools: list = None,
timeout: float = None,
extra_body: dict = None,
reasoning_config: Optional[dict] = None,
api_mode: str | None = None,
api_mode: str = None,
stream: bool = False,
stream_options: dict | None = None,
stream_options: dict = None,
) -> Any:
"""Centralized synchronous LLM call.
@@ -6953,11 +6818,6 @@ def call_llm(
Raises:
RuntimeError: If no provider is configured.
"""
# Capture one immutable runtime snapshot for keying, resolution, retries,
# and fallbacks. Reading ambient state independently in each phase lets a
# concurrent /model switch produce a key for one runtime and a client for
# another.
main_runtime = _normalize_main_runtime(main_runtime)
resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
task, provider, model, base_url, api_key)
if api_mode:
@@ -6972,7 +6832,6 @@ def call_llm(
base_url=resolved_base_url or base_url,
api_key=resolved_api_key or api_key,
async_mode=False,
main_runtime=main_runtime,
)
if client is None and resolved_provider != "auto" and not resolved_base_url:
logger.warning(
@@ -6983,7 +6842,6 @@ def call_llm(
provider="auto",
model=resolved_model,
async_mode=False,
main_runtime=main_runtime,
)
if client is None:
raise RuntimeError(
@@ -7567,28 +7425,25 @@ def extract_content_or_reasoning(response) -> str:
async def async_call_llm(
task: str | None = None,
task: str = None,
*,
provider: str | None = None,
model: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
provider: str = None,
model: str = None,
base_url: str = None,
api_key: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
temperature: Optional[float] = None,
max_tokens: int | None = None,
tools: list | None = None,
timeout: float | None = None,
extra_body: dict | None = None,
max_tokens: int = None,
tools: list = None,
timeout: float = None,
extra_body: dict = None,
reasoning_config: Optional[dict] = None,
) -> Any:
"""Centralized asynchronous LLM call.
Same as call_llm() but async. See call_llm() for full documentation.
"""
# Keep every async phase on the same runtime identity, even if another
# session switches models while this task is awaiting network I/O.
main_runtime = _normalize_main_runtime(main_runtime)
resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
task, provider, model, base_url, api_key)
effective_extra_body = _get_task_extra_body(task)
@@ -7601,7 +7456,6 @@ async def async_call_llm(
base_url=resolved_base_url or base_url,
api_key=resolved_api_key or api_key,
async_mode=True,
main_runtime=main_runtime,
)
if client is None and resolved_provider != "auto" and not resolved_base_url:
logger.warning(
@@ -7612,7 +7466,6 @@ async def async_call_llm(
provider="auto",
model=resolved_model,
async_mode=True,
main_runtime=main_runtime,
)
if client is None:
raise RuntimeError(
@@ -7628,7 +7481,6 @@ async def async_call_llm(
base_url=resolved_base_url,
api_key=resolved_api_key,
api_mode=resolved_api_mode,
main_runtime=main_runtime,
)
if client is None:
_explicit = (resolved_provider or "").strip().lower()
-34
View File
@@ -2144,10 +2144,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
invalidate_runtime_client(region)
raise
# Claim the delta sink for this bedrock stream (#65991) so a
# superseded attempt's callbacks are fenced by the sink guard.
agent._claim_stream_writer()
def _on_text(text):
_fire_first()
agent._fire_stream_delta(text)
@@ -2346,11 +2342,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_diag = agent._stream_diag_init()
request_client_holder["diag"] = _diag
stream = request_client.chat.completions.create(**stream_kwargs)
# Claim the delta sink for THIS attempt (#65991). If a prior attempt's
# stream is somehow still alive (a stale-stream reconnect whose socket
# abort raced), this claim supersedes it so its late chunks are fenced
# out of the turn instead of interleaving with ours.
_writer_token = agent._claim_stream_writer()
# Some OpenAI-compatible adapters (for example copilot-acp, and the MoA
# openai-codex aggregator) accept stream=True but still return a
@@ -2423,18 +2414,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
reasoning_parts: list = []
usage_obj = None
for chunk in stream:
# Stop the moment a newer attempt has claimed the delta sink
# (#65991): this attempt has been superseded, so it must neither
# fire deltas (incl. the tool-suppressed raw-callback path below)
# nor keep consuming a stream that would interleave into the turn.
if not agent._stream_writer_is_current(_writer_token):
logger.warning(
"Streaming attempt superseded by a newer stream; stopping "
"consumption to preserve the single-writer invariant "
"(model=%s).",
api_kwargs.get("model", "unknown"),
)
break
last_chunk_time["t"] = time.time()
agent._touch_activity("receiving stream response")
@@ -2757,20 +2736,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
)
except Exception:
pass
# Claim the delta sink for THIS attempt (#65991) — parity with the
# chat_completions path so a superseded anthropic stream is fenced.
_writer_token = agent._claim_stream_writer()
for event in stream:
# Bail the instant a newer attempt supersedes this one so a
# stale stream can't interleave tokens into the turn.
if not agent._stream_writer_is_current(_writer_token):
logger.warning(
"Anthropic streaming attempt superseded by a newer "
"stream; stopping consumption to preserve the "
"single-writer invariant (model=%s).",
api_kwargs.get("model", "unknown"),
)
break
saw_stream_event = True
# Update stale-stream timer on every event so the
# outer poll loop knows data is flowing. Without
+5 -45
View File
@@ -287,23 +287,10 @@ def _record_codex_app_server_compaction(
# therefore deserve a tool_progress bubble pair). The projector lives in
# agent/transports/codex_event_projector.py — keep these in sync so the
# tool name shown in the UI matches the name recorded in messages.
# webSearch is codex's built-in web search tool — it has no projector
# entry (codex handles it internally) but still deserves a bubble.
_CODEX_TOOL_ITEM_TYPES = frozenset(
{"commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "webSearch"}
{"commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall"}
)
# Internal MCP server that wraps Hermes' native tools for codex. When
# codex calls back through it, the inner dispatch runs in a SEPARATE
# hermes-tools-mcp-server subprocess that has no access to the parent
# agent's tool_progress_callback — so the inner call can never surface
# its own native progress event. The codex-level mcpToolCall event IS
# the display event for those calls; we strip the mcp.hermes-tools.*
# namespacing and emit the bare tool name (web_search, browser_navigate,
# vision_analyze, ...) since the user thinks of these as Hermes tools,
# not as MCP calls.
_INTERNAL_MCP_SERVER = "hermes-tools"
def _codex_item_to_tool_name(item: dict) -> str:
"""Synthetic Hermes tool name for a codex item. Mirrors
@@ -317,13 +304,9 @@ def _codex_item_to_tool_name(item: dict) -> str:
if item_type == "mcpToolCall":
server = item.get("server") or "mcp"
tool = item.get("tool") or "unknown"
if server == _INTERNAL_MCP_SERVER:
return tool
return f"mcp.{server}.{tool}"
if item_type == "dynamicToolCall":
return item.get("tool") or "dynamic"
if item_type == "webSearch":
return "web_search"
return item_type or "unknown"
@@ -344,8 +327,6 @@ def _codex_item_to_args(item: dict) -> dict:
if item_type in {"mcpToolCall", "dynamicToolCall"}:
args = item.get("arguments") or {}
return args if isinstance(args, dict) else {"arguments": args}
if item_type == "webSearch":
return {"query": item.get("query") or ""}
return {}
@@ -373,9 +354,6 @@ def _codex_item_to_preview(item: dict) -> Any:
return json.dumps(args, ensure_ascii=False)[:120]
except (TypeError, ValueError):
return None
if item_type == "webSearch":
query = item.get("query") or ""
return query[:120] if query else None
return None
@@ -1165,6 +1143,9 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
agent._codex_stream_last_event_ts = time.time()
agent._touch_activity("receiving stream response")
def _interrupt_check() -> bool:
return bool(agent._interrupt_requested)
for attempt in range(max_stream_retries + 1):
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted before Codex stream retry")
@@ -1184,27 +1165,6 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
continue
raise
# Claim the delta sink for THIS attempt (#65991) — parity with the
# chat_completions/anthropic/bedrock paths. If a prior attempt's
# stream is somehow still alive, this claim supersedes it so its
# late deltas are fenced out of the turn; conversely, a newer
# attempt supersedes us and the interrupt_check below stops our
# consumption immediately.
_writer_token = agent._claim_stream_writer()
def _interrupt_or_superseded(_tok=_writer_token) -> bool:
if agent._interrupt_requested:
return True
if not agent._stream_writer_is_current(_tok):
logger.warning(
"Codex streaming attempt superseded by a newer stream; "
"stopping consumption to preserve the single-writer "
"invariant (model=%s).",
api_kwargs.get("model", "unknown"),
)
return True
return False
try:
# Compatibility: some mocks/providers return a concrete response
# instead of an iterable. Pass it straight through.
@@ -1227,7 +1187,7 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
),
on_first_delta=on_first_delta,
on_event=_on_event,
interrupt_check=_interrupt_or_superseded,
interrupt_check=_interrupt_check,
)
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
if attempt < max_stream_retries:
+1 -36
View File
@@ -107,9 +107,6 @@ SUMMARY_PREFIX = (
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"None of the above restricts HOW you work: your tools remain fully "
"active — keep calling them normally for the active task (edit files, "
"run commands, search) instead of merely narrating what you would do. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
@@ -196,36 +193,6 @@ _MERGED_SUMMARY_DELIMITER = "[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
# Jul 2026 (#65848 class): identical to the current prefix except it
# lacked the explicit "tools remain fully active" clause — the strong
# REFERENCE ONLY framing bled into general tool-use suppression
# (observed: 7 consecutive narration-only turns immediately after a
# compression event on a production deployment).
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
"Topic overlap with the summary does NOT mean you should resume its "
"task: even on similar topics, the latest user message WINS. Treat ONLY "
"the latest message as the active task and discard stale items from "
f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / "
f"'{HISTORICAL_PENDING_ASKS_HEADING}' / "
f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or "
"'finish' work described there unless the latest message explicitly "
"asks for it. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
"topic) must immediately end any in-flight work described in the "
"summary; do not re-surface it in later turns. "
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:",
# Carveout era (#41607/#38364/#42812): "consistent → use as background"
# licensed stale-task resumption on topic overlap.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
@@ -1175,9 +1142,6 @@ class ContextCompressor(ContextEngine):
if runtime_changed:
self._fallback_compression_streak = 0
self._persist_fallback_compression_streak()
# Failure cooldowns are scoped to the model/provider that failed.
# A switch must give the new runtime an immediate summary attempt.
self._clear_compression_failure_cooldown()
self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False
@@ -1263,6 +1227,7 @@ class ContextCompressor(ContextEngine):
return max(1, min(int(effective_window * ContextCompressor._MIN_CTX_TRIGGER_RATIO),
effective_window - 1))
return floored
def __init__(
self,
model: str,
+24 -102
View File
@@ -483,34 +483,6 @@ _CONTENT_POLICY_RECOVERY_HINT = (
)
def _invalid_tool_name_error_content(name: str, valid_tool_names) -> str:
"""Error-result content for a tool call whose name isn't a real tool.
A blank/whitespace-only name is not a typo the model can fuzzy-correct
toward a real tool — it is almost always a weak open model echoing
tool-call XML/JSON it saw in file or tool output (#47967:
<tool_call>/<invoke name=...> payloads in a file prime
mimo/nemotron-class models to emit empty structured calls), or a model
degrading at very large context (observed with gpt-5.6 past ~350K input).
Dumping the full tool catalog in that case feeds the priming loop more
names to mimic and inflates context 3-4x across retries, so send a terse
error that tells the model in-context tool-call syntax is DATA, not a
call to make. A genuinely-wrong-but-nonempty name (an actual typo) still
gets the catalog so the model can self-correct.
"""
if not (name or "").strip():
return (
"Tool call rejected: the tool name was empty. "
"If tool-call XML or JSON appeared in file "
"contents or tool output, that is data — do "
"not re-emit it as a tool call. To call a "
"tool, use a valid name from your tool list; "
"otherwise reply in plain text."
)
available = ", ".join(sorted(valid_tool_names))
return f"Tool '{name}' does not exist. Available tools: {available}"
def _content_policy_blocked_result(
messages: List[Dict],
api_call_count: int,
@@ -4657,38 +4629,12 @@ def run_conversation(
tc.function.name for tc in assistant_message.tool_calls
if tc.function.name not in agent.valid_tool_names
]
# Mixed batch: at least one valid call alongside the invalid
# one(s). Degrading models (observed with gpt-5.6 at very
# large context) emit batches like 6 named calls + 1
# blank-name call; voiding the whole turn throws away real
# work and, across the 3-strike budget, halts sessions that
# were still making progress. Instead: error-result ONLY the
# invalid calls (below, after dedup/cap guardrails) and let
# the valid ones execute. The strike counter only advances
# when a turn contains NO valid call, so a fully-degenerate
# model still halts at 3 while a mostly-coherent one keeps
# working.
_mixed_invalid_batch = bool(invalid_tool_calls) and any(
tc.function.name in agent.valid_tool_names
for tc in assistant_message.tool_calls
)
if _mixed_invalid_batch:
agent._invalid_tool_retries = 0
invalid_name = invalid_tool_calls[0]
invalid_preview = invalid_name[:80] + "..." if len(invalid_name) > 80 else invalid_name
_n_valid = sum(
1 for tc in assistant_message.tool_calls
if tc.function.name in agent.valid_tool_names
)
agent._buffer_vprint(
f"⚠️ Unknown tool '{invalid_preview}' in batch — erroring that call, "
f"executing {_n_valid} valid call(s)"
)
elif invalid_tool_calls:
if invalid_tool_calls:
# Track retries for invalid tool calls
agent._invalid_tool_retries += 1
# Return helpful error to model — model can agent-correct next turn
available = ", ".join(sorted(agent.valid_tool_names))
invalid_name = invalid_tool_calls[0]
invalid_preview = invalid_name[:80] + "..." if len(invalid_name) > 80 else invalid_name
agent._buffer_vprint(f"⚠️ Unknown tool '{invalid_preview}' — sending error to model for agent-correction ({agent._invalid_tool_retries}/3)")
@@ -4713,11 +4659,28 @@ def run_conversation(
for tc in assistant_message.tool_calls:
_tc_name = tc.function.name
if _tc_name not in agent.valid_tool_names:
# See _invalid_tool_name_error_content for the
# blank-name anti-priming rationale (#47967).
content = _invalid_tool_name_error_content(
_tc_name, agent.valid_tool_names
)
# A blank/whitespace-only name is not a typo the
# model can fuzzy-correct toward a real tool — it is
# almost always a weak open model echoing tool-call
# XML/JSON it saw in file or tool output (#47967:
# <tool_call>/<invoke name=...> payloads in a file
# prime mimo/nemotron-class models to emit empty
# structured calls). Dumping the full tool catalog
# in that case feeds the priming loop more names to
# mimic and inflates context 3-4x across retries, so
# send a terse error that tells the model in-context
# tool-call syntax is DATA, not a call to make.
if not (_tc_name or "").strip():
content = (
"Tool call rejected: the tool name was empty. "
"If tool-call XML or JSON appeared in file "
"contents or tool output, that is data — do "
"not re-emit it as a tool call. To call a "
"tool, use a valid name from your tool list; "
"otherwise reply in plain text."
)
else:
content = f"Tool '{_tc_name}' does not exist. Available tools: {available}"
else:
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
messages.append({
@@ -4748,14 +4711,6 @@ def run_conversation(
try:
json.loads(args)
except json.JSONDecodeError as e:
if (
_mixed_invalid_batch
and tc.function.name not in agent.valid_tool_names
):
# This call never executes — it gets an
# invalid-name error result below. Don't let its
# broken args trigger the whole-turn JSON retry.
continue
invalid_json_args.append((tc.function.name, str(e)))
if invalid_json_args:
@@ -4839,18 +4794,6 @@ def run_conversation(
assistant_message.tool_calls
)
# Mixed-batch invalid-name handling: collect the invalid
# calls now so the assistant message (built below) keeps
# EVERY call the model emitted — providers require each
# tool_call to have a matching tool result and vice versa —
# while only the valid subset is dispatched for execution.
_invalid_batch_calls = []
if _mixed_invalid_batch:
_invalid_batch_calls = [
tc for tc in assistant_message.tool_calls
if tc.function.name not in agent.valid_tool_names
]
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
turn_content = assistant_message.content or ""
@@ -4942,27 +4885,6 @@ def run_conversation(
messages.append(assistant_msg)
if not duplicate_previous_interim:
agent._emit_interim_assistant_message(assistant_msg)
# Mixed batch: error-result the invalid calls and strip them
# from the execution set. The assistant message above keeps
# all calls (each gets a matching tool result — the invalid
# ones get theirs here, the valid ones during execution), so
# provider-side tool_call/result pairing stays intact.
if _invalid_batch_calls:
for tc in _invalid_batch_calls:
messages.append({
"role": "tool",
"name": tc.function.name,
"tool_call_id": tc.id,
"content": _invalid_tool_name_error_content(
tc.function.name, agent.valid_tool_names
),
})
assistant_message.tool_calls = [
tc for tc in assistant_message.tool_calls
if tc.function.name in agent.valid_tool_names
]
try:
# Persist the assistant tool-call turn before any tool
# side effects run. If a destructive tool restarts or
+1 -40
View File
@@ -114,20 +114,6 @@ EXHAUSTED_TTL_401_SECONDS = 5 * 60 # 5 minutes
EXHAUSTED_TTL_429_SECONDS = 60 * 60 # 1 hour
EXHAUSTED_TTL_DEFAULT_SECONDS = 60 * 60 # 1 hour
# Throttle window for the "no available entries" INFO line. Credential
# selection runs on a hot path (every model call, plus auxiliary tasks like
# compression/moa/titles), so when a pool is empty or fully exhausted the
# un-throttled log fires on *every* selection. On Windows several Hermes
# processes share one rotating log guarded by concurrent-log-handler's
# cross-process lock; that per-selection volume storms the lock
# (``RuntimeError: Cannot acquire lock after 20 attempts``), pegs a core, and
# stalls the asyncio event loop long enough to fail the Desktop backend
# readiness handshake ("Timed out connecting to Hermes backend after
# 15000ms"). Logging the condition at most once per window preserves the
# signal while removing the storm — same class of fix as the warn-once
# dedup in #58265.
NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS = 60.0
# Pool key prefix for custom OpenAI-compatible endpoints.
# Custom endpoints all share provider='custom' but are keyed by their
# custom_providers name: 'custom:<normalized_name>'.
@@ -580,12 +566,6 @@ class CredentialPool:
self._lock = threading.Lock()
self._active_leases: Dict[str, int] = {}
self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL
# Monotonic timestamp of the last "no available entries" log, used to
# throttle that message so an empty/exhausted pool cannot storm the
# shared rotating log (see NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS).
# Re-armed to None on every successful selection so a recover→re-exhaust
# transition logs promptly instead of being swallowed by a stale window.
self._last_no_entries_log_at: Optional[float] = None
def has_credentials(self) -> bool:
return bool(self._entries)
@@ -1624,32 +1604,13 @@ class CredentialPool:
self._persist(removed_ids=entries_to_prune)
return available
def _log_no_available_entries(self) -> None:
"""Emit the empty-pool INFO line at most once per throttle window.
Called on every selection while the pool is empty/exhausted. Without
throttling this storms the Windows cross-process log lock and stalls the
event loop (see NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS).
"""
now = time.monotonic()
last = self._last_no_entries_log_at
if last is not None and (now - last) < NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS:
return
self._last_no_entries_log_at = now
logger.info("credential pool: no available entries (all exhausted or empty)")
def _select_unlocked(self, *, refresh: bool = True) -> Optional[PooledCredential]:
available = self._available_entries(clear_expired=True, refresh=refresh)
if not available:
self._current_id = None
self._log_no_available_entries()
logger.info("credential pool: no available entries (all exhausted or empty)")
return None
# A successful selection means the pool recovered; re-arm the throttle
# so a later re-exhaustion logs immediately rather than being silenced
# by a window opened during the previous empty stretch.
self._last_no_entries_log_at = None
if self._strategy == STRATEGY_RANDOM:
entry = random.choice(available)
self._current_id = entry.id
+8
View File
@@ -775,6 +775,14 @@ def classify_api_error(
if classified is not None:
return classified
# Local MoA config drift is deterministic: a persisted session can retain
# a preset name that was later renamed/deleted. Retrying the same lookup
# cannot recover and makes a clear config error look like an API outage.
from agent.errors import MoAPresetNotFoundError
if isinstance(error, MoAPresetNotFoundError):
return _result(FailoverReason.model_not_found, retryable=False)
# ── 3. Error code classification ────────────────────────────────
if error_code:
+4
View File
@@ -7,3 +7,7 @@ class EmptyStreamError(RuntimeError):
"""Raised when a provider closes a stream without yielding a response."""
pass
class MoAPresetNotFoundError(ValueError):
"""Raised when a persisted MoA preset no longer exists in config."""
+3 -5
View File
@@ -267,12 +267,10 @@ def _resolve_inference_base_url(
) -> str:
"""Best-effort base URL for the active inference provider."""
try:
from agent.auxiliary_client import _runtime_main_value
from agent.auxiliary_client import _RUNTIME_MAIN_BASE_URL
runtime = str(_runtime_main_value("base_url") or "").strip()
runtime_provider = str(_runtime_main_value("provider") or "").strip().lower()
requested_provider = str(provider or "").strip().lower()
if runtime and (not requested_provider or requested_provider == runtime_provider):
runtime = str(_RUNTIME_MAIN_BASE_URL or "").strip()
if runtime:
return runtime
except Exception:
pass
+46 -2
View File
@@ -13,6 +13,20 @@ from agent.transports.base import ProviderTransport
from agent.transports.types import NormalizedResponse, ToolCall
def _bounded_prompt_cache_key(value: Any) -> Optional[str]:
"""Return a provider-safe cache key without changing session identity."""
if value is None:
return None
key = str(value).strip()
if not key:
return None
if len(key) <= 64:
return key
# Match _content_cache_key's compact, collision-resistant routing-key shape.
digest = hashlib.sha256(key.encode("utf-8", errors="replace")).hexdigest()[:24]
return f"pck_{digest}"
def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]:
"""Content-address the prompt cache key from the static request prefix.
@@ -304,6 +318,13 @@ class ResponsesApiTransport(ProviderTransport):
if request_overrides:
kwargs.update(request_overrides)
if "prompt_cache_key" in kwargs:
bounded_cache_key = _bounded_prompt_cache_key(kwargs["prompt_cache_key"])
if bounded_cache_key:
kwargs["prompt_cache_key"] = bounded_cache_key
else:
kwargs.pop("prompt_cache_key", None)
# xAI Responses API rejects ``service_tier`` (HTTP 400 "Argument not
# supported: service_tier") — hit when ``/fast`` priority-processing
# mode lingers from a prior model in the same session, or when a
@@ -337,7 +358,7 @@ class ResponsesApiTransport(ProviderTransport):
# remain high. Send session_id / x-client-request-id as HTTP
# headers while keeping ``prompt_cache_key`` in the body for
# standard OpenAI routing as a belt-and-braces fallback.
cache_scope_id = str(session_id or "").strip()
cache_scope_id = _bounded_prompt_cache_key(session_id)
if cache_scope_id:
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
@@ -382,6 +403,14 @@ class ResponsesApiTransport(ProviderTransport):
merged_extra_body.setdefault("prompt_cache_key", cache_key)
kwargs["extra_body"] = merged_extra_body
extra_body = kwargs.get("extra_body")
if isinstance(extra_body, dict) and "prompt_cache_key" in extra_body:
bounded_cache_key = _bounded_prompt_cache_key(extra_body["prompt_cache_key"])
if bounded_cache_key:
extra_body["prompt_cache_key"] = bounded_cache_key
else:
extra_body.pop("prompt_cache_key", None)
return kwargs
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
@@ -470,11 +499,26 @@ class ResponsesApiTransport(ProviderTransport):
Normalizes input items, strips unsupported fields, validates structure.
"""
from agent.codex_responses_adapter import _preflight_codex_api_kwargs
return _preflight_codex_api_kwargs(
normalized = _preflight_codex_api_kwargs(
api_kwargs,
allow_stream=allow_stream,
is_github_responses=is_github_responses,
)
if "prompt_cache_key" in normalized:
bounded = _bounded_prompt_cache_key(normalized["prompt_cache_key"])
if bounded:
normalized["prompt_cache_key"] = bounded
else:
normalized.pop("prompt_cache_key", None)
extra_body = normalized.get("extra_body")
if isinstance(extra_body, dict) and "prompt_cache_key" in extra_body:
bounded = _bounded_prompt_cache_key(extra_body["prompt_cache_key"])
if bounded:
extra_body["prompt_cache_key"] = bounded
else:
extra_body.pop("prompt_cache_key", None)
return normalized
def map_finish_reason(self, raw_reason: str) -> str:
"""Map Codex response.status to OpenAI finish_reason.
@@ -505,20 +505,6 @@ class CodexAppServerSession:
pending = self._client.take_notification(timeout=0)
if pending is None:
break
# Mirror the main notification-handling block below so
# display events surface and stay in step with projector
# state. Without this, item/started / item/completed
# events drained as part of the approval-roundtrip
# preamble are projected into messages but never reach
# the tool-progress display, silently hiding tool
# bubbles around approvals.
if self._on_event is not None:
try:
self._on_event(pending)
except Exception: # pragma: no cover - display callback
logger.debug(
"on_event callback raised", exc_info=True
)
_apply_token_usage_notification(result, pending)
_apply_compaction_notification(result, pending)
self._track_pending_file_change(pending)
+10 -12
View File
@@ -151,17 +151,7 @@ def build_turn_context(
# null; rebuilding from scratch" warning and a needless first-turn prefix
# cache miss. (Issue #45499.)
# Tag log records on this thread with the session ID for ``hermes logs``.
set_session_context(agent.session_id)
# Bind the skill write-origin ContextVar for this thread.
set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool"))
# Restore the primary runtime if the previous turn activated fallback.
agent._restore_primary_runtime()
# Tell auxiliary_client what the live main provider/model are for this turn
# after primary restoration has settled the runtime.
# Tell auxiliary_client what the live main provider/model are for this turn.
try:
from agent.auxiliary_client import set_runtime_main
set_runtime_main(
@@ -170,11 +160,19 @@ def build_turn_context(
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
auth_mode=getattr(agent, "auth_mode", "") or "",
)
except Exception:
pass
# Tag log records on this thread with the session ID for ``hermes logs``.
set_session_context(agent.session_id)
# Bind the skill write-origin ContextVar for this thread.
set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool"))
# Restore the primary runtime if the previous turn activated fallback.
agent._restore_primary_runtime()
# Between-turns MCP refresh: an MCP server that finished connecting since
# the previous turn (slow HTTP/OAuth servers routinely take 2-6s on a cold
# connect, missing the bounded startup wait) lands in THIS turn's tool
@@ -45,15 +45,6 @@ export const PROVIDER_GROUPS: ProviderPrefix[] = [
docsUrl: 'https://portal.nousresearch.com',
priority: 0
},
{
prefix: 'FIREWORKS_',
name: 'Fireworks AI',
description: 'OpenAI-compatible direct model API',
docsUrl: 'https://app.fireworks.ai/settings/users/api-keys',
// Slot #2 — mirrors CANONICAL_PROVIDERS (after Nous, ahead of OpenRouter).
// Same numeric priority as OpenRouter; name sort puts Fireworks first.
priority: 1
},
{
prefix: 'OPENROUTER_',
name: 'OpenRouter',
@@ -122,7 +122,6 @@ describe('settings helpers', () => {
it('maps a provider env var to its labeled group', () => {
expect(providerGroup('XAI_API_KEY')).toBe('xAI')
expect(providerGroup('NOUS_API_KEY')).toBe('Nous Portal')
expect(providerGroup('FIREWORKS_API_KEY')).toBe('Fireworks AI')
expect(providerGroup('OPENROUTER_API_KEY')).toBe('OpenRouter')
})
@@ -6,8 +6,7 @@ import { runInTerminal } from '@/app/right-sidebar/store'
import {
FEATURED_ID,
FeaturedProviderRow,
FireworksProviderRow,
OpenRouterProviderRow,
KeyProviderRow,
ProviderRow,
providerTitle,
sortProviders
@@ -115,12 +114,11 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
// Deliberately a near-1:1 replica of the first-run onboarding picker
// (`Picker` in desktop-onboarding-overlay): same recommended card, same
// Fireworks #2 quick-key row, same provider rows, same "Other providers"
// disclosure, same OpenRouter quick-key row, and the same bottom-right
// "I have an API key" affordance. The leaf cards are the exact shared
// components, so the two surfaces stay visually identical. Selecting a
// provider hands off to the shared onboarding overlay, which runs that
// provider's real sign-in flow; the key affordances open the API-key
// provider rows, same "Other providers" disclosure, same OpenRouter quick-key
// row, and the same bottom-right "I have an API key" affordance. The leaf cards
// are the exact shared components, so the two surfaces stay visually identical.
// Selecting a provider hands off to the shared onboarding overlay, which runs
// that provider's real sign-in flow; the key affordances open the API-key
// catalog below.
function OAuthPicker({
disconnecting,
@@ -174,8 +172,6 @@ function OAuthPicker({
{p.intro}
</p>
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
{/* Slot #2 — always visible, matching onboarding / CANONICAL_PROVIDERS. */}
<FireworksProviderRow onClick={onWantApiKey} />
{connected.length > 0 && (
<>
<GroupLabel>{p.connected}</GroupLabel>
@@ -197,7 +193,7 @@ function OAuthPicker({
{others.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
<OpenRouterProviderRow onClick={onWantApiKey} />
<KeyProviderRow onClick={onWantApiKey} />
</>
)}
{collapsible && (
@@ -62,9 +62,6 @@ describe('onboarding Picker', () => {
expect(screen.getByText('Nous Portal')).toBeTruthy()
expect(screen.getByText('Recommended')).toBeTruthy()
// Fireworks is the always-visible #2 slot (after Nous), even while OAuth
// alternatives stay collapsed behind the disclosure.
expect(screen.getByText('Fireworks AI')).toBeTruthy()
expect(screen.queryByText('Anthropic API Key')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Other providers' }))
@@ -73,32 +70,10 @@ describe('onboarding Picker', () => {
expect(screen.getByRole('button', { name: 'Collapse' })).toBeTruthy()
})
it('shows Fireworks in slot #2 ahead of other OAuth providers', () => {
setProviders([
provider('openai-codex', 'OpenAI Codex / ChatGPT'),
provider('minimax-oauth', 'MiniMax'),
provider('nous', 'Nous Portal')
])
render(<Picker ctx={ctx} />)
fireEvent.click(screen.getByRole('button', { name: 'Other providers' }))
const labels = screen
.getAllByRole('button')
.map(el => el.textContent ?? '')
.filter(text => /Nous Portal|Fireworks AI|OpenAI OAuth|MiniMax|OpenRouter/.test(text))
const indexOf = (needle: string) => labels.findIndex(text => text.includes(needle))
expect(indexOf('Nous Portal')).toBeGreaterThanOrEqual(0)
expect(indexOf('Fireworks AI')).toBeGreaterThan(indexOf('Nous Portal'))
expect(indexOf('OpenAI OAuth')).toBeGreaterThan(indexOf('Fireworks AI'))
expect(indexOf('MiniMax')).toBeGreaterThan(indexOf('OpenAI OAuth'))
})
it('shows every provider directly when Nous Portal is absent', () => {
setProviders([provider('anthropic', 'Anthropic Claude'), provider('openai-codex', 'OpenAI Codex / ChatGPT')])
render(<Picker ctx={ctx} />)
expect(screen.getByText('Fireworks AI')).toBeTruthy()
expect(screen.getByText('Anthropic API Key')).toBeTruthy()
expect(screen.getByText('OpenAI OAuth (ChatGPT)')).toBeTruthy()
expect(screen.queryByText('Other sign-in options')).toBeNull()
@@ -28,23 +28,9 @@ import {
import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes'
import { DocsLink, FlowPanel, Status } from './flow'
import {
FeaturedProviderRow,
FireworksProviderRow,
OpenRouterProviderRow,
ProviderRow,
sortProviders
} from './providers'
import { FeaturedProviderRow, KeyProviderRow, ProviderRow, sortProviders } from './providers'
export {
FeaturedProviderRow,
FireworksProviderRow,
KeyProviderRow,
OpenRouterProviderRow,
ProviderRow,
providerTitle,
sortProviders
} from './providers'
export { FeaturedProviderRow, KeyProviderRow, ProviderRow, providerTitle, sortProviders } from './providers'
interface DesktopOnboardingOverlayProps {
enabled: boolean
@@ -62,21 +48,19 @@ export interface ApiKeyOption {
short?: string
}
// Curated order mirrors CANONICAL_PROVIDERS: Fireworks sits #2 overall (after
// Nous Portal OAuth), ahead of OpenRouter and the rest of the key catalog.
const API_KEY_OPTIONS: ApiKeyOption[] = [
{
id: 'fireworks',
name: 'Fireworks AI',
envKey: 'FIREWORKS_API_KEY',
docsUrl: 'https://app.fireworks.ai/settings/users/api-keys'
},
{
id: 'openrouter',
name: 'OpenRouter',
envKey: 'OPENROUTER_API_KEY',
docsUrl: 'https://openrouter.ai/keys'
},
{
id: 'fireworks',
name: 'Fireworks AI',
envKey: 'FIREWORKS_API_KEY',
docsUrl: 'https://app.fireworks.ai/settings/users/api-keys'
},
{
id: 'openai',
name: 'OpenAI',
@@ -469,14 +453,12 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
<div className="grid gap-2">
<div className="grid max-h-[60dvh] gap-2 overflow-y-auto p-1">
{featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null}
{/* Slot #2 — always visible, matching CANONICAL_PROVIDERS (Nous → Fireworks). */}
<FireworksProviderRow onClick={() => openKeyForm('FIREWORKS_API_KEY')} />
{showRest ? (
<>
{rest.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
<OpenRouterProviderRow onClick={() => openKeyForm('OPENROUTER_API_KEY')} />
<KeyProviderRow onClick={() => openKeyForm('OPENROUTER_API_KEY')} />
</>
) : null}
</div>
@@ -76,31 +76,20 @@ function ConnectedTag() {
const PROVIDER_ROW_CLASS =
'group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)'
/** Quick-key row for API-key providers (Fireworks #2 after Nous, OpenRouter further down). */
export function KeyProviderRow({ onClick, pitch, title }: { onClick: () => void; pitch: string; title: string }) {
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
const { t } = useI18n()
return (
<RowButton className={PROVIDER_ROW_CLASS} onClick={onClick}>
<div className="min-w-0">
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{pitch}</p>
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.openRouterPitch}</p>
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
</RowButton>
)
}
export function FireworksProviderRow({ onClick }: { onClick: () => void }) {
const { t } = useI18n()
return <KeyProviderRow onClick={onClick} pitch={t.onboarding.fireworksPitch} title="Fireworks AI" />
}
export function OpenRouterProviderRow({ onClick }: { onClick: () => void }) {
const { t } = useI18n()
return <KeyProviderRow onClick={onClick} pitch={t.onboarding.openRouterPitch} title="OpenRouter" />
}
export function ProviderRow({
onSelect,
provider
-1
View File
@@ -1971,7 +1971,6 @@ export const en: Translations = {
recommended: 'Recommended',
connected: 'Connected',
featuredPitch: 'One subscription, 300+ frontier models — the recommended way to run Hermes',
fireworksPitch: 'Direct model API — Fireworks-hosted frontier models',
openRouterPitch: 'One key, hundreds of models — a solid default',
apiKeyOptions: {
fireworks: {
-1
View File
@@ -1890,7 +1890,6 @@ export const ja = defineLocale({
recommended: '推奨',
connected: '接続済み',
featuredPitch: '1 つのサブスクリプションで 300 以上の最先端モデル — Hermes を実行するための推奨方法',
fireworksPitch: '直接モデル API — Fireworks がホストする最先端モデル',
openRouterPitch: '1 つのキーで数百のモデル — 堅実なデフォルト',
apiKeyOptions: {
fireworks: {
-1
View File
@@ -1622,7 +1622,6 @@ export interface Translations {
recommended: string
connected: string
featuredPitch: string
fireworksPitch: string
openRouterPitch: string
apiKeyOptions: Record<string, { short: string; description: string }>
backToSignIn: string
-1
View File
@@ -1834,7 +1834,6 @@ export const zhHant = defineLocale({
recommended: '建議',
connected: '已連線',
featuredPitch: '一個訂閱,300+ 前沿模型 — 執行 Hermes 的建議方式',
fireworksPitch: '直接模型 API — Fireworks 託管的前沿模型',
openRouterPitch: '一個金鑰,數百個模型 — 穩定的預設選擇',
apiKeyOptions: {
fireworks: { short: '直接模型 API', description: '直接存取 Fireworks AI 託管的模型。' },
-1
View File
@@ -2144,7 +2144,6 @@ export const zh: Translations = {
recommended: '推荐',
connected: '已连接',
featuredPitch: '一个订阅,300+ 前沿模型 — 运行 Hermes 的推荐方式',
fireworksPitch: '直接模型 API — Fireworks 托管的前沿模型',
openRouterPitch: '一个密钥,数百个模型 — 稳妥的默认选择',
apiKeyOptions: {
fireworks: { short: '直接模型 API', description: '直接访问 Fireworks AI 托管的模型。' },
@@ -9,29 +9,20 @@ describe('completeMcpDesktopOAuth', () => {
const status = vi
.fn()
.mockResolvedValueOnce({
flow_id: 'flow-1',
server_name: 'reports',
status: 'authorization_required',
authorization_url: 'https://idp.example/authorize',
error: null
flow_id: 'flow-1', server_name: 'reports', status: 'authorization_required',
authorization_url: 'https://idp.example/authorize', error: null
})
.mockResolvedValueOnce({
flow_id: 'flow-1',
server_name: 'reports',
status: 'approved',
authorization_url: 'https://idp.example/authorize',
error: null,
flow_id: 'flow-1', server_name: 'reports', status: 'approved',
authorization_url: 'https://idp.example/authorize', error: null,
tools: [{ name: 'list_reports', description: 'List reports' }]
})
const result = await completeMcpDesktopOAuth({
serverName: 'reports',
start: vi.fn().mockResolvedValue({
flow_id: 'flow-1',
server_name: 'reports',
status: 'authorization_required',
authorization_url: 'https://idp.example/authorize',
error: null
flow_id: 'flow-1', server_name: 'reports', status: 'authorization_required',
authorization_url: 'https://idp.example/authorize', error: null
}),
status,
openExternal,
@@ -43,23 +34,19 @@ describe('completeMcpDesktopOAuth', () => {
})
it('retries a transient status failure', async () => {
const status = vi.fn().mockRejectedValueOnce(new Error('temporary network failure')).mockResolvedValueOnce({
flow_id: 'flow-2',
server_name: 'reports',
status: 'approved',
authorization_url: 'https://idp.example/authorize',
error: null,
tools: []
})
const status = vi
.fn()
.mockRejectedValueOnce(new Error('temporary network failure'))
.mockResolvedValueOnce({
flow_id: 'flow-2', server_name: 'reports', status: 'approved',
authorization_url: 'https://idp.example/authorize', error: null, tools: []
})
const result = await completeMcpDesktopOAuth({
serverName: 'reports',
start: vi.fn().mockResolvedValue({
flow_id: 'flow-2',
server_name: 'reports',
status: 'authorization_required',
authorization_url: 'https://idp.example/authorize',
error: null
flow_id: 'flow-2', server_name: 'reports', status: 'authorization_required',
authorization_url: 'https://idp.example/authorize', error: null
}),
status,
openExternal: vi.fn().mockResolvedValue(undefined),
@@ -69,4 +56,4 @@ describe('completeMcpDesktopOAuth', () => {
expect(result.status).toBe('approved')
expect(status).toHaveBeenCalledTimes(2)
})
})
})
+3 -2
View File
@@ -16,7 +16,8 @@ interface CompleteOptions {
maxPollFailures?: number
}
const defaultSleep = (milliseconds: number) => new Promise<void>(resolve => window.setTimeout(resolve, milliseconds))
const defaultSleep = (milliseconds: number) =>
new Promise<void>(resolve => window.setTimeout(resolve, milliseconds))
export async function completeMcpDesktopOAuth({
serverName,
@@ -68,4 +69,4 @@ export async function completeMcpDesktopOAuth({
await sleep(1000)
}
}
}
+24 -24
View File
@@ -536,22 +536,22 @@ class BatchRunner:
run_name: str,
distribution: str = "default",
max_iterations: int = 10,
base_url: str | None = None,
api_key: str | None = None,
base_url: str = None,
api_key: str = None,
model: str = "claude-opus-4-20250514",
num_workers: int = 4,
verbose: bool = False,
ephemeral_system_prompt: str | None = None,
ephemeral_system_prompt: str = None,
log_prefix_chars: int = 100,
providers_allowed: List[str] | None = None,
providers_ignored: List[str] | None = None,
providers_order: List[str] | None = None,
provider_sort: str | None = None,
providers_allowed: List[str] = None,
providers_ignored: List[str] = None,
providers_order: List[str] = None,
provider_sort: str = None,
openrouter_min_coding_score: Optional[float] = None,
max_tokens: int | None = None,
reasoning_config: Dict[str, Any] | None = None,
prefill_messages: List[Dict[str, Any]] | None = None,
max_samples: int | None = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
prefill_messages: List[Dict[str, Any]] = None,
max_samples: int = None,
):
"""
Initialize the batch runner.
@@ -1145,29 +1145,29 @@ class BatchRunner:
def main(
dataset_file: str | None = None,
batch_size: int | None = None,
run_name: str | None = None,
dataset_file: str = None,
batch_size: int = None,
run_name: str = None,
distribution: str = "default",
model: str = "anthropic/claude-sonnet-4.6",
api_key: str | None = None,
api_key: str = None,
base_url: str = "https://openrouter.ai/api/v1",
max_turns: int = 10,
num_workers: int = 4,
resume: bool = False,
verbose: bool = False,
list_distributions: bool = False,
ephemeral_system_prompt: str | None = None,
ephemeral_system_prompt: str = None,
log_prefix_chars: int = 100,
providers_allowed: str | None = None,
providers_ignored: str | None = None,
providers_order: str | None = None,
provider_sort: str | None = None,
max_tokens: int | None = None,
reasoning_effort: str | None = None,
providers_allowed: str = None,
providers_ignored: str = None,
providers_order: str = None,
provider_sort: str = None,
max_tokens: int = None,
reasoning_effort: str = None,
reasoning_disabled: bool = False,
prefill_messages_file: str | None = None,
max_samples: int | None = None,
prefill_messages_file: str = None,
max_samples: int = None,
):
"""
Run batch processing of agent prompts from a dataset.
+18 -73
View File
@@ -1056,50 +1056,6 @@ def _arm_exit_watchdog(timeout_s: float | None = None) -> None:
pass # best-effort — never block shutdown on watchdog setup
_signal_watchdog_armed = False
def _arm_exit_watchdog_on_shutdown_signal() -> None:
"""Arm the exit backstop the moment a termination signal arrives.
SIGTERM/SIGHUP establish unambiguous shutdown intent, but the graceful
path from signal → ``agent.interrupt()`` → ``app.exit()`` /
``KeyboardInterrupt`` → ``finally`` → ``_run_cleanup`` has several wedge
points BEFORE ``_run_cleanup`` arms the normal watchdog: a main thread
parked in a syscall that never observes the unwind, a prompt_toolkit
teardown that never returns, or an agent worker blocking the ``finally``.
When that happens the process has NO backstop and a "dead" CLI lingers
(observed: ``hermes --tui`` alive ~47 min at 4% CPU after terminal close —
the #65998 class).
Arming at signal time closes that window. The leash is 2× the normal
cleanup timeout so a slow-but-progressing ``_run_cleanup`` (which arms
its own tighter timer when it starts) is never cut short by this outer
backstop — this timer only wins when cleanup was never reached at all.
Deliberately NOT armed at chat startup: the watchdog thread calls
``os._exit(0)`` unconditionally after its sleep, so arming without
shutdown intent would hard-kill every session that outlives the timeout.
Idempotent (module flag) so repeated signals don't stack timer threads.
Never raises — safe to call from a signal handler.
"""
global _signal_watchdog_armed
if _signal_watchdog_armed:
return
_signal_watchdog_armed = True
try:
base = float(os.getenv("HERMES_EXIT_WATCHDOG_S", "30"))
except (TypeError, ValueError):
base = 30.0
if base <= 0:
return # explicitly disabled
try:
_arm_exit_watchdog(timeout_s=base * 2)
except Exception:
pass # never let the backstop break signal handling
def _run_cleanup(*, notify_session_finalize: bool = True):
"""Run resource cleanup exactly once."""
global _cleanup_done
@@ -3708,15 +3664,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
def __init__(
self,
model: str | None = None,
toolsets: List[str] | None = None,
provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
max_turns: int | None = None,
model: str = None,
toolsets: List[str] = None,
provider: str = None,
api_key: str = None,
base_url: str = None,
max_turns: int = None,
verbose: Optional[bool] = None,
compact: bool = False,
resume: str | None = None,
resume: str = None,
checkpoints: bool = False,
pass_session_id: bool = False,
ignore_rules: bool = False,
@@ -15618,13 +15574,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
logger.debug("Received signal %s, triggering graceful shutdown", signum)
except Exception:
pass # never let logging raise from a signal handler (#13710 regression)
# Shutdown intent is now unambiguous — arm the exit backstop
# IMMEDIATELY, before the graceful unwind below. If any step of
# that unwind wedges (main thread parked in a syscall, prompt_toolkit
# teardown never returning), _run_cleanup never runs and would
# never arm its own watchdog — leaving a "dead" CLI alive for
# minutes (#65998 class). Never raises.
_arm_exit_watchdog_on_shutdown_signal()
try:
if getattr(self, "agent", None) and getattr(self, "_agent_running", False):
self.agent.interrupt(f"received signal {signum}")
@@ -15999,23 +15948,23 @@ def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None:
def main(
query: str | None = None,
q: str | None = None,
image: str | None = None,
toolsets: str | None = None,
skills: str | list[str] | tuple[str, ...] | None = None,
model: str | None = None,
provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
max_turns: int | None = None,
query: str = None,
q: str = None,
image: str = None,
toolsets: str = None,
skills: str | list[str] | tuple[str, ...] = None,
model: str = None,
provider: str = None,
api_key: str = None,
base_url: str = None,
max_turns: int = None,
verbose: Optional[bool] = None,
quiet: bool = False,
compact: bool = False,
list_tools: bool = False,
list_toolsets: bool = False,
gateway: bool = False,
resume: str | None = None,
resume: str = None,
worktree: bool = False,
w: bool = False,
checkpoints: bool = False,
@@ -16229,10 +16178,6 @@ def main(
# default for debugging.
def _signal_handler_q(signum, frame):
logger.debug("Received signal %s in single-query mode", signum)
# Arm the exit backstop now that shutdown intent is unambiguous —
# covers wedges in the unwind below that would otherwise leave the
# process alive with no watchdog (#65998 class). Never raises.
_arm_exit_watchdog_on_shutdown_signal()
try:
_agent = getattr(cli, "agent", None)
if _agent is not None:
+2 -2
View File
@@ -2891,8 +2891,8 @@ class APIServerAdapter(BasePlatformAdapter):
async def _write_sse_chat_completion(
self, request: "web.Request", completion_id: str, model: str,
created: int, stream_q, agent_task, agent_ref | None = None, session_id: str = None,
gateway_session_key: str | None = None,
created: int, stream_q, agent_task, agent_ref=None, session_id: str = None,
gateway_session_key: str = None,
) -> "web.StreamResponse":
"""Write real streaming SSE from agent's stream_delta_callback queue.
+9 -29
View File
@@ -219,7 +219,7 @@ def _gateway_platform_value(platform: Any) -> str:
def _non_conversational_metadata(
metadata: Optional[Dict[str, Any]] = None,
*,
platform: Any | None = None,
platform: Any = None,
) -> Optional[Dict[str, Any]]:
"""Mark Discord lifecycle/status sends without changing other platforms."""
if _gateway_platform_value(platform) != "discord":
@@ -542,7 +542,7 @@ def _resolve_gateway_display_bool(
setting: str,
*,
default: bool = False,
platform: Any | None = None,
platform: Any = None,
require_platform_override_for: set[Any] | None = None,
) -> bool:
"""Resolve a boolean display setting with optional platform-only opt-in.
@@ -10860,30 +10860,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Image routing: text (mode=%s). Pre-analyzing %d image(s) via vision_analyze.",
_img_mode, len(image_paths),
)
# Vision enrichment runs before AIAgent.run_conversation(),
# so bind this session's resolved runtime explicitly rather
# than consulting process-global compatibility mirrors.
vision_runtime = None
try:
turn_model, runtime_kwargs = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
)
vision_runtime = dict(runtime_kwargs or {})
vision_runtime["model"] = turn_model
except Exception:
logger.debug(
"vision enrichment: session runtime resolution failed",
exc_info=True,
)
from agent.auxiliary_client import scoped_runtime_main
with scoped_runtime_main(vision_runtime):
message_text = await self._enrich_message_with_vision(
message_text,
image_paths,
)
message_text = await self._enrich_message_with_vision(
message_text,
image_paths,
)
if audio_paths:
message_text, _successful_transcripts = await self._enrich_message_with_transcription(
@@ -17311,7 +17291,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
history: List[Dict[str, Any]],
source: "SessionSource",
session_id: str,
session_key: str | None = None,
session_key: str = None,
run_generation: Optional[int] = None,
event_message_id: Optional[str] = None,
) -> Dict[str, Any]:
@@ -17610,7 +17590,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
history: List[Dict[str, Any]],
source: SessionSource,
session_id: str,
session_key: str | None = None,
session_key: str = None,
run_generation: Optional[int] = None,
_interrupt_depth: int = 0,
event_message_id: Optional[str] = None,
@@ -17760,7 +17740,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
history: List[Dict[str, Any]],
source: SessionSource,
session_id: str,
session_key: str | None = None,
session_key: str = None,
run_generation: Optional[int] = None,
_interrupt_depth: int = 0,
event_message_id: Optional[str] = None,
+23 -138
View File
@@ -1028,10 +1028,6 @@ class SessionStore:
self._persisted_routing_generation = 0
self._inflight_lock = threading.Lock()
self._inflight_sessions: Dict[str, _SessionFlight] = {}
self._transcript_retry_lock = threading.Lock()
self._dirty_transcripts: Dict[str, List[Dict[str, Any]]] = {}
self._transcript_append_failures: Dict[str, int] = {}
self._fts_rebuild_attempted = False
self._has_active_processes_fn = has_active_processes_fn
# Whether to keep writing the legacy sessions.json mirror alongside
# the primary gateway_routing table in state.db. Default True for
@@ -2509,140 +2505,31 @@ class SessionStore:
_flush_messages_to_session_db(), preventing the
duplicate-write bug (#860).
"""
if not self._db or skip_db:
return
with self._transcript_retry_lock:
pending = self._dirty_transcripts.setdefault(session_id, [])
pending.append(dict(message))
# Cap pending messages per session to avoid unbounded memory
# growth when the DB is persistently broken. Drop the oldest.
if len(pending) > self._MAX_PENDING_PER_SESSION:
dropped = pending.pop(0)
logger.warning(
"Session DB transcript pending queue full for %s "
"(cap=%d); dropping oldest message to make room",
session_id, self._MAX_PENDING_PER_SESSION,
)
# Snapshot the first pending message, then release the lock
# before the DB write so other sessions are not blocked.
msg = pending[0]
# DB write outside the retry lock — other sessions can append
# concurrently. We re-acquire the lock only to update the queue.
while True:
if self._db and not skip_db:
try:
self._append_transcript_message(session_id, msg)
except Exception as exc:
if self._is_fts_corruption_error(exc) and self._rebuild_fts_once():
try:
self._append_transcript_message(session_id, msg)
except Exception as retry_exc:
exc = retry_exc
else:
with self._transcript_retry_lock:
if pending and pending[0] is msg:
pending.pop(0)
if not pending:
self._dirty_transcripts.pop(session_id, None)
self._transcript_append_failures.pop(session_id, None)
continue
with self._transcript_retry_lock:
failures = self._transcript_append_failures.get(session_id, 0) + 1
self._transcript_append_failures[session_id] = failures
logger.warning(
"Session DB transcript append failed for %s "
"(failure_count=%d, pending=%d); will retry: %s",
session_id, failures, len(pending), exc,
self._db.append_message(
session_id=session_id,
role=message.get("role", "unknown"),
content=message.get("content"),
tool_name=message.get("tool_name"),
tool_calls=message.get("tool_calls"),
tool_call_id=message.get("tool_call_id"),
reasoning=message.get("reasoning") if message.get("role") == "assistant" else None,
reasoning_content=message.get("reasoning_content") if message.get("role") == "assistant" else None,
reasoning_details=message.get("reasoning_details") if message.get("role") == "assistant" else None,
codex_reasoning_items=message.get("codex_reasoning_items") if message.get("role") == "assistant" else None,
codex_message_items=message.get("codex_message_items") if message.get("role") == "assistant" else None,
# Platform-side message id (yuanbao msg_id, telegram update_id, …).
# Accept either explicit ``platform_message_id`` or the legacy
# ``message_id`` key the JSONL transcript used.
platform_message_id=(
message.get("platform_message_id") or message.get("message_id")
),
observed=bool(message.get("observed")),
timestamp=message.get("timestamp"),
)
return
else:
with self._transcript_retry_lock:
if pending and pending[0] is msg:
pending.pop(0)
if not pending:
self._dirty_transcripts.pop(session_id, None)
self._transcript_append_failures.pop(session_id, None)
return
msg = pending[0]
continue
def _append_transcript_message(self, session_id: str, message: Dict[str, Any]) -> None:
"""Write one transcript row. Caller handles retry queuing."""
self._db.append_message(
session_id=session_id,
role=message.get("role", "unknown"),
content=message.get("content"),
tool_name=message.get("tool_name"),
tool_calls=message.get("tool_calls"),
tool_call_id=message.get("tool_call_id"),
reasoning=message.get("reasoning") if message.get("role") == "assistant" else None,
reasoning_content=message.get("reasoning_content") if message.get("role") == "assistant" else None,
reasoning_details=message.get("reasoning_details") if message.get("role") == "assistant" else None,
codex_reasoning_items=message.get("codex_reasoning_items") if message.get("role") == "assistant" else None,
codex_message_items=message.get("codex_message_items") if message.get("role") == "assistant" else None,
platform_message_id=(message.get("platform_message_id") or message.get("message_id")),
observed=bool(message.get("observed")),
timestamp=message.get("timestamp"),
)
# Maximum in-memory pending messages per session before dropping the
# oldest. Prevents unbounded growth when the DB is persistently broken.
_MAX_PENDING_PER_SESSION = 200
@staticmethod
def _is_fts_corruption_error(exc: Exception) -> bool:
"""True if *exc* looks like an FTS index corruption error.
Matches the specific SQLite error strings for malformed disk images
and FTS table corruption — not bare ``"fts"`` substrings which match
unrelated words like ``"shifts"`` or ``"gifts"``.
"""
text = str(exc).lower()
return any(
marker in text
for marker in (
"database disk image is malformed",
"malformed database schema",
"messages_fts",
"no such table: messages_fts",
)
)
def _rebuild_fts_once(self) -> bool:
"""Attempt FTS5 ``rebuild`` command once per store lifetime.
Delegates to ``SessionDB.rebuild_fts()`` which handles locking and
table-existence checks internally. Returns ``True`` when at least
one index was rebuilt.
"""
if self._fts_rebuild_attempted:
return False
self._fts_rebuild_attempted = True
db = self._db
if db is None or not hasattr(db, "rebuild_fts"):
return False
try:
rebuilt = db.rebuild_fts()
except Exception as exc:
logger.warning("Session DB FTS rebuild failed: %s", exc)
return False
if rebuilt:
logger.warning(
"Rebuilt %d Session DB FTS index(es) after append corruption",
rebuilt,
)
return rebuilt > 0
def _clear_dirty_transcript(self, session_id: str) -> None:
"""Drop queued pending messages for a session.
Called by ``rewrite_transcript`` and ``rewind_session`` so that
/retry, /undo, /compress — which replace or truncate the transcript —
don't leave stale messages that would be re-inserted on the next
append.
"""
with self._transcript_retry_lock:
self._dirty_transcripts.pop(session_id, None)
self._transcript_append_failures.pop(session_id, None)
except Exception as e:
logger.debug("Session DB operation failed: %s", e)
def has_platform_message_id(
self, session_id: str, platform_message_id: str
@@ -2678,7 +2565,6 @@ class SessionStore:
"""
if not self._db:
return True
self._clear_dirty_transcript(session_id)
try:
self._db.replace_messages(session_id, messages)
return True
@@ -2721,7 +2607,6 @@ class SessionStore:
"""
if not self._db:
return None
self._clear_dirty_transcript(session_id)
if n < 1:
n = 1
try:
+6 -6
View File
@@ -2132,8 +2132,8 @@ def _scope_values(raw_scope: Any) -> set[str]:
def _nous_invoke_jwt_status(
token: Any,
*,
scope: Any | None = None,
expires_at: Any | None = None,
scope: Any = None,
expires_at: Any = None,
min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS,
) -> Optional[str]:
"""Return None when the token can be used for inference, else a reason."""
@@ -2161,8 +2161,8 @@ def _nous_invoke_jwt_status(
def _nous_invoke_jwt_is_usable(
token: Any,
*,
scope: Any | None = None,
expires_at: Any | None = None,
scope: Any = None,
expires_at: Any = None,
min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS,
) -> bool:
return (
@@ -2179,7 +2179,7 @@ def _nous_invoke_jwt_is_usable(
def _assert_nous_inference_jwt_usable(
state: Dict[str, Any],
*,
access_token: Any | None = None,
access_token: Any = None,
) -> None:
token = state.get("access_token") if access_token is None else access_token
reason = _nous_invoke_jwt_status(
@@ -2263,7 +2263,7 @@ def _set_nous_agent_key_from_invoke_jwt(
def _select_nous_invoke_jwt(
state: Dict[str, Any],
*,
access_token: Any | None = None,
access_token: Any = None,
sequence_id: Optional[str] = None,
) -> None:
if isinstance(access_token, str) and access_token.strip():
+5 -5
View File
@@ -578,12 +578,12 @@ def _display_toolset_name(toolset_name: str) -> str:
def build_welcome_banner(console: "Console", model: str, cwd: str,
tools: List[dict] | None = None,
enabled_toolsets: List[str] | None = None,
session_id: str | None = None,
tools: List[dict] = None,
enabled_toolsets: List[str] = None,
session_id: str = None,
get_toolset_for_tool=None,
context_length: int | None = None,
provider: str | None = None):
context_length: int = None,
provider: str = None):
"""Build and print a welcome banner with caduceus on left and info on right.
Args:
+2 -8
View File
@@ -770,14 +770,8 @@ class CLICommandsMixin:
self._pending_title = None
_sync_process_session_id(target_id)
# Load conversation history (strip transcript-only metadata entries).
# repair_alternation: this /resume feeds LIVE REPLAY — ``restored``
# becomes ``self.conversation_history`` for subsequent turns. Heal a
# durable ``user;user`` violation once here instead of re-firing the
# pre-request repair on every request for the rest of the session.
restored = self._session_db.get_messages_as_conversation(
target_id, repair_alternation=True
)
# Load conversation history (strip transcript-only metadata entries)
restored = self._session_db.get_messages_as_conversation(target_id)
restored = [m for m in (restored or []) if m.get("role") != "session_meta"]
self.conversation_history = restored
+3 -3
View File
@@ -3391,7 +3391,7 @@ def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool:
def recompute_ready(
conn: sqlite3.Connection, failure_limit: int | None = None,
conn: sqlite3.Connection, failure_limit: int = None,
) -> int:
"""Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.
@@ -7025,7 +7025,7 @@ def _record_task_failure(
error: str,
*,
outcome: str,
failure_limit: int | None = None,
failure_limit: int = None,
force_trip: bool = False,
release_claim: bool = False,
end_run: bool = False,
@@ -7190,7 +7190,7 @@ def _record_spawn_failure(
task_id: str,
error: str,
*,
failure_limit: int | None = None,
failure_limit: int = None,
) -> bool:
return _record_task_failure(
conn, task_id, error,
+2 -2
View File
@@ -2008,8 +2008,8 @@ def _launch_tui(
tui_dev: bool = False,
model: Optional[str] = None,
provider: Optional[str] = None,
toolsets: object | None = None,
skills: object | None = None,
toolsets: object = None,
skills: object = None,
verbose: Optional[bool] = None,
quiet: bool = False,
query: Optional[str] = None,
+7 -1
View File
@@ -295,7 +295,13 @@ def resolve_moa_preset(config: Any, name: str | None = None) -> dict[str, Any]:
preset_name = str(name or cfg.get("default_preset") or DEFAULT_MOA_PRESET_NAME).strip()
preset = cfg["presets"].get(preset_name)
if preset is None:
raise KeyError(preset_name)
from agent.errors import MoAPresetNotFoundError
available = ", ".join(cfg["presets"]) or "(none)"
raise MoAPresetNotFoundError(
f"MoA preset '{preset_name}' was not found. Available presets: "
f"{available}. Run `hermes moa list`."
)
return deepcopy(preset)
+4 -4
View File
@@ -638,7 +638,7 @@ def resolve_alias(
def get_authenticated_provider_slugs(
current_provider: str = "",
user_providers: dict | None = None,
user_providers: dict = None,
custom_providers: list | None = None,
) -> list[str]:
"""Return slugs of providers that have credentials.
@@ -801,7 +801,7 @@ def switch_model(
current_api_key: str = "",
is_global: bool = False,
explicit_provider: str = "",
user_providers: dict | None = None,
user_providers: dict = None,
custom_providers: list | None = None,
) -> ModelSwitchResult:
"""Core model-switching pipeline shared between CLI and gateway.
@@ -1471,7 +1471,7 @@ def prewarm_picker_cache_async() -> Optional["_threading.Thread"]:
def list_authenticated_providers(
current_provider: str = "",
current_base_url: str = "",
user_providers: dict | None = None,
user_providers: dict = None,
custom_providers: list | None = None,
*,
force_fresh_nous_tier: bool = False,
@@ -2438,7 +2438,7 @@ def _prepend_moa_picker_provider(providers: List[dict], current_provider: str =
def list_picker_providers(
current_provider: str = "",
current_base_url: str = "",
user_providers: dict | None = None,
user_providers: dict = None,
custom_providers: list | None = None,
max_models: int | None = None,
current_model: str = "",
+5 -5
View File
@@ -4042,9 +4042,9 @@ def get_sessions(
min_messages: int = 0,
archived: str = "exclude",
order: str = "created",
source: str | None = None,
exclude_sources: str | None = None,
cwd_prefix: str | None = None,
source: str = None,
exclude_sources: str = None,
cwd_prefix: str = None,
full: bool = False,
profile: Optional[str] = None,
):
@@ -4142,8 +4142,8 @@ def get_profiles_sessions(
archived: str = "exclude",
order: str = "recent",
profile: str = "all",
source: str | None = None,
exclude_sources: str | None = None,
source: str = None,
exclude_sources: str = None,
full: bool = False,
):
"""Unified, read-only session list aggregated across ALL profiles.
+49 -164
View File
@@ -1016,12 +1016,6 @@ class SessionDB:
self._lock = threading.Lock()
self._write_count = 0
# One-shot guard for the runtime FTS rebuild recovery on the write
# path. A corrupt FTS shadow table makes EVERY message write raise
# the malformed/corrupt error class via the sync triggers; we repair
# in place at most once per SessionDB instance so a genuinely
# unrecoverable database can't put writers into a rebuild loop.
self._fts_runtime_rebuild_attempted = False
self._fts_enabled = False
self._trigram_available = False
self._fts_unavailable_warned = False
@@ -1303,89 +1297,11 @@ class SessionDB:
continue
# Non-lock error or retries exhausted — propagate.
raise
except sqlite3.DatabaseError as exc:
# Corrupt FTS shadow tables make every write raise the
# malformed/corrupt error class through the FTS sync triggers
# while the canonical messages table is intact. The gateway
# session store has its own retry queue for transcript
# appends (#65637 salvage), but cron and CLI writers call
# SessionDB directly — without this, their writes hard-fail
# until the next process restart triggers the offline repair.
# Rebuild the FTS index in place (once per instance) via
# rebuild_fts() and retry the failed write immediately.
if not self._try_runtime_fts_rebuild(exc):
raise
continue
# Retries exhausted (shouldn't normally reach here).
raise last_err or sqlite3.OperationalError(
"database is locked after max retries"
)
@staticmethod
def _is_fts_write_corruption_error(exc: sqlite3.DatabaseError) -> bool:
"""True for the error class a corrupt FTS index raises on writes.
The message varies by SQLite version: older builds raise the generic
``database disk image is malformed`` (covered by
``is_malformed_db_error``); newer builds (e.g. ubuntu-latest CI)
raise the FTS5-specific ``fts5: corrupt structure record for table
"messages_fts"``. Both mean the same thing for the write path: the
canonical rows are fine, the FTS shadow tables are not.
"""
if is_malformed_db_error(exc):
return True
msg = str(exc).lower()
return "fts5" in msg and "corrupt" in msg
def _try_runtime_fts_rebuild(self, exc: sqlite3.DatabaseError) -> bool:
"""One-shot in-place FTS rebuild after a corrupt-index write failure.
Returns True when a rebuild was performed and the failed write should
be retried; False when the error isn't the FTS-corruption class, FTS
is disabled, or a rebuild was already attempted for this instance.
Delegates to :meth:`rebuild_fts` (the FTS5 ``'rebuild'`` command —
index rewritten from the canonical messages table, zero message-row
mutation). Safe to call from ``_execute_write``'s except path: the
failed transaction was rolled back and ``self._lock`` released before
the exception propagated, and ``rebuild_fts`` re-acquires it.
E2E-verified: a corrupted ``messages_fts_data`` shadow table rejects
every append; after the in-place rebuild the same append succeeds and
search works again.
"""
if self._fts_runtime_rebuild_attempted:
return False
if not self._fts_enabled:
return False
if not self._is_fts_write_corruption_error(exc):
return False
self._fts_runtime_rebuild_attempted = True
logger.warning(
"state.db write failed with an FTS-corruption error (%s) — "
"attempting one-shot in-place FTS rebuild; canonical message "
"rows are preserved.", exc,
)
try:
rebuilt = self.rebuild_fts()
except Exception as rebuild_exc:
logger.error(
"In-place FTS rebuild failed (%s); the database needs the "
"full offline repair path (repair_state_db_schema).",
rebuild_exc,
)
return False
if not rebuilt:
logger.error(
"In-place FTS rebuild made no progress; the database needs "
"the full offline repair path (repair_state_db_schema)."
)
return False
logger.warning(
"state.db FTS indexes rebuilt in place (%d); retrying the failed write.",
rebuilt,
)
return True
def _try_wal_checkpoint(self) -> None:
"""Best-effort PASSIVE WAL checkpoint. Never raises.
@@ -1928,17 +1844,17 @@ class SessionDB:
self,
session_id: str,
source: str,
model: str | None = None,
model_config: Dict[str, Any] | None = None,
system_prompt: str | None = None,
user_id: str | None = None,
session_key: str | None = None,
chat_id: str | None = None,
chat_type: str | None = None,
thread_id: str | None = None,
parent_session_id: str | None = None,
cwd: str | None = None,
profile_name: str | None = None,
model: str = None,
model_config: Dict[str, Any] = None,
system_prompt: str = None,
user_id: str = None,
session_key: str = None,
chat_id: str = None,
chat_type: str = None,
thread_id: str = None,
parent_session_id: str = None,
cwd: str = None,
profile_name: str = None,
) -> None:
"""Insert a session row, enriching NULL metadata on conflict.
@@ -2005,13 +1921,13 @@ class SessionDB:
session_id: str,
*,
source: str,
user_id: str | None = None,
session_key: str | None = None,
chat_id: str | None = None,
chat_type: str | None = None,
thread_id: str | None = None,
display_name: str | None = None,
origin_json: str | None = None,
user_id: str = None,
session_key: str = None,
chat_id: str = None,
chat_type: str = None,
thread_id: str = None,
display_name: str = None,
origin_json: str = None,
) -> None:
"""Persist the gateway routing peer for an existing session row.
@@ -2830,7 +2746,7 @@ class SessionDB:
session_id: str,
input_tokens: int = 0,
output_tokens: int = 0,
model: str | None = None,
model: str = None,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
reasoning_tokens: int = 0,
@@ -3100,7 +3016,7 @@ class SessionDB:
self,
session_id: str,
source: str = "unknown",
model: str | None = None,
model: str = None,
**kwargs,
) -> str:
"""Ensure a session row exists (INSERT OR IGNORE). Accepts optional kwargs."""
@@ -3678,9 +3594,9 @@ class SessionDB:
def list_sessions_rich(
self,
source: str | None = None,
exclude_sources: List[str] | None = None,
cwd_prefix: str | None = None,
source: str = None,
exclude_sources: List[str] = None,
cwd_prefix: str = None,
limit: int = 20,
offset: int = 0,
include_children: bool = False,
@@ -3689,8 +3605,8 @@ class SessionDB:
order_by_last_active: bool = False,
include_archived: bool = False,
archived_only: bool = False,
id_query: str | None = None,
search_query: str | None = None,
id_query: str = None,
search_query: str = None,
compact_rows: bool = False,
) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last active timestamp.
@@ -4123,21 +4039,21 @@ class SessionDB:
self,
session_id: str,
role: str,
content: str | None = None,
tool_name: str | None = None,
tool_calls: Any | None = None,
tool_call_id: str | None = None,
token_count: int | None = None,
finish_reason: str | None = None,
reasoning: str | None = None,
reasoning_content: str | None = None,
reasoning_details: Any | None = None,
codex_reasoning_items: Any | None = None,
codex_message_items: Any | None = None,
platform_message_id: str | None = None,
content: str = None,
tool_name: str = None,
tool_calls: Any = None,
tool_call_id: str = None,
token_count: int = None,
finish_reason: str = None,
reasoning: str = None,
reasoning_content: str = None,
reasoning_details: Any = None,
codex_reasoning_items: Any = None,
codex_message_items: Any = None,
platform_message_id: str = None,
observed: bool = False,
effect_disposition: Optional[str] = None,
timestamp: Any | None = None,
timestamp: Any = None,
) -> int:
"""
Append a message to a session. Returns the message row ID.
@@ -5238,12 +5154,12 @@ class SessionDB:
def search_messages(
self,
query: str,
source_filter: List[str] | None = None,
exclude_sources: List[str] | None = None,
role_filter: List[str] | None = None,
source_filter: List[str] = None,
exclude_sources: List[str] = None,
role_filter: List[str] = None,
limit: int = 20,
offset: int = 0,
sort: str | None = None,
sort: str = None,
include_inactive: bool = False,
) -> List[Dict[str, Any]]:
"""
@@ -5605,7 +5521,7 @@ class SessionDB:
def search_sessions(
self,
source: str | None = None,
source: str = None,
limit: int = 20,
offset: int = 0,
) -> List[Dict[str, Any]]:
@@ -5645,13 +5561,13 @@ class SessionDB:
def session_count(
self,
source: str | None = None,
cwd_prefix: str | None = None,
source: str = None,
cwd_prefix: str = None,
min_message_count: int = 0,
include_archived: bool = False,
archived_only: bool = False,
exclude_children: bool = False,
exclude_sources: List[str] | None = None,
exclude_sources: List[str] = None,
) -> int:
"""Count sessions, optionally filtered by source.
@@ -6653,7 +6569,7 @@ class SessionDB:
def list_prune_candidates(
self,
older_than_days: Optional[float] = None,
source: str | None = None,
source: str = None,
**filters,
) -> List[Dict[str, Any]]:
"""Return the sessions a matching :meth:`prune_sessions` /
@@ -6681,7 +6597,7 @@ class SessionDB:
def archive_sessions(
self,
older_than_days: Optional[float] = None,
source: str | None = None,
source: str = None,
**filters,
) -> int:
"""Bulk-archive (soft-hide) every session matching the filters.
@@ -6707,7 +6623,7 @@ class SessionDB:
def prune_sessions(
self,
older_than_days: Optional[float] = 90,
source: str | None = None,
source: str = None,
sessions_dir: Optional[Path] = None,
**filters,
) -> int:
@@ -7357,37 +7273,6 @@ class SessionDB:
)
return optimized
def rebuild_fts(self) -> int:
"""Rebuild FTS5 indexes from the canonical ``messages`` table.
Uses the FTS5 ``'rebuild'`` command, which rewrites the internal
b-tree segments from the content rows. This is the documented
recovery for a corrupt FTS index that rejects message writes while
reads still succeed (issue #50502). Unlike ``optimize_fts`` (which
merges existing segments), ``rebuild`` discards and recreates the
index data entirely.
Safe to call when FTS tables don't exist (skips them).
Returns the number of FTS indexes that were rebuilt.
"""
rebuilt = 0
with self._lock:
for tbl in self._FTS_TABLES:
if not self._fts_table_exists(tbl):
continue
try:
self._conn.execute(
f"INSERT INTO {tbl}({tbl}) VALUES('rebuild')"
)
self._conn.commit()
rebuilt += 1
except sqlite3.OperationalError as exc:
self._conn.rollback()
logger.warning(
"FTS rebuild failed for %s: %s", tbl, exc
)
return rebuilt
def vacuum(self) -> int:
"""Run VACUUM to reclaim disk space after large deletes.
+6 -6
View File
@@ -163,8 +163,8 @@ class MiniSWERunner:
def __init__(
self,
model: str = "anthropic/claude-sonnet-4.6",
base_url: str | None = None,
api_key: str | None = None,
base_url: str = None,
api_key: str = None,
env_type: str = "local",
image: str = "python:3.11-slim",
cwd: str = "/tmp",
@@ -628,12 +628,12 @@ Complete the user's task step by step."""
# ============================================================================
def main(
task: str | None = None,
prompts_file: str | None = None,
task: str = None,
prompts_file: str = None,
output_file: str = "swe-runner-test1.jsonl",
model: str = "claude-sonnet-4-20250514",
base_url: str | None = None,
api_key: str | None = None,
base_url: str = None,
api_key: str = None,
env: str = "local",
image: str = "python:3.11-slim",
cwd: str = "/tmp",
+8 -25
View File
@@ -9,7 +9,6 @@
stdenv,
makeWrapper,
callPackage,
python311,
python312,
nodejs_22,
electron,
@@ -38,28 +37,15 @@
}:
let
nodejs = nodejs_22;
mkHermesVenv =
{
extraDependencyGroups,
python ? null,
}:
extraDependencyGroups:
callPackage ./python.nix {
inherit
uv2nix
pyproject-nix
pyproject-build-systems
python
;
inherit uv2nix pyproject-nix pyproject-build-systems;
pythonSrc = hermesNpmLib.pythonSrc;
dependency-groups = [ "all" ] ++ extraDependencyGroups;
};
hermesVenv =
(mkHermesVenv {
inherit extraDependencyGroups;
python = python312;
}).venv;
hermesVenv = (mkHermesVenv extraDependencyGroups).venv;
hermesNpmLib = callPackage ./lib.nix {
inherit npm-lockfile-fix nodejs;
@@ -75,7 +61,8 @@ let
bundledSkills = lib.cleanSourceWith {
src = ../skills;
filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
filter =
path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
};
# Optional skills are NOT in the wheel (pythonSrc excludes them, see
@@ -83,7 +70,8 @@ let
# same mechanism Homebrew packaging uses.
bundledOptionalSkills = lib.cleanSourceWith {
src = ../optional-skills;
filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
filter =
path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
};
# Import bundled plugins (memory, context_engine, platforms/*). Keeping
@@ -236,13 +224,8 @@ stdenv.mkDerivation (finalAttrs: {
'';
passthru =
python:
let
devPython =
(mkHermesVenv ({
extraDependencyGroups = extraDependencyGroups ++ [ "dev" ];
python = python311;
})).editableVenv;
devPython = (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])).editableVenv;
in
{
inherit
+10 -10
View File
@@ -1,6 +1,6 @@
# nix/python.nix — uv2nix virtual environment builder
{
python,
python312,
lib,
callPackage,
uv2nix,
@@ -65,30 +65,30 @@ let
final: _prev:
if isAarch64Darwin then
{
numpy = mkPrebuiltOverride final python.pkgs.numpy { };
numpy = mkPrebuiltOverride final python312.pkgs.numpy { };
pyarrow = mkPrebuiltOverride final python.pkgs.pyarrow { };
pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { };
av = mkPrebuiltOverride final python.pkgs.av { };
av = mkPrebuiltOverride final python312.pkgs.av { };
humanfriendly = mkPrebuiltOverride final python.pkgs.humanfriendly { };
humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { };
coloredlogs = mkPrebuiltOverride final python.pkgs.coloredlogs {
coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs {
humanfriendly = [ ];
};
onnxruntime = mkPrebuiltOverride final python.pkgs.onnxruntime {
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
coloredlogs = [ ];
numpy = [ ];
packaging = [ ];
};
ctranslate2 = mkPrebuiltOverride final python.pkgs.ctranslate2 {
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
numpy = [ ];
pyyaml = [ ];
};
faster-whisper = mkPrebuiltOverride final python.pkgs.faster-whisper {
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
av = [ ];
ctranslate2 = [ ];
huggingface-hub = [ ];
@@ -102,7 +102,7 @@ let
pythonSet =
(callPackage pyproject-nix.build.packages {
python = python;
python = python312;
}).overrideScope
(
lib.composeManyExtensions [
-312
View File
@@ -1,312 +0,0 @@
# ty type-checking notes — antipatterns & refactors spotted
working through the codebase root-to-tip with astral.sh `ty`.
logging antipatterns, clean refactors, and observations as we go.
---
## tools/registry.py (dependency root, 810 lines)
### invalid-parameter-default (FIXED)
three params in `ToolEntry.register()` were annotated as bare `Callable` / `list`
but defaulted to `None`. this is the #1 pattern ty flags across the whole codebase
(~423 occurrences). the fix is always the same: add `| None` to the annotation.
```python
# BEFORE
check_fn: Callable = None,
requires_env: list = None,
dynamic_schema_overrides: Callable = None,
# AFTER
check_fn: Callable | None = None,
requires_env: list | None = None,
dynamic_schema_overrides: Callable | None = None,
```
this is the single highest-leverage fix across the codebase. each one of these
cascades: when ty sees `None` as a possible value, every downstream `d["key"]`,
`d.get(...)`, `d.pop()` on that variable becomes an `unresolved-attribute` or
`not-subscriptable` or `invalid-argument-type` error. fixing the parameter
default upstream makes all those downstream errors vanish.
### antipattern: ToolEntry.__init__ has zero annotations
```python
def __init__(self, name, toolset, schema, handler, check_fn,
requires_env, is_async, description, emoji,
max_result_size_chars=None, dynamic_schema_overrides=None):
```
this is a core data class (every tool in the system passes through it) but has
no type annotations on any parameter. `__slots__` is used so the shape is
well-defined — adding annotations would be straightforward and high-value.
ty can't infer much here because everything comes through as `Unknown`.
not fixed yet — would benefit from a proper pass:
```python
def __init__(
self,
name: str,
toolset: str,
schema: dict,
handler: Callable,
check_fn: Callable | None,
requires_env: list[str],
is_async: bool,
description: str,
emoji: str,
max_result_size_chars: int | float | None = None,
dynamic_schema_overrides: Callable | None = None,
):
```
### antipattern: tool_error / tool_result helpers lack annotations
```python
def tool_error(message, **extra) -> str: # message: str, **extra: Any
def tool_result(data=None, **kwargs) -> str: # data: dict | None, **kwargs: Any
```
these are the canonical serialization helpers used by hundreds of tool handlers.
low-effort to annotate, high-value since they're the return path for everything.
### clean pattern: _check_fn_cached TTL + grace window
this is well-done code. the TTL cache with transient-failure suppression is
a correct implementation of a flaky-external-check absorption pattern. the
docstrings explain WHY (issue #21658 / #5304 — flaky docker probes stripping
tools mid-session). nothing to change here, just noting it as a positive
example of defensive caching done right.
### clean pattern: _snapshot_state() for thread safety
using `_lock` + snapshot copies for reads is the right pattern for a registry
that can be mutated by MCP dynamic refresh while other threads read. the
generation counter for cache invalidation is also clean.
### observation: `from typing import` vs PEP 604 `X | None`
the file imports `Optional`, `Callable`, `Dict`, `List`, `Set` from `typing`
but also uses `int | float | None` (PEP 604) in the same signatures. the
codebase targets python >=3.11 so PEP 604 is always available. there's a
mix of `Optional[X]` and `X | None` styles across files — not a bug, but
worth standardizing on `X | None` (PEP 604) as we type-sweep since it's
shorter and the modern idiom.
---
## agent/agent_init.py + run_agent.py (AIAgent constructor, 60+ params)
### invalid-parameter-default (FIXED — 31 + 54 params)
same pattern as registry.py but at massive scale. the AIAgent.__init__ in
`agent_init.py` and the class `AIAgent.__init__` in `run_agent.py` each take
~60 params, nearly all defaulting to None but annotated as bare `str`, `list`,
`dict`, `int`, etc.
### antipattern: lowercase `callable` used as a type annotation
**this is a real runtime bug, not just a type error.** 14 callback params in
both `agent_init.py` and `run_agent.py` were annotated as `callable` (the
builtin *function*) instead of `Callable` (from `typing`).
```python
# BEFORE — runtime crash if you try `X | None`
tool_progress_callback: callable = None, # callable is the builtin function
# AFTER
tool_progress_callback: Callable | None = None, # Callable is the type
```
when the automated sweep script added `| None` to these, it produced
`callable | None` which crashes at *import time* with:
```
TypeError: unsupported operand type(s) for |: 'builtin_function_or_method' and 'NoneType'
```
this was lurking silently as long as nobody tried to make the annotation
nullable — the `callable` builtin is truthy so `callable = None` didn't
crash, it just stored a nonsensical annotation. the fix is `Callable` (capital C).
also spotted in `agent/transports/chat_completions.py` in docstring-like
comments (lines 290, 314-315) — not executable code but worth standardizing.
### antipattern: string forward-ref with `| None` doesn't work
```python
# WRONG — crashes at class-def time
iteration_budget: "IterationBudget" | None = None,
# TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'
# RIGHT — wrap the whole thing in Optional
iteration_budget: Optional["IterationBudget"] = None,
```
when a type is a forward reference (string-quoted because it's not yet defined),
you CANNOT use PEP 604 `| None` on it directly — the `|` operator tries to
OR a `str` with `NoneType` and crashes. must use `Optional["ForwardRef"]`.
this affected both `agent_init.py` and `run_agent.py`.
---
## hermes_state.py (49 params fixed)
same `X = None` → `X | None = None` sweep. this is the SQLite session store
module — heavily imported by cli.py, run_agent.py, gateway/, etc.
## batch_runner.py (24 params fixed)
parallel batch processing entry point.
## cli.py (18 params fixed)
the HermesCLI class constructor + helper methods.
## tools/*.py (20+ params fixed across file_tools, file_operations,
skills_tool, skills_hub, terminal_tool, browser_tool, memory_tool,
delegate_tool, process_registry, skill_manager_tool, session_search_tool)
---
## summary of the invalid-parameter-default sweep
total params fixed: ~250 across ~25 core files
the pattern is always identical: `param: Type = None` → `param: Type | None = None`
two runtime-breaking gotchas found during the sweep:
1. lowercase `callable` (builtin function) → must be `Callable` (typing)
2. string forward-refs can't use `| None` → must use `Optional["Ref"]`
---
## run_agent.py — unresolved-attribute analysis (190 errors)
### category 1: "Self@<method>" — init_agent() cross-module init (186/190)
these are ALL the same root cause: `AIAgent.__init__` in `run_agent.py` is a
thin forwarder that calls `init_agent(self, ...)` from `agent/agent_init.py`.
that function sets `self.model`, `self.provider`, `self.session_id`, etc. on
the instance — but ty can't see across the module boundary that these
attributes are being set. so every method that accesses `self.model`,
`self.provider`, etc. gets flagged.
**these are NOT bugs.** the attributes are correctly set at runtime by
`init_agent()`. this is a ty limitation — it can't track attribute assignments
made in a function defined in a different module that receives `self` as a
regular parameter (not as a method on the class).
example:
```python
# run_agent.py
class AIAgent:
def __init__(self, model: str = "", ...):
from agent.agent_init import init_agent
init_agent(self, model=model, ...) # sets self.model, self.provider, etc.
def _resolved_api_call_timeout(self):
return self.provider # ty: "Self@_resolved_api_call_timeout has no attribute provider"
```
**fix approach:** this is the single biggest cluster of ty errors in the
codebase (186 in run_agent.py alone, plus similar in other files). the cleanest
fix would be to declare the attributes as class-level annotations in the
`AIAgent` class body so ty knows they exist:
```python
class AIAgent:
# Instance attributes — set by init_agent() in agent/agent_init.py
model: str
provider: str | None
session_id: str | None
# ... etc
```
this is a one-time declaration that would clear ~186 errors instantly. it's
also good documentation — currently there's no single place that lists all
instance attributes; they're scattered across the 60-param init_agent() body.
### category 2: object.function — duck-typed tool_calls access (2 errors)
line 1968: `tc.function.name` and `tc.function.arguments` on objects typed as
`object`. this is because `msg.tool_calls` is checked via `hasattr` +
`isinstance(list)` but the list elements are `object` to ty.
```python
if hasattr(msg, "tool_calls") and isinstance(msg.tool_calls, list) and msg.tool_calls:
tool_calls_data = [
{"name": tc.function.name, "arguments": tc.function.arguments}
for tc in msg.tool_calls
]
```
**not a bug** — this is duck-typed access to OpenAI SDK objects. the `hasattr`
guard makes it safe at runtime. ty just can't infer the element type of a
list accessed via `hasattr`. could be fixed with a `type: ignore` or by
narrowing `msg` to a proper type.
### category 3: ~AlwaysFalsy.on_session_end — duck-typed context_compressor (2 errors)
lines 3416, 3441: `self.context_compressor.on_session_end(...)` after a
`hasattr(self, "context_compressor") and self.context_compressor` guard.
```python
if hasattr(self, "context_compressor") and self.context_compressor:
self.context_compressor.on_session_end(self.session_id or "", messages or [])
```
ty narrows `self.context_compressor` to `~AlwaysFalsy` (the truthy branch of
the `and`) but doesn't know it has `on_session_end`. **not a bug** — the
`hasattr` guard makes this safe. again a type-system limitation with
duck-typed access.
### verdict for run_agent.py: 0 real bugs, 190 type-system limitations
all 190 are ty being unable to track either cross-module attribute init
(186) or duck-typed hasattr-guarded access (4). no logic bugs found.
### remaining 6 unresolved-attribute (post class-level annotation fix)
1. **line 2162** (`object.function` ×2): duck-typed access to OpenAI SDK
`ChatCompletionMessageToolCall` objects. `hasattr` guard makes it safe.
ty can't infer the list element type through `hasattr`. not a bug.
2. **lines 3586-3587** (`iteration_budget.used` / `.max_total`): `iteration_budget`
is `Optional[IterationBudget]` but `init_agent` always sets it to a non-None
value (`iteration_budget or IterationBudget(max_iterations)`). the annotation
says `| None` but the code guarantees non-None at runtime. not a bug — but
the annotation could be tightened to just `IterationBudget` (without Optional).
3. **lines 4677 + 4799** (`_anthropic_client.close()`): `_anthropic_client` is
`Any | None` — could be None if the anthropic path was never taken. but both
call sites are wrapped in `try/except Exception: pass`, so even if it IS None,
the AttributeError is caught. safe.
---
## plugins/platforms/discord/adapter.py — unresolved-attribute analysis (269 errors)
### root cause: ty resolving the wrong Python environment
the `.venv` directory (created by `uv run` earlier) contained Python 3.13 with
NO dependencies installed. ty auto-discovers `.venv` and uses it for module
resolution — so it couldn't find `discord.py`, `aiohttp`, etc., producing 254
`Module 'discord' has no member X` errors.
the actual dependencies are installed in the nix develop environment
(Python 3.12, all deps in site-packages). removing the empty `.venv` made ty
fall back to the system/nix python, resolving all 254 import errors instantly.
**fix:** `rm -rf .venv` (it was a stray artifact, not a real venv).
also set `python-version = "3.12"` in `[tool.ty.environment]` to match the
nix env, with a comment explaining why.
**impact:** 4,422 → 3,385 diagnostics (−1,037) from this single fix.
### remaining 122 discord errors (post-fix)
after ty could resolve discord.py:
- 17 `not defined on None` — `self._client` (Optional[commands.Bot]) accessed
before ty can prove it's non-None. all are in event handlers called after
the bot is ready. not bugs, but could benefit from `assert self._client is not None`
guards for type narrowing.
- ~100 remaining are real type-checking errors from ty now being able to
see discord.py's actual types (mismatched args, wrong attribute access, etc.)
these need individual triage.
@@ -92,12 +92,12 @@ class EvidenceStore:
source: str,
content: str,
evidence_type: str,
actor: str | None = None,
url: str | None = None,
timestamp: str | None = None,
ioc_type: str | None = None,
actor: str = None,
url: str = None,
timestamp: str = None,
ioc_type: str = None,
verification: str = "unverified",
notes: str | None = None,
notes: str = None,
) -> str:
evidence_id = self._next_id()
entry = {
+131 -324
View File
@@ -509,7 +509,6 @@
const [board, setBoard] = useState(() => readSelectedBoard() || null);
const [boardList, setBoardList] = useState([]); // [{slug, name, counts, ...}]
const [showNewBoard, setShowNewBoard] = useState(false);
const [showBoardSettings, setShowBoardSettings] = useState(false);
const [kanbanBoard, setKanbanBoard] = useState(null); // the grid data
// Alias so the rest of the function can keep using `board` semantically
@@ -972,20 +971,6 @@
});
}, [loadBoardList, switchBoard, board]);
// PATCH board metadata (name / description / default project directory).
// Refreshes the board list so InlineCreate's workspace defaults pick up
// the new default_workdir immediately.
const updateBoard = useCallback(function (slug, payload) {
return SDK.fetchJSON(`${API}/boards/${encodeURIComponent(slug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).then(function (res) {
loadBoardList();
return res;
});
}, [loadBoardList]);
const deleteBoard = useCallback(function (slug) {
if (!slug || slug === "default") return Promise.resolve();
return SDK.fetchJSON(`${API}/boards/${encodeURIComponent(slug)}`, {
@@ -1049,7 +1034,6 @@
boardList: boardList,
onSwitch: switchBoard,
onNewClick: function () { setShowNewBoard(true); },
onSettingsClick: function () { setShowBoardSettings(true); },
onDeleteBoard: deleteBoard,
}),
showNewBoard ? h(NewBoardDialog, {
@@ -1058,14 +1042,6 @@
return createNewBoard(payload).then(function () { setShowNewBoard(false); });
},
}) : null,
showBoardSettings ? h(BoardSettingsDialog, {
board: boardList.find(function (item) { return item.slug === board; })
|| { slug: board },
onCancel: function () { setShowBoardSettings(false); },
onSave: function (payload) {
return updateBoard(board, payload).then(function () { setShowBoardSettings(false); });
},
}) : null,
h(OrchestrationPanel, null),
h(AttentionStrip, {
boardData,
@@ -1855,13 +1831,6 @@
size: "sm",
className: "h-7 text-xs",
}, tx(t, "newBoard", "+ New board")),
h(Button, {
onClick: props.onSettingsClick,
size: "sm",
className: "h-7 text-xs",
title: tx(t, "boardSettingsTitle",
"Board settings — name, description, and the default project directory new tasks inherit"),
}, tx(t, "boardSettings", "Settings")),
h(DocsLink, null),
);
}
@@ -1891,13 +1860,6 @@
),
h("div", { className: "flex-1" }),
h(DocsLink, null),
h(Button, {
onClick: props.onSettingsClick,
size: "sm",
className: "h-8",
title: tx(t, "boardSettingsTitle",
"Board settings — name, description, and the default project directory new tasks inherit"),
}, tx(t, "boardSettings", "Settings")),
h(Button, {
onClick: props.onNewClick,
size: "sm",
@@ -2066,102 +2028,6 @@
);
}
// Board settings dialog — edit display name, description, and the
// board-level default project directory (default_workdir). The workdir
// is the board-level setting every new task's workspace kind/path is
// seeded from; task-level values in the create dialog override it.
function BoardSettingsDialog(props) {
const { t } = useI18n();
const b = props.board || {};
const [name, setName] = useState(b.name || "");
const [description, setDescription] = useState(b.description || "");
const [projectDirectory, setProjectDirectory] = useState(b.default_workdir || "");
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState(null);
function onSubmit(ev) {
if (ev) ev.preventDefault();
setSubmitting(true);
setErr(null);
// Send default_workdir unconditionally: "" clears it on the server,
// a path sets it (validated server-side: absolute + existing dir).
props.onSave({
name: name.trim() || undefined,
description: description.trim() || undefined,
default_workdir: projectDirectory.trim(),
}).catch(function (e) {
setErr(parseApiErrorMessage(e));
setSubmitting(false);
});
}
return h("div", {
className: "hermes-kanban-dialog-backdrop",
onClick: function (e) { if (e.target === e.currentTarget) props.onCancel(); },
onKeyDown: function (e) { if (e.key === "Escape") props.onCancel(); },
},
h("form", {
className: "hermes-kanban-dialog",
onSubmit: onSubmit,
},
h("div", { className: "hermes-kanban-dialog-title" },
tx(t, "boardSettingsTitleFor", "Board settings — {name}",
{ name: b.name || b.slug || "default" })),
h("div", { className: "flex flex-col gap-3" },
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, tx(t, "displayName", "Display name")),
h(Input, {
value: name,
onChange: function (e) { setName(e.target.value); },
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, tx(t, "description", "Description")),
h(Input, {
value: description,
onChange: function (e) { setDescription(e.target.value); },
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" },
tx(t, "projectDirectory", "Project directory")),
h(Input, {
value: projectDirectory,
onChange: function (e) { setProjectDirectory(e.target.value); },
placeholder: tx(t, "projectDirectoryPlaceholder",
"Absolute path to the project folder"),
title: tx(t, "projectDirectoryHelp",
"Git projects use preserved worktrees. Other folders use the directory directly. Leave blank only for temporary work."),
className: "h-8",
autoCapitalize: "none",
autoCorrect: "off",
spellCheck: false,
}),
h("div", { className: "text-xs text-muted-foreground" },
tx(t, "projectDirectoryOverrideHint",
"New tasks inherit this as their workspace default; each task can still override it in the create dialog.")),
),
),
err ? h("div", { className: "text-xs text-destructive mt-2" }, err) : null,
h("div", { className: "hermes-kanban-dialog-actions" },
h(Button, {
type: "button",
onClick: props.onCancel,
size: "sm",
disabled: submitting,
}, tx(t, "cancel", "Cancel")),
h(Button, {
type: "submit",
size: "sm",
disabled: submitting,
}, submitting ? tx(t, "saving", "Saving…") : tx(t, "save", "Save")),
),
),
);
}
// -------------------------------------------------------------------------
// Toolbar
// -------------------------------------------------------------------------
@@ -2897,12 +2763,7 @@
}
// -------------------------------------------------------------------------
// Create-task dialog (modal, with parent selector)
//
// Launched from a column's [+] button. Was an inline form squeezed into
// the ~280px column (8 fields, unlabeled, no room to breathe); now a
// centered modal reusing the hermes-kanban-dialog chrome so the form is
// resizable-window friendly and every field has a visible label.
// Inline create (with parent selector)
// -------------------------------------------------------------------------
function InlineCreate(props) {
@@ -2971,165 +2832,122 @@
: tx(t, "workspacePathOptional",
"repository path (optional when the board has a workdir)");
const fieldLabel = function (text, hint) {
return h(Label, { className: "text-xs" }, text,
hint ? h("span", { className: "text-muted-foreground" }, " ", hint) : null);
};
return h("div", {
className: "hermes-kanban-dialog-backdrop",
onClick: function (e) { if (e.target === e.currentTarget) props.onCancel(); },
onKeyDown: function (e) { if (e.key === "Escape") props.onCancel(); },
},
h("form", {
className: "hermes-kanban-dialog hermes-kanban-create-dialog",
onSubmit: function (e) { e.preventDefault(); submit(); },
},
h("div", { className: "hermes-kanban-dialog-title" },
tx(t, "newTaskTitle", "New task — {column}",
{ column: getColumnLabel(t, props.columnName) || props.columnName })),
h("div", { className: "flex flex-col gap-3" },
h("div", { className: "flex flex-col gap-1" },
fieldLabel(tx(t, "taskTitleLabel", "Title")),
h("textarea", {
value: title,
onChange: function (e) { setTitle(e.target.value); },
onKeyDown: function (e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
},
placeholder: props.columnName === "triage"
? tx(t, "triagePlaceholder", "Rough idea — AI will spec it…")
: tx(t, "taskTitlePlaceholder", "New task title…"),
autoFocus: true,
className: "text-sm min-h-[3rem] max-h-48 resize-y w-full border border-input bg-transparent px-2 py-1 rounded-md focus:outline-none focus:ring-2 focus:ring-ring",
rows: 3,
}),
),
h("div", { className: "flex gap-2" },
h("div", { className: "flex flex-col gap-1 flex-1" },
fieldLabel(props.columnName === "triage"
? tx(t, "specifier", "specifier")
: tx(t, "assigneeLabel", "Assignee"),
tx(t, "assigneeLabelHint", "(blank = dispatcher picks)")),
h(Input, {
value: assignee,
onChange: function (e) { setAssignee(e.target.value); },
placeholder: props.columnName === "triage"
? tx(t, "specifier", "specifier")
: tx(t, "assigneePlaceholder", "assignee"),
className: "h-8 text-sm",
title: props.columnName === "triage"
? "Hermes profile that will spec this task (default: the dispatcher's configured specifier). Leave blank to let the dispatcher pick."
: "Hermes profile to assign. Leave blank and the dispatcher will pick from available profiles when the task is Ready.",
style: { textTransform: "none" },
autoCapitalize: "none",
autoCorrect: "off",
spellCheck: false,
}),
),
h("div", { className: "flex flex-col gap-1 w-20" },
fieldLabel(tx(t, "priority", "Priority")),
h(Input, {
type: "number",
value: priority,
onChange: function (e) { setPriority(e.target.value); },
placeholder: "pri",
className: "h-8 text-sm",
title: "Priority. Higher-priority tasks are claimed first by the dispatcher. 0 = default.",
}),
),
),
h("div", { className: "flex flex-col gap-1" },
fieldLabel(tx(t, "skillsLabel", "Skills"),
tx(t, "skillsLabelHint", "(optional, comma-separated)")),
h(Input, {
value: skills,
onChange: function (e) { setSkills(e.target.value); },
placeholder: tx(t, "skillsPlaceholder",
"skills (optional, comma-separated): translation, github-code-review"),
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
className: "h-8 text-sm",
}),
),
h("div", { className: "flex flex-col gap-1" },
fieldLabel(tx(t, "workspace", "Workspace")),
h("div", { className: "flex gap-2" },
h(Select, Object.assign({
value: workspaceKind,
title: "Choose whether task files are temporary or preserved after completion.",
className: "h-8 text-sm flex-1",
}, selectChangeHandler(setWorkspaceKind)),
h(SelectOption, { value: "scratch" },
tx(t, "workspaceScratch", "Temporary — deleted on completion")),
h(SelectOption, { value: "worktree" },
tx(t, "workspaceWorktree", "Git worktree — preserved")),
h(SelectOption, { value: "dir" },
tx(t, "workspaceDir", "Directory — preserved")),
),
showPathInput ? h(Input, {
value: workspacePath,
onChange: function (e) { setWorkspacePath(e.target.value); },
placeholder: pathPlaceholder,
className: "h-8 text-sm flex-1",
}) : null,
),
workspaceKind === "scratch" ? h("div", {
className: "text-xs text-destructive",
role: "alert",
}, tx(t, "workspaceScratchWarning",
"This workspace and any files left in it are deleted when the task completes.")) : null,
),
h("div", { className: "flex flex-col gap-1" },
fieldLabel(tx(t, "parentLabel", "Parent task"),
tx(t, "parentLabelHint", "(child stays blocked until the parent is done)")),
h(Select, Object.assign({
value: parent,
className: "h-8 text-sm",
title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.",
}, selectChangeHandler(setParent)),
h(SelectOption, { value: "" }, tx(t, "noParent", "— no parent —")),
(props.allTasks || []).map(function (task) {
return h(SelectOption, { key: task.id, value: task.id },
`${task.id} — ${(task.title || "").slice(0, 50)}`);
}),
),
),
h("div", { className: "flex gap-2 items-center" },
h("label", {
className: "flex items-center gap-1.5 text-xs cursor-pointer select-none",
title: "Goal mode: the worker keeps going in the same session until a judge agrees the card is done (or the turn budget runs out, which blocks it for review). Best for open-ended cards one shot rarely finishes.",
},
h("input", {
type: "checkbox",
checked: goalMode,
onChange: function (e) { setGoalMode(!!e.target.checked); },
className: "h-3.5 w-3.5 accent-current",
}),
tx(t, "goalMode", "goal mode"),
),
goalMode ? h(Input, {
type: "number",
value: goalMaxTurns,
onChange: function (e) { setGoalMaxTurns(e.target.value); },
placeholder: tx(t, "goalMaxTurns", "max turns (default 20)"),
className: "h-8 text-sm w-44",
title: "Turn budget for the goal loop. Blank = backend default (20).",
min: 1,
}) : null,
),
return h("div", { className: "hermes-kanban-inline-create" },
h("textarea", {
value: title,
onChange: function (e) { setTitle(e.target.value); },
onKeyDown: function (e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
if (e.key === "Escape") props.onCancel();
},
placeholder: props.columnName === "triage"
? tx(t, "triagePlaceholder", "Rough idea — AI will spec it…")
: tx(t, "taskTitlePlaceholder", "New task title…"),
autoFocus: true,
className: "text-sm min-h-[2rem] max-h-32 resize-y w-full border border-input bg-transparent px-2 py-1 rounded-md focus:outline-none focus:ring-2 focus:ring-ring",
rows: 2,
}),
h("div", { className: "flex gap-2" },
h(Input, {
value: assignee,
onChange: function (e) { setAssignee(e.target.value); },
placeholder: props.columnName === "triage"
? tx(t, "specifier", "specifier")
: tx(t, "assigneePlaceholder", "assignee"),
className: "h-7 text-xs flex-1",
title: props.columnName === "triage"
? "Hermes profile that will spec this task (default: the dispatcher's configured specifier). Leave blank to let the dispatcher pick."
: "Hermes profile to assign. Leave blank and the dispatcher will pick from available profiles when the task is Ready.",
style: { textTransform: "none" },
autoCapitalize: "none",
autoCorrect: "off",
spellCheck: false,
}),
h(Input, {
type: "number",
value: priority,
onChange: function (e) { setPriority(e.target.value); },
placeholder: "pri",
className: "h-7 text-xs w-16",
title: "Priority. Higher-priority tasks are claimed first by the dispatcher. 0 = default.",
}),
),
h(Input, {
value: skills,
onChange: function (e) { setSkills(e.target.value); },
placeholder: tx(t, "skillsPlaceholder",
"skills (optional, comma-separated): translation, github-code-review"),
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
className: "h-7 text-xs",
}),
h("div", { className: "flex gap-2 items-center" },
h("label", {
className: "flex items-center gap-1.5 text-xs cursor-pointer select-none",
title: "Goal mode: the worker keeps going in the same session until a judge agrees the card is done (or the turn budget runs out, which blocks it for review). Best for open-ended cards one shot rarely finishes.",
},
h("input", {
type: "checkbox",
checked: goalMode,
onChange: function (e) { setGoalMode(!!e.target.checked); },
className: "h-3.5 w-3.5 accent-current",
}),
tx(t, "goalMode", "goal mode"),
),
h("div", { className: "hermes-kanban-dialog-actions" },
h(Button, {
type: "button",
onClick: props.onCancel,
size: "sm",
}, tx(t, "cancel", "Cancel")),
h(Button, {
type: "submit",
size: "sm",
disabled: !title.trim(),
}, tx(t, "create", "Create")),
goalMode ? h(Input, {
type: "number",
value: goalMaxTurns,
onChange: function (e) { setGoalMaxTurns(e.target.value); },
placeholder: tx(t, "goalMaxTurns", "max turns (default 20)"),
className: "h-7 text-xs w-40",
title: "Turn budget for the goal loop. Blank = backend default (20).",
min: 1,
}) : null,
),
h("div", { className: "flex gap-2" },
h(Select, Object.assign({
value: workspaceKind,
title: "Choose whether task files are temporary or preserved after completion.",
className: "h-7 text-xs flex-1",
}, selectChangeHandler(setWorkspaceKind)),
h(SelectOption, { value: "scratch" },
tx(t, "workspaceScratch", "Temporary — deleted on completion")),
h(SelectOption, { value: "worktree" },
tx(t, "workspaceWorktree", "Git worktree — preserved")),
h(SelectOption, { value: "dir" },
tx(t, "workspaceDir", "Directory — preserved")),
),
showPathInput ? h(Input, {
value: workspacePath,
onChange: function (e) { setWorkspacePath(e.target.value); },
placeholder: pathPlaceholder,
className: "h-7 text-xs flex-1",
}) : null,
),
workspaceKind === "scratch" ? h("div", {
className: "text-xs text-destructive",
role: "alert",
}, tx(t, "workspaceScratchWarning",
"This workspace and any files left in it are deleted when the task completes.")) : null,
h(Select, Object.assign({
value: parent,
className: "h-7 text-xs",
title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.",
}, selectChangeHandler(setParent)),
h(SelectOption, { value: "" }, tx(t, "noParent", "— no parent —")),
(props.allTasks || []).map(function (task) {
return h(SelectOption, { key: task.id, value: task.id },
`${task.id} — ${(task.title || "").slice(0, 50)}`);
}),
),
h("div", { className: "flex gap-2" },
h(Button, {
onClick: submit,
size: "sm",
}, "Create"),
h(Button, {
onClick: props.onCancel,
size: "sm",
}, tx(t, "cancel", "Cancel")),
),
);
}
@@ -3410,33 +3228,22 @@
if (props.onOpenTask) props.onOpenTask(taskId);
},
}) : null,
data ? h("div", { className: "hermes-kanban-drawer-comment-foot" },
h("div", {
className: "hermes-kanban-comment-hint text-xs text-muted-foreground",
title: tx(t, "commentHintTitle",
"Comments are the channel for talking to a task's worker. They land on the thread immediately — no need to block the task first. A running worker picks the thread up on its next kanban_show() or respawn; blocking is only for when you want the worker to STOP and wait for your input."),
},
"ⓘ ",
tx(t, "commentHint",
"Comments reach the worker on its next run or kanban_show() — no need to block the task first."),
),
h("div", { className: "hermes-kanban-drawer-comment-row" },
h(Input, {
value: newComment,
onChange: function (e) { setNewComment(e.target.value); },
onKeyDown: function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); handleComment();
}
},
placeholder: tx(t, "addComment", "Add a comment… (Enter to submit)"),
className: "h-8 text-sm flex-1",
}),
h(Button, {
onClick: handleComment,
size: "sm",
}, tx(t, "comment", "Comment")),
),
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
h(Input, {
value: newComment,
onChange: function (e) { setNewComment(e.target.value); },
onKeyDown: function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); handleComment();
}
},
placeholder: tx(t, "addComment", "Add a comment… (Enter to submit)"),
className: "h-8 text-sm flex-1",
}),
h(Button, {
onClick: handleComment,
size: "sm",
}, tx(t, "comment", "Comment")),
) : null,
),
);
+17 -17
View File
@@ -311,12 +311,22 @@
margin-left: auto;
}
/* ---- Create-task dialog ----------------------------------------------- */
/* ---- Inline create --------------------------------------------------- */
/* Wider than the base dialog so the workspace/parent selectors and the
labeled two-column rows breathe; still clamped to the viewport. */
.hermes-kanban-create-dialog {
width: 36rem;
.hermes-kanban-inline-create {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: color-mix(in srgb, var(--color-card) 70%, transparent);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm, 0.25rem);
}
.hermes-kanban-inline-create > .flex.gap-2:last-child > button:first-of-type {
flex: 1;
min-width: 0;
}
/* ---- Drawer (task detail side panel) --------------------------------- */
@@ -566,18 +576,6 @@
max-width: 280px;
}
/* Comment footer = workflow hint + input row. The border-top lives on the
wrapper so the hint sits inside the same visual block as the input. */
.hermes-kanban-drawer-comment-foot {
border-top: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-card) 90%, transparent);
}
.hermes-kanban-comment-hint {
padding: 0.45rem 0.75rem 0;
cursor: help;
}
.hermes-kanban-drawer-comment-row {
display: flex;
gap: 0.4rem;
@@ -585,6 +583,8 @@
/* Keep the comment input clear of the mobile browser nav bar / home
indicator by extending the bottom padding with the safe-area inset. */
padding-bottom: max(0.55rem, calc(0.55rem + env(safe-area-inset-bottom)));
border-top: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-card) 90%, transparent);
}
.hermes-kanban-count {
+13 -33
View File
@@ -1990,9 +1990,6 @@ class RenameBoardBody(BaseModel):
description: Optional[str] = None
icon: Optional[str] = None
color: Optional[str] = None
# Board-level default project directory for new tasks. ``None`` =
# leave unchanged; empty string = clear; a path = validate + set.
default_workdir: Optional[str] = None
def _board_counts(slug: str) -> dict[str, int]:
@@ -2037,32 +2034,23 @@ def list_boards(include_archived: bool = Query(False)):
return {"boards": boards, "current": current}
def _validate_workdir(raw: str) -> str:
"""Validate a board default_workdir value; return the resolved path.
Raises :class:`HTTPException` (400) for relative or non-directory
paths — mirroring the create-board contract.
"""
requested = Path(raw).expanduser()
if not requested.is_absolute():
raise HTTPException(
status_code=400,
detail="Project directory must be an absolute path.",
)
if not requested.is_dir():
raise HTTPException(
status_code=400,
detail="Project directory must be an existing directory.",
)
return str(requested.resolve())
@router.post("/boards")
def create_board_endpoint(payload: CreateBoardBody):
"""Create a new board. Idempotent — ``slug`` collision returns existing."""
default_workdir = None
if payload.default_workdir:
default_workdir = _validate_workdir(payload.default_workdir)
requested = Path(payload.default_workdir).expanduser()
if not requested.is_absolute():
raise HTTPException(
status_code=400,
detail="Project directory must be an absolute path.",
)
if not requested.is_dir():
raise HTTPException(
status_code=400,
detail="Project directory must be an existing directory.",
)
default_workdir = str(requested.resolve())
try:
meta = kanban_db.create_board(
payload.slug,
@@ -2085,28 +2073,20 @@ def create_board_endpoint(payload: CreateBoardBody):
@router.patch("/boards/{slug}")
def rename_board(slug: str, payload: RenameBoardBody):
"""Update a board's display metadata + default project directory (slug is immutable — create a new one to rename the directory)."""
"""Update a board's display metadata (slug is immutable — create a new one to rename the directory)."""
try:
normed = kanban_db._normalize_board_slug(slug)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not normed or not kanban_db.board_exists(normed):
raise HTTPException(status_code=404, detail=f"board {slug!r} does not exist")
# default_workdir: None = leave unchanged; "" = clear; path = validate + set.
# write_board_metadata treats a falsy value as "clear", so pass "" through.
default_workdir: Optional[str] = None
if payload.default_workdir is not None:
raw = payload.default_workdir.strip()
default_workdir = _validate_workdir(raw) if raw else ""
meta = kanban_db.write_board_metadata(
normed,
name=payload.name,
description=payload.description,
icon=payload.icon,
color=payload.color,
default_workdir=default_workdir,
)
meta["default_workspace_kind"] = _default_workspace_kind(meta)
return {"board": meta}
+19 -2
View File
@@ -160,6 +160,23 @@ class OSSBackend(Mem0Backend):
import os
from mem0 import Memory
def _provider_block(name: str) -> dict:
block = dict(oss_config[name])
provider = str(block.get("provider") or "").strip().lower()
provider_config = dict(block.get("config", {}))
legacy_base = provider_config.pop("api_base", None)
if legacy_base:
from ._oss_providers import EMBEDDER_PROVIDERS, LLM_PROVIDERS
provider_def = (
LLM_PROVIDERS if name == "llm" else EMBEDDER_PROVIDERS
).get(provider, {})
canonical_key = provider_def.get("base_url_key")
if canonical_key:
provider_config.setdefault(canonical_key, legacy_base)
block["config"] = provider_config
return block
vector_store = dict(oss_config["vector_store"])
vs_config = dict(vector_store.get("config", {}))
@@ -182,8 +199,8 @@ class OSSBackend(Mem0Backend):
config = {
"vector_store": vector_store,
"llm": oss_config["llm"],
"embedder": oss_config["embedder"],
"llm": _provider_block("llm"),
"embedder": _provider_block("embedder"),
"version": "v1.1",
}
self._memory = Memory.from_config(config)
+4
View File
@@ -11,12 +11,14 @@ LLM_PROVIDERS: dict[str, dict[str, Any]] = {
"needs_key": True,
"env_var": "OPENAI_API_KEY",
"default_model": "gpt-5-mini",
"base_url_key": "openai_base_url",
},
"ollama": {
"label": "Ollama (local)",
"needs_key": False,
"default_model": "llama3.1:8b",
"default_url": "http://localhost:11434",
"base_url_key": "ollama_base_url",
"pip_dep": "ollama",
},
}
@@ -27,6 +29,7 @@ EMBEDDER_PROVIDERS: dict[str, dict[str, Any]] = {
"needs_key": True,
"env_var": "OPENAI_API_KEY",
"default_model": "text-embedding-3-small",
"base_url_key": "openai_base_url",
"dims": 1536,
},
"ollama": {
@@ -34,6 +37,7 @@ EMBEDDER_PROVIDERS: dict[str, dict[str, Any]] = {
"needs_key": False,
"default_model": "nomic-embed-text",
"default_url": "http://localhost:11434",
"base_url_key": "ollama_base_url",
"dims": 768,
"pip_dep": "ollama",
},
+6 -4
View File
@@ -135,15 +135,17 @@ def build_oss_config(flags: dict[str, str]) -> tuple[dict, dict[str, str]]:
llm_def = LLM_PROVIDERS[llm_id]
llm_model = flags.get("oss_llm_model") or llm_def["default_model"]
llm_config: dict[str, Any] = {"model": llm_model}
if "default_url" in llm_def:
llm_config["ollama_base_url"] = flags.get("oss_llm_url") or llm_def["default_url"]
llm_url = flags.get("oss_llm_url") or llm_def.get("default_url")
if llm_url and llm_def.get("base_url_key"):
llm_config[llm_def["base_url_key"]] = llm_url
embedder_id = flags.get("oss_embedder", "openai")
embedder_def = EMBEDDER_PROVIDERS[embedder_id]
embedder_model = flags.get("oss_embedder_model") or embedder_def["default_model"]
embedder_config: dict[str, Any] = {"model": embedder_model}
if "default_url" in embedder_def:
embedder_config["ollama_base_url"] = flags.get("oss_embedder_url") or embedder_def["default_url"]
embedder_url = flags.get("oss_embedder_url") or embedder_def.get("default_url")
if embedder_url and embedder_def.get("base_url_key"):
embedder_config[embedder_def["base_url_key"]] = embedder_url
dims = KNOWN_DIMS.get(embedder_model)
if dims:
embedder_config["embedding_dims"] = dims
+1 -7
View File
@@ -366,18 +366,12 @@ markers = [
addopts = "-m 'not integration'"
[tool.ty.environment]
# Match requires-python floor (>=3.11).
python-version = "3.11"
python-version = "3.13"
[tool.ty.rules]
unknown-argument = "warn"
redundant-cast = "ignore"
# Exclude tests from type-checking — they're the lowest-value typing target
# (~57% of diagnostics) and we want to focus ty on the core codebase.
[tool.ty.src]
exclude = ["tests/"]
[tool.ruff]
preview = true # required for PLW1514 (unspecified-encoding) — preview rule
+69 -362
View File
@@ -400,200 +400,6 @@ class AIAgent:
for AI models that support function calling.
"""
# -----------------------------------------------------------------------
# Instance attributes — set by init_agent() in agent/agent_init.py.
# Ideally, we could refactor this class into smaller parts so that
# we don't need to split its __init__ into a separate file.
# Declared here so type checkers know they exist (init_agent receives
# the instance as a plain `agent` parameter, not as a method receiver,
# so cross-module attribute assignment is invisible to static analysis).
# -----------------------------------------------------------------------
# scalars
_anthropic_api_key: str
_anthropic_base_url: str | None
_budget_exhausted_injected: bool
_budget_grace_call: bool
_cache_ttl: str
_cached_system_prompt: str | None
_chat_id: str | None
_chat_name: str | None
_chat_type: str | None
_client_kwargs: dict
_codex_reasoning_replay_enabled: bool
_compression_feasibility_checked: bool
_credits_latch: dict
_current_streamed_assistant_text: str
_delegate_depth: int
_end_session_on_close: bool
_executing_tools: bool
_fallback_activated: bool
_fallback_chain: list
_fallback_index: int
_force_ascii_payload: bool
_interrupt_requested: bool
_interrupt_thread_signal_pending: bool
_is_anthropic_oauth: bool
_is_user_initiated_turn: bool
_iters_since_skill: int
_last_flushed_db_idx: int
_memory_enabled: bool
_memory_nudge_interval: int
_memory_write_context: str
_memory_write_origin: str
_parent_session_id: str | None
_persist_disabled: bool
_primary_runtime: dict
_session_db_created: bool
_session_init_model_config: dict
_session_json_enabled: bool
_skill_nudge_interval: int
_skip_mcp_refresh: bool
_stream_needs_break: bool
_stream_writer_dropped: int
_stream_writer_token: int
_thread_id: str | None
_tool_snapshot_generation: int
_turns_since_memory: int
_user_name: str | None
_user_profile_enabled: bool
_user_turn_count: int
acp_command: str | None
api_key: str | None
api_mode: str | None
ephemeral_system_prompt: str | None
lmstudio_load_mode: str
load_soul_identity: bool
log_prefix: str
log_prefix_chars: int
max_iterations: int
max_tokens: int | None
memory_notifications: str
model: str
pass_session_id: bool
platform: str | None
provider: str | None
provider_data_collection: str | None
provider_require_parameters: bool
provider_sort: str | None
quiet_mode: bool
save_trajectories: bool
service_tier: str | None
session_api_calls: int
session_cache_read_tokens: int
session_cache_write_tokens: int
session_completion_tokens: int
session_cost_source: str
session_cost_status: str
session_estimated_cost_usd: float
session_id: str | None
session_input_tokens: int
session_output_tokens: int
session_prompt_tokens: int
session_reasoning_tokens: int
session_total_tokens: int
show_commentary: bool
skip_context_files: bool
suppress_status_output: bool
tool_delay: float
tool_progress_mode: str
verbose_logging: bool
# callbacks
clarify_callback: Callable | None
event_callback: Optional[Callable[[str, dict], None]]
interim_assistant_callback: Callable | None
notice_callback: Callable | None
notice_clear_callback: Callable | None
reaction_callback: Optional[Callable[[str], None]]
read_terminal_callback: Callable | None
reasoning_callback: Callable | None
status_callback: Callable | None
step_callback: Callable | None
stream_delta_callback: Callable | None
thinking_callback: Callable | None
tool_complete_callback: Callable | None
tool_gen_callback: Callable | None
tool_progress_callback: Callable | None
tool_start_callback: Callable | None
# collections
acp_args: list[str] | None
disabled_toolsets: List[str] | None
enabled_toolsets: List[str] | None
prefill_messages: List[Dict[str, Any]] | None
providers_allowed: List[str] | None
providers_ignored: List[str] | None
providers_order: List[str] | None
reasoning_config: Dict[str, Any] | None
request_overrides: Dict[str, Any] | None
# internal / runtime state
_active_children: list
_active_children_lock: Any
_anthropic_client: Any | None
_anthropic_image_fallback_cache: Any
_api_max_retries: Any
_aux_compression_context_length_config: Any | None
_base_url_hostname: Any
_bedrock_guardrail_config: Any | None
_bedrock_region: Any
_checkpoint_mgr: Any
_client_lock: Any
_compression_threshold_autoraised: Any | None
_compression_warning: Any | None
_config_context_length: Any
_credential_pool: Any
_credits_session_start_micros: Any | None
_credits_state: Any | None
_custom_providers: Any
_environment_probe: Any
_execution_thread_id: Any
_fallback_model: Any
_gateway_session_key: Any
_intent_ack_continuation: Any
_interrupt_message: Any
_kanban_worker_guidance: Any
_memory_manager: Any | None
_memory_store: Any | None
_ollama_num_ctx: Any
_parallel_tool_call_guidance: Any
_pending_cli_user_message: Any | None
_pending_steer_lock: Any
_persist_user_message_idx: Any | None
_persist_user_message_override: Any | None
_persist_user_message_timestamp: Any | None
_platform_hint_overrides: Any
_print_fn: Any | None
_session_db: Any
_session_persist_lock: Any
_stream_callback: Any | None
_stream_context_scrubber: Any
_stream_think_scrubber: Any
_stream_writer_lock: Any
_stream_writer_tls: Any
_subdirectory_hints: Any
_task_completion_guidance: Any
_todo_store: Any
_tool_guardrails: Any
_tool_use_enforcement: Any
_tool_worker_threads_lock: Any
_user_id: Any
_user_id_alt: Any
background_review_callback: Any
client: Any | None
codex_app_server_auto_compaction: Any
compression_enabled: Any
compression_in_place: Any
context_compressor: Any
iteration_budget: Optional[IterationBudget]
logs_dir: Any
openrouter_min_coding_score: Optional[float]
session_start: Any
tools: Any
valid_tool_names: Any
_current_tool: Any
_api_call_count: int
_TOOL_CALL_ARGUMENTS_CORRUPTION_MARKER = (
"[hermes-agent: tool call arguments were corrupted in this session and "
"have been dropped to keep the conversation alive. See issue #15236.]"
@@ -611,71 +417,71 @@ class AIAgent:
def __init__(
self,
base_url: str | None = None,
api_key: str | None = None,
provider: str | None = None,
api_mode: str | None = None,
acp_command: str | None = None,
base_url: str = None,
api_key: str = None,
provider: str = None,
api_mode: str = None,
acp_command: str = None,
acp_args: list[str] | None = None,
command: str | None = None,
command: str = None,
args: list[str] | None = None,
model: str = "",
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
tool_delay: float = 1.0,
enabled_toolsets: List[str] | None = None,
disabled_toolsets: List[str] | None = None,
enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None,
save_trajectories: bool = False,
verbose_logging: bool = False,
quiet_mode: bool = False,
tool_progress_mode: str = "all",
ephemeral_system_prompt: str | None = None,
ephemeral_system_prompt: str = None,
log_prefix_chars: int = 100,
log_prefix: str = "",
providers_allowed: List[str] | None = None,
providers_ignored: List[str] | None = None,
providers_order: List[str] | None = None,
provider_sort: str | None = None,
providers_allowed: List[str] = None,
providers_ignored: List[str] = None,
providers_order: List[str] = None,
provider_sort: str = None,
provider_require_parameters: bool = False,
provider_data_collection: str | None = None,
provider_data_collection: str = None,
openrouter_min_coding_score: Optional[float] = None,
session_id: str | None = None,
tool_progress_callback: Callable | None = None,
tool_start_callback: Callable | None = None,
tool_complete_callback: Callable | None = None,
thinking_callback: Callable | None = None,
reasoning_callback: Callable | None = None,
clarify_callback: Callable | None = None,
read_terminal_callback: Callable | None = None,
step_callback: Callable | None = None,
stream_delta_callback: Callable | None = None,
interim_assistant_callback: Callable | None = None,
tool_gen_callback: Callable | None = None,
status_callback: Callable | None = None,
notice_callback: Callable | None = None,
notice_clear_callback: Callable | None = None,
session_id: str = None,
tool_progress_callback: callable = None,
tool_start_callback: callable = None,
tool_complete_callback: callable = None,
thinking_callback: callable = None,
reasoning_callback: callable = None,
clarify_callback: callable = None,
read_terminal_callback: callable = None,
step_callback: callable = None,
stream_delta_callback: callable = None,
interim_assistant_callback: callable = None,
tool_gen_callback: callable = None,
status_callback: callable = None,
notice_callback: callable = None,
notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
reaction_callback: Optional[Callable[[str], None]] = None,
max_tokens: int | None = None,
reasoning_config: Dict[str, Any] | None = None,
service_tier: str | None = None,
request_overrides: Dict[str, Any] | None = None,
prefill_messages: List[Dict[str, Any]] | None = None,
platform: str | None = None,
user_id: str | None = None,
user_id_alt: str | None = None,
user_name: str | None = None,
chat_id: str | None = None,
chat_name: str | None = None,
chat_type: str | None = None,
thread_id: str | None = None,
gateway_session_key: str | None = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
service_tier: str = None,
request_overrides: Dict[str, Any] = None,
prefill_messages: List[Dict[str, Any]] = None,
platform: str = None,
user_id: str = None,
user_id_alt: str = None,
user_name: str = None,
chat_id: str = None,
chat_name: str = None,
chat_type: str = None,
thread_id: str = None,
gateway_session_key: str = None,
skip_context_files: bool = False,
load_soul_identity: bool = False,
skip_memory: bool = False,
session_db=None,
parent_session_id: str | None = None,
iteration_budget: Optional["IterationBudget"] = None,
fallback_model: Dict[str, Any] | None = None,
parent_session_id: str = None,
iteration_budget: "IterationBudget" = None,
fallback_model: Dict[str, Any] = None,
credential_pool=None,
checkpoints_enabled: bool = False,
checkpoint_max_snapshots: int = 20,
@@ -1393,7 +1199,6 @@ class AIAgent:
"base_url": getattr(self, "base_url", "") or "",
"api_key": getattr(self, "api_key", "") or "",
"api_mode": getattr(self, "api_mode", "") or "",
"auth_mode": getattr(self, "auth_mode", "") or "",
}
def _check_compression_model_feasibility(self) -> None:
@@ -4927,12 +4732,6 @@ class AIAgent:
def _record_streamed_assistant_text(self, text: str) -> None:
"""Accumulate visible assistant text emitted through stream callbacks."""
# Single-writer guard (#65991): a superseded stream must not pollute the
# turn's accumulated text (which also feeds the interim-visible-text
# de-dup comparison), even when a caller reaches this directly (the
# tool-suppressed content path) rather than through _fire_stream_delta.
if self._stream_writer_superseded():
return
if isinstance(text, str) and text:
self._current_streamed_assistant_text = (
getattr(self, "_current_streamed_assistant_text", "") + text
@@ -5095,88 +4894,8 @@ class AIAgent:
except Exception:
logger.debug("interim_assistant_callback error", exc_info=True)
def _ensure_stream_writer_state(self) -> None:
"""Lazily create the single-writer guard fields (#65991).
The fields are normally set in ``agent_init``, but agents constructed
via ``AIAgent.__new__`` (test doubles, legacy/partially-initialized
instances) skip that path. Claiming/checking the writer must not crash
those agents, so initialize the fields on first use.
"""
if getattr(self, "_stream_writer_lock", None) is None:
self._stream_writer_lock = threading.Lock()
if not hasattr(self, "_stream_writer_token"):
self._stream_writer_token = 0
if getattr(self, "_stream_writer_tls", None) is None:
self._stream_writer_tls = threading.local()
if not hasattr(self, "_stream_writer_dropped"):
self._stream_writer_dropped = 0
def _claim_stream_writer(self) -> int:
"""Claim exclusive ownership of the streaming delta sink for the calling
stream attempt and return its monotonic writer token (#65991).
Every streaming attempt (each provider path, each retry) calls this
right before it begins consuming its stream. Claiming bumps the shared
token, so any earlier attempt still alive on another thread is
immediately superseded: its cached token no longer matches and the sink
fences its late chunks out. The token is stored per-thread, so a thread
that never claimed (a non-streaming caller) is never treated as a
writer and can never be fenced.
"""
self._ensure_stream_writer_state()
with self._stream_writer_lock:
self._stream_writer_token += 1
token = self._stream_writer_token
self._stream_writer_tls.token = token
return token
def _stream_writer_is_current(self, token: int) -> bool:
"""True when ``token`` (from a prior _claim_stream_writer) is still the
active writer — i.e. no newer stream attempt has claimed the sink since
(#65991). Lets a stream loop bail out the instant it is superseded."""
return token == getattr(self, "_stream_writer_token", token)
def _stream_writer_superseded(self) -> bool:
"""True when the calling thread claimed the delta sink but a newer
stream attempt has since claimed it — i.e. this thread is a stale
writer whose chunks must be dropped (#65991).
A thread that never claimed (``token is None``) is not a writer and is
never reported as superseded, so non-streaming delta callers are
unaffected.
"""
tls = getattr(self, "_stream_writer_tls", None)
token = getattr(tls, "token", None) if tls is not None else None
if token is None:
return False
return token != getattr(self, "_stream_writer_token", token)
def _note_dropped_stream_writer(self, where: str) -> None:
"""Record + log that a superseded stream's delta was discarded."""
try:
self._stream_writer_dropped = int(getattr(self, "_stream_writer_dropped", 0)) + 1
except Exception:
self._stream_writer_dropped = 1
# Log sparsely (first drop, then powers of two) so a chatty superseded
# stream can't flood the log, but a real provider problem is still
# visible. A silent discard would hide genuine failures.
_n = self._stream_writer_dropped
if _n == 1 or (_n & (_n - 1)) == 0:
logger.warning(
"Dropped delta from a superseded stream writer at %s "
"(discarded=%d this turn) — a stale stream tried to write into "
"the turn after a retry superseded it.",
where, _n,
)
def _fire_stream_delta(self, text: str) -> None:
"""Fire all registered stream delta callbacks (display + TTS)."""
# Single-writer guard (#65991): a superseded stream must not interleave
# its tokens into the turn alongside the retry that replaced it.
if self._stream_writer_superseded():
self._note_dropped_stream_writer("_fire_stream_delta")
return
# If a tool iteration set the break flag, prepend a single paragraph
# break before the first real text delta. This prevents the original
# problem (text concatenation across tool boundaries) without stacking
@@ -5230,11 +4949,6 @@ class AIAgent:
def _fire_reasoning_delta(self, text: str) -> None:
"""Fire reasoning callback if registered."""
# Single-writer guard (#65991): fence out a superseded stream's
# reasoning deltas the same way as content deltas.
if self._stream_writer_superseded():
self._note_dropped_stream_writer("_fire_reasoning_delta")
return
cb = self.reasoning_callback
if cb is not None:
try:
@@ -6126,7 +5840,7 @@ class AIAgent:
messages: list,
*,
logger=None,
session_id: str | None = None,
session_id: str = None,
) -> int:
"""Forwarder — see ``agent.agent_runtime_helpers.sanitize_tool_call_arguments``."""
from agent.agent_runtime_helpers import sanitize_tool_call_arguments
@@ -6365,9 +6079,9 @@ class AIAgent:
def run_conversation(
self,
user_message: Any,
system_message: str | None = None,
conversation_history: List[Dict[str, Any]] | None = None,
task_id: str | None = None,
system_message: str = None,
conversation_history: List[Dict[str, Any]] = None,
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
@@ -6396,28 +6110,21 @@ class AIAgent:
acct_token = set_accounting_context(
getattr(self, "_session_db", None), getattr(self, "session_id", None)
)
from agent.auxiliary_client import scoped_runtime_main
# The outer token restores the caller's Context even though turn setup
# replaces the value with the live runtime after fallback restoration.
# Keep the scope local instead of storing ContextVar tokens on the agent,
# which may be observed from another thread.
with scoped_runtime_main({}):
try:
return run_conversation(
self,
user_message,
system_message,
conversation_history,
task_id,
stream_callback,
persist_user_message,
persist_user_timestamp=persist_user_timestamp,
moa_config=moa_config,
)
finally:
reset_accounting_context(acct_token)
reset_conversation_context(token)
try:
return run_conversation(
self,
user_message,
system_message,
conversation_history,
task_id,
stream_callback,
persist_user_message,
persist_user_timestamp=persist_user_timestamp,
moa_config=moa_config,
)
finally:
reset_accounting_context(acct_token)
reset_conversation_context(token)
def chat(self, message: str, stream_callback: Optional[callable] = None) -> str:
"""
@@ -6447,13 +6154,13 @@ class AIAgent:
return run_codex_app_server_turn(self, user_message=user_message, original_user_message=original_user_message, messages=messages, effective_task_id=effective_task_id, should_review_memory=should_review_memory)
def main(
query: str | None = None,
query: str = None,
model: str = "",
api_key: str | None = None,
api_key: str = None,
base_url: str = "",
max_turns: int = 10,
enabled_toolsets: str | None = None,
disabled_toolsets: str | None = None,
enabled_toolsets: str = None,
disabled_toolsets: str = None,
list_tools: bool = False,
save_trajectories: bool = False,
save_sample: bool = False,
+1 -2
View File
@@ -371,8 +371,7 @@ AUTHOR_MAP = {
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
"dirtyren@users.noreply.github.com": "dirtyren",
"krowd3v@users.noreply.github.com": "krowd3v",
"dfein38347g@users.noreply.github.com": "dfein38347g",
"nicktaylor@TheWorldofNick-Lappy.local": "thegoodguysla",
"s96919@gmail.com": "s96919",
"rasitakyol@hotmail.com": "rasitakyol",
"thatgfsj@gmail.com": "Thatgfsj",
@@ -1,365 +0,0 @@
"""Regression coverage for implicit live-runtime auxiliary cache keys.
#49151/#49156 is specifically the ``provider='auto'`` path where callers omit
``main_runtime`` after a mid-session model switch. This is distinct from
#56889, which isolates callers that pass different explicit ``model=`` values.
"""
import asyncio
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import agent.auxiliary_client as aux
@pytest.fixture(autouse=True)
def _clean_aux_state():
aux.shutdown_cached_clients()
aux.clear_runtime_main()
yield
aux.shutdown_cached_clients()
aux.clear_runtime_main()
def _runtime(model: str, *, provider: str = "custom:llama-swap") -> dict:
return {
"provider": provider,
"model": model,
"base_url": "http://llama-swap.test/v1",
"api_key": "local-key",
"api_mode": "chat_completions",
"auth_mode": "api_key",
}
def test_implicit_auto_cache_rebuilds_after_runtime_model_switch():
"""A /model switch must not reuse the old implicit-auto cache entry."""
built = []
def fake_resolve(_provider, _model, _async_mode, *, main_runtime, **_kwargs):
client = MagicMock(name=f"client-{main_runtime['model']}")
built.append((client, dict(main_runtime)))
return client, main_runtime["model"]
with patch.object(aux, "resolve_provider_client", side_effect=fake_resolve):
aux.set_runtime_main(**_runtime("qwen35b-code"))
first_client, first_model = aux._get_cached_client("auto")
aux.set_runtime_main(**_runtime("qwen27b-code"))
second_client, second_model = aux._get_cached_client("auto")
assert first_model == "qwen35b-code"
assert second_model == "qwen27b-code"
assert second_client is not first_client
assert [runtime["model"] for _, runtime in built] == [
"qwen35b-code",
"qwen27b-code",
]
def test_implicit_runtime_cache_key_covers_full_connection_and_auth_surface():
"""Provider/endpoint/credential/wire/auth changes all isolate auto clients."""
base = _runtime("same-model")
variants = [
{**base, "provider": "custom:other"},
{**base, "base_url": "https://other.test/v1"},
{**base, "api_key": "other-key"},
{**base, "api_mode": "codex_responses"},
{**base, "auth_mode": "entra_id", "api_key": lambda: "token"},
]
aux.set_runtime_main(**base)
baseline = aux._client_cache_key("auto", async_mode=False)
keys = []
for variant in variants:
aux.set_runtime_main(**variant)
keys.append(aux._client_cache_key("auto", async_mode=False))
assert all(key != baseline for key in keys)
assert len(set(keys)) == len(keys)
def test_implicit_runtime_is_isolated_between_concurrent_session_contexts():
"""Concurrent gateway sessions must not read each other's live runtime."""
barrier = Barrier(2)
def session(model: str):
aux.set_runtime_main(**_runtime(model))
barrier.wait()
normalized = aux._normalize_main_runtime(None)
return normalized["model"], aux._client_cache_key("auto", async_mode=False)
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(session, "session-a-model")
second = pool.submit(session, "session-b-model")
model_a, key_a = first.result()
model_b, key_b = second.result()
assert model_a == "session-a-model"
assert model_b == "session-b-model"
assert key_a != key_b
def test_context_without_runtime_does_not_fall_back_to_other_session_globals():
"""A fresh context must not inherit another session's compatibility mirrors."""
aux.set_runtime_main(**_runtime("other-session-model"))
def fresh_context():
return aux._normalize_main_runtime(None)
import contextvars
assert contextvars.Context().run(fresh_context) == {}
def test_runtime_context_token_restores_previous_value_after_turn():
"""Turn-scoped runtime binding must not leak into later work in the same context."""
token = aux.set_runtime_main(**_runtime("turn-model"))
assert aux._normalize_main_runtime(None)["model"] == "turn-model"
aux.reset_runtime_main(token)
assert aux._normalize_main_runtime(None) == {}
def test_aiagent_wrapper_resets_runtime_context_after_turn():
"""Every production run_conversation exit restores the caller's Context."""
from run_agent import AIAgent
agent = SimpleNamespace(
_conversation_root_id=lambda: "root-session",
_session_db=None,
session_id="session-id",
)
def fake_turn(*_args, **_kwargs):
aux.set_runtime_main(**_runtime("wrapped-turn"))
return {"final_response": "ok"}
with patch("agent.conversation_loop.run_conversation", side_effect=fake_turn):
result = AIAgent.run_conversation(agent, "hello")
assert result["final_response"] == "ok"
assert aux._normalize_main_runtime(None) == {}
def test_legacy_patched_globals_are_visible_only_without_an_active_runtime():
"""Direct legacy patches work, but never override context-local session state."""
with patch.object(aux, "_RUNTIME_MAIN_PROVIDER", "custom:legacy"), patch.object(
aux, "_RUNTIME_MAIN_MODEL", "legacy-model"
), patch.object(
aux, "_RUNTIME_MAIN_BASE_URL", "https://legacy.test/v1"
):
assert aux._normalize_main_runtime(None)["model"] == "legacy-model"
aux.set_runtime_main(**_runtime("active-session-model"))
runtime = aux._normalize_main_runtime(None)
assert runtime["model"] == "active-session-model"
assert runtime["base_url"] == "http://llama-swap.test/v1"
def test_concurrent_vision_probes_use_each_sessions_endpoint_and_model():
"""Vision auto-routing must not mix custom endpoints across sessions."""
barrier = Barrier(2)
def fake_resolve(provider, model, **kwargs):
barrier.wait()
client = MagicMock()
client.probed_base_url = kwargs.get("explicit_base_url")
return client, model
def probe(model: str, base_url: str):
runtime = _runtime(model)
runtime["base_url"] = base_url
aux.set_runtime_main(**runtime)
provider, client, resolved_model = aux.resolve_vision_provider_client()
assert client is not None
return provider, resolved_model, client.probed_base_url
with patch.object(
aux, "_resolve_task_provider_model", return_value=("auto", None, None, None, None)
), patch.object(aux, "_main_model_supports_vision", return_value=True), patch.object(
aux, "resolve_provider_client", side_effect=fake_resolve
):
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(probe, "vision-a", "https://a.test/v1")
second = pool.submit(probe, "vision-b", "https://b.test/v1")
result_a = first.result()
result_b = second.result()
assert result_a == ("custom:llama-swap", "vision-a", "https://a.test/v1")
assert result_b == ("custom:llama-swap", "vision-b", "https://b.test/v1")
def test_explicit_model_cache_isolation_remains_independent_of_runtime_key():
"""#56889 remains covered: explicit model values isolate non-auto clients."""
first = aux._client_cache_key(
"openrouter", async_mode=False, model="anthropic/claude-opus-4.8"
)
second = aux._client_cache_key(
"openrouter", async_mode=False, model="openai/gpt-5.5"
)
assert first != second
def test_pinned_provider_without_model_inherits_live_runtime_model_in_cache_key():
"""A pinned provider with model=auto must follow the switched main model."""
first = aux._client_cache_key(
"openrouter",
async_mode=False,
main_runtime=_runtime("old-model", provider="openrouter"),
)
second = aux._client_cache_key(
"openrouter",
async_mode=False,
main_runtime=_runtime("new-model", provider="openrouter"),
)
assert first != second
def test_explicit_vision_runtime_wins_over_stale_ambient_runtime():
"""Vision resolution must use the immutable runtime supplied by its caller."""
aux.set_runtime_main(**_runtime("ambient-old"))
explicit = _runtime("explicit-new")
captured = {}
def fake_resolve(provider, model, **kwargs):
captured.update(provider=provider, model=model, **kwargs)
return MagicMock(), model
with patch.object(
aux, "_resolve_task_provider_model", return_value=("auto", None, None, None, None)
), patch.object(aux, "_main_model_supports_vision", return_value=True), patch.object(
aux, "resolve_provider_client", side_effect=fake_resolve
):
provider, _client, model = aux.resolve_vision_provider_client(
main_runtime=explicit
)
assert provider == "custom:llama-swap"
assert model == "explicit-new"
assert captured["explicit_base_url"] == "http://llama-swap.test/v1"
def test_image_routing_does_not_borrow_base_url_from_different_provider():
"""An explicit provider must not inherit another runtime's custom endpoint."""
from agent.image_routing import _resolve_inference_base_url
aux.set_runtime_main(**_runtime("custom-model"))
cfg = {
"model": {
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
}
}
assert (
_resolve_inference_base_url(cfg, "openrouter")
== "https://openrouter.ai/api/v1"
)
def test_async_initial_cache_lookup_receives_explicit_runtime_snapshot():
"""The first async lookup must not drop main_runtime and only pass it on fallback."""
runtime = _runtime("async-new")
response = MagicMock()
response.choices = [MagicMock(message=MagicMock(content="ok"))]
client = MagicMock()
client.chat.completions.create = AsyncMock(return_value=response)
with patch.object(
aux,
"_resolve_task_provider_model",
return_value=("openrouter", None, None, None, None),
), patch.object(aux, "_get_cached_client", return_value=(client, "async-new")) as get_client:
asyncio.run(
aux.async_call_llm(
task="approval",
main_runtime=runtime,
messages=[{"role": "user", "content": "approve?"}],
)
)
assert get_client.call_args.kwargs["main_runtime"] == aux._normalize_main_runtime(runtime)
def test_unhashable_callable_runtime_api_keys_are_safe_secret_free_discriminators():
"""Callable token providers remain cacheable without leaking returned tokens."""
class TokenProvider(list):
def __init__(self, token: str):
super().__init__()
self.token = token
def __call__(self) -> str:
return self.token
first_provider = TokenProvider("first-super-secret-token")
second_provider = TokenProvider("second-super-secret-token")
first = aux._client_cache_key(
"auto", async_mode=False, main_runtime={**_runtime("same"), "api_key": first_provider}
)
second = aux._client_cache_key(
"auto", async_mode=False, main_runtime={**_runtime("same"), "api_key": second_provider}
)
hash(first)
hash(second)
assert first != second
rendered = repr((first, second))
assert "first-super-secret-token" not in rendered
assert "second-super-secret-token" not in rendered
def test_string_api_keys_are_not_retained_in_cache_key_repr():
"""String credentials discriminate clients without living in cache-key memory."""
first_secret = "first-literal-super-secret"
second_secret = "second-literal-super-secret"
first = aux._client_cache_key(
"auto",
async_mode=False,
api_key=first_secret,
main_runtime={**_runtime("same"), "api_key": first_secret},
)
second = aux._client_cache_key(
"auto",
async_mode=False,
api_key=second_secret,
main_runtime={**_runtime("same"), "api_key": second_secret},
)
assert first != second
rendered = repr((first, second))
assert first_secret not in rendered
assert second_secret not in rendered
def test_fifo_eviction_does_not_close_client_that_may_have_an_inflight_call():
"""A bounded-cache eviction must not invalidate another caller's client."""
clients = []
def fake_resolve(_provider, model, _async_mode, **_kwargs):
client = MagicMock(name=f"client-{model}")
clients.append(client)
return client, model
with patch.object(aux, "resolve_provider_client", side_effect=fake_resolve):
for index in range(65):
aux._get_cached_client("custom", model=f"model-{index}")
assert len(aux._client_cache) == 64
for client in clients:
client.close.assert_not_called()
aux.shutdown_cached_clients()
clients[0].close.assert_not_called()
for client in clients[1:]:
client.close.assert_called_once_with()
@@ -26,9 +26,6 @@ class _FakeAgent:
def _has_stream_consumers(self):
return False
def _claim_stream_writer(self):
return 1
def _fire_stream_delta(self, text):
pass
@@ -79,22 +79,6 @@ class TestCodexItemToToolName:
{"type": "dynamicToolCall", "tool": "web_search"}
) == "web_search"
def test_hermes_tools_mcp_server_emits_bare_tool_name(self):
"""The hermes-tools MCP server wraps Hermes' own tools for codex;
the inner dispatch subprocess can't fire native progress events,
so the codex-level event IS the display event — shown without the
mcp.hermes-tools.* namespacing (from #26541 by @simpolism)."""
assert _codex_item_to_tool_name(
{"type": "mcpToolCall", "server": "hermes-tools", "tool": "web_search"}
) == "web_search"
assert _codex_item_to_tool_name(
{"type": "mcpToolCall", "server": "hermes-tools", "tool": "browser_navigate"}
) == "browser_navigate"
def test_web_search_builtin_maps_to_web_search(self):
"""Codex's built-in webSearch tool gets a bubble too (#26541)."""
assert _codex_item_to_tool_name({"type": "webSearch"}) == "web_search"
def test_unknown_type_returns_type_string(self):
assert _codex_item_to_tool_name(
{"type": "plan"}
@@ -384,27 +368,6 @@ class TestToolProgressDispatch:
assert completed.kwargs["is_error"] is False
assert "results" in completed.kwargs["result"]
def test_web_search_builtin_fires_started_and_completed(self):
"""Codex's built-in webSearch produces a start/complete bubble pair
with the query as preview and args (#26541)."""
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
bridge(_item_started({
"type": "webSearch",
"id": "ws-1",
"query": "hermes agent docs",
}))
bridge(_item_completed({
"type": "webSearch",
"id": "ws-1",
"query": "hermes agent docs",
}))
calls = agent.tool_progress_callback.call_args_list
assert [c.args[0] for c in calls] == ["tool.started", "tool.completed"]
assert calls[0].args[1] == "web_search"
assert calls[0].args[2] == "hermes agent docs"
assert calls[0].args[3] == {"query": "hermes agent docs"}
def test_duration_falls_back_to_wall_time_when_codex_missing_ms(self):
agent = _make_stub_agent()
bridge = make_codex_app_server_event_bridge(agent)
-25
View File
@@ -3039,31 +3039,6 @@ class TestUpdateModelResetsCalibration:
assert comp.should_defer_preflight_to_real_usage(comp.threshold_tokens + 5_000) is False
def test_summary_failure_cooldown_cleared(self):
"""Stale summary-failure cooldown from the old model must not block
the new model from generating summaries after a switch."""
import time
comp = self._comp()
# Simulate a 600-second cooldown set because the old model had no
# provider configured for summarization.
comp._summary_failure_cooldown_until = time.monotonic() + 600
comp.update_model("new-model", context_length=128_000)
assert comp._summary_failure_cooldown_until == 0.0
def test_summary_failure_cooldown_survives_same_runtime_refresh(self):
"""Refreshing metadata for the same runtime must not defeat backoff."""
import time
comp = self._comp()
cooldown_until = time.monotonic() + 600
comp._summary_failure_cooldown_until = cooldown_until
comp.update_model("big-model", context_length=128_000)
assert comp._summary_failure_cooldown_until == cooldown_until
class TestTruncateToolCallArgsJson:
"""Regression tests for #11762.
@@ -1,103 +0,0 @@
"""Regression: the credential-pool "no available entries" INFO line must be
throttled so an empty/exhausted pool cannot storm the shared rotating log.
Selection runs on a hot path (every model call plus auxiliary tasks). Before
the throttle, an empty/exhausted pool logged this line on *every* select(),
which on Windows storms concurrent-log-handler's cross-process lock
("Cannot acquire lock after 20 attempts"), stalls the asyncio event loop, and
fails the Desktop backend readiness handshake ("Timed out connecting to Hermes
backend after 15000ms"). See #58265 for the same fix class on another message.
"""
from __future__ import annotations
import logging
from agent.credential_pool import (
NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS,
CredentialPool,
PooledCredential,
)
_NO_ENTRIES_MSG = "credential pool: no available entries (all exhausted or empty)"
class _FakeClock:
"""Deterministic monotonic clock driven by the test."""
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
def _no_entries_records(caplog) -> list[logging.LogRecord]:
return [r for r in caplog.records if r.getMessage() == _NO_ENTRIES_MSG]
def _make_entry(entry_id: str) -> PooledCredential:
return PooledCredential(
provider="test",
id=entry_id,
label=entry_id,
auth_type="api_key",
source="manual",
access_token=f"tok-{entry_id}",
priority=0,
)
def test_empty_pool_logs_once_within_throttle_window(monkeypatch, caplog):
clock = _FakeClock()
monkeypatch.setattr("agent.credential_pool.time.monotonic", clock)
pool = CredentialPool("test", [])
with caplog.at_level(logging.INFO, logger="agent.credential_pool"):
for _ in range(50):
clock.now += 0.1 # tighter than the throttle window
assert pool.select() is None
# 50 selections, well inside one window -> exactly one log line.
assert len(_no_entries_records(caplog)) == 1
def test_logs_again_after_throttle_window_elapses(monkeypatch, caplog):
clock = _FakeClock()
monkeypatch.setattr("agent.credential_pool.time.monotonic", clock)
pool = CredentialPool("test", [])
with caplog.at_level(logging.INFO, logger="agent.credential_pool"):
assert pool.select() is None # log #1
clock.now += NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS + 1
assert pool.select() is None # window elapsed -> log #2
assert len(_no_entries_records(caplog)) == 2
def test_successful_selection_rearms_throttle(monkeypatch, caplog):
"""A recover -> re-exhaust transition must log immediately, even inside the
window opened by the previous empty stretch (observability of the flip)."""
clock = _FakeClock()
monkeypatch.setattr("agent.credential_pool.time.monotonic", clock)
pool = CredentialPool("test", [])
with caplog.at_level(logging.INFO, logger="agent.credential_pool"):
assert pool.select() is None # log #1, throttle armed
clock.now += 1
assert pool.select() is None # within window -> no log
# Pool recovers: a successful selection re-arms the throttle.
pool._entries = [_make_entry("a")]
clock.now += 1
assert pool.select() is not None
# Pool empties again shortly after (< throttle window since log #1).
pool._entries = []
clock.now += 1
assert pool.select() is None # re-armed -> log #2 immediately
assert len(_no_entries_records(caplog)) == 2
@@ -95,22 +95,6 @@ def _tc_resp(name: str, args: str = "{}") -> dict:
}
def _batch_tc_resp(calls: list[tuple[str, str]]) -> dict:
"""Multi-call batch response: calls = [(name, arguments), ...]."""
return {
"id": "m",
"choices": [{"index": 0, "message": {
"role": "assistant", "content": "",
"tool_calls": [
{"id": f"call_{i}", "type": "function",
"function": {"name": name, "arguments": args}}
for i, (name, args) in enumerate(calls)
]},
"finish_reason": "tool_calls"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 0, "total_tokens": 10},
}
def _text_resp(text: str) -> dict:
return {
"id": "m",
@@ -197,97 +181,3 @@ def test_unknown_nonempty_name_keeps_catalog(agent_env):
assert "frobnicate_xyz" in joined
assert "Available tools:" in joined
assert "tool name was empty" not in joined
# ── Mixed batches: valid calls execute, invalid calls get error results ──
#
# Degrading models (observed with gpt-5.6 past ~350K input; jonny's July 2026
# report) emit batches like 6 named calls + 1 blank-name call. Before the fix,
# the whole turn was voided ("Skipped: another tool call in this turn used an
# invalid name") and three such batches halted the session as partial even
# though most of the model's work was coherent.
def test_mixed_batch_executes_valid_and_errors_blank(agent_env):
"""Valid siblings of a blank-name call must execute, not be skipped."""
agent, handler = agent_env
agent.valid_tool_names = agent.valid_tool_names | {"todo"}
handler.response_queue.append(_batch_tc_resp([("todo", "{}"), ("", "{}")]))
handler.response_queue.append(_text_resp("done"))
result = agent.run_conversation("track work", conversation_history=[], task_id="t")
joined = " ".join(_tool_results(handler))
# The blank call got the terse anti-priming error...
assert "tool name was empty" in joined
# ...the valid sibling was NOT punished...
assert "Skipped: another tool call" not in joined
# ...and actually executed (todo returns its list, not an error result).
assert result.get("completed", False)
def test_mixed_batch_preserves_tool_call_result_pairing(agent_env):
"""Every emitted tool_call keeps a matching tool result (provider invariant)."""
agent, handler = agent_env
agent.valid_tool_names = agent.valid_tool_names | {"todo"}
handler.response_queue.append(_batch_tc_resp([("todo", "{}"), ("", "{}")]))
handler.response_queue.append(_text_resp("done"))
result = agent.run_conversation("track work", conversation_history=[], task_id="t")
msgs = result["messages"]
tc_ids = []
for m in msgs:
if isinstance(m, dict) and m.get("role") == "assistant" and m.get("tool_calls"):
tc_ids.extend(tc["id"] for tc in m["tool_calls"])
result_ids = [
m.get("tool_call_id") or "" for m in msgs
if isinstance(m, dict) and m.get("role") == "tool"
]
# Both the valid and blank call must appear in the assistant message,
# and each must have exactly one matching tool result.
assert set(tc_ids) == {"call_0", "call_1"}
assert sorted(result_ids) == sorted(tc_ids)
def test_mixed_batches_do_not_strike_out_session(agent_env):
"""4 consecutive mixed batches must not trip the 3-strike halt."""
agent, handler = agent_env
agent.valid_tool_names = agent.valid_tool_names | {"todo"}
for _ in range(4):
handler.response_queue.append(_batch_tc_resp([("todo", "{}"), ("", "{}")]))
handler.response_queue.append(_text_resp("survived"))
result = agent.run_conversation("keep going", conversation_history=[], task_id="t")
assert result.get("completed", False)
assert not result.get("partial", False)
assert "survived" in (result.get("final_response") or "")
def test_all_invalid_batch_still_strikes_out(agent_env):
"""A turn with NO valid call must still advance the 3-strike halt."""
agent, handler = agent_env
for _ in range(3):
handler.response_queue.append(_batch_tc_resp([("", "{}"), (" ", "{}")]))
result = agent.run_conversation("degenerate", conversation_history=[], task_id="t")
assert result.get("partial", False)
assert "invalid tool call" in (result.get("error") or "")
def test_mixed_batch_invalid_call_with_broken_json_does_not_retry_turn(agent_env):
"""Broken args on a never-executing invalid call must not trigger the JSON retry loop."""
agent, handler = agent_env
agent.valid_tool_names = agent.valid_tool_names | {"todo"}
handler.response_queue.append(_batch_tc_resp([("todo", "{}"), ("", '{"unclosed')]))
handler.response_queue.append(_text_resp("done"))
result = agent.run_conversation("track work", conversation_history=[], task_id="t")
assert result.get("completed", False)
# Exactly 2 chat API calls: the batch turn + the final answer. A JSON
# retry would add a third identical request.
chat_calls = [r for r in handler.captured_requests if "messages" in r]
assert len(chat_calls) == 2
@@ -1,53 +0,0 @@
"""Regression tests for the SUMMARY_PREFIX tool-use clause (#65848 class).
The REFERENCE ONLY framing must keep its anti-resumption protections while
explicitly NOT restricting tool use — the strong wording was observed bleeding
into general tool-use suppression (narration-only turns after compression).
"""
from agent.context_compressor import (
_HISTORICAL_SUMMARY_PREFIXES,
LEGACY_SUMMARY_PREFIX,
SUMMARY_PREFIX,
)
class TestSummaryPrefixToolUseClause:
def test_prefix_affirms_tools_remain_active(self):
assert "tools remain fully active" in SUMMARY_PREFIX
assert "narrating" in SUMMARY_PREFIX
def test_prefix_keeps_anti_resumption_protections(self):
"""The clause is additive — every load-bearing protection stays."""
assert "REFERENCE ONLY" in SUMMARY_PREFIX
assert "Do NOT answer questions or fulfill requests" in SUMMARY_PREFIX
assert "the latest user message WINS" in SUMMARY_PREFIX
assert "Reverse signals" in SUMMARY_PREFIX
assert "ALWAYS authoritative" in SUMMARY_PREFIX
def test_previous_generation_frozen_in_historical_prefixes(self):
"""Per the module contract: whenever SUMMARY_PREFIX changes, the prior
generation must be frozen into _HISTORICAL_SUMMARY_PREFIXES so old
persisted summaries still get the directive-strip on re-compaction."""
assert len(_HISTORICAL_SUMMARY_PREFIXES) >= 3
newest_frozen = _HISTORICAL_SUMMARY_PREFIXES[0]
# The frozen copy is the pre-clause generation: same framing, no clause.
assert "tools remain fully active" not in newest_frozen
assert "Do NOT answer questions or fulfill requests" in newest_frozen
assert newest_frozen != SUMMARY_PREFIX
def test_historical_prefixes_are_distinct_from_current(self):
for frozen in _HISTORICAL_SUMMARY_PREFIXES:
assert frozen != SUMMARY_PREFIX
assert LEGACY_SUMMARY_PREFIX != SUMMARY_PREFIX
def test_strip_recognizes_current_and_frozen_prefixes(self):
"""Re-compaction normalization must strip both the live prefix and the
newly frozen one (the incident generation)."""
from agent.context_compressor import ContextCompressor
for prefix in (SUMMARY_PREFIX, _HISTORICAL_SUMMARY_PREFIXES[0]):
text = f"{prefix}\nsummary body here"
stripped = ContextCompressor._strip_summary_prefix(text)
assert "summary body here" in stripped
assert "REFERENCE ONLY" not in stripped
-34
View File
@@ -259,40 +259,6 @@ def test_pending_cli_message_uses_clean_override_for_api_local_note():
assert agent._pending_cli_user_message is None
def test_runtime_main_sync_happens_after_restore():
agent = _FakeAgent()
agent.model = "stale-fallback-model"
agent.provider = "openai-codex"
agent.base_url = "https://chatgpt.com/backend-api/codex"
agent.api_key = "fallback-key"
agent.api_mode = "codex_responses"
def restore_primary():
agent.model = "primary-model"
agent.provider = "anthropic"
agent.base_url = "https://api.anthropic.com"
agent.api_key = "primary-key"
agent.api_mode = "anthropic_messages"
agent._restore_primary_runtime = restore_primary
calls = []
with patch(
"agent.auxiliary_client.set_runtime_main",
side_effect=lambda *args, **kwargs: calls.append((args, kwargs)),
):
_build(agent)
assert calls == [(
("anthropic", "primary-model"),
{
"base_url": "https://api.anthropic.com",
"api_key": "primary-key",
"api_mode": "anthropic_messages",
"auth_mode": "",
},
)]
def test_memory_nudge_fires_at_interval():
agent = _FakeAgent()
agent._memory_nudge_interval = 1
@@ -640,64 +640,6 @@ class TestServerRequestRouting:
for (rid, code, _msg) in client.error_responses
)
def test_on_event_fires_during_approval_drain(self):
"""When a server-initiated approval request arrives, the session
drains up to 8 pending notifications first so per-turn state
(e.g. _pending_file_changes for fileChange approvals) is current.
Those drained notifications must also reach the on_event display
hook — otherwise tool bubbles around approvals silently disappear.
Regression for the issue where item/started events that landed
in the queue alongside (or just before) an approval request got
projected into messages but never displayed.
"""
client = FakeClient()
# An item/started notification is queued first, then a server
# request — the session sees both during a single drain loop.
client.queue_notification(
"item/started",
item={
"type": "commandExecution",
"id": "exec-1",
"command": "echo drained",
"cwd": "/tmp",
},
)
client.queue_server_request(
"item/commandExecution/requestApproval", request_id="req-d",
command="echo drained",
cwd="/tmp",
)
client.queue_notification(
"turn/completed", threadId="t",
turn={"id": "tu1", "status": "completed", "error": None},
)
events: list[dict] = []
def cb(command, description, *, allow_permanent=True):
return "once"
s = make_session(
client,
approval_callback=cb,
on_event=events.append,
)
s.run_turn("hi", turn_timeout=1.0)
# The on_event hook must have seen the item/started even though
# it was drained as part of the approval roundtrip — not just
# events that arrive on the main notification path.
item_started_events = [
e for e in events
if e.get("method") == "item/started"
]
assert item_started_events, (
"item/started drained alongside the approval was not "
"forwarded to on_event — display will miss tool bubbles "
"around approvals"
)
def test_mcp_elicitation_for_hermes_tools_auto_accepts(self):
"""When codex elicits on behalf of hermes-tools (our own callback),
accept automatically — the user already opted in by enabling the
@@ -320,6 +320,96 @@ class TestCodexBuildKwargs:
assert headers.get("session_id") == "conv-codex-1"
assert headers.get("x-client-request-id") == "conv-codex-1"
def test_codex_backend_hashes_overlength_cache_routing_headers(self, transport):
messages = [{"role": "user", "content": "Hi"}]
long_session_id = "paperclip:company:" + "a" * 80
kw = transport.build_kwargs(
model="gpt-5.4",
messages=messages,
tools=[],
session_id=long_session_id,
is_codex_backend=True,
)
headers = kw["extra_headers"]
cache_scope = headers["session_id"]
assert cache_scope == headers["x-client-request-id"]
assert cache_scope.startswith("pck_")
assert len(cache_scope) <= 64
assert cache_scope != long_session_id
assert kw["prompt_cache_key"].startswith("pck_")
assert len(kw["prompt_cache_key"]) <= 64
@pytest.mark.parametrize("length", [64, 65])
def test_codex_cache_scope_boundary(self, transport, length):
session_id = "s" * length
scope = transport.build_kwargs(
model="gpt-5.4",
messages=[{"role": "user", "content": "Hi"}],
tools=[],
session_id=session_id,
is_codex_backend=True,
request_overrides={"extra_headers": {"x-test": "1"}},
)["extra_headers"]
assert scope["x-test"] == "1"
assert len(scope["session_id"]) <= 64
assert scope["x-client-request-id"] == scope["session_id"]
if length == 64:
assert scope["session_id"] == session_id
else:
assert scope["session_id"].startswith("pck_")
assert scope["session_id"] != session_id
def test_codex_backend_overlength_cache_scope_is_stable_and_collision_resistant(self, transport):
common = "paperclip:company:" + "a" * 80
def cache_scope(session_id):
return transport.build_kwargs(
model="gpt-5.4",
messages=[{"role": "user", "content": "Hi"}],
tools=[],
session_id=session_id,
is_codex_backend=True,
)["extra_headers"]["session_id"]
assert cache_scope(common + "1") == cache_scope(common + "1")
assert cache_scope(common + "1") != cache_scope(common + "2")
def test_long_override_keys_are_bounded_at_build_and_preflight(self, transport):
long_key = "paperclip:" + "x" * 130
kwargs = transport.build_kwargs(
model="gpt-5.4",
messages=[{"role": "user", "content": "Hi"}],
tools=[],
request_overrides={"prompt_cache_key": long_key},
)
assert len(kwargs["prompt_cache_key"]) <= 64
middleware_payload = dict(kwargs)
middleware_payload["prompt_cache_key"] = long_key
preflight = transport.preflight_kwargs(middleware_payload)
assert preflight["prompt_cache_key"].startswith("pck_")
assert len(preflight["prompt_cache_key"]) <= 64
def test_xai_long_override_key_is_bounded_at_build_and_preflight(self, transport):
long_key = "paperclip:" + "x" * 130
kwargs = transport.build_kwargs(
model="grok-4.3",
messages=[{"role": "user", "content": "Hi"}],
tools=[],
is_xai_responses=True,
request_overrides={"extra_body": {"prompt_cache_key": long_key}},
)
assert len(kwargs["extra_body"]["prompt_cache_key"]) <= 64
middleware_payload = dict(kwargs)
middleware_payload["extra_body"] = {"prompt_cache_key": long_key}
preflight = transport.preflight_kwargs(middleware_payload)
assert preflight["extra_body"]["prompt_cache_key"].startswith("pck_")
assert len(preflight["extra_body"]["prompt_cache_key"]) <= 64
def test_codex_backend_no_headers_without_session_id(self, transport):
messages = [{"role": "user", "content": "Hi"}]
-123
View File
@@ -1,123 +0,0 @@
"""Exit watchdog: arm on shutdown *intent* (signal), never at chat startup.
Regression coverage for the #65998 class: a ``hermes --tui`` process whose
main thread wedges before ``app.run()`` returns never executes the ``finally``
that calls ``_run_cleanup`` — the only place the exit watchdog used to be
armed — so a "dead" CLI lingered indefinitely (observed ~47 min at 4% CPU).
The fix arms the backstop from the SIGTERM/SIGHUP handlers via
``_arm_exit_watchdog_on_shutdown_signal()``. Arming at *startup* (the
rejected #65998 approach) is specifically forbidden: the watchdog thread
calls ``os._exit(0)`` unconditionally after its sleep, so a startup-armed
timer hard-kills every session that outlives the timeout.
"""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import time
from unittest.mock import patch
import pytest
import cli
@pytest.fixture(autouse=True)
def _reset_arm_flag(monkeypatch):
"""Each test starts with the idempotency flag clear."""
monkeypatch.setattr(cli, "_signal_watchdog_armed", False)
class TestSignalArmLogic:
def test_arms_with_double_cleanup_timeout(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "7")
with patch.object(cli, "_arm_exit_watchdog") as arm:
cli._arm_exit_watchdog_on_shutdown_signal()
arm.assert_called_once_with(timeout_s=14.0)
def test_idempotent_across_repeated_signals(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "7")
with patch.object(cli, "_arm_exit_watchdog") as arm:
cli._arm_exit_watchdog_on_shutdown_signal()
cli._arm_exit_watchdog_on_shutdown_signal()
cli._arm_exit_watchdog_on_shutdown_signal()
assert arm.call_count == 1
def test_disabled_via_env_zero(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "0")
with patch.object(cli, "_arm_exit_watchdog") as arm:
cli._arm_exit_watchdog_on_shutdown_signal()
arm.assert_not_called()
def test_bad_env_value_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "not-a-number")
with patch.object(cli, "_arm_exit_watchdog") as arm:
cli._arm_exit_watchdog_on_shutdown_signal()
arm.assert_called_once_with(timeout_s=60.0)
def test_never_raises_even_if_arm_explodes(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "7")
with patch.object(cli, "_arm_exit_watchdog", side_effect=RuntimeError("boom")):
cli._arm_exit_watchdog_on_shutdown_signal() # must not raise
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# A minimal stand-in for the wedged-CLI shape: signal handlers mirror the
# production wiring (arm-on-signal, then a graceful unwind that wedges), and
# the main thread parks the way a stuck app.run() does.
_WEDGE_SRC = """
import os, signal, sys, time
sys.path.insert(0, {repo!r})
import cli
def _handler(signum, frame):
# Production wiring: arm the backstop the moment shutdown intent exists,
# then attempt a graceful unwind — which, in this repro, wedges (the
# KeyboardInterrupt lands in a frame that swallows it).
cli._arm_exit_watchdog_on_shutdown_signal()
signal.signal(signal.SIGTERM, _handler)
print("READY", flush=True)
while True: # the wedge: never observes any unwind
time.sleep(0.2)
"""
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals")
def test_sigterm_on_wedged_process_forces_exit_within_leash():
"""E2E: a wedged process armed via the signal path self-exits at ~2×
HERMES_EXIT_WATCHDOG_S; without the signal it would live forever."""
env = dict(os.environ, HERMES_EXIT_WATCHDOG_S="1", PYTHONPATH=_REPO_ROOT)
# _arm_exit_watchdog refuses to arm under pytest (it would kill the test
# worker); the subprocess must look like a real CLI.
env.pop("PYTEST_CURRENT_TEST", None)
src = _WEDGE_SRC.format(repo=_REPO_ROOT)
p = subprocess.Popen(
[sys.executable, "-c", src],
env=env,
stdout=subprocess.PIPE,
text=True,
)
try:
assert p.stdout is not None
assert p.stdout.readline().strip() == "READY"
# Wedged, no signal yet: must still be alive well past the leash
# (proves we did NOT arm at startup — the #65998 regression).
time.sleep(3.0)
assert p.poll() is None, "watchdog fired without shutdown intent"
p.send_signal(signal.SIGTERM)
t0 = time.time()
rc = p.wait(timeout=10)
elapsed = time.time() - t0
assert rc == 0
# Leash is 2×1s; generous CI slack.
assert elapsed < 8.0, f"exit took {elapsed:.1f}s; leash should be ~2s"
finally:
if p.poll() is None:
p.kill()
@@ -123,13 +123,8 @@ async def test_prepare_image_routing_falls_back_to_text_for_text_only_session_ov
monkeypatch.setattr("agent.image_routing._lookup_supports_vision", fake_supports)
async def fake_enrich(user_text, image_paths):
from agent import auxiliary_client as aux
assert user_text == "look"
assert image_paths == ["/tmp/cashback.png"]
runtime = aux._normalize_main_runtime(None)
assert runtime["provider"] == "xiaomi"
assert runtime["model"] == "mimo-v2.5-pro"
return "[vision summary]\n\nlook"
monkeypatch.setattr(runner, "_enrich_message_with_vision", fake_enrich)
-151
View File
@@ -1660,157 +1660,6 @@ class TestRewriteTranscriptPreservesReasoning:
class TestGatewaySessionDbRecovery:
def test_transcript_append_rebuilds_fts_and_retries_dirty_rows_in_order(self):
import threading
class FakeDb:
def __init__(self):
self.attempts = []
self.persisted = []
self.rebuild_calls = 0
def rebuild_fts(self):
self.rebuild_calls += 1
return 1
def append_message(self, **kwargs):
content = kwargs["content"]
self.attempts.append(content)
if len(self.attempts) <= 2:
raise RuntimeError("database disk image is malformed")
self.persisted.append(content)
store = object.__new__(SessionStore)
store._db = FakeDb()
store._transcript_retry_lock = threading.Lock()
store._dirty_transcripts = {}
store._transcript_append_failures = {}
store._fts_rebuild_attempted = False
store.append_to_transcript("s1", {"role": "user", "content": "first"})
assert [m["content"] for m in store._dirty_transcripts["s1"]] == ["first"]
assert store._db.rebuild_calls == 1
store.append_to_transcript("s1", {"role": "assistant", "content": "second"})
assert store._db.persisted == ["first", "second"]
assert "s1" not in store._dirty_transcripts
def test_transcript_append_clears_dirty_on_rewrite(self):
"""rewrite_transcript must clear pending dirty messages so /retry
and /compress don't re-insert replaced rows."""
import threading
class FakeDb:
def __init__(self):
self.persisted = []
self.replaced = []
def rebuild_fts(self):
return 0
def append_message(self, **kwargs):
raise RuntimeError("database disk image is malformed")
def replace_messages(self, session_id, messages):
self.replaced.append((session_id, messages))
store = object.__new__(SessionStore)
store._db = FakeDb()
store._transcript_retry_lock = threading.Lock()
store._dirty_transcripts = {}
store._transcript_append_failures = {}
store._fts_rebuild_attempted = True # prevent rebuild attempt
# Queue a failed message
store.append_to_transcript("s1", {"role": "user", "content": "stale"})
assert "s1" in store._dirty_transcripts
# rewrite_transcript should clear the dirty queue
store.rewrite_transcript("s1", [{"role": "user", "content": "fresh"}])
assert "s1" not in store._dirty_transcripts
assert len(store._db.replaced) == 1
def test_transcript_append_clears_dirty_on_rewind(self):
"""rewind_session must clear pending dirty messages so /undo
doesn't re-insert rewound rows."""
import threading
class FakeDb:
def __init__(self):
self.persisted = []
def rebuild_fts(self):
return 0
def append_message(self, **kwargs):
raise RuntimeError("database disk image is malformed")
def list_recent_user_messages(self, session_id, limit=10):
return [{"id": 1, "content": "old"}]
def rewind_to_message(self, session_id, target_id):
return {"target_message": {"id": target_id, "content": "old"}}
store = object.__new__(SessionStore)
store._db = FakeDb()
store._transcript_retry_lock = threading.Lock()
store._dirty_transcripts = {}
store._transcript_append_failures = {}
store._fts_rebuild_attempted = True
store.append_to_transcript("s1", {"role": "user", "content": "stale"})
assert "s1" in store._dirty_transcripts
store.rewind_session("s1", 1)
assert "s1" not in store._dirty_transcripts
def test_fts_corruption_error_does_not_match_false_positives(self):
"""_is_fts_corruption_error must not match unrelated error strings
containing 'fts' as a substring (e.g. 'shifts', 'gifts')."""
assert SessionStore._is_fts_corruption_error(
RuntimeError("database disk image is malformed")
)
assert SessionStore._is_fts_corruption_error(
RuntimeError("no such table: messages_fts")
)
assert not SessionStore._is_fts_corruption_error(
RuntimeError("shifts were applied")
)
assert not SessionStore._is_fts_corruption_error(
RuntimeError("gifts received")
)
def test_pending_queue_caps_at_max(self):
"""Pending queue should drop oldest messages when exceeding the cap
to prevent unbounded memory growth on persistent DB failure."""
import threading
class FakeDb:
def __init__(self):
self.count = 0
def rebuild_fts(self):
return 0
def append_message(self, **kwargs):
self.count += 1
raise RuntimeError("database disk image is malformed")
store = object.__new__(SessionStore)
store._db = FakeDb()
store._transcript_retry_lock = threading.Lock()
store._dirty_transcripts = {}
store._transcript_append_failures = {}
store._fts_rebuild_attempted = True
# Fill beyond the cap
for i in range(store._MAX_PENDING_PER_SESSION + 10):
store.append_to_transcript("s1", {"role": "user", "content": f"msg{i}"})
pending = store._dirty_transcripts.get("s1", [])
assert len(pending) <= store._MAX_PENDING_PER_SESSION
def test_new_session_records_gateway_peer_fields(self, tmp_path):
store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig())
source = SessionSource(
+43
View File
@@ -1,3 +1,6 @@
import pytest
from agent.errors import MoAPresetNotFoundError
from hermes_cli.moa_config import (
DEFAULT_MOA_AGGREGATOR,
DEFAULT_MOA_PRESET_NAME,
@@ -205,6 +208,46 @@ def test_resolve_moa_preset_returns_requested_model_set():
]
def test_resolve_missing_moa_preset_has_actionable_error():
cfg = {
"default_preset": "日常对话-高峰",
"presets": {"日常对话-高峰": {}, "日常对话-非高峰": {}},
}
with pytest.raises(MoAPresetNotFoundError) as exc_info:
resolve_moa_preset(cfg, "日常对话-高峰期")
message = str(exc_info.value)
assert "日常对话-高峰期" in message
assert "日常对话-高峰" in message
assert "日常对话-非高峰" in message
assert "hermes moa list" in message
def test_resolve_missing_moa_preset_does_not_silently_fallback():
cfg = {
"default_preset": "日常对话-高峰",
"presets": {"日常对话-高峰": {}},
}
with pytest.raises(MoAPresetNotFoundError):
resolve_moa_preset(cfg, "renamed-preset")
def test_missing_moa_preset_is_non_retryable():
from agent.error_classifier import FailoverReason, classify_api_error
result = classify_api_error(
MoAPresetNotFoundError("MoA preset 'old' was not found"),
provider="moa",
model="old",
)
assert result.reason == FailoverReason.model_not_found
assert result.retryable is False
assert result.should_fallback is False
def test_build_moa_turn_prompt_encodes_one_shot_default_preset():
prompt = build_moa_turn_prompt("write a file then inspect it")
@@ -70,45 +70,3 @@ def test_repair_noop_on_clean_transcript(db):
repaired = db.get_messages_as_conversation("s2", repair_alternation=True)
assert [m["role"] for m in repaired] == [m["role"] for m in verbatim]
assert [m["content"] for m in repaired] == [m["content"] for m in verbatim]
# ---------------------------------------------------------------------------
# The live-replay restore SITES must pass repair_alternation=True. The initial
# fix covered gateway load_transcript + CLI startup resume; these are the other
# live-replay restore paths (ACP session resume, CLI /resume, TUI resume) that
# hand the loaded transcript to a live agent for subsequent turns.
# ---------------------------------------------------------------------------
def _seed_wedged_acp_session(db, session_id="acp1"):
db.create_session(session_id, "acp")
db.append_message(session_id=session_id, role="user", content="first ask")
db.append_message(session_id=session_id, role="assistant", content="first reply")
db.append_message(session_id=session_id, role="user", content="unanswered turn")
db.append_message(session_id=session_id, role="user", content="next turn")
db.append_message(session_id=session_id, role="assistant", content="next reply")
def test_acp_restore_heals_alternation_for_live_replay(db):
"""acp_adapter.SessionManager._restore feeds LIVE REPLAY: the loaded history
becomes the resumed agent's working conversation. It must be alternation-
clean so the pre-request repair doesn't re-fire every turn."""
from acp_adapter.session import SessionManager
_seed_wedged_acp_session(db, "acp1")
class _StubAgent:
model = "stub"
mgr = SessionManager(agent_factory=lambda: _StubAgent(), db=db)
state = mgr._restore("acp1")
assert state is not None
roles = [m["role"] for m in state.history]
# No consecutive user turns — the durable user;user wedge was healed.
assert roles == ["user", "assistant", "user", "assistant"], roles
for a, b in zip(roles, roles[1:]):
assert not (a == "user" and b == "user"), "unhealed user;user in ACP live replay"
# No user input lost — both user texts survive, merged in order.
merged = state.history[2]["content"]
assert "unanswered turn" in merged and "next turn" in merged
+32
View File
@@ -1,5 +1,6 @@
"""Tests for Mem0Backend abstraction — PlatformBackend, OSSBackend, SelfHostedBackend."""
import copy
import pytest
from plugins.memory.mem0._backend import (
@@ -191,6 +192,37 @@ class TestOSSBackend:
result = backend.delete("m1")
assert result == {"result": "Memory deleted.", "memory_id": "m1"}
def test_legacy_api_base_aliases_are_normalized_before_mem0_init(self, monkeypatch):
captured = {}
class Memory:
@staticmethod
def from_config(config):
captured.update(config)
return FakeOSSMemory()
monkeypatch.setattr("mem0.Memory", Memory)
raw = {
"llm": {
"provider": "openai",
"config": {"model": "gpt-5-mini", "api_base": "https://llm.example/v1"},
},
"embedder": {
"provider": "ollama",
"config": {"model": "nomic-embed-text", "api_base": "http://ollama:11434"},
},
"vector_store": {"provider": "qdrant", "config": {}},
}
before = copy.deepcopy(raw)
OSSBackend(raw)
assert captured["llm"]["config"]["openai_base_url"] == "https://llm.example/v1"
assert captured["embedder"]["config"]["ollama_base_url"] == "http://ollama:11434"
assert "api_base" not in captured["llm"]["config"]
assert "api_base" not in captured["embedder"]["config"]
assert raw == before
httpx = pytest.importorskip("httpx")
+17
View File
@@ -96,11 +96,28 @@ class TestBuildOSSConfig:
assert oss["vector_store"]["provider"] == "qdrant"
assert env_writes["OPENAI_API_KEY"] == "sk-oai"
def test_openai_custom_urls_use_mem0_provider_specific_keys(self):
flags = parse_flags([
"--mode", "oss",
"--oss-llm-key", "sk-oai",
"--oss-llm-url", "https://llm.example/v1",
"--oss-embedder-url", "https://embed.example/v1",
])
oss, _ = build_oss_config(flags)
assert oss["llm"]["config"]["openai_base_url"] == "https://llm.example/v1"
assert oss["embedder"]["config"]["openai_base_url"] == "https://embed.example/v1"
assert "api_base" not in oss["llm"]["config"]
assert "api_base" not in oss["embedder"]["config"]
def test_ollama_no_key_needed(self):
flags = parse_flags(["--mode", "oss", "--oss-llm", "ollama", "--oss-embedder", "ollama"])
oss, env_writes = build_oss_config(flags)
assert oss["llm"]["provider"] == "ollama"
assert "model" in oss["llm"]["config"]
assert oss["llm"]["config"]["ollama_base_url"] == "http://localhost:11434"
assert oss["embedder"]["config"]["ollama_base_url"] == "http://localhost:11434"
assert env_writes == {}
def test_embedder_reuses_llm_key(self):
@@ -173,67 +173,6 @@ def test_create_board_rejects_invalid_project_directory(client, path):
assert "project directory" in response.json()["detail"].lower()
def test_patch_board_sets_project_directory(client, tmp_path):
"""Board-level default_workdir must be editable after creation."""
kb.create_board("late-config")
project_dir = tmp_path / "late-project"
project_dir.mkdir()
response = client.patch(
"/api/plugins/kanban/boards/late-config",
json={"default_workdir": str(project_dir)},
)
assert response.status_code == 200, response.text
board = response.json()["board"]
assert board["default_workdir"] == str(project_dir.resolve())
# The recommendation flips from scratch to a persistent kind so the
# create-task dialog's workspace default follows the board setting.
assert board["default_workspace_kind"] == "dir"
assert kb.read_board_metadata("late-config")["default_workdir"] == str(
project_dir.resolve()
)
def test_patch_board_clears_project_directory(client, tmp_path):
"""Empty string clears default_workdir; omitting it leaves it unchanged."""
project_dir = tmp_path / "was-configured"
project_dir.mkdir()
kb.create_board("clearable", default_workdir=str(project_dir))
# Omitted key → unchanged.
r = client.patch(
"/api/plugins/kanban/boards/clearable",
json={"name": "Renamed Only"},
)
assert r.status_code == 200
assert r.json()["board"]["default_workdir"] == str(project_dir.resolve())
# Empty string → cleared, recommendation falls back to scratch.
r = client.patch(
"/api/plugins/kanban/boards/clearable",
json={"default_workdir": ""},
)
assert r.status_code == 200
board = r.json()["board"]
assert not board.get("default_workdir")
assert board["default_workspace_kind"] == "scratch"
@pytest.mark.parametrize("path", ["relative/project", "~/missing-project"])
def test_patch_board_rejects_invalid_project_directory(client, path):
"""PATCH must validate default_workdir like board creation does."""
kb.create_board("strict")
response = client.patch(
"/api/plugins/kanban/boards/strict",
json={"default_workdir": path},
)
assert response.status_code == 400
assert "project directory" in response.json()["detail"].lower()
def test_new_board_dialog_collects_project_directory():
"""Board creation should expose the setting that controls safe task defaults."""
bundle = (
@@ -167,7 +167,6 @@ def test_feasibility_check_passes_live_main_runtime():
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "codex-token",
"api_mode": "codex_responses",
"auth_mode": "",
},
)
@@ -1163,6 +1163,35 @@ def test_copilot_final_preflight_sanitizes_both_middleware_layers(monkeypatch):
]
def test_codex_final_preflight_bounds_middleware_cache_key(monkeypatch):
"""Execution middleware cannot reintroduce an over-length provider key."""
agent = _build_agent(monkeypatch)
setattr(agent, "_disable_streaming", True)
captured = {}
long_key = "paperclip:" + "x" * 130
def _execution_middleware(request, next_call, **_context):
replacement = dict(request)
replacement["prompt_cache_key"] = long_key
return next_call(replacement)
def _capture_api_call(api_kwargs):
captured.update(api_kwargs)
return _codex_message_response("OK")
monkeypatch.setattr(
"hermes_cli.middleware.run_llm_execution_middleware",
_execution_middleware,
)
monkeypatch.setattr(agent, "_interruptible_api_call", _capture_api_call)
result = agent.run_conversation("Say OK")
assert result["completed"] is True
assert captured["prompt_cache_key"].startswith("pck_")
assert len(captured["prompt_cache_key"]) <= 64
def test_run_conversation_codex_empty_output_with_output_text(monkeypatch):
"""Regression: empty response.output + valid output_text should succeed,
not trigger retry/fallback. The validation stage must defer to
@@ -1,213 +0,0 @@
"""Regression tests for the streaming single-writer invariant (#65991).
A retry that supersedes a still-live SSE stream must fence the old stream out
of the delta sink; otherwise both streams write into the same turn and the
persisted transcript is two coherent responses interleaved token-by-token.
These tests exercise the real ``AIAgent`` guard helpers and the streaming
consume-loop, asserting that exactly one writer ever reaches the turn.
"""
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
def _make_agent():
from run_agent import AIAgent
agent = AIAgent(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
model="test/model",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
agent.api_mode = "chat_completions"
agent._interrupt_requested = False
return agent
def _chunk(content=None, finish_reason=None, model=None):
delta = SimpleNamespace(content=content, tool_calls=None, reasoning_content=None, reasoning=None)
choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)
return SimpleNamespace(choices=[choice], model=model, usage=None)
class TestSingleWriterSink:
def test_superseded_writer_deltas_are_dropped(self):
"""A stale writer (older token, other thread) is fenced; only the
newest writer reaches the callbacks and the accumulated turn text."""
agent = _make_agent()
delivered = []
agent.stream_delta_callback = lambda t: delivered.append(t)
agent._stream_callback = None
a_claimed = threading.Event()
b_claimed = threading.Event()
def writer_a():
agent._claim_stream_writer() # token 1
a_claimed.set()
b_claimed.wait(timeout=2) # let B supersede us first
# We are now stale — every sink call must be a no-op.
agent._fire_stream_delta("A-should-drop")
agent._fire_reasoning_delta("A-reason-drop")
agent._record_streamed_assistant_text("A-record-drop")
def writer_b():
a_claimed.wait(timeout=2)
agent._claim_stream_writer() # token 2 — supersedes A
b_claimed.set()
tb = threading.Thread(target=writer_b)
ta = threading.Thread(target=writer_a)
tb.start()
ta.start()
ta.join(timeout=3)
tb.join(timeout=3)
assert delivered == [], "a superseded stream must not deliver any deltas"
assert "A-record-drop" not in (agent._current_streamed_assistant_text or "")
assert agent._stream_writer_dropped >= 1
def test_current_writer_is_never_fenced(self):
"""The active writer always delivers — the guard can only drop a
stream that a *newer* claim has superseded."""
agent = _make_agent()
delivered = []
agent.stream_delta_callback = lambda t: delivered.append(t)
agent._stream_callback = None
agent._claim_stream_writer()
agent._fire_stream_delta("hello ")
agent._fire_stream_delta("world")
assert "".join(delivered) == "hello world"
assert agent._stream_writer_dropped == 0
def test_non_claiming_thread_is_not_a_writer(self):
"""A thread that never claimed (a non-streaming delta caller) is never
treated as a stale writer, even after other attempts have claimed."""
agent = _make_agent()
delivered = []
agent.stream_delta_callback = lambda t: delivered.append(t)
agent._stream_callback = None
# Some other thread runs a couple of stream attempts and bumps the token.
def other():
agent._claim_stream_writer()
agent._claim_stream_writer()
t = threading.Thread(target=other)
t.start()
t.join(timeout=3)
# This (main) thread never claimed → not superseded → delivers.
assert agent._stream_writer_superseded() is False
agent._fire_stream_delta("plain")
assert delivered == ["plain"]
class TestSingleWriterLoop:
@patch("run_agent.AIAgent._create_request_openai_client")
@patch("run_agent.AIAgent._close_request_openai_client")
def test_consume_loop_stops_when_superseded_mid_stream(self, _close, mock_create):
"""The real streaming loop bails out the moment a newer attempt claims
the sink, so a superseded stream cannot interleave into the turn."""
agent = _make_agent()
delivered = []
agent.stream_delta_callback = lambda t: delivered.append(t)
agent._stream_callback = None
def stream_gen():
yield _chunk(content="first")
# A concurrent retry supersedes this stream between chunks.
agent._claim_stream_writer()
yield _chunk(content="-stale-tail", finish_reason="stop", model="m")
mock_client = MagicMock()
mock_client.chat.completions.create.return_value = stream_gen()
mock_create.return_value = mock_client
agent._interruptible_streaming_api_call({})
assert "".join(delivered) == "first"
assert "-stale-tail" not in "".join(delivered)
class TestCodexSingleWriter:
"""The codex_responses path claims the sink and stops when superseded,
matching the chat_completions/anthropic/bedrock parity added in salvage."""
def _codex_event(self, event_type, **fields):
return SimpleNamespace(type=event_type, **fields)
def test_codex_stream_claims_writer_and_stops_when_superseded(self):
from agent.codex_runtime import run_codex_stream
agent = _make_agent()
agent.api_mode = "codex_responses"
delivered = []
agent.stream_delta_callback = lambda t: delivered.append(t)
agent._stream_callback = None
def event_gen():
yield self._codex_event(
"response.output_text.delta", delta="first", item_id="i1",
)
# A concurrent retry supersedes this stream between events.
agent._claim_stream_writer()
yield self._codex_event(
"response.output_text.delta", delta="-stale-tail", item_id="i1",
)
yield self._codex_event(
"response.completed",
response=SimpleNamespace(
id="r1", status="completed", output=[], usage=None,
),
)
mock_client = MagicMock()
mock_client.responses.create.return_value = event_gen()
run_codex_stream(agent, {"model": "gpt-5.3-codex"}, client=mock_client)
assert "".join(delivered) == "first"
assert "-stale-tail" not in "".join(delivered)
def test_codex_stream_undisturbed_when_sole_writer(self):
from agent.codex_runtime import run_codex_stream
agent = _make_agent()
agent.api_mode = "codex_responses"
delivered = []
agent.stream_delta_callback = lambda t: delivered.append(t)
agent._stream_callback = None
def event_gen():
yield self._codex_event(
"response.output_text.delta", delta="hello ", item_id="i1",
)
yield self._codex_event(
"response.output_text.delta", delta="world", item_id="i1",
)
yield self._codex_event(
"response.completed",
response=SimpleNamespace(
id="r1", status="completed", output=[], usage=None,
),
)
mock_client = MagicMock()
mock_client.responses.create.return_value = event_gen()
run_codex_stream(agent, {"model": "gpt-5.3-codex"}, client=mock_client)
assert "".join(delivered) == "hello world"
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))
-134
View File
@@ -1,134 +0,0 @@
"""Runtime FTS-corruption self-heal on the SessionDB write path (#65637 class).
A corrupted FTS5 shadow table (``messages_fts_data``) makes every message
write raise ``sqlite3.DatabaseError: database disk image is malformed``
through the FTS sync triggers, while the canonical ``messages`` rows stay
intact. Before this fix the gateway swallowed the failure at debug level and
the in-memory session advanced while disk silently fell behind — surfacing
later as "Persisted transcript lagged live cached history" amnesia.
The fix: ``_execute_write`` detects the malformed-image class, performs a
one-shot in-place FTS rebuild (FTS5 ``'rebuild'`` command — index rewritten
from canonical rows, no messages touched), and retries the failed write.
"""
import sqlite3
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path):
d = SessionDB(db_path=tmp_path / "state.db")
yield d
try:
d.close()
except Exception:
pass
def _corrupt_fts(db_path):
raw = sqlite3.connect(str(db_path))
raw.execute(
"UPDATE messages_fts_data SET block = X'DEADBEEFDEADBEEFDEADBEEFDEADBEEF'"
)
raw.commit()
raw.close()
def _message_contents(db_path):
raw = sqlite3.connect(str(db_path))
rows = raw.execute("SELECT content FROM messages ORDER BY id").fetchall()
raw.close()
return [r[0] for r in rows]
class TestRuntimeFtsRebuild:
def test_corruption_error_classification_covers_both_sqlite_messages(self):
"""SQLite's message for a corrupt FTS index varies by version: older
builds raise the generic malformed-image error, newer builds raise an
FTS5-specific one. Both must trigger the self-heal."""
assert SessionDB._is_fts_write_corruption_error(
sqlite3.DatabaseError("database disk image is malformed")
)
assert SessionDB._is_fts_write_corruption_error(
sqlite3.DatabaseError(
'fts5: corrupt structure record for table "messages_fts"'
)
)
assert not SessionDB._is_fts_write_corruption_error(
sqlite3.DatabaseError("no such table: nothing_fts_related")
)
def test_append_self_heals_after_fts_corruption(self, db, tmp_path):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db.create_session("s1", source="test")
db.append_message("s1", "user", "hello world")
_corrupt_fts(tmp_path / "state.db")
# Before the fix this raised DatabaseError and the row was lost.
msg_id = db.append_message("s1", "user", "healed append")
assert msg_id is not None
assert _message_contents(tmp_path / "state.db") == [
"hello world",
"healed append",
]
def test_search_works_after_self_heal(self, db, tmp_path):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db.create_session("s1", source="test")
db.append_message("s1", "user", "before corruption")
_corrupt_fts(tmp_path / "state.db")
db.append_message("s1", "user", "searchable needle text")
raw = sqlite3.connect(str(tmp_path / "state.db"))
hits = raw.execute(
"SELECT rowid FROM messages_fts WHERE messages_fts MATCH 'needle'"
).fetchall()
raw.close()
assert len(hits) == 1
def test_rebuild_is_one_shot_per_instance(self, db, tmp_path):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
db.create_session("s1", source="test")
db.append_message("s1", "user", "seed")
_corrupt_fts(tmp_path / "state.db")
db.append_message("s1", "user", "first heal") # consumes the one shot
assert db._fts_runtime_rebuild_attempted is True
# Corrupt again: the guard must NOT loop — the write now propagates.
_corrupt_fts(tmp_path / "state.db")
with pytest.raises(sqlite3.DatabaseError):
db.append_message("s1", "user", "second corruption")
def test_non_fts_errors_still_propagate(self, db):
db.create_session("s1", source="test")
def _bad(conn):
raise sqlite3.IntegrityError("NOT NULL constraint failed: x.y")
with pytest.raises(sqlite3.IntegrityError):
db._execute_write(_bad)
# The guard must not have been consumed by an unrelated error class.
assert db._fts_runtime_rebuild_attempted is False
def test_lock_retry_path_unchanged(self, db):
"""A locked error still follows the jitter-retry path, untouched by
the DatabaseError handler (OperationalError is caught first)."""
calls = {"n": 0}
def _flaky(conn):
calls["n"] += 1
if calls["n"] < 3:
raise sqlite3.OperationalError("database is locked")
return "ok"
assert db._execute_write(_flaky) == "ok"
assert calls["n"] == 3
assert db._fts_runtime_rebuild_attempted is False
+4 -4
View File
@@ -1392,7 +1392,7 @@ def test_session_resume_uses_parent_lineage_for_display(monkeypatch):
def reopen_session(self, target):
captured["reopened"] = target
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, target, include_ancestors=False):
captured.setdefault("history_calls", []).append((target, include_ancestors))
return (
[
@@ -1528,7 +1528,7 @@ def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
def reopen_session(self, target):
pass
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, target, include_ancestors=False):
return [{"role": "user", "content": "hello"}]
def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
@@ -1588,7 +1588,7 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path):
def reopen_session(self, _target):
captured["reopened"] = _target
def get_messages_as_conversation(self, _target, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, _target, include_ancestors=False):
return [{"role": "user", "content": "hello"}]
def update_session_cwd(self, *_args):
@@ -5262,7 +5262,7 @@ def test_slash_exec_r7_read_commands_use_metadata_mirror_flag_on(monkeypatch):
"pinned": True,
}
def get_messages_as_conversation(self, key, include_ancestors=True, repair_alternation=False):
def get_messages_as_conversation(self, key, include_ancestors=True):
assert key == "session-key"
assert include_ancestors is True
return list(history_from_db)
+1 -5
View File
@@ -562,11 +562,7 @@ class TestToolNamePreservation(unittest.TestCase):
captured["acp_command"] = kwargs.get("acp_command")
captured["acp_args"] = kwargs.get("acp_args")
# any_call, not called_with: the patch is global to shutil.which, so an
# unrelated which("uv") from a code path reached later in the same
# process (order-dependent under CI test-slicing) can be the *last*
# call. The intent here is only that the copilot binary was probed.
mock_which.assert_any_call("copilot")
mock_which.assert_called_with("copilot")
self.assertNotEqual(
captured["provider"],
"copilot-acp",
@@ -1,83 +0,0 @@
"""Regression tests for inaccessible-cwd fallback (#65583).
``/root`` leaking into a non-root gateway/cron process's terminal cwd used to
kill every command with ``PermissionError: [Errno 13] Permission denied:
'/root'`` — ``os.path.isdir('/root')`` is True for a non-root user (stat only
needs search permission on ``/``), so the old existence-only check in
``_resolve_safe_cwd`` happily returned a directory ``subprocess.Popen`` could
not enter. The fix checks X_OK and falls back to the nearest usable ancestor.
"""
import os
import sys
import tempfile
import pytest
from tools.environments.local import LocalEnvironment, _cwd_usable, _resolve_safe_cwd
@pytest.fixture
def denied_dir(tmp_path):
"""A directory that exists but cannot be entered (simulates /root)."""
d = tmp_path / "rootlike"
d.mkdir()
d.chmod(0)
yield d
d.chmod(0o755) # so pytest can clean up
needs_posix_perms = pytest.mark.skipif(
sys.platform == "win32" or os.geteuid() == 0,
reason="chmod-based access denial needs POSIX + non-root",
)
@needs_posix_perms
class TestInaccessibleCwdFallback:
def test_cwd_usable_rejects_unenterable_directory(self, denied_dir):
assert os.path.isdir(denied_dir) # the trap: stat succeeds
assert _cwd_usable(str(denied_dir)) is False
def test_resolve_safe_cwd_falls_back_from_denied_dir(self, denied_dir, tmp_path):
resolved = _resolve_safe_cwd(str(denied_dir))
assert resolved != str(denied_dir)
assert os.access(resolved, os.X_OK)
# Nearest usable ancestor is the tmp_path parent, not a random tempdir.
assert resolved == str(tmp_path)
def test_resolve_safe_cwd_climbs_past_denied_ancestor(self, denied_dir, tmp_path):
missing_child = str(denied_dir / "sub" / "dir")
resolved = _resolve_safe_cwd(missing_child)
assert os.access(resolved, os.X_OK)
assert resolved == str(tmp_path)
def test_local_environment_survives_denied_cwd(self, denied_dir):
"""The #65583 shape: env constructed with an unenterable cwd must
still execute commands instead of raising PermissionError."""
env = LocalEnvironment(cwd=str(denied_dir), timeout=30)
try:
handle = env._run_bash("echo ALIVE")
out = handle.stdout.read() if handle.stdout else b""
rc = handle.wait(timeout=15)
assert rc == 0
assert b"ALIVE" in (out if isinstance(out, bytes) else out.encode())
finally:
try:
env.cleanup()
except Exception:
pass
class TestUsableCwdBehaviorUnchanged:
def test_existing_accessible_cwd_returned_verbatim(self, tmp_path):
assert _resolve_safe_cwd(str(tmp_path)) == str(tmp_path)
def test_missing_cwd_still_climbs_to_existing_ancestor(self, tmp_path):
missing = str(tmp_path / "gone" / "deeper")
assert _resolve_safe_cwd(missing) == str(tmp_path)
def test_hopeless_path_falls_back_to_tempdir(self):
# A path whose every component is missing outside any real tree.
resolved = _resolve_safe_cwd("")
assert resolved == tempfile.gettempdir()
@@ -1,34 +0,0 @@
"""Regression coverage for live TUI image-routing identity."""
from types import SimpleNamespace
from unittest.mock import patch
from tui_gateway.server import _active_image_routing_identity
def test_live_agent_identity_wins_after_model_switch():
agent = SimpleNamespace(provider="alibaba", model="qwen3.7-plus")
with patch(
"agent.auxiliary_client._read_main_provider",
side_effect=AssertionError("stale provider fallback used"),
), patch(
"agent.auxiliary_client._read_main_model",
side_effect=AssertionError("stale model fallback used"),
):
identity = _active_image_routing_identity(agent)
assert identity == ("alibaba", "qwen3.7-plus")
def test_missing_agent_identity_uses_runtime_fallback():
agent = SimpleNamespace(provider="", model=None)
with patch(
"agent.auxiliary_client._read_main_provider", return_value="openai-codex"
), patch(
"agent.auxiliary_client._read_main_model", return_value="gpt-5.5-codex"
):
identity = _active_image_routing_identity(agent)
assert identity == ("openai-codex", "gpt-5.5-codex")
+8 -8
View File
@@ -346,7 +346,7 @@ def test_session_resume_returns_hydrated_messages(server, monkeypatch):
def reopen_session(self, _sid):
return None
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, _sid, include_ancestors=False):
return [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "yo", "reasoning": "thoughts"},
@@ -406,7 +406,7 @@ def test_session_resume_defaults_to_deferred_build(server, monkeypatch):
def reopen_session(self, _sid):
return None
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, _sid, include_ancestors=False):
return [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "yo"},
@@ -547,7 +547,7 @@ def test_session_resume_handles_multimodal_list_content(server, monkeypatch):
def reopen_session(self, _sid):
return None
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, _sid, include_ancestors=False):
return [multimodal_user, text_only_assistant]
monkeypatch.setattr(server, "_get_db", lambda: _DB())
@@ -597,7 +597,7 @@ def test_session_resume_lazy_registers_watch_session_without_agent(server, monke
def reopen_session(self, _sid):
return None
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, _sid, include_ancestors=False):
return [
{"role": "user", "content": "delegated goal"},
]
@@ -670,7 +670,7 @@ def test_session_resume_lazy_reports_running_for_inflight_child(server, monkeypa
def reopen_session(self, _sid):
return None
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, _sid, include_ancestors=False):
return [{"role": "user", "content": "delegated goal"}]
monkeypatch.setattr(server, "_get_db", lambda: _DB())
@@ -721,7 +721,7 @@ def test_session_resume_lazy_tolerates_missing_row_for_active_child(server, monk
def reopen_session(self, _sid):
return None
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, _sid, include_ancestors=False):
# No rows for an unwritten session.
return []
@@ -818,7 +818,7 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch):
def reopen_session(self, _sid):
return None
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, _sid, include_ancestors=False):
return [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "yo"},
@@ -1035,7 +1035,7 @@ def test_session_resume_live_payload_uses_current_history_with_ancestors(server,
def reopen_session(self, _sid):
return None
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
def get_messages_as_conversation(self, _sid, include_ancestors=False):
if include_ancestors:
return ancestor_history + current_history
return list(current_history)
+1 -1
View File
@@ -2266,7 +2266,7 @@ def _extract_screenshot_path_from_text(text: str) -> Optional[str]:
def _run_browser_command(
task_id: str,
command: str,
args: List[str] | None = None,
args: List[str] = None,
timeout: Optional[int] = None,
_engine_override: Optional[str] = None,
) -> Dict[str, Any]:
+1 -1
View File
@@ -869,7 +869,7 @@ def _build_child_progress_callback(
return kw
def _relay(
event_type: str, tool_name: str | None = None, preview: str = None, args=None, **kwargs
event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs
):
if not parent_cb:
return
+9 -36
View File
@@ -136,27 +136,11 @@ def _quote_bash_path(path: str) -> str:
return shlex.quote(_bash_safe_path(path))
def _cwd_usable(path: str) -> bool:
"""True when *path* is a directory this process can actually chdir into.
``os.path.isdir`` alone is not enough: stat() on ``/root`` succeeds for a
non-root user (only ``/`` needs search permission), but
``subprocess.Popen(cwd='/root')`` then dies with ``PermissionError:
[Errno 13] Permission denied: '/root'``. Seen in the wild when a
root-launched CLI session leaks ``/root`` into shared state that a
non-root gateway/cron process later reads (#65583) — every cron job's
terminal/file tool then fails on every command, forever. Checking
X_OK up front lets the caller fall back instead.
"""
return os.path.isdir(path) and os.access(path, os.X_OK)
def _resolve_safe_cwd(cwd: str) -> str:
"""Return ``cwd`` if it exists as a directory this process can enter,
else the nearest existing accessible ancestor. Falls back to
``tempfile.gettempdir()`` only if walking up the path can't find any
usable directory (effectively never on a healthy filesystem, but cheap
belt-and-braces).
"""Return ``cwd`` if it exists as a directory, else the nearest existing
ancestor. Falls back to ``tempfile.gettempdir()`` only if walking up the
path can't find any existing directory (effectively never on a healthy
filesystem, but cheap belt-and-braces).
On Windows, also normalizes Git Bash / MSYS-style POSIX paths
(``/c/Users/x``) to native Windows form before the isdir check so a
@@ -165,27 +149,16 @@ def _resolve_safe_cwd(cwd: str) -> str:
Used by ``_run_bash`` to recover when the configured cwd is gone — most
commonly because a previous tool call deleted its own working directory
(issue #17558) — or inaccessible to this user, e.g. ``/root`` leaking
from a root-launched CLI session into a non-root gateway's cron jobs
(issue #65583). Without this guard, ``subprocess.Popen(..., cwd=...)``
raises ``FileNotFoundError``/``PermissionError`` before bash starts,
wedging every subsequent terminal call until the gateway restarts.
(issue #17558). Without this guard, ``subprocess.Popen(..., cwd=...)``
raises ``FileNotFoundError`` before bash starts, wedging every subsequent
terminal call until the gateway restarts.
"""
cwd = _msys_to_windows_path(cwd) if _IS_WINDOWS else cwd
if cwd and _cwd_usable(cwd):
return cwd
if cwd and os.path.isdir(cwd):
logger.warning(
"Configured terminal cwd %r exists but is not accessible to "
"this user (uid=%s) — falling back to the nearest usable "
"directory. If this is a gateway/cron process, check for "
"root-owned paths leaking into terminal.cwd / TERMINAL_CWD "
"(#65583).",
cwd, getattr(os, "getuid", lambda: "?")(),
)
return cwd
parent = os.path.dirname(cwd) if cwd else ""
while parent:
if _cwd_usable(parent):
if os.path.isdir(parent):
return parent
next_parent = os.path.dirname(parent)
if next_parent == parent:
+1 -1
View File
@@ -834,7 +834,7 @@ class ShellFileOperations(FileOperations):
self._command_cache: Dict[str, bool] = {}
def _exec(self, command: str, cwd: str = None, timeout: int = None,
stdin_data: str | None = None) -> ExecuteResult:
stdin_data: str = None) -> ExecuteResult:
"""Execute command via terminal backend.
Args:
+2 -2
View File
@@ -1653,7 +1653,7 @@ def write_file_tool(path: str, content: str, task_id: str = "default",
def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
new_string: str | None = None, replace_all: bool = False, patch: str = None,
new_string: str = None, replace_all: bool = False, patch: str = None,
task_id: str = "default", cross_profile: bool = False,
session_id: str | None = None) -> str:
"""Patch a file using replace mode or V4A patch format.
@@ -1847,7 +1847,7 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
def search_tool(pattern: str, target: str = "content", path: str = ".",
file_glob: str | None = None, limit: int = 50, offset: int = 0,
file_glob: str = None, limit: int = 50, offset: int = 0,
output_mode: str = "content", context: int = 0,
task_id: str = "default") -> str:
"""Search for content or files."""
+3 -3
View File
@@ -957,10 +957,10 @@ def _missing_old_text_error(store: "MemoryStore", target: str, action: str) -> s
def memory_tool(
action: str | None = None,
action: str = None,
target: str = "memory",
content: str | None = None,
old_text: str | None = None,
content: str = None,
old_text: str = None,
operations: Optional[List[Dict[str, Any]]] = None,
store: Optional[MemoryStore] = None,
) -> str:
+3 -3
View File
@@ -689,10 +689,10 @@ class ProcessRegistry:
def spawn_local(
self,
command: str,
cwd: str | None = None,
cwd: str = None,
task_id: str = "",
session_key: str = "",
env_vars: dict | None = None,
env_vars: dict = None,
use_pty: bool = False,
) -> ProcessSession:
"""
@@ -829,7 +829,7 @@ class ProcessRegistry:
self,
env: Any,
command: str,
cwd: str | None = None,
cwd: str = None,
task_id: str = "",
session_key: str = "",
timeout: int = 10,
+3 -3
View File
@@ -368,13 +368,13 @@ class ToolRegistry:
toolset: str,
schema: dict,
handler: Callable,
check_fn: Callable | None = None,
requires_env: list | None = None,
check_fn: Callable = None,
requires_env: list = None,
is_async: bool = False,
description: str = "",
emoji: str = "",
max_result_size_chars: int | float | None = None,
dynamic_schema_overrides: Callable | None = None,
dynamic_schema_overrides: Callable = None,
override: bool = False,
):
"""Register a tool. Called at module-import time by each tool file.
+8 -8
View File
@@ -305,7 +305,7 @@ def _scroll(
session_id: str,
around_message_id: int,
window: int = 5,
current_session_id: str | None = None,
current_session_id: str = None,
) -> str:
"""Scroll shape: return a window of messages centered on an anchor.
@@ -502,7 +502,7 @@ def _discover(
role_filter: Optional[List[str]],
limit: int,
sort: Optional[str],
current_session_id: str | None = None,
current_session_id: str = None,
) -> str:
"""Discovery shape: FTS5 + anchored window + bookends per hit. Single call."""
role_list = role_filter if role_filter else ["user", "assistant"]
@@ -618,18 +618,18 @@ def _discover(
def session_search(
query: str = "",
role_filter: str | None = None,
role_filter: str = None,
limit: int = 3,
db=None,
current_session_id: str | None = None,
current_session_id: str = None,
# Scroll shape
session_id: str | None = None,
around_message_id: int | None = None,
session_id: str = None,
around_message_id: int = None,
window: int = 5,
# Discovery shape
sort: str | None = None,
sort: str = None,
# Cross-profile (any shape)
profile: str | None = None,
profile: str = None,
) -> str:
"""Single-shape tool. Mode inferred from which args are set.
+8 -8
View File
@@ -918,7 +918,7 @@ def _patch_skill(
name: str,
old_string: str,
new_string: str,
file_path: str | None = None,
file_path: str = None,
replace_all: bool = False,
) -> Dict[str, Any]:
"""Targeted find-and-replace within a skill file.
@@ -1323,14 +1323,14 @@ def apply_skill_pending(payload: Dict[str, Any]) -> str:
def skill_manage(
action: str,
name: str,
content: str | None = None,
category: str | None = None,
file_path: str | None = None,
file_content: str | None = None,
old_string: str | None = None,
new_string: str | None = None,
content: str = None,
category: str = None,
file_path: str = None,
file_content: str = None,
old_string: str = None,
new_string: str = None,
replace_all: bool = False,
absorbed_into: str | None = None,
absorbed_into: str = None,
) -> str:
"""
Manage user-created skills. Dispatches to the appropriate action handler.
+2 -2
View File
@@ -960,8 +960,8 @@ def _serve_plugin_skill(
def skill_view(
name: str,
file_path: str | None = None,
task_id: str | None = None,
file_path: str = None,
task_id: str = None,
preprocess: bool = True,
) -> str:
"""
+3 -3
View File
@@ -1482,10 +1482,10 @@ def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]:
def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
ssh_config: dict | None = None, container_config: dict = None,
local_config: dict | None = None,
ssh_config: dict = None, container_config: dict = None,
local_config: dict = None,
task_id: str = "default",
host_cwd: str | None = None):
host_cwd: str = None):
"""
Create an execution environment for sandboxed command execution.
+4 -4
View File
@@ -1355,11 +1355,11 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
def main(
input: str,
output: str | None = None,
output: str = None,
config: str = "configs/trajectory_compression.yaml",
target_max_tokens: int | None = None,
tokenizer: str | None = None,
sample_percent: float | None = None,
target_max_tokens: int = None,
tokenizer: str = None,
sample_percent: float = None,
seed: int = 42,
dry_run: bool = False,
):
+10 -31
View File
@@ -5091,16 +5091,6 @@ def _resolve_checkpoint_hash(mgr, cwd: str, ref: str) -> str:
raise ValueError(f"Invalid checkpoint number. Use 1-{len(checkpoints)}.")
def _active_image_routing_identity(agent: Any) -> tuple[str, str]:
"""Return the live provider/model, falling back before agent startup."""
from agent.auxiliary_client import _read_main_model, _read_main_provider
return (
getattr(agent, "provider", "") or _read_main_provider(),
getattr(agent, "model", "") or _read_main_model(),
)
def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str:
"""Pre-analyze attached images via vision and prepend descriptions to user text."""
import asyncio, json as _json
@@ -6010,11 +6000,7 @@ def _(rid, params: dict) -> dict:
db.reopen_session(target)
# The child's OWN conversation only — include_ancestors would prepend
# the parent's transcript onto the subagent's branch.
# repair_alternation: this resume feeds LIVE REPLAY (the loaded
# history becomes the resumed session record's working conversation),
# so heal a durable ``user;user`` violation once here instead of
# re-firing the pre-request repair on every subsequent turn.
history = db.get_messages_as_conversation(target, repair_alternation=True)
history = db.get_messages_as_conversation(target)
except Exception as e:
if lease is not None:
lease.release()
@@ -6079,11 +6065,7 @@ def _(rid, params: dict) -> dict:
_enable_gateway_prompts()
try:
db.reopen_session(target)
# repair_alternation on the model-fed copy only: this resume feeds
# LIVE REPLAY (raw_history → sanitize_replay_history → the resumed
# session's working conversation). display_history stays verbatim —
# inspection/export must show what is actually stored.
raw_history = db.get_messages_as_conversation(target, repair_alternation=True)
raw_history = db.get_messages_as_conversation(target)
display_history = db.get_messages_as_conversation(target, include_ancestors=True)
except Exception as e:
if lease is not None:
@@ -6157,9 +6139,7 @@ def _(rid, params: dict) -> dict:
)
try:
db.reopen_session(target)
# repair_alternation on the model-fed copy only (see the interactive
# resume above): this loads LIVE REPLAY history; display stays verbatim.
raw_history = db.get_messages_as_conversation(target, repair_alternation=True)
raw_history = db.get_messages_as_conversation(target)
display_history = db.get_messages_as_conversation(
target, include_ancestors=True
)
@@ -9433,13 +9413,16 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
decide_image_input_mode,
build_native_content_parts,
)
from agent.auxiliary_client import (
_read_main_model,
_read_main_provider,
)
from hermes_cli.config import load_config as _tui_load_config
_cfg = _tui_load_config()
_provider, _model = _active_image_routing_identity(agent)
_mode = decide_image_input_mode(
_provider,
_model,
_read_main_provider(),
_read_main_model(),
_cfg,
)
if getattr(agent, "api_mode", "") == "codex_app_server":
@@ -12728,12 +12711,8 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5008, f"undo: {e}")
# Reload the active-only transcript into the in-memory session
# history so subsequent turns see the truncated view.
# repair_alternation: this reload feeds LIVE REPLAY — session["history"]
# is the working conversation for subsequent turns, and a rewind that
# lands on a durable user;user pair would otherwise re-fire the
# pre-request repair on every request from here on.
try:
active = db.get_messages_as_conversation(session_key, repair_alternation=True)
active = db.get_messages_as_conversation(session_key)
except Exception:
active = []
with session["history_lock"]:
-20
View File
@@ -828,25 +828,5 @@ export const en: Translations = {
"workspace path (optional, derived from assignee if blank)",
logTruncated: "(showing last 100 KB — full log at ",
logAt: ")",
newTaskTitle: "New task — {column}",
taskTitleLabel: "Title",
assigneeLabel: "Assignee",
assigneeLabelHint: "(blank = dispatcher picks)",
skillsLabel: "Skills",
skillsLabelHint: "(optional, comma-separated)",
parentLabel: "Parent task",
parentLabelHint: "(child stays blocked until the parent is done)",
create: "Create",
boardSettings: "Settings",
boardSettingsTitle:
"Board settings — name, description, and the default project directory new tasks inherit",
boardSettingsTitleFor: "Board settings — {name}",
projectDirectoryOverrideHint:
"New tasks inherit this as their workspace default; each task can still override it in the create dialog.",
saving: "Saving…",
commentHint:
"Comments reach the worker on its next run or kanban_show() — no need to block the task first.",
commentHintTitle:
"Comments are the channel for talking to a task's worker. They land on the thread immediately — no need to block the task first. A running worker picks the thread up on its next kanban_show() or respawn; blocking is only for when you want the worker to STOP and wait for your input.",
},
};

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