mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-18 05:30:21 +00:00
Merge remote-tracking branch 'origin/master' into p6137b
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"""Fail-closed guard for in-process Hermes Agent source revisions.
|
||||
|
||||
Hermes WebUI currently imports ``run_agent.AIAgent`` into its long-lived server
|
||||
process. If the Agent checkout changes while that process is alive, Python may
|
||||
combine already-cached modules with newly-read source. Refuse to reuse that
|
||||
mixed runtime and require a clean WebUI restart instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
# Retain the discovered path as a diagnostic/test-visible compatibility value;
|
||||
# runtime identity is deliberately captured from the loaded module below.
|
||||
from api.config import _AGENT_DIR # noqa: F401
|
||||
|
||||
_RESTART_MESSAGE = (
|
||||
"Hermes Agent was updated while Hermes WebUI was running. "
|
||||
"Restart Hermes WebUI before retrying this action."
|
||||
)
|
||||
|
||||
|
||||
def _read_agent_revision(
|
||||
agent_dir: Path | None,
|
||||
*,
|
||||
module_path: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Return the loaded Agent checkout HEAD, or ``None`` if it is not tracked."""
|
||||
if agent_dir is None:
|
||||
return None
|
||||
|
||||
if module_path is None:
|
||||
module = sys.modules.get("run_agent")
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return None
|
||||
try:
|
||||
module_path = Path(module_file).resolve()
|
||||
except (OSError, RuntimeError, TypeError):
|
||||
return None
|
||||
|
||||
try:
|
||||
worktree_result = subprocess.run(
|
||||
["git", "-C", str(agent_dir), "rev-parse", "--show-toplevel"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if worktree_result.returncode != 0:
|
||||
return None
|
||||
worktree = Path(worktree_result.stdout.strip()).resolve()
|
||||
relative_module = module_path.relative_to(worktree).as_posix()
|
||||
tracked_result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"--literal-pathspecs",
|
||||
"-C",
|
||||
str(worktree),
|
||||
"ls-files",
|
||||
"--error-unmatch",
|
||||
"--",
|
||||
relative_module,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if tracked_result.returncode != 0:
|
||||
return None
|
||||
revision_result = subprocess.run(
|
||||
["git", "-C", str(worktree), "rev-parse", "--verify", "HEAD"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired, RuntimeError, ValueError):
|
||||
return None
|
||||
|
||||
revision = revision_result.stdout.strip()
|
||||
return revision if revision_result.returncode == 0 and revision else None
|
||||
|
||||
|
||||
_AGENT_SOURCE_DIR: Path | None = None
|
||||
_AGENT_MODULE_PATH: Path | None = None
|
||||
_AGENT_REVISION: str | None = None
|
||||
_AIAgent = None
|
||||
_RUNTIME_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class AgentRuntimeChangedError(RuntimeError):
|
||||
"""Raised when the loaded Agent runtime no longer matches its source tree."""
|
||||
|
||||
|
||||
def _loaded_agent_source_identity() -> tuple[Path, Path] | None:
|
||||
"""Return the source directory and file that supplied ``run_agent``."""
|
||||
module = sys.modules.get("run_agent")
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return None
|
||||
try:
|
||||
module_path = Path(module_file).resolve()
|
||||
return module_path.parent, module_path
|
||||
except (OSError, RuntimeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _capture_loaded_agent_revision() -> None:
|
||||
"""Bind the guard to the checkout that supplied the loaded Agent module."""
|
||||
global _AGENT_SOURCE_DIR, _AGENT_MODULE_PATH, _AGENT_REVISION
|
||||
|
||||
if _AGENT_REVISION is not None:
|
||||
ensure_agent_runtime_current()
|
||||
return
|
||||
|
||||
identity = _loaded_agent_source_identity()
|
||||
if identity is None:
|
||||
return
|
||||
source_dir, module_path = identity
|
||||
current_revision = _read_agent_revision(source_dir, module_path=module_path)
|
||||
_AGENT_SOURCE_DIR = source_dir
|
||||
_AGENT_MODULE_PATH = module_path
|
||||
_AGENT_REVISION = current_revision
|
||||
|
||||
|
||||
def ensure_agent_runtime_current() -> None:
|
||||
"""Reject a known Git checkout change instead of mixing Python modules."""
|
||||
if _AGENT_REVISION is None:
|
||||
return
|
||||
if (
|
||||
_read_agent_revision(_AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH)
|
||||
!= _AGENT_REVISION
|
||||
):
|
||||
raise AgentRuntimeChangedError(_RESTART_MESSAGE)
|
||||
|
||||
|
||||
def require_ai_agent_class():
|
||||
"""Import ``AIAgent`` after proving the loaded source revision is current."""
|
||||
ensure_agent_runtime_current()
|
||||
from run_agent import AIAgent # noqa: PLC0415
|
||||
|
||||
_capture_loaded_agent_revision()
|
||||
return AIAgent
|
||||
|
||||
|
||||
def get_ai_agent_class():
|
||||
"""Return ``AIAgent`` while preserving the existing lazy-import retry."""
|
||||
global _AIAgent, _AGENT_REVISION
|
||||
|
||||
with _RUNTIME_LOCK:
|
||||
ensure_agent_runtime_current()
|
||||
if _AIAgent is None:
|
||||
try:
|
||||
agent_class = require_ai_agent_class()
|
||||
except ImportError:
|
||||
return None
|
||||
_AIAgent = agent_class
|
||||
return _AIAgent
|
||||
+17
-1
@@ -63,7 +63,16 @@ _CLI_SESSIONS_CACHE_STREAMING_TTL_SECONDS = 45.0
|
||||
_CLI_SESSIONS_CACHE_LOCK = threading.Lock()
|
||||
_CLI_SESSIONS_CACHE_INFLIGHT: "dict[tuple, threading.Event]" = {}
|
||||
_CLI_SESSIONS_CACHE_INVALIDATION_VERSION = 0
|
||||
_CLI_SESSIONS_CACHE = {}
|
||||
# LRU-bounded (drop-oldest) so a long-lived process under churn — where the
|
||||
# state.db fingerprint advances on every streamed message and the structural
|
||||
# clear-on-mutation listener doesn't fire for every fingerprint advance — can't
|
||||
# accumulate orphaned heavy deepcopies. Each value is a copy.deepcopy() of the
|
||||
# full CLI/cron session list (the expensive projection behind #4842/#4672), so
|
||||
# the cap is deliberately small. TTL is still the primary freshness control;
|
||||
# the cap is the backstop that the plain dict previously lacked. Mirrors the
|
||||
# _CLAUDE_CODE_PARSE_CACHE / _SIDECAR_METADATA_CACHE LRU pattern.
|
||||
_CLI_SESSIONS_CACHE: "collections.OrderedDict[tuple, tuple]" = collections.OrderedDict()
|
||||
_CLI_SESSIONS_CACHE_MAX_ENTRIES = 8
|
||||
_CLI_SESSIONS_CACHE_WAIT_SECONDS = 0.25
|
||||
# Event waits that keep stale rows visible while a rebuild is in flight.
|
||||
_CLI_SESSIONS_CACHE_STALE_WAIT_SECONDS = 0.10
|
||||
@@ -6405,6 +6414,9 @@ def _cache_cli_sessions_if_current(
|
||||
invalidation_stamp,
|
||||
_copy_cli_sessions(sessions),
|
||||
)
|
||||
_CLI_SESSIONS_CACHE.move_to_end(cache_key)
|
||||
while len(_CLI_SESSIONS_CACHE) > _CLI_SESSIONS_CACHE_MAX_ENTRIES:
|
||||
_CLI_SESSIONS_CACHE.popitem(last=False)
|
||||
return True
|
||||
|
||||
|
||||
@@ -6423,6 +6435,8 @@ def _copy_fresh_cli_sessions_cache_entry(cache_key: tuple):
|
||||
return None
|
||||
if cached_expires_at <= time.monotonic():
|
||||
return None
|
||||
# LRU: a fresh hit is the most-recently-used entry.
|
||||
_CLI_SESSIONS_CACHE.move_to_end(cache_key)
|
||||
return _copy_cli_sessions(cached_sessions)
|
||||
|
||||
|
||||
@@ -7272,6 +7286,8 @@ def get_cli_sessions(
|
||||
if cached_stamp != _CLI_SESSIONS_CACHE_INVALIDATION_VERSION:
|
||||
_CLI_SESSIONS_CACHE.pop(cache_key, None)
|
||||
elif cached_expires_at > now:
|
||||
# LRU: a fresh hit is the most-recently-used entry.
|
||||
_CLI_SESSIONS_CACHE.move_to_end(cache_key)
|
||||
return _copy_cli_sessions(cached_sessions)
|
||||
else:
|
||||
stale_sessions = _copy_cli_sessions(cached_sessions)
|
||||
|
||||
+145
-8
@@ -34,6 +34,11 @@ from contextlib import closing
|
||||
from urllib.parse import parse_qs, quote, unquote, urljoin, urlsplit
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
|
||||
from api.agent_runtime import (
|
||||
AgentRuntimeChangedError,
|
||||
ensure_agent_runtime_current,
|
||||
require_ai_agent_class,
|
||||
)
|
||||
from api.agent_sessions import (
|
||||
MESSAGING_SOURCES,
|
||||
_looks_like_default_cli_title,
|
||||
@@ -16052,6 +16057,7 @@ def handle_post(handler, parsed) -> bool:
|
||||
"api_key": _main_api_key,
|
||||
}
|
||||
|
||||
ensure_agent_runtime_current()
|
||||
try:
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
|
||||
@@ -16072,7 +16078,7 @@ def handle_post(handler, parsed) -> bool:
|
||||
except Exception as _e:
|
||||
logger.debug("update summary auxiliary model failed; falling back to main model: %s", _e)
|
||||
|
||||
from run_agent import AIAgent
|
||||
AIAgent = require_ai_agent_class()
|
||||
|
||||
agent = AIAgent(
|
||||
model=_main_model,
|
||||
@@ -20425,6 +20431,9 @@ def _handle_btw(handler, body):
|
||||
require(body, "question")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
stale_response = _agent_runtime_barrier_response(runner_local_owned=False)
|
||||
if stale_response is not None:
|
||||
return j(handler, stale_response, status=409)
|
||||
if _session_is_subagent_view_only(str(body.get("session_id") or "")):
|
||||
return bad(handler, "Subagent sessions are view-only and cannot be used for /btw from WebUI", 400)
|
||||
try:
|
||||
@@ -20484,6 +20493,9 @@ def _handle_background(handler, body):
|
||||
require(body, "prompt")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
stale_response = _agent_runtime_barrier_response(runner_local_owned=False)
|
||||
if stale_response is not None:
|
||||
return j(handler, stale_response, status=409)
|
||||
try:
|
||||
s = get_session(body["session_id"])
|
||||
except KeyError:
|
||||
@@ -20770,6 +20782,31 @@ def _active_run_stream_for_session(session_id: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _agent_runtime_barrier_response(
|
||||
*,
|
||||
runner_local_owned: bool = False,
|
||||
external_runtime_owned: bool | None = None,
|
||||
) -> dict | None:
|
||||
"""Return the typed stale-runtime response for local in-process turns."""
|
||||
if external_runtime_owned is True:
|
||||
return None
|
||||
if runner_local_owned and webui_gateway_chat_enabled(get_config()):
|
||||
return None
|
||||
from api.runtime_adapter import runtime_adapter_runner_enabled
|
||||
|
||||
if runner_local_owned and runtime_adapter_runner_enabled():
|
||||
return None
|
||||
try:
|
||||
ensure_agent_runtime_current()
|
||||
except AgentRuntimeChangedError as exc:
|
||||
return {
|
||||
"error": str(exc),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _start_chat_stream_for_session(
|
||||
s,
|
||||
*,
|
||||
@@ -20783,8 +20820,18 @@ def _start_chat_stream_for_session(
|
||||
goal_related: bool = False,
|
||||
source: str = "webui",
|
||||
moa_config=None,
|
||||
external_runtime_owned: bool | None = None,
|
||||
):
|
||||
"""Persist pending state, register an SSE channel, and start an agent turn."""
|
||||
if external_runtime_owned is None:
|
||||
external_runtime_owned = webui_gateway_chat_enabled(get_config())
|
||||
backend_is_gateway = bool(external_runtime_owned)
|
||||
stale_response = _agent_runtime_barrier_response(
|
||||
external_runtime_owned=backend_is_gateway,
|
||||
)
|
||||
if stale_response is not None:
|
||||
stale_response["_status"] = 409
|
||||
return stale_response
|
||||
attachments = attachments or []
|
||||
# Prevent duplicate runs in the same session while a stream is still active.
|
||||
# This commonly happens after page refresh/reconnect races and can produce
|
||||
@@ -20903,7 +20950,6 @@ def _start_chat_stream_for_session(
|
||||
if goal_related:
|
||||
STREAM_GOAL_RELATED[stream_id] = True
|
||||
diag.stage("worker_thread_start") if diag else None
|
||||
backend_is_gateway = webui_gateway_chat_enabled(get_config())
|
||||
worker_target = _run_gateway_chat_streaming if backend_is_gateway else _run_agent_streaming
|
||||
worker_kwargs = {"model_provider": model_provider, "goal_related": goal_related}
|
||||
if moa_config and not backend_is_gateway:
|
||||
@@ -21074,6 +21120,7 @@ def _start_run(
|
||||
diag=diag,
|
||||
source=source,
|
||||
moa_config=moa_config,
|
||||
external_runtime_owned=webui_gateway_chat_enabled(get_config()),
|
||||
)
|
||||
|
||||
|
||||
@@ -21167,6 +21214,10 @@ def start_session_turn(
|
||||
msg = str(message or "").strip()
|
||||
if not msg:
|
||||
return {"error": "message is required", "_status": 400}
|
||||
stale_response = _agent_runtime_barrier_response(runner_local_owned=True)
|
||||
if stale_response is not None:
|
||||
stale_response["_status"] = 409
|
||||
return stale_response
|
||||
turn_source = str(source or "process_wakeup").strip() or "process_wakeup"
|
||||
try:
|
||||
s = get_session(session_id)
|
||||
@@ -21643,6 +21694,7 @@ def _handle_goal_command(handler, body):
|
||||
model_provider=model_provider,
|
||||
normalized_model=normalized_model,
|
||||
goal_related=True,
|
||||
external_runtime_owned=webui_gateway_chat_enabled(get_config()),
|
||||
)
|
||||
status = int(stream_response.pop("_status", 200) or 200)
|
||||
payload.update(stream_response)
|
||||
@@ -21661,6 +21713,12 @@ def _handle_chat_start(handler, body, diag=None):
|
||||
require(body, "session_id")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
# Reject a stale local Agent runtime before materialising, claiming, or
|
||||
# mutating any session state. Gateway-backed turns run in the gateway's
|
||||
# process and do not depend on this WebUI process's imported checkout.
|
||||
stale_response = _agent_runtime_barrier_response(runner_local_owned=True)
|
||||
if stale_response is not None:
|
||||
return j(handler, stale_response, status=409)
|
||||
diag.stage("get_session") if diag else None
|
||||
try:
|
||||
s = _get_or_materialize_session(body["session_id"], refresh_cli_messages=True)
|
||||
@@ -21956,6 +22014,9 @@ def _normalize_chat_attachments(raw_attachments):
|
||||
|
||||
def _handle_chat_sync(handler, body):
|
||||
"""Fallback synchronous chat endpoint (POST /api/chat). Not used by frontend."""
|
||||
stale_response = _agent_runtime_barrier_response(runner_local_owned=False)
|
||||
if stale_response is not None:
|
||||
return j(handler, stale_response, status=409)
|
||||
if _session_is_subagent_view_only(str(body.get("session_id") or "")):
|
||||
return bad(handler, "Subagent sessions are view-only and cannot be written from WebUI", 400)
|
||||
s = get_session(body["session_id"])
|
||||
@@ -21991,7 +22052,7 @@ def _handle_chat_sync(handler, body):
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = s.session_id
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
AIAgent = require_ai_agent_class()
|
||||
|
||||
with CHAT_LOCK:
|
||||
from api.config import (
|
||||
@@ -22622,6 +22683,7 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None)
|
||||
"base_url": _main_base_url,
|
||||
"api_key": _main_api_key,
|
||||
}
|
||||
ensure_agent_runtime_current()
|
||||
try:
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
|
||||
@@ -22638,7 +22700,7 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None)
|
||||
except Exception as _e:
|
||||
logger.debug("git commit message auxiliary model failed; falling back to main model: %s", _e)
|
||||
|
||||
from run_agent import AIAgent
|
||||
AIAgent = require_ai_agent_class()
|
||||
|
||||
agent = AIAgent(
|
||||
model=_main_model,
|
||||
@@ -22684,6 +22746,12 @@ def _handle_git_commit_message(handler, body):
|
||||
return bad(handler, str(e))
|
||||
except GitWorkspaceError as e:
|
||||
return _git_bad(handler, e)
|
||||
except AgentRuntimeChangedError as e:
|
||||
return j(handler, {
|
||||
"error": str(e),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}, status=409)
|
||||
except Exception as e:
|
||||
logger.exception("git commit message generation failed")
|
||||
return bad(handler, _sanitize_error(e), 500)
|
||||
@@ -22715,6 +22783,12 @@ def _handle_git_commit_message_selected(handler, body):
|
||||
return bad(handler, str(e))
|
||||
except GitWorkspaceError as e:
|
||||
return _git_bad(handler, e)
|
||||
except AgentRuntimeChangedError as e:
|
||||
return j(handler, {
|
||||
"error": str(e),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}, status=409)
|
||||
except Exception as e:
|
||||
logger.exception("selected git commit message generation failed")
|
||||
return bad(handler, _sanitize_error(e), 500)
|
||||
@@ -23739,6 +23813,10 @@ def _manual_compression_status_payload(job):
|
||||
payload["ok"] = False
|
||||
payload["error"] = job.get("error") or "Compression failed"
|
||||
payload["error_status"] = int(job.get("error_status") or 400)
|
||||
if job.get("error_type"):
|
||||
payload["type"] = job["error_type"]
|
||||
if job.get("retryable") is not None:
|
||||
payload["retryable"] = bool(job["retryable"])
|
||||
elif status == "cancelled":
|
||||
payload["ok"] = False
|
||||
payload["error"] = job.get("error") or "Compression cancelled"
|
||||
@@ -23773,6 +23851,8 @@ def _run_manual_compression_job(sid, body):
|
||||
"status": "error",
|
||||
"error": str((payload or {}).get("error") or "Compression failed"),
|
||||
"error_status": status,
|
||||
"error_type": (payload or {}).get("type"),
|
||||
"retryable": (payload or {}).get("retryable"),
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
@@ -23784,6 +23864,21 @@ def _run_manual_compression_job(sid, body):
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
except AgentRuntimeChangedError as exc:
|
||||
logger.warning("Manual compression worker found stale Agent runtime for session %s", sid)
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
job = _MANUAL_COMPRESSION_JOBS.get(sid)
|
||||
if job:
|
||||
job.update(
|
||||
{
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
"error_status": 409,
|
||||
"error_type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Manual compression worker failed for session %s: %s", sid, exc)
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
@@ -23820,6 +23915,34 @@ def _handle_session_compress_start(handler, body):
|
||||
if focus_topic:
|
||||
job_body["focus_topic"] = focus_topic
|
||||
|
||||
# Repeated start requests observe an existing running job and do not admit
|
||||
# new Agent work, so preserve that idempotent read even if the checkout has
|
||||
# since changed. Do not hold the job lock while running Git subprocesses.
|
||||
now = time.time()
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
_manual_compression_cleanup_locked(now)
|
||||
existing = _MANUAL_COMPRESSION_JOBS.get(sid)
|
||||
if existing:
|
||||
existing_payload = _manual_compression_status_payload(existing)
|
||||
if existing_payload.get("status") == "running":
|
||||
return j(handler, existing_payload)
|
||||
|
||||
# Reject a stale local Agent runtime before creating the asynchronous job.
|
||||
try:
|
||||
ensure_agent_runtime_current()
|
||||
except AgentRuntimeChangedError as exc:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"error": str(exc),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
|
||||
# Another start request may have admitted a job while the runtime check ran.
|
||||
# Re-check under the lock so only one worker is created.
|
||||
now = time.time()
|
||||
with _MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
_manual_compression_cleanup_locked(now)
|
||||
@@ -24035,10 +24158,11 @@ def _handle_session_compress(handler, body):
|
||||
focus_topic,
|
||||
)
|
||||
|
||||
ensure_agent_runtime_current()
|
||||
import api.config as _cfg
|
||||
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
|
||||
import hermes_cli.runtime_provider as _runtime_provider
|
||||
import run_agent as _run_agent
|
||||
AIAgent = require_ai_agent_class()
|
||||
|
||||
resolved_model, resolved_provider, resolved_base_url = _cfg.resolve_model_provider(
|
||||
_cfg.model_with_provider_context(s.model, getattr(s, "model_provider", None))
|
||||
@@ -24081,7 +24205,7 @@ def _handle_session_compress(handler, body):
|
||||
)
|
||||
approx_tokens = _estimate_messages_tokens_rough(original_messages)
|
||||
|
||||
agent = _run_agent.AIAgent(
|
||||
agent = AIAgent(
|
||||
model=resolved_model,
|
||||
provider=resolved_provider,
|
||||
base_url=resolved_base_url,
|
||||
@@ -24176,6 +24300,12 @@ def _handle_session_compress(handler, body):
|
||||
"focus_topic": focus_topic,
|
||||
},
|
||||
)
|
||||
except AgentRuntimeChangedError as e:
|
||||
return j(handler, {
|
||||
"error": str(e),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}, status=409)
|
||||
except Exception as e:
|
||||
logger.warning("Manual session compression failed: %s", e)
|
||||
return bad(handler, f"Compression failed: {_sanitize_error(e)}")
|
||||
@@ -24685,10 +24815,11 @@ def _handle_handoff_summary(handler, body):
|
||||
|
||||
# Call LLM for summary.
|
||||
try:
|
||||
ensure_agent_runtime_current()
|
||||
import api.config as _cfg
|
||||
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
|
||||
import hermes_cli.runtime_provider as _runtime_provider
|
||||
import run_agent as _run_agent
|
||||
AIAgent = require_ai_agent_class()
|
||||
|
||||
# Try to resolve model from an existing session, fall back to default.
|
||||
resolved_model = None
|
||||
@@ -24744,7 +24875,7 @@ def _handle_handoff_summary(handler, body):
|
||||
"fallback": True,
|
||||
})
|
||||
|
||||
agent = _run_agent.AIAgent(
|
||||
agent = AIAgent(
|
||||
model=resolved_model,
|
||||
provider=resolved_provider,
|
||||
base_url=resolved_base_url,
|
||||
@@ -24824,6 +24955,12 @@ def _handle_handoff_summary(handler, body):
|
||||
"rounds": rounds,
|
||||
"fallback": fallback,
|
||||
})
|
||||
except AgentRuntimeChangedError as e:
|
||||
return j(handler, {
|
||||
"error": str(e),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
}, status=409)
|
||||
except Exception as e:
|
||||
logger.warning("Handoff summary generation failed: %s", e)
|
||||
summary_text = _fallback_handoff_summary(msgs)
|
||||
|
||||
+109
-20
@@ -564,10 +564,13 @@ def _prewarm_skill_tool_modules():
|
||||
|
||||
|
||||
# Lazy import to avoid circular deps -- hermes-agent is on sys.path via api/config.py
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
except ImportError:
|
||||
AIAgent = None
|
||||
from api.agent_runtime import ensure_agent_runtime_current, get_ai_agent_class
|
||||
|
||||
|
||||
# Eagerly attempt the import at startup, matching the pre-guard behavior. If
|
||||
# dependencies are not ready yet, _get_ai_agent() retries when a chat starts.
|
||||
AIAgent = get_ai_agent_class()
|
||||
|
||||
|
||||
def _get_ai_agent():
|
||||
"""Return AIAgent class, retrying the import if the initial attempt failed.
|
||||
@@ -575,15 +578,13 @@ def _get_ai_agent():
|
||||
auto_install_agent_deps() in server.py may install missing packages after
|
||||
this module is first imported (common in Docker with a volume-mounted agent).
|
||||
Re-attempting the import here picks up the newly installed packages without
|
||||
requiring a server restart.
|
||||
requiring a server restart. The shared runtime guard also refuses to reuse
|
||||
cached Agent modules after the source checkout changes.
|
||||
"""
|
||||
global AIAgent
|
||||
ensure_agent_runtime_current()
|
||||
if AIAgent is None:
|
||||
try:
|
||||
from run_agent import AIAgent as _cls # noqa: PLC0415
|
||||
AIAgent = _cls
|
||||
except ImportError:
|
||||
pass
|
||||
AIAgent = get_ai_agent_class()
|
||||
return AIAgent
|
||||
|
||||
|
||||
@@ -6265,6 +6266,65 @@ def _last_resort_sync_from_core(session, stream_id, agent_lock):
|
||||
)
|
||||
|
||||
|
||||
def _session_db_is_open(session_db) -> bool:
|
||||
"""True when *session_db* still has a live sqlite connection.
|
||||
|
||||
SessionDB.close() sets ``_conn = None``. Subagents capture the parent's
|
||||
SessionDB object by reference at spawn time (delegate_tool), so closing
|
||||
that object mid-parent-turn makes every subsequent child
|
||||
``append_message`` fail with
|
||||
``'NoneType' object has no attribute 'execute'``.
|
||||
"""
|
||||
if session_db is None:
|
||||
return False
|
||||
return getattr(session_db, "_conn", None) is not None
|
||||
|
||||
|
||||
def _adopt_session_db_for_cached_agent(agent, new_session_db):
|
||||
"""Attach a SessionDB to a reused cached agent without breaking subagents.
|
||||
|
||||
Historical behaviour (PR #1421 FD-leak fix): create a fresh SessionDB every
|
||||
stream request and close the previous handle before replacing
|
||||
``agent._session_db``. That stops EMFILE growth, but a server-side wakeup
|
||||
/ new turn for the same parent session will close the shared handle while
|
||||
background subagents are still writing into it.
|
||||
|
||||
Policy now:
|
||||
- If the cached agent already holds an *open* SessionDB, keep it and close
|
||||
the unused new handle (no FD leak; live subagents keep working).
|
||||
- If the existing handle is missing or already closed, adopt *new_session_db*.
|
||||
- If *new_session_db* is None, leave the existing handle alone.
|
||||
"""
|
||||
if agent is None:
|
||||
return new_session_db
|
||||
existing = getattr(agent, "_session_db", None)
|
||||
if new_session_db is None:
|
||||
return existing
|
||||
if existing is new_session_db:
|
||||
return existing
|
||||
if _session_db_is_open(existing):
|
||||
try:
|
||||
new_session_db.close()
|
||||
except Exception:
|
||||
# Same observability as _replace_session_db_in_kwargs: a failed
|
||||
# close here reintroduces the EMFILE pressure PR #1421 fixed.
|
||||
logger.debug(
|
||||
"Failed to close unused session_db handle in adopt helper",
|
||||
exc_info=True,
|
||||
)
|
||||
return existing
|
||||
if existing is not None:
|
||||
try:
|
||||
existing.close()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to close previous session_db handle in adopt helper",
|
||||
exc_info=True,
|
||||
)
|
||||
agent._session_db = new_session_db
|
||||
return new_session_db
|
||||
|
||||
|
||||
def _build_session_db_for_stream(state_db_path):
|
||||
"""Build a per-request SessionDB handle for WebUI session search.
|
||||
|
||||
@@ -6280,12 +6340,36 @@ def _build_session_db_for_stream(state_db_path):
|
||||
|
||||
|
||||
def _replace_session_db_in_kwargs(agent_kwargs, state_db_path):
|
||||
"""Build a fresh SessionDB and replace ``agent_kwargs['session_db']`` safely."""
|
||||
"""Build a fresh SessionDB and replace ``agent_kwargs['session_db']`` safely.
|
||||
|
||||
Does not close an existing open handle that may still be shared with live
|
||||
subagents; only replaces when the prior handle is missing or already closed.
|
||||
"""
|
||||
if not isinstance(agent_kwargs, dict):
|
||||
return None
|
||||
|
||||
_old_session_db = agent_kwargs.get("session_db")
|
||||
_next_session_db = _build_session_db_for_stream(state_db_path)
|
||||
if _next_session_db is None:
|
||||
# Replacement construction failed. Keep the prior handle only if it is
|
||||
# still open (live subagents may hold it by reference); otherwise
|
||||
# degrade cleanly to None — as master did — so the rebuilt agent lazily
|
||||
# reinitialises its SessionDB instead of reusing a closed handle and
|
||||
# failing every persist/search with
|
||||
# "'NoneType' object has no attribute 'execute'".
|
||||
if _session_db_is_open(_old_session_db):
|
||||
return _old_session_db
|
||||
agent_kwargs["session_db"] = None
|
||||
return None
|
||||
if _session_db_is_open(_old_session_db):
|
||||
# Keep the live handle; discard the unused new one.
|
||||
try:
|
||||
if _next_session_db is not _old_session_db:
|
||||
_next_session_db.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close unused session_db handle during self-heal")
|
||||
agent_kwargs["session_db"] = _old_session_db
|
||||
return _old_session_db
|
||||
if _old_session_db is not None and _old_session_db is not _next_session_db:
|
||||
try:
|
||||
_old_session_db.close()
|
||||
@@ -7213,6 +7297,7 @@ def _run_agent_streaming(
|
||||
logger.debug("register_process_session failed", exc_info=True)
|
||||
# first-time module initialisation (which can be slow) does not
|
||||
# block other concurrent sessions waiting on _ENV_LOCK (#2024).
|
||||
ensure_agent_runtime_current()
|
||||
_prewarm_skill_tool_modules()
|
||||
_install_streaming_cronjob_profile_wrapper()
|
||||
# Still set process-level env as fallback for tools that bypass thread-local
|
||||
@@ -8295,15 +8380,19 @@ def _run_agent_streaming(
|
||||
if 'prefill_messages' in _agent_kwargs and hasattr(agent, 'prefill_messages'):
|
||||
agent.prefill_messages = list(_agent_kwargs.get('prefill_messages') or [])
|
||||
if _session_db is not None:
|
||||
# Close any previously held SessionDB connection before
|
||||
# replacing it. Without this, each streaming request creates
|
||||
# a new SessionDB whose WAL handles leak indefinitely,
|
||||
# eventually causing EMFILE crashes (#streaming FD leak).
|
||||
if hasattr(agent, '_session_db') and agent._session_db is not None:
|
||||
try:
|
||||
agent._session_db.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Prefer reusing a still-open SessionDB on the cached
|
||||
# agent. Closing it mid-turn breaks background
|
||||
# subagents that hold a reference to the same object
|
||||
# (delegate_tool copies parent._session_db by ref) —
|
||||
# they then fail with
|
||||
# 'NoneType' object has no attribute 'execute'.
|
||||
# When the existing handle is already closed/missing,
|
||||
# adopt the fresh per-request SessionDB (and close the
|
||||
# dead one) so we still avoid the EMFILE FD-leak from
|
||||
# PR #1421.
|
||||
_session_db = _adopt_session_db_for_cached_agent(
|
||||
agent, _session_db
|
||||
)
|
||||
agent._session_db = _session_db
|
||||
if hasattr(agent, '_api_call_count'):
|
||||
agent._api_call_count = 0
|
||||
|
||||
Reference in New Issue
Block a user