Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions acp_adapter/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,16 +247,22 @@ def main(argv: list[str] | None = None) -> None:
import acp
from .server import HermesACPAgent

# MCP tool discovery from config.yaml — run before asyncio.run() so
# it's safe to use blocking waits. (ACP also registers per-session
# MCP servers dynamically via asyncio.to_thread inside the event
# loop; that path is unaffected.) Moved from model_tools.py module
# scope to avoid freezing the gateway's loop on lazy import (#16856).
# MCP tool discovery from config.yaml — fire-and-forget in a
# background daemon thread so the ACP server becomes responsive
# immediately while MCP servers connect. Previously this blocked
# asyncio.run() for 2-5 s. (ACP also registers per-session MCP
# servers dynamically via asyncio.to_thread inside the event loop;
# that path is unaffected.) Moved from model_tools.py module scope
# to avoid freezing the gateway's loop on lazy import (#16856).
# Metadata-only hosts can opt out of unrelated global MCP startup.
if os.environ.get("HERMES_ACP_SKIP_CONFIGURED_MCP", "").strip() != "1":
try:
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
from hermes_cli.mcp_startup import start_background_mcp_discovery

start_background_mcp_discovery(
logger=logger,
thread_name="acp-mcp-discovery",
)
except Exception:
logger.debug("MCP tool discovery failed at ACP startup", exc_info=True)

Expand Down
99 changes: 99 additions & 0 deletions acp_adapter/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,102 @@ async def _register_session_mcp_servers(
exc_info=True,
)

def _schedule_mcp_late_refresh(self, state: SessionState) -> None:
"""Refresh the agent's tool snapshot when background MCP discovery lands late.

ACP entry.py starts MCP tool discovery in a background daemon thread so a
slow/dead configured server can't block ``asyncio.run()``. ``_make_agent``
briefly joins that thread (``wait_for_mcp_discovery``, bounded ~1.5s) so
already-spawning fast servers land in the snapshot — but a server slower
than the bound lands *after* the agent is built, leaving its tools absent
for the whole session.

This schedules an off-critical-path daemon that waits for discovery to
finish (bounded 30s), then rebuilds the snapshot via the shared
``refresh_agent_mcp_tools`` helper — the same rebuild ``/reload-mcp``
performs, but automatic. Mirrors the TUI late-refresh (PR #48403).

Cache safety: the rebuild only runs while the session is still
pre-first-turn (no API call made yet → nothing cached to invalidate).
Once the user has sent a message we leave the snapshot frozen rather
than break the cached prompt prefix mid-conversation; servers that land
later are picked up cache-safely by the between-turns prologue refresh
(``agent/turn_context.py``) at the next turn boundary. The marginal
value of this pre-first-turn daemon is therefore freshness in the
window [session created → first message] — e.g. the "Available tools"
listing a client may request before the first prompt.
No-op when discovery already finished, when the join times out, when the
registry was unchanged, or when the session was closed while waiting.
"""
try:
from hermes_cli.mcp_startup import mcp_discovery_in_flight
except Exception:
return
if not mcp_discovery_in_flight():
return

import threading

agent = state.agent
session_id = state.session_id

def _wait_then_refresh() -> None:
try:
from hermes_cli.mcp_startup import join_mcp_discovery

if not join_mcp_discovery(timeout=30.0):
return

# Session may have been closed while we waited. In-memory-only
# lookup on purpose: ``get_session()`` falls through to a DB
# restore that builds a whole new AIAgent as a side effect just
# to decide "no-op" here (the TUI equivalent also checks its
# in-memory dict only).
with self.session_manager._lock:
current = self.session_manager._sessions.get(session_id)
if current is None or current.agent is not agent:
return

# Cache safety: never rebuild the tool list once the conversation
# has started — that would invalidate the cached prompt prefix.
# Serialized with turn start: ``prompt()`` flips ``is_running``
# under ``runtime_lock`` before dispatching, so holding it here
# (and bailing when a turn is already running) closes the window
# where the guard passes but the first prompt starts before the
# refresh publishes — which would swap ``tools=`` mid-turn and
# break the just-created cache prefix.
with current.runtime_lock:
if current.is_running:
return
if (
int(getattr(agent, "_user_turn_count", 0) or 0) > 0
or int(getattr(agent, "_api_call_count", 0) or 0) > 0
):
return

from tools.mcp_tool import refresh_agent_mcp_tools

added = refresh_agent_mcp_tools(agent, quiet_mode=True)
if added:
logger.info(
"Session %s: late MCP refresh added %d tools: %s",
session_id,
len(added),
", ".join(sorted(added)),
)
except Exception:
logger.debug(
"Session %s: late MCP refresh failed",
session_id,
exc_info=True,
)

threading.Thread(
target=_wait_then_refresh,
name=f"acp-mcp-late-refresh-{session_id}",
daemon=True,
).start()

# ---- ACP lifecycle ------------------------------------------------------

async def initialize(
Expand Down Expand Up @@ -1343,6 +1439,7 @@ async def new_session(
) -> NewSessionResponse:
state = self.session_manager.create_session(cwd=cwd)
await self._register_session_mcp_servers(state, mcp_servers)
self._schedule_mcp_late_refresh(state)
logger.info("New session %s (cwd=%s)", state.session_id, cwd)
self._schedule_available_commands_update(state.session_id)
self._schedule_usage_update(state)
Expand All @@ -1367,6 +1464,7 @@ async def load_session(
logger.warning("load_session: session %s not found", session_id)
return None
await self._register_session_mcp_servers(state, mcp_servers)
self._schedule_mcp_late_refresh(state)
logger.info("Loaded session %s", session_id)
# Per ACP spec, `session/load` must stream the prior conversation back
# to the client via `session/update` notifications BEFORE responding,
Expand Down Expand Up @@ -1414,6 +1512,7 @@ async def resume_session(
logger.warning("resume_session: session %s not found, creating new", session_id)
state = self.session_manager.create_session(cwd=cwd)
await self._register_session_mcp_servers(state, mcp_servers)
self._schedule_mcp_late_refresh(state)
logger.info("Resumed session %s", state.session_id)
# See `load_session` above for the spec rationale — replay must
# complete before the response so clients receive the full transcript
Expand Down
24 changes: 24 additions & 0 deletions acp_adapter/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,30 @@ def _make_agent(
logger.debug("ACP session falling back to default provider resolution", exc_info=True)

_register_task_cwd(session_id, cwd)

# Bounded wait for background MCP discovery so already-spawning fast
# servers land in the agent's tool snapshot. ACP entry.py fires
# discovery in a background daemon thread (start_background_mcp_discovery);
# the agent snapshots tools once at build (run_agent/agent_init) and
# never re-reads the registry, so without this join a reachable-but-
# slow configured server would be invisible for the whole session.
# ``ensure_mcp_discovery_before_agent_build`` also (re)starts discovery
# when the entry.py spawn never ran or exited with zero connected
# servers (the retry-after-zero-connected allowance), making this
# construction site self-sufficient. Bounded by
# ``mcp_discovery_timeout`` (config.yaml, default ~1.5s) so a dead
# server can't block — servers that miss the bound are picked up by
# the automatic late-refresh (see 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)

agent = AIAgent(**kwargs)
# Codex app-server sessions are spawned lazily on the first turn. Stamp
# the ACP workspace onto the agent so the Codex runtime starts from the
Expand Down
Loading
Loading