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",