OUTPUT #691
PARÇA 1 / 5
TOPLAM: 167636 karakter | 2901 satır
BU PARÇA: 40000 karakter
716-        except Exception as _hook_err:
717-            logger.debug("pre_tool_call hook error: %s", _hook_err)
718-        if block_message is not None:
719-            return function_args, (tool_error(block_message), "plugin_block", block_message)
720-
721:    # ACP/Zed edit approval before any file mutation. The requester is bound
722:    # via ContextVar only for ACP sessions, so CLI/gateway paths are unaffected.
723-    try:
724:        from acp_adapter.edit_approval import maybe_require_edit_approval
725:        edit_block_message = maybe_require_edit_approval(function_name, function_args)
726-        if edit_block_message is not None:
727:            return function_args, (edit_block_message, "edit_approval_denied", None)
728:    except Exception as _edit_approval_err:
729:        logger.debug("ACP edit approval guard error: %s", _edit_approval_err)
730-        if function_name in {"write_file", "patch"}:
731:            return function_args, (tool_error("Edit approval denied: approval guard failed"), "edit_approval_error", None)
732-    return function_args, None
733-
734-
735:@contextmanager
736:def _approval_observability(ids: _CallIds):
737:    """Bind the approval observability context (turn/tool_call/session ids) for the block."""
738-    try:
739:        from tools.approval_context import reset_current_observability_context, set_current_observability_context
740:        tokens = set_current_observability_context(turn_id=ids.turn_id or "", tool_call_id=ids.tool_call_id or "",
741-                                                   session_id=ids.session_id or "")
742-    except Exception:
743-        yield
744-        return
745-    try:
746-        yield
747-    finally:
748-        try:
749:            reset_current_observability_context(tokens)
750-        except Exception:
751-            pass
752-
753-
754-def _execute_tool(function_name: str, function_args: Dict[str, Any], original_args: Dict[str, Any], ids: _CallIds,
755-                  *, user_task: Optional[str], enabled_tools: Optional[List[str]], skip_tool_execution_middleware: bool) -> Any:
756-    """Run the registry handler (through tool-execution middleware unless skipped)
757:    with the approval observability context bound for the duration."""
758-    dispatch_kwargs: Dict[str, Any] = {"task_id": ids.task_id, "session_id": ids.session_id}
759-    if function_name == "execute_code":
760-        # Prefer the caller's list so subagents can't overwrite the parent's
761-        # tool set via the process-global.
762-        dispatch_kwargs["enabled_tools"] = enabled_tools if enabled_tools is not None else _last_resolved_tool_names
763-    else:
764-        dispatch_kwargs["user_task"] = user_task
765-
766-    def _dispatch(next_args: Dict[str, Any]) -> Any:
767-        return registry.dispatch(function_name, next_args, **dispatch_kwargs)
768-
769:    with _approval_observability(ids):
770-        if skip_tool_execution_middleware:
771-            return _dispatch(function_args)
772-        from hermes_cli.middleware import run_tool_execution_middleware
773-        return run_tool_execution_middleware(function_name, function_args, _dispatch, original_args=original_args,
774-                                             **ids.hook_kwargs())
775-
776-
777-def _apply_transform_tool_result_hook(function_name: str, function_args: Dict[str, Any], result: Any, duration_ms: int,
778-                                      ids: _CallIds) -> Any:
779-    """transform_tool_result: plugins may replace the final result string.
780-
781:    Runs after post_tool_call and before the result enters context. Fail-open;
782-    first string return wins. Gated on has_hook so the no-listener path is cheap.
783-    """
784-    try:
785-        from hermes_cli.lifecycle import has_hook, invoke_hook
786-        if has_hook("transform_tool_result"):
787-            status, error_type, error_message = _tool_result_observer_fields(function_name, result)
--
801-def handle_function_call(
802-    function_name: str, function_args: Dict[str, Any], task_id: Optional[str] = None,
803-    tool_call_id: Optional[str] = None, session_id: Optional[str] = None, turn_id: Optional[str] = None,
804-    api_request_id: Optional[str] = None, user_task: Optional[str] = None, enabled_tools: Optional[List[str]] = None,
805-    skip_pre_tool_call_hook: bool = False, skip_tool_request_middleware: bool = False,
806-    skip_tool_execution_middleware: bool = False, tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
807:    enabled_toolsets: Optional[List[str]] = None, disabled_toolsets: Optional[List[str]] = None,
808-) -> str:
809-    """Route a tool call through hooks/middleware to the registry; returns a JSON string.
810-
811-    task_id isolates terminal/browser sessions; user_task feeds browser_snapshot.
812-    enabled_tools picks execute_code's sandbox tools (default: the process-global
813-    ``_last_resolved_tool_names``). skip_pre_tool_call_hook: caller already fired
814:    it (single-fire contract). enabled/disabled_toolsets scope the Tool Search
815-    bridge catalog to this session's grant (None = unrestricted).
816-    """
817-    function_args = coerce_tool_args(function_name, function_args)
818-    if not isinstance(function_args, dict):
819-        function_args = {}
820-    trace = list(tool_request_middleware_trace or [])
--
827-        _emit_post_tool_call_hook(function_name=function_name, function_args=function_args, result=result,
828-                                  **asdict(ids), middleware_trace=list(trace), **extra)
829-        return result
830-
831-    # Tool Search bridge: tool_search / tool_describe are catalog reads handled
832-    # inline; tool_call is unwrapped so every downstream hook (pre/post, edit
833:    # approval, guardrails) sees the real tool name, never the bridge.
834:    bridged = _dispatch_bridge_tool(function_name, function_args, enabled_toolsets, disabled_toolsets)
835-    if bridged is not None:
836-        result, underlying = bridged
837-        if underlying is None:
838-            return _emit(result, duration_ms=_elapsed_ms(start))
839-        return handle_function_call(
840-            *underlying, **asdict(ids), user_task=user_task, enabled_tools=enabled_tools,
841-            skip_pre_tool_call_hook=skip_pre_tool_call_hook, skip_tool_request_middleware=skip_tool_request_middleware,
842-            skip_tool_execution_middleware=skip_tool_execution_middleware, tool_request_middleware_trace=list(trace),
843:            enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets,
844-        )
845-
846-    original_args = dict(function_args)
847-    if not skip_tool_request_middleware:
848-        function_args, original_args, trace = _apply_request_middleware(function_name, function_args, ids, trace)
849-
--
884-# =============================================================================
885-
886-def get_all_tool_names() -> List[str]:
887-    return registry.get_all_tool_names()
888-
889-
890:def get_toolset_for_tool(tool_name: str) -> Optional[str]:
891:    return registry.get_toolset_for_tool(tool_name)
892-
893-
894:def get_available_toolsets() -> Dict[str, dict]:
895:    """Toolset availability info for UI display."""
896:    return registry.get_available_toolsets()
897-
898-
899:def check_toolset_requirements() -> Dict[str, bool]:
900:    """{toolset: available_bool} for every registered toolset."""
901:    return registry.check_toolset_requirements()
902-
903-
904-def check_tool_availability(quiet: bool = False) -> Tuple[List[str], List[dict]]:
905:    """(available_toolsets, unavailable_info)."""
906-    return registry.check_tool_availability(quiet=quiet)

===== /home/hermes/.hermes/hermes-agent/registration_lifecycle.py =====
1-"""Ownership leases for replaceable runtime registrations.
2-
3:The coordinator models registration *generations*, not just value identity: the same provider
4-singleton may be registered again after an older ownership generation was unloaded.
5-"""
6-
7-from __future__ import annotations
8-
9-import threading
10-from collections.abc import Callable, Hashable
11:from contextlib import contextmanager
12-from dataclasses import dataclass, field
13-from typing import Any
14-
15-
16-def same_registration(left: Any, right: Any) -> bool:
17-    """Compare opaque registry snapshots using identity only (element-wise for tuples)."""
--
41-    """Link and remove registration generations in arbitrary unload order."""
42-
43-    def __init__(self) -> None:
44-        self._active: dict[Hashable, list[ReplacementLease]] = {}
45-        self._lock = threading.RLock()
46-
47:    @contextmanager
48-    def transaction(self):
49-        """Serialize a registry snapshot/write/acquire with lease disposal."""
50-        with self._lock:
51-            yield
52-
53-    def acquire(self, slot: Hashable, *, current: Any, previous: Any, restore: Callable[[Any], bool],

===== /home/hermes/.hermes/hermes-agent/run_agent.py =====
1-#!/usr/bin/env python3
2-"""AIAgent: the tool-calling agent runner (conversation loop, tool execution, session lifecycle).
3-
4-    from run_agent import AIAgent
5:    agent = AIAgent(base_url="http://localhost:30000/v1", model="claude-opus-4-20250514")
6-    response = agent.run_conversation("Tell me about the latest Python updates")
7-"""
8-
9-# hermes_bootstrap must be the very first import (UTF-8 stdio on Windows; no-op on POSIX).
10-try:
11-    import hermes_bootstrap  # noqa: F401
--
42-    except OSError:  # cwd was unlinked out from under us
43-        return None
44-
45-
46-def _session_source_for_agent(platform: Optional[str]) -> str:
47-    try:
48:        from gateway.session_context import get_session_env
49-
50-        source = get_session_env("HERMES_SESSION_SOURCE", "")
51-    except Exception:
52-        source = os.environ.get("HERMES_SESSION_SOURCE", "")
53-    return str(source or "").strip() or platform or "cli"
54-
--
86-    except Exception:
87-        return None
88-
89-
90-from agent.iteration_budget import IterationBudget
91-from hermes_cli.env_loader import load_hermes_dotenv
92:from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout
93-
94-_hermes_home = get_hermes_home()  # read by agent_init via _ra()._hermes_home
95-_loaded_env_paths = load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent / '.env')
96-for _env_path in _loaded_env_paths:
97-    logger.info("Loaded environment variables from %s", _env_path)
98-if not _loaded_env_paths:
99-    logger.info("No .env file found. Using system environment variables.")
100-
101-
102:from model_tools import get_toolset_for_tool
103-from tools.terminal_tool_lifecycle import cleanup_vm, get_active_env
104-from tools.interrupt import set_interrupt as _set_interrupt
105-from tools.browser_tool_lifecycle import cleanup_browser
106-
107-from agent.memory_provider import is_trivial_prompt
108-from agent.client_lifecycle import ClientLifecycleMixin
--
110-from agent.status_output import StatusOutputMixin
111-from agent.api_request_hooks import ApiRequestHooksMixin
112-from agent.api_error_summary import ApiErrorSummaryMixin
113-from agent.interrupt_control import InterruptControlMixin
114-from agent.turn_explainers import TurnExplainersMixin
115-from agent.activity_tracking import ActivityTrackingMixin
116:from agent.rate_limit_credits import RateLimitCreditsMixin
117-from agent.session_persistence import SessionPersistenceMixin
118:from agent.compression_facade import CompressionFacadeMixin
119-from agent.turn_facade import TurnFacadeMixin
120:from agent.vision_message_prep import VisionMessagePrepMixin
121:from agent.reasoning_params import ReasoningParamsMixin
122-from agent.lazy_forward import forward as _forward, forward_static as _forward_static
123-from agent.session_activity import ActivityProvenance
124:from agent.model_metadata import is_local_endpoint
125-from agent.message_sanitization import (
126-    coalesce_tool_call_id as _sanitize_coalesce_tool_call_id,
127-    deterministic_call_id as _codex_deterministic_call_id,
128-    uniquify_tool_call_ids as _sanitize_uniquify_tool_call_ids,
129-)
130-from agent.codex_responses_adapter import (
131-    _derive_responses_function_call_id as _codex_derive_responses_function_call_id,
132-    _split_responses_tool_id as _codex_split_responses_tool_id,
133-    _summarize_user_message_for_log,
134-)
135-from agent.tool_guardrails import ToolGuardrailDecision, append_toolguard_guidance, toolguard_synthetic_result
136:from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens
137-
138-
139-_MAX_TOOL_WORKERS = 8
140-
141-
142-# Spawn the OpenRouter pre-warm thread once per process, not per AIAgent (gateway thread leak).
--
149-        fn(*args, **kwargs)
150-    except Exception:
151-        pass
152-
153-
154-def _call_engine_hook(engine: Any, hook: str, *args, **kwargs) -> None:
155:    """Invoke an optional context-engine lifecycle hook; failures are logged, never raised."""
156-    if not hasattr(engine, hook):
157-        return
158-    try:
159-        getattr(engine, hook)(*args, **kwargs)
160-    except Exception as exc:
161:        logger.debug("context engine %s during transition: %s", hook, exc)
162-
163-
164-def _positive_int(value: Any) -> Optional[int]:
165-    """``value`` when it is a real positive int (bools excluded), else None."""
166-    return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
167-
168-
169-def _review_should_defer(agent: Any, task_cfg: Optional[Dict[str, Any]]) -> bool:
170:    """True when an automatic background review targets the managed local runtime under ``defer: auto``."""
171-    from agent.review_idle_queue import defer_mode, review_targets_managed_local
172-    return defer_mode(task_cfg) == "auto" and review_targets_managed_local(agent, task_cfg)
173-
174-
175-def _review_queue_key(agent: Any) -> str:
176-    return str(getattr(agent, "session_id", None) or id(agent))
177-
178-
179:def _notify_context_engine_session_end(agent: Any, messages: Optional[list]) -> None:
180:    """Tell the context engine the session ended (flush DAG, close DBs) at the same lifecycle moment as the
181-    memory manager, so per-session engine state never leaks into the next session."""
182:    engine = getattr(agent, "context_compressor", None)
183-    if engine:
184-        _quietly(lambda: engine.on_session_end(agent.session_id or "", messages or []))
185-
186-
187:def _pool_may_recover_from_rate_limit(pool) -> bool:
188:    """Wait for credential-pool rotation (True) or fall back to ``fallback_model`` (False) after a 429.
189-
190:    Rotation only helps when the pool has somewhere to go; a single-credential pool would retry the same quota.
191-
192-    See issues #11314 and #13636.
193-    """
194-    return pool is not None and pool.has_available() and len(pool.entries()) > 1
195-
196-
--
201-    """
202-
203-    def __init__(self, message: str, *, code: Optional[str] = None, param: Optional[str] = None,
204-                 status_code: Optional[int] = None) -> None:
205-        super().__init__(message)
206-        self.message, self.code, self.param, self.status_code = message, code, param, status_code
207:        # OpenAI SDK-shaped body so _extract_api_error_context / _summarize_api_error / classify_api_error pick it up.
208-        self.body: Dict[str, Any] = {"error": {"message": message, "code": code, "param": param, "type": "error"}}
209-
210-
211-class AIAgent(
212-    ClientLifecycleMixin, StreamDeliveryMixin, StatusOutputMixin, ApiRequestHooksMixin, ApiErrorSummaryMixin,
213:    InterruptControlMixin, TurnExplainersMixin, ActivityTrackingMixin, RateLimitCreditsMixin,
214:    SessionPersistenceMixin, CompressionFacadeMixin, TurnFacadeMixin, VisionMessagePrepMixin, ReasoningParamsMixin,
215-):
216-    """AI Agent with tool calling capabilities."""
217-
218-    _TOOL_CALL_ARGUMENTS_CORRUPTION_MARKER = (
219-        "[hermes-agent: tool call arguments were corrupted in this session and "
220-        "have been dropped to keep the conversation alive. See issue #15236.]"
--
231-        self._base_url_hostname = base_url_hostname(value)
232-
233-    def __init__(
234-        self,
235-        base_url: str = None, api_key: str = None, provider: str = None, api_mode: str = None,
236-        acp_command: str = None, acp_args: list[str] | None = None, command: str = None, args: list[str] | None = None,
237:        model: str = "",
238:        max_iterations: int = sys.maxsize,  # unlimited tool-calling iterations by default (shared with subagents)
239-        tool_delay: float = None,  # deprecated: accepted for compatibility, ignored
240:        enabled_toolsets: List[str] = None, disabled_toolsets: List[str] = None,
241-        save_trajectories: bool = False, verbose_logging: bool = False, quiet_mode: bool = False,
242-        tool_progress_mode: str = "all", ephemeral_system_prompt: str = None,
243-        log_prefix_chars: int = 100, log_prefix: str = "",
244-        providers_allowed: List[str] = None, providers_ignored: List[str] = None, providers_order: List[str] = None,
245-        provider_sort: str = None, provider_require_parameters: bool = False, provider_data_collection: str = None,
246-        openrouter_min_coding_score: Optional[float] = None,
247-        session_id: str = None,
248-        tool_progress_callback: callable = None, tool_start_callback: callable = None,
249:        tool_complete_callback: callable = None, thinking_callback: callable = None,
250:        reasoning_callback: callable = None, clarify_callback: callable = None,
251-        read_terminal_callback: callable = None, read_preview_callback: callable = None,
252-        drive_preview_callback: callable = None, read_window_below_callback: callable = None,
253-        setup_mcp_callback: callable = None, tour_callback: callable = None, step_callback: callable = None,
254-        stream_delta_callback: callable = None, interim_assistant_callback: callable = None,
255-        tool_gen_callback: callable = None, status_callback: callable = None,
256-        notice_callback: callable = None, notice_clear_callback: callable = None,
257-        event_callback: Optional[Callable[[str, dict], None]] = None,
258-        reaction_callback: Optional[Callable[[str], None]] = None,
259:        max_tokens: int = None, reasoning_config: Dict[str, Any] = None, service_tier: str = None,
260-        request_overrides: Dict[str, Any] = None, prefill_messages: List[Dict[str, Any]] = None,
261-        platform: str = None, user_id: str = None, user_id_alt: str = None, user_name: str = None,
262-        chat_id: str = None, chat_name: str = None, chat_type: str = None, thread_id: str = None,
263-        gateway_session_key: str = None,
264:        skip_context_files: bool = False, load_soul_identity: bool = False,
265:        skip_memory: bool = False, skip_background_review: bool = False,
266-        session_db=None, parent_session_id: str = None,
267-        iteration_budget: "IterationBudget" = None, run_budget_seconds: Optional[float] = None,
268:        fallback_model: Dict[str, Any] = None, credential_pool=None,
269-        checkpoints_enabled: bool = False, checkpoint_max_snapshots: int = 20,
270-        checkpoint_max_total_size_mb: int = 500, checkpoint_max_file_size_mb: int = 10,
271-        pass_session_id: bool = False, requested_provider: str = None,
272-        capabilities: Dict[str, bool] | None = None,
273-    ):
274-        """Forwarder — see ``agent.agent_init.init_agent`` (same keyword parameters, minus ``tool_delay``)."""
--
279-        from agent.agent_init import init_agent
280-        init_agent(self, **init_kwargs)
281-
282-    def _get_session_db_for_recall(self):
283-        """SessionDB for recall, opening the default state DB when no ``session_db`` was passed so the
284-        advertised ``session_search`` tool stays usable."""
285:        # Persistence-isolated forks (background review) must not lazily open the canonical state DB —
286-        # that would re-arm the flush to write the fork's harness turn into the user's real session.
287-        if getattr(self, "_persist_disabled", False):
288-            return None
289-        if self._session_db is not None:
290-            return self._session_db
291-        try:
--
295-            self._owns_session_db = True  # we opened it, so close() must release it
296-            return self._session_db
297-        except Exception:
298-            logger.debug("SessionDB unavailable for recall", exc_info=True)
299-            return None
300-
301:    def _session_row_model_config(self) -> Any:
302:        """``model_config`` for the session row: the init config plus the live YOLO bypass.
303-
304-        The row is created lazily on the first turn, so this is the only chance to record a pre-first-turn
305-        /yolo toggle for ``hermes --resume``.
306-        """
307:        model_config = self._session_init_model_config
308-        try:
309:            from tools.approval import is_session_yolo_enabled
310-            if is_session_yolo_enabled(self.session_id):
311:                model_config = dict(model_config or {})
312:                model_config["yolo_mode"] = True
313-        except Exception:
314-            pass
315:        return model_config
316-
317-    def _ensure_db_session(self) -> None:
318:        """Create the session DB row on first use; a transient failure leaves it to retry next turn."""
319-        if getattr(self, "_persist_disabled", False) or self._session_db_created or not self._session_db:
320-            return
321-        source = _session_source_for_agent(self.platform)
322-        try:
323-            # Persist the profile name explicitly, including "default": profile-keyed consumers treat NULL
324-            # as unowned.
--
332-                # @session:<profile>/<id> deep links) treat NULL as unowned — rows minted NULL after the
333-                # one-shot backfill vanished from the sidebar (#99222).
334-                profile_for_session = None
335-            # Carry the gateway routing identity: when the gateway SessionStore degraded to JSONL (corrupt
336-            # state.db) this lazy create is the ONLY durable write, and an identity-less row is unrecoverable.
337-            self._session_db.create_session(
338:                session_id=self.session_id, source=source, model=self.model,
339:                model_config=self._session_row_model_config(), system_prompt=self._cached_system_prompt,
340-                user_id=getattr(self, "_user_id", None), session_key=getattr(self, "_gateway_session_key", None),
341-                chat_id=getattr(self, "_chat_id", None), chat_type=getattr(self, "_chat_type", None),
342-                thread_id=getattr(self, "_thread_id", None),
343-                display_name=getattr(self, "_chat_name", None) or getattr(self, "_user_name", None),
344-                origin_json=_gateway_origin_json(self), parent_session_id=self._parent_session_id,
345-                cwd=_launch_cwd_for_session(source), profile_name=profile_for_session,
346-            )
347-            self._session_db_created = True
348-        except Exception as e:
349-            # Transient failure (e.g. SQLite lock): _session_db_created stays False so the next turn retries.
350:            logger.warning("Session DB creation failed (will retry next turn): %s", e)
351-
352:    def _transition_context_engine_session(
353-        self, *, old_session_id: Optional[str] = None, new_session_id: Optional[str] = None,
354:        previous_messages: Optional[list] = None, carry_over_context: bool = False, reset_engine: bool = True,
355:        **extra_context,
356-    ) -> None:
357:        """Drive the context engine's session transition: on_session_end → on_session_reset → on_session_start
358:        → carry_over_new_session_context. Each hook is optional (the built-in compressor only resets)."""
359:        engine = getattr(self, "context_compressor", None)
360-        if not engine:
361-            return
362-        if old_session_id and previous_messages is not None:
363-            _call_engine_hook(engine, "on_session_end", old_session_id, previous_messages)
364-        if reset_engine:
365-            _call_engine_hook(engine, "on_session_reset")
366-
367:        should_start = bool(old_session_id or previous_messages is not None or carry_over_context or extra_context)
368-        target_session_id = new_session_id or getattr(self, "session_id", "") or ""
369-        if should_start and target_session_id and hasattr(engine, "on_session_start"):
370:            start_context = {
371:                "old_session_id": old_session_id, "carry_over_context": carry_over_context,
372-                "platform": _session_source_for_agent(getattr(self, "platform", None)),
373:                "model": getattr(self, "model", ""), "context_length": getattr(engine, "context_length", None),
374:                "conversation_id": getattr(self, "_gateway_session_key", None), **extra_context,
375-            }
376:            start_context = {k: v for k, v in start_context.items() if v not in (None, "")}
377:            _call_engine_hook(engine, "on_session_start", target_session_id, **start_context)
378:        if carry_over_context and old_session_id and target_session_id:
379:            _call_engine_hook(engine, "carry_over_new_session_context", old_session_id, target_session_id)
380-
381-    def reset_session_state(self, previous_messages: Optional[list] = None, old_session_id: Optional[str] = None,
382:                            carry_over_context: bool = False):
383:        """Reset session-scoped token/cost counters and compressor state for a fresh session.
384-
385:        With ``previous_messages`` / ``old_session_id`` / ``carry_over_context`` the context engine gets the
386-        full transition lifecycle instead of a bare reset.
387-        """
388-        for counter in (
389-            "session_total_tokens", "session_input_tokens", "session_output_tokens", "session_prompt_tokens",
390-            "session_completion_tokens", "session_cache_read_tokens", "session_cache_write_tokens",
391:            "session_reasoning_tokens", "session_api_calls",
392-        ):
393-            setattr(self, counter, 0)
394-        self.session_estimated_cost_usd = 0.0
395-        self.session_cost_status = "unknown"
396-        self.session_cost_source = "none"
397-
--
404-
405-        # Turn counter (added after reset_session_state was first written — #2635)
406-        self._user_turn_count = 0
407-        # Copilot x-initiator: True for the first API call of a user turn, False for tool-loop follow-ups.
408-        self._is_user_initiated_turn = False
409-
410:        self._transition_context_engine_session(
411-            old_session_id=old_session_id, new_session_id=getattr(self, "session_id", None),
412:            previous_messages=previous_messages, carry_over_context=carry_over_context, reset_engine=True,
413-        )
414-
415-        # Reset-only switches (/new, /resume, /branch) change session_id before this call; rebind the
416:        # built-in compressor's session-keyed cooldown state when no full start hook ran.
417:        engine = getattr(self, "context_compressor", None)
418-        target_session_id = getattr(self, "session_id", "") or ""
419-        if (engine is not None and hasattr(engine, "bind_session_state") and target_session_id
420-                and target_session_id != getattr(engine, "_session_id", "")):
421-            try:
422-                engine.bind_session_state(getattr(self, "_session_db", None), target_session_id)
423-            except Exception as exc:
424:                logger.debug("context engine bind_session_state during reset: %s", exc)
425-
426-    @staticmethod
427:    def _effective_lmstudio_context_length(config_context_length: Optional[int], runtime_context_length: Any) -> Optional[int]:
428:        """Return a safe context budget from explicit intent and verified runtime."""
429:        explicit = _positive_int(config_context_length)
430:        runtime = _positive_int(getattr(runtime_context_length, "context_length", runtime_context_length))
431:        if bool(getattr(runtime_context_length, "rejected", False)) or (
432:            bool(getattr(runtime_context_length, "load_attempted", False)) and runtime is None
433-        ):
434-            return None
435-        if runtime is not None and explicit is not None:
436-            return min(runtime, explicit)
437-        return runtime if runtime is not None else explicit
438-
439-    @staticmethod
440-    def _lmstudio_load_was_unverified(load_result: Any) -> bool:
441-        """Return true when a management load was rejected or unverifiable."""
442-        return bool(getattr(load_result, "rejected", False)) or (
443:            bool(getattr(load_result, "load_attempted", False)) and getattr(load_result, "context_length", None) is None
444-        )
445-
446:    def _ensure_lmstudio_runtime_loaded(self, config_context_length: Optional[int] = None) -> Any:
447-        """Preload LM Studio unless configured to rely on JIT loading."""
448-        if (self.provider or "").strip().lower() != "lmstudio":
449-            return None
450-        if (getattr(self, "lmstudio_load_mode", "explicit") or "explicit").strip().lower() == "jit":
451-            logger.debug("LM Studio explicit preload skipped: lmstudio_load_mode=jit")
452-            return None
453:        from hermes_cli.models_local import ensure_lmstudio_model_loaded
454-
455:        if config_context_length is None:
456:            config_context_length = getattr(self, "_config_context_length", None)
457:        return ensure_lmstudio_model_loaded(
458:            self.model, self.base_url, getattr(self, "api_key", ""), config_context_length, return_load_result=True,
459-        )
460-
461:    switch_model = _forward("agent.agent_runtime_helpers", "switch_model")
462-
463:    def _disable_codex_reasoning_replay(self, messages: Optional[List[Dict[str, Any]]] = None) -> Dict[str, int]:
464:        """On HTTP 400 ``invalid_encrypted_content``: disable Responses reasoning replay and pop
465:        ``codex_reasoning_items`` from every assistant message. Returns ``{"messages", "items"}`` counts."""
466-        stripped_messages = stripped_items = 0
467-        for msg in (messages if isinstance(messages, list) else []):
468-            if not isinstance(msg, dict) or msg.get("role") != "assistant":
469-                continue
470:            items = msg.pop("codex_reasoning_items", None)
471-            if isinstance(items, list) and items:
472-                stripped_messages += 1
473-                stripped_items += len(items)
474:        self._codex_reasoning_replay_enabled = False
475-        return {"messages": stripped_messages, "items": stripped_items}
476-
477-    _stream_diag_init = _forward_static("agent.stream_diag", "stream_diag_init")
478-    _stream_diag_capture_response = _forward("agent.stream_diag", "stream_diag_capture_response")
479-    _flatten_exception_chain = _forward_static("agent.stream_diag", "flatten_exception_chain")
480-
481-    def _is_provider_stream_parse_error(self, error: BaseException) -> bool:
482-        """True for a malformed Anthropic event-stream frame (surfaced by the SDK as a plain ``ValueError``);
483:        that is wire trouble, not local validation, so it follows the truncated-JSON retry path."""
484-        return (getattr(self, "api_mode", None) == "anthropic_messages" and isinstance(error, ValueError)
485-                and not isinstance(error, (UnicodeEncodeError, json.JSONDecodeError))
486-                and "expected ident at line" in str(error).strip().lower())
487-
488:    _log_stream_retry = _forward("agent.stream_diag", "log_stream_retry")
489-    _emit_stream_drop = _forward("agent.stream_diag", "emit_stream_drop")
490-
491:    def _emit_auxiliary_failure(self, task: str, exc: BaseException) -> None:
492:        """Surface a compact warning for failed auxiliary work."""
493-        try:
494-            detail = self._summarize_api_error(exc)
495-        except Exception:
496-            detail = str(exc)
497-        detail = (detail or exc.__class__.__name__).strip()
498-        if len(detail) > 220:
499-            detail = detail[:217].rstrip() + "..."
500:        self._emit_warning(f"⚠ Auxiliary {task} failed: {detail}")
501-
502-    def _current_main_runtime(self) -> Dict[str, str]:
503:        """Return the live main runtime for session-scoped auxiliary routing."""
504:        return {key: getattr(self, key, "") or "" for key in ("model", "provider", "base_url", "api_key", "api_mode", "auth_mode")}
505-
506:    _check_compression_model_feasibility = _forward("agent.conversation_compression", "check_compression_model_feasibility")
507:    _replay_compression_warning = _forward("agent.conversation_compression", "replay_compression_warning")
508-
509-    def _hostname_for(self, base_url: Optional[str]) -> str:
510-        """Hostname of ``base_url``, or of the agent's own base URL when None."""
511-        if base_url is not None:
512-            return base_url_hostname(base_url)
513-        return getattr(self, "_base_url_hostname", "") or base_url_hostname(getattr(self, "_base_url_lower", ""))
--
523-
524-    def _is_github_copilot_url(self, base_url: str = None) -> bool:
525-        """Return True when a base URL targets GitHub Copilot's OpenAI-compatible API."""
526-        hostname = self._hostname_for(base_url)
527-        return bool(hostname) and (hostname == "api.githubcopilot.com" or hostname.endswith(".githubcopilot.com"))
528-
529:    def _resolved_api_call_timeout(self) -> float:
530:        """Per-call request timeout: per-model ``timeout_seconds`` > provider ``request_timeout_seconds`` >
531:        ``HERMES_API_TIMEOUT`` > 1800s."""
532:        cfg = get_provider_request_timeout(self.provider, self.model)
533:        return cfg if cfg is not None else env_float("HERMES_API_TIMEOUT", 1800.0)
534-
535:    def _resolved_api_call_stale_timeout_base(self) -> tuple[float, bool]:
536:        """Base non-stream stale timeout: per-model ``stale_timeout_seconds`` > provider-wide >
537:        ``HERMES_API_CALL_STALE_TIMEOUT`` > reasoning floor > 90s.
538-
539-        Returns ``(seconds, uses_implicit_default)``; the implicit flag lets callers auto-disable the detector
540-        for local endpoints only when the user configured nothing.
541-        """
542:        cfg = get_provider_stale_timeout(self.provider, self.model)
543-        if cfg is not None:
544-            return cfg, False
545:        env_timeout = os.getenv("HERMES_API_CALL_STALE_TIMEOUT")
546:        if env_timeout is not None:
547:            return float(env_timeout), False
548:        # Reasoning-model floor (cloud gateways idle-kill mid-think); not "implicit" so the local-endpoint
549-        # short-circuit does not disable stale detection here.
550:        from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
551:        reasoning_floor = get_reasoning_stale_timeout_floor(self.model)
552:        if reasoning_floor is not None:
553:            return reasoning_floor, False
554-        return 90.0, True
555-
556:    def _compute_non_stream_stale_timeout(self, api_payload: Any) -> float:
557:        """Effective non-stream stale timeout for ``api_payload`` (an ``api_kwargs`` dict or legacy ``messages``
558:        list), scaled by estimated context size and capped by the run budget."""
559:        stale_base, uses_implicit_default = self._resolved_api_call_stale_timeout_base()
560-        base_url = getattr(self, "_base_url", None) or self.base_url or ""
561-        if uses_implicit_default and base_url and is_local_endpoint(base_url):
562-            return float("inf")
563-
564:        from agent.chat_completion_helpers import estimate_request_context_tokens
565:        est_tokens = estimate_request_context_tokens(api_payload)
566:        timeout = max(stale_base, 240.0) if est_tokens > 100_000 else max(stale_base, 150.0) if est_tokens > 50_000 else stale_base
567:        # Run-budget cap: an implicit stale timeout is capped at half the remaining budget (>= 60s) so one
568:        # hung call cannot eat the run. Never raises the timeout; explicit user config still wins.
569-        run_budget = getattr(self, "run_budget_seconds", None)
570-        started = getattr(self, "_run_budget_started_at", None)
571:        if run_budget and started and not self._stale_timeout_is_explicit():
572-            remaining = float(run_budget) - (time.time() - started)
573:            timeout = min(timeout, max(60.0, remaining * 0.5))
574:        return timeout
575-
576:    def _stale_timeout_is_explicit(self) -> bool:
577:        """True when the user explicitly configured the stale timeout (config or env var); implicit values
578:        (reasoning floors, the 90s default) yield to the run-budget cap, explicit ones never do."""
579:        return (get_provider_stale_timeout(self.provider, self.model) is not None
580:                or os.getenv("HERMES_API_CALL_STALE_TIMEOUT") is not None)
581-
582:    def _codex_silent_hang_hint(self, model: Optional[str] = None) -> Optional[str]:
583-        """Actionable hint when the request matches a known Codex silent-reject shape (currently the ``gpt-5.5``
584:        family: connection accepted, no events, no error), else None. Makes the stale timeout actionable."""
585-        if self.api_mode != "codex_responses":
586-            return None
587-        from agent.codex_responses_adapter import classify_responses_route
588-
589-        if not classify_responses_route(self).is_codex_backend:
590-            return None
591:        eff_model = (model if model is not None else self.model) or ""
592-        # Match the gpt-5.5 family at word boundaries (bare, -codex, vendor-prefixed) but not gpt-5.50.
593:        if not re.search(r"(?:^|[/\-_])gpt-5\.5(?:$|[\-_])", eff_model.lower()):
594-            return None
595-        return (
596:            f"Codex backend appears to be silently rejecting {eff_model!r} "
597-            "on chatgpt.com/backend-api/codex (no stream events, no error). "
598-            "This is a known backend-side pattern that has affected ChatGPT "
599-            "Plus accounts intermittently. "
600-            "Workaround: try `gpt-5.4` on the same OAuth profile, or `gpt-5.3-codex`, "
601:            "or switch to a different model/provider in your fallback chain. "
602-            "Some ChatGPT Codex accounts do not support `gpt-5.4-codex`. "
603-            "See hermes-agent#21444 for symptom history."
604-        )
605-
606-    def _is_openrouter_url(self) -> bool:
607-        """Return True when the base URL targets OpenRouter."""
608-        return base_url_host_matches(self._base_url_lower, "openrouter.ai")
609-
610-    def _is_copilot_url(self) -> bool:
611:        """Return True when the base URL targets GitHub Copilot or GitHub Models."""
612:        return any(base_url_host_matches(self._base_url_lower, h) for h in ("api.githubcopilot.com",