"""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-`` 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 []), }) # The resolved provider's request body (a custom entry's extra_body); an explicit base_url pointing # elsewhere is another endpoint, which must not inherit it. if runtime.get("request_overrides") and (not base_url or base_url == runtime.get("base_url")): kwargs["request_overrides"] = runtime["request_overrides"] 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