diff --git a/agent/onboarding.py b/agent/onboarding.py new file mode 100644 index 0000000000..eed832ab90 --- /dev/null +++ b/agent/onboarding.py @@ -0,0 +1,144 @@ +""" +Contextual first-touch onboarding hints. + +Instead of blocking first-run questionnaires, show a one-time hint the *first* +time a user hits a behavior fork β€” message-while-running, first long-running +tool, etc. Each hint is shown once per install (tracked in ``config.yaml`` under +``onboarding.seen.``) and then never again. + +Keep this module tiny and dependency-free so both the CLI and gateway can import +it without pulling in heavy modules. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Mapping, Optional + +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------------- +# Flag names (stable β€” used as config.yaml keys under onboarding.seen) +# ------------------------------------------------------------------------- + +BUSY_INPUT_FLAG = "busy_input_prompt" +TOOL_PROGRESS_FLAG = "tool_progress_prompt" + + +# ------------------------------------------------------------------------- +# Hint content +# ------------------------------------------------------------------------- + +def busy_input_hint_gateway(mode: str) -> str: + """Hint shown the first time a user messages while the agent is busy. + + ``mode`` is the effective busy_input_mode that was just applied, so the + message matches reality ("I just interrupted…" vs "I just queued…"). + """ + if mode == "queue": + return ( + "πŸ’‘ First-time tip β€” I queued your message instead of interrupting. " + "Send `/busy interrupt` to make new messages stop the current task " + "immediately, or `/busy status` to check. This notice won't appear again." + ) + return ( + "πŸ’‘ First-time tip β€” I just interrupted my current task to answer you. " + "Send `/busy queue` to queue follow-ups for after the current task instead, " + "or `/busy status` to check. This notice won't appear again." + ) + + +def busy_input_hint_cli(mode: str) -> str: + """CLI version of the busy-input hint (plain text, no markdown).""" + if mode == "queue": + return ( + "(tip) Your message was queued for the next turn. " + "Use /busy interrupt to make Enter stop the current run instead. " + "This tip only shows once." + ) + return ( + "(tip) Your message interrupted the current run. " + "Use /busy queue to queue messages for the next turn instead. " + "This tip only shows once." + ) + + +def tool_progress_hint_gateway() -> str: + return ( + "πŸ’‘ First-time tip β€” that tool took a while and I'm streaming every step. " + "If the progress messages feel noisy, send `/verbose` to cycle modes " + "(all β†’ new β†’ off). This notice won't appear again." + ) + + +def tool_progress_hint_cli() -> str: + return ( + "(tip) That tool ran for a while. Use /verbose to cycle tool-progress " + "display modes (all -> new -> off -> verbose). This tip only shows once." + ) + + +# ------------------------------------------------------------------------- +# State read / write +# ------------------------------------------------------------------------- + +def _get_seen_dict(config: Mapping[str, Any]) -> Mapping[str, Any]: + onboarding = config.get("onboarding") if isinstance(config, Mapping) else None + if not isinstance(onboarding, Mapping): + return {} + seen = onboarding.get("seen") + return seen if isinstance(seen, Mapping) else {} + + +def is_seen(config: Mapping[str, Any], flag: str) -> bool: + """Return True if the user has already been shown this first-touch hint.""" + return bool(_get_seen_dict(config).get(flag)) + + +def mark_seen(config_path: Path, flag: str) -> bool: + """Persist ``onboarding.seen. = True`` to ``config_path``. + + Uses the atomic YAML writer so a concurrent process can't observe a + partially-written file. Returns True on success, False on any error + (including the config file being absent β€” onboarding is best-effort). + """ + try: + import yaml + from utils import atomic_yaml_write + except Exception as e: # pragma: no cover β€” dependency issue + logger.debug("onboarding: failed to import yaml/utils: %s", e) + return False + + try: + cfg: dict = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + if not isinstance(cfg.get("onboarding"), dict): + cfg["onboarding"] = {} + seen = cfg["onboarding"].get("seen") + if not isinstance(seen, dict): + seen = {} + cfg["onboarding"]["seen"] = seen + if seen.get(flag) is True: + return True # already marked β€” nothing to do + seen[flag] = True + atomic_yaml_write(config_path, cfg) + return True + except Exception as e: + logger.debug("onboarding: failed to mark flag %s: %s", flag, e) + return False + + +__all__ = [ + "BUSY_INPUT_FLAG", + "TOOL_PROGRESS_FLAG", + "busy_input_hint_gateway", + "busy_input_hint_cli", + "tool_progress_hint_gateway", + "tool_progress_hint_cli", + "is_seen", + "mark_seen", +] diff --git a/cli.py b/cli.py index bcb6190125..b4f8e0f384 100644 --- a/cli.py +++ b/cli.py @@ -417,6 +417,11 @@ def load_cli_config() -> Dict[str, Any]: "base_url": "", # Direct OpenAI-compatible endpoint for subagents "api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY) }, + "onboarding": { + # First-touch hint flags (see agent/onboarding.py). Each hint is + # shown once per install then latched here. + "seen": {}, + }, } # Track whether the config file explicitly set terminal config. @@ -6148,8 +6153,6 @@ class HermesCLI: self._handle_agents_command() elif canonical == "background": self._handle_background_command(cmd_original) - elif canonical == "btw": - self._handle_btw_command(cmd_original) elif canonical == "queue": # Extract prompt after "/queue " or "/q " parts = cmd_original.split(None, 1) @@ -6436,122 +6439,6 @@ class HermesCLI: self._background_tasks[task_id] = thread thread.start() - def _handle_btw_command(self, cmd: str): - """Handle /btw β€” ephemeral side question using session context. - - Snapshots the current conversation history, spawns a no-tools agent in - a background thread, and prints the answer without persisting anything - to the main session. - """ - parts = cmd.strip().split(maxsplit=1) - if len(parts) < 2 or not parts[1].strip(): - _cprint(" Usage: /btw ") - _cprint(" Example: /btw what module owns session title sanitization?") - _cprint(" Answers using session context. No tools, not persisted.") - return - - question = parts[1].strip() - task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}" - - if not self._ensure_runtime_credentials(): - _cprint(" (>_<) Cannot start /btw: no valid credentials.") - return - - turn_route = self._resolve_turn_agent_config(question) - history_snapshot = list(self.conversation_history) - - preview = question[:60] + ("..." if len(question) > 60 else "") - _cprint(f' πŸ’¬ /btw: "{preview}"') - - def run_btw(): - try: - btw_agent = AIAgent( - model=turn_route["model"], - api_key=turn_route["runtime"].get("api_key"), - base_url=turn_route["runtime"].get("base_url"), - provider=turn_route["runtime"].get("provider"), - api_mode=turn_route["runtime"].get("api_mode"), - acp_command=turn_route["runtime"].get("command"), - acp_args=turn_route["runtime"].get("args"), - max_iterations=8, - enabled_toolsets=[], - quiet_mode=True, - verbose_logging=False, - session_id=task_id, - platform="cli", - reasoning_config=self.reasoning_config, - service_tier=self.service_tier, - request_overrides=turn_route.get("request_overrides"), - providers_allowed=self._providers_only, - providers_ignored=self._providers_ignore, - providers_order=self._providers_order, - provider_sort=self._provider_sort, - provider_require_parameters=self._provider_require_params, - provider_data_collection=self._provider_data_collection, - fallback_model=self._fallback_model, - session_db=None, - skip_memory=True, - skip_context_files=True, - persist_session=False, - ) - - btw_prompt = ( - "[Ephemeral /btw side question. Answer using the conversation " - "context. No tools available. Be direct and concise.]\n\n" - + question - ) - result = btw_agent.run_conversation( - user_message=btw_prompt, - conversation_history=history_snapshot, - task_id=task_id, - ) - - response = (result.get("final_response") or "") if result else "" - if not response and result and result.get("error"): - response = f"Error: {result['error']}" - - # TUI refresh before printing - if self._app: - self._app.invalidate() - time.sleep(0.05) - print() - - if response: - try: - from hermes_cli.skin_engine import get_active_skin - _skin = get_active_skin() - _resp_color = _skin.get_color("response_border", "#4F6D4A") - except Exception: - _resp_color = "#4F6D4A" - - ChatConsole().print(Panel( - _render_final_assistant_content(response, mode=self.final_response_markdown), - title=f"[{_resp_color} bold]βš• /btw[/]", - title_align="left", - border_style=_resp_color, - box=rich_box.HORIZONTALS, - padding=(1, 4), - )) - else: - _cprint(" πŸ’¬ /btw: (no response)") - - if self.bell_on_complete: - sys.stdout.write("\a") - sys.stdout.flush() - - except Exception as e: - if self._app: - self._app.invalidate() - time.sleep(0.05) - print() - _cprint(f" ❌ /btw failed: {e}") - finally: - if self._app: - self._invalidate(min_interval=0) - - thread = threading.Thread(target=run_btw, daemon=True, name=f"btw-{task_id}") - thread.start() - @staticmethod def _try_launch_chrome_debug(port: int, system: str) -> bool: """Try to launch Chrome/Chromium with remote debugging enabled. @@ -7436,6 +7323,31 @@ class HermesCLI: _cprint(f" {line}") except Exception: pass + # First-touch onboarding: on the first tool in this process + # that takes longer than the threshold while we're in the + # noisiest progress mode, print a one-time hint about + # /verbose. Latched on self so it fires at most once per + # process; persisted to config.yaml so it never fires again + # across processes either. + try: + if ( + not getattr(self, "_long_tool_hint_fired", False) + and self.tool_progress_mode == "all" + and duration >= 30.0 + ): + from agent.onboarding import ( + TOOL_PROGRESS_FLAG, + is_seen, + mark_seen, + tool_progress_hint_cli, + ) + if not is_seen(CLI_CONFIG, TOOL_PROGRESS_FLAG): + self._long_tool_hint_fired = True + _cprint(f" {_DIM}{tool_progress_hint_cli()}{_RST}") + mark_seen(_hermes_home / "config.yaml", TOOL_PROGRESS_FLAG) + CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[TOOL_PROGRESS_FLAG] = True + except Exception: + pass self._invalidate() return if event_type != "tool.started": @@ -9334,6 +9246,24 @@ class HermesCLI: f"agent_running={self._agent_running}\n") except Exception: pass + # First-touch onboarding: on the very first busy-while-running + # event for this install, print a one-line tip explaining the + # /busy knob. Flag persists to config.yaml and never fires + # again. Guarded for exceptions so onboarding can't break + # the input loop. + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + busy_input_hint_cli, + is_seen, + mark_seen, + ) + if not is_seen(CLI_CONFIG, BUSY_INPUT_FLAG): + _cprint(f" {_DIM}{busy_input_hint_cli(self.busy_input_mode)}{_RST}") + mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) + CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[BUSY_INPUT_FLAG] = True + except Exception: + pass else: self._pending_input.put(payload) event.app.current_buffer.reset(append_to_history=True) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 5d30f244e8..b4018c6df6 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2315,11 +2315,6 @@ class DiscordAdapter(BasePlatformAdapter): async def slash_background(interaction: discord.Interaction, prompt: str): await self._run_simple_slash(interaction, f"/background {prompt}", "Background task started~") - @tree.command(name="btw", description="Ephemeral side question using session context") - @discord.app_commands.describe(question="Your side question (no tools, not persisted)") - async def slash_btw(interaction: discord.Interaction, question: str): - await self._run_simple_slash(interaction, f"/btw {question}") - # ── Auto-register any gateway-available commands not yet on the tree ── # This ensures new commands added to COMMAND_REGISTRY in # hermes_cli/commands.py automatically appear as Discord slash diff --git a/gateway/run.py b/gateway/run.py index 5de5059488..aa04a5ef77 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1630,6 +1630,27 @@ class GatewayRunner: f"I'll respond to your message shortly." ) + # First-touch onboarding: the very first time a user sends a message + # while the agent is busy, append a one-time hint explaining the + # queue/interrupt knob. Flag is persisted to config.yaml so it never + # fires again on this install. + try: + from agent.onboarding import ( + BUSY_INPUT_FLAG, + busy_input_hint_gateway, + is_seen, + mark_seen, + ) + _user_cfg = _load_gateway_config() + if not is_seen(_user_cfg, BUSY_INPUT_FLAG): + message = ( + f"{message}\n\n" + f"{busy_input_hint_gateway('queue' if is_queue_mode else 'interrupt')}" + ) + mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) + except Exception as _onb_err: + logger.debug("Failed to apply busy-input onboarding hint: %s", _onb_err) + thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None try: await adapter._send_with_retry( @@ -3478,6 +3499,8 @@ class GatewayRunner: # /background must bypass the running-agent guard β€” it starts a # parallel task and must never interrupt the active conversation. + # /btw is an alias of /background and resolves to the same canonical + # name, so this branch handles both commands. if _cmd_def_inner and _cmd_def_inner.name == "background": return await self._handle_background_command(event) @@ -3753,9 +3776,6 @@ class GatewayRunner: if canonical == "background": return await self._handle_background_command(event) - if canonical == "btw": - return await self._handle_btw_command(event) - if canonical == "steer": # No active agent β€” /steer has no tool call to inject into. # Strip the prefix so downstream treats it as a normal user @@ -6653,177 +6673,6 @@ class GatewayRunner: except Exception: pass - async def _handle_btw_command(self, event: MessageEvent) -> str: - """Handle /btw β€” ephemeral side question in the same chat.""" - question = event.get_command_args().strip() - if not question: - return ( - "Usage: /btw \n" - "Example: /btw what module owns session title sanitization?\n\n" - "Answers using session context. No tools, not persisted." - ) - - source = event.source - session_key = self._session_key_for_source(source) - - # Guard: one /btw at a time per session - existing = getattr(self, "_active_btw_tasks", {}).get(session_key) - if existing and not existing.done(): - return "A /btw is already running for this chat. Wait for it to finish." - - if not hasattr(self, "_active_btw_tasks"): - self._active_btw_tasks: dict = {} - - import uuid as _uuid - task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{_uuid.uuid4().hex[:6]}" - _task = asyncio.create_task(self._run_btw_task(question, source, session_key, task_id)) - self._background_tasks.add(_task) - self._active_btw_tasks[session_key] = _task - - def _cleanup(task): - self._background_tasks.discard(task) - if self._active_btw_tasks.get(session_key) is task: - self._active_btw_tasks.pop(session_key, None) - - _task.add_done_callback(_cleanup) - - preview = question[:60] + ("..." if len(question) > 60 else "") - return f'πŸ’¬ /btw: "{preview}"\nReply will appear here shortly.' - - async def _run_btw_task( - self, question: str, source, session_key: str, task_id: str, - ) -> None: - """Execute an ephemeral /btw side question and deliver the answer.""" - from run_agent import AIAgent - - adapter = self.adapters.get(source.platform) - if not adapter: - logger.warning("No adapter for platform %s in /btw task %s", source.platform, task_id) - return - - _thread_meta = {"thread_id": source.thread_id} if source.thread_id else None - - try: - user_config = _load_gateway_config() - model, runtime_kwargs = self._resolve_session_agent_runtime( - source=source, - session_key=session_key, - user_config=user_config, - ) - if not runtime_kwargs.get("api_key"): - await adapter.send( - source.chat_id, - "❌ /btw failed: no provider credentials configured.", - metadata=_thread_meta, - ) - return - - platform_key = _platform_config_key(source.platform) - reasoning_config = self._resolve_session_reasoning_config( - source=source, - session_key=session_key, - ) - self._service_tier = self._load_service_tier() - turn_route = self._resolve_turn_agent_config(question, model, runtime_kwargs) - pr = self._provider_routing - - # Snapshot history from running agent or stored transcript - running_agent = self._running_agents.get(session_key) - if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: - history_snapshot = list(getattr(running_agent, "_session_messages", []) or []) - else: - session_entry = self.session_store.get_or_create_session(source) - history_snapshot = self.session_store.load_transcript(session_entry.session_id) - - btw_prompt = ( - "[Ephemeral /btw side question. Answer using the conversation " - "context. No tools available. Be direct and concise.]\n\n" - + question - ) - - def run_sync(): - agent = AIAgent( - model=turn_route["model"], - **turn_route["runtime"], - max_iterations=8, - quiet_mode=True, - verbose_logging=False, - enabled_toolsets=[], - reasoning_config=reasoning_config, - service_tier=self._service_tier, - request_overrides=turn_route.get("request_overrides"), - providers_allowed=pr.get("only"), - providers_ignored=pr.get("ignore"), - providers_order=pr.get("order"), - provider_sort=pr.get("sort"), - provider_require_parameters=pr.get("require_parameters", False), - provider_data_collection=pr.get("data_collection"), - session_id=task_id, - platform=platform_key, - session_db=None, - fallback_model=self._fallback_model, - skip_memory=True, - skip_context_files=True, - persist_session=False, - ) - try: - return agent.run_conversation( - user_message=btw_prompt, - conversation_history=history_snapshot, - task_id=task_id, - ) - finally: - self._cleanup_agent_resources(agent) - - result = await self._run_in_executor_with_context(run_sync) - - response = (result.get("final_response") or "") if result else "" - if not response and result and result.get("error"): - response = f"Error: {result['error']}" - if not response: - response = "(No response generated)" - - media_files, response = adapter.extract_media(response) - images, text_content = adapter.extract_images(response) - preview = question[:60] + ("..." if len(question) > 60 else "") - header = f'πŸ’¬ /btw: "{preview}"\n\n' - - if text_content: - await adapter.send( - chat_id=source.chat_id, - content=header + text_content, - metadata=_thread_meta, - ) - elif not images and not media_files: - await adapter.send( - chat_id=source.chat_id, - content=header + "(No response generated)", - metadata=_thread_meta, - ) - - for image_url, alt_text in (images or []): - try: - await adapter.send_image(chat_id=source.chat_id, image_url=image_url, caption=alt_text) - except Exception: - pass - - for media_path, _is_voice in (media_files or []): - try: - await adapter.send_file(chat_id=source.chat_id, file_path=media_path) - except Exception: - pass - - except Exception as e: - logger.exception("/btw task %s failed", task_id) - try: - await adapter.send( - chat_id=source.chat_id, - content=f"❌ /btw failed: {e}", - metadata=_thread_meta, - ) - except Exception: - pass - async def _handle_reasoning_command(self, event: MessageEvent) -> str: """Handle /reasoning command β€” manage reasoning effort and display toggle. @@ -9412,12 +9261,42 @@ class GatewayRunner: last_tool = [None] # Mutable container for tracking in closure last_progress_msg = [None] # Track last message for dedup repeat_count = [0] # How many times the same message repeated - + # First-touch onboarding latch: fires at most once per run, even if + # several tools exceed the threshold. + long_tool_hint_fired = [False] + _LONG_TOOL_THRESHOLD_S = 30.0 + def progress_callback(event_type: str, tool_name: str = None, preview: str = None, args: dict = None, **kwargs): """Callback invoked by agent on tool lifecycle events.""" if not progress_queue or not _run_still_current(): return + # First-touch onboarding: the first time a tool takes longer than + # _LONG_TOOL_THRESHOLD_S during a run that's streaming every tool + # (progress_mode == "all"), append a one-time hint suggesting + # /verbose. We only fire when (a) the user hasn't seen the hint + # before and (b) /verbose is actually usable on this platform + # (gateway gate must be open). The CLI has its own trigger. + if event_type == "tool.completed" and not long_tool_hint_fired[0]: + try: + duration = kwargs.get("duration") or 0 + if duration >= _LONG_TOOL_THRESHOLD_S and progress_mode == "all": + from agent.onboarding import ( + TOOL_PROGRESS_FLAG, + is_seen, + mark_seen, + tool_progress_hint_gateway, + ) + _cfg = _load_gateway_config() + gate_on = bool(_cfg.get("display", {}).get("tool_progress_command", False)) + if gate_on and not is_seen(_cfg, TOOL_PROGRESS_FLAG): + long_tool_hint_fired[0] = True + progress_queue.put(tool_progress_hint_gateway()) + mark_seen(_hermes_home / "config.yaml", TOOL_PROGRESS_FLAG) + except Exception as _hint_err: + logger.debug("tool-progress onboarding hint failed: %s", _hint_err) + return + # Only act on tool.started events (ignore tool.completed, reasoning.available, etc.) if event_type not in ("tool.started",): return diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 830cf91d03..466e9be887 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -84,9 +84,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("deny", "Deny a pending dangerous command", "Session", gateway_only=True), CommandDef("background", "Run a prompt in the background", "Session", - aliases=("bg",), args_hint=""), - CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session", - args_hint=""), + aliases=("bg", "btw"), args_hint=""), CommandDef("agents", "Show active agents and running tasks", "Session", aliases=("tasks",)), CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 9bc9da4729..1d8b082524 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1045,6 +1045,13 @@ DEFAULT_CONFIG = { "min_interval_hours": 24, }, + # Contextual first-touch onboarding hints (see agent/onboarding.py). + # Each hint is shown once per install and then latched here so it + # never fires again. Users can wipe the section to re-see all hints. + "onboarding": { + "seen": {}, + }, + # Config schema version - bump this when adding new required fields "_config_version": 22, } diff --git a/hermes_cli/fallback_cmd.py b/hermes_cli/fallback_cmd.py new file mode 100644 index 0000000000..02c0a01c39 --- /dev/null +++ b/hermes_cli/fallback_cmd.py @@ -0,0 +1,361 @@ +""" +hermes fallback β€” manage the fallback provider chain. + +Fallback providers are tried in order when the primary model fails with +rate-limit, overload, or connection errors. See: +https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers + +Subcommands: + hermes fallback [list] Show the current fallback chain (default when no subcommand) + hermes fallback add Pick provider + model via the same picker as `hermes model`, + then append the selection to the chain + hermes fallback remove Pick an entry to delete from the chain + hermes fallback clear Remove all fallback entries + +Storage: ``fallback_providers`` in ``~/.hermes/config.yaml`` (top-level, list of +``{provider, model, base_url?, api_mode?}`` dicts). The legacy single-dict +``fallback_model`` format is migrated to the new list format on first add. +""" +from __future__ import annotations + +import copy +from typing import Any, Dict, List, Optional + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _read_chain(config: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return the normalized fallback chain as a list of dicts. + + Accepts both the new list format (``fallback_providers``) and the legacy + single-dict format (``fallback_model``). The returned list is always a + fresh copy β€” callers can mutate without touching the config dict. + """ + chain = config.get("fallback_providers") or [] + if isinstance(chain, list): + result = [dict(e) for e in chain if isinstance(e, dict) and e.get("provider") and e.get("model")] + if result: + return result + legacy = config.get("fallback_model") + if isinstance(legacy, dict) and legacy.get("provider") and legacy.get("model"): + return [dict(legacy)] + if isinstance(legacy, list): + return [dict(e) for e in legacy if isinstance(e, dict) and e.get("provider") and e.get("model")] + return [] + + +def _write_chain(config: Dict[str, Any], chain: List[Dict[str, Any]]) -> None: + """Persist the chain to ``fallback_providers`` and clear legacy key.""" + config["fallback_providers"] = chain + # Drop the legacy single-dict key on write so there's only one source of truth. + if "fallback_model" in config: + config.pop("fallback_model", None) + + +def _format_entry(entry: Dict[str, Any]) -> str: + """One-line human-readable rendering of a fallback entry.""" + provider = entry.get("provider", "?") + model = entry.get("model", "?") + base = entry.get("base_url") + suffix = f" [{base}]" if base else "" + return f"{model} (via {provider}){suffix}" + + +def _extract_fallback_from_model_cfg(model_cfg: Any) -> Optional[Dict[str, Any]]: + """Pull the ``{provider, model, base_url?, api_mode?}`` dict from a ``config["model"]`` snapshot.""" + if not isinstance(model_cfg, dict): + return None + provider = (model_cfg.get("provider") or "").strip() + # The picker writes the selected model to ``model.default``. + model = (model_cfg.get("default") or model_cfg.get("model") or "").strip() + if not provider or not model: + return None + entry: Dict[str, Any] = {"provider": provider, "model": model} + base_url = (model_cfg.get("base_url") or "").strip() + if base_url: + entry["base_url"] = base_url + api_mode = (model_cfg.get("api_mode") or "").strip() + if api_mode: + entry["api_mode"] = api_mode + return entry + + +def _snapshot_auth_active_provider() -> Any: + """Return the current ``active_provider`` in auth.json, or a sentinel if unavailable.""" + try: + from hermes_cli.auth import _load_auth_store + store = _load_auth_store() + return store.get("active_provider") + except Exception: + return None + + +def _restore_auth_active_provider(value: Any) -> None: + """Write back a previously snapshotted ``active_provider`` value.""" + try: + from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store + with _auth_store_lock(): + store = _load_auth_store() + store["active_provider"] = value + _save_auth_store(store) + except Exception: + # Best-effort β€” if auth.json can't be restored, the user's primary + # provider may have been deactivated by the picker. They can re-run + # `hermes model` to fix it. Don't fail the fallback add. + pass + + +# --------------------------------------------------------------------------- +# Subcommand handlers +# --------------------------------------------------------------------------- + +def cmd_fallback_list(args) -> None: # noqa: ARG001 + """Print the current fallback chain.""" + from hermes_cli.config import load_config + + config = load_config() + chain = _read_chain(config) + + print() + if not chain: + print(" No fallback providers configured.") + print() + print(" Add one with: hermes fallback add") + print() + return + + primary = _describe_primary(config) + if primary: + print(f" Primary: {primary}") + print() + print(f" Fallback chain ({len(chain)} {'entry' if len(chain) == 1 else 'entries'}):") + for i, entry in enumerate(chain, 1): + print(f" {i}. {_format_entry(entry)}") + print() + print(" Tried in order when the primary fails (rate-limit, 5xx, connection errors).") + print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers") + print() + + +def _describe_primary(config: Dict[str, Any]) -> Optional[str]: + """One-line description of the primary model for display purposes.""" + model_cfg = config.get("model") + if isinstance(model_cfg, dict): + provider = (model_cfg.get("provider") or "?").strip() or "?" + model = (model_cfg.get("default") or model_cfg.get("model") or "?").strip() or "?" + return f"{model} (via {provider})" + if isinstance(model_cfg, str) and model_cfg.strip(): + return model_cfg.strip() + return None + + +def cmd_fallback_add(args) -> None: + """Launch the same picker as `hermes model`, then append the selection to the chain.""" + from hermes_cli.main import _require_tty, select_provider_and_model + from hermes_cli.config import load_config, save_config + + _require_tty("fallback add") + + # Snapshot BEFORE the picker runs so we can distinguish "user actually + # picked something" from "user cancelled" by comparing before/after. + before_cfg = load_config() + model_before = copy.deepcopy(before_cfg.get("model")) + active_provider_before = _snapshot_auth_active_provider() + + print() + print(" Adding a fallback provider. The picker below is the same one used by") + print(" `hermes model` β€” select the provider + model you want as a fallback.") + print() + + try: + select_provider_and_model(args=args) + except SystemExit: + # Some provider flows exit on auth failure β€” restore state and re-raise. + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + raise + + # Read the post-picker state to see what the user selected. + after_cfg = load_config() + model_after = after_cfg.get("model") + + new_entry = _extract_fallback_from_model_cfg(model_after) + if not new_entry: + # Picker didn't complete (user cancelled or flow bailed). Nothing to do. + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + print() + print(" No fallback added.") + return + + # Picker picked the same thing that's already the primary β†’ nothing changed, + # and there's nothing useful to add as a fallback to itself. + primary_entry = _extract_fallback_from_model_cfg(model_before) + if primary_entry and primary_entry["provider"] == new_entry["provider"] \ + and primary_entry["model"] == new_entry["model"]: + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + print() + print(f" Selected model matches the current primary ({_format_entry(new_entry)}).") + print(" A provider cannot be a fallback for itself β€” no change.") + return + + # Reload the config with the primary restored, then append the new entry + # to ``fallback_providers``. We deliberately re-load (rather than mutating + # ``after_cfg``) because the picker may have touched other top-level keys + # (custom_providers, providers credentials) that we want to keep. + _restore_model_cfg(model_before) + _restore_auth_active_provider(active_provider_before) + + final_cfg = load_config() + chain = _read_chain(final_cfg) + + # Reject exact-duplicate fallback entries. + for existing in chain: + if existing.get("provider") == new_entry["provider"] \ + and existing.get("model") == new_entry["model"]: + print() + print(f" {_format_entry(new_entry)} is already in the fallback chain β€” skipped.") + return + + chain.append(new_entry) + _write_chain(final_cfg, chain) + save_config(final_cfg) + + print() + print(f" Added fallback: {_format_entry(new_entry)}") + print(f" Chain is now {len(chain)} {'entry' if len(chain) == 1 else 'entries'} long.") + print() + print(" Run `hermes fallback list` to view, or `hermes fallback remove` to delete.") + + +def _restore_model_cfg(model_before: Any) -> None: + """Restore ``config["model"]`` to a previously-captured snapshot.""" + from hermes_cli.config import load_config, save_config + + cfg = load_config() + if model_before is None: + cfg.pop("model", None) + else: + cfg["model"] = copy.deepcopy(model_before) + save_config(cfg) + + +def cmd_fallback_remove(args) -> None: # noqa: ARG001 + """Pick an entry from the chain and remove it.""" + from hermes_cli.config import load_config, save_config + + config = load_config() + chain = _read_chain(config) + + if not chain: + print() + print(" No fallback providers configured β€” nothing to remove.") + print() + return + + choices = [_format_entry(e) for e in chain] + choices.append("Cancel") + + try: + from hermes_cli.setup import _curses_prompt_choice + idx = _curses_prompt_choice("Select a fallback to remove:", choices, 0) + except Exception: + idx = _numbered_pick("Select a fallback to remove:", choices) + + if idx is None or idx < 0 or idx >= len(chain): + print() + print(" Cancelled β€” no change.") + return + + removed = chain.pop(idx) + _write_chain(config, chain) + save_config(config) + + print() + print(f" Removed fallback: {_format_entry(removed)}") + if chain: + print(f" Chain is now {len(chain)} {'entry' if len(chain) == 1 else 'entries'} long.") + else: + print(" Fallback chain is now empty.") + print() + + +def cmd_fallback_clear(args) -> None: # noqa: ARG001 + """Remove all fallback entries (with confirmation).""" + from hermes_cli.config import load_config, save_config + + config = load_config() + chain = _read_chain(config) + + if not chain: + print() + print(" No fallback providers configured β€” nothing to clear.") + print() + return + + print() + print(f" Current fallback chain ({len(chain)} {'entry' if len(chain) == 1 else 'entries'}):") + for i, entry in enumerate(chain, 1): + print(f" {i}. {_format_entry(entry)}") + print() + try: + resp = input(" Clear all entries? [y/N]: ").strip().lower() + except (KeyboardInterrupt, EOFError): + print() + print(" Cancelled.") + return + if resp not in ("y", "yes"): + print(" Cancelled β€” no change.") + return + + _write_chain(config, []) + save_config(config) + print() + print(" Fallback chain cleared.") + print() + + +def _numbered_pick(question: str, choices: List[str]) -> Optional[int]: + """Fallback numbered-list picker when curses is unavailable.""" + print(question) + for i, c in enumerate(choices, 1): + print(f" {i}. {c}") + print() + while True: + try: + val = input(f"Choice [1-{len(choices)}]: ").strip() + if not val: + return None + idx = int(val) - 1 + if 0 <= idx < len(choices): + return idx + print(f"Please enter 1-{len(choices)}") + except ValueError: + print("Please enter a number") + except (KeyboardInterrupt, EOFError): + print() + return None + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +def cmd_fallback(args) -> None: + """Top-level dispatcher for ``hermes fallback [subcommand]``.""" + sub = getattr(args, "fallback_command", None) + if sub in (None, "", "list", "ls"): + cmd_fallback_list(args) + elif sub == "add": + cmd_fallback_add(args) + elif sub in ("remove", "rm"): + cmd_fallback_remove(args) + elif sub == "clear": + cmd_fallback_clear(args) + else: + print(f"Unknown fallback subcommand: {sub}") + print("Use one of: list, add, remove, clear") + raise SystemExit(2) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0fc1b6d962..e8aa0f84f6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7223,6 +7223,9 @@ Examples: hermes auth remove

Remove pooled credential by index, id, or label hermes auth reset Clear exhaustion status for a provider hermes model Select default model + hermes fallback [list] Show fallback provider chain + hermes fallback add Add a fallback provider (same picker as `hermes model`) + hermes fallback remove Remove a fallback provider from the chain hermes config View configuration hermes config edit Edit config in $EDITOR hermes config set model gpt-4 Set a config value @@ -7564,6 +7567,42 @@ For more help on a command: ) model_parser.set_defaults(func=cmd_model) + # ========================================================================= + # fallback command β€” manage the fallback provider chain + # ========================================================================= + from hermes_cli.fallback_cmd import cmd_fallback + + fallback_parser = subparsers.add_parser( + "fallback", + help="Manage fallback providers (tried when the primary model fails)", + description=( + "Manage the fallback provider chain. Fallback providers are tried " + "in order when the primary model fails with rate-limit, overload, or " + "connection errors. See: " + "https://hermes-agent.nousresearch.com/docs/user-guide/features/fallback-providers" + ), + ) + fallback_subparsers = fallback_parser.add_subparsers(dest="fallback_command") + fallback_subparsers.add_parser( + "list", + aliases=["ls"], + help="Show the current fallback chain (default when no subcommand)", + ) + fallback_subparsers.add_parser( + "add", + help="Pick a provider + model (same picker as `hermes model`) and append to the chain", + ) + fallback_subparsers.add_parser( + "remove", + aliases=["rm"], + help="Pick an entry to delete from the chain", + ) + fallback_subparsers.add_parser( + "clear", + help="Remove all fallback entries", + ) + fallback_parser.set_defaults(func=cmd_fallback) + # ========================================================================= # gateway command # ========================================================================= diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index db66e1db1b..a93a31db13 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -10,8 +10,7 @@ import random TIPS = [ # --- Slash Commands --- - "/btw asks a quick side question without tools or history β€” great for clarifications.", - "/background runs a task in a separate session while your current one stays free.", + "/background (alias /bg or /btw) runs a task in a separate session while your current one stays free.", "/branch forks the current session so you can explore a different direction without losing progress.", "/compress manually compresses conversation context when things get long.", "/rollback lists filesystem checkpoints β€” restore files the agent modified to any prior state.", diff --git a/run_agent.py b/run_agent.py index 7b23b5b41c..43c367e460 100644 --- a/run_agent.py +++ b/run_agent.py @@ -892,7 +892,6 @@ class AIAgent: checkpoints_enabled: bool = False, checkpoint_max_snapshots: int = 50, pass_session_id: bool = False, - persist_session: bool = True, ): """ Initialize the AI Agent. @@ -964,7 +963,6 @@ class AIAgent: self.background_review_callback = None # Optional sync callback for gateway delivery self.skip_context_files = skip_context_files self.pass_session_id = pass_session_id - self.persist_session = persist_session self._credential_pool = credential_pool self.log_prefix_chars = log_prefix_chars self.log_prefix = f"{log_prefix} " if log_prefix else "" @@ -3353,10 +3351,7 @@ class AIAgent: """Save session state to both JSON log and SQLite on any exit path. Ensures conversations are never lost, even on errors or early returns. - Skipped when ``persist_session=False`` (ephemeral helper flows). """ - if not self.persist_session: - return self._apply_persist_user_message_override(messages) self._session_messages = messages self._save_session_log(messages) diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 4ed03a904c..76a0e51b6c 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -281,7 +281,6 @@ Type these during an interactive chat session. ### Utility ``` /branch (/fork) Branch the current session -/btw Ephemeral side question (doesn't interrupt main task) /fast Toggle priority/fast processing /browser Open CDP browser connection /history Show conversation history (CLI) diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py new file mode 100644 index 0000000000..a14c7d1797 --- /dev/null +++ b/tests/agent/test_onboarding.py @@ -0,0 +1,164 @@ +"""Tests for agent/onboarding.py β€” contextual first-touch hint helpers.""" + +from __future__ import annotations + +import yaml +import pytest + +from agent.onboarding import ( + BUSY_INPUT_FLAG, + TOOL_PROGRESS_FLAG, + busy_input_hint_cli, + busy_input_hint_gateway, + is_seen, + mark_seen, + tool_progress_hint_cli, + tool_progress_hint_gateway, +) + + +class TestIsSeen: + def test_empty_config_unseen(self): + assert is_seen({}, BUSY_INPUT_FLAG) is False + + def test_missing_onboarding_unseen(self): + assert is_seen({"display": {}}, BUSY_INPUT_FLAG) is False + + def test_onboarding_not_dict_unseen(self): + assert is_seen({"onboarding": "nope"}, BUSY_INPUT_FLAG) is False + + def test_seen_dict_missing_flag(self): + assert is_seen({"onboarding": {"seen": {}}}, BUSY_INPUT_FLAG) is False + + def test_seen_flag_true(self): + cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: True}}} + assert is_seen(cfg, BUSY_INPUT_FLAG) is True + + def test_seen_flag_falsy(self): + cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: False}}} + assert is_seen(cfg, BUSY_INPUT_FLAG) is False + + def test_other_flags_isolated(self): + cfg = {"onboarding": {"seen": {BUSY_INPUT_FLAG: True}}} + assert is_seen(cfg, TOOL_PROGRESS_FLAG) is False + + +class TestMarkSeen: + def test_creates_missing_file_and_sets_flag(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_preserves_other_config(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({ + "model": {"default": "claude-sonnet-4.6"}, + "display": {"skin": "default"}, + })) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + + assert loaded["model"]["default"] == "claude-sonnet-4.6" + assert loaded["display"]["skin"] == "default" + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_preserves_other_seen_flags(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({ + "onboarding": {"seen": {TOOL_PROGRESS_FLAG: True}}, + })) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + + assert loaded["onboarding"]["seen"][TOOL_PROGRESS_FLAG] is True + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_idempotent(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + mark_seen(cfg_path, BUSY_INPUT_FLAG) + first = cfg_path.read_text() + + # Second call must be a no-op on-disk content (file may be touched, + # but the YAML contents should be identical). + mark_seen(cfg_path, BUSY_INPUT_FLAG) + second = cfg_path.read_text() + + assert yaml.safe_load(first) == yaml.safe_load(second) + + def test_handles_non_dict_onboarding(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({"onboarding": "corrupted"})) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + def test_handles_non_dict_seen(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({"onboarding": {"seen": "corrupted"}})) + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["onboarding"]["seen"][BUSY_INPUT_FLAG] is True + + +class TestHintMessages: + def test_busy_input_hint_gateway_interrupt(self): + msg = busy_input_hint_gateway("interrupt") + assert "/busy queue" in msg + assert "interrupted" in msg.lower() + + def test_busy_input_hint_gateway_queue(self): + msg = busy_input_hint_gateway("queue") + assert "/busy interrupt" in msg + assert "queued" in msg.lower() + + def test_busy_input_hint_cli_interrupt(self): + msg = busy_input_hint_cli("interrupt") + assert "/busy queue" in msg + + def test_busy_input_hint_cli_queue(self): + msg = busy_input_hint_cli("queue") + assert "/busy interrupt" in msg + + def test_tool_progress_hints_mention_verbose(self): + assert "/verbose" in tool_progress_hint_gateway() + assert "/verbose" in tool_progress_hint_cli() + + def test_hints_are_not_empty(self): + for hint in ( + busy_input_hint_gateway("queue"), + busy_input_hint_gateway("interrupt"), + busy_input_hint_cli("queue"), + busy_input_hint_cli("interrupt"), + tool_progress_hint_gateway(), + tool_progress_hint_cli(), + ): + assert hint.strip() + + +class TestRoundTrip: + """After mark_seen, is_seen on the re-loaded config must return True.""" + + def test_mark_then_is_seen(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + + assert mark_seen(cfg_path, BUSY_INPUT_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + + assert is_seen(loaded, BUSY_INPUT_FLAG) is True + assert is_seen(loaded, TOOL_PROGRESS_FLAG) is False + + def test_mark_both_flags_independently(self, tmp_path): + cfg_path = tmp_path / "config.yaml" + + mark_seen(cfg_path, BUSY_INPUT_FLAG) + mark_seen(cfg_path, TOOL_PROGRESS_FLAG) + loaded = yaml.safe_load(cfg_path.read_text()) + + assert is_seen(loaded, BUSY_INPUT_FLAG) is True + assert is_seen(loaded, TOOL_PROGRESS_FLAG) is True diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index 290c1a4b89..2d5f30f6d3 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -349,3 +349,121 @@ class TestBusySessionAck: result = await runner._handle_active_session_busy_message(event, sk) assert result is False # not handled, let default path try + + +class TestBusySessionOnboardingHint: + """First-touch hint appended to the busy-ack the first time it fires.""" + + @pytest.mark.asyncio + async def test_first_busy_ack_appends_interrupt_hint(self, tmp_path, monkeypatch): + """First busy-while-running message gets an extra hint about /busy.""" + import gateway.run as _gr + + monkeypatch.setattr(_gr, "_hermes_home", tmp_path) + # mark_seen imports utils.atomic_yaml_write; make sure it resolves + # against a writable dir by pointing _hermes_home at tmp_path. + monkeypatch.setattr(_gr, "_load_gateway_config", lambda: {}) + + runner, _sentinel = _make_runner() + runner._busy_input_mode = "interrupt" + adapter = _make_adapter() + + event = _make_event(text="ping") + sk = build_session_key(event.source) + + agent = MagicMock() + agent.get_activity_summary.return_value = { + "api_call_count": 3, "max_iterations": 60, + "current_tool": None, "last_activity_ts": time.time(), + "last_activity_desc": "api", "seconds_since_activity": 0.1, + } + runner._running_agents[sk] = agent + runner._running_agents_ts[sk] = time.time() - 5 + runner.adapters[event.source.platform] = adapter + + await runner._handle_active_session_busy_message(event, sk) + + call_kwargs = adapter._send_with_retry.call_args + content = call_kwargs.kwargs.get("content", "") + + # Normal ack body + assert "Interrupting" in content + # First-touch hint appended + assert "First-time tip" in content + assert "/busy queue" in content + + # The flag is now persisted to tmp_path/config.yaml + import yaml + cfg = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert cfg["onboarding"]["seen"]["busy_input_prompt"] is True + + @pytest.mark.asyncio + async def test_second_busy_ack_omits_hint(self, tmp_path, monkeypatch): + """Once the flag is marked, the hint never appears again.""" + import gateway.run as _gr + import yaml + + monkeypatch.setattr(_gr, "_hermes_home", tmp_path) + # Pre-populate the config so is_seen() returns True from the start. + (tmp_path / "config.yaml").write_text(yaml.safe_dump({ + "onboarding": {"seen": {"busy_input_prompt": True}}, + })) + monkeypatch.setattr( + _gr, "_load_gateway_config", + lambda: yaml.safe_load((tmp_path / "config.yaml").read_text()), + ) + + runner, _sentinel = _make_runner() + runner._busy_input_mode = "interrupt" + adapter = _make_adapter() + + event = _make_event(text="ping again") + sk = build_session_key(event.source) + + agent = MagicMock() + agent.get_activity_summary.return_value = { + "api_call_count": 3, "max_iterations": 60, + "current_tool": None, "last_activity_ts": time.time(), + "last_activity_desc": "api", "seconds_since_activity": 0.1, + } + runner._running_agents[sk] = agent + runner._running_agents_ts[sk] = time.time() - 5 + runner.adapters[event.source.platform] = adapter + + await runner._handle_active_session_busy_message(event, sk) + + call_kwargs = adapter._send_with_retry.call_args + content = call_kwargs.kwargs.get("content", "") + + assert "Interrupting" in content + assert "First-time tip" not in content + assert "/busy queue" not in content + + @pytest.mark.asyncio + async def test_queue_mode_hint_points_to_interrupt(self, tmp_path, monkeypatch): + """In queue mode the hint should suggest /busy interrupt, not /busy queue.""" + import gateway.run as _gr + + monkeypatch.setattr(_gr, "_hermes_home", tmp_path) + monkeypatch.setattr(_gr, "_load_gateway_config", lambda: {}) + + runner, _sentinel = _make_runner() + runner._busy_input_mode = "queue" + adapter = _make_adapter() + + event = _make_event(text="queue me") + sk = build_session_key(event.source) + runner.adapters[event.source.platform] = adapter + + agent = MagicMock() + runner._running_agents[sk] = agent + + with patch("gateway.run.merge_pending_message_event"): + await runner._handle_active_session_busy_message(event, sk) + + content = adapter._send_with_retry.call_args.kwargs.get("content", "") + assert "Queued for the next turn" in content + assert "First-time tip" in content + assert "/busy interrupt" in content + # Must NOT tell the user to /busy queue when they're already on queue. + assert "/busy queue" not in content diff --git a/tests/gateway/test_running_agent_session_toggles.py b/tests/gateway/test_running_agent_session_toggles.py index fbe0d5163c..6bf8be9973 100644 --- a/tests/gateway/test_running_agent_session_toggles.py +++ b/tests/gateway/test_running_agent_session_toggles.py @@ -165,3 +165,26 @@ async def test_reasoning_rejected_mid_run(): assert result is not None assert "can't run mid-turn" in result assert "/reasoning" in result + + +@pytest.mark.asyncio +async def test_btw_dispatches_mid_run(): + """/btw mid-run must dispatch to /background's handler, not hit the catch-all. + + /btw is an alias of /background (see hermes_cli/commands.py). Typing + /btw mid-turn must spawn a parallel background task β€” that's the whole + point of the command. Before the mid-turn bypass was added for + /background, /btw fell through to the "Agent is running β€” wait or + /stop first" catch-all, making it useless in exactly the scenario it + was designed for. The alias and the bypass together make it work. + """ + runner = _make_runner() + runner._handle_background_command = AsyncMock( + return_value='πŸš€ Background task started: "what module owns titles?"' + ) + + result = await runner._handle_message(_make_event("/btw what module owns titles?")) + + runner._handle_background_command.assert_awaited_once() + assert result is not None + assert "can't run mid-turn" not in result diff --git a/tests/hermes_cli/test_fallback_cmd.py b/tests/hermes_cli/test_fallback_cmd.py new file mode 100644 index 0000000000..a88c84b3aa --- /dev/null +++ b/tests/hermes_cli/test_fallback_cmd.py @@ -0,0 +1,486 @@ +"""Tests for `hermes fallback` β€” chain reading, add/remove/clear, legacy migration.""" +from __future__ import annotations + +import io +import types +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + + +# --------------------------------------------------------------------------- +# Shared fixture β€” isolate HERMES_HOME so save_config writes to tmp_path +# --------------------------------------------------------------------------- + +@pytest.fixture() +def isolated_home(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + home = tmp_path / ".hermes" + home.mkdir(exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + return tmp_path + + +def _write_config(home: Path, data: dict) -> None: + config_path = home / ".hermes" / "config.yaml" + config_path.write_text(yaml.safe_dump(data), encoding="utf-8") + + +def _read_config(home: Path) -> dict: + config_path = home / ".hermes" / "config.yaml" + return yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + + +# --------------------------------------------------------------------------- +# _read_chain / _write_chain +# --------------------------------------------------------------------------- + +class TestReadChain: + def test_returns_empty_list_when_unset(self): + from hermes_cli.fallback_cmd import _read_chain + assert _read_chain({}) == [] + + def test_reads_new_list_format(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = { + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"}, + ] + } + assert _read_chain(cfg) == [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"}, + ] + + def test_migrates_legacy_single_dict(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = {"fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}} + assert _read_chain(cfg) == [{"provider": "openrouter", "model": "gpt-5.4"}] + + def test_skips_incomplete_entries(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = { + "fallback_providers": [ + {"provider": "openrouter"}, # missing model + {"model": "gpt-5.4"}, # missing provider + {"provider": "nous", "model": "foo"}, # valid + "not-a-dict", # noise + ] + } + assert _read_chain(cfg) == [{"provider": "nous", "model": "foo"}] + + def test_returns_copies_not_aliases(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = {"fallback_providers": [{"provider": "nous", "model": "foo"}]} + result = _read_chain(cfg) + result[0]["provider"] = "mutated" + assert cfg["fallback_providers"][0]["provider"] == "nous" + + +# --------------------------------------------------------------------------- +# _extract_fallback_from_model_cfg +# --------------------------------------------------------------------------- + +class TestExtractFallback: + def test_extracts_from_default_field(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + model_cfg = {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"} + assert _extract_fallback_from_model_cfg(model_cfg) == { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4.6", + } + + def test_extracts_optional_base_url_and_api_mode(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + model_cfg = { + "provider": "custom", + "default": "local-model", + "base_url": "http://localhost:11434/v1", + "api_mode": "chat_completions", + } + assert _extract_fallback_from_model_cfg(model_cfg) == { + "provider": "custom", + "model": "local-model", + "base_url": "http://localhost:11434/v1", + "api_mode": "chat_completions", + } + + def test_returns_none_without_provider(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + assert _extract_fallback_from_model_cfg({"default": "foo"}) is None + + def test_returns_none_without_model(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + assert _extract_fallback_from_model_cfg({"provider": "openrouter"}) is None + + def test_returns_none_for_non_dict(self): + from hermes_cli.fallback_cmd import _extract_fallback_from_model_cfg + assert _extract_fallback_from_model_cfg("plain-string") is None + assert _extract_fallback_from_model_cfg(None) is None + + +# --------------------------------------------------------------------------- +# cmd_fallback_list +# --------------------------------------------------------------------------- + +class TestListCommand: + def test_list_empty(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback_list + cmd_fallback_list(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "No fallback providers configured" in out + assert "hermes fallback add" in out + + def test_list_with_entries(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4"}, + ], + }) + from hermes_cli.fallback_cmd import cmd_fallback_list + cmd_fallback_list(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "Fallback chain (2 entries)" in out + assert "anthropic/claude-sonnet-4.6" in out + assert "Hermes-4" in out + # Primary should be shown too + assert "claude-sonnet-4-6" in out + + def test_list_migrates_legacy_for_display(self, isolated_home, capsys): + _write_config(isolated_home, { + "fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}, + }) + from hermes_cli.fallback_cmd import cmd_fallback_list + cmd_fallback_list(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "1 entry" in out + assert "gpt-5.4" in out + + +# --------------------------------------------------------------------------- +# cmd_fallback_add β€” mock select_provider_and_model +# --------------------------------------------------------------------------- + +class TestAddCommand: + def test_add_appends_new_entry(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + }) + + def fake_picker(args=None): + # Simulate what the real picker does: writes the selection to config["model"] + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = { + "provider": "openrouter", + "default": "anthropic/claude-sonnet-4.6", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + } + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + # Primary is preserved + assert cfg["model"]["provider"] == "anthropic" + assert cfg["model"]["default"] == "claude-sonnet-4-6" + # Fallback was appended + assert cfg["fallback_providers"] == [ + { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4.6", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + } + ] + out = capsys.readouterr().out + assert "Added fallback" in out + + def test_add_rejects_duplicate(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + ], + }) + + def fake_picker(args=None): + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = {"provider": "openrouter", "default": "gpt-5.4"} + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + # Should still have exactly one entry + assert len(cfg["fallback_providers"]) == 1 + out = capsys.readouterr().out + assert "already in the fallback chain" in out + + def test_add_rejects_same_as_primary(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "openrouter", "default": "gpt-5.4"}, + }) + + def fake_picker(args=None): + # User picks the same thing that's already the primary + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = {"provider": "openrouter", "default": "gpt-5.4"} + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert "fallback_providers" not in cfg or cfg["fallback_providers"] == [] + out = capsys.readouterr().out + assert "matches the current primary" in out + + def test_add_preserves_primary_when_picker_changes_it(self, isolated_home): + """The picker mutates config["model"]; fallback_add must restore the primary.""" + _write_config(isolated_home, { + "model": { + "provider": "anthropic", + "default": "claude-sonnet-4-6", + "base_url": "https://api.anthropic.com", + "api_mode": "anthropic_messages", + }, + }) + + def fake_picker(args=None): + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = { + "provider": "openrouter", + "default": "anthropic/claude-sonnet-4.6", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "chat_completions", + } + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + # Primary exactly as it was + assert cfg["model"]["provider"] == "anthropic" + assert cfg["model"]["default"] == "claude-sonnet-4-6" + assert cfg["model"]["base_url"] == "https://api.anthropic.com" + assert cfg["model"]["api_mode"] == "anthropic_messages" + # Fallback added + assert len(cfg["fallback_providers"]) == 1 + assert cfg["fallback_providers"][0]["provider"] == "openrouter" + + def test_add_noop_when_picker_cancelled(self, isolated_home, capsys): + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + }) + + def fake_picker(args=None): + # User cancelled β€” no change to config + pass + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert "fallback_providers" not in cfg or cfg["fallback_providers"] == [] + out = capsys.readouterr().out + # Either "No fallback added" (picker fully cancelled) or "matches the current primary" + # (picker left config untouched) β€” both indicate a non-add outcome. + assert ("No fallback added" in out) or ("matches the current primary" in out) + + def test_add_noop_when_picker_clears_model(self, isolated_home, capsys): + """Simulate picker explicitly clearing model.default (unusual but possible).""" + _write_config(isolated_home, { + "model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}, + }) + + def fake_picker(args=None): + from hermes_cli.config import load_config, save_config + cfg = load_config() + cfg["model"] = {"provider": "", "default": ""} + save_config(cfg) + + with patch("hermes_cli.main.select_provider_and_model", side_effect=fake_picker), \ + patch("hermes_cli.main._require_tty"): + from hermes_cli.fallback_cmd import cmd_fallback_add + cmd_fallback_add(types.SimpleNamespace()) + + out = capsys.readouterr().out + assert "No fallback added" in out + + +# --------------------------------------------------------------------------- +# cmd_fallback_remove +# --------------------------------------------------------------------------- + +class TestRemoveCommand: + def test_remove_empty_chain(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback_remove + cmd_fallback_remove(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "nothing to remove" in out + + def test_remove_selected_entry(self, isolated_home, capsys): + _write_config(isolated_home, { + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + {"provider": "nous", "model": "Hermes-4"}, + {"provider": "anthropic", "model": "claude-sonnet-4-6"}, + ], + }) + + # Picker returns index 1 (the middle entry, "nous / Hermes-4") + with patch("hermes_cli.setup._curses_prompt_choice", return_value=1): + from hermes_cli.fallback_cmd import cmd_fallback_remove + cmd_fallback_remove(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert cfg["fallback_providers"] == [ + {"provider": "openrouter", "model": "gpt-5.4"}, + {"provider": "anthropic", "model": "claude-sonnet-4-6"}, + ] + out = capsys.readouterr().out + assert "Removed fallback" in out + assert "Hermes-4" in out + + def test_remove_cancel_keeps_chain(self, isolated_home): + _write_config(isolated_home, { + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + ], + }) + + # Cancel = last item (index == len(chain) == 1 in our menu) + with patch("hermes_cli.setup._curses_prompt_choice", return_value=1): + from hermes_cli.fallback_cmd import cmd_fallback_remove + cmd_fallback_remove(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert len(cfg["fallback_providers"]) == 1 + + +# --------------------------------------------------------------------------- +# cmd_fallback_clear +# --------------------------------------------------------------------------- + +class TestClearCommand: + def test_clear_empty_chain(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback_clear + cmd_fallback_clear(types.SimpleNamespace()) + out = capsys.readouterr().out + assert "nothing to clear" in out + + def test_clear_with_confirmation(self, isolated_home, capsys, monkeypatch): + _write_config(isolated_home, { + "fallback_providers": [ + {"provider": "openrouter", "model": "gpt-5.4"}, + {"provider": "nous", "model": "Hermes-4"}, + ], + }) + monkeypatch.setattr("builtins.input", lambda *a, **kw: "y") + from hermes_cli.fallback_cmd import cmd_fallback_clear + cmd_fallback_clear(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert cfg.get("fallback_providers") == [] + out = capsys.readouterr().out + assert "Fallback chain cleared" in out + + def test_clear_cancelled(self, isolated_home, monkeypatch): + _write_config(isolated_home, { + "fallback_providers": [{"provider": "openrouter", "model": "gpt-5.4"}], + }) + monkeypatch.setattr("builtins.input", lambda *a, **kw: "n") + from hermes_cli.fallback_cmd import cmd_fallback_clear + cmd_fallback_clear(types.SimpleNamespace()) + + cfg = _read_config(isolated_home) + assert len(cfg["fallback_providers"]) == 1 + + +# --------------------------------------------------------------------------- +# cmd_fallback dispatcher +# --------------------------------------------------------------------------- + +class TestDispatcher: + def test_no_subcommand_lists(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + cmd_fallback(types.SimpleNamespace(fallback_command=None)) + out = capsys.readouterr().out + assert "No fallback providers configured" in out + + def test_list_alias(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + cmd_fallback(types.SimpleNamespace(fallback_command="ls")) + out = capsys.readouterr().out + assert "No fallback providers configured" in out + + def test_remove_alias(self, isolated_home, capsys): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + cmd_fallback(types.SimpleNamespace(fallback_command="rm")) + out = capsys.readouterr().out + assert "nothing to remove" in out + + def test_unknown_subcommand_exits(self, isolated_home): + _write_config(isolated_home, {}) + from hermes_cli.fallback_cmd import cmd_fallback + with pytest.raises(SystemExit): + cmd_fallback(types.SimpleNamespace(fallback_command="nope")) + + +# --------------------------------------------------------------------------- +# argparse wiring β€” verify the subparser is registered +# --------------------------------------------------------------------------- + +class TestArgparseWiring: + """Verify `hermes fallback` is wired into main.py's argparse tree. + + main() builds the parser inline, so we invoke main([...]) via subprocess + with --help to introspect registered subcommands without side effects. + """ + + def test_fallback_help_lists_subcommands(self): + import subprocess + import sys + result = subprocess.run( + [sys.executable, "-m", "hermes_cli.main", "fallback", "--help"], + capture_output=True, + text=True, + timeout=30, + ) + # --help exits 0 + assert result.returncode == 0, f"stderr: {result.stderr}" + out = result.stdout + result.stderr + # All four subcommands should appear in help + assert "list" in out + assert "add" in out + assert "remove" in out + assert "clear" in out diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03631bf174..30531aab28 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2550,48 +2550,6 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"task_id": task_id}) -@method("prompt.btw") -def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) - if err: - return err - text, sid = params.get("text", ""), params.get("session_id", "") - if not text: - return _err(rid, 4012, "text required") - snapshot = list(session.get("history", [])) - - def run(): - session_tokens = _set_session_context(session["session_key"]) - try: - from run_agent import AIAgent - - result = AIAgent( - model=_resolve_model(), - quiet_mode=True, - platform="tui", - max_iterations=8, - enabled_toolsets=[], - ).run_conversation(text, conversation_history=snapshot) - _emit( - "btw.complete", - sid, - { - "text": ( - result.get("final_response", str(result)) - if isinstance(result, dict) - else str(result) - ) - }, - ) - except Exception as e: - _emit("btw.complete", sid, {"text": f"error: {e}"}) - finally: - _clear_session_context(session_tokens) - - threading.Thread(target=run, daemon=True).start() - return _ok(rid, {"status": "running"}) - - # ── Methods: respond ───────────────────────────────────────────────── diff --git a/ui-tui/README.md b/ui-tui/README.md index 2f95a47aa2..17d57f08af 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -252,7 +252,6 @@ Primary event types the client handles today: | `sudo.request` | `{ request_id }` | | `secret.request` | `{ prompt, env_var, request_id }` | | `background.complete` | `{ task_id, text }` | -| `btw.complete` | `{ text }` | | `error` | `{ message }` | | `gateway.stderr` | synthesized from child stderr | | `gateway.protocol_error` | synthesized from malformed stdout | diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 15cf00a5a9..0bd505078f 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -431,12 +431,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return - case 'btw.complete': - dropBgTask('btw:x') - sys(`[btw] ${ev.payload.text}`) - - return - case 'subagent.spawn_requested': // Child built but not yet running (waiting on ThreadPoolExecutor slot). // Preserve completed state if a later event races in before this one. diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 1049ee34d8..df106e1d86 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,7 +1,6 @@ import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' import type { BackgroundStartResponse, - BtwStartResponse, ConfigGetValueResponse, ConfigSetResponse, ImageAttachResponse, @@ -18,7 +17,7 @@ import type { SlashCommand } from '../types.js' export const sessionCommands: SlashCommand[] = [ { - aliases: ['bg'], + aliases: ['bg', 'btw'], help: 'launch a background prompt', name: 'background', run: (arg, ctx) => { @@ -39,23 +38,6 @@ export const sessionCommands: SlashCommand[] = [ } }, - { - help: 'by-the-way follow-up', - name: 'btw', - run: (arg, ctx) => { - if (!arg) { - return ctx.transcript.sys('/btw ') - } - - ctx.gateway.rpc('prompt.btw', { session_id: ctx.sid, text: arg }).then( - ctx.guarded(() => { - patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') })) - ctx.transcript.sys('btw running…') - }) - ) - } - }, - { help: 'change or show model', aliases: ['provider'], diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index e64d113c22..ce056040c2 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -178,10 +178,6 @@ export interface BackgroundStartResponse { task_id?: string } -export interface BtwStartResponse { - ok?: boolean -} - export interface ClarifyRespondResponse { ok?: boolean } @@ -403,7 +399,6 @@ export type GatewayEvent = | { payload: { request_id: string }; session_id?: string; type: 'sudo.request' } | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } - | { payload: { text: string }; session_id?: string; type: 'btw.complete' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' } diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts index 012482b710..fa58841ce1 100644 --- a/web/src/lib/gatewayClient.ts +++ b/web/src/lib/gatewayClient.ts @@ -32,7 +32,6 @@ export type GatewayEventName = | "sudo.request" | "secret.request" | "background.complete" - | "btw.complete" | "error" | "skin.changed" | (string & {}); diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 6e04bcd010..ed2a2ff2fc 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -36,8 +36,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/resume [name]` | Resume a previously-named session | | `/status` | Show session info | | `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. | -| `/background ` (alias: `/bg`) | Run a prompt in a separate background session. The agent processes your prompt independently β€” your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/docs/user-guide/cli#background-sessions). | -| `/btw ` | Ephemeral side question using session context (no tools, not persisted). Useful for quick clarifications without affecting the conversation history. | +| `/background ` (alias: `/bg`, `/btw`) | Run a prompt in a separate background session. The agent processes your prompt independently β€” your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/docs/user-guide/cli#background-sessions). | | `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path) | ### Configuration diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 90b571aa8b..0ba7245958 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -242,6 +242,10 @@ You can also change it inside the CLI: /busy status ``` +:::tip First-touch hint +The very first time you press Enter while Hermes is working, Hermes prints a one-line reminder explaining the `/busy` knob (`"(tip) Your message interrupted the current run…"`). It only fires once per install β€” a flag in `config.yaml` under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again. +::: + ### Suspending to Background On Unix systems, press **`Ctrl+Z`** to suspend Hermes to the background β€” just like any terminal process. The shell prints a confirmation: diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index dcde46a6b5..2e6fa4f212 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -219,6 +219,17 @@ Send any message while the agent is working to interrupt it. Key behaviors: - **Multiple messages are combined** β€” messages sent during interruption are joined into one prompt - **`/stop` command** β€” interrupts without queuing a follow-up message +### Queue vs interrupt (busy-input mode) + +By default, messaging a busy agent interrupts it. To switch the whole install so follow-ups queue behind the current task instead, set: + +```yaml +display: + busy_input_mode: queue # default: interrupt +``` + +The first time you message a busy agent on any platform, Hermes appends a one-line reminder to the busy-ack explaining the knob (`"πŸ’‘ First-time tip β€” …"`). The reminder fires once per install β€” a flag under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again. + ## Tool Progress Notifications Control how much tool activity is displayed in `~/.hermes/config.yaml`: diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index efd6326259..10a91f2aae 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -298,7 +298,6 @@ Type these during an interactive chat session. ### Utility ``` /branch (/fork) Branch the current session -/btw Ephemeral side question (doesn't interrupt main task) /fast Toggle priority/fast processing /browser Open CDP browser connection /history Show conversation history (CLI)