diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 23d29493a84a9..68546d4951368 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1803,7 +1803,11 @@ def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Op or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY") if not or_key: - _mark_provider_unhealthy("openrouter", ttl=60) + # No key configured at all — permanently unavailable, not a + # transient payment error. Use the full unhealthy TTL (600s) + # so we don't retry every 60s and spam errors.log with WARNINGs + # for a provider the user never configured (#59974). + _mark_provider_unhealthy("openrouter") return None, None logger.debug("Auxiliary client: OpenRouter") return _create_openai_client(api_key=or_key, base_url=OPENROUTER_BASE_URL, @@ -1847,7 +1851,9 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: "Auxiliary Nous client unavailable: no Nous authentication found " "(run: hermes auth)." ) - _mark_provider_unhealthy("nous", ttl=60) + # Permanently unavailable — use full TTL (600s) to avoid log spam + # from retrying every 60s for a provider the user never configured. + _mark_provider_unhealthy("nous") return None, None if runtime is None and nous: logger.debug( diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 761c8c0f79e69..5b3b81475ee80 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -324,6 +324,15 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) ) if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt): + # The user set an explicit personality via /personality or the + # caller passed an ephemeral override. Use it even when a stored + # prompt would otherwise match — the user's explicit intent wins + # over prefix-cache reuse. Caching will miss for this turn, but + # that is the expected trade-off for a deliberate personality switch + # (#58774). + if getattr(agent, "ephemeral_system_prompt", None): + agent._cached_system_prompt = agent.ephemeral_system_prompt + return # Continuing session — reuse the exact system prompt from the # previous turn so the Anthropic cache prefix matches. agent._cached_system_prompt = stored_prompt @@ -1161,7 +1170,7 @@ def run_conversation( # Copilot x-initiator: the first API call of a user turn is # marked "user" so Copilot bills a premium request; tool-loop # follow-ups keep the default "agent" header (#3040). - if getattr(agent, "_is_user_initiated_turn", False) and agent._is_copilot_url(): + if getattr(agent, "_is_user_initiated_turn", False) and getattr(agent, "_is_copilot_url", lambda: False)(): _xh = dict(api_kwargs.get("extra_headers") or {}) _xh["x-initiator"] = "user" api_kwargs["extra_headers"] = _xh diff --git a/cli.py b/cli.py index 4dc546bac4e8b..b774a97cf2e2d 100644 --- a/cli.py +++ b/cli.py @@ -15844,7 +15844,50 @@ def main( missing_display, ", ".join(loaded_skills), ) + # Surface the skip on the kanban board so the card author + # knows the worker ran without their intended skill context, + # rather than only having this in the worker log file (#59764). + kanban_task_id = os.environ.get("HERMES_KANBAN_TASK", "") + if kanban_task_id: + try: + from hermes_cli import kanban_db as _kb + conn = _kb.connect() + try: + _kb.add_comment( + conn, kanban_task_id, + f"[worker] Skipped unknown skills: {missing_display}. " + f"Running with: {', '.join(loaded_skills)}.", + ) + finally: + conn.close() + except Exception: + pass else: + # When running as a kanban worker (HERMES_KANBAN_TASK set), + # all skills missing is a card-configuration error, not a + # transient failure. Block the task with a structured reason + # instead of raising ValueError — otherwise the dispatcher + # retries and the worker crashes on every spawn (#59764). + kanban_task_id = os.environ.get("HERMES_KANBAN_TASK", "") + if kanban_task_id: + reason = ( + f"Worker missing all required skills: {missing_display}. " + f"Install the skills on profile or update the card." + ) + try: + from hermes_cli import kanban_db as _kb + conn = _kb.connect() + try: + _kb.block_task( + conn, kanban_task_id, + reason=reason, + kind="capability", + ) + finally: + conn.close() + except Exception: + pass + sys.exit(0) raise ValueError(f"Unknown skill(s): {missing_display}") if skills_prompt: cli.system_prompt = "\n\n".join( diff --git a/scripts/release.py b/scripts/release.py index 01275e6e8d0a5..dbc50f1539382 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -67,6 +67,7 @@ "huanshan5195@users.noreply.github.com": "huanshan5195", # PR #57601 salvage (custom-provider: emit reasoning_effort at the live CustomProfile path so GLM-5.2/ARK/vLLM/Ollama endpoints receive it; + "max" reasoning level) "infinitycrew39@gmail.com": "infinitycrew39", # PR #56431 salvage (honor live vLLM context limits on local endpoints) "jonathan.kovacs999@gmail.com": "CocaKova", # PR #57692 salvage (cron: run jobs under the profile secret scope so get_secret does not fail-close with UnscopedSecretError under profile isolation) + "ishengeqi@163.com": "isheng-eqi", "hermes.wanderer@yahoo.com": "trismegistus-wanderer", # PR #31856 salvage (gateway: defer idle-TTL agent-cache eviction until the session store says the session actually expired, so the expiry watcher can still fire MemoryProvider.on_session_end with the live transcript; #11205) "louis@letsfive.io": "Mibayy", # PR #3243 salvage (/compact alias + preview/aggressive flags for /compress) "louis@letsfive.io": "Mibayy", # PR #3176 salvage (api-server: per-client model routing via model_routes) diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 49c75add205e1..63e2218589560 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -1118,7 +1118,7 @@ def test_try_openrouter_pool_exhausted_falls_back_to_env(self, monkeypatch): assert mock_openai.call_args.kwargs["base_url"] == OPENROUTER_BASE_URL def test_try_openrouter_pool_exhausted_no_env_marks_unhealthy(self, monkeypatch): - """Pool exhausted AND no env var → final failure marks provider unhealthy.""" + """Pool exhausted AND no env var -> final failure marks provider unhealthy.""" monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) with patch("agent.auxiliary_client._select_pool_entry", return_value=(True, None)), \ patch("agent.auxiliary_client._mark_provider_unhealthy") as mock_mark, \ @@ -1128,7 +1128,8 @@ def test_try_openrouter_pool_exhausted_no_env_marks_unhealthy(self, monkeypatch) assert client is None assert model is None mock_openai.assert_not_called() - mock_mark.assert_called_once_with("openrouter", ttl=60) + # Permanently unavailable (no key) -> default TTL, not ttl=60 + mock_mark.assert_called_once_with("openrouter") class TestGetTextAuxiliaryClient: """Test the full resolution chain for get_text_auxiliary_client.""" diff --git a/tests/agent/test_system_prompt_restore.py b/tests/agent/test_system_prompt_restore.py index 956c1152a42b1..312f426b7b7d8 100644 --- a/tests/agent/test_system_prompt_restore.py +++ b/tests/agent/test_system_prompt_restore.py @@ -33,6 +33,9 @@ def _make_agent(session_db=None, prebuilt_prompt: str = "BUILT_PROMPT"): agent.platform = "cli" agent._session_db = session_db agent._build_system_prompt = MagicMock(return_value=prebuilt_prompt) + # Explicitly None — MagicMock auto-creates attributes on access, so + # getattr(agent, "ephemeral_system_prompt", None) would return a Mock. + agent.ephemeral_system_prompt = None return agent @@ -261,5 +264,49 @@ def test_restored_prompt_is_byte_identical_to_stored(self): assert agent._cached_system_prompt.encode("utf-8") == stored.encode("utf-8") +class TestEphemeralSystemPromptOverride: + """When the caller sets an ephemeral system prompt (e.g. /personality), + it must win over the session-DB stored prompt (#58774).""" + + def test_ephemeral_overrides_stored_prompt(self): + """ephemeral_system_prompt takes precedence over a matching stored prompt.""" + stored = "Stored prompt from session DB" + db = MagicMock() + db.get_session.return_value = {"system_prompt": stored} + agent = _make_agent(session_db=db) + agent.ephemeral_system_prompt = "Personality: pirate" + + _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) + + assert agent._cached_system_prompt == "Personality: pirate" + agent._build_system_prompt.assert_not_called() + + def test_ephemeral_none_does_not_block_restore(self): + """ephemeral_system_prompt=None (the default) should still restore from DB.""" + stored = "Stored prompt from session DB" + db = MagicMock() + db.get_session.return_value = {"system_prompt": stored} + agent = _make_agent(session_db=db) + agent.ephemeral_system_prompt = None + + _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) + + assert agent._cached_system_prompt == stored + agent._build_system_prompt.assert_not_called() + + def test_ephemeral_empty_string_does_not_block_restore(self): + """ephemeral_system_prompt='' should still restore from DB (empty is falsy).""" + stored = "Stored prompt from session DB" + db = MagicMock() + db.get_session.return_value = {"system_prompt": stored} + agent = _make_agent(session_db=db) + agent.ephemeral_system_prompt = "" + + _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) + + assert agent._cached_system_prompt == stored + agent._build_system_prompt.assert_not_called() + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/test_copilot_initiator.py b/tests/test_copilot_initiator.py index aa2915d022570..cdd82b7fd5cc9 100644 --- a/tests/test_copilot_initiator.py +++ b/tests/test_copilot_initiator.py @@ -52,7 +52,7 @@ def _make_agent(monkeypatch, base_url, api_mode="chat_completions"): def _inject(agent, api_kwargs): """Mirror the injection block in agent/conversation_loop.py.""" - if getattr(agent, "_is_user_initiated_turn", False) and agent._is_copilot_url(): + if getattr(agent, "_is_user_initiated_turn", False) and getattr(agent, "_is_copilot_url", lambda: False)(): _xh = dict(api_kwargs.get("extra_headers") or {}) _xh["x-initiator"] = "user" api_kwargs["extra_headers"] = _xh @@ -131,6 +131,18 @@ def test_non_copilot_flag_not_flipped(self, monkeypatch): # Flag unchanged — non-Copilot path doesn't touch it assert agent._is_user_initiated_turn is True + def test_missing_is_copilot_url_does_not_crash(self): + """Guard against intermittent missing _is_copilot_url (#59845).""" + # Simulate a partially-initialized agent (module-reload / wrapper) + # that has _is_user_initiated_turn but not _is_copilot_url. + class PartialAgent: + _is_user_initiated_turn = True + agent = PartialAgent() + kwargs = _inject(agent, {}) + assert "extra_headers" not in kwargs + # Flag unchanged — guard falls through cleanly + assert agent._is_user_initiated_turn is True + class TestHeaderValues: """copilot_default_headers(is_agent_turn=...) sets x-initiator correctly.""" diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index cdf8c4cdb5dae..b3181b883c4ef 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -741,22 +741,26 @@ def _make_goal_mode_worker_env(monkeypatch, tmp_path): return goal_task_id -def test_block_goal_mode_rejects_missing_kind(monkeypatch, tmp_path): - """A goal_mode worker calling kanban_block with no kind must not be able - to use it as an unguarded escape from the goal loop (Issue #38696, - sibling of the kanban_complete judge gate / Issue #38367).""" +def test_block_goal_mode_coerces_missing_kind_to_needs_input(monkeypatch, tmp_path): + """A goal_mode worker calling kanban_block with no kind gets kind='needs_input'. + + The tool schema marks ``kind`` as optional, so workers following the + schema may omit it. Before #59764 this was rejected for goal_mode tasks, + breaking workers that followed the documented contract. Now a missing + kind is coerced to 'needs_input' so the block succeeds (#59764).""" from tools import kanban_tools as kt from hermes_cli import kanban_db as kb tid = _make_goal_mode_worker_env(monkeypatch, tmp_path) out = kt._handle_block({"reason": "giving up"}) d = json.loads(out) - assert "error" in d - assert "goal_mode" in d["error"] + assert d.get("ok") is True, f"expected ok, got: {d}" conn = kb.connect() try: - assert kb.get_task(conn, tid).status == "running" + task = kb.get_task(conn, tid) + assert task.status == "blocked" + assert task.block_kind == "needs_input" finally: conn.close() diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 2e81f94429e0a..500220444d14f 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -696,22 +696,22 @@ def _handle_block(args: dict, **kw) -> str: # kanban_block(reason="anything") to escape the loop instead. # Restrict goal_mode tasks to the kinds that represent a genuine # external blocker the worker cannot resolve itself; `capability` - # and `transient` (or an unset kind) route back through - # kanban_complete, which the judge now gates. + # and `transient` route back through kanban_complete, which the + # judge now gates. An omitted (None) kind defaults to "needs_input" + # since the tool schema marks kind as optional (#59764). task = kb.get_task(conn, tid) - if ( - task - and task.goal_mode - and kind not in _GOAL_MODE_BLOCK_ALLOWED_KINDS - ): - conn.close() - return tool_error( - f"goal_mode tasks can only block with kind in " - f"{sorted(_GOAL_MODE_BLOCK_ALLOWED_KINDS)} (got {kind!r}). " - f"If the task is actually finished or cannot proceed for " - f"another reason, call kanban_complete instead — the " - f"completion judge will evaluate it." - ) + if task and task.goal_mode: + if kind is None: + kind = "needs_input" + if kind not in _GOAL_MODE_BLOCK_ALLOWED_KINDS: + conn.close() + return tool_error( + f"goal_mode tasks can only block with kind in " + f"{sorted(_GOAL_MODE_BLOCK_ALLOWED_KINDS)} (got {kind!r}). " + f"If the task is actually finished or cannot proceed for " + f"another reason, call kanban_complete instead — the " + f"completion judge will evaluate it." + ) try: ok = kb.block_task( conn, tid, @@ -1322,7 +1322,10 @@ def _board_schema_prop() -> dict[str, str]: "description": ( "Why you're blocked. 'dependency' waits in todo and " "resumes automatically; the others surface to a human. " - "Omit only if none apply." + "Omit only if none apply. " + "On goal_mode tasks only 'dependency' and 'needs_input' " + "are accepted — the completion judge gates all other exits " + "(omit to default to 'needs_input')." ), }, "board": _board_schema_prop(), diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2721783d84e5b..7d09b72d7f056 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -8298,6 +8298,81 @@ def _notification_event_dedup_key(evt: dict) -> tuple: return (evt_sid, evt_type) +def _poll_kanban_task_events( + session: dict, sid: str, emitted: set +) -> None: + """Poll kanban_notify_subs for task events belonging to this TUI session. + + The gateway's _kanban_notifier_watcher delivers kanban notifications + for messaging channels (telegram/discord/etc.), but TUI subscriptions + are not handled there because the gateway has no TUI adapter. This + function fills that gap: it reads kanban_notify_subs for the session, + claims unseen terminal events, and emits status.update messages so the + user sees kanban task completions/blocks in their TUI session (#59960). + """ + import os + + from hermes_cli import kanban_db as _kb + + session_key = os.environ.get("HERMES_SESSION_KEY", "") + if not session_key: + return + + # Open the default kanban board — TUI subscriptions always target the + # default board (kanban_tools._maybe_auto_subscribe doesn't set a board). + try: + conn = _kb.connect() + except Exception: + return + + try: + subs = _kb.list_notify_subs(conn) + except Exception: + conn.close() + return + + TERMINAL_KINDS = ( + "completed", "blocked", "gave_up", "crashed", + "timed_out", "status", "archived", "unblocked", + ) + for sub in subs: + if (sub.get("platform") or "").lower() != "tui": + continue + if (sub.get("chat_id") or "") != session_key: + continue + try: + _old, _new, events = _kb.claim_unseen_events_for_sub( + conn, + task_id=sub["task_id"], + platform=sub["platform"], + chat_id=sub["chat_id"], + thread_id=sub.get("thread_id") or "", + kinds=TERMINAL_KINDS, + ) + except Exception: + continue + if not events: + continue + task = _kb.get_task(conn, sub["task_id"]) + for ev in events: + kind = ev.kind + task_title = (task.title if task else sub["task_id"])[:80] + if kind == "completed": + text = f"Kanban task completed: {task_title}" + elif kind == "blocked": + text = f"Kanban task blocked: {task_title}" + elif kind in ("gave_up", "crashed", "timed_out"): + text = f"Kanban task {kind}: {task_title}" + else: + text = f"Kanban task {kind}: {task_title}" + _dedup_key = (sub["task_id"], kind, str(ev.event_id)) + if _dedup_key not in emitted: + _emit("status.update", sid, {"kind": "kanban", "text": text}) + emitted.add(_dedup_key) + + conn.close() + + def _notification_poller_loop( stop_event: threading.Event, sid: str, session: dict ) -> None: @@ -8315,10 +8390,27 @@ def _notification_poller_loop( from tools.process_registry import process_registry, format_process_notification _emitted = set() # dedup re-queued events so same completion isn't emitted 50 times while session is busy + _last_kanban_poll = 0.0 + KANBAN_POLL_INTERVAL = 5.0 # seconds between kanban subscription polls + while not stop_event.is_set() and not session.get("_finalized"): try: evt = process_registry.completion_queue.get(timeout=0.5) except Exception: + # Queue timeout — check for kanban task event notifications. + # The gateway's _kanban_notifier_watcher handles messaging + # channels (telegram/discord/etc.), but TUI subscriptions are + # delivered here since the gateway has no TUI adapter (#59960). + now = time.time() + if now - _last_kanban_poll >= KANBAN_POLL_INTERVAL: + _last_kanban_poll = now + try: + _poll_kanban_task_events(session, sid, _emitted) + except Exception: + logger.debug( + "kanban notification poll for TUI session %s failed", + sid, exc_info=True, + ) continue # Multiple desktop sessions share this one process-wide queue. Only