Files
hermes-agent/acp_adapter/session.py
Hermes Agent 9fb2dd4c39 fix(acp): stamp ended_at on ACP sessions at adapter shutdown (#118216)
ACP v0.9 has no per-session destroy, so the adapter's stdio shutdown is
the session end — but nothing ever wrote it: session rows created with
source="acp" kept ended_at NULL forever, the ended-session guard in
hermes_state_maintenance (prune/archive share it) could never select
them, and the desktop recents list accumulated one auto-titled row per
editor wake.

- SessionManager.end_all_sessions(): best-effort end_session("acp_disconnect")
  for every live session; called from entry.py's finally so EOF, SIGINT and
  a crash all stamp the rows.
- _restore() reopens a row ended by a previous adapter process before
  resuming it — the same contract the TUI gateway's cold resume uses, so
  load/resume across editor restarts keeps working.
- Desktop sidebar: 'acp' joins SIDEBAR_EXCLUDED_SOURCES (recents) and
  LOCAL_SESSION_SOURCE_IDS (keeps it out of the messaging slice); the
  conversations live in the editor, not the app's recents.

The prune/archive "open session(s) also match these filters" warning the
issue asks for already exists on main (count_open_prune_matches in
_cmd_prune_or_archive).
2026-09-26 21:00:46 -05:00

586 lines
29 KiB
Python

"""ACP session manager — maps ACP sessions to Hermes AIAgent instances.
Sessions are persisted to the shared SessionDB (``~/.hermes/state.db``) so they
survive process restarts and appear in ``session_search``; ``load_session`` /
``resume_session`` after an editor reconnect restore the full history from there.
"""
from __future__ import annotations
from hermes_constants import get_hermes_home, translate_cwd_for_wsl_backend, windows_path_to_wsl
import copy
import json
import logging
import os
import re
import sys
import threading
import time
import uuid
from datetime import datetime, timezone
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
def _translate_acp_cwd(cwd: str) -> str:
"""Translate Windows ACP cwd values (``E:\\Projects``, ``\\\\wsl.localhost\\``) to POSIX form
when Hermes runs in WSL so agents, tools, and persisted sessions agree; no-op elsewhere."""
return translate_cwd_for_wsl_backend(str(cwd))
def _normalize_cwd_for_compare(cwd: str | None) -> str:
expanded = os.path.expanduser(str(cwd or ".").strip() or ".")
# Windows drive paths -> WSL mount form so history filters match across hosts.
translated = windows_path_to_wsl(expanded)
if translated is not None:
expanded = translated
elif re.match(r"^/mnt/[A-Za-z]/", expanded):
expanded = f"/mnt/{expanded[5].lower()}/{expanded[7:]}"
# realpath resolves symlink aliases (macOS ``/var`` vs ``/private/var``, ``/tmp`` vs
# ``/private/tmp``) that otherwise drop a workspace's own sessions; it is lexical
# for missing paths (e.g. WSL-translated drives).
try:
# Resolve symlink aliases so equivalent spellings of the same directory compare equal — macOS
# reports editor workspaces as ``/var/...`` while sessions get stored under ``/private/var/...``
# (and ``/tmp`` vs ``/private/tmp``), which made ACP history filters silently drop a workspace's own
# sessions. WSL-translated Windows drives — keep the previous normpath behavior. Ported from
# PrimeIntellect-ai/prime-agent#628.
return os.path.realpath(expanded)
except OSError:
return os.path.normpath(expanded)
def _build_session_title(title: Any, preview: Any, cwd: str | None) -> str:
leaf = os.path.basename(str(cwd or "").rstrip("/\\"))
return str(title or "").strip() or str(preview or "").strip() or leaf or "New thread"
def _format_updated_at(value: Any) -> str | None:
if value is None or (isinstance(value, str) and value.strip()):
return value
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
except Exception:
return None
def _updated_at_sort_key(value: Any) -> float:
if isinstance(value, (int, float)):
return float(value)
raw = str(value).strip() if value is not None else ""
if not raw:
return float("-inf")
for parse in (lambda s: datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp(), float):
try:
return parse(raw)
except Exception:
continue
return float("-inf")
def _acp_stderr_print(*args, **kwargs) -> None:
"""Route incidental AIAgent output to stderr; ACP reserves stdout for JSON-RPC."""
kwargs.setdefault("file", sys.stderr)
print(*args, **kwargs)
def _register_task_cwd(task_id: str, cwd: str) -> None:
"""Bind a task/session id to the editor cwd for tools. Zed may send a Windows cwd while
the ACP process runs in WSL; tools need the WSL mount or subprocess creation fails."""
if not task_id:
return
try:
from tools.terminal_tool import register_task_env_overrides
register_task_env_overrides(task_id, {"cwd": _translate_acp_cwd(cwd)})
except Exception:
logger.debug("Failed to register ACP task cwd override", exc_info=True)
def _expand_acp_enabled_toolsets(toolsets: List[str] | None = None,
mcp_server_names: List[str] | None = None) -> List[str]:
"""Return ACP toolsets plus explicit MCP server toolsets for this session."""
names = [n for n in (["hermes-acp"] if toolsets is None else toolsets) if n]
names += [f"mcp-{s}" for s in (mcp_server_names or []) if s]
return list(dict.fromkeys(names))
def _parse_model_config(mc: Any) -> dict:
"""Decode a persisted model_config JSON blob; ``{}`` when absent/invalid/non-dict."""
try:
meta = json.loads(mc) if mc else None
except (json.JSONDecodeError, TypeError):
meta = None
return meta if isinstance(meta, dict) else {}
def _session_info(sid: str, cwd: str, model: Any, history_len: int, title: Any, preview: Any,
updated_at: Any) -> Dict[str, Any]:
return {"session_id": sid, "cwd": cwd, "model": model, "history_len": history_len,
"title": _build_session_title(title, preview, cwd), "updated_at": _format_updated_at(updated_at)}
def _first_user_preview(history: List[Dict[str, Any]], default: str) -> str:
return next((str(m.get("content") or "").strip() for m in history
if m.get("role") == "user" and str(m.get("content") or "").strip()), default)
@dataclass
class SessionState:
"""Tracks per-session state for an ACP-managed Hermes agent."""
session_id: str
agent: Any # AIAgent instance
cwd: str = "."
model: str = ""
history: List[Dict[str, Any]] = field(default_factory=list)
cancel_event: Any = None # threading.Event
is_running: bool = False
# A state-mutating slash command (/reset, /compress, /model) is in flight. Turn claims
# must queue behind it: /compress's LLM call and /model's agent rebuild take seconds, so
# a bare is_running check in the slash thread would leave a check-then-act window where
# a prompt claims the turn mid-mutation.
command_op: bool = False
queued_prompts: List[str] = field(default_factory=list)
runtime_lock: Any = field(default_factory=threading.Lock)
current_prompt_text: str = ""
interrupted_prompt_text: str = ""
# Per-session allocator for ACP assistant messageIds (lazily created by
# the server so streamed chunks group into distinct assistant replies).
message_ids: Any = None
class SessionManager:
"""Thread-safe manager for ACP sessions backed by Hermes AIAgent instances.
Sessions are held in-memory for fast access **and** persisted to the shared
SessionDB so they survive restarts and are searchable via ``session_search``."""
def __init__(self, agent_factory=None, db=None):
"""``agent_factory``: AIAgent-like factory (tests); default builds a real AIAgent from
the runtime provider config. ``db``: SessionDB; default lazily opens ``~/.hermes/state.db``."""
self._sessions: Dict[str, SessionState] = {}
self._lock = threading.Lock()
# Serializes DB restores: session construction runs off the event loop, so two
# overlapping session/load for one id must share a single agent build.
self._restore_lock = threading.Lock()
self._agent_factory = agent_factory
self._db_instance = db # None → lazy-init on first use
self._cwd_backfilled = False
# ---- public API ---------------------------------------------------------
def create_session(self, cwd: str = ".") -> SessionState:
"""Create a new session with a unique ID and a fresh AIAgent."""
cwd = _translate_acp_cwd(cwd)
session_id = str(uuid.uuid4())
agent = self._make_agent(session_id=session_id, cwd=cwd)
state = self._install_state(session_id, agent, cwd, getattr(agent, "model", "") or "", [])
logger.info("Created ACP session %s (cwd=%s)", session_id, cwd)
return state
def get_session(self, session_id: str) -> Optional[SessionState]:
"""Return the session, transparently restoring it from the DB (e.g. after
a process restart) when it is not in memory; ``None`` if unknown."""
with self._lock:
state = self._sessions.get(session_id)
if state is not None:
return state
with self._restore_lock:
with self._lock:
state = self._sessions.get(session_id) # a concurrent restore may have installed it
return state if state is not None else self._restore(session_id)
def fork_session(self, session_id: str, cwd: str = ".") -> Optional[SessionState]:
"""Deep-copy a session's history into a new session."""
cwd = _translate_acp_cwd(cwd)
original = self.get_session(session_id) # checks DB too
if original is None:
return None
new_id = str(uuid.uuid4())
agent = self._make_agent(session_id=new_id, cwd=cwd, model=original.model or None)
model = getattr(agent, "model", original.model) or original.model
state = self._install_state(new_id, agent, cwd, model, copy.deepcopy(original.history))
logger.info("Forked ACP session %s -> %s", session_id, new_id)
return state
def list_sessions(self, cwd: str | None = None) -> List[Dict[str, Any]]:
"""Return lightweight info dicts for all sessions (memory + database)."""
normalized_cwd = _normalize_cwd_for_compare(cwd) if cwd else None
db = self._get_db()
persisted_rows: dict[str, dict[str, Any]] = {}
try:
for row in (db.list_sessions_rich(source="acp", limit=1000) if db is not None else ()):
persisted_rows[str(row["id"])] = dict(row)
except Exception:
logger.debug("Failed to load ACP sessions from DB", exc_info=True)
def _matches(session_cwd: str) -> bool:
return not normalized_cwd or _normalize_cwd_for_compare(session_cwd) == normalized_cwd
# In-memory sessions first.
with self._lock:
seen_ids = set(self._sessions.keys())
results = []
for s in self._sessions.values():
if not s.history or not _matches(s.cwd):
continue
persisted = persisted_rows.get(s.session_id, {})
results.append(_session_info(
s.session_id, s.cwd, s.model, len(s.history), persisted.get("title"),
_first_user_preview(s.history, persisted.get("preview") or ""),
persisted.get("last_active") or persisted.get("started_at") or time.time(),
))
# Then persisted sessions not currently in memory.
for sid, row in persisted_rows.items():
message_count = int(row.get("message_count") or 0)
session_cwd = _parse_model_config(row.get("model_config")).get("cwd", ".")
if sid in seen_ids or message_count <= 0 or not _matches(session_cwd):
continue
results.append(_session_info(
sid, session_cwd, row.get("model") or "", message_count, row.get("title"),
row.get("preview"), row.get("last_active") or row.get("started_at"),
))
results.sort(key=lambda item: _updated_at_sort_key(item.get("updated_at")), reverse=True)
return results
def update_cwd(self, session_id: str, cwd: str) -> Optional[SessionState]:
"""Update the working directory for a session and its tool overrides."""
cwd = _translate_acp_cwd(cwd)
state = self.get_session(session_id) # checks DB too
if state is None:
return None
state.cwd = cwd
_register_task_cwd(session_id, cwd)
self._persist(state)
# Promote the authoritative column and claim an ordering generation, the
# same contract tui_gateway/session_workdir.py uses: a git probe may only
# publish while its generation is still current, so a slow probe for a
# previous workspace cannot overwrite a newer claim (A -> B -> A).
self._schedule_git_metadata(state, self._claim_cwd_generation(state))
return state
def save_session(self, session_id: str) -> None:
"""Persist a session; called by the server after prompt completion,
history-mutating slash commands, and model switches."""
with self._lock:
state = self._sessions.get(session_id)
if state is not None:
self._persist(state)
def end_all_sessions(self, end_reason: str = "acp_disconnect") -> int:
"""Stamp ``ended_at`` on every live session (#118216).
ACP v0.9 has no per-session destroy, so the stdio shutdown that ends
this process is the session end: the client that drove the
conversation is gone. Without this writer, source='acp' rows keep
``ended_at`` NULL forever and the ended-session guard shared by
prune/archive (``hermes_state_maintenance``) can never reach them.
A later load/resume reopens the row (see ``_restore``), the same
contract the TUI gateway's resume path uses. Best-effort: teardown
must never raise. Returns the number of sessions ended.
"""
db = self._get_db()
if db is None:
return 0
with self._lock:
session_ids = list(self._sessions.keys())
ended = 0
for session_id in session_ids:
try:
db.end_session(session_id, end_reason)
ended += 1
except Exception:
logger.debug("Failed to end ACP session %s", session_id, exc_info=True)
if ended:
logger.info("Ended %d ACP session(s) on shutdown (%s)", ended, end_reason)
return ended
# ---- persistence via SessionDB ------------------------------------------
def _install_state(self, session_id: str, agent: Any, cwd: str, model: str,
history: List[Dict[str, Any]], *, persist: bool = True) -> SessionState:
"""Build a SessionState, register it in memory, bind its cwd for tools, optionally persist."""
state = SessionState(session_id=session_id, agent=agent, cwd=cwd, model=model,
history=history, cancel_event=threading.Event())
with self._lock:
self._sessions[session_id] = state
_register_task_cwd(session_id, cwd)
if persist:
self._persist(state)
return state
def _get_db(self):
"""Lazily acquire the process-shared SessionDB; ``None`` if unavailable (e.g. import
error in a minimal test env). ``HERMES_HOME`` is resolved here, not via the import-time
``DEFAULT_DB_PATH``, so test fixtures that change the env var later are honoured. The
registry handle is the one in-process tools (delegation, session_search, goals) also
acquire, so the ACP server holds ONE writer on state.db instead of two (#100896)."""
if self._db_instance is None:
try:
from hermes_state_registry import acquire
self._db_instance = acquire(get_hermes_home() / "state.db")
except Exception:
logger.debug("SessionDB unavailable for ACP persistence", exc_info=True)
if self._db_instance is not None and not self._cwd_backfilled:
# Rows minted before the adapter wrote the cwd column still carry the workspace
# in model_config; one idempotent UPDATE per process repairs them (#115705).
self._cwd_backfilled = True
try:
repaired = self._db_instance.backfill_acp_session_cwd()
if repaired:
logger.info("Backfilled cwd for %d ACP session(s) from model_config", repaired)
except Exception:
logger.debug("ACP session cwd backfill failed", exc_info=True)
return self._db_instance
def _persist(self, state: SessionState) -> None:
"""Create/update the session record, then sync the live message set."""
db = self._get_db()
if db is None:
return
# Ensure model is a plain string (not a MagicMock or other proxy).
model_str = str(state.model) if state.model else None
session_meta = {"cwd": state.cwd}
for key in ("provider", "base_url", "api_mode"):
value = getattr(state.agent, key, None)
if isinstance(value, str) and value.strip():
session_meta[key] = value.strip()
try:
if db.get_session(state.session_id) is None:
if not state.history:
# Empty editor probes stay ephemeral; copied fork history persists.
return
db.create_session(session_id=state.session_id, source="acp", model=model_str,
model_config=session_meta, cwd=state.cwd or None)
else:
try:
db.update_session_meta(state.session_id, json.dumps(session_meta), model_str)
except Exception:
logger.debug("Failed to update ACP session metadata", exc_info=True)
# The create branch above is not the live path: an agent that owns
# persistence to this same DB flushes the transcript incrementally,
# so the row already exists by the time we get here.
# update_session_meta touches only model_config/model, so without
# this promotion the column stays NULL for the whole session and
# Desktop files it as unassigned. Claiming a generation keeps the
# A -> B -> A ordering contract shared with update_cwd().
if state.cwd:
self._schedule_git_metadata(state, self._claim_cwd_generation(state))
# An agent that owns persistence to this same DB already flushed the transcript
# incrementally (append_message) and keeps pre-compaction turns as archived
# active=0 rows; replace_messages() would DELETE those (and, after a compression
# id rotation, clobber the ended parent transcript). Skip it in that case.
# Calling replace_messages() here would then be a redundant double-write that DELETEs exactly
# those archived rows (and, after a compression-driven id rotation where agent.session_id no
# longer equals state.session_id, clobbers the ended parent transcript) — silent data loss for
# any ACP conversation long enough to compress. Only fall back to the destructive atomic replace
# when the agent is NOT persisting itself to this DB (e.g. a test agent factory, or a fresh
# create/fork whose copied history the agent has not flushed yet). That path still rolls back on
# a mid-rewrite failure so the previously persisted conversation survives (salvaged from
# #13675).
agent = state.agent
if getattr(agent, "_session_db", None) is db and getattr(agent, "_session_db_created", False):
return
# A non-owning agent (model switch, /restore: fresh agent, _session_db_created=False)
# may still sit on archived rows, so replace ONLY the active=1 set: on a fresh
# create/fork every row is active (== full replace), and archived rows survive.
# Unconditional because an existence probe would fail OPEN on DB error and can
# race a concurrent archive_and_compact. Still rolls back on mid-rewrite failure.
db.replace_messages(state.session_id, state.history, active_only=True)
except Exception:
logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True)
def _claim_cwd_generation(self, state: SessionState) -> Optional[int]:
"""Write the cwd column and return its new git-metadata generation.
Returns None when the row does not exist yet (a contentless session is
deliberately ephemeral until it has history) or the DB is unavailable.
"""
db = self._get_db()
if db is None or not state.cwd:
return None
try:
return db.update_session_cwd(state.session_id, state.cwd)
except Exception:
logger.debug("Failed to persist ACP session cwd column for %s",
state.session_id, exc_info=True)
return None
def _schedule_git_metadata(self, state: SessionState, generation: Optional[int]) -> None:
"""Probe git off the critical path and publish under ``generation``.
``session/new`` is on the editor's interactive path; ``git rev-parse``
on a cold or networked filesystem is not something to put in front of
the user. The generation guard in ``publish_session_git_metadata``
means a slow probe for a previous workspace is dropped rather than
applied to the new one.
"""
if not generation or not state.cwd:
return
session_id, cwd = state.session_id, state.cwd
def _run() -> None:
try:
from tui_gateway import git_probe
branch, root = git_probe.branch(cwd), git_probe.common_repo_root(cwd)
if not (branch or root):
return
db = self._get_db()
if db is not None:
db.publish_session_git_metadata(session_id, cwd, generation, branch, root)
except Exception:
logger.debug("Failed to publish ACP git metadata for %s", session_id, exc_info=True)
threading.Thread(target=_run, name=f"acp-git-meta-{session_id[:8]}", daemon=True).start()
def _restore(self, session_id: str) -> Optional[SessionState]:
"""Load an ACP session from the database into memory, recreating the AIAgent."""
db = self._get_db()
if db is None:
return None
try:
row = db.get_session(session_id)
except Exception:
logger.debug("Failed to query DB for ACP session %s", session_id, exc_info=True)
return None
if row is None or row.get("source") != "acp":
return None
# A previous adapter process stamped the row ended at its stdio
# shutdown (#118216); resuming the conversation reopens it, the same
# contract the TUI gateway's cold-resume path uses.
if row.get("ended_at") is not None:
try:
db.reopen_session(session_id)
except Exception:
logger.debug("Failed to reopen ACP session %s", session_id, exc_info=True)
meta = _parse_model_config(row.get("model_config"))
cwd, model = meta.get("cwd", "."), row.get("model") or None
# repair_alternation: this list becomes the resumed agent's LIVE conversation; a durable
# ``user;user`` violation in state.db would otherwise re-fire the pre-request repair every request.
try:
history = db.get_messages_as_conversation(session_id, repair_alternation=True)
except Exception:
logger.warning("Failed to load messages for ACP session %s", session_id, exc_info=True)
history = []
try:
agent = self._make_agent(
session_id=session_id, cwd=cwd, model=model, api_mode=meta.get("api_mode") or None,
requested_provider=meta.get("provider") or row.get("billing_provider"),
base_url=meta.get("base_url") or row.get("billing_base_url"))
except Exception:
logger.warning("Failed to recreate agent for ACP session %s", session_id, exc_info=True)
return None
state = self._install_state(session_id, agent, cwd, model or getattr(agent, "model", "") or "",
history, persist=False)
logger.info("Restored ACP session %s from DB (%d messages)", session_id, len(history))
return state
# ---- internal -----------------------------------------------------------
def _make_agent(self, *, session_id: str, cwd: str, model: str | None = None,
requested_provider: str | None = None, base_url: str | None = None, api_mode: str | None = None,
enabled_toolsets: list[str] | None = None, disabled_toolsets: list[str] | None = None):
"""``enabled_toolsets``/``disabled_toolsets`` carry a live session's toolsets into a rebuild; ``None`` derives
them from config (fresh session)."""
if self._agent_factory is not None:
return self._agent_factory()
from run_agent import AIAgent
from agent.skill_utils import parse_config_string_list
from hermes_cli.config import load_config
from hermes_cli.runtime_provider import resolve_runtime_provider
from hermes_cli.tools_config import _get_platform_tools, enabled_mcp_server_names
from hermes_constants import resolve_reasoning_config
config = load_config()
model_cfg = config.get("model")
default_model, config_provider = "", None
if isinstance(model_cfg, dict):
default_model, config_provider = str(model_cfg.get("default") or ""), model_cfg.get("provider")
elif isinstance(model_cfg, str):
default_model = model_cfg.strip()
if enabled_toolsets is None:
# The same per-platform resolver as the gateway/cron/api_server: platform_toolsets.acp wins, else
# hermes-acp; its MCP half (every enabled server, a listed-name allowlist, or none for ``no_mcp``)
# comes back as bare server names, which ACP keys as ``mcp-<server>`` like its session servers.
resolved = _get_platform_tools(config, "acp")
mcp_servers = resolved & enabled_mcp_server_names(config)
enabled_toolsets = _expand_acp_enabled_toolsets(sorted(resolved - mcp_servers), sorted(mcp_servers))
kwargs = {
"platform": "acp", "quiet_mode": True, "session_id": session_id, "session_db": self._get_db(),
"enabled_toolsets": list(enabled_toolsets),
# agent.disabled_toolsets is subtracted at tool granularity by the agent, as on the CLI/gateway/cron.
"disabled_toolsets": (list(disabled_toolsets) if disabled_toolsets is not None
else parse_config_string_list((config.get("agent") or {}).get("disabled_toolsets")) or None),
"model": model or default_model,
"cwd": cwd,
# Same chokepoint as the CLI/gateway/TUI/cron: without it ``agent.reasoning_effort: none`` never
# reaches an ACP session and the transport applies its default effort (a 400 on non-reasoning
# models). Resolved against the session's model so per-model overrides apply.
"reasoning_config": resolve_reasoning_config(config, model or default_model),
}
resolve_error: Exception | None = None
try:
runtime = resolve_runtime_provider(
requested=requested_provider or config_provider, target_model=(model or default_model) or None)
kwargs.update({
"provider": runtime.get("provider"), "api_mode": api_mode or runtime.get("api_mode"),
"base_url": base_url or runtime.get("base_url"), "api_key": runtime.get("api_key"),
"credential_pool": runtime.get("credential_pool"),
"command": runtime.get("command"), "args": list(runtime.get("args") or []),
})
except Exception as exc:
resolve_error = exc
logger.debug("ACP session falling back to default provider resolution", exc_info=True)
_register_task_cwd(session_id, cwd)
# Bounded wait for the background MCP discovery started by entry.py: the agent
# snapshots tools once at build and never re-reads the registry, so without this
# join a slow-but-reachable server would be invisible all session. ensure_* also
# (re)starts discovery if the entry spawn never ran or connected zero servers.
# Bounded by ``mcp_discovery_timeout`` (config.yaml, ~1.5s); late servers are
# picked up by HermesACPAgent._schedule_mcp_late_refresh.
try:
from hermes_cli.mcp_startup import ensure_mcp_discovery_before_agent_build
ensure_mcp_discovery_before_agent_build(logger=logger, thread_name="acp-mcp-discovery")
except Exception:
logger.debug("ACP: bounded MCP discovery wait failed", exc_info=True)
try:
agent = AIAgent(**kwargs)
except Exception as exc:
# The bare-AIAgent fallback dies with "No LLM provider configured. Run `hermes setup`" on a
# machine that is configured and was working a call earlier; the swallowed resolution
# failure (revoked OAuth, disabled provider, ...) is the actionable error (#91090).
if resolve_error is not None:
raise resolve_error from exc
raise
# ACP stdio: stdout is protocol-only JSON-RPC; agent chatter goes to stderr.
agent._print_fn = _acp_stderr_print
return agent
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from threading import Lock # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----