Merge remote-tracking branch 'origin/main' into ethie/pm-clean
Resolved toward the branch: PM provisions uv/python (main's install.ps1 uv-shim salvage + its test and workflow steps dropped), the shim re-exec stays retired, package.json carries no electron-builder block (afterExtract identity stamp wired into electron-builder.config.cjs instead; after-pack.mjs keeps signing only), Desktop workspace-deps helpers stay retired. Main's scratch-dir bootstrap (export_scratch_tmp_env) is taken and re-run after profile resolution.
This commit is contained in:
@@ -199,14 +199,14 @@ def build_acp_edit_tool_call(proposal: EditProposal):
|
||||
|
||||
def make_acp_edit_approval_requester(
|
||||
request_permission_fn: Callable, loop: asyncio.AbstractEventLoop, session_id: str,
|
||||
timeout: float = 60.0, auto_approve_getter: Callable[[], tuple[str, str | None]] | None = None,
|
||||
timeout: float | None = None, auto_approve_getter: Callable[[], tuple[str, str | None]] | None = None,
|
||||
send_update: Callable[[object], None] | None = None,
|
||||
) -> EditApprovalRequester:
|
||||
"""Return a sync requester that bridges edit proposals to ACP permissions."""
|
||||
|
||||
def _requester(proposal: EditProposal) -> bool:
|
||||
from acp.schema import PermissionOption
|
||||
from acp_adapter.permissions import await_permission
|
||||
from acp_adapter.permissions import await_permission, resolve_permission_timeout
|
||||
|
||||
if auto_approve_getter is not None:
|
||||
try:
|
||||
@@ -221,7 +221,7 @@ def make_acp_edit_approval_requester(
|
||||
request_permission_fn, loop, session_id, tool_call=build_acp_edit_tool_call(proposal),
|
||||
options=[PermissionOption(option_id="allow_once", kind="allow_once", name="Allow edit"),
|
||||
PermissionOption(option_id="deny", kind="reject_once", name="Deny")],
|
||||
timeout=timeout, what="Edit approval request", send_update=send_update,
|
||||
timeout=resolve_permission_timeout(timeout), what="Edit approval request", send_update=send_update,
|
||||
)
|
||||
outcome = getattr(response, "outcome", None)
|
||||
return getattr(outcome, "outcome", None) == "selected" and getattr(outcome, "option_id", None) == "allow_once"
|
||||
|
||||
@@ -145,6 +145,14 @@ def _run_setup_browser(assume_yes: bool = False) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _warm_memory_provider_import(logger: logging.Logger) -> None:
|
||||
"""Import ``memory.provider``'s module + numpy (no provider instance) before the ACP threads start."""
|
||||
from plugins.memory import import_memory_provider_module
|
||||
|
||||
if not import_memory_provider_module():
|
||||
logger.debug("memory provider not warmed (none configured or import failed; agent init reports that)")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""Entry point: load env, configure logging, run the ACP agent."""
|
||||
args = _parse_args(argv)
|
||||
@@ -170,6 +178,15 @@ def main(argv: list[str] | None = None) -> None:
|
||||
import acp
|
||||
from .server import HermesACPAgent
|
||||
|
||||
# Windows: import the configured memory provider (and numpy) on the main thread before
|
||||
# the MCP-discovery and ACP stdin-reader threads start (hermes_cli's ~150 ms
|
||||
# plugin-discovery thread is the only one already running). A first-time
|
||||
# native-extension import (numpy via holographic / mnemosyne / hindsight) racing another
|
||||
# thread's import chain deadlocked in create_module and session/new never answered
|
||||
# (#58083). After this the off-loop agent build finds the modules in sys.modules.
|
||||
if sys.platform == "win32":
|
||||
_warm_memory_provider_import(logger)
|
||||
|
||||
# MCP discovery from config.yaml runs in a background daemon thread so the ACP server is
|
||||
# responsive immediately (blocking here cost 2-5 s); per-session MCP servers registered via
|
||||
# asyncio.to_thread are unaffected. Metadata-only hosts can opt out of the global startup.
|
||||
|
||||
@@ -114,12 +114,24 @@ def await_permission(
|
||||
return response, timed_out
|
||||
|
||||
|
||||
def resolve_permission_timeout(timeout: float | None) -> float:
|
||||
"""``None`` → the user's ``approvals.timeout`` (same knob as CLI/gateway prompts, default
|
||||
300 s). The ACP bridges used to hardcode 60 s, so a host whose approval card was still
|
||||
waiting saw Hermes self-deny under it (#73403)."""
|
||||
if timeout is not None:
|
||||
return float(timeout)
|
||||
from tools.approval_context import _get_approval_timeout
|
||||
|
||||
return float(_get_approval_timeout())
|
||||
|
||||
|
||||
def make_approval_callback(request_permission_fn: Callable, loop: asyncio.AbstractEventLoop,
|
||||
session_id: str, timeout: float = 60.0,
|
||||
session_id: str, timeout: float | None = None,
|
||||
send_update: Callable[[object], None] | None = None) -> Callable[..., str]:
|
||||
"""Return a Hermes approval callback (``command, description, **kw`` as used by
|
||||
``tools.approval.prompt_dangerous_approval()``) that bridges to the ACP
|
||||
connection's ``request_permission`` coroutine on ``loop``; auto-denies after ``timeout`` s."""
|
||||
connection's ``request_permission`` coroutine on ``loop``; auto-denies after ``timeout`` s
|
||||
(``None`` → ``approvals.timeout``, read per request)."""
|
||||
|
||||
def _callback(command: str, description: str, *, allow_permanent: bool = True,
|
||||
allow_session: bool = True, smart_denied: bool = False, **_: object) -> str:
|
||||
@@ -127,7 +139,8 @@ def make_approval_callback(request_permission_fn: Callable, loop: asyncio.Abstra
|
||||
smart_denied=smart_denied)
|
||||
response, timed_out = await_permission(
|
||||
request_permission_fn, loop, session_id, tool_call=_build_permission_tool_call(command, description),
|
||||
options=options, timeout=timeout, what="Permission request", send_update=send_update,
|
||||
options=options, timeout=resolve_permission_timeout(timeout), what="Permission request",
|
||||
send_update=send_update,
|
||||
)
|
||||
if timed_out:
|
||||
# Distinct from an explicit deny: tools.approval reports "timed out
|
||||
|
||||
@@ -238,7 +238,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
|
||||
"accept_edits": (
|
||||
"workspace_session",
|
||||
"Accept Edits",
|
||||
"Auto-allow workspace and /tmp edits; still asks for sensitive paths.",
|
||||
"Auto-allow workspace and temp-dir edits; still asks for sensitive paths.",
|
||||
),
|
||||
"dont_ask": (
|
||||
"session", "Don't Ask", "Auto-allow file edits for this session except sensitive paths."
|
||||
@@ -601,14 +601,16 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
|
||||
logger.info(log, *log_args)
|
||||
|
||||
async def new_session(self, cwd: str, mcp_servers: list | None = None, **kwargs: Any) -> NewSessionResponse:
|
||||
state = self.session_manager.create_session(cwd=cwd)
|
||||
# Agent construction (config, memory-provider import, SessionDB) is slow and fully
|
||||
# blocking; inline it froze the loop serving every JSON-RPC request (#58083).
|
||||
state = await asyncio.to_thread(self.session_manager.create_session, cwd=cwd)
|
||||
await self._attach_session_mcp(state, mcp_servers, "New session %s (cwd=%s)", state.session_id, cwd)
|
||||
return NewSessionResponse(session_id=state.session_id, **await self._session_response_fields(state))
|
||||
|
||||
async def load_session(
|
||||
self, cwd: str, session_id: str, mcp_servers: list | None = None, **kwargs: Any
|
||||
) -> LoadSessionResponse | None:
|
||||
state = self.session_manager.update_cwd(session_id, cwd)
|
||||
state = await asyncio.to_thread(self.session_manager.update_cwd, session_id, cwd)
|
||||
if state is None:
|
||||
logger.warning("load_session: session %s not found", session_id)
|
||||
return None
|
||||
@@ -618,15 +620,17 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
|
||||
async def resume_session(
|
||||
self, cwd: str, session_id: str, mcp_servers: list | None = None, **kwargs: Any
|
||||
) -> ResumeSessionResponse:
|
||||
state = self.session_manager.update_cwd(session_id, cwd)
|
||||
state = await asyncio.to_thread(self.session_manager.update_cwd, session_id, cwd)
|
||||
if state is None:
|
||||
logger.warning("resume_session: session %s not found, creating new", session_id)
|
||||
state = self.session_manager.create_session(cwd=cwd)
|
||||
state = await asyncio.to_thread(self.session_manager.create_session, cwd=cwd)
|
||||
await self._attach_session_mcp(state, mcp_servers, "Resumed session %s", state.session_id)
|
||||
return ResumeSessionResponse(**await self._session_response_fields(state, "resume"))
|
||||
|
||||
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
||||
state = self.session_manager.get_session(session_id)
|
||||
# get_session restores a not-in-memory id from the DB (full AIAgent build) and waits
|
||||
# on the restore lock — off the loop, like new/load/resume/fork (#58083).
|
||||
state = await asyncio.to_thread(self.session_manager.get_session, session_id)
|
||||
if not (state and state.cancel_event):
|
||||
return
|
||||
with state.runtime_lock:
|
||||
@@ -649,7 +653,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
|
||||
async def fork_session(
|
||||
self, cwd: str, session_id: str, mcp_servers: list | None = None, **kwargs: Any
|
||||
) -> ForkSessionResponse:
|
||||
state = self.session_manager.fork_session(session_id, cwd=cwd)
|
||||
state = await asyncio.to_thread(self.session_manager.fork_session, session_id, cwd=cwd)
|
||||
if state is None:
|
||||
logger.info("Forked session %s -> %s", session_id, "")
|
||||
return ForkSessionResponse(session_id="")
|
||||
@@ -799,7 +803,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
|
||||
|
||||
async def prompt(self, prompt: list[PromptBlock], session_id: str, **kwargs: Any) -> PromptResponse:
|
||||
"""Run Hermes on the user's prompt and stream events back to the editor."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
state = await asyncio.to_thread(self.session_manager.get_session, session_id)
|
||||
if state is None:
|
||||
logger.error("prompt: session %s not found", session_id)
|
||||
return PromptResponse(stop_reason="refusal")
|
||||
@@ -994,7 +998,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
|
||||
|
||||
async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) -> SetSessionModelResponse | None:
|
||||
"""Switch the model for a session (called by ACP protocol)."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
state = await asyncio.to_thread(self.session_manager.get_session, session_id)
|
||||
if state:
|
||||
# switch_model() does synchronous network I/O (models.dev, custom-endpoint probes,
|
||||
# ~10 s cold) — off the loop, like the gateway, so other ACP sessions keep flowing.
|
||||
@@ -1009,7 +1013,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
|
||||
|
||||
async def set_session_mode(self, mode_id: str, session_id: str, **kwargs: Any) -> SetSessionModeResponse | None:
|
||||
"""Persist the editor-requested mode so ACP clients do not fail on mode switches."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
state = await asyncio.to_thread(self.session_manager.get_session, session_id)
|
||||
if state is None:
|
||||
logger.warning("Session %s: mode switch requested for missing session", session_id)
|
||||
return None
|
||||
@@ -1025,7 +1029,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
|
||||
self, config_id: str, session_id: str, value: str, **kwargs: Any
|
||||
) -> SetSessionConfigOptionResponse | None:
|
||||
"""Accept ACP config option updates even when Hermes has no typed ACP config surface yet."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
state = await asyncio.to_thread(self.session_manager.get_session, session_id)
|
||||
if state is None:
|
||||
logger.warning("Session %s: config update requested for missing session", session_id)
|
||||
return None
|
||||
|
||||
@@ -159,6 +159,9 @@ class SessionManager:
|
||||
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
|
||||
|
||||
@@ -178,7 +181,12 @@ class SessionManager:
|
||||
a process restart) when it is not in memory; ``None`` if unknown."""
|
||||
with self._lock:
|
||||
state = self._sessions.get(session_id)
|
||||
return state if state is not None else self._restore(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."""
|
||||
@@ -383,6 +391,7 @@ class SessionManager:
|
||||
from run_agent import AIAgent
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
from hermes_constants import resolve_reasoning_config
|
||||
|
||||
config = load_config()
|
||||
model_cfg = config.get("model")
|
||||
@@ -403,6 +412,10 @@ class SessionManager:
|
||||
"disabled_toolsets": list(disabled_toolsets) if disabled_toolsets is not None else 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),
|
||||
}
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
@@ -410,6 +423,7 @@ class SessionManager:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user