diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 75f32ea7e6..8d14aff5fc 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -209,11 +209,9 @@ class GatewaySlashCommandsMixin( # ------------------------------------------------------------------ shared helpers def _cached_agent_for(self, session_key: str): - """Peek the cached AIAgent for *session_key* without evicting it, or None. - - Cache entries are ``(agent, signature, ...)`` tuples; bare agents (test doubles) are - accepted too. Lock/cache may be absent on fixtures that skip ``__init__``. - """ + """Peek the cached AIAgent for *session_key* without evicting it, or None. Entries are + ``(agent, signature, ...)`` tuples (bare agents from test doubles accepted); lock/cache may + be absent on fixtures that skip ``__init__``.""" cache = getattr(self, "_agent_cache", None) if cache is None: return None @@ -227,10 +225,8 @@ class GatewaySlashCommandsMixin( return entry or None def _resident_agent_for(self, session_key: str): - """The live running agent for *session_key*, else the cached one, else None. - - The pending sentinel (a run that is starting) never counts as a usable agent. - """ + """The live running agent for *session_key*, else the cached one, else None. The pending + sentinel (a run that is starting) never counts as a usable agent.""" from gateway.run import _AGENT_PENDING_SENTINEL agent = self._running_agents.get(session_key) if agent is not None and agent is not _AGENT_PENDING_SENTINEL: @@ -271,12 +267,9 @@ class GatewaySlashCommandsMixin( ) def _write_approval_setter(self, section: str, event: MessageEvent): - """``set_mode_fn`` for /memory and /skills: persist ``
.write_approval``. - - Raw read is correct for the write-back round-trip (merged defaults must not be persisted - back to the user's file). The new setting must take effect next message, so the cached - agent is dropped. - """ + """``set_mode_fn`` for /memory and /skills: persist ``
.write_approval``. Raw read is + correct for the write-back round-trip (merged defaults must not be persisted back to the + user's file); the cached agent is dropped so the setting takes effect next message.""" from gateway.run import _gateway_config_home from hermes_cli.config import read_user_config_raw config_path = _gateway_config_home() / "config.yaml" @@ -291,12 +284,9 @@ class GatewaySlashCommandsMixin( return _set_approval async def _deliver_approval_confirmation(self, event: MessageEvent, confirmation_text: str, verb: str): - """Return *confirmation_text* for normal delivery, or push it on native-streaming adapters. - - Native-streaming adapters (WeCom msgtype:"stream") need the confirmation sent directly with - control-lane metadata (reliable proactive send, not the finalized reply stream). Everyone - else returns text for normal delivery. (``is not True``: mocks auto-create attrs.) - """ + """Return *confirmation_text* for normal delivery, or push it on native-streaming adapters + (WeCom msgtype:"stream"), which need it sent directly with control-lane metadata (reliable + proactive send, not the finalized reply stream). ``is not True``: mocks auto-create attrs.""" source = event.source adapter = self.adapters.get(source.platform) if adapter: @@ -360,8 +350,7 @@ class GatewaySlashCommandsMixin( ]) async def _handle_whoami_command(self, event: MessageEvent) -> str: - """Handle /whoami — the user's slash command access on this scope (always allowed: slash_access - floor). Reports platform, DM-vs-group scope, tier, and the commands the user can run here.""" + """Handle /whoami — platform, DM-vs-group scope, tier and runnable commands (always allowed).""" from gateway.slash_access import policy_for_source source = event.source policy = policy_for_source(self.config, source) @@ -381,11 +370,8 @@ class GatewaySlashCommandsMixin( return head + f"Tier: user\nSlash commands you can run: {runnable_str}" async def _handle_kanban_command(self, event: MessageEvent) -> str: - """Handle /kanban — delegate to the shared kanban CLI. - - DB work runs in a thread pool to keep the event loop responsive. Reads and mutations are - allowed while an agent runs: the board is profile-agnostic and never touches agent state. - """ + """Handle /kanban — delegate to the shared kanban CLI (DB work in a thread pool). Allowed + while an agent runs: the board is profile-agnostic and never touches agent state.""" from hermes_cli.kanban import run_slash # Strip the leading "/kanban" (with or without slash), leaving args. @@ -521,8 +507,7 @@ class GatewaySlashCommandsMixin( async def _handle_platform_command(self, event: MessageEvent) -> str: """Handle ``/platform list|pause|resume [name]`` — inspect and manually control failed/paused - gateway adapters (pause stops the reconnect watcher; resume re-queues for retry). - """ + adapters (pause stops the reconnect watcher; resume re-queues for retry).""" text = (getattr(event, "content", "") or "").strip() # Strip the leading "/platform" (or "/PLATFORM") token if present parts = text.split(maxsplit=2) @@ -861,8 +846,7 @@ class GatewaySlashCommandsMixin( async def _handle_background_command(self, event: MessageEvent) -> str: """Handle /bg — run a prompt in a background thread with its own session; the - result is sent to the same chat without touching the active session's history. - """ + result is sent to the same chat without touching the active session's history.""" prompt = event.get_command_args().strip() if not prompt: return t("gateway.background.usage") @@ -877,10 +861,9 @@ class GatewaySlashCommandsMixin( return t("gateway.background.started", preview=_preview(prompt), task_id=task_id) async def _handle_btw_command(self, event: MessageEvent) -> str: - """Handle /btw — answer a side question via a one-shot auxiliary LLM call on a - transcript snapshot; live history is never touched (alternation + prompt cache intact, - current turn keeps running). Unlike /bg, which spawns a fresh contextless session. - """ + """Handle /btw — one-shot auxiliary LLM call on a transcript snapshot; live history + is never touched (alternation + prompt cache intact, current turn keeps running). Unlike /bg, + which spawns a fresh contextless session.""" question = event.get_command_args().strip() if not question: return t("gateway.btw.usage") @@ -928,10 +911,8 @@ class GatewaySlashCommandsMixin( return t("gateway.btw.started", preview=preview) async def _handle_memory_command(self, event: MessageEvent) -> str: - """Handle /memory — review pending memory writes + toggle the approval gate. - - Entries are small enough to review inline, so the full flow works on every platform. - """ + """Handle /memory — review pending memory writes + toggle the approval gate. Entries are small + enough to review inline, so the full flow works on every platform.""" from hermes_cli.write_approval_commands import handle_pending_subcommand from tools import write_approval as wa from tools.memory_tool import load_on_disk_store @@ -946,11 +927,9 @@ class GatewaySlashCommandsMixin( ) async def _handle_skills_command(self, event: MessageEvent) -> str: - """Handle /skills on the gateway — pending skill-write review only (hub stays CLI-only). - - Gated by ``skills.write_approval`` but still answers when staged writes exist after the - gate is off (never stranded). ``diff`` is truncated for chat. - """ + """Handle /skills on the gateway — pending skill-write review only (hub stays CLI-only). Gated + by ``skills.write_approval`` but still answers when staged writes exist after the gate is off + (never stranded). ``diff`` is truncated for chat.""" from hermes_cli.write_approval_commands import handle_pending_subcommand from tools import write_approval as wa args = event.get_command_args().strip().split() @@ -1004,11 +983,9 @@ class GatewaySlashCommandsMixin( return EphemeralReply(t("gateway.yolo.enabled")) async def _handle_verbose_command(self, event: MessageEvent) -> str: - """Handle /verbose command — cycle tool progress display mode. - - Gated by ``display.tool_progress_command`` (default off). Cycles off → new → all → verbose - → log per *current platform*, saved to ``display.platforms..tool_progress``. - """ + """Handle /verbose — cycle tool progress display mode (off → new → all → verbose → log) per + *current platform*, saved to ``display.platforms..tool_progress``. Gated by + ``display.tool_progress_command`` (default off).""" from gateway.run import _load_gateway_config config_path, platform_key = self._display_config_target(event) try: @@ -1129,12 +1106,9 @@ class GatewaySlashCommandsMixin( return t("gateway.footer.saved", state=_state(new_state), example=example) async def _handle_reload_mcp_command(self, event: MessageEvent) -> Optional[str]: - """Handle /reload-mcp — reconnect MCP servers and rebuild the cached agent. - - Reloading invalidates the provider prompt cache (tool schemas live in the system prompt), - so it routes through slash-confirm; "Always Approve" persists - ``approvals.mcp_reload_confirm: false``. - """ + """Handle /reload-mcp — reconnect MCP servers and rebuild the cached agent. Reloading + invalidates the provider prompt cache (tool schemas live in the system prompt), so it routes + through slash-confirm; "Always Approve" persists ``approvals.mcp_reload_confirm: false``.""" session_key = self._session_key_for_source(event.source) # Read the gate fresh from disk so a prior "always" click takes effect on the next @@ -1170,12 +1144,10 @@ class GatewaySlashCommandsMixin( ) async def _handle_reload_skills_command(self, event: MessageEvent) -> str: - """Handle /reload-skills — rescan skills dir, queue a note for next turn. - - Skills are invoked at runtime, not baked into the system prompt, so this does NOT clear the - prompt cache. Added/removed skills go into ``_pending_skills_reload_notes[session_key]``, - prepended to the NEXT user message — nothing out-of-band, so alternation is preserved. - """ + """Handle /reload-skills — rescan skills dir, queue a note for next turn. Skills are invoked at + runtime, not baked into the system prompt, so this does NOT clear the prompt cache. The diff + goes into ``_pending_skills_reload_notes[session_key]``, prepended to the NEXT user message — + nothing out-of-band, so alternation is preserved.""" try: from agent.skill_commands import reload_skills @@ -1237,10 +1209,8 @@ class GatewaySlashCommandsMixin( return t("gateway.reload_skills.failed", error=e) async def _handle_bundles_command(self, event: MessageEvent) -> str: - """Handle /bundles — list installed skill bundles (mirrors the CLI handler). - - Bundles are loaded by invoking their own ``/`` command, not by this one. - """ + """Handle /bundles — list installed skill bundles (mirrors the CLI handler). Bundles are + loaded by invoking their own ``/`` command, not by this one.""" reply = _execute("bundles") if "error" in reply.data: logger.warning("Bundles command unavailable: %s", reply.data["error"]) @@ -1266,9 +1236,7 @@ class GatewaySlashCommandsMixin( def _blocking_approval_or_stale(self, event: MessageEvent, stale_key: str, none_key: str): """``(session_key, None)`` when an agent thread is blocked on approval, else the reply to send. - - A pending-approvals entry with no blocked thread is a stale prompt: drop it and say so. - """ + A pending-approvals entry with no blocked thread is a stale prompt: drop it and say so.""" from tools.approval import has_blocking_approval session_key = self._session_key_for_source(event.source) if has_blocking_approval(session_key): @@ -1279,11 +1247,8 @@ class GatewaySlashCommandsMixin( return session_key, t(none_key) async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]: - """Handle /approve command — unblock waiting agent thread(s). - - Agent threads block inside tools/approval.py; signalling the event resumes them so the - command executes inline — same flow as the CLI's synchronous approval. - """ + """Handle /approve — unblock waiting agent thread(s). They block inside tools/approval.py; + signalling the event resumes them so the command executes inline (same flow as the CLI).""" from tools.approval import resolve_gateway_approval session_key, stale = self._blocking_approval_or_stale( event, "gateway.approval_expired", "gateway.approve.no_pending" @@ -1304,11 +1269,8 @@ class GatewaySlashCommandsMixin( return await self._deliver_approval_confirmation(event, confirmation_text, "approve") async def _handle_deny_command(self, event: MessageEvent) -> str: - """Handle /deny command — reject pending dangerous command(s). - - Signals blocked thread(s) with a 'deny' result so they get a definitive BLOCKED message, - as in the CLI. ``/deny`` denies the oldest; ``/deny all`` denies everything. - """ + """Handle /deny — reject pending dangerous command(s) with a definitive BLOCKED result, as in + the CLI. ``/deny`` denies the oldest; ``/deny all`` denies everything.""" from tools.approval import resolve_gateway_approval session_key, stale = self._blocking_approval_or_stale( event, "gateway.deny.stale", "gateway.deny.no_pending" @@ -1333,11 +1295,8 @@ class GatewaySlashCommandsMixin( return await self._deliver_approval_confirmation(event, confirmation_text, "deny") async def _handle_debug_command(self, event: MessageEvent) -> str: - """Handle /debug — upload debug report (summary only) and return paste URLs. - - Uploads ONLY the summary (system info + log tails), never full logs, to protect privacy; - use ``hermes debug share`` from the CLI for full uploads. - """ + """Handle /debug — upload ONLY the summary (system info + log tails), never full logs, to + protect privacy; ``hermes debug share`` from the CLI does full uploads.""" from hermes_cli.debug import ( _GATEWAY_PRIVACY_NOTICE, _best_effort_sweep_expired_pastes, _capture_dump, _schedule_auto_delete, collect_debug_report, upload_to_pastebin, @@ -1365,11 +1324,8 @@ class GatewaySlashCommandsMixin( return await self._run_in_executor_with_context(_collect_and_upload) async def _handle_update_command(self, event: MessageEvent) -> str: - """Handle /update command — update Hermes Agent to the latest version. - - Spawns ``hermes update`` detached (``setsid``) so it survives the gateway restart it may - trigger; marker files let this or the next gateway process notify the user on completion. - """ + """Handle /update — spawn ``hermes update`` detached (``setsid``) so it survives the gateway + restart it may trigger; marker files let this or the next gateway process notify the user.""" from gateway.run import _hermes_home, _resolve_hermes_bin import json from hermes_cli.config import is_managed, format_managed_message diff --git a/gateway/slash_commands_goals.py b/gateway/slash_commands_goals.py index 8fc7f1a77b..6105bd7a10 100644 --- a/gateway/slash_commands_goals.py +++ b/gateway/slash_commands_goals.py @@ -1,8 +1,5 @@ """Autonomy-loop gateway commands: /goal, /subgoal, /heartbeat, /loop, /refine, /review. - -Split out of ``gateway/slash_commands.py``; bound onto ``GatewayRunner`` through -``GatewaySlashCommandsMixin``. -""" +Bound onto ``GatewayRunner`` through ``GatewaySlashCommandsMixin``.""" from __future__ import annotations @@ -89,8 +86,7 @@ class GatewayGoalCommandsMixin: """Enqueue *text* as the next turn through the adapter FIFO (the post-turn judge's path). A kickoff keeps the triggering message id / channel prompt; a resume continuation carries - none. *route* is a pre-resolved ``(adapter, quick_key)``; otherwise resolved here. - Best-effort: enqueue failures are logged, never surfaced. + none. *route* is a pre-resolved ``(adapter, quick_key)``. Best-effort: failures only logged. """ try: adapter, quick_key = route or self._adapter_and_key_for(event) @@ -279,10 +275,8 @@ class GatewayGoalCommandsMixin: ) def _idle_cached_agent_or_error(self, event: MessageEvent, verb: str): - """``(session_key, cached_agent, None)`` for /refine and /review, or ``(_, _, error_text)``. - - Both need a cached agent from a completed turn and refuse while a run is in flight. - """ + """``(session_key, cached_agent, None)`` for /refine and /review, or ``(_, _, error_text)``: + both need a cached agent from a completed turn and refuse while a run is in flight.""" quick_key = self._session_key_for_source(event.source) if event.source else None if not quick_key: return None, None, f"{verb.capitalize()} unavailable (no session)." @@ -294,11 +288,8 @@ class GatewayGoalCommandsMixin: return quick_key, agent, None async def _handle_refine_command(self, event: MessageEvent) -> str: - """Handle /refine — run the memory/skill review fork on demand. - - Runs in a daemon thread against a snapshot of the cached AIAgent's conversation; the live - session and prompt cache are untouched. Requires at least one completed turn. - """ + """Handle /refine — run the memory/skill review fork on demand, in a daemon thread against a + snapshot of the cached AIAgent's conversation (live session and prompt cache untouched).""" args = (event.get_command_args() or "").strip() _quick_key, agent, error = self._idle_cached_agent_or_error(event, "refine") if error: @@ -321,11 +312,9 @@ class GatewayGoalCommandsMixin: ) async def _handle_review_command(self, event: MessageEvent) -> str: - """Handle /review — spawn an independent reviewer subagent. - - The approval session-key contextvar is only bound during agent turns, so bind it explicitly - here or the completion event carries no gateway route and never re-enters this chat. - """ + """Handle /review — spawn an independent reviewer subagent. The approval session-key + contextvar is only bound during agent turns, so bind it explicitly here or the completion + event carries no gateway route and never re-enters this chat.""" args = (event.get_command_args() or "").strip() quick_key, agent, error = self._idle_cached_agent_or_error(event, "review") if error: diff --git a/gateway/slash_commands_status.py b/gateway/slash_commands_status.py index f05ba31dcd..b847c48d2c 100644 --- a/gateway/slash_commands_status.py +++ b/gateway/slash_commands_status.py @@ -1,8 +1,5 @@ """Read-only gateway introspection commands: /status, /context, /usage, /agents, /insights, /topup. - -Split out of ``gateway/slash_commands.py``; bound onto ``GatewayRunner`` through -``GatewaySlashCommandsMixin``. -""" +Bound onto ``GatewayRunner`` through ``GatewaySlashCommandsMixin``.""" from __future__ import annotations @@ -222,11 +219,9 @@ class GatewayStatusCommandsMixin: # model/context display, but it still occupies the session slot. agent = self._running_agents.get(session_key) is_running = agent is not None and agent is not _AGENT_PENDING_SENTINEL - # Pending /queue follow-ups (slot + overflow). adapter = self.adapters.get(source.platform) if source else None queue_depth = self._queue_depth(session_key, adapter=adapter) - title, session_row, db_total_tokens, persisted_route = await self._status_session_db_facts( session_entry.session_id ) @@ -237,14 +232,13 @@ class GatewayStatusCommandsMixin: status_agent, persisted_route, session_row, session_entry ) + stamp = "%Y-%m-%d %H:%M" lines = [t("gateway.status.header"), "", t("gateway.status.session_id", session_id=session_entry.session_id)] if title: lines.append(t("gateway.status.title", title=title)) - lines += [ - t("gateway.status.created", timestamp=session_entry.created_at.strftime('%Y-%m-%d %H:%M')), - t("gateway.status.last_activity", timestamp=session_entry.updated_at.strftime('%Y-%m-%d %H:%M')), - ] + lines += [t("gateway.status.created", timestamp=session_entry.created_at.strftime(stamp)), + t("gateway.status.last_activity", timestamp=session_entry.updated_at.strftime(stamp))] if model_name and provider_name: lines.append(t("gateway.status.model_provider", model=model_name, provider=provider_name)) elif model_name: @@ -256,10 +250,8 @@ class GatewayStatusCommandsMixin: elif context_used: lines.append(t("gateway.status.context_used", used=_fmt(context_used))) state = t("gateway.status.state_yes") if is_running else t("gateway.status.state_no") - lines += [ - t("gateway.status.tokens", tokens=_fmt(db_total_tokens)), - t("gateway.status.agent_running", state=state), - ] + lines += [t("gateway.status.tokens", tokens=_fmt(db_total_tokens)), + t("gateway.status.agent_running", state=state)] if queue_depth: lines.append(t("gateway.status.queued", count=queue_depth)) if source.platform == Platform.MATRIX: @@ -281,9 +273,8 @@ class GatewayStatusCommandsMixin: async def _status_session_db_facts(self, session_id: str): """``(title, session_row, db_total_tokens, persisted_route)`` for /status; each fail-open. - Token totals come from the SQLite session DB rather than the in-memory SessionStore: the - agent's per-turn token deltas are persisted into sessions_db (run_agent.py), not into - SessionEntry, so session_entry.total_tokens is always 0. + Token totals come from the SQLite session DB, not SessionStore: run_agent.py persists per-turn + token deltas into sessions_db, never into SessionEntry (its total_tokens is always 0). """ db = self._session_db if not db: @@ -307,10 +298,9 @@ class GatewayStatusCommandsMixin: async def _handle_context_command(self, event: MessageEvent) -> str: """Handle /context — the deep context-window view (/status has the one-line summary). - Usage gauge, auto-compression threshold and headroom, compression count and last savings, - and cumulative throughput (clearly labelled as throughput, NOT context size). Resolution - order: running agent, cached agent, SessionStore/SessionDB metadata, transcript estimate as - last resort. ``/context all`` adds per-skill/toolset listings. + Gauge, auto-compression threshold/headroom, compression count + last savings, and cumulative + throughput (labelled as throughput, NOT context size). Resolution: running agent -> cached + agent -> SessionStore/SessionDB metadata -> transcript estimate. ``all`` adds listings. """ source = event.source session_entry = await self.async_session_store.get_or_create_session(source) @@ -326,9 +316,8 @@ class GatewayStatusCommandsMixin: # Gauge path: real current-context figure if used > 0 and context_length > 0: pct = _pct(used, context_length) - bar_width = 24 - filled = int(round(pct / 100 * bar_width)) - bar = "█" * max(0, filled) + "░" * max(0, bar_width - filled) + filled = int(round(pct / 100 * 24)) + bar = "█" * max(0, filled) + "░" * max(0, 24 - filled) lines = [ t("gateway.context.header"), "", @@ -368,12 +357,8 @@ class GatewayStatusCommandsMixin: return t("gateway.context.no_data") async def _resolve_context_figures(self, agent, ctx, session_entry, source): - """``(used, context_length, model_name)`` for /context with cascading fallbacks. - - used : compressor.last_prompt_tokens -> SessionStore.last_prompt_tokens - model : agent.model -> SessionDB row model - window: compressor.context_length -> effective gateway model route -> model metadata - """ + """``(used, context_length, model_name)`` for /context: used = compressor -> SessionStore; + model = agent -> SessionDB row; window = compressor -> gateway model route -> model metadata.""" used = _n(ctx, "last_prompt_tokens") if ctx is not None else 0 context_length = _n(ctx, "context_length") if ctx is not None else 0 model_name = _clean_str(getattr(agent, "model", "")) if agent is not None else "" @@ -463,11 +448,8 @@ class GatewayStatusCommandsMixin: return "\n".join(lines) async def _handle_topup_command(self, event: MessageEvent) -> str: - """Handle /topup -- show the Nous balance and hand off to the portal. - - Does NOT charge, confirm, or track payment — that happens in the browser; the next /topup - shows the new balance. Fetched off the event loop; fail-open. - """ + """Handle /topup -- show the Nous balance and hand off to the portal. Does NOT charge, confirm, + or track payment (that happens in the browser; the next /topup shows the new balance).""" from agent.account_usage import build_credits_view view = await _quiet(lambda: asyncio.to_thread(build_credits_view, markdown=True)) if view is None or not view.logged_in: @@ -487,10 +469,8 @@ class GatewayStatusCommandsMixin: return "\n".join(lines) def _context_breakdown_block(self, agent, source, expanded: bool) -> list[str]: - """Render the /context per-category block (plain text, no grid). - - Estimated (chars/4), same engine as /usage. Runs in a thread; returns [] and never raises. - """ + """/context per-category block (plain text, chars/4 estimate, same engine as /usage). + Runs in a thread; returns [] and never raises.""" try: from agent.context_breakdown import compute_context_details, render_context_breakdown_lines payload = self._session_context_breakdown(agent, source) @@ -514,10 +494,7 @@ class GatewayStatusCommandsMixin: return compute_session_context_breakdown(agent, _quiet_sync(_history, [])) def _context_breakdown_lines(self, agent, source) -> list[str]: - """Render the per-category context breakdown for /usage. - - Estimated (chars/4). Returns [] and never raises so /usage stays robust. - """ + """/usage per-category context breakdown (chars/4 estimate). Returns [] and never raises.""" try: payload = self._session_context_breakdown(agent, source) categories = payload.get("categories") or []