From 0397be5939079d0a0f6df491637825e7f1583f2f Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 12:23:21 -0700
Subject: [PATCH 001/124] feat(tui): remove /provider alias for /model (#20358)
/model is the canonical command; /provider was a redundant alias that
dispatched to the same ModelPicker overlay. Drop the alias, the regex
branch in useCompletion, and the alias-coverage test.
---
ui-tui/src/__tests__/createSlashHandler.test.ts | 8 --------
ui-tui/src/app/slash/commands/session.ts | 1 -
ui-tui/src/hooks/useCompletion.ts | 4 ++--
3 files changed, 2 insertions(+), 11 deletions(-)
diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts
index c9447f16d8..53ca44a8fe 100644
--- a/ui-tui/src/__tests__/createSlashHandler.test.ts
+++ b/ui-tui/src/__tests__/createSlashHandler.test.ts
@@ -18,14 +18,6 @@ describe('createSlashHandler', () => {
expect(getOverlayState().picker).toBe(true)
})
- it('treats /provider as a local /model alias', () => {
- const ctx = buildCtx()
-
- expect(createSlashHandler(ctx)('/provider')).toBe(true)
- expect(getOverlayState().modelPicker).toBe(true)
- expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
- })
-
it('keeps typed /model switches session-scoped by default', async () => {
patchUiState({ sid: 'sid-abc' })
diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts
index a75419c3b0..9dddd85372 100644
--- a/ui-tui/src/app/slash/commands/session.ts
+++ b/ui-tui/src/app/slash/commands/session.ts
@@ -62,7 +62,6 @@ export const sessionCommands: SlashCommand[] = [
{
help: 'change or show model',
- aliases: ['provider'],
name: 'model',
run: (arg, ctx) => {
if (ctx.session.guardBusySessionSwitch('change models')) {
diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts
index 6bafc35843..d32b0de647 100644
--- a/ui-tui/src/hooks/useCompletion.ts
+++ b/ui-tui/src/hooks/useCompletion.ts
@@ -21,9 +21,9 @@ export function completionRequestForInput(
return null
}
- // `/model` / `/provider` use the two-step ModelPicker (real curated IDs).
+ // `/model` uses the two-step ModelPicker (real curated IDs).
// Slash completion here only showed short aliases + vendor/family meta.
- if (isSlashCommand && /^\/(?:model|provider)(?:\s|$)/.test(input)) {
+ if (isSlashCommand && /^\/model(?:\s|$)/.test(input)) {
return null
}
From 3b750715a39ed8a96fe90dc4f7a5b7b2ff9b794e Mon Sep 17 00:00:00 2001
From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com>
Date: Wed, 6 May 2026 01:11:49 +0530
Subject: [PATCH 002/124] fix: resolve lazy session creation regressions
(#18370 fallout) (#20363)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fix three regressions introduced by PR #18370 (lazy session creation):
1. _finalize_session() uses stale session_key after compression (#20001)
2. session_key not synced after auto-compression in run_conversation (#20001)
3. pending_title ValueError leaves title wedged forever (#19029)
4. Gateway silently swallows null responses when agent did work (#18765)
5. One-time cleanup for accumulated ghost compression continuations (#20001)
Changes:
- tui_gateway/server.py: _finalize_session() now uses agent.session_id
(falls back to session_key when agent is None). Refactor
_sync_session_key_after_compress() with clear_pending_title and
restart_slash_worker policy flags. Call it post-run_conversation()
to sync session_key after auto-compression. Add ValueError handler
to pending_title flush.
- gateway/run.py: Extract _normalize_empty_agent_response() helper that
consolidates failed/partial/null response handling. Surfaces user-facing
error when agent did work (api_calls > 0) but returned no text.
- hermes_state.py: Add finalize_orphaned_compression_sessions() — marks
ghost continuation sessions as ended (non-destructive, preserves data).
- cli.py: One-time startup migration for orphaned compression sessions.
Test changes:
- tests/test_tui_gateway_server.py: Update pending_title ValueError test
for post-#18370 architecture (title applied post-message, not at create).
- tests/test_lazy_session_regressions.py: 14 new regression tests covering
all fixed paths.
---
cli.py | 12 +
gateway/run.py | 78 ++--
hermes_state.py | 39 ++
tests/test_lazy_session_regressions.py | 608 +++++++++++++++++++++++++
tests/test_tui_gateway_server.py | 84 ++--
tui_gateway/server.py | 62 ++-
6 files changed, 809 insertions(+), 74 deletions(-)
create mode 100644 tests/test_lazy_session_regressions.py
diff --git a/cli.py b/cli.py
index 0292a2b943..3806dc4a3a 100644
--- a/cli.py
+++ b/cli.py
@@ -940,6 +940,18 @@ def _run_state_db_auto_maintenance(session_db) -> None:
except Exception as _prune_exc:
logger.debug("Ghost session prune skipped: %s", _prune_exc)
+ # One-time finalize of orphaned compression continuations (#20001).
+ try:
+ if not session_db.get_meta("orphaned_compression_finalize_v1"):
+ finalized = session_db.finalize_orphaned_compression_sessions()
+ session_db.set_meta("orphaned_compression_finalize_v1", "1")
+ if finalized:
+ logger.info(
+ "Finalized %d orphaned compression sessions", finalized
+ )
+ except Exception as _finalize_exc:
+ logger.debug("Orphan compression finalize skipped: %s", _finalize_exc)
+
cfg = (_load_full_config().get("sessions") or {})
if not cfg.get("auto_prune", False):
return
diff --git a/gateway/run.py b/gateway/run.py
index ed3bd47b96..66c31c4382 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -939,6 +939,52 @@ import weakref as _weakref
_gateway_runner_ref: _weakref.ref = lambda: None
+def _normalize_empty_agent_response(
+ agent_result: dict,
+ response: str,
+ *,
+ history_len: int = 0,
+) -> str:
+ """Normalize empty/None agent responses into user-facing messages.
+
+ Consolidates the existing ``failed`` handler and adds a catch-all for
+ the case where the agent did work (api_calls > 0) but returned no text.
+ Fix for #18765.
+ """
+ if response:
+ return response
+
+ if agent_result.get("failed"):
+ error_detail = agent_result.get("error", "unknown error")
+ error_str = str(error_detail).lower()
+ is_context_failure = any(
+ p in error_str
+ for p in ("context", "token", "too large", "too long", "exceed", "payload")
+ ) or ("400" in error_str and history_len > 50)
+ if is_context_failure:
+ return (
+ "⚠️ Session too large for the model's context window.\n"
+ "Use /compact to compress the conversation, or "
+ "/reset to start fresh."
+ )
+ return (
+ f"The request failed: {str(error_detail)[:300]}\n"
+ "Try again or use /reset to start a fresh session."
+ )
+
+ api_calls = int(agent_result.get("api_calls", 0) or 0)
+ if api_calls > 0 and not agent_result.get("interrupted"):
+ if agent_result.get("partial"):
+ err = agent_result.get("error", "processing incomplete")
+ return f"⚠️ Processing stopped: {str(err)[:200]}. Try again."
+ return (
+ "⚠️ Processing completed but no response was generated. "
+ "This may be a transient error — try sending your message again."
+ )
+
+ return response
+
+
class GatewayRunner:
"""
Main gateway controller.
@@ -6439,33 +6485,11 @@ class GatewayRunner:
session_key, _e,
)
- # Surface error details when the agent failed silently (final_response=None)
- if not response and agent_result.get("failed"):
- error_detail = agent_result.get("error", "unknown error")
- error_str = str(error_detail).lower()
-
- # Detect context-overflow failures and give specific guidance.
- # Generic 400 "Error" from Anthropic with large sessions is the
- # most common cause of this (#1630).
- _is_ctx_fail = any(p in error_str for p in (
- "context", "token", "too large", "too long",
- "exceed", "payload",
- )) or (
- "400" in error_str
- and len(history) > 50
- )
-
- if _is_ctx_fail:
- response = (
- "⚠️ Session too large for the model's context window.\n"
- "Use /compact to compress the conversation, or "
- "/reset to start fresh."
- )
- else:
- response = (
- f"The request failed: {str(error_detail)[:300]}\n"
- "Try again or use /reset to start a fresh session."
- )
+ # Normalize empty responses: surface errors, partial failures, and
+ # the case where agent did work but returned no text. Fix for #18765.
+ response = _normalize_empty_agent_response(
+ agent_result, response, history_len=len(history),
+ )
# If the agent's session_id changed during compression, update
# session_entry so transcript writes below go to the right session.
diff --git a/hermes_state.py b/hermes_state.py
index 98bd68bee5..444af16772 100644
--- a/hermes_state.py
+++ b/hermes_state.py
@@ -718,6 +718,45 @@ class SessionDB:
self._remove_session_files(sessions_dir, sid)
return len(removed_ids)
+ def finalize_orphaned_compression_sessions(self) -> int:
+ """Mark orphaned compression continuation sessions as ended.
+
+ Targets child sessions that were never finalized: parent is ended
+ with reason='compression', child has messages but no end_reason/ended_at
+ and api_call_count=0. Non-destructive: preserves all messages and sets
+ end_reason='orphaned_compression'. Fix for #20001.
+ """
+ cutoff = time.time() - 604800 # 7 days
+
+ def _do(conn):
+ now = time.time()
+ result = conn.execute(
+ """
+ UPDATE sessions
+ SET ended_at = ?,
+ end_reason = 'orphaned_compression'
+ WHERE api_call_count = 0
+ AND end_reason IS NULL
+ AND ended_at IS NULL
+ AND started_at < ?
+ AND parent_session_id IS NOT NULL
+ AND EXISTS (
+ SELECT 1 FROM sessions p
+ WHERE p.id = sessions.parent_session_id
+ AND p.end_reason = 'compression'
+ AND p.ended_at IS NOT NULL
+ )
+ AND EXISTS (
+ SELECT 1 FROM messages m
+ WHERE m.session_id = sessions.id
+ )
+ """,
+ (now, cutoff),
+ )
+ return result.rowcount
+
+ return self._execute_write(_do) or 0
+
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Get a session by ID."""
with self._lock:
diff --git a/tests/test_lazy_session_regressions.py b/tests/test_lazy_session_regressions.py
new file mode 100644
index 0000000000..511554a417
--- /dev/null
+++ b/tests/test_lazy_session_regressions.py
@@ -0,0 +1,608 @@
+"""Reproduction tests for #18370 fallout: lazy session creation regressions.
+
+Tests cover:
+1. Bug #20001 — _finalize_session() uses stale session_key after compression rotation
+2. Bug #20001 — _sync_session_key_after_compress called post-run_conversation
+3. Bug #19029 — pending_title ValueError leaves title wedged
+4. Bug #18765 — gateway surfaces null response when agent did work
+5. Prune — finalize_orphaned_compression_sessions catches ghost continuations
+"""
+
+import threading
+import time
+import types
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+# ===========================================================================
+# Helpers
+# ===========================================================================
+
+def _make_session_db(tmp_path):
+ """Create a real SessionDB for integration-style tests."""
+ from hermes_state import SessionDB
+ db_path = tmp_path / "test_state.db"
+ return SessionDB(db_path=db_path)
+
+
+def _tui_session(agent=None, session_key="session-key-old", **extra):
+ """Minimal TUI gateway session dict matching server._sessions values."""
+ return {
+ "agent": agent if agent is not None else types.SimpleNamespace(session_id=session_key),
+ "session_key": session_key,
+ "history": [],
+ "history_lock": threading.Lock(),
+ "history_version": 0,
+ "running": False,
+ "attached_images": [],
+ "image_counter": 0,
+ "cols": 80,
+ "slash_worker": None,
+ "show_reasoning": False,
+ "tool_progress_mode": "all",
+ "pending_title": None,
+ **extra,
+ }
+
+
+# ===========================================================================
+# Bug #20001: _finalize_session uses stale session_key
+# ===========================================================================
+
+class TestFinalizeSessionUsesAgentSessionId:
+ """After compression rotates agent.session_id, _finalize_session()
+ must call end_session() on the NEW (current) session_id, not the stale
+ session_key stored in the session dict."""
+
+ def test_finalize_targets_agent_session_id_not_stale_key(self, tmp_path):
+ """Reproduction: agent.session_id rotated by compression, but
+ session['session_key'] still holds old value. _finalize_session()
+ should end the agent's current session."""
+ from tui_gateway import server
+
+ db = _make_session_db(tmp_path)
+
+ # Create two sessions: parent (already ended by compression) and continuation
+ db.create_session(session_id="parent-session", source="tui", model="test")
+ db.end_session("parent-session", "compression")
+
+ db.create_session(
+ session_id="continuation-session",
+ source="tui",
+ model="test",
+ parent_session_id="parent-session",
+ )
+ # Continuation is NOT ended — this is the bug state
+
+ # Agent has rotated to continuation session
+ agent = types.SimpleNamespace(
+ session_id="continuation-session",
+ commit_memory_session=lambda h: None,
+ )
+
+ # Session dict still holds stale key (the bug condition)
+ session = _tui_session(
+ agent=agent,
+ session_key="parent-session",
+ history=[{"role": "user", "content": "hello"}],
+ )
+
+ # Monkeypatch _get_db to return our test DB
+ with patch.object(server, "_get_db", return_value=db):
+ with patch.object(server, "_notify_session_boundary", lambda *a: None):
+ server._finalize_session(session, end_reason="tui_close")
+
+ # The continuation session should be ended
+ continuation = db.get_session("continuation-session")
+ assert continuation["ended_at"] is not None, (
+ "_finalize_session should end the agent's current session (continuation), "
+ "not the already-ended parent"
+ )
+ assert continuation["end_reason"] == "tui_close"
+
+ def test_finalize_fallback_to_session_key_when_agent_is_none(self, tmp_path):
+ """When agent is None (e.g. session never fully initialized),
+ _finalize_session falls back to session_key."""
+ from tui_gateway import server
+
+ db = _make_session_db(tmp_path)
+ db.create_session(session_id="orphan-key", source="tui", model="test")
+
+ session = _tui_session(agent=None, session_key="orphan-key")
+
+ with patch.object(server, "_get_db", return_value=db):
+ with patch.object(server, "_notify_session_boundary", lambda *a: None):
+ server._finalize_session(session, end_reason="tui_close")
+
+ row = db.get_session("orphan-key")
+ assert row["ended_at"] is not None
+ assert row["end_reason"] == "tui_close"
+
+
+# ===========================================================================
+# Bug #20001: _sync_session_key_after_compress post-run_conversation
+# ===========================================================================
+
+class TestSyncSessionKeyAfterAutoCompress:
+ """When auto-compression fires inside run_conversation(), the post-turn
+ code in _run_prompt_submit must call _sync_session_key_after_compress
+ to update session_key for downstream consumers (title, goals, etc.)."""
+
+ def test_session_key_synced_after_run_conversation_with_compression(self, monkeypatch):
+ """Simulate: run_conversation() internally compresses and rotates
+ agent.session_id. After it returns, session['session_key'] must match."""
+ from tui_gateway import server
+
+ class _CompressingAgent:
+ """Agent that simulates compression-driven session_id rotation."""
+ def __init__(self):
+ self.session_id = "pre-compress-key"
+ self._cached_system_prompt = ""
+
+ def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
+ # Simulate what _compress_context does: rotate session_id
+ self.session_id = "post-compress-key"
+ return {
+ "final_response": "done",
+ "messages": [
+ {"role": "user", "content": prompt},
+ {"role": "assistant", "content": "done"},
+ ],
+ }
+
+ agent = _CompressingAgent()
+ session = _tui_session(agent=agent, session_key="pre-compress-key")
+
+ # Track if _sync_session_key_after_compress was called
+ sync_calls = []
+ original_sync = server._sync_session_key_after_compress
+
+ def _tracking_sync(sid, sess, **kwargs):
+ sync_calls.append((sid, sess.get("session_key")))
+ # Just update the key directly (skip approval routing etc.)
+ new_id = getattr(sess.get("agent"), "session_id", None) or ""
+ if new_id and new_id != sess.get("session_key"):
+ sess["session_key"] = new_id
+
+ monkeypatch.setattr(server, "_sync_session_key_after_compress", _tracking_sync)
+ monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
+ monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
+ monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
+
+ # Use _ImmediateThread pattern to run synchronously
+ class _ImmediateThread:
+ def __init__(self, target=None, daemon=None, **kw):
+ self._target = target
+ def start(self):
+ self._target()
+
+ server._sessions["test-sid"] = session
+ monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
+
+ try:
+ server.handle_request({
+ "id": "1",
+ "method": "prompt.submit",
+ "params": {"session_id": "test-sid", "text": "hello"},
+ })
+
+ # Sync should have been called
+ assert len(sync_calls) > 0, (
+ "_sync_session_key_after_compress must be called after run_conversation "
+ "to pick up compression-driven session_id rotation"
+ )
+
+ # session_key should now match agent.session_id
+ assert session["session_key"] == "post-compress-key", (
+ "session_key must be updated to match agent.session_id after compression"
+ )
+ finally:
+ server._sessions.pop("test-sid", None)
+
+
+# ===========================================================================
+# Bug #19029: pending_title ValueError wedge
+# ===========================================================================
+
+class TestPendingTitleValueError:
+ """When set_session_title raises ValueError (duplicate/invalid title),
+ pending_title must be cleared — not left wedged forever."""
+
+ def test_valueerror_clears_pending_title(self, monkeypatch):
+ """ValueError from set_session_title should drop pending_title."""
+ from tui_gateway import server
+
+ mock_db = MagicMock()
+ mock_db.set_session_title.side_effect = ValueError("duplicate title")
+
+ class _Agent:
+ session_id = "test-session"
+ _cached_system_prompt = ""
+ def run_conversation(self, prompt, **kw):
+ return {
+ "final_response": "ok",
+ "messages": [{"role": "assistant", "content": "ok"}],
+ }
+
+ session = _tui_session(
+ agent=_Agent(),
+ session_key="test-session",
+ pending_title="My Title",
+ )
+
+ monkeypatch.setattr(server, "_get_db", lambda: mock_db)
+ monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
+ monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
+ monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
+ monkeypatch.setattr(
+ server, "_sync_session_key_after_compress", lambda *a, **kw: None
+ )
+
+ class _ImmediateThread:
+ def __init__(self, target=None, daemon=None, **kw):
+ self._target = target
+ def start(self):
+ self._target()
+
+ server._sessions["sid"] = session
+ monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
+
+ try:
+ server.handle_request({
+ "id": "1",
+ "method": "prompt.submit",
+ "params": {"session_id": "sid", "text": "hello"},
+ })
+
+ # pending_title should be cleared on ValueError, not left wedged
+ assert session.get("pending_title") is None, (
+ "ValueError from set_session_title must clear pending_title "
+ "so auto-title can take over"
+ )
+ finally:
+ server._sessions.pop("sid", None)
+
+ def test_other_exception_keeps_pending_title_for_retry(self, monkeypatch):
+ """Non-ValueError exceptions should keep pending_title for retry."""
+ from tui_gateway import server
+
+ mock_db = MagicMock()
+ mock_db.set_session_title.side_effect = RuntimeError("transient DB lock")
+
+ class _Agent:
+ session_id = "test-session"
+ _cached_system_prompt = ""
+ def run_conversation(self, prompt, **kw):
+ return {
+ "final_response": "ok",
+ "messages": [{"role": "assistant", "content": "ok"}],
+ }
+
+ session = _tui_session(
+ agent=_Agent(),
+ session_key="test-session",
+ pending_title="My Title",
+ )
+
+ monkeypatch.setattr(server, "_get_db", lambda: mock_db)
+ monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
+ monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
+ monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
+ monkeypatch.setattr(
+ server, "_sync_session_key_after_compress", lambda *a, **kw: None
+ )
+
+ class _ImmediateThread:
+ def __init__(self, target=None, daemon=None, **kw):
+ self._target = target
+ def start(self):
+ self._target()
+
+ server._sessions["sid"] = session
+ monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
+
+ try:
+ server.handle_request({
+ "id": "1",
+ "method": "prompt.submit",
+ "params": {"session_id": "sid", "text": "hello"},
+ })
+
+ # Non-ValueError should keep pending_title for retry
+ assert session.get("pending_title") == "My Title", (
+ "Non-ValueError exceptions should keep pending_title intact "
+ "for retry on next turn"
+ )
+ finally:
+ server._sessions.pop("sid", None)
+
+
+# ===========================================================================
+# Bug #18765: Gateway surfaces null response
+# ===========================================================================
+
+class TestGatewaySurfacesNullResponse:
+ """When the agent does work (api_calls > 0) but returns no final_response,
+ the gateway must surface an error to the user instead of silently sending
+ nothing. Tests exercise the production _normalize_empty_agent_response helper."""
+
+ def test_partial_response_surfaces_error(self):
+ """Agent returns partial=True with no response → user sees error."""
+ from gateway.run import _normalize_empty_agent_response
+
+ agent_result = {
+ "final_response": None,
+ "api_calls": 5,
+ "partial": True,
+ "interrupted": False,
+ "error": "Model generated invalid tool call: nonexistent_tool",
+ }
+
+ response = agent_result.get("final_response") or ""
+ response = _normalize_empty_agent_response(
+ agent_result, response, history_len=10,
+ )
+
+ assert response != "", "Null response with api_calls>0 must be surfaced"
+ assert "nonexistent_tool" in response
+
+ def test_interrupted_response_stays_empty(self):
+ """Interrupted agent → response stays empty (platform handles UX)."""
+ from gateway.run import _normalize_empty_agent_response
+
+ agent_result = {
+ "final_response": None,
+ "api_calls": 3,
+ "partial": False,
+ "interrupted": True,
+ }
+
+ response = agent_result.get("final_response") or ""
+ response = _normalize_empty_agent_response(
+ agent_result, response, history_len=10,
+ )
+
+ assert response == "", "Interrupted turns should not get synthetic responses"
+
+ def test_failed_context_overflow(self):
+ """Agent failed with context overflow → specific guidance message."""
+ from gateway.run import _normalize_empty_agent_response
+
+ agent_result = {
+ "final_response": None,
+ "api_calls": 0,
+ "failed": True,
+ "error": "400 Bad Request: context length exceeded",
+ }
+
+ response = agent_result.get("final_response") or ""
+ response = _normalize_empty_agent_response(
+ agent_result, response, history_len=60,
+ )
+
+ assert "context window" in response
+ assert "/compact" in response
+
+ def test_failed_generic_error(self):
+ """Agent failed with non-context error → generic error message."""
+ from gateway.run import _normalize_empty_agent_response
+
+ agent_result = {
+ "final_response": None,
+ "api_calls": 0,
+ "failed": True,
+ "error": "500 Internal Server Error",
+ }
+
+ response = agent_result.get("final_response") or ""
+ response = _normalize_empty_agent_response(
+ agent_result, response, history_len=5,
+ )
+
+ assert "500 Internal Server Error" in response
+ assert "/reset" in response
+
+ def test_nonempty_response_passes_through(self):
+ """Non-empty response is returned unchanged."""
+ from gateway.run import _normalize_empty_agent_response
+
+ agent_result = {"final_response": "Hello!", "api_calls": 1}
+ response = "Hello!"
+ result = _normalize_empty_agent_response(
+ agent_result, response, history_len=5,
+ )
+
+ assert result == "Hello!"
+
+
+# ===========================================================================
+# Prune: finalize_orphaned_compression_sessions
+# ===========================================================================
+
+class TestFinalizeOrphanedCompressionSessions:
+ """The prune migration marks ghost compression continuations as ended."""
+
+ def test_marks_ghost_continuation_with_compression_parent(self, tmp_path):
+ """Ghost session with compression-ended parent + messages → finalized."""
+ db = _make_session_db(tmp_path)
+
+ # Parent session (ended by compression — this is the key condition)
+ db.create_session(session_id="parent", source="tui", model="test")
+ db.end_session("parent", "compression")
+
+ # Ghost continuation (has messages, never finalized)
+ db.create_session(
+ session_id="ghost-cont",
+ source="tui",
+ model="test",
+ parent_session_id="parent",
+ )
+ db.append_message("ghost-cont", role="user", content="hello")
+ db.append_message("ghost-cont", role="assistant", content="hi")
+
+ # Make it old enough (fake started_at)
+ db._execute_write(
+ lambda conn: conn.execute(
+ "UPDATE sessions SET started_at = ? WHERE id = ?",
+ (time.time() - 800000, "ghost-cont"), # ~9 days old
+ )
+ )
+
+ count = db.finalize_orphaned_compression_sessions()
+ assert count == 1
+
+ session = db.get_session("ghost-cont")
+ assert session["ended_at"] is not None
+ assert session["end_reason"] == "orphaned_compression"
+
+ def test_skips_session_without_parent(self, tmp_path):
+ """Ghost session without parent_session_id is NOT a compression
+ continuation — should not be touched by this prune."""
+ db = _make_session_db(tmp_path)
+
+ db.create_session(session_id="ghost-notitle", source="tui", model="test")
+ db.append_message("ghost-notitle", role="user", content="test")
+
+ db._execute_write(
+ lambda conn: conn.execute(
+ "UPDATE sessions SET started_at = ? WHERE id = ?",
+ (time.time() - 800000, "ghost-notitle"),
+ )
+ )
+
+ count = db.finalize_orphaned_compression_sessions()
+ assert count == 0
+
+ def test_skips_recent_sessions(self, tmp_path):
+ """Sessions younger than 7 days are not touched."""
+ db = _make_session_db(tmp_path)
+
+ # Create parent first to satisfy FK constraint
+ db.create_session(session_id="some-parent", source="tui", model="test")
+ db.create_session(
+ session_id="recent",
+ source="tui",
+ model="test",
+ parent_session_id="some-parent",
+ )
+ db.append_message("recent", role="user", content="hello")
+ # started_at is now() — within 7 days
+
+ count = db.finalize_orphaned_compression_sessions()
+ assert count == 0
+
+ def test_skips_sessions_with_end_reason(self, tmp_path):
+ """Properly finalized sessions (even without api_call_count) are skipped."""
+ db = _make_session_db(tmp_path)
+
+ # Create parent first to satisfy FK constraint
+ db.create_session(session_id="parent", source="tui", model="test")
+ db.end_session("parent", "compression")
+
+ db.create_session(
+ session_id="already-ended",
+ source="tui",
+ model="test",
+ parent_session_id="parent",
+ )
+ db.append_message("already-ended", role="user", content="hello")
+ db.end_session("already-ended", "user_exit")
+
+ db._execute_write(
+ lambda conn: conn.execute(
+ "UPDATE sessions SET started_at = ? WHERE id = ?",
+ (time.time() - 800000, "already-ended"),
+ )
+ )
+
+ count = db.finalize_orphaned_compression_sessions()
+ assert count == 0
+
+ def test_skips_session_with_non_compression_parent(self, tmp_path):
+ """Child session whose parent was NOT ended by compression should
+ not be touched — it's not from the compression continuation path."""
+ db = _make_session_db(tmp_path)
+
+ # Parent ended by user_exit, not compression
+ db.create_session(session_id="parent", source="tui", model="test")
+ db.end_session("parent", "user_exit")
+
+ db.create_session(
+ session_id="child",
+ source="tui",
+ model="test",
+ parent_session_id="parent",
+ )
+ db.append_message("child", role="user", content="hello")
+
+ db._execute_write(
+ lambda conn: conn.execute(
+ "UPDATE sessions SET started_at = ? WHERE id = ?",
+ (time.time() - 800000, "child"),
+ )
+ )
+
+ count = db.finalize_orphaned_compression_sessions()
+ assert count == 0
+
+ def test_skips_sessions_without_messages(self, tmp_path):
+ """Empty sessions (no messages) are NOT targeted by this prune —
+ those are handled by prune_empty_ghost_sessions()."""
+ db = _make_session_db(tmp_path)
+
+ # Create parent first to satisfy FK constraint
+ db.create_session(session_id="parent", source="tui", model="test")
+ db.end_session("parent", "compression")
+
+ db.create_session(
+ session_id="empty-ghost",
+ source="tui",
+ model="test",
+ parent_session_id="parent",
+ )
+ # No messages appended
+
+ db._execute_write(
+ lambda conn: conn.execute(
+ "UPDATE sessions SET started_at = ? WHERE id = ?",
+ (time.time() - 800000, "empty-ghost"),
+ )
+ )
+
+ count = db.finalize_orphaned_compression_sessions()
+ assert count == 0
+
+ def test_titled_ghost_with_parent_is_caught(self, tmp_path):
+ """Ghost continuation that HAS a title (propagated from parent by
+ _compress_context) is still caught via parent with end_reason='compression'."""
+ db = _make_session_db(tmp_path)
+
+ # Create parent first — ended by compression
+ db.create_session(session_id="parent", source="tui", model="test")
+ db.set_session_title("parent", "Chat")
+ db.end_session("parent", "compression")
+
+ db.create_session(
+ session_id="titled-ghost",
+ source="tui",
+ model="test",
+ parent_session_id="parent",
+ )
+ db.set_session_title("titled-ghost", "Chat (2)")
+ db.append_message("titled-ghost", role="user", content="continued...")
+
+ db._execute_write(
+ lambda conn: conn.execute(
+ "UPDATE sessions SET started_at = ? WHERE id = ?",
+ (time.time() - 800000, "titled-ghost"),
+ )
+ )
+
+ count = db.finalize_orphaned_compression_sessions()
+ assert count == 1
+
+ session = db.get_session("titled-ghost")
+ assert session["end_reason"] == "orphaned_compression"
diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py
index 469b8895ea..03647f55f0 100644
--- a/tests/test_tui_gateway_server.py
+++ b/tests/test_tui_gateway_server.py
@@ -921,56 +921,70 @@ def test_session_title_set_errors_when_row_lookup_fails_after_noop(monkeypatch):
def test_session_create_drops_pending_title_on_valueerror(monkeypatch):
- unblock_agent = threading.Event()
+ """When set_session_title raises ValueError during post-message title flush,
+ pending_title should be dropped (non-retryable). Updated for post-#18370
+ lazy session creation where title is applied post-first-message.
+ """
- class _FakeWorker:
- def __init__(self, key, model):
- self.key = key
-
- def close(self):
- return None
-
- class _FakeAgent:
+ class _Agent:
+ session_id = "test-session"
model = "x"
provider = "openrouter"
base_url = ""
api_key = ""
+ _cached_system_prompt = ""
+
+ def run_conversation(self, prompt, **kw):
+ return {
+ "final_response": "ok",
+ "messages": [{"role": "assistant", "content": "ok"}],
+ }
class _FakeDB:
- def create_session(self, _key, source="tui", model=None):
- return None
-
def set_session_title(self, _key, _title):
raise ValueError("Title already in use")
- def _make_agent(_sid, _key):
- unblock_agent.wait(timeout=2.0)
- return _FakeAgent()
+ class _ImmediateThread:
+ def __init__(self, target=None, daemon=None, **kw):
+ self._target = target
- monkeypatch.setattr(server, "_make_agent", _make_agent)
- monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
+ def start(self):
+ self._target()
+
+ agent = _Agent()
+ session = {
+ "agent": agent,
+ "session_key": "test-session",
+ "history": [],
+ "history_lock": threading.Lock(),
+ "history_version": 0,
+ "running": False,
+ "attached_images": [],
+ "image_counter": 0,
+ "cols": 80,
+ "slash_worker": None,
+ "show_reasoning": False,
+ "tool_progress_mode": "all",
+ "pending_title": "duplicate title",
+ }
+
+ server._sessions["sid"] = session
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
- monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"})
- monkeypatch.setattr(server, "_probe_credentials", lambda _a: None)
- monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
-
- import tools.approval as _approval
-
- monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
- monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)
-
- resp = server.handle_request(
- {"id": "1", "method": "session.create", "params": {"cols": 80}}
+ monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
+ monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
+ monkeypatch.setattr(
+ server, "_sync_session_key_after_compress", lambda *a, **kw: None
)
- sid = resp["result"]["session_id"]
- session = server._sessions[sid]
- session["pending_title"] = "duplicate title"
- unblock_agent.set()
- session["agent_ready"].wait(timeout=2.0)
+ monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
- assert session["pending_title"] is None
- server._sessions.pop(sid, None)
+ try:
+ server.handle_request(
+ {"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hello"}}
+ )
+ assert session["pending_title"] is None
+ finally:
+ server._sessions.pop("sid", None)
def test_config_set_yolo_toggles_session_scope():
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 2a4377c3f1..68b03f091a 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -304,12 +304,14 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
_notify_session_boundary("on_session_finalize", session_id)
# Mark session ended in DB so it doesn't linger as a ghost row in /resume.
- # Adapted from #18283 (luyao618) and #18299 (Bartok9).
- if session_key:
+ # Use session_id (from agent.session_id) not session_key — after compression,
+ # session_key may be stale (the ended parent) while session_id is the live
+ # continuation. Fix for #20001.
+ if session_id:
try:
db = _get_db()
if db is not None:
- db.end_session(session_key, end_reason)
+ db.end_session(session_id, end_reason)
except Exception:
pass
@@ -1175,7 +1177,13 @@ def _compress_session_history(
return len(history) - len(compressed), usage
-def _sync_session_key_after_compress(sid: str, session: dict) -> None:
+def _sync_session_key_after_compress(
+ sid: str,
+ session: dict,
+ *,
+ clear_pending_title: bool = True,
+ restart_slash_worker: bool = True,
+) -> None:
"""Re-anchor session_key when AIAgent._compress_context rotates session_id.
AIAgent._compress_context ends the current SessionDB session and creates
@@ -1184,7 +1192,14 @@ def _sync_session_key_after_compress(sid: str, session: dict) -> None:
approval routing, slash worker init, DB title/history lookups, yolo
state). Without this sync, those operations would target the ended
parent session while the agent writes to the new continuation session.
- Mirrors HermesCLI._manual_compress's session_id sync.
+
+ Policy flags:
+ clear_pending_title: True for manual /compress (title belongs to old
+ session). False for post-turn auto-compression (preserve user
+ intent so pending_title can be applied to the continuation).
+ restart_slash_worker: True for manual /compress and post-turn
+ auto-compression (worker holds stale session key). False only
+ if the caller manages the worker lifecycle separately.
"""
agent = session.get("agent")
new_session_id = getattr(agent, "session_id", None) or ""
@@ -1229,11 +1244,13 @@ def _sync_session_key_after_compress(sid: str, session: dict) -> None:
# don't keep targeting the ended row.
session["session_key"] = new_session_id
- session["pending_title"] = None
- try:
- _restart_slash_worker(session)
- except Exception:
- pass
+ if clear_pending_title:
+ session["pending_title"] = None
+ if restart_slash_worker:
+ try:
+ _restart_slash_worker(session)
+ except Exception:
+ pass
def _get_usage(agent) -> dict:
@@ -2965,6 +2982,17 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
"History changed during this turn — the response above is visible "
"but was not saved to session history."
)
+
+ # If auto-compression fired inside run_conversation(), agent.session_id
+ # may have rotated. Sync session_key before downstream title/goal/finalize
+ # handling uses it. Preserve pending_title (user intent) so it can be
+ # applied to the continuation. Restart slash worker so subsequent
+ # worker-backed commands (/title etc.) target the live session.
+ # Fix for #20001.
+ _sync_session_key_after_compress(
+ sid, session, clear_pending_title=False, restart_slash_worker=True,
+ )
+
raw = result.get("final_response", "")
status = (
"interrupted"
@@ -3042,11 +3070,21 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
if _pending and status == "complete":
_pdb = _get_db()
if _pdb:
+ _session_key = session.get("session_key") or sid
try:
- if _pdb.set_session_title(session.get("session_key") or sid, _pending):
+ if _pdb.set_session_title(_session_key, _pending):
session["pending_title"] = None
+ except ValueError as exc:
+ # Invalid/duplicate title — non-retryable, drop it.
+ # Auto-title will take over. Fix for #19029.
+ session["pending_title"] = None
+ logger.info(
+ "Dropping pending title for session %s: %s",
+ _session_key, exc,
+ )
except Exception:
- pass # Best effort — auto-title will handle it below
+ # Transient DB failure — keep pending_title for retry.
+ pass
if (
status == "complete"
From a6289927d39eb21df03a70f3d91e7eb80c54de7c Mon Sep 17 00:00:00 2001
From: beardthelion
Date: Mon, 4 May 2026 23:02:24 -0500
Subject: [PATCH 003/124] docs(web_tools): correct web_extract summarizer
timeout comment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The comment at tools/web_tools.py:700-702 stated the runtime default for
auxiliary.web_extract.timeout is 360s. The actual runtime default is 30s
(_DEFAULT_AUX_TIMEOUT in agent/auxiliary_client.py:3140), used by
_get_task_timeout when no auxiliary.web_extract.timeout key is present in
config.yaml.
The 360s figure is the config template default written by
hermes_cli/config.py:697 into freshly-generated config.yaml files. It only
takes effect when that key exists in the user's config — not as a fallback.
Users on configs that predate commit 20b4060d (Apr 5, 2026), or who removed
the key, fall through to the 30s _DEFAULT_AUX_TIMEOUT runtime default.
The comment was introduced in 20b4060d alongside the template-default bump
from 30 to 360. The runtime default in auxiliary_client.py was not changed
in that commit and has remained 30s since 839d9d74 (Mar 28, 2026).
---
tools/web_tools.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/tools/web_tools.py b/tools/web_tools.py
index 352b4a55b1..e24ace2f87 100644
--- a/tools/web_tools.py
+++ b/tools/web_tools.py
@@ -698,8 +698,10 @@ Create a markdown summary that captures all key information in a well-organized,
"temperature": 0.1,
"max_tokens": max_tokens,
# No explicit timeout — async_call_llm reads auxiliary.web_extract.timeout
- # from config (default 360s / 6min). Users with slow local models can
- # increase it in config.yaml.
+ # from config.yaml. Fresh configs ship with 360s; if the key is absent
+ # the runtime default is 30s (_DEFAULT_AUX_TIMEOUT in
+ # agent/auxiliary_client.py). Users with slow local models should set
+ # or increase auxiliary.web_extract.timeout in config.yaml.
}
if extra_body:
call_kwargs["extra_body"] = extra_body
From ce9888b52abb942c0bfbe4afdc58cb6b4e82b8c2 Mon Sep 17 00:00:00 2001
From: LeonSGP43
Date: Tue, 5 May 2026 11:33:44 +0800
Subject: [PATCH 004/124] docs(config): fix fallback provider config paths
---
website/docs/reference/cli-commands.md | 2 +-
website/docs/reference/environment-variables.md | 12 +++++++-----
2 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md
index 4f307f15e7..a36fe9819c 100644
--- a/website/docs/reference/cli-commands.md
+++ b/website/docs/reference/cli-commands.md
@@ -1093,7 +1093,7 @@ Typical session:
2. Use `↑`/`↓` to reorder fallbacks (first-in-list is tried first).
3. Press `d` to remove one.
-All changes persist to `fallback_providers:` under `model:` in `config.yaml`. Interacts with [Credential Pools](/docs/user-guide/features/credential-pools): pools rotate keys *within* a provider, fallbacks switch to a *different* provider entirely.
+All changes persist to the top-level `fallback_providers:` list in `config.yaml`. Interacts with [Credential Pools](/docs/user-guide/features/credential-pools): pools rotate keys *within* a provider, fallbacks switch to a *different* provider entirely.
See [Fallback Providers](/docs/user-guide/features/fallback-providers) for behavior details and interaction with `fallback_model` (legacy single-fallback key).
diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md
index 9bcda5695e..c962c20b76 100644
--- a/website/docs/reference/environment-variables.md
+++ b/website/docs/reference/environment-variables.md
@@ -514,16 +514,18 @@ Older configs with `compression.summary_model`, `compression.summary_provider`,
For task-specific direct endpoints, Hermes uses the task's configured API key or `OPENAI_API_KEY`. It does not reuse `OPENROUTER_API_KEY` for those custom endpoints.
-## Fallback Model (config.yaml only)
+## Fallback Providers (config.yaml only)
-The primary model fallback is configured exclusively through `config.yaml` — there are no environment variables for it. Add a `fallback_model` section with `provider` and `model` keys to enable automatic failover when your main model encounters errors.
+The primary model fallback chain is configured exclusively through `config.yaml` — there are no environment variables for it. Add a top-level `fallback_providers` list with `provider` and `model` keys to enable automatic failover when your main model encounters errors.
```yaml
-fallback_model:
- provider: openrouter
- model: anthropic/claude-sonnet-4
+fallback_providers:
+ - provider: openrouter
+ model: anthropic/claude-sonnet-4
```
+The older top-level `fallback_model` single-provider shape is still read for backward compatibility, but new configuration should use `fallback_providers`.
+
See [Fallback Providers](/docs/user-guide/features/fallback-providers) for full details.
## Provider Routing (config.yaml only)
From 27a8ba42ed73d6c8af98ace165c853dbbedad97c Mon Sep 17 00:00:00 2001
From: Brandon Zarnitz
Date: Mon, 4 May 2026 22:00:57 -0400
Subject: [PATCH 005/124] docs(prompt): clarify supported customization
surfaces
---
.../docs/developer-guide/prompt-assembly.md | 24 +++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/website/docs/developer-guide/prompt-assembly.md b/website/docs/developer-guide/prompt-assembly.md
index 047117fa7e..f23705870e 100644
--- a/website/docs/developer-guide/prompt-assembly.md
+++ b/website/docs/developer-guide/prompt-assembly.md
@@ -230,6 +230,30 @@ Long files are truncated before injection.
The skills system contributes a compact skills index to the prompt when skills tooling is available.
+## Supported prompt customization surfaces
+
+Most users should treat `agent/prompt_builder.py` as implementation code, not a configuration surface. The supported customization path is to change the prompt inputs Hermes already loads, rather than editing Python templates in place.
+
+### Use these surfaces first
+
+- `~/.hermes/SOUL.md` — replace the built-in default identity block with your own agent persona and standing behavior.
+- `~/.hermes/MEMORY.md` and `~/.hermes/USER.md` — provide durable cross-session facts and user profile data that should be snapshotted into new sessions.
+- Project context files such as `.hermes.md`, `HERMES.md`, `AGENTS.md`, `CLAUDE.md`, or `.cursorrules` — inject repo-specific working rules.
+- Skills — package reusable workflows and references without editing core prompt code.
+- Optional system prompt config / API overrides — add deployment-specific instruction text without forking Hermes.
+- Ephemeral overlays such as `HERMES_EPHEMERAL_SYSTEM_PROMPT` or prefill messages — add turn-scoped guidance that should not become part of the cached prompt prefix.
+
+### When to edit code instead
+
+Edit `agent/prompt_builder.py` only if you are intentionally maintaining a fork or contributing upstream behavior changes. That file assembles the prompt plumbing, cache boundaries, and injection order for every session. Direct edits there are global product changes, not per-user prompt customization.
+
+In other words:
+
+- if you want a different assistant identity, edit `SOUL.md`
+- if you want different repo rules, edit project context files
+- if you want reusable operating procedures, add or modify skills
+- if you want to change how Hermes assembles prompts for everyone, change Python and treat it as a code contribution
+
## Why prompt assembly is split this way
The architecture is intentionally optimized to:
From c85a25faaa561a0d6708cc32c374b28774a3f71f Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:26:03 -0700
Subject: [PATCH 006/124] chore: AUTHOR_MAP entry for Beandon13
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index 6ced354cbb..916cc4b5e0 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -85,6 +85,7 @@ AUTHOR_MAP = {
"mrhanoi@outlook.com": "qxxaa",
"emelyanenko.kirill@gmail.com": "EmelyanenkoK",
"lazycat.manatee@gmail.com": "manateelazycat",
+ "bzarnitz13@gmail.com": "Beandon13",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 2d5f20684a9e4574120a4e632b711d4037301da8 Mon Sep 17 00:00:00 2001
From: WadydX <65117428+WadydX@users.noreply.github.com>
Date: Thu, 23 Apr 2026 14:47:08 +0100
Subject: [PATCH 007/124] docs: remove dead reference links in flash-attention
skill
---
optional-skills/mlops/flash-attention/SKILL.md | 4 ----
1 file changed, 4 deletions(-)
diff --git a/optional-skills/mlops/flash-attention/SKILL.md b/optional-skills/mlops/flash-attention/SKILL.md
index 6a3839bf78..89a860e67d 100644
--- a/optional-skills/mlops/flash-attention/SKILL.md
+++ b/optional-skills/mlops/flash-attention/SKILL.md
@@ -345,10 +345,6 @@ Flash Attention uses float16/bfloat16 for speed. Float32 not supported.
**Performance benchmarks**: See [references/benchmarks.md](references/benchmarks.md) for detailed speed and memory comparisons across GPUs and sequence lengths.
-**Algorithm details**: See [references/algorithm.md](references/algorithm.md) for tiling strategy, recomputation, and IO complexity analysis.
-
-**Advanced features**: See [references/advanced-features.md](references/advanced-features.md) for rotary embeddings, ALiBi, paged KV cache, and custom attention masks.
-
## Hardware requirements
- **GPU**: NVIDIA Ampere+ (A100, A10, A30) or AMD MI200+
From 58f93fb7d38b167a9c41271d3707ee99f6f44de1 Mon Sep 17 00:00:00 2001
From: WadydX <65117428+WadydX@users.noreply.github.com>
Date: Thu, 23 Apr 2026 14:54:27 +0100
Subject: [PATCH 008/124] docs: remove dead papers.md link from saelens
references
---
optional-skills/mlops/saelens/references/README.md | 1 -
1 file changed, 1 deletion(-)
diff --git a/optional-skills/mlops/saelens/references/README.md b/optional-skills/mlops/saelens/references/README.md
index 0ec3b7cff9..69d0618123 100644
--- a/optional-skills/mlops/saelens/references/README.md
+++ b/optional-skills/mlops/saelens/references/README.md
@@ -6,7 +6,6 @@ This directory contains comprehensive reference materials for SAELens.
- [api.md](api.md) - Complete API reference for SAE, TrainingSAE, and configuration classes
- [tutorials.md](tutorials.md) - Step-by-step tutorials for training and analyzing SAEs
-- [papers.md](papers.md) - Key research papers on sparse autoencoders
## Quick Links
From 0664bf961a3a4e1e9e7b9b4f0235a37bcb7c7646 Mon Sep 17 00:00:00 2001
From: WadydX <65117428+WadydX@users.noreply.github.com>
Date: Thu, 23 Apr 2026 15:55:07 +0100
Subject: [PATCH 009/124] docs: fix broken nix-setup anchor for container-aware
CLI
---
website/docs/getting-started/nix-setup.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md
index ceeabec9c6..aa52aff324 100644
--- a/website/docs/getting-started/nix-setup.md
+++ b/website/docs/getting-started/nix-setup.md
@@ -122,7 +122,9 @@ services.hermes-agent.environmentFiles = [ "/var/lib/hermes/env" ];
Setting `addToSystemPackages = true` does two things: puts the `hermes` CLI on your system PATH **and** sets `HERMES_HOME` system-wide so the interactive CLI shares state (sessions, skills, cron) with the gateway service. Without it, running `hermes` in your shell creates a separate `~/.hermes/` directory.
:::
-:::info Container-aware CLI
+### Container-aware CLI
+
+:::info
When `container.enable = true` and `addToSystemPackages = true`, **every** `hermes` command on the host automatically routes into the managed container. This means your interactive CLI session runs inside the same environment as the gateway service — with access to all container-installed packages and tools.
- The routing is transparent: `hermes chat`, `hermes sessions list`, `hermes version`, etc. all exec into the container under the hood
From 41545f7ec59dfe9b05f58374113635eeae0d1bfc Mon Sep 17 00:00:00 2001
From: helix4u <4317663+helix4u@users.noreply.github.com>
Date: Tue, 5 May 2026 13:36:33 -0600
Subject: [PATCH 010/124] fix(telegram): keep DM topic typing scoped
---
gateway/platforms/telegram.py | 11 ++++++-----
.../gateway/test_telegram_thread_fallback.py | 19 +++++++++++++++++++
2 files changed, 25 insertions(+), 5 deletions(-)
diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py
index ad5ed66920..51b2bc848a 100644
--- a/gateway/platforms/telegram.py
+++ b/gateway/platforms/telegram.py
@@ -2516,11 +2516,12 @@ class TelegramAdapter(BasePlatformAdapter):
)
except Exception as e:
if message_thread_id is not None and self._is_thread_not_found_error(e):
- await self._bot.send_chat_action(
- chat_id=int(chat_id),
- action="typing",
- message_thread_id=None,
- )
+ if str(_typing_thread) == self._GENERAL_TOPIC_THREAD_ID:
+ await self._bot.send_chat_action(
+ chat_id=int(chat_id),
+ action="typing",
+ message_thread_id=None,
+ )
else:
raise
except Exception as e:
diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py
index 4930467bfe..3b7069d6fa 100644
--- a/tests/gateway/test_telegram_thread_fallback.py
+++ b/tests/gateway/test_telegram_thread_fallback.py
@@ -179,6 +179,25 @@ async def test_send_typing_retries_without_general_thread_when_not_found():
]
+@pytest.mark.asyncio
+async def test_send_typing_does_not_fall_back_to_root_for_dm_topic():
+ """Typing failures in DM topics should not show an indicator in All Messages."""
+ adapter = _make_adapter()
+ call_log = []
+
+ async def mock_send_chat_action(**kwargs):
+ call_log.append(dict(kwargs))
+ raise FakeBadRequest("Message thread not found")
+
+ adapter._bot = SimpleNamespace(send_chat_action=mock_send_chat_action)
+
+ await adapter.send_typing("12345", metadata={"thread_id": "22182"})
+
+ assert call_log == [
+ {"chat_id": 12345, "action": "typing", "message_thread_id": 22182},
+ ]
+
+
@pytest.mark.asyncio
async def test_send_retries_without_thread_on_thread_not_found():
"""When message_thread_id causes 'thread not found', retry without it."""
From d5357f816d669084b9b7dc2da906100d2034212f Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:27:27 -0700
Subject: [PATCH 011/124] refactor(telegram): make typing thread-id resolver
symmetric with send
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mirror _message_thread_id_for_typing() with _message_thread_id_for_send():
both now map the General forum topic (thread id "1") to None upfront.
That removes the need for the retry-without-thread fallback in send_typing()
entirely — if _message_thread_id_for_typing() returns a non-None value, it's
a real user-created topic and falling back to the root chat is never correct.
If Telegram rejects the typing action (e.g. topic deleted mid-session), we
swallow it at debug level instead of bleeding the indicator into All Messages.
Updates the General-topic typing regression test to assert the new single-call
contract.
---
gateway/platforms/telegram.py | 31 +++++++++----------
.../gateway/test_telegram_thread_fallback.py | 12 ++++---
2 files changed, 21 insertions(+), 22 deletions(-)
diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py
index 51b2bc848a..83e8173687 100644
--- a/gateway/platforms/telegram.py
+++ b/gateway/platforms/telegram.py
@@ -353,7 +353,10 @@ class TelegramAdapter(BasePlatformAdapter):
@classmethod
def _message_thread_id_for_typing(cls, thread_id: Optional[str]) -> Optional[int]:
- if not thread_id:
+ # Mirrors _message_thread_id_for_send: the General forum topic (thread id
+ # "1") is represented as "no thread id" on the wire. User-created topics
+ # keep their real id so typing stays scoped to that topic.
+ if not thread_id or str(thread_id) == cls._GENERAL_TOPIC_THREAD_ID:
return None
return int(thread_id)
@@ -2508,22 +2511,16 @@ class TelegramAdapter(BasePlatformAdapter):
try:
_typing_thread = self._metadata_thread_id(metadata)
message_thread_id = self._message_thread_id_for_typing(_typing_thread)
- try:
- await self._bot.send_chat_action(
- chat_id=int(chat_id),
- action="typing",
- message_thread_id=message_thread_id,
- )
- except Exception as e:
- if message_thread_id is not None and self._is_thread_not_found_error(e):
- if str(_typing_thread) == self._GENERAL_TOPIC_THREAD_ID:
- await self._bot.send_chat_action(
- chat_id=int(chat_id),
- action="typing",
- message_thread_id=None,
- )
- else:
- raise
+ # No retry-without-thread fallback here: _message_thread_id_for_typing
+ # already maps the forum General topic to None, so any non-None value
+ # reaching this call is a user-created topic. If Telegram rejects it
+ # (e.g. topic deleted mid-session), we swallow the failure rather than
+ # showing a typing indicator in the wrong chat/All Messages.
+ await self._bot.send_chat_action(
+ chat_id=int(chat_id),
+ action="typing",
+ message_thread_id=message_thread_id,
+ )
except Exception as e:
# Typing failures are non-fatal; log at debug level only.
logger.debug(
diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py
index 3b7069d6fa..b8330822b3 100644
--- a/tests/gateway/test_telegram_thread_fallback.py
+++ b/tests/gateway/test_telegram_thread_fallback.py
@@ -159,22 +159,24 @@ async def test_send_omits_general_topic_thread_id():
@pytest.mark.asyncio
-async def test_send_typing_retries_without_general_thread_when_not_found():
- """Typing for forum General should fall back if Telegram rejects thread 1."""
+async def test_send_typing_general_topic_uses_none_thread_id():
+ """Typing for forum General should hit the API with message_thread_id=None directly.
+
+ _message_thread_id_for_typing() maps the General topic (thread id "1") to None
+ the same way _message_thread_id_for_send() does, so there's no retry path — the
+ first call is already correct.
+ """
adapter = _make_adapter()
call_log = []
async def mock_send_chat_action(**kwargs):
call_log.append(dict(kwargs))
- if kwargs.get("message_thread_id") == 1:
- raise FakeBadRequest("Message thread not found")
adapter._bot = SimpleNamespace(send_chat_action=mock_send_chat_action)
await adapter.send_typing("-100123", metadata={"thread_id": "1"})
assert call_log == [
- {"chat_id": -100123, "action": "typing", "message_thread_id": 1},
{"chat_id": -100123, "action": "typing", "message_thread_id": None},
]
From c28c2a2380751feda59de6838a652730fbff304f Mon Sep 17 00:00:00 2001
From: r266-tech
Date: Wed, 22 Apr 2026 12:15:31 +0800
Subject: [PATCH 012/124] docs(tts): document per-provider max_text_length caps
PR #13743 replaced the global MAX_TEXT_LENGTH=4000 with a per-provider
table and a user-override 'max_text_length:' key, but the user-guide
TTS page documented no length behaviour at all. Users hitting truncation
had no way to discover the new caps or the override.
Add an 'Input length limits' subsection after the existing Configuration
YAML block: provider default caps (Edge 5000 / OpenAI 4096 / xAI 15000 /
MiniMax 10000 / Mistral 4000 / Gemini 5000 / ElevenLabs model-aware /
NeuTTS,KittenTTS 2000), ElevenLabs model_id -> cap table (5k-40k), an
override example, and the validation rules (non-positive / non-integer /
boolean values fall through to the provider default).
---
website/docs/user-guide/features/tts.md | 37 +++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/website/docs/user-guide/features/tts.md b/website/docs/user-guide/features/tts.md
index 14d44daa89..4e38139f35 100644
--- a/website/docs/user-guide/features/tts.md
+++ b/website/docs/user-guide/features/tts.md
@@ -97,6 +97,43 @@ tts:
**Speed control**: The global `tts.speed` value applies to all providers by default. Each provider can override it with its own `speed` setting (e.g., `tts.openai.speed: 1.5`). Provider-specific speed takes precedence over the global value. Default is `1.0` (normal speed).
+
+### Input length limits
+
+Each provider has a documented per-request input-character cap. Hermes truncates text before calling the provider so requests never fail with a length error:
+
+| Provider | Default cap (chars) |
+|----------|---------------------|
+| Edge TTS | 5000 |
+| OpenAI | 4096 |
+| xAI | 15000 |
+| MiniMax | 10000 |
+| Mistral | 4000 |
+| Google Gemini | 5000 |
+| ElevenLabs | Model-aware (see below) |
+| NeuTTS | 2000 |
+| KittenTTS | 2000 |
+
+**ElevenLabs** picks a cap from the configured `model_id`:
+
+| `model_id` | Cap (chars) |
+|------------|-------------|
+| `eleven_flash_v2_5` | 40000 |
+| `eleven_flash_v2` | 30000 |
+| `eleven_multilingual_v2` (default), `eleven_multilingual_v1`, `eleven_english_sts_v2`, `eleven_english_sts_v1` | 10000 |
+| `eleven_v3`, `eleven_ttv_v3` | 5000 |
+| Unknown model | Falls back to provider default (10000) |
+
+**Override per provider** with `max_text_length:` under the provider section of your TTS config:
+
+```yaml
+tts:
+ openai:
+ max_text_length: 8192 # raise or lower the provider cap
+```
+
+Only positive integers are honored. Zero, negative, non-numeric, or boolean values fall through to the provider default, so a broken config can't accidentally disable truncation.
+
### Telegram Voice Bubbles & ffmpeg
Telegram voice bubbles require Opus/OGG audio format:
From 0dc677f0718b54ec3669206b6741ce97269e061b Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:28:49 -0700
Subject: [PATCH 013/124] docs(skill/hermes-agent): sync slash commands + add
durable-systems section
Mirrors the AGENTS.md #20226 additions (Toolsets / Delegation / Curator /
Cron / Kanban) into the user-facing hermes-agent skill, and closes the
drift in the in-session slash command list.
User report (wxrrior in Discord): the skill did not mention /goal, so a
brand-new session answering "/hermes-agent do you have any info on /goal"
confidently said it did not exist. Cross-check against the CommandDef
registry found 16 commands missing from the static list: /goal, /agents,
/busy, /copy, /curator, /debug, /footer, /gquota, /indicator, /kanban,
/redraw, /reload, /reload-skills, /snapshot, /steer, /topic.
Changes:
- Slash Commands header now tells the reader to run /help or check the
live docs reference as the source of truth, and names the registry
of record (hermes_cli/commands.py) so future drift gets flagged
honestly instead of answered confidently wrong.
- Added all 16 missing commands, slotted into existing subsections
(/goal and /steer in Session; /busy + /indicator + /footer in
Configuration; /curator + /kanban + /reload-skills + /reload in
Tools & Skills; /topic in Gateway; /copy in Utility; /gquota +
/debug in Info).
- Toolsets table updated to the authoritative 30-key list from
toolsets.py (added kanban, yuanbao, spotify, safe, debugging, video,
feishu_doc, feishu_drive, discord, discord_admin, clarify; previously
stopped at 20 keys).
- New "Durable & Background Systems" section before Troubleshooting
covers Delegation, Cron, Curator, Kanban - each with a short rundown
of CLI verbs, key invariants, and a pointer to the user-facing docs.
Mirrors AGENTS.md #20226 but in the skill's user-facing register.
- Bumped version 2.0.0 -> 2.1.0.
---
.../hermes-agent/SKILL.md | 134 +++++++++++++++++-
1 file changed, 129 insertions(+), 5 deletions(-)
diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md
index d97b39f584..f9670c9ad8 100644
--- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md
+++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md
@@ -1,7 +1,7 @@
---
name: hermes-agent
description: "Configure, extend, or contribute to Hermes Agent."
-version: 2.0.0
+version: 2.1.0
author: Hermes Agent + Teknium
license: MIT
metadata:
@@ -227,7 +227,11 @@ hermes uninstall Uninstall Hermes
## Slash Commands (In-Session)
-Type these during an interactive chat session.
+Type these during an interactive chat session. New commands land fairly
+often; if something below looks stale, run `/help` in-session for the
+authoritative list or see the [live slash commands reference](https://hermes-agent.nousresearch.com/docs/reference/slash-commands).
+The registry of record is `hermes_cli/commands.py` — every consumer
+(autocomplete, Telegram menu, Slack mapping, `/help`) derives from it.
### Session Control
```
@@ -239,9 +243,15 @@ Type these during an interactive chat session.
/compress Manually compress context
/stop Kill background processes
/rollback [N] Restore filesystem checkpoint
+/snapshot [sub] Create or restore state snapshots of Hermes config/state (CLI)
/background Run prompt in background
/queue Queue for next turn
+/steer Inject a message after the next tool call without interrupting
+/agents (/tasks) Show active agents and running tasks
/resume [name] Resume a named session
+/goal [text|sub] Set a standing goal Hermes works on across turns until achieved
+ (subcommands: status, pause, resume, clear)
+/redraw Force a full UI repaint (CLI)
```
### Configuration
@@ -253,6 +263,11 @@ Type these during an interactive chat session.
/verbose Cycle: off → new → all → verbose
/voice [on|off|tts] Voice mode
/yolo Toggle approval bypass
+/busy [sub] Control what Enter does while Hermes is working (CLI)
+ (subcommands: queue, steer, interrupt, status)
+/indicator [style] Pick the TUI busy-indicator style (CLI)
+ (styles: kaomoji, emoji, unicode, ascii)
+/footer [on|off] Toggle gateway runtime-metadata footer on final replies
/skin [name] Change theme (CLI)
/statusbar Toggle status bar (CLI)
```
@@ -263,8 +278,12 @@ Type these during an interactive chat session.
/toolsets List toolsets (CLI)
/skills Search/install skills (CLI)
/skill Load a skill into session
-/cron Manage cron jobs (CLI)
+/reload-skills Re-scan ~/.hermes/skills/ for added/removed skills
+/reload Reload .env variables into the running session (CLI)
/reload-mcp Reload MCP servers
+/cron Manage cron jobs (CLI)
+/curator [sub] Background skill maintenance (status, run, pin, archive, …)
+/kanban [sub] Multi-profile collaboration board (tasks, links, comments)
/plugins List plugins (CLI)
```
@@ -275,6 +294,7 @@ Type these during an interactive chat session.
/restart Restart gateway (gateway)
/sethome Set current chat as home channel (gateway)
/update Update Hermes to latest (gateway)
+/topic [sub] Enable or inspect Telegram DM topic sessions (gateway)
/platforms (/gateway) Show platform connection status (gateway)
```
@@ -285,6 +305,7 @@ Type these during an interactive chat session.
/browser Open CDP browser connection
/history Show conversation history (CLI)
/save Save conversation to file (CLI)
+/copy [N] Copy the last assistant response to clipboard (CLI)
/paste Attach clipboard image (CLI)
/image Attach local image file (CLI)
```
@@ -295,8 +316,10 @@ Type these during an interactive chat session.
/commands [page] Browse all commands (gateway)
/usage Token usage
/insights [days] Usage analytics
+/gquota Show Google Gemini Code Assist quota usage (CLI)
/status Session info (gateway)
/profile Active profile info
+/debug Upload debug report (system info + logs) and get shareable links
```
### Exit
@@ -378,12 +401,14 @@ Enable/disable via `hermes tools` (interactive) or `hermes tools enable/disable
| Toolset | What it provides |
|---------|-----------------|
| `web` | Web search and content extraction |
+| `search` | Web search only (subset of `web`) |
| `browser` | Browser automation (Browserbase, Camofox, or local Chromium) |
| `terminal` | Shell commands and process management |
| `file` | File read/write/search/patch |
| `code_execution` | Sandboxed Python execution |
| `vision` | Image analysis |
| `image_gen` | AI image generation |
+| `video` | Video analysis and generation |
| `tts` | Text-to-speech |
| `skills` | Skill browsing and management |
| `memory` | Persistent cross-session memory |
@@ -392,11 +417,21 @@ Enable/disable via `hermes tools` (interactive) or `hermes tools enable/disable
| `cronjob` | Scheduled task management |
| `clarify` | Ask user clarifying questions |
| `messaging` | Cross-platform message sending |
-| `search` | Web search only (subset of `web`) |
| `todo` | In-session task planning and tracking |
+| `kanban` | Multi-agent work-queue tools (gated to workers) |
+| `debugging` | Extra introspection/debug tools (off by default) |
+| `safe` | Minimal, low-risk toolset for locked-down sessions |
+| `spotify` | Spotify playback and playlist control |
+| `homeassistant` | Smart home control (off by default) |
+| `discord` | Discord integration tools |
+| `discord_admin` | Discord admin/moderation tools |
+| `feishu_doc` | Feishu (Lark) document tools |
+| `feishu_drive` | Feishu (Lark) drive tools |
+| `yuanbao` | Yuanbao integration tools |
| `rl` | Reinforcement learning tools (off by default) |
| `moa` | Mixture of Agents (off by default) |
-| `homeassistant` | Smart home control (off by default) |
+
+Full enumeration lives in `toolsets.py` as the `TOOLSETS` dict; `_HERMES_CORE_TOOLS` is the default bundle most platforms inherit from.
Tool changes take effect on `/reset` (new session). They do NOT apply mid-conversation to preserve prompt caching.
@@ -576,6 +611,95 @@ terminal(command="tmux new-session -d -s resumed 'hermes --resume 20260225_14305
---
+## Durable & Background Systems
+
+Four systems run alongside the main conversation loop. Quick reference
+here; full developer notes live in `AGENTS.md`, user-facing docs under
+`website/docs/user-guide/features/`.
+
+### Delegation (`delegate_task`)
+
+Synchronous subagent spawn — the parent waits for the child's summary
+before continuing its own loop. Isolated context + terminal session.
+
+- **Single:** `delegate_task(goal, context, toolsets)`.
+- **Batch:** `delegate_task(tasks=[{goal, ...}, ...])` runs children in
+ parallel, capped by `delegation.max_concurrent_children` (default 3).
+- **Roles:** `leaf` (default; cannot re-delegate) vs `orchestrator`
+ (can spawn its own workers, bounded by `delegation.max_spawn_depth`).
+- **Not durable.** If the parent is interrupted, the child is
+ cancelled. For work that must outlive the turn, use `cronjob` or
+ `terminal(background=True, notify_on_complete=True)`.
+
+Config: `delegation.*` in `config.yaml`.
+
+### Cron (scheduled jobs)
+
+Durable scheduler — `cron/jobs.py` + `cron/scheduler.py`. Drive it via
+the `cronjob` tool, the `hermes cron` CLI (`list`, `add`, `edit`,
+`pause`, `resume`, `run`, `remove`), or the `/cron` slash command.
+
+- **Schedules:** duration (`"30m"`, `"2h"`), "every" phrase
+ (`"every monday 9am"`), 5-field cron (`"0 9 * * *"`), or ISO timestamp.
+- **Per-job knobs:** `skills`, `model`/`provider` override, `script`
+ (pre-run data collection; `no_agent=True` makes the script the whole
+ job), `context_from` (chain job A's output into job B), `workdir`
+ (run in a specific dir with its `AGENTS.md` / `CLAUDE.md` loaded),
+ multi-platform delivery.
+- **Invariants:** 3-minute hard interrupt per run, `.tick.lock` file
+ prevents duplicate ticks across processes, cron sessions pass
+ `skip_memory=True` by default, and cron deliveries are framed with a
+ header/footer instead of being mirrored into the target gateway
+ session (keeps role alternation intact).
+
+User docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/cron
+
+### Curator (skill lifecycle)
+
+Background maintenance for agent-created skills. Tracks usage, marks
+idle skills stale, archives stale ones, keeps a pre-run tar.gz backup
+so nothing is lost.
+
+- **CLI:** `hermes curator ` — `status`, `run`, `pause`, `resume`,
+ `pin`, `unpin`, `archive`, `restore`, `prune`, `backup`, `rollback`.
+- **Slash:** `/curator ` mirrors the CLI.
+- **Scope:** only touches skills with `created_by: "agent"` provenance.
+ Bundled + hub-installed skills are off-limits. **Never deletes** —
+ max destructive action is archive. Pinned skills are exempt from
+ every auto-transition and every LLM review pass.
+- **Telemetry:** sidecar at `~/.hermes/skills/.usage.json` holds
+ per-skill `use_count`, `view_count`, `patch_count`,
+ `last_activity_at`, `state`, `pinned`.
+
+Config: `curator.*` (`enabled`, `interval_hours`, `min_idle_hours`,
+`stale_after_days`, `archive_after_days`, `backup.*`).
+User docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/curator
+
+### Kanban (multi-agent work queue)
+
+Durable SQLite board for multi-profile / multi-worker collaboration.
+Users drive it via `hermes kanban `; dispatcher-spawned workers
+see a focused `kanban_*` toolset gated by `HERMES_KANBAN_TASK` so the
+schema footprint is zero outside worker processes.
+
+- **CLI verbs (common):** `init`, `create`, `list` (alias `ls`),
+ `show`, `assign`, `link`, `unlink`, `comment`, `complete`, `block`,
+ `unblock`, `archive`, `tail`. Less common: `watch`, `stats`, `runs`,
+ `log`, `dispatch`, `daemon`, `gc`.
+- **Worker toolset:** `kanban_show`, `kanban_complete`, `kanban_block`,
+ `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`.
+- **Dispatcher** runs inside the gateway by default
+ (`kanban.dispatch_in_gateway: true`) — reclaims stale claims,
+ promotes ready tasks, atomically claims, spawns assigned profiles.
+ Auto-blocks a task after ~5 consecutive spawn failures.
+- **Isolation:** board is the hard boundary (workers get
+ `HERMES_KANBAN_BOARD` pinned in env); tenant is a soft namespace
+ within a board for workspace-path + memory-key isolation.
+
+User docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban
+
+---
+
## Troubleshooting
### Voice not working
From ee502e5640ab482e0531617e867f0ce8419817e0 Mon Sep 17 00:00:00 2001
From: r266-tech
Date: Mon, 20 Apr 2026 00:14:56 +0800
Subject: [PATCH 014/124] docs(cli): add --deliver-only flag to hermes webhook
subscribe
PR #12473 (merged 2026-04-19) added a new --deliver-only flag to
`hermes webhook subscribe` for zero-LLM direct delivery, but
website/docs/reference/cli-commands.md options table did not
reference it. Add the row so CLI users can discover the flag from
the reference page instead of having to read the source.
---
website/docs/reference/cli-commands.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md
index a36fe9819c..890271eb8a 100644
--- a/website/docs/reference/cli-commands.md
+++ b/website/docs/reference/cli-commands.md
@@ -431,6 +431,7 @@ hermes webhook subscribe [options]
| `--deliver` | Delivery target: `log` (default), `telegram`, `discord`, `slack`, `github_comment`. |
| `--deliver-chat-id` | Target chat/channel ID for cross-platform delivery. |
| `--secret` | Custom HMAC secret. Auto-generated if omitted. |
+| `--deliver-only` | Skip the agent — deliver the rendered `--prompt` as the literal message. Zero LLM cost, sub-second delivery. Requires `--deliver` to be a real target (not `log`). |
Subscriptions persist to `~/.hermes/webhook_subscriptions.json` and are hot-reloaded by the webhook adapter without a gateway restart.
From 00d25595c1c7656ba8055b375fba278e9bef7f8f Mon Sep 17 00:00:00 2001
From: Brooklyn Nicholson
Date: Tue, 5 May 2026 15:30:27 -0500
Subject: [PATCH 015/124] perf(ui-tui): narrow overlay subscriptions to focused
selectors
Subscribe overlay components to computed theme/session selectors instead of the full UI store so unrelated UI state updates trigger fewer overlay renders.
---
ui-tui/src/app/uiStore.ts | 5 ++-
ui-tui/src/components/appOverlays.tsx | 45 ++++++++++++++-------------
2 files changed, 27 insertions(+), 23 deletions(-)
diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts
index b3d5a942c7..ea592700b7 100644
--- a/ui-tui/src/app/uiStore.ts
+++ b/ui-tui/src/app/uiStore.ts
@@ -1,4 +1,4 @@
-import { atom } from 'nanostores'
+import { atom, computed } from 'nanostores'
import { MOUSE_TRACKING } from '../config/env.js'
import { ZERO } from '../domain/usage.js'
@@ -30,6 +30,9 @@ const buildUiState = (): UiState => ({
export const $uiState = atom(buildUiState())
+export const $uiTheme = computed($uiState, state => state.theme)
+export const $uiSessionId = computed($uiState, state => state.sid)
+
export const getUiState = () => $uiState.get()
export const patchUiState = (next: Partial | ((state: UiState) => UiState)) =>
diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx
index 1e33559f0a..e4a80ba816 100644
--- a/ui-tui/src/components/appOverlays.tsx
+++ b/ui-tui/src/components/appOverlays.tsx
@@ -4,7 +4,7 @@ import { useStore } from '@nanostores/react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppOverlaysProps } from '../app/interfaces.js'
import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
-import { $uiState } from '../app/uiStore.js'
+import { $uiSessionId, $uiTheme } from '../app/uiStore.js'
import { FloatBox } from './appChrome.js'
import { MaskedPrompt } from './maskedPrompt.js'
@@ -24,12 +24,12 @@ export function PromptZone({
onSudoSubmit
}: Pick) {
const overlay = useStore($overlayState)
- const ui = useStore($uiState)
+ const theme = useStore($uiTheme)
if (overlay.approval) {
return (
-
+
)
}
@@ -46,7 +46,7 @@ export function PromptZone({
return (
-
+
)
}
@@ -59,7 +59,7 @@ export function PromptZone({
onAnswer={onClarifyAnswer}
onCancel={() => onClarifyAnswer('')}
req={overlay.clarify}
- t={ui.theme}
+ t={theme}
/>
)
@@ -68,7 +68,7 @@ export function PromptZone({
if (overlay.sudo) {
return (
-
+
)
}
@@ -82,7 +82,7 @@ export function PromptZone({
label={overlay.secret.prompt}
onSubmit={onSecretSubmit}
sub={`for ${overlay.secret.envVar}`}
- t={ui.theme}
+ t={theme}
/>
)
@@ -101,7 +101,8 @@ export function FloatingOverlays({
}: Pick) {
const { gw } = useGateway()
const overlay = useStore($overlayState)
- const ui = useStore($uiState)
+ const sid = useStore($uiSessionId)
+ const theme = useStore($uiTheme)
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length
@@ -119,40 +120,40 @@ export function FloatingOverlays({
return (
{overlay.picker && (
-
+
patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
- t={ui.theme}
+ t={theme}
/>
)}
{overlay.modelPicker && (
-
+
patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
- sessionId={ui.sid}
- t={ui.theme}
+ sessionId={sid}
+ t={theme}
/>
)}
{overlay.skillsHub && (
-
- patchOverlayState({ skillsHub: false })} t={ui.theme} />
+
+ patchOverlayState({ skillsHub: false })} t={theme} />
)}
{overlay.pager && (
-
+
{overlay.pager.title && (
-
+
{overlay.pager.title}
@@ -163,7 +164,7 @@ export function FloatingOverlays({
))}
-
+
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
@@ -174,23 +175,23 @@ export function FloatingOverlays({
)}
{!!completions.length && (
-
+
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
return (
-
+
{' '}
{item.display}
- {item.meta ? {item.meta} : null}
+ {item.meta ? {item.meta} : null}
)
})}
From ec7f2f249edb484c3a081ef2451bf40fc4e45abc Mon Sep 17 00:00:00 2001
From: r266-tech
Date: Fri, 17 Apr 2026 18:15:06 +0800
Subject: [PATCH 016/124] docs(cli): add skills reset subcommand to CLI
reference
PR #11468 added `hermes skills reset` but cli-commands.md was not
updated. Adds the subcommand to the table and usage examples.
Closes #11543
---
website/docs/reference/cli-commands.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md
index 890271eb8a..927135721e 100644
--- a/website/docs/reference/cli-commands.md
+++ b/website/docs/reference/cli-commands.md
@@ -709,6 +709,7 @@ Subcommands:
| `update` | Reinstall hub skills with upstream changes when available. |
| `audit` | Re-scan installed hub skills. |
| `uninstall` | Remove a hub-installed skill. |
+| `reset` | Un-stick a bundled skill flagged as `user_modified` by clearing its manifest entry. With `--restore`, also replaces the user copy with the bundled version. |
| `publish` | Publish a skill to a registry. |
| `snapshot` | Export/import skill configurations. |
| `tap` | Manage custom skill sources. |
@@ -730,6 +731,8 @@ hermes skills install https://example.com/SKILL.md --name my-skill # Over
hermes skills check
hermes skills update
hermes skills config
+hermes skills reset google-workspace
+hermes skills reset google-workspace --restore --yes
```
Notes:
From f67063ba81f9d7de2e42003dd086633d28448ae8 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:32:42 -0700
Subject: [PATCH 017/124] feat(kanban): generic diagnostics engine for task
distress signals (#20332)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(kanban): generic diagnostics engine for task distress signals
Replaces the hallucination-specific ``warnings`` / ``RecoverySection``
surface (shipped in PR #20232) with a reusable diagnostic-rule engine
that covers five distress kinds in v1 and can be extended without
touching UI code. The "something's wrong with this task" signal is
no longer limited to phantom card ids.
Closes the follow-up from #20232 discussion.
New module
----------
``hermes_cli/kanban_diagnostics.py`` — stateless, no-side-effect rule
engine. Each rule is a pure function of
``(task, events, runs, now, config) -> list[Diagnostic]``. Registry
is a simple list; adding a new distress kind is one function + one
import, no UI or API changes required.
v1 rule set
-----------
* ``hallucinated_cards`` (error) — folds the existing
``completion_blocked_hallucination`` event into the new surface.
* ``prose_phantom_refs`` (warning) — folds
``suspected_hallucinated_references``.
* ``repeated_spawn_failures`` (error → critical at 2x threshold) —
fires when ``tasks.spawn_failures >= 3``; suggests
``hermes -p doctor`` / ``auth``.
* ``repeated_crashes`` (error → critical) — fires after N consecutive
``crashed`` run outcomes with no successful completion between;
suggests ``hermes kanban log ``.
* ``stuck_in_blocked`` (warning) — fires after 24h in ``blocked``
state with no comments / unblock attempts; suggests commenting.
Every diagnostic carries structured ``actions`` (reclaim, reassign,
unblock, cli_hint, comment, open_docs) that render consistently in
both CLI and dashboard. Suggested actions are highlighted; generic
recovery actions (reclaim / reassign) are available on every kind as
fallbacks.
Diagnostics auto-clear when the underlying failure resolves — a
clean ``completed``/``edited`` event drops hallucination diagnostics,
a successful run drops crash diagnostics, a comment drops
stuck-blocked diagnostics. Audit events persist; the badge goes away.
API
---
``plugin_api.py``:
* ``/board`` now attaches ``diagnostics`` (full list) and
``warnings`` (compact summary with ``highest_severity``) per task.
* ``/tasks/{id}`` attaches diagnostics so the drawer's Diagnostics
section auto-opens on flagged tasks.
* NEW ``/diagnostics`` endpoint — fleet-wide listing, filterable by
severity, sorted critical-first.
CLI
---
* NEW ``hermes kanban diagnostics [--severity X] [--task id]
[--json]`` — fleet view or single-task view, matches dashboard rule
output so CLI users see the same picture.
* ``hermes kanban show `` now renders a Diagnostics section near
the top with severity markers + suggested actions.
Dashboard
---------
* Card badge is severity-coloured (⚠ amber warning, !! orange error,
!!! red critical) using ``warnings.highest_severity``.
* Attention strip above the toolbar counts EVERY task with active
diagnostics (not just hallucinations), severity-coloured, lists
affected tasks with Open buttons when expanded.
* Drawer's old ``RecoverySection`` replaced with generic
``DiagnosticsSection`` rendering a card per active diagnostic:
title + detail + structured data (task-id chips when payload keys
look like id lists) + action buttons. Reassign profile picker is
inline per-diagnostic. Clipboard fallback uses ``.catch()`` for
environments where writeText rejects.
* Three-rung severity palette; amber for warning, orange for error,
red for critical. Uses CSS variables so theming is straightforward.
Tests
-----
* NEW ``tests/hermes_cli/test_kanban_diagnostics.py`` — 14 unit tests
covering each rule's positive/negative/threshold paths, severity
sorting, broken-rule isolation, and sqlite3.Row integration.
* Dashboard plugin tests extended: ``/diagnostics`` endpoint (empty,
populated, severity-filtered), ``/board`` exposes both diagnostic
list and compact summary with ``highest_severity``.
* Existing hallucination-specific test (``test_board_surfaces_
warnings_field_for_hallucinated_completions``) updated to reflect
the new contract: warning summary keys by diagnostic kind
(``hallucinated_cards``) not event kind.
379 kanban-suite tests pass (+16 net from this PR).
Live verification
-----------------
Seeded all 5 diagnostic kinds + one clean + one plain-running task
(7 total) into an isolated HERMES_HOME, spun up the dashboard, and
verified:
* Attention strip: shows ``!! 5 tasks need attention`` in the
error-severity orange; Show expands to a list of 5 rows ordered
critical > error > warning.
* Card badges: error tasks render ``!!`` orange, warning tasks
render ``⚠`` amber, clean and plain-running tasks render no badge.
* Each of the 5 rules opens a correctly-coloured, correctly-styled
diagnostic card in the drawer with its specific suggested action.
* Live reassign from a diagnostic card flipped
``broken-ml-worker → alice`` and the drawer refreshed with the
new assignee + the same diagnostic still firing (correct:
spawn_failures counter hasn't reset yet).
* CLI ``hermes kanban diagnostics`` prints all 5 in severity order;
``--severity error`` narrows to 3; ``kanban show `` includes
the Diagnostics block at the top with suggested action hint.
Migration note
--------------
The old ``warnings`` shape (``{count, kinds, latest_at}``) is
preserved on the API but ``kinds`` now keys by diagnostic kind
(``hallucinated_cards``) instead of event kind
(``completion_blocked_hallucination``). ``highest_severity`` is a
new required field. The dashboard was the only consumer and has
been updated in the same commit; external API consumers of the
``warnings`` field will need to update their kind-match logic.
* feat(kanban/diagnostics): lead titles with the actual error text
The generic 'Worker crashed N runs in a row' / 'Worker failed to spawn
N times' titles buried the actual cause in the data section. Operators
had to open logs or expand the diagnostic to see WHY the worker is
stuck — rate-limit vs insufficient quota vs bad auth vs context
overflow vs network blip all looked identical at a glance.
New titles:
Agent crashed 3x: openai: 429 Too Many Requests - rate limit reached
Agent crashed 3x: anthropic: 402 insufficient_quota - credit balance
Agent crashed 3x: provider auth error: 401 Unauthorized
Agent spawn failed 4x: insufficient_quota: You exceeded your current
Detail keeps the full error snippet (capped at 500 chars + ellipsis
for tracebacks). Title takes the first line capped at 160 chars.
Fallback title if no error recorded stays honest ('no error recorded').
Tests: 4 new cases covering 429/billing/spawn/truncation. 383 total
pass (+4).
Live-verified on dashboard with 6 seeded scenarios
(rate-limit, billing, auth, context, network, spawn-billing) —
each card title leads with the actionable error text.
---
hermes_cli/kanban.py | 171 ++++++
hermes_cli/kanban_diagnostics.py | 570 ++++++++++++++++++
plugins/kanban/dashboard/dist/index.js | 565 ++++++++++-------
plugins/kanban/dashboard/dist/style.css | 170 ++++++
plugins/kanban/dashboard/plugin_api.py | 246 ++++++--
tests/hermes_cli/test_kanban_diagnostics.py | 353 +++++++++++
tests/plugins/test_kanban_dashboard_plugin.py | 109 +++-
7 files changed, 1895 insertions(+), 289 deletions(-)
create mode 100644 hermes_cli/kanban_diagnostics.py
create mode 100644 tests/hermes_cli/test_kanban_diagnostics.py
diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py
index f166582d82..9f293c555e 100644
--- a/hermes_cli/kanban.py
+++ b/hermes_cli/kanban.py
@@ -337,6 +337,28 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
help="Human-readable reason (recorded on the reclaimed event)",
)
+ # --- diagnostics (board-wide health) ---
+ p_diag = sub.add_parser(
+ "diagnostics",
+ aliases=["diag"],
+ help="List active diagnostics on the current board",
+ )
+ p_diag.add_argument(
+ "--severity",
+ choices=["warning", "error", "critical"],
+ default=None,
+ help="Only show diagnostics at or above this severity",
+ )
+ p_diag.add_argument(
+ "--task",
+ default=None,
+ help="Only show diagnostics for one task id",
+ )
+ p_diag.add_argument(
+ "--json", action="store_true",
+ help="Emit JSON (structured) instead of the default human table",
+ )
+
# --- link / unlink ---
p_link = sub.add_parser("link", help="Add a parent->child dependency")
p_link.add_argument("parent_id")
@@ -628,6 +650,8 @@ def kanban_command(args: argparse.Namespace) -> int:
"assign": _cmd_assign,
"reclaim": _cmd_reclaim,
"reassign": _cmd_reassign,
+ "diagnostics": _cmd_diagnostics,
+ "diag": _cmd_diagnostics,
"link": _cmd_link,
"unlink": _cmd_unlink,
"claim": _cmd_claim,
@@ -1091,6 +1115,31 @@ def _cmd_show(args: argparse.Namespace) -> int:
if task.skills:
print(f" skills: {', '.join(task.skills)}")
print(f" created: {_fmt_ts(task.created_at)} by {task.created_by or '-'}")
+
+ # Diagnostics section — surface active distress signals at the top
+ # of show output so CLI users see them before scrolling through
+ # comments / runs.
+ from hermes_cli import kanban_diagnostics as kd
+ diags = kd.compute_task_diagnostics(task, events, runs)
+ if diags:
+ sev_marker = {"warning": "⚠", "error": "!!", "critical": "!!!"}
+ print(f"\n Diagnostics ({len(diags)}):")
+ for d in diags:
+ print(f" {sev_marker.get(d.severity, '?')} [{d.severity}] {d.title}")
+ if d.data:
+ bits = []
+ for k, v in d.data.items():
+ if isinstance(v, list):
+ bits.append(f"{k}={','.join(str(x) for x in v)}")
+ else:
+ bits.append(f"{k}={v}")
+ if bits:
+ print(f" data: {' | '.join(bits)}")
+ # Only show suggested actions in show output to keep it tight;
+ # full list is available via `kanban diagnostics --task `.
+ for a in d.actions:
+ if a.suggested:
+ print(f" → {a.label}")
if task.started_at:
print(f" started: {_fmt_ts(task.started_at)}")
if task.completed_at:
@@ -1187,6 +1236,128 @@ def _cmd_reassign(args: argparse.Namespace) -> int:
return 0
+def _cmd_diagnostics(args: argparse.Namespace) -> int:
+ """List active diagnostics on the board. Wraps the same rule engine
+ the dashboard uses, so CLI output matches what the UI shows.
+ """
+ from hermes_cli import kanban_diagnostics as kd
+
+ with kb.connect() as conn:
+ # Either one-task mode or fleet mode.
+ if getattr(args, "task", None):
+ task = kb.get_task(conn, args.task)
+ if task is None:
+ print(f"no such task: {args.task}", file=sys.stderr)
+ return 1
+ diags_by_task = {
+ args.task: kd.compute_task_diagnostics(
+ task,
+ kb.list_events(conn, args.task),
+ kb.list_runs(conn, args.task),
+ )
+ }
+ else:
+ # Fleet mode: pull all non-archived tasks + their events/runs.
+ rows = list(conn.execute(
+ "SELECT * FROM tasks WHERE status != 'archived'"
+ ).fetchall())
+ ids = [r["id"] for r in rows]
+ if not ids:
+ diags_by_task = {}
+ else:
+ placeholders = ",".join(["?"] * len(ids))
+ ev_by = {i: [] for i in ids}
+ for row in conn.execute(
+ f"SELECT * FROM task_events WHERE task_id IN ({placeholders}) ORDER BY id",
+ tuple(ids),
+ ):
+ ev_by.setdefault(row["task_id"], []).append(row)
+ run_by = {i: [] for i in ids}
+ for row in conn.execute(
+ f"SELECT * FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY id",
+ tuple(ids),
+ ):
+ run_by.setdefault(row["task_id"], []).append(row)
+ diags_by_task = {}
+ for r in rows:
+ tid = r["id"]
+ dl = kd.compute_task_diagnostics(r, ev_by.get(tid, []), run_by.get(tid, []))
+ if dl:
+ diags_by_task[tid] = dl
+
+ # Severity filter.
+ sev = getattr(args, "severity", None)
+ if sev:
+ for tid in list(diags_by_task.keys()):
+ kept = [d for d in diags_by_task[tid] if d.severity == sev]
+ if kept:
+ diags_by_task[tid] = kept
+ else:
+ del diags_by_task[tid]
+
+ # Map task_id → title/status/assignee for the table output.
+ meta: dict[str, dict] = {}
+ if diags_by_task:
+ placeholders = ",".join(["?"] * len(diags_by_task))
+ for r in conn.execute(
+ f"SELECT id, title, status, assignee FROM tasks WHERE id IN ({placeholders})",
+ tuple(diags_by_task.keys()),
+ ):
+ meta[r["id"]] = {
+ "title": r["title"], "status": r["status"],
+ "assignee": r["assignee"],
+ }
+
+ if getattr(args, "json", False):
+ out_json = [
+ {
+ "task_id": tid,
+ **meta.get(tid, {}),
+ "diagnostics": [d.to_dict() for d in dl],
+ }
+ for tid, dl in diags_by_task.items()
+ ]
+ print(json.dumps(out_json, indent=2, ensure_ascii=False))
+ return 0
+
+ if not diags_by_task:
+ print("No active diagnostics on this board.")
+ return 0
+
+ # Human-readable summary: grouped by task, severity-marked, with
+ # suggested actions inline.
+ sev_marker = {"warning": "⚠", "error": "!!", "critical": "!!!"}
+ total = sum(len(dl) for dl in diags_by_task.values())
+ print(
+ f"{total} active diagnostic(s) across "
+ f"{len(diags_by_task)} task(s):\n"
+ )
+ for tid, dl in diags_by_task.items():
+ m = meta.get(tid, {})
+ title = m.get("title") or "(untitled)"
+ status = m.get("status") or "?"
+ assignee = m.get("assignee") or "(unassigned)"
+ print(f" {tid} {status:8s} @{assignee:18s} {title}")
+ for d in dl:
+ print(f" {sev_marker.get(d.severity, '?')} [{d.severity}] {d.kind}: {d.title}")
+ if d.data:
+ # Compact key:value pairs on one line.
+ bits = []
+ for k, v in d.data.items():
+ if isinstance(v, list):
+ bits.append(f"{k}={','.join(str(x) for x in v)}")
+ else:
+ bits.append(f"{k}={v}")
+ if bits:
+ print(f" data: {' | '.join(bits)}")
+ # Suggested actions first.
+ for a in d.actions:
+ if a.suggested:
+ print(f" → {a.label}")
+ print()
+ return 0
+
+
def _cmd_link(args: argparse.Namespace) -> int:
with kb.connect() as conn:
kb.link_tasks(conn, args.parent_id, args.child_id)
diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py
new file mode 100644
index 0000000000..5a08ee6df5
--- /dev/null
+++ b/hermes_cli/kanban_diagnostics.py
@@ -0,0 +1,570 @@
+"""Kanban diagnostics — structured, actionable distress signals for tasks.
+
+A ``Diagnostic`` is a machine-readable description of something that's wrong
+with a kanban task: a hallucinated card id, a spawn crash-loop, a task
+stuck blocked for too long, etc. Each one carries:
+
+* A **kind** (canonical code; UI/tests match on this).
+* A **severity** (``warning`` / ``error`` / ``critical``).
+* A **title** (one-line human description) and **detail** (longer text).
+* A list of **suggested actions** — structured entries the dashboard
+ turns into buttons and the CLI turns into hints.
+
+Rules run over (task, recent events, recent runs) and emit diagnostics.
+They are stateless and read-only — no DB writes. Callers compute
+diagnostics on demand (on ``/board`` load, ``/tasks/:id`` fetch, or
+``hermes kanban diagnostics``).
+
+Design goals:
+
+* Fixable-on-the-operator's-side signals only (missing config, phantom
+ ids, crash loop). Not "the provider returned 502 once" — that's a
+ transient runtime blip, not a diagnostic.
+* Recoverable: every diagnostic comes with at least one suggested
+ recovery action the operator can actually take from the UI.
+* Auto-clearing: when the underlying failure mode resolves (a clean
+ ``completed`` event arrives, a spawn succeeds, the task gets
+ unblocked), the diagnostic stops firing. The audit event trail stays.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Callable, Iterable, Optional
+import json
+import time
+
+
+# Severity rungs, ordered least → most urgent. The UI colors them
+# amber (warning), orange (error), red (critical). Sorted outputs put
+# critical first so operators see the worst fires at the top.
+SEVERITY_ORDER = ("warning", "error", "critical")
+
+
+@dataclass
+class DiagnosticAction:
+ """A single recovery action attached to a diagnostic.
+
+ The ``kind`` determines how both the UI and CLI render it:
+
+ * ``reclaim`` / ``reassign`` — POST to the matching /tasks/:id/*
+ endpoint; dashboard wires into the existing recovery popover.
+ * ``unblock`` — PATCH status back to ``ready`` (for stuck-blocked
+ diagnostics).
+ * ``cli_hint`` — print/copy a shell command (e.g.
+ ``hermes -p auth``). No HTTP side effect.
+ * ``open_docs`` — deep-link to the docs URL named in ``payload.url``.
+ * ``comment`` — nudge the operator to add a comment (for
+ stuck-blocked tasks that need human input).
+
+ ``suggested=True`` marks the action as the recommended first step;
+ the UI highlights it. Multiple actions can be suggested if they're
+ equally valid.
+ """
+
+ kind: str
+ label: str
+ payload: dict = field(default_factory=dict)
+ suggested: bool = False
+
+ def to_dict(self) -> dict:
+ return {
+ "kind": self.kind,
+ "label": self.label,
+ "payload": self.payload,
+ "suggested": self.suggested,
+ }
+
+
+@dataclass
+class Diagnostic:
+ """One active distress signal on a task."""
+
+ kind: str
+ severity: str # "warning" | "error" | "critical"
+ title: str
+ detail: str
+ actions: list[DiagnosticAction] = field(default_factory=list)
+ first_seen_at: int = 0
+ last_seen_at: int = 0
+ count: int = 1
+ # Optional: the run id this diagnostic is scoped to. None = task-wide.
+ run_id: Optional[int] = None
+ # Optional structured payload for the UI (phantom ids, failure count).
+ data: dict = field(default_factory=dict)
+
+ def to_dict(self) -> dict:
+ return {
+ "kind": self.kind,
+ "severity": self.severity,
+ "title": self.title,
+ "detail": self.detail,
+ "actions": [a.to_dict() for a in self.actions],
+ "first_seen_at": self.first_seen_at,
+ "last_seen_at": self.last_seen_at,
+ "count": self.count,
+ "run_id": self.run_id,
+ "data": self.data,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Rule helpers
+# ---------------------------------------------------------------------------
+
+def _task_field(task, name, default=None):
+ """Read a field from a task regardless of representation.
+
+ Callers pass sqlite3.Row (dict-like with [] but no attribute
+ access), kanban_db.Task dataclasses (attribute access), or plain
+ dicts (both). This normalises them so rule functions don't have
+ to branch on type each time.
+ """
+ if task is None:
+ return default
+ # sqlite Row + plain dicts both support mapping access; Row also
+ # supports .keys().
+ try:
+ # Row raises IndexError if the key isn't a column in the query;
+ # dicts return default via .get. Handle both.
+ if hasattr(task, "keys") and name in task.keys():
+ return task[name]
+ except Exception:
+ pass
+ if isinstance(task, dict):
+ return task.get(name, default)
+ return getattr(task, name, default)
+
+
+def _parse_payload(ev) -> dict:
+ """Tolerate event.payload being either a dict or a JSON string."""
+ p = _task_field(ev, "payload", None)
+ if p is None:
+ return {}
+ if isinstance(p, dict):
+ return p
+ if isinstance(p, str):
+ try:
+ return json.loads(p) or {}
+ except Exception:
+ return {}
+ return {}
+
+
+def _event_kind(ev) -> str:
+ return _task_field(ev, "kind", "") or ""
+
+
+def _event_ts(ev) -> int:
+ t = _task_field(ev, "created_at", 0)
+ return int(t or 0)
+
+
+def _active_hallucination_events(
+ events: Iterable[Any],
+ kind: str,
+) -> list[Any]:
+ """Return events of ``kind`` that have no ``completed``/``edited``
+ event *strictly after* them. Walks chronologically: each clean
+ event resets the accumulator; each matching event gets appended.
+
+ Events must be sorted by id (i.e. arrival order); callers pass the
+ task's full event list which the DB already returns in that order.
+ """
+ # Events arrive sorted by id asc (chronological). Walk once, track
+ # which hallucination events are still "active" (no clean event
+ # supersedes them).
+ active: list[Any] = []
+ for ev in events:
+ k = _event_kind(ev)
+ if k in ("completed", "edited"):
+ active.clear()
+ elif k == kind:
+ active.append(ev)
+ return active
+
+
+def _latest_clean_event_ts(events: Iterable[Any]) -> int:
+ """Timestamp of the most recent clean completion / edit event.
+
+ Kept for general "has this task ever been successfully completed"
+ lookups; hallucination rules use ``_active_hallucination_events``
+ instead because they need strict ordering.
+ """
+ latest = 0
+ for ev in events:
+ if _event_kind(ev) in ("completed", "edited"):
+ t = _event_ts(ev)
+ if t > latest:
+ latest = t
+ return latest
+
+
+# Standard always-available actions. Every diagnostic can offer these as
+# fallbacks regardless of kind — they're the two baseline recovery
+# primitives the kernel supports.
+def _generic_recovery_actions(task: Any, *, running: bool) -> list[DiagnosticAction]:
+ out: list[DiagnosticAction] = []
+ if running:
+ out.append(DiagnosticAction(
+ kind="reclaim",
+ label="Reclaim task",
+ payload={},
+ ))
+ out.append(DiagnosticAction(
+ kind="reassign",
+ label="Reassign to different profile",
+ payload={"reclaim_first": running},
+ ))
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Rule implementations
+# ---------------------------------------------------------------------------
+
+# Each rule takes (task, events, runs, now_ts, config) and returns
+# zero or more Diagnostic instances. ``events`` / ``runs`` are lists of
+# kanban_db.Event / kanban_db.Run (or plain dicts matching the same
+# shape — for test convenience).
+
+RuleFn = Callable[[Any, list[Any], list[Any], int, dict], list[Diagnostic]]
+
+
+def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]:
+ """Blocked-hallucination gate fires: a worker called kanban_complete
+ with created_cards that didn't exist or weren't created by the
+ completing profile. Task stayed in its prior state; the operator
+ needs to decide how to proceed.
+
+ Auto-clears when a successful completion (or edit) follows the
+ blocked event.
+ """
+ hits = _active_hallucination_events(events, "completion_blocked_hallucination")
+ if not hits:
+ return []
+ phantom_ids: list[str] = []
+ first = _event_ts(hits[0])
+ last = _event_ts(hits[-1])
+ for ev in hits:
+ payload = _parse_payload(ev)
+ for pid in payload.get("phantom_cards", []) or []:
+ if pid not in phantom_ids:
+ phantom_ids.append(pid)
+ running = _task_field(task, "status") == "running"
+ actions: list[DiagnosticAction] = []
+ actions.append(DiagnosticAction(
+ kind="comment",
+ label="Add a comment explaining what to do",
+ suggested=False,
+ ))
+ actions.extend(_generic_recovery_actions(task, running=running))
+ return [Diagnostic(
+ kind="hallucinated_cards",
+ severity="error",
+ title="Worker claimed cards that don't exist",
+ detail=(
+ f"The completing worker declared created_cards that either didn't "
+ f"exist or weren't created by its profile. The completion was "
+ f"blocked and the task stayed in its prior state. "
+ f"Usually means the worker hallucinated ids instead of capturing "
+ f"return values from kanban_create."
+ ),
+ actions=actions,
+ first_seen_at=first,
+ last_seen_at=last,
+ count=len(hits),
+ data={"phantom_ids": phantom_ids},
+ )]
+
+
+def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]:
+ """Advisory prose-scan: the completion summary mentions ``t_``
+ ids that don't resolve. Non-blocking; surfaced as a warning only.
+
+ Auto-clears when a fresh clean completion arrives AFTER the
+ suspected event.
+ """
+ hits = _active_hallucination_events(events, "suspected_hallucinated_references")
+ if not hits:
+ return []
+ phantom_refs: list[str] = []
+ for ev in hits:
+ for pid in _parse_payload(ev).get("phantom_refs", []) or []:
+ if pid not in phantom_refs:
+ phantom_refs.append(pid)
+ running = _task_field(task, "status") == "running"
+ return [Diagnostic(
+ kind="prose_phantom_refs",
+ severity="warning",
+ title="Completion summary references unknown task ids",
+ detail=(
+ "The completion summary mentions task ids that don't resolve "
+ "in this board's database. The completion itself succeeded, "
+ "but downstream consumers parsing the summary may be pointed "
+ "at cards that never existed."
+ ),
+ actions=_generic_recovery_actions(task, running=running),
+ first_seen_at=_event_ts(hits[0]),
+ last_seen_at=_event_ts(hits[-1]),
+ count=len(hits),
+ data={"phantom_refs": phantom_refs},
+ )]
+
+
+def _rule_repeated_spawn_failures(task, events, runs, now, cfg) -> list[Diagnostic]:
+ """Task's ``spawn_failures`` counter is climbing — worker can't
+ even start. Usually a profile misconfiguration (missing config.yaml,
+ bad PATH/venv, wrong credentials).
+
+ Threshold: cfg["spawn_failure_threshold"] (default 3).
+ """
+ threshold = int(cfg.get("spawn_failure_threshold", 3))
+ failures = _task_field(task, "spawn_failures", 0)
+ if failures is None or failures < threshold:
+ return []
+ last_err = _task_field(task, "last_spawn_error")
+ assignee = _task_field(task, "assignee")
+ actions: list[DiagnosticAction] = []
+ if assignee and assignee != "default":
+ actions.append(DiagnosticAction(
+ kind="cli_hint",
+ label=f"Verify profile: hermes -p {assignee} doctor",
+ payload={"command": f"hermes -p {assignee} doctor"},
+ suggested=True,
+ ))
+ actions.append(DiagnosticAction(
+ kind="cli_hint",
+ label=f"Fix profile auth: hermes -p {assignee} auth",
+ payload={"command": f"hermes -p {assignee} auth"},
+ ))
+ actions.extend(_generic_recovery_actions(task, running=False))
+ severity = "critical" if failures >= threshold * 2 else "error"
+ err_text = (last_err or "").strip() if last_err else ""
+ err_snippet = err_text[:500] + ("…" if len(err_text) > 500 else "") if err_text else ""
+ if err_snippet:
+ title = f"Agent spawn failed {failures}x: {err_snippet.splitlines()[0][:160]}"
+ detail = (
+ f"The dispatcher tried to launch a worker {failures} times "
+ f"and failed every time. Full last error:\n\n{err_snippet}\n\n"
+ f"Common causes: missing config.yaml, bad venv/PATH, or "
+ f"missing credentials for the profile's configured provider."
+ )
+ else:
+ title = f"Agent spawn failed {failures}x (no error recorded)"
+ detail = (
+ f"The dispatcher tried to launch a worker {failures} times "
+ f"and failed every time, but no error text was captured. "
+ f"Usually a profile configuration issue — check profile "
+ f"health with the suggested command."
+ )
+ return [Diagnostic(
+ kind="repeated_spawn_failures",
+ severity=severity,
+ title=title,
+ detail=detail,
+ actions=actions,
+ first_seen_at=now,
+ last_seen_at=now,
+ count=failures,
+ data={"spawn_failures": failures, "last_spawn_error": last_err},
+ )]
+
+
+def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]:
+ """The worker spawns fine but keeps crashing mid-run. Check the last
+ N runs' outcomes; N consecutive ``crashed`` without a successful
+ ``completed`` means something about the task + profile combo is
+ broken (OOM, missing dependency, tool it needs is down).
+
+ Threshold: cfg["crash_threshold"] (default 2).
+ """
+ threshold = int(cfg.get("crash_threshold", 2))
+ ordered = sorted(runs, key=lambda r: _task_field(r, "id", 0))
+ # Count trailing consecutive 'crashed' outcomes.
+ consecutive = 0
+ last_err = None
+ for r in reversed(ordered):
+ outcome = _task_field(r, "outcome")
+ if outcome == "crashed":
+ consecutive += 1
+ if last_err is None:
+ last_err = _task_field(r, "error")
+ elif outcome in ("completed", "reclaimed"):
+ # A success (or manual reclaim) breaks the streak.
+ break
+ else:
+ # Other outcomes (timed_out, blocked, spawn_failed, gave_up)
+ # aren't crash signals — don't count them, but they also
+ # don't break the crash streak.
+ continue
+ if consecutive < threshold:
+ return []
+ task_id = _task_field(task, "id")
+ actions: list[DiagnosticAction] = []
+ if task_id:
+ actions.append(DiagnosticAction(
+ kind="cli_hint",
+ label=f"Check logs: hermes kanban log {task_id}",
+ payload={"command": f"hermes kanban log {task_id}"},
+ suggested=True,
+ ))
+ running = _task_field(task, "status") == "running"
+ actions.extend(_generic_recovery_actions(task, running=running))
+ severity = "critical" if consecutive >= threshold * 2 else "error"
+ # Put the actual error up-front so operators see WHAT broke without
+ # having to open the logs. Truncate defensively — these can be huge
+ # (full tracebacks).
+ err_text = (last_err or "").strip() if last_err else ""
+ err_snippet = err_text[:500] + ("…" if len(err_text) > 500 else "") if err_text else ""
+ if err_snippet:
+ title = f"Agent crashed {consecutive}x: {err_snippet.splitlines()[0][:160]}"
+ detail = (
+ f"The last {consecutive} runs ended with outcome=crashed. "
+ f"Full last error:\n\n{err_snippet}"
+ )
+ else:
+ title = f"Agent crashed {consecutive}x (no error recorded)"
+ detail = (
+ f"The last {consecutive} runs ended with outcome=crashed but "
+ f"no error text was captured. Check the worker log for more."
+ )
+ return [Diagnostic(
+ kind="repeated_crashes",
+ severity=severity,
+ title=title,
+ detail=detail,
+ actions=actions,
+ first_seen_at=now,
+ last_seen_at=now,
+ count=consecutive,
+ data={"consecutive_crashes": consecutive, "last_error": last_err},
+ )]
+
+
+def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]:
+ """Task has been in ``blocked`` status for too long without a comment.
+
+ Threshold: cfg["blocked_stale_hours"] (default 24).
+ Surfaced as a warning so humans know there's a pending unblock.
+ """
+ hours = float(cfg.get("blocked_stale_hours", 24))
+ status = _task_field(task, "status")
+ if status != "blocked":
+ return []
+ # Find the most recent ``blocked`` event.
+ last_blocked_ts = 0
+ for ev in events:
+ if _event_kind(ev) == "blocked":
+ t = _event_ts(ev)
+ if t > last_blocked_ts:
+ last_blocked_ts = t
+ if last_blocked_ts == 0:
+ return []
+ age_hours = (now - last_blocked_ts) / 3600.0
+ if age_hours < hours:
+ return []
+ # Any comment / unblock after the block breaks the "stale" signal.
+ for ev in events:
+ if _event_kind(ev) in ("commented", "unblocked") and _event_ts(ev) > last_blocked_ts:
+ return []
+ actions: list[DiagnosticAction] = [
+ DiagnosticAction(
+ kind="comment",
+ label="Add a comment / unblock the task",
+ suggested=True,
+ ),
+ ]
+ return [Diagnostic(
+ kind="stuck_in_blocked",
+ severity="warning",
+ title=f"Task has been blocked for {int(age_hours)}h",
+ detail=(
+ f"This task transitioned to blocked {int(age_hours)}h ago and "
+ f"has had no comments or unblock attempts since. Blocked tasks "
+ f"are waiting for human input — check the block reason and "
+ f"either unblock with feedback or answer with a comment."
+ ),
+ actions=actions,
+ first_seen_at=last_blocked_ts,
+ last_seen_at=last_blocked_ts,
+ count=1,
+ data={"blocked_at": last_blocked_ts, "age_hours": round(age_hours, 1)},
+ )]
+
+
+# Registry — order matters: rules higher on the list render first when
+# severity ties. Add new rules here.
+_RULES: list[RuleFn] = [
+ _rule_hallucinated_cards,
+ _rule_prose_phantom_refs,
+ _rule_repeated_spawn_failures,
+ _rule_repeated_crashes,
+ _rule_stuck_in_blocked,
+]
+
+
+# Known kinds (for the UI's filter / legend / i18n keys). Update when
+# rules are added.
+DIAGNOSTIC_KINDS = (
+ "hallucinated_cards",
+ "prose_phantom_refs",
+ "repeated_spawn_failures",
+ "repeated_crashes",
+ "stuck_in_blocked",
+)
+
+
+DEFAULT_CONFIG = {
+ "spawn_failure_threshold": 3,
+ "crash_threshold": 2,
+ "blocked_stale_hours": 24,
+}
+
+
+def compute_task_diagnostics(
+ task,
+ events: list,
+ runs: list,
+ *,
+ now: Optional[int] = None,
+ config: Optional[dict] = None,
+) -> list[Diagnostic]:
+ """Run every rule against a single task's state and return a
+ severity-sorted list of active diagnostics.
+
+ Sorting: critical first, then error, then warning; ties broken by
+ most-recent ``last_seen_at``.
+ """
+ now_ts = int(now if now is not None else time.time())
+ cfg = {**DEFAULT_CONFIG, **(config or {})}
+ out: list[Diagnostic] = []
+ for rule in _RULES:
+ try:
+ out.extend(rule(task, events, runs, now_ts, cfg))
+ except Exception:
+ # A broken rule must never crash the dashboard. Rule bugs
+ # get caught in tests; in production we'd rather drop the
+ # diagnostic than 500 a whole /board request.
+ continue
+ severity_idx = {s: i for i, s in enumerate(SEVERITY_ORDER)}
+ out.sort(
+ key=lambda d: (
+ -severity_idx.get(d.severity, -1),
+ -(d.last_seen_at or 0),
+ )
+ )
+ return out
+
+
+def severity_of_highest(diagnostics: Iterable[Diagnostic]) -> Optional[str]:
+ """Highest severity present in the list, or None if empty. Useful
+ for card badges that need a single color."""
+ highest_idx = -1
+ highest = None
+ for d in diagnostics:
+ idx = SEVERITY_ORDER.index(d.severity) if d.severity in SEVERITY_ORDER else -1
+ if idx > highest_idx:
+ highest_idx = idx
+ highest = d.severity
+ return highest
diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js
index 7d5434729f..02935b73eb 100644
--- a/plugins/kanban/dashboard/dist/index.js
+++ b/plugins/kanban/dashboard/dist/index.js
@@ -60,30 +60,19 @@
blocked: "Mark this task as blocked? The worker's claim is released.",
};
- // Event kinds that indicate a hallucinated/phantom task-id reference
- // in a completion. ``completion_blocked_hallucination`` is emitted when
- // the kernel's ``created_cards`` gate rejects a completion; the task is
- // left in its prior state and the worker can retry. ``suspected_
- // hallucinated_references`` is the advisory prose-scan result — the
- // completion succeeded but the summary text references task ids that
- // do not resolve.
- const HALLUCINATION_EVENT_KINDS = [
- "completion_blocked_hallucination",
- "suspected_hallucinated_references",
- ];
- const HALLUCINATION_EVENT_LABELS = {
- completion_blocked_hallucination: "Completion blocked — phantom card ids",
- suspected_hallucinated_references: "Prose referenced phantom card ids",
+ // Diagnostic kind labels for the events-tab callout. Event kinds emitted
+ // by the kernel get a human-readable header when we detect them in the
+ // events list; add new entries here as new diagnostic event kinds land.
+ const DIAGNOSTIC_EVENT_LABELS = {
+ completion_blocked_hallucination: "⚠ Completion blocked — phantom card ids",
+ suspected_hallucinated_references: "⚠ Prose referenced phantom card ids",
};
- function isHallucinationEvent(kind) {
- return HALLUCINATION_EVENT_KINDS.indexOf(kind) !== -1;
+ function isDiagnosticEvent(kind) {
+ return Object.prototype.hasOwnProperty.call(DIAGNOSTIC_EVENT_LABELS, kind);
}
function phantomIdsFromEvent(ev) {
- // Payload shapes:
- // completion_blocked_hallucination: {phantom_cards, verified_cards, summary_preview}
- // suspected_hallucinated_references: {phantom_refs, source}
if (!ev || !ev.payload) return [];
const p = ev.payload;
return p.phantom_cards || p.phantom_refs || [];
@@ -725,24 +714,36 @@
}
// -------------------------------------------------------------------------
- // Attention strip — surfaces tasks with active hallucination warnings.
- // Renders a collapsed bar just below the board switcher; clicking expands
- // a list of affected tasks with an "Open" button each. Dismissible per
- // session via state flag; tasks re-appear on page reload if they still
- // have warnings.
+ // Attention strip — surfaces every task with active diagnostics,
+ // severity-marked (warning/error/critical). Collapsed by default; click
+ // Show to expand into per-task rows with Open buttons. Dismissible
+ // per session via state flag.
// -------------------------------------------------------------------------
- function collectWarningTasks(boardData) {
+ function collectDiagTasks(boardData) {
if (!boardData || !boardData.columns) return [];
const out = [];
for (const col of boardData.columns) {
for (const t of col.tasks || []) {
- if (t.warnings && t.warnings.count > 0) out.push(t);
+ if (t.diagnostics && t.diagnostics.length > 0) out.push(t);
+ else if (t.warnings && t.warnings.count > 0) out.push(t);
}
}
- // Sort: most recent warning first.
+ // Sort: highest severity first (critical > error > warning), then by
+ // most recent latest_at.
+ const sevIdx = function (s) {
+ if (s === "critical") return 3;
+ if (s === "error") return 2;
+ if (s === "warning") return 1;
+ return 0;
+ };
out.sort(function (a, b) {
- return (b.warnings.latest_at || 0) - (a.warnings.latest_at || 0);
+ const aSev = sevIdx((a.warnings && a.warnings.highest_severity) || "warning");
+ const bSev = sevIdx((b.warnings && b.warnings.highest_severity) || "warning");
+ if (aSev !== bSev) return bSev - aSev;
+ const aLa = (a.warnings && a.warnings.latest_at) || 0;
+ const bLa = (b.warnings && b.warnings.latest_at) || 0;
+ return bLa - aLa;
});
return out;
}
@@ -750,18 +751,31 @@
function AttentionStrip(props) {
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState(false);
- const warnTasks = useMemo(
- function () { return collectWarningTasks(props.boardData); },
+ const diagTasks = useMemo(
+ function () { return collectDiagTasks(props.boardData); },
[props.boardData]
);
- if (dismissed || warnTasks.length === 0) return null;
- return h("div", { className: "hermes-kanban-attention" },
+ if (dismissed || diagTasks.length === 0) return null;
+ // Pick the highest severity present so we can colour the strip.
+ let topSev = "warning";
+ for (const t of diagTasks) {
+ const s = (t.warnings && t.warnings.highest_severity) || "warning";
+ if (s === "critical") { topSev = "critical"; break; }
+ if (s === "error" && topSev !== "critical") topSev = "error";
+ }
+ return h("div", {
+ className: cn(
+ "hermes-kanban-attention",
+ "hermes-kanban-attention--" + topSev,
+ ),
+ },
h("div", { className: "hermes-kanban-attention-bar" },
- h("span", { className: "hermes-kanban-attention-icon" }, "⚠"),
+ h("span", { className: "hermes-kanban-attention-icon" },
+ topSev === "critical" ? "!!!" : topSev === "error" ? "!!" : "⚠"),
h("span", { className: "hermes-kanban-attention-text" },
- warnTasks.length === 1
- ? "1 task with hallucination warnings"
- : `${warnTasks.length} tasks with hallucination warnings`,
+ diagTasks.length === 1
+ ? "1 task needs attention"
+ : `${diagTasks.length} tasks need attention`,
),
h("button", {
className: "hermes-kanban-attention-toggle",
@@ -773,19 +787,29 @@
onClick: function () { setDismissed(true); },
title: "Hide until next page reload",
type: "button",
- }, "✕"),
+ }, "\u2715"),
),
expanded
? h("div", { className: "hermes-kanban-attention-list" },
- warnTasks.map(function (t) {
- return h("div", { key: t.id, className: "hermes-kanban-attention-row" },
+ diagTasks.map(function (t) {
+ const sev = (t.warnings && t.warnings.highest_severity) || "warning";
+ const kinds = t.warnings && t.warnings.kinds ? Object.keys(t.warnings.kinds) : [];
+ return h("div", {
+ key: t.id,
+ className: cn(
+ "hermes-kanban-attention-row",
+ "hermes-kanban-attention-row--" + sev,
+ ),
+ },
+ h("span", { className: "hermes-kanban-attention-row-sev" },
+ sev === "critical" ? "!!!" : sev === "error" ? "!!" : "⚠"),
h("span", { className: "hermes-kanban-attention-row-id" }, t.id),
h("span", { className: "hermes-kanban-attention-row-title" },
t.title || "(untitled)"),
h("span", { className: "hermes-kanban-attention-row-meta" },
t.assignee ? "@" + t.assignee : "unassigned",
- " · ",
- `${t.warnings.count} event${t.warnings.count === 1 ? "" : "s"}`,
+ " \u00b7 ",
+ kinds.length > 0 ? kinds.join(", ") : "diagnostic",
),
h("button", {
className: "hermes-kanban-attention-row-btn",
@@ -800,195 +824,266 @@
}
// -------------------------------------------------------------------------
- // Recovery popover — operator actions for a task flagged with
- // hallucination warnings. Three primary actions:
- // 1. Reclaim — release a running worker's claim; task back to ready.
- // 2. Reassign — switch the task to a different profile (with optional
- // reclaim-first toggle for currently-running tasks).
- // 3. Edit profile — copy the CLI hint for `hermes -p model`
- // (the dashboard can't edit profile config from the
- // browser; it lives on the filesystem).
- // Rendered from inside TaskDetail via a toggle button.
+ // Diagnostics section — generic renderer for a task's active distress
+ // signals. Each diagnostic carries its own title, detail, data payload,
+ // and a list of structured actions; the section renders them uniformly
+ // regardless of kind. Replaces the hallucination-specific
+ // ``RecoveryPopover`` from the previous iteration.
+ //
+ // Action kinds supported today:
+ // reclaim → POST /tasks/:id/reclaim
+ // reassign → POST /tasks/:id/reassign (with profile picker)
+ // unblock → PATCH /tasks/:id body: {status: "ready"}
+ // comment → scroll to the comment input at the bottom of the drawer
+ // cli_hint → copy payload.command to clipboard
+ // open_docs → open payload.url in a new tab
+ // Unknown kinds are rendered as a disabled informational row so the
+ // server can add new action kinds without breaking the UI.
// -------------------------------------------------------------------------
- function RecoveryPopover(props) {
- const t = props.task;
- const board = props.boardSlug;
- const assignees = props.assignees || [];
- const [reason, setReason] = useState("");
- const [newProfile, setNewProfile] = useState(t.assignee || "");
- const [reclaimFirst, setReclaimFirst] = useState(t.status === "running");
+ function DiagnosticActionButton(props) {
+ const { action, onExec, busy, extra } = props;
+ const label = (action.suggested ? "\u2606 " : "") + action.label;
+ const cls = cn(
+ "hermes-kanban-diag-action-btn",
+ action.suggested ? "hermes-kanban-diag-action-btn--suggested" : "",
+ );
+ if (action.kind === "reclaim" || action.kind === "reassign" ||
+ action.kind === "unblock") {
+ return h("button", {
+ className: cls,
+ disabled: busy || (extra && extra.disabled),
+ onClick: function () { onExec(action); },
+ type: "button",
+ }, label);
+ }
+ if (action.kind === "cli_hint") {
+ return h("button", {
+ className: cls,
+ disabled: busy,
+ onClick: function () { onExec(action); },
+ type: "button",
+ title: "Copy command to clipboard",
+ }, (extra && extra.copied) ? "Copied" : label);
+ }
+ if (action.kind === "comment") {
+ return h("button", {
+ className: cls,
+ onClick: function () { onExec(action); },
+ type: "button",
+ }, label);
+ }
+ if (action.kind === "open_docs") {
+ return h("a", {
+ className: cls,
+ href: (action.payload && action.payload.url) || "#",
+ target: "_blank",
+ rel: "noreferrer",
+ }, label);
+ }
+ // Unknown kind — render informational, non-interactive.
+ return h("span", { className: cls + " hermes-kanban-diag-action-btn--unknown" },
+ label);
+ }
+
+ function DiagnosticCard(props) {
+ const { diag, task, boardSlug, assignees, onRefresh } = props;
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(null);
- const [copied, setCopied] = useState(false);
+ const [copiedKey, setCopiedKey] = useState(null);
+ const [reassignProfile, setReassignProfile] = useState(task.assignee || "");
- const act = function (kind) {
+ const execAction = function (action) {
if (busy) return;
- setBusy(true);
- setMsg(null);
- const urlBase = `${API}/tasks/${encodeURIComponent(t.id)}`;
- const url = kind === "reclaim"
- ? withBoard(`${urlBase}/reclaim`, board)
- : withBoard(`${urlBase}/reassign`, board);
- const body = kind === "reclaim"
- ? { reason: reason || null }
- : {
- profile: newProfile || null,
- reclaim_first: !!reclaimFirst,
- reason: reason || null,
- };
- SDK.fetchJSON(url, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- }).then(function () {
- setMsg({ ok: true, text:
- kind === "reclaim"
- ? `Reclaimed ${t.id}. Task back to ready.`
- : `Reassigned ${t.id} to ${newProfile || "(unassigned)"}.`
- });
- if (props.onActionComplete) props.onActionComplete(kind);
- }).catch(function (err) {
- setMsg({ ok: false, text: `Failed: ${err.message || err}` });
- }).then(function () {
- setBusy(false);
- });
- };
-
- const profileCmd = `hermes -p ${t.assignee || ""} model`;
- const copyCmd = function () {
- try {
- navigator.clipboard.writeText(profileCmd).then(function () {
- setCopied(true);
- setTimeout(function () { setCopied(false); }, 2000);
- });
- } catch (_) {
- window.prompt("Copy this command:", profileCmd);
+ if (action.kind === "cli_hint") {
+ const cmd = (action.payload && action.payload.command) || action.label;
+ const fallback = function () { window.prompt("Copy this command:", cmd); };
+ try {
+ const p = navigator.clipboard && navigator.clipboard.writeText(cmd);
+ if (p && p.then) {
+ p.then(function () {
+ setCopiedKey(action.label);
+ setTimeout(function () { setCopiedKey(null); }, 2000);
+ }).catch(fallback);
+ } else {
+ fallback();
+ }
+ } catch (_) {
+ fallback();
+ }
+ return;
+ }
+ if (action.kind === "comment") {
+ // Scroll the comment input into view; the drawer already has one
+ // at the bottom. Focus it so the operator can start typing.
+ const ta = document.querySelector(".hermes-kanban-drawer-comment-row input, .hermes-kanban-drawer-comment-row textarea");
+ if (ta) {
+ ta.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ ta.focus();
+ }
+ return;
+ }
+ if (action.kind === "unblock") {
+ setBusy(true); setMsg(null);
+ const url = withBoard(`${API}/tasks/${encodeURIComponent(task.id)}`, boardSlug);
+ SDK.fetchJSON(url, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ status: "ready" }),
+ }).then(function () {
+ setMsg({ ok: true, text: `Unblocked ${task.id}. Task is ready for the next tick.` });
+ if (onRefresh) onRefresh();
+ }).catch(function (err) {
+ setMsg({ ok: false, text: `Unblock failed: ${err.message || err}` });
+ }).then(function () { setBusy(false); });
+ return;
+ }
+ if (action.kind === "reclaim") {
+ setBusy(true); setMsg(null);
+ const url = withBoard(`${API}/tasks/${encodeURIComponent(task.id)}/reclaim`, boardSlug);
+ SDK.fetchJSON(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reason: `recovery action for ${diag.kind}` }),
+ }).then(function () {
+ setMsg({ ok: true, text: `Reclaimed ${task.id}. Task is back to ready.` });
+ if (onRefresh) onRefresh();
+ }).catch(function (err) {
+ setMsg({ ok: false, text: `Reclaim failed: ${err.message || err}` });
+ }).then(function () { setBusy(false); });
+ return;
+ }
+ if (action.kind === "reassign") {
+ if (!reassignProfile) {
+ setMsg({ ok: false, text: "Pick a profile first." });
+ return;
+ }
+ setBusy(true); setMsg(null);
+ const url = withBoard(`${API}/tasks/${encodeURIComponent(task.id)}/reassign`, boardSlug);
+ const body = {
+ profile: reassignProfile || null,
+ reclaim_first: !!(action.payload && action.payload.reclaim_first),
+ reason: `recovery action for ${diag.kind}`,
+ };
+ SDK.fetchJSON(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ }).then(function () {
+ setMsg({
+ ok: true,
+ text: `Reassigned ${task.id} to ${reassignProfile}.`,
+ });
+ if (onRefresh) onRefresh();
+ }).catch(function (err) {
+ setMsg({ ok: false, text: `Reassign failed: ${err.message || err}` });
+ }).then(function () { setBusy(false); });
+ return;
}
};
- return h("div", { className: "hermes-kanban-recovery" },
- h("div", { className: "hermes-kanban-recovery-title" },
- "Recovery actions"),
- h("div", { className: "hermes-kanban-recovery-hint" },
- "Use these when a worker is stuck (crash loop, repeated hallucination, ",
- "broken model). Events in this task's history are preserved as audit trail."),
+ // Pull out the reassign action so we can render its picker inline.
+ const reassignAction = (diag.actions || []).find(function (a) {
+ return a.kind === "reassign";
+ });
- // Reason input (shared across actions)
- h("div", { className: "hermes-kanban-recovery-section" },
- h("label", { className: "hermes-kanban-recovery-label" },
- "Reason (optional, logged on event)"),
- h("input", {
- type: "text",
- className: "hermes-kanban-recovery-input",
- value: reason,
- onChange: function (e) { setReason(e.target.value); },
- placeholder: "e.g. model hallucinating, switching to larger",
+ const sevClass = "hermes-kanban-diag--" + (diag.severity || "warning");
+ return h("div", { className: cn("hermes-kanban-diag", sevClass) },
+ h("div", { className: "hermes-kanban-diag-header" },
+ h("span", { className: "hermes-kanban-diag-sev" },
+ diag.severity === "critical" ? "!!!" :
+ diag.severity === "error" ? "!!" : "\u26a0"),
+ h("span", { className: "hermes-kanban-diag-title" },
+ diag.title),
+ ),
+ h("div", { className: "hermes-kanban-diag-detail" },
+ diag.detail),
+ diag.data && Object.keys(diag.data).length > 0
+ ? h("div", { className: "hermes-kanban-diag-data" },
+ Object.keys(diag.data).map(function (k) {
+ const v = diag.data[k];
+ if (Array.isArray(v) && v.length > 0 && typeof v[0] === "string" &&
+ v[0].indexOf("t_") === 0) {
+ // Task-id list — render as chips.
+ return h("div", { key: k, className: "hermes-kanban-diag-data-row" },
+ h("span", { className: "hermes-kanban-diag-data-key" }, k + ":"),
+ v.map(function (x) {
+ return h("code", {
+ key: x, className: "hermes-kanban-event-phantom-chip",
+ }, x);
+ }),
+ );
+ }
+ return h("div", { key: k, className: "hermes-kanban-diag-data-row" },
+ h("span", { className: "hermes-kanban-diag-data-key" }, k + ":"),
+ h("span", { className: "hermes-kanban-diag-data-val" },
+ Array.isArray(v) ? v.join(", ") : String(v)),
+ );
+ }),
+ )
+ : null,
+ // Inline reassign picker — only shown when the diagnostic offers
+ // a reassign action. Profile list comes from the board payload.
+ reassignAction
+ ? h("div", { className: "hermes-kanban-diag-reassign-row" },
+ h("span", { className: "hermes-kanban-diag-reassign-label" },
+ "Reassign to:"),
+ h("select", {
+ className: "hermes-kanban-recovery-select",
+ value: reassignProfile,
+ onChange: function (e) { setReassignProfile(e.target.value); },
+ },
+ h("option", { value: "" }, "(unassigned)"),
+ (assignees || []).map(function (a) {
+ return h("option", { key: a, value: a }, a);
+ }),
+ ),
+ )
+ : null,
+ h("div", { className: "hermes-kanban-diag-actions" },
+ (diag.actions || []).map(function (a, i) {
+ return h(DiagnosticActionButton, {
+ key: a.kind + i,
+ action: a,
+ onExec: execAction,
+ busy: busy,
+ extra: {
+ copied: copiedKey === a.label,
+ disabled: (a.kind === "reassign" && !reassignProfile),
+ },
+ });
}),
),
-
- // Action 1: Reclaim
- h("div", { className: "hermes-kanban-recovery-section" },
- h("div", { className: "hermes-kanban-recovery-action-row" },
- h("div", { className: "hermes-kanban-recovery-action-label" },
- "1. Reclaim"),
- h("div", { className: "hermes-kanban-recovery-action-desc" },
- t.status === "running"
- ? "Abort the running worker and reset to ready."
- : "Task is not running — nothing to reclaim."),
- h("button", {
- className: "hermes-kanban-recovery-btn",
- disabled: busy || t.status !== "running",
- onClick: function () { act("reclaim"); },
- type: "button",
- }, "Reclaim"),
- ),
- ),
-
- // Action 2: Reassign
- h("div", { className: "hermes-kanban-recovery-section" },
- h("div", { className: "hermes-kanban-recovery-action-row" },
- h("div", { className: "hermes-kanban-recovery-action-label" },
- "2. Reassign"),
- h("div", { className: "hermes-kanban-recovery-action-desc" },
- "Switch to a different worker profile and retry."),
- ),
- h("div", { className: "hermes-kanban-recovery-reassign-row" },
- h("select", {
- className: "hermes-kanban-recovery-select",
- value: newProfile,
- onChange: function (e) { setNewProfile(e.target.value); },
- },
- h("option", { value: "" }, "(unassigned)"),
- assignees.map(function (a) {
- return h("option", { key: a, value: a }, a);
- }),
- ),
- h("label", { className: "hermes-kanban-recovery-checkbox" },
- h("input", {
- type: "checkbox",
- checked: reclaimFirst,
- onChange: function (e) { setReclaimFirst(e.target.checked); },
- }),
- " Reclaim first",
- ),
- h("button", {
- className: "hermes-kanban-recovery-btn",
- disabled: busy,
- onClick: function () { act("reassign"); },
- type: "button",
- }, "Reassign"),
- ),
- ),
-
- // Action 3: Edit profile model (CLI hint)
- h("div", { className: "hermes-kanban-recovery-section" },
- h("div", { className: "hermes-kanban-recovery-action-row" },
- h("div", { className: "hermes-kanban-recovery-action-label" },
- "3. Change profile model"),
- h("div", { className: "hermes-kanban-recovery-action-desc" },
- "Profile config lives on disk — change it from a terminal, ",
- "then use Reclaim above to retry with the new model."),
- ),
- h("div", { className: "hermes-kanban-recovery-cmd-row" },
- h("code", { className: "hermes-kanban-recovery-cmd" }, profileCmd),
- h("button", {
- className: "hermes-kanban-recovery-btn",
- onClick: copyCmd,
- type: "button",
- }, copied ? "Copied" : "Copy"),
- ),
- ),
-
msg
? h("div", {
className: cn(
- "hermes-kanban-recovery-msg",
- msg.ok ? "hermes-kanban-recovery-msg--ok" : "hermes-kanban-recovery-msg--err",
+ "hermes-kanban-diag-msg",
+ msg.ok ? "hermes-kanban-diag-msg--ok" : "hermes-kanban-diag-msg--err",
),
}, msg.text)
: null,
);
}
- // Thin wrapper that toggles the RecoveryPopover visibility inside a
- // task drawer. Auto-opens when the task has active hallucination
- // warnings; operators can still collapse it. Always available via a
- // header button for tasks without warnings, so reclaim/reassign is
- // accessible for other stuck-worker scenarios too.
- function RecoverySection(props) {
- const [open, setOpen] = useState(!!props.hasWarnings);
- // Re-open automatically if warnings appear while the drawer is open.
+ function DiagnosticsSection(props) {
+ const diags = props.diagnostics || [];
+ const hasOpenDiags = diags.length > 0;
+ const [open, setOpen] = useState(hasOpenDiags);
useEffect(function () {
- if (props.hasWarnings) setOpen(true);
- }, [props.hasWarnings]);
+ if (hasOpenDiags) setOpen(true);
+ }, [hasOpenDiags]);
+ if (!hasOpenDiags && !props.alwaysVisible) {
+ // Nothing active. Collapse the section entirely rather than showing
+ // an empty "Recovery" header — keeps clean tasks visually clean.
+ return null;
+ }
return h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head-row" },
h("span", { className: "hermes-kanban-section-head" },
- props.hasWarnings
+ hasOpenDiags
? h("span", { className: "hermes-kanban-section-head-warning" },
- "⚠ Recovery")
- : "Recovery",
+ `\u26a0 Diagnostics (${diags.length})`)
+ : "Diagnostics",
),
h("button", {
className: "hermes-kanban-section-toggle",
@@ -997,24 +1092,23 @@
}, open ? "Hide" : "Show"),
),
open
- ? h(RecoveryPopover, {
- // Keyed by task id so React tears the popover down and
- // remounts it when the drawer swaps to a different task —
- // otherwise reason / newProfile / success toast from the
- // previous task leak into the new one.
- key: props.task.id,
- task: props.task,
- boardSlug: props.boardSlug,
- assignees: props.assignees,
- onActionComplete: function () {
- if (props.onRefresh) props.onRefresh();
- },
- })
+ ? h("div", { className: "hermes-kanban-diag-list" },
+ diags.map(function (d, i) {
+ return h(DiagnosticCard, {
+ key: props.task.id + ":" + d.kind + i,
+ diag: d,
+ task: props.task,
+ boardSlug: props.boardSlug,
+ assignees: props.assignees,
+ onRefresh: props.onRefresh,
+ });
+ }),
+ )
: null,
);
}
- // -------------------------------------------------------------------------
+ // -------------------------------------------------------------------------
// Board switcher (multi-project)
// -------------------------------------------------------------------------
@@ -1545,11 +1639,18 @@
h("span", { className: "hermes-kanban-card-id" }, t.id),
t.warnings && t.warnings.count > 0
? h("span", {
- className: "hermes-kanban-warning-badge",
- title: `⚠ ${t.warnings.count} hallucination ` +
- `event(s) since last clean completion. ` +
- `Click to open for details.`,
- }, "⚠")
+ className: cn(
+ "hermes-kanban-warning-badge",
+ "hermes-kanban-warning-badge--" + (t.warnings.highest_severity || "warning"),
+ ),
+ title: (
+ `${t.warnings.count} active diagnostic` +
+ (t.warnings.count === 1 ? "" : "s") +
+ ` (severity: ${t.warnings.highest_severity || "warning"}). ` +
+ `Click to open for details.`
+ ),
+ }, t.warnings.highest_severity === "critical" ? "!!!" :
+ t.warnings.highest_severity === "error" ? "!!" : "⚠")
: null,
t.priority > 0
? h(Badge, { className: "hermes-kanban-priority" }, `P${t.priority}`)
@@ -1945,11 +2046,11 @@
t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null,
),
h(StatusActions, { task: t, onPatch: props.onPatch }),
- h(RecoverySection, {
+ h(DiagnosticsSection, {
task: t,
boardSlug: props.boardSlug,
assignees: props.assignees,
- hasWarnings: t.warnings && t.warnings.count > 0,
+ diagnostics: t.diagnostics || [],
onRefresh: props.onRefresh,
}),
h(HomeSubsSection, {
@@ -1992,20 +2093,20 @@
h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head" }, `Events (${events.length})`),
events.slice().reverse().slice(0, 20).map(function (e) {
- const isHall = isHallucinationEvent(e.kind);
- const phantoms = isHall ? phantomIdsFromEvent(e) : [];
+ const isDiag = isDiagnosticEvent(e.kind);
+ const phantoms = isDiag ? phantomIdsFromEvent(e) : [];
return h("div", {
key: e.id,
className: cn(
"hermes-kanban-event",
- isHall ? "hermes-kanban-event--hallucination" : "",
+ isDiag ? "hermes-kanban-event--hallucination" : "",
),
},
- isHall
+ isDiag
? h("div", { className: "hermes-kanban-event-header" },
h("span", { className: "hermes-kanban-event-warning-icon" }, "⚠"),
h("span", { className: "hermes-kanban-event-warning-label" },
- HALLUCINATION_EVENT_LABELS[e.kind] || e.kind),
+ DIAGNOSTIC_EVENT_LABELS[e.kind] || e.kind),
h("span", { className: "hermes-kanban-event-ago" },
timeAgo ? timeAgo(e.created_at) : ""),
)
@@ -2014,7 +2115,7 @@
h("span", { className: "hermes-kanban-event-ago" },
timeAgo ? timeAgo(e.created_at) : ""),
),
- isHall && phantoms.length > 0
+ isDiag && phantoms.length > 0
? h("div", { className: "hermes-kanban-event-phantom-row" },
h("span", { className: "hermes-kanban-event-phantom-label" },
"Phantom ids:"),
@@ -2026,7 +2127,7 @@
}),
)
: null,
- e.payload && !isHall
+ e.payload && !isDiag
? h("code", { className: "hermes-kanban-event-payload" },
JSON.stringify(e.payload))
: null,
diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css
index d993af510a..d10b766bd2 100644
--- a/plugins/kanban/dashboard/dist/style.css
+++ b/plugins/kanban/dashboard/dist/style.css
@@ -1100,3 +1100,173 @@
color: #ff8b8b;
border: 1px solid rgba(255, 107, 107, 0.3);
}
+
+/* ---------------------------------------------------------------------- */
+/* Diagnostics — generic, severity-coloured distress signals on tasks. */
+/* Three rungs: warning (amber), error (orange), critical (red). */
+/* ---------------------------------------------------------------------- */
+
+/* Severity token variables so every diagnostic-coloured surface uses the */
+/* same palette. */
+.hermes-kanban-diag,
+.hermes-kanban-attention,
+.hermes-kanban-warning-badge,
+.hermes-kanban-attention-row {
+ --hermes-diag-warning: #ff9e3b;
+ --hermes-diag-error: #ff6b3d;
+ --hermes-diag-critical: #ff4d4d;
+}
+
+/* Warning-badge severity variants (overrides the base colour). */
+.hermes-kanban-warning-badge--warning { color: var(--hermes-diag-warning); }
+.hermes-kanban-warning-badge--error { color: var(--hermes-diag-error); font-weight: 700; }
+.hermes-kanban-warning-badge--critical { color: var(--hermes-diag-critical); font-weight: 700; }
+
+/* Attention-strip severity variants. */
+.hermes-kanban-attention--warning {
+ border-color: rgba(255, 158, 59, 0.35);
+ background: rgba(255, 158, 59, 0.06);
+}
+.hermes-kanban-attention--error {
+ border-color: rgba(255, 107, 61, 0.45);
+ background: rgba(255, 107, 61, 0.08);
+}
+.hermes-kanban-attention--critical {
+ border-color: rgba(255, 77, 77, 0.55);
+ background: rgba(255, 77, 77, 0.10);
+}
+.hermes-kanban-attention--error .hermes-kanban-attention-icon { color: var(--hermes-diag-error); }
+.hermes-kanban-attention--critical .hermes-kanban-attention-icon { color: var(--hermes-diag-critical); }
+
+/* Per-row severity marker in the expanded attention list. */
+.hermes-kanban-attention-row-sev {
+ display: inline-block;
+ min-width: 1.5rem;
+ font-weight: 600;
+}
+.hermes-kanban-attention-row--warning .hermes-kanban-attention-row-sev { color: var(--hermes-diag-warning); }
+.hermes-kanban-attention-row--error .hermes-kanban-attention-row-sev { color: var(--hermes-diag-error); font-weight: 700; }
+.hermes-kanban-attention-row--critical .hermes-kanban-attention-row-sev { color: var(--hermes-diag-critical); font-weight: 700; }
+
+/* Individual diagnostic card inside the drawer's Diagnostics section. */
+.hermes-kanban-diag-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+.hermes-kanban-diag {
+ border-left: 3px solid var(--hermes-diag-warning);
+ background: rgba(255, 158, 59, 0.05);
+ border-radius: 0.35rem;
+ padding: 0.6rem 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+.hermes-kanban-diag--error {
+ border-left-color: var(--hermes-diag-error);
+ background: rgba(255, 107, 61, 0.06);
+}
+.hermes-kanban-diag--critical {
+ border-left-color: var(--hermes-diag-critical);
+ background: rgba(255, 77, 77, 0.07);
+}
+.hermes-kanban-diag-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.hermes-kanban-diag-sev {
+ font-weight: 700;
+ min-width: 1.5rem;
+}
+.hermes-kanban-diag--warning .hermes-kanban-diag-sev { color: var(--hermes-diag-warning); }
+.hermes-kanban-diag--error .hermes-kanban-diag-sev { color: var(--hermes-diag-error); }
+.hermes-kanban-diag--critical .hermes-kanban-diag-sev { color: var(--hermes-diag-critical); }
+.hermes-kanban-diag-title {
+ font-weight: 600;
+ font-size: 0.875rem;
+}
+.hermes-kanban-diag-detail {
+ font-size: 0.8125rem;
+ color: var(--color-foreground, #ccc);
+ line-height: 1.4;
+}
+.hermes-kanban-diag-data {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ font-size: 0.75rem;
+}
+.hermes-kanban-diag-data-row {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+}
+.hermes-kanban-diag-data-key {
+ color: var(--color-muted-foreground, #888);
+ font-weight: 500;
+}
+.hermes-kanban-diag-data-val {
+ font-family: ui-monospace, SFMono-Regular, monospace;
+}
+.hermes-kanban-diag-reassign-row {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.75rem;
+}
+.hermes-kanban-diag-reassign-label {
+ color: var(--color-muted-foreground, #888);
+}
+.hermes-kanban-diag-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-top: 0.1rem;
+}
+.hermes-kanban-diag-action-btn {
+ padding: 0.25rem 0.6rem;
+ font-size: 0.75rem;
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid rgba(120, 120, 140, 0.3);
+ border-radius: 0.3rem;
+ color: inherit;
+ cursor: pointer;
+ text-decoration: none;
+}
+.hermes-kanban-diag-action-btn:hover:not(:disabled) {
+ background: rgba(0, 0, 0, 0.3);
+}
+.hermes-kanban-diag-action-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+.hermes-kanban-diag-action-btn--suggested {
+ background: rgba(255, 158, 59, 0.15);
+ border-color: rgba(255, 158, 59, 0.4);
+ font-weight: 600;
+}
+.hermes-kanban-diag-action-btn--suggested:hover:not(:disabled) {
+ background: rgba(255, 158, 59, 0.25);
+}
+.hermes-kanban-diag-action-btn--unknown {
+ opacity: 0.6;
+ cursor: default;
+}
+.hermes-kanban-diag-msg {
+ font-size: 0.75rem;
+ padding: 0.35rem 0.5rem;
+ border-radius: 0.3rem;
+}
+.hermes-kanban-diag-msg--ok {
+ background: rgba(120, 200, 120, 0.12);
+ color: #6bc46b;
+ border: 1px solid rgba(120, 200, 120, 0.3);
+}
+.hermes-kanban-diag-msg--err {
+ background: rgba(255, 107, 61, 0.12);
+ color: #ff8b6b;
+ border: 1px solid rgba(255, 107, 61, 0.3);
+}
diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py
index 59c6a9e233..2b5bcd0dad 100644
--- a/plugins/kanban/dashboard/plugin_api.py
+++ b/plugins/kanban/dashboard/plugin_api.py
@@ -187,63 +187,109 @@ _WARNING_EVENT_KINDS = (
)
-def _compute_warnings_for_tasks(
+def _compute_task_diagnostics(
conn: sqlite3.Connection,
task_ids: Optional[list[str]] = None,
-) -> dict[str, dict]:
- """Return {task_id: {count, kinds, latest_at}} for tasks with
- hallucination warnings that occurred AFTER the most recent clean
- completion event (completed / edited). An empty dict means no tasks
- on the board have active warnings.
+) -> dict[str, list[dict]]:
+ """Run the diagnostic rule engine against every task (or a subset)
+ and return ``{task_id: [diagnostic_dict, ...]}``.
- ``task_ids`` narrows the query; pass ``None`` to scan the whole DB
- (matches board-level rollup). Used by both the /board aggregate and
- per-task /tasks/:id endpoints.
+ Tasks with no active diagnostics are omitted from the result.
+ Uses ``hermes_cli.kanban_diagnostics`` — see that module for the
+ rule definitions.
"""
- params: tuple = ()
+ from hermes_cli import kanban_diagnostics as kd
+
+ # Build the candidate task list. We need each task's row + its
+ # events + its runs. Doing N separate queries works but scales
+ # poorly; do three aggregate queries instead.
if task_ids is not None:
if not task_ids:
return {}
placeholders = ",".join(["?"] * len(task_ids))
- sql = (
- "SELECT task_id, kind, created_at FROM task_events "
- f"WHERE task_id IN ({placeholders}) AND kind IN "
- "('completion_blocked_hallucination', "
- " 'suspected_hallucinated_references', "
- " 'completed', 'edited') "
- "ORDER BY task_id, id"
- )
- params = tuple(task_ids)
+ rows = conn.execute(
+ f"SELECT * FROM tasks WHERE id IN ({placeholders})",
+ tuple(task_ids),
+ ).fetchall()
else:
- sql = (
- "SELECT task_id, kind, created_at FROM task_events "
- "WHERE kind IN "
- "('completion_blocked_hallucination', "
- " 'suspected_hallucinated_references', "
- " 'completed', 'edited') "
- "ORDER BY task_id, id"
- )
+ rows = conn.execute(
+ "SELECT * FROM tasks WHERE status != 'archived'",
+ ).fetchall()
- out: dict[str, dict] = {}
- for row in conn.execute(sql, params).fetchall():
- tid = row["task_id"]
- kind = row["kind"]
- created_at = row["created_at"]
- if kind in ("completed", "edited"):
- # Clean event wipes prior warning counters; only events after
- # this timestamp count.
- out.pop(tid, None)
- continue
- bucket = out.setdefault(
- tid, {"count": 0, "kinds": {}, "latest_at": 0}
+ if not rows:
+ return {}
+
+ # Index events + runs by task id. For very large boards this will
+ # slurp a lot — acceptable on the dashboard's typical working set
+ # (hundreds of tasks), but we can add pagination / filtering later
+ # if profiling shows it's a hotspot.
+ row_ids = [r["id"] for r in rows]
+ placeholders = ",".join(["?"] * len(row_ids))
+ events_by_task: dict[str, list] = {tid: [] for tid in row_ids}
+ for ev_row in conn.execute(
+ f"SELECT * FROM task_events WHERE task_id IN ({placeholders}) ORDER BY id",
+ tuple(row_ids),
+ ).fetchall():
+ events_by_task.setdefault(ev_row["task_id"], []).append(ev_row)
+ runs_by_task: dict[str, list] = {tid: [] for tid in row_ids}
+ for run_row in conn.execute(
+ f"SELECT * FROM task_runs WHERE task_id IN ({placeholders}) ORDER BY id",
+ tuple(row_ids),
+ ).fetchall():
+ runs_by_task.setdefault(run_row["task_id"], []).append(run_row)
+
+ out: dict[str, list[dict]] = {}
+ for r in rows:
+ tid = r["id"]
+ diags = kd.compute_task_diagnostics(
+ r,
+ events_by_task.get(tid, []),
+ runs_by_task.get(tid, []),
)
- bucket["count"] += 1
- bucket["kinds"][kind] = bucket["kinds"].get(kind, 0) + 1
- if created_at > bucket["latest_at"]:
- bucket["latest_at"] = created_at
+ if diags:
+ out[tid] = [d.to_dict() for d in diags]
return out
+def _warnings_summary_from_diagnostics(
+ diagnostics: list[dict],
+) -> Optional[dict]:
+ """Compact summary for cards: {count, highest_severity, kinds,
+ latest_at}. Replaces the old hallucination-only ``warnings`` object
+ — same shape additions plus ``highest_severity`` so the UI can color
+ badges per diagnostic severity.
+
+ Returns None when ``diagnostics`` is empty.
+ """
+ if not diagnostics:
+ return None
+ from hermes_cli.kanban_diagnostics import SEVERITY_ORDER
+
+ kinds: dict[str, int] = {}
+ latest = 0
+ highest_idx = -1
+ highest_sev: Optional[str] = None
+ count = 0
+ for d in diagnostics:
+ kinds[d["kind"]] = kinds.get(d["kind"], 0) + d.get("count", 1)
+ count += d.get("count", 1)
+ la = d.get("last_seen_at") or 0
+ if la > latest:
+ latest = la
+ sev = d.get("severity")
+ if sev in SEVERITY_ORDER:
+ idx = SEVERITY_ORDER.index(sev)
+ if idx > highest_idx:
+ highest_idx = idx
+ highest_sev = sev
+ return {
+ "count": count,
+ "kinds": kinds,
+ "latest_at": latest,
+ "highest_severity": highest_sev,
+ }
+
+
def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]:
"""Return {'parents': [...], 'children': [...]} for a task."""
parents = [
@@ -321,10 +367,11 @@ def get_board(
if row["cstatus"] == "done":
p["done"] += 1
- # Hallucination-warning rollup for this board (all tasks).
- # Delegated to _compute_warnings_for_tasks so the per-task
- # /tasks/:id endpoint can reuse the same rule.
- warnings_per_task = _compute_warnings_for_tasks(conn, task_ids=None)
+ # Diagnostics rollup for this board — see kanban_diagnostics.
+ # We get the full structured list per task AND a compact
+ # summary for the card badge (so cards don't carry the detail
+ # text; the drawer fetches that via /tasks/:id or /diagnostics).
+ diagnostics_per_task = _compute_task_diagnostics(conn, task_ids=None)
latest_event_id = conn.execute(
"SELECT COALESCE(MAX(id), 0) AS m FROM task_events"
@@ -339,9 +386,13 @@ def get_board(
d["link_counts"] = link_counts.get(t.id, {"parents": 0, "children": 0})
d["comment_count"] = comment_counts.get(t.id, 0)
d["progress"] = progress.get(t.id) # None when the task has no children
- w = warnings_per_task.get(t.id)
- if w:
- d["warnings"] = w
+ diags = diagnostics_per_task.get(t.id)
+ if diags:
+ # Full list goes into the payload so the drawer can render
+ # without a second round-trip. The board-level badge only
+ # needs the summary.
+ d["diagnostics"] = diags
+ d["warnings"] = _warnings_summary_from_diagnostics(diags)
col = t.status if t.status in columns else "todo"
columns[col].append(d)
@@ -390,11 +441,13 @@ def get_task(task_id: str, board: Optional[str] = Query(None)):
if task is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
task_d = _task_dict(task)
- # Attach warnings metadata so the drawer's Recovery section can
- # auto-open when a hallucination is unresolved.
- warnings = _compute_warnings_for_tasks(conn, task_ids=[task_id])
- if warnings.get(task_id):
- task_d["warnings"] = warnings[task_id]
+ # Attach diagnostics so the drawer's Diagnostics section can
+ # render recovery actions without a second round-trip.
+ diags = _compute_task_diagnostics(conn, task_ids=[task_id])
+ diag_list = diags.get(task_id) or []
+ if diag_list:
+ task_d["diagnostics"] = diag_list
+ task_d["warnings"] = _warnings_summary_from_diagnostics(diag_list)
return {
"task": task_d,
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
@@ -795,6 +848,89 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
conn.close()
+# ---------------------------------------------------------------------------
+# Diagnostics — fleet-wide distress signals (hallucinations, crashes,
+# spawn failures, stuck-blocked). See hermes_cli.kanban_diagnostics for
+# the rule engine.
+# ---------------------------------------------------------------------------
+
+@router.get("/diagnostics")
+def list_diagnostics(
+ board: Optional[str] = Query(None, description="Kanban board slug (omit for current)"),
+ severity: Optional[str] = Query(
+ None,
+ description="Filter by severity: warning|error|critical",
+ ),
+):
+ """Return ``[{task_id, task_title, task_status, task_assignee,
+ diagnostics: [...]}, ...]`` for every task on the board with at
+ least one active diagnostic.
+
+ Severity-filterable so the UI can render "just the critical ones"
+ or the CLI can grep. Useful for the board-header attention strip
+ AND for ``hermes kanban diagnostics`` which shells to this
+ endpoint when the dashboard's running, or invokes the engine
+ directly when it isn't.
+ """
+ board = _resolve_board(board)
+ conn = _conn(board=board)
+ try:
+ diags_by_task = _compute_task_diagnostics(conn, task_ids=None)
+ if not diags_by_task:
+ return {"diagnostics": [], "count": 0}
+
+ # Narrow by severity if asked.
+ if severity:
+ filtered: dict[str, list[dict]] = {}
+ for tid, dl in diags_by_task.items():
+ keep = [d for d in dl if d.get("severity") == severity]
+ if keep:
+ filtered[tid] = keep
+ diags_by_task = filtered
+ if not diags_by_task:
+ return {"diagnostics": [], "count": 0}
+
+ # Pull the task rows we need in one query so we can include
+ # titles/statuses without a per-task lookup.
+ ids = list(diags_by_task.keys())
+ placeholders = ",".join(["?"] * len(ids))
+ rows = {
+ r["id"]: r
+ for r in conn.execute(
+ f"SELECT id, title, status, assignee FROM tasks WHERE id IN ({placeholders})",
+ tuple(ids),
+ ).fetchall()
+ }
+
+ out = []
+ for tid, dl in diags_by_task.items():
+ r = rows.get(tid)
+ out.append({
+ "task_id": tid,
+ "task_title": r["title"] if r else None,
+ "task_status": r["status"] if r else None,
+ "task_assignee": r["assignee"] if r else None,
+ "diagnostics": dl,
+ })
+ # Sort: highest severity first, then most recent.
+ from hermes_cli.kanban_diagnostics import SEVERITY_ORDER
+ sev_idx = {s: i for i, s in enumerate(SEVERITY_ORDER)}
+ def _sort_key(row):
+ top = row["diagnostics"][0]
+ return (
+ -sev_idx.get(top.get("severity"), -1),
+ -(top.get("last_seen_at") or 0),
+ )
+ out.sort(key=_sort_key)
+
+ return {
+ "diagnostics": out,
+ "count": sum(len(d["diagnostics"]) for d in out),
+ }
+ finally:
+ conn.close()
+
+
# ---------------------------------------------------------------------------
# Recovery actions — reclaim a running claim, reassign to a new profile
# ---------------------------------------------------------------------------
diff --git a/tests/hermes_cli/test_kanban_diagnostics.py b/tests/hermes_cli/test_kanban_diagnostics.py
new file mode 100644
index 0000000000..0fabd8558e
--- /dev/null
+++ b/tests/hermes_cli/test_kanban_diagnostics.py
@@ -0,0 +1,353 @@
+"""Tests for hermes_cli.kanban_diagnostics — rule-engine that produces
+structured distress signals (diagnostics) for kanban tasks.
+
+These tests exercise each rule in isolation using minimal in-memory
+task/event/run fixtures (no DB) plus a few integration-style cases
+that round-trip through the real kanban_db to make sure the rule
+engine works on sqlite3.Row objects as well as dataclasses.
+"""
+
+from __future__ import annotations
+
+import time
+from pathlib import Path
+
+import pytest
+
+from hermes_cli import kanban_db as kb
+from hermes_cli import kanban_diagnostics as kd
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def kanban_home(tmp_path, monkeypatch):
+ home = tmp_path / ".hermes"
+ home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ kb.init_db()
+ return home
+
+
+def _task(**overrides):
+ base = {
+ "id": "t_demo00",
+ "title": "demo task",
+ "assignee": "demo",
+ "status": "ready",
+ "spawn_failures": 0,
+ "last_spawn_error": None,
+ }
+ base.update(overrides)
+ return base
+
+
+def _event(kind, ts=None, **payload):
+ return {
+ "kind": kind,
+ "created_at": int(ts if ts is not None else time.time()),
+ "payload": payload or None,
+ }
+
+
+def _run(outcome="completed", run_id=1, error=None):
+ return {
+ "id": run_id,
+ "outcome": outcome,
+ "error": error,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Each rule — positive + negative + clearing
+# ---------------------------------------------------------------------------
+
+
+def test_hallucinated_cards_fires_on_blocked_event():
+ task = _task(status="ready")
+ events = [
+ _event("created", ts=100),
+ _event("completion_blocked_hallucination", ts=200,
+ phantom_cards=["t_bad1", "t_bad2"],
+ verified_cards=["t_good1"]),
+ ]
+ diags = kd.compute_task_diagnostics(task, events, [])
+ assert len(diags) == 1
+ d = diags[0]
+ assert d.kind == "hallucinated_cards"
+ assert d.severity == "error"
+ assert d.data["phantom_ids"] == ["t_bad1", "t_bad2"]
+ # Generic recovery actions always available; comment action too.
+ kinds = [a.kind for a in d.actions]
+ assert "comment" in kinds
+ assert "reassign" in kinds
+
+
+def test_hallucinated_cards_clears_on_subsequent_completion():
+ task = _task(status="done")
+ events = [
+ _event("completion_blocked_hallucination", ts=100, phantom_cards=["t_x"]),
+ _event("completed", ts=200, summary="retry worked"),
+ ]
+ diags = kd.compute_task_diagnostics(task, events, [])
+ assert diags == []
+
+
+def test_prose_phantom_refs_fires_after_clean_completion():
+ # Prose scan emits its event AFTER the completed event in the DB
+ # path, but a subsequent clean completion clears it. Phantom id
+ # must be valid hex — the scanner regex is ``t_[a-f0-9]{8,}``.
+ task = _task(status="done")
+ events = [
+ _event("completed", ts=100, summary="referenced t_bad", result_len=0),
+ _event("suspected_hallucinated_references", ts=101,
+ phantom_refs=["t_deadbeef99"], source="completion_summary"),
+ ]
+ diags = kd.compute_task_diagnostics(task, events, [])
+ assert len(diags) == 1
+ assert diags[0].kind == "prose_phantom_refs"
+ assert diags[0].severity == "warning"
+ assert diags[0].data["phantom_refs"] == ["t_deadbeef99"]
+
+
+def test_prose_phantom_refs_clears_on_later_clean_edit():
+ task = _task(status="done")
+ events = [
+ _event("completed", ts=100, summary="bad"),
+ _event("suspected_hallucinated_references", ts=101,
+ phantom_refs=["t_ffff0000cc"]),
+ _event("edited", ts=200, fields=["result", "summary"]),
+ ]
+ diags = kd.compute_task_diagnostics(task, events, [])
+ assert diags == []
+
+
+def test_repeated_spawn_failures_fires_at_threshold():
+ task = _task(status="blocked", spawn_failures=3,
+ last_spawn_error="Profile 'debugger' does not exist")
+ diags = kd.compute_task_diagnostics(task, [], [])
+ assert len(diags) == 1
+ d = diags[0]
+ assert d.kind == "repeated_spawn_failures"
+ assert d.severity == "error"
+ # CLI hints are what operators actually need here.
+ suggested = [a.label for a in d.actions if a.suggested]
+ assert any("doctor" in s for s in suggested)
+
+
+def test_repeated_spawn_failures_escalates_to_critical():
+ task = _task(spawn_failures=6, last_spawn_error="boom")
+ diags = kd.compute_task_diagnostics(task, [], [])
+ assert diags[0].severity == "critical"
+
+
+def test_repeated_spawn_failures_below_threshold_silent():
+ task = _task(spawn_failures=2)
+ assert kd.compute_task_diagnostics(task, [], []) == []
+
+
+def test_repeated_crashes_counts_trailing_streak_only():
+ task = _task(status="ready", assignee="crashy")
+ runs = [
+ _run(outcome="completed", run_id=1),
+ _run(outcome="crashed", run_id=2, error="OOM"),
+ _run(outcome="crashed", run_id=3, error="OOM again"),
+ ]
+ diags = kd.compute_task_diagnostics(task, [], runs)
+ assert len(diags) == 1
+ d = diags[0]
+ assert d.kind == "repeated_crashes"
+ # 2 consecutive crashes at the end → default threshold 2 → error severity.
+ assert d.severity == "error"
+ assert d.data["consecutive_crashes"] == 2
+
+
+def test_repeated_crashes_breaks_on_recent_success():
+ task = _task(status="ready", assignee="fixed")
+ runs = [
+ _run(outcome="crashed", run_id=1),
+ _run(outcome="crashed", run_id=2),
+ _run(outcome="completed", run_id=3),
+ ]
+ assert kd.compute_task_diagnostics(task, [], runs) == []
+
+
+def test_repeated_crashes_escalates_on_many_crashes():
+ task = _task(status="ready", assignee="x")
+ runs = [_run(outcome="crashed", run_id=i) for i in range(1, 6)] # 5 in a row
+ diags = kd.compute_task_diagnostics(task, [], runs)
+ assert diags[0].severity == "critical"
+
+
+def test_stuck_in_blocked_fires_past_threshold():
+ now = int(time.time())
+ task = _task(status="blocked")
+ events = [
+ _event("blocked", ts=now - 3600 * 48, reason="needs approval"),
+ ]
+ diags = kd.compute_task_diagnostics(
+ task, events, [], now=now,
+ )
+ assert len(diags) == 1
+ d = diags[0]
+ assert d.kind == "stuck_in_blocked"
+ assert d.severity == "warning"
+ assert d.data["age_hours"] >= 48
+
+
+def test_stuck_in_blocked_silent_with_recent_comment():
+ now = int(time.time())
+ task = _task(status="blocked")
+ events = [
+ _event("blocked", ts=now - 3600 * 48),
+ _event("commented", ts=now - 3600 * 2, author="human"),
+ ]
+ assert kd.compute_task_diagnostics(task, events, [], now=now) == []
+
+
+def test_stuck_in_blocked_silent_when_not_blocked():
+ task = _task(status="ready")
+ events = [_event("blocked", ts=1000)]
+ assert kd.compute_task_diagnostics(task, events, [], now=9999999) == []
+
+
+def test_repeated_crashes_surfaces_actual_error_in_title():
+ """The title should lead with the actual error text so operators
+ see WHAT broke (e.g. rate-limit, auth, OOM) without opening logs.
+ """
+ task = _task(status="ready", assignee="x")
+ runs = [
+ _run(outcome="crashed", run_id=1, error="openai: 429 Too Many Requests"),
+ _run(outcome="crashed", run_id=2, error="openai: 429 Too Many Requests"),
+ ]
+ diags = kd.compute_task_diagnostics(task, [], runs)
+ assert len(diags) == 1
+ d = diags[0]
+ assert "429" in d.title
+ assert "Too Many Requests" in d.title
+ # Full error in detail.
+ assert "429 Too Many Requests" in d.detail
+
+
+def test_repeated_crashes_no_error_fallback_title():
+ task = _task(status="ready", assignee="x")
+ runs = [
+ _run(outcome="crashed", run_id=1, error=None),
+ _run(outcome="crashed", run_id=2, error=None),
+ ]
+ diags = kd.compute_task_diagnostics(task, [], runs)
+ assert "no error recorded" in diags[0].title
+
+
+def test_repeated_spawn_failures_surfaces_actual_error_in_title():
+ task = _task(spawn_failures=5,
+ last_spawn_error="insufficient_quota: billing limit reached")
+ diags = kd.compute_task_diagnostics(task, [], [])
+ assert len(diags) == 1
+ d = diags[0]
+ assert "insufficient_quota" in d.title or "billing limit" in d.title
+ assert "insufficient_quota" in d.detail
+
+
+def test_repeated_crashes_truncates_huge_tracebacks():
+ """Full Python tracebacks can be tens of KB. The title stays one
+ line (≤160 chars); the detail caps at 500 chars + ellipsis so the
+ card doesn't explode visually."""
+ huge = "Traceback (most recent call last):\n" + (" File\n" * 500)
+ task = _task(status="ready")
+ runs = [
+ _run(outcome="crashed", run_id=1, error=huge),
+ _run(outcome="crashed", run_id=2, error=huge),
+ ]
+ diags = kd.compute_task_diagnostics(task, [], runs)
+ d = diags[0]
+ # Title only the first line, capped.
+ assert "\n" not in d.title
+ assert len(d.title) < 250
+ # Detail contains the snippet with ellipsis.
+ assert d.detail.endswith("…") or len(d.detail) < 700
+
+
+# ---------------------------------------------------------------------------
+# Severity sorting
+# ---------------------------------------------------------------------------
+
+
+def test_diagnostics_sorted_critical_first():
+ """A task with both a critical (many spawn failures) and a warning
+ (prose phantoms) diagnostic should list the critical one first."""
+ task = _task(status="done", spawn_failures=10,
+ last_spawn_error="nope")
+ events = [
+ _event("completed", ts=100, summary="referenced t_missing"),
+ _event("suspected_hallucinated_references", ts=101,
+ phantom_refs=["t_missing11"]),
+ ]
+ diags = kd.compute_task_diagnostics(task, events, [])
+ kinds = [d.kind for d in diags]
+ assert kinds[0] == "repeated_spawn_failures" # critical
+ assert "prose_phantom_refs" in kinds
+
+
+# ---------------------------------------------------------------------------
+# Integration — runs through real kanban_db so sqlite.Row fields work
+# ---------------------------------------------------------------------------
+
+
+def test_engine_works_on_sqlite_row_objects(kanban_home):
+ """Regression: the rule functions must handle sqlite3.Row (which
+ supports mapping access but not attribute access and isn't a dict)
+ as well as dataclass Task / plain dict. The API layer passes Row
+ objects directly.
+ """
+ conn = kb.connect()
+ try:
+ parent = kb.create_task(conn, title="p", assignee="w")
+ real = kb.create_task(conn, title="r", assignee="x", created_by="w")
+ with pytest.raises(kb.HallucinatedCardsError):
+ kb.complete_task(
+ conn, parent,
+ summary="with phantom", created_cards=[real, "t_deadbeef1"],
+ )
+ # Pull Row objects the way the API helper does.
+ row = conn.execute(
+ "SELECT * FROM tasks WHERE id = ?", (parent,),
+ ).fetchone()
+ events = list(conn.execute(
+ "SELECT * FROM task_events WHERE task_id = ? ORDER BY id",
+ (parent,),
+ ).fetchall())
+ runs = list(conn.execute(
+ "SELECT * FROM task_runs WHERE task_id = ? ORDER BY id",
+ (parent,),
+ ).fetchall())
+ diags = kd.compute_task_diagnostics(row, events, runs)
+ assert len(diags) == 1
+ assert diags[0].kind == "hallucinated_cards"
+ assert "t_deadbeef1" in diags[0].data["phantom_ids"]
+ finally:
+ conn.close()
+
+
+# ---------------------------------------------------------------------------
+# Error-tolerance: a broken rule shouldn't 500 the whole compute call
+# ---------------------------------------------------------------------------
+
+
+def test_broken_rule_is_isolated(monkeypatch):
+ def _bad_rule(task, events, runs, now, cfg):
+ raise RuntimeError("synthetic rule bug")
+
+ # Insert a broken rule at the front of the registry; subsequent
+ # rules should still run and produce their diagnostics.
+ monkeypatch.setattr(kd, "_RULES", [_bad_rule] + kd._RULES)
+
+ task = _task(spawn_failures=5, last_spawn_error="e")
+ diags = kd.compute_task_diagnostics(task, [], [])
+ # The broken rule silently drops, the real one still fires.
+ kinds = [d.kind for d in diags]
+ assert "repeated_spawn_failures" in kinds
diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py
index 82c67f37a7..0b6a3510f8 100644
--- a/tests/plugins/test_kanban_dashboard_plugin.py
+++ b/tests/plugins/test_kanban_dashboard_plugin.py
@@ -1126,7 +1126,11 @@ def test_home_channels_empty_when_no_homes_configured(client, monkeypatch):
def test_board_surfaces_warnings_field_for_hallucinated_completions(client):
"""Tasks with a pending completion_blocked_hallucination event surface
a ``warnings`` object on the /board payload so the UI can badge
- them without fetching per-task events."""
+ them without fetching per-task events. The warnings summary is
+ keyed by diagnostic kind (``hallucinated_cards``) rather than the
+ raw event kind — see hermes_cli.kanban_diagnostics for the rule
+ that produces it.
+ """
conn = kb.connect()
try:
parent = kb.create_task(conn, title="parent", assignee="alice")
@@ -1150,7 +1154,12 @@ def test_board_surfaces_warnings_field_for_hallucinated_completions(client):
assert parent_dict.get("warnings") is not None
w = parent_dict["warnings"]
assert w["count"] >= 1
- assert "completion_blocked_hallucination" in w["kinds"]
+ assert "hallucinated_cards" in w["kinds"]
+ assert w["highest_severity"] == "error"
+ # Full diagnostic list also on the payload for drawer rendering.
+ assert parent_dict.get("diagnostics") is not None
+ assert parent_dict["diagnostics"][0]["kind"] == "hallucinated_cards"
+ assert "t_deadbeefcafe" in parent_dict["diagnostics"][0]["data"]["phantom_ids"]
def test_board_warnings_cleared_after_clean_completion(client):
@@ -1335,3 +1344,99 @@ def test_reassign_endpoint_with_reclaim_first_succeeds_on_running(client):
assert row["assignee"] == "new"
finally:
conn2.close()
+
+
+# ---------------------------------------------------------------------------
+# Diagnostics endpoint (/api/plugins/kanban/diagnostics)
+# ---------------------------------------------------------------------------
+
+def test_diagnostics_endpoint_empty_for_clean_board(client):
+ r = client.get("/api/plugins/kanban/diagnostics")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["count"] == 0
+ assert data["diagnostics"] == []
+
+
+def test_diagnostics_endpoint_surfaces_blocked_hallucination(client):
+ conn = kb.connect()
+ try:
+ parent = kb.create_task(conn, title="parent", assignee="alice")
+ real = kb.create_task(conn, title="real", assignee="x", created_by="alice")
+ import pytest as _pytest
+ with _pytest.raises(kb.HallucinatedCardsError):
+ kb.complete_task(
+ conn, parent, summary="phantom",
+ created_cards=[real, "t_ffff00001234"],
+ )
+ finally:
+ conn.close()
+
+ r = client.get("/api/plugins/kanban/diagnostics")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["count"] == 1
+ row = data["diagnostics"][0]
+ assert row["task_id"] == parent
+ assert row["diagnostics"][0]["kind"] == "hallucinated_cards"
+ assert row["diagnostics"][0]["severity"] == "error"
+ assert "t_ffff00001234" in row["diagnostics"][0]["data"]["phantom_ids"]
+
+
+def test_diagnostics_endpoint_severity_filter(client):
+ """Warning-severity filter excludes error-severity entries."""
+ conn = kb.connect()
+ try:
+ # A warning-severity diagnostic (prose phantom) on one task.
+ # Phantom id must be valid hex — the prose scanner regex
+ # requires ``t_[a-f0-9]{8,}``.
+ p1 = kb.create_task(conn, title="prose", assignee="a")
+ kb.complete_task(conn, p1, summary="mentioned t_deadbeef1234")
+ # An error-severity diagnostic (spawn failures) on another
+ p2 = kb.create_task(conn, title="spawn", assignee="b")
+ conn.execute(
+ "UPDATE tasks SET spawn_failures=5, last_spawn_error='x' WHERE id=?",
+ (p2,),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ r = client.get("/api/plugins/kanban/diagnostics?severity=warning")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["count"] == 1
+ assert data["diagnostics"][0]["task_id"] == p1
+
+ r = client.get("/api/plugins/kanban/diagnostics?severity=error")
+ data = r.json()
+ assert data["count"] == 1
+ assert data["diagnostics"][0]["task_id"] == p2
+
+
+def test_board_exposes_diagnostics_list_and_summary(client):
+ """/board should attach both the full diagnostics list AND the
+ compact warnings summary (with highest_severity) on each task
+ that has any diagnostic.
+ """
+ conn = kb.connect()
+ try:
+ t = kb.create_task(conn, title="crashy", assignee="worker")
+ # Simulate 2 consecutive crashes -> repeated_crashes error diag
+ for i in range(2):
+ conn.execute(
+ "INSERT INTO task_runs (task_id, status, outcome, started_at, "
+ "ended_at, error) VALUES (?, 'crashed', 'crashed', ?, ?, ?)",
+ (t, int(time.time()) - 100, int(time.time()) - 50, "OOM"),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ r = client.get("/api/plugins/kanban/board")
+ data = r.json()
+ tasks = [x for col in data["columns"] for x in col["tasks"]]
+ task_dict = next(x for x in tasks if x["title"] == "crashy")
+ assert task_dict["warnings"] is not None
+ assert task_dict["warnings"]["highest_severity"] == "error"
+ assert task_dict["diagnostics"][0]["kind"] == "repeated_crashes"
From 72c33dfe955f45b8b78a22d1d98b3e1b702cff8d Mon Sep 17 00:00:00 2001
From: Bartok9
Date: Thu, 23 Apr 2026 14:38:57 +0700
Subject: [PATCH 018/124] docs(agent): remove stale BuiltinMemoryProvider
references from memory module docstrings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The BuiltinMemoryProvider class was removed from the codebase but its
name lingered in the module-level docstrings of memory_manager.py and
memory_provider.py, creating false expectations:
- memory_manager.py docstring showed example code doing
add_provider(BuiltinMemoryProvider(...)) which ImportError at runtime
- memory_provider.py docstring listed BuiltinMemoryProvider as
'always present, not removable' — misleading for new contributors
The regression test (test_memory_user_id.py) already passes without
any reference to BuiltinMemoryProvider; it uses RecordingProvider
instances directly. The stale references were docs-only drift.
Update both docstrings to reflect the actual current architecture:
MemoryManager accepts external plugin providers only (one at a time).
Closes #14402
---
agent/memory_manager.py | 9 +++------
agent/memory_provider.py | 17 ++++++++---------
2 files changed, 11 insertions(+), 15 deletions(-)
diff --git a/agent/memory_manager.py b/agent/memory_manager.py
index ea9b7425fc..9a58735999 100644
--- a/agent/memory_manager.py
+++ b/agent/memory_manager.py
@@ -1,17 +1,14 @@
-"""MemoryManager — orchestrates the built-in memory provider plus at most
-ONE external plugin memory provider.
+"""MemoryManager — orchestrates memory providers for the agent.
Single integration point in run_agent.py. Replaces scattered per-backend
code with one manager that delegates to registered providers.
-The BuiltinMemoryProvider is always registered first and cannot be removed.
-Only ONE external (non-builtin) provider is allowed at a time — attempting
-to register a second external provider is rejected with a warning. This
+Only ONE external plugin provider is allowed at a time — attempting to
+register a second external provider is rejected with a warning. This
prevents tool schema bloat and conflicting memory backends.
Usage in run_agent.py:
self._memory_manager = MemoryManager()
- self._memory_manager.add_provider(BuiltinMemoryProvider(...))
# Only ONE of these:
self._memory_manager.add_provider(plugin_provider)
diff --git a/agent/memory_provider.py b/agent/memory_provider.py
index 1c8dbaf682..c9abc48c7a 100644
--- a/agent/memory_provider.py
+++ b/agent/memory_provider.py
@@ -1,17 +1,16 @@
"""Abstract base class for pluggable memory providers.
-Memory providers give the agent persistent recall across sessions. One
-external provider is active at a time alongside the always-on built-in
-memory (MEMORY.md / USER.md). The MemoryManager enforces this limit.
+Memory providers give the agent persistent recall across sessions.
+The MemoryManager enforces a one-external-provider limit to prevent
+tool schema bloat and conflicting memory backends.
-Built-in memory is always active as the first provider and cannot be removed.
-External providers (Honcho, Hindsight, Mem0, etc.) are additive — they never
-disable the built-in store. Only one external provider runs at a time to
-prevent tool schema bloat and conflicting memory backends.
+External providers (Honcho, Hindsight, Mem0, etc.) are registered
+and managed via MemoryManager. Only one external provider runs at a
+time.
Registration:
- 1. Built-in: BuiltinMemoryProvider — always present, not removable.
- 2. Plugins: Ship in plugins/memory//, activated by memory.provider config.
+ Plugins ship in plugins/memory// and are activated via
+ the memory.provider config key.
Lifecycle (called by MemoryManager, wired in run_agent.py):
initialize() — connect, create resources, warm up
From 91f339b98193d884af50299afc90ad28f9f35884 Mon Sep 17 00:00:00 2001
From: r266-tech
Date: Thu, 16 Apr 2026 18:12:45 +0800
Subject: [PATCH 019/124] docs(plugins): document ctx.dispatch_tool() in plugin
capabilities table
---
website/docs/user-guide/features/plugins.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md
index ee19888225..383c8aaa83 100644
--- a/website/docs/user-guide/features/plugins.md
+++ b/website/docs/user-guide/features/plugins.md
@@ -98,6 +98,7 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable
| Add tools | `ctx.register_tool(name=..., toolset=..., schema=..., handler=...)` |
| Add hooks | `ctx.register_hook("post_tool_call", callback)` |
| Add slash commands | `ctx.register_command(name, handler, description)` — adds `/name` in CLI and gateway sessions |
+| Dispatch tools from commands | `ctx.dispatch_tool(name, args)` — invokes a registered tool with parent-agent context auto-wired |
| Add CLI commands | `ctx.register_cli_command(name, help, setup_fn, handler_fn)` — adds `hermes ` |
| Inject messages | `ctx.inject_message(content, role="user")` — see [Injecting Messages](#injecting-messages) |
| Ship data files | `Path(__file__).parent / "data" / "file.yaml"` |
From b6e4e40df4e5ab219ef20d9c070eb59760410150 Mon Sep 17 00:00:00 2001
From: r266-tech
Date: Thu, 16 Apr 2026 18:12:51 +0800
Subject: [PATCH 020/124] docs(guide): add Dispatch tools from slash commands
section
---
website/docs/guides/build-a-hermes-plugin.md | 39 ++++++++++++++++++++
1 file changed, 39 insertions(+)
diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md
index 3b1afb4870..d702b70b43 100644
--- a/website/docs/guides/build-a-hermes-plugin.md
+++ b/website/docs/guides/build-a-hermes-plugin.md
@@ -628,6 +628,45 @@ def register(ctx):
ctx.register_command("check", handler=_handle_check, description="Run async check")
```
+### Dispatch tools from slash commands
+
+Slash command handlers that need to orchestrate tools (spawn a subagent via `delegate_task`, call `file_edit`, etc.) should use `ctx.dispatch_tool()` instead of reaching into framework internals. The parent-agent context (workspace hints, spinner, model inheritance) is wired up automatically.
+
+```python
+def register(ctx):
+ def _handle_deliver(raw_args: str):
+ result = ctx.dispatch_tool(
+ "delegate_task",
+ {
+ "goal": raw_args,
+ "toolsets": ["terminal", "file", "web"],
+ },
+ )
+ return result
+
+ ctx.register_command(
+ "deliver",
+ handler=_handle_deliver,
+ description="Delegate a goal to a subagent",
+ )
+```
+
+**Signature:** `ctx.dispatch_tool(name: str, args: dict, *, parent_agent=None) -> str`
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `name` | `str` | Tool name as registered in the tool registry (e.g. `"delegate_task"`, `"file_edit"`) |
+| `args` | `dict` | Tool arguments, same shape the model would send |
+| `parent_agent` | `Agent \| None` | Optional override. When omitted, resolves from the current CLI agent (or degrades gracefully in gateway mode) |
+
+**Runtime behavior:**
+
+- **CLI mode:** `parent_agent` is resolved from the active CLI agent so workspace hints, spinner, and model selection inherit as expected.
+- **Gateway mode:** There is no CLI agent, so tools degrade gracefully — workspace is read from `TERMINAL_CWD` and no spinner is shown.
+- **Explicit override:** If the caller passes `parent_agent=` explicitly, it is respected and not overwritten.
+
+This is the public, stable interface for tool dispatch from plugin commands. Plugins should not reach into `ctx._cli_ref.agent` or similar private state.
+
:::tip
This guide covers **general plugins** (tools, hooks, slash commands, CLI commands). For specialized plugin types, see:
- [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) — cross-session knowledge backends
From e4723f671a594f515becbf5d42ae703e79244510 Mon Sep 17 00:00:00 2001
From: Tony Simons
Date: Tue, 5 May 2026 13:31:32 -0700
Subject: [PATCH 021/124] docs(cron): add context_from chaining section
Resolved merge against current main (new No-agent mode section added in parallel).
Co-authored-by: Tony Simons
---
website/docs/user-guide/features/cron.md | 55 ++++++++++++++++++++++++
1 file changed, 55 insertions(+)
diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md
index dd151dece7..f02b13934f 100644
--- a/website/docs/user-guide/features/cron.md
+++ b/website/docs/user-guide/features/cron.md
@@ -331,6 +331,61 @@ It picks `no_agent=True` automatically when the message content is fully determi
See the [Script-Only Cron Jobs guide](/docs/guides/cron-script-only) for worked examples.
+## Chaining jobs with `context_from`
+
+Cron jobs run in isolated sessions with no memory of previous runs. But sometimes one job's output is exactly what the next job needs. The `context_from` parameter wires that connection automatically — Job B's prompt gets Job A's most recent output prepended as context at runtime.
+
+```python
+# Job 1: Collect raw data
+cronjob(
+ action="create",
+ prompt="Fetch the top 10 AI/ML stories from Hacker News. Save them to ~/.hermes/data/briefs/raw.md in markdown format with title, URL, and score.",
+ schedule="0 7 * * *",
+ name="AI News Collector",
+)
+
+# Job 2: Triage — receives Job 1's output as context
+# Get Job 1's ID from: cronjob(action="list")
+cronjob(
+ action="create",
+ prompt="Read ~/.hermes/data/briefs/raw.md. Score each story 1–10 for engagement potential and novelty. Output the top 5 to ~/.hermes/data/briefs/ranked.md.",
+ schedule="30 7 * * *",
+ context_from="",
+ name="AI News Triage",
+)
+
+# Job 3: Ship — receives Job 2's output as context
+cronjob(
+ action="create",
+ prompt="Read ~/.hermes/data/briefs/ranked.md. Write 3 tweet drafts (hook + body + hashtags). Deliver to telegram:7976161601.",
+ schedule="0 8 * * *",
+ context_from="",
+ name="AI News Brief",
+)
+```
+
+**How it works:**
+
+- When Job 2 fires, Hermes reads Job 1's most recent output from `~/.hermes/cron/output/{job1_id}/*.md`
+- That output is prepended to Job 2's prompt automatically
+- Job 2 doesn't need to hardcode "read this file" — it receives the content as context
+- The chain can be any length: Job 1 → Job 2 → Job 3 → ...
+
+**What `context_from` accepts:**
+
+| Format | Example |
+|--------|---------|
+| Single job ID (string) | `context_from="a1b2c3d4"` |
+| Multiple job IDs (list) | `context_from=["job_a", "job_b"]` |
+
+Outputs are concatenated in the order listed.
+
+**When to use it:**
+
+- Multi-stage pipelines (collect → filter → format → deliver)
+- Dependent tasks where step N's work depends on step N−1's output
+- Fan-out/fan-in patterns where one job aggregates results from several others
+
## Provider recovery
Cron jobs inherit your configured fallback providers and credential pool rotation. If the primary API key is rate-limited or the provider returns an error, the cron agent can:
From 2b500ed68a02bec5925b75776a3779bfa8f58383 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:31:32 -0700
Subject: [PATCH 022/124] chore: AUTHOR_MAP entry for asimons81
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index 916cc4b5e0..109a36abb1 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -86,6 +86,7 @@ AUTHOR_MAP = {
"emelyanenko.kirill@gmail.com": "EmelyanenkoK",
"lazycat.manatee@gmail.com": "manateelazycat",
"bzarnitz13@gmail.com": "Beandon13",
+ "tony@tonysimons.dev": "asimons81",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 20a4f79ed11da67318756d7a98141c0ebf56183f Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Tue, 5 May 2026 10:18:49 -0700
Subject: [PATCH 023/124] =?UTF-8?q?feat:=20provider=20modules=20=E2=80=94?=
=?UTF-8?q?=20ProviderProfile=20ABC,=2033=20providers,=20fetch=5Fmodels,?=
=?UTF-8?q?=20transport=20single-path?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduces providers/ package — single source of truth for every
inference provider. Adding a simple api-key provider now requires one
providers/.py file with zero edits anywhere else.
What this PR ships:
- providers/ package (ProviderProfile ABC + 33 profiles across 4 api_modes)
- ProviderProfile declarative fields: name, api_mode, aliases, display_name,
env_vars, base_url, models_url, auth_type, fallback_models, hostname,
default_headers, fixed_temperature, default_max_tokens, default_aux_model
- 4 overridable hooks: prepare_messages, build_extra_body,
build_api_kwargs_extras, fetch_models
- chat_completions.build_kwargs: profile path via _build_kwargs_from_profile,
legacy flag path retained for lmstudio/tencent-tokenhub (which have
session-aware reasoning probing that doesn't map cleanly to hooks yet)
- run_agent.py: profile path for all registered providers; legacy path
variable scoping fixed (all flags defined before branching)
- Auto-wires: auth.PROVIDER_REGISTRY, models.CANONICAL_PROVIDERS,
doctor health checks, config.OPTIONAL_ENV_VARS, model_metadata._URL_TO_PROVIDER
- GeminiProfile: thinking_config translation (native + openai-compat nested)
- New tests/providers/ (79 tests covering profile declarations, transport
parity, hook overrides, e2e kwargs assembly)
Deltas vs original PR (salvaged onto current main):
- Added profiles: alibaba-coding-plan, azure-foundry, minimax-oauth
(were added to main since original PR)
- Skipped profiles: lmstudio, tencent-tokenhub stay on legacy path (their
reasoning_effort probing has no clean hook equivalent yet)
- Removed lmstudio alias from custom profile (it's a separate provider now)
- Skipped openrouter/custom from PROVIDER_REGISTRY auto-extension
(resolve_provider special-cases them; adding breaks runtime resolution)
- runtime_provider: profile.api_mode only as fallback when URL detection
finds nothing (was breaking minimax /v1 override)
- Preserved main's legacy-path improvements: deepseek reasoning_content
preserve, gemini Gemma skip, OpenRouter response caching, Anthropic 1M
beta recovery, etc.
- Kept agent/copilot_acp_client.py in place (rejected PR's relocation —
main has 7 fixes landed since; relocation would revert them)
- _API_KEY_PROVIDER_AUX_MODELS alias kept for backward compat with existing
test imports
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Closes #14418
---
agent/auxiliary_client.py | 49 ++-
agent/model_metadata.py | 11 +
agent/transports/__init__.py | 14 +-
agent/transports/chat_completions.py | 248 +++++++++-----
agent/transports/types.py | 29 +-
hermes_cli/auth.py | 45 +++
hermes_cli/config.py | 42 +++
hermes_cli/doctor.py | 105 ++++--
hermes_cli/main.py | 33 +-
hermes_cli/models.py | 47 +++
providers/README.md | 307 ++++++++++++++++++
providers/__init__.py | 76 +++++
providers/alibaba.py | 13 +
providers/alibaba_coding_plan.py | 21 ++
providers/anthropic.py | 52 +++
providers/arcee.py | 13 +
providers/azure_foundry.py | 21 ++
providers/base.py | 165 ++++++++++
providers/bedrock.py | 29 ++
providers/copilot.py | 58 ++++
providers/copilot_acp.py | 34 ++
providers/custom.py | 68 ++++
providers/deepseek.py | 20 ++
providers/gemini.py | 72 ++++
providers/gmi.py | 26 ++
providers/huggingface.py | 20 ++
providers/kilocode.py | 14 +
providers/kimi.py | 71 ++++
providers/minimax.py | 45 +++
providers/nous.py | 53 +++
providers/nvidia.py | 21 ++
providers/ollama_cloud.py | 14 +
providers/openai_codex.py | 15 +
providers/opencode.py | 30 ++
providers/openrouter.py | 86 +++++
providers/qwen.py | 82 +++++
providers/stepfun.py | 14 +
providers/vercel.py | 43 +++
providers/xai.py | 15 +
providers/xiaomi.py | 13 +
providers/zai.py | 21 ++
pyproject.toml | 2 +-
run_agent.py | 73 ++++-
tests/agent/test_minimax_provider.py | 14 +-
.../agent/transports/test_chat_completions.py | 68 +++-
tests/hermes_cli/test_gmi_provider.py | 4 +-
tests/providers/__init__.py | 0
tests/providers/test_e2e_wiring.py | 118 +++++++
tests/providers/test_profile_wiring.py | 290 +++++++++++++++++
tests/providers/test_provider_profiles.py | 203 ++++++++++++
tests/providers/test_transport_parity.py | 258 +++++++++++++++
tests/run_agent/test_run_agent.py | 54 ++-
.../docs/developer-guide/adding-providers.md | 36 ++
.../docs/developer-guide/provider-runtime.md | 3 +
website/docs/integrations/providers.md | 40 ++-
.../docs/reference/environment-variables.md | 6 +-
.../user-guide/features/fallback-providers.md | 2 +
57 files changed, 3149 insertions(+), 177 deletions(-)
create mode 100644 providers/README.md
create mode 100644 providers/__init__.py
create mode 100644 providers/alibaba.py
create mode 100644 providers/alibaba_coding_plan.py
create mode 100644 providers/anthropic.py
create mode 100644 providers/arcee.py
create mode 100644 providers/azure_foundry.py
create mode 100644 providers/base.py
create mode 100644 providers/bedrock.py
create mode 100644 providers/copilot.py
create mode 100644 providers/copilot_acp.py
create mode 100644 providers/custom.py
create mode 100644 providers/deepseek.py
create mode 100644 providers/gemini.py
create mode 100644 providers/gmi.py
create mode 100644 providers/huggingface.py
create mode 100644 providers/kilocode.py
create mode 100644 providers/kimi.py
create mode 100644 providers/minimax.py
create mode 100644 providers/nous.py
create mode 100644 providers/nvidia.py
create mode 100644 providers/ollama_cloud.py
create mode 100644 providers/openai_codex.py
create mode 100644 providers/opencode.py
create mode 100644 providers/openrouter.py
create mode 100644 providers/qwen.py
create mode 100644 providers/stepfun.py
create mode 100644 providers/vercel.py
create mode 100644 providers/xai.py
create mode 100644 providers/xiaomi.py
create mode 100644 providers/zai.py
create mode 100644 tests/providers/__init__.py
create mode 100644 tests/providers/test_e2e_wiring.py
create mode 100644 tests/providers/test_profile_wiring.py
create mode 100644 tests/providers/test_provider_profiles.py
create mode 100644 tests/providers/test_transport_parity.py
diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index 54a1d63a7f..337ed21ea3 100644
--- a/agent/auxiliary_client.py
+++ b/agent/auxiliary_client.py
@@ -216,7 +216,26 @@ def _fixed_temperature_for_model(
return None
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
-_API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
+def _get_aux_model_for_provider(provider_id: str) -> str:
+ """Return the cheap auxiliary model for a provider.
+
+ Reads from ProviderProfile.default_aux_model first, falling back to the
+ legacy hardcoded dict for providers that predate the profiles system.
+ """
+ try:
+ from providers import get_provider_profile
+ _p = get_provider_profile(provider_id)
+ if _p and _p.default_aux_model:
+ return _p.default_aux_model
+ except Exception:
+ pass
+ return _API_KEY_PROVIDER_AUX_MODELS_FALLBACK.get(provider_id, "")
+
+
+# Fallback for providers not yet migrated to ProviderProfile.default_aux_model,
+# plus providers we intentionally keep pinned here (e.g. Anthropic predates
+# profiles). New providers should set default_aux_model on their profile instead.
+_API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"gemini": "gemini-3-flash-preview",
"zai": "glm-4.5-flash",
"kimi-coding": "kimi-k2-turbo-preview",
@@ -235,6 +254,10 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
"tencent-tokenhub": "hy3-preview",
}
+# Legacy alias — callers that haven't been updated to _get_aux_model_for_provider()
+# can still use this dict directly. Kept in sync with _FALLBACK above.
+_API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = _API_KEY_PROVIDER_AUX_MODELS_FALLBACK
+
# Vision-specific model overrides for direct providers.
# When the user's main provider has a dedicated vision/multimodal model that
# differs from their main chat model, map it here. The vision auto-detect
@@ -1157,7 +1180,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
raw_base_url = _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
base_url = _to_openai_base_url(raw_base_url)
- model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
+ model = _get_aux_model_for_provider(provider_id) or None
if model is None:
continue # skip provider if we don't know a valid aux model
logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model)
@@ -1173,6 +1196,14 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
from hermes_cli.models import copilot_default_headers
extra["default_headers"] = copilot_default_headers()
+ else:
+ try:
+ from providers import get_provider_profile as _gpf_aux
+ _ph_aux = _gpf_aux(provider_id)
+ if _ph_aux and _ph_aux.default_headers:
+ extra["default_headers"] = dict(_ph_aux.default_headers)
+ except Exception:
+ pass
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
@@ -1184,7 +1215,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
base_url = _to_openai_base_url(raw_base_url)
- model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
+ model = _get_aux_model_for_provider(provider_id) or None
if model is None:
continue # skip provider if we don't know a valid aux model
logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
@@ -1200,6 +1231,14 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
from hermes_cli.models import copilot_default_headers
extra["default_headers"] = copilot_default_headers()
+ else:
+ try:
+ from providers import get_provider_profile as _gpf_aux2
+ _ph_aux2 = _gpf_aux2(provider_id)
+ if _ph_aux2 and _ph_aux2.default_headers:
+ extra["default_headers"] = dict(_ph_aux2.default_headers)
+ except Exception:
+ pass
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
@@ -1572,7 +1611,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
from agent.anthropic_adapter import _is_oauth_token
is_oauth = _is_oauth_token(token)
- model = _API_KEY_PROVIDER_AUX_MODELS.get("anthropic", "claude-haiku-4-5-20251001")
+ model = _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001"
logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", model, base_url, is_oauth)
try:
real_client = build_anthropic_client(token, base_url)
@@ -2408,7 +2447,7 @@ def resolve_provider_client(
if explicit_base_url:
base_url = _to_openai_base_url(explicit_base_url.strip().rstrip("/"))
- default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
+ default_model = _get_aux_model_for_provider(provider)
final_model = _normalize_resolved_model(model or default_model, provider)
if provider == "gemini":
diff --git a/agent/model_metadata.py b/agent/model_metadata.py
index 12117f1446..c362a9ec93 100644
--- a/agent/model_metadata.py
+++ b/agent/model_metadata.py
@@ -318,6 +318,17 @@ _URL_TO_PROVIDER: Dict[str, str] = {
"ollama.com": "ollama-cloud",
}
+# Auto-extend with hostnames derived from provider profiles.
+# Any provider with a base_url not already in the map gets added automatically.
+try:
+ from providers import list_providers as _list_providers
+ for _pp in _list_providers():
+ _host = _pp.get_hostname()
+ if _host and _host not in _URL_TO_PROVIDER:
+ _URL_TO_PROVIDER[_host] = _pp.name
+except Exception:
+ pass
+
def _infer_provider_from_url(base_url: str) -> Optional[str]:
"""Infer the models.dev provider name from a base URL.
diff --git a/agent/transports/__init__.py b/agent/transports/__init__.py
index d1c8251ed2..b606da7fec 100644
--- a/agent/transports/__init__.py
+++ b/agent/transports/__init__.py
@@ -6,9 +6,16 @@ Usage:
result = transport.normalize_response(raw_response)
"""
-from agent.transports.types import NormalizedResponse, ToolCall, Usage, build_tool_call, map_finish_reason # noqa: F401
+from agent.transports.types import (
+ NormalizedResponse,
+ ToolCall,
+ Usage,
+ build_tool_call,
+ map_finish_reason,
+) # noqa: F401
_REGISTRY: dict = {}
+_discovered: bool = False
def register_transport(api_mode: str, transport_cls: type) -> None:
@@ -23,6 +30,9 @@ def get_transport(api_mode: str):
This allows gradual migration — call sites can check for None
and fall back to the legacy code path.
"""
+ global _discovered
+ if not _discovered:
+ _discover_transports()
cls = _REGISTRY.get(api_mode)
if cls is None:
# The registry can be partially populated when a specific transport
@@ -38,6 +48,8 @@ def get_transport(api_mode: str):
def _discover_transports() -> None:
"""Import all transport modules to trigger auto-registration."""
+ global _discovered
+ _discovered = True
try:
import agent.transports.anthropic # noqa: F401
except ImportError:
diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py
index 9a115e4547..ca29b39ffe 100644
--- a/agent/transports/chat_completions.py
+++ b/agent/transports/chat_completions.py
@@ -109,7 +109,9 @@ class ChatCompletionsTransport(ProviderTransport):
def api_mode(self) -> str:
return "chat_completions"
- def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> List[Dict[str, Any]]:
+ def convert_messages(
+ self, messages: list[dict[str, Any]], **kwargs
+ ) -> list[dict[str, Any]]:
"""Messages are already in OpenAI format — sanitize Codex leaks only.
Strips Codex Responses API fields (``codex_reasoning_items`` /
@@ -126,7 +128,9 @@ class ChatCompletionsTransport(ProviderTransport):
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
for tc in tool_calls:
- if isinstance(tc, dict) and ("call_id" in tc or "response_item_id" in tc):
+ if isinstance(tc, dict) and (
+ "call_id" in tc or "response_item_id" in tc
+ ):
needs_sanitize = True
break
if needs_sanitize:
@@ -149,39 +153,41 @@ class ChatCompletionsTransport(ProviderTransport):
tc.pop("response_item_id", None)
return sanitized
- def convert_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Tools are already in OpenAI format — identity."""
return tools
def build_kwargs(
self,
model: str,
- messages: List[Dict[str, Any]],
- tools: Optional[List[Dict[str, Any]]] = None,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
**params,
- ) -> Dict[str, Any]:
+ ) -> dict[str, Any]:
"""Build chat.completions.create() kwargs.
- This is the most complex transport method — it handles ~16 providers
- via params rather than subclasses.
-
- params:
+ params (all optional):
timeout: float — API call timeout
max_tokens: int | None — user-configured max tokens
- ephemeral_max_output_tokens: int | None — one-shot override (error recovery)
+ ephemeral_max_output_tokens: int | None — one-shot override
max_tokens_param_fn: callable — returns {max_tokens: N} or {max_completion_tokens: N}
reasoning_config: dict | None
request_overrides: dict | None
session_id: str | None
- qwen_session_metadata: dict | None — {sessionId, promptId} precomputed
model_lower: str — lowercase model name for pattern matching
- # Provider detection flags (all optional, default False)
+ # Provider profile path (all per-provider quirks live in providers/)
+ provider_profile: ProviderProfile | None — when present, delegates to
+ _build_kwargs_from_profile(); all flag params below are bypassed.
+ # Legacy-path flags — only used when provider_profile is None
+ # (i.e. custom / unregistered providers). Known providers all go
+ # through provider_profile.
is_openrouter: bool
is_nous: bool
is_qwen_portal: bool
is_github_models: bool
is_nvidia_nim: bool
is_kimi: bool
+ is_tokenhub: bool
is_lmstudio: bool
is_custom_provider: bool
ollama_num_ctx: int | None
@@ -190,6 +196,7 @@ class ChatCompletionsTransport(ProviderTransport):
# Qwen-specific
qwen_prepare_fn: callable | None — runs AFTER codex sanitization
qwen_prepare_inplace_fn: callable | None — in-place variant for deepcopied lists
+ qwen_session_metadata: dict | None
# Temperature
fixed_temperature: Any — from _fixed_temperature_for_model()
omit_temperature: bool
@@ -199,28 +206,21 @@ class ChatCompletionsTransport(ProviderTransport):
lmstudio_reasoning_options: list[str] | None # raw allowed_options from /api/v1/models
# Claude on OpenRouter/Nous max output
anthropic_max_output: int | None
- # Extra
- extra_body_additions: dict | None — pre-built extra_body entries
+ extra_body_additions: dict | None
"""
# Codex sanitization: drop reasoning_items / call_id / response_item_id
sanitized = self.convert_messages(messages)
- # Qwen portal prep AFTER codex sanitization. If sanitize already
- # deepcopied, reuse that copy via the in-place variant to avoid a
- # second deepcopy.
- is_qwen = params.get("is_qwen_portal", False)
- if is_qwen:
- qwen_prep = params.get("qwen_prepare_fn")
- qwen_prep_inplace = params.get("qwen_prepare_inplace_fn")
- if sanitized is messages:
- if qwen_prep is not None:
- sanitized = qwen_prep(sanitized)
- else:
- # Already deepcopied — transform in place
- if qwen_prep_inplace is not None:
- qwen_prep_inplace(sanitized)
- elif qwen_prep is not None:
- sanitized = qwen_prep(sanitized)
+ # ── Provider profile: single-path when present ──────────────────
+ _profile = params.get("provider_profile")
+ if _profile:
+ return self._build_kwargs_from_profile(
+ _profile, model, sanitized, tools, params
+ )
+
+ # ── Legacy fallback (unregistered / unknown provider) ───────────
+ # Reached only when get_provider_profile() returned None.
+ # Known providers always go through the profile path above.
# Developer role swap for GPT-5/Codex models
model_lower = params.get("model_lower", (model or "").lower())
@@ -233,7 +233,7 @@ class ChatCompletionsTransport(ProviderTransport):
sanitized = list(sanitized)
sanitized[0] = {**sanitized[0], "role": "developer"}
- api_kwargs: Dict[str, Any] = {
+ api_kwargs: dict[str, Any] = {
"model": model,
"messages": sanitized,
}
@@ -242,19 +242,6 @@ class ChatCompletionsTransport(ProviderTransport):
if timeout is not None:
api_kwargs["timeout"] = timeout
- # Temperature
- fixed_temp = params.get("fixed_temperature")
- omit_temp = params.get("omit_temperature", False)
- if omit_temp:
- api_kwargs.pop("temperature", None)
- elif fixed_temp is not None:
- api_kwargs["temperature"] = fixed_temp
-
- # Qwen metadata (caller precomputes {sessionId, promptId})
- qwen_meta = params.get("qwen_session_metadata")
- if qwen_meta and is_qwen:
- api_kwargs["metadata"] = qwen_meta
-
# Tools
if tools:
# Moonshot/Kimi uses a stricter flavored JSON Schema. Rewriting
@@ -278,13 +265,6 @@ class ChatCompletionsTransport(ProviderTransport):
api_kwargs.update(max_tokens_fn(ephemeral))
elif max_tokens is not None and max_tokens_fn:
api_kwargs.update(max_tokens_fn(max_tokens))
- elif is_nvidia_nim and max_tokens_fn:
- api_kwargs.update(max_tokens_fn(16384))
- elif is_qwen and max_tokens_fn:
- api_kwargs.update(max_tokens_fn(65536))
- elif is_kimi and max_tokens_fn:
- # Kimi/Moonshot: 32000 matches Kimi CLI's default
- api_kwargs.update(max_tokens_fn(32000))
elif anthropic_max_out is not None:
api_kwargs["max_tokens"] = anthropic_max_out
@@ -331,7 +311,7 @@ class ChatCompletionsTransport(ProviderTransport):
api_kwargs["reasoning_effort"] = _lm_effort
# extra_body assembly
- extra_body: Dict[str, Any] = {}
+ extra_body: dict[str, Any] = {}
is_openrouter = params.get("is_openrouter", False)
is_nous = params.get("is_nous", False)
@@ -361,35 +341,7 @@ class ChatCompletionsTransport(ProviderTransport):
if gh_reasoning is not None:
extra_body["reasoning"] = gh_reasoning
else:
- if reasoning_config is not None:
- rc = dict(reasoning_config)
- if is_nous and rc.get("enabled") is False:
- pass # omit for Nous when disabled
- else:
- extra_body["reasoning"] = rc
- else:
- extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
-
- if is_nous:
- extra_body["tags"] = ["product=hermes-agent"]
-
- # Ollama num_ctx
- ollama_ctx = params.get("ollama_num_ctx")
- if ollama_ctx:
- options = extra_body.get("options", {})
- options["num_ctx"] = ollama_ctx
- extra_body["options"] = options
-
- # Ollama/custom think=false
- if params.get("is_custom_provider", False):
- if reasoning_config and isinstance(reasoning_config, dict):
- _effort = (reasoning_config.get("effort") or "").strip().lower()
- _enabled = reasoning_config.get("enabled", True)
- if _effort == "none" or _enabled is False:
- extra_body["think"] = False
-
- if is_qwen:
- extra_body["vl_high_resolution_images"] = True
+ extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
if provider_name == "gemini":
raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config)
@@ -423,6 +375,120 @@ class ChatCompletionsTransport(ProviderTransport):
return api_kwargs
+ def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params):
+ """Build API kwargs using a ProviderProfile — single path, no legacy flags.
+
+ This method replaces the entire flag-based kwargs assembly when a
+ provider_profile is passed. Every quirk comes from the profile object.
+ """
+ from providers.base import OMIT_TEMPERATURE
+
+ # Message preprocessing
+ sanitized = profile.prepare_messages(sanitized)
+
+ # Developer role swap — model-name-based, applies to all providers
+ _model_lower = (model or "").lower()
+ if (
+ sanitized
+ and isinstance(sanitized[0], dict)
+ and sanitized[0].get("role") == "system"
+ and any(p in _model_lower for p in DEVELOPER_ROLE_MODELS)
+ ):
+ sanitized = list(sanitized)
+ sanitized[0] = {**sanitized[0], "role": "developer"}
+
+ api_kwargs: dict[str, Any] = {
+ "model": model,
+ "messages": sanitized,
+ }
+
+ # Temperature
+ if profile.fixed_temperature is OMIT_TEMPERATURE:
+ pass # Don't include temperature at all
+ elif profile.fixed_temperature is not None:
+ api_kwargs["temperature"] = profile.fixed_temperature
+ else:
+ # Use caller's temperature if provided
+ temp = params.get("temperature")
+ if temp is not None:
+ api_kwargs["temperature"] = temp
+
+ # Timeout
+ timeout = params.get("timeout")
+ if timeout is not None:
+ api_kwargs["timeout"] = timeout
+
+ # Tools — apply Moonshot/Kimi schema sanitization regardless of path
+ if tools:
+ if is_moonshot_model(model):
+ tools = sanitize_moonshot_tools(tools)
+ api_kwargs["tools"] = tools
+
+ # max_tokens resolution — priority: ephemeral > user > profile default
+ max_tokens_fn = params.get("max_tokens_param_fn")
+ ephemeral = params.get("ephemeral_max_output_tokens")
+ user_max = params.get("max_tokens")
+ anthropic_max = params.get("anthropic_max_output")
+
+ if ephemeral is not None and max_tokens_fn:
+ api_kwargs.update(max_tokens_fn(ephemeral))
+ elif user_max is not None and max_tokens_fn:
+ api_kwargs.update(max_tokens_fn(user_max))
+ elif profile.default_max_tokens and max_tokens_fn:
+ api_kwargs.update(max_tokens_fn(profile.default_max_tokens))
+ elif anthropic_max is not None:
+ api_kwargs["max_tokens"] = anthropic_max
+
+ # Provider-specific api_kwargs extras (reasoning_effort, metadata, etc.)
+ reasoning_config = params.get("reasoning_config")
+ extra_body_from_profile, top_level_from_profile = (
+ profile.build_api_kwargs_extras(
+ reasoning_config=reasoning_config,
+ supports_reasoning=params.get("supports_reasoning", False),
+ qwen_session_metadata=params.get("qwen_session_metadata"),
+ model=model,
+ ollama_num_ctx=params.get("ollama_num_ctx"),
+ )
+ )
+ api_kwargs.update(top_level_from_profile)
+
+ # extra_body assembly
+ extra_body: dict[str, Any] = {}
+
+ # Profile's extra_body (tags, provider prefs, vl_high_resolution, etc.)
+ profile_body = profile.build_extra_body(
+ session_id=params.get("session_id"),
+ provider_preferences=params.get("provider_preferences"),
+ model=model,
+ base_url=params.get("base_url"),
+ reasoning_config=reasoning_config,
+ )
+ if profile_body:
+ extra_body.update(profile_body)
+
+ # Profile's reasoning/thinking extra_body entries
+ if extra_body_from_profile:
+ extra_body.update(extra_body_from_profile)
+
+ # Merge any pre-built extra_body additions from the caller
+ additions = params.get("extra_body_additions")
+ if additions:
+ extra_body.update(additions)
+
+ # Request overrides (user config)
+ overrides = params.get("request_overrides")
+ if overrides:
+ for k, v in overrides.items():
+ if k == "extra_body" and isinstance(v, dict):
+ extra_body.update(v)
+ else:
+ api_kwargs[k] = v
+
+ if extra_body:
+ api_kwargs["extra_body"] = extra_body
+
+ return api_kwargs
+
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
"""Normalize OpenAI ChatCompletion to NormalizedResponse.
@@ -444,7 +510,7 @@ class ChatCompletionsTransport(ProviderTransport):
# Gemini 3 thinking models attach extra_content with
# thought_signature — without replay on the next turn the API
# rejects the request with 400.
- tc_provider_data: Dict[str, Any] = {}
+ tc_provider_data: dict[str, Any] = {}
extra = getattr(tc, "extra_content", None)
if extra is None and hasattr(tc, "model_extra"):
extra = (tc.model_extra or {}).get("extra_content")
@@ -455,12 +521,14 @@ class ChatCompletionsTransport(ProviderTransport):
except Exception:
pass
tc_provider_data["extra_content"] = extra
- tool_calls.append(ToolCall(
- id=tc.id,
- name=tc.function.name,
- arguments=tc.function.arguments,
- provider_data=tc_provider_data or None,
- ))
+ tool_calls.append(
+ ToolCall(
+ id=tc.id,
+ name=tc.function.name,
+ arguments=tc.function.arguments,
+ provider_data=tc_provider_data or None,
+ )
+ )
usage = None
if hasattr(response, "usage") and response.usage:
@@ -508,7 +576,7 @@ class ChatCompletionsTransport(ProviderTransport):
return False
return True
- def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
+ def extract_cache_stats(self, response: Any) -> dict[str, int] | None:
"""Extract OpenRouter/OpenAI cache stats from prompt_tokens_details."""
usage = getattr(response, "usage", None)
if usage is None:
diff --git a/agent/transports/types.py b/agent/transports/types.py
index 68a807b47c..f0da1eb6f8 100644
--- a/agent/transports/types.py
+++ b/agent/transports/types.py
@@ -12,7 +12,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass, field
-from typing import Any, Dict, List, Optional
+from typing import Any
@dataclass
@@ -32,10 +32,10 @@ class ToolCall:
* Others: ``None``
"""
- id: Optional[str]
+ id: str | None
name: str
arguments: str # JSON string
- provider_data: Optional[Dict[str, Any]] = field(default=None, repr=False)
+ provider_data: dict[str, Any] | None = field(default=None, repr=False)
# ── Backward compatibility ──────────────────────────────────
# The agent loop reads tc.function.name / tc.function.arguments
@@ -47,17 +47,17 @@ class ToolCall:
return "function"
@property
- def function(self) -> "ToolCall":
+ def function(self) -> ToolCall:
"""Return self so tc.function.name / tc.function.arguments work."""
return self
@property
- def call_id(self) -> Optional[str]:
+ def call_id(self) -> str | None:
"""Codex call_id from provider_data, accessed via getattr by _build_assistant_message."""
return (self.provider_data or {}).get("call_id")
@property
- def response_item_id(self) -> Optional[str]:
+ def response_item_id(self) -> str | None:
"""Codex response_item_id from provider_data."""
return (self.provider_data or {}).get("response_item_id")
@@ -101,18 +101,18 @@ class NormalizedResponse:
* Others: ``None``
"""
- content: Optional[str]
- tool_calls: Optional[List[ToolCall]]
+ content: str | None
+ tool_calls: list[ToolCall] | None
finish_reason: str # "stop", "tool_calls", "length", "content_filter"
- reasoning: Optional[str] = None
- usage: Optional[Usage] = None
- provider_data: Optional[Dict[str, Any]] = field(default=None, repr=False)
+ reasoning: str | None = None
+ usage: Usage | None = None
+ provider_data: dict[str, Any] | None = field(default=None, repr=False)
# ── Backward compatibility ──────────────────────────────────
# The shim _nr_to_assistant_message() mapped these from provider_data.
# These properties let NormalizedResponse pass through directly.
@property
- def reasoning_content(self) -> Optional[str]:
+ def reasoning_content(self) -> str | None:
pd = self.provider_data or {}
return pd.get("reasoning_content")
@@ -136,8 +136,9 @@ class NormalizedResponse:
# Factory helpers
# ---------------------------------------------------------------------------
+
def build_tool_call(
- id: Optional[str],
+ id: str | None,
name: str,
arguments: Any,
**provider_fields: Any,
@@ -151,7 +152,7 @@ def build_tool_call(
return ToolCall(id=id, name=name, arguments=args_str, provider_data=pd)
-def map_finish_reason(reason: Optional[str], mapping: Dict[str, str]) -> str:
+def map_finish_reason(reason: str | None, mapping: dict[str, str]) -> str:
"""Translate a provider-specific stop reason to the normalised set.
Falls back to ``"stop"`` for unknown or ``None`` reasons.
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index 5b63d41eb1..6695c9ab95 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -416,6 +416,40 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
),
}
+# Auto-extend PROVIDER_REGISTRY with any api-key provider registered in
+# providers/ that is not already declared above. New providers only need a
+# providers/*.py file — no edits to this file required.
+try:
+ from providers import list_providers as _list_providers_for_registry
+ for _pp in _list_providers_for_registry():
+ if _pp.name in PROVIDER_REGISTRY:
+ continue
+ if _pp.auth_type != "api_key" or not _pp.env_vars:
+ continue
+ # Skip providers that need custom token resolution or are special-cased
+ # in resolve_provider() (copilot/kimi/zai have bespoke token refresh;
+ # openrouter/custom are aggregator/user-supplied and handled outside
+ # the registry — adding them here breaks runtime_provider resolution
+ # that relies on `openrouter not in PROVIDER_REGISTRY`).
+ if _pp.name in {"copilot", "kimi-coding", "kimi-coding-cn", "zai", "openrouter", "custom"}:
+ continue
+ _api_key_vars = tuple(v for v in _pp.env_vars if not v.endswith("_BASE_URL") and not v.endswith("_URL"))
+ _base_url_var = next((v for v in _pp.env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")), None)
+ PROVIDER_REGISTRY[_pp.name] = ProviderConfig(
+ id=_pp.name,
+ name=_pp.display_name or _pp.name,
+ auth_type="api_key",
+ inference_base_url=_pp.base_url,
+ api_key_env_vars=_api_key_vars or _pp.env_vars,
+ base_url_env_var=_base_url_var or "",
+ )
+ # Also register aliases so resolve_provider() resolves them
+ for _alias in _pp.aliases:
+ if _alias not in PROVIDER_REGISTRY:
+ PROVIDER_REGISTRY[_alias] = PROVIDER_REGISTRY[_pp.name]
+except Exception:
+ pass
+
# =============================================================================
# Anthropic Key Helper
@@ -1195,6 +1229,17 @@ def resolve_provider(
"vllm": "custom", "llamacpp": "custom",
"llama.cpp": "custom", "llama-cpp": "custom",
}
+ # Extend with aliases declared in providers/*.py that aren't already mapped.
+ # This keeps providers/ as the single source for new aliases while the
+ # hardcoded dict above remains authoritative for existing ones.
+ try:
+ from providers import list_providers as _lp
+ for _pp in _lp():
+ for _alias in _pp.aliases:
+ if _alias not in _PROVIDER_ALIASES:
+ _PROVIDER_ALIASES[_alias] = _pp.name
+ except Exception:
+ pass
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
if normalized == "openrouter":
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 6ca56422e2..25b949ac56 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -4840,3 +4840,45 @@ def config_command(args):
print(" hermes config path Show config file path")
print(" hermes config env-path Show .env file path")
sys.exit(1)
+
+
+# ── Profile-driven env var injection ─────────────────────────────────────────
+# Any provider registered in providers/ with auth_type="api_key" automatically
+# gets its env_vars exposed in OPTIONAL_ENV_VARS without editing this file.
+# Runs once at import time.
+
+_profile_env_vars_injected = False
+
+
+def _inject_profile_env_vars() -> None:
+ """Populate OPTIONAL_ENV_VARS from provider profiles not already listed.
+
+ Called once at module load time. Idempotent — repeated calls are no-ops.
+ """
+ global _profile_env_vars_injected
+ if _profile_env_vars_injected:
+ return
+ _profile_env_vars_injected = True
+ try:
+ from providers import list_providers
+ for _pp in list_providers():
+ if _pp.auth_type not in ("api_key",):
+ continue
+ for _var in _pp.env_vars:
+ if _var in OPTIONAL_ENV_VARS:
+ continue
+ _is_key = not _var.endswith("_BASE_URL") and not _var.endswith("_URL")
+ OPTIONAL_ENV_VARS[_var] = {
+ "description": f"{_pp.display_name or _pp.name} {'API key' if _is_key else 'base URL override'}",
+ "prompt": f"{_pp.display_name or _pp.name} {'API key' if _is_key else 'base URL (leave empty for default)'}",
+ "url": _pp.signup_url or None,
+ "password": _is_key,
+ "category": "provider",
+ "advanced": True,
+ }
+ except Exception:
+ pass
+
+
+# Eagerly inject so that OPTIONAL_ENV_VARS is fully populated at import time.
+_inject_profile_env_vars()
diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py
index 53fa31098f..2ccb0e0d1e 100644
--- a/hermes_cli/doctor.py
+++ b/hermes_cli/doctor.py
@@ -169,6 +169,85 @@ def _check_gateway_service_linger(issues: list[str]) -> None:
check_warn("Could not verify systemd linger", f"({linger_detail})")
+_APIKEY_PROVIDERS_CACHE: list | None = None
+
+
+def _build_apikey_providers_list() -> list:
+ """Build the API-key provider health-check list once and cache it.
+
+ Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)
+ Base list augmented with any ProviderProfile with auth_type="api_key" not
+ already present — adding providers/*.py is sufficient to get into doctor.
+ """
+ _static = [
+ ("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
+ ("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
+ ("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True),
+ ("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
+ ("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
+ ("GMI Cloud", ("GMI_API_KEY",), "https://api.gmi-serving.com/v1/models", "GMI_BASE_URL", True),
+ ("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
+ ("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
+ ("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
+ ("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
+ # MiniMax global: /v1 endpoint supports /models.
+ ("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
+ # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).
+ ("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False),
+ ("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
+ ("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
+ ("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
+ # OpenCode Go has no shared /models endpoint; skip the health check.
+ ("OpenCode Go", ("OPENCODE_GO_API_KEY",), None, "OPENCODE_GO_BASE_URL", False),
+ ]
+ _known_names = {t[0] for t in _static}
+ # Also index by profile canonical name so profiles without display_name
+ # don't create duplicate entries for providers already in the static list.
+ _known_canonical: set[str] = set()
+ _name_to_canonical = {
+ "Z.AI / GLM": "zai", "Kimi / Moonshot": "kimi-coding",
+ "StepFun Step Plan": "stepfun", "Kimi / Moonshot (China)": "kimi-coding-cn",
+ "Arcee AI": "arcee", "GMI Cloud": "gmi", "DeepSeek": "deepseek",
+ "Hugging Face": "huggingface", "NVIDIA NIM": "nvidia",
+ "Alibaba/DashScope": "alibaba", "MiniMax": "minimax",
+ "MiniMax (China)": "minimax-cn", "Vercel AI Gateway": "ai-gateway",
+ "Kilo Code": "kilocode", "OpenCode Zen": "opencode-zen",
+ "OpenCode Go": "opencode-go",
+ }
+ for _label, _canonical in _name_to_canonical.items():
+ _known_canonical.add(_canonical)
+ try:
+ from providers import list_providers
+ from providers.base import ProviderProfile as _PP
+ for _pp in list_providers():
+ if not isinstance(_pp, _PP) or _pp.auth_type != "api_key" or not _pp.env_vars:
+ continue
+ _label = _pp.display_name or _pp.name
+ if _label in _known_names or _pp.name in _known_canonical:
+ continue
+ # Separate API-key vars from base-URL override vars — the health-check
+ # loop sends the first found value as Authorization: Bearer, so a URL
+ # string must never be picked.
+ _key_vars = tuple(
+ v for v in _pp.env_vars
+ if not v.endswith("_BASE_URL") and not v.endswith("_URL")
+ )
+ _base_var = next(
+ (v for v in _pp.env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")),
+ None,
+ )
+ if not _key_vars:
+ continue
+ _models_url = (
+ (_pp.models_url or (_pp.base_url.rstrip("/") + "/models"))
+ if _pp.base_url else None
+ )
+ _static.append((_label, _key_vars, _models_url, _base_var, True))
+ except Exception:
+ pass
+ return _static
+
+
def run_doctor(args):
"""Run diagnostic checks."""
should_fix = getattr(args, 'fix', False)
@@ -1081,27 +1160,11 @@ def run_doctor(args):
# -- API-key providers --
# Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)
# If supports_models_endpoint is False, we skip the health check and just show "configured"
- _apikey_providers = [
- ("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
- ("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
- ("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True),
- ("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
- ("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
- ("GMI Cloud", ("GMI_API_KEY",), "https://api.gmi-serving.com/v1/models", "GMI_BASE_URL", True),
- ("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
- ("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
- ("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
- ("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
- # MiniMax global: /v1 endpoint supports /models.
- ("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
- # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).
- ("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False),
- ("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
- ("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
- ("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
- # OpenCode Go has no shared /models endpoint; skip the health check.
- ("OpenCode Go", ("OPENCODE_GO_API_KEY",), None, "OPENCODE_GO_BASE_URL", False),
- ]
+ # Cached at module level after first build — profiles auto-extend it.
+ global _APIKEY_PROVIDERS_CACHE
+ if _APIKEY_PROVIDERS_CACHE is None:
+ _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()
+ _apikey_providers = _APIKEY_PROVIDERS_CACHE
for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:
_key = ""
for _ev in _env_vars:
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 112d839db2..89dd166776 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -1611,6 +1611,21 @@ def cmd_model(args):
select_provider_and_model(args=args)
+def _is_profile_api_key_provider(provider_id: str) -> bool:
+ """Return True when provider_id maps to a profile with auth_type='api_key'.
+
+ Used as a catch-all in select_provider_and_model() so that new providers
+ declared in providers/*.py automatically dispatch to _model_flow_api_key_provider
+ without requiring an explicit elif branch here.
+ """
+ try:
+ from providers import get_provider_profile
+ _p = get_provider_profile(provider_id)
+ return _p is not None and _p.auth_type == "api_key"
+ except Exception:
+ return False
+
+
def select_provider_and_model(args=None):
"""Core provider selection + model picking logic.
@@ -1907,7 +1922,7 @@ def select_provider_and_model(args=None):
"ollama-cloud",
"tencent-tokenhub",
"lmstudio",
- ):
+ ) or _is_profile_api_key_provider(selected_provider):
_model_flow_api_key_provider(config, selected_provider, current_model)
# ── Post-switch cleanup: clear stale OPENAI_BASE_URL ──────────────
@@ -8215,6 +8230,22 @@ def cmd_logs(args):
)
+def _build_provider_choices() -> list[str]:
+ """Build the --provider choices list from CANONICAL_PROVIDERS + 'auto'."""
+ try:
+ from hermes_cli.models import CANONICAL_PROVIDERS as _cp
+ return ["auto"] + [p.slug for p in _cp]
+ except Exception:
+ # Fallback: static list guarantees the CLI always works
+ return [
+ "auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot",
+ "anthropic", "gemini", "google-gemini-cli", "xai", "bedrock", "azure-foundry",
+ "ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn",
+ "stepfun", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee",
+ "nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go",
+ ]
+
+
def main():
"""Main entry point for hermes CLI."""
from hermes_cli._parser import build_top_level_parser
diff --git a/hermes_cli/models.py b/hermes_cli/models.py
index 816af02789..4bf03b002b 100644
--- a/hermes_cli/models.py
+++ b/hermes_cli/models.py
@@ -806,6 +806,25 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway"),
]
+# Auto-extend CANONICAL_PROVIDERS with any provider registered in providers/
+# that is not already in the list above. Adding providers/*.py is sufficient
+# to expose a new provider in the model picker, /model, and all downstream
+# consumers — no edits to this file needed.
+_canonical_slugs = {p.slug for p in CANONICAL_PROVIDERS}
+try:
+ from providers import list_providers as _list_providers_for_canonical
+ for _pp in _list_providers_for_canonical():
+ if _pp.name in _canonical_slugs:
+ continue
+ if _pp.auth_type in ("oauth_device_code", "oauth_external", "external_process", "aws_sdk", "copilot"):
+ continue # non-api-key flows need bespoke picker UX; skip auto-inject
+ _label = _pp.display_name or _pp.name
+ _desc = _pp.description or f"{_label} (direct API)"
+ CANONICAL_PROVIDERS.append(ProviderEntry(_pp.name, _label, _desc))
+ _canonical_slugs.add(_pp.name)
+except Exception:
+ pass
+
# Derived dicts — used throughout the codebase
_PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
@@ -2023,6 +2042,34 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
return ids
except Exception:
pass
+
+ # ── Profile-based generic live fetch (all simple api-key providers) ──
+ # Handles any provider registered in providers/ with auth_type="api_key".
+ # Replaces per-provider copy-paste blocks (stepfun, gmi, zai, etc.).
+ try:
+ from providers import get_provider_profile
+ from hermes_cli.auth import resolve_api_key_provider_credentials
+
+ _p = get_provider_profile(normalized)
+ if _p and _p.auth_type == "api_key" and _p.base_url:
+ try:
+ creds = resolve_api_key_provider_credentials(normalized)
+ api_key = str(creds.get("api_key") or "").strip()
+ base_url = str(creds.get("base_url") or "").strip()
+ except Exception:
+ api_key, base_url = "", _p.base_url
+ if not base_url:
+ base_url = _p.base_url
+ if api_key:
+ live = _p.fetch_models(api_key=api_key)
+ if live:
+ return live
+ # Use profile's fallback_models if defined
+ if _p.fallback_models:
+ return list(_p.fallback_models)
+ except Exception:
+ pass
+
curated_static = list(_PROVIDER_MODELS.get(normalized, []))
if normalized in _MODELS_DEV_PREFERRED:
return _merge_with_models_dev(normalized, curated_static)
diff --git a/providers/README.md b/providers/README.md
new file mode 100644
index 0000000000..786bc3c2e9
--- /dev/null
+++ b/providers/README.md
@@ -0,0 +1,307 @@
+# providers/
+
+Single source of truth for every inference provider Hermes knows about.
+
+Each provider is declared once here as a `ProviderProfile`. Every other layer —
+auth resolution, transport kwargs, model listing, runtime routing — reads from
+these profiles instead of maintaining its own parallel data.
+
+---
+
+## Directory layout
+
+```
+providers/
+├── base.py ProviderProfile dataclass + OMIT_TEMPERATURE sentinel
+├── __init__.py Registry: register_provider(), get_provider_profile()
+├── README.md This file
+│
+├── # Simple providers — just identity + auth + endpoint
+├── alibaba.py Alibaba Cloud DashScope
+├── arcee.py Arcee AI
+├── bedrock.py AWS Bedrock (api_mode=bedrock_converse)
+├── deepseek.py DeepSeek
+├── huggingface.py Hugging Face Inference API
+├── kilocode.py Kilo Code
+├── minimax.py MiniMax (international + CN)
+├── nvidia.py NVIDIA NIM (default_max_tokens=16384)
+├── ollama_cloud.py Ollama Cloud
+├── stepfun.py StepFun
+├── xiaomi.py Xiaomi MiMo
+├── xai.py xAI Grok (api_mode=codex_responses)
+├── zai.py Z.AI / GLM
+│
+├── # Medium — one or two quirks
+├── anthropic.py Native Anthropic (x-api-key header, api_mode=anthropic_messages)
+├── copilot.py GitHub Copilot (auth_type=copilot, reasoning per model)
+├── copilot_acp.py Copilot ACP subprocess (api_mode=copilot_acp)
+├── custom.py Custom/Ollama local (think=false, num_ctx)
+├── gemini.py Google Gemini AI Studio + Cloud Code OAuth
+├── kimi.py Kimi Coding (OMIT_TEMPERATURE, thinking, dual endpoint)
+├── openai_codex.py OpenAI Codex OAuth (api_mode=codex_responses)
+├── opencode.py OpenCode Zen + Go (per-model api_mode routing)
+│
+├── # Complex — subclasses with multiple overrides
+├── nous.py Nous Portal (tags, attribution, reasoning omit-when-disabled)
+├── openrouter.py OpenRouter (provider preferences, public model fetch)
+├── qwen.py Qwen OAuth (message normalization, cache_control, vl_hires)
+└── vercel.py Vercel AI Gateway (attribution headers, reasoning passthrough)
+```
+
+---
+
+## ProviderProfile fields
+
+```python
+@dataclass
+class ProviderProfile:
+ # Identity
+ name: str # canonical ID — auto-registered as PROVIDER_REGISTRY key for new api-key providers
+ api_mode: str # "chat_completions" | "anthropic_messages" |
+ # "codex_responses" | "bedrock_converse" | "copilot_acp"
+ aliases: tuple # alternate names resolved by get_provider_profile()
+
+ # Auth & endpoints
+ env_vars: tuple # env var names holding the API key, in priority order
+ base_url: str # default inference endpoint
+ models_url: str # explicit models endpoint; falls back to {base_url}/models
+ # set when the models catalog lives at a different URL
+ # (e.g. OpenRouter: public /api/v1/models vs /api/v1 inference)
+ auth_type: str # "api_key" | "oauth_device_code" | "oauth_external" |
+ # "copilot" | "aws" | "external_process"
+
+ # Client-level quirks
+ default_headers: dict # extra HTTP headers sent on every request
+
+ # Request-level quirks
+ fixed_temperature: Any # None = use caller's default; OMIT_TEMPERATURE = don't send
+ default_max_tokens: int|None # inject max_tokens when caller omits it
+ default_aux_model: str # cheap model for auxiliary tasks (compression, vision, etc.)
+ # empty string = use main model (default)
+```
+
+---
+
+## Hooks (override in a subclass)
+
+| Method | When to override |
+|--------|-----------------|
+| `prepare_messages(messages)` | Provider needs message pre-processing (Qwen: string → list-of-parts, cache_control) |
+| `build_extra_body(*, session_id, **ctx)` | Provider-specific `extra_body` fields (Nous: tags, OpenRouter: provider preferences) |
+| `build_api_kwargs_extras(*, reasoning_config, **ctx)` | Returns `(extra_body_additions, top_level_kwargs)` — use when some fields go to `extra_body` and some go top-level (Kimi: `reasoning_effort` top-level; OpenRouter: `reasoning` in extra_body) |
+| `fetch_models(*, api_key, timeout)` | Custom model listing (Anthropic: x-api-key header; OpenRouter: public endpoint, no auth; Bedrock/copilot-acp: return None) |
+
+All hooks have safe defaults — only override what differs from the base.
+
+---
+
+## How to add a new provider
+
+### 1. Simple (standard OpenAI-compatible endpoint)
+
+```python
+# providers/myprovider.py
+from providers import register_provider
+from providers.base import ProviderProfile
+
+myprovider = ProviderProfile(
+ name="myprovider", # must match id in hermes_cli/auth.py PROVIDER_REGISTRY
+ aliases=("my-provider", "myp"),
+ api_mode="chat_completions",
+ env_vars=("MYPROVIDER_API_KEY",),
+ base_url="https://api.myprovider.com/v1",
+ auth_type="api_key",
+)
+
+register_provider(myprovider)
+```
+
+The default `fetch_models()` will call `GET https://api.myprovider.com/v1/models`
+with Bearer auth automatically. No override needed for standard `/v1/models`.
+
+### 2. With quirks (subclass)
+
+```python
+# providers/myprovider.py
+from typing import Any
+from providers import register_provider
+from providers.base import ProviderProfile
+
+
+class MyProviderProfile(ProviderProfile):
+ """My provider — custom reasoning header."""
+
+ def build_api_kwargs_extras(
+ self,
+ *,
+ reasoning_config: dict | None = None,
+ **ctx: Any,
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ extra_body: dict[str, Any] = {}
+ if reasoning_config:
+ extra_body["my_reasoning"] = reasoning_config.get("effort", "medium")
+ return extra_body, {}
+
+ def fetch_models(
+ self,
+ *,
+ api_key: str | None = None,
+ timeout: float = 8.0,
+ ) -> list[str] | None:
+ # Override only if your endpoint differs from standard /v1/models
+ return super().fetch_models(api_key=api_key, timeout=timeout)
+
+
+myprovider = MyProviderProfile(
+ name="myprovider",
+ aliases=("myp",),
+ env_vars=("MYPROVIDER_API_KEY",),
+ base_url="https://api.myprovider.com/v1",
+)
+
+register_provider(myprovider)
+```
+
+### 3. Wire it up
+
+After creating the file, add `name` to the `_PROFILE_ACTIVE_PROVIDERS` set in
+`run_agent.py` once you've verified parity against the legacy flag path. Start
+with a simple provider (no message prep, no reasoning quirks) and work up.
+
+---
+
+## fetch_models contract
+
+```python
+def fetch_models(
+ self,
+ *,
+ api_key: str | None = None,
+ timeout: float = 8.0,
+) -> list[str] | None:
+ ...
+```
+
+- Returns `list[str]`: model IDs from the provider's live endpoint.
+- Returns `None`: provider doesn't support REST model listing (Bedrock, copilot-acp),
+ or the request failed. Callers **must** fall back to `_PROVIDER_MODELS` on `None`.
+- Never raises — swallow exceptions and return `None`.
+- Default implementation: `GET {base_url}/models` with Bearer auth. Works for any
+ standard OpenAI-compatible provider.
+
+**Override when:**
+- Auth header is not `Bearer` (Anthropic: `x-api-key`)
+- Endpoint path differs from `/models` AND you can't just set `models_url` (OpenRouter: public endpoint, pass `api_key=None` explicitly)
+- Response format differs (extra wrapping, non-standard `id` field)
+- Provider has no REST endpoint (Bedrock, copilot-acp → return `None`)
+- Filtering needed post-fetch (only tool-capable models, etc.)
+
+Use `models_url` instead of overriding when the only difference is the URL:
+
+```python
+# No subclass needed — just set models_url
+myprovider = ProviderProfile(
+ name="myprovider",
+ base_url="https://api.myprovider.com/v1",
+ models_url="https://catalog.myprovider.com/models", # different host
+)
+```
+
+---
+
+## Debugging
+
+### Check if a provider resolves
+
+```python
+from providers import get_provider_profile
+
+p = get_provider_profile("myprovider")
+print(p) # ProviderProfile(name='myprovider', ...)
+print(p.base_url)
+print(p.api_mode)
+```
+
+### Check all registered providers
+
+```python
+from providers import _REGISTRY
+print(list(_REGISTRY.keys()))
+```
+
+### Test live model fetch
+
+```python
+import os
+from providers import get_provider_profile
+
+p = get_provider_profile("myprovider")
+key = os.getenv("MYPROVIDER_API_KEY")
+models = p.fetch_models(api_key=key, timeout=5.0)
+print(models) # list of model IDs, or None on failure
+```
+
+### Test alias resolution
+
+```python
+from providers import get_provider_profile
+
+# All of these should return the same profile
+assert get_provider_profile("openrouter").name == "openrouter"
+assert get_provider_profile("or").name == "openrouter"
+```
+
+### Run the provider test suite
+
+```bash
+# From the repo root
+source venv/bin/activate
+python -m pytest tests/providers/ -v
+```
+
+### Check ruff + ty compliance
+
+```bash
+source venv/bin/activate
+ruff format providers/*.py
+ruff check providers/*.py --select UP,E,F,I,W
+ty check providers/*.py
+```
+
+---
+
+## Common mistakes
+
+**Wrong `name`** — must be the same string that appears as the key in
+`hermes_cli/auth.py` `PROVIDER_REGISTRY`. New api-key providers auto-register
+into `PROVIDER_REGISTRY` from the profile, so the name IS the key. For providers
+with a pre-existing `PROVIDER_REGISTRY` entry, use the exact `id` field value.
+
+**Wrong `env_vars`** — separate API-key vars from base-URL override vars in the
+tuple. Env vars that end with `_BASE_URL` or `_URL` are treated as URL overrides;
+everything else is treated as an API key. Getting this wrong causes the doctor
+health check to send a URL string as a Bearer token.
+
+**Wrong `base_url`** — several providers have non-obvious paths:
+`stepfun: /step_plan/v1`, `opencode-go: /zen/go/v1`. The profile's `base_url`
+is also used as the `inference_base_url` when auto-registering into `PROVIDER_REGISTRY`
+for new providers, so it must be correct for auth resolution to work.
+
+**Skipping `api_mode`** — defaults to `chat_completions`. Providers that use
+`anthropic_messages`, `codex_responses`, `bedrock_converse`, or `copilot_acp`
+must set it explicitly.
+
+**Forgetting `register_provider()`** — auto-discovery runs `pkgutil.iter_modules`
+over the package and imports each module, but only if `register_provider()` is
+called at module level. Without it the profile is never in `_REGISTRY`.
+
+**`fetch_models` returning the wrong shape** — must return `list[str]` (plain
+model IDs), not `list[tuple]` or `list[dict]`. Callers expect plain strings.
+
+**Wrong `build_api_kwargs_extras` return shape** — must return a 2-tuple
+`(extra_body_dict, top_level_dict)`. Returning a single dict causes a
+`ValueError: not enough values to unpack` in the transport.
+
+**`build_api_kwargs_extras` wrong tuple** — must return `(extra_body_dict,
+top_level_dict)`. Returning a flat dict or swapping the order silently sends
+fields to the wrong place.
diff --git a/providers/__init__.py b/providers/__init__.py
new file mode 100644
index 0000000000..9c80b449a9
--- /dev/null
+++ b/providers/__init__.py
@@ -0,0 +1,76 @@
+"""Provider module registry.
+
+Auto-discovers ProviderProfile instances from providers/*.py modules.
+Each module should define a module-level PROVIDER or PROVIDERS list.
+
+Usage:
+ from providers import get_provider_profile
+ profile = get_provider_profile("nvidia") # returns ProviderProfile or None
+ profile = get_provider_profile("kimi") # checks name + aliases
+"""
+
+from __future__ import annotations
+
+from providers.base import OMIT_TEMPERATURE, ProviderProfile # noqa: F401
+
+_REGISTRY: dict[str, ProviderProfile] = {}
+_ALIASES: dict[str, str] = {}
+_discovered = False
+
+
+def register_provider(profile: ProviderProfile) -> None:
+ """Register a provider profile by name and aliases."""
+ _REGISTRY[profile.name] = profile
+ for alias in profile.aliases:
+ _ALIASES[alias] = profile.name
+
+
+def get_provider_profile(name: str) -> ProviderProfile | None:
+ """Look up a provider profile by name or alias.
+
+ Returns None if the provider has no profile (falls back to generic).
+ """
+ if not _discovered:
+ _discover_providers()
+ canonical = _ALIASES.get(name, name)
+ return _REGISTRY.get(canonical)
+
+
+def list_providers() -> list[ProviderProfile]:
+ """Return all registered provider profiles (one per canonical name)."""
+ if not _discovered:
+ _discover_providers()
+ # Deduplicate: _REGISTRY has canonical names; _ALIASES points to same objects
+ seen: set[int] = set()
+ result: list[ProviderProfile] = []
+ for profile in _REGISTRY.values():
+ pid = id(profile)
+ if pid not in seen:
+ seen.add(pid)
+ result.append(profile)
+ return result
+
+
+def _discover_providers() -> None:
+ """Import all provider modules to trigger registration."""
+ global _discovered
+ if _discovered:
+ return
+ _discovered = True
+
+ import importlib
+ import pkgutil
+
+ import providers as _pkg
+
+ for _importer, modname, _ispkg in pkgutil.iter_modules(_pkg.__path__):
+ if modname.startswith("_") or modname == "base":
+ continue
+ try:
+ importlib.import_module(f"providers.{modname}")
+ except ImportError as e:
+ import logging
+
+ logging.getLogger(__name__).warning(
+ "Failed to import provider module %s: %s", modname, e
+ )
diff --git a/providers/alibaba.py b/providers/alibaba.py
new file mode 100644
index 0000000000..5772bc87e6
--- /dev/null
+++ b/providers/alibaba.py
@@ -0,0 +1,13 @@
+"""Alibaba Cloud DashScope provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+alibaba = ProviderProfile(
+ name="alibaba",
+ aliases=("dashscope", "alibaba-cloud", "qwen-dashscope"),
+ env_vars=("DASHSCOPE_API_KEY",),
+ base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
+)
+
+register_provider(alibaba)
diff --git a/providers/alibaba_coding_plan.py b/providers/alibaba_coding_plan.py
new file mode 100644
index 0000000000..607439a365
--- /dev/null
+++ b/providers/alibaba_coding_plan.py
@@ -0,0 +1,21 @@
+"""Alibaba Cloud Coding Plan provider profile.
+
+Separate from the standard `alibaba` profile because it hits a different
+endpoint (coding-intl.dashscope.aliyuncs.com) with a dedicated API key tier.
+"""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+alibaba_coding_plan = ProviderProfile(
+ name="alibaba-coding-plan",
+ aliases=("alibaba_coding", "alibaba-coding", "dashscope-coding"),
+ display_name="Alibaba Cloud (Coding Plan)",
+ description="Alibaba Cloud Coding Plan — dedicated coding tier",
+ signup_url="https://help.aliyun.com/zh/model-studio/",
+ env_vars=("ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY", "ALIBABA_CODING_PLAN_BASE_URL"),
+ base_url="https://coding-intl.dashscope.aliyuncs.com/v1",
+ auth_type="api_key",
+)
+
+register_provider(alibaba_coding_plan)
diff --git a/providers/anthropic.py b/providers/anthropic.py
new file mode 100644
index 0000000000..f1f45eb82c
--- /dev/null
+++ b/providers/anthropic.py
@@ -0,0 +1,52 @@
+"""Native Anthropic provider profile."""
+
+import json
+import logging
+import urllib.request
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+logger = logging.getLogger(__name__)
+
+
+class AnthropicProfile(ProviderProfile):
+ """Native Anthropic — uses x-api-key header, not Bearer."""
+
+ def fetch_models(
+ self,
+ *,
+ api_key: str | None = None,
+ timeout: float = 8.0,
+ ) -> list[str] | None:
+ """Anthropic uses x-api-key header and anthropic-version."""
+ if not api_key:
+ return None
+ try:
+ req = urllib.request.Request("https://api.anthropic.com/v1/models")
+ req.add_header("x-api-key", api_key)
+ req.add_header("anthropic-version", "2023-06-01")
+ req.add_header("Accept", "application/json")
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ data = json.loads(resp.read().decode())
+ return [
+ m["id"]
+ for m in data.get("data", [])
+ if isinstance(m, dict) and "id" in m
+ ]
+ except Exception as exc:
+ logger.debug("fetch_models(anthropic): %s", exc)
+ return None
+
+
+anthropic = AnthropicProfile(
+ name="anthropic",
+ aliases=("claude", "claude-oauth", "claude-code"),
+ api_mode="anthropic_messages",
+ env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
+ base_url="https://api.anthropic.com",
+ auth_type="api_key",
+ default_aux_model="claude-haiku-4-5-20251001",
+)
+
+register_provider(anthropic)
diff --git a/providers/arcee.py b/providers/arcee.py
new file mode 100644
index 0000000000..46afb6e16e
--- /dev/null
+++ b/providers/arcee.py
@@ -0,0 +1,13 @@
+"""Arcee AI provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+arcee = ProviderProfile(
+ name="arcee",
+ aliases=("arcee-ai", "arceeai"),
+ env_vars=("ARCEEAI_API_KEY",),
+ base_url="https://api.arcee.ai/api/v1",
+)
+
+register_provider(arcee)
diff --git a/providers/azure_foundry.py b/providers/azure_foundry.py
new file mode 100644
index 0000000000..a8e29f241c
--- /dev/null
+++ b/providers/azure_foundry.py
@@ -0,0 +1,21 @@
+"""Azure AI Foundry provider profile.
+
+Azure Foundry exposes an OpenAI-compatible endpoint; users supply their own
+base URL at setup since endpoints are per-resource.
+"""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+azure_foundry = ProviderProfile(
+ name="azure-foundry",
+ aliases=("azure", "azure-ai-foundry", "azure-ai"),
+ display_name="Azure Foundry",
+ description="Azure AI Foundry — OpenAI-compatible endpoint (user-supplied base URL)",
+ signup_url="https://ai.azure.com/",
+ env_vars=("AZURE_FOUNDRY_API_KEY", "AZURE_FOUNDRY_BASE_URL"),
+ base_url="", # per-resource; user provides at setup
+ auth_type="api_key",
+)
+
+register_provider(azure_foundry)
diff --git a/providers/base.py b/providers/base.py
new file mode 100644
index 0000000000..2c685f9b81
--- /dev/null
+++ b/providers/base.py
@@ -0,0 +1,165 @@
+"""Provider profile base class.
+
+A ProviderProfile declares everything about an inference provider in one place:
+auth, endpoints, client quirks, request-time quirks. The transport reads this
+instead of receiving 20+ boolean flags.
+
+Provider profiles are DECLARATIVE — they describe the provider's behavior.
+They do NOT own client construction, credential rotation, or streaming.
+Those stay on AIAgent.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+# Sentinel for "omit temperature entirely" (Kimi: server manages it)
+OMIT_TEMPERATURE = object()
+
+
+@dataclass
+class ProviderProfile:
+ """Base provider profile — subclass or instantiate with overrides."""
+
+ # ── Identity ─────────────────────────────────────────────
+ name: str
+ api_mode: str = "chat_completions"
+ aliases: tuple = ()
+
+ # ── Human-readable metadata ───────────────────────────────
+ display_name: str = "" # e.g. "GMI Cloud" — shown in picker/labels
+ description: str = "" # e.g. "GMI Cloud (multi-model direct API)" — picker subtitle
+ signup_url: str = "" # e.g. "https://www.gmicloud.ai/" — shown during setup
+
+ # ── Auth & endpoints ─────────────────────────────────────
+ env_vars: tuple = ()
+ base_url: str = ""
+ models_url: str = "" # explicit models endpoint; falls back to {base_url}/models
+ auth_type: str = "api_key" # api_key|oauth_device_code|oauth_external|copilot|aws_sdk
+
+ # ── Model catalog ─────────────────────────────────────────
+ # fallback_models: curated list shown in /model picker when live fetch fails.
+ # Only agentic models that support tool calling should appear here.
+ fallback_models: tuple = ()
+
+ # hostname: base hostname for URL→provider reverse-mapping in model_metadata.py
+ # e.g. "api.gmi-serving.com". Derived from base_url when empty.
+ hostname: str = ""
+
+ # ── Client-level quirks (set once at client construction) ─
+ default_headers: dict[str, str] = field(default_factory=dict)
+
+ # ── Request-level quirks ─────────────────────────────────
+ # Temperature: None = use caller's default, OMIT_TEMPERATURE = don't send
+ fixed_temperature: Any = None
+ default_max_tokens: int | None = None
+ default_aux_model: str = (
+ "" # cheap model for auxiliary tasks (compression, vision, etc.)
+ )
+ # empty = use main model
+
+ # ── Hooks (override in subclass for complex providers) ───
+
+ def get_hostname(self) -> str:
+ """Return the provider's base hostname for URL-based detection.
+
+ Uses self.hostname if set explicitly, otherwise derives it from base_url.
+ e.g. 'https://api.gmi-serving.com/v1' → 'api.gmi-serving.com'
+ """
+ if self.hostname:
+ return self.hostname
+ if self.base_url:
+ from urllib.parse import urlparse
+ return urlparse(self.base_url).hostname or ""
+ return ""
+
+ def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Provider-specific message preprocessing.
+
+ Called AFTER codex field sanitization, BEFORE developer role swap.
+ Default: pass-through.
+ """
+ return messages
+
+ def build_extra_body(
+ self, *, session_id: str | None = None, **context: Any
+ ) -> dict[str, Any]:
+ """Provider-specific extra_body fields.
+
+ Merged into the API kwargs extra_body. Default: empty dict.
+ """
+ return {}
+
+ def build_api_kwargs_extras(
+ self,
+ *,
+ reasoning_config: dict | None = None,
+ **context: Any,
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ """Provider-specific kwargs split between extra_body and top-level api_kwargs.
+
+ Returns (extra_body_additions, top_level_kwargs).
+ The transport merges extra_body_additions into extra_body, and
+ top_level_kwargs directly into api_kwargs.
+
+ This split exists because some providers put reasoning config in
+ extra_body (OpenRouter: extra_body.reasoning) while others put it
+ as top-level api_kwargs (Kimi: api_kwargs.reasoning_effort).
+
+ Default: ({}, {}).
+ """
+ return {}, {}
+
+ def fetch_models(
+ self,
+ *,
+ api_key: str | None = None,
+ timeout: float = 8.0,
+ ) -> list[str] | None:
+ """Fetch the live model list from the provider's models endpoint.
+
+ Returns a list of model ID strings, or None if the fetch failed or
+ the provider does not support live model listing.
+
+ Resolution order for the endpoint URL:
+ 1. self.models_url (explicit override — use when the models
+ endpoint differs from the inference base URL, e.g. OpenRouter
+ exposes a public catalog at /api/v1/models while inference is
+ at /api/v1)
+ 2. self.base_url + "/models" (standard OpenAI-compat fallback)
+
+ The default implementation sends Bearer auth when api_key is given
+ and forwards self.default_headers. Override to customise auth, path,
+ response shape, or to return None for providers with no REST catalog.
+
+ Callers must always fall back to the static _PROVIDER_MODELS list
+ when this returns None.
+ """
+ url = (self.models_url or "").strip()
+ if not url:
+ if not self.base_url:
+ return None
+ url = self.base_url.rstrip("/") + "/models"
+
+ import json
+ import urllib.request
+
+ req = urllib.request.Request(url)
+ if api_key:
+ req.add_header("Authorization", f"Bearer {api_key}")
+ req.add_header("Accept", "application/json")
+ for k, v in self.default_headers.items():
+ req.add_header(k, v)
+
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ data = json.loads(resp.read().decode())
+ items = data if isinstance(data, list) else data.get("data", [])
+ return [m["id"] for m in items if isinstance(m, dict) and "id" in m]
+ except Exception as exc:
+ logger.debug("fetch_models(%s): %s", self.name, exc)
+ return None
diff --git a/providers/bedrock.py b/providers/bedrock.py
new file mode 100644
index 0000000000..6fdbbe834d
--- /dev/null
+++ b/providers/bedrock.py
@@ -0,0 +1,29 @@
+"""AWS Bedrock provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+
+class BedrockProfile(ProviderProfile):
+ """AWS Bedrock — no REST /v1/models endpoint; uses AWS SDK."""
+
+ def fetch_models(
+ self,
+ *,
+ api_key: str | None = None,
+ timeout: float = 8.0,
+ ) -> list[str] | None:
+ """Bedrock model listing requires AWS SDK, not a REST call."""
+ return None
+
+
+bedrock = BedrockProfile(
+ name="bedrock",
+ aliases=("aws", "aws-bedrock", "amazon-bedrock", "amazon"),
+ api_mode="bedrock_converse",
+ env_vars=(), # AWS SDK credentials — not env vars
+ base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
+ auth_type="aws_sdk",
+)
+
+register_provider(bedrock)
diff --git a/providers/copilot.py b/providers/copilot.py
new file mode 100644
index 0000000000..d4409c108d
--- /dev/null
+++ b/providers/copilot.py
@@ -0,0 +1,58 @@
+"""Copilot / GitHub Models provider profile.
+
+Copilot uses per-model api_mode routing:
+ - GPT-5+ / Codex models → codex_responses
+ - Claude models → anthropic_messages
+ - Everything else → chat_completions (this profile covers that subset)
+
+Key quirks for the chat_completions subset:
+ - Editor attribution headers (via copilot_default_headers())
+ - GitHub Models reasoning extra_body (model-catalog gated)
+"""
+
+from typing import Any
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+
+class CopilotProfile(ProviderProfile):
+ """GitHub Copilot / GitHub Models — editor headers + reasoning."""
+
+ def build_api_kwargs_extras(
+ self,
+ *,
+ model: str | None = None,
+ reasoning_config: dict | None = None,
+ supports_reasoning: bool = False,
+ **ctx,
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ extra_body: dict[str, Any] = {}
+ if supports_reasoning and model:
+ try:
+ from hermes_cli.models import github_model_reasoning_efforts
+
+ supported_efforts = github_model_reasoning_efforts(model)
+ if supported_efforts and reasoning_config:
+ effort = reasoning_config.get("effort", "medium")
+ # Normalize non-standard effort levels to the nearest supported
+ if effort == "xhigh":
+ effort = "high"
+ if effort in supported_efforts:
+ extra_body["reasoning"] = {"effort": effort}
+ elif supported_efforts:
+ extra_body["reasoning"] = {"effort": "medium"}
+ except Exception:
+ pass
+ return extra_body, {}
+
+
+copilot = CopilotProfile(
+ name="copilot",
+ aliases=("github-copilot", "github-models", "github-model", "github"),
+ env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"),
+ base_url="https://api.githubcopilot.com",
+ auth_type="copilot",
+)
+
+register_provider(copilot)
diff --git a/providers/copilot_acp.py b/providers/copilot_acp.py
new file mode 100644
index 0000000000..21ec7da2e9
--- /dev/null
+++ b/providers/copilot_acp.py
@@ -0,0 +1,34 @@
+"""GitHub Copilot ACP provider profile.
+
+copilot-acp uses an external ACP subprocess — NOT the standard
+transport. api_mode="copilot_acp" is handled separately in run_agent.py.
+The profile captures auth + endpoint metadata for registry migration.
+"""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+
+class CopilotACPProfile(ProviderProfile):
+ """GitHub Copilot ACP — external process, no REST models endpoint."""
+
+ def fetch_models(
+ self,
+ *,
+ api_key: str | None = None,
+ timeout: float = 8.0,
+ ) -> list[str] | None:
+ """Model listing is handled by the ACP subprocess."""
+ return None
+
+
+copilot_acp = CopilotACPProfile(
+ name="copilot-acp",
+ aliases=("github-copilot-acp", "copilot-acp-agent"),
+ api_mode="chat_completions", # ACP subprocess uses chat_completions routing
+ env_vars=(), # Managed by ACP subprocess
+ base_url="acp://copilot", # ACP internal scheme
+ auth_type="external_process",
+)
+
+register_provider(copilot_acp)
diff --git a/providers/custom.py b/providers/custom.py
new file mode 100644
index 0000000000..65e42e1fbe
--- /dev/null
+++ b/providers/custom.py
@@ -0,0 +1,68 @@
+"""Custom / Ollama (local) provider profile.
+
+Covers any endpoint registered as provider="custom", including local
+Ollama instances. Key quirks:
+ - ollama_num_ctx → extra_body.options.num_ctx (local context window)
+ - reasoning_config disabled → extra_body.think = False
+"""
+
+from typing import Any
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+
+class CustomProfile(ProviderProfile):
+ """Custom/Ollama local provider — think=false and num_ctx support."""
+
+ def build_api_kwargs_extras(
+ self,
+ *,
+ reasoning_config: dict | None = None,
+ ollama_num_ctx: int | None = None,
+ **ctx: Any,
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ extra_body: dict[str, Any] = {}
+
+ # Ollama context window
+ if ollama_num_ctx:
+ options = extra_body.get("options", {})
+ options["num_ctx"] = ollama_num_ctx
+ extra_body["options"] = options
+
+ # Disable thinking when reasoning is turned off
+ if reasoning_config and isinstance(reasoning_config, dict):
+ _effort = (reasoning_config.get("effort") or "").strip().lower()
+ _enabled = reasoning_config.get("enabled", True)
+ if _effort == "none" or _enabled is False:
+ extra_body["think"] = False
+
+ return extra_body, {}
+
+ def fetch_models(
+ self,
+ *,
+ api_key: str | None = None,
+ timeout: float = 8.0,
+ ) -> list[str] | None:
+ """Custom/Ollama: base_url is user-configured; fetch if set."""
+ if not self.base_url:
+ return None
+ return super().fetch_models(api_key=api_key, timeout=timeout)
+
+
+custom = CustomProfile(
+ name="custom",
+ aliases=(
+ "ollama",
+ "local",
+ "vllm",
+ "llamacpp",
+ "llama.cpp",
+ "llama-cpp",
+ ),
+ env_vars=(), # No fixed key — custom endpoint
+ base_url="", # User-configured
+)
+
+register_provider(custom)
diff --git a/providers/deepseek.py b/providers/deepseek.py
new file mode 100644
index 0000000000..59d738f50f
--- /dev/null
+++ b/providers/deepseek.py
@@ -0,0 +1,20 @@
+"""DeepSeek provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+deepseek = ProviderProfile(
+ name="deepseek",
+ aliases=("deepseek-chat",),
+ env_vars=("DEEPSEEK_API_KEY",),
+ display_name="DeepSeek",
+ description="DeepSeek — native DeepSeek API",
+ signup_url="https://platform.deepseek.com/",
+ fallback_models=(
+ "deepseek-chat",
+ "deepseek-reasoner",
+ ),
+ base_url="https://api.deepseek.com/v1",
+)
+
+register_provider(deepseek)
diff --git a/providers/gemini.py b/providers/gemini.py
new file mode 100644
index 0000000000..0812f07ba5
--- /dev/null
+++ b/providers/gemini.py
@@ -0,0 +1,72 @@
+"""Google Gemini provider profiles.
+
+gemini: Google AI Studio (API key) — uses GeminiNativeClient
+google-gemini-cli: Google Cloud Code Assist (OAuth) — uses GeminiCloudCodeClient
+
+Both report api_mode="chat_completions" but use custom native clients
+that bypass the standard OpenAI transport. The profile captures auth
+and endpoint metadata for auth.py / runtime_provider.py migration, and
+carries the thinking_config translation hook so the transport's profile
+path produces the same extra_body shape the legacy flag path did.
+"""
+
+from typing import Any
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+
+class GeminiProfile(ProviderProfile):
+ """Gemini — translate reasoning_config to thinking_config in extra_body."""
+
+ def build_extra_body(
+ self, *, session_id: str | None = None, **context: Any
+ ) -> dict[str, Any]:
+ """Emit extra_body.thinking_config (native) or extra_body.extra_body.google.thinking_config
+ (OpenAI-compat /openai subpath), mirroring the legacy path's behavior.
+ """
+ from agent.transports.chat_completions import (
+ _build_gemini_thinking_config,
+ _is_gemini_openai_compat_base_url,
+ _snake_case_gemini_thinking_config,
+ )
+
+ model = context.get("model") or ""
+ reasoning_config = context.get("reasoning_config")
+ base_url = context.get("base_url") or self.base_url
+
+ raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config)
+ if not raw_thinking_config:
+ return {}
+
+ body: dict[str, Any] = {}
+ if self.name == "gemini" and _is_gemini_openai_compat_base_url(base_url):
+ thinking_config = _snake_case_gemini_thinking_config(raw_thinking_config)
+ if thinking_config:
+ body["extra_body"] = {"google": {"thinking_config": thinking_config}}
+ else:
+ body["thinking_config"] = raw_thinking_config
+ return body
+
+
+gemini = GeminiProfile(
+ name="gemini",
+ aliases=("google", "google-gemini", "google-ai-studio"),
+ api_mode="chat_completions",
+ env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
+ base_url="https://generativelanguage.googleapis.com/v1beta",
+ auth_type="api_key",
+ default_aux_model="gemini-3-flash-preview",
+)
+
+google_gemini_cli = GeminiProfile(
+ name="google-gemini-cli",
+ aliases=("gemini-cli", "gemini-oauth"),
+ api_mode="chat_completions",
+ env_vars=(), # OAuth — no API key
+ base_url="cloudcode-pa://google", # Cloud Code Assist internal scheme
+ auth_type="oauth_external",
+)
+
+register_provider(gemini)
+register_provider(google_gemini_cli)
diff --git a/providers/gmi.py b/providers/gmi.py
new file mode 100644
index 0000000000..a7cc32e552
--- /dev/null
+++ b/providers/gmi.py
@@ -0,0 +1,26 @@
+"""GMI Cloud provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+gmi = ProviderProfile(
+ name="gmi",
+ aliases=("gmi-cloud", "gmicloud"),
+ display_name="GMI Cloud",
+ description="GMI Cloud — multi-model direct API (slash-form model IDs)",
+ signup_url="https://www.gmicloud.ai/",
+ env_vars=("GMI_API_KEY", "GMI_BASE_URL"),
+ base_url="https://api.gmi-serving.com/v1",
+ auth_type="api_key",
+ default_aux_model="google/gemini-3.1-flash-lite-preview",
+ fallback_models=(
+ "zai-org/GLM-5.1-FP8",
+ "deepseek-ai/DeepSeek-V3.2",
+ "moonshotai/Kimi-K2.5",
+ "google/gemini-3.1-flash-lite-preview",
+ "anthropic/claude-sonnet-4.6",
+ "openai/gpt-5.4",
+ ),
+)
+
+register_provider(gmi)
diff --git a/providers/huggingface.py b/providers/huggingface.py
new file mode 100644
index 0000000000..039d5a1319
--- /dev/null
+++ b/providers/huggingface.py
@@ -0,0 +1,20 @@
+"""Hugging Face provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+huggingface = ProviderProfile(
+ name="huggingface",
+ aliases=("hf", "hugging-face", "huggingface-hub"),
+ env_vars=("HF_TOKEN",),
+ display_name="HuggingFace",
+ description="HuggingFace Inference API",
+ signup_url="https://huggingface.co/settings/tokens",
+ fallback_models=(
+ "Qwen/Qwen3.5-72B-Instruct",
+ "deepseek-ai/DeepSeek-V3.2",
+ ),
+ base_url="https://router.huggingface.co/v1",
+)
+
+register_provider(huggingface)
diff --git a/providers/kilocode.py b/providers/kilocode.py
new file mode 100644
index 0000000000..23123966aa
--- /dev/null
+++ b/providers/kilocode.py
@@ -0,0 +1,14 @@
+"""Kilo Code provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+kilocode = ProviderProfile(
+ name="kilocode",
+ aliases=("kilo-code", "kilo", "kilo-gateway"),
+ env_vars=("KILOCODE_API_KEY",),
+ base_url="https://api.kilo.ai/api/gateway",
+ default_aux_model="google/gemini-3-flash-preview",
+)
+
+register_provider(kilocode)
diff --git a/providers/kimi.py b/providers/kimi.py
new file mode 100644
index 0000000000..b5cf53a801
--- /dev/null
+++ b/providers/kimi.py
@@ -0,0 +1,71 @@
+"""Kimi / Moonshot provider profiles.
+
+Kimi has dual endpoints:
+ - sk-kimi-* keys → api.kimi.com/coding (Anthropic Messages API)
+ - legacy keys → api.moonshot.ai/v1 (OpenAI chat completions)
+
+This module covers the chat_completions path (/v1 endpoint).
+"""
+
+from typing import Any
+
+from providers import register_provider
+from providers.base import OMIT_TEMPERATURE, ProviderProfile
+
+
+class KimiProfile(ProviderProfile):
+ """Kimi/Moonshot — temperature omitted, thinking + reasoning_effort."""
+
+ def build_api_kwargs_extras(
+ self, *, reasoning_config: dict | None = None, **context
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ """Kimi uses extra_body.thinking + top-level reasoning_effort."""
+ extra_body = {}
+ top_level = {}
+
+ if not reasoning_config or not isinstance(reasoning_config, dict):
+ # No config → thinking enabled, default effort
+ extra_body["thinking"] = {"type": "enabled"}
+ top_level["reasoning_effort"] = "medium"
+ return extra_body, top_level
+
+ enabled = reasoning_config.get("enabled", True)
+ if enabled is False:
+ extra_body["thinking"] = {"type": "disabled"}
+ return extra_body, top_level
+
+ # Enabled
+ extra_body["thinking"] = {"type": "enabled"}
+ effort = (reasoning_config.get("effort") or "").strip().lower()
+ if effort in ("low", "medium", "high"):
+ top_level["reasoning_effort"] = effort
+ else:
+ top_level["reasoning_effort"] = "medium"
+
+ return extra_body, top_level
+
+
+kimi = KimiProfile(
+ name="kimi-coding",
+ aliases=("kimi", "moonshot", "kimi-for-coding"),
+ env_vars=("KIMI_API_KEY", "KIMI_CODING_API_KEY"),
+ base_url="https://api.moonshot.ai/v1",
+ fixed_temperature=OMIT_TEMPERATURE,
+ default_max_tokens=32000,
+ default_headers={"User-Agent": "hermes-agent/1.0"},
+ default_aux_model="kimi-k2-turbo-preview",
+)
+
+kimi_cn = KimiProfile(
+ name="kimi-coding-cn",
+ aliases=("kimi-cn", "moonshot-cn"),
+ env_vars=("KIMI_CN_API_KEY",),
+ base_url="https://api.moonshot.cn/v1",
+ fixed_temperature=OMIT_TEMPERATURE,
+ default_max_tokens=32000,
+ default_headers={"User-Agent": "hermes-agent/1.0"},
+ default_aux_model="kimi-k2-turbo-preview",
+)
+
+register_provider(kimi)
+register_provider(kimi_cn)
diff --git a/providers/minimax.py b/providers/minimax.py
new file mode 100644
index 0000000000..f29eb1aa07
--- /dev/null
+++ b/providers/minimax.py
@@ -0,0 +1,45 @@
+"""MiniMax provider profiles (international + China).
+
+Both use anthropic_messages api_mode — their inference_base_url
+ends with /anthropic which triggers auto-detection to anthropic_messages.
+"""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+minimax = ProviderProfile(
+ name="minimax",
+ aliases=("mini-max",),
+ api_mode="anthropic_messages",
+ env_vars=("MINIMAX_API_KEY",),
+ base_url="https://api.minimax.io/anthropic",
+ auth_type="api_key",
+ default_aux_model="MiniMax-M2.7",
+)
+
+minimax_cn = ProviderProfile(
+ name="minimax-cn",
+ aliases=("minimax-china", "minimax_cn"),
+ api_mode="anthropic_messages",
+ env_vars=("MINIMAX_CN_API_KEY",),
+ base_url="https://api.minimaxi.com/anthropic",
+ auth_type="api_key",
+ default_aux_model="MiniMax-M2.7",
+)
+
+minimax_oauth = ProviderProfile(
+ name="minimax-oauth",
+ aliases=("minimax_oauth", "minimax-oauth-io"),
+ api_mode="anthropic_messages",
+ display_name="MiniMax (OAuth)",
+ description="MiniMax via OAuth browser flow — no API key required",
+ signup_url="https://api.minimax.io/",
+ env_vars=(), # OAuth — tokens in auth.json, not env
+ base_url="https://api.minimax.io/anthropic",
+ auth_type="oauth_external",
+ default_aux_model="MiniMax-M2.7-highspeed",
+)
+
+register_provider(minimax)
+register_provider(minimax_cn)
+register_provider(minimax_oauth)
diff --git a/providers/nous.py b/providers/nous.py
new file mode 100644
index 0000000000..f89e56c23a
--- /dev/null
+++ b/providers/nous.py
@@ -0,0 +1,53 @@
+"""Nous Portal provider profile."""
+
+from typing import Any
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+
+class NousProfile(ProviderProfile):
+ """Nous Portal — product tags, reasoning with Nous-specific omission."""
+
+ def build_extra_body(
+ self, *, session_id: str | None = None, **context
+ ) -> dict[str, Any]:
+ return {"tags": ["product=hermes-agent"]}
+
+ def build_api_kwargs_extras(
+ self,
+ *,
+ reasoning_config: dict | None = None,
+ supports_reasoning: bool = False,
+ **context,
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ """Nous: passes full reasoning_config, but OMITS when disabled."""
+ extra_body = {}
+ if supports_reasoning:
+ if reasoning_config is not None:
+ rc = dict(reasoning_config)
+ if rc.get("enabled") is False:
+ pass # Nous omits reasoning when disabled
+ else:
+ extra_body["reasoning"] = rc
+ else:
+ extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
+ return extra_body, {}
+
+
+nous = NousProfile(
+ name="nous",
+ aliases=("nous-portal", "nousresearch"),
+ env_vars=("NOUS_API_KEY",),
+ display_name="Nous Research",
+ description="Nous Research — Hermes model family",
+ signup_url="https://nousresearch.com/",
+ fallback_models=(
+ "hermes-3-405b",
+ "hermes-3-70b",
+ ),
+ base_url="https://inference.nousresearch.com/v1",
+ auth_type="oauth_device_code",
+)
+
+register_provider(nous)
diff --git a/providers/nvidia.py b/providers/nvidia.py
new file mode 100644
index 0000000000..f6fdc550f6
--- /dev/null
+++ b/providers/nvidia.py
@@ -0,0 +1,21 @@
+"""NVIDIA NIM provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+nvidia = ProviderProfile(
+ name="nvidia",
+ aliases=("nvidia-nim",),
+ env_vars=("NVIDIA_API_KEY",),
+ display_name="NVIDIA NIM",
+ description="NVIDIA NIM — accelerated inference",
+ signup_url="https://build.nvidia.com/",
+ fallback_models=(
+ "nvidia/llama-3.1-nemotron-70b-instruct",
+ "nvidia/llama-3.3-70b-instruct",
+ ),
+ base_url="https://integrate.api.nvidia.com/v1",
+ default_max_tokens=16384,
+)
+
+register_provider(nvidia)
diff --git a/providers/ollama_cloud.py b/providers/ollama_cloud.py
new file mode 100644
index 0000000000..f25c442a40
--- /dev/null
+++ b/providers/ollama_cloud.py
@@ -0,0 +1,14 @@
+"""Ollama Cloud provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+ollama_cloud = ProviderProfile(
+ name="ollama-cloud",
+ aliases=("ollama_cloud",),
+ default_aux_model="nemotron-3-nano:30b",
+ env_vars=("OLLAMA_API_KEY",),
+ base_url="https://ollama.com/v1",
+)
+
+register_provider(ollama_cloud)
diff --git a/providers/openai_codex.py b/providers/openai_codex.py
new file mode 100644
index 0000000000..8124b9efe4
--- /dev/null
+++ b/providers/openai_codex.py
@@ -0,0 +1,15 @@
+"""OpenAI Codex (Responses API) provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+openai_codex = ProviderProfile(
+ name="openai-codex",
+ aliases=("codex", "openai_codex"),
+ api_mode="codex_responses",
+ env_vars=(), # OAuth external — no API key
+ base_url="https://chatgpt.com/backend-api/codex",
+ auth_type="oauth_external",
+)
+
+register_provider(openai_codex)
diff --git a/providers/opencode.py b/providers/opencode.py
new file mode 100644
index 0000000000..f720e8f5fa
--- /dev/null
+++ b/providers/opencode.py
@@ -0,0 +1,30 @@
+"""OpenCode provider profiles (Zen + Go).
+
+Both use per-model api_mode routing:
+ - OpenCode Zen: Claude → anthropic_messages, GPT-5/Codex → codex_responses,
+ everything else → chat_completions (this profile)
+ - OpenCode Go: MiniMax → anthropic_messages, GLM/Kimi → chat_completions
+ (this profile)
+"""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+opencode_zen = ProviderProfile(
+ name="opencode-zen",
+ aliases=("opencode", "opencode_zen", "zen"),
+ env_vars=("OPENCODE_ZEN_API_KEY",),
+ base_url="https://opencode.ai/zen/v1",
+ default_aux_model="gemini-3-flash",
+)
+
+opencode_go = ProviderProfile(
+ name="opencode-go",
+ aliases=("opencode_go", "go", "opencode-go-sub"),
+ env_vars=("OPENCODE_GO_API_KEY",),
+ base_url="https://opencode.ai/zen/go/v1",
+ default_aux_model="glm-5",
+)
+
+register_provider(opencode_zen)
+register_provider(opencode_go)
diff --git a/providers/openrouter.py b/providers/openrouter.py
new file mode 100644
index 0000000000..6aad8fc65d
--- /dev/null
+++ b/providers/openrouter.py
@@ -0,0 +1,86 @@
+"""OpenRouter provider profile."""
+
+import logging
+from typing import Any
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+logger = logging.getLogger(__name__)
+
+_CACHE: list[str] | None = None
+
+
+class OpenRouterProfile(ProviderProfile):
+ """OpenRouter aggregator — provider preferences, reasoning config passthrough."""
+
+ def fetch_models(
+ self,
+ *,
+ api_key: str | None = None,
+ timeout: float = 8.0,
+ ) -> list[str] | None:
+ """Fetch from public OpenRouter catalog — no auth required.
+
+ Note: Tool-call capability filtering is applied by hermes_cli/models.py
+ via fetch_openrouter_models() → _openrouter_model_supports_tools(), not
+ here. The picker early-returns via the dedicated openrouter path before
+ reaching this method, so filtering here would be unreachable.
+ """
+ global _CACHE # noqa: PLW0603
+ if _CACHE is not None:
+ return _CACHE
+ try:
+ result = super().fetch_models(api_key=None, timeout=timeout)
+ if result is not None:
+ _CACHE = result
+ return result
+ except Exception as exc:
+ logger.debug("fetch_models(openrouter): %s", exc)
+ return None
+
+ def build_extra_body(
+ self, *, session_id: str | None = None, **context: Any
+ ) -> dict[str, Any]:
+ body: dict[str, Any] = {}
+ prefs = context.get("provider_preferences")
+ if prefs:
+ body["provider"] = prefs
+ return body
+
+ def build_api_kwargs_extras(
+ self,
+ *,
+ reasoning_config: dict | None = None,
+ supports_reasoning: bool = False,
+ **context: Any,
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ """OpenRouter passes the full reasoning_config dict as extra_body.reasoning."""
+ extra_body: dict[str, Any] = {}
+ if supports_reasoning:
+ if reasoning_config is not None:
+ extra_body["reasoning"] = dict(reasoning_config)
+ else:
+ extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
+ return extra_body, {}
+
+
+openrouter = OpenRouterProfile(
+ name="openrouter",
+ aliases=("or",),
+ env_vars=("OPENROUTER_API_KEY",),
+ display_name="OpenRouter",
+ description="OpenRouter — unified API for 200+ models",
+ signup_url="https://openrouter.ai/keys",
+ base_url="https://openrouter.ai/api/v1",
+ models_url="https://openrouter.ai/api/v1/models",
+ fallback_models=(
+ "anthropic/claude-sonnet-4.6",
+ "openai/gpt-5.4",
+ "deepseek/deepseek-chat",
+ "google/gemini-3-flash-preview",
+ "qwen/qwen3-plus",
+ ),
+)
+
+register_provider(openrouter)
diff --git a/providers/qwen.py b/providers/qwen.py
new file mode 100644
index 0000000000..a6ba29f76c
--- /dev/null
+++ b/providers/qwen.py
@@ -0,0 +1,82 @@
+"""Qwen Portal provider profile."""
+
+import copy
+from typing import Any
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+
+class QwenProfile(ProviderProfile):
+ """Qwen Portal — message normalization, vl_high_resolution, metadata top-level."""
+
+ def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Normalize content to list-of-dicts format.
+
+ Inject cache_control on system message.
+
+ Matches the behavior of run_agent.py:_qwen_prepare_chat_messages().
+ """
+ prepared = copy.deepcopy(messages)
+ if not prepared:
+ return prepared
+
+ for msg in prepared:
+ if not isinstance(msg, dict):
+ continue
+ content = msg.get("content")
+ if isinstance(content, str):
+ msg["content"] = [{"type": "text", "text": content}]
+ elif isinstance(content, list):
+ normalized_parts = []
+ for part in content:
+ if isinstance(part, str):
+ normalized_parts.append({"type": "text", "text": part})
+ elif isinstance(part, dict):
+ normalized_parts.append(part)
+ if normalized_parts:
+ msg["content"] = normalized_parts
+
+ # Inject cache_control on the last part of the system message.
+ for msg in prepared:
+ if isinstance(msg, dict) and msg.get("role") == "system":
+ content = msg.get("content")
+ if (
+ isinstance(content, list)
+ and content
+ and isinstance(content[-1], dict)
+ ):
+ content[-1]["cache_control"] = {"type": "ephemeral"}
+ break
+
+ return prepared
+
+ def build_extra_body(
+ self, *, session_id: str | None = None, **context
+ ) -> dict[str, Any]:
+ return {"vl_high_resolution_images": True}
+
+ def build_api_kwargs_extras(
+ self,
+ *,
+ reasoning_config: dict | None = None,
+ qwen_session_metadata: dict | None = None,
+ **context,
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ """Qwen metadata goes to top-level api_kwargs, not extra_body."""
+ top_level = {}
+ if qwen_session_metadata:
+ top_level["metadata"] = qwen_session_metadata
+ return {}, top_level
+
+
+qwen = QwenProfile(
+ name="qwen-oauth",
+ aliases=("qwen", "qwen-portal", "qwen-cli"),
+ env_vars=("QWEN_API_KEY",),
+ base_url="https://portal.qwen.ai/v1",
+ auth_type="oauth_external",
+ default_max_tokens=65536,
+)
+
+register_provider(qwen)
diff --git a/providers/stepfun.py b/providers/stepfun.py
new file mode 100644
index 0000000000..1ec92cd8be
--- /dev/null
+++ b/providers/stepfun.py
@@ -0,0 +1,14 @@
+"""StepFun provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+stepfun = ProviderProfile(
+ name="stepfun",
+ aliases=("step", "stepfun-coding-plan"),
+ default_aux_model="step-3.5-flash",
+ env_vars=("STEPFUN_API_KEY",),
+ base_url="https://api.stepfun.ai/step_plan/v1",
+)
+
+register_provider(stepfun)
diff --git a/providers/vercel.py b/providers/vercel.py
new file mode 100644
index 0000000000..9d01ab9824
--- /dev/null
+++ b/providers/vercel.py
@@ -0,0 +1,43 @@
+"""Vercel AI Gateway provider profile.
+
+AI Gateway routes to multiple backends. Hermes sends attribution
+headers and full reasoning config passthrough.
+"""
+
+from typing import Any
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+
+class VercelAIGatewayProfile(ProviderProfile):
+ """Vercel AI Gateway — attribution headers + reasoning passthrough."""
+
+ def build_api_kwargs_extras(
+ self,
+ *,
+ reasoning_config: dict | None = None,
+ supports_reasoning: bool = True,
+ **ctx: Any,
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ extra_body: dict[str, Any] = {}
+ if supports_reasoning and reasoning_config is not None:
+ extra_body["reasoning"] = dict(reasoning_config)
+ elif supports_reasoning:
+ extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
+ return extra_body, {}
+
+
+vercel = VercelAIGatewayProfile(
+ name="ai-gateway",
+ aliases=("vercel", "vercel-ai-gateway", "ai_gateway", "aigateway"),
+ env_vars=("AI_GATEWAY_API_KEY",),
+ base_url="https://ai-gateway.vercel.sh/v1",
+ default_headers={
+ "HTTP-Referer": "https://hermes-agent.nousresearch.com",
+ "X-Title": "Hermes Agent",
+ },
+ default_aux_model="google/gemini-3-flash",
+)
+
+register_provider(vercel)
diff --git a/providers/xai.py b/providers/xai.py
new file mode 100644
index 0000000000..8d73ae0199
--- /dev/null
+++ b/providers/xai.py
@@ -0,0 +1,15 @@
+"""xAI (Grok) provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+xai = ProviderProfile(
+ name="xai",
+ aliases=("grok", "x-ai", "x.ai"),
+ api_mode="codex_responses",
+ env_vars=("XAI_API_KEY",),
+ base_url="https://api.x.ai/v1",
+ auth_type="api_key",
+)
+
+register_provider(xai)
diff --git a/providers/xiaomi.py b/providers/xiaomi.py
new file mode 100644
index 0000000000..2e0c8db7db
--- /dev/null
+++ b/providers/xiaomi.py
@@ -0,0 +1,13 @@
+"""Xiaomi MiMo provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+xiaomi = ProviderProfile(
+ name="xiaomi",
+ aliases=("mimo", "xiaomi-mimo"),
+ env_vars=("XIAOMI_API_KEY",),
+ base_url="https://api.xiaomimimo.com/v1",
+)
+
+register_provider(xiaomi)
diff --git a/providers/zai.py b/providers/zai.py
new file mode 100644
index 0000000000..70aa8704d1
--- /dev/null
+++ b/providers/zai.py
@@ -0,0 +1,21 @@
+"""ZAI / GLM provider profile."""
+
+from providers import register_provider
+from providers.base import ProviderProfile
+
+zai = ProviderProfile(
+ name="zai",
+ aliases=("glm", "z-ai", "z.ai", "zhipu"),
+ env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
+ display_name="Z.AI (GLM)",
+ description="Z.AI / GLM — Zhipu AI models",
+ signup_url="https://z.ai/",
+ fallback_models=(
+ "glm-5",
+ "glm-4-9b",
+ ),
+ base_url="https://api.z.ai/api/paas/v4",
+ default_aux_model="glm-4.5-flash",
+)
+
+register_provider(zai)
diff --git a/pyproject.toml b/pyproject.toml
index b5de3d69f6..6c1cd9d459 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -142,7 +142,7 @@ hermes_cli = ["web_dist/**/*"]
gateway = ["assets/**/*"]
[tool.setuptools.packages.find]
-include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
+include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
diff --git a/run_agent.py b/run_agent.py
index 546cc0ef65..c76d2a61b5 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -1461,6 +1461,17 @@ class AIAgent:
elif base_url_host_matches(effective_base, "chatgpt.com"):
from agent.auxiliary_client import _codex_cloudflare_headers
client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key)
+ elif "default_headers" not in client_kwargs:
+ # Fall back to profile.default_headers for providers that
+ # declare custom headers (e.g. Vercel AI Gateway attribution,
+ # Kimi User-Agent on non-kimi.com endpoints).
+ try:
+ from providers import get_provider_profile as _gpf
+ _ph = _gpf(self.provider)
+ if _ph and _ph.default_headers:
+ client_kwargs["default_headers"] = dict(_ph.default_headers)
+ except Exception:
+ pass
else:
# No explicit creds — use the centralized provider router
from agent.auxiliary_client import resolve_provider_client
@@ -6261,7 +6272,19 @@ class AIAgent:
self._client_kwargs.get("api_key", "")
)
else:
- self._client_kwargs.pop("default_headers", None)
+ # No URL-specific headers — check profile.default_headers before clearing.
+ _ph_headers = None
+ try:
+ from providers import get_provider_profile as _gpf2
+ _ph2 = _gpf2(self.provider)
+ if _ph2 and _ph2.default_headers:
+ _ph_headers = dict(_ph2.default_headers)
+ except Exception:
+ pass
+ if _ph_headers:
+ self._client_kwargs["default_headers"] = _ph_headers
+ else:
+ self._client_kwargs.pop("default_headers", None)
def _swap_credential(self, entry) -> None:
runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
@@ -8494,7 +8517,7 @@ class AIAgent:
_omit_temp = False
_fixed_temp = None
- # Provider preferences (OpenRouter-specific)
+ # Provider preferences (OpenRouter-style)
_prefs: Dict[str, Any] = {}
if self.providers_allowed:
_prefs["only"] = self.providers_allowed
@@ -8509,16 +8532,16 @@ class AIAgent:
if self.provider_data_collection:
_prefs["data_collection"] = self.provider_data_collection
- # Anthropic max output for Claude on OpenRouter/Nous
+ # Claude max-output override on aggregators
_ant_max = None
if (_is_or or _is_nous) and "claude" in (self.model or "").lower():
try:
from agent.anthropic_adapter import _get_anthropic_max_output
_ant_max = _get_anthropic_max_output(self.model)
except Exception:
- pass # fail open — let the proxy pick its default
+ pass
- # Qwen session metadata precomputed here (promptId is per-call random)
+ # Qwen session metadata
_qwen_meta = None
if _is_qwen:
_qwen_meta = {
@@ -8526,8 +8549,44 @@ class AIAgent:
"promptId": str(uuid.uuid4()),
}
- # Ephemeral max output override — consume immediately so the next
- # turn doesn't inherit it.
+ # ── Provider profile path (registered providers) ───────────────────
+ # Profiles handle per-provider quirks via hooks. When a profile is
+ # found, delegate fully; otherwise fall through to the legacy flag path.
+ try:
+ from providers import get_provider_profile
+ _profile = get_provider_profile(self.provider)
+ except Exception:
+ _profile = None
+
+ if _profile:
+ _ephemeral_out = getattr(self, "_ephemeral_max_output_tokens", None)
+ if _ephemeral_out is not None:
+ self._ephemeral_max_output_tokens = None
+
+ return _ct.build_kwargs(
+ model=self.model,
+ messages=api_messages,
+ tools=self.tools,
+ base_url=self.base_url,
+ timeout=self._resolved_api_call_timeout(),
+ max_tokens=self.max_tokens,
+ ephemeral_max_output_tokens=_ephemeral_out,
+ max_tokens_param_fn=self._max_tokens_param,
+ reasoning_config=self.reasoning_config,
+ request_overrides=self.request_overrides,
+ session_id=getattr(self, "session_id", None),
+ provider_profile=_profile,
+ ollama_num_ctx=self._ollama_num_ctx,
+ # Context forwarded to profile hooks:
+ provider_preferences=_prefs or None,
+ anthropic_max_output=_ant_max,
+ supports_reasoning=self._supports_reasoning_extra_body(),
+ qwen_session_metadata=_qwen_meta,
+ )
+
+ # ── Legacy flag path ────────────────────────────────────────────
+ # Reached only when get_provider_profile() returns None — i.e. a
+ # completely unknown provider not in providers/ registry.
_ephemeral_out = getattr(self, "_ephemeral_max_output_tokens", None)
if _ephemeral_out is not None:
self._ephemeral_max_output_tokens = None
diff --git a/tests/agent/test_minimax_provider.py b/tests/agent/test_minimax_provider.py
index 7c64b3575a..2e7f134e4d 100644
--- a/tests/agent/test_minimax_provider.py
+++ b/tests/agent/test_minimax_provider.py
@@ -71,17 +71,17 @@ class TestMinimaxThinkingSupport:
class TestMinimaxAuxModel:
- """Verify auxiliary model is standard (not highspeed)."""
+ """Verify auxiliary model is standard (not highspeed) — now reads from profiles."""
def test_minimax_aux_is_standard(self):
- from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
- assert _API_KEY_PROVIDER_AUX_MODELS["minimax"] == "MiniMax-M2.7"
- assert _API_KEY_PROVIDER_AUX_MODELS["minimax-cn"] == "MiniMax-M2.7"
+ from agent.auxiliary_client import _get_aux_model_for_provider
+ assert _get_aux_model_for_provider("minimax") == "MiniMax-M2.7"
+ assert _get_aux_model_for_provider("minimax-cn") == "MiniMax-M2.7"
def test_minimax_aux_not_highspeed(self):
- from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
- assert "highspeed" not in _API_KEY_PROVIDER_AUX_MODELS["minimax"]
- assert "highspeed" not in _API_KEY_PROVIDER_AUX_MODELS["minimax-cn"]
+ from agent.auxiliary_client import _get_aux_model_for_provider
+ assert "highspeed" not in _get_aux_model_for_provider("minimax")
+ assert "highspeed" not in _get_aux_model_for_provider("minimax-cn")
class TestMinimaxBetaHeaders:
diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py
index b8fdced8aa..4e16757c15 100644
--- a/tests/agent/transports/test_chat_completions.py
+++ b/tests/agent/transports/test_chat_completions.py
@@ -73,17 +73,21 @@ class TestChatCompletionsBuildKwargs:
assert kw["tools"] == tools
def test_openrouter_provider_prefs(self, transport):
+ from providers import get_provider_profile
+ profile = get_provider_profile("openrouter")
msgs = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="gpt-4o", messages=msgs,
- is_openrouter=True,
+ provider_profile=profile,
provider_preferences={"only": ["openai"]},
)
assert kw["extra_body"]["provider"] == {"only": ["openai"]}
def test_nous_tags(self, transport):
+ from providers import get_provider_profile
+ profile = get_provider_profile("nous")
msgs = [{"role": "user", "content": "Hi"}]
- kw = transport.build_kwargs(model="gpt-4o", messages=msgs, is_nous=True)
+ kw = transport.build_kwargs(model="gpt-4o", messages=msgs, provider_profile=profile)
assert kw["extra_body"]["tags"] == ["product=hermes-agent"]
def test_reasoning_default(self, transport):
@@ -95,29 +99,36 @@ class TestChatCompletionsBuildKwargs:
assert kw["extra_body"]["reasoning"] == {"enabled": True, "effort": "medium"}
def test_nous_omits_disabled_reasoning(self, transport):
+ from providers import get_provider_profile
+ profile = get_provider_profile("nous")
msgs = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="gpt-4o", messages=msgs,
+ provider_profile=profile,
supports_reasoning=True,
- is_nous=True,
reasoning_config={"enabled": False},
)
# Nous rejects enabled=false; reasoning omitted entirely
assert "reasoning" not in kw.get("extra_body", {})
def test_ollama_num_ctx(self, transport):
+ from providers import get_provider_profile
+ profile = get_provider_profile("custom")
msgs = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="llama3", messages=msgs,
+ provider_profile=profile,
ollama_num_ctx=32768,
)
assert kw["extra_body"]["options"]["num_ctx"] == 32768
def test_custom_think_false(self, transport):
+ from providers import get_provider_profile
+ profile = get_provider_profile("custom")
msgs = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="qwen3", messages=msgs,
- is_custom_provider=True,
+ provider_profile=profile,
reasoning_config={"effort": "none"},
)
assert kw["extra_body"]["think"] is False
@@ -304,23 +315,29 @@ class TestChatCompletionsBuildKwargs:
assert kw["max_tokens"] == 2048
def test_nvidia_default_max_tokens(self, transport):
+ """NVIDIA max_tokens=16384 is now set via ProviderProfile, not legacy flag."""
+ from providers import get_provider_profile
+
+ profile = get_provider_profile("nvidia")
msgs = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
- model="glm-4.7", messages=msgs,
- is_nvidia_nim=True,
+ model="nvidia/llama-3.1-405b-instruct",
+ messages=msgs,
max_tokens_param_fn=lambda n: {"max_tokens": n},
+ provider_profile=profile,
)
- # NVIDIA default: 16384
assert kw["max_tokens"] == 16384
def test_qwen_default_max_tokens(self, transport):
+ from providers import get_provider_profile
+ profile = get_provider_profile("qwen-oauth")
msgs = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="qwen3-coder-plus", messages=msgs,
- is_qwen_portal=True,
+ provider_profile=profile,
max_tokens_param_fn=lambda n: {"max_tokens": n},
)
- # Qwen default: 65536
+ # Qwen default: 65536 from profile.default_max_tokens
assert kw["max_tokens"] == 65536
def test_anthropic_max_output_for_claude_on_aggregator(self, transport):
@@ -343,14 +360,23 @@ class TestChatCompletionsBuildKwargs:
assert kw["service_tier"] == "priority"
def test_fixed_temperature(self, transport):
+ """Fixed temperature is now set via ProviderProfile.fixed_temperature."""
+ from providers.base import ProviderProfile
msgs = [{"role": "user", "content": "Hi"}]
- kw = transport.build_kwargs(model="gpt-4o", messages=msgs, fixed_temperature=0.6)
+ kw = transport.build_kwargs(
+ model="gpt-4o", messages=msgs,
+ provider_profile=ProviderProfile(name="_t", fixed_temperature=0.6),
+ )
assert kw["temperature"] == 0.6
def test_omit_temperature(self, transport):
+ """Omit temperature is set via ProviderProfile with OMIT_TEMPERATURE sentinel."""
+ from providers.base import ProviderProfile, OMIT_TEMPERATURE
msgs = [{"role": "user", "content": "Hi"}]
- kw = transport.build_kwargs(model="gpt-4o", messages=msgs, omit_temperature=True, fixed_temperature=0.5)
- # omit wins
+ kw = transport.build_kwargs(
+ model="gpt-4o", messages=msgs,
+ provider_profile=ProviderProfile(name="_t", fixed_temperature=OMIT_TEMPERATURE),
+ )
assert "temperature" not in kw
@@ -358,18 +384,22 @@ class TestChatCompletionsKimi:
"""Regression tests for the Kimi/Moonshot quirks migrated into the transport."""
def test_kimi_max_tokens_default(self, transport):
+ from providers import get_provider_profile
+ profile = get_provider_profile("kimi-coding")
kw = transport.build_kwargs(
model="kimi-k2", messages=[{"role": "user", "content": "Hi"}],
- is_kimi=True,
+ provider_profile=profile,
max_tokens_param_fn=lambda n: {"max_tokens": n},
)
- # Kimi CLI default: 32000
+ # Kimi CLI default: 32000 from KimiProfile.default_max_tokens
assert kw["max_tokens"] == 32000
def test_kimi_reasoning_effort_top_level(self, transport):
+ from providers import get_provider_profile
+ profile = get_provider_profile("kimi-coding")
kw = transport.build_kwargs(
model="kimi-k2", messages=[{"role": "user", "content": "Hi"}],
- is_kimi=True,
+ provider_profile=profile,
reasoning_config={"effort": "high"},
max_tokens_param_fn=lambda n: {"max_tokens": n},
)
@@ -387,17 +417,21 @@ class TestChatCompletionsKimi:
assert "reasoning_effort" not in kw
def test_kimi_thinking_enabled_extra_body(self, transport):
+ from providers import get_provider_profile
+ profile = get_provider_profile("kimi-coding")
kw = transport.build_kwargs(
model="kimi-k2", messages=[{"role": "user", "content": "Hi"}],
- is_kimi=True,
+ provider_profile=profile,
max_tokens_param_fn=lambda n: {"max_tokens": n},
)
assert kw["extra_body"]["thinking"] == {"type": "enabled"}
def test_kimi_thinking_disabled_extra_body(self, transport):
+ from providers import get_provider_profile
+ profile = get_provider_profile("kimi-coding")
kw = transport.build_kwargs(
model="kimi-k2", messages=[{"role": "user", "content": "Hi"}],
- is_kimi=True,
+ provider_profile=profile,
reasoning_config={"enabled": False},
max_tokens_param_fn=lambda n: {"max_tokens": n},
)
diff --git a/tests/hermes_cli/test_gmi_provider.py b/tests/hermes_cli/test_gmi_provider.py
index d3b8c1d7aa..0b9363e675 100644
--- a/tests/hermes_cli/test_gmi_provider.py
+++ b/tests/hermes_cli/test_gmi_provider.py
@@ -269,9 +269,9 @@ class TestGmiModelMetadata:
class TestGmiAuxiliary:
def test_aux_default_model(self):
- from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
+ from agent.auxiliary_client import _get_aux_model_for_provider
- assert _API_KEY_PROVIDER_AUX_MODELS["gmi"] == "google/gemini-3.1-flash-lite-preview"
+ assert _get_aux_model_for_provider("gmi") == "google/gemini-3.1-flash-lite-preview"
def test_resolve_provider_client_uses_gmi_aux_default(self, monkeypatch):
monkeypatch.setenv("GMI_API_KEY", "gmi-test-key")
diff --git a/tests/providers/__init__.py b/tests/providers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/test_e2e_wiring.py b/tests/providers/test_e2e_wiring.py
new file mode 100644
index 0000000000..424dad69bc
--- /dev/null
+++ b/tests/providers/test_e2e_wiring.py
@@ -0,0 +1,118 @@
+"""E2E tests: verify _build_kwargs_from_profile produces correct output.
+
+These tests call _build_kwargs_from_profile on the transport directly,
+without importing run_agent (which would cause xdist worker contamination).
+"""
+
+import pytest
+from agent.transports.chat_completions import ChatCompletionsTransport
+from providers import get_provider_profile
+
+
+@pytest.fixture
+def transport():
+ return ChatCompletionsTransport()
+
+
+def _msgs():
+ return [{"role": "user", "content": "hi"}]
+
+
+class TestNvidiaProfileWiring:
+ def test_nvidia_gets_default_max_tokens(self, transport):
+ profile = get_provider_profile("nvidia")
+ kwargs = transport.build_kwargs(
+ model="nvidia/llama-3.1-nemotron-70b-instruct",
+ messages=_msgs(),
+ tools=None,
+ provider_profile=profile,
+ max_tokens=None,
+ max_tokens_param_fn=lambda x: {"max_tokens": x} if x else {},
+ timeout=300,
+ reasoning_config=None,
+ request_overrides=None,
+ session_id="test",
+ ollama_num_ctx=None,
+ )
+ # NVIDIA profile sets default_max_tokens=16384
+ assert kwargs.get("max_tokens") == 16384
+
+ def test_nvidia_nim_alias(self, transport):
+ profile = get_provider_profile("nvidia-nim")
+ assert profile is not None
+ assert profile.name == "nvidia"
+ assert profile.default_max_tokens == 16384
+
+ def test_nvidia_model_passed(self, transport):
+ profile = get_provider_profile("nvidia")
+ kwargs = transport.build_kwargs(
+ model="nvidia/test-model",
+ messages=_msgs(),
+ tools=None,
+ provider_profile=profile,
+ max_tokens=None,
+ max_tokens_param_fn=lambda x: {"max_tokens": x} if x else {},
+ timeout=300,
+ reasoning_config=None,
+ request_overrides=None,
+ session_id="test",
+ ollama_num_ctx=None,
+ )
+ assert kwargs["model"] == "nvidia/test-model"
+
+ def test_nvidia_messages_passed(self, transport):
+ profile = get_provider_profile("nvidia")
+ msgs = _msgs()
+ kwargs = transport.build_kwargs(
+ model="nvidia/test",
+ messages=msgs,
+ tools=None,
+ provider_profile=profile,
+ max_tokens=None,
+ max_tokens_param_fn=lambda x: {"max_tokens": x} if x else {},
+ timeout=300,
+ reasoning_config=None,
+ request_overrides=None,
+ session_id="test",
+ ollama_num_ctx=None,
+ )
+ assert kwargs["messages"] == msgs
+
+
+class TestDeepSeekProfileWiring:
+ def test_deepseek_no_forced_max_tokens(self, transport):
+ profile = get_provider_profile("deepseek")
+ kwargs = transport.build_kwargs(
+ model="deepseek-chat",
+ messages=_msgs(),
+ tools=None,
+ provider_profile=profile,
+ max_tokens=None,
+ max_tokens_param_fn=lambda x: {"max_tokens": x} if x else {},
+ timeout=300,
+ reasoning_config=None,
+ request_overrides=None,
+ session_id="test",
+ ollama_num_ctx=None,
+ )
+ # DeepSeek has no default_max_tokens
+ assert kwargs["model"] == "deepseek-chat"
+ assert kwargs.get("max_tokens") is None or "max_tokens" not in kwargs
+
+ def test_deepseek_messages_passed(self, transport):
+ profile = get_provider_profile("deepseek")
+ msgs = _msgs()
+ kwargs = transport.build_kwargs(
+ model="deepseek-chat",
+ messages=msgs,
+ tools=None,
+ provider_profile=profile,
+ max_tokens=None,
+ max_tokens_param_fn=lambda x: {"max_tokens": x} if x else {},
+ timeout=300,
+ reasoning_config=None,
+ request_overrides=None,
+ session_id="test",
+ ollama_num_ctx=None,
+ )
+ assert kwargs["messages"] == msgs
diff --git a/tests/providers/test_profile_wiring.py b/tests/providers/test_profile_wiring.py
new file mode 100644
index 0000000000..9096c82b6a
--- /dev/null
+++ b/tests/providers/test_profile_wiring.py
@@ -0,0 +1,290 @@
+"""Profile-path parity tests: verify profile path produces identical output to legacy flags.
+
+Each test calls build_kwargs twice — once with legacy flags, once with provider_profile —
+and asserts the output is identical. This catches any behavioral drift between the two paths.
+"""
+
+import pytest
+from agent.transports.chat_completions import ChatCompletionsTransport
+from providers import get_provider_profile
+
+
+@pytest.fixture
+def transport():
+ return ChatCompletionsTransport()
+
+
+def _msgs():
+ return [{"role": "user", "content": "hello"}]
+
+
+def _max_tokens_fn(n):
+ return {"max_completion_tokens": n}
+
+
+class TestNvidiaProfileParity:
+ def test_max_tokens_match(self, transport):
+ """NVIDIA profile sets max_tokens=16384; legacy flag is removed."""
+ profile = transport.build_kwargs(
+ model="nvidia/nemotron", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("nvidia"),
+ max_tokens_param_fn=_max_tokens_fn,
+ )
+ assert profile["max_completion_tokens"] == 16384
+
+
+class TestKimiProfileParity:
+ def test_temperature_omitted(self, transport):
+ legacy = transport.build_kwargs(
+ model="kimi-k2", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("kimi-coding"), omit_temperature=True,
+ )
+ profile = transport.build_kwargs(
+ model="kimi-k2", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("kimi"),
+ )
+ assert "temperature" not in legacy
+ assert "temperature" not in profile
+
+ def test_max_tokens(self, transport):
+ legacy = transport.build_kwargs(
+ model="kimi-k2", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("kimi-coding"), max_tokens_param_fn=_max_tokens_fn,
+ )
+ profile = transport.build_kwargs(
+ model="kimi-k2", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("kimi"),
+ max_tokens_param_fn=_max_tokens_fn,
+ )
+ assert profile["max_completion_tokens"] == legacy["max_completion_tokens"] == 32000
+
+ def test_thinking_enabled(self, transport):
+ rc = {"enabled": True, "effort": "high"}
+ legacy = transport.build_kwargs(
+ model="kimi-k2", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("kimi-coding"), reasoning_config=rc,
+ )
+ profile = transport.build_kwargs(
+ model="kimi-k2", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("kimi"),
+ reasoning_config=rc,
+ )
+ assert profile["extra_body"]["thinking"] == legacy["extra_body"]["thinking"]
+ assert profile["reasoning_effort"] == legacy["reasoning_effort"] == "high"
+
+ def test_thinking_disabled(self, transport):
+ rc = {"enabled": False}
+ legacy = transport.build_kwargs(
+ model="kimi-k2", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("kimi-coding"), reasoning_config=rc,
+ )
+ profile = transport.build_kwargs(
+ model="kimi-k2", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("kimi"),
+ reasoning_config=rc,
+ )
+ assert profile["extra_body"]["thinking"] == legacy["extra_body"]["thinking"]
+ assert profile["extra_body"]["thinking"]["type"] == "disabled"
+ assert "reasoning_effort" not in profile
+ assert "reasoning_effort" not in legacy
+
+ def test_reasoning_effort_default(self, transport):
+ rc = {"enabled": True}
+ legacy = transport.build_kwargs(
+ model="kimi-k2", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("kimi-coding"), reasoning_config=rc,
+ )
+ profile = transport.build_kwargs(
+ model="kimi-k2", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("kimi"),
+ reasoning_config=rc,
+ )
+ assert profile["reasoning_effort"] == legacy["reasoning_effort"] == "medium"
+
+
+class TestOpenRouterProfileParity:
+ def test_provider_preferences(self, transport):
+ prefs = {"allow": ["anthropic"]}
+ legacy = transport.build_kwargs(
+ model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("openrouter"), provider_preferences=prefs,
+ )
+ profile = transport.build_kwargs(
+ model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("openrouter"),
+ provider_preferences=prefs,
+ )
+ assert profile["extra_body"]["provider"] == legacy["extra_body"]["provider"]
+
+ def test_reasoning_full_config(self, transport):
+ rc = {"enabled": True, "effort": "high"}
+ legacy = transport.build_kwargs(
+ model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("openrouter"), supports_reasoning=True, reasoning_config=rc,
+ )
+ profile = transport.build_kwargs(
+ model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("openrouter"),
+ supports_reasoning=True, reasoning_config=rc,
+ )
+ assert profile["extra_body"]["reasoning"] == legacy["extra_body"]["reasoning"]
+
+ def test_default_reasoning(self, transport):
+ legacy = transport.build_kwargs(
+ model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("openrouter"), supports_reasoning=True,
+ )
+ profile = transport.build_kwargs(
+ model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("openrouter"),
+ supports_reasoning=True,
+ )
+ assert profile["extra_body"]["reasoning"] == legacy["extra_body"]["reasoning"]
+
+
+class TestNousProfileParity:
+ def test_tags(self, transport):
+ legacy = transport.build_kwargs(
+ model="hermes-3", messages=_msgs(), tools=None, provider_profile=get_provider_profile("nous"),
+ )
+ profile = transport.build_kwargs(
+ model="hermes-3", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("nous"),
+ )
+ assert profile["extra_body"]["tags"] == legacy["extra_body"]["tags"]
+
+ def test_reasoning_omitted_when_disabled(self, transport):
+ rc = {"enabled": False}
+ legacy = transport.build_kwargs(
+ model="hermes-3", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("nous"), supports_reasoning=True, reasoning_config=rc,
+ )
+ profile = transport.build_kwargs(
+ model="hermes-3", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("nous"),
+ supports_reasoning=True, reasoning_config=rc,
+ )
+ assert "reasoning" not in legacy.get("extra_body", {})
+ assert "reasoning" not in profile.get("extra_body", {})
+
+
+class TestQwenProfileParity:
+ def test_max_tokens(self, transport):
+ legacy = transport.build_kwargs(
+ model="qwen3.5", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("qwen-oauth"), max_tokens_param_fn=_max_tokens_fn,
+ )
+ profile = transport.build_kwargs(
+ model="qwen3.5", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("qwen"),
+ max_tokens_param_fn=_max_tokens_fn,
+ )
+ assert profile["max_completion_tokens"] == legacy["max_completion_tokens"] == 65536
+
+ def test_vl_high_resolution(self, transport):
+ legacy = transport.build_kwargs(
+ model="qwen3.5", messages=_msgs(), tools=None, provider_profile=get_provider_profile("qwen-oauth"),
+ )
+ profile = transport.build_kwargs(
+ model="qwen3.5", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("qwen"),
+ )
+ assert profile["extra_body"]["vl_high_resolution_images"] == legacy["extra_body"]["vl_high_resolution_images"]
+
+ def test_metadata_top_level(self, transport):
+ meta = {"sessionId": "s123", "promptId": "p456"}
+ legacy = transport.build_kwargs(
+ model="qwen3.5", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("qwen-oauth"), qwen_session_metadata=meta,
+ )
+ profile = transport.build_kwargs(
+ model="qwen3.5", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("qwen"),
+ qwen_session_metadata=meta,
+ )
+ assert profile["metadata"] == legacy["metadata"] == meta
+ assert "metadata" not in profile.get("extra_body", {})
+
+ def test_message_preprocessing(self, transport):
+ """Qwen profile normalizes string content to list-of-parts."""
+ msgs = [
+ {"role": "system", "content": "You are helpful."},
+ {"role": "user", "content": "hello"},
+ ]
+ profile = transport.build_kwargs(
+ model="qwen3.5", messages=msgs, tools=None,
+ provider_profile=get_provider_profile("qwen"),
+ )
+ out_msgs = profile["messages"]
+ # System message content normalized + cache_control injected
+ assert isinstance(out_msgs[0]["content"], list)
+ assert out_msgs[0]["content"][0]["type"] == "text"
+ assert "cache_control" in out_msgs[0]["content"][-1]
+ # User message content normalized
+ assert isinstance(out_msgs[1]["content"], list)
+ assert out_msgs[1]["content"][0] == {"type": "text", "text": "hello"}
+
+
+class TestDeveloperRoleParity:
+ """Developer role swap must work on BOTH legacy and profile paths."""
+
+ def test_legacy_path_swaps_for_gpt5(self, transport):
+ msgs = [{"role": "system", "content": "Be helpful"}, {"role": "user", "content": "hi"}]
+ kw = transport.build_kwargs(
+ model="gpt-5.4", messages=msgs, tools=None,
+ )
+ assert kw["messages"][0]["role"] == "developer"
+
+ def test_profile_path_swaps_for_gpt5(self, transport):
+ msgs = [{"role": "system", "content": "Be helpful"}, {"role": "user", "content": "hi"}]
+ kw = transport.build_kwargs(
+ model="gpt-5.4", messages=msgs, tools=None,
+ provider_profile=get_provider_profile("openrouter"),
+ )
+ assert kw["messages"][0]["role"] == "developer"
+
+ def test_profile_path_no_swap_for_claude(self, transport):
+ msgs = [{"role": "system", "content": "Be helpful"}, {"role": "user", "content": "hi"}]
+ kw = transport.build_kwargs(
+ model="anthropic/claude-sonnet-4.6", messages=msgs, tools=None,
+ provider_profile=get_provider_profile("openrouter"),
+ )
+ assert kw["messages"][0]["role"] == "system"
+
+
+class TestRequestOverridesParity:
+ """request_overrides with extra_body must merge identically on both paths."""
+
+ def test_extra_body_override_legacy(self, transport):
+ kw = transport.build_kwargs(
+ model="gpt-5.4", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("openrouter"),
+ request_overrides={"extra_body": {"custom_key": "custom_val"}},
+ )
+ assert kw["extra_body"]["custom_key"] == "custom_val"
+
+ def test_extra_body_override_profile(self, transport):
+ kw = transport.build_kwargs(
+ model="gpt-5.4", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("openrouter"),
+ request_overrides={"extra_body": {"custom_key": "custom_val"}},
+ )
+ assert kw["extra_body"]["custom_key"] == "custom_val"
+
+ def test_extra_body_override_merges_with_provider_body(self, transport):
+ """Override extra_body merges WITH provider extra_body, not replaces."""
+ kw = transport.build_kwargs(
+ model="hermes-3", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("nous"),
+ request_overrides={"extra_body": {"custom": True}},
+ )
+ assert kw["extra_body"]["tags"] == ["product=hermes-agent"] # from profile
+ assert kw["extra_body"]["custom"] is True # from override
+
+ def test_top_level_override(self, transport):
+ kw = transport.build_kwargs(
+ model="gpt-5.4", messages=_msgs(), tools=None,
+ provider_profile=get_provider_profile("openrouter"),
+ request_overrides={"top_p": 0.9},
+ )
+ assert kw["top_p"] == 0.9
diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py
new file mode 100644
index 0000000000..3e80b0d2f2
--- /dev/null
+++ b/tests/providers/test_provider_profiles.py
@@ -0,0 +1,203 @@
+"""Tests for the provider module registry and profiles."""
+
+import pytest
+from providers import get_provider_profile, _REGISTRY
+from providers.base import ProviderProfile, OMIT_TEMPERATURE
+
+
+class TestRegistry:
+ def test_discovery_populates_registry(self):
+ p = get_provider_profile("nvidia")
+ assert p is not None
+ assert p.name == "nvidia"
+
+ def test_alias_lookup(self):
+ assert get_provider_profile("kimi").name == "kimi-coding"
+ assert get_provider_profile("moonshot").name == "kimi-coding"
+ assert get_provider_profile("kimi-coding-cn").name == "kimi-coding-cn"
+ assert get_provider_profile("or").name == "openrouter"
+ assert get_provider_profile("nous-portal").name == "nous"
+ assert get_provider_profile("qwen").name == "qwen-oauth"
+ assert get_provider_profile("qwen-portal").name == "qwen-oauth"
+
+ def test_unknown_provider_returns_none(self):
+ assert get_provider_profile("nonexistent-provider") is None
+
+ def test_all_providers_have_name(self):
+ get_provider_profile("nvidia") # trigger discovery
+ for name, profile in _REGISTRY.items():
+ assert profile.name == name
+
+
+class TestNvidiaProfile:
+ def test_max_tokens(self):
+ p = get_provider_profile("nvidia")
+ assert p.default_max_tokens == 16384
+
+ def test_no_special_temperature(self):
+ p = get_provider_profile("nvidia")
+ assert p.fixed_temperature is None
+
+ def test_base_url(self):
+ p = get_provider_profile("nvidia")
+ assert "nvidia.com" in p.base_url
+
+
+class TestKimiProfile:
+ def test_temperature_omit(self):
+ p = get_provider_profile("kimi")
+ assert p.fixed_temperature is OMIT_TEMPERATURE
+
+ def test_max_tokens(self):
+ p = get_provider_profile("kimi")
+ assert p.default_max_tokens == 32000
+
+ def test_cn_separate_profile(self):
+ p = get_provider_profile("kimi-coding-cn")
+ assert p.name == "kimi-coding-cn"
+ assert p.env_vars == ("KIMI_CN_API_KEY",)
+ assert "moonshot.cn" in p.base_url
+
+ def test_cn_not_alias_of_kimi(self):
+ kimi = get_provider_profile("kimi-coding")
+ cn = get_provider_profile("kimi-coding-cn")
+ assert kimi is not cn
+ assert kimi.base_url != cn.base_url
+
+ def test_thinking_enabled(self):
+ p = get_provider_profile("kimi")
+ eb, tl = p.build_api_kwargs_extras(reasoning_config={"enabled": True, "effort": "high"})
+ assert eb["thinking"] == {"type": "enabled"}
+ assert tl["reasoning_effort"] == "high"
+
+ def test_thinking_disabled(self):
+ p = get_provider_profile("kimi")
+ eb, tl = p.build_api_kwargs_extras(reasoning_config={"enabled": False})
+ assert eb["thinking"] == {"type": "disabled"}
+ assert "reasoning_effort" not in tl
+
+ def test_reasoning_effort_default(self):
+ p = get_provider_profile("kimi")
+ eb, tl = p.build_api_kwargs_extras(reasoning_config={"enabled": True})
+ assert tl["reasoning_effort"] == "medium"
+
+ def test_no_config_defaults(self):
+ p = get_provider_profile("kimi")
+ eb, tl = p.build_api_kwargs_extras(reasoning_config=None)
+ assert eb["thinking"] == {"type": "enabled"}
+ assert tl["reasoning_effort"] == "medium"
+
+
+class TestOpenRouterProfile:
+ def test_extra_body_with_prefs(self):
+ p = get_provider_profile("openrouter")
+ body = p.build_extra_body(provider_preferences={"allow": ["anthropic"]})
+ assert body["provider"] == {"allow": ["anthropic"]}
+
+ def test_extra_body_no_prefs(self):
+ p = get_provider_profile("openrouter")
+ body = p.build_extra_body()
+ assert body == {}
+
+ def test_reasoning_full_config(self):
+ p = get_provider_profile("openrouter")
+ eb, _ = p.build_api_kwargs_extras(
+ reasoning_config={"enabled": True, "effort": "high"},
+ supports_reasoning=True,
+ )
+ assert eb["reasoning"] == {"enabled": True, "effort": "high"}
+
+ def test_reasoning_disabled_still_passes(self):
+ """OpenRouter passes disabled reasoning through (unlike Nous)."""
+ p = get_provider_profile("openrouter")
+ eb, _ = p.build_api_kwargs_extras(
+ reasoning_config={"enabled": False},
+ supports_reasoning=True,
+ )
+ assert eb["reasoning"] == {"enabled": False}
+
+ def test_default_reasoning(self):
+ p = get_provider_profile("openrouter")
+ eb, _ = p.build_api_kwargs_extras(supports_reasoning=True)
+ assert eb["reasoning"] == {"enabled": True, "effort": "medium"}
+
+
+class TestNousProfile:
+ def test_tags(self):
+ p = get_provider_profile("nous")
+ body = p.build_extra_body()
+ assert body["tags"] == ["product=hermes-agent"]
+
+ def test_auth_type(self):
+ p = get_provider_profile("nous")
+ assert p.auth_type == "oauth_device_code"
+
+ def test_reasoning_enabled(self):
+ p = get_provider_profile("nous")
+ eb, _ = p.build_api_kwargs_extras(
+ reasoning_config={"enabled": True, "effort": "medium"},
+ supports_reasoning=True,
+ )
+ assert eb["reasoning"] == {"enabled": True, "effort": "medium"}
+
+ def test_reasoning_omitted_when_disabled(self):
+ p = get_provider_profile("nous")
+ eb, _ = p.build_api_kwargs_extras(
+ reasoning_config={"enabled": False},
+ supports_reasoning=True,
+ )
+ assert "reasoning" not in eb
+
+
+class TestQwenProfile:
+ def test_max_tokens(self):
+ p = get_provider_profile("qwen-oauth")
+ assert p.default_max_tokens == 65536
+
+ def test_auth_type(self):
+ p = get_provider_profile("qwen-oauth")
+ assert p.auth_type == "oauth_external"
+
+ def test_extra_body_vl(self):
+ p = get_provider_profile("qwen-oauth")
+ body = p.build_extra_body()
+ assert body["vl_high_resolution_images"] is True
+
+ def test_prepare_messages_normalizes_content(self):
+ p = get_provider_profile("qwen-oauth")
+ msgs = [
+ {"role": "system", "content": "Be helpful"},
+ {"role": "user", "content": "hello"},
+ ]
+ result = p.prepare_messages(msgs)
+ # System message: content normalized to list, cache_control on last part
+ assert isinstance(result[0]["content"], list)
+ assert result[0]["content"][-1].get("cache_control") == {"type": "ephemeral"}
+ assert result[0]["content"][-1]["text"] == "Be helpful"
+ # User message: content normalized to list
+ assert isinstance(result[1]["content"], list)
+ assert result[1]["content"][0]["text"] == "hello"
+
+ def test_metadata_top_level(self):
+ p = get_provider_profile("qwen-oauth")
+ meta = {"sessionId": "s123", "promptId": "p456"}
+ eb, tl = p.build_api_kwargs_extras(qwen_session_metadata=meta)
+ assert tl["metadata"] == meta
+ assert "metadata" not in eb
+
+
+class TestBaseProfile:
+ def test_prepare_messages_passthrough(self):
+ p = ProviderProfile(name="test")
+ msgs = [{"role": "user", "content": "hi"}]
+ assert p.prepare_messages(msgs) is msgs
+
+ def test_build_extra_body_empty(self):
+ p = ProviderProfile(name="test")
+ assert p.build_extra_body() == {}
+
+ def test_build_api_kwargs_extras_empty(self):
+ p = ProviderProfile(name="test")
+ eb, tl = p.build_api_kwargs_extras()
+ assert eb == {}
+ assert tl == {}
diff --git a/tests/providers/test_transport_parity.py b/tests/providers/test_transport_parity.py
new file mode 100644
index 0000000000..be88bc580a
--- /dev/null
+++ b/tests/providers/test_transport_parity.py
@@ -0,0 +1,258 @@
+"""Parity tests: pin the exact current transport behavior per provider.
+
+These tests document the flag-based contract between run_agent.py and
+ChatCompletionsTransport.build_kwargs(). When the next PR wires profiles
+to replace flags, every assertion here must still pass — any failure is
+a behavioral regression.
+"""
+
+import pytest
+from agent.transports.chat_completions import ChatCompletionsTransport
+from providers import get_provider_profile
+
+
+@pytest.fixture
+def transport():
+ return ChatCompletionsTransport()
+
+
+def _simple_messages():
+ return [{"role": "user", "content": "hello"}]
+
+
+def _max_tokens_fn(n):
+ return {"max_completion_tokens": n}
+
+
+class TestNvidiaParity:
+ """NVIDIA NIM: default max_tokens=16384."""
+
+ def test_default_max_tokens(self, transport):
+ """NVIDIA default max_tokens=16384 comes from profile, not legacy is_nvidia_nim flag."""
+ from providers import get_provider_profile
+
+ profile = get_provider_profile("nvidia")
+ kw = transport.build_kwargs(
+ model="nvidia/llama-3.1-nemotron-70b-instruct",
+ messages=_simple_messages(),
+ tools=None,
+ max_tokens_param_fn=_max_tokens_fn,
+ provider_profile=profile,
+ )
+ assert kw["max_completion_tokens"] == 16384
+
+ def test_user_max_tokens_overrides(self, transport):
+ from providers import get_provider_profile
+
+ profile = get_provider_profile("nvidia")
+ kw = transport.build_kwargs(
+ model="nvidia/llama-3.1-nemotron-70b-instruct",
+ messages=_simple_messages(),
+ tools=None,
+ max_tokens=4096,
+ max_tokens_param_fn=_max_tokens_fn,
+ provider_profile=profile,
+ )
+ assert kw["max_completion_tokens"] == 4096 # user overrides default
+
+
+class TestKimiParity:
+ """Kimi: OMIT temperature, max_tokens=32000, thinking + reasoning_effort."""
+
+ def test_temperature_omitted(self, transport):
+ kw = transport.build_kwargs(
+ model="kimi-k2",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("kimi-coding"),
+ omit_temperature=True,
+ )
+ assert "temperature" not in kw
+
+ def test_default_max_tokens(self, transport):
+ kw = transport.build_kwargs(
+ model="kimi-k2",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("kimi-coding"),
+ max_tokens_param_fn=_max_tokens_fn,
+ )
+ assert kw["max_completion_tokens"] == 32000
+
+ def test_thinking_enabled(self, transport):
+ kw = transport.build_kwargs(
+ model="kimi-k2",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("kimi-coding"),
+ reasoning_config={"enabled": True, "effort": "high"},
+ )
+ assert kw["extra_body"]["thinking"] == {"type": "enabled"}
+
+ def test_thinking_disabled(self, transport):
+ kw = transport.build_kwargs(
+ model="kimi-k2",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("kimi-coding"),
+ reasoning_config={"enabled": False},
+ )
+ assert kw["extra_body"]["thinking"] == {"type": "disabled"}
+
+ def test_reasoning_effort_top_level(self, transport):
+ """Kimi reasoning_effort is a TOP-LEVEL api_kwargs key, NOT in extra_body."""
+ kw = transport.build_kwargs(
+ model="kimi-k2",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("kimi-coding"),
+ reasoning_config={"enabled": True, "effort": "high"},
+ )
+ assert kw.get("reasoning_effort") == "high"
+ assert "reasoning_effort" not in kw.get("extra_body", {})
+
+ def test_reasoning_effort_default_medium(self, transport):
+ kw = transport.build_kwargs(
+ model="kimi-k2",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("kimi-coding"),
+ reasoning_config={"enabled": True},
+ )
+ assert kw.get("reasoning_effort") == "medium"
+
+
+class TestOpenRouterParity:
+ """OpenRouter: provider preferences, reasoning in extra_body."""
+
+ def test_provider_preferences(self, transport):
+ prefs = {"allow": ["anthropic"], "sort": "price"}
+ kw = transport.build_kwargs(
+ model="anthropic/claude-sonnet-4.6",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("openrouter"),
+ provider_preferences=prefs,
+ )
+ assert kw["extra_body"]["provider"] == prefs
+
+ def test_reasoning_passes_full_config(self, transport):
+ """OpenRouter passes the FULL reasoning_config dict, not just effort."""
+ rc = {"enabled": True, "effort": "high"}
+ kw = transport.build_kwargs(
+ model="anthropic/claude-sonnet-4.6",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("openrouter"),
+ supports_reasoning=True,
+ reasoning_config=rc,
+ )
+ assert kw["extra_body"]["reasoning"] == rc
+
+ def test_default_reasoning_when_no_config(self, transport):
+ """When supports_reasoning=True but no config, adds default."""
+ kw = transport.build_kwargs(
+ model="anthropic/claude-sonnet-4.6",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("openrouter"),
+ supports_reasoning=True,
+ )
+ assert kw["extra_body"]["reasoning"] == {"enabled": True, "effort": "medium"}
+
+
+class TestNousParity:
+ """Nous: product tags, reasoning, omit when disabled."""
+
+ def test_tags(self, transport):
+ kw = transport.build_kwargs(
+ model="hermes-3-llama-3.1-405b",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("nous"),
+ )
+ assert kw["extra_body"]["tags"] == ["product=hermes-agent"]
+
+ def test_reasoning_omitted_when_disabled(self, transport):
+ """Nous special case: reasoning omitted entirely when disabled."""
+ kw = transport.build_kwargs(
+ model="hermes-3-llama-3.1-405b",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("nous"),
+ supports_reasoning=True,
+ reasoning_config={"enabled": False},
+ )
+ assert "reasoning" not in kw.get("extra_body", {})
+
+ def test_reasoning_enabled(self, transport):
+ rc = {"enabled": True, "effort": "high"}
+ kw = transport.build_kwargs(
+ model="hermes-3-llama-3.1-405b",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("nous"),
+ supports_reasoning=True,
+ reasoning_config=rc,
+ )
+ assert kw["extra_body"]["reasoning"] == rc
+
+
+class TestQwenParity:
+ """Qwen: max_tokens=65536, vl_high_resolution, metadata top-level."""
+
+ def test_default_max_tokens(self, transport):
+ kw = transport.build_kwargs(
+ model="qwen3.5-plus",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("qwen-oauth"),
+ max_tokens_param_fn=_max_tokens_fn,
+ )
+ assert kw["max_completion_tokens"] == 65536
+
+ def test_vl_high_resolution(self, transport):
+ kw = transport.build_kwargs(
+ model="qwen3.5-plus",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("qwen-oauth"),
+ )
+ assert kw["extra_body"]["vl_high_resolution_images"] is True
+
+ def test_metadata_top_level(self, transport):
+ """Qwen metadata goes to top-level api_kwargs, NOT extra_body."""
+ meta = {"sessionId": "s123", "promptId": "p456"}
+ kw = transport.build_kwargs(
+ model="qwen3.5-plus",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("qwen-oauth"),
+ qwen_session_metadata=meta,
+ )
+ assert kw["metadata"] == meta
+ assert "metadata" not in kw.get("extra_body", {})
+
+
+class TestCustomOllamaParity:
+ """Custom/Ollama: num_ctx, think=false — now tested via profile."""
+
+ def test_ollama_num_ctx(self, transport):
+ kw = transport.build_kwargs(
+ model="llama3.1",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("custom"),
+ ollama_num_ctx=131072,
+ )
+ assert kw["extra_body"]["options"]["num_ctx"] == 131072
+
+ def test_think_false_when_disabled(self, transport):
+ kw = transport.build_kwargs(
+ model="qwen3:72b",
+ messages=_simple_messages(),
+ tools=None,
+ provider_profile=get_provider_profile("custom"),
+ reasoning_config={"enabled": False, "effort": "none"},
+ )
+ assert kw["extra_body"]["think"] is False
diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py
index eba186cf2c..42f1902db8 100644
--- a/tests/run_agent/test_run_agent.py
+++ b/tests/run_agent/test_run_agent.py
@@ -1117,6 +1117,7 @@ class TestBuildApiKwargs:
assert "temperature" not in kwargs
def test_kimi_coding_endpoint_omits_temperature(self, agent):
+ agent.provider = "kimi-coding"
agent.base_url = "https://api.kimi.com/coding/v1"
agent._base_url_lower = agent.base_url.lower()
agent.model = "kimi-k2.5"
@@ -1129,6 +1130,7 @@ class TestBuildApiKwargs:
def test_kimi_coding_endpoint_sends_max_tokens_and_reasoning(self, agent):
"""Kimi endpoint should send max_tokens=32000 and reasoning_effort as
top-level params, matching Kimi CLI's default behavior."""
+ agent.provider = "kimi-coding"
agent.base_url = "https://api.kimi.com/coding/v1"
agent._base_url_lower = agent.base_url.lower()
agent.model = "kimi-for-coding"
@@ -1141,6 +1143,7 @@ class TestBuildApiKwargs:
def test_kimi_coding_endpoint_respects_custom_effort(self, agent):
"""reasoning_effort should reflect reasoning_config.effort when set."""
+ agent.provider = "kimi-coding"
agent.base_url = "https://api.kimi.com/coding/v1"
agent._base_url_lower = agent.base_url.lower()
agent.model = "kimi-for-coding"
@@ -1154,6 +1157,7 @@ class TestBuildApiKwargs:
def test_kimi_coding_endpoint_sends_thinking_extra_body(self, agent):
"""Kimi endpoint should send extra_body.thinking={"type":"enabled"}
to activate reasoning mode, mirroring Kimi CLI's with_thinking()."""
+ agent.provider = "kimi-coding"
agent.base_url = "https://api.kimi.com/coding/v1"
agent._base_url_lower = agent.base_url.lower()
agent.model = "kimi-for-coding"
@@ -1167,6 +1171,7 @@ class TestBuildApiKwargs:
"""When reasoning_config.enabled=False, thinking should be disabled
and reasoning_effort should be omitted entirely — mirroring Kimi
CLI's with_thinking("off") which maps to reasoning_effort=None."""
+ agent.provider = "kimi-coding"
agent.base_url = "https://api.kimi.com/coding/v1"
agent._base_url_lower = agent.base_url.lower()
agent.model = "kimi-for-coding"
@@ -1180,6 +1185,7 @@ class TestBuildApiKwargs:
def test_moonshot_endpoint_sends_max_tokens_and_reasoning(self, agent):
"""api.moonshot.ai should get the same Kimi-compatible params."""
+ agent.provider = "kimi-coding"
agent.base_url = "https://api.moonshot.ai/v1"
agent._base_url_lower = agent.base_url.lower()
agent.model = "kimi-k2.5"
@@ -1193,6 +1199,7 @@ class TestBuildApiKwargs:
def test_moonshot_cn_endpoint_sends_max_tokens_and_reasoning(self, agent):
"""api.moonshot.cn (China endpoint) should get the same params."""
+ agent.provider = "kimi-coding-cn"
agent.base_url = "https://api.moonshot.cn/v1"
agent._base_url_lower = agent.base_url.lower()
agent.model = "kimi-k2.5"
@@ -1205,6 +1212,7 @@ class TestBuildApiKwargs:
assert kwargs["extra_body"]["thinking"] == {"type": "enabled"}
def test_provider_preferences_injected(self, agent):
+ agent.provider = "openrouter"
agent.base_url = "https://openrouter.ai/api/v1"
agent.providers_allowed = ["Anthropic"]
messages = [{"role": "user", "content": "hi"}]
@@ -1213,6 +1221,7 @@ class TestBuildApiKwargs:
def test_reasoning_config_default_openrouter(self, agent):
"""Default reasoning config for OpenRouter should be medium."""
+ agent.provider = "openrouter"
agent.base_url = "https://openrouter.ai/api/v1"
agent.model = "anthropic/claude-sonnet-4-20250514"
messages = [{"role": "user", "content": "hi"}]
@@ -1222,6 +1231,7 @@ class TestBuildApiKwargs:
assert reasoning["effort"] == "medium"
def test_reasoning_config_custom(self, agent):
+ agent.provider = "openrouter"
agent.base_url = "https://openrouter.ai/api/v1"
agent.model = "anthropic/claude-sonnet-4-20250514"
agent.reasoning_config = {"enabled": False}
@@ -1237,6 +1247,7 @@ class TestBuildApiKwargs:
assert "reasoning" not in kwargs.get("extra_body", {})
def test_reasoning_sent_for_supported_openrouter_model(self, agent):
+ agent.provider = "openrouter"
agent.base_url = "https://openrouter.ai/api/v1"
agent.model = "qwen/qwen3.5-plus-02-15"
messages = [{"role": "user", "content": "hi"}]
@@ -1244,6 +1255,7 @@ class TestBuildApiKwargs:
assert kwargs["extra_body"]["reasoning"]["effort"] == "medium"
def test_reasoning_sent_for_nous_route(self, agent):
+ agent.provider = "nous"
agent.base_url = "https://inference-api.nousresearch.com/v1"
agent.model = "minimax/minimax-m2.5"
messages = [{"role": "user", "content": "hi"}]
@@ -1251,18 +1263,38 @@ class TestBuildApiKwargs:
assert kwargs["extra_body"]["reasoning"]["effort"] == "medium"
def test_reasoning_sent_for_copilot_gpt5(self, agent):
- agent.base_url = "https://api.githubcopilot.com"
- agent.model = "gpt-5.4"
- messages = [{"role": "user", "content": "hi"}]
- kwargs = agent._build_api_kwargs(messages)
+ """Copilot/GitHub Models: GPT-5 reasoning goes in extra_body.reasoning."""
+ from agent.transports import get_transport
+ from providers import get_provider_profile
+
+ transport = get_transport("chat_completions")
+ profile = get_provider_profile("copilot")
+ msgs = [{"role": "user", "content": "hi"}]
+ kwargs = transport.build_kwargs(
+ model="gpt-5.4",
+ messages=msgs,
+ tools=None,
+ supports_reasoning=True,
+ provider_profile=profile,
+ )
assert kwargs["extra_body"]["reasoning"] == {"effort": "medium"}
def test_reasoning_xhigh_normalized_for_copilot(self, agent):
- agent.base_url = "https://api.githubcopilot.com"
- agent.model = "gpt-5.4"
- agent.reasoning_config = {"enabled": True, "effort": "xhigh"}
- messages = [{"role": "user", "content": "hi"}]
- kwargs = agent._build_api_kwargs(messages)
+ """xhigh effort should normalize to high for Copilot GitHub Models."""
+ from agent.transports import get_transport
+ from providers import get_provider_profile
+
+ transport = get_transport("chat_completions")
+ profile = get_provider_profile("copilot")
+ msgs = [{"role": "user", "content": "hi"}]
+ kwargs = transport.build_kwargs(
+ model="gpt-5.4",
+ messages=msgs,
+ tools=None,
+ supports_reasoning=True,
+ reasoning_config={"enabled": True, "effort": "xhigh"},
+ provider_profile=profile,
+ )
assert kwargs["extra_body"]["reasoning"] == {"effort": "high"}
def test_reasoning_omitted_for_non_reasoning_copilot_model(self, agent):
@@ -1280,6 +1312,7 @@ class TestBuildApiKwargs:
def test_qwen_portal_formats_messages_and_metadata(self, agent):
+ agent.provider = "qwen-oauth"
agent.base_url = "https://portal.qwen.ai/v1"
agent._base_url_lower = agent.base_url.lower()
agent.session_id = "sess-123"
@@ -1296,6 +1329,7 @@ class TestBuildApiKwargs:
assert kwargs["messages"][2]["content"][0]["text"] == "hi"
def test_qwen_portal_normalizes_bare_string_content_parts(self, agent):
+ agent.provider = "qwen-oauth"
agent.base_url = "https://portal.qwen.ai/v1"
agent._base_url_lower = agent.base_url.lower()
messages = [
@@ -1308,6 +1342,7 @@ class TestBuildApiKwargs:
assert user_content[1] == {"type": "text", "text": "world"}
def test_qwen_portal_no_system_message(self, agent):
+ agent.provider = "qwen-oauth"
agent.base_url = "https://portal.qwen.ai/v1"
agent._base_url_lower = agent.base_url.lower()
messages = [{"role": "user", "content": "hi"}]
@@ -1328,6 +1363,7 @@ class TestBuildApiKwargs:
def test_qwen_portal_default_max_tokens(self, agent):
"""When max_tokens is None, Qwen Portal gets a default of 65536
to prevent reasoning models from exhausting their output budget."""
+ agent.provider = "qwen-oauth"
agent.base_url = "https://portal.qwen.ai/v1"
agent._base_url_lower = agent.base_url.lower()
agent.max_tokens = None
diff --git a/website/docs/developer-guide/adding-providers.md b/website/docs/developer-guide/adding-providers.md
index 793d0354d1..5ec127d663 100644
--- a/website/docs/developer-guide/adding-providers.md
+++ b/website/docs/developer-guide/adding-providers.md
@@ -93,6 +93,42 @@ This path includes everything from Path A plus:
11. `run_agent.py`
12. `pyproject.toml` if a provider SDK is required
+## Fast path: Simple API-key providers
+
+If your provider is just an OpenAI-compatible endpoint that authenticates with a single API key, you do not need to touch `auth.py`, `runtime_provider.py`, `main.py`, or any of the other files in the full checklist below.
+
+All you need is:
+
+1. A file in `providers/` (e.g. `providers/myprovider.py`) that calls `register_provider()` with the provider config.
+2. That's it. `auth.py` auto-registers every file in `providers/` at startup via a module-level import sweep.
+
+When you add a `providers/*.py` file and call `register_provider()`, the following wire up automatically:
+
+1. `PROVIDER_REGISTRY` entry in `auth.py` (credential resolution, env-var lookup)
+2. `api_mode` set to `chat_completions`
+3. `base_url` sourced from the config or the declared env var
+4. `env_vars` checked in priority order for the API key
+5. `fallback_models` list registered for the provider
+6. `--provider` CLI flag accepts the provider id
+7. `hermes model` menu includes the provider
+8. `hermes setup` wizard delegates to `main.py` automatically
+9. `provider:model` alias syntax works
+10. Runtime resolver returns the correct `base_url` and `api_key`
+11. `HERMES_INFERENCE_PROVIDER` env-var override accepts the provider id
+12. Fallback model activation can switch into the provider cleanly
+
+See `providers/nvidia.py` or `providers/gmi.py` as a template.
+
+## Full path: OAuth and complex providers
+
+Use the full checklist below when your provider needs any of the following:
+
+- OAuth or token refresh (Nous Portal, Codex, Google Gemini, Qwen Portal, Copilot)
+- A non-OpenAI API shape that requires a new adapter (Anthropic Messages, Codex Responses)
+- Custom endpoint detection or multi-region probing (z.ai, Kimi)
+- A curated static model catalog or live `/models` fetch
+- Provider-specific `hermes model` menu entries with bespoke auth flows
+
## Step 1: Pick one canonical provider id
Choose a single provider id and use it everywhere.
diff --git a/website/docs/developer-guide/provider-runtime.md b/website/docs/developer-guide/provider-runtime.md
index 415962f90b..b2e798a267 100644
--- a/website/docs/developer-guide/provider-runtime.md
+++ b/website/docs/developer-guide/provider-runtime.md
@@ -20,6 +20,9 @@ Primary implementation:
- `hermes_cli/auth.py` — provider registry, `resolve_provider()`
- `hermes_cli/model_switch.py` — shared `/model` switch pipeline (CLI + gateway)
- `agent/auxiliary_client.py` — auxiliary model routing
+- `providers/` — declarative source for `api_mode`, `base_url`, `env_vars`, `fallback_models` (auto-registered into `auth.py` `PROVIDER_REGISTRY` at startup)
+
+`get_provider_profile()` in `providers/` returns a typed dict for a given provider id. `runtime_provider.py` calls this at resolution time to get the canonical `base_url`, `env_vars` priority list, `api_mode`, and `fallback_models` without needing to duplicate that data in multiple files. Adding a new `providers/*.py` file that calls `register_provider()` is enough for `runtime_provider.py` to pick it up — no branch needed in the resolver itself.
If you are trying to add a new first-class inference provider, read [Adding Providers](./adding-providers.md) alongside this page.
diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md
index 80d122b7b2..84e5e92cae 100644
--- a/website/docs/integrations/providers.md
+++ b/website/docs/integrations/providers.md
@@ -480,6 +480,44 @@ model:
For on-prem deployments (DGX Spark, local GPU), set `NVIDIA_BASE_URL=http://localhost:8000/v1`. NIM exposes the same OpenAI-compatible chat completions API as build.nvidia.com, so switching between cloud and local is a one-line env-var change.
:::
+### GMI Cloud
+
+Open and reasoning models via [GMI Cloud](https://inference.gmi.ai) — OpenAI-compatible API, API key authentication.
+
+```bash
+# GMI Cloud
+hermes chat --provider gmi --model deepseek-ai/DeepSeek-R1
+# Requires: GMI_API_KEY in ~/.hermes/.env
+```
+
+Or set it permanently in `config.yaml`:
+```yaml
+model:
+ provider: "gmi"
+ default: "deepseek-ai/DeepSeek-R1"
+```
+
+The base URL can be overridden with `GMI_BASE_URL` (default: `https://api.gmi.ai/v1`).
+
+### StepFun
+
+Step-series models via [StepFun](https://platform.stepfun.com) — OpenAI-compatible API, API key authentication.
+
+```bash
+# StepFun
+hermes chat --provider stepfun --model step-3-mini
+# Requires: STEPFUN_API_KEY in ~/.hermes/.env
+```
+
+Or set it permanently in `config.yaml`:
+```yaml
+model:
+ provider: "stepfun"
+ default: "step-3-mini"
+```
+
+The base URL can be overridden with `STEPFUN_BASE_URL` (default: `https://api.stepfun.com/v1`).
+
### Hugging Face Inference Providers
[Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers) routes to 20+ open models through a unified OpenAI-compatible endpoint (`router.huggingface.co/v1`). Requests are automatically routed to the fastest available backend (Groq, Together, SambaNova, etc.) with automatic failover.
@@ -1239,7 +1277,7 @@ fallback_model:
When activated, the fallback swaps the model and provider mid-session without losing your conversation. It fires **at most once** per session.
-Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `bedrock`, `ai-gateway`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `tencent-tokenhub`, `custom`.
+Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `bedrock`, `ai-gateway`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `alibaba`, `tencent-tokenhub`, `custom`.
:::tip
Fallback is configured exclusively through `config.yaml` — there are no environment variables for it. For full details on when it triggers, supported providers, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/docs/user-guide/features/fallback-providers).
diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md
index c962c20b76..05206eb0c9 100644
--- a/website/docs/reference/environment-variables.md
+++ b/website/docs/reference/environment-variables.md
@@ -69,6 +69,10 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
| `DEEPSEEK_BASE_URL` | Custom DeepSeek API base URL |
| `NVIDIA_API_KEY` | NVIDIA NIM API key — Nemotron and open models ([build.nvidia.com](https://build.nvidia.com)) |
| `NVIDIA_BASE_URL` | Override NVIDIA base URL (default: `https://integrate.api.nvidia.com/v1`; set to `http://localhost:8000/v1` for a local NIM endpoint) |
+| `GMI_API_KEY` | GMI Cloud API key — open and reasoning models ([inference.gmi.ai](https://inference.gmi.ai)) |
+| `GMI_BASE_URL` | Override GMI Cloud base URL (default: `https://api.gmi.ai/v1`) |
+| `STEPFUN_API_KEY` | StepFun API key — Step-series models ([platform.stepfun.com](https://platform.stepfun.com)) |
+| `STEPFUN_BASE_URL` | Override StepFun base URL (default: `https://api.stepfun.com/v1`) |
| `OLLAMA_API_KEY` | Ollama Cloud API key — managed Ollama catalog without local GPU ([ollama.com/settings/keys](https://ollama.com/settings/keys)) |
| `OLLAMA_BASE_URL` | Override Ollama Cloud base URL (default: `https://ollama.com/v1`) |
| `XAI_API_KEY` | xAI (Grok) API key for chat + TTS ([console.x.ai](https://console.x.ai/)) |
@@ -99,7 +103,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| Variable | Description |
|----------|-------------|
-| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `tencent-tokenhub` (default: `auto`) |
+| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `tencent-tokenhub` (default: `auto`) |
| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) |
| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL |
| `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) |
diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md
index f60faf9247..df52eb1a66 100644
--- a/website/docs/user-guide/features/fallback-providers.md
+++ b/website/docs/user-guide/features/fallback-providers.md
@@ -60,6 +60,8 @@ Both `provider` and `model` are **required**. If either is missing, the fallback
| MiniMax (China) | `minimax-cn` | `MINIMAX_CN_API_KEY` |
| DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` |
| NVIDIA NIM | `nvidia` | `NVIDIA_API_KEY` (optional: `NVIDIA_BASE_URL`) |
+| GMI Cloud | `gmi` | `GMI_API_KEY` (optional: `GMI_BASE_URL`) |
+| StepFun | `stepfun` | `STEPFUN_API_KEY` (optional: `STEPFUN_BASE_URL`) |
| Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` |
| Google Gemini (OAuth) | `google-gemini-cli` | `hermes model` (Google OAuth; optional: `HERMES_GEMINI_PROJECT_ID`) |
| Google AI Studio | `gemini` | `GOOGLE_API_KEY` (alias: `GEMINI_API_KEY`) |
From 9022804d78e88253d138d448e9107a3884b2b96c Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:36:08 -0700
Subject: [PATCH 024/124] feat(providers): make all 33 providers pluggable
under plugins/model-providers/
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every provider profile is now a self-contained plugin under
plugins/model-providers//, mirroring the plugins/platforms/
pattern established for IRC and Teams. The ProviderProfile ABC
stays in providers/; the per-provider profile data moves out.
- plugins/model-providers//__init__.py calls register_provider()
- plugins/model-providers//plugin.yaml declares kind: model-provider
- providers/__init__.py._discover_providers() lazily scans bundled plugins
then $HERMES_HOME/plugins/model-providers// (user override path)
- User plugins with the same name override bundled ones (last-writer-wins
in register_provider)
- Legacy providers/.py layout still supported for back-compat with
out-of-tree editable installs
- Hermes PluginManager: new kind=model-provider; skipped like memory
plugins (providers/ discovery owns them); standalone plugins with
register_provider+ProviderProfile in their __init__.py auto-coerce to
this kind (same heuristic as memory providers)
- skip_names extended to include 'model-providers' so the general
PluginManager doesn't double-scan the category
- 4 new tests in tests/providers/test_plugin_discovery.py covering
bundled discovery, user override, and general-loader isolation
- Docs updated: website/docs/developer-guide/adding-providers.md,
provider-runtime.md, providers/README.md, plugins/model-providers/README.md
No API break: auth.py / config.py / doctor.py / models.py / runtime_provider.py /
model_metadata.py / auxiliary_client.py / chat_completions.py / run_agent.py
all still consume providers via get_provider_profile() / list_providers() —
they just now see plugin-discovered entries instead of pkgutil-iterated ones.
Third parties can now drop a single directory into
~/.hermes/plugins/model-providers// to add or override an inference
provider without touching the repo.
---
hermes_cli/plugins.py | 40 ++-
plugins/model-providers/README.md | 70 ++++
.../model-providers/ai-gateway/__init__.py | 0
.../model-providers/ai-gateway/plugin.yaml | 5 +
.../alibaba-coding-plan/__init__.py | 0
.../alibaba-coding-plan/plugin.yaml | 5 +
.../model-providers/alibaba/__init__.py | 0
plugins/model-providers/alibaba/plugin.yaml | 5 +
.../model-providers/anthropic/__init__.py | 0
plugins/model-providers/anthropic/plugin.yaml | 5 +
.../model-providers/arcee/__init__.py | 0
plugins/model-providers/arcee/plugin.yaml | 5 +
.../model-providers/azure-foundry/__init__.py | 0
.../model-providers/azure-foundry/plugin.yaml | 5 +
.../model-providers/bedrock/__init__.py | 0
plugins/model-providers/bedrock/plugin.yaml | 5 +
.../model-providers/copilot-acp/__init__.py | 0
.../model-providers/copilot-acp/plugin.yaml | 5 +
.../model-providers/copilot/__init__.py | 0
plugins/model-providers/copilot/plugin.yaml | 5 +
.../model-providers/custom/__init__.py | 0
plugins/model-providers/custom/plugin.yaml | 5 +
.../model-providers/deepseek/__init__.py | 0
plugins/model-providers/deepseek/plugin.yaml | 5 +
.../model-providers/gemini/__init__.py | 0
plugins/model-providers/gemini/plugin.yaml | 5 +
.../model-providers/gmi/__init__.py | 0
plugins/model-providers/gmi/plugin.yaml | 5 +
.../model-providers/huggingface/__init__.py | 0
.../model-providers/huggingface/plugin.yaml | 5 +
.../model-providers/kilocode/__init__.py | 0
plugins/model-providers/kilocode/plugin.yaml | 5 +
.../model-providers/kimi-coding/__init__.py | 0
.../model-providers/kimi-coding/plugin.yaml | 5 +
.../model-providers/minimax/__init__.py | 0
plugins/model-providers/minimax/plugin.yaml | 5 +
.../model-providers/nous/__init__.py | 0
plugins/model-providers/nous/plugin.yaml | 5 +
.../model-providers/nvidia/__init__.py | 0
plugins/model-providers/nvidia/plugin.yaml | 5 +
.../model-providers/ollama-cloud/__init__.py | 0
.../model-providers/ollama-cloud/plugin.yaml | 5 +
.../model-providers/openai-codex/__init__.py | 0
.../model-providers/openai-codex/plugin.yaml | 5 +
.../model-providers/opencode-zen/__init__.py | 0
.../model-providers/opencode-zen/plugin.yaml | 5 +
.../model-providers/openrouter/__init__.py | 0
.../model-providers/openrouter/plugin.yaml | 5 +
.../model-providers/qwen-oauth/__init__.py | 0
.../model-providers/qwen-oauth/plugin.yaml | 5 +
.../model-providers/stepfun/__init__.py | 0
plugins/model-providers/stepfun/plugin.yaml | 5 +
.../model-providers/xai/__init__.py | 0
plugins/model-providers/xai/plugin.yaml | 5 +
.../model-providers/xiaomi/__init__.py | 0
plugins/model-providers/xiaomi/plugin.yaml | 5 +
.../model-providers/zai/__init__.py | 0
plugins/model-providers/zai/plugin.yaml | 5 +
providers/README.md | 327 +++---------------
providers/__init__.py | 155 +++++++--
tests/providers/test_plugin_discovery.py | 145 ++++++++
.../docs/developer-guide/adding-providers.md | 12 +-
.../docs/developer-guide/provider-runtime.md | 5 +-
63 files changed, 585 insertions(+), 309 deletions(-)
create mode 100644 plugins/model-providers/README.md
rename providers/vercel.py => plugins/model-providers/ai-gateway/__init__.py (100%)
create mode 100644 plugins/model-providers/ai-gateway/plugin.yaml
rename providers/alibaba_coding_plan.py => plugins/model-providers/alibaba-coding-plan/__init__.py (100%)
create mode 100644 plugins/model-providers/alibaba-coding-plan/plugin.yaml
rename providers/alibaba.py => plugins/model-providers/alibaba/__init__.py (100%)
create mode 100644 plugins/model-providers/alibaba/plugin.yaml
rename providers/anthropic.py => plugins/model-providers/anthropic/__init__.py (100%)
create mode 100644 plugins/model-providers/anthropic/plugin.yaml
rename providers/arcee.py => plugins/model-providers/arcee/__init__.py (100%)
create mode 100644 plugins/model-providers/arcee/plugin.yaml
rename providers/azure_foundry.py => plugins/model-providers/azure-foundry/__init__.py (100%)
create mode 100644 plugins/model-providers/azure-foundry/plugin.yaml
rename providers/bedrock.py => plugins/model-providers/bedrock/__init__.py (100%)
create mode 100644 plugins/model-providers/bedrock/plugin.yaml
rename providers/copilot_acp.py => plugins/model-providers/copilot-acp/__init__.py (100%)
create mode 100644 plugins/model-providers/copilot-acp/plugin.yaml
rename providers/copilot.py => plugins/model-providers/copilot/__init__.py (100%)
create mode 100644 plugins/model-providers/copilot/plugin.yaml
rename providers/custom.py => plugins/model-providers/custom/__init__.py (100%)
create mode 100644 plugins/model-providers/custom/plugin.yaml
rename providers/deepseek.py => plugins/model-providers/deepseek/__init__.py (100%)
create mode 100644 plugins/model-providers/deepseek/plugin.yaml
rename providers/gemini.py => plugins/model-providers/gemini/__init__.py (100%)
create mode 100644 plugins/model-providers/gemini/plugin.yaml
rename providers/gmi.py => plugins/model-providers/gmi/__init__.py (100%)
create mode 100644 plugins/model-providers/gmi/plugin.yaml
rename providers/huggingface.py => plugins/model-providers/huggingface/__init__.py (100%)
create mode 100644 plugins/model-providers/huggingface/plugin.yaml
rename providers/kilocode.py => plugins/model-providers/kilocode/__init__.py (100%)
create mode 100644 plugins/model-providers/kilocode/plugin.yaml
rename providers/kimi.py => plugins/model-providers/kimi-coding/__init__.py (100%)
create mode 100644 plugins/model-providers/kimi-coding/plugin.yaml
rename providers/minimax.py => plugins/model-providers/minimax/__init__.py (100%)
create mode 100644 plugins/model-providers/minimax/plugin.yaml
rename providers/nous.py => plugins/model-providers/nous/__init__.py (100%)
create mode 100644 plugins/model-providers/nous/plugin.yaml
rename providers/nvidia.py => plugins/model-providers/nvidia/__init__.py (100%)
create mode 100644 plugins/model-providers/nvidia/plugin.yaml
rename providers/ollama_cloud.py => plugins/model-providers/ollama-cloud/__init__.py (100%)
create mode 100644 plugins/model-providers/ollama-cloud/plugin.yaml
rename providers/openai_codex.py => plugins/model-providers/openai-codex/__init__.py (100%)
create mode 100644 plugins/model-providers/openai-codex/plugin.yaml
rename providers/opencode.py => plugins/model-providers/opencode-zen/__init__.py (100%)
create mode 100644 plugins/model-providers/opencode-zen/plugin.yaml
rename providers/openrouter.py => plugins/model-providers/openrouter/__init__.py (100%)
create mode 100644 plugins/model-providers/openrouter/plugin.yaml
rename providers/qwen.py => plugins/model-providers/qwen-oauth/__init__.py (100%)
create mode 100644 plugins/model-providers/qwen-oauth/plugin.yaml
rename providers/stepfun.py => plugins/model-providers/stepfun/__init__.py (100%)
create mode 100644 plugins/model-providers/stepfun/plugin.yaml
rename providers/xai.py => plugins/model-providers/xai/__init__.py (100%)
create mode 100644 plugins/model-providers/xai/plugin.yaml
rename providers/xiaomi.py => plugins/model-providers/xiaomi/__init__.py (100%)
create mode 100644 plugins/model-providers/xiaomi/plugin.yaml
rename providers/zai.py => plugins/model-providers/zai/__init__.py (100%)
create mode 100644 plugins/model-providers/zai/plugin.yaml
create mode 100644 tests/providers/test_plugin_discovery.py
diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py
index e921034699..5b30e7e7ca 100644
--- a/hermes_cli/plugins.py
+++ b/hermes_cli/plugins.py
@@ -173,7 +173,7 @@ def _get_enabled_plugins() -> Optional[set]:
# Data classes
# ---------------------------------------------------------------------------
-_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"}
+_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform", "model-provider"}
@dataclass
@@ -643,15 +643,17 @@ class PluginManager:
# - flat: ``plugins/disk-cleanup/plugin.yaml`` (standalone)
# - category: ``plugins/image_gen/openai/plugin.yaml`` (backend)
#
- # ``memory/`` and ``context_engine/`` are skipped at the top level —
- # they have their own discovery systems. ``platforms/`` is a category
- # holding platform adapters (scanned one level deeper below).
+ # ``memory/``, ``context_engine/``, and ``model-providers/`` are
+ # skipped at the top level — they have their own discovery systems
+ # (plugins/memory/__init__.py, providers/__init__.py). ``platforms/``
+ # is a category holding platform adapters (scanned one level deeper
+ # below).
repo_plugins = get_bundled_plugins_dir()
manifests.extend(
self._scan_directory(
repo_plugins,
source="bundled",
- skip_names={"memory", "context_engine", "platforms"},
+ skip_names={"memory", "context_engine", "platforms", "model-providers"},
)
)
manifests.extend(
@@ -709,6 +711,21 @@ class PluginManager:
)
continue
+ # Model provider plugins are loaded by providers/__init__.py
+ # (its own lazy discovery keyed off first get_provider_profile()
+ # call). We record the manifest here for introspection but do
+ # not import the module — a second import would create two
+ # ProviderProfile instances and break the "last writer wins"
+ # override semantics between bundled and user plugins.
+ if manifest.kind == "model-provider":
+ loaded = LoadedPlugin(manifest=manifest, enabled=True)
+ self._plugins[lookup_key] = loaded
+ logger.debug(
+ "Skipping '%s' (model-provider, handled by providers/ discovery)",
+ lookup_key,
+ )
+ continue
+
# Built-in backends auto-load — they ship with hermes and must
# just work. Selection among them (e.g. which image_gen backend
# services calls) is driven by ``.provider`` config,
@@ -886,6 +903,19 @@ class PluginManager:
"treating as kind='exclusive'",
key,
)
+ elif (
+ "register_provider" in source_text
+ and "ProviderProfile" in source_text
+ ):
+ # Model provider plugin (calls register_provider()
+ # from ``providers`` with a ProviderProfile). Route
+ # to providers/__init__.py discovery.
+ kind = "model-provider"
+ logger.debug(
+ "Plugin %s: detected model provider, "
+ "treating as kind='model-provider'",
+ key,
+ )
except Exception:
pass
diff --git a/plugins/model-providers/README.md b/plugins/model-providers/README.md
new file mode 100644
index 0000000000..d1d1025f47
--- /dev/null
+++ b/plugins/model-providers/README.md
@@ -0,0 +1,70 @@
+# Model Provider Plugins
+
+Each subdirectory is a self-contained provider profile plugin. The
+directory layout mirrors `plugins/platforms/`:
+
+```
+plugins/model-providers/
+├── openrouter/
+│ ├── __init__.py # registers the ProviderProfile
+│ └── plugin.yaml # manifest: name, kind, version, description
+├── anthropic/
+│ ├── __init__.py
+│ └── plugin.yaml
+└── ...
+```
+
+## How discovery works
+
+`providers/__init__.py._discover_providers()` scans this directory (and
+`$HERMES_HOME/plugins/model-providers/`) the first time anything calls
+`get_provider_profile()` or `list_providers()`. Each `__init__.py` is
+imported and expected to call `providers.register_provider(profile)`.
+
+User plugins at `$HERMES_HOME/plugins/model-providers//` override
+bundled plugins of the same name — last-writer-wins in
+`register_provider()`. Drop a file there to replace a built-in.
+
+## Adding a new provider
+
+1. Create `plugins/model-providers//__init__.py`:
+
+ ```python
+ from providers import register_provider
+ from providers.base import ProviderProfile
+
+ my_provider = ProviderProfile(
+ name="your-provider",
+ aliases=("alias1", "alias2"),
+ display_name="Your Provider",
+ description="One-line description shown in the setup picker",
+ signup_url="https://your-provider.example.com/keys",
+ env_vars=("YOUR_PROVIDER_API_KEY", "YOUR_PROVIDER_BASE_URL"),
+ base_url="https://api.your-provider.example.com/v1",
+ default_aux_model="your-cheap-model",
+ )
+
+ register_provider(my_provider)
+ ```
+
+2. Create `plugins/model-providers//plugin.yaml`:
+
+ ```yaml
+ name: your-provider-profile
+ kind: model-provider
+ version: 1.0.0
+ description: Short sentence about the provider
+ author: Your Name
+ ```
+
+Nothing else needs to change. `auth.py`, `config.py`, `models.py`,
+`doctor.py`, `model_metadata.py`, `runtime_provider.py`, and the
+chat_completions transport all auto-wire from the registry.
+
+## Non-trivial profiles
+
+Override the `ProviderProfile` hooks in a subclass for per-provider
+quirks — see `plugins/model-providers/openrouter/__init__.py` for
+`build_extra_body` and `build_api_kwargs_extras` examples, and
+`plugins/model-providers/gemini/__init__.py` for `thinking_config`
+translation.
diff --git a/providers/vercel.py b/plugins/model-providers/ai-gateway/__init__.py
similarity index 100%
rename from providers/vercel.py
rename to plugins/model-providers/ai-gateway/__init__.py
diff --git a/plugins/model-providers/ai-gateway/plugin.yaml b/plugins/model-providers/ai-gateway/plugin.yaml
new file mode 100644
index 0000000000..252ca42ed6
--- /dev/null
+++ b/plugins/model-providers/ai-gateway/plugin.yaml
@@ -0,0 +1,5 @@
+name: ai-gateway-provider
+kind: model-provider
+version: 1.0.0
+description: Vercel AI Gateway
+author: Nous Research
diff --git a/providers/alibaba_coding_plan.py b/plugins/model-providers/alibaba-coding-plan/__init__.py
similarity index 100%
rename from providers/alibaba_coding_plan.py
rename to plugins/model-providers/alibaba-coding-plan/__init__.py
diff --git a/plugins/model-providers/alibaba-coding-plan/plugin.yaml b/plugins/model-providers/alibaba-coding-plan/plugin.yaml
new file mode 100644
index 0000000000..a158f23d99
--- /dev/null
+++ b/plugins/model-providers/alibaba-coding-plan/plugin.yaml
@@ -0,0 +1,5 @@
+name: alibaba-coding-plan-provider
+kind: model-provider
+version: 1.0.0
+description: Alibaba Cloud Coding Plan
+author: Nous Research
diff --git a/providers/alibaba.py b/plugins/model-providers/alibaba/__init__.py
similarity index 100%
rename from providers/alibaba.py
rename to plugins/model-providers/alibaba/__init__.py
diff --git a/plugins/model-providers/alibaba/plugin.yaml b/plugins/model-providers/alibaba/plugin.yaml
new file mode 100644
index 0000000000..08fcf50bf1
--- /dev/null
+++ b/plugins/model-providers/alibaba/plugin.yaml
@@ -0,0 +1,5 @@
+name: alibaba-provider
+kind: model-provider
+version: 1.0.0
+description: Alibaba DashScope (international)
+author: Nous Research
diff --git a/providers/anthropic.py b/plugins/model-providers/anthropic/__init__.py
similarity index 100%
rename from providers/anthropic.py
rename to plugins/model-providers/anthropic/__init__.py
diff --git a/plugins/model-providers/anthropic/plugin.yaml b/plugins/model-providers/anthropic/plugin.yaml
new file mode 100644
index 0000000000..7770a5ce85
--- /dev/null
+++ b/plugins/model-providers/anthropic/plugin.yaml
@@ -0,0 +1,5 @@
+name: anthropic-provider
+kind: model-provider
+version: 1.0.0
+description: Anthropic (Claude)
+author: Nous Research
diff --git a/providers/arcee.py b/plugins/model-providers/arcee/__init__.py
similarity index 100%
rename from providers/arcee.py
rename to plugins/model-providers/arcee/__init__.py
diff --git a/plugins/model-providers/arcee/plugin.yaml b/plugins/model-providers/arcee/plugin.yaml
new file mode 100644
index 0000000000..8a12c52033
--- /dev/null
+++ b/plugins/model-providers/arcee/plugin.yaml
@@ -0,0 +1,5 @@
+name: arcee-provider
+kind: model-provider
+version: 1.0.0
+description: Arcee AI
+author: Nous Research
diff --git a/providers/azure_foundry.py b/plugins/model-providers/azure-foundry/__init__.py
similarity index 100%
rename from providers/azure_foundry.py
rename to plugins/model-providers/azure-foundry/__init__.py
diff --git a/plugins/model-providers/azure-foundry/plugin.yaml b/plugins/model-providers/azure-foundry/plugin.yaml
new file mode 100644
index 0000000000..791f82b75a
--- /dev/null
+++ b/plugins/model-providers/azure-foundry/plugin.yaml
@@ -0,0 +1,5 @@
+name: azure-foundry-provider
+kind: model-provider
+version: 1.0.0
+description: Azure AI Foundry
+author: Nous Research
diff --git a/providers/bedrock.py b/plugins/model-providers/bedrock/__init__.py
similarity index 100%
rename from providers/bedrock.py
rename to plugins/model-providers/bedrock/__init__.py
diff --git a/plugins/model-providers/bedrock/plugin.yaml b/plugins/model-providers/bedrock/plugin.yaml
new file mode 100644
index 0000000000..8516f29e41
--- /dev/null
+++ b/plugins/model-providers/bedrock/plugin.yaml
@@ -0,0 +1,5 @@
+name: bedrock-provider
+kind: model-provider
+version: 1.0.0
+description: AWS Bedrock
+author: Nous Research
diff --git a/providers/copilot_acp.py b/plugins/model-providers/copilot-acp/__init__.py
similarity index 100%
rename from providers/copilot_acp.py
rename to plugins/model-providers/copilot-acp/__init__.py
diff --git a/plugins/model-providers/copilot-acp/plugin.yaml b/plugins/model-providers/copilot-acp/plugin.yaml
new file mode 100644
index 0000000000..bb3d7ace5a
--- /dev/null
+++ b/plugins/model-providers/copilot-acp/plugin.yaml
@@ -0,0 +1,5 @@
+name: copilot-acp-provider
+kind: model-provider
+version: 1.0.0
+description: GitHub Copilot via ACP subprocess
+author: Nous Research
diff --git a/providers/copilot.py b/plugins/model-providers/copilot/__init__.py
similarity index 100%
rename from providers/copilot.py
rename to plugins/model-providers/copilot/__init__.py
diff --git a/plugins/model-providers/copilot/plugin.yaml b/plugins/model-providers/copilot/plugin.yaml
new file mode 100644
index 0000000000..cdaa8f5495
--- /dev/null
+++ b/plugins/model-providers/copilot/plugin.yaml
@@ -0,0 +1,5 @@
+name: copilot-provider
+kind: model-provider
+version: 1.0.0
+description: GitHub Copilot
+author: Nous Research
diff --git a/providers/custom.py b/plugins/model-providers/custom/__init__.py
similarity index 100%
rename from providers/custom.py
rename to plugins/model-providers/custom/__init__.py
diff --git a/plugins/model-providers/custom/plugin.yaml b/plugins/model-providers/custom/plugin.yaml
new file mode 100644
index 0000000000..9784ee2028
--- /dev/null
+++ b/plugins/model-providers/custom/plugin.yaml
@@ -0,0 +1,5 @@
+name: custom-provider
+kind: model-provider
+version: 1.0.0
+description: Custom / Ollama / local OpenAI-compatible endpoint
+author: Nous Research
diff --git a/providers/deepseek.py b/plugins/model-providers/deepseek/__init__.py
similarity index 100%
rename from providers/deepseek.py
rename to plugins/model-providers/deepseek/__init__.py
diff --git a/plugins/model-providers/deepseek/plugin.yaml b/plugins/model-providers/deepseek/plugin.yaml
new file mode 100644
index 0000000000..0a33565f80
--- /dev/null
+++ b/plugins/model-providers/deepseek/plugin.yaml
@@ -0,0 +1,5 @@
+name: deepseek-provider
+kind: model-provider
+version: 1.0.0
+description: DeepSeek
+author: Nous Research
diff --git a/providers/gemini.py b/plugins/model-providers/gemini/__init__.py
similarity index 100%
rename from providers/gemini.py
rename to plugins/model-providers/gemini/__init__.py
diff --git a/plugins/model-providers/gemini/plugin.yaml b/plugins/model-providers/gemini/plugin.yaml
new file mode 100644
index 0000000000..cd586b0886
--- /dev/null
+++ b/plugins/model-providers/gemini/plugin.yaml
@@ -0,0 +1,5 @@
+name: gemini-provider
+kind: model-provider
+version: 1.0.0
+description: Google Gemini (API key + Cloud Code OAuth)
+author: Nous Research
diff --git a/providers/gmi.py b/plugins/model-providers/gmi/__init__.py
similarity index 100%
rename from providers/gmi.py
rename to plugins/model-providers/gmi/__init__.py
diff --git a/plugins/model-providers/gmi/plugin.yaml b/plugins/model-providers/gmi/plugin.yaml
new file mode 100644
index 0000000000..95f61a48a0
--- /dev/null
+++ b/plugins/model-providers/gmi/plugin.yaml
@@ -0,0 +1,5 @@
+name: gmi-provider
+kind: model-provider
+version: 1.0.0
+description: GMI Cloud
+author: Nous Research
diff --git a/providers/huggingface.py b/plugins/model-providers/huggingface/__init__.py
similarity index 100%
rename from providers/huggingface.py
rename to plugins/model-providers/huggingface/__init__.py
diff --git a/plugins/model-providers/huggingface/plugin.yaml b/plugins/model-providers/huggingface/plugin.yaml
new file mode 100644
index 0000000000..006368718b
--- /dev/null
+++ b/plugins/model-providers/huggingface/plugin.yaml
@@ -0,0 +1,5 @@
+name: huggingface-provider
+kind: model-provider
+version: 1.0.0
+description: HuggingFace Inference Providers
+author: Nous Research
diff --git a/providers/kilocode.py b/plugins/model-providers/kilocode/__init__.py
similarity index 100%
rename from providers/kilocode.py
rename to plugins/model-providers/kilocode/__init__.py
diff --git a/plugins/model-providers/kilocode/plugin.yaml b/plugins/model-providers/kilocode/plugin.yaml
new file mode 100644
index 0000000000..96ea65440a
--- /dev/null
+++ b/plugins/model-providers/kilocode/plugin.yaml
@@ -0,0 +1,5 @@
+name: kilocode-provider
+kind: model-provider
+version: 1.0.0
+description: Kilo Code
+author: Nous Research
diff --git a/providers/kimi.py b/plugins/model-providers/kimi-coding/__init__.py
similarity index 100%
rename from providers/kimi.py
rename to plugins/model-providers/kimi-coding/__init__.py
diff --git a/plugins/model-providers/kimi-coding/plugin.yaml b/plugins/model-providers/kimi-coding/plugin.yaml
new file mode 100644
index 0000000000..c9f00d87b6
--- /dev/null
+++ b/plugins/model-providers/kimi-coding/plugin.yaml
@@ -0,0 +1,5 @@
+name: kimi-coding-provider
+kind: model-provider
+version: 1.0.0
+description: Moonshot Kimi Coding (global + China)
+author: Nous Research
diff --git a/providers/minimax.py b/plugins/model-providers/minimax/__init__.py
similarity index 100%
rename from providers/minimax.py
rename to plugins/model-providers/minimax/__init__.py
diff --git a/plugins/model-providers/minimax/plugin.yaml b/plugins/model-providers/minimax/plugin.yaml
new file mode 100644
index 0000000000..131eb7de16
--- /dev/null
+++ b/plugins/model-providers/minimax/plugin.yaml
@@ -0,0 +1,5 @@
+name: minimax-provider
+kind: model-provider
+version: 1.0.0
+description: MiniMax M-series (global + China + OAuth)
+author: Nous Research
diff --git a/providers/nous.py b/plugins/model-providers/nous/__init__.py
similarity index 100%
rename from providers/nous.py
rename to plugins/model-providers/nous/__init__.py
diff --git a/plugins/model-providers/nous/plugin.yaml b/plugins/model-providers/nous/plugin.yaml
new file mode 100644
index 0000000000..6ec234b6ee
--- /dev/null
+++ b/plugins/model-providers/nous/plugin.yaml
@@ -0,0 +1,5 @@
+name: nous-provider
+kind: model-provider
+version: 1.0.0
+description: Nous Research Portal
+author: Nous Research
diff --git a/providers/nvidia.py b/plugins/model-providers/nvidia/__init__.py
similarity index 100%
rename from providers/nvidia.py
rename to plugins/model-providers/nvidia/__init__.py
diff --git a/plugins/model-providers/nvidia/plugin.yaml b/plugins/model-providers/nvidia/plugin.yaml
new file mode 100644
index 0000000000..dd548034cc
--- /dev/null
+++ b/plugins/model-providers/nvidia/plugin.yaml
@@ -0,0 +1,5 @@
+name: nvidia-provider
+kind: model-provider
+version: 1.0.0
+description: NVIDIA NIM
+author: Nous Research
diff --git a/providers/ollama_cloud.py b/plugins/model-providers/ollama-cloud/__init__.py
similarity index 100%
rename from providers/ollama_cloud.py
rename to plugins/model-providers/ollama-cloud/__init__.py
diff --git a/plugins/model-providers/ollama-cloud/plugin.yaml b/plugins/model-providers/ollama-cloud/plugin.yaml
new file mode 100644
index 0000000000..a0ebed67a9
--- /dev/null
+++ b/plugins/model-providers/ollama-cloud/plugin.yaml
@@ -0,0 +1,5 @@
+name: ollama-cloud-provider
+kind: model-provider
+version: 1.0.0
+description: Ollama Cloud
+author: Nous Research
diff --git a/providers/openai_codex.py b/plugins/model-providers/openai-codex/__init__.py
similarity index 100%
rename from providers/openai_codex.py
rename to plugins/model-providers/openai-codex/__init__.py
diff --git a/plugins/model-providers/openai-codex/plugin.yaml b/plugins/model-providers/openai-codex/plugin.yaml
new file mode 100644
index 0000000000..f397cd4f6f
--- /dev/null
+++ b/plugins/model-providers/openai-codex/plugin.yaml
@@ -0,0 +1,5 @@
+name: openai-codex-provider
+kind: model-provider
+version: 1.0.0
+description: OpenAI Codex (Responses API)
+author: Nous Research
diff --git a/providers/opencode.py b/plugins/model-providers/opencode-zen/__init__.py
similarity index 100%
rename from providers/opencode.py
rename to plugins/model-providers/opencode-zen/__init__.py
diff --git a/plugins/model-providers/opencode-zen/plugin.yaml b/plugins/model-providers/opencode-zen/plugin.yaml
new file mode 100644
index 0000000000..23a3c90da1
--- /dev/null
+++ b/plugins/model-providers/opencode-zen/plugin.yaml
@@ -0,0 +1,5 @@
+name: opencode-zen-provider
+kind: model-provider
+version: 1.0.0
+description: OpenCode (Zen + Go)
+author: Nous Research
diff --git a/providers/openrouter.py b/plugins/model-providers/openrouter/__init__.py
similarity index 100%
rename from providers/openrouter.py
rename to plugins/model-providers/openrouter/__init__.py
diff --git a/plugins/model-providers/openrouter/plugin.yaml b/plugins/model-providers/openrouter/plugin.yaml
new file mode 100644
index 0000000000..e278aadaee
--- /dev/null
+++ b/plugins/model-providers/openrouter/plugin.yaml
@@ -0,0 +1,5 @@
+name: openrouter-provider
+kind: model-provider
+version: 1.0.0
+description: OpenRouter aggregator
+author: Nous Research
diff --git a/providers/qwen.py b/plugins/model-providers/qwen-oauth/__init__.py
similarity index 100%
rename from providers/qwen.py
rename to plugins/model-providers/qwen-oauth/__init__.py
diff --git a/plugins/model-providers/qwen-oauth/plugin.yaml b/plugins/model-providers/qwen-oauth/plugin.yaml
new file mode 100644
index 0000000000..2cecc002fe
--- /dev/null
+++ b/plugins/model-providers/qwen-oauth/plugin.yaml
@@ -0,0 +1,5 @@
+name: qwen-oauth-provider
+kind: model-provider
+version: 1.0.0
+description: Qwen Portal (OAuth)
+author: Nous Research
diff --git a/providers/stepfun.py b/plugins/model-providers/stepfun/__init__.py
similarity index 100%
rename from providers/stepfun.py
rename to plugins/model-providers/stepfun/__init__.py
diff --git a/plugins/model-providers/stepfun/plugin.yaml b/plugins/model-providers/stepfun/plugin.yaml
new file mode 100644
index 0000000000..36d3e36f01
--- /dev/null
+++ b/plugins/model-providers/stepfun/plugin.yaml
@@ -0,0 +1,5 @@
+name: stepfun-provider
+kind: model-provider
+version: 1.0.0
+description: StepFun Step Plan
+author: Nous Research
diff --git a/providers/xai.py b/plugins/model-providers/xai/__init__.py
similarity index 100%
rename from providers/xai.py
rename to plugins/model-providers/xai/__init__.py
diff --git a/plugins/model-providers/xai/plugin.yaml b/plugins/model-providers/xai/plugin.yaml
new file mode 100644
index 0000000000..10e884e8a1
--- /dev/null
+++ b/plugins/model-providers/xai/plugin.yaml
@@ -0,0 +1,5 @@
+name: xai-provider
+kind: model-provider
+version: 1.0.0
+description: xAI Grok (Responses API)
+author: Nous Research
diff --git a/providers/xiaomi.py b/plugins/model-providers/xiaomi/__init__.py
similarity index 100%
rename from providers/xiaomi.py
rename to plugins/model-providers/xiaomi/__init__.py
diff --git a/plugins/model-providers/xiaomi/plugin.yaml b/plugins/model-providers/xiaomi/plugin.yaml
new file mode 100644
index 0000000000..e422fb7013
--- /dev/null
+++ b/plugins/model-providers/xiaomi/plugin.yaml
@@ -0,0 +1,5 @@
+name: xiaomi-provider
+kind: model-provider
+version: 1.0.0
+description: Xiaomi MiMo
+author: Nous Research
diff --git a/providers/zai.py b/plugins/model-providers/zai/__init__.py
similarity index 100%
rename from providers/zai.py
rename to plugins/model-providers/zai/__init__.py
diff --git a/plugins/model-providers/zai/plugin.yaml b/plugins/model-providers/zai/plugin.yaml
new file mode 100644
index 0000000000..a7bf3736eb
--- /dev/null
+++ b/plugins/model-providers/zai/plugin.yaml
@@ -0,0 +1,5 @@
+name: zai-provider
+kind: model-provider
+version: 1.0.0
+description: Z.AI / GLM
+author: Nous Research
diff --git a/providers/README.md b/providers/README.md
index 786bc3c2e9..e1aa400f59 100644
--- a/providers/README.md
+++ b/providers/README.md
@@ -1,307 +1,78 @@
# providers/
-Single source of truth for every inference provider Hermes knows about.
+Registry and ABC for every inference provider Hermes knows about.
-Each provider is declared once here as a `ProviderProfile`. Every other layer —
+Each provider is declared once as a `ProviderProfile`. Every other layer —
auth resolution, transport kwargs, model listing, runtime routing — reads from
these profiles instead of maintaining its own parallel data.
---
-## Directory layout
+## Layout
```
providers/
-├── base.py ProviderProfile dataclass + OMIT_TEMPERATURE sentinel
-├── __init__.py Registry: register_provider(), get_provider_profile()
-├── README.md This file
-│
-├── # Simple providers — just identity + auth + endpoint
-├── alibaba.py Alibaba Cloud DashScope
-├── arcee.py Arcee AI
-├── bedrock.py AWS Bedrock (api_mode=bedrock_converse)
-├── deepseek.py DeepSeek
-├── huggingface.py Hugging Face Inference API
-├── kilocode.py Kilo Code
-├── minimax.py MiniMax (international + CN)
-├── nvidia.py NVIDIA NIM (default_max_tokens=16384)
-├── ollama_cloud.py Ollama Cloud
-├── stepfun.py StepFun
-├── xiaomi.py Xiaomi MiMo
-├── xai.py xAI Grok (api_mode=codex_responses)
-├── zai.py Z.AI / GLM
-│
-├── # Medium — one or two quirks
-├── anthropic.py Native Anthropic (x-api-key header, api_mode=anthropic_messages)
-├── copilot.py GitHub Copilot (auth_type=copilot, reasoning per model)
-├── copilot_acp.py Copilot ACP subprocess (api_mode=copilot_acp)
-├── custom.py Custom/Ollama local (think=false, num_ctx)
-├── gemini.py Google Gemini AI Studio + Cloud Code OAuth
-├── kimi.py Kimi Coding (OMIT_TEMPERATURE, thinking, dual endpoint)
-├── openai_codex.py OpenAI Codex OAuth (api_mode=codex_responses)
-├── opencode.py OpenCode Zen + Go (per-model api_mode routing)
-│
-├── # Complex — subclasses with multiple overrides
-├── nous.py Nous Portal (tags, attribution, reasoning omit-when-disabled)
-├── openrouter.py OpenRouter (provider preferences, public model fetch)
-├── qwen.py Qwen OAuth (message normalization, cache_control, vl_hires)
-└── vercel.py Vercel AI Gateway (attribution headers, reasoning passthrough)
+├── base.py ProviderProfile dataclass + OMIT_TEMPERATURE sentinel
+├── __init__.py Registry: register_provider(), get_provider_profile(), list_providers()
+└── README.md This file
```
+The **profiles themselves** live as plugins under
+`plugins/model-providers//` (bundled in this repo) and
+`$HERMES_HOME/plugins/model-providers//` (per-user overrides). The
+registry in `providers/__init__.py` lazily discovers them the first time any
+consumer calls `get_provider_profile()` or `list_providers()`. See
+`plugins/model-providers/README.md` for the plugin contract and examples.
+
---
-## ProviderProfile fields
+## How it wires in
-```python
-@dataclass
-class ProviderProfile:
- # Identity
- name: str # canonical ID — auto-registered as PROVIDER_REGISTRY key for new api-key providers
- api_mode: str # "chat_completions" | "anthropic_messages" |
- # "codex_responses" | "bedrock_converse" | "copilot_acp"
- aliases: tuple # alternate names resolved by get_provider_profile()
+The registry is populated on first access. After that, every downstream
+layer reads from it:
- # Auth & endpoints
- env_vars: tuple # env var names holding the API key, in priority order
- base_url: str # default inference endpoint
- models_url: str # explicit models endpoint; falls back to {base_url}/models
- # set when the models catalog lives at a different URL
- # (e.g. OpenRouter: public /api/v1/models vs /api/v1 inference)
- auth_type: str # "api_key" | "oauth_device_code" | "oauth_external" |
- # "copilot" | "aws" | "external_process"
-
- # Client-level quirks
- default_headers: dict # extra HTTP headers sent on every request
-
- # Request-level quirks
- fixed_temperature: Any # None = use caller's default; OMIT_TEMPERATURE = don't send
- default_max_tokens: int|None # inject max_tokens when caller omits it
- default_aux_model: str # cheap model for auxiliary tasks (compression, vision, etc.)
- # empty string = use main model (default)
-```
+- `hermes_cli/auth.py` extends `PROVIDER_REGISTRY` with every api-key
+ profile it sees (skipping `copilot`, `kimi-coding`, `kimi-coding-cn`,
+ `zai`, `openrouter`, `custom` — those need bespoke token resolution).
+- `hermes_cli/models.py` extends `CANONICAL_PROVIDERS` and calls
+ `profile.fetch_models()` inside `provider_model_ids()`.
+- `hermes_cli/doctor.py` adds a `/models` health check for each
+ `auth_type="api_key"` profile.
+- `hermes_cli/config.py` injects every `env_var` into
+ `OPTIONAL_ENV_VARS` so the setup wizard knows about it.
+- `hermes_cli/runtime_provider.py` reads `profile.api_mode` as a fallback
+ when URL detection finds nothing.
+- `agent/model_metadata.py` maps hostname → provider via
+ `profile.get_hostname()`.
+- `agent/auxiliary_client.py` reads `profile.default_aux_model` first
+ before falling back to the legacy hardcoded dict.
+- `agent/transports/chat_completions.py::_build_kwargs_from_profile()`
+ invokes `profile.prepare_messages()`, `profile.build_extra_body()`,
+ and `profile.build_api_kwargs_extras()` on every call.
+- `run_agent.py` passes `provider_profile=` so the
+ transport takes the profile path instead of the legacy flag path.
---
-## Hooks (override in a subclass)
+## Adding a provider
-| Method | When to override |
-|--------|-----------------|
-| `prepare_messages(messages)` | Provider needs message pre-processing (Qwen: string → list-of-parts, cache_control) |
-| `build_extra_body(*, session_id, **ctx)` | Provider-specific `extra_body` fields (Nous: tags, OpenRouter: provider preferences) |
-| `build_api_kwargs_extras(*, reasoning_config, **ctx)` | Returns `(extra_body_additions, top_level_kwargs)` — use when some fields go to `extra_body` and some go top-level (Kimi: `reasoning_effort` top-level; OpenRouter: `reasoning` in extra_body) |
-| `fetch_models(*, api_key, timeout)` | Custom model listing (Anthropic: x-api-key header; OpenRouter: public endpoint, no auth; Bedrock/copilot-acp: return None) |
-
-All hooks have safe defaults — only override what differs from the base.
+See `plugins/model-providers/README.md` — drop a new directory there (or
+under `$HERMES_HOME/plugins/model-providers/` for a private plugin).
---
-## How to add a new provider
+## Hooks you can override on `ProviderProfile`
-### 1. Simple (standard OpenAI-compatible endpoint)
-
-```python
-# providers/myprovider.py
-from providers import register_provider
-from providers.base import ProviderProfile
-
-myprovider = ProviderProfile(
- name="myprovider", # must match id in hermes_cli/auth.py PROVIDER_REGISTRY
- aliases=("my-provider", "myp"),
- api_mode="chat_completions",
- env_vars=("MYPROVIDER_API_KEY",),
- base_url="https://api.myprovider.com/v1",
- auth_type="api_key",
-)
-
-register_provider(myprovider)
-```
-
-The default `fetch_models()` will call `GET https://api.myprovider.com/v1/models`
-with Bearer auth automatically. No override needed for standard `/v1/models`.
-
-### 2. With quirks (subclass)
-
-```python
-# providers/myprovider.py
-from typing import Any
-from providers import register_provider
-from providers.base import ProviderProfile
-
-
-class MyProviderProfile(ProviderProfile):
- """My provider — custom reasoning header."""
-
- def build_api_kwargs_extras(
- self,
- *,
- reasoning_config: dict | None = None,
- **ctx: Any,
- ) -> tuple[dict[str, Any], dict[str, Any]]:
- extra_body: dict[str, Any] = {}
- if reasoning_config:
- extra_body["my_reasoning"] = reasoning_config.get("effort", "medium")
- return extra_body, {}
-
- def fetch_models(
- self,
- *,
- api_key: str | None = None,
- timeout: float = 8.0,
- ) -> list[str] | None:
- # Override only if your endpoint differs from standard /v1/models
- return super().fetch_models(api_key=api_key, timeout=timeout)
-
-
-myprovider = MyProviderProfile(
- name="myprovider",
- aliases=("myp",),
- env_vars=("MYPROVIDER_API_KEY",),
- base_url="https://api.myprovider.com/v1",
-)
-
-register_provider(myprovider)
-```
-
-### 3. Wire it up
-
-After creating the file, add `name` to the `_PROFILE_ACTIVE_PROVIDERS` set in
-`run_agent.py` once you've verified parity against the legacy flag path. Start
-with a simple provider (no message prep, no reasoning quirks) and work up.
+| Hook | Purpose |
+|------|---------|
+| `get_hostname()` | URL-based detection — default derives from `base_url`. |
+| `prepare_messages(msgs)` | Provider-specific message preprocessing (Qwen normalises to list-of-parts, injects `cache_control`). |
+| `build_extra_body(**ctx)` | Provider-specific `extra_body` (OpenRouter provider prefs, Gemini `thinking_config`). |
+| `build_api_kwargs_extras(**ctx)` | `(extra_body_additions, top_level_kwargs)` — Kimi puts reasoning_effort top-level, Qwen splits `enable_thinking`/`thinking_budget`. |
+| `fetch_models(*, api_key)` | Live catalog fetch — default hits `{models_url or base_url}/models` with Bearer auth. Override for no-REST providers (Bedrock), OAuth catalogs (Anthropic), or public catalogs (OpenRouter). |
---
-## fetch_models contract
+## Configuration fields
-```python
-def fetch_models(
- self,
- *,
- api_key: str | None = None,
- timeout: float = 8.0,
-) -> list[str] | None:
- ...
-```
-
-- Returns `list[str]`: model IDs from the provider's live endpoint.
-- Returns `None`: provider doesn't support REST model listing (Bedrock, copilot-acp),
- or the request failed. Callers **must** fall back to `_PROVIDER_MODELS` on `None`.
-- Never raises — swallow exceptions and return `None`.
-- Default implementation: `GET {base_url}/models` with Bearer auth. Works for any
- standard OpenAI-compatible provider.
-
-**Override when:**
-- Auth header is not `Bearer` (Anthropic: `x-api-key`)
-- Endpoint path differs from `/models` AND you can't just set `models_url` (OpenRouter: public endpoint, pass `api_key=None` explicitly)
-- Response format differs (extra wrapping, non-standard `id` field)
-- Provider has no REST endpoint (Bedrock, copilot-acp → return `None`)
-- Filtering needed post-fetch (only tool-capable models, etc.)
-
-Use `models_url` instead of overriding when the only difference is the URL:
-
-```python
-# No subclass needed — just set models_url
-myprovider = ProviderProfile(
- name="myprovider",
- base_url="https://api.myprovider.com/v1",
- models_url="https://catalog.myprovider.com/models", # different host
-)
-```
-
----
-
-## Debugging
-
-### Check if a provider resolves
-
-```python
-from providers import get_provider_profile
-
-p = get_provider_profile("myprovider")
-print(p) # ProviderProfile(name='myprovider', ...)
-print(p.base_url)
-print(p.api_mode)
-```
-
-### Check all registered providers
-
-```python
-from providers import _REGISTRY
-print(list(_REGISTRY.keys()))
-```
-
-### Test live model fetch
-
-```python
-import os
-from providers import get_provider_profile
-
-p = get_provider_profile("myprovider")
-key = os.getenv("MYPROVIDER_API_KEY")
-models = p.fetch_models(api_key=key, timeout=5.0)
-print(models) # list of model IDs, or None on failure
-```
-
-### Test alias resolution
-
-```python
-from providers import get_provider_profile
-
-# All of these should return the same profile
-assert get_provider_profile("openrouter").name == "openrouter"
-assert get_provider_profile("or").name == "openrouter"
-```
-
-### Run the provider test suite
-
-```bash
-# From the repo root
-source venv/bin/activate
-python -m pytest tests/providers/ -v
-```
-
-### Check ruff + ty compliance
-
-```bash
-source venv/bin/activate
-ruff format providers/*.py
-ruff check providers/*.py --select UP,E,F,I,W
-ty check providers/*.py
-```
-
----
-
-## Common mistakes
-
-**Wrong `name`** — must be the same string that appears as the key in
-`hermes_cli/auth.py` `PROVIDER_REGISTRY`. New api-key providers auto-register
-into `PROVIDER_REGISTRY` from the profile, so the name IS the key. For providers
-with a pre-existing `PROVIDER_REGISTRY` entry, use the exact `id` field value.
-
-**Wrong `env_vars`** — separate API-key vars from base-URL override vars in the
-tuple. Env vars that end with `_BASE_URL` or `_URL` are treated as URL overrides;
-everything else is treated as an API key. Getting this wrong causes the doctor
-health check to send a URL string as a Bearer token.
-
-**Wrong `base_url`** — several providers have non-obvious paths:
-`stepfun: /step_plan/v1`, `opencode-go: /zen/go/v1`. The profile's `base_url`
-is also used as the `inference_base_url` when auto-registering into `PROVIDER_REGISTRY`
-for new providers, so it must be correct for auth resolution to work.
-
-**Skipping `api_mode`** — defaults to `chat_completions`. Providers that use
-`anthropic_messages`, `codex_responses`, `bedrock_converse`, or `copilot_acp`
-must set it explicitly.
-
-**Forgetting `register_provider()`** — auto-discovery runs `pkgutil.iter_modules`
-over the package and imports each module, but only if `register_provider()` is
-called at module level. Without it the profile is never in `_REGISTRY`.
-
-**`fetch_models` returning the wrong shape** — must return `list[str]` (plain
-model IDs), not `list[tuple]` or `list[dict]`. Callers expect plain strings.
-
-**Wrong `build_api_kwargs_extras` return shape** — must return a 2-tuple
-`(extra_body_dict, top_level_dict)`. Returning a single dict causes a
-`ValueError: not enough values to unpack` in the transport.
-
-**`build_api_kwargs_extras` wrong tuple** — must return `(extra_body_dict,
-top_level_dict)`. Returning a flat dict or swapping the order silently sends
-fields to the wrong place.
+Full reference in `providers/base.py` dataclass definition.
diff --git a/providers/__init__.py b/providers/__init__.py
index 9c80b449a9..a394e74b33 100644
--- a/providers/__init__.py
+++ b/providers/__init__.py
@@ -1,25 +1,62 @@
"""Provider module registry.
-Auto-discovers ProviderProfile instances from providers/*.py modules.
-Each module should define a module-level PROVIDER or PROVIDERS list.
+Provider profiles can live in two places:
+
+1. Bundled plugins: ``plugins/model-providers//`` (shipped with hermes-agent)
+2. User plugins: ``$HERMES_HOME/plugins/model-providers//``
+
+Each plugin directory contains:
+ - ``__init__.py`` — calls ``register_provider(profile)`` at import
+ - ``plugin.yaml`` — manifest (name, kind: model-provider, version, description)
+
+Discovery is lazy: the first call to ``get_provider_profile()`` or
+``list_providers()`` scans both locations and imports every plugin. User
+plugins override bundled plugins on name collision (last-writer-wins), so
+third parties can monkey-patch or replace any built-in profile without
+editing the repo.
+
+For backward compatibility, ``providers/*.py`` files (other than ``base.py``
+and ``__init__.py``) are still discovered via ``pkgutil.iter_modules``.
+This lets out-of-tree users drop a single-file profile into an editable
+install without the plugin dir structure. New profiles should prefer the
+plugin layout.
+
+Usage::
-Usage:
from providers import get_provider_profile
- profile = get_provider_profile("nvidia") # returns ProviderProfile or None
- profile = get_provider_profile("kimi") # checks name + aliases
+ profile = get_provider_profile("nvidia") # ProviderProfile or None
+ profile = get_provider_profile("kimi") # checks name + aliases
"""
from __future__ import annotations
+import importlib
+import importlib.util
+import logging
+import sys
+from pathlib import Path
+
from providers.base import OMIT_TEMPERATURE, ProviderProfile # noqa: F401
+logger = logging.getLogger(__name__)
+
_REGISTRY: dict[str, ProviderProfile] = {}
_ALIASES: dict[str, str] = {}
_discovered = False
+# Repo-root ``plugins/model-providers/`` — populated at discovery time.
+_BUNDLED_PLUGINS_DIR = (
+ Path(__file__).resolve().parent.parent / "plugins" / "model-providers"
+)
+
def register_provider(profile: ProviderProfile) -> None:
- """Register a provider profile by name and aliases."""
+ """Register a provider profile by name and aliases.
+
+ Later registrations with the same name replace earlier ones — so user
+ plugins under ``$HERMES_HOME/plugins/model-providers/`` can override
+ bundled profiles without editing repo code.
+ """
_REGISTRY[profile.name] = profile
for alias in profile.aliases:
_ALIASES[alias] = profile.name
@@ -51,26 +88,104 @@ def list_providers() -> list[ProviderProfile]:
return result
+def _user_plugins_dir() -> Path | None:
+ """Return ``$HERMES_HOME/plugins/model-providers/`` if it exists."""
+ try:
+ from hermes_constants import get_hermes_home
+
+ d = get_hermes_home() / "plugins" / "model-providers"
+ return d if d.is_dir() else None
+ except Exception:
+ return None
+
+
+def _import_plugin_dir(plugin_dir: Path, source: str) -> None:
+ """Import a single plugin directory so it self-registers.
+
+ ``source`` is "bundled" or "user", used only for log messages.
+ """
+ init_file = plugin_dir / "__init__.py"
+ if not init_file.exists():
+ return
+
+ # Give bundled plugins a stable import path (``plugins.model_providers.``)
+ # so relative imports within the plugin work. User plugins load via
+ # ``importlib.util.spec_from_file_location`` with a unique module name so
+ # multiple HERMES_HOME profiles don't alias each other.
+ safe_name = plugin_dir.name.replace("-", "_")
+ if source == "bundled":
+ module_name = f"plugins.model_providers.{safe_name}"
+ else:
+ module_name = f"_hermes_user_provider_{safe_name}"
+
+ if module_name in sys.modules:
+ return # already imported
+
+ try:
+ spec = importlib.util.spec_from_file_location(
+ module_name, init_file, submodule_search_locations=[str(plugin_dir)]
+ )
+ if spec is None or spec.loader is None:
+ return
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+ spec.loader.exec_module(module)
+ except Exception as exc:
+ logger.warning(
+ "Failed to load %s provider plugin %s: %s", source, plugin_dir.name, exc
+ )
+ sys.modules.pop(module_name, None)
+
+
def _discover_providers() -> None:
- """Import all provider modules to trigger registration."""
+ """Populate the registry by importing every provider plugin.
+
+ Order:
+ 1. Bundled plugins at ``/plugins/model-providers//``
+ 2. User plugins at ``$HERMES_HOME/plugins/model-providers//``
+ 3. Legacy per-file modules at ``providers/.py`` (back-compat)
+
+ Each step imports its plugins, which call ``register_provider()`` at
+ module-level. Later steps win on name collision.
+ """
global _discovered
if _discovered:
return
_discovered = True
- import importlib
- import pkgutil
+ # 1. Bundled plugins — shipped with hermes-agent.
+ if _BUNDLED_PLUGINS_DIR.is_dir():
+ for child in sorted(_BUNDLED_PLUGINS_DIR.iterdir()):
+ if not child.is_dir() or child.name.startswith(("_", ".")):
+ continue
+ _import_plugin_dir(child, "bundled")
- import providers as _pkg
+ # 2. User plugins — under $HERMES_HOME/plugins/model-providers//.
+ # These can override any bundled profile of the same name (last-writer-wins
+ # in register_provider()).
+ user_dir = _user_plugins_dir()
+ if user_dir is not None:
+ for child in sorted(user_dir.iterdir()):
+ if not child.is_dir() or child.name.startswith(("_", ".")):
+ continue
+ _import_plugin_dir(child, "user")
- for _importer, modname, _ispkg in pkgutil.iter_modules(_pkg.__path__):
- if modname.startswith("_") or modname == "base":
- continue
- try:
- importlib.import_module(f"providers.{modname}")
- except ImportError as e:
- import logging
+ # 3. Legacy single-file profiles at providers/.py. Kept for
+ # back-compat — if someone drops a ``providers/foo.py`` into an
+ # editable install, it still works without the plugin layout.
+ try:
+ import pkgutil
- logging.getLogger(__name__).warning(
- "Failed to import provider module %s: %s", modname, e
- )
+ import providers as _pkg
+
+ for _importer, modname, _ispkg in pkgutil.iter_modules(_pkg.__path__):
+ if modname.startswith("_") or modname == "base":
+ continue
+ try:
+ importlib.import_module(f"providers.{modname}")
+ except ImportError as exc:
+ logger.warning(
+ "Failed to import legacy provider module %s: %s", modname, exc
+ )
+ except Exception:
+ pass
diff --git a/tests/providers/test_plugin_discovery.py b/tests/providers/test_plugin_discovery.py
new file mode 100644
index 0000000000..9ad6713e3e
--- /dev/null
+++ b/tests/providers/test_plugin_discovery.py
@@ -0,0 +1,145 @@
+"""Tests for the model-providers plugin discovery system.
+
+Verifies that:
+ 1. All bundled providers at plugins/model-providers// are discovered
+ 2. User plugins at $HERMES_HOME/plugins/model-providers// override bundled
+ 3. plugin.yaml manifests with kind=model-provider are correctly categorized
+"""
+
+from __future__ import annotations
+
+import importlib
+import sys
+from pathlib import Path
+
+import pytest
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
+
+def _clear_provider_caches():
+ """Force providers/__init__.py to re-discover on next list_providers()."""
+ import providers as _pkg
+ _pkg._REGISTRY.clear()
+ _pkg._ALIASES.clear()
+ _pkg._discovered = False
+ # Evict any cached plugin modules so the next import re-executes.
+ for mod in list(sys.modules.keys()):
+ if (
+ mod.startswith("plugins.model_providers")
+ or mod.startswith("_hermes_user_provider")
+ ):
+ del sys.modules[mod]
+
+
+def test_bundled_plugins_discovered():
+ """Every plugins/model-providers// should contain a plugin.yaml + __init__.py."""
+ plugins_dir = REPO_ROOT / "plugins" / "model-providers"
+ assert plugins_dir.is_dir(), f"Missing {plugins_dir}"
+
+ child_dirs = [c for c in plugins_dir.iterdir() if c.is_dir()]
+ assert len(child_dirs) >= 28, f"Expected at least 28 provider plugins, found {len(child_dirs)}"
+
+ for child in child_dirs:
+ assert (child / "__init__.py").exists(), f"{child.name} missing __init__.py"
+ assert (child / "plugin.yaml").exists(), f"{child.name} missing plugin.yaml"
+
+
+def test_all_33_profiles_register():
+ """After discovery, the registry must contain exactly 33 distinct profiles."""
+ _clear_provider_caches()
+ from providers import list_providers
+
+ profiles = list_providers()
+ names = sorted(p.name for p in profiles)
+ assert len(names) == 33, f"Expected 33 profiles, got {len(names)}: {names}"
+
+ # Spot-check representative providers from different categories
+ for required in (
+ "openrouter", "anthropic", "custom", "bedrock", "openai-codex",
+ "minimax-oauth", "gmi", "xiaomi", "alibaba-coding-plan",
+ ):
+ assert required in names, f"Missing profile: {required}"
+
+
+def test_user_plugin_overrides_bundled(tmp_path, monkeypatch):
+ """A user plugin with the same name must override the bundled profile."""
+ # Point HERMES_HOME at a fresh temp dir
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ # get_hermes_home() may be module-cached depending on codebase; ensure the
+ # env var is the source of truth. Most code paths re-read it each call.
+
+ # Drop a user plugin that replaces 'gmi'
+ user_gmi = hermes_home / "plugins" / "model-providers" / "gmi"
+ user_gmi.mkdir(parents=True)
+ (user_gmi / "__init__.py").write_text(
+ "from providers import register_provider\n"
+ "from providers.base import ProviderProfile\n"
+ "\n"
+ "custom_gmi = ProviderProfile(\n"
+ ' name="gmi",\n'
+ ' aliases=("gmi-user-override-test",),\n'
+ ' env_vars=("GMI_API_KEY",),\n'
+ ' base_url="https://user-override.example.com/v1",\n'
+ ' auth_type="api_key",\n'
+ ")\n"
+ "register_provider(custom_gmi)\n"
+ )
+ (user_gmi / "plugin.yaml").write_text(
+ "name: gmi-user-override\n"
+ "kind: model-provider\n"
+ "version: 0.0.1\n"
+ "description: Test user override\n"
+ )
+
+ _clear_provider_caches()
+ from providers import get_provider_profile
+
+ gmi = get_provider_profile("gmi")
+ assert gmi is not None
+ assert gmi.base_url == "https://user-override.example.com/v1", (
+ f"User override not applied; got base_url={gmi.base_url!r}"
+ )
+ assert "gmi-user-override-test" in gmi.aliases
+
+ # Clean up: reset discovery state so other tests see the bundled version
+ _clear_provider_caches()
+
+
+def test_general_plugin_manager_skips_model_provider_kind(tmp_path, monkeypatch):
+ """The general PluginManager must NOT import model-provider plugins
+ (providers/__init__.py handles them). It records the manifest only."""
+ from hermes_cli import plugins as plugin_mod
+
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ # Create a user-installed plugin with an explicit kind: model-provider.
+ user_plugin = hermes_home / "plugins" / "test-model-provider"
+ user_plugin.mkdir(parents=True)
+ (user_plugin / "plugin.yaml").write_text(
+ "name: test-model-provider\n"
+ "kind: model-provider\n"
+ "version: 0.0.1\n"
+ )
+ (user_plugin / "__init__.py").write_text(
+ # Intentionally broken import — if the general loader tries to
+ # import this module, the test will fail with ImportError.
+ "raise AssertionError('model-provider plugins must not be imported by PluginManager')\n"
+ )
+
+ # Fresh manager
+ manager = plugin_mod.PluginManager()
+ manager.discover_and_load(force=True)
+
+ # The manifest should be recorded but not loaded
+ loaded = manager._plugins.get("test-model-provider")
+ assert loaded is not None
+ assert loaded.manifest.kind == "model-provider"
+ # No import means the module must NOT be in the plugins list as a loaded one.
+ # We check that the general loader didn't crash and didn't raise from the
+ # broken __init__.py.
diff --git a/website/docs/developer-guide/adding-providers.md b/website/docs/developer-guide/adding-providers.md
index 5ec127d663..3cd358849a 100644
--- a/website/docs/developer-guide/adding-providers.md
+++ b/website/docs/developer-guide/adding-providers.md
@@ -99,10 +99,12 @@ If your provider is just an OpenAI-compatible endpoint that authenticates with a
All you need is:
-1. A file in `providers/` (e.g. `providers/myprovider.py`) that calls `register_provider()` with the provider config.
-2. That's it. `auth.py` auto-registers every file in `providers/` at startup via a module-level import sweep.
+1. A plugin directory under `plugins/model-providers//` containing:
+ - `__init__.py` — calls `register_provider(profile)` at module-level
+ - `plugin.yaml` — manifest (name, kind: model-provider, version, description)
+2. That's it. Provider plugins auto-load the first time anything calls `get_provider_profile()` or `list_providers()` — bundled plugins (this repo) and user plugins at `$HERMES_HOME/plugins/model-providers/` both get picked up.
-When you add a `providers/*.py` file and call `register_provider()`, the following wire up automatically:
+When you add a plugin and it calls `register_provider()`, the following wire up automatically:
1. `PROVIDER_REGISTRY` entry in `auth.py` (credential resolution, env-var lookup)
2. `api_mode` set to `chat_completions`
@@ -117,7 +119,9 @@ When you add a `providers/*.py` file and call `register_provider()`, the followi
11. `HERMES_INFERENCE_PROVIDER` env-var override accepts the provider id
12. Fallback model activation can switch into the provider cleanly
-See `providers/nvidia.py` or `providers/gmi.py` as a template.
+User plugins at `$HERMES_HOME/plugins/model-providers//` override bundled plugins of the same name (last-writer-wins in `register_provider()`) — so third parties can monkey-patch or replace any built-in profile without editing the repo.
+
+See `plugins/model-providers/nvidia/` or `plugins/model-providers/gmi/` as a template, and `plugins/model-providers/README.md` for the full contract.
## Full path: OAuth and complex providers
diff --git a/website/docs/developer-guide/provider-runtime.md b/website/docs/developer-guide/provider-runtime.md
index b2e798a267..40d6cd7d9a 100644
--- a/website/docs/developer-guide/provider-runtime.md
+++ b/website/docs/developer-guide/provider-runtime.md
@@ -20,9 +20,10 @@ Primary implementation:
- `hermes_cli/auth.py` — provider registry, `resolve_provider()`
- `hermes_cli/model_switch.py` — shared `/model` switch pipeline (CLI + gateway)
- `agent/auxiliary_client.py` — auxiliary model routing
-- `providers/` — declarative source for `api_mode`, `base_url`, `env_vars`, `fallback_models` (auto-registered into `auth.py` `PROVIDER_REGISTRY` at startup)
+- `providers/` — ABC + registry entry points (`ProviderProfile`, `register_provider`, `get_provider_profile`, `list_providers`)
+- `plugins/model-providers//` — per-provider plugins (bundled) that declare `api_mode`, `base_url`, `env_vars`, `fallback_models` and register themselves into the registry on first access. User plugins at `$HERMES_HOME/plugins/model-providers//` override bundled ones of the same name.
-`get_provider_profile()` in `providers/` returns a typed dict for a given provider id. `runtime_provider.py` calls this at resolution time to get the canonical `base_url`, `env_vars` priority list, `api_mode`, and `fallback_models` without needing to duplicate that data in multiple files. Adding a new `providers/*.py` file that calls `register_provider()` is enough for `runtime_provider.py` to pick it up — no branch needed in the resolver itself.
+`get_provider_profile()` in `providers/` returns a `ProviderProfile` for a given provider id. `runtime_provider.py` calls this at resolution time to get the canonical `base_url`, `env_vars` priority list, `api_mode`, and `fallback_models` without needing to duplicate that data in multiple files. Adding a new plugin under `plugins/model-providers//` (or `$HERMES_HOME/plugins/model-providers//`) that calls `register_provider()` is enough for `runtime_provider.py` to pick it up — no branch needed in the resolver itself.
If you are trying to add a new first-class inference provider, read [Adding Providers](./adding-providers.md) alongside this page.
From 84ec27616a36b975e771e5c8b66d7b7a0eec3211 Mon Sep 17 00:00:00 2001
From: Serhat Dolmac
Date: Thu, 23 Apr 2026 23:17:05 +0300
Subject: [PATCH 025/124] =?UTF-8?q?docs(cli):=20expand=20hermes=20import?=
=?UTF-8?q?=20reference=20=E2=80=94=20add=20description,=20warning,=20and?=
=?UTF-8?q?=20examples?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
website/docs/reference/cli-commands.md | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md
index 927135721e..cf1c80379d 100644
--- a/website/docs/reference/cli-commands.md
+++ b/website/docs/reference/cli-commands.md
@@ -585,11 +585,21 @@ hermes backup --quick --label "pre-upgrade" # Quick snapshot with label
hermes import [options]
```
-Restore a previously created Hermes backup into your Hermes home directory.
+Restore a previously created Hermes backup into your Hermes home directory. All files in the archive overwrite existing files in your Hermes home; `--force` only skips the confirmation prompt that fires when the target already has a Hermes installation.
| Option | Description |
|--------|-------------|
-| `-f`, `--force` | Overwrite existing files without confirmation. |
+| `-f`, `--force` | Skip the existing-installation confirmation prompt. |
+
+:::warning
+Stop the gateway before importing to avoid conflicts with running processes.
+:::
+
+### Examples
+```bash
+hermes import ~/hermes-backup-20260423.zip # Prompts before overwriting existing config
+hermes import ~/hermes-backup-20260423.zip --force # Overwrite without prompting
+```
## `hermes logs`
From 7b05ccddc79654dbe7126a38ecf8994c317c3a6d Mon Sep 17 00:00:00 2001
From: JiaDe-Wu
Date: Thu, 16 Apr 2026 15:45:19 +0000
Subject: [PATCH 026/124] docs(bedrock): fix IAM permissions, add quickstart
entry, add fallback provider, fix deployment section
---
hermes_cli/config.py | 2 ++
website/docs/getting-started/quickstart.md | 1 +
website/docs/guides/aws-bedrock.md | 6 ++++++
3 files changed, 9 insertions(+)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 25b949ac56..1ac9881d89 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -3952,6 +3952,7 @@ _FALLBACK_COMMENT = """
# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
+# bedrock (AWS IAM / boto3) — AWS Bedrock (Converse API)
#
# For custom OpenAI-compatible endpoints, add base_url and key_env.
#
@@ -3983,6 +3984,7 @@ _COMMENTED_SECTIONS = """
# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
+# bedrock (AWS IAM / boto3) — AWS Bedrock (Converse API)
#
# For custom OpenAI-compatible endpoints, add base_url and key_env.
#
diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md
index a65177f901..d62f347668 100644
--- a/website/docs/getting-started/quickstart.md
+++ b/website/docs/getting-started/quickstart.md
@@ -97,6 +97,7 @@ Good defaults:
| **MiniMax China** | China-region MiniMax endpoint | Set `MINIMAX_CN_API_KEY` |
| **Alibaba Cloud** | Qwen models via DashScope | Set `DASHSCOPE_API_KEY` |
| **Hugging Face** | 20+ open models via unified router (Qwen, DeepSeek, Kimi, etc.) | Set `HF_TOKEN` |
+| **AWS Bedrock** | Claude, Nova, Llama, DeepSeek via native Converse API | IAM role or `aws configure` ([guide](../guides/aws-bedrock.md)) |
| **Kilo Code** | KiloCode-hosted models | Set `KILOCODE_API_KEY` |
| **OpenCode Zen** | Pay-as-you-go access to curated models | Set `OPENCODE_ZEN_API_KEY` |
| **OpenCode Go** | $10/month subscription for open models | Set `OPENCODE_GO_API_KEY` |
diff --git a/website/docs/guides/aws-bedrock.md b/website/docs/guides/aws-bedrock.md
index cf5aec4e3f..3e09822c1a 100644
--- a/website/docs/guides/aws-bedrock.md
+++ b/website/docs/guides/aws-bedrock.md
@@ -162,3 +162,9 @@ Use an **inference profile ID** (prefixed with `us.` or `global.`) instead of th
### "ThrottlingException"
You've hit the Bedrock per-model rate limit. Hermes automatically retries with backoff. To increase limits, request a quota increase in the [AWS Service Quotas console](https://console.aws.amazon.com/servicequotas/).
+
+## One-Click AWS Deployment
+
+For a fully automated deployment on EC2 with CloudFormation:
+
+**[sample-hermes-agent-on-aws-with-bedrock](https://github.com/JiaDe-Wu/sample-hermes-agent-on-aws-with-bedrock)** — creates VPC, IAM role, EC2 instance, and configures Bedrock automatically. Deploy in any region with one click.
From af312ccc97152ae73a2f764879e8d946804b79ce Mon Sep 17 00:00:00 2001
From: Wysie
Date: Wed, 29 Apr 2026 15:27:37 +0800
Subject: [PATCH 027/124] docs: fix Camofox Docker setup instructions
---
website/docs/user-guide/features/browser.md | 56 +++++++++++++++++++--
1 file changed, 51 insertions(+), 5 deletions(-)
diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md
index 3bc1b0bb72..a5b1e39d00 100644
--- a/website/docs/user-guide/features/browser.md
+++ b/website/docs/user-guide/features/browser.md
@@ -125,12 +125,58 @@ your LAN through the public path).
[Camofox](https://github.com/jo-inc/camofox-browser) is a self-hosted Node.js server wrapping Camoufox (a Firefox fork with C++ fingerprint spoofing). It provides local anti-detection browsing without cloud dependencies.
```bash
-# Install and run
-git clone https://github.com/jo-inc/camofox-browser && cd camofox-browser
-npm install && npm start # downloads Camoufox (~300MB) on first run
+# Clone the Camofox browser server first
+git clone https://github.com/jo-inc/camofox-browser
+cd camofox-browser
-# Or via Docker
-docker run -d --network host -e CAMOFOX_PORT=9377 jo-inc/camofox-browser
+# Build and start with Docker using the default container settings
+# (auto-detects arch: aarch64 on M1/M2, x86_64 on Intel)
+make up
+
+# Stop and remove the default container
+make down
+
+# Force a clean rebuild (for example, after upgrading VERSION/RELEASE)
+make reset
+
+# Just download binaries without building
+make fetch
+
+# Override arch or version explicitly
+make up ARCH=x86_64
+make up VERSION=135.0.1 RELEASE=beta.24
+```
+
+`make up` starts the default container immediately. If you want custom runtime settings such as a larger Node heap, VNC, or a persistent profile directory, build the image first and then run it yourself:
+
+```bash
+# Build the image without starting the default container
+make build
+
+# Start with persistence, VNC live view, and a larger Node heap
+mkdir -p ~/.camofox-docker
+docker run -d \
+ --name camofox-browser \
+ --restart unless-stopped \
+ -p 9377:9377 \
+ -p 6080:6080 \
+ -p 5901:5900 \
+ -e CAMOFOX_PORT=9377 \
+ -e ENABLE_VNC=1 \
+ -e VNC_BIND=0.0.0.0 \
+ -e VNC_RESOLUTION=1920x1080 \
+ -e MAX_OLD_SPACE_SIZE=2048 \
+ -v ~/.camofox-docker:/root/.camofox \
+ camofox-browser:135.0.1-aarch64
+```
+
+With VNC enabled, the browser runs in headed mode and can be watched live in your browser at `http://localhost:6080` (noVNC). You can also connect a native VNC client to `localhost:5901`.
+
+If you already ran `make up`, stop and remove that default container before starting the custom one:
+
+```bash
+make down
+# then run the custom docker run command above
```
Then set in `~/.hermes/.env`:
From acca3ec3af7ebe99f520bd8f3d1e84f6447b57ac Mon Sep 17 00:00:00 2001
From: Jason Perlow
Date: Sat, 25 Apr 2026 05:53:24 -0400
Subject: [PATCH 028/124] docs(providers): Together/Groq/Perplexity cookbook
via custom_providers
Three worked recipes for OpenAI-compatible cloud providers, plus the
Copilot HTTP 401 auto-recovery info block and the GMI Cloud row in the
compatible providers table. All three additions were on the original
docs/custom-providers-cookbook branch but its merge base predated 1186
main commits, making the rebase impractical (84k+ line conflict).
Replays just the providers.md additions onto current main.
---
website/docs/integrations/providers.md | 107 +++++++++++++++++++++++++
1 file changed, 107 insertions(+)
diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md
index 84e5e92cae..4073594ba5 100644
--- a/website/docs/integrations/providers.md
+++ b/website/docs/integrations/providers.md
@@ -1190,6 +1190,113 @@ You can also select named custom providers from the interactive `hermes model` m
---
+### Cookbook: Together AI, Groq, Perplexity
+
+The cloud providers listed in [Other Compatible Providers](#other-compatible-providers) all speak OpenAI's REST dialect, so they wire up the same way under `custom_providers:`. Three worked recipes follow. Each drops into `~/.hermes/config.yaml` and the matching API key goes in `~/.hermes/.env`.
+
+#### Together AI
+
+Hosts open-weight models (Llama, MiniMax, Gemma, DeepSeek, Qwen) at prices significantly below first-party APIs. Good default for multi-model fleets.
+
+```yaml
+# ~/.hermes/config.yaml
+custom_providers:
+ - name: together
+ base_url: https://api.together.xyz/v1
+ key_env: TOGETHER_API_KEY
+ # api_mode: chat_completions # default — no need to set
+
+model:
+ default: MiniMaxAI/MiniMax-M2.7 # or any model from together.ai/models
+ provider: custom:together
+```
+
+```bash
+# ~/.hermes/.env
+TOGETHER_API_KEY=your-together-key
+```
+
+Switch models mid-session:
+
+```
+/model custom:together:meta-llama/Llama-3.3-70B-Instruct-Turbo
+/model custom:together:google/gemma-4-31b-it
+/model custom:together:deepseek-ai/DeepSeek-V3
+```
+
+Together's `/v1/models` endpoint works, so `hermes model` can auto-discover available models.
+
+#### Groq
+
+Ultra-fast inference (~500 tok/s on Llama-3.3-70B). Small catalog but strong for latency-sensitive interactive use.
+
+```yaml
+# ~/.hermes/config.yaml
+custom_providers:
+ - name: groq
+ base_url: https://api.groq.com/openai/v1
+ key_env: GROQ_API_KEY
+
+model:
+ default: llama-3.3-70b-versatile
+ provider: custom:groq
+```
+
+```bash
+# ~/.hermes/.env
+GROQ_API_KEY=your-groq-key
+```
+
+#### Perplexity
+
+Useful when you want a model that does live web search and citation automatically. Strict about which models are available — check [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api) for the current list.
+
+```yaml
+# ~/.hermes/config.yaml
+custom_providers:
+ - name: perplexity
+ base_url: https://api.perplexity.ai
+ key_env: PERPLEXITY_API_KEY
+
+model:
+ default: sonar
+ provider: custom:perplexity
+```
+
+```bash
+# ~/.hermes/.env
+PERPLEXITY_API_KEY=your-perplexity-key
+```
+
+#### Multiple providers in one config
+
+The three recipes compose — use all of them together and switch per turn with `/model custom::`:
+
+```yaml
+custom_providers:
+ - name: together
+ base_url: https://api.together.xyz/v1
+ key_env: TOGETHER_API_KEY
+ - name: groq
+ base_url: https://api.groq.com/openai/v1
+ key_env: GROQ_API_KEY
+ - name: perplexity
+ base_url: https://api.perplexity.ai
+ key_env: PERPLEXITY_API_KEY
+
+model:
+ default: MiniMaxAI/MiniMax-M2.7
+ provider: custom:together # boot to Together; switch freely after
+```
+
+:::tip Troubleshooting
+- `hermes doctor` should print no `Unknown provider` warnings for any of these names after the CLI validator fixes in #15083.
+- If a provider's `/v1/models` endpoint is unreachable (Perplexity is the common one), `hermes model` will persist the model with a warning rather than hard-reject — see #15136.
+- To skip `custom_providers:` entirely and use bare `provider: custom` with `CUSTOM_BASE_URL` env var, see #15103.
+:::
+
+---
+
### Choosing the Right Setup
| Use Case | Recommended |
From 794f48766c7e984236ec993e26b0da1c2586448b Mon Sep 17 00:00:00 2001
From: brooklyn!
Date: Tue, 5 May 2026 13:42:39 -0700
Subject: [PATCH 029/124] fix(tui): close slash parity gaps with CLI (#20339)
* fix(tui): close slash parity gaps with CLI
Route unsupported /skills subcommands through slash.exec, support /new
titles, and handle /redraw natively so TUI behavior matches classic CLI. Also
filter gateway-only commands out of the TUI catalog while keeping /status
discoverable.
* fix(tui): run remaining CLI parity paths natively
Forward chat launch flags into the TUI runtime and handle live-session status
and skill reloads in the gateway process so TUI state no longer depends on the
slash worker's stale CLI instance.
* fix(tui): block stale snapshot restores
Prevent snapshot restore from running through the isolated slash worker because
it mutates disk state without refreshing the live TUI agent.
* chore: uptick
* fix(tui): guard async session title updates
Handle failures from the fire-and-forget session.title RPC so title-setting errors do not surface as unhandled promise rejections while preserving session-scoped messaging.
---
hermes_cli/main.py | 532 +++++++++++++-----
tests/hermes_cli/test_tui_resume_flow.py | 147 ++++-
tests/test_tui_gateway_server.py | 222 ++++++--
tests/tui_gateway/test_make_agent_provider.py | 43 ++
tui_gateway/server.py | 332 +++++++++--
.../src/__tests__/createSlashHandler.test.ts | 79 ++-
ui-tui/src/app/createGatewayEventHandler.ts | 39 +-
ui-tui/src/app/interfaces.ts | 7 +-
ui-tui/src/app/slash/commands/core.ts | 34 +-
ui-tui/src/app/slash/commands/ops.ts | 59 +-
ui-tui/src/app/useMainApp.ts | 20 +-
ui-tui/src/app/useSessionLifecycle.ts | 30 +-
ui-tui/src/config/env.ts | 2 +
ui-tui/src/gatewayTypes.ts | 4 +
14 files changed, 1266 insertions(+), 284 deletions(-)
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 89dd166776..9601f31ab5 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -52,6 +52,7 @@ import sys
from pathlib import Path
from typing import Optional
+
def _add_accept_hooks_flag(parser) -> None:
"""Attach the ``--accept-hooks`` flag. Shared across every agent
subparser so the flag works regardless of CLI position."""
@@ -120,6 +121,7 @@ def _apply_profile_override() -> None:
# resolve_profile_env() with a value it must reject + sys.exit on.
if profile_name is not None and consume == 2:
import re as _re
+
if not _re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", profile_name):
profile_name = None
consume = 0
@@ -191,6 +193,7 @@ load_hermes_dotenv(project_env=PROJECT_ROOT / ".env")
try:
if "HERMES_REDACT_SECRETS" not in os.environ:
import yaml as _yaml_early
+
_cfg_path = get_hermes_home() / "config.yaml"
if _cfg_path.exists():
with open(_cfg_path, encoding="utf-8") as _f:
@@ -793,9 +796,15 @@ def _read_tui_active_session_file(path: Optional[str]) -> Optional[str]:
return None
-def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Optional[str] = None) -> None:
+def _print_tui_exit_summary(
+ session_id: Optional[str], active_session_file: Optional[str] = None
+) -> None:
"""Print a shell-visible epilogue after TUI exits."""
- target = _read_tui_active_session_file(active_session_file) or session_id or _resolve_last_session(source="tui")
+ target = (
+ _read_tui_active_session_file(active_session_file)
+ or session_id
+ or _resolve_last_session(source="tui")
+ )
if not target:
return
@@ -914,7 +923,9 @@ def _tui_need_npm_install(root: Path) -> bool:
continue
return True
- if isinstance(installed[name], dict) and comparable(pkg) != comparable(installed[name]):
+ if isinstance(installed[name], dict) and comparable(pkg) != comparable(
+ installed[name]
+ ):
return True
return False
@@ -1156,6 +1167,16 @@ def _launch_tui(
model: Optional[str] = None,
provider: Optional[str] = None,
toolsets: object = None,
+ skills: object = None,
+ verbose: bool = False,
+ quiet: bool = False,
+ query: Optional[str] = None,
+ image: Optional[str] = None,
+ worktree: bool = False,
+ checkpoints: bool = False,
+ pass_session_id: bool = False,
+ max_turns: Optional[int] = None,
+ accept_hooks: bool = False,
):
"""Replace current process with the TUI."""
tui_dir = PROJECT_ROOT / "ui-tui"
@@ -1174,6 +1195,29 @@ def _launch_tui(
env.setdefault("HERMES_PYTHON", sys.executable)
env.setdefault("HERMES_CWD", os.getcwd())
env.setdefault("NODE_ENV", "development" if tui_dev else "production")
+
+ wt_info = None
+ if worktree:
+ try:
+ from cli import (
+ _cleanup_worktree,
+ _git_repo_root,
+ _prune_stale_worktrees,
+ _setup_worktree,
+ )
+
+ repo = _git_repo_root()
+ if repo:
+ _prune_stale_worktrees(repo)
+ wt_info = _setup_worktree()
+ except Exception as exc:
+ print(f"✗ Failed to create TUI worktree: {exc}", file=sys.stderr)
+ wt_info = None
+ if not wt_info:
+ sys.exit(1)
+ env["HERMES_CWD"] = wt_info["path"]
+ env["TERMINAL_CWD"] = wt_info["path"]
+
if model:
env["HERMES_MODEL"] = model
env["HERMES_INFERENCE_MODEL"] = model
@@ -1183,6 +1227,35 @@ def _launch_tui(
tui_toolsets = _normalize_tui_toolsets(toolsets)
if tui_toolsets:
env["HERMES_TUI_TOOLSETS"] = ",".join(tui_toolsets)
+ if skills:
+ if isinstance(skills, (list, tuple)):
+ flattened = []
+ for item in skills:
+ flattened.extend(
+ part.strip() for part in str(item).split(",") if part.strip()
+ )
+ if flattened:
+ env["HERMES_TUI_SKILLS"] = ",".join(flattened)
+ else:
+ value = str(skills).strip()
+ if value:
+ env["HERMES_TUI_SKILLS"] = value
+ if query:
+ env["HERMES_TUI_QUERY"] = query
+ if image:
+ env["HERMES_TUI_IMAGE"] = image
+ if checkpoints:
+ env["HERMES_TUI_CHECKPOINTS"] = "1"
+ if pass_session_id:
+ env["HERMES_TUI_PASS_SESSION_ID"] = "1"
+ if max_turns is not None:
+ env["HERMES_TUI_MAX_TURNS"] = str(max_turns)
+ if verbose:
+ env["HERMES_TUI_TOOL_PROGRESS"] = "verbose"
+ elif quiet:
+ env["HERMES_TUI_TOOL_PROGRESS"] = "off"
+ if accept_hooks:
+ env["HERMES_ACCEPT_HOOKS"] = "1"
# Guarantee an 8GB V8 heap + exposed GC for the TUI. Default node cap is
# ~1.5–4GB depending on version and can fatal-OOM on long sessions with
# large transcripts / reasoning blobs. Token-level merge: respect any
@@ -1212,6 +1285,11 @@ def _launch_tui(
os.unlink(active_session_file)
except OSError:
pass
+ if wt_info:
+ try:
+ _cleanup_worktree(wt_info)
+ except Exception:
+ pass
sys.exit(code)
@@ -1231,6 +1309,7 @@ def _pin_kanban_board_env() -> None:
return
try:
from hermes_cli.kanban_db import get_current_board
+
os.environ["HERMES_KANBAN_BOARD"] = get_current_board()
except Exception:
pass
@@ -1353,6 +1432,16 @@ def cmd_chat(args):
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
+ skills=getattr(args, "skills", None),
+ verbose=getattr(args, "verbose", False),
+ quiet=getattr(args, "quiet", False),
+ query=getattr(args, "query", None),
+ image=getattr(args, "image", None),
+ worktree=getattr(args, "worktree", False),
+ checkpoints=getattr(args, "checkpoints", False),
+ pass_session_id=getattr(args, "pass_session_id", False),
+ max_turns=getattr(args, "max_turns", None),
+ accept_hooks=getattr(args, "accept_hooks", False),
)
# Import and run the CLI
@@ -1504,7 +1593,9 @@ def cmd_whatsapp(args):
return
if not (bridge_dir / "node_modules").exists():
- print("\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)...")
+ print(
+ "\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)..."
+ )
npm = shutil.which("npm")
if not npm:
print(" ✗ npm not found on PATH — install Node.js first")
@@ -1740,9 +1831,7 @@ def select_provider_and_model(args=None):
raw_api_key_refs.setdefault((name.lower(), model), template)
if provider_key:
raw_api_key_refs.setdefault((provider_key.lower(),), template)
- raw_api_key_refs.setdefault(
- (provider_key.lower(), model), template
- )
+ raw_api_key_refs.setdefault((provider_key.lower(), model), template)
raw_list = raw_cfg.get("custom_providers")
if isinstance(raw_list, list):
@@ -1752,8 +1841,7 @@ def select_provider_and_model(args=None):
_record_raw(
raw_entry.get("name", ""),
"",
- raw_entry.get("model", "")
- or raw_entry.get("default_model", ""),
+ raw_entry.get("model", "") or raw_entry.get("default_model", ""),
raw_entry.get("api_key", ""),
)
raw_providers = raw_cfg.get("providers")
@@ -1764,8 +1852,7 @@ def select_provider_and_model(args=None):
_record_raw(
raw_entry.get("name", "") or raw_key,
raw_key,
- raw_entry.get("model", "")
- or raw_entry.get("default_model", ""),
+ raw_entry.get("model", "") or raw_entry.get("default_model", ""),
raw_entry.get("api_key", ""),
)
@@ -1806,9 +1893,7 @@ def select_provider_and_model(args=None):
"model": entry.get("model", ""),
"api_mode": entry.get("api_mode", ""),
"provider_key": provider_key,
- "api_key_ref": _lookup_ref(
- name, provider_key, entry.get("model", "")
- ),
+ "api_key_ref": _lookup_ref(name, provider_key, entry.get("model", "")),
}
return custom_provider_map
@@ -1982,15 +2067,15 @@ def _clear_stale_openai_base_url():
# (task_key, display_name, short_description)
_AUX_TASKS: list[tuple[str, str, str]] = [
- ("vision", "Vision", "image/screenshot analysis"),
- ("compression", "Compression", "context summarization"),
- ("web_extract", "Web extract", "web page summarization"),
- ("session_search", "Session search", "past-conversation recall"),
- ("approval", "Approval", "smart command approval"),
- ("mcp", "MCP", "MCP tool reasoning"),
+ ("vision", "Vision", "image/screenshot analysis"),
+ ("compression", "Compression", "context summarization"),
+ ("web_extract", "Web extract", "web page summarization"),
+ ("session_search", "Session search", "past-conversation recall"),
+ ("approval", "Approval", "smart command approval"),
+ ("mcp", "MCP", "MCP tool reasoning"),
("title_generation", "Title generation", "session titles"),
- ("skills_hub", "Skills hub", "skills search/install"),
- ("curator", "Curator", "skill-usage review pass"),
+ ("skills_hub", "Skills hub", "skills search/install"),
+ ("curator", "Curator", "skill-usage review pass"),
]
@@ -2089,7 +2174,7 @@ def _aux_config_menu() -> None:
print(" Auxiliary models — side-task routing")
print()
print(" Side tasks (vision, compression, web extraction, etc.) default")
- print(" to your main chat model. \"auto\" means \"use my main model\" —")
+ print(' to your main chat model. "auto" means "use my main model" —')
print(" Hermes only falls back to a lightweight backend (OpenRouter,")
print(" Nous Portal) if the main model is unavailable. Override a")
print(" task below if you want it pinned to a specific provider/model.")
@@ -2100,15 +2185,20 @@ def _aux_config_menu() -> None:
desc_col = max(len(desc) for _, _, desc in _AUX_TASKS) + 4
entries: list[tuple[str, str]] = []
for task_key, name, desc in _AUX_TASKS:
- task_cfg = aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {}
+ task_cfg = (
+ aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {}
+ )
current = _format_aux_current(task_cfg)
- label = f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}"
+ label = (
+ f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}"
+ )
entries.append((task_key, label))
entries.append(("__reset__", "Reset all to auto"))
- entries.append(("__back__", "Back"))
+ entries.append(("__back__", "Back"))
idx = _prompt_provider_choice(
- [label for _, label in entries], default=0,
+ [label for _, label in entries],
+ default=0,
)
if idx is None:
return
@@ -2160,7 +2250,9 @@ def _aux_select_for_task(task: str) -> None:
entries: list[tuple[str, str, list[str]]] = [] # (slug, label, models)
# "auto" always first
- auto_marker = " ← current" if current_provider == "auto" and not current_base_url else ""
+ auto_marker = (
+ " ← current" if current_provider == "auto" and not current_base_url else ""
+ )
entries.append(("__auto__", f"auto (recommended){auto_marker}", []))
for p in providers:
@@ -2169,7 +2261,9 @@ def _aux_select_for_task(task: str) -> None:
total = p.get("total_models", 0)
models = p.get("models") or []
model_hint = f" — {total} models" if total else ""
- marker = " ← current" if slug == current_provider and not current_base_url else ""
+ marker = (
+ " ← current" if slug == current_provider and not current_base_url else ""
+ )
entries.append((slug, f"{name}{model_hint}{marker}", list(models)))
# Custom endpoint (raw base_url)
@@ -2237,14 +2331,17 @@ def _aux_flow_provider_model(
selected = val or ""
else:
selected = _prompt_model_selection(
- model_list, current_model=current_model, pricing=pricing,
+ model_list,
+ current_model=current_model,
+ pricing=pricing,
)
if selected is None:
print("No change.")
return
- _save_aux_choice(task, provider=provider_slug, model=selected or "",
- base_url="", api_key="")
+ _save_aux_choice(
+ task, provider=provider_slug, model=selected or "", base_url="", api_key=""
+ )
if selected:
print(f"{display_name}: {provider_slug} · {selected}")
else:
@@ -2264,7 +2361,9 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
print(" Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)")
print()
try:
- url_prompt = f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: "
+ url_prompt = (
+ f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: "
+ )
url = input(url_prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
@@ -2274,20 +2373,30 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
print("No URL provided. No change.")
return
try:
- model_prompt = f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): "
+ model_prompt = (
+ f"Model slug (optional) [{current_model}]: "
+ if current_model
+ else "Model slug (optional): "
+ )
model = input(model_prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
return
model = model or current_model
try:
- api_key = getpass.getpass("API key (optional, blank = use OPENAI_API_KEY): ").strip()
+ api_key = getpass.getpass(
+ "API key (optional, blank = use OPENAI_API_KEY): "
+ ).strip()
except (KeyboardInterrupt, EOFError):
print()
return
_save_aux_choice(
- task, provider="custom", model=model, base_url=url, api_key=api_key,
+ task,
+ provider="custom",
+ model=model,
+ base_url=url,
+ api_key=api_key,
)
short_url = url.replace("https://", "").replace("http://", "").rstrip("/")
print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else ""))
@@ -2403,7 +2512,9 @@ def _model_flow_ai_gateway(config, current_model=""):
api_key = get_env_value("AI_GATEWAY_API_KEY")
if not api_key:
print("No Vercel AI Gateway API key configured.")
- print("Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway")
+ print(
+ "Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway"
+ )
print("Add a payment method to get $5 in free credits.")
print()
try:
@@ -2772,6 +2883,7 @@ def _model_flow_minimax_oauth(config, current_model="", args=None):
_login_minimax_oauth,
PROVIDER_REGISTRY,
)
+
state = get_provider_auth_state("minimax-oauth")
if not state or not state.get("access_token"):
print("Not logged into MiniMax. Starting OAuth login...")
@@ -2797,6 +2909,7 @@ def _model_flow_minimax_oauth(config, current_model="", args=None):
return
from hermes_cli.models import _PROVIDER_MODELS
+
model_ids = _PROVIDER_MODELS.get("minimax-oauth", [])
selected = _prompt_model_selection(model_ids, current_model)
if not selected:
@@ -3186,7 +3299,12 @@ def _model_flow_azure_foundry(config, current_model=""):
(models.dev, provider metadata, hardcoded family fallbacks).
"""
from hermes_cli.auth import _save_model_choice, deactivate_provider # noqa: F401
- from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
+ from hermes_cli.config import (
+ get_env_value,
+ save_env_value,
+ load_config,
+ save_config,
+ )
from hermes_cli import azure_detect
import getpass
@@ -3214,7 +3332,11 @@ def _model_flow_azure_foundry(config, current_model=""):
if current_base_url:
print(f" Current endpoint: {current_base_url}")
if current_api_mode:
- _lbl = "OpenAI-style" if current_api_mode == "chat_completions" else "Anthropic-style"
+ _lbl = (
+ "OpenAI-style"
+ if current_api_mode == "chat_completions"
+ else "Anthropic-style"
+ )
print(f" Current API mode: {_lbl}")
if current_api_key:
print(f" Current API key: {current_api_key[:8]}...")
@@ -3261,12 +3383,16 @@ def _model_flow_azure_foundry(config, current_model=""):
api_mode: str = detection.api_mode or ""
if api_mode:
- mode_label = "OpenAI-style" if api_mode == "chat_completions" else "Anthropic-style"
+ mode_label = (
+ "OpenAI-style" if api_mode == "chat_completions" else "Anthropic-style"
+ )
print(f"✓ Detected API transport: {mode_label}")
if detection.reason:
print(f" ({detection.reason})")
if discovered_models:
- print(f"✓ Found {len(discovered_models)} deployed model(s) on this endpoint")
+ print(
+ f"✓ Found {len(discovered_models)} deployed model(s) on this endpoint"
+ )
else:
print(f"⚠ Auto-detection incomplete: {detection.reason}")
print()
@@ -3277,7 +3403,10 @@ def _model_flow_azure_foundry(config, current_model=""):
print(" For: Claude models deployed via Anthropic API format")
try:
default_choice = "2" if current_api_mode == "anthropic_messages" else "1"
- mode_choice = input(f"API format [1/2] ({default_choice}): ").strip() or default_choice
+ mode_choice = (
+ input(f"API format [1/2] ({default_choice}): ").strip()
+ or default_choice
+ )
except (KeyboardInterrupt, EOFError):
print("\nCancelled.")
return
@@ -3291,7 +3420,9 @@ def _model_flow_azure_foundry(config, current_model=""):
for i, mid in enumerate(discovered_models[:30], start=1):
print(f" {i:>2}. {mid}")
if len(discovered_models) > 30:
- print(f" ... and {len(discovered_models) - 30} more (type name manually if not shown)")
+ print(
+ f" ... and {len(discovered_models) - 30} more (type name manually if not shown)"
+ )
print()
try:
pick = input(
@@ -3322,7 +3453,9 @@ def _model_flow_azure_foundry(config, current_model=""):
# ── Step 5: context-length lookup ────────────────────────────────
ctx_len = azure_detect.lookup_context_length(
- effective_model, effective_url, effective_key,
+ effective_model,
+ effective_url,
+ effective_key,
)
# ── Step 6: persist ──────────────────────────────────────────────
@@ -3578,9 +3711,7 @@ def _model_flow_named_custom(config, provider_info):
original_api_key_ref = str(
provider_info.get("api_key_ref", "") or ""
).strip()
- original_api_key = str(
- provider_info.get("api_key", "") or ""
- ).strip()
+ original_api_key = str(provider_info.get("api_key", "") or "").strip()
had_inline_api_key = bool(original_api_key_ref or original_api_key)
if (
had_inline_api_key
@@ -4082,7 +4213,9 @@ def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple:
if choice.startswith("c"):
save_env_value(key_env, "")
- print(f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again.")
+ print(
+ f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again."
+ )
return "", True
# Keep (default, or any other input)
@@ -4124,7 +4257,9 @@ def _model_flow_kimi(config, current_model=""):
if existing_key:
break
- existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
+ existing_key, abort = _prompt_api_key(
+ pconfig, existing_key, provider_id=provider_id
+ )
if abort:
return
@@ -4213,7 +4348,12 @@ def _model_flow_stepfun(config, current_model=""):
_save_model_choice,
deactivate_provider,
)
- from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
+ from hermes_cli.config import (
+ get_env_value,
+ save_env_value,
+ load_config,
+ save_config,
+ )
from hermes_cli.models import fetch_api_models
provider_id = "stepfun"
@@ -4227,7 +4367,9 @@ def _model_flow_stepfun(config, current_model=""):
if existing_key:
break
- existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
+ existing_key, abort = _prompt_api_key(
+ pconfig, existing_key, provider_id=provider_id
+ )
if abort:
return
@@ -4241,7 +4383,10 @@ def _model_flow_stepfun(config, current_model=""):
current_region = _infer_stepfun_region(current_base or pconfig.inference_base_url)
region_choices = [
- ("international", f"International ({_stepfun_base_url_for_region('international')})"),
+ (
+ "international",
+ f"International ({_stepfun_base_url_for_region('international')})",
+ ),
("china", f"China ({_stepfun_base_url_for_region('china')})"),
]
ordered_regions = []
@@ -4605,7 +4750,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
if existing_key:
break
- existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
+ existing_key, abort = _prompt_api_key(
+ pconfig, existing_key, provider_id=provider_id
+ )
if abort:
return
@@ -4711,7 +4858,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
try:
- model_list = fetch_lmstudio_models(api_key=api_key_for_probe, base_url=effective_base)
+ model_list = fetch_lmstudio_models(
+ api_key=api_key_for_probe, base_url=effective_base
+ )
except AuthError as exc:
print(f" LM Studio rejected the request: {exc}")
print(" Set LM_API_KEY (or update it) to match the server's bearer token.")
@@ -5136,6 +5285,7 @@ def cmd_kanban(args):
def cmd_hooks(args):
"""Shell-hook inspection and management."""
from hermes_cli.hooks import hooks_command
+
hooks_command(args)
@@ -5463,10 +5613,12 @@ def _find_stale_dashboard_pids() -> list[int]:
# UnicodeDecodeError from leaving result.stdout=None and turning
# the later .split() into an AttributeError (#17049).
result = subprocess.run(
- ["wmic", "process", "get", "ProcessId,CommandLine",
- "/FORMAT:LIST"],
- capture_output=True, text=True, timeout=10,
- encoding="utf-8", errors="ignore",
+ ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ encoding="utf-8",
+ errors="ignore",
)
if result.returncode != 0 or result.stdout is None:
return []
@@ -5474,11 +5626,13 @@ def _find_stale_dashboard_pids() -> list[int]:
for line in result.stdout.split("\n"):
line = line.strip()
if line.startswith("CommandLine="):
- current_cmd = line[len("CommandLine="):]
+ current_cmd = line[len("CommandLine=") :]
elif line.startswith("ProcessId="):
- pid_str = line[len("ProcessId="):]
- if (any(p in current_cmd for p in patterns)
- and int(pid_str) != self_pid):
+ pid_str = line[len("ProcessId=") :]
+ if (
+ any(p in current_cmd for p in patterns)
+ and int(pid_str) != self_pid
+ ):
try:
dashboard_pids.append(int(pid_str))
except ValueError:
@@ -5492,7 +5646,9 @@ def _find_stale_dashboard_pids() -> list[int]:
# both words (e.g. a chat session discussing "dashboard").
result = subprocess.run(
["ps", "-A", "-o", "pid=,command="],
- capture_output=True, text=True, timeout=10,
+ capture_output=True,
+ text=True,
+ timeout=10,
)
if result.returncode == 0:
for line in getattr(result, "stdout", "").split("\n"):
@@ -5507,8 +5663,7 @@ def _find_stale_dashboard_pids() -> list[int]:
except ValueError:
continue
command = parts[1]
- if (any(p in command for p in patterns)
- and pid != self_pid):
+ if any(p in command for p in patterns) and pid != self_pid:
dashboard_pids.append(pid)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return []
@@ -5552,7 +5707,9 @@ def _print_curator_first_run_notice() -> None:
)
print(" Preview now: hermes curator run --dry-run")
print(" Pause it: hermes curator pause")
- print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/curator")
+ print(
+ " Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/curator"
+ )
def _kill_stale_dashboard_processes(
@@ -5591,7 +5748,9 @@ def _kill_stale_dashboard_processes(
try:
result = subprocess.run(
["taskkill", "/PID", str(pid), "/F"],
- capture_output=True, text=True, timeout=10,
+ capture_output=True,
+ text=True,
+ timeout=10,
)
if result.returncode == 0:
killed.append(pid)
@@ -5616,8 +5775,9 @@ def _kill_stale_dashboard_processes(
# Poll for exit up to ~3s total.
deadline = _time.monotonic() + 3.0
- pending = [p for p in pids if p not in killed
- and p not in {f[0] for f in failed}]
+ pending = [
+ p for p in pids if p not in killed and p not in {f[0] for f in failed}
+ ]
while pending and _time.monotonic() < deadline:
_time.sleep(0.1)
still_pending = []
@@ -6604,6 +6764,7 @@ def _cmd_update_check():
commits_word = "commit" if behind == 1 else "commits"
print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.")
from hermes_cli.config import recommended_update_command
+
print(f" Run '{recommended_update_command()}' to install.")
@@ -6642,11 +6803,19 @@ def _ensure_fhs_path_guard() -> None:
home = os.environ.get("HOME") or "/root"
try:
probe = subprocess.run(
- ["env", "-i",
- f"HOME={home}",
- f"TERM={os.environ.get('TERM', 'dumb')}",
- "bash", "-i", "-c", "command -v hermes"],
- capture_output=True, text=True, timeout=10,
+ [
+ "env",
+ "-i",
+ f"HOME={home}",
+ f"TERM={os.environ.get('TERM', 'dumb')}",
+ "bash",
+ "-i",
+ "-c",
+ "command -v hermes",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return # no bash or probe hung — don't block update on this
@@ -6655,8 +6824,7 @@ def _ensure_fhs_path_guard() -> None:
path_line = 'export PATH="/usr/local/bin:$PATH"'
path_comment = (
- "# Hermes Agent — ensure /usr/local/bin is on PATH "
- "(RHEL non-login shells)"
+ "# Hermes Agent — ensure /usr/local/bin is on PATH " "(RHEL non-login shells)"
)
wrote_any = False
for candidate in (".bashrc", ".bash_profile"):
@@ -6709,9 +6877,12 @@ def _run_pre_update_backup(args) -> None:
try:
from hermes_cli.config import load_config
+
cfg = load_config()
except Exception as exc:
- logging.getLogger(__name__).debug("Could not load config for pre-update backup: %s", exc)
+ logging.getLogger(__name__).debug(
+ "Could not load config for pre-update backup: %s", exc
+ )
cfg = {}
updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {}
@@ -6727,7 +6898,9 @@ def _run_pre_update_backup(args) -> None:
try:
from hermes_cli.backup import create_pre_update_backup
except Exception as exc:
- print(f"⚠ Pre-update backup: could not load backup module ({exc}); continuing update.")
+ print(
+ f"⚠ Pre-update backup: could not load backup module ({exc}); continuing update."
+ )
print()
return
@@ -6764,6 +6937,7 @@ def _run_pre_update_backup(args) -> None:
# Render path using display_hermes_home so the user sees ~/.hermes/...
try:
from hermes_constants import get_hermes_home, display_hermes_home
+
home = get_hermes_home()
try:
display_path = f"{display_hermes_home()}/{out_path.relative_to(home)}"
@@ -7204,7 +7378,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
print()
if assume_yes:
- print(" ℹ --yes: auto-applying config migration (skipping API-key prompts).")
+ print(
+ " ℹ --yes: auto-applying config migration (skipping API-key prompts)."
+ )
response = "y"
elif gateway_mode:
response = (
@@ -7309,7 +7485,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
import signal as _signal
def _wait_for_service_active(
- scope_cmd_: list, svc_name_: str, timeout: float = 10.0,
+ scope_cmd_: list,
+ svc_name_: str,
+ timeout: float = 10.0,
) -> bool:
"""Poll ``systemctl is-active`` until the unit reports active.
@@ -7323,7 +7501,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
try:
_verify = subprocess.run(
scope_cmd_ + ["is-active", svc_name_],
- capture_output=True, text=True, timeout=5,
+ capture_output=True,
+ text=True,
+ timeout=5,
)
if _verify.stdout.strip() == "active":
return True
@@ -7334,7 +7514,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
_time.sleep(0.5)
def _service_restart_sec(
- scope_cmd_: list, svc_name_: str, default: float = 0.0,
+ scope_cmd_: list,
+ svc_name_: str,
+ default: float = 0.0,
) -> float:
"""Read the unit's ``RestartUSec`` (RestartSec) in seconds.
@@ -7346,11 +7528,16 @@ def _cmd_update_impl(args, gateway_mode: bool):
"""
try:
_show = subprocess.run(
- scope_cmd_ + [
- "show", svc_name_,
- "--property=RestartUSec", "--value",
+ scope_cmd_
+ + [
+ "show",
+ svc_name_,
+ "--property=RestartUSec",
+ "--value",
],
- capture_output=True, text=True, timeout=5,
+ capture_output=True,
+ text=True,
+ timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return default
@@ -7392,12 +7579,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
_cfg_drain = None
try:
from hermes_cli.config import load_config
- _cfg_agent = (load_config().get("agent") or {})
+
+ _cfg_agent = load_config().get("agent") or {}
_cfg_drain = _cfg_agent.get("restart_drain_timeout")
except Exception:
pass
try:
- _drain_budget = float(_cfg_drain) if _cfg_drain is not None else float(_DEFAULT_DRAIN)
+ _drain_budget = (
+ float(_cfg_drain)
+ if _cfg_drain is not None
+ else float(_DEFAULT_DRAIN)
+ )
except (TypeError, ValueError):
_drain_budget = float(_DEFAULT_DRAIN)
# Add a 15s margin so the drain loop + final exit finish before
@@ -7463,14 +7655,23 @@ def _cmd_update_impl(args, gateway_mode: bool):
_main_pid = 0
try:
_show = subprocess.run(
- scope_cmd + [
- "show", svc_name,
- "--property=MainPID", "--value",
+ scope_cmd
+ + [
+ "show",
+ svc_name,
+ "--property=MainPID",
+ "--value",
],
- capture_output=True, text=True, timeout=5,
+ capture_output=True,
+ text=True,
+ timeout=5,
)
_main_pid = int((_show.stdout or "").strip() or 0)
- except (ValueError, subprocess.TimeoutExpired, FileNotFoundError):
+ except (
+ ValueError,
+ subprocess.TimeoutExpired,
+ FileNotFoundError,
+ ):
_main_pid = 0
_graceful_ok = False
@@ -7479,7 +7680,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
f" → {svc_name}: draining (up to {int(_drain_budget)}s)..."
)
_graceful_ok = _graceful_restart_via_sigusr1(
- _main_pid, drain_timeout=_drain_budget,
+ _main_pid,
+ drain_timeout=_drain_budget,
)
if _graceful_ok:
@@ -7492,13 +7694,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
# units without RestartSec set we fall back
# to the original 10s budget.
_restart_sec = _service_restart_sec(
- scope_cmd, svc_name, default=0.0,
+ scope_cmd,
+ svc_name,
+ default=0.0,
)
_post_drain_timeout = max(
- 10.0, _restart_sec + 10.0,
+ 10.0,
+ _restart_sec + 10.0,
)
if _wait_for_service_active(
- scope_cmd, svc_name,
+ scope_cmd,
+ svc_name,
timeout=_post_drain_timeout,
):
restarted_services.append(svc_name)
@@ -7527,7 +7733,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
# restart. systemctl restart returns 0 even
# if the new process crashes immediately.
if _wait_for_service_active(
- scope_cmd, svc_name, timeout=10.0,
+ scope_cmd,
+ svc_name,
+ timeout=10.0,
):
restarted_services.append(svc_name)
else:
@@ -7544,7 +7752,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
timeout=15,
)
if _wait_for_service_active(
- scope_cmd, svc_name, timeout=10.0,
+ scope_cmd,
+ svc_name,
+ timeout=10.0,
):
restarted_services.append(svc_name)
print(f" ✓ {svc_name} recovered on retry")
@@ -7610,7 +7820,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
# the drain budget, fall back to SIGTERM — the watcher
# still sees the exit and relaunches either way.
drained = _graceful_restart_via_sigusr1(
- pid, drain_timeout=_drain_budget,
+ pid,
+ drain_timeout=_drain_budget,
)
if not drained:
try:
@@ -7662,7 +7873,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
_time.sleep(3.0)
_service_pids_after = _get_service_pids()
_surviving = find_gateway_pids(
- exclude_pids=_service_pids_after, all_profiles=True,
+ exclude_pids=_service_pids_after,
+ all_profiles=True,
)
# Scope to PIDs we already tried to kill during this
# update (killed_pids). Anything new is a gateway that
@@ -7921,7 +8133,9 @@ def cmd_profile(args):
if clone_all:
print(f"Full copy from {source_label}.")
else:
- print(f"Cloned config, .env, SOUL.md, and skills from {source_label}.")
+ print(
+ f"Cloned config, .env, SOUL.md, and skills from {source_label}."
+ )
# Auto-clone Honcho config for the new profile (only with --clone/--clone-all)
if clone or clone_all:
@@ -8135,8 +8349,12 @@ def _report_dashboard_status() -> int:
cmdline_path = f"/proc/{pid}/cmdline"
if os.path.exists(cmdline_path):
with open(cmdline_path, "rb") as f:
- cmdline = f.read().replace(b"\x00", b" ").decode(
- "utf-8", errors="replace").strip()
+ cmdline = (
+ f.read()
+ .replace(b"\x00", b" ")
+ .decode("utf-8", errors="replace")
+ .strip()
+ )
except (OSError, ValueError):
pass
if cmdline:
@@ -8508,14 +8726,14 @@ def main():
"--reconfigure",
action="store_true",
help="(Default on existing installs.) Re-run the full wizard, "
- "showing current values as defaults. Kept for backwards "
- "compatibility — a bare 'hermes setup' now does this.",
+ "showing current values as defaults. Kept for backwards "
+ "compatibility — a bare 'hermes setup' now does this.",
)
setup_parser.add_argument(
"--quick",
action="store_true",
help="On existing installs: only prompt for items that are missing "
- "or unset, instead of running the full reconfigure wizard.",
+ "or unset, instead of running the full reconfigure wizard.",
)
setup_parser.set_defaults(func=cmd_setup)
@@ -8541,7 +8759,7 @@ def main():
slack_manifest = slack_sub.add_parser(
"manifest",
help="Print or write a Slack app manifest with every gateway command "
- "registered as a native slash (/btw, /stop, /model, ...)",
+ "registered as a native slash (/btw, /stop, /model, ...)",
description=(
"Generate a Slack app manifest that registers every gateway "
"command in COMMAND_REGISTRY as a first-class Slack slash "
@@ -8557,7 +8775,7 @@ def main():
default=None,
metavar="PATH",
help="Write manifest to a file instead of stdout. With no PATH "
- "writes to $HERMES_HOME/slack-manifest.json.",
+ "writes to $HERMES_HOME/slack-manifest.json.",
)
slack_manifest.add_argument(
"--name",
@@ -8573,7 +8791,7 @@ def main():
"--slashes-only",
action="store_true",
help="Emit only the features.slash_commands array (for merging "
- "into an existing manifest manually).",
+ "into an existing manifest manually).",
)
slack_parser.set_defaults(func=cmd_slack)
@@ -8690,17 +8908,39 @@ def main():
"reset", help="Clear exhaustion status for all credentials for a provider"
)
auth_reset.add_argument("provider", help="Provider id")
- auth_status = auth_subparsers.add_parser("status", help="Show auth status for a provider")
+ auth_status = auth_subparsers.add_parser(
+ "status", help="Show auth status for a provider"
+ )
auth_status.add_argument("provider", help="Provider id")
- auth_logout = auth_subparsers.add_parser("logout", help="Log out a provider and clear stored auth state")
+ auth_logout = auth_subparsers.add_parser(
+ "logout", help="Log out a provider and clear stored auth state"
+ )
auth_logout.add_argument("provider", help="Provider id")
- auth_spotify = auth_subparsers.add_parser("spotify", help="Authenticate Hermes with Spotify via PKCE")
- auth_spotify.add_argument("spotify_action", nargs="?", choices=["login", "status", "logout"], default="login")
- auth_spotify.add_argument("--client-id", help="Spotify app client_id (or set HERMES_SPOTIFY_CLIENT_ID)")
- auth_spotify.add_argument("--redirect-uri", help="Allow-listed localhost redirect URI for your Spotify app")
+ auth_spotify = auth_subparsers.add_parser(
+ "spotify", help="Authenticate Hermes with Spotify via PKCE"
+ )
+ auth_spotify.add_argument(
+ "spotify_action",
+ nargs="?",
+ choices=["login", "status", "logout"],
+ default="login",
+ )
+ auth_spotify.add_argument(
+ "--client-id", help="Spotify app client_id (or set HERMES_SPOTIFY_CLIENT_ID)"
+ )
+ auth_spotify.add_argument(
+ "--redirect-uri",
+ help="Allow-listed localhost redirect URI for your Spotify app",
+ )
auth_spotify.add_argument("--scope", help="Override requested Spotify scopes")
- auth_spotify.add_argument("--no-browser", action="store_true", help="Do not attempt to open the browser automatically")
- auth_spotify.add_argument("--timeout", type=float, help="Callback/token exchange timeout in seconds")
+ auth_spotify.add_argument(
+ "--no-browser",
+ action="store_true",
+ help="Do not attempt to open the browser automatically",
+ )
+ auth_spotify.add_argument(
+ "--timeout", type=float, help="Callback/token exchange timeout in seconds"
+ )
auth_parser.set_defaults(func=cmd_auth)
# =========================================================================
@@ -8938,6 +9178,7 @@ def main():
# kanban command — multi-profile collaboration board
# =========================================================================
from hermes_cli.kanban import build_parser as _build_kanban_parser
+
kanban_parser = _build_kanban_parser(subparsers)
kanban_parser.set_defaults(func=cmd_kanban)
@@ -8956,7 +9197,8 @@ def main():
hooks_subparsers = hooks_parser.add_subparsers(dest="hooks_action")
hooks_subparsers.add_parser(
- "list", aliases=["ls"],
+ "list",
+ aliases=["ls"],
help="List configured hooks with matcher, timeout, and consent status",
)
@@ -8969,14 +9211,18 @@ def main():
help="Hook event name (e.g. pre_tool_call, pre_llm_call, subagent_stop)",
)
_hk_test.add_argument(
- "--for-tool", dest="for_tool", default=None,
+ "--for-tool",
+ dest="for_tool",
+ default=None,
help=(
"Only fire hooks whose matcher matches this tool name "
"(used for pre_tool_call / post_tool_call)"
),
)
_hk_test.add_argument(
- "--payload-file", dest="payload_file", default=None,
+ "--payload-file",
+ dest="payload_file",
+ default=None,
help=(
"Path to a JSON file whose contents are merged into the "
"synthetic payload before execution"
@@ -8984,7 +9230,8 @@ def main():
)
_hk_revoke = hooks_subparsers.add_parser(
- "revoke", aliases=["remove", "rm"],
+ "revoke",
+ aliases=["remove", "rm"],
help="Remove a command's allowlist entries (takes effect on next restart)",
)
_hk_revoke.add_argument(
@@ -9299,7 +9546,7 @@ Examples:
"--enabled-only",
action="store_true",
help="Hide disabled skills. Use with -p to see exactly "
- "which skills will load for that profile.",
+ "which skills will load for that profile.",
)
skills_check = skills_subparsers.add_parser(
@@ -9508,6 +9755,7 @@ Examples:
)
try:
from hermes_cli.curator import register_cli as _register_curator_cli
+
_register_curator_cli(curator_parser)
except Exception as _exc:
logging.getLogger(__name__).debug("curator CLI wiring failed: %s", _exc)
@@ -9940,8 +10188,9 @@ Examples:
print("Cancelled.")
return
sessions_dir = get_hermes_home() / "sessions"
- count = db.prune_sessions(older_than_days=days, source=args.source,
- sessions_dir=sessions_dir)
+ count = db.prune_sessions(
+ older_than_days=days, source=args.source, sessions_dir=sessions_dir
+ )
print(f"Pruned {count} session(s).")
elif action == "rename":
@@ -9978,6 +10227,7 @@ Examples:
# Launch hermes --resume by replacing the current process
print(f"Resuming session: {selected_id}")
from hermes_cli.relaunch import relaunch
+
relaunch(["--resume", selected_id])
return # won't reach here after execvp
@@ -10501,22 +10751,23 @@ Examples:
# the nested subcommand (dest varies by parser).
_AGENT_COMMANDS = {None, "chat", "acp", "rl"}
_AGENT_SUBCOMMANDS = {
- "cron": ("cron_command", {"run", "tick"}),
+ "cron": ("cron_command", {"run", "tick"}),
"gateway": ("gateway_command", {"run"}),
- "mcp": ("mcp_action", {"serve"}),
+ "mcp": ("mcp_action", {"serve"}),
}
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
- if (
- args.command in _AGENT_COMMANDS
- or (_sub_attr and getattr(args, _sub_attr, None) in _sub_set)
+ if args.command in _AGENT_COMMANDS or (
+ _sub_attr and getattr(args, _sub_attr, None) in _sub_set
):
_accept_hooks = bool(getattr(args, "accept_hooks", False))
try:
from hermes_cli.plugins import discover_plugins
+
discover_plugins()
except Exception:
logger.debug(
- "plugin discovery failed at CLI startup", exc_info=True,
+ "plugin discovery failed at CLI startup",
+ exc_info=True,
)
try:
# MCP tool discovery — no event loop running in CLI/TUI startup,
@@ -10524,14 +10775,17 @@ Examples:
# to avoid freezing the gateway's event loop on its first message
# via the same lazy import path (#16856).
from tools.mcp_tool import discover_mcp_tools
+
discover_mcp_tools()
except Exception:
logger.debug(
- "MCP tool discovery failed at CLI startup", exc_info=True,
+ "MCP tool discovery failed at CLI startup",
+ exc_info=True,
)
try:
from hermes_cli.config import load_config
from agent.shell_hooks import register_from_config
+
register_from_config(load_config(), accept_hooks=_accept_hooks)
except Exception:
logger.debug(
@@ -10544,12 +10798,14 @@ Examples:
if getattr(args, "oneshot", None):
from hermes_cli.oneshot import run_oneshot
- sys.exit(run_oneshot(
- args.oneshot,
- model=getattr(args, "model", None),
- provider=getattr(args, "provider", None),
- toolsets=getattr(args, "toolsets", None),
- ))
+ sys.exit(
+ run_oneshot(
+ args.oneshot,
+ model=getattr(args, "model", None),
+ provider=getattr(args, "provider", None),
+ toolsets=getattr(args, "toolsets", None),
+ )
+ )
# Handle top-level --resume / --continue as shortcut to chat
if (args.resume or args.continue_last) and args.command is None:
diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py
index 8086ee87e3..76533a3451 100644
--- a/tests/hermes_cli/test_tui_resume_flow.py
+++ b/tests/hermes_cli/test_tui_resume_flow.py
@@ -36,7 +36,14 @@ def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod):
calls.append(source)
return "20260408_235959_a1b2c3" if source == "tui" else None
- def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
+ def fake_launch(
+ resume_session_id=None,
+ tui_dev=False,
+ model=None,
+ provider=None,
+ toolsets=None,
+ **kwargs,
+ ):
captured["resume"] = resume_session_id
raise SystemExit(0)
@@ -63,7 +70,14 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai
return "20260408_235959_d4e5f6"
return None
- def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
+ def fake_launch(
+ resume_session_id=None,
+ tui_dev=False,
+ model=None,
+ provider=None,
+ toolsets=None,
+ **kwargs,
+ ):
captured["resume"] = resume_session_id
raise SystemExit(0)
@@ -81,7 +95,14 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai
def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod):
captured = {}
- def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
+ def fake_launch(
+ resume_session_id=None,
+ tui_dev=False,
+ model=None,
+ provider=None,
+ toolsets=None,
+ **kwargs,
+ ):
captured["resume"] = resume_session_id
raise SystemExit(0)
@@ -99,7 +120,14 @@ def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod)
def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod):
captured = {}
- def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
+ def fake_launch(
+ resume_session_id=None,
+ tui_dev=False,
+ model=None,
+ provider=None,
+ toolsets=None,
+ **kwargs,
+ ):
captured.update(
{
"model": model,
@@ -130,7 +158,14 @@ def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod):
def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod):
captured = {}
- def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
+ def fake_launch(
+ resume_session_id=None,
+ tui_dev=False,
+ model=None,
+ provider=None,
+ toolsets=None,
+ **kwargs,
+ ):
captured["toolsets"] = toolsets
raise SystemExit(0)
@@ -142,22 +177,74 @@ def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod):
assert captured["toolsets"] == "web,terminal"
+def test_cmd_chat_tui_forwards_chat_flags(monkeypatch, main_mod):
+ captured = {}
+
+ def fake_launch(resume_session_id=None, **kwargs):
+ captured["resume_session_id"] = resume_session_id
+ captured.update(kwargs)
+ raise SystemExit(0)
+
+ monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
+
+ with pytest.raises(SystemExit):
+ main_mod.cmd_chat(
+ _args(
+ skills=["foo,bar"],
+ verbose=True,
+ quiet=True,
+ query="hello",
+ image="/tmp/cat.png",
+ worktree=True,
+ checkpoints=True,
+ pass_session_id=True,
+ max_turns=7,
+ accept_hooks=True,
+ )
+ )
+
+ assert captured["skills"] == ["foo,bar"]
+ assert captured["verbose"] is True
+ assert captured["quiet"] is True
+ assert captured["query"] == "hello"
+ assert captured["image"] == "/tmp/cat.png"
+ assert captured["worktree"] is True
+ assert captured["checkpoints"] is True
+ assert captured["pass_session_id"] is True
+ assert captured["max_turns"] == 7
+ assert captured["accept_hooks"] is True
+
+
def test_main_top_level_tui_accepts_toolsets(monkeypatch, main_mod):
captured = {}
import hermes_cli.config as config_mod
monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "--toolsets", "web,terminal"])
- monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None))
- monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None))
+ monkeypatch.setitem(
+ sys.modules,
+ "hermes_cli.plugins",
+ types.SimpleNamespace(discover_plugins=lambda: None),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "tools.mcp_tool",
+ types.SimpleNamespace(discover_mcp_tools=lambda: None),
+ )
monkeypatch.setattr(config_mod, "load_config", lambda: {})
monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None)
monkeypatch.setitem(
sys.modules,
"agent.shell_hooks",
- types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None),
+ types.SimpleNamespace(
+ register_from_config=lambda _cfg, accept_hooks=False: None
+ ),
+ )
+ monkeypatch.setattr(
+ main_mod,
+ "cmd_chat",
+ lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}),
)
- monkeypatch.setattr(main_mod, "cmd_chat", lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}))
main_mod.main()
@@ -169,27 +256,49 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
import hermes_cli.config as config_mod
- monkeypatch.setattr(sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"])
- monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None))
- monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None))
+ monkeypatch.setattr(
+ sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"]
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "hermes_cli.plugins",
+ types.SimpleNamespace(discover_plugins=lambda: None),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "tools.mcp_tool",
+ types.SimpleNamespace(discover_mcp_tools=lambda: None),
+ )
monkeypatch.setattr(config_mod, "load_config", lambda: {})
monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None)
monkeypatch.setitem(
sys.modules,
"agent.shell_hooks",
- types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None),
+ types.SimpleNamespace(
+ register_from_config=lambda _cfg, accept_hooks=False: None
+ ),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.oneshot",
- types.SimpleNamespace(run_oneshot=lambda prompt, **kwargs: captured.update({"prompt": prompt, **kwargs}) or 0),
+ types.SimpleNamespace(
+ run_oneshot=lambda prompt, **kwargs: captured.update(
+ {"prompt": prompt, **kwargs}
+ )
+ or 0
+ ),
)
with pytest.raises(SystemExit) as exc:
main_mod.main()
assert exc.value.code == 0
- assert captured == {"prompt": "hello", "model": None, "provider": None, "toolsets": "web,terminal"}
+ assert captured == {
+ "prompt": "hello",
+ "model": None,
+ "provider": None,
+ "toolsets": "web,terminal",
+ }
def _stub_plugin_discovery(monkeypatch):
@@ -256,7 +365,9 @@ def test_oneshot_accepts_plugin_toolset_after_discovery(monkeypatch):
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
- types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})),
+ types.SimpleNamespace(
+ discover_plugins=lambda: discovered.update({"ready": True})
+ ),
)
valid, error = _validate_explicit_toolsets("plugin_demo")
@@ -328,7 +439,9 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
monkeypatch.setattr(main_mod.subprocess, "call", fake_call)
with pytest.raises(SystemExit):
- main_mod._launch_tui(model="nous/hermes-test", provider="nous", toolsets="web, terminal")
+ main_mod._launch_tui(
+ model="nous/hermes-test", provider="nous", toolsets="web, terminal"
+ )
env = captured["env"]
assert env["HERMES_MODEL"] == "nous/hermes-test"
diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py
index 03647f55f0..5a25a306ba 100644
--- a/tests/test_tui_gateway_server.py
+++ b/tests/test_tui_gateway_server.py
@@ -70,9 +70,7 @@ def test_dispatch_rejects_non_object_request():
def test_dispatch_rejects_non_object_params():
- resp = server.dispatch(
- {"id": "1", "method": "session.create", "params": []}
- )
+ resp = server.dispatch({"id": "1", "method": "session.create", "params": []})
assert resp == {
"jsonrpc": "2.0",
@@ -133,12 +131,16 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})
status_resp = server.dispatch(
- {"id": "voice-status", "method": "voice.toggle", "params": {"action": "status"}}
+ {
+ "id": "voice-status",
+ "method": "voice.toggle",
+ "params": {"action": "status"},
+ }
)
- assert status_resp["result"]["record_key"] == "ctrl+b", (
- f"voice.record_key fell back to default for voice={bad!r}"
- )
+ assert (
+ status_resp["result"]["record_key"] == "ctrl+b"
+ ), f"voice.record_key fell back to default for voice={bad!r}"
# Round-4 follow-up: the YAML root itself may be a non-dict. A
# hand-edit that collapses config.yaml to a scalar / list would
@@ -148,12 +150,16 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda r=bad_root: r)
status_resp = server.dispatch(
- {"id": "voice-status-root", "method": "voice.toggle", "params": {"action": "status"}}
+ {
+ "id": "voice-status-root",
+ "method": "voice.toggle",
+ "params": {"action": "status"},
+ }
)
- assert status_resp["result"]["record_key"] == "ctrl+b", (
- f"voice.record_key fell back to default for root={bad_root!r}"
- )
+ assert (
+ status_resp["result"]["record_key"] == "ctrl+b"
+ ), f"voice.record_key fell back to default for root={bad_root!r}"
def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
@@ -174,7 +180,9 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
monkeypatch.setitem(
sys.modules,
"hermes_cli.voice",
- types.SimpleNamespace(start_continuous=fake_start_continuous, stop_continuous=lambda: None),
+ types.SimpleNamespace(
+ start_continuous=fake_start_continuous, stop_continuous=lambda: None
+ ),
)
monkeypatch.setenv("HERMES_VOICE", "1")
@@ -183,10 +191,16 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})
resp = server.dispatch(
- {"id": "voice-record", "method": "voice.record", "params": {"action": "start"}}
+ {
+ "id": "voice-record",
+ "method": "voice.record",
+ "params": {"action": "start"},
+ }
)
- assert "result" in resp, f"voice.record raised for voice={bad!r}: {resp.get('error')}"
+ assert (
+ "result" in resp
+ ), f"voice.record raised for voice={bad!r}: {resp.get('error')}"
assert resp["result"]["status"] == "recording"
assert captured["silence_threshold"] == 200
assert captured["silence_duration"] == 3.0
@@ -204,16 +218,20 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda c=bad_bool_cfg: {"voice": c})
resp = server.dispatch(
- {"id": "voice-record-bool", "method": "voice.record", "params": {"action": "start"}}
+ {
+ "id": "voice-record-bool",
+ "method": "voice.record",
+ "params": {"action": "start"},
+ }
)
assert "result" in resp, f"voice.record raised for bool cfg={bad_bool_cfg!r}"
- assert captured["silence_threshold"] == 200, (
- f"bool silence_threshold leaked through for {bad_bool_cfg!r}"
- )
- assert captured["silence_duration"] == 3.0, (
- f"bool silence_duration leaked through for {bad_bool_cfg!r}"
- )
+ assert (
+ captured["silence_threshold"] == 200
+ ), f"bool silence_threshold leaked through for {bad_bool_cfg!r}"
+ assert (
+ captured["silence_duration"] == 3.0
+ ), f"bool silence_duration leaked through for {bad_bool_cfg!r}"
def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch):
@@ -281,7 +299,9 @@ def test_load_enabled_toolsets_accepts_plugin_env_after_discovery(monkeypatch):
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
- types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})),
+ types.SimpleNamespace(
+ discover_plugins=lambda: discovered.update({"ready": True})
+ ),
)
assert server._load_enabled_toolsets() == ["plugin_demo"]
@@ -302,7 +322,9 @@ def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys):
"read_raw_config",
lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
)
- monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}})
+ monkeypatch.setattr(
+ config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
+ )
# Sorted: ["kanban", "memory"]. `kanban` is auto-recovered by
# _get_platform_tools because it's a non-configurable platform toolset
@@ -324,7 +346,9 @@ def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, caps
import hermes_cli.config as config_mod
- monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}})
+ monkeypatch.setattr(
+ config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
+ )
assert server._load_enabled_toolsets() == ["kanban", "memory"]
assert "using configured CLI toolsets" in capsys.readouterr().err
@@ -340,7 +364,9 @@ def test_load_enabled_toolsets_warns_when_config_fallback_fails(monkeypatch, cap
import hermes_cli.config as config_mod
- monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
+ monkeypatch.setattr(
+ config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))
+ )
assert server._load_enabled_toolsets() is None
assert "could not be loaded" in capsys.readouterr().err
@@ -351,7 +377,9 @@ def test_load_enabled_toolsets_honors_builtin_env_if_config_fails(monkeypatch):
import hermes_cli.config as config_mod
- monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
+ monkeypatch.setattr(
+ config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))
+ )
assert server._load_enabled_toolsets() == ["web"]
@@ -362,7 +390,9 @@ def test_load_enabled_toolsets_all_env_means_all(monkeypatch):
assert server._load_enabled_toolsets() is None
-def test_load_enabled_toolsets_all_env_warns_about_ignored_extra_entries(monkeypatch, capsys):
+def test_load_enabled_toolsets_all_env_warns_about_ignored_extra_entries(
+ monkeypatch, capsys
+):
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "all,nope")
assert server._load_enabled_toolsets() is None
@@ -1801,9 +1831,7 @@ def test_session_compress_uses_compress_helper(monkeypatch):
emit.assert_any_call("session.info", "sid", {"model": "x"})
# Final status.update clears the pinned "compressing" indicator so the
# status bar can revert to the neutral state when compaction finishes.
- emit.assert_any_call(
- "status.update", "sid", {"kind": "status", "text": "ready"}
- )
+ emit.assert_any_call("status.update", "sid", {"kind": "status", "text": "ready"})
def test_session_compress_syncs_session_key_after_rotation(monkeypatch):
@@ -2050,6 +2078,120 @@ def test_commands_catalog_includes_tui_mouse_command():
assert "/mouse" in tui_pairs
+def test_commands_catalog_filters_gateway_only_commands_and_keeps_status_visible():
+ resp = server.handle_request(
+ {"id": "1", "method": "commands.catalog", "params": {}}
+ )
+
+ pairs = dict(resp["result"]["pairs"])
+ canon = resp["result"]["canon"]
+
+ assert "/status" in pairs
+ assert canon["/status"] == "/status"
+
+ assert "/topic" not in pairs
+ assert "/approve" not in pairs
+ assert "/deny" not in pairs
+ assert "/sethome" not in pairs
+
+ assert "/topic" not in canon
+ assert "/approve" not in canon
+ assert "/deny" not in canon
+ assert "/set-home" not in canon
+
+
+def test_session_status_reads_live_gateway_agent(monkeypatch):
+ agent = types.SimpleNamespace(
+ model="live-model",
+ provider="live-provider",
+ session_total_tokens=1234,
+ )
+ server._sessions["sid"] = _session(agent=agent, running=True)
+
+ class _DB:
+ def get_session(self, key):
+ assert key == "session-key"
+ return {
+ "title": "Live TUI",
+ "started_at": 1_700_000_000,
+ "updated_at": 1_700_000_060,
+ }
+
+ monkeypatch.setattr(server, "_get_db", lambda: _DB())
+ try:
+ resp = server.handle_request(
+ {"id": "1", "method": "session.status", "params": {"session_id": "sid"}}
+ )
+ finally:
+ server._sessions.pop("sid", None)
+
+ out = resp["result"]["output"]
+ assert "Hermes TUI Status" in out
+ assert "Session ID: session-key" in out
+ assert "Title: Live TUI" in out
+ assert "Model: live-model (live-provider)" in out
+ assert "Tokens: 1,234" in out
+ assert "Agent Running: Yes" in out
+
+
+def test_skills_reload_runs_in_gateway_process(monkeypatch):
+ import agent.skill_commands as skill_commands
+
+ called = {}
+ monkeypatch.setattr(
+ skill_commands,
+ "reload_skills",
+ lambda: called.setdefault(
+ "result",
+ {
+ "added": [{"name": "new-skill", "description": "demo"}],
+ "removed": [],
+ "total": 42,
+ },
+ ),
+ )
+
+ resp = server.handle_request({"id": "1", "method": "skills.reload", "params": {}})
+
+ assert called["result"]["total"] == 42
+ assert "new-skill" in resp["result"]["output"]
+ assert "42 skill(s) available" in resp["result"]["output"]
+
+
+def test_snapshot_restore_is_blocked_from_tui_worker():
+ server._sessions["sid"] = _session()
+ try:
+ worker_resp = server.handle_request(
+ {
+ "id": "1",
+ "method": "slash.exec",
+ "params": {"command": "snapshot restore latest", "session_id": "sid"},
+ }
+ )
+ dispatch_resp = server.handle_request(
+ {
+ "id": "2",
+ "method": "command.dispatch",
+ "params": {
+ "arg": "restore latest",
+ "name": "snapshot",
+ "session_id": "sid",
+ },
+ }
+ )
+ finally:
+ server._sessions.pop("sid", None)
+
+ assert worker_resp["error"]["code"] == 4018
+ assert (
+ "snapshot restore mutates live config/state" in worker_resp["error"]["message"]
+ )
+ assert dispatch_resp["result"]["type"] == "exec"
+ assert (
+ "/snapshot restore is blocked in the TUI" in dispatch_resp["result"]["output"]
+ )
+
+
def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
monkeypatch.setattr(
server,
@@ -4161,9 +4303,7 @@ def test_reload_env_rpc_calls_hermes_cli_reload_env(monkeypatch):
fake = types.SimpleNamespace(reload_env=_fake_reload)
with patch.dict(sys.modules, {"hermes_cli.config": fake}):
- resp = server.handle_request(
- {"id": "1", "method": "reload.env", "params": {}}
- )
+ resp = server.handle_request({"id": "1", "method": "reload.env", "params": {}})
assert resp["result"] == {"updated": 7}
assert calls["n"] == 1
@@ -4175,9 +4315,7 @@ def test_reload_env_rpc_surfaces_errors(monkeypatch):
fake = types.SimpleNamespace(reload_env=_broken)
with patch.dict(sys.modules, {"hermes_cli.config": fake}):
- resp = server.handle_request(
- {"id": "1", "method": "reload.env", "params": {}}
- )
+ resp = server.handle_request({"id": "1", "method": "reload.env", "params": {}})
assert "error" in resp
assert "env path locked" in resp["error"]["message"]
@@ -4188,7 +4326,9 @@ def test_reload_env_rpc_surfaces_errors(monkeypatch):
def _setup_make_agent_mocks(monkeypatch, cfg):
monkeypatch.setattr(server, "_load_cfg", lambda: cfg)
- monkeypatch.setattr(server, "_resolve_startup_runtime", lambda: ("test-model", None))
+ monkeypatch.setattr(
+ server, "_resolve_startup_runtime", lambda: ("test-model", None)
+ )
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda requested=None, target_model=None: {
@@ -4219,7 +4359,9 @@ def test_make_agent_reads_nested_max_turns(monkeypatch):
def test_make_agent_nested_max_turns_takes_priority(monkeypatch):
- _setup_make_agent_mocks(monkeypatch, {"agent": {"max_turns": 500}, "max_turns": 100})
+ _setup_make_agent_mocks(
+ monkeypatch, {"agent": {"max_turns": 500}, "max_turns": 100}
+ )
with patch("run_agent.AIAgent") as mock_agent:
server._make_agent("sid1", "key1")
@@ -4309,6 +4451,8 @@ def test_config_show_displays_nested_max_turns(monkeypatch):
resp = server.handle_request({"id": "1", "method": "config.show", "params": {}})
sections = resp["result"]["sections"]
- agent_rows = next(section["rows"] for section in sections if section["title"] == "Agent")
+ agent_rows = next(
+ section["rows"] for section in sections if section["title"] == "Agent"
+ )
assert ["Max Turns", "120"] in agent_rows
diff --git a/tests/tui_gateway/test_make_agent_provider.py b/tests/tui_gateway/test_make_agent_provider.py
index 44d7ff7902..896f68a382 100644
--- a/tests/tui_gateway/test_make_agent_provider.py
+++ b/tests/tui_gateway/test_make_agent_provider.py
@@ -5,6 +5,7 @@ Without resolve_runtime_provider(), bare-slug models in config
provider/base_url/api_key empty in AIAgent, causing HTTP 404.
"""
+import os
from unittest.mock import MagicMock, patch
@@ -97,6 +98,48 @@ def test_make_agent_ignores_display_personality_without_system_prompt():
assert mock_agent.call_args.kwargs["ephemeral_system_prompt"] is None
+def test_make_agent_honors_tui_launch_env_flags():
+ fake_runtime = {
+ "provider": "openrouter",
+ "base_url": "https://api.synthetic.new/v1",
+ "api_key": "sk-test",
+ "api_mode": "chat_completions",
+ "command": None,
+ "args": None,
+ "credential_pool": None,
+ }
+ fake_cfg = {"agent": {"system_prompt": ""}, "model": {"default": "glm-5"}}
+
+ with (
+ patch.dict(
+ os.environ,
+ {
+ "HERMES_TUI_MAX_TURNS": "7",
+ "HERMES_TUI_CHECKPOINTS": "1",
+ "HERMES_TUI_PASS_SESSION_ID": "1",
+ "HERMES_IGNORE_RULES": "1",
+ },
+ ),
+ patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
+ patch("tui_gateway.server._get_db", return_value=MagicMock()),
+ patch(
+ "hermes_cli.runtime_provider.resolve_runtime_provider",
+ return_value=fake_runtime,
+ ),
+ patch("run_agent.AIAgent") as mock_agent,
+ ):
+ from tui_gateway.server import _make_agent
+
+ _make_agent("sid-env", "key-env")
+
+ kwargs = mock_agent.call_args.kwargs
+ assert kwargs["max_iterations"] == 7
+ assert kwargs["checkpoints_enabled"] is True
+ assert kwargs["pass_session_id"] is True
+ assert kwargs["skip_context_files"] is True
+ assert kwargs["skip_memory"] is True
+
+
def test_probe_config_health_flags_null_sections():
"""Bare YAML keys (`agent:` with no value) parse as None and silently
drop nested settings; probe must surface them so users can fix."""
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 68b03f091a..1e1bb2af34 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -157,7 +157,9 @@ _LONG_HANDLERS = frozenset(
)
try:
- _rpc_pool_workers = max(2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "4"))
+ _rpc_pool_workers = max(
+ 2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "4")
+ )
except (ValueError, TypeError):
_rpc_pool_workers = 4
_pool = concurrent.futures.ThreadPoolExecutor(
@@ -567,7 +569,10 @@ def _start_agent_build(sid: str, session: dict) -> None:
register_gateway_notify,
load_permanent_allowlist,
)
- register_gateway_notify(key, lambda data: _emit("approval.request", sid, data))
+
+ register_gateway_notify(
+ key, lambda data: _emit("approval.request", sid, data)
+ )
notify_registered = True
load_permanent_allowlist()
except Exception:
@@ -598,6 +603,7 @@ def _start_agent_build(sid: str, session: dict) -> None:
if notify_registered:
try:
from tools.approval import unregister_gateway_notify
+
unregister_gateway_notify(key)
except Exception:
pass
@@ -877,6 +883,9 @@ def _load_show_reasoning() -> bool:
def _load_tool_progress_mode() -> str:
+ env = os.environ.get("HERMES_TUI_TOOL_PROGRESS", "").strip().lower()
+ if env in {"off", "new", "all", "verbose"}:
+ return env
raw = (_load_cfg().get("display") or {}).get("tool_progress", "all")
if raw is False:
return "off"
@@ -938,7 +947,11 @@ def _load_enabled_toolsets() -> list[str] | None:
from hermes_cli.tools_config import _parse_enabled_flag
raw_cfg = read_raw_config()
- mcp_servers = raw_cfg.get("mcp_servers") if isinstance(raw_cfg.get("mcp_servers"), dict) else {}
+ mcp_servers = (
+ raw_cfg.get("mcp_servers")
+ if isinstance(raw_cfg.get("mcp_servers"), dict)
+ else {}
+ )
for name, server_cfg in mcp_servers.items():
if not isinstance(server_cfg, dict):
continue
@@ -952,7 +965,11 @@ def _load_enabled_toolsets() -> list[str] | None:
mcp_valid = [name for name in unresolved if name in mcp_names]
disabled = [name for name in unresolved if name in mcp_disabled]
- unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled]
+ unknown = [
+ name
+ for name in unresolved
+ if name not in mcp_names and name not in mcp_disabled
+ ]
valid = built_in + mcp_valid
if unknown:
@@ -973,7 +990,9 @@ def _load_enabled_toolsets() -> list[str] | None:
if valid:
return valid
- fallback_notice = "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets"
+ fallback_notice = (
+ "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets"
+ )
try:
from hermes_cli.config import load_config
@@ -1715,10 +1734,28 @@ def _apply_personality_to_session(
def _cfg_max_turns(cfg: dict, default: int) -> int:
+ try:
+ env_max = int(os.environ.get("HERMES_TUI_MAX_TURNS", "") or 0)
+ if env_max > 0:
+ return env_max
+ except (TypeError, ValueError):
+ pass
agent_cfg = cfg.get("agent") or {}
return int(agent_cfg.get("max_turns") or cfg.get("max_turns") or default)
+def _parse_tui_skills_env() -> list[str]:
+ raw = os.environ.get("HERMES_TUI_SKILLS", "")
+ skills: list[str] = []
+ seen: set[str] = set()
+ for part in raw.replace("\n", ",").split(","):
+ item = part.strip()
+ if item and item not in seen:
+ seen.add(item)
+ skills.append(item)
+ return skills
+
+
def _background_agent_kwargs(agent, task_id: str) -> dict:
cfg = _load_cfg()
@@ -1788,6 +1825,20 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
cfg = _load_cfg()
agent_cfg = cfg.get("agent") or {}
system_prompt = (agent_cfg.get("system_prompt", "") or "").strip()
+ startup_skills = _parse_tui_skills_env()
+ if startup_skills:
+ from agent.skill_commands import build_preloaded_skills_prompt
+
+ skills_prompt, _loaded_skills, missing_skills = build_preloaded_skills_prompt(
+ startup_skills,
+ task_id=session_id or key,
+ )
+ if missing_skills:
+ raise ValueError(f"Unknown skill(s): {', '.join(missing_skills)}")
+ if skills_prompt:
+ system_prompt = "\n\n".join(
+ part for part in (system_prompt, skills_prompt) if part
+ ).strip()
model, requested_provider = _resolve_startup_runtime()
runtime = resolve_runtime_provider(
requested=requested_provider,
@@ -1812,6 +1863,10 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
session_id=session_id or key,
session_db=_get_db(),
ephemeral_system_prompt=system_prompt or None,
+ checkpoints_enabled=is_truthy_value(os.environ.get("HERMES_TUI_CHECKPOINTS")),
+ pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")),
+ skip_context_files=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
+ skip_memory=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
**_agent_cbs(sid),
)
@@ -1856,10 +1911,8 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
# prompt_toolkit; the TUI has no equivalent print surface, so without
# this callback the review would write the skill/memory change silently.
try:
- agent.background_review_callback = (
- lambda message, _sid=sid: _emit(
- "review.summary", _sid, {"text": str(message)}
- )
+ agent.background_review_callback = lambda message, _sid=sid: _emit(
+ "review.summary", _sid, {"text": str(message)}
)
except Exception:
# Bare AIAgents that don't expose the attribute (unlikely, but keep
@@ -2269,7 +2322,71 @@ def _(rid, params: dict) -> dict:
if err:
return err
agent = session.get("agent")
- return _ok(rid, _get_usage(agent) if agent is not None else {"calls": 0, "input": 0, "output": 0, "total": 0})
+ return _ok(
+ rid,
+ (
+ _get_usage(agent)
+ if agent is not None
+ else {"calls": 0, "input": 0, "output": 0, "total": 0}
+ ),
+ )
+
+
+@method("session.status")
+def _(rid, params: dict) -> dict:
+ session, err = _sess_nowait(params, rid)
+ if err:
+ return err
+
+ from hermes_constants import display_hermes_home
+
+ key = session.get("session_key") or params.get("session_id") or ""
+ agent = session.get("agent")
+ meta = {}
+ db = _get_db()
+ if db and key:
+ try:
+ meta = db.get_session(key) or {}
+ except Exception:
+ meta = {}
+
+ def _dt(value, fallback: datetime | None = None) -> datetime:
+ if value:
+ try:
+ return datetime.fromtimestamp(float(value))
+ except Exception:
+ pass
+ return fallback or datetime.now()
+
+ created = _dt(meta.get("started_at"))
+ updated = created
+ for field in ("updated_at", "last_updated_at", "last_activity_at"):
+ if meta.get(field):
+ updated = _dt(meta.get(field), created)
+ break
+
+ usage = _get_usage(agent) if agent is not None else {}
+ provider = getattr(agent, "provider", None) or "unknown"
+ model = getattr(agent, "model", None) or "(unknown)"
+ lines = [
+ "Hermes TUI Status",
+ "",
+ f"Session ID: {key}",
+ f"Path: {display_hermes_home()}",
+ ]
+ title = (meta.get("title") or "").strip()
+ if title:
+ lines.append(f"Title: {title}")
+ lines.extend(
+ [
+ f"Model: {model} ({provider})",
+ f"Created: {created.strftime('%Y-%m-%d %H:%M')}",
+ f"Last Activity: {updated.strftime('%Y-%m-%d %H:%M')}",
+ f"Tokens: {int(usage.get('total') or 0):,}",
+ f"Agent Running: {'Yes' if session.get('running') else 'No'}",
+ ]
+ )
+ return _ok(rid, {"output": "\n".join(lines)})
@method("session.history")
@@ -2375,7 +2492,9 @@ def _(rid, params: dict) -> dict:
after_count = len(messages)
# Re-read system prompt + tools after compression — _compress_context
# may have rebuilt the system prompt (_cached_system_prompt=None).
- _sys_prompt_after = getattr(_agent, "_cached_system_prompt", "") or _sys_prompt
+ _sys_prompt_after = (
+ getattr(_agent, "_cached_system_prompt", "") or _sys_prompt
+ )
_tools_after = getattr(_agent, "tools", None) or _tools
after_tokens = (
estimate_request_tokens_rough(
@@ -2823,7 +2942,15 @@ def _(rid, params: dict) -> dict:
def run_after_agent_ready() -> None:
err = _wait_agent(session, rid)
if err:
- _emit("error", sid, {"message": err.get("error", {}).get("message", "agent initialization failed")})
+ _emit(
+ "error",
+ sid,
+ {
+ "message": err.get("error", {}).get(
+ "message", "agent initialization failed"
+ )
+ },
+ )
with session["history_lock"]:
session["running"] = False
return
@@ -2867,7 +2994,9 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
provider=getattr(agent, "provider", "") or "",
- config_context_length=getattr(agent, "_config_context_length", None),
+ config_context_length=getattr(
+ agent, "_config_context_length", None
+ ),
)
ctx = preprocess_context_references(
prompt,
@@ -3024,18 +3153,14 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
# ("✓ Goal achieved" / "⏸ budget exhausted") is surfaced as
# a system line so the user sees progress regardless of
# outcome. Mirrors gateway/run._post_turn_goal_continuation.
- if (
- status == "complete"
- and isinstance(raw, str)
- and raw.strip()
- ):
+ if status == "complete" and isinstance(raw, str) and raw.strip():
try:
from hermes_cli.goals import GoalManager
sid_key = session.get("session_key") or ""
if sid_key:
try:
- goals_cfg = (_load_cfg().get("goals") or {})
+ goals_cfg = _load_cfg().get("goals") or {}
goal_max_turns = int(goals_cfg.get("max_turns", 20) or 20)
except Exception:
goal_max_turns = 20
@@ -3045,7 +3170,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
)
if goal_mgr.is_active():
decision = goal_mgr.evaluate_after_turn(
- raw, user_initiated=True,
+ raw,
+ user_initiated=True,
)
verdict_msg = decision.get("message") or ""
if verdict_msg:
@@ -3578,7 +3704,9 @@ def _(rid, params: dict) -> dict:
arg = str(value or "").strip().lower()
if arg in ("show", "on"):
cfg = _load_cfg()
- display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
+ display = (
+ cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
+ )
sections = (
display.get("sections")
if isinstance(display.get("sections"), dict)
@@ -3594,7 +3722,9 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"key": key, "value": "show"})
if arg in ("hide", "off"):
cfg = _load_cfg()
- display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
+ display = (
+ cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
+ )
sections = (
display.get("sections")
if isinstance(display.get("sections"), dict)
@@ -3625,7 +3755,9 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4002, f"unknown details_mode: {value}")
cfg = _load_cfg()
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
- sections = display.get("sections") if isinstance(display.get("sections"), dict) else {}
+ sections = (
+ display.get("sections") if isinstance(display.get("sections"), dict) else {}
+ )
display["details_mode"] = nv
for section in _DETAIL_SECTION_NAMES:
sections[section] = nv
@@ -3952,6 +4084,7 @@ def _(rid, params: dict) -> dict:
if not user_confirm:
try:
from hermes_cli.config import load_config as _load_config
+
_cfg = _load_config()
_approvals = _cfg.get("approvals") if isinstance(_cfg, dict) else None
_confirm_required = True
@@ -3965,15 +4098,18 @@ def _(rid, params: dict) -> dict:
# Ink's ops.ts reads ``status`` and prints ``message`` to
# the transcript; a follow-up invocation with confirm=true
# (or an `always` choice that flips the config) proceeds.
- return _ok(rid, {
- "status": "confirm_required",
- "message": (
- "⚠️ /reload-mcp invalidates the prompt cache (next "
- "message re-sends full input tokens). Reply `/reload-mcp "
- "now` to proceed, or `/reload-mcp always` to proceed and "
- "silence this prompt permanently."
- ),
- })
+ return _ok(
+ rid,
+ {
+ "status": "confirm_required",
+ "message": (
+ "⚠️ /reload-mcp invalidates the prompt cache (next "
+ "message re-sends full input tokens). Reply `/reload-mcp "
+ "now` to proceed, or `/reload-mcp always` to proceed and "
+ "silence this prompt permanently."
+ ),
+ },
+ )
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools
@@ -3989,6 +4125,7 @@ def _(rid, params: dict) -> dict:
if bool(params.get("always", False)):
try:
from cli import save_config_value as _save_cfg
+
_save_cfg("approvals.mcp_reload_confirm", False)
except Exception as _exc:
logger.warning("Failed to persist mcp_reload_confirm=false: %s", _exc)
@@ -4025,7 +4162,6 @@ _TUI_HIDDEN: frozenset[str] = frozenset(
"set-home",
"update",
"commands",
- "status",
"approve",
"deny",
}
@@ -4051,6 +4187,8 @@ _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset(
}
)
+_WORKER_BLOCKED_COMMANDS: frozenset[str] = frozenset({"snapshot", "snap"})
+
@method("commands.catalog")
def _(rid, params: dict) -> dict:
@@ -4069,14 +4207,14 @@ def _(rid, params: dict) -> dict:
cat_order: list[str] = []
for cmd in COMMAND_REGISTRY:
+ if cmd.name in _TUI_HIDDEN or cmd.gateway_only:
+ continue
+
c = f"/{cmd.name}"
canon[c.lower()] = c
for a in cmd.aliases:
canon[f"/{a}".lower()] = c
- if cmd.name in _TUI_HIDDEN:
- continue
-
desc = _build_description(cmd)
all_pairs.append([c, desc])
@@ -4373,7 +4511,7 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4001, "no session key")
try:
- goals_cfg = (_load_cfg().get("goals") or {})
+ goals_cfg = _load_cfg().get("goals") or {}
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
except Exception:
max_turns = 20
@@ -4431,6 +4569,21 @@ def _(rid, params: dict) -> dict:
{"type": "send", "notice": notice, "message": state.goal},
)
+ if name in ("snapshot", "snap"):
+ subcommand = arg.split(maxsplit=1)[0].lower() if arg else ""
+ if subcommand in {"restore", "rewind"}:
+ return _ok(
+ rid,
+ {
+ "type": "exec",
+ "output": (
+ "/snapshot restore is blocked in the TUI because it changes "
+ "config/state on disk while the live agent has cached settings. "
+ "Run it in the classic CLI, then restart the TUI."
+ ),
+ },
+ )
+
return _err(rid, 4018, f"not a quick/plugin/skill command: {name}")
@@ -4967,6 +5120,7 @@ def _(rid, params: dict) -> dict:
# Build final list in CANONICAL_PROVIDERS order, merging auth data
from hermes_cli.auth import PROVIDER_REGISTRY as _auth_reg
+
ordered: list = []
for entry in CANONICAL_PROVIDERS:
if entry.slug in authed_map:
@@ -4974,24 +5128,30 @@ def _(rid, params: dict) -> dict:
else:
pconfig = _auth_reg.get(entry.slug)
auth_type = pconfig.auth_type if pconfig else "api_key"
- key_env = pconfig.api_key_env_vars[0] if (pconfig and pconfig.api_key_env_vars) else ""
+ key_env = (
+ pconfig.api_key_env_vars[0]
+ if (pconfig and pconfig.api_key_env_vars)
+ else ""
+ )
if auth_type == "api_key" and key_env:
warning = f"paste {key_env} to activate"
else:
warning = f"run `hermes model` to configure ({auth_type})"
- ordered.append({
- "slug": entry.slug,
- "name": _PROVIDER_LABELS.get(entry.slug, entry.label),
- "is_current": entry.slug == current_provider,
- "is_user_defined": False,
- "models": [],
- "total_models": 0,
- "source": "built-in",
- "authenticated": False,
- "auth_type": auth_type,
- "key_env": key_env,
- "warning": warning,
- })
+ ordered.append(
+ {
+ "slug": entry.slug,
+ "name": _PROVIDER_LABELS.get(entry.slug, entry.label),
+ "is_current": entry.slug == current_provider,
+ "is_user_defined": False,
+ "models": [],
+ "total_models": 0,
+ "source": "built-in",
+ "authenticated": False,
+ "auth_type": auth_type,
+ "key_env": key_env,
+ "warning": warning,
+ }
+ )
# Append user-defined/custom providers not in canonical list
ordered.extend(authed_extra)
@@ -5037,9 +5197,10 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4002, f"unknown provider: {slug}")
if pconfig.auth_type != "api_key":
return _err(
- rid, 4003,
+ rid,
+ 4003,
f"{pconfig.name} uses {pconfig.auth_type} auth — "
- f"run `hermes model` to configure"
+ f"run `hermes model` to configure",
)
if not pconfig.api_key_env_vars:
return _err(rid, 4004, f"no env var defined for {pconfig.name}")
@@ -5049,6 +5210,7 @@ def _(rid, params: dict) -> dict:
save_env_value(env_var, api_key)
# Also set in current process so list_authenticated_providers sees it
import os
+
os.environ[env_var] = api_key
# Refresh provider data
@@ -5132,11 +5294,14 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4005, f"no credentials found for {slug}")
provider_name = pconfig.name if pconfig else slug
- return _ok(rid, {
- "slug": slug,
- "name": provider_name,
- "disconnected": True,
- })
+ return _ok(
+ rid,
+ {
+ "slug": slug,
+ "name": provider_name,
+ "disconnected": True,
+ },
+ )
except Exception as e:
return _err(rid, 5035, str(e))
@@ -5222,6 +5387,15 @@ def _(rid, params: dict) -> dict:
rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}"
)
+ if _cmd_base in _WORKER_BLOCKED_COMMANDS:
+ subcommand = _cmd_arg.split(maxsplit=1)[0].lower() if _cmd_arg else ""
+ if subcommand in {"restore", "rewind"}:
+ return _err(
+ rid,
+ 4018,
+ "snapshot restore mutates live config/state; use command.dispatch for /snapshot restore",
+ )
+
try:
from agent.skill_commands import get_skill_commands
@@ -5471,8 +5645,17 @@ def _(rid, params: dict) -> dict:
voice_cfg = _voice_cfg_dict()
threshold = voice_cfg.get("silence_threshold")
duration = voice_cfg.get("silence_duration")
- safe_threshold = threshold if isinstance(threshold, (int, float)) and not isinstance(threshold, bool) else 200
- safe_duration = duration if isinstance(duration, (int, float)) and not isinstance(duration, bool) else 3.0
+ safe_threshold = (
+ threshold
+ if isinstance(threshold, (int, float))
+ and not isinstance(threshold, bool)
+ else 200
+ )
+ safe_duration = (
+ duration
+ if isinstance(duration, (int, float)) and not isinstance(duration, bool)
+ else 3.0
+ )
start_continuous(
on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}),
on_status=lambda s: _voice_emit("voice.status", {"state": s}),
@@ -5772,7 +5955,9 @@ def _browser_connect(rid, params: dict) -> dict:
raw_url = params.get("url")
if raw_url is not None and not isinstance(raw_url, str):
- return _err(rid, 4015, f"browser url must be a string, got {type(raw_url).__name__}")
+ return _err(
+ rid, 4015, f"browser url must be a string, got {type(raw_url).__name__}"
+ )
url = (raw_url or "").strip() or DEFAULT_BROWSER_CDP_URL
sid = params.get("session_id") or ""
@@ -6225,6 +6410,31 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5024, str(e))
+@method("skills.reload")
+def _(rid, params: dict) -> dict:
+ try:
+ from agent.skill_commands import reload_skills
+
+ result = reload_skills()
+ added = result.get("added") or []
+ removed = result.get("removed") or []
+ total = int(result.get("total") or 0)
+
+ lines = ["Reloading skills..."]
+ if not added and not removed:
+ lines.append("No new skills detected.")
+ if added:
+ lines.append("Added skills:")
+ lines.extend(f" - {item.get('name', '')}" for item in added)
+ if removed:
+ lines.append("Removed skills:")
+ lines.extend(f" - {item.get('name', '')}" for item in removed)
+ lines.append(f"{total} skill(s) available")
+ return _ok(rid, {"output": "\n".join(lines), "result": result})
+ except Exception as e:
+ return _err(rid, 5025, str(e))
+
+
# ── Methods: shell ───────────────────────────────────────────────────
diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts
index 53ca44a8fe..64aa83274a 100644
--- a/ui-tui/src/__tests__/createSlashHandler.test.ts
+++ b/ui-tui/src/__tests__/createSlashHandler.test.ts
@@ -18,6 +18,27 @@ describe('createSlashHandler', () => {
expect(getOverlayState().picker).toBe(true)
})
+ it('handles /redraw locally without slash worker fallback', () => {
+ const ctx = buildCtx()
+
+ expect(createSlashHandler(ctx)('/redraw')).toBe(true)
+ expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
+ expect(ctx.transcript.sys).toHaveBeenCalledWith('ui redrawn')
+ })
+
+ it('routes /status to live session.status instead of slash worker', async () => {
+ patchUiState({ sid: 'sid-abc' })
+ const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' }))
+ const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
+
+ expect(createSlashHandler(ctx)('/status')).toBe(true)
+ expect(rpc).toHaveBeenCalledWith('session.status', { session_id: 'sid-abc' })
+ expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
+ await vi.waitFor(() => {
+ expect(ctx.transcript.page).toHaveBeenCalledWith('Hermes TUI Status', 'Status')
+ })
+ })
+
it('keeps typed /model switches session-scoped by default', async () => {
patchUiState({ sid: 'sid-abc' })
@@ -157,12 +178,49 @@ describe('createSlashHandler', () => {
})
})
- it('shows usage for an unknown /skills subcommand', () => {
+ it('delegates non-native /skills subcommands to slash.exec', () => {
const ctx = buildCtx()
- createSlashHandler(ctx)('/skills zzz')
+ createSlashHandler(ctx)('/skills check')
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
- expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills'))
+ expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
+ command: 'skills check',
+ session_id: null
+ })
+ })
+
+ it('passes /new through to the session lifecycle', () => {
+ const ctx = buildCtx()
+
+ createSlashHandler(ctx)('/new sprint planning')
+ getOverlayState().confirm?.onConfirm()
+
+ expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', 'sprint planning')
+ expect(ctx.gateway.rpc).not.toHaveBeenCalled()
+ })
+
+ it('reloads skills in the live gateway and refreshes the catalog', async () => {
+ const rpc = vi.fn((method: string) => {
+ if (method === 'skills.reload') {
+ return Promise.resolve({ output: '42 skill(s) available' })
+ }
+ if (method === 'commands.catalog') {
+ return Promise.resolve({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
+ }
+ return Promise.resolve({})
+ })
+ const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
+
+ createSlashHandler(ctx)('/reload-skills')
+
+ expect(rpc).toHaveBeenCalledWith('skills.reload', {})
+ await vi.waitFor(() => {
+ expect(ctx.transcript.page).toHaveBeenCalledWith('42 skill(s) available', 'Reload Skills')
+ expect(ctx.local.setCatalog).toHaveBeenCalledWith(
+ expect.objectContaining({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
+ )
+ })
+ expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
// Regressions from Copilot review on #19835: /voice output + frontend
@@ -192,9 +250,7 @@ describe('createSlashHandler', () => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice mode enabled')
expect(ctx.transcript.sys).toHaveBeenCalledWith(' Alt+R to start/stop recording')
})
- expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(
- expect.objectContaining({ ch: 'r', mod: 'alt' })
- )
+ expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'r', mod: 'alt' }))
})
it('/voice falls back to Ctrl+B when the gateway response omits record_key', async () => {
@@ -447,17 +503,17 @@ describe('createSlashHandler', () => {
local: {
catalog: {
canon: {
- '/status': '/status',
- '/statusbar': '/statusbar'
+ '/profile': '/profile',
+ '/plugins': '/plugins'
}
}
}
})
- expect(createSlashHandler(ctx)('/status')).toBe(true)
+ expect(createSlashHandler(ctx)('/profile')).toBe(true)
await vi.waitFor(() => {
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
- command: 'status',
+ command: 'profile',
session_id: null
})
})
@@ -675,7 +731,8 @@ const buildLocal = () => ({
catalog: null,
getHistoryItems: vi.fn(() => []),
getLastUserMsg: vi.fn(() => ''),
- maybeWarn: vi.fn()
+ maybeWarn: vi.fn(),
+ setCatalog: vi.fn()
})
const buildSession = () => ({
diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts
index 270024a8ef..555a35e8af 100644
--- a/ui-tui/src/app/createGatewayEventHandler.ts
+++ b/ui-tui/src/app/createGatewayEventHandler.ts
@@ -1,5 +1,6 @@
+import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js'
import { STREAM_BATCH_MS } from '../config/timing.js'
-import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
+import { SETUP_REQUIRED_TITLE, buildSetupRequiredSections } from '../content/setup.js'
import type {
CommandsCatalogResponse,
ConfigFullResponse,
@@ -64,6 +65,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
let pendingThinkingStatus = ''
let thinkingStatusTimer: null | ReturnType = null
+ let startupPromptSubmitted = false
// Inject the disk-save callback into turnController so recordMessageComplete
// can fire-and-forget a persist without having to plumb a gateway ref around.
@@ -146,6 +148,36 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}, ms)
}
+ const scheduleStartupPrompt = () => {
+ if (startupPromptSubmitted || (!STARTUP_QUERY && !STARTUP_IMAGE)) {
+ return
+ }
+
+ startupPromptSubmitted = true
+ setTimeout(async () => {
+ let sid = getUiState().sid
+
+ for (let i = 0; !sid && i < 40; i += 1) {
+ await new Promise(resolve => setTimeout(resolve, 100))
+ sid = getUiState().sid
+ }
+
+ if (!sid) {
+ return sys('startup query skipped: no active session')
+ }
+
+ if (STARTUP_IMAGE) {
+ try {
+ await rpc('image.attach', { path: STARTUP_IMAGE, session_id: sid })
+ } catch (e) {
+ sys(`startup image attach failed: ${rpcErrorMessage(e)}`)
+ }
+ }
+
+ submitRef.current(STARTUP_QUERY || 'What do you see in this image?')
+ }, 0)
+ }
+
// Terminal statuses are never overwritten by late-arriving live events —
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
// `failed` or `interrupted` terminal state (Copilot review #14045).
@@ -181,6 +213,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (STARTUP_RESUME_ID) {
patchUiState({ status: 'resuming…' })
resumeById(STARTUP_RESUME_ID)
+ scheduleStartupPrompt()
return
}
@@ -196,6 +229,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (!cfg?.config?.display?.tui_auto_resume_recent) {
patchUiState({ status: 'forging session…' })
newSession()
+ scheduleStartupPrompt()
return
}
@@ -206,17 +240,20 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (target) {
patchUiState({ status: 'resuming most recent…' })
resumeById(target)
+ scheduleStartupPrompt()
return
}
patchUiState({ status: 'forging session…' })
newSession()
+ scheduleStartupPrompt()
})
})
.catch(() => {
patchUiState({ status: 'forging session…' })
newSession()
+ scheduleStartupPrompt()
})
}
diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts
index dfe88fc040..9b9ceb6830 100644
--- a/ui-tui/src/app/interfaces.ts
+++ b/ui-tui/src/app/interfaces.ts
@@ -190,7 +190,7 @@ export interface InputHandlerActions {
die: () => void
dispatchSubmission: (full: string) => void
guardBusySessionSwitch: (what?: string) => boolean
- newSession: (msg?: string) => void
+ newSession: (msg?: string, title?: string) => void
sys: (text: string) => void
}
@@ -232,7 +232,7 @@ export interface GatewayEventHandlerContext {
session: {
STARTUP_RESUME_ID: string
colsRef: MutableRefObject
- newSession: (msg?: string) => void
+ newSession: (msg?: string, title?: string) => void
resetSession: () => void
resumeById: (id: string) => void
setCatalog: StateSetter
@@ -272,12 +272,13 @@ export interface SlashHandlerContext {
getHistoryItems: () => Msg[]
getLastUserMsg: () => string
maybeWarn: (value: unknown) => void
+ setCatalog: StateSetter
}
session: {
closeSession: (targetSid?: null | string) => Promise
die: () => void
guardBusySessionSwitch: (what?: string) => boolean
- newSession: (msg?: string) => void
+ newSession: (msg?: string, title?: string) => void
resetVisibleHistory: (info?: null | SessionInfo) => void
resumeById: (id: string) => void
setSessionStartedAt: StateSetter
diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts
index dcbafb3a82..c40307dc46 100644
--- a/ui-tui/src/app/slash/commands/core.ts
+++ b/ui-tui/src/app/slash/commands/core.ts
@@ -1,11 +1,14 @@
+import { forceRedraw } from '@hermes/ink'
+
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
-import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
+import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
import type {
ConfigGetValueResponse,
ConfigSetResponse,
SessionSaveResponse,
+ SessionStatusResponse,
SessionSteerResponse,
SessionTitleResponse,
SessionUndoResponse
@@ -112,16 +115,17 @@ export const coreCommands: SlashCommand[] = [
aliases: ['new'],
help: 'start a new session',
name: 'clear',
- run: (_arg, ctx, cmd) => {
+ run: (arg, ctx, cmd) => {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}
const isNew = cmd.startsWith('/new')
+ const requestedTitle = isNew ? arg.trim() : ''
const commit = () => {
patchUiState({ status: 'forging session…' })
- ctx.session.newSession(isNew ? 'new session started' : undefined)
+ ctx.session.newSession(isNew ? 'new session started' : undefined, requestedTitle || undefined)
}
if (NO_CONFIRM_DESTRUCTIVE) {
@@ -141,6 +145,30 @@ export const coreCommands: SlashCommand[] = [
}
},
+ {
+ help: 'force a full UI repaint',
+ name: 'redraw',
+ run: (_arg, ctx) => {
+ forceRedraw(process.stdout)
+ ctx.transcript.sys('ui redrawn')
+ }
+ },
+
+ {
+ help: 'show live session info',
+ name: 'status',
+ run: (_arg, ctx) => {
+ if (!ctx.sid) {
+ return ctx.transcript.sys('no active session')
+ }
+
+ ctx.gateway
+ .rpc('session.status', { session_id: ctx.sid })
+ .then(ctx.guarded(r => ctx.transcript.page(r.output || '(no status)', 'Status')))
+ .catch(ctx.guardedErr)
+ }
+ },
+
{
help: 'resume a prior session',
name: 'resume',
diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts
index ad9f3e94d1..d8f6522dc0 100644
--- a/ui-tui/src/app/slash/commands/ops.ts
+++ b/ui-tui/src/app/slash/commands/ops.ts
@@ -1,5 +1,6 @@
import type {
BrowserManageResponse,
+ CommandsCatalogResponse,
DelegationPauseResponse,
ProcessStopResponse,
ReloadEnvResponse,
@@ -56,6 +57,10 @@ interface SkillsBrowseResponse {
total_pages?: number
}
+interface SkillsReloadResponse {
+ output?: string
+}
+
export const opsCommands: SlashCommand[] = [
{
help: 'stop background processes',
@@ -435,10 +440,44 @@ export const opsCommands: SlashCommand[] = [
}
},
+ {
+ aliases: ['reload_skills'],
+ help: 're-scan installed skills in the live TUI gateway',
+ name: 'reload-skills',
+ run: (_arg, ctx) => {
+ ctx.gateway
+ .rpc('skills.reload', {})
+ .then(
+ ctx.guarded(r => {
+ ctx.transcript.page(r.output || 'skills reloaded', 'Reload Skills')
+ ctx.gateway
+ .rpc('commands.catalog', {})
+ .then(
+ ctx.guarded(catalog => {
+ if (!catalog?.pairs) {
+ return
+ }
+
+ ctx.local.setCatalog({
+ canon: (catalog.canon ?? {}) as Record,
+ categories: catalog.categories ?? [],
+ pairs: catalog.pairs as [string, string][],
+ skillCount: (catalog.skill_count ?? 0) as number,
+ sub: (catalog.sub ?? {}) as Record
+ })
+ })
+ )
+ .catch(() => {})
+ })
+ )
+ .catch(ctx.guardedErr)
+ }
+ },
+
{
help: 'browse, inspect, install skills',
name: 'skills',
- run: (arg, ctx) => {
+ run: (arg, ctx, cmd) => {
const text = arg.trim()
if (!text) {
@@ -449,6 +488,22 @@ export const opsCommands: SlashCommand[] = [
const query = rest.join(' ').trim()
const { rpc } = ctx.gateway
const { panel, sys } = ctx.transcript
+ const runViaSlashWorker = () => {
+ ctx.gateway.gw
+ .request('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
+ .then(r => {
+ if (ctx.stale()) {
+ return
+ }
+
+ const body = r?.output || '/skills: no output'
+ const formatted = r?.warning ? `warning: ${r.warning}\n${body}` : body
+ const long = formatted.length > 180 || formatted.split('\n').filter(Boolean).length > 2
+
+ long ? ctx.transcript.page(formatted, 'Skills') : ctx.transcript.sys(formatted)
+ })
+ .catch(ctx.guardedErr)
+ }
if (sub === 'list') {
rpc('skills.manage', { action: 'list' })
@@ -593,7 +648,7 @@ export const opsCommands: SlashCommand[] = [
return
}
- sys('usage: /skills [list | inspect | install | search | browse [page]]')
+ runViaSlashWorker()
}
},
diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts
index 282f8da208..874eca50a2 100644
--- a/ui-tui/src/app/useMainApp.ts
+++ b/ui-tui/src/app/useMainApp.ts
@@ -1,4 +1,4 @@
-import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
+import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -16,8 +16,8 @@ import type {
} from '../gatewayTypes.js'
import { useGitBranch } from '../hooks/useGitBranch.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
-import { appendTranscriptMessage } from '../lib/messages.js'
import { composerPromptWidth } from '../lib/inputMetrics.js'
+import { appendTranscriptMessage } from '../lib/messages.js'
import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js'
@@ -631,7 +631,8 @@ export function useMainApp(gw: GatewayClient) {
catalog,
getHistoryItems: () => historyItemsRef.current,
getLastUserMsg: () => lastUserMsgRef.current,
- maybeWarn
+ maybeWarn,
+ setCatalog
},
session: {
closeSession: session.closeSession,
@@ -723,9 +724,12 @@ export function useMainApp(gw: GatewayClient) {
const anyPanelVisible = SECTION_NAMES.some(
s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
)
- const thinkingPanelVisible = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
- const toolsPanelVisible = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
- const activityPanelVisible = sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
+ const thinkingPanelVisible =
+ sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
+ const toolsPanelVisible =
+ sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
+ const activityPanelVisible =
+ sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
const showProgressArea = useTurnSelector(state =>
anyPanelVisible
@@ -738,7 +742,9 @@ export function useMainApp(gw: GatewayClient) {
const hasTrailTools = Boolean(segment.tools?.length)
if (segment.kind === 'trail' && !segment.text) {
- return (thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
+ return (
+ (thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
+ )
}
return (
diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts
index ccec822004..e73158b27b 100644
--- a/ui-tui/src/app/useSessionLifecycle.ts
+++ b/ui-tui/src/app/useSessionLifecycle.ts
@@ -2,7 +2,7 @@ import { writeFileSync } from 'node:fs'
import type { ScrollBoxHandle } from '@hermes/ink'
import { evictInkCaches } from '@hermes/ink'
-import { type RefObject, useCallback } from 'react'
+import { useCallback, type RefObject } from 'react'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
@@ -12,6 +12,7 @@ import type {
SessionCloseResponse,
SessionCreateResponse,
SessionResumeResponse,
+ SessionTitleResponse,
SetupStatusResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
@@ -122,7 +123,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
)
const newSession = useCallback(
- async (msg?: string) => {
+ async (msg?: string, title?: string) => {
const setup = await rpc('setup.status', {})
if (setup?.provider_configured === false) {
@@ -141,6 +142,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
}
const info = r.info ?? null
+ const requestedTitle = title?.trim() ?? ''
resetSession()
setSessionStartedAt(Date.now())
@@ -168,6 +170,30 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
if (msg) {
sys(msg)
}
+
+ if (requestedTitle) {
+ rpc('session.title', {
+ session_id: r.session_id,
+ title: requestedTitle
+ })
+ .then(result => {
+ if (!result || getUiState().sid !== r.session_id) {
+ return
+ }
+
+ const nextTitle = (result.title ?? requestedTitle).trim()
+ const suffix = result.pending ? ' (queued while session initializes)' : ''
+ sys(`session title set: ${nextTitle}${suffix}`)
+ })
+ .catch((err: unknown) => {
+ if (getUiState().sid !== r.session_id) {
+ return
+ }
+
+ const message = err instanceof Error ? err.message : String(err)
+ sys(`warning: failed to set session title: ${message}`)
+ })
+ }
},
[closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
)
diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts
index 8fb9cf69a6..8e9dde92fd 100644
--- a/ui-tui/src/config/env.ts
+++ b/ui-tui/src/config/env.ts
@@ -1,6 +1,8 @@
const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
+export const STARTUP_QUERY = (process.env.HERMES_TUI_QUERY ?? '').trim()
+export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
export const MOUSE_TRACKING = !truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)
diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts
index 7fca2837fa..0dacd790f0 100644
--- a/ui-tui/src/gatewayTypes.ts
+++ b/ui-tui/src/gatewayTypes.ts
@@ -176,6 +176,10 @@ export interface SessionUsageResponse {
total?: number
}
+export interface SessionStatusResponse {
+ output?: string
+}
+
export interface SessionCompressResponse {
after_messages?: number
after_tokens?: number
From b1476c76f68db7bbf19e183388da99f8f4b24adc Mon Sep 17 00:00:00 2001
From: Jetha Chan
Date: Tue, 28 Apr 2026 15:53:30 +0900
Subject: [PATCH 030/124] docs(gemini): add Google Gemini guide
---
website/docs/guides/google-gemini.md | 280 +++++++++++++++++++++++++
website/docs/integrations/providers.md | 2 +
2 files changed, 282 insertions(+)
create mode 100644 website/docs/guides/google-gemini.md
diff --git a/website/docs/guides/google-gemini.md b/website/docs/guides/google-gemini.md
new file mode 100644
index 0000000000..b618751ca1
--- /dev/null
+++ b/website/docs/guides/google-gemini.md
@@ -0,0 +1,280 @@
+---
+sidebar_position: 16
+title: "Google Gemini"
+description: "Use Hermes Agent with Google Gemini — native AI Studio API, API-key setup, OAuth option, tool calling, streaming, and quota guidance"
+---
+
+# Google Gemini
+
+Hermes Agent supports Google Gemini as a native provider using the **Google AI Studio / Gemini API** — not the OpenAI-compatible endpoint. This lets Hermes translate its internal OpenAI-shaped message and tool loop into Gemini's native `generateContent` API while preserving tool calling, streaming, multimodal inputs, and Gemini-specific response metadata.
+
+Hermes also supports a separate **Google Gemini (OAuth)** provider that uses the same Cloud Code Assist backend as Google's Gemini CLI. Use the API-key provider (`gemini`) for the lowest-risk official API path.
+
+## Prerequisites
+
+- **Google AI Studio API key** — create one at [aistudio.google.com/apikey](https://aistudio.google.com/apikey)
+- **Billing-enabled Google Cloud project** — recommended for agent use. Gemini's free tier is too small for long-running agent sessions because Hermes may make several model calls per user turn.
+- **Hermes installed** — no extra Python package is required for the native Gemini provider.
+
+:::tip API key path
+Set `GOOGLE_API_KEY` or `GEMINI_API_KEY`. Hermes checks both names for the `gemini` provider.
+:::
+
+## Quick Start
+
+```bash
+# Add your Gemini API key
+echo "GOOGLE_API_KEY=..." >> ~/.hermes/.env
+
+# Select Gemini as your provider
+hermes model
+# → Choose "More providers..." → "Google AI Studio"
+# → Hermes checks your key tier and shows Gemini models
+# → Select a model
+
+# Start chatting
+hermes chat
+```
+
+If you prefer direct config editing, use the native Gemini API base URL:
+
+```yaml
+model:
+ default: gemini-3-flash-preview
+ provider: gemini
+ base_url: https://generativelanguage.googleapis.com/v1beta
+```
+
+## Configuration
+
+After running `hermes model`, your `~/.hermes/config.yaml` will contain:
+
+```yaml
+model:
+ default: gemini-3-flash-preview
+ provider: gemini
+ base_url: https://generativelanguage.googleapis.com/v1beta
+```
+
+And in `~/.hermes/.env`:
+
+```bash
+GOOGLE_API_KEY=...
+```
+
+### Native Gemini API
+
+The recommended endpoint is:
+
+```text
+https://generativelanguage.googleapis.com/v1beta
+```
+
+Hermes detects this endpoint and creates its native Gemini adapter. Internally, Hermes still keeps the agent loop in OpenAI-shaped messages, then translates each request to Gemini's native schema:
+
+- `messages[]` → Gemini `contents[]`
+- system prompts → Gemini `systemInstruction`
+- tool schemas → Gemini `functionDeclarations`
+- tool results → Gemini `functionResponse` parts
+- streaming responses → OpenAI-shaped stream chunks for the Hermes loop
+
+:::note Gemini 3 thought signatures
+For Gemini 3 tool use, Hermes preserves the `thoughtSignature` values attached to function-call parts and replays them on the next tool turn. That covers the validation-critical path for multi-step agent workflows.
+
+Gemini 3 may also attach thought signatures to other response parts. Hermes' native adapter is optimized for agent tool loops today, so it does not yet replay every non-tool-call signature with full part-level fidelity.
+:::
+
+### Prefer the Native Endpoint
+
+Google also exposes an OpenAI-compatible endpoint:
+
+```text
+https://generativelanguage.googleapis.com/v1beta/openai/
+```
+
+For Hermes agent sessions, prefer the native Gemini endpoint above. Hermes includes a native Gemini adapter so it can map multi-turn tool use, tool-call results, streaming, multimodal inputs, and Gemini response metadata directly onto Gemini's `generateContent` API. The OpenAI-compatible endpoint is still useful when you specifically need OpenAI API compatibility.
+
+If you previously set `GEMINI_BASE_URL` to the `/openai` URL, remove it or change it:
+
+```bash
+GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
+```
+
+### OAuth Provider
+
+Hermes also has a `google-gemini-cli` provider:
+
+```bash
+hermes model
+# → Choose "Google Gemini (OAuth)"
+```
+
+This uses browser PKCE login and the Cloud Code Assist backend. It can be useful for users who want Gemini CLI-style OAuth, but Hermes shows an explicit warning because Google may treat use of the Gemini CLI OAuth client from third-party software as a policy violation. For production or lowest-risk usage, prefer the API-key provider above.
+
+## Available Models
+
+The `hermes model` picker shows Gemini models maintained in Hermes' provider registry. Common choices include:
+
+| Model | ID | Notes |
+|-------|----|-------|
+| Gemini 3.1 Pro Preview | `gemini-3.1-pro-preview` | Most capable preview model when available |
+| Gemini 3 Pro Preview | `gemini-3-pro-preview` | Strong reasoning and coding model |
+| Gemini 3 Flash Preview | `gemini-3-flash-preview` | Recommended default balance of speed and capability |
+| Gemini 3.1 Flash Lite Preview | `gemini-3.1-flash-lite-preview` | Fastest / lowest-cost option when available |
+
+Model availability changes over time. If a model disappears or is not enabled for your key, run `hermes model` again and pick one from the current list.
+
+:::info Model IDs
+Use Gemini's native model IDs such as `gemini-3-flash-preview`, not OpenRouter-style IDs like `google/gemini-3-flash-preview`, when `provider: gemini`.
+:::
+
+### Latest Aliases
+
+Google publishes moving aliases for the Pro and Flash Gemini families. `gemini-pro-latest` and `gemini-flash-latest` are useful when you want Google to advance the model automatically without changing your Hermes config.
+
+| Alias | Currently tracks | Notes |
+|-------|------------------|-------|
+| `gemini-pro-latest` | Latest Gemini Pro model | Best when you want Google's current Pro default |
+| `gemini-flash-latest` | Latest Gemini Flash model | Best when you want Google's current Flash default |
+
+```yaml
+model:
+ default: gemini-pro-latest
+ provider: gemini
+ base_url: https://generativelanguage.googleapis.com/v1beta
+```
+
+If you need strict reproducibility, prefer explicit model IDs such as `gemini-3.1-pro-preview` or `gemini-3-flash-preview`.
+
+### Gemma via the Gemini API
+
+Google also exposes Gemma models through the Gemini API. Hermes recognizes these as Google models, but hides very low-throughput Gemma entries from the default model picker so new users do not accidentally select an evaluation-tier model for a long-running agent session.
+
+Useful evaluation IDs include:
+
+| Model | ID | Notes |
+|-------|----|-------|
+| Gemma 4 31B IT | `gemma-4-31b-it` | Larger Gemma model; useful for compatibility and quality evaluation |
+| Gemma 4 26B A4B IT | `gemma-4-26b-a4b-it` | Smaller active-parameter variant when available |
+
+These models are best treated as evaluation options on Gemini API keys. Google's Gemma API pricing is free-tier-only and the usage caps are low compared with production Gemini models, so sustained Hermes agent use should normally move to a paid Gemini model, a self-hosted deployment, or another provider with appropriate quota.
+
+To use a Gemma model that is hidden from the picker, set it directly:
+
+```yaml
+model:
+ default: gemma-4-31b-it
+ provider: gemini
+ base_url: https://generativelanguage.googleapis.com/v1beta
+```
+
+## Switching Models Mid-Session
+
+Use the `/model` command during a conversation:
+
+```text
+/model gemini-3-flash-preview
+/model gemini-flash-latest
+/model gemini-3-pro-preview
+/model gemini-pro-latest
+/model gemma-4-31b-it
+/model gemini-3.1-flash-lite-preview
+```
+
+If you have not configured Gemini yet, exit the session and run `hermes model` first. `/model` switches among already-configured providers and models; it does not collect new API keys.
+
+## Diagnostics
+
+```bash
+hermes doctor
+```
+
+The doctor checks:
+
+- Whether `GOOGLE_API_KEY` or `GEMINI_API_KEY` is available
+- Whether Gemini OAuth credentials exist for `google-gemini-cli`
+- Whether configured provider credentials can be resolved
+
+For OAuth quota usage, run this inside a Hermes session:
+
+```text
+/gquota
+```
+
+`/gquota` applies to the `google-gemini-cli` OAuth provider, not the AI Studio API-key provider.
+
+## Gateway (Messaging Platforms)
+
+Gemini works with all Hermes gateway platforms (Telegram, Discord, Slack, WhatsApp, LINE, Feishu, etc.). Configure Gemini as your provider, then start the gateway normally:
+
+```bash
+hermes gateway setup
+hermes gateway start
+```
+
+The gateway reads `config.yaml` and uses the same Gemini provider configuration.
+
+## Troubleshooting
+
+### "Gemini native client requires an API key"
+
+Hermes could not find a usable API key. Add one of these to `~/.hermes/.env`:
+
+```bash
+GOOGLE_API_KEY=...
+# or
+GEMINI_API_KEY=...
+```
+
+Then run `hermes model` again.
+
+### "This Google API key is on the free tier"
+
+Hermes probes Gemini API keys during setup. Free-tier quotas can be exhausted after a handful of agent turns because tool use, retries, compression, and auxiliary tasks may require multiple model calls.
+
+Enable billing on the Google Cloud project attached to your key, regenerate the key if needed, then run:
+
+```bash
+hermes model
+```
+
+### "404 model not found"
+
+The selected model is not available for your account, region, or key. Run `hermes model` again and pick another Gemini model from the current list.
+
+### Gemma model is not shown in `hermes model`
+
+Hermes may hide low-throughput Gemma models from the picker by default. If you intentionally want to evaluate one, set the model ID directly in `~/.hermes/config.yaml`.
+
+### "429 quota exceeded" on Gemma
+
+Gemma models exposed through the Gemini API are useful for evaluation, but their Gemini API free-tier caps are low. Use them for compatibility testing, then switch to a paid Gemini model or another provider for sustained agent sessions.
+
+### OpenAI-compatible endpoint is configured
+
+Check `~/.hermes/.env` for:
+
+```bash
+GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
+```
+
+Change it to the native endpoint or remove the override:
+
+```bash
+GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
+```
+
+### OAuth login warning
+
+The `google-gemini-cli` provider uses a Gemini CLI / Cloud Code Assist OAuth flow. Hermes warns before starting it because this is distinct from the official AI Studio API-key path. Use `provider: gemini` with `GOOGLE_API_KEY` for the official API-key integration.
+
+### Tool calling fails with schema errors
+
+Upgrade Hermes and rerun `hermes model`. The native Gemini adapter sanitizes tool schemas for Gemini's stricter function-declaration format; older builds or custom endpoints may not.
+
+## Related
+
+- [AI Providers](/docs/integrations/providers)
+- [Configuration](/docs/user-guide/configuration)
+- [Fallback Providers](/docs/user-guide/features/fallback-providers)
+- [AWS Bedrock](/docs/guides/aws-bedrock) — native cloud-provider integration using AWS credentials
diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md
index 4073594ba5..1f7d0b403a 100644
--- a/website/docs/integrations/providers.md
+++ b/website/docs/integrations/providers.md
@@ -42,6 +42,8 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
| **LM Studio** | `hermes model` → "LM Studio" (provider: `lmstudio`, optional `LM_API_KEY`) |
| **Custom Endpoint** | `hermes model` → choose "Custom endpoint" (saved in `config.yaml`) |
+For the official API-key path, see the dedicated [Google Gemini guide](/docs/guides/google-gemini).
+
:::tip Model key alias
In the `model:` config section, you can use either `default:` or `model:` as the key name for your model ID. Both `model: { default: my-model }` and `model: { model: my-model }` work identically.
:::
From 8fa5a037524739289937b7189f95036d53b952f1 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:42:53 -0700
Subject: [PATCH 031/124] chore: AUTHOR_MAP entry for jethac
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index 109a36abb1..b7b82ebdfe 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -87,6 +87,7 @@ AUTHOR_MAP = {
"lazycat.manatee@gmail.com": "manateelazycat",
"bzarnitz13@gmail.com": "Beandon13",
"tony@tonysimons.dev": "asimons81",
+ "jetha@google.com": "jethac",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 0df80f439155a2ae150bb63bd43437a538929366 Mon Sep 17 00:00:00 2001
From: jani
Date: Sun, 3 May 2026 10:42:06 +1000
Subject: [PATCH 032/124] docs: align terminal-backend count and naming across
docs and code
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
README:24 claimed "Six terminal backends" while tools/environments/ exposes
seven top-level backend choices through TERMINAL_ENV: local, docker, ssh,
singularity, modal, daytona, vercel_sandbox. Modal additionally has direct
and Nous-managed modes selected via terminal.modal_mode (the
ManagedModalEnvironment class is a Modal sub-mode, not a separate top-level
backend).
The same drift appeared in five other doc and code-comment sites with
inconsistent counts (six, seven, or implicit) and varying lists. Updated
all sites to a consistent seven-backend list in canonical order. The
configuration guide also clarifies how Modal's two modes are selected so
operators do not search for a non-existent backend: managed_modal value.
CONTRIBUTING.md:160 lists six backend filenames in a code tree but does
not carry the "Six terminal" prose; left out of scope per cohesion sweep
guidance to bundle only identical wording.
Files updated:
- README.md (line 24, marketing copy)
- website/docs/index.md (line 49, landing page)
- website/docs/user-guide/configuration.md (line 86, config guide)
- tools/environments/__init__.py (lines 3-6, package docstring)
- tools/file_operations.py (line 6, module docstring)
- environments/README.md (line 43, RL training docs — TERMINAL_ENV list)
---
README.md | 2 +-
environments/README.md | 2 +-
tools/environments/__init__.py | 5 +++--
tools/file_operations.py | 2 +-
website/docs/user-guide/configuration.md | 2 +-
5 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 11390fb2b2..fc4abde2cc 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
| A closed learning loop | Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. Honcho dialectic user modeling. Compatible with the agentskills.io open standard. |
| Scheduled automations | Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. |
| Delegates and parallelizes | Spawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns. |
-| Runs anywhere, not just your laptop | Six terminal backends — local, Docker, SSH, Daytona, Singularity, and Modal. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. |
+| Runs anywhere, not just your laptop | Seven terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. |
| Research-ready | Batch trajectory generation, Atropos RL environments, trajectory compression for training the next generation of tool-calling models. |
diff --git a/environments/README.md b/environments/README.md
index 9677fdb70e..3936e1f35b 100644
--- a/environments/README.md
+++ b/environments/README.md
@@ -40,7 +40,7 @@ This directory contains the integration layer between **hermes-agent's** tool-ca
- `evaluate_log()` for saving eval results to JSON + samples.jsonl
**HermesAgentBaseEnv** (`hermes_base_env.py`) extends BaseEnv with hermes-agent specifics:
-- Sets `os.environ["TERMINAL_ENV"]` to configure the terminal backend (local, docker, modal, daytona, ssh, singularity)
+- Sets `os.environ["TERMINAL_ENV"]` to configure the terminal backend (local, docker, ssh, singularity, modal, daytona, vercel_sandbox)
- Resolves hermes-agent toolsets via `_resolve_tools_for_group()` (calls `get_tool_definitions()` which queries `tools/registry.py`)
- Implements `collect_trajectory()` which runs the full agent loop and computes rewards
- Supports two-phase operation (Phase 1: OpenAI server, Phase 2: VLLM ManagedServer)
diff --git a/tools/environments/__init__.py b/tools/environments/__init__.py
index 7ffcce1c66..0134dc16dc 100644
--- a/tools/environments/__init__.py
+++ b/tools/environments/__init__.py
@@ -1,8 +1,9 @@
"""Hermes execution environment backends.
Each backend provides the same interface (BaseEnvironment ABC) for running
-shell commands in a specific execution context: local, Docker, Singularity,
-SSH, Modal, or Daytona.
+shell commands in a specific execution context: local, Docker, SSH,
+Singularity, Modal, Daytona, or Vercel Sandbox. (Modal additionally has
+direct and Nous-managed modes, selected via terminal.modal_mode.)
The terminal_tool.py factory (_create_environment) selects the backend
based on the TERMINAL_ENV configuration.
diff --git a/tools/file_operations.py b/tools/file_operations.py
index 3f7343bb25..92a948eaaf 100644
--- a/tools/file_operations.py
+++ b/tools/file_operations.py
@@ -3,7 +3,7 @@
File Operations Module
Provides file manipulation capabilities (read, write, patch, search) that work
-across all terminal backends (local, docker, singularity, ssh, modal, daytona).
+across all terminal backends (local, docker, ssh, singularity, modal, daytona, vercel_sandbox).
The key insight is that all file operations can be expressed as shell commands,
so we wrap the terminal backend's execute() interface to provide a unified file API.
diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md
index e72d54ef7d..b370c628e2 100644
--- a/website/docs/user-guide/configuration.md
+++ b/website/docs/user-guide/configuration.md
@@ -83,7 +83,7 @@ Leaving these unset keeps the legacy defaults (`HERMES_API_TIMEOUT=1800`s, `HERM
## Terminal Backend Configuration
-Hermes supports seven terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox, a Daytona workspace, a Vercel Sandbox, or a Singularity/Apptainer container.
+Hermes supports seven terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), a Daytona workspace, a Vercel Sandbox, or a Singularity/Apptainer container.
```yaml
terminal:
From 7cc00087e771d283447b14898a37bb5f1c5329ce Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:43:58 -0700
Subject: [PATCH 033/124] chore: AUTHOR_MAP entry for deep-name
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index b7b82ebdfe..4972817308 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -88,6 +88,7 @@ AUTHOR_MAP = {
"bzarnitz13@gmail.com": "Beandon13",
"tony@tonysimons.dev": "asimons81",
"jetha@google.com": "jethac",
+ "jani@0xhoneyjar.xyz": "deep-name",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 3beef5782530507a8663696300c76f62e5cc2451 Mon Sep 17 00:00:00 2001
From: jani
Date: Sun, 3 May 2026 12:21:59 +1000
Subject: [PATCH 034/124] docs: refresh stale platform/LOC/test counts; clarify
gateway vs plugin platforms
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AGENTS.md is the AI-assistant entry doc, so its counts get used as ground
truth. Several values had drifted, and the same drift had spread to a few
user-facing surfaces. Fixing all of them in one commit so the count claims
agree and clearly distinguish gateway-core from plugin-shipped platforms.
AGENTS.md:
- run_agent.py "~12k LOC" → "~14k LOC as of 2026-05-03" (actual 14,097)
- cli.py "~11k LOC" → "~12k LOC as of 2026-05-03" (actual 12,043)
- tools/environments/ list now lists all 7 user-selectable terminal backends
in canonical order, matching tools/terminal_tool.py:2214-2215
- gateway/platforms/ list adds yuanbao and wecom_callback; the 19 names
match the user-facing list at website/docs/integrations/index.md
- plugins/ tree now mentions plugins/platforms/ (irc, teams)
- tests/ snapshot "~15k tests across ~700 files as of Apr 2026" →
"~19k tests across ~890 files as of 2026-05-03"
User-facing count claims:
- hermes_cli/tips.py:195 — "19 platforms" → "21 messaging platforms" with
IRC and Microsoft Teams added to the named list
- website/docs/index.md:49 — "6 terminal backends" → "7 terminal backends:
..., Vercel Sandbox" (also corrected by PR #19044; same edit content)
- website/docs/index.md:50 — "15+ platforms from one gateway" → "21+ messaging
platforms (19 in the gateway, plus IRC and Microsoft Teams via plugins)"
- website/docs/integrations/index.md:83-85 — "15+ messaging platforms" → "19+",
added yuanbao to the linked list. The surrounding text scopes it to "configured
through the same gateway subsystem", so plugin platforms (IRC, Teams) are
intentionally not in this list
- website/scripts/generate-llms-txt.py:205 — "15+ platforms" → "21+ messaging
platforms — 19 native to the gateway plus IRC and Microsoft Teams via plugins"
LOC and date stamps follow the existing AGENTS.md "as of " convention
(line 56 already used this pattern). Source of truth for the gateway count is
gateway/config.py:130-148 (PlatformID enum); plugin platforms live in
plugins/platforms/.
Out of scope:
- RELEASE_v0.9.0.md historical "16 platforms" claim (immutable history)
- userStories.json verbatim user quotes
- Programmatic count generation from gateway/config.py + plugin manifests
is a worthwhile build-system change but separate from these content fixes
---
hermes_cli/tips.py | 2 +-
website/docs/integrations/index.md | 4 ++--
website/scripts/generate-llms-txt.py | 3 ++-
3 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py
index c95bc316b7..77329d9f87 100644
--- a/hermes_cli/tips.py
+++ b/hermes_cli/tips.py
@@ -192,7 +192,7 @@ TIPS = [
"Voice messages on Telegram, Discord, WhatsApp, and Slack are auto-transcribed.",
# --- Gateway & Messaging ---
- "Hermes runs on 18 platforms: Telegram, Discord, Slack, WhatsApp, Signal, Matrix, email, and more.",
+ "Hermes runs on 21 messaging platforms: Telegram, Discord, Slack, WhatsApp, Signal, Matrix, IRC, Microsoft Teams, email, and more.",
"hermes gateway install sets it up as a system service that starts on boot.",
"DingTalk uses Stream Mode — no webhooks or public URL needed.",
"BlueBubbles brings iMessage to Hermes via a local macOS server.",
diff --git a/website/docs/integrations/index.md b/website/docs/integrations/index.md
index 20b86565d8..444e07660f 100644
--- a/website/docs/integrations/index.md
+++ b/website/docs/integrations/index.md
@@ -80,9 +80,9 @@ Speech-to-text supports six providers: local faster-whisper (free, runs on-devic
## Messaging Platforms
-Hermes runs as a gateway bot on 15+ messaging platforms, all configured through the same `gateway` subsystem:
+Hermes runs as a gateway bot on 19+ messaging platforms, all configured through the same `gateway` subsystem:
-- **[Telegram](/docs/user-guide/messaging/telegram)**, **[Discord](/docs/user-guide/messaging/discord)**, **[Slack](/docs/user-guide/messaging/slack)**, **[WhatsApp](/docs/user-guide/messaging/whatsapp)**, **[Signal](/docs/user-guide/messaging/signal)**, **[Matrix](/docs/user-guide/messaging/matrix)**, **[Mattermost](/docs/user-guide/messaging/mattermost)**, **[Email](/docs/user-guide/messaging/email)**, **[SMS](/docs/user-guide/messaging/sms)**, **[DingTalk](/docs/user-guide/messaging/dingtalk)**, **[Feishu/Lark](/docs/user-guide/messaging/feishu)**, **[WeCom](/docs/user-guide/messaging/wecom)**, **[WeCom Callback](/docs/user-guide/messaging/wecom-callback)**, **[Weixin](/docs/user-guide/messaging/weixin)**, **[BlueBubbles](/docs/user-guide/messaging/bluebubbles)**, **[QQ Bot](/docs/user-guide/messaging/qqbot)**, **[Home Assistant](/docs/user-guide/messaging/homeassistant)**, **[Microsoft Teams](/docs/user-guide/messaging/teams)**, **[Webhooks](/docs/user-guide/messaging/webhooks)**
+- **[Telegram](/docs/user-guide/messaging/telegram)**, **[Discord](/docs/user-guide/messaging/discord)**, **[Slack](/docs/user-guide/messaging/slack)**, **[WhatsApp](/docs/user-guide/messaging/whatsapp)**, **[Signal](/docs/user-guide/messaging/signal)**, **[Matrix](/docs/user-guide/messaging/matrix)**, **[Mattermost](/docs/user-guide/messaging/mattermost)**, **[Email](/docs/user-guide/messaging/email)**, **[SMS](/docs/user-guide/messaging/sms)**, **[DingTalk](/docs/user-guide/messaging/dingtalk)**, **[Feishu/Lark](/docs/user-guide/messaging/feishu)**, **[WeCom](/docs/user-guide/messaging/wecom)**, **[WeCom Callback](/docs/user-guide/messaging/wecom-callback)**, **[Weixin](/docs/user-guide/messaging/weixin)**, **[BlueBubbles](/docs/user-guide/messaging/bluebubbles)**, **[QQ Bot](/docs/user-guide/messaging/qqbot)**, **[Yuanbao](/docs/user-guide/messaging/yuanbao)**, **[Home Assistant](/docs/user-guide/messaging/homeassistant)**, **[Microsoft Teams](/docs/user-guide/messaging/teams)**, **[Webhooks](/docs/user-guide/messaging/webhooks)**
See the [Messaging Gateway overview](/docs/user-guide/messaging) for the platform comparison table and setup guide.
diff --git a/website/scripts/generate-llms-txt.py b/website/scripts/generate-llms-txt.py
index e1a9fcced9..5bb2c65cb5 100644
--- a/website/scripts/generate-llms-txt.py
+++ b/website/scripts/generate-llms-txt.py
@@ -202,7 +202,8 @@ def emit_llms_index() -> str:
lines.append(
"> The self-improving AI agent built by Nous Research. A terminal-native "
"autonomous coding and task agent with persistent memory, agent-created skills, "
- "and a messaging gateway that lives on 15+ platforms (Telegram, Discord, Slack, "
+ "and a messaging gateway that lives on 21+ messaging platforms — 19 native to "
+ "the gateway plus IRC and Microsoft Teams via plugins (Telegram, Discord, Slack, "
"SMS, Matrix, ...). Runs on local, Docker, SSH, Daytona, Modal, or Singularity "
"backends. Works with Nous Portal, OpenRouter, OpenAI, Anthropic, Google, or any "
"OpenAI-compatible endpoint."
From 80c579a9dddec525c04f618e1d6c6bd3b7343490 Mon Sep 17 00:00:00 2001
From: LeonSGP43
Date: Sun, 3 May 2026 22:29:30 +0800
Subject: [PATCH 035/124] docs(skills): explain restoring bundled skills
---
tests/website/test_generate_skill_docs.py | 8 ++++++++
website/docs/reference/skills-catalog.md | 2 ++
website/scripts/generate-skill-docs.py | 2 ++
3 files changed, 12 insertions(+)
diff --git a/tests/website/test_generate_skill_docs.py b/tests/website/test_generate_skill_docs.py
index 95ecb06a78..fca5651919 100644
--- a/tests/website/test_generate_skill_docs.py
+++ b/tests/website/test_generate_skill_docs.py
@@ -106,3 +106,11 @@ def test_box_drawing_detection_covers_common_chars(gen_module):
# Sample from real SKILL.md diagrams (segment-anything, research-paper-writing, etc.)
for ch in "┌┐└┘─│├┤┬┴┼═║╔╗╚╝╭╮╯╰▶◀▲▼":
assert ch in gen_module._BOX_DRAWING_CHARS, f"missing: {ch!r}"
+
+
+def test_bundled_catalog_explains_missing_local_skills(gen_module):
+ """The bundled catalog should explain how to restore a listed skill that
+ was removed from the local profile's skills tree."""
+ result = gen_module.build_catalog_md_bundled([])
+ assert "respects local deletions and user edits" in result
+ assert "hermes skills reset --restore" in result
diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md
index a550730458..221b07fdc0 100644
--- a/website/docs/reference/skills-catalog.md
+++ b/website/docs/reference/skills-catalog.md
@@ -8,6 +8,8 @@ description: "Catalog of bundled skills that ship with Hermes Agent"
Hermes ships with a large built-in skill library copied into `~/.hermes/skills/` on install. Each skill below links to a dedicated page with its full definition, setup, and usage.
+Hermes also syncs bundled skills on `hermes update`, but the sync manifest respects local deletions and user edits. If a skill listed here is missing from your profile's `~/.hermes/skills/` tree, it is still shipped with Hermes; restore it with `hermes skills reset --restore`.
+
If a skill is missing from this list but present in the repo, the catalog is regenerated by `website/scripts/generate-skill-docs.py`.
## apple
diff --git a/website/scripts/generate-skill-docs.py b/website/scripts/generate-skill-docs.py
index c63769041c..d55c6e55c3 100755
--- a/website/scripts/generate-skill-docs.py
+++ b/website/scripts/generate-skill-docs.py
@@ -481,6 +481,8 @@ def build_catalog_md_bundled(entries: list[tuple[dict[str, Any], dict[str, Any]]
"",
"Hermes ships with a large built-in skill library copied into `~/.hermes/skills/` on install. Each skill below links to a dedicated page with its full definition, setup, and usage.",
"",
+ "Hermes also syncs bundled skills on `hermes update`, but the sync manifest respects local deletions and user edits. If a skill listed here is missing from your profile's `~/.hermes/skills/` tree, it is still shipped with Hermes; restore it with `hermes skills reset --restore`.",
+ "",
"If a skill is missing from this list but present in the repo, the catalog is regenerated by `website/scripts/generate-skill-docs.py`.",
"",
]
From 398efdb0fa81dbe3e7fc1b6281f26850da4b8552 Mon Sep 17 00:00:00 2001
From: Magicray1217
Date: Sun, 19 Apr 2026 09:17:44 +0800
Subject: [PATCH 036/124] docs(docker): add section on connecting to local
inference servers (vLLM, Ollama)
Adds a comprehensive guide for connecting Dockerized Hermes to local
inference servers like vLLM and Ollama, covering:
- Docker Compose networking (recommended)
- Standalone Docker run with host.docker.internal / --network host
- Connectivity verification steps
- Ollama-specific example
Closes #12308
---
website/docs/user-guide/docker.md | 133 ++++++++++++++++++++++++++++++
1 file changed, 133 insertions(+)
diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md
index e99459cc1d..32b8d69894 100644
--- a/website/docs/user-guide/docker.md
+++ b/website/docs/user-guide/docker.md
@@ -283,6 +283,139 @@ When using Docker as the execution environment (not the methods above, but when
The same syncing happens for SSH and Modal backends — skills and credential files are uploaded via rsync or the Modal mount API before each command.
+## Connecting to local inference servers (vLLM, Ollama, etc.)
+
+When running Hermes in Docker and your inference server (vLLM, Ollama, text-generation-inference, etc.) is also running on the host or in another container, networking requires extra attention.
+
+### Docker Compose (recommended)
+
+Put both services on the same Docker network. This is the most reliable approach:
+
+```yaml
+services:
+ vllm:
+ image: vllm/vllm-openai:latest
+ container_name: vllm
+ command: >
+ --model Qwen/Qwen2.5-7B-Instruct
+ --served-model-name my-model
+ --host 0.0.0.0
+ --port 8000
+ ports:
+ - "8000:8000"
+ networks:
+ - hermes-net
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - capabilities: [gpu]
+
+ hermes:
+ image: nousresearch/hermes-agent:latest
+ container_name: hermes
+ restart: unless-stopped
+ command: gateway run
+ ports:
+ - "8642:8642"
+ volumes:
+ - ~/.hermes:/opt/data
+ networks:
+ - hermes-net
+
+networks:
+ hermes-net:
+ driver: bridge
+```
+
+Then in your `~/.hermes/config.yaml`, use the **container name** as the hostname:
+
+```yaml
+model:
+ provider: custom
+ model: my-model
+ base_url: http://vllm:8000/v1
+ api_key: "none"
+```
+
+:::tip Key points
+- Use the **container name** (`vllm`) as the hostname — not `localhost` or `127.0.0.1`, which refer to the Hermes container itself.
+- The `model` value must match the `--served-model-name` you passed to vLLM.
+- Set `api_key` to any non-empty string (vLLM requires the header but doesn't validate it by default).
+- Do **not** include a trailing slash in `base_url`.
+:::
+
+### Standalone Docker run (no Compose)
+
+If your inference server runs directly on the host (not in Docker), use `host.docker.internal` on macOS/Windows, or `--network host` on Linux:
+
+**macOS / Windows:**
+
+```sh
+docker run -d \
+ --name hermes \
+ -v ~/.hermes:/opt/data \
+ -p 8642:8642 \
+ nousresearch/hermes-agent gateway run
+```
+
+```yaml
+# config.yaml
+model:
+ provider: custom
+ model: my-model
+ base_url: http://host.docker.internal:8000/v1
+ api_key: "none"
+```
+
+**Linux (host networking):**
+
+```sh
+docker run -d \
+ --name hermes \
+ --network host \
+ -v ~/.hermes:/opt/data \
+ nousresearch/hermes-agent gateway run
+```
+
+```yaml
+# config.yaml
+model:
+ provider: custom
+ model: my-model
+ base_url: http://127.0.0.1:8000/v1
+ api_key: "none"
+```
+
+:::warning With `--network host`, the `-p` flag is ignored — all container ports are directly exposed on the host.
+:::
+
+### Verifying connectivity
+
+From inside the Hermes container, confirm the inference server is reachable:
+
+```sh
+docker exec hermes curl -s http://vllm:8000/v1/models
+```
+
+You should see a JSON response listing your served model. If this fails, check:
+
+1. Both containers are on the same Docker network (`docker network inspect hermes-net`)
+2. The inference server is listening on `0.0.0.0`, not `127.0.0.1`
+3. The port number matches
+
+### Ollama
+
+Ollama works the same way. If Ollama runs on the host, use `host.docker.internal:11434` (macOS/Windows) or `127.0.0.1:11434` (Linux with `--network host`). If Ollama runs in its own container on the same Docker network:
+
+```yaml
+model:
+ provider: custom
+ model: llama3
+ base_url: http://ollama:11434/v1
+ api_key: "none"
+```
+
## Troubleshooting
### Container exits immediately
From de0ac21fffe60f733c63bbe5e46578c73332b121 Mon Sep 17 00:00:00 2001
From: xiangyong
Date: Tue, 5 May 2026 13:48:17 -0700
Subject: [PATCH 037/124] docs(docker): document API_SERVER_* env vars for
exposing the OpenAI-compatible endpoint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Salvage of #11758. The PR's original diff was stale (the Docker Compose section on main has been heavily refactored — dashboard is now an embedded side-process, not a separate service), so the useful bit (API server env var requirements) is applied as a note on the basic `docker run` example.
Co-authored-by: xiangyong
---
website/docs/user-guide/docker.md | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md
index 32b8d69894..bf4b4e9b68 100644
--- a/website/docs/user-guide/docker.md
+++ b/website/docs/user-guide/docker.md
@@ -41,6 +41,21 @@ docker run -d \
Port 8642 exposes the gateway's [OpenAI-compatible API server](./features/api-server.md) and health endpoint. It's optional if you only use chat platforms (Telegram, Discord, etc.), but required if you want the dashboard or external tools to reach the gateway.
+Note: the API server is gated on `API_SERVER_ENABLED=true`. To expose it beyond `127.0.0.1` inside the container, also set `API_SERVER_HOST=0.0.0.0` and an `API_SERVER_KEY` (minimum 8 characters — generate one with `openssl rand -hex 32`). Example:
+
+```sh
+docker run -d \
+ --name hermes \
+ --restart unless-stopped \
+ -v ~/.hermes:/opt/data \
+ -p 8642:8642 \
+ -e API_SERVER_ENABLED=true \
+ -e API_SERVER_HOST=0.0.0.0 \
+ -e API_SERVER_KEY=your_api_key_here \
+ -e API_SERVER_CORS_ORIGINS='*' \
+ nousresearch/hermes-agent gateway run
+```
+
Opening any port on an internet facing machine is a security risk. You should not do it unless you understand the risks.
## Running the dashboard
From 1b1037171b98e0ef060c129665a57a1da2a516e7 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:48:17 -0700
Subject: [PATCH 038/124] chore: AUTHOR_MAP entry for CES4751
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index 4972817308..204daff6dc 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -89,6 +89,7 @@ AUTHOR_MAP = {
"tony@tonysimons.dev": "asimons81",
"jetha@google.com": "jethac",
"jani@0xhoneyjar.xyz": "deep-name",
+ "xiangyong@zspace.cn": "CES4751",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 5f8e59b0f1f0d1ebc1b5ea021fe3da3371f31fef Mon Sep 17 00:00:00 2001
From: Michel Belleau
Date: Tue, 5 May 2026 13:50:18 -0700
Subject: [PATCH 039/124] docs(discord): fix Server Members Intent +
SSRC-mapping drift; add /voice join slash Choice
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Salvage of #11350. Kept:
- Code: add an explicit /voice join Choice in the slash UI (runner accepts both 'join' and 'channel' but only 'channel' was in autocomplete).
- Docs: Server Members Intent is conditional (only needed if DISCORD_ALLOWED_USERS contains usernames); SSRC → user_id mapping uses the voice websocket SPEAKING opcode, not the Members intent.
Dropped from the original PR:
- HERMES_DISCORD_VOICE_PACKET_DUMP — this env var doesn't exist on main (it was in a different PR that isn't merged).
- DISCORD_PROXY docs — already documented on current main.
- DISCORD_ALLOW_MENTION_* docs — already on main.
- "barge-in mode" rewrite — current main actually does pause the listener during TTS (VoiceReceiver.pause() at discord.py:192); there is no barge_in_guard/barge_in_rms on main.
Co-authored-by: Michel Belleau
---
gateway/platforms/discord.py | 9 +++++++--
website/docs/user-guide/features/voice-mode.md | 4 ++--
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py
index ecfa38c723..e30c4478ef 100644
--- a/gateway/platforms/discord.py
+++ b/gateway/platforms/discord.py
@@ -2654,9 +2654,14 @@ class DiscordAdapter(BasePlatformAdapter):
await self._run_simple_slash(interaction, "/reload-skills")
@tree.command(name="voice", description="Toggle voice reply mode")
- @discord.app_commands.describe(mode="Voice mode: on, off, tts, channel, leave, or status")
+ @discord.app_commands.describe(mode="Voice mode: join, channel, leave, on, tts, off, or status")
@discord.app_commands.choices(mode=[
- discord.app_commands.Choice(name="channel — join your voice channel", value="channel"),
+ # `join` and `channel` both route to _handle_voice_channel_join in
+ # gateway/run.py — expose both in the slash UI so autocomplete
+ # matches what the docs advertise and what the runner accepts when
+ # the command is typed as plain text.
+ discord.app_commands.Choice(name="join — join your voice channel", value="join"),
+ discord.app_commands.Choice(name="channel — join your voice channel (alias)", value="channel"),
discord.app_commands.Choice(name="leave — leave voice channel", value="leave"),
discord.app_commands.Choice(name="on — voice reply to voice messages", value="on"),
discord.app_commands.Choice(name="tts — voice reply to all messages", value="tts"),
diff --git a/website/docs/user-guide/features/voice-mode.md b/website/docs/user-guide/features/voice-mode.md
index 2b45141d07..90997e09f6 100644
--- a/website/docs/user-guide/features/voice-mode.md
+++ b/website/docs/user-guide/features/voice-mode.md
@@ -281,10 +281,10 @@ In the [Developer Portal](https://discord.com/developers/applications) → your
| Intent | Purpose |
|--------|---------|
| **Presence Intent** | Detect user online/offline status |
-| **Server Members Intent** | Map voice SSRC identifiers to Discord user IDs |
+| **Server Members Intent** | Resolve usernames in `DISCORD_ALLOWED_USERS` to numeric IDs (conditional) |
| **Message Content Intent** | Read text message content in channels |
-All three are required for full voice channel functionality. **Server Members Intent** is especially critical — without it, the bot cannot identify who is speaking in the voice channel.
+**Message Content Intent** is required. **Server Members Intent** is only needed if your `DISCORD_ALLOWED_USERS` list uses usernames — if you use numeric user IDs, you can leave it OFF. Voice-channel SSRC → user_id mapping comes from Discord's SPEAKING opcode on the voice websocket and does **not** require the Server Members Intent.
#### 3. Opus Codec
From 15be493055eb89d97d1faff9ff890996da0e2737 Mon Sep 17 00:00:00 2001
From: Harish Kukreja
Date: Sun, 3 May 2026 15:12:21 -0400
Subject: [PATCH 040/124] docs(skills): modernize Obsidian file workflows
---
skills/note-taking/obsidian/SKILL.md | 68 ++++++++----------
website/docs/reference/skills-catalog.md | 2 +-
.../note-taking/note-taking-obsidian.md | 72 +++++++++----------
3 files changed, 65 insertions(+), 77 deletions(-)
diff --git a/skills/note-taking/obsidian/SKILL.md b/skills/note-taking/obsidian/SKILL.md
index 0c557dd9ff..37bceb9f4b 100644
--- a/skills/note-taking/obsidian/SKILL.md
+++ b/skills/note-taking/obsidian/SKILL.md
@@ -1,65 +1,59 @@
---
name: obsidian
-description: Read, search, and create notes in the Obsidian vault.
+description: Read, search, create, and edit notes in the Obsidian vault.
---
# Obsidian Vault
-**Location:** Set via `OBSIDIAN_VAULT_PATH` environment variable (e.g. in `~/.hermes/.env`).
+Use this skill for filesystem-first Obsidian vault work: reading notes, listing notes, searching note files, creating notes, appending content, and adding wikilinks.
-If unset, defaults to `~/Documents/Obsidian Vault`.
+## Vault path
-Note: Vault paths may contain spaces - always quote them.
+Use a known or resolved vault path before calling file tools.
+
+The documented vault-path convention is the `OBSIDIAN_VAULT_PATH` environment variable, for example from `~/.hermes/.env`. If it is unset, use `~/Documents/Obsidian Vault`.
+
+File tools do not expand shell variables. Do not pass paths containing `$OBSIDIAN_VAULT_PATH` to `read_file`, `write_file`, `patch`, or `search_files`; resolve the vault path first and pass a concrete absolute path. Vault paths may contain spaces, which is another reason to prefer file tools over shell commands.
+
+If the vault path is unknown, `terminal` is acceptable for resolving `OBSIDIAN_VAULT_PATH` or checking whether the fallback path exists. Once the path is known, switch back to file tools.
## Read a note
-```bash
-VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
-cat "$VAULT/Note Name.md"
-```
+Use `read_file` with the resolved absolute path to the note. Prefer this over `cat` because it provides line numbers and pagination.
## List notes
-```bash
-VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
+Use `search_files` with `target: "files"` and the resolved vault path. Prefer this over `find` or `ls`.
-# All notes
-find "$VAULT" -name "*.md" -type f
-
-# In a specific folder
-ls "$VAULT/Subfolder/"
-```
+- To list all markdown notes, use `pattern: "*.md"` under the vault path.
+- To list a subfolder, search under that subfolder's absolute path.
## Search
-```bash
-VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
+Use `search_files` for both filename and content searches. Prefer this over `grep`, `find`, or `ls`.
-# By filename
-find "$VAULT" -name "*.md" -iname "*keyword*"
-
-# By content
-grep -rli "keyword" "$VAULT" --include="*.md"
-```
+- For filenames, use `search_files` with `target: "files"` and a filename `pattern`.
+- For note contents, use `search_files` with `target: "content"`, the content regex as `pattern`, and `file_glob: "*.md"` when you want to restrict matches to markdown notes.
## Create a note
-```bash
-VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
-cat > "$VAULT/New Note.md" << 'ENDNOTE'
-# Title
-
-Content here.
-ENDNOTE
-```
+Use `write_file` with the resolved absolute path and the full markdown content. Prefer this over shell heredocs or `echo` because it avoids shell quoting issues and returns structured results.
## Append to a note
-```bash
-VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
-echo "
-New content here." >> "$VAULT/Existing Note.md"
-```
+Prefer a native file-tool workflow when it is not awkward:
+
+- Read the target note with `read_file`.
+- Use `patch` for an anchored append when there is stable context, such as adding a section after an existing heading or appending before a known trailing block.
+- Use `write_file` when rewriting the whole note is clearer than constructing a fragile patch.
+
+For an anchored append with `patch`, replace the anchor with the anchor plus the new content.
+
+For a simple append with no stable context, `terminal` is acceptable if it is the clearest safe option.
+
+## Targeted edits
+
+Use `patch` for focused note changes when the current content gives you stable context. Prefer this over shell text rewriting.
## Wikilinks
diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md
index 221b07fdc0..2bc686e38d 100644
--- a/website/docs/reference/skills-catalog.md
+++ b/website/docs/reference/skills-catalog.md
@@ -136,7 +136,7 @@ If a skill is missing from this list but present in the repo, the catalog is reg
| Skill | Description | Path |
|-------|-------------|------|
-| [`obsidian`](/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian) | Read, search, and create notes in the Obsidian vault. | `note-taking/obsidian` |
+| [`obsidian`](/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian) | Read, search, create, and edit notes in the Obsidian vault. | `note-taking/obsidian` |
## productivity
diff --git a/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md b/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md
index 38ff151902..56e6292b22 100644
--- a/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md
+++ b/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md
@@ -1,14 +1,14 @@
---
-title: "Obsidian — Read, search, and create notes in the Obsidian vault"
+title: "Obsidian — Read, search, create, and edit notes in the Obsidian vault"
sidebar_label: "Obsidian"
-description: "Read, search, and create notes in the Obsidian vault"
+description: "Read, search, create, and edit notes in the Obsidian vault"
---
{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}
# Obsidian
-Read, search, and create notes in the Obsidian vault.
+Read, search, create, and edit notes in the Obsidian vault.
## Skill metadata
@@ -25,61 +25,55 @@ The following is the complete skill definition that Hermes loads when this skill
# Obsidian Vault
-**Location:** Set via `OBSIDIAN_VAULT_PATH` environment variable (e.g. in `~/.hermes/.env`).
+Use this skill for filesystem-first Obsidian vault work: reading notes, listing notes, searching note files, creating notes, appending content, and adding wikilinks.
-If unset, defaults to `~/Documents/Obsidian Vault`.
+## Vault path
-Note: Vault paths may contain spaces - always quote them.
+Use a known or resolved vault path before calling file tools.
+
+The documented vault-path convention is the `OBSIDIAN_VAULT_PATH` environment variable, for example from `~/.hermes/.env`. If it is unset, use `~/Documents/Obsidian Vault`.
+
+File tools do not expand shell variables. Do not pass paths containing `$OBSIDIAN_VAULT_PATH` to `read_file`, `write_file`, `patch`, or `search_files`; resolve the vault path first and pass a concrete absolute path. Vault paths may contain spaces, which is another reason to prefer file tools over shell commands.
+
+If the vault path is unknown, `terminal` is acceptable for resolving `OBSIDIAN_VAULT_PATH` or checking whether the fallback path exists. Once the path is known, switch back to file tools.
## Read a note
-```bash
-VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
-cat "$VAULT/Note Name.md"
-```
+Use `read_file` with the resolved absolute path to the note. Prefer this over `cat` because it provides line numbers and pagination.
## List notes
-```bash
-VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
+Use `search_files` with `target: "files"` and the resolved vault path. Prefer this over `find` or `ls`.
-# All notes
-find "$VAULT" -name "*.md" -type f
-
-# In a specific folder
-ls "$VAULT/Subfolder/"
-```
+- To list all markdown notes, use `pattern: "*.md"` under the vault path.
+- To list a subfolder, search under that subfolder's absolute path.
## Search
-```bash
-VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
+Use `search_files` for both filename and content searches. Prefer this over `grep`, `find`, or `ls`.
-# By filename
-find "$VAULT" -name "*.md" -iname "*keyword*"
-
-# By content
-grep -rli "keyword" "$VAULT" --include="*.md"
-```
+- For filenames, use `search_files` with `target: "files"` and a filename `pattern`.
+- For note contents, use `search_files` with `target: "content"`, the content regex as `pattern`, and `file_glob: "*.md"` when you want to restrict matches to markdown notes.
## Create a note
-```bash
-VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
-cat > "$VAULT/New Note.md" << 'ENDNOTE'
-# Title
-
-Content here.
-ENDNOTE
-```
+Use `write_file` with the resolved absolute path and the full markdown content. Prefer this over shell heredocs or `echo` because it avoids shell quoting issues and returns structured results.
## Append to a note
-```bash
-VAULT="${OBSIDIAN_VAULT_PATH:-$HOME/Documents/Obsidian Vault}"
-echo "
-New content here." >> "$VAULT/Existing Note.md"
-```
+Prefer a native file-tool workflow when it is not awkward:
+
+- Read the target note with `read_file`.
+- Use `patch` for an anchored append when there is stable context, such as adding a section after an existing heading or appending before a known trailing block.
+- Use `write_file` when rewriting the whole note is clearer than constructing a fragile patch.
+
+For an anchored append with `patch`, replace the anchor with the anchor plus the new content.
+
+For a simple append with no stable context, `terminal` is acceptable if it is the clearest safe option.
+
+## Targeted edits
+
+Use `patch` for focused note changes when the current content gives you stable context. Prefer this over shell text rewriting.
## Wikilinks
From 79902a02782cf04b2c38d9b72533ca2c0e32f468 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:51:42 -0700
Subject: [PATCH 041/124] chore: AUTHOR_MAP entry for counterposition
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index 204daff6dc..32762790f2 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -90,6 +90,7 @@ AUTHOR_MAP = {
"jetha@google.com": "jethac",
"jani@0xhoneyjar.xyz": "deep-name",
"xiangyong@zspace.cn": "CES4751",
+ "harish.kukreja@gmail.com": "counterposition",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 5bd75c73ed635ace31897c24b82e297d5901c5a9 Mon Sep 17 00:00:00 2001
From: 0xVox <35294173+Fearvox@users.noreply.github.com>
Date: Sun, 3 May 2026 23:54:32 -0400
Subject: [PATCH 042/124] docs(kanban): document handoff evidence metadata
---
website/docs/user-guide/features/kanban.md | 34 ++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md
index f1bad41a20..c82311538d 100644
--- a/website/docs/user-guide/features/kanban.md
+++ b/website/docs/user-guide/features/kanban.md
@@ -292,6 +292,40 @@ Three reasons:
The `kanban-worker` and `kanban-orchestrator` skills teach the model which tool to call when and in what order.
+### Recommended handoff evidence
+
+`kanban_complete(summary=..., metadata={...})` is intentionally flexible:
+the summary is the human-readable closeout, and `metadata` is the
+machine-readable handoff that downstream agents, reviewers, or dashboards can
+reuse without scraping prose.
+
+For engineering and review tasks, prefer this optional metadata shape:
+
+```json
+{
+ "changed_files": ["path/to/file.py"],
+ "verification": ["pytest tests/hermes_cli/test_kanban_db.py -q"],
+ "dependencies": ["parent task id or external issue, if any"],
+ "blocked_reason": null,
+ "retry_notes": "what failed before, if this was a retry",
+ "residual_risk": ["what was not tested or still needs human review"]
+}
+```
+
+These keys are a convention, not a schema requirement. The useful property is
+that every worker leaves enough evidence for the next reader to answer four
+questions quickly:
+
+1. What changed?
+2. How was it verified?
+3. What can unblock or retry this if it fails?
+4. What risk is still deliberately left open?
+
+Keep secrets, raw logs, tokens, OAuth material, and unrelated transcripts out of
+`metadata`. Store pointers and summaries instead. If a task has no files or
+tests, say so explicitly in `summary` and use `metadata` for the evidence that
+does exist, such as source URLs, issue ids, or manual review steps.
+
### The worker skill
Any profile that should be able to work kanban tasks must load the `kanban-worker` skill. It teaches the worker the full lifecycle in **tool calls**, not CLI commands:
From bb2b129549976a461664e3f96691fe20cfa671e3 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:52:36 -0700
Subject: [PATCH 043/124] chore: AUTHOR_MAP entry for Fearvox
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index 32762790f2..c3623881bd 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -91,6 +91,7 @@ AUTHOR_MAP = {
"jani@0xhoneyjar.xyz": "deep-name",
"xiangyong@zspace.cn": "CES4751",
"harish.kukreja@gmail.com": "counterposition",
+ "35294173+Fearvox@users.noreply.github.com": "Fearvox",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From f13b349b9a8a901072fb26b970b96c2771cbf721 Mon Sep 17 00:00:00 2001
From: LeonSGP43
Date: Sat, 2 May 2026 13:28:36 +0800
Subject: [PATCH 044/124] docs: clarify Telegram group chat troubleshooting
---
website/docs/user-guide/messaging/telegram.md | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md
index eab5212241..d41633e995 100644
--- a/website/docs/user-guide/messaging/telegram.md
+++ b/website/docs/user-guide/messaging/telegram.md
@@ -300,6 +300,28 @@ Hermes Agent works in Telegram group chats with a few considerations:
- Use `telegram.ignored_threads` to keep Hermes silent in specific Telegram forum topics, even when the group would otherwise allow free responses or mention-triggered replies
- If `telegram.require_mention` is left unset or false, Hermes keeps the previous open-group behavior and responds to normal group messages it can see
+### Troubleshooting: works in DMs but not groups
+
+If the bot responds in a private chat but stays silent in a group, check these
+gates in order:
+
+1. **Telegram delivery:** turn off BotFather privacy mode, promote the bot to
+ admin, or mention the bot directly. Hermes cannot respond to group messages
+ that Telegram never delivers to the bot.
+2. **Rejoin after changing privacy:** remove the bot from the group and add it
+ again after changing BotFather privacy settings. Telegram may keep the old
+ delivery behavior for existing memberships.
+3. **Hermes authorization:** make sure the sender is listed in
+ `TELEGRAM_ALLOWED_USERS` or `TELEGRAM_GROUP_ALLOWED_USERS`, or allow the
+ group chat with `TELEGRAM_GROUP_ALLOWED_CHATS`.
+4. **Mention filters:** if `telegram.require_mention: true` is set, normal
+ group chatter is ignored unless the message is a slash command, reply to the
+ bot, `@botusername` mention, or configured `mention_patterns` match.
+
+Negative chat IDs are normal for Telegram groups and supergroups. If you use
+chat-scoped authorization, put those IDs in `TELEGRAM_GROUP_ALLOWED_CHATS`, not
+the sender-user allowlist.
+
### Example group trigger configuration
Add this to `~/.hermes/config.yaml`:
From ca8e68822d997ad6dbc7984a1ff30cdd17c8b9fb Mon Sep 17 00:00:00 2001
From: LeonSGP43
Date: Sat, 2 May 2026 14:08:04 +0800
Subject: [PATCH 045/124] docs(codex): clarify OAuth auth prerequisite
---
skills/autonomous-ai-agents/codex/SKILL.md | 9 ++++++++-
.../autonomous-ai-agents/autonomous-ai-agents-codex.md | 9 ++++++++-
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/skills/autonomous-ai-agents/codex/SKILL.md b/skills/autonomous-ai-agents/codex/SKILL.md
index aa3f358028..40107ed8fd 100644
--- a/skills/autonomous-ai-agents/codex/SKILL.md
+++ b/skills/autonomous-ai-agents/codex/SKILL.md
@@ -26,10 +26,17 @@ Requires the codex CLI and a git repository.
## Prerequisites
- Codex installed: `npm install -g @openai/codex`
-- OpenAI API key configured
+- OpenAI auth configured: either `OPENAI_API_KEY` or Codex OAuth credentials
+ from the Codex CLI login flow
- **Must run inside a git repository** — Codex refuses to run outside one
- Use `pty=true` in terminal calls — Codex is an interactive terminal app
+For Hermes itself, `model.provider: openai-codex` uses Hermes-managed Codex
+OAuth from `~/.hermes/auth.json` after `hermes auth add openai-codex`. For the
+standalone Codex CLI, a valid CLI OAuth session may live under
+`~/.codex/auth.json`; do not treat a missing `OPENAI_API_KEY` alone as proof
+that Codex auth is missing.
+
## One-Shot Tasks
```
diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex.md
index 6f21a4ae6a..1866faf252 100644
--- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex.md
+++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex.md
@@ -44,10 +44,17 @@ Requires the codex CLI and a git repository.
## Prerequisites
- Codex installed: `npm install -g @openai/codex`
-- OpenAI API key configured
+- OpenAI auth configured: either `OPENAI_API_KEY` or Codex OAuth credentials
+ from the Codex CLI login flow
- **Must run inside a git repository** — Codex refuses to run outside one
- Use `pty=true` in terminal calls — Codex is an interactive terminal app
+For Hermes itself, `model.provider: openai-codex` uses Hermes-managed Codex
+OAuth from `~/.hermes/auth.json` after `hermes auth add openai-codex`. For the
+standalone Codex CLI, a valid CLI OAuth session may live under
+`~/.codex/auth.json`; do not treat a missing `OPENAI_API_KEY` alone as proof
+that Codex auth is missing.
+
## One-Shot Tasks
```
From 39560c948dee11244b6df7b11050537f3eabbfd7 Mon Sep 17 00:00:00 2001
From: Yuan Tao-Wen
Date: Fri, 1 May 2026 03:36:01 +0800
Subject: [PATCH 046/124] docs(voice): add Doubao speech integration examples
(TTS + STT)
---
website/docs/user-guide/features/tts.md | 44 ++++++++++++++++++++++++-
1 file changed, 43 insertions(+), 1 deletion(-)
diff --git a/website/docs/user-guide/features/tts.md b/website/docs/user-guide/features/tts.md
index 4e38139f35..5dbcc36b19 100644
--- a/website/docs/user-guide/features/tts.md
+++ b/website/docs/user-guide/features/tts.md
@@ -235,6 +235,30 @@ tts:
output_format: wav
```
+#### Example: Doubao (Chinese seed-tts-2.0)
+
+For high-quality Chinese TTS via ByteDance's [seed-tts-2.0](https://www.volcengine.com/docs/6561/1257544) bidirectional-streaming API, install the [`doubao-speech`](https://pypi.org/project/doubao-speech/) PyPI package and wire it in as a command provider:
+
+```bash
+pip install doubao-speech
+export VOLCENGINE_APP_ID="your-app-id"
+export VOLCENGINE_ACCESS_TOKEN="your-access-token"
+```
+
+```yaml
+tts:
+ provider: doubao
+ providers:
+ doubao:
+ type: command
+ command: "doubao-speech say --text-file {input_path} --out {output_path}"
+ output_format: mp3
+ max_text_length: 1024
+ timeout: 30
+```
+
+Credentials come from your shell environment (`VOLCENGINE_APP_ID` / `VOLCENGINE_ACCESS_TOKEN`) or `~/.doubao-speech/config.yaml`. Pick a voice by adding `--voice zh-female-warm` (or any other alias from `doubao-speech list-voices`) to the command. `doubao-speech` also bundles streaming ASR — see the [STT section below](#example-doubao--volcengine-asr) for Hermes integration. Source and full docs: [github.com/Hypnus-Yuan/doubao-speech](https://github.com/Hypnus-Yuan/doubao-speech).
+
#### Placeholders
Your command template can reference these placeholders. Hermes substitutes them at render time and shell-quotes each value for the surrounding context (bare / single-quoted / double-quoted), so paths with spaces and other shell-sensitive characters are safe.
@@ -323,7 +347,25 @@ stt:
**xAI Grok STT** — Requires `XAI_API_KEY`. Posts to `https://api.x.ai/v1/stt` as multipart/form-data. Good choice if you're already using xAI for chat or TTS and want one API key for everything. Auto-detection order puts it after Groq — explicitly set `stt.provider: xai` to force it.
-**Custom local CLI fallback** — Set `HERMES_LOCAL_STT_COMMAND` if you want Hermes to call a local transcription command directly. The command template supports `{input_path}`, `{output_dir}`, `{language}`, and `{model}` placeholders.
+**Custom local CLI fallback** — Set `HERMES_LOCAL_STT_COMMAND` if you want Hermes to call a local transcription command directly. The command template supports `{input_path}`, `{output_dir}`, `{language}`, and `{model}` placeholders. Your command must write a `.txt` transcript somewhere under `{output_dir}`.
+
+#### Example: Doubao / Volcengine ASR
+
+If you use [`doubao-speech`](https://pypi.org/project/doubao-speech/) for Doubao TTS (see [above](#example-doubao-chinese-seed-tts-20)), the same package handles speech-to-text via the local-command STT surface:
+
+```bash
+pip install doubao-speech
+export VOLCENGINE_APP_ID="your-app-id"
+export VOLCENGINE_ACCESS_TOKEN="your-access-token"
+export HERMES_LOCAL_STT_COMMAND='doubao-speech transcribe {input_path} --out {output_dir}/transcript.txt'
+```
+
+```yaml
+stt:
+ provider: local_command
+```
+
+Hermes writes the incoming voice message to `{input_path}`, runs the command, and reads the `.txt` file produced under `{output_dir}`. Language is auto-detected by the Volcengine bigmodel endpoint.
### Fallback Behavior
From 391e3fff56766a73e7105c278b42400b47a63d3a Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:54:26 -0700
Subject: [PATCH 047/124] chore: AUTHOR_MAP entry for Hypnus-Yuan
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index c3623881bd..f0790cd285 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -92,6 +92,7 @@ AUTHOR_MAP = {
"xiangyong@zspace.cn": "CES4751",
"harish.kukreja@gmail.com": "counterposition",
"35294173+Fearvox@users.noreply.github.com": "Fearvox",
+ "hypnus.yuan@gmail.com": "Hypnus-Yuan",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 144ba71a33344a9a936da48f612fe39e548d67ef Mon Sep 17 00:00:00 2001
From: xsfx20 <15558128926@qq.com>
Date: Fri, 1 May 2026 01:37:21 +0800
Subject: [PATCH 048/124] docs(faq): use messaging extra for gateway deps
---
website/docs/reference/faq.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md
index f4a37dd697..d3b2dc2eed 100644
--- a/website/docs/reference/faq.md
+++ b/website/docs/reference/faq.md
@@ -418,8 +418,8 @@ Configure in `~/.hermes/config.yaml` under your gateway's settings. See the [Mes
**Solution:**
```bash
-# Install messaging dependencies
-pip install "hermes-agent[telegram]" # or [discord], [slack], [whatsapp]
+# Install core messaging gateway dependencies
+pip install "hermes-agent[messaging]" # Telegram, Discord, Slack, and shared gateway deps
# Check for port conflicts
lsof -i :8080
From 587ef55f2c551430f21195a14b7d8d4c89c9babd Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:55:10 -0700
Subject: [PATCH 049/124] chore: AUTHOR_MAP entry for xsfX20
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index f0790cd285..e8b0932fec 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -93,6 +93,7 @@ AUTHOR_MAP = {
"harish.kukreja@gmail.com": "counterposition",
"35294173+Fearvox@users.noreply.github.com": "Fearvox",
"hypnus.yuan@gmail.com": "Hypnus-Yuan",
+ "15558128926@qq.com": "xsfX20",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 1fc8733a698664441d923408f66eaa307d44dd9a Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 13:55:37 -0700
Subject: [PATCH 050/124] fix(kanban): unify failure counter across
spawn/timeout/crash outcomes (#20410)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The dispatcher's circuit breaker only protected against spawn-side
failures (profile missing, workspace mount error, exec failure).
Workers that successfully spawned but then timed out or crashed
re-queued to ``ready`` with no counter increment, so the next tick
re-spawned them — loops forever until someone noticed. Reported
externally on Twitter (Forbidden Seeds) and confirmed by walking the
kernel: ``enforce_max_runtime`` flipped the task back to ready, emitted
a ``timed_out`` event, and never touched ``spawn_failures``; same for
``detect_crashed_workers``.
Fix: unify the counter across all non-success outcomes.
Schema
------
* ``tasks.spawn_failures`` → ``tasks.consecutive_failures``
* ``tasks.last_spawn_error`` → ``tasks.last_failure_error``
* Migration renames the columns in-place on existing DBs (``ALTER
TABLE RENAME COLUMN`` — SQLite >= 3.25) so historical counter
values are preserved. Row mappers fall through to the legacy names
if both column renames and a migration somehow got out of sync.
Counter lifecycle
-----------------
New helper ``_record_task_failure(conn, task_id, error, *, outcome,
release_claim, end_run, event_payload_extra)`` is the single point
every non-success outcome funnels through:
* ``spawn_failed`` → ``_record_spawn_failure`` (kept as alias)
calls it with ``release_claim=True, end_run=True`` — transitions
running→ready, clears claim, closes run.
* ``timed_out`` → ``enforce_max_runtime`` already does the status
transition + run close + event emission, then calls
``_record_task_failure`` with ``release_claim=False, end_run=False``
just to bump the counter (and trip the breaker if needed).
* ``crashed`` → ``detect_crashed_workers`` same pattern, but the
counter increment runs after the main write_txn closes (SQLite
doesn't nest write transactions).
If the counter hits the breaker threshold (``DEFAULT_FAILURE_LIMIT=5``,
same as before), the task transitions to ``blocked`` with a ``gave_up``
event on top of whatever outcome-specific event was already emitted.
Reset semantics changed: the counter now clears only on successful
``complete_task`` (and operator ``reclaim_task`` — an explicit "I've
looked at this, try again with a fresh budget"). Previously
``_clear_spawn_failures`` ran on every successful spawn, which would
have wiped the counter before a timeout could accumulate past threshold
— exactly the loop this fix prevents.
Diagnostics
-----------
* ``_rule_repeated_spawn_failures`` → ``_rule_repeated_failures``. Now
fires regardless of which outcome is at fault. Classifies the most
recent failure (spawn_failed / timed_out / crashed) from the run
history so the title ("Agent timeout x3", "Agent crash x4", "Agent
spawn x5") and suggested action (``doctor`` for spawn, ``log`` for
timeout/crash) stay outcome-specific without N duplicate rules.
* ``_rule_repeated_crashes`` kept as a narrower early-warning at
threshold 2 (vs 3 for the unified rule), but now suppresses itself
when the unified rule would also fire — avoids double-flagging.
* Diagnostic ``data`` payload now carries
``{consecutive_failures, most_recent_outcome, last_error}`` instead
of spawn-specific keys.
CLI
---
* ``Task.consecutive_failures`` / ``Task.last_failure_error`` are the
public fields now. Existing callers that referenced the old names
get migrated (tests updated in this commit).
* Backward-compat: ``DEFAULT_SPAWN_FAILURE_LIMIT``,
``_clear_spawn_failures``, ``_record_spawn_failure`` stay as aliases.
Tests
-----
* 6 new kernel tests: timeout increments counter, 3 consecutive
timeouts trip the breaker (was the reported gap), crash increments
counter, reclaim clears counter, completion clears counter, spawn
success does NOT clear counter.
* Diagnostic tests: updated ``repeated_spawn_failures`` cases to use
the new kind name and add a timeout-loop test.
* Dashboard API test: spawn_failures column update → consecutive_failures.
389/389 kanban-suite tests pass.
Live verification
-----------------
Seeded 4 tasks in an isolated HERMES_HOME: 3 timeouts, 4 crashes,
2-spawn-failed + 2-timed-out, and a task that had prior failures but
completed successfully. Board correctly shows "!! 3 tasks need
attention" (the successful one has no badge because the counter
reset). Drawer for the timeout-loop task renders "Agent timeout x3"
with most_recent_outcome=timed_out and the "Check logs" suggested
action (not the spawn-flavoured "Verify profile"). The successful
task has zero diagnostics.
Closes the Forbidden-Seeds-reported gap.
---
hermes_cli/kanban_db.py | 327 ++++++++++++++----
hermes_cli/kanban_diagnostics.py | 127 +++++--
.../test_kanban_core_functionality.py | 233 ++++++++++++-
tests/hermes_cli/test_kanban_diagnostics.py | 66 +++-
tests/plugins/test_kanban_dashboard_plugin.py | 2 +-
5 files changed, 630 insertions(+), 125 deletions(-)
diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py
index 575c90e32d..f526215094 100644
--- a/hermes_cli/kanban_db.py
+++ b/hermes_cli/kanban_db.py
@@ -573,9 +573,18 @@ class Task:
tenant: Optional[str]
result: Optional[str] = None
idempotency_key: Optional[str] = None
- spawn_failures: int = 0
+ # Unified non-success counter. Incremented on any of:
+ # * spawn failure (dispatcher couldn't launch the worker)
+ # * timed_out outcome (worker exceeded max_runtime_seconds)
+ # * crashed outcome (worker PID vanished)
+ # Reset to 0 only on a successful completion. See
+ # ``_record_task_failure`` for the circuit-breaker trip rule.
+ # (Pre-rename column: ``spawn_failures``.)
+ consecutive_failures: int = 0
worker_pid: Optional[int] = None
- last_spawn_error: Optional[str] = None
+ # Short excerpt of the last failure's error text (any outcome, not
+ # just spawn). Pre-rename column: ``last_spawn_error``.
+ last_failure_error: Optional[str] = None
max_runtime_seconds: Optional[int] = None
last_heartbeat_at: Optional[int] = None
current_run_id: Optional[int] = None
@@ -617,9 +626,15 @@ class Task:
tenant=row["tenant"] if "tenant" in keys else None,
result=row["result"] if "result" in keys else None,
idempotency_key=row["idempotency_key"] if "idempotency_key" in keys else None,
- spawn_failures=row["spawn_failures"] if "spawn_failures" in keys else 0,
+ consecutive_failures=(
+ row["consecutive_failures"] if "consecutive_failures" in keys
+ else (row["spawn_failures"] if "spawn_failures" in keys else 0)
+ ),
worker_pid=row["worker_pid"] if "worker_pid" in keys else None,
- last_spawn_error=row["last_spawn_error"] if "last_spawn_error" in keys else None,
+ last_failure_error=(
+ row["last_failure_error"] if "last_failure_error" in keys
+ else (row["last_spawn_error"] if "last_spawn_error" in keys else None)
+ ),
max_runtime_seconds=(
row["max_runtime_seconds"] if "max_runtime_seconds" in keys else None
),
@@ -735,9 +750,14 @@ CREATE TABLE IF NOT EXISTS tasks (
tenant TEXT,
result TEXT,
idempotency_key TEXT,
- spawn_failures INTEGER NOT NULL DEFAULT 0,
+ -- Unified consecutive-failure counter. Incremented on spawn
+ -- failure, timeout, or crash; reset only on successful completion.
+ -- The circuit breaker in _record_task_failure trips when this
+ -- exceeds DEFAULT_FAILURE_LIMIT consecutive non-successes.
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
worker_pid INTEGER,
- last_spawn_error TEXT,
+ -- Short excerpt of the most recent failure's error text.
+ last_failure_error TEXT,
max_runtime_seconds INTEGER,
last_heartbeat_at INTEGER,
-- Pointer into task_runs for the currently-active run (NULL if no
@@ -933,14 +953,31 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_tasks_idempotency "
"ON tasks(idempotency_key)"
)
- if "spawn_failures" not in cols:
- conn.execute(
- "ALTER TABLE tasks ADD COLUMN spawn_failures INTEGER NOT NULL DEFAULT 0"
- )
+ # Legacy column rename: ``spawn_failures`` → ``consecutive_failures``
+ # and ``last_spawn_error`` → ``last_failure_error``. The counter was
+ # originally spawn-only; it's now unified across spawn/timeout/
+ # crash outcomes. Rename when only the legacy columns exist to
+ # preserve historical counter values across upgrades. Add fresh
+ # otherwise.
+ if "consecutive_failures" not in cols:
+ if "spawn_failures" in cols:
+ conn.execute(
+ "ALTER TABLE tasks RENAME COLUMN spawn_failures TO consecutive_failures"
+ )
+ else:
+ conn.execute(
+ "ALTER TABLE tasks ADD COLUMN consecutive_failures "
+ "INTEGER NOT NULL DEFAULT 0"
+ )
if "worker_pid" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN worker_pid INTEGER")
- if "last_spawn_error" not in cols:
- conn.execute("ALTER TABLE tasks ADD COLUMN last_spawn_error TEXT")
+ if "last_failure_error" not in cols:
+ if "last_spawn_error" in cols:
+ conn.execute(
+ "ALTER TABLE tasks RENAME COLUMN last_spawn_error TO last_failure_error"
+ )
+ else:
+ conn.execute("ALTER TABLE tasks ADD COLUMN last_failure_error TEXT")
if "max_runtime_seconds" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN max_runtime_seconds INTEGER")
if "last_heartbeat_at" not in cols:
@@ -1895,6 +1932,11 @@ def reclaim_task(
},
run_id=run_id,
)
+ # Operator intervention — they've looked at the task, so the
+ # consecutive-failures counter is now stale. Give the next retry
+ # a fresh budget. (_clear_failure_counter opens its own write_txn,
+ # so it runs after the enclosing one commits.)
+ _clear_failure_counter(conn, task_id)
return True
@@ -2186,6 +2228,11 @@ def complete_task(
},
run_id=run_id,
)
+ # Successful completion — wipe the consecutive-failures counter.
+ # Failure history stays on the event log for audit; the counter
+ # just tracks "is there a current pathology the breaker should
+ # care about", and a success resets that question.
+ _clear_failure_counter(conn, task_id)
# Recompute ready status for dependents (separate txn so children see done).
recompute_ready(conn)
return True
@@ -2444,7 +2491,9 @@ def set_workspace_path(
# stops retrying and parks the task in ``blocked`` with a reason so a human
# can investigate. Prevents the dispatcher from thrashing forever on a task
# whose profile doesn't exist, whose workspace is unmountable, etc.
-DEFAULT_SPAWN_FAILURE_LIMIT = 5
+DEFAULT_FAILURE_LIMIT = 5
+# Legacy alias — callers / tests still reference the old name.
+DEFAULT_SPAWN_FAILURE_LIMIT = DEFAULT_FAILURE_LIMIT
# Max bytes to keep in a single worker log file. The dispatcher truncates
# and rotates on spawn if the file is larger than this at spawn time.
@@ -2668,6 +2717,20 @@ def enforce_max_runtime(
conn, tid, "timed_out", payload, run_id=run_id,
)
timed_out.append(tid)
+ # Increment the unified failure counter. Outside the write_txn
+ # above because ``_record_task_failure`` opens its own. If the
+ # breaker trips, this flips the task ``ready → blocked`` and
+ # emits a ``gave_up`` event on top of the ``timed_out`` we
+ # already emitted.
+ if cur.rowcount == 1:
+ _record_task_failure(
+ conn, tid,
+ error=f"elapsed {int(elapsed)}s > limit {int(row['max_runtime_seconds'])}s",
+ outcome="timed_out",
+ release_claim=False,
+ end_run=False,
+ event_payload_extra={"pid": pid, "sigkill": killed},
+ )
return timed_out
@@ -2699,6 +2762,10 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
dispatcher (the whole design is single-host).
"""
crashed: list[str] = []
+ # Per-crash details collected inside the main txn, used after it
+ # closes to run ``_record_task_failure`` (which needs its own
+ # write_txn so can't nest).
+ crash_details: list[tuple[str, int, str]] = [] # (task_id, pid, claimer)
with write_txn(conn):
rows = conn.execute(
"SELECT id, worker_pid, claim_lock FROM tasks "
@@ -2734,67 +2801,169 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
run_id=run_id,
)
crashed.append(row["id"])
+ crash_details.append(
+ (row["id"], int(row["worker_pid"]), row["claim_lock"])
+ )
+ # Outside the main txn: increment the unified failure counter for
+ # each crashed task. If the breaker trips, the task transitions
+ # ready → blocked with a ``gave_up`` event on top of the ``crashed``
+ # event we already emitted.
+ for tid, pid, claimer in crash_details:
+ _record_task_failure(
+ conn, tid,
+ error=f"pid {pid} not alive",
+ outcome="crashed",
+ release_claim=False,
+ end_run=False,
+ event_payload_extra={"pid": pid, "claimer": claimer},
+ )
return crashed
+def _record_task_failure(
+ conn: sqlite3.Connection,
+ task_id: str,
+ error: str,
+ *,
+ outcome: str,
+ failure_limit: int = None,
+ release_claim: bool = False,
+ end_run: bool = False,
+ event_payload_extra: Optional[dict] = None,
+) -> bool:
+ """Record a non-success outcome (spawn_failed / crashed / timed_out)
+ and maybe trip the circuit breaker.
+
+ Unified replacement for the old spawn-only ``_record_spawn_failure``.
+ Every path that ends a task with a non-success outcome funnels
+ through here so the ``consecutive_failures`` counter and the
+ auto-block threshold stay consistent.
+
+ Returns True when the task was auto-blocked (counter reached
+ ``failure_limit``), False when it was just updated in place.
+
+ Modes:
+
+ * ``release_claim=True, end_run=True`` — spawn-failure path.
+ Caller has a running task with an open run; this transitions
+ it back to ``ready`` (or ``blocked`` when the breaker trips),
+ releases the claim, and closes the run with ``outcome=``.
+
+ * ``release_claim=False, end_run=False`` — timeout/crash path.
+ Caller has ALREADY flipped the task to ``ready`` and closed the
+ run with the appropriate outcome. This just increments the
+ counter; if the breaker trips, the task is re-transitioned
+ ``ready → blocked`` and a ``gave_up`` event is emitted.
+
+ ``event_payload_extra`` merges into the ``gave_up`` event payload
+ when the breaker trips, so callers can include outcome-specific
+ context (e.g. pid on crash, elapsed on timeout).
+ """
+ if failure_limit is None:
+ failure_limit = DEFAULT_FAILURE_LIMIT
+ blocked = False
+ with write_txn(conn):
+ row = conn.execute(
+ "SELECT consecutive_failures, status FROM tasks WHERE id = ?", (task_id,),
+ ).fetchone()
+ if row is None:
+ return False
+ failures = int(row["consecutive_failures"]) + 1
+ cur_status = row["status"]
+
+ if failures >= failure_limit:
+ # Trip the breaker.
+ if release_claim:
+ # Spawn path: still running, also clear claim state.
+ conn.execute(
+ "UPDATE tasks SET status = 'blocked', claim_lock = NULL, "
+ "claim_expires = NULL, worker_pid = NULL, "
+ "consecutive_failures = ?, last_failure_error = ? "
+ "WHERE id = ? AND status IN ('running', 'ready')",
+ (failures, error[:500], task_id),
+ )
+ else:
+ # Timeout/crash path: task is already at ``ready``
+ # with claim cleared; just flip to blocked + update
+ # counter fields.
+ conn.execute(
+ "UPDATE tasks SET status = 'blocked', "
+ "consecutive_failures = ?, last_failure_error = ? "
+ "WHERE id = ? AND status IN ('ready', 'running')",
+ (failures, error[:500], task_id),
+ )
+ run_id = None
+ if end_run:
+ # Only the spawn path has an open run to close.
+ run_id = _end_run(
+ conn, task_id,
+ outcome="gave_up", status="gave_up",
+ error=error[:500],
+ metadata={"failures": failures, "trigger_outcome": outcome},
+ )
+ payload = {
+ "failures": failures,
+ "error": error[:500],
+ "trigger_outcome": outcome,
+ }
+ if event_payload_extra:
+ payload.update(event_payload_extra)
+ _append_event(
+ conn, task_id, "gave_up", payload, run_id=run_id,
+ )
+ blocked = True
+ else:
+ # Below threshold.
+ if release_claim:
+ # Spawn path: transition running → ready + clear claim.
+ conn.execute(
+ "UPDATE tasks SET status = 'ready', claim_lock = NULL, "
+ "claim_expires = NULL, worker_pid = NULL, "
+ "consecutive_failures = ?, last_failure_error = ? "
+ "WHERE id = ? AND status = 'running'",
+ (failures, error[:500], task_id),
+ )
+ else:
+ # Timeout/crash path: task is already at ``ready`` via
+ # its own UPDATE. Just bookkeep the counter + last error.
+ conn.execute(
+ "UPDATE tasks SET consecutive_failures = ?, "
+ "last_failure_error = ? WHERE id = ?",
+ (failures, error[:500], task_id),
+ )
+ if end_run:
+ # Spawn path: close the open run with outcome.
+ run_id = _end_run(
+ conn, task_id,
+ outcome=outcome, status=outcome,
+ error=error[:500],
+ metadata={"failures": failures},
+ )
+ _append_event(
+ conn, task_id, outcome,
+ {"error": error[:500], "failures": failures},
+ run_id=run_id,
+ )
+ # Timeout/crash path's caller already emitted its own event.
+ return blocked
+
+
+# Backward-compat alias. Old name is referenced from tests and possibly
+# third-party callers. New code should call ``_record_task_failure``.
def _record_spawn_failure(
conn: sqlite3.Connection,
task_id: str,
error: str,
*,
- failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT,
+ failure_limit: int = None,
) -> bool:
- """Release the claim, increment the failure counter, maybe auto-block.
-
- Returns True when the task was auto-blocked (N failures exceeded),
- False when it was just released back to ``ready`` for another try.
- """
- blocked = False
- with write_txn(conn):
- row = conn.execute(
- "SELECT spawn_failures FROM tasks WHERE id = ?", (task_id,),
- ).fetchone()
- failures = int(row["spawn_failures"]) + 1 if row else 1
- if failures >= failure_limit:
- conn.execute(
- "UPDATE tasks SET status = 'blocked', claim_lock = NULL, "
- "claim_expires = NULL, worker_pid = NULL, "
- "spawn_failures = ?, last_spawn_error = ? "
- "WHERE id = ? AND status IN ('running', 'ready')",
- (failures, error[:500], task_id),
- )
- run_id = _end_run(
- conn, task_id,
- outcome="gave_up", status="gave_up",
- error=error[:500],
- metadata={"failures": failures},
- )
- _append_event(
- conn, task_id, "gave_up",
- {"failures": failures, "error": error[:500]},
- run_id=run_id,
- )
- blocked = True
- else:
- conn.execute(
- "UPDATE tasks SET status = 'ready', claim_lock = NULL, "
- "claim_expires = NULL, worker_pid = NULL, "
- "spawn_failures = ?, last_spawn_error = ? "
- "WHERE id = ? AND status = 'running'",
- (failures, error[:500], task_id),
- )
- run_id = _end_run(
- conn, task_id,
- outcome="spawn_failed", status="spawn_failed",
- error=error[:500],
- metadata={"failures": failures},
- )
- _append_event(
- conn, task_id, "spawn_failed",
- {"error": error[:500], "failures": failures},
- run_id=run_id,
- )
- return blocked
+ return _record_task_failure(
+ conn, task_id, error,
+ outcome="spawn_failed",
+ failure_limit=failure_limit,
+ release_claim=True,
+ end_run=True,
+ )
def _set_worker_pid(conn: sqlite3.Connection, task_id: str, pid: int) -> None:
@@ -2818,16 +2987,28 @@ def _set_worker_pid(conn: sqlite3.Connection, task_id: str, pid: int) -> None:
_append_event(conn, task_id, "spawned", {"pid": int(pid)}, run_id=run_id)
-def _clear_spawn_failures(conn: sqlite3.Connection, task_id: str) -> None:
- """Reset the failure counter after a successful spawn."""
+def _clear_failure_counter(conn: sqlite3.Connection, task_id: str) -> None:
+ """Reset the unified consecutive-failures counter.
+
+ Called from ``complete_task`` on successful completion — a fresh
+ success means the task + profile combination is working and any
+ past failures are history. NOT called on spawn success anymore:
+ a successful spawn proves the worker could start but says nothing
+ about whether the run will succeed, so we need to let timeouts and
+ crashes accumulate across spawn boundaries.
+ """
with write_txn(conn):
conn.execute(
- "UPDATE tasks SET spawn_failures = 0, last_spawn_error = NULL "
- "WHERE id = ?",
+ "UPDATE tasks SET consecutive_failures = 0, "
+ "last_failure_error = NULL WHERE id = ?",
(task_id,),
)
+# Legacy alias for test-code and anything else that still imports it.
+_clear_spawn_failures = _clear_failure_counter
+
+
def has_spawnable_ready(conn: sqlite3.Connection) -> bool:
"""Return True iff there is at least one ready+assigned+unclaimed task
whose assignee maps to a real Hermes profile.
@@ -2964,7 +3145,13 @@ def dispatch_once(
pid = _spawn(claimed, str(workspace))
if pid:
_set_worker_pid(conn, claimed.id, int(pid))
- _clear_spawn_failures(conn, claimed.id)
+ # NOTE: we intentionally do NOT reset consecutive_failures
+ # here. A successful spawn proves the worker can start but
+ # doesn't prove the run will succeed. Under unified
+ # failure counting, resetting on spawn would let a task
+ # that keeps timing out after spawn loop forever. The
+ # counter is cleared only on successful completion (see
+ # complete_task).
result.spawned.append((claimed.id, claimed.assignee or "", str(workspace)))
spawned += 1
except Exception as exc:
diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py
index 5a08ee6df5..d2ba26cb83 100644
--- a/hermes_cli/kanban_diagnostics.py
+++ b/hermes_cli/kanban_diagnostics.py
@@ -312,21 +312,57 @@ def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]:
)]
-def _rule_repeated_spawn_failures(task, events, runs, now, cfg) -> list[Diagnostic]:
- """Task's ``spawn_failures`` counter is climbing — worker can't
- even start. Usually a profile misconfiguration (missing config.yaml,
- bad PATH/venv, wrong credentials).
+def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]:
+ """Task's unified ``consecutive_failures`` counter is climbing —
+ something about this task+profile combo is broken and each retry
+ fails the same way. Triggers regardless of the specific failure
+ mode (spawn error, timeout, crash) because operationally they
+ all look the same: the kernel keeps retrying and the operator
+ needs to intervene.
- Threshold: cfg["spawn_failure_threshold"] (default 3).
+ Threshold: cfg["failure_threshold"] (default 3). A threshold of 3
+ is one below the circuit-breaker's default (5), so the diagnostic
+ surfaces BEFORE the breaker trips — giving operators a window to
+ fix the problem while the dispatcher's still retrying.
+
+ Accepts the legacy ``spawn_failure_threshold`` config key for
+ back-compat.
"""
- threshold = int(cfg.get("spawn_failure_threshold", 3))
- failures = _task_field(task, "spawn_failures", 0)
+ threshold = int(cfg.get(
+ "failure_threshold",
+ cfg.get("spawn_failure_threshold", 3),
+ ))
+ # Read the new unified counter name, with a fallback to the legacy
+ # column name so this rule keeps working against old DB rows the
+ # caller somehow materialised without running the migration.
+ failures = (
+ _task_field(task, "consecutive_failures", None)
+ if _task_field(task, "consecutive_failures", None) is not None
+ else _task_field(task, "spawn_failures", 0)
+ )
if failures is None or failures < threshold:
return []
- last_err = _task_field(task, "last_spawn_error")
+ last_err = (
+ _task_field(task, "last_failure_error", None)
+ if _task_field(task, "last_failure_error", None) is not None
+ else _task_field(task, "last_spawn_error", None)
+ )
assignee = _task_field(task, "assignee")
+
+ # Classify the most recent failure by peeking at run outcomes so
+ # the title + suggested action can be specific without a separate
+ # per-outcome rule.
+ ordered_runs = sorted(runs, key=lambda r: _task_field(r, "id", 0))
+ most_recent_outcome = None
+ for r in reversed(ordered_runs):
+ oc = _task_field(r, "outcome")
+ if oc in ("spawn_failed", "timed_out", "crashed"):
+ most_recent_outcome = oc
+ break
+
actions: list[DiagnosticAction] = []
- if assignee and assignee != "default":
+ if most_recent_outcome == "spawn_failed" and assignee and assignee != "default":
+ # Spawn is failing specifically — profile setup issue.
actions.append(DiagnosticAction(
kind="cli_hint",
label=f"Verify profile: hermes -p {assignee} doctor",
@@ -338,28 +374,49 @@ def _rule_repeated_spawn_failures(task, events, runs, now, cfg) -> list[Diagnost
label=f"Fix profile auth: hermes -p {assignee} auth",
payload={"command": f"hermes -p {assignee} auth"},
))
- actions.extend(_generic_recovery_actions(task, running=False))
+ elif most_recent_outcome in ("timed_out", "crashed"):
+ # Worker got off the ground but died. Logs are the right place
+ # to diagnose; reclaim/reassign are the recovery levers.
+ task_id = _task_field(task, "id")
+ if task_id:
+ actions.append(DiagnosticAction(
+ kind="cli_hint",
+ label=f"Check logs: hermes kanban log {task_id}",
+ payload={"command": f"hermes kanban log {task_id}"},
+ suggested=True,
+ ))
+ actions.extend(_generic_recovery_actions(
+ task, running=_task_field(task, "status") == "running",
+ ))
+
severity = "critical" if failures >= threshold * 2 else "error"
err_text = (last_err or "").strip() if last_err else ""
err_snippet = err_text[:500] + ("…" if len(err_text) > 500 else "") if err_text else ""
+ outcome_label = {
+ "spawn_failed": "spawn",
+ "timed_out": "timeout",
+ "crashed": "crash",
+ }.get(most_recent_outcome or "", "failure")
if err_snippet:
- title = f"Agent spawn failed {failures}x: {err_snippet.splitlines()[0][:160]}"
+ title = f"Agent {outcome_label} x{failures}: {err_snippet.splitlines()[0][:160]}"
detail = (
- f"The dispatcher tried to launch a worker {failures} times "
- f"and failed every time. Full last error:\n\n{err_snippet}\n\n"
- f"Common causes: missing config.yaml, bad venv/PATH, or "
- f"missing credentials for the profile's configured provider."
+ f"This task has failed {failures} times in a row "
+ f"(most recent: {outcome_label}). Full last error:\n\n"
+ f"{err_snippet}\n\n"
+ f"The dispatcher will keep retrying until the consecutive-"
+ f"failures counter trips the circuit breaker (default 5), "
+ f"at which point the task auto-blocks. Fix the root cause "
+ f"and reclaim to retry."
)
else:
- title = f"Agent spawn failed {failures}x (no error recorded)"
+ title = f"Agent {outcome_label} x{failures} (no error recorded)"
detail = (
- f"The dispatcher tried to launch a worker {failures} times "
- f"and failed every time, but no error text was captured. "
- f"Usually a profile configuration issue — check profile "
- f"health with the suggested command."
+ f"This task has failed {failures} times in a row "
+ f"(most recent: {outcome_label}) but no error text was "
+ f"captured. Check the suggested command or the worker log."
)
return [Diagnostic(
- kind="repeated_spawn_failures",
+ kind="repeated_failures",
severity=severity,
title=title,
detail=detail,
@@ -367,7 +424,11 @@ def _rule_repeated_spawn_failures(task, events, runs, now, cfg) -> list[Diagnost
first_seen_at=now,
last_seen_at=now,
count=failures,
- data={"spawn_failures": failures, "last_spawn_error": last_err},
+ data={
+ "consecutive_failures": failures,
+ "most_recent_outcome": most_recent_outcome,
+ "last_error": last_err,
+ },
)]
@@ -378,7 +439,23 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]:
broken (OOM, missing dependency, tool it needs is down).
Threshold: cfg["crash_threshold"] (default 2).
+
+ Narrower than ``repeated_failures`` — fires earlier (2 crashes vs 3
+ total failures) so the operator gets a crash-specific heads-up
+ before the unified rule kicks in. Suppresses itself when the
+ unified rule is also about to fire, to avoid double-flagging.
"""
+ failure_threshold = int(cfg.get(
+ "failure_threshold",
+ cfg.get("spawn_failure_threshold", 3),
+ ))
+ unified_counter = (
+ _task_field(task, "consecutive_failures", 0) or 0
+ )
+ # Unified rule will catch this — let it handle to avoid double fire.
+ if unified_counter >= failure_threshold:
+ return []
+
threshold = int(cfg.get("crash_threshold", 2))
ordered = sorted(runs, key=lambda r: _task_field(r, "id", 0))
# Count trailing consecutive 'crashed' outcomes.
@@ -498,7 +575,7 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]:
_RULES: list[RuleFn] = [
_rule_hallucinated_cards,
_rule_prose_phantom_refs,
- _rule_repeated_spawn_failures,
+ _rule_repeated_failures,
_rule_repeated_crashes,
_rule_stuck_in_blocked,
]
@@ -509,13 +586,15 @@ _RULES: list[RuleFn] = [
DIAGNOSTIC_KINDS = (
"hallucinated_cards",
"prose_phantom_refs",
- "repeated_spawn_failures",
+ "repeated_failures",
"repeated_crashes",
"stuck_in_blocked",
)
DEFAULT_CONFIG = {
+ "failure_threshold": 3,
+ # Legacy alias accepted at read time by _rule_repeated_failures.
"spawn_failure_threshold": 3,
"crash_threshold": 2,
"blocked_stale_hours": 24,
diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py
index 01d623239b..86536596e6 100644
--- a/tests/hermes_cli/test_kanban_core_functionality.py
+++ b/tests/hermes_cli/test_kanban_core_functionality.py
@@ -96,7 +96,7 @@ def test_spawn_failure_auto_blocks_after_limit(kanban_home, all_assignees_spawna
assert tid not in res.auto_blocked
task = kb.get_task(conn, tid)
assert task.status == "ready"
- assert task.spawn_failures == 3
+ assert task.consecutive_failures == 3
# Two more ticks → fifth failure exceeds the limit.
res1 = kb.dispatch_once(conn, spawn_fn=_bad_spawn, failure_limit=5)
@@ -105,15 +105,20 @@ def test_spawn_failure_auto_blocks_after_limit(kanban_home, all_assignees_spawna
assert tid in res2.auto_blocked
task = kb.get_task(conn, tid)
assert task.status == "blocked"
- assert task.spawn_failures >= 5
- assert task.last_spawn_error and "no PATH" in task.last_spawn_error
+ assert task.consecutive_failures >= 5
+ assert task.last_failure_error and "no PATH" in task.last_failure_error
finally:
conn.close()
-def test_successful_spawn_resets_failure_counter(kanban_home, all_assignees_spawnable):
- """A successful spawn clears the counter so past failures don't count
- against future retries of the same task."""
+def test_successful_spawn_does_not_reset_failure_counter(kanban_home, all_assignees_spawnable):
+ """Under unified consecutive-failure counting, a successful spawn
+ does NOT reset the counter — past failures stay on the books until
+ a successful completion. This is by design: it prevents a task
+ that keeps timing out after spawn from looping forever.
+ (Pre-unification behaviour was to reset on spawn success; see the
+ complete_task reset for the replacement point.)
+ """
calls = [0]
def _flaky_spawn(task, ws):
calls[0] += 1
@@ -128,11 +133,12 @@ def test_successful_spawn_resets_failure_counter(kanban_home, all_assignees_spaw
kb.dispatch_once(conn, spawn_fn=_flaky_spawn, failure_limit=5)
kb.dispatch_once(conn, spawn_fn=_flaky_spawn, failure_limit=5)
task = kb.get_task(conn, tid)
- assert task.spawn_failures == 2
+ assert task.consecutive_failures == 2
kb.dispatch_once(conn, spawn_fn=_flaky_spawn, failure_limit=5)
task = kb.get_task(conn, tid)
- assert task.spawn_failures == 0
- assert task.last_spawn_error is None
+ # Counter STAYS at 2 — spawn succeeded but run isn't complete yet.
+ assert task.consecutive_failures == 2
+ assert task.last_failure_error is not None
# Task is now running with a pid.
assert task.status == "running"
assert task.worker_pid == 99999
@@ -140,6 +146,30 @@ def test_successful_spawn_resets_failure_counter(kanban_home, all_assignees_spaw
conn.close()
+def test_successful_completion_resets_failure_counter(kanban_home, all_assignees_spawnable):
+ """A successful kb.complete_task wipes the counter — the task+profile
+ combination proved it can succeed, so past failures are history."""
+ conn = kb.connect()
+ try:
+ tid = kb.create_task(conn, title="x", assignee="worker")
+ # Simulate 2 prior failures on the record.
+ kb.write_txn_ctx = kb.write_txn
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET consecutive_failures = 2, "
+ "last_failure_error = 'old failure' WHERE id = ?",
+ (tid,),
+ )
+ # Complete the task.
+ ok = kb.complete_task(conn, tid, summary="done")
+ assert ok
+ task = kb.get_task(conn, tid)
+ assert task.consecutive_failures == 0
+ assert task.last_failure_error is None
+ finally:
+ conn.close()
+
+
def test_workspace_resolution_failure_also_counts(kanban_home, all_assignees_spawnable):
"""`dir:` workspace with no path should fail workspace resolution AND
count against the failure budget — not just crash the tick."""
@@ -158,9 +188,9 @@ def test_workspace_resolution_failure_also_counts(kanban_home, all_assignees_spa
)
res = kb.dispatch_once(conn, failure_limit=3)
task = kb.get_task(conn, tid)
- assert task.spawn_failures == 1
+ assert task.consecutive_failures == 1
assert task.status == "ready"
- assert task.last_spawn_error and "workspace" in task.last_spawn_error
+ assert task.last_failure_error and "workspace" in task.last_failure_error
# Run twice more → auto-blocked.
kb.dispatch_once(conn, failure_limit=3)
res = kb.dispatch_once(conn, failure_limit=3)
@@ -3052,3 +3082,184 @@ def test_reassign_task_with_reclaim_first_switches_profile(kanban_home):
assert row["status"] == "ready"
finally:
conn.close()
+
+
+# ---------------------------------------------------------------------------
+# Unified failure counter — timeout + crash paths increment the same counter
+# as spawn failures, and the circuit breaker trips after N consecutive
+# failures regardless of which outcome caused them.
+# ---------------------------------------------------------------------------
+
+def test_enforce_max_runtime_increments_consecutive_failures(kanban_home, monkeypatch):
+ """A single timeout increments consecutive_failures by 1 (was the
+ infinite-respawn gap before unification)."""
+ import hermes_cli.kanban_db as _kb
+ state = {"sent_term": False}
+ def _alive(pid):
+ return not state["sent_term"]
+ def _signal(pid, sig):
+ import signal as _sig
+ if sig == _sig.SIGTERM:
+ state["sent_term"] = True
+ monkeypatch.setattr(_kb, "_pid_alive", _alive)
+
+ conn = kb.connect()
+ try:
+ tid = kb.create_task(
+ conn, title="overrun", assignee="worker",
+ max_runtime_seconds=1,
+ )
+ kb.claim_task(conn, tid)
+ kb._set_worker_pid(conn, tid, os.getpid())
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET started_at = ? WHERE id = ?",
+ (int(time.time()) - 30, tid),
+ )
+ before = kb.get_task(conn, tid)
+ assert before.consecutive_failures == 0
+
+ kb.enforce_max_runtime(conn, signal_fn=_signal)
+
+ after = kb.get_task(conn, tid)
+ assert after.consecutive_failures == 1
+ assert "elapsed" in (after.last_failure_error or "")
+ # Task status flipped back to ready (not yet past threshold).
+ assert after.status == "ready"
+ finally:
+ conn.close()
+
+
+def test_repeated_timeouts_trip_the_circuit_breaker(kanban_home, monkeypatch):
+ """N consecutive timeouts with the unified counter should eventually
+ hit the failure_limit threshold and auto-block the task. This closes
+ the Forbidden-Seeds-reported gap where timeout loops never capped.
+ """
+ import hermes_cli.kanban_db as _kb
+ state = {"sent_term": False}
+ def _alive(pid):
+ return not state["sent_term"]
+ def _signal(pid, sig):
+ import signal as _sig
+ if sig == _sig.SIGTERM:
+ state["sent_term"] = True
+ monkeypatch.setattr(_kb, "_pid_alive", _alive)
+
+ conn = kb.connect()
+ try:
+ tid = kb.create_task(
+ conn, title="loop forever", assignee="slow-worker",
+ max_runtime_seconds=1,
+ )
+ # Drop the failure_limit to 3 so we don't need 5 timeouts.
+ # This uses the module-level DEFAULT; we simulate by calling
+ # _record_task_failure directly with a tight limit.
+ for _ in range(3):
+ # Fresh claim + "started long ago" each iteration.
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET status='running', claim_lock=?, "
+ "claim_expires=?, worker_pid=?, started_at=? "
+ "WHERE id=?",
+ (
+ f"{_kb._claimer_id().split(':', 1)[0]}:lock",
+ int(time.time()) + 3600,
+ os.getpid(),
+ int(time.time()) - 30,
+ tid,
+ ),
+ )
+ conn.execute(
+ "INSERT INTO task_runs (task_id, status, claim_lock, "
+ "claim_expires, worker_pid, started_at) "
+ "VALUES (?, 'running', ?, ?, ?, ?)",
+ (
+ tid,
+ f"{_kb._claimer_id().split(':', 1)[0]}:lock",
+ int(time.time()) + 3600,
+ os.getpid(),
+ int(time.time()) - 30,
+ ),
+ )
+ rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
+ conn.execute(
+ "UPDATE tasks SET current_run_id=? WHERE id=?",
+ (rid, tid),
+ )
+ state["sent_term"] = False
+ # Lower the threshold by monkeypatching the default.
+ monkeypatch.setattr(_kb, "DEFAULT_FAILURE_LIMIT", 3)
+ kb.enforce_max_runtime(conn, signal_fn=_signal)
+
+ final = kb.get_task(conn, tid)
+ # After 3 consecutive timeouts with failure_limit=3, task should
+ # be auto-blocked, not looping forever as ``ready``.
+ assert final.status == "blocked", \
+ f"expected blocked after 3 timeouts, got {final.status}"
+ assert final.consecutive_failures >= 3
+ # ``gave_up`` event emitted (plus 3 ``timed_out`` events).
+ kinds = [
+ r["kind"] for r in conn.execute(
+ "SELECT kind FROM task_events WHERE task_id=? ORDER BY id",
+ (tid,),
+ )
+ ]
+ assert kinds.count("timed_out") >= 3
+ assert "gave_up" in kinds
+ finally:
+ conn.close()
+
+
+def test_detect_crashed_workers_increments_counter(kanban_home):
+ """A single crash increments the consecutive_failures counter."""
+ conn = kb.connect()
+ try:
+ tid = kb.create_task(conn, title="crashy", assignee="worker")
+ kb.claim_task(conn, tid)
+ kb._set_worker_pid(conn, tid, 99999) # fake pid — not alive
+
+ kb.detect_crashed_workers(conn)
+
+ task = kb.get_task(conn, tid)
+ assert task.consecutive_failures == 1
+ assert task.status == "ready"
+ finally:
+ conn.close()
+
+
+def test_reclaim_task_clears_failure_counter(kanban_home):
+ """Operator reclaim wipes the counter so the next retry gets a fresh
+ budget."""
+ import secrets
+ conn = kb.connect()
+ try:
+ tid = kb.create_task(conn, title="stuck", assignee="worker")
+ lock = secrets.token_hex(4)
+ with kb.write_txn(conn):
+ conn.execute(
+ "UPDATE tasks SET status='running', claim_lock=?, "
+ "claim_expires=?, worker_pid=?, consecutive_failures=4, "
+ "last_failure_error='prior issue' WHERE id=?",
+ (lock, int(time.time()) + 3600, 12345, tid),
+ )
+ conn.execute(
+ "INSERT INTO task_runs (task_id, status, claim_lock, "
+ "claim_expires, worker_pid, started_at) "
+ "VALUES (?, 'running', ?, ?, ?, ?)",
+ (tid, lock, int(time.time()) + 3600, 12345, int(time.time())),
+ )
+ rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
+ conn.execute(
+ "UPDATE tasks SET current_run_id=? WHERE id=?",
+ (rid, tid),
+ )
+
+ ok = kb.reclaim_task(conn, tid, reason="operator fixed config")
+ assert ok
+
+ task = kb.get_task(conn, tid)
+ assert task.consecutive_failures == 0
+ assert task.last_failure_error is None
+ assert task.status == "ready"
+ finally:
+ conn.close()
diff --git a/tests/hermes_cli/test_kanban_diagnostics.py b/tests/hermes_cli/test_kanban_diagnostics.py
index 0fabd8558e..d39695ca94 100644
--- a/tests/hermes_cli/test_kanban_diagnostics.py
+++ b/tests/hermes_cli/test_kanban_diagnostics.py
@@ -39,8 +39,8 @@ def _task(**overrides):
"title": "demo task",
"assignee": "demo",
"status": "ready",
- "spawn_failures": 0,
- "last_spawn_error": None,
+ "consecutive_failures": 0,
+ "last_failure_error": None,
}
base.update(overrides)
return base
@@ -126,27 +126,55 @@ def test_prose_phantom_refs_clears_on_later_clean_edit():
assert diags == []
-def test_repeated_spawn_failures_fires_at_threshold():
- task = _task(status="blocked", spawn_failures=3,
- last_spawn_error="Profile 'debugger' does not exist")
- diags = kd.compute_task_diagnostics(task, [], [])
+def test_repeated_failures_fires_at_threshold_on_spawn():
+ """A task with multiple spawn_failed runs gets a spawn-flavoured
+ diagnostic (title mentions 'spawn', suggested action is ``doctor``).
+ """
+ task = _task(status="ready", consecutive_failures=3,
+ last_failure_error="Profile 'debugger' does not exist")
+ runs = [
+ _run(outcome="spawn_failed", run_id=1),
+ _run(outcome="spawn_failed", run_id=2),
+ _run(outcome="spawn_failed", run_id=3),
+ ]
+ diags = kd.compute_task_diagnostics(task, [], runs)
assert len(diags) == 1
d = diags[0]
- assert d.kind == "repeated_spawn_failures"
+ assert d.kind == "repeated_failures"
assert d.severity == "error"
# CLI hints are what operators actually need here.
suggested = [a.label for a in d.actions if a.suggested]
assert any("doctor" in s for s in suggested)
-def test_repeated_spawn_failures_escalates_to_critical():
- task = _task(spawn_failures=6, last_spawn_error="boom")
+def test_repeated_failures_fires_on_timeout_loop():
+ """The rule surfaces for timeout loops too — that's the point of
+ unifying the counter. Suggested action is 'check logs', not
+ 'fix profile'."""
+ task = _task(status="ready", consecutive_failures=3,
+ last_failure_error="elapsed 600s > limit 300s")
+ runs = [
+ _run(outcome="timed_out", run_id=1),
+ _run(outcome="timed_out", run_id=2),
+ _run(outcome="timed_out", run_id=3),
+ ]
+ diags = kd.compute_task_diagnostics(task, [], runs)
+ assert len(diags) == 1
+ d = diags[0]
+ assert d.kind == "repeated_failures"
+ assert d.data["most_recent_outcome"] == "timed_out"
+ suggested = [a.label for a in d.actions if a.suggested]
+ assert any("log" in s.lower() for s in suggested)
+
+
+def test_repeated_failures_escalates_to_critical():
+ task = _task(consecutive_failures=6, last_failure_error="boom")
diags = kd.compute_task_diagnostics(task, [], [])
assert diags[0].severity == "critical"
-def test_repeated_spawn_failures_below_threshold_silent():
- task = _task(spawn_failures=2)
+def test_repeated_failures_below_threshold_silent():
+ task = _task(consecutive_failures=2)
assert kd.compute_task_diagnostics(task, [], []) == []
@@ -243,9 +271,9 @@ def test_repeated_crashes_no_error_fallback_title():
assert "no error recorded" in diags[0].title
-def test_repeated_spawn_failures_surfaces_actual_error_in_title():
- task = _task(spawn_failures=5,
- last_spawn_error="insufficient_quota: billing limit reached")
+def test_repeated_failures_surfaces_actual_error_in_title():
+ task = _task(consecutive_failures=5,
+ last_failure_error="insufficient_quota: billing limit reached")
diags = kd.compute_task_diagnostics(task, [], [])
assert len(diags) == 1
d = diags[0]
@@ -280,8 +308,8 @@ def test_repeated_crashes_truncates_huge_tracebacks():
def test_diagnostics_sorted_critical_first():
"""A task with both a critical (many spawn failures) and a warning
(prose phantoms) diagnostic should list the critical one first."""
- task = _task(status="done", spawn_failures=10,
- last_spawn_error="nope")
+ task = _task(status="done", consecutive_failures=10,
+ last_failure_error="nope")
events = [
_event("completed", ts=100, summary="referenced t_missing"),
_event("suspected_hallucinated_references", ts=101,
@@ -289,7 +317,7 @@ def test_diagnostics_sorted_critical_first():
]
diags = kd.compute_task_diagnostics(task, events, [])
kinds = [d.kind for d in diags]
- assert kinds[0] == "repeated_spawn_failures" # critical
+ assert kinds[0] == "repeated_failures" # critical
assert "prose_phantom_refs" in kinds
@@ -346,8 +374,8 @@ def test_broken_rule_is_isolated(monkeypatch):
# rules should still run and produce their diagnostics.
monkeypatch.setattr(kd, "_RULES", [_bad_rule] + kd._RULES)
- task = _task(spawn_failures=5, last_spawn_error="e")
+ task = _task(consecutive_failures=5, last_failure_error="e")
diags = kd.compute_task_diagnostics(task, [], [])
# The broken rule silently drops, the real one still fires.
kinds = [d.kind for d in diags]
- assert "repeated_spawn_failures" in kinds
+ assert "repeated_failures" in kinds
diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py
index 0b6a3510f8..580b187ecc 100644
--- a/tests/plugins/test_kanban_dashboard_plugin.py
+++ b/tests/plugins/test_kanban_dashboard_plugin.py
@@ -1395,7 +1395,7 @@ def test_diagnostics_endpoint_severity_filter(client):
# An error-severity diagnostic (spawn failures) on another
p2 = kb.create_task(conn, title="spawn", assignee="b")
conn.execute(
- "UPDATE tasks SET spawn_failures=5, last_spawn_error='x' WHERE id=?",
+ "UPDATE tasks SET consecutive_failures=5, last_failure_error='x' WHERE id=?",
(p2,),
)
conn.commit()
From 9a0a4c5831256551394c3ca99c3913653ea53691 Mon Sep 17 00:00:00 2001
From: binhnt92
Date: Tue, 7 Apr 2026 20:24:18 +0700
Subject: [PATCH 051/124] docs(guides): add guide for running Hermes locally
with Ollama
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step-by-step guide covering Ollama installation, model selection,
Hermes configuration, speed optimization, and optional gateway bot
setup — all running on local hardware with zero API cost.
Includes hardware requirements, model comparison table with tool-call
support status, context window tuning, GPU offloading tips, fallback
provider setup, troubleshooting, and cost comparison.
---
website/docs/guides/local-ollama-setup.md | 317 ++++++++++++++++++++++
1 file changed, 317 insertions(+)
create mode 100644 website/docs/guides/local-ollama-setup.md
diff --git a/website/docs/guides/local-ollama-setup.md b/website/docs/guides/local-ollama-setup.md
new file mode 100644
index 0000000000..ae0cc445a8
--- /dev/null
+++ b/website/docs/guides/local-ollama-setup.md
@@ -0,0 +1,317 @@
+---
+sidebar_position: 9
+title: "Run Hermes Locally with Ollama — Zero API Cost"
+description: "Step-by-step guide to running Hermes Agent entirely on your own machine with Ollama and open-weight models like Gemma 4, no cloud API keys or paid subscriptions needed"
+---
+
+# Run Hermes Locally with Ollama — Zero API Cost
+
+## The Problem
+
+Cloud LLM APIs charge per token. A heavy coding session can cost $5–20. For personal projects, learning, or privacy-sensitive work, that adds up — and you're sending every conversation to a third party.
+
+## What This Guide Solves
+
+You'll set up Hermes Agent running entirely on your own hardware, using [Ollama](https://ollama.com) as the model backend. No API keys, no subscriptions, no data leaving your machine. Once configured, Hermes works exactly like it does with OpenRouter or Anthropic — terminal commands, file editing, web browsing, delegation — but the model runs locally.
+
+By the end, you'll have:
+
+- Ollama serving one or more open-weight models
+- Hermes connected to Ollama as a custom endpoint
+- A working local agent that can edit files, run commands, and browse the web
+- Optional: a Telegram/Discord bot powered entirely by your own hardware
+
+## What You Need
+
+| Component | Minimum | Recommended |
+|-----------|---------|-------------|
+| **RAM** | 8 GB (for 3B models) | 32+ GB (for 27B+ models) |
+| **Storage** | 5 GB free | 30+ GB (for multiple models) |
+| **CPU** | 4 cores | 8+ cores (AMD EPYC, Ryzen, Intel Xeon) |
+| **GPU** | Not required | NVIDIA GPU with 8+ GB VRAM speeds things up significantly |
+
+:::tip CPU-only works, but expect slower responses
+Ollama runs on CPU-only servers. A 9B model on a modern 8-core CPU gives ~10 tokens/sec. A 31B model on CPU is slower (~2–5 tokens/sec) — each response takes 30–120 seconds, but it works. A GPU dramatically improves this. For CPU-only setups, increase the API timeout in config:
+
+```yaml
+agent:
+ api_timeout: 1800 # 30 minutes — generous for slow local models
+```
+:::
+
+## Step 1: Install Ollama
+
+```bash
+curl -fsSL https://ollama.com/install.sh | sh
+```
+
+Verify it's running:
+
+```bash
+ollama --version
+curl http://localhost:11434/api/tags # Should return {"models":[]}
+```
+
+## Step 2: Pull a Model
+
+Choose based on your hardware:
+
+| Model | Size on Disk | RAM Needed | Tool Calling | Best For |
+|-------|-------------|------------|:------------:|----------|
+| `gemma4:31b` | ~20 GB | 24+ GB | Yes | Best quality — strong tool use and reasoning |
+| `gemma2:27b` | ~16 GB | 20+ GB | No | Conversational tasks, no tool use |
+| `gemma2:9b` | ~5 GB | 8+ GB | No | Fast chat, Q&A — cannot call tools |
+| `llama3.2:3b` | ~2 GB | 4+ GB | No | Lightweight quick answers only |
+
+:::warning Tool calling matters
+Hermes is an **agentic** assistant — it edits files, runs commands, and browses the web through tool calls. Models without tool-call support can only chat; they can't take actions. For the full Hermes experience, use a model that supports tools (like `gemma4:31b`).
+:::
+
+Pull your chosen model:
+
+```bash
+ollama pull gemma4:31b
+```
+
+:::info Multiple models
+You can pull several models and switch between them inside Hermes with `/model`. Ollama loads the active model into memory on demand and unloads idle ones automatically.
+:::
+
+Verify the model works:
+
+```bash
+curl http://localhost:11434/v1/chat/completions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "gemma4:31b",
+ "messages": [{"role": "user", "content": "Say hello"}],
+ "max_tokens": 50
+ }'
+```
+
+You should see a JSON response with the model's reply.
+
+## Step 3: Configure Hermes
+
+Run the Hermes setup wizard:
+
+```bash
+hermes setup
+```
+
+When prompted for a provider, select **Custom Endpoint** and enter:
+
+- **Base URL:** `http://localhost:11434/v1`
+- **API Key:** Leave empty or type `no-key` (Ollama doesn't need one)
+- **Model:** `gemma4:31b` (or whichever model you pulled)
+
+Alternatively, edit `~/.hermes/config.yaml` directly:
+
+```yaml
+model:
+ default: "gemma4:31b"
+ provider: "custom"
+ base_url: "http://localhost:11434/v1"
+```
+
+## Step 4: Start Using Hermes
+
+```bash
+hermes
+```
+
+That's it. You're now running a fully local agent. Try it out:
+
+```
+You: List all Python files in this directory and count the lines of code in each
+
+You: Read the README.md and summarize what this project does
+
+You: Create a Python script that fetches the weather for Ho Chi Minh City
+```
+
+Hermes will use the terminal tool, file operations, and your local model — no cloud calls.
+
+## Step 5: Pick the Right Model for Your Task
+
+Not every task needs the biggest model. Here's a practical guide:
+
+| Task | Recommended Model | Why |
+|------|-------------------|-----|
+| File edits, code, terminal commands | `gemma4:31b` | Only model with reliable tool calling |
+| Quick Q&A (no tool use needed) | `gemma2:9b` | Fast responses for conversational tasks |
+| Lightweight chat | `llama3.2:3b` | Fastest, but very limited capabilities |
+
+:::note
+For full agentic work (editing files, running commands, browsing), `gemma4:31b` is currently the best local option with tool-call support. Check [Ollama's model library](https://ollama.com/library) for newer models — tool-calling support is expanding rapidly.
+:::
+
+Switch models on the fly inside a session:
+
+```
+/model gemma2:9b
+```
+
+## Step 6: Optimize for Speed
+
+### Increase Ollama's Context Window
+
+By default, Ollama uses a 2048-token context. For agentic work (tool calls, long conversations), you need more:
+
+```bash
+# Create a Modelfile that extends context
+cat > /tmp/Modelfile << 'EOF'
+FROM gemma4:31b
+PARAMETER num_ctx 16384
+EOF
+
+ollama create gemma4-16k -f /tmp/Modelfile
+```
+
+Then update your Hermes config to use `gemma4-16k` as the model name.
+
+### Keep the Model Loaded
+
+By default, Ollama unloads models after 5 minutes of inactivity. For a persistent gateway bot, keep it loaded:
+
+```bash
+# Set keep-alive to 24 hours
+curl http://localhost:11434/api/generate \
+ -d '{"model": "gemma4:31b", "keep_alive": "24h"}'
+```
+
+Or set it globally in Ollama's environment:
+
+```bash
+# /etc/systemd/system/ollama.service.d/override.conf
+[Service]
+Environment="OLLAMA_KEEP_ALIVE=24h"
+```
+
+### Use GPU Offloading (If Available)
+
+If you have an NVIDIA GPU, Ollama automatically offloads layers to it. Check with:
+
+```bash
+ollama ps # Shows which model is loaded and how many GPU layers
+```
+
+For a 31B model on a 12 GB GPU, you'll get partial offload (~40 layers on GPU, rest on CPU), which still gives a significant speedup.
+
+## Step 7: Run as a Gateway Bot (Optional)
+
+Once Hermes works locally in the CLI, you can expose it as a Telegram or Discord bot — still running entirely on your hardware.
+
+### Telegram
+
+1. Create a bot via [@BotFather](https://t.me/BotFather) and get the token
+2. Add to your `~/.hermes/config.yaml`:
+
+```yaml
+model:
+ default: "gemma4:31b"
+ provider: "custom"
+ base_url: "http://localhost:11434/v1"
+
+platforms:
+ telegram:
+ enabled: true
+ token: "YOUR_TELEGRAM_BOT_TOKEN"
+```
+
+3. Start the gateway:
+
+```bash
+hermes gateway
+```
+
+Now message your bot on Telegram — it responds using your local model.
+
+### Discord
+
+1. Create a Discord application at [discord.com/developers](https://discord.com/developers/applications)
+2. Add to config:
+
+```yaml
+platforms:
+ discord:
+ enabled: true
+ token: "YOUR_DISCORD_BOT_TOKEN"
+```
+
+3. Start: `hermes gateway`
+
+## Step 8: Set Up Fallbacks (Optional)
+
+Local models can struggle with complex tasks. Set up a cloud fallback that only activates when the local model fails:
+
+```yaml
+model:
+ default: "gemma4:31b"
+ provider: "custom"
+ base_url: "http://localhost:11434/v1"
+
+fallback_providers:
+ - provider: openrouter
+ model: anthropic/claude-sonnet-4
+```
+
+This way, 90% of your usage is free (local), and only the hard tasks hit the paid API.
+
+## Troubleshooting
+
+### "Connection refused" on startup
+
+Ollama isn't running. Start it:
+
+```bash
+sudo systemctl start ollama
+# or
+ollama serve
+```
+
+### Slow responses
+
+- **Check model size vs RAM:** If your model needs more RAM than available, it swaps to disk. Use a smaller model or add RAM.
+- **Check `ollama ps`:** If no GPU layers are offloaded, responses are CPU-bound. This is normal for CPU-only servers.
+- **Reduce context:** Large conversations slow down inference. Use `/compress` regularly, or set a lower compression threshold in config.
+
+### Model doesn't follow tool calls
+
+Smaller models (3B, 7B) sometimes ignore tool-call instructions and produce plain text instead of structured function calls. Solutions:
+
+- **Use a bigger model** — `gemma4:31b` or `gemma2:27b` handle tool calls much better than 3B/7B models.
+- **Hermes has auto-repair** — it detects malformed tool calls and attempts to fix them automatically.
+- **Set up a fallback** — if the local model fails 3 times, Hermes falls back to a cloud provider.
+
+### Context window errors
+
+The default Ollama context (2048 tokens) is too small for agentic work. See [Step 6](#step-6-optimize-for-speed) to increase it.
+
+## Cost Comparison
+
+Here's what running locally saves compared to cloud APIs, based on a typical coding session (~100K tokens input, ~20K tokens output):
+
+| Provider | Cost per Session | Monthly (daily use) |
+|----------|-----------------|---------------------|
+| Anthropic Claude Sonnet | ~$0.80 | ~$24 |
+| OpenRouter (GPT-4o) | ~$0.60 | ~$18 |
+| **Ollama (local)** | **$0.00** | **$0.00** |
+
+Your only cost is electricity — roughly $0.01–0.05 per session depending on hardware.
+
+## What Works Well Locally
+
+- **File editing and code generation** — models 9B+ handle this well
+- **Terminal commands** — Hermes wraps the command, runs it, reads output regardless of model
+- **Web browsing** — the browser tool does the fetching; the model just interprets results
+- **Cron jobs and scheduled tasks** — work identically to cloud setups
+- **Multi-platform gateway** — Telegram, Discord, Slack all work with local models
+
+## What's Better with Cloud Models
+
+- **Very complex multi-step reasoning** — 70B+ or cloud models like Claude Opus are noticeably better
+- **Long context windows** — cloud models offer 100K–1M tokens; local models are typically 8K–32K
+- **Speed on large responses** — cloud inference is faster than CPU-only local for long generations
+
+The sweet spot: use local for everyday tasks, set up a cloud fallback for the hard stuff.
From 92a08c633f1085143d24a4e834e24aeb4751acac Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 14:11:05 -0700
Subject: [PATCH 052/124] chore: AUTHOR_MAP entry for binhnt92
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index e8b0932fec..17da5568c6 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -94,6 +94,7 @@ AUTHOR_MAP = {
"35294173+Fearvox@users.noreply.github.com": "Fearvox",
"hypnus.yuan@gmail.com": "Hypnus-Yuan",
"15558128926@qq.com": "xsfX20",
+ "binhnt.ht.92@gmail.com": "binhnt92",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 1c42d8ff5307849b3c450a5536f641739e220227 Mon Sep 17 00:00:00 2001
From: Zhen Liu
Date: Tue, 14 Apr 2026 17:46:30 +0800
Subject: [PATCH 053/124] docs: add Open WebUI bootstrap script
---
scripts/setup_open_webui.sh | 349 ++++++++++++++++++
.../docs/user-guide/messaging/open-webui.md | 44 ++-
2 files changed, 392 insertions(+), 1 deletion(-)
create mode 100755 scripts/setup_open_webui.sh
diff --git a/scripts/setup_open_webui.sh b/scripts/setup_open_webui.sh
new file mode 100755
index 0000000000..0cca44ddd7
--- /dev/null
+++ b/scripts/setup_open_webui.sh
@@ -0,0 +1,349 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Bootstrap Open WebUI against Hermes Agent's OpenAI-compatible API server.
+#
+# Idempotent by design:
+# - ensures ~/.hermes/.env has API server settings
+# - installs Open WebUI into ~/.local/open-webui-venv
+# - writes a reusable launcher at ~/.local/bin/start-open-webui-hermes.sh
+# - optionally installs a user service (launchd on macOS, systemd --user on Linux)
+#
+# Usage:
+# bash scripts/setup_open_webui.sh
+#
+# Optional environment overrides:
+# OPEN_WEBUI_PORT=8080
+# OPEN_WEBUI_HOST=127.0.0.1
+# OPEN_WEBUI_NAME='Johnny Hermes'
+# OPEN_WEBUI_ENABLE_SIGNUP=true
+# OPEN_WEBUI_ENABLE_SERVICE=auto # auto|true|false
+# OPEN_WEBUI_VENV=~/.local/open-webui-venv
+# OPEN_WEBUI_DATA_DIR=~/.local/share/open-webui/data
+# HERMES_API_PORT=8642
+# HERMES_API_HOST=127.0.0.1
+# HERMES_API_MODEL_NAME='Hermes Agent'
+
+OPEN_WEBUI_PORT="${OPEN_WEBUI_PORT:-8080}"
+OPEN_WEBUI_HOST="${OPEN_WEBUI_HOST:-127.0.0.1}"
+OPEN_WEBUI_NAME="${OPEN_WEBUI_NAME:-Hermes Agent WebUI}"
+OPEN_WEBUI_ENABLE_SIGNUP="${OPEN_WEBUI_ENABLE_SIGNUP:-true}"
+OPEN_WEBUI_ENABLE_SERVICE="${OPEN_WEBUI_ENABLE_SERVICE:-auto}"
+OPEN_WEBUI_VENV="${OPEN_WEBUI_VENV:-$HOME/.local/open-webui-venv}"
+OPEN_WEBUI_DATA_DIR="${OPEN_WEBUI_DATA_DIR:-$HOME/.local/share/open-webui/data}"
+HERMES_ENV_FILE="${HERMES_ENV_FILE:-$HOME/.hermes/.env}"
+HERMES_API_PORT="${HERMES_API_PORT:-8642}"
+HERMES_API_HOST="${HERMES_API_HOST:-127.0.0.1}"
+HERMES_API_CONNECT_HOST="${HERMES_API_CONNECT_HOST:-127.0.0.1}"
+HERMES_API_MODEL_NAME="${HERMES_API_MODEL_NAME:-Hermes Agent}"
+HERMES_API_BASE_URL="http://${HERMES_API_CONNECT_HOST}:${HERMES_API_PORT}/v1"
+LAUNCHER_PATH="$HOME/.local/bin/start-open-webui-hermes.sh"
+LOG_DIR="$HOME/.hermes/logs"
+
+log() {
+ printf '[open-webui-bootstrap] %s\n' "$*"
+}
+
+require_cmd() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "Missing required command: $1" >&2
+ exit 1
+ fi
+}
+
+choose_python() {
+ if command -v python3.11 >/dev/null 2>&1; then
+ echo python3.11
+ elif command -v python3 >/dev/null 2>&1; then
+ echo python3
+ else
+ echo "Python 3 is required." >&2
+ exit 1
+ fi
+}
+
+upsert_env() {
+ local key="$1"
+ local value="$2"
+ local file="$3"
+
+ mkdir -p "$(dirname "$file")"
+ touch "$file"
+
+ python3 - "$file" "$key" "$value" <<'PY'
+from pathlib import Path
+import sys
+path = Path(sys.argv[1])
+key = sys.argv[2]
+value = sys.argv[3]
+lines = path.read_text().splitlines() if path.exists() else []
+out = []
+seen = False
+for raw in lines:
+ stripped = raw.strip()
+ if stripped.startswith(f"{key}="):
+ if not seen:
+ out.append(f"{key}={value}")
+ seen = True
+ continue
+ out.append(raw)
+if not seen:
+ if out and out[-1] != "":
+ out.append("")
+ out.append(f"{key}={value}")
+path.write_text("\n".join(out).rstrip() + "\n")
+PY
+}
+
+get_env_value() {
+ local key="$1"
+ local file="$2"
+ python3 - "$file" "$key" <<'PY'
+from pathlib import Path
+import sys
+path = Path(sys.argv[1])
+key = sys.argv[2]
+if not path.exists():
+ raise SystemExit(0)
+for raw in path.read_text().splitlines():
+ line = raw.strip()
+ if line.startswith(f"{key}="):
+ print(line.split("=", 1)[1])
+ raise SystemExit(0)
+PY
+}
+
+generate_secret() {
+ python3 - <<'PY'
+import secrets
+print(secrets.token_urlsafe(32))
+PY
+}
+
+shell_quote() {
+ python3 - "$1" <<'PY'
+import shlex
+import sys
+print(shlex.quote(sys.argv[1]))
+PY
+}
+
+can_use_systemd_user() {
+ [[ "$(uname -s)" == "Linux" ]] || return 1
+ command -v systemctl >/dev/null 2>&1 || return 1
+
+ local uid runtime_dir bus_path
+ uid="$(id -u)"
+ runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$uid}"
+ bus_path="$runtime_dir/bus"
+
+ if [[ -z "${XDG_RUNTIME_DIR:-}" && -d "$runtime_dir" ]]; then
+ export XDG_RUNTIME_DIR="$runtime_dir"
+ fi
+ if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" && -S "$bus_path" ]]; then
+ export DBUS_SESSION_BUS_ADDRESS="unix:path=$bus_path"
+ fi
+
+ systemctl --user show-environment >/dev/null 2>&1
+}
+
+install_macos_dependencies() {
+ if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; then
+ if ! command -v pandoc >/dev/null 2>&1; then
+ log 'Installing pandoc with Homebrew (recommended by Open WebUI docs)...'
+ brew install pandoc
+ fi
+ fi
+}
+
+install_open_webui() {
+ local py
+ py="$(choose_python)"
+ log "Using Python interpreter: $py"
+ "$py" -m venv "$OPEN_WEBUI_VENV"
+ # shellcheck disable=SC1090
+ source "$OPEN_WEBUI_VENV/bin/activate"
+ python -m pip install --upgrade pip setuptools wheel
+ python -m pip install open-webui
+}
+
+write_launcher() {
+ mkdir -p "$(dirname "$LAUNCHER_PATH")" "$OPEN_WEBUI_DATA_DIR" "$LOG_DIR"
+
+ local quoted_data_dir quoted_name quoted_base_url quoted_host quoted_port quoted_venv
+ quoted_data_dir="$(shell_quote "$OPEN_WEBUI_DATA_DIR")"
+ quoted_name="$(shell_quote "$OPEN_WEBUI_NAME")"
+ quoted_base_url="$(shell_quote "$HERMES_API_BASE_URL")"
+ quoted_host="$(shell_quote "$OPEN_WEBUI_HOST")"
+ quoted_port="$(shell_quote "$OPEN_WEBUI_PORT")"
+ quoted_venv="$(shell_quote "$OPEN_WEBUI_VENV")"
+
+ cat > "$LAUNCHER_PATH" </dev/null || true
+}
+
+install_launchd_service() {
+ local plist="$HOME/Library/LaunchAgents/ai.openwebui.hermes.plist"
+ mkdir -p "$(dirname "$plist")"
+ cat > "$plist" <
+
+
+
+ Label
+ ai.openwebui.hermes
+ ProgramArguments
+
+ /bin/bash
+ ${LAUNCHER_PATH}
+
+ RunAtLoad
+
+ KeepAlive
+
+ WorkingDirectory
+ ${HOME}
+ StandardOutPath
+ ${LOG_DIR}/openwebui.log
+ StandardErrorPath
+ ${LOG_DIR}/openwebui.error.log
+
+
+EOF
+ launchctl bootout "gui/$(id -u)" "$plist" >/dev/null 2>&1 || true
+ launchctl bootstrap "gui/$(id -u)" "$plist"
+ launchctl enable "gui/$(id -u)/ai.openwebui.hermes"
+ launchctl kickstart -k "gui/$(id -u)/ai.openwebui.hermes"
+}
+
+install_systemd_user_service() {
+ require_cmd systemctl
+ local unit_dir="$HOME/.config/systemd/user"
+ local unit="$unit_dir/openwebui-hermes.service"
+ mkdir -p "$unit_dir"
+ cat > "$unit" </dev/null 2>&1 || true
+ sleep 4
+ if ! curl -fsS "http://${HERMES_API_CONNECT_HOST}:${HERMES_API_PORT}/health" >/dev/null; then
+ log 'Hermes API server did not answer on the first check. Trying to start gateway in the background...'
+ nohup hermes gateway run >/dev/null 2>&1 &
+ sleep 6
+ fi
+ curl -fsS "http://${HERMES_API_CONNECT_HOST}:${HERMES_API_PORT}/health" >/dev/null
+
+ log 'Installing Open WebUI into a dedicated virtualenv...'
+ install_open_webui
+ write_launcher
+
+ case "$OPEN_WEBUI_ENABLE_SERVICE" in
+ true|auto)
+ if [[ "$(uname -s)" == "Darwin" ]]; then
+ install_launchd_service
+ elif can_use_systemd_user; then
+ install_systemd_user_service
+ else
+ log 'No usable user service manager detected; falling back to the launcher script.'
+ start_foreground_hint
+ fi
+ ;;
+ false)
+ start_foreground_hint
+ ;;
+ *)
+ echo "OPEN_WEBUI_ENABLE_SERVICE must be one of: auto, true, false" >&2
+ exit 1
+ ;;
+ esac
+
+ log "Done. Open WebUI should be available at: http://${OPEN_WEBUI_HOST}:${OPEN_WEBUI_PORT}"
+ log "Hermes API endpoint: ${HERMES_API_BASE_URL}"
+ log 'Important: Open WebUI persists connection settings after first launch. If you later save a wrong API key in the Admin UI, update/delete that connection there or reset its database.'
+}
+
+main "$@"
diff --git a/website/docs/user-guide/messaging/open-webui.md b/website/docs/user-guide/messaging/open-webui.md
index 9c90eb7998..4366a0e65e 100644
--- a/website/docs/user-guide/messaging/open-webui.md
+++ b/website/docs/user-guide/messaging/open-webui.md
@@ -24,6 +24,44 @@ Open WebUI talks to Hermes server-to-server, so you do not need `API_SERVER_CORS
## Quick Setup
+### One-command local bootstrap (macOS/Linux, no Docker)
+
+If you want Hermes + Open WebUI wired together locally with a reusable launcher, run:
+
+```bash
+cd ~/.hermes/hermes-agent
+bash scripts/setup_open_webui.sh
+```
+
+What the script does:
+
+- ensures `~/.hermes/.env` contains `API_SERVER_ENABLED`, `API_SERVER_HOST`, `API_SERVER_KEY`, `API_SERVER_PORT`, and `API_SERVER_MODEL_NAME`
+- restarts the Hermes gateway so the API server comes up
+- installs Open WebUI into `~/.local/open-webui-venv`
+- writes a launcher at `~/.local/bin/start-open-webui-hermes.sh`
+- on macOS, installs a `launchd` user service; on Linux with `systemd --user`, installs a user service there
+
+Defaults:
+
+- Hermes API: `http://127.0.0.1:8642/v1`
+- Open WebUI: `http://127.0.0.1:8080`
+- model name advertised to Open WebUI: `Hermes Agent`
+
+Useful overrides:
+
+```bash
+OPEN_WEBUI_NAME='My Hermes UI' \
+OPEN_WEBUI_ENABLE_SIGNUP=true \
+HERMES_API_MODEL_NAME='My Hermes Agent' \
+bash scripts/setup_open_webui.sh
+```
+
+On Linux, automatic background service setup requires a working `systemd --user` session. If you are on a headless SSH box and want to skip service installation, run:
+
+```bash
+OPEN_WEBUI_ENABLE_SERVICE=false bash scripts/setup_open_webui.sh
+```
+
### 1. Enable the API server
```bash
@@ -124,7 +162,7 @@ If you prefer to configure the connection through the UI instead of environment
5. Click **+ Add New Connection**
6. Enter:
- **URL**: `http://host.docker.internal:8642/v1`
- - **API Key**: your key or any non-empty value (e.g., `not-needed`)
+ - **API Key**: the exact same value as `API_SERVER_KEY` in Hermes
7. Click the **checkmark** to verify the connection
8. **Save**
@@ -219,6 +257,10 @@ Hermes Agent may be executing multiple tool calls (reading files, running comman
Make sure your `OPENAI_API_KEY` in Open WebUI matches the `API_SERVER_KEY` in Hermes Agent.
+:::warning
+Open WebUI persists OpenAI-compatible connection settings in its own database after first launch. If you accidentally saved a wrong key in the Admin UI, fixing the environment variables alone is not enough — update or delete the saved connection in **Admin Settings → Connections**, or reset the Open WebUI data directory / database.
+:::
+
## Multi-User Setup with Profiles
To run separate Hermes instances per user — each with their own config, memory, and skills — use [profiles](/docs/user-guide/profiles). Each profile runs its own API server on a different port and automatically advertises the profile name as the model in Open WebUI.
From a860a1098fe7196c449be7a28420fcbff784c60e Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 14:11:58 -0700
Subject: [PATCH 054/124] chore: AUTHOR_MAP entry for acesjohnny
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index 17da5568c6..4d0b606a27 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -95,6 +95,7 @@ AUTHOR_MAP = {
"hypnus.yuan@gmail.com": "Hypnus-Yuan",
"15558128926@qq.com": "xsfX20",
"binhnt.ht.92@gmail.com": "binhnt92",
+ "johnny@Jons-MBA-M4.local": "acesjohnny",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From a11234dd68107228f7f4c9f2b8c3eea3de7aa31a Mon Sep 17 00:00:00 2001
From: liuyuqi <1581133593@qq.com>
Date: Sun, 12 Apr 2026 18:34:13 +0800
Subject: [PATCH 055/124] docs(browser): document WSL-to-Windows Chrome MCP
bridge
---
website/docs/guides/use-mcp-with-hermes.md | 75 +++++++++++++++++++++
website/docs/reference/faq.md | 18 +++++
website/docs/user-guide/features/browser.md | 16 +++++
3 files changed, 109 insertions(+)
diff --git a/website/docs/guides/use-mcp-with-hermes.md b/website/docs/guides/use-mcp-with-hermes.md
index 23f3813886..6d86eea1ee 100644
--- a/website/docs/guides/use-mcp-with-hermes.md
+++ b/website/docs/guides/use-mcp-with-hermes.md
@@ -109,6 +109,81 @@ mcp_servers:
This is usually the best default for sensitive systems.
+## WSL2: bridge Hermes in WSL to Windows Chrome
+
+This is the practical setup when:
+
+- Hermes runs inside WSL2
+- the browser you want to control is your normal signed-in Chrome on Windows
+- `/browser connect` is awkward or unreliable from WSL
+
+In this setup, Hermes does **not** connect to Chrome directly. Instead:
+
+- Hermes runs in WSL
+- Hermes starts a local stdio MCP server
+- that MCP server is launched through Windows interop (`cmd.exe` or `powershell.exe`)
+- the MCP server attaches to your live Windows Chrome session
+
+Mental model:
+
+```text
+Hermes (WSL) -> MCP stdio bridge -> Windows Chrome
+```
+
+### Why this mode is useful
+
+- you keep your real Windows browser profile, cookies, and logins
+- Hermes stays in its supported Unix environment (WSL2)
+- browser control is exposed as MCP tools instead of relying on Hermes core browser transport
+
+### Recommended server
+
+Use `chrome-devtools-mcp`.
+
+If your Windows Chrome already has live remote debugging enabled from `chrome://inspect/#remote-debugging`, add it like this from WSL:
+
+```bash
+hermes mcp add chrome-devtools-win --command cmd.exe --args /c "npx -y chrome-devtools-mcp@latest --autoConnect --no-usage-statistics"
+```
+
+After saving the server:
+
+```bash
+hermes mcp test chrome-devtools-win
+```
+
+Then start a fresh Hermes session or run:
+
+```text
+/reload-mcp
+```
+
+### Typical prompt
+
+Once loaded, Hermes can use the MCP-prefixed browser tools directly. For example:
+
+```text
+调用 MCP 工具 mcp_chrome_devtools_win_list_pages,列出当前浏览器标签页。
+```
+
+### When `/browser connect` is the wrong tool
+
+If Hermes runs in WSL and Chrome runs on Windows, `/browser connect` may fail even though Chrome is open and debuggable.
+
+Common reasons:
+
+- WSL cannot reach the same host-local endpoint Chrome exposes to Windows tools
+- newer Chrome live-debugging flows are not the same as a classic `ws://localhost:9222`
+- the browser is easier to attach to from a Windows-side helper like `chrome-devtools-mcp`
+
+In those cases, keep `/browser connect` for same-environment setups and use MCP for WSL-to-Windows browser bridging.
+
+### Known pitfalls
+
+- Start Hermes from a Windows-mounted path like `/mnt/c/Users/` or `/mnt/c/workspace/...` when using Windows stdio executables through MCP.
+- If you start Hermes from `/root` or `/home/...`, Windows may emit a `UNC` current-directory warning before the MCP server starts.
+- If `chrome-devtools-mcp --autoConnect` times out while enumerating pages, reduce background/frozen tabs in Chrome and retry.
+
### Example: blacklist dangerous actions
```yaml
diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md
index d3b2dc2eed..ca1c61a443 100644
--- a/website/docs/reference/faq.md
+++ b/website/docs/reference/faq.md
@@ -36,6 +36,24 @@ Set your provider with `hermes model` or by editing `~/.hermes/.env`. See the [E
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
+### I run Hermes in WSL2. What's the best way to control my normal Windows Chrome?
+
+Prefer an MCP bridge over `/browser connect`.
+
+Recommended pattern:
+
+- run Hermes inside WSL2
+- keep using your normal signed-in Chrome on Windows
+- add `chrome-devtools-mcp` as an MCP server through `cmd.exe` or `powershell.exe`
+- let Hermes use the resulting MCP browser tools
+
+This is more reliable than trying to force Hermes core browser transport to attach directly across the WSL2/Windows boundary.
+
+See:
+
+- [Use MCP with Hermes](../guides/use-mcp-with-hermes.md#wsl2-bridge-hermes-in-wsl-to-windows-chrome)
+- [Browser Automation](../user-guide/features/browser.md#wsl2--windows-chrome-prefer-mcp-over-browser-connect)
+
### Does it work on Android / Termux?
Yes — Hermes now has a tested Termux install path for Android phones.
diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md
index a5b1e39d00..c078ed4976 100644
--- a/website/docs/user-guide/features/browser.md
+++ b/website/docs/user-guide/features/browser.md
@@ -284,6 +284,22 @@ Then launch the Hermes CLI and run `/browser connect`.
When connected via CDP, all browser tools (`browser_navigate`, `browser_click`, etc.) operate on your live Chrome instance instead of spinning up a cloud session.
+### WSL2 + Windows Chrome: prefer MCP over `/browser connect`
+
+If Hermes runs inside WSL2 but the Chrome window you want to control runs on the Windows host, `/browser connect` is often not the best path.
+
+Why:
+
+- `/browser connect` expects Hermes itself to reach a usable CDP endpoint
+- modern Chrome live-debugging sessions often expose a host-local endpoint that is not directly reachable from WSL the same way a classic `9222` port is
+- even when Windows Chrome is debuggable, the cleanest integration is often to let a Windows-side browser MCP server attach to Chrome and let Hermes talk to that MCP server
+
+For that setup, prefer `chrome-devtools-mcp` through Hermes MCP support.
+
+See the MCP guide for the practical setup:
+
+- [Use MCP with Hermes](../../guides/use-mcp-with-hermes.md#wsl2-bridge-hermes-in-wsl-to-windows-chrome)
+
### Local browser mode
If you do **not** set any cloud credentials and don't use `/browser connect`, Hermes can still use the browser tools through a local Chromium install driven by `agent-browser`.
From a321874ab45a452b6d52b01ff00eaf0bbafcc2cc Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 14:12:38 -0700
Subject: [PATCH 056/124] chore: AUTHOR_MAP entry for liu-collab
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index 4d0b606a27..d749b30a71 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -96,6 +96,7 @@ AUTHOR_MAP = {
"15558128926@qq.com": "xsfX20",
"binhnt.ht.92@gmail.com": "binhnt92",
"johnny@Jons-MBA-M4.local": "acesjohnny",
+ "1581133593@qq.com": "liu-collab",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 74e4f5f97aca5471cfa0b595aa94e1a10e5f3b4e Mon Sep 17 00:00:00 2001
From: haidao1919
Date: Sat, 18 Apr 2026 02:09:06 +0800
Subject: [PATCH 057/124] docs(i18n): add zh-Hans Tool Gateway, image gen, and
Windows WSL guide
Made-with: Cursor
---
scripts/release.py | 1 +
.../docs/user-guide/windows-wsl-quickstart.md | 22 +++
website/docusaurus.config.ts | 17 +-
.../user-guide/features/image-generation.md | 153 ++++++++++++++
.../user-guide/features/tool-gateway.md | 187 ++++++++++++++++++
.../user-guide/windows-wsl-quickstart.md | 65 ++++++
website/sidebars.ts | 1 +
7 files changed, 444 insertions(+), 2 deletions(-)
create mode 100644 website/docs/user-guide/windows-wsl-quickstart.md
create mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/image-generation.md
create mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tool-gateway.md
create mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md
diff --git a/scripts/release.py b/scripts/release.py
index d749b30a71..95bf5eda81 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -97,6 +97,7 @@ AUTHOR_MAP = {
"binhnt.ht.92@gmail.com": "binhnt92",
"johnny@Jons-MBA-M4.local": "acesjohnny",
"1581133593@qq.com": "liu-collab",
+ "haidaoe@proton.me": "haidao1919",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
diff --git a/website/docs/user-guide/windows-wsl-quickstart.md b/website/docs/user-guide/windows-wsl-quickstart.md
new file mode 100644
index 0000000000..7500694121
--- /dev/null
+++ b/website/docs/user-guide/windows-wsl-quickstart.md
@@ -0,0 +1,22 @@
+---
+title: "Windows (WSL2) Quick Start"
+description: "Run Hermes Agent on Windows using WSL2 — supported path for CLI and Tool Gateway"
+sidebar_label: "Windows (WSL2)"
+sidebar_position: 2
+---
+
+# Windows (WSL2) Quick Start
+
+Hermes Agent is developed and tested on **Linux** and **macOS**. On Windows, the supported setup is **WSL2** (Windows Subsystem for Linux), not legacy native Windows shells.
+
+:::info Full guide in Chinese
+The detailed checklist (WSL2, `uv`, repo clone, gateway tips) is maintained in **简体中文**. Use the **language** menu (top right) and select **简体中文**, then open this same page again.
+:::
+
+## Minimum path
+
+1. Install [WSL2](https://learn.microsoft.com/windows/wsl/install) and a recent Ubuntu (or another supported distro).
+2. Open your WSL terminal and follow [Installation](/getting-started/installation) inside that environment.
+3. Run `hermes model` / `hermes tools` from WSL so paths, process isolation, and the Tool Gateway match upstream expectations.
+
+For Tool Gateway and image tooling behavior, see [Tool Gateway](/user-guide/features/tool-gateway) and [Image Generation](/user-guide/features/image-generation).
diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts
index 551242b758..6d6904d6cb 100644
--- a/website/docusaurus.config.ts
+++ b/website/docusaurus.config.ts
@@ -24,7 +24,16 @@ const config: Config = {
i18n: {
defaultLocale: 'en',
- locales: ['en'],
+ locales: ['en', 'zh-Hans'],
+ localeConfigs: {
+ en: {
+ label: 'English',
+ },
+ 'zh-Hans': {
+ label: '简体中文',
+ htmlLang: 'zh-Hans',
+ },
+ },
},
themes: [
@@ -34,7 +43,7 @@ const config: Config = {
/** @type {import("@easyops-cn/docusaurus-search-local").PluginOptions} */
({
hashed: true,
- language: ['en'],
+ language: ['en', 'zh'],
indexBlog: false,
docsRouteBasePath: '/',
// Disabled: appends ?_highlight=... to URLs (before the #anchor),
@@ -104,6 +113,10 @@ const config: Config = {
label: 'Skills',
position: 'left',
},
+ {
+ type: 'localeDropdown',
+ position: 'right',
+ },
{
href: 'https://hermes-agent.nousresearch.com',
label: 'Home',
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/image-generation.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/image-generation.md
new file mode 100644
index 0000000000..29b22d972e
--- /dev/null
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/image-generation.md
@@ -0,0 +1,153 @@
+---
+title: 文生图(Image Generation)
+description: 通过 FAL.ai 文生图;支持 8 个模型,含 FLUX 2、GPT-Image、Nano Banana Pro、Ideogram、Recraft V4 Pro 等,可用 hermes tools 切换。
+sidebar_label: 文生图
+sidebar_position: 6
+---
+
+# 文生图(Image Generation)
+
+Hermes Agent 通过 FAL.ai 根据文字提示生成图像。默认内置 8 个模型,在速度、画质与成本上各有取舍。当前模型可通过 `hermes tools` 配置,并持久化在 `config.yaml`。
+
+## 支持的模型
+
+| 模型 | 速度 | 特点 | 参考价格 |
+|------|------|------|----------|
+| `fal-ai/flux-2/klein/9b` *(默认)* | `<1s` | 快、文字清晰 | $0.006/MP |
+| `fal-ai/flux-2-pro` | ~6s | 棚拍级写实 | $0.03/MP |
+| `fal-ai/z-image/turbo` | ~2s | 中英双语,6B | $0.005/MP |
+| `fal-ai/nano-banana-pro` | ~8s | Gemini 3 Pro、推理与文字渲染 | $0.15/张(1K) |
+| `fal-ai/gpt-image-1.5` | ~15s | 强指令遵循 | $0.034/张 |
+| `fal-ai/ideogram/v3` | ~5s | 排版最佳 | $0.03–0.09/张 |
+| `fal-ai/recraft/v4/pro/text-to-image` | ~8s | 设计 / 品牌系统 / 可交付生产 | $0.25/张 |
+| `fal-ai/qwen-image` | ~12s | 偏 LLM 式、复杂文字 | $0.02/MP |
+
+价格为撰写时的 FAL 官方口径;最新计费请以 [fal.ai](https://fal.ai/) 为准。
+
+## 配置
+
+:::tip Nous 订阅用户
+若你持有付费 [Nous Portal](https://portal.nousresearch.com) 订阅,可通过 **[Tool Gateway](tool-gateway.md)** 使用文生图,**无需** `FAL_KEY`。模型选择在「直连 FAL」与「订阅网关」两条路径下保持一致。
+
+若托管网关对某一模型返回 `HTTP 4xx`,通常表示该模型尚未在 Portal 侧代理——智能体会给出处理建议(例如配置 `FAL_KEY` 直连,或换用其他模型)。
+:::
+
+### 获取 FAL API Key
+
+1. 在 [fal.ai](https://fal.ai/) 注册
+2. 在控制台生成 API Key
+
+### 配置并选择模型
+
+执行:
+
+```bash
+hermes tools
+```
+
+进入 **🎨 Image Generation**,选择后端(Nous Subscription 或 FAL.ai),随后在表格中用方向键选择模型,回车确认:
+
+```
+ Model Speed Strengths Price
+ fal-ai/flux-2/klein/9b <1s Fast, crisp text $0.006/MP ← currently in use
+ fal-ai/flux-2-pro ~6s Studio photorealism $0.03/MP
+ fal-ai/z-image/turbo ~2s Bilingual EN/CN, 6B $0.005/MP
+ ...
+```
+
+选择会写入 `config.yaml`:
+
+```yaml
+image_gen:
+ model: fal-ai/flux-2/klein/9b
+ use_gateway: false # 使用 Nous Subscription 时为 true
+```
+
+### GPT-Image 画质档位
+
+`fal-ai/gpt-image-1.5` 的请求画质固定为 `medium`(约 1024×1024 下 $0.034/张)。面向用户**不开放** `low` / `high` 档位,以便 Nous Portal 侧计费在全体用户间更可预期(档位价差约 22×)。若需要更便宜的 GPT-Image 路线,请换其他模型;若追求更高画质,可考虑 Klein 9B 或同类 Imagen 系模型。
+
+## 使用方式
+
+对智能体暴露的 schema 刻意保持简单——具体行为由你在本机的配置决定:
+
+```
+Generate an image of a serene mountain landscape with cherry blossoms
+```
+
+```
+Create a square portrait of a wise old owl — use the typography model
+```
+
+```
+Make me a futuristic cityscape, landscape orientation
+```
+
+## 宽高比
+
+从智能体视角,三个宽高比词对所有模型通用;内部会映射到各模型原生参数:
+
+| 智能体输入 | image_size(flux/z-image/qwen/recraft/ideogram) | aspect_ratio(nano-banana-pro) | image_size(gpt-image) |
+|---|---|---|---|
+| `landscape` | `landscape_16_9` | `16:9` | `1536x1024` |
+| `square` | `square_hd` | `1:1` | `1024x1024` |
+| `portrait` | `portrait_16_9` | `9:16` | `1024x1536` |
+
+该映射在 `_build_fal_payload()` 中完成,智能体代码无需了解各模型 schema 差异。
+
+## 自动超分(Upscale)
+
+是否启用 FAL **Clarity Upscaler** 按模型区分:
+
+| 模型 | 超分? | 原因 |
+|---|---|---|
+| `fal-ai/flux-2-pro` | ✓ | 历史兼容(选择器出现前的默认) |
+| 其他 | ✗ | 亚秒级模型若再超分会失去速度优势;高分辨率模型本身已足够清晰 |
+
+超分启用时的主要参数:
+
+| 项 | 值 |
+|---|---|
+| 放大倍数 | 2× |
+| Creativity | 0.35 |
+| Resemblance | 0.6 |
+| Guidance scale | 4 |
+| Inference steps | 18 |
+
+若超分失败(网络、限流等),会自动回退为返回原始图像。
+
+## 内部流程概要
+
+1. **模型解析** — `_resolve_fal_model()` 读取 `config.yaml` 的 `image_gen.model`,否则看 `FAL_IMAGE_MODEL` 环境变量,再否则默认 `fal-ai/flux-2/klein/9b`。
+2. **构造请求体** — `_build_fal_payload()` 将 `aspect_ratio` 转为各模型枚举或字面量,合并默认参数与调用方覆盖,并按 `supports` 白名单过滤非法字段。
+3. **提交** — `_submit_fal_request()` 根据凭据走直连 FAL 或 Nous 托管网关。
+4. **超分** — 仅当模型元数据标记 `upscale: True` 时执行。
+5. **交付** — 最终图像 URL 返回给智能体,并发出 `MEDIA:`,由各平台适配器转为原生媒体消息。
+
+## 调试
+
+打开调试日志:
+
+```bash
+export IMAGE_TOOLS_DEBUG=true
+```
+
+日志写入 `./logs/image_tools_debug_.json`,包含每次调用的模型、参数、耗时与错误信息。
+
+## 各平台展示
+
+| 平台 | 行为 |
+|---|---|
+| **CLI** | 图像 URL 以 Markdown `` 打印,可点击打开 |
+| **Telegram** | 以图片消息发送,附提示词为说明 |
+| **Discord** | 嵌入消息 |
+| **Slack** | URL 由 Slack 展开预览 |
+| **WhatsApp** | 媒体消息 |
+| **其他** | 纯文本中的 URL |
+
+## 限制
+
+- **需要 FAL 凭据**(直连 `FAL_KEY` 或 Nous 订阅网关)
+- **仅文生图** — 不支持局部重绘、图生图或编辑类工作流
+- **临时 URL** — FAL 托管链接会在数小时至数天后过期;请自行落盘保存
+- **按模型能力裁剪** — 部分模型不支持 `seed`、`num_inference_steps` 等;`supports` 会静默丢弃不支持的参数,属预期行为
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tool-gateway.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tool-gateway.md
new file mode 100644
index 0000000000..e561641571
--- /dev/null
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tool-gateway.md
@@ -0,0 +1,187 @@
+---
+title: "Nous Tool Gateway(工具网关)"
+description: "通过 Nous 订阅统一使用网页搜索、文生图、语音合成与浏览器自动化,无需单独申请 Firecrawl、FAL、OpenAI、Browser Use 等 API Key"
+sidebar_label: "Tool Gateway"
+sidebar_position: 2
+---
+
+# Nous Tool Gateway(工具网关)
+
+:::tip 快速开始
+Tool Gateway 包含在付费 Nous Portal 订阅中。**[管理订阅 →](https://portal.nousresearch.com/manage-subscription)**
+:::
+
+**Tool Gateway** 让已付费的 [Nous Portal](https://portal.nousresearch.com) 用户通过同一份订阅,直接使用网页搜索、文生图、语音合成(TTS)与浏览器自动化,而**不必**再分别注册 Firecrawl、FAL、OpenAI、Browser Use 等服务的 API Key。
+
+## 包含能力
+
+| 工具 | 作用 | 若不用网关,可改用 |
+|------|------|---------------------|
+| **网页搜索与抓取** | 通过 Firecrawl 搜索并抽取页面内容 | `FIRECRAWL_API_KEY`、`EXA_API_KEY`、`PARALLEL_API_KEY`、`TAVILY_API_KEY` |
+| **文生图** | 通过 FAL 生成图像(8 个模型:FLUX 2 Klein/Pro、GPT-Image、Nano Banana Pro、Ideogram、Recraft V4 Pro、Qwen、Z-Image) | `FAL_KEY` |
+| **语音合成** | 通过 OpenAI TTS 将文字转为语音 | `VOICE_TOOLS_OPENAI_KEY`、`ELEVENLABS_API_KEY` |
+| **浏览器自动化** | 通过 Browser Use 控制云端浏览器 | `BROWSER_USE_API_KEY`、`BROWSERBASE_API_KEY` |
+
+上述四类能力均计入 Nous 订阅计费。你可以按需组合——例如网页与文生图走网关,TTS 仍使用自己的 ElevenLabs Key。
+
+## 资格与账号
+
+Tool Gateway 仅对 **[付费](https://portal.nousresearch.com/manage-subscription)** Nous Portal 订阅开放;免费档不可用——请 [升级订阅](https://portal.nousresearch.com/manage-subscription) 后解锁。
+
+检查当前状态:
+
+```bash
+hermes status
+```
+
+在输出中找到 **Nous Tool Gateway** 小节:会标明哪些工具经订阅网关启用、哪些使用直连 Key、哪些尚未配置。
+
+## 如何启用 Tool Gateway
+
+### 在模型配置流程中
+
+运行 `hermes model` 并选择 Nous Portal 作为提供商时,Hermes 会主动询问是否启用 Tool Gateway:
+
+```
+Your Nous subscription includes the Tool Gateway.
+
+ The Tool Gateway gives you access to web search, image generation,
+ text-to-speech, and browser automation through your Nous subscription.
+ No need to sign up for separate API keys — just pick the tools you want.
+
+ ○ Web search & extract (Firecrawl) — not configured
+ ○ Image generation (FAL) — not configured
+ ○ Text-to-speech (OpenAI TTS) — not configured
+ ○ Browser automation (Browser Use) — not configured
+
+ ● Enable Tool Gateway
+ ○ Skip
+```
+
+选择 **Enable Tool Gateway** 即可。
+
+若 `.env` 中已有部分直连 API Key,提示会相应变化:可为全部工具启用网关(直连 Key 仍保留在 `.env` 但运行时不用)、仅为未配置项启用,或完全跳过。
+
+### 通过 `hermes tools`
+
+也可在交互式工具配置中逐项启用:
+
+```bash
+hermes tools
+```
+
+选择工具类别(Web、Browser、Image Generation、TTS),再将提供商选为 **Nous Subscription**。这会在配置里把对应工具的 `use_gateway` 设为 `true`。
+
+### 手动编辑配置
+
+在 `~/.hermes/config.yaml` 中直接设置 `use_gateway`:
+
+```yaml
+web:
+ backend: firecrawl
+ use_gateway: true
+
+image_gen:
+ use_gateway: true
+
+tts:
+ provider: openai
+ use_gateway: true
+
+browser:
+ cloud_provider: browser-use
+ use_gateway: true
+```
+
+## 工作原理
+
+当某工具的 `use_gateway: true` 时,运行时会把 API 调用路由到 Nous Tool Gateway,而不是使用直连 Key:
+
+1. **网页工具** — `web_search` / `web_extract` 走网关的 Firecrawl 端点
+2. **文生图** — `image_generate` 走网关的 FAL 端点
+3. **TTS** — `text_to_speech` 走网关的 OpenAI Audio 端点
+4. **浏览器** — `browser_navigate` 等走网关的 Browser Use 端点
+
+网关使用 Nous Portal 凭据认证(在 `hermes model` 完成后写入 `~/.hermes/auth.json`)。
+
+### 优先级
+
+每个工具都会先看 `use_gateway`:
+
+- **`use_gateway: true`** → 强制走网关,即使 `.env` 里仍有直连 Key
+- **`use_gateway: false`**(或未设置)→ 若有直连 Key 则优先直连;仅在没有直连凭据时才回退到网关
+
+因此你可以在网关与直连之间切换,而无需删除 `.env` 中的旧 Key。
+
+## 切回直连 Key
+
+对单个工具停用网关:
+
+```bash
+hermes tools # 选择该工具 → 选直连提供商
+```
+
+或在配置中设 `use_gateway: false`:
+
+```yaml
+web:
+ backend: firecrawl
+ use_gateway: false # 此时使用 .env 中的 FIRECRAWL_API_KEY
+```
+
+在 `hermes tools` 中选择非网关提供商时,`use_gateway` 会自动设为 `false`,避免配置自相矛盾。
+
+## 查看状态
+
+```bash
+hermes status
+```
+
+**Nous Tool Gateway** 小节示例:
+
+```
+◆ Nous Tool Gateway
+ Nous Portal ✓ managed tools available
+ Web tools ✓ active via Nous subscription
+ Image gen ✓ active via Nous subscription
+ TTS ✓ active via Nous subscription
+ Browser ○ active via Browser Use key
+ Modal ○ available via subscription (optional)
+```
+
+标记为 “active via Nous subscription” 的即经网关路由;带自有 Key 的会显示当前激活的提供商。
+
+## 进阶:自建网关
+
+若使用自建或自定义网关,可在 `~/.hermes/.env` 中用环境变量覆盖端点:
+
+```bash
+TOOL_GATEWAY_DOMAIN=nousresearch.com # 网关路由基础域名
+TOOL_GATEWAY_SCHEME=https # http 或 https(默认 https)
+TOOL_GATEWAY_USER_TOKEN=your-token # 鉴权 Token(通常由程序自动填充)
+FIRECRAWL_GATEWAY_URL=https://... # 单独覆盖 Firecrawl 端点
+```
+
+这些变量与订阅状态无关,始终可在配置中看到,便于自建基础设施。
+
+## 常见问题
+
+### 需要删掉已有的 API Key 吗?
+
+不需要。`use_gateway: true` 时运行时会跳过直连 Key 并走网关;Key 仍保留在 `.env`。之后若关闭网关,会自动恢复使用直连 Key。
+
+### 能否部分工具走网关、部分走直连?
+
+可以。`use_gateway` 按工具独立配置。例如:网页与文生图走网关,TTS 用 ElevenLabs,浏览器用 Browserbase。
+
+### 订阅到期会怎样?
+
+经网关路由的工具会停止工作,直到你 [续订](https://portal.nousresearch.com/manage-subscription) 或通过 `hermes tools` 改回直连 Key。
+
+### 与「消息网关」(各聊天平台)是否冲突?
+
+不冲突。Tool Gateway 作用于**工具运行时**的 API 路由,与 CLI、Telegram、Discord 等入口无关。
+
+### Modal 算在 Tool Gateway 里吗?
+
+Modal(无服务器终端后端)可作为 Nous 订阅的可选附加能力,但**不会**由 Tool Gateway 安装向导一并打开——请单独通过 `hermes setup terminal` 或在 `config.yaml` 中配置。
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md
new file mode 100644
index 0000000000..a058fc0cc2
--- /dev/null
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md
@@ -0,0 +1,65 @@
+---
+title: "Windows 用户快速上手(WSL2)"
+description: "在 Windows 上通过 WSL2 安装 uv、Hermes 与 Tool Gateway 的推荐路径与常见坑"
+sidebar_label: "Windows(WSL2)"
+sidebar_position: 2
+---
+
+# Windows 用户快速上手(WSL2)
+
+上游开发与 CI 以 **Linux / macOS** 为主;在 Windows 上,**官方推荐路径是 WSL2**,而不是在「旧版原生 CMD/PowerShell」里直接跑完整 Hermes 栈。本页给出从 0 到可跑 `hermes` + Tool Gateway 的最短闭环。
+
+## 1. 安装 WSL2 与发行版
+
+1. 以管理员打开 PowerShell,安装 WSL 与默认 Ubuntu(具体命令以 [微软文档](https://learn.microsoft.com/zh-cn/windows/wsl/install) 为准):
+ ```powershell
+ wsl --install
+ ```
+2. 重启后完成 Ubuntu 首次用户名/密码设置。
+3. 在 Microsoft Store 或 `wsl --list --online` 中可选用较新 Ubuntu LTS,便于获得较新的 `glibc` 与 Python 工具链。
+
+:::caution 关于「原生 Windows」
+若你只在 PowerShell 里装 Python/uv,可能遇到路径、子进程、网关单例与 Token 缓存等与上游假设不一致的问题。**请优先在 WSL 终端内**完成安装与日常使用。
+:::
+
+## 2. 在 WSL 内安装 `uv`
+
+在 **WSL 的 Bash** 中执行(勿混用 Windows 路径):
+
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+将 `uv` 加入当前 shell 的 `PATH`(安装脚本结尾会提示),然后:
+
+```bash
+uv --version
+```
+
+## 3. 获取 Hermes Agent
+
+在 WSL 里 clone 本仓库(或你的 fork),进入目录后按 [安装说明](/getting-started/installation) 使用 `uv sync` / 文档中的推荐命令安装依赖。
+
+:::tip 路径与权限
+Hermes 默认配置目录为 `~/.hermes/`(在 WSL 内即 Linux 家目录)。请勿把 WSL 项目放在会被 Windows 杀毒实时深度扫描的极慢盘符上;推荐放在 WSL 文件系统(例如 `~/projects/...`)而非 `/mnt/c/...` 下的重度 IO 路径。
+:::
+
+## 4. 模型与 Tool Gateway
+
+1. 在 WSL 内运行 `hermes model`,按提示绑定 **Nous Portal**(或其他提供商)。
+2. 付费订阅用户可启用 **[Tool Gateway](/user-guide/features/tool-gateway)**,用于网页搜索、文生图、TTS、浏览器自动化等,而无需单独配置 `FAL_KEY` / Firecrawl 等(详见该页)。
+3. 文生图模型列表与计费说明见 **[文生图](/user-guide/features/image-generation)**。
+
+## 5. 常见故障速查
+
+| 现象 | 建议 |
+|------|------|
+| 网关相关进程重复 / 端口占用 | 确认是否同时在 Windows 侧与 WSL 侧各启动了一份 agent;同一机器上只保留**一个**常驻会话。 |
+| `hermes` 找不到 | 确认 `uv run hermes` 或按安装文档将 CLI 暴露到 `PATH`;命令应在 **WSL** 内执行。 |
+| 图像工具 4xx | 可能是 Portal 尚未代理该 FAL 模型;可换模型或配置直连 `FAL_KEY`(见文生图文档)。 |
+
+## 6. 下一步
+
+- 英文摘要页(默认语言):仍保留轻量说明,便于非中文读者理解 WSL2 要求。
+- 深入 CLI:见 [CLI 界面](/user-guide/cli)。
+- 全局配置项:见 [配置说明](/user-guide/configuration)。
diff --git a/website/sidebars.ts b/website/sidebars.ts
index a24366474e..96ea3d6179 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -23,6 +23,7 @@ const sidebars: SidebarsConfig = {
items: [
'user-guide/cli',
'user-guide/tui',
+ 'user-guide/windows-wsl-quickstart',
'user-guide/configuration',
'user-guide/configuring-models',
'user-guide/sessions',
From 05cdcac36240df5ef1348f7f527cc3e1a341282d Mon Sep 17 00:00:00 2001
From: zhangguangtao <50561768+zhanggttry@users.noreply.github.com>
Date: Tue, 21 Apr 2026 20:31:52 +0800
Subject: [PATCH 058/124] docs: add Chinese (zh-CN) README translation
Closes #12954
- Add README.zh-CN.md with complete Simplified Chinese translation
- Add language switcher badge in README.md linking to Chinese version
- Add language switcher badge in README.zh-CN.md linking to English version
---
README.md | 1 +
README.zh-CN.md | 186 ++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 187 insertions(+)
create mode 100644 README.zh-CN.md
diff --git a/README.md b/README.md
index fc4abde2cc..2674cabe77 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@
+
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 0000000000..ea7fea8dcc
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,186 @@
+
+
+
+
+# Hermes Agent ☤
+
+
+
+
+
+
+
+
+
+**由 [Nous Research](https://nousresearch.com) 构建的自进化 AI 代理。** 它是唯一内置学习闭环的智能代理——从经验中创建技能,在使用中改进技能,主动持久化知识,搜索过往对话,并在跨会话中逐步构建对你的深度理解。可以在 $5 的 VPS 上运行,也可以在 GPU 集群上运行,或者使用几乎零成本的 Serverless 基础设施。它不绑定你的笔记本——你可以在 Telegram 上与它对话,而它在云端 VM 上工作。
+
+支持任意模型——[Nous Portal](https://portal.nousresearch.com)、[OpenRouter](https://openrouter.ai)(200+ 模型)、[NVIDIA NIM](https://build.nvidia.com)(Nemotron)、[小米 MiMo](https://platform.xiaomimimo.com)、[z.ai/GLM](https://z.ai)、[Kimi/Moonshot](https://platform.moonshot.ai)、[MiniMax](https://www.minimax.io)、[Hugging Face](https://huggingface.co)、OpenAI,或自定义端点。使用 `hermes model` 即可切换——无需改代码,无锁定。
+
+
+| 真正的终端界面 | 完整的 TUI,支持多行编辑、斜杠命令自动补全、对话历史、中断重定向和流式工具输出。 |
+| 随你所在 | Telegram、Discord、Slack、WhatsApp、Signal 和 CLI——全部从单个网关进程运行。语音备忘录转写、跨平台对话连续性。 |
+| 闭环学习 | 代理管理记忆并定期自我提醒。复杂任务后自动创建技能。技能在使用中自我改进。FTS5 会话搜索配合 LLM 摘要实现跨会话回溯。Honcho 辩证式用户建模。兼容 agentskills.io 开放标准。 |
+| 定时自动化 | 内置 cron 调度器,支持向任何平台投递。日报、夜间备份、周审计——全部用自然语言描述,无人值守运行。 |
+| 委派与并行 | 生成隔离子代理处理并行工作流。编写 Python 脚本通过 RPC 调用工具,将多步管道压缩为零上下文开销的轮次。 |
+| 随处运行 | 六种终端后端——本地、Docker、SSH、Daytona、Singularity 和 Modal。Daytona 和 Modal 提供 Serverless 持久化——代理环境空闲时休眠、按需唤醒,空闲期间几乎零成本。$5 VPS 或 GPU 集群都能跑。 |
+| 研究就绪 | 批量轨迹生成、Atropos RL 环境、轨迹压缩——用于训练下一代工具调用模型。 |
+
+
+---
+
+## 快速安装
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
+```
+
+支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。
+
+> **Android / Termux:** 已测试的手动安装路径请参考 [Termux 指南](https://hermes-agent.nousresearch.com/docs/getting-started/termux)。在 Termux 上,Hermes 会安装精选的 `.[termux]` 扩展,因为完整的 `.[all]` 扩展会拉取 Android 不兼容的语音依赖。
+>
+> **Windows:** 原生 Windows 不受支持。请安装 [WSL2](https://learn.microsoft.com/zh-cn/windows/wsl/install) 并运行上述命令。
+
+安装后:
+
+```bash
+source ~/.bashrc # 重新加载 shell(或: source ~/.zshrc)
+hermes # 开始对话!
+```
+
+---
+
+## 快速入门
+
+```bash
+hermes # 交互式 CLI — 开始对话
+hermes model # 选择 LLM 提供商和模型
+hermes tools # 配置启用的工具
+hermes config set # 设置单个配置项
+hermes gateway # 启动消息网关(Telegram、Discord 等)
+hermes setup # 运行完整设置向导(一次性配置所有内容)
+hermes claw migrate # 从 OpenClaw 迁移(如果来自 OpenClaw)
+hermes update # 更新到最新版本
+hermes doctor # 诊断问题
+```
+
+📖 **[完整文档 →](https://hermes-agent.nousresearch.com/docs/)**
+
+## CLI 与消息平台 快速对照
+
+Hermes 有两种入口:用 `hermes` 启动终端 UI,或运行网关从 Telegram、Discord、Slack、WhatsApp、Signal 或 Email 与之对话。进入对话后,许多斜杠命令在两种界面中通用。
+
+| 操作 | CLI | 消息平台 |
+|------|-----|----------|
+| 开始对话 | `hermes` | 运行 `hermes gateway setup` + `hermes gateway start`,然后给机器人发消息 |
+| 开始新对话 | `/new` 或 `/reset` | `/new` 或 `/reset` |
+| 更换模型 | `/model [provider:model]` | `/model [provider:model]` |
+| 设置人格 | `/personality [name]` | `/personality [name]` |
+| 重试或撤销上一轮 | `/retry`、`/undo` | `/retry`、`/undo` |
+| 压缩上下文 / 查看用量 | `/compress`、`/usage`、`/insights [--days N]` | `/compress`、`/usage`、`/insights [days]` |
+| 浏览技能 | `/skills` 或 `/` | `/skills` 或 `/` |
+| 中断当前工作 | `Ctrl+C` 或发送新消息 | `/stop` 或发送新消息 |
+| 平台特定状态 | `/platforms` | `/status`、`/sethome` |
+
+完整命令列表请参阅 [CLI 指南](https://hermes-agent.nousresearch.com/docs/user-guide/cli) 和 [消息网关指南](https://hermes-agent.nousresearch.com/docs/user-guide/messaging)。
+
+---
+
+## 文档
+
+所有文档位于 **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)**:
+
+| 章节 | 内容 |
+|------|------|
+| [快速开始](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | 安装 → 设置 → 2 分钟内开始首次对话 |
+| [CLI 使用](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | 命令、快捷键、人格、会话 |
+| [配置](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | 配置文件、提供商、模型、所有选项 |
+| [消息网关](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram、Discord、Slack、WhatsApp、Signal、Home Assistant |
+| [安全](https://hermes-agent.nousresearch.com/docs/user-guide/security) | 命令审批、DM 配对、容器隔离 |
+| [工具与工具集](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40+ 工具、工具集系统、终端后端 |
+| [技能系统](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | 过程记忆、技能中心、创建技能 |
+| [记忆](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | 持久记忆、用户画像、最佳实践 |
+| [MCP 集成](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | 连接任意 MCP 服务器扩展能力 |
+| [定时调度](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | 定时任务与平台投递 |
+| [上下文文件](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | 影响每次对话的项目上下文 |
+| [架构](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | 项目结构、代理循环、关键类 |
+| [贡献](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | 开发设置、PR 流程、代码风格 |
+| [CLI 参考](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | 所有命令和标志 |
+| [环境变量](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | 完整环境变量参考 |
+
+---
+
+## 从 OpenClaw 迁移
+
+如果你来自 OpenClaw,Hermes 可以自动导入你的设置、记忆、技能和 API 密钥。
+
+**首次安装时:** 安装向导(`hermes setup`)会自动检测 `~/.openclaw` 并在配置开始前提供迁移选项。
+
+**安装后任意时间:**
+
+```bash
+hermes claw migrate # 交互式迁移(完整预设)
+hermes claw migrate --dry-run # 预览将要迁移的内容
+hermes claw migrate --preset user-data # 仅迁移用户数据,不含密钥
+hermes claw migrate --overwrite # 覆盖已有冲突
+```
+
+导入内容:
+- **SOUL.md** — 人格文件
+- **记忆** — MEMORY.md 和 USER.md 条目
+- **技能** — 用户创建的技能 → `~/.hermes/skills/openclaw-imports/`
+- **命令白名单** — 审批模式
+- **消息设置** — 平台配置、允许用户、工作目录
+- **API 密钥** — 白名单中的密钥(Telegram、OpenRouter、OpenAI、Anthropic、ElevenLabs)
+- **TTS 资产** — 工作区音频文件
+- **工作区指令** — AGENTS.md(使用 `--workspace-target`)
+
+使用 `hermes claw migrate --help` 查看所有选项,或使用 `openclaw-migration` 技能进行交互式代理引导迁移(含干运行预览)。
+
+---
+
+## 贡献
+
+欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。
+
+贡献者快速开始——克隆并使用 `setup-hermes.sh`:
+
+```bash
+git clone https://github.com/NousResearch/hermes-agent.git
+cd hermes-agent
+./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes
+./hermes # 自动检测 venv,无需先 source
+```
+
+手动安装(等效于上述命令):
+
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+uv venv venv --python 3.11
+source venv/bin/activate
+uv pip install -e ".[all,dev]"
+python -m pytest tests/ -q
+```
+
+> **RL 训练(可选):** 如需参与 RL/Tinker-Atropos 集成开发:
+> ```bash
+> git submodule update --init tinker-atropos
+> uv pip install -e "./tinker-atropos"
+> ```
+
+---
+
+## 社区
+
+- 💬 [Discord](https://discord.gg/NousResearch)
+- 📚 [技能中心](https://agentskills.io)
+- 🐛 [问题反馈](https://github.com/NousResearch/hermes-agent/issues)
+- 💡 [讨论区](https://github.com/NousResearch/hermes-agent/discussions)
+- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — 社区微信桥接:在同一微信账号上运行 Hermes Agent 和 OpenClaw。
+
+---
+
+## 许可证
+
+MIT — 详见 [LICENSE](LICENSE)。
+
+由 [Nous Research](https://nousresearch.com) 构建。
From f97d022149043ac92db49fce9f4900cd16b1c764 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 14:14:55 -0700
Subject: [PATCH 059/124] chore: AUTHOR_MAP entry for zhanggttry
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index 95bf5eda81..f6c9a04cdd 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -98,6 +98,7 @@ AUTHOR_MAP = {
"johnny@Jons-MBA-M4.local": "acesjohnny",
"1581133593@qq.com": "liu-collab",
"haidaoe@proton.me": "haidao1919",
+ "50561768+zhanggttry@users.noreply.github.com": "zhanggttry",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 0d945d1541eece83efa3f19bf9fc3550e55a32e6 Mon Sep 17 00:00:00 2001
From: Jun Han
Date: Sun, 19 Apr 2026 17:33:33 +0800
Subject: [PATCH 060/124] docs: update VS Code setup instructions for ACP
Client integration
---
website/docs/user-guide/features/acp.md | 20 +++++++++++++-------
1 file changed, 13 insertions(+), 7 deletions(-)
diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md
index 3b1dce824e..1822f7adfa 100644
--- a/website/docs/user-guide/features/acp.md
+++ b/website/docs/user-guide/features/acp.md
@@ -67,18 +67,24 @@ Hermes logs to stderr so stdout remains reserved for ACP JSON-RPC traffic.
### VS Code
-Install an ACP client extension, then point it at the repo's `acp_registry/` directory.
+Install the [ACP Client](https://marketplace.visualstudio.com/items?itemName=formulahendry.acp-client) extension.
-Example settings snippet:
+To connect:
+
+1. Open the ACP Client panel from the Activity Bar.
+2. Select **Hermes Agent** from the built-in agent list.
+3. Connect and start chatting.
+
+If you want to define Hermes manually, add it through VS Code settings under `acp.agents`:
```json
{
- "acpClient.agents": [
- {
- "name": "hermes-agent",
- "registryDir": "/path/to/hermes-agent/acp_registry"
+ "acp.agents": {
+ "Hermes Agent": {
+ "command": "hermes",
+ "args": ["acp"]
}
- ]
+ }
}
```
From 50ab0a85a7472017a26abd7794103ddffed3d450 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 14:16:23 -0700
Subject: [PATCH 061/124] chore: AUTHOR_MAP entry for formulahendry
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index f6c9a04cdd..0f11326af1 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -99,6 +99,7 @@ AUTHOR_MAP = {
"1581133593@qq.com": "liu-collab",
"haidaoe@proton.me": "haidao1919",
"50561768+zhanggttry@users.noreply.github.com": "zhanggttry",
+ "formulahendry@gmail.com": "formulahendry",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 0b9cbc8b23fc922b0317d788806f5a8270370f56 Mon Sep 17 00:00:00 2001
From: 0xVox <35294173+Fearvox@users.noreply.github.com>
Date: Mon, 4 May 2026 00:32:45 -0400
Subject: [PATCH 062/124] test(kanban): cover metadata handoff round-trip
---
tests/tools/test_kanban_tools.py | 26 ++++++++++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py
index 9031d81d8e..5e5c515355 100644
--- a/tests/tools/test_kanban_tools.py
+++ b/tests/tools/test_kanban_tools.py
@@ -133,6 +133,32 @@ def test_complete_happy_path(worker_env):
conn.close()
+def test_complete_metadata_round_trips_through_show(worker_env):
+ """Structured completion metadata should be visible to downstream agents."""
+ from tools import kanban_tools as kt
+
+ handoff = {
+ "changed_files": ["hermes_cli/kanban.py"],
+ "verification": ["pytest tests/tools/test_kanban_tools.py -q"],
+ "dependencies": [],
+ "blocked_reason": None,
+ "retry_notes": "none",
+ "residual_risk": ["dashboard rendering not exercised"],
+ }
+
+ complete_out = kt._handle_complete({
+ "summary": "finished with structured evidence",
+ "metadata": handoff,
+ })
+ assert json.loads(complete_out)["ok"] is True
+
+ show_out = kt._handle_show({"task_id": worker_env})
+ shown = json.loads(show_out)
+ assert shown["task"]["status"] == "done"
+ assert shown["runs"][-1]["summary"] == "finished with structured evidence"
+ assert shown["runs"][-1]["metadata"] == handoff
+
+
def test_complete_with_result_only(worker_env):
"""`result` alone (without summary) is accepted for legacy compat."""
from tools import kanban_tools as kt
From f0d278412f8c14e94a11678be424f6a6ddb79fa2 Mon Sep 17 00:00:00 2001
From: Moonyeah
Date: Mon, 4 May 2026 14:38:43 +0800
Subject: [PATCH 063/124] feat(gateway): respect kanban.max_spawn config to
limit concurrent tasks
The dispatch_once function already accepts a max_spawn parameter but the
gateway was calling it without passing any value, effectively ignoring
the configuration. This change reads kanban.max_spawn from config.yaml
and passes it through, allowing users to limit concurrent kanban tasks.
This prevents resource exhaustion scenarios where kanban dispatcher
spawns too many parallel workers on constrained hardware.
---
gateway/run.py | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/gateway/run.py b/gateway/run.py
index 66c31c4382..22b8fbdb64 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -3623,6 +3623,11 @@ class GatewayRunner:
if interval < 1.0:
interval = 1.0 # sanity floor — tighter than this is a footgun
+ # Read max_spawn config to limit concurrent kanban tasks
+ max_spawn = kanban_cfg.get("max_spawn", None)
+ if max_spawn is not None:
+ logger.info(f"kanban dispatcher: max_spawn={max_spawn}")
+
# Initial delay so the gateway finishes wiring adapters before the
# dispatcher spawns workers (those workers may hit gateway notify
# subscriptions etc.). Matches the notifier watcher's delay.
@@ -3651,7 +3656,7 @@ class GatewayRunner:
_kb.init_db(board=slug) # idempotent, handles first-run
except Exception:
pass
- return _kb.dispatch_once(conn, board=slug)
+ return _kb.dispatch_once(conn, board=slug, max_spawn=max_spawn)
except Exception:
logger.exception("kanban dispatcher: tick failed on board %s", slug)
return None
From 56b4795115e309b8d65bc68729fc591e90e6ffaa Mon Sep 17 00:00:00 2001
From: misery-hl <207811921+misery-hl@users.noreply.github.com>
Date: Mon, 4 May 2026 09:39:47 -0700
Subject: [PATCH 064/124] guard kanban worker lifecycle by run id
---
hermes_cli/kanban.py | 27 ++++-
hermes_cli/kanban_db.py | 114 +++++++++++++-----
.../test_kanban_core_functionality.py | 73 +++++++++++
tests/tools/test_kanban_tools.py | 38 ++++++
tools/kanban_tools.py | 27 ++++-
5 files changed, 243 insertions(+), 36 deletions(-)
diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py
index 9f293c555e..87f3b7f9d1 100644
--- a/hermes_cli/kanban.py
+++ b/hermes_cli/kanban.py
@@ -943,7 +943,12 @@ def _cmd_init(args: argparse.Namespace) -> int:
def _cmd_heartbeat(args: argparse.Namespace) -> int:
with kb.connect() as conn:
- ok = kb.heartbeat_worker(conn, args.task_id, note=getattr(args, "note", None))
+ ok = kb.heartbeat_worker(
+ conn,
+ args.task_id,
+ note=getattr(args, "note", None),
+ expected_run_id=_worker_run_id_for(args.task_id),
+ )
if not ok:
print(f"cannot heartbeat {args.task_id} (not running?)", file=sys.stderr)
return 1
@@ -1406,6 +1411,18 @@ def _cmd_comment(args: argparse.Namespace) -> int:
return 0
+def _worker_run_id_for(task_id: str) -> Optional[int]:
+ if os.environ.get("HERMES_KANBAN_TASK") != task_id:
+ return None
+ raw = os.environ.get("HERMES_KANBAN_RUN_ID")
+ if not raw:
+ return None
+ try:
+ return int(raw)
+ except ValueError:
+ return None
+
+
def _cmd_complete(args: argparse.Namespace) -> int:
"""Mark one or more tasks done. Supports a single id or a list."""
ids = list(args.task_ids or [])
@@ -1442,6 +1459,7 @@ def _cmd_complete(args: argparse.Namespace) -> int:
result=args.result,
summary=summary,
metadata=metadata,
+ expected_run_id=_worker_run_id_for(tid),
):
failed.append(tid)
print(f"cannot complete {tid} (unknown id or terminal state)", file=sys.stderr)
@@ -1487,7 +1505,12 @@ def _cmd_block(args: argparse.Namespace) -> int:
for tid in ids:
if reason:
kb.add_comment(conn, tid, author, f"BLOCKED: {reason}")
- if not kb.block_task(conn, tid, reason=reason):
+ if not kb.block_task(
+ conn,
+ tid,
+ reason=reason,
+ expected_run_id=_worker_run_id_for(tid),
+ ):
failed.append(tid)
print(f"cannot block {tid}", file=sys.stderr)
else:
diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py
index f526215094..97f24c435b 100644
--- a/hermes_cli/kanban_db.py
+++ b/hermes_cli/kanban_db.py
@@ -2098,6 +2098,7 @@ def complete_task(
summary: Optional[str] = None,
metadata: Optional[dict] = None,
created_cards: Optional[Iterable[str]] = None,
+ expected_run_id: Optional[int] = None,
) -> bool:
"""Transition ``running|ready -> done`` and record ``result``.
@@ -2157,20 +2158,37 @@ def complete_task(
verified_cards = []
with write_txn(conn):
- cur = conn.execute(
- """
- UPDATE tasks
- SET status = 'done',
- result = ?,
- completed_at = ?,
- claim_lock = NULL,
- claim_expires= NULL,
- worker_pid = NULL
- WHERE id = ?
- AND status IN ('running', 'ready', 'blocked')
- """,
- (result, now, task_id),
- )
+ if expected_run_id is None:
+ cur = conn.execute(
+ """
+ UPDATE tasks
+ SET status = 'done',
+ result = ?,
+ completed_at = ?,
+ claim_lock = NULL,
+ claim_expires= NULL,
+ worker_pid = NULL
+ WHERE id = ?
+ AND status IN ('running', 'ready', 'blocked')
+ """,
+ (result, now, task_id),
+ )
+ else:
+ cur = conn.execute(
+ """
+ UPDATE tasks
+ SET status = 'done',
+ result = ?,
+ completed_at = ?,
+ claim_lock = NULL,
+ claim_expires= NULL,
+ worker_pid = NULL
+ WHERE id = ?
+ AND status IN ('running', 'ready', 'blocked')
+ AND current_run_id = ?
+ """,
+ (result, now, task_id, int(expected_run_id)),
+ )
if cur.rowcount != 1:
return False
run_id = _end_run(
@@ -2310,21 +2328,37 @@ def block_task(
task_id: str,
*,
reason: Optional[str] = None,
+ expected_run_id: Optional[int] = None,
) -> bool:
"""Transition ``running -> blocked``."""
with write_txn(conn):
- cur = conn.execute(
- """
- UPDATE tasks
- SET status = 'blocked',
- claim_lock = NULL,
- claim_expires= NULL,
- worker_pid = NULL
- WHERE id = ?
- AND status IN ('running', 'ready')
- """,
- (task_id,),
- )
+ if expected_run_id is None:
+ cur = conn.execute(
+ """
+ UPDATE tasks
+ SET status = 'blocked',
+ claim_lock = NULL,
+ claim_expires= NULL,
+ worker_pid = NULL
+ WHERE id = ?
+ AND status IN ('running', 'ready')
+ """,
+ (task_id,),
+ )
+ else:
+ cur = conn.execute(
+ """
+ UPDATE tasks
+ SET status = 'blocked',
+ claim_lock = NULL,
+ claim_expires= NULL,
+ worker_pid = NULL
+ WHERE id = ?
+ AND status IN ('running', 'ready')
+ AND current_run_id = ?
+ """,
+ (task_id, int(expected_run_id)),
+ )
if cur.rowcount != 1:
return False
run_id = _end_run(
@@ -2596,6 +2630,7 @@ def heartbeat_worker(
task_id: str,
*,
note: Optional[str] = None,
+ expected_run_id: Optional[int] = None,
) -> bool:
"""Record a ``heartbeat`` event + touch ``last_heartbeat_at``.
@@ -2609,14 +2644,25 @@ def heartbeat_worker(
"""
now = int(time.time())
with write_txn(conn):
- cur = conn.execute(
- "UPDATE tasks SET last_heartbeat_at = ? "
- "WHERE id = ? AND status = 'running'",
- (now, task_id),
- )
+ if expected_run_id is None:
+ cur = conn.execute(
+ "UPDATE tasks SET last_heartbeat_at = ? "
+ "WHERE id = ? AND status = 'running'",
+ (now, task_id),
+ )
+ else:
+ cur = conn.execute(
+ "UPDATE tasks SET last_heartbeat_at = ? "
+ "WHERE id = ? AND status = 'running' AND current_run_id = ?",
+ (now, task_id, int(expected_run_id)),
+ )
if cur.rowcount != 1:
return False
- run_id = _current_run_id(conn, task_id)
+ run_id = (
+ int(expected_run_id)
+ if expected_run_id is not None
+ else _current_run_id(conn, task_id)
+ )
if run_id is not None:
conn.execute(
"UPDATE task_runs SET last_heartbeat_at = ? WHERE id = ?",
@@ -3219,6 +3265,10 @@ def _default_spawn(
env["HERMES_TENANT"] = task.tenant
env["HERMES_KANBAN_TASK"] = task.id
env["HERMES_KANBAN_WORKSPACE"] = workspace
+ if task.current_run_id is not None:
+ env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id)
+ if task.claim_lock:
+ env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock
# Pin the shared board + workspaces root the dispatcher resolved, so
# that even when the worker activates a profile (`hermes -p `
# rewrites HERMES_HOME), its kanban paths still match the
diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py
index 86536596e6..219aa2546d 100644
--- a/tests/hermes_cli/test_kanban_core_functionality.py
+++ b/tests/hermes_cli/test_kanban_core_functionality.py
@@ -1186,6 +1186,79 @@ def test_multiple_attempts_preserved_as_runs(kanban_home):
conn.close()
+def test_stale_run_cannot_complete_new_attempt(kanban_home, monkeypatch):
+ """A worker from an earlier attempt cannot close a later retry."""
+ import hermes_cli.kanban_db as _kb
+
+ conn = kb.connect()
+ try:
+ tid = kb.create_task(conn, title="retry guarded", assignee="worker")
+
+ kb.claim_task(conn, tid)
+ run1 = kb.latest_run(conn, tid)
+ kb._set_worker_pid(conn, tid, 98765)
+ monkeypatch.setattr(_kb, "_pid_alive", lambda pid: False)
+ assert kb.detect_crashed_workers(conn) == [tid]
+
+ kb.claim_task(conn, tid)
+ run2 = kb.latest_run(conn, tid)
+ assert run2.id != run1.id
+
+ assert not kb.complete_task(
+ conn,
+ tid,
+ summary="late stale completion",
+ expected_run_id=run1.id,
+ )
+ task = kb.get_task(conn, tid)
+ assert task.status == "running"
+ assert task.current_run_id == run2.id
+
+ assert kb.complete_task(
+ conn,
+ tid,
+ summary="current completion",
+ expected_run_id=run2.id,
+ )
+ runs = kb.list_runs(conn, tid)
+ assert [r.outcome for r in runs] == ["crashed", "completed"]
+ assert runs[-1].summary == "current completion"
+ finally:
+ conn.close()
+
+
+def test_stale_run_cannot_block_or_heartbeat_new_attempt(kanban_home, monkeypatch):
+ """Stale retry attempts cannot mutate the active run lifecycle."""
+ import hermes_cli.kanban_db as _kb
+
+ conn = kb.connect()
+ try:
+ tid = kb.create_task(conn, title="retry heartbeat guarded", assignee="worker")
+
+ kb.claim_task(conn, tid)
+ run1 = kb.latest_run(conn, tid)
+ kb._set_worker_pid(conn, tid, 98765)
+ monkeypatch.setattr(_kb, "_pid_alive", lambda pid: False)
+ assert kb.detect_crashed_workers(conn) == [tid]
+
+ kb.claim_task(conn, tid)
+ run2 = kb.latest_run(conn, tid)
+ assert run2.id != run1.id
+
+ assert not kb.heartbeat_worker(conn, tid, note="late", expected_run_id=run1.id)
+ assert not kb.block_task(conn, tid, reason="late block", expected_run_id=run1.id)
+ task = kb.get_task(conn, tid)
+ assert task.status == "running"
+ assert task.current_run_id == run2.id
+ assert task.last_heartbeat_at is None
+
+ assert kb.heartbeat_worker(conn, tid, note="current", expected_run_id=run2.id)
+ assert kb.block_task(conn, tid, reason="current block", expected_run_id=run2.id)
+ assert kb.get_task(conn, tid).status == "blocked"
+ finally:
+ conn.close()
+
+
def test_run_on_block_with_reason(kanban_home):
conn = kb.connect()
try:
diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py
index 5e5c515355..f00a33d544 100644
--- a/tests/tools/test_kanban_tools.py
+++ b/tests/tools/test_kanban_tools.py
@@ -611,6 +611,44 @@ def test_worker_complete_own_task_still_works(worker_env):
assert d.get("ok") is True and d.get("task_id") == worker_env
+def test_worker_complete_rejects_stale_run_id(worker_env, monkeypatch):
+ """A retried worker cannot complete the task using an old run token."""
+ from hermes_cli import kanban_db as kb
+ import hermes_cli.kanban_db as _kb
+
+ conn = kb.connect()
+ try:
+ run1 = kb.latest_run(conn, worker_env)
+ kb._set_worker_pid(conn, worker_env, 98765)
+ monkeypatch.setattr(_kb, "_pid_alive", lambda pid: False)
+ assert kb.detect_crashed_workers(conn) == [worker_env]
+
+ kb.claim_task(conn, worker_env)
+ run2 = kb.latest_run(conn, worker_env)
+ assert run2.id != run1.id
+ finally:
+ conn.close()
+
+ from tools import kanban_tools as kt
+ monkeypatch.setenv("HERMES_KANBAN_RUN_ID", str(run1.id))
+ out = kt._handle_complete({"summary": "late stale completion"})
+ d = json.loads(out)
+ assert d.get("ok") is not True
+
+ conn = kb.connect()
+ try:
+ task = kb.get_task(conn, worker_env)
+ assert task.status == "running"
+ assert task.current_run_id == run2.id
+ finally:
+ conn.close()
+
+ monkeypatch.setenv("HERMES_KANBAN_RUN_ID", str(run2.id))
+ out = kt._handle_complete({"summary": "current completion"})
+ d = json.loads(out)
+ assert d.get("ok") is True
+
+
def test_orchestrator_complete_any_task_allowed(monkeypatch, tmp_path):
"""Orchestrator profiles (no HERMES_KANBAN_TASK) can still complete
any task via explicit task_id. The check only applies to workers."""
diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py
index 926059880f..2f40b3f0de 100644
--- a/tools/kanban_tools.py
+++ b/tools/kanban_tools.py
@@ -79,6 +79,19 @@ def _default_task_id(arg: Optional[str]) -> Optional[str]:
return env_tid or None
+def _worker_run_id(task_id: str) -> Optional[int]:
+ """Return this worker's dispatcher run id when it is scoped to task_id."""
+ if os.environ.get("HERMES_KANBAN_TASK") != task_id:
+ return None
+ raw = os.environ.get("HERMES_KANBAN_RUN_ID")
+ if not raw:
+ return None
+ try:
+ return int(raw)
+ except ValueError:
+ return None
+
+
def _enforce_worker_task_ownership(tid: str) -> Optional[str]:
"""Reject worker-driven destructive calls on foreign task IDs.
@@ -240,6 +253,7 @@ def _handle_complete(args: dict, **kw) -> str:
conn, tid,
result=result, summary=summary, metadata=metadata,
created_cards=created_cards,
+ expected_run_id=_worker_run_id(tid),
)
except kb.HallucinatedCardsError as hall_err:
# Structured rejection — surface the phantom ids so the
@@ -281,7 +295,11 @@ def _handle_block(args: dict, **kw) -> str:
try:
kb, conn = _connect()
try:
- ok = kb.block_task(conn, tid, reason=reason)
+ ok = kb.block_task(
+ conn, tid,
+ reason=reason,
+ expected_run_id=_worker_run_id(tid),
+ )
if not ok:
return tool_error(
f"could not block {tid} (unknown id or not in "
@@ -310,7 +328,12 @@ def _handle_heartbeat(args: dict, **kw) -> str:
try:
kb, conn = _connect()
try:
- ok = kb.heartbeat_worker(conn, tid, note=note)
+ ok = kb.heartbeat_worker(
+ conn,
+ tid,
+ note=note,
+ expected_run_id=_worker_run_id(tid),
+ )
if not ok:
return tool_error(
f"could not heartbeat {tid} (unknown id or not running)"
From 1efed67056b890ba130e568925ad5bf069a623ff Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 15:06:01 -0700
Subject: [PATCH 065/124] chore(release): AUTHOR_MAP entries for momowind and
misery-hl
---
scripts/release.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/scripts/release.py b/scripts/release.py
index 0f11326af1..aa9d6ead89 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -126,6 +126,9 @@ AUTHOR_MAP = {
"yuxiangl490@gmail.com": "y0shua1ee",
"manmit0x@gmail.com": "0xDevNinja",
"stevekelly622@gmail.com": "steezkelly",
+ "momowind@gmail.com": "momowind",
+ "clockwork-codex@users.noreply.github.com": "misery-hl",
+ "207811921+misery-hl@users.noreply.github.com": "misery-hl",
"aamirjawaid@microsoft.com": "heyitsaamir",
"johnnncenaaa77@gmail.com": "johnncenae",
"thomasjhon6666@gmail.com": "ThomassJonax",
From 3082fa0829e0df4ce682358481fb59275b31a46e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nicol=C3=B2=20Boschi?=
Date: Tue, 5 May 2026 14:46:22 +0200
Subject: [PATCH 066/124] feat(hindsight): probe API for update_mode='append'
support, dedupe across processes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mirrors the pattern already shipping in hindsight-integrations/openclaw:
probe `/version` once per process, gate on Hindsight ≥ 0.5.0.
When supported, retains use a stable session-scoped `document_id`
(`session_id`) plus `update_mode='append'` so cross-process retains for
the same session merge into one document instead of producing
N-different-process-stamped duplicates. When unsupported (or probe
fails), fall back to the existing per-process unique
`f"{session_id}-{start_ts}"` document_id with no `update_mode` — the
resume-overwrite fix (#6654) keeps working unchanged on legacy servers.
Closes the dedup half of #20115. The proposed `document_id_strategy`
config knob isn't needed: auto-detection via the same /version probe
the OpenClaw plugin already uses gives the same outcome with no extra
config burden, and the choice is purely a function of what the server
can do.
Plumbing
--------
- Module-level helpers (`_meets_minimum_version`, `_fetch_hindsight_api_version`,
`_check_api_supports_update_mode_append`) cache the result per api_url
so every provider in the process gets one /version round-trip.
- One-time WARN logged when the API is older than 0.5.0, telling the
user to upgrade for cross-session deduplication.
- New instance helper `_resolve_retain_target(fallback_doc_id)` returns
`(document_id, update_mode)` based on cached capability. Wired into
`sync_turn` and the `on_session_switch` flush path.
- For local_embedded mode, the probe URL is taken from the running
client (`client.url`) so we hit the actual daemon port rather than
the configured default.
- `update_mode` is set on the per-item dict; `aretain_batch` already
threads `item['update_mode']` into the API call.
Tests
-----
- `TestUpdateModeAppendCapability` (5 cases): legacy fallback, modern
stable+append, per-url cache, one-time warn, flush-on-switch resolves
against the OLD session.
- Existing `_make_hindsight_provider` factory in the manager-side test
file extended to seed `_mode`/`_api_url`/`_api_key`/`_client` and stub
`_resolve_retain_target` so the bypass-init pattern keeps working.
E2E verified against installed `~/.hermes/hermes-agent`:
- Legacy probe (unreachable host) → `legacy-session-` doc_id,
no `update_mode`.
- Modern probe (live local_embedded 0.5.6 daemon) → stable
`modern-session` doc_id + `update_mode='append'`.
- `test_hermes_embedded_smoke.py` passes (90s).
---
plugins/memory/hindsight/__init__.py | 151 +++++++++++++++++-
tests/agent/test_memory_session_switch.py | 8 +
.../plugins/memory/test_hindsight_provider.py | 104 ++++++++++++
3 files changed, 257 insertions(+), 6 deletions(-)
diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py
index a280cbafd4..b7751a918e 100644
--- a/plugins/memory/hindsight/__init__.py
+++ b/plugins/memory/hindsight/__init__.py
@@ -52,6 +52,12 @@ _DEFAULT_LOCAL_URL = "http://localhost:8888"
_MIN_CLIENT_VERSION = "0.4.22"
_DEFAULT_TIMEOUT = 120 # seconds — cloud API can take 30-40s per request
_DEFAULT_IDLE_TIMEOUT = 300 # seconds — Hindsight embedded daemon default
+# Mirrors hindsight-integrations/openclaw — Hindsight 0.5.0 added
+# `update_mode='append'` semantics on retain (vectorize-io/hindsight#932).
+# Without it, reusing a stable session-scoped document_id silently
+# overwrites prior turns server-side, so we keep the per-process
+# unique document_id fallback for older APIs.
+_MIN_VERSION_FOR_UPDATE_MODE_APPEND = "0.5.0"
_VALID_BUDGETS = {"low", "mid", "high"}
_PROVIDER_DEFAULT_MODELS = {
"openai": "gpt-4o-mini",
@@ -93,6 +99,95 @@ def _check_local_runtime() -> tuple[bool, str | None]:
return False, str(exc)
+# ---------------------------------------------------------------------------
+# Hindsight API capability probe — mirrors hindsight-integrations/openclaw.
+# ---------------------------------------------------------------------------
+
+# Cache of API_URL -> bool (whether that API supports update_mode='append').
+# Probed once per URL per process — every provider talking to the same API
+# gets the same answer without re-hitting /version on each initialize().
+_append_capability_cache: Dict[str, bool] = {}
+_append_capability_lock = threading.Lock()
+
+
+def _meets_minimum_version(actual: str | None, required: str) -> bool:
+ """Return True if *actual* ≥ *required* (semver). False on missing/invalid."""
+ if not actual:
+ return False
+ try:
+ from packaging.version import Version
+ return Version(actual) >= Version(required)
+ except Exception:
+ return False
+
+
+def _fetch_hindsight_api_version(api_url: str, api_key: str | None = None,
+ timeout: float = 5.0) -> str | None:
+ """GET ``/version`` and return the version string (or None on failure).
+
+ Hindsight's `/version` endpoint returns ``{"version": "0.5.6", ...}``.
+ Any failure (timeout, 404, malformed JSON, missing key) → None, which
+ the caller treats as "legacy API, no update_mode support".
+ """
+ import urllib.error
+ import urllib.request
+ if not api_url:
+ return None
+ url = api_url.rstrip("/") + "/version"
+ req = urllib.request.Request(url)
+ if api_key:
+ req.add_header("Authorization", f"Bearer {api_key}")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
+ payload = resp.read().decode("utf-8", errors="replace")
+ data = json.loads(payload)
+ except Exception as exc:
+ logger.debug("Hindsight /version probe failed for %s: %s", url, exc)
+ return None
+ if not isinstance(data, dict):
+ return None
+ version = data.get("version") or data.get("api_version")
+ return str(version) if version else None
+
+
+def _check_api_supports_update_mode_append(api_url: str,
+ api_key: str | None = None) -> bool:
+ """Cached capability check for ``update_mode='append'`` on *api_url*.
+
+ Probes once per URL per process. Returns False on any probe failure —
+ that's the safe default: a per-process unique ``document_id`` and no
+ ``update_mode`` keeps the resume-overwrite fix (#6654) intact.
+ """
+ if not api_url:
+ return False
+ with _append_capability_lock:
+ if api_url in _append_capability_cache:
+ return _append_capability_cache[api_url]
+ version = _fetch_hindsight_api_version(api_url, api_key)
+ supported = _meets_minimum_version(version, _MIN_VERSION_FOR_UPDATE_MODE_APPEND)
+ with _append_capability_lock:
+ # Re-check after acquiring the lock in case a concurrent probe filled it.
+ cached = _append_capability_cache.get(api_url)
+ if cached is None:
+ _append_capability_cache[api_url] = supported
+ else:
+ supported = cached
+ if not supported:
+ logger.warning(
+ "Hindsight API at %s reports version %r, older than %s. "
+ "Falling back to per-process document_id — retains across "
+ "processes/sessions create separate documents instead of "
+ "appending to a session-scoped one. Upgrade Hindsight to "
+ "%s+ to enable update_mode='append' deduplication.",
+ api_url, version, _MIN_VERSION_FOR_UPDATE_MODE_APPEND,
+ _MIN_VERSION_FOR_UPDATE_MODE_APPEND,
+ )
+ else:
+ logger.debug("Hindsight API %s version %s supports update_mode='append'",
+ api_url, version)
+ return supported
+
+
# ---------------------------------------------------------------------------
# Dedicated event loop for Hindsight async calls (one per process, reused).
# Avoids creating ephemeral loops that leak aiohttp sessions.
@@ -918,6 +1013,40 @@ class HindsightMemoryProvider(MemoryProvider):
self._client = client
return self._run_sync(operation(client))
+ def _probe_url(self) -> str:
+ """Return the URL to probe /version on.
+
+ For local_embedded the daemon is on a per-profile dynamic port,
+ so we prefer the running client's URL when available; otherwise
+ fall back to the configured api_url.
+ """
+ if self._mode == "local_embedded" and self._client is not None:
+ url = getattr(self._client, "url", None)
+ if url:
+ return str(url)
+ return self._api_url or ""
+
+ def _resolve_retain_target(self, fallback_document_id: str) -> tuple[str, str | None]:
+ """Pick (document_id, update_mode) based on live API capability.
+
+ On Hindsight ≥ 0.5.0 the API supports ``update_mode='append'``,
+ which lets us reuse a stable session-scoped ``document_id`` across
+ process lifecycles without overwriting prior turns. On older APIs
+ we fall back to *fallback_document_id* (the per-process unique
+ ``f"{session_id}-{start_ts}"`` minted at initialize / switch time)
+ and don't pass ``update_mode`` at all — that's the only way the
+ resume-overwrite fix (#6654) keeps working on legacy servers.
+
+ Probe is cached at module level per API URL, so this is one HTTP
+ round-trip per (process, api_url) pair regardless of how many
+ retains fire.
+ """
+ if not self._session_id:
+ return fallback_document_id, None
+ if _check_api_supports_update_mode_append(self._probe_url(), self._api_key):
+ return self._session_id, "append"
+ return fallback_document_id, None
+
def initialize(self, session_id: str, **kwargs) -> None:
self._session_id = str(session_id or "").strip()
self._parent_session_id = str(kwargs.get("parent_session_id", "") or "").strip()
@@ -1319,7 +1448,7 @@ class HindsightMemoryProvider(MemoryProvider):
turn_index=self._turn_index,
)
num_turns = len(self._session_turns)
- document_id = self._document_id
+ document_id, update_mode = self._resolve_retain_target(self._document_id)
bank_id = self._bank_id
retain_async_flag = self._retain_async
retain_context = self._retain_context
@@ -1333,8 +1462,10 @@ class HindsightMemoryProvider(MemoryProvider):
)
item.pop("bank_id", None)
item.pop("retain_async", None)
- logger.debug("Hindsight retain: bank=%s, doc=%s, async=%s, content_len=%d, num_turns=%d",
- bank_id, document_id, retain_async_flag, len(content), num_turns)
+ if update_mode is not None:
+ item["update_mode"] = update_mode
+ logger.debug("Hindsight retain: bank=%s, doc=%s, mode=%s, async=%s, content_len=%d, num_turns=%d",
+ bank_id, document_id, update_mode, retain_async_flag, len(content), num_turns)
self._run_hindsight_operation(
lambda client: client.aretain_batch(
bank_id=bank_id,
@@ -1471,7 +1602,6 @@ class HindsightMemoryProvider(MemoryProvider):
if self._session_turns:
old_turns = list(self._session_turns)
old_session_id = self._session_id
- old_document_id = self._document_id
old_parent_session_id = self._parent_session_id
old_turn_index = self._turn_index
old_metadata = self._build_metadata(
@@ -1484,6 +1614,13 @@ class HindsightMemoryProvider(MemoryProvider):
if old_parent_session_id:
old_lineage_tags.append(f"parent:{old_parent_session_id}")
old_content = "[" + ",".join(old_turns) + "]"
+ # Resolve doc_id + update_mode against the OLD session BEFORE
+ # we rotate _session_id, so the flush lands in the old
+ # session's document either way (legacy: per-process unique;
+ # ≥0.5.0: stable session-scoped + append).
+ old_document_id, old_update_mode = self._resolve_retain_target(
+ self._document_id
+ )
def _flush():
try:
@@ -1495,9 +1632,11 @@ class HindsightMemoryProvider(MemoryProvider):
)
item.pop("bank_id", None)
item.pop("retain_async", None)
+ if old_update_mode is not None:
+ item["update_mode"] = old_update_mode
logger.debug(
- "Hindsight flush-on-switch: bank=%s, doc=%s, num_turns=%d",
- self._bank_id, old_document_id, len(old_turns),
+ "Hindsight flush-on-switch: bank=%s, doc=%s, mode=%s, num_turns=%d",
+ self._bank_id, old_document_id, old_update_mode, len(old_turns),
)
self._run_hindsight_operation(
lambda client: client.aretain_batch(
diff --git a/tests/agent/test_memory_session_switch.py b/tests/agent/test_memory_session_switch.py
index 610c09b29f..61cd6edbaf 100644
--- a/tests/agent/test_memory_session_switch.py
+++ b/tests/agent/test_memory_session_switch.py
@@ -248,6 +248,14 @@ def _make_hindsight_provider():
provider._atexit_registered = True
provider._ensure_writer = lambda: None
provider._register_atexit = lambda: None
+ # Mode + API state used by _resolve_retain_target; stub the resolver
+ # so tests don't actually probe the API. Real probe behavior is
+ # exercised by tests in tests/plugins/memory/test_hindsight_provider.py.
+ provider._mode = "cloud"
+ provider._api_url = ""
+ provider._api_key = ""
+ provider._client = None
+ provider._resolve_retain_target = lambda fb: (fb, None)
# Stub the network-touching helper so any enqueued flush closure is
# a no-op if ever drained in a unit test.
provider._run_hindsight_operation = lambda _op: None
diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py
index 334e6ab5ea..fcda46e56b 100644
--- a/tests/plugins/memory/test_hindsight_provider.py
+++ b/tests/plugins/memory/test_hindsight_provider.py
@@ -1072,6 +1072,110 @@ class TestSessionSwitchBufferFlush:
assert call_order[1] == "3"
+# ---------------------------------------------------------------------------
+# update_mode='append' capability probe + retain dispatch
+# ---------------------------------------------------------------------------
+
+
+class TestUpdateModeAppendCapability:
+ def _clear_capability_cache(self):
+ from plugins.memory.hindsight import _append_capability_cache, _append_capability_lock
+ with _append_capability_lock:
+ _append_capability_cache.clear()
+
+ def test_legacy_api_falls_back_to_per_process_doc_id(self, provider, monkeypatch):
+ """API returns no /version (or pre-0.5.0) — sync_turn must use the
+ per-process unique doc_id and NOT pass update_mode."""
+ self._clear_capability_cache()
+ monkeypatch.setattr(
+ "plugins.memory.hindsight._fetch_hindsight_api_version",
+ lambda *a, **kw: None,
+ )
+ old_doc = provider._document_id
+ provider.sync_turn("hello", "hi")
+ provider._retain_queue.join()
+
+ kw = provider._client.aretain_batch.call_args.kwargs
+ assert kw["document_id"] == old_doc
+ assert kw["document_id"].startswith("test-session-")
+ item = kw["items"][0]
+ assert "update_mode" not in item
+
+ def test_modern_api_uses_stable_doc_id_with_append(self, provider, monkeypatch):
+ """API on >=0.5.0 — retain uses stable session_id and sets update_mode='append'."""
+ self._clear_capability_cache()
+ monkeypatch.setattr(
+ "plugins.memory.hindsight._fetch_hindsight_api_version",
+ lambda *a, **kw: "0.5.6",
+ )
+ provider.sync_turn("hello", "hi")
+ provider._retain_queue.join()
+
+ kw = provider._client.aretain_batch.call_args.kwargs
+ # Stable: just the session id, no per-process timestamp suffix.
+ assert kw["document_id"] == "test-session"
+ item = kw["items"][0]
+ assert item["update_mode"] == "append"
+
+ def test_capability_cached_per_url(self, provider, monkeypatch):
+ """The /version probe must run at most once per (process, api_url)."""
+ self._clear_capability_cache()
+ calls = {"n": 0}
+
+ def _spy(*a, **kw):
+ calls["n"] += 1
+ return "0.5.6"
+
+ monkeypatch.setattr(
+ "plugins.memory.hindsight._fetch_hindsight_api_version", _spy
+ )
+ provider.sync_turn("a", "b")
+ provider._retain_queue.join()
+ provider.sync_turn("c", "d")
+ provider._retain_queue.join()
+ assert calls["n"] == 1
+
+ def test_legacy_warning_emitted_once(self, provider, monkeypatch, caplog):
+ """One-time WARN nudges users to upgrade Hindsight."""
+ import logging
+ self._clear_capability_cache()
+ monkeypatch.setattr(
+ "plugins.memory.hindsight._fetch_hindsight_api_version",
+ lambda *a, **kw: "0.4.22",
+ )
+ with caplog.at_level(logging.WARNING, logger="plugins.memory.hindsight"):
+ provider.sync_turn("a", "b")
+ provider._retain_queue.join()
+ provider.sync_turn("c", "d")
+ provider._retain_queue.join()
+ warns = [r for r in caplog.records
+ if r.levelno == logging.WARNING
+ and "older than 0.5.0" in r.getMessage()]
+ # Cache hit on the second call → no second warn.
+ assert len(warns) == 1
+
+ def test_session_switch_flush_picks_capability_against_old_session(
+ self, provider_with_config, monkeypatch
+ ):
+ """When the API supports append, the flush on /reset must land
+ in the OLD session's stable document, not a per-process id."""
+ self._clear_capability_cache()
+ monkeypatch.setattr(
+ "plugins.memory.hindsight._fetch_hindsight_api_version",
+ lambda *a, **kw: "0.5.6",
+ )
+ p = provider_with_config(retain_every_n_turns=3, retain_async=False)
+ p.sync_turn("turn1-user", "turn1-asst")
+ p.sync_turn("turn2-user", "turn2-asst")
+ p.on_session_switch("new-sid", parent_session_id="test-session", reset=True)
+ p._retain_queue.join()
+
+ kw = p._client.aretain_batch.call_args.kwargs
+ # Flush goes to the OLD session's stable doc, not new-sid's.
+ assert kw["document_id"] == "test-session"
+ assert kw["items"][0]["update_mode"] == "append"
+
+
# ---------------------------------------------------------------------------
# System prompt tests
# ---------------------------------------------------------------------------
From 3188e63b05a1902baecfcd7c30da3301d74b8737 Mon Sep 17 00:00:00 2001
From: bogerman1 <93757150+bogerman1@users.noreply.github.com>
Date: Tue, 5 May 2026 15:12:21 -0700
Subject: [PATCH 067/124] fix(api_server): SSE token batching + error handling
for Open WebUI performance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reduces SSE event rate ~500/turn → ~20/turn via 50ms text-delta batching in
_dispatch(), which eliminates markdown re-render storms on Open WebUI. Also:
- Trim tool_call.arguments in the response.completed event to 100KB
(prevents silent hangs on 848KB+ single-line SSE events).
- Catch-all exception handlers in _write_sse_responses() + _write_sse_chat_completion()
emit a proper error chunk instead of TransferEncodingError from incomplete
chunked encoding when the agent crashes mid-stream.
- MAX_REQUEST_BYTES 1MB → 10MB; pass client_max_size to aiohttp Application to
avoid silent 400s on truncated request bodies for long conversations.
Salvage of #17552 (api_server portion only). The contrib/openwebui-filter/
payload from that PR — Open WebUI Filter Function + benchmark writeup — is
a client-side user-installable add-on and doesn't need to live in the repo;
dropped here. Closes #17537.
Co-authored-by: bogerman1 <93757150+bogerman1@users.noreply.github.com>
---
gateway/platforms/api_server.py | 125 ++++++++++++++++++++++++++++++--
1 file changed, 117 insertions(+), 8 deletions(-)
diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py
index b460754331..ae77100f6a 100644
--- a/gateway/platforms/api_server.py
+++ b/gateway/platforms/api_server.py
@@ -56,7 +56,7 @@ logger = logging.getLogger(__name__)
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8642
MAX_STORED_RESPONSES = 100
-MAX_REQUEST_BYTES = 1_000_000 # 1 MB default limit for POST bodies
+MAX_REQUEST_BYTES = 10_000_000 # 10 MB — accommodates long agent conversations with tool calls
CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0
MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts
MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array
@@ -1349,6 +1349,22 @@ class APIServerAdapter(BasePlatformAdapter):
except (asyncio.CancelledError, Exception):
pass
logger.info("SSE client disconnected; interrupted agent task %s", completion_id)
+ except Exception as _exc:
+ # Agent crashed mid-stream. Try to emit an error chunk
+ # so the client gets a proper response instead of a
+ # TransferEncodingError from incomplete chunked encoding.
+ import traceback as _tb
+ logger.error("Agent crashed mid-stream for %s: %s", completion_id, _tb.format_exc()[:300])
+ try:
+ error_chunk = {
+ "id": completion_id, "object": "chat.completion.chunk",
+ "created": created, "model": model,
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "error"}],
+ }
+ await response.write(f"data: {json.dumps(error_chunk)}\n\n".encode())
+ await response.write(b"data: [DONE]\n\n")
+ except Exception:
+ pass
return response
@@ -1669,20 +1685,54 @@ class APIServerAdapter(BasePlatformAdapter):
async def _dispatch(it) -> None:
"""Route a queue item to the correct SSE emitter.
- Plain strings are text deltas. Tagged tuples with
- ``__tool_started__`` / ``__tool_completed__`` prefixes
- are tool lifecycle events.
+ Plain strings are text deltas — they are batched (50ms)
+ to reduce Open WebUI re-render storms. Tagged tuples
+ with ``__tool_started__`` / ``__tool_completed__``
+ prefixes are tool lifecycle events and flush the buffer
+ before emitting.
"""
+ nonlocal _batch_timer
if isinstance(it, tuple) and len(it) == 2 and isinstance(it[0], str):
tag, payload = it
+ # Flush batched text before tool events
+ if _batch_buf:
+ await _flush_batch()
if tag == "__tool_started__":
await _emit_tool_started(payload)
elif tag == "__tool_completed__":
await _emit_tool_completed(payload)
- # Unknown tags are silently ignored (forward-compat).
elif isinstance(it, str):
- await _emit_text_delta(it)
- # Other types (non-string, non-tuple) are silently dropped.
+ # Batch text deltas — append to buffer, flush on timer
+ _batch_buf.append(it)
+ if _batch_timer is None:
+ _batch_timer = asyncio.create_task(_batch_flush_after(0.05))
+ # Other types are silently dropped.
+
+ # ── Batching state ──
+ _batch_buf: List[str] = []
+ _batch_timer: Optional[asyncio.Task] = None
+ _batch_lock = asyncio.Lock()
+
+ async def _batch_flush_after(delay: float) -> None:
+ """Wait delay seconds, then flush accumulated text deltas."""
+ try:
+ await asyncio.sleep(delay)
+ except asyncio.CancelledError:
+ return
+ # Clear timer reference BEFORE flush so new deltas
+ # can start a fresh timer while we emit
+ nonlocal _batch_buf, _batch_timer
+ _batch_timer = None
+ await _flush_batch()
+
+ async def _flush_batch() -> None:
+ """Emit a single SSE delta for all accumulated text."""
+ nonlocal _batch_buf
+ async with _batch_lock:
+ if _batch_buf:
+ combined = "".join(_batch_buf)
+ _batch_buf = []
+ await _emit_text_delta(combined)
loop = asyncio.get_running_loop()
while True:
@@ -1707,11 +1757,21 @@ class APIServerAdapter(BasePlatformAdapter):
continue
if item is None: # EOS sentinel
+ # Cancel pending timer and flush remaining batched text
+ if _batch_timer and not _batch_timer.done():
+ _batch_timer.cancel()
+ _batch_timer = None
+ if _batch_buf:
+ await _flush_batch()
break
await _dispatch(item)
last_activity = time.monotonic()
+ # Flush any final batched text before processing result
+ if _batch_buf:
+ await _flush_batch()
+
# Pick up agent result + usage from the completed task
try:
result, agent_usage = await agent_task
@@ -1762,6 +1822,31 @@ class APIServerAdapter(BasePlatformAdapter):
# payload still see the assistant text. This mirrors the
# shape produced by _extract_output_items in the batch path.
final_items: List[Dict[str, Any]] = list(emitted_items)
+
+ # Trim large content from tool call arguments to keep the
+ # response.completed event under ~100KB. Clients already
+ # received full details via incremental events.
+ for _item in final_items:
+ if _item.get("type") == "function_call":
+ try:
+ _args = json.loads(_item.get("arguments", "{}")) if isinstance(_item.get("arguments"), str) else _item.get("arguments", {})
+ if isinstance(_args, dict):
+ for _k in ("content", "query", "pattern", "old_string", "new_string"):
+ if isinstance(_args.get(_k), str) and len(_args[_k]) > 500:
+ _args[_k] = "[" + str(len(_args[_k])) + " chars — truncated for response.completed]"
+ _item["arguments"] = json.dumps(_args)
+ except Exception:
+ pass
+ elif _item.get("type") == "function_call_output":
+ _output = _item.get("output", [])
+ if isinstance(_output, list) and _output:
+ _first = _output[0]
+ if isinstance(_first, dict) and _first.get("type") == "input_text":
+ _text = _first.get("text", "")
+ if len(_text) > 1000:
+ _first["text"] = _text[:500] + "...[" + str(len(_text) - 500) + " more chars]"
+ _item["output"] = [_first]
+
final_items.append({
"type": "message",
"role": "assistant",
@@ -1852,6 +1937,30 @@ class APIServerAdapter(BasePlatformAdapter):
agent_task.cancel()
logger.info("SSE task cancelled; persisted incomplete snapshot for %s", response_id)
raise
+ except Exception as _exc:
+ # Agent crashed with an unhandled error (e.g. model API error like
+ # BadRequestError, AuthenticationError). Emit a response.failed
+ # event and properly terminate the SSE stream so the client doesn't
+ # get a TransferEncodingError from incomplete chunked encoding.
+ import traceback as _tb
+ _persist_incomplete_if_needed()
+ agent_error = _tb.format_exc()
+ try:
+ failed_env = _envelope("failed")
+ failed_env["output"] = list(emitted_items)
+ failed_env["error"] = {"message": str(_exc)[:500], "type": "server_error"}
+ failed_env["usage"] = {
+ "input_tokens": usage.get("input_tokens", 0),
+ "output_tokens": usage.get("output_tokens", 0),
+ "total_tokens": usage.get("total_tokens", 0),
+ }
+ await _write_event("response.failed", {
+ "type": "response.failed",
+ "response": failed_env,
+ })
+ except Exception:
+ pass
+ logger.error("Agent crashed mid-stream for %s: %s", response_id, str(agent_error)[:300])
return response
@@ -2935,7 +3044,7 @@ class APIServerAdapter(BasePlatformAdapter):
try:
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
- self._app = web.Application(middlewares=mws)
+ self._app = web.Application(middlewares=mws, client_max_size=MAX_REQUEST_BYTES)
self._app["api_server_adapter"] = self
self._app.router.add_get("/health", self._handle_health)
self._app.router.add_get("/health/detailed", self._handle_health_detailed)
From ee8edd41697d8d99b2828d76c0378cc37341c1dd Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 5 May 2026 15:12:37 -0700
Subject: [PATCH 068/124] chore: AUTHOR_MAP entry for bogerman1
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index aa9d6ead89..3f7d35f5ca 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -100,6 +100,7 @@ AUTHOR_MAP = {
"haidaoe@proton.me": "haidao1919",
"50561768+zhanggttry@users.noreply.github.com": "zhanggttry",
"formulahendry@gmail.com": "formulahendry",
+ "93757150+bogerman1@users.noreply.github.com": "bogerman1",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
From 0d41e94ca99ca873148081e597fabf5d339f267b Mon Sep 17 00:00:00 2001
From: Miniding
Date: Tue, 5 May 2026 19:26:00 +0200
Subject: [PATCH 069/124] feat(i18n): add French (fr) locale support
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add fr.yaml with French translations for approval prompts and gateway messages
- Register 'fr' in SUPPORTED_LANGUAGES
- Add French aliases: french, français, fr-fr, fr-be, fr-ca, fr-ch
- Update locale sync comment in en.yaml
---
agent/i18n.py | 5 +++--
locales/en.yaml | 2 +-
locales/fr.yaml | 24 ++++++++++++++++++++++++
3 files changed, 28 insertions(+), 3 deletions(-)
create mode 100644 locales/fr.yaml
diff --git a/agent/i18n.py b/agent/i18n.py
index 98d7ebce9a..c700491522 100644
--- a/agent/i18n.py
+++ b/agent/i18n.py
@@ -25,7 +25,7 @@ Language resolution order:
3. ``display.language`` from config.yaml
4. ``"en"`` (baseline)
-Supported languages: en, zh, ja, de, es. Unknown values fall back to en.
+Supported languages: en, zh, ja, de, es, fr. Unknown values fall back to en.
"""
from __future__ import annotations
@@ -39,7 +39,7 @@ from typing import Any
logger = logging.getLogger(__name__)
-SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es")
+SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es", "fr")
DEFAULT_LANGUAGE = "en"
# Accept a few natural aliases so users who type "chinese" / "zh-CN" / "jp"
@@ -50,6 +50,7 @@ _LANGUAGE_ALIASES: dict[str, str] = {
"japanese": "ja", "jp": "ja", "ja-jp": "ja",
"german": "de", "deutsch": "de", "de-de": "de",
"spanish": "es", "español": "es", "espanol": "es", "es-es": "es", "es-mx": "es",
+ "french": "fr", "français": "fr", "france": "fr", "fr-fr": "fr", "fr-be": "fr", "fr-ca": "fr", "fr-ch": "fr",
}
_catalog_cache: dict[str, dict[str, str]] = {}
diff --git a/locales/en.yaml b/locales/en.yaml
index 283f8cb871..10c522dd25 100644
--- a/locales/en.yaml
+++ b/locales/en.yaml
@@ -7,7 +7,7 @@
#
# Keys are dotted paths; nesting below is purely for readability. Values may
# contain {placeholder} tokens for str.format substitution. When adding a
-# new key, add it to EVERY locale file (en/zh/ja/de/es) in the same commit --
+# new key, add it to EVERY locale file (en/zh/ja/de/es/fr) in the same commit --
# tests/agent/test_i18n.py asserts catalog parity.
approval:
diff --git a/locales/fr.yaml b/locales/fr.yaml
new file mode 100644
index 0000000000..2127f7396b
--- /dev/null
+++ b/locales/fr.yaml
@@ -0,0 +1,24 @@
+# Hermes static-message catalog -- French (français)
+# See locales/en.yaml for the source of truth; keep keys in sync.
+
+approval:
+ dangerous_header: "⚠️ COMMANDE DANGEREUSE : {description}"
+ choose_long: " [o]ne fois | [s]ession | [t]oujours | [r]efuser"
+ choose_short: " [o]ne fois | [s]ession | [r]efuser"
+ prompt_long: " Choix [o/s/t/R] : "
+ prompt_short: " Choix [o/s/R] : "
+ timeout: " ⏱ Délai dépassé — commande refusée"
+ allowed_once: " ✓ Autorisé une fois"
+ allowed_session: " ✓ Autorisé pour cette session"
+ allowed_always: " ✓ Ajouté à la liste d'autorisation permanente"
+ denied: " ✗ Refusé"
+ cancelled: " ✗ Annulé"
+ blocklist_message: "Cette commande est sur la liste de blocage inconditionnel et ne peut pas être approuvée."
+
+gateway:
+ approval_expired: "⚠️ Approbation expirée (l'agent n'attend plus). Demandez à l'agent de réessayer."
+ draining: "⏳ Vidage de {count} agent(s) actif(s) avant redémarrage..."
+ goal_cleared: "✓ Objectif effacé."
+ no_active_goal: "Aucun objectif actif."
+ config_read_failed: "⚠️ Impossible de lire config.yaml : {error}"
+ config_save_failed: "⚠️ Impossible de sauvegarder la configuration : {error}"
From c4b287ba539de06f79b867319568a4aa8c02a5ac Mon Sep 17 00:00:00 2001
From: Oleksii Lisikh
Date: Tue, 5 May 2026 17:44:43 +0200
Subject: [PATCH 070/124] feat(i18n): add Ukrainian locale
---
agent/i18n.py | 5 +++--
hermes_cli/config.py | 2 +-
locales/en.yaml | 2 +-
locales/uk.yaml | 24 ++++++++++++++++++++++++
tests/agent/test_i18n.py | 4 ++++
website/docs/user-guide/configuration.md | 4 ++--
6 files changed, 35 insertions(+), 6 deletions(-)
create mode 100644 locales/uk.yaml
diff --git a/agent/i18n.py b/agent/i18n.py
index c700491522..fff0577cc1 100644
--- a/agent/i18n.py
+++ b/agent/i18n.py
@@ -25,7 +25,7 @@ Language resolution order:
3. ``display.language`` from config.yaml
4. ``"en"`` (baseline)
-Supported languages: en, zh, ja, de, es, fr. Unknown values fall back to en.
+Supported languages: en, zh, ja, de, es, fr, uk. Unknown values fall back to en.
"""
from __future__ import annotations
@@ -39,7 +39,7 @@ from typing import Any
logger = logging.getLogger(__name__)
-SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es", "fr")
+SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es", "fr", "uk")
DEFAULT_LANGUAGE = "en"
# Accept a few natural aliases so users who type "chinese" / "zh-CN" / "jp"
@@ -51,6 +51,7 @@ _LANGUAGE_ALIASES: dict[str, str] = {
"german": "de", "deutsch": "de", "de-de": "de",
"spanish": "es", "español": "es", "espanol": "es", "es-es": "es", "es-mx": "es",
"french": "fr", "français": "fr", "france": "fr", "fr-fr": "fr", "fr-be": "fr", "fr-ca": "fr", "fr-ch": "fr",
+ "ukrainian": "uk", "ukrainisch": "uk", "українська": "uk", "uk-ua": "uk", "ua": "uk",
}
_catalog_cache: dict[str, dict[str, str]] = {}
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 1ac9881d89..1d9f88e593 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -784,7 +784,7 @@ DEFAULT_CONFIG = {
# UI language for static user-facing messages (approval prompts, a
# handful of gateway slash-command replies). Does NOT affect agent
# responses, log lines, tool outputs, or slash-command descriptions.
- # Supported: en, zh, ja, de, es. Unknown values fall back to en.
+ # Supported: en, zh, ja, de, es, fr, uk. Unknown values fall back to en.
"language": "en",
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
# spinner), or ascii. Live-swappable via `/indicator