Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	cli.py
#	hermes_cli/main.py
#	run_agent.py
#	tests/hermes_cli/test_cmd_update.py
#	tools/mcp_tool.py
#	web/src/lib/gatewayClient.ts
This commit is contained in:
Brooklyn Nicholson
2026-05-18 01:26:56 -05:00
260 changed files with 24547 additions and 13573 deletions
+797 -90
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -48,9 +48,9 @@ def parse_args(arg_string: str) -> tuple[Optional[str], list[str]]:
if not raw:
return None, []
# Accept human-friendly synonyms
if raw in ("on", "codex", "enable"):
if raw in {"on", "codex", "enable"}:
return "codex_app_server", []
if raw in ("off", "default", "disable", "hermes"):
if raw in {"off", "default", "disable", "hermes"}:
return "auto", []
if raw in VALID_RUNTIMES:
return raw, []
+2 -1
View File
@@ -123,7 +123,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("model", "Switch model for this session", "Configuration",
aliases=("provider",), args_hint="[model] [--provider name] [--global]"),
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", args_hint="[auto|codex_app_server]"),
"Configuration", aliases=("codex_runtime",),
args_hint="[auto|codex_app_server]"),
CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info",
cli_only=True),
+49
View File
@@ -926,6 +926,31 @@ DEFAULT_CONFIG = {
"timeout": 120,
"extra_body": {},
},
# Kanban decomposer — decomposes a triage task into a graph of
# child tasks routed to specialist profiles by description.
# Invoked by ``hermes kanban decompose`` and the kanban
# auto-decompose dispatcher tick. Returns a JSON task graph;
# uses more tokens than the specifier so allow more headroom.
"kanban_decomposer": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 180,
"extra_body": {},
},
# Profile describer — auto-generates a 1-2 sentence description
# of what a profile is good at. Invoked by
# ``hermes profile describe <name> --auto`` and the dashboard's
# auto-generate button. Short, cheap call.
"profile_describer": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 60,
"extra_body": {},
},
# Curator — skill-usage review fork. Timeout is generous because the
# review pass can take several minutes on reasoning models (umbrella
# building over hundreds of candidate skills). "auto" = use main chat
@@ -1473,6 +1498,25 @@ DEFAULT_CONFIG = {
# same task/profile (spawn_failed, timed_out, or crashed). Reassignment
# resets the streak for the new profile.
"failure_limit": 2,
# Profile that decomposes tasks in the Triage column. When unset,
# falls back to the default profile (the one `hermes` launches with
# no -p flag). Set this to a dedicated 'orchestrator' profile if you
# want decomposition to use a different model/skills from your main
# working profile.
"orchestrator_profile": "",
# Where a child task lands if the orchestrator can't match an
# assignee to any installed profile. When unset, falls back to the
# default profile. A task never ends up with assignee=None.
"default_assignee": "",
# When true, the kanban dispatcher auto-runs the decomposer on
# tasks that land in Triage (every dispatcher tick). When false,
# decomposition is manual via `hermes kanban decompose <id>` or
# the dashboard's Decompose button.
"auto_decompose": True,
# Max triage tasks to decompose per dispatcher tick. Prevents a
# large bulk-load of triage tasks from spending a burst of aux
# LLM calls in one tick. Excess tasks defer to the next tick.
"auto_decompose_per_tick": 3,
},
# execute_code settings — controls the tool used for programmatic tool calls.
@@ -2913,6 +2957,7 @@ def _normalize_custom_provider_entry(
"api_mode", "transport", "model", "default_model", "models",
"context_length", "rate_limit_delay",
"request_timeout_seconds", "stale_timeout_seconds",
"discover_models",
}
for camel, snake in _CAMEL_ALIASES.items():
if camel in entry and snake not in entry:
@@ -3003,6 +3048,10 @@ def _normalize_custom_provider_entry(
if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:
normalized["rate_limit_delay"] = rate_limit_delay
discover_models = entry.get("discover_models")
if isinstance(discover_models, bool):
normalized["discover_models"] = discover_models
return normalized
+1 -1
View File
@@ -91,7 +91,7 @@ def ensure_dependency(dep: str, interactive: bool = True) -> bool:
reply = input(f"{desc} is not installed. Install now? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
return False
if reply not in ("", "y", "yes"):
if reply not in {"", "y", "yes"}:
return False
result = subprocess.run(
+81 -32
View File
@@ -160,19 +160,25 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool
still show a failed API-key connectivity row, but it should not promote
that direct-key problem into the final blocking summary.
"""
try:
from hermes_cli.auth import (
get_gemini_oauth_auth_status,
get_minimax_oauth_auth_status,
)
except Exception:
return False
normalized = (provider_label or "").strip().lower()
if normalized in {"google / gemini", "gemini"}:
return bool((get_gemini_oauth_auth_status() or {}).get("logged_in"))
try:
from hermes_cli.auth import get_gemini_oauth_auth_status
return bool((get_gemini_oauth_auth_status() or {}).get("logged_in"))
except Exception:
return False
if normalized == "minimax":
return bool((get_minimax_oauth_auth_status() or {}).get("logged_in"))
try:
from hermes_cli.auth import get_minimax_oauth_auth_status
return bool((get_minimax_oauth_auth_status() or {}).get("logged_in"))
except Exception:
return False
if normalized == "xai":
try:
from hermes_cli.auth import get_xai_oauth_auth_status
return bool((get_xai_oauth_auth_status() or {}).get("logged_in"))
except Exception:
return False
return False
@@ -645,31 +651,41 @@ def run_doctor(args):
# Check credentials for the configured provider.
# Limit to API-key providers in PROVIDER_REGISTRY — other provider
# types (OAuth, SDK, openrouter/anthropic/custom/auto) have their
# own env-var checks elsewhere in doctor, and get_auth_status()
# returns a bare {logged_in: False} for anything it doesn't
# explicitly dispatch, which would produce false positives.
if runtime_provider and runtime_provider not in {"auto", "custom", "openrouter"}:
# types (OAuth, SDK, anthropic/custom/auto) have their own env-var
# checks elsewhere in doctor, and get_auth_status() returns a bare
# {logged_in: False} for anything it doesn't explicitly dispatch,
# which would produce false positives.
if runtime_provider and runtime_provider not in ("auto", "custom"):
try:
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
pconfig = PROVIDER_REGISTRY.get(runtime_provider)
if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
status = get_auth_status(runtime_provider) or {}
if runtime_provider == "openrouter":
from hermes_cli.config import get_env_value
configured = bool(
status.get("configured")
or status.get("logged_in")
or status.get("api_key")
str(get_env_value("OPENROUTER_API_KEY") or "").strip()
or str(get_env_value("OPENAI_API_KEY") or "").strip()
)
if not configured:
check_fail(
f"model.provider '{runtime_provider}' is set but no API key is configured",
"(check ~/.hermes/.env or run 'hermes setup')",
)
issues.append(
f"No credentials found for provider '{runtime_provider}'. "
f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, "
f"or switch providers with 'hermes config set model.provider <name>'"
else:
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
pconfig = PROVIDER_REGISTRY.get(runtime_provider)
configured = True
if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
status = get_auth_status(runtime_provider) or {}
configured = bool(
status.get("configured")
or status.get("logged_in")
or status.get("api_key")
)
if not configured:
check_fail(
f"model.provider '{runtime_provider}' is set but no API key is configured",
"(check ~/.hermes/.env or run 'hermes setup')",
)
issues.append(
f"No credentials found for provider '{runtime_provider}'. "
f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, "
f"or switch providers with 'hermes config set model.provider <name>'"
)
except Exception:
pass
@@ -817,6 +833,20 @@ def run_doctor(args):
except Exception as e:
check_warn("Auth provider status", f"(could not check: {e})")
# xAI OAuth — separate try/except so an import failure here cannot
# disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.
try:
from hermes_cli.auth import get_xai_oauth_auth_status
xai_oauth_status = get_xai_oauth_auth_status() or {}
if xai_oauth_status.get("logged_in"):
check_ok("xAI OAuth", "(logged in)")
else:
check_warn("xAI OAuth", "(not logged in)")
if xai_oauth_status.get("error"):
check_info(xai_oauth_status["error"])
except Exception:
pass
if _safe_which("codex"):
check_ok("codex CLI")
else:
@@ -1073,10 +1103,20 @@ def run_doctor(args):
if terminal_env == "ssh":
ssh_host = os.getenv("TERMINAL_SSH_HOST")
if ssh_host:
ssh_user = os.getenv("TERMINAL_SSH_USER")
ssh_port = os.getenv("TERMINAL_SSH_PORT")
ssh_key = os.getenv("TERMINAL_SSH_KEY")
target = f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host
cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"]
if ssh_port:
cmd += ["-p", ssh_port]
if ssh_key:
cmd += ["-i", os.path.expanduser(ssh_key)]
cmd += [target, "echo ok"]
# Try to connect
try:
result = subprocess.run(
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"],
cmd,
capture_output=True,
text=True,
timeout=15
@@ -1474,6 +1514,15 @@ def run_doctor(args):
}
if base_url_host_matches(base, "api.kimi.com"):
headers["User-Agent"] = "claude-code/0.1.0"
# Google's Generative Language API (generativelanguage.googleapis.com)
# rejects ``Authorization: Bearer <api-key>`` with 401
# ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for
# OAuth 2 access tokens, not plain API keys. Plain keys use
# ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid
# GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.
if url and base_url_host_matches(url, "generativelanguage.googleapis.com"):
headers.pop("Authorization", None)
headers["x-goog-api-key"] = key
r = httpx.get(url, headers=headers, timeout=10)
if (
pname == "Alibaba/DashScope"
+10 -4
View File
@@ -2110,24 +2110,30 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]:
if project_root is None:
project_root = PROJECT_ROOT
def _is_dir(path: Path) -> bool:
try:
return path.is_dir()
except OSError:
return False
candidates = []
venv_bin = project_root / "venv" / "bin"
if venv_bin.is_dir():
if _is_dir(venv_bin):
candidates.append(str(venv_bin))
elif sys.prefix != sys.base_prefix:
candidates.append(str(Path(sys.prefix) / "bin"))
node_bin = project_root / "node_modules" / ".bin"
if node_bin.is_dir():
if _is_dir(node_bin):
candidates.append(str(node_bin))
hermes_home = get_hermes_home()
hermes_node = hermes_home / "node" / "bin"
if hermes_node.is_dir():
if _is_dir(hermes_node):
candidates.append(str(hermes_node))
hermes_nm = hermes_home / "node_modules" / ".bin"
if hermes_nm.is_dir():
if _is_dir(hermes_nm):
candidates.append(str(hermes_nm))
return candidates
+6
View File
@@ -34,6 +34,7 @@ import logging
import re
import time
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
@@ -110,6 +111,7 @@ JUDGE_SYSTEM_PROMPT = (
JUDGE_USER_PROMPT_TEMPLATE = (
"Goal:\n{goal}\n\n"
"Agent's most recent response:\n{response}\n\n"
"Current time: {current_time}\n\n"
"Is the goal satisfied?"
)
@@ -120,6 +122,7 @@ JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE = (
"Additional criteria the user added mid-loop (all must also be "
"satisfied for the goal to be DONE):\n{subgoals_block}\n\n"
"Agent's most recent response:\n{response}\n\n"
"Current time: {current_time}\n\n"
"Decision: For each numbered criterion above, find concrete "
"evidence in the agent's response that the criterion is "
"satisfied. Do not accept generic phrases like 'all requirements "
@@ -415,6 +418,7 @@ def judge_goal(
# Build the prompt — pick the with-subgoals variant when applicable.
clean_subgoals = [s.strip() for s in (subgoals or []) if s and s.strip()]
current_time = datetime.now(tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
if clean_subgoals:
subgoals_block = "\n".join(
f"- {i}. {text}" for i, text in enumerate(clean_subgoals, start=1)
@@ -423,11 +427,13 @@ def judge_goal(
goal=_truncate(goal, 2000),
subgoals_block=_truncate(subgoals_block, 2000),
response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS),
current_time=current_time,
)
else:
prompt = JUDGE_USER_PROMPT_TEMPLATE.format(
goal=_truncate(goal, 2000),
response=_truncate(last_response, _JUDGE_RESPONSE_SNIPPET_CHARS),
current_time=current_time,
)
try:
+119
View File
@@ -610,6 +610,43 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
help="Emit one JSON object per task on stdout",
)
# --- decompose --- (triage → fan-out via auxiliary LLM + orchestrator)
p_decompose = sub.add_parser(
"decompose",
help="Decompose a triage-column task into a graph of child tasks "
"routed to specialist profiles by description. Falls back to "
"specify-style single-task promotion when the task doesn't "
"benefit from fan-out. Uses auxiliary.kanban_decomposer.",
)
p_decompose.add_argument(
"task_id",
nargs="?",
default=None,
help="Task id to decompose (required unless --all is given)",
)
p_decompose.add_argument(
"--all",
dest="all_triage",
action="store_true",
help="Decompose every task currently in the triage column",
)
p_decompose.add_argument(
"--tenant",
default=None,
help="When used with --all, restrict the sweep to this tenant",
)
p_decompose.add_argument(
"--author",
default=None,
help="Author name recorded on the audit comment "
"(default: $HERMES_PROFILE or 'decomposer')",
)
p_decompose.add_argument(
"--json",
action="store_true",
help="Emit one JSON object per task on stdout",
)
# --- gc ---
p_gc = sub.add_parser(
"gc", help="Garbage-collect archived-task workspaces, old events, and old logs",
@@ -740,6 +777,7 @@ def kanban_command(args: argparse.Namespace) -> int:
"notify-unsubscribe": _cmd_notify_unsubscribe,
"context": _cmd_context,
"specify": _cmd_specify,
"decompose": _cmd_decompose,
"gc": _cmd_gc,
}
handler = handlers.get(action)
@@ -2115,6 +2153,87 @@ def _cmd_specify(args: argparse.Namespace) -> int:
return 0 if (ok_count > 0 or not ids) else 1
def _cmd_decompose(args: argparse.Namespace) -> int:
"""Fan a triage task (or all of them) out into a graph of child
tasks via the auxiliary LLM, routed to specialist profiles by
description. Thin wrapper over ``kanban_decompose``."""
from hermes_cli import kanban_decompose as decomp
all_flag = bool(getattr(args, "all_triage", False))
tenant = getattr(args, "tenant", None)
author = getattr(args, "author", None) or _profile_author()
want_json = bool(getattr(args, "json", False))
if args.task_id and all_flag:
print(
"kanban: pass either a task id OR --all, not both",
file=sys.stderr,
)
return 2
if all_flag:
ids = decomp.list_triage_ids(tenant=tenant)
if not ids:
msg = (
"No triage tasks"
+ (f" for tenant {tenant!r}" if tenant else "")
+ "."
)
if want_json:
print(json.dumps({"decomposed": 0, "total": 0}))
else:
print(msg)
return 0
elif args.task_id:
ids = [args.task_id]
else:
print(
"kanban: decompose requires a task id or --all",
file=sys.stderr,
)
return 2
ok_count = 0
for tid in ids:
outcome = decomp.decompose_task(tid, author=author)
if outcome.ok:
ok_count += 1
if want_json:
print(json.dumps({
"task_id": outcome.task_id,
"ok": outcome.ok,
"reason": outcome.reason,
"fanout": outcome.fanout,
"child_ids": outcome.child_ids,
"new_title": outcome.new_title,
}))
elif outcome.ok:
if outcome.fanout and outcome.child_ids:
child_summary = ", ".join(outcome.child_ids)
print(
f"Decomposed {outcome.task_id}{len(outcome.child_ids)} "
f"children ({child_summary}); root promoted to todo"
)
else:
title_suffix = (
f" — retitled: {outcome.new_title!r}"
if outcome.new_title
else ""
)
print(
f"Specified {outcome.task_id} → todo "
f"(no fanout){title_suffix}"
)
else:
print(
f"kanban: decompose {outcome.task_id}: {outcome.reason}",
file=sys.stderr,
)
if not all_flag:
return 0 if ok_count == 1 else 1
return 0 if (ok_count > 0 or not ids) else 1
def _cmd_gc(args: argparse.Namespace) -> int:
"""Remove scratch workspaces of archived tasks, prune old events, and
delete old worker logs."""
+176
View File
@@ -93,6 +93,7 @@ from toolsets import get_toolset_names
VALID_STATUSES = {"triage", "todo", "ready", "running", "blocked", "done", "archived"}
VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"}
KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names())
_IS_WINDOWS = sys.platform == "win32"
# A running task's claim is valid for 15 minutes; after that the next
# dispatcher tick reclaims it. Workers that outlive this window should call
@@ -2776,6 +2777,180 @@ def specify_triage_task(
return True
def decompose_triage_task(
conn: sqlite3.Connection,
task_id: str,
*,
root_assignee: Optional[str],
children: list[dict],
author: Optional[str] = None,
) -> Optional[list[str]]:
"""Fan a triage task out into child tasks and promote the root to ``todo``.
The root task stays alive and becomes the parent of every child
when all children reach ``done``, the root promotes to ``ready`` and
its assignee (typically the orchestrator profile) wakes back up to
judge completion or spawn more work.
``children`` is a list of dicts, each shaped like::
{
"title": "...",
"body": "...", # optional
"assignee": "profile-name", # optional, None -> default fallback
"parents": [0, 2], # indices into this same children list
}
Returns the list of created child task ids (in input order) on
success. Returns ``None`` when:
- The root task does not exist
- The root task is not in ``triage``
- A cycle would result (caller built a bad graph)
Validation of titles/assignees happens inside the same write_txn as
the inserts so a malformed entry aborts the whole decomposition
cleanly (no orphan children).
"""
if not children:
return None
if root_assignee is not None:
root_assignee = _canonical_assignee(root_assignee)
# Pre-validate the children list shape outside the txn. Cheap checks
# that don't need DB access. Bad input aborts before we touch the DB.
for idx, child in enumerate(children):
if not isinstance(child, dict):
raise ValueError(f"child[{idx}] is not a dict")
title = child.get("title")
if not isinstance(title, str) or not title.strip():
raise ValueError(f"child[{idx}].title is required")
parents_idx = child.get("parents") or []
if not isinstance(parents_idx, list):
raise ValueError(f"child[{idx}].parents must be a list")
for p in parents_idx:
if not isinstance(p, int) or p < 0 or p >= len(children):
raise ValueError(
f"child[{idx}].parents[{p}] is not a valid index into children"
)
if p == idx:
raise ValueError(f"child[{idx}] cannot list itself as a parent")
# We do the full decomposition in a SINGLE write_txn so it's
# atomic: either every child is created AND the root flips to
# ``todo``, or nothing changes. We deliberately do NOT call any
# kb helper that opens its own write_txn (create_task, link_tasks,
# add_comment) from inside this block — see architecture.md
# write_txn pitfalls. Instead we inline the INSERTs and
# _append_event calls.
now = int(time.time())
child_ids: list[str] = []
with write_txn(conn):
root_row = conn.execute(
"SELECT id, status, tenant FROM tasks WHERE id = ?", (task_id,)
).fetchone()
if root_row is None:
return None
if root_row["status"] != "triage":
return None
tenant = root_row["tenant"]
# Create children. Status is 'todo' regardless of parents — we
# link them under the root AFTER creation so the dispatcher
# sees a coherent state, and recompute_ready() at the end
# promotes parent-free children to 'ready'.
for idx, child in enumerate(children):
new_id = _new_task_id()
title = child["title"].strip()
body = child.get("body")
assignee = _canonical_assignee(child.get("assignee"))
conn.execute(
"INSERT INTO tasks "
"(id, title, body, assignee, status, workspace_kind, "
" tenant, created_at, created_by) "
"VALUES (?, ?, ?, ?, 'todo', 'scratch', ?, ?, ?)",
(
new_id,
title,
body if isinstance(body, str) else None,
assignee,
tenant,
now,
(author or "decomposer"),
),
)
_append_event(
conn, new_id, "created",
{"by": author or "decomposer", "from_decompose_of": task_id},
)
child_ids.append(new_id)
# Link children to their sibling parents (within the decomposed graph).
for idx, child in enumerate(children):
for p_idx in child.get("parents") or []:
parent_id = child_ids[p_idx]
child_id = child_ids[idx]
conn.execute(
"INSERT OR IGNORE INTO task_links (parent_id, child_id) "
"VALUES (?, ?)",
(parent_id, child_id),
)
_append_event(
conn, child_id, "linked",
{"parent": parent_id, "child": child_id},
)
# Link the ROOT task as a child of every leaf child — i.e. the
# root waits for the whole graph. Simpler than computing leaves:
# link root under every child. Cycle-free because the root is
# only ever a child here, never a parent of children.
for cid in child_ids:
conn.execute(
"INSERT OR IGNORE INTO task_links (parent_id, child_id) "
"VALUES (?, ?)",
(cid, task_id),
)
# Flip the root: triage -> todo, set assignee to the orchestrator.
sets = ["status = 'todo'"]
params: list[Any] = []
if root_assignee is not None:
sets.append("assignee = ?")
params.append(root_assignee)
params.append(task_id)
conn.execute(
f"UPDATE tasks SET {', '.join(sets)} WHERE id = ?",
tuple(params),
)
# Audit comment + event on the root so the timeline shows the fan-out.
if author and author.strip():
conn.execute(
"INSERT INTO task_comments (task_id, author, body, created_at) "
"VALUES (?, ?, ?, ?)",
(
task_id,
author.strip(),
"Decomposed into "
+ ", ".join(child_ids)
+ ". Root will wake when all children complete.",
now,
),
)
_append_event(
conn, task_id, "decomposed",
{
"child_ids": child_ids,
"root_assignee": root_assignee,
},
)
# Outside the write_txn: promote parent-free children to 'ready'
# so the dispatcher picks them up on its next tick. Same pattern
# specify_triage_task uses.
recompute_ready(conn)
return child_ids
def archive_task(conn: sqlite3.Connection, task_id: str) -> bool:
with write_txn(conn):
cur = conn.execute(
@@ -4024,6 +4199,7 @@ def _default_spawn(
stderr=subprocess.STDOUT,
env=env,
start_new_session=True,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
)
except FileNotFoundError:
log_f.close()
+440
View File
@@ -0,0 +1,440 @@
"""Kanban decomposer — fan a triage task out into a graph of child tasks.
Invoked by ``hermes kanban decompose [task_id | --all]`` and the
auto-decompose path in the gateway dispatcher loop. Reads the user's
profile roster (with descriptions) and asks the auxiliary LLM to
return a task graph in JSON. Then atomically creates the children,
links them under the root, and flips the root ``triage -> todo``.
The root task stays alive and becomes the parent of every leaf child,
so when the whole graph completes the root wakes back up its
assignee (the orchestrator profile) gets a chance to judge completion
and add more tasks if the work isn't done yet.
Design notes
------------
* Mirrors the shape of ``hermes_cli/kanban_specify.py``: lazy aux
client import inside the function, lenient response parse, never
raises on expected failure modes.
* The system prompt sees the *configured* profile roster names plus
descriptions plus the default fallback. Profiles without a
description are still listed (with a note) so the orchestrator can
match on name as a fallback, but the user has an obvious incentive
to describe them.
* ``fanout=false`` collapses to the same effect as ``kanban specify``:
we tighten the body and flip ``triage -> todo`` as a single task,
no children created. This makes ``decompose`` a strict superset of
``specify`` from the user's perspective.
* If the LLM picks an assignee that doesn't exist as a profile, we
rewrite it to the configured ``default_assignee`` (or the default
profile if unset). A child task NEVER ends up with ``assignee=None``.
"""
from __future__ import annotations
import json
import logging
import os
import re
from dataclasses import dataclass
from typing import Optional
from hermes_cli import kanban_db as kb
from hermes_cli import profiles as profiles_mod
logger = logging.getLogger(__name__)
_SYSTEM_PROMPT = """You are the Kanban decomposer for the Hermes Agent board.
A user dropped a rough idea into the Triage column. Your job is to break it
into a small graph of concrete child tasks and route each one to the best-
matching profile from the available roster.
You will be given:
- The original task title and body
- The list of available profiles (each with name + description)
- The fallback "default_assignee" used when no profile fits
Output a single JSON object with this exact shape:
{
"fanout": true,
"rationale": "<one sentence on why this decomposition>",
"tasks": [
{
"title": "<concrete task title, imperative voice, <= 80 chars>",
"body": "<detailed spec for the worker on this child task>",
"assignee": "<profile name from the roster, or null for default>",
"parents": [<int>, ...]
},
...
]
}
Rules:
- "parents" is a list of INDICES (0-based) into this same "tasks" list,
expressing actual data dependencies. Tasks with no parents run in
PARALLEL. Tasks with parents wait until every parent completes.
- Prefer parallelism. If two tasks can be done independently, give
them no parents so the dispatcher fans them out at once.
- Use 2-6 tasks for normal work. Don't create 20 tiny tasks. Don't
cram everything into 1 task.
- Pick assignees from the roster by matching the task to the profile's
DESCRIPTION (not just the name). When nothing matches well, use null
and the system will route to the default_assignee.
- Each child task body is what a fresh worker will read with no other
context be specific about goal, approach, and acceptance criteria.
When the task is genuinely a single unit of work (no useful decomposition),
return:
{
"fanout": false,
"rationale": "<one sentence>",
"title": "<tightened title>",
"body": "<concrete spec for a single worker>"
}
In that case the task stays as one work item, just with a tightened spec.
No preamble, no closing remarks, no code fences. Output only the JSON object.
"""
_USER_TEMPLATE = """Task id: {task_id}
Title: {title}
Body:
{body}
Available profiles (assignees you may pick from):
{roster}
Default assignee (used when no profile fits a task): {default_assignee}
"""
_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE)
@dataclass
class DecomposeOutcome:
"""Result of decomposing a single triage task."""
task_id: str
ok: bool
reason: str = ""
fanout: bool = False
child_ids: list[str] | None = None
new_title: Optional[str] = None
def _truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[: limit - 1] + ""
def _extract_json_blob(raw: str) -> Optional[dict]:
if not raw:
return None
stripped = _FENCE_RE.sub("", raw.strip())
first = stripped.find("{")
last = stripped.rfind("}")
if first == -1 or last == -1 or last <= first:
return None
candidate = stripped[first : last + 1]
try:
val = json.loads(candidate)
except (ValueError, json.JSONDecodeError):
return None
if not isinstance(val, dict):
return None
return val
def _profile_author() -> str:
"""Mirror of ``hermes_cli.kanban._profile_author``."""
return (
os.environ.get("HERMES_PROFILE")
or os.environ.get("USER")
or "decomposer"
)
def _load_config() -> dict:
try:
from hermes_cli.config import load_config
return load_config() or {}
except Exception:
return {}
def _resolve_orchestrator_profile(cfg: dict) -> str:
"""Resolve which profile owns decomposition.
Falls back to the active default profile when ``kanban.orchestrator_profile``
is unset, so a task is never stranded for lack of an orchestrator.
"""
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
explicit = (kanban_cfg.get("orchestrator_profile") or "").strip()
if explicit:
try:
if profiles_mod.profile_exists(explicit):
return explicit
except Exception:
pass
# Fall back to the active default profile.
try:
return profiles_mod.get_active_profile_name() or "default"
except Exception:
return "default"
def _resolve_default_assignee(cfg: dict) -> str:
"""Resolve which profile catches child tasks the orchestrator can't route."""
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
explicit = (kanban_cfg.get("default_assignee") or "").strip()
if explicit:
try:
if profiles_mod.profile_exists(explicit):
return explicit
except Exception:
pass
try:
return profiles_mod.get_active_profile_name() or "default"
except Exception:
return "default"
def _build_roster() -> tuple[list[dict], set[str]]:
"""Return (roster_for_prompt, valid_assignee_names).
Each roster entry is ``{name, description, has_description}``. The
valid-set is used after the LLM responds to rewrite invalid
assignees to the default fallback.
"""
roster: list[dict] = []
valid: set[str] = set()
try:
all_profiles = profiles_mod.list_profiles()
except Exception as exc:
logger.warning("decompose: failed to list profiles: %s", exc)
return roster, valid
for p in all_profiles:
desc = (p.description or "").strip()
roster.append({
"name": p.name,
"description": desc or f"(no description; profile named {p.name!r})",
"has_description": bool(desc),
})
valid.add(p.name)
return roster, valid
def _format_roster(roster: list[dict]) -> str:
if not roster:
return " (no profiles installed — decomposer cannot route work)"
lines = []
for entry in roster:
tag = "" if entry["has_description"] else " ⚠ undescribed"
lines.append(f" - {entry['name']}{tag}: {entry['description']}")
return "\n".join(lines)
def decompose_task(
task_id: str,
*,
author: Optional[str] = None,
timeout: Optional[int] = None,
) -> DecomposeOutcome:
"""Decompose a triage task into a graph of child tasks.
Returns an outcome describing what happened. Never raises for
expected failure modes (task not in triage, no aux client
configured, API error, malformed response, decomposer returned
fanout=true with empty task list) those surface via ``ok=False``.
"""
with kb.connect() as conn:
task = kb.get_task(conn, task_id)
if task is None:
return DecomposeOutcome(task_id, False, "unknown task id")
if task.status != "triage":
return DecomposeOutcome(
task_id, False, f"task is not in triage (status={task.status!r})"
)
cfg = _load_config()
orchestrator = _resolve_orchestrator_profile(cfg)
default_assignee = _resolve_default_assignee(cfg)
roster, valid_names = _build_roster()
try:
from agent.auxiliary_client import ( # type: ignore
get_auxiliary_extra_body,
get_text_auxiliary_client,
)
except Exception as exc:
logger.debug("decompose: auxiliary client import failed: %s", exc)
return DecomposeOutcome(task_id, False, "auxiliary client unavailable")
try:
client, model = get_text_auxiliary_client("kanban_decomposer")
except Exception as exc:
logger.debug("decompose: get_text_auxiliary_client failed: %s", exc)
return DecomposeOutcome(task_id, False, "auxiliary client unavailable")
if client is None or not model:
return DecomposeOutcome(task_id, False, "no auxiliary client configured")
user_msg = _USER_TEMPLATE.format(
task_id=task.id,
title=_truncate(task.title or "", 400),
body=_truncate(task.body or "(no body)", 4000),
roster=_format_roster(roster),
default_assignee=default_assignee,
)
try:
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
temperature=0.3,
max_tokens=4000,
timeout=timeout or 180,
extra_body=get_auxiliary_extra_body() or None,
)
except Exception as exc:
logger.info(
"decompose: API call failed for %s (%s)", task_id, exc,
)
return DecomposeOutcome(task_id, False, f"LLM error: {type(exc).__name__}")
try:
raw = resp.choices[0].message.content or ""
except Exception:
raw = ""
parsed = _extract_json_blob(raw)
if parsed is None:
return DecomposeOutcome(task_id, False, "LLM returned malformed JSON")
fanout = bool(parsed.get("fanout"))
audit_author = author or _profile_author()
if not fanout:
# Fall back to single-task spec promotion (same effect as specify).
new_title = parsed.get("title")
new_body = parsed.get("body")
title_val = new_title.strip() if isinstance(new_title, str) and new_title.strip() else None
body_val = new_body if isinstance(new_body, str) and new_body.strip() else None
if title_val is None and body_val is None:
return DecomposeOutcome(
task_id, False, "decomposer returned fanout=false with no title/body",
)
with kb.connect() as conn:
ok = kb.specify_triage_task(
conn,
task_id,
title=title_val,
body=body_val,
author=audit_author,
)
if not ok:
return DecomposeOutcome(
task_id, False, "task moved out of triage before promotion",
)
return DecomposeOutcome(
task_id, True, "single task (no fanout)",
fanout=False, new_title=title_val,
)
raw_tasks = parsed.get("tasks") or []
if not isinstance(raw_tasks, list) or not raw_tasks:
return DecomposeOutcome(
task_id, False, "decomposer returned fanout=true with empty tasks list",
)
# Rewrite invalid assignees to the default fallback. Never leave a
# task with assignee=None — the user explicitly does not want that.
children: list[dict] = []
for idx, entry in enumerate(raw_tasks):
if not isinstance(entry, dict):
return DecomposeOutcome(
task_id, False, f"tasks[{idx}] is not an object",
)
title = entry.get("title")
if not isinstance(title, str) or not title.strip():
return DecomposeOutcome(
task_id, False, f"tasks[{idx}].title is missing or empty",
)
body = entry.get("body")
if not isinstance(body, str):
body = ""
assignee = entry.get("assignee")
if not isinstance(assignee, str) or not assignee.strip():
chosen = default_assignee
elif assignee not in valid_names:
logger.info(
"decompose: task %s child %d picked unknown assignee %r"
"routing to default_assignee %r",
task_id, idx, assignee, default_assignee,
)
chosen = default_assignee
else:
chosen = assignee
parents = entry.get("parents") or []
if not isinstance(parents, list):
parents = []
# Clean parent indices: drop non-int and out-of-range.
clean_parents = [p for p in parents if isinstance(p, int) and 0 <= p < len(raw_tasks) and p != idx]
children.append({
"title": title.strip()[:200],
"body": body.strip(),
"assignee": chosen,
"parents": clean_parents,
})
try:
with kb.connect() as conn:
child_ids = kb.decompose_triage_task(
conn,
task_id,
root_assignee=orchestrator,
children=children,
author=audit_author,
)
except ValueError as exc:
return DecomposeOutcome(task_id, False, f"DB rejected graph: {exc}")
except Exception as exc:
logger.exception("decompose: DB error on task %s", task_id)
return DecomposeOutcome(task_id, False, f"DB error: {type(exc).__name__}")
if child_ids is None:
return DecomposeOutcome(
task_id, False, "task moved out of triage before decomposition",
)
return DecomposeOutcome(
task_id, True, f"decomposed into {len(child_ids)} children",
fanout=True, child_ids=child_ids,
)
def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
"""Return task ids currently in the triage column."""
with kb.connect() as conn:
rows = kb.list_tasks(
conn,
status="triage",
tenant=tenant,
limit=1000,
)
return [row.id for row in rows]
+145 -2
View File
@@ -9082,6 +9082,7 @@ def cmd_profile(args):
clone_config=clone,
no_alias=no_alias,
no_skills=no_skills,
description=getattr(args, "description", None),
)
print(f"\nProfile '{name}' created at {profile_dir}")
@@ -9181,6 +9182,107 @@ def cmd_profile(args):
print(f"Error: {e}")
sys.exit(1)
elif action == "describe":
# Read or write a profile's description. The description is
# consumed by the kanban decomposer to route tasks based on
# role instead of name alone.
from hermes_cli import profiles as _profiles_mod
all_flag = bool(getattr(args, "all_missing", False))
auto_flag = bool(getattr(args, "auto", False))
overwrite_flag = bool(getattr(args, "overwrite", False))
text_value = getattr(args, "text", None)
name = getattr(args, "profile_name", None)
if all_flag and not auto_flag:
print("profile describe: --all requires --auto", file=sys.stderr)
sys.exit(2)
if all_flag and (text_value or name):
print(
"profile describe: --all is mutually exclusive with a profile name / --text",
file=sys.stderr,
)
sys.exit(2)
if not all_flag and not name:
print("profile describe: profile name is required (or --all --auto)", file=sys.stderr)
sys.exit(2)
if text_value and auto_flag:
print(
"profile describe: --text is mutually exclusive with --auto",
file=sys.stderr,
)
sys.exit(2)
# Show current description if no operation requested.
if name and not text_value and not auto_flag:
try:
if _profiles_mod.normalize_profile_name(name) == "default":
from hermes_constants import get_hermes_home as _hh
profile_dir = Path(_hh())
else:
profile_dir = _profiles_mod.get_profile_dir(name)
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if not profile_dir.is_dir():
print(f"Error: profile '{name}' not found", file=sys.stderr)
sys.exit(1)
meta = _profiles_mod.read_profile_meta(profile_dir)
desc = meta.get("description") or ""
if not desc:
print(f"(no description set for '{name}')")
else:
tag = "[auto] " if meta.get("description_auto") else ""
print(f"{tag}{desc}")
sys.exit(0)
# --text path: just write the user-authored description.
if text_value:
try:
if _profiles_mod.normalize_profile_name(name) == "default":
from hermes_constants import get_hermes_home as _hh
profile_dir = Path(_hh())
else:
profile_dir = _profiles_mod.get_profile_dir(name)
_profiles_mod.write_profile_meta(
profile_dir,
description=text_value,
description_auto=False,
)
print(f"Description updated for '{name}'.")
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
sys.exit(0)
# --auto path: invoke the LLM describer.
from hermes_cli import profile_describer as _pd
if all_flag:
targets = _pd.list_describable_profiles(missing_only=True)
if not targets:
print("All profiles already have descriptions.")
sys.exit(0)
else:
targets = [name]
ok_count = 0
fail_count = 0
for tgt in targets:
outcome = _pd.describe_profile(tgt, overwrite=overwrite_flag)
if outcome.ok:
ok_count += 1
print(f"Described '{outcome.profile_name}': {outcome.description}")
else:
fail_count += 1
print(
f"profile describe {outcome.profile_name}: {outcome.reason}",
file=sys.stderr,
)
if not all_flag:
sys.exit(0 if ok_count == 1 else 1)
sys.exit(0 if ok_count > 0 else 1)
elif action == "show":
name = args.profile_name
from hermes_cli.profiles import (
@@ -9684,8 +9786,8 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"config", "cron", "curator", "dashboard", "debug", "doctor",
"dump", "fallback", "gateway", "hooks", "import", "insights",
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory",
"model", "pairing", "plugins", "postinstall", "profile", "proxy", "send",
"sessions", "setup",
"model", "pairing", "plugins", "postinstall", "profile", "proxy",
"send", "sessions", "setup",
"skills", "slack", "status", "tools", "uninstall", "update",
"version", "webhook", "whatsapp", "chat",
# Help-ish invocations — plugin commands not being listed in
@@ -12076,6 +12178,13 @@ Examples:
action="store_true",
help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)",
)
profile_create.add_argument(
"--description",
default=None,
help="One- or two-sentence description of what this profile is good at. "
"Used by the kanban decomposer to route tasks based on role instead "
"of profile name alone. Skip and add later via `hermes profile describe`.",
)
profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile")
profile_delete.add_argument("profile_name", help="Profile to delete")
@@ -12083,6 +12192,40 @@ Examples:
"-y", "--yes", action="store_true", help="Skip confirmation prompt"
)
profile_describe = profile_subparsers.add_parser(
"describe",
help="Read or set a profile's description (used by the kanban orchestrator)",
)
profile_describe.add_argument(
"profile_name",
nargs="?",
default=None,
help="Profile to describe (omit + use --all --auto to sweep)",
)
profile_describe.add_argument(
"--text",
default=None,
help="Set description to this exact text (overwrites any existing description)",
)
profile_describe.add_argument(
"--auto",
action="store_true",
help="Auto-generate description via the auxiliary LLM "
"(uses auxiliary.profile_describer)",
)
profile_describe.add_argument(
"--overwrite",
action="store_true",
help="With --auto, replace user-authored descriptions too (default: only "
"fill in missing or previously-auto descriptions)",
)
profile_describe.add_argument(
"--all",
dest="all_missing",
action="store_true",
help="With --auto, run on every profile missing a description",
)
profile_show = profile_subparsers.add_parser("show", help="Show profile details")
profile_show.add_argument("profile_name", help="Profile to show")
+20 -1
View File
@@ -1688,7 +1688,26 @@ def list_authenticated_providers(
continue
# Live model discovery from custom provider endpoints (matches
# Section 3 behavior for user ``providers:`` entries).
if api_url and api_key:
# Also probes when no api_key is set (e.g. local llama.cpp /
# Ollama servers) — the /models endpoint often works without
# auth. The CLI's _model_flow_named_custom always probes, so
# the Telegram/Discord picker should do the same for parity.
# Live-discovery policy:
# - With an api_key, the user has explicitly opted into the
# endpoint and live /models is the source of truth — replace
# the (possibly partial) ``models:`` subset configured for
# context-length overrides with the full live catalog.
# This is the Bifrost / aggregator-gateway case.
# - Without an api_key but with an explicit ``models:`` list
# (or top-level ``model:``), the user is narrowing a public
# endpoint to a specific subset (e.g. ollama.com /v1/models
# returns 35 models but the user only wants 4). Preserve the
# explicit list and skip live discovery.
# - Without an api_key AND no explicit models, fall through to
# live discovery so bare-endpoint custom providers (local
# llama.cpp / Ollama servers) still appear populated.
should_probe = bool(api_url) and (bool(api_key) or not grp["models"])
if should_probe:
try:
from hermes_cli.models import fetch_api_models
+32
View File
@@ -608,6 +608,38 @@ class PluginContext:
self.manifest.name, provider.name,
)
# -- browser provider registration ---------------------------------------
def register_browser_provider(self, provider) -> None:
"""Register a cloud browser backend.
``provider`` must be an instance of
:class:`agent.browser_provider.BrowserProvider`. The
``provider.name`` attribute is what ``browser.cloud_provider`` in
``config.yaml`` matches against when routing cloud-mode
``browser_*`` tool calls.
Mirrors :meth:`register_web_search_provider` exactly same
registration shape, same gating, same logging. The browser
subsystem's dispatcher (:func:`tools.browser_tool._get_cloud_provider`)
consults the registry built up by these calls.
"""
from agent.browser_provider import BrowserProvider
from agent.browser_registry import register_provider as _register_browser_provider
if not isinstance(provider, BrowserProvider):
logger.warning(
"Plugin '%s' tried to register a browser provider that does "
"not inherit from BrowserProvider. Ignoring.",
self.manifest.name,
)
return
_register_browser_provider(provider)
logger.info(
"Plugin '%s' registered browser provider: %s",
self.manifest.name, provider.name,
)
# -- platform adapter registration ---------------------------------------
def register_platform(
+299
View File
@@ -0,0 +1,299 @@
"""Profile describer — auto-generate ``description`` for a profile.
Used by ``hermes profile describe <name> --auto`` and the dashboard's
"auto-generate description" button. Reads the profile's installed
skills, model+provider, name, and optionally a small slice of memory,
then asks the auxiliary LLM to produce a 1-2 sentence description of
what the profile is good at.
Result is written to ``<profile_dir>/profile.yaml`` with
``description_auto: true`` so the dashboard can surface a "review"
badge. User can edit afterward to confirm.
Design notes
------------
- Mirrors the shape of ``hermes_cli/kanban_specify.py``: lazy aux
client import inside the function, lenient response parse, never
raises on expected failure modes.
- Reads at most ``MAX_SKILLS_FOR_PROMPT`` skill names to keep the
prompt bounded. No skill body names + categories are enough
signal and avoid blowing context on profiles with 100+ skills.
- Memory is intentionally NOT read here. Memories are personal and
the orchestrator routes work to a *role* not a *biography*. If we
find later that memory adds signal we can wire it; for now,
skills + name + model is plenty.
"""
from __future__ import annotations
import json
import logging
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from hermes_cli import profiles as profiles_mod
logger = logging.getLogger(__name__)
# Cap on how many skill names we feed the LLM. Profiles with 200+
# skills (uncommon but possible) would blow context otherwise. The cap
# is per-category — see _collect_skills.
MAX_SKILLS_FOR_PROMPT = 60
_SYSTEM_PROMPT = """You are a profile-describer for the Hermes Agent kanban board.
A user runs multiple "profiles" distinct agent identities, each with their
own skills, model, and configuration. The kanban board's orchestrator routes
work to whichever profile best fits each task. To do that well, every
profile needs a short, concrete description of what it's good at.
You are given a profile's:
- Name
- Model / provider
- List of installed skill names (a strong signal of role / domain)
Produce a single JSON object with exactly one key:
{
"description": "<1-2 sentence description, plain prose, no preamble>"
}
Rules:
- The description is what an orchestrator will read to decide whether to
route a task here. Lead with the profile's strongest capability.
- Stay concrete. Bad: "an AI agent that helps users."
Good: "Reads and modifies Python codebases — runs tests,
refactors functions, opens GitHub PRs."
- 1-2 sentences, <= 280 characters total.
- Never invent capabilities the skills don't suggest.
- Never write "Hermes Agent profile" or other meta-narration.
- No code fences, no preamble, no closing remarks. Output only JSON.
"""
_USER_TEMPLATE = """Profile name: {name}
Default model: {model}
Provider: {provider}
Installed skill count: {skill_count}
Notable skills (up to {skill_cap}):
{skill_list}
"""
_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE)
@dataclass
class DescribeOutcome:
"""Result of describing a single profile."""
profile_name: str
ok: bool
reason: str = ""
description: Optional[str] = None
def _collect_skills(profile_dir: Path) -> list[str]:
"""Return a stable, capped list of skill names for the prompt.
Format: ``category/skill_name`` where category is the immediate
subdir under ``skills/`` (e.g. ``devops``, ``research``). Skills
that live directly under ``skills/`` show as bare ``skill_name``.
"""
skills_dir = profile_dir / "skills"
if not skills_dir.is_dir():
return []
names: list[str] = []
for md in skills_dir.rglob("SKILL.md"):
path_str = str(md)
if "/.hub/" in path_str or "/.git/" in path_str:
continue
try:
rel = md.relative_to(skills_dir)
except ValueError:
continue
parts = rel.parts[:-1] # drop SKILL.md filename
if not parts:
continue
# parts[-1] is the skill dir name; parts[:-1] is the category path
if len(parts) == 1:
names.append(parts[0])
else:
names.append(f"{parts[0]}/{parts[-1]}")
names.sort()
# Keep within prompt budget. Skills earlier in alphabet aren't more
# important — we'll let the LLM see a sample. Pick evenly-spaced
# entries instead of just the head so a profile with skills A..Z
# doesn't get described as "starts with A".
if len(names) <= MAX_SKILLS_FOR_PROMPT:
return names
step = len(names) / MAX_SKILLS_FOR_PROMPT
sampled = [names[int(i * step)] for i in range(MAX_SKILLS_FOR_PROMPT)]
return sampled
def _extract_json_blob(raw: str) -> Optional[dict]:
if not raw:
return None
stripped = _FENCE_RE.sub("", raw.strip())
first = stripped.find("{")
last = stripped.rfind("}")
if first == -1 or last == -1 or last <= first:
return None
candidate = stripped[first : last + 1]
try:
val = json.loads(candidate)
except (ValueError, json.JSONDecodeError):
return None
if not isinstance(val, dict):
return None
return val
def describe_profile(
profile_name: str,
*,
overwrite: bool = False,
timeout: Optional[int] = None,
) -> DescribeOutcome:
"""Auto-generate a description for one profile.
Returns an outcome describing what happened. Never raises for
expected failure modes (profile missing, no aux client configured,
API error, malformed response) those surface via ``ok=False`` so
a sweep can continue past individual failures.
``overwrite`` controls whether an existing user-authored description
is replaced. By default we refuse to overwrite a description with
``description_auto: false`` to protect curated text. Auto-generated
descriptions (``description_auto: true``) are always replaceable.
"""
canon = profiles_mod.normalize_profile_name(profile_name)
if not profiles_mod.profile_exists(canon):
# Special case: "default" exists as a virtual profile name
# mapped to the default home dir. profile_exists() handles it.
return DescribeOutcome(canon, False, "profile not found")
try:
if canon == "default":
from hermes_constants import get_hermes_home # type: ignore
profile_dir = Path(get_hermes_home())
else:
profile_dir = profiles_mod.get_profile_dir(canon)
except Exception as exc:
return DescribeOutcome(canon, False, f"cannot resolve profile dir: {exc}")
# Honor curated descriptions unless --overwrite.
existing = profiles_mod.read_profile_meta(profile_dir)
if existing.get("description") and not existing.get("description_auto") and not overwrite:
return DescribeOutcome(
canon,
False,
"profile already has a user-authored description "
"(use --overwrite to replace)",
)
skill_names = _collect_skills(profile_dir)
skill_list = "\n".join(f" - {n}" for n in skill_names) or " (no skills installed)"
skill_count = sum(
1 for _ in (profile_dir / "skills").rglob("SKILL.md")
if "/.hub/" not in str(_) and "/.git/" not in str(_)
) if (profile_dir / "skills").is_dir() else 0
# Read model + provider from the profile's config.
try:
model, provider = profiles_mod._read_config_model(profile_dir)
except Exception:
model, provider = None, None
try:
from agent.auxiliary_client import ( # type: ignore
get_auxiliary_extra_body,
get_text_auxiliary_client,
)
except Exception as exc:
logger.debug("describe: auxiliary client import failed: %s", exc)
return DescribeOutcome(canon, False, "auxiliary client unavailable")
try:
client, aux_model = get_text_auxiliary_client("profile_describer")
except Exception as exc:
logger.debug("describe: get_text_auxiliary_client failed: %s", exc)
return DescribeOutcome(canon, False, "auxiliary client unavailable")
if client is None or not aux_model:
return DescribeOutcome(canon, False, "no auxiliary client configured")
user_msg = _USER_TEMPLATE.format(
name=canon,
model=(model or "(unset)"),
provider=(provider or "(unset)"),
skill_count=skill_count,
skill_cap=MAX_SKILLS_FOR_PROMPT,
skill_list=skill_list,
)
try:
resp = client.chat.completions.create(
model=aux_model,
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
temperature=0.3,
max_tokens=400,
timeout=timeout or 60,
extra_body=get_auxiliary_extra_body() or None,
)
except Exception as exc:
logger.info("describe: API call failed for %s (%s)", canon, exc)
return DescribeOutcome(canon, False, f"LLM error: {type(exc).__name__}")
try:
raw = resp.choices[0].message.content or ""
except Exception:
raw = ""
parsed = _extract_json_blob(raw)
if parsed is None:
# Fall back: take the raw text trimmed to one paragraph.
text = raw.strip().split("\n\n", 1)[0]
if not text:
return DescribeOutcome(canon, False, "LLM returned an empty response")
description = text[:280]
else:
val = parsed.get("description")
if not isinstance(val, str) or not val.strip():
return DescribeOutcome(
canon, False, "LLM response missing 'description' field"
)
description = val.strip()[:280]
try:
profiles_mod.write_profile_meta(
profile_dir,
description=description,
description_auto=True,
)
except Exception as exc:
return DescribeOutcome(canon, False, f"failed to write profile.yaml: {exc}")
return DescribeOutcome(canon, True, "described", description=description)
def list_describable_profiles(*, missing_only: bool = True) -> list[str]:
"""Return profile names that can be described.
``missing_only=True`` (default) returns only profiles without a
description. ``missing_only=False`` returns every profile.
"""
out: list[str] = []
for p in profiles_mod.list_profiles():
if missing_only and (p.description or "").strip() and not p.description_auto:
continue
out.append(p.name)
return out
+107
View File
@@ -412,6 +412,17 @@ class ProfileInfo:
distribution_name: Optional[str] = None
distribution_version: Optional[str] = None
distribution_source: Optional[str] = None
# Free-form description (1-2 sentences) of what this profile is good
# at. Persisted in ``<profile_dir>/profile.yaml``. Empty when the
# user has not described the profile (legacy profiles, fresh
# installs). Surfaced to the kanban decomposer so it can route work
# to the right profile based on role rather than name alone.
description: str = ""
# When True, ``description`` was auto-generated by the LLM
# describer and has not been confirmed by the user. The dashboard
# surfaces a "review" badge in this case so the user can edit or
# accept.
description_auto: bool = False
def _read_distribution_meta(profile_dir: Path) -> tuple:
@@ -479,6 +490,82 @@ def _count_skills(profile_dir: Path) -> int:
return count
# ---------------------------------------------------------------------------
# profile.yaml — per-profile metadata (description, role, etc.)
# ---------------------------------------------------------------------------
#
# We keep this file deliberately tiny and separate from the profile's
# ``config.yaml``. ``config.yaml`` is the user-facing Hermes config
# (~5000 lines of defaults); ``profile.yaml`` is metadata ABOUT the
# profile itself (its role, who described it). Mixing them makes both
# harder to read.
#
# Missing file -> empty defaults; never an error. The kanban decomposer
# tolerates empty descriptions and just falls back to the profile name.
def _profile_yaml_path(profile_dir: Path) -> Path:
return profile_dir / "profile.yaml"
def read_profile_meta(profile_dir: Path) -> dict:
"""Read ``<profile_dir>/profile.yaml`` and return a dict.
Returns ``{"description": "", "description_auto": False}`` when the
file is missing or unreadable. Never raises a corrupt
profile.yaml on an unrelated profile must not break
``hermes profile list``.
"""
path = _profile_yaml_path(profile_dir)
if not path.is_file():
return {"description": "", "description_auto": False}
try:
import yaml
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
except Exception:
return {"description": "", "description_auto": False}
if not isinstance(data, dict):
return {"description": "", "description_auto": False}
return {
"description": str(data.get("description") or "").strip(),
"description_auto": bool(data.get("description_auto", False)),
}
def write_profile_meta(
profile_dir: Path,
*,
description: Optional[str] = None,
description_auto: Optional[bool] = None,
) -> None:
"""Update ``<profile_dir>/profile.yaml`` in place.
Only the explicitly passed fields are overwritten; unspecified
fields preserve existing values. Creates the file if missing.
Profile directory itself must exist.
"""
if not profile_dir.is_dir():
raise FileNotFoundError(f"profile directory does not exist: {profile_dir}")
import yaml
path = _profile_yaml_path(profile_dir)
existing: dict = {}
if path.is_file():
try:
with open(path, "r", encoding="utf-8") as f:
loaded = yaml.safe_load(f) or {}
if isinstance(loaded, dict):
existing = loaded
except Exception:
existing = {}
if description is not None:
existing["description"] = description.strip()
if description_auto is not None:
existing["description_auto"] = bool(description_auto)
with open(path, "w", encoding="utf-8") as f:
yaml.safe_dump(existing, f, sort_keys=False, default_flow_style=False)
# ---------------------------------------------------------------------------
# CRUD operations
# ---------------------------------------------------------------------------
@@ -493,6 +580,7 @@ def list_profiles() -> List[ProfileInfo]:
if default_home.is_dir():
model, provider = _read_config_model(default_home)
dist_name, dist_version, dist_source = _read_distribution_meta(default_home)
meta = read_profile_meta(default_home)
profiles.append(ProfileInfo(
name="default",
path=default_home,
@@ -505,6 +593,8 @@ def list_profiles() -> List[ProfileInfo]:
distribution_name=dist_name,
distribution_version=dist_version,
distribution_source=dist_source,
description=meta.get("description", ""),
description_auto=meta.get("description_auto", False),
))
# Named profiles
@@ -519,6 +609,7 @@ def list_profiles() -> List[ProfileInfo]:
model, provider = _read_config_model(entry)
alias_path = wrapper_dir / name
dist_name, dist_version, dist_source = _read_distribution_meta(entry)
meta = read_profile_meta(entry)
profiles.append(ProfileInfo(
name=name,
path=entry,
@@ -532,6 +623,8 @@ def list_profiles() -> List[ProfileInfo]:
distribution_name=dist_name,
distribution_version=dist_version,
distribution_source=dist_source,
description=meta.get("description", ""),
description_auto=meta.get("description_auto", False),
))
return profiles
@@ -544,6 +637,7 @@ def create_profile(
clone_config: bool = False,
no_alias: bool = False,
no_skills: bool = False,
description: Optional[str] = None,
) -> Path:
"""Create a new profile directory.
@@ -667,6 +761,19 @@ def create_profile(
except OSError:
pass # best-effort — the feature still works via the empty skills/ dir
# Persist description if the caller provided one. Done last so a
# partial-create failure doesn't strand a description file in an
# incomplete profile.
if description and description.strip():
try:
write_profile_meta(
profile_dir,
description=description.strip(),
description_auto=False,
)
except Exception:
pass # non-fatal — user can describe later with `hermes profile describe`
return profile_dir
+15
View File
@@ -81,6 +81,21 @@ class UpstreamAdapter(ABC):
refresh fails. The proxy will return 401 to the client.
"""
def get_retry_credential(
self,
*,
failed_credential: UpstreamCredential,
status_code: int,
) -> Optional[UpstreamCredential]:
"""Return an alternate credential after an upstream auth failure.
The default is no retry. Providers can override this for one-shot
fallback paths, such as switching from a preferred token type to a
legacy bearer after the upstream rejects the first request.
"""
_ = failed_credential, status_code
return None
def describe(self) -> str:
"""One-line status summary for ``proxy status``."""
try:
+79 -25
View File
@@ -1,12 +1,13 @@
"""Nous Portal upstream adapter.
Reads the user's Nous OAuth state from ``~/.hermes/auth.json``, refreshes
the access token and mints a fresh agent key when needed, and exposes the
upstream base URL plus minted bearer for the proxy server to forward to.
Reads the user's Nous OAuth state from ``~/.hermes/auth.json`` through the
shared runtime resolver, refreshes the access token and resolves the
``agent_key`` compatibility credential when needed, then exposes the upstream
base URL plus bearer for the proxy server to forward to.
The minted ``agent_key`` (not the OAuth ``access_token``) is what
``inference-api.nousresearch.com`` accepts as a bearer. The refresh helper
already handles both see :func:`hermes_cli.auth.refresh_nous_oauth_from_state`.
The ``agent_key`` field may hold either a NAS invoke JWT or the legacy
opaque session key. The refresh helper handles both see
:func:`hermes_cli.auth.resolve_nous_runtime_credentials`.
"""
from __future__ import annotations
@@ -16,11 +17,18 @@ import threading
from typing import Any, Dict, FrozenSet, Optional
from hermes_cli.auth import (
AuthError,
DEFAULT_NOUS_INFERENCE_URL,
NOUS_INFERENCE_AUTH_MODE_AUTO,
NOUS_INFERENCE_AUTH_MODE_LEGACY,
_load_auth_store,
_auth_store_lock,
_is_terminal_nous_refresh_error,
_quarantine_nous_oauth_state,
_quarantine_nous_pool_entries,
_save_auth_store,
_write_shared_nous_state,
refresh_nous_oauth_from_state,
resolve_nous_runtime_credentials,
)
from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential
@@ -43,9 +51,8 @@ class NousPortalAdapter(UpstreamAdapter):
"""Proxy upstream for the Nous Portal inference API."""
def __init__(self) -> None:
# Lock guards _load → refresh → _save against parallel proxy requests
# racing to refresh expired tokens. Refresh itself is HTTP, so we
# hold the lock across the network call (brief; OAuth refresh is fast).
# Serialize proxy requests in this process; cross-process token refresh
# and persistence are handled by resolve_nous_runtime_credentials().
self._lock = threading.Lock()
@property
@@ -72,6 +79,26 @@ class NousPortalAdapter(UpstreamAdapter):
)
def get_credential(self) -> UpstreamCredential:
return self._get_credential(
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_AUTO,
)
def get_retry_credential(
self,
*,
failed_credential: UpstreamCredential,
status_code: int,
) -> Optional[UpstreamCredential]:
if status_code != 401:
return None
if failed_credential.bearer.count(".") != 2:
return None
logger.info("proxy: Nous upstream rejected bearer; retrying with legacy session key")
return self._get_credential(
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY,
)
def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential:
with self._lock:
state = self._read_state()
if state is None:
@@ -80,28 +107,43 @@ class NousPortalAdapter(UpstreamAdapter):
)
try:
refreshed = refresh_nous_oauth_from_state(state)
refreshed = resolve_nous_runtime_credentials(
inference_auth_mode=inference_auth_mode,
)
except AuthError as exc:
if _is_terminal_nous_refresh_error(exc):
_quarantine_nous_oauth_state(
state,
exc,
reason="proxy_refresh_failure",
)
self._save_state(
state,
quarantine_error=exc,
quarantine_reason="proxy_refresh_failure",
)
raise RuntimeError(
f"Failed to refresh Nous Portal credentials: {exc}"
) from exc
except Exception as exc:
raise RuntimeError(
f"Failed to refresh Nous Portal credentials: {exc}"
) from exc
self._save_state(refreshed)
agent_key = refreshed.get("agent_key")
agent_key = refreshed.get("api_key")
if not agent_key:
raise RuntimeError(
"Nous Portal refresh did not return a usable agent_key. "
"Try `hermes login nous` to re-authenticate."
)
base_url = refreshed.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL
base_url = refreshed.get("base_url") or DEFAULT_NOUS_INFERENCE_URL
base_url = base_url.rstrip("/")
return UpstreamCredential(
bearer=agent_key,
base_url=base_url,
expires_at=refreshed.get("agent_key_expires_at"),
expires_at=refreshed.get("expires_at"),
)
# ------------------------------------------------------------------
@@ -111,7 +153,8 @@ class NousPortalAdapter(UpstreamAdapter):
def _read_state(self) -> Optional[Dict[str, Any]]:
try:
store = _load_auth_store()
with _auth_store_lock():
store = _load_auth_store()
except Exception as exc:
logger.warning("proxy: failed to load auth store: %s", exc)
return None
@@ -121,17 +164,28 @@ class NousPortalAdapter(UpstreamAdapter):
return None
return dict(state) # copy so the refresh helper can mutate freely
def _save_state(self, state: Dict[str, Any]) -> None:
def _save_state(
self,
state: Dict[str, Any],
*,
quarantine_error: Optional[AuthError] = None,
quarantine_reason: Optional[str] = None,
) -> None:
try:
store = _load_auth_store()
providers = store.setdefault("providers", {})
providers["nous"] = state
_save_auth_store(store)
with _auth_store_lock():
store = _load_auth_store()
if quarantine_error is not None and quarantine_reason:
_quarantine_nous_pool_entries(
store,
quarantine_error,
reason=quarantine_reason,
)
providers = store.setdefault("providers", {})
providers["nous"] = state
_save_auth_store(store)
_write_shared_nous_state(state)
except Exception as exc:
# Best effort — we still return the fresh credential. The next
# request just won't see cached state, which means another refresh.
logger.warning("proxy: failed to persist refreshed Nous state: %s", exc)
logger.warning("proxy: failed to persist Nous quarantine state: %s", exc)
__all__ = ["NousPortalAdapter"]
+1 -1
View File
@@ -114,7 +114,7 @@ def cmd_proxy(args: Any) -> int:
return cmd_proxy_start(args)
if sub == "status":
return cmd_proxy_status(args)
if sub in ("providers", "list"):
if sub in {"providers", "list"}:
return cmd_proxy_list_providers(args)
# No subcommand → print short help.
print(
+80 -37
View File
@@ -26,7 +26,7 @@ except ImportError:
web = None # type: ignore[assignment]
AIOHTTP_AVAILABLE = False
from hermes_cli.proxy.adapters.base import UpstreamAdapter
from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential
logger = logging.getLogger(__name__)
@@ -76,7 +76,7 @@ def _filter_response_headers(headers) -> dict:
if key.lower() in _HOP_BY_HOP_HEADERS:
continue
# aiohttp recomputes Content-Encoding/Content-Length on stream — let it.
if key.lower() in ("content-encoding", "content-length"):
if key.lower() in {"content-encoding", "content-length"}:
continue
out[key] = value
return out
@@ -136,50 +136,93 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application":
logger.warning("proxy: credential resolution failed: %s", exc)
return _json_error(401, str(exc), code="upstream_auth_failed")
upstream_url = f"{cred.base_url.rstrip('/')}{rel_path}"
# Preserve query string verbatim.
if request.query_string:
upstream_url = f"{upstream_url}?{request.query_string}"
# Forward body verbatim. Read into memory once — request bodies for
# chat/completions/embeddings are small (<1MB typically). If we ever
# need to forward large multipart uploads we'll switch to streaming
# the request body too.
body = await request.read()
fwd_headers = _filter_request_headers(request.headers)
fwd_headers["Authorization"] = f"{cred.token_type} {cred.bearer}"
logger.debug(
"proxy: forwarding %s %s -> %s (body=%d bytes)",
request.method, rel_path, upstream_url, len(body),
)
# Use a per-request session so connection state doesn't leak between
# clients. Could be optimized to a shared session later.
timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=300)
try:
session = aiohttp.ClientSession(timeout=timeout)
except Exception as exc: # pragma: no cover - aiohttp setup issue
return _json_error(500, f"proxy session init failed: {exc}")
try:
upstream_resp = await session.request(
request.method,
upstream_url,
data=body if body else None,
headers=fwd_headers,
allow_redirects=False,
async def _send_upstream(active_cred: UpstreamCredential):
upstream_url = f"{active_cred.base_url.rstrip('/')}{rel_path}"
# Preserve query string verbatim.
if request.query_string:
upstream_url = f"{upstream_url}?{request.query_string}"
fwd_headers = _filter_request_headers(request.headers)
fwd_headers["Authorization"] = f"{active_cred.token_type} {active_cred.bearer}"
logger.debug(
"proxy: forwarding %s %s -> %s (body=%d bytes)",
request.method, rel_path, upstream_url, len(body),
)
except aiohttp.ClientError as exc:
await session.close()
logger.warning("proxy: upstream connection failed: %s", exc)
return _json_error(502, f"upstream connection failed: {exc}",
code="upstream_unreachable")
except asyncio.TimeoutError:
await session.close()
return _json_error(504, "upstream request timed out",
code="upstream_timeout")
try:
session = aiohttp.ClientSession(timeout=timeout)
except Exception as exc: # pragma: no cover - aiohttp setup issue
raise RuntimeError(f"proxy session init failed: {exc}") from exc
try:
upstream_resp = await session.request(
request.method,
upstream_url,
data=body if body else None,
headers=fwd_headers,
allow_redirects=False,
)
except Exception:
await session.close()
raise
return session, upstream_resp
async def _open_upstream(active_cred: UpstreamCredential):
try:
return await _send_upstream(active_cred)
except RuntimeError as exc:
return _json_error(500, str(exc)), None
except aiohttp.ClientError as exc:
logger.warning("proxy: upstream connection failed: %s", exc)
return (
_json_error(
502,
f"upstream connection failed: {exc}",
code="upstream_unreachable",
),
None,
)
except asyncio.TimeoutError:
return (
_json_error(
504,
"upstream request timed out",
code="upstream_timeout",
),
None,
)
session_or_response, upstream_resp = await _open_upstream(cred)
if upstream_resp is None:
return session_or_response
session = session_or_response
if upstream_resp.status == 401:
try:
retry_cred = adapter.get_retry_credential(
failed_credential=cred,
status_code=upstream_resp.status,
)
except Exception as exc:
logger.warning("proxy: retry credential resolution failed: %s", exc)
retry_cred = None
if retry_cred is not None:
upstream_resp.release()
await session.close()
session_or_response, upstream_resp = await _open_upstream(retry_cred)
if upstream_resp is None:
return session_or_response
session = session_or_response
# Stream response back. Headers first, then chunked body.
resp = web.StreamResponse(
+9 -8
View File
@@ -209,7 +209,7 @@ def _maybe_apply_codex_app_server_runtime(
Returns the (possibly-rewritten) api_mode."""
if not model_cfg:
return api_mode
if provider not in ("openai", "openai-codex"):
if provider not in {"openai", "openai-codex"}:
return api_mode
runtime = str(model_cfg.get("openai_runtime") or "").strip().lower()
if runtime == "codex_app_server":
@@ -875,10 +875,9 @@ def _resolve_explicit_runtime(
explicit_base_url
or str(state.get("inference_base_url") or auth_mod.DEFAULT_NOUS_INFERENCE_URL).strip().rstrip("/")
)
# Only use agent_key for inference — access_token is an OAuth token for the
# portal API (minting keys, refreshing tokens), not for the inference API.
# Falling back to access_token sends an OAuth bearer token to the inference
# endpoint, which returns 404 because it is not a valid inference credential.
# Only use the agent_key compatibility field for inference. It may be
# either a NAS invoke JWT or a legacy opaque session key; raw OAuth
# access_token fallback is handled by resolve_nous_runtime_credentials().
api_key = explicit_api_key or str(state.get("agent_key") or "").strip()
expires_at = state.get("agent_key_expires_at") or state.get("expires_at")
if not api_key:
@@ -1069,17 +1068,19 @@ def resolve_runtime_provider(
getattr(entry, "runtime_api_key", None)
or getattr(entry, "access_token", "")
)
# For Nous, the pool entry's runtime_api_key is the agent_key — a
# short-lived inference credential (~30 min TTL). The pool doesn't
# For Nous, the pool entry's runtime_api_key is the agent_key
# compatibility field: either an invoke JWT or legacy opaque key.
# The pool doesn't
# refresh it during selection (that would trigger network calls in
# non-runtime contexts like `hermes auth list`). If the key is
# expired, clear pool_api_key so we fall through to
# resolve_nous_runtime_credentials() which handles refresh + mint.
# resolve_nous_runtime_credentials() which handles refresh + fallback.
if provider == "nous" and entry is not None and pool_api_key:
min_ttl = max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")))
nous_state = {
"agent_key": getattr(entry, "agent_key", None),
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
"scope": getattr(entry, "scope", None),
}
if not _agent_key_is_usable(nous_state, min_ttl):
logger.debug("Nous pool entry agent_key expired/missing, falling through to runtime resolution")
+1 -1
View File
@@ -171,7 +171,7 @@ def _recent_window(
cut = 0
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if isinstance(msg, Mapping) and msg.get("role") in ("user", "assistant"):
if isinstance(msg, Mapping) and msg.get("role") in {"user", "assistant"}:
count += 1
if count >= window:
cut = i
+21
View File
@@ -259,6 +259,27 @@ def show_status(args):
if minimax_status.get("error") and not minimax_logged_in:
print(f" Error: {minimax_status.get('error')}")
# xAI OAuth — separate try/except so an import failure here cannot
# disrupt the already-printed Nous/Codex/Qwen/MiniMax rows above.
try:
from hermes_cli.auth import get_xai_oauth_auth_status
xai_oauth_status = get_xai_oauth_auth_status() or {}
except Exception:
xai_oauth_status = {}
xai_oauth_logged_in = bool(xai_oauth_status.get("logged_in"))
print(
f" {'xAI OAuth':<12} {check_mark(xai_oauth_logged_in)} "
f"{'logged in' if xai_oauth_logged_in else 'not logged in (run: hermes auth add xai-oauth)'}"
)
xai_auth_file = xai_oauth_status.get("auth_store")
if xai_auth_file:
print(f" Auth file: {xai_auth_file}")
if xai_oauth_status.get("last_refresh"):
print(f" Refreshed: {_format_iso_timestamp(xai_oauth_status.get('last_refresh'))}")
if xai_oauth_status.get("error") and not xai_oauth_logged_in:
print(f" Error: {xai_oauth_status.get('error')}")
# =========================================================================
# Nous Subscription Features
# =========================================================================
+131 -35
View File
@@ -88,12 +88,40 @@ CONFIGURABLE_TOOLSETS = [
# who want it opt in via `hermes tools` → Video Generation, which walks
# them through provider + model selection.
#
# X search is off by default — gated on xAI credentials (SuperGrok OAuth
# or XAI_API_KEY). Users opt in via `hermes tools` → X (Twitter) Search,
# which walks them through credential setup. The tool's check_fn means
# the schema won't appear to the model even if enabled without credentials.
# X search is off by default for users without xAI credentials, but
# auto-enables when SuperGrok OAuth tokens are stored OR XAI_API_KEY is
# set — mirroring the HASS_TOKEN → homeassistant auto-enable below. The
# `hermes tools` → X (Twitter) Search setup walks users through credential
# setup. The tool's check_fn means the schema still won't appear to the
# model if the credential later goes missing or expires.
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search"}
def _xai_credentials_present() -> bool:
"""Cheap, side-effect-free check for usable xAI credentials.
Used to auto-enable the ``x_search`` toolset when the user has either
completed xAI Grok OAuth (SuperGrok subscription) or set
``XAI_API_KEY``. Does NOT hit the network only inspects the local
auth store and environment. The tool's runtime ``check_fn`` still
gates schema registration if creds later expire or get revoked.
"""
try:
from hermes_cli.auth import _read_xai_oauth_tokens
_read_xai_oauth_tokens()
return True
except Exception:
pass
try:
from tools.xai_http import get_env_value as _xai_get_env_value
if str(_xai_get_env_value("XAI_API_KEY") or "").strip():
return True
except Exception:
pass
return bool(str(os.environ.get("XAI_API_KEY") or "").strip())
# Platform-scoped toolsets: only appear in the `hermes tools` checklist for
# these platforms, and only resolve/save for these platforms. A toolset
# absent from this map is available on every platform (current behaviour).
@@ -350,6 +378,17 @@ TOOL_CATEGORIES = {
"browser": {
"name": "Browser Automation",
"icon": "🌐",
# Per-provider rows for Browserbase, Browser Use, and Firecrawl are
# injected at runtime from plugins.browser.<vendor>.provider via
# _plugin_browser_providers() in _visible_providers(). Only
# non-provider UX setup-flow rows remain here:
# - "Nous Subscription (Browser Use cloud)" — managed Browser Use
# billed via Nous subscription (requires_nous_auth +
# override_env_vars). Uses the browser-use plugin as the
# underlying backend but has a distinct setup UX.
# - "Local Browser" — non-cloud option, no CloudBrowserProvider.
# - "Camofox" — anti-detection local Firefox; short-circuits the
# cloud-provider dispatch path via _is_camofox_mode().
"providers": [
{
"name": "Nous Subscription (Browser Use cloud)",
@@ -370,37 +409,6 @@ TOOL_CATEGORIES = {
"browser_provider": "local",
"post_setup": "agent_browser",
},
{
"name": "Browserbase",
"badge": "paid",
"tag": "Cloud browser with stealth and proxies",
"env_vars": [
{"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"},
{"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"},
],
"browser_provider": "browserbase",
"post_setup": "agent_browser",
},
{
"name": "Browser Use",
"badge": "paid",
"tag": "Cloud browser with remote execution",
"env_vars": [
{"key": "BROWSER_USE_API_KEY", "prompt": "Browser Use API key", "url": "https://browser-use.com"},
],
"browser_provider": "browser-use",
"post_setup": "agent_browser",
},
{
"name": "Firecrawl",
"badge": "paid",
"tag": "Cloud browser with remote execution",
"env_vars": [
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
],
"browser_provider": "firecrawl",
"post_setup": "agent_browser",
},
{
"name": "Camofox",
"badge": "free · local",
@@ -1170,6 +1178,23 @@ def _get_platform_tools(
if ts_tools and ts_tools.issubset(all_tool_names):
enabled_toolsets.add(ts_key)
# Auto-enable ``x_search`` when xAI credentials are configured.
# Unlike ``homeassistant`` (whose ``ha_*`` tools live inside the
# platform composite and thus pass the subset check above),
# ``x_search`` is its own one-tool toolset that the composite does
# NOT include, so the subset loop never picks it up. Inject it
# directly here, mirroring the HASS_TOKEN → ``homeassistant`` rule
# below: once you have working creds, you don't have to also click
# through ``hermes tools`` to flip the toolset on. Only fires when
# the user has not yet saved an explicit toolset list — once they
# do, the saved list is authoritative.
x_search_auto_enabled = (
_toolset_allowed_for_platform("x_search", platform)
and _xai_credentials_present()
)
if x_search_auto_enabled:
enabled_toolsets.add("x_search")
default_off = set(_DEFAULT_OFF_TOOLSETS)
# Legacy safety: if the platform's own name matches a default-off
# toolset (e.g. `homeassistant` platform + `homeassistant` toolset),
@@ -1187,6 +1212,11 @@ def _get_platform_tools(
# regressed after #14798 made cron honor per-platform tool config.
if "homeassistant" in default_off and os.getenv("HASS_TOKEN"):
default_off.remove("homeassistant")
# Symmetric carve-out for x_search auto-enable (see the inject
# block above). Without this, the default_off subtraction would
# strip the entry we just added.
if x_search_auto_enabled and "x_search" in default_off:
default_off.remove("x_search")
enabled_toolsets -= default_off
# Recover non-configurable platform toolsets (e.g. discord, feishu_doc,
@@ -1653,6 +1683,61 @@ def _plugin_web_search_providers() -> list[dict]:
return rows
# Mirror of _plugin_web_search_providers for cloud browser backends. After
# PR #25214, Browserbase / Browser Use / Firecrawl live as plugins under
# plugins/browser/<vendor>/; this helper is the sole source of provider rows
# for those three in the "Browser Automation" picker. The hardcoded
# ``TOOL_CATEGORIES["browser"]`` entries that drove the category before
# were deleted in the same PR; only non-provider UX setup-flow rows remain
# ("Nous Subscription", "Local Browser", "Camofox") — see the comment block
# in ``TOOL_CATEGORIES["browser"]`` for why each one stays hardcoded.
def _plugin_browser_providers() -> list[dict]:
"""Build picker-row dicts from plugin-registered cloud browser providers.
Each returned dict mirrors the legacy ``TOOL_CATEGORIES["browser"]``
schema (``name`` / ``badge`` / ``tag`` / ``env_vars`` /
``browser_provider`` / ``post_setup``) so the picker behaves identically
whether a provider was hardcoded or plugin-registered.
Populates ``browser_provider`` (the legacy config key written to
``browser.cloud_provider``) and a ``browser_plugin_name`` marker so
setup / write paths can route through the registry when they want to.
"""
try:
from agent.browser_registry import list_providers as _list_browser_providers
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
providers = _list_browser_providers()
except Exception:
return []
rows: list[dict] = []
for provider in providers:
name = getattr(provider, "name", None)
if not name:
continue
try:
schema = provider.get_setup_schema()
except Exception:
continue
if not isinstance(schema, dict):
continue
row = {
"name": schema.get("name", provider.display_name),
"badge": schema.get("badge", ""),
"tag": schema.get("tag", ""),
"env_vars": schema.get("env_vars", []),
"browser_provider": name,
"browser_plugin_name": name,
}
# Pass-through optional fields the schema can opt into.
if schema.get("post_setup"):
row["post_setup"] = schema["post_setup"]
rows.append(row)
return rows
def _visible_providers(cat: dict, config: dict) -> list[dict]:
"""Return provider entries visible for the current auth/config state."""
features = get_nous_subscription_features(config)
@@ -1682,6 +1767,14 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]:
if cat.get("name") == "Web Search & Extract":
visible.extend(_plugin_web_search_providers())
# Inject plugin-registered cloud browser backends. After PR #25214,
# Browserbase / Browser Use / Firecrawl are the plugin-supplied rows;
# the hardcoded "Nous Subscription" / "Local Browser" / "Camofox" rows
# stay because they're non-provider UX setup flows (subscription auth,
# local fallback, and the REST-API anti-detection backend respectively).
if cat.get("name") == "Browser Automation":
visible.extend(_plugin_browser_providers())
return visible
@@ -2590,6 +2683,9 @@ def _reconfigure_provider(provider: dict, config: dict):
else:
_print_info(" Kept current")
if provider.get("post_setup"):
_run_post_setup(provider["post_setup"])
# Imagegen backends prompt for model selection on reconfig too.
plugin_name = provider.get("image_gen_plugin_name")
if plugin_name:
+37 -10
View File
@@ -2609,7 +2609,11 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
so the UI can render the verification page link + user code.
"""
if provider_id == "nous":
from hermes_cli.auth import _request_device_code, PROVIDER_REGISTRY
from hermes_cli.auth import (
_nous_device_scope_with_env_override,
_request_nous_device_code_with_scope_fallback,
PROVIDER_REGISTRY,
)
import httpx
pconfig = PROVIDER_REGISTRY["nous"]
portal_base_url = (
@@ -2618,22 +2622,34 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
or pconfig.portal_base_url
).rstrip("/")
client_id = pconfig.client_id
scope = pconfig.scope
scope, explicit_scope = _nous_device_scope_with_env_override(
None,
default_scope=pconfig.scope,
)
def _do_nous_device_request():
with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client:
return _request_device_code(
with httpx.Client(
timeout=httpx.Timeout(15.0),
headers={"Accept": "application/json"},
) as client:
return _request_nous_device_code_with_scope_fallback(
client=client,
portal_base_url=portal_base_url,
client_id=client_id,
scope=scope,
allow_legacy_fallback=not explicit_scope,
)
device_data = await asyncio.get_running_loop().run_in_executor(None, _do_nous_device_request)
device_data, effective_scope = await asyncio.get_running_loop().run_in_executor(
None, _do_nous_device_request
)
sid, sess = _new_oauth_session("nous", "device_code")
sess["device_code"] = str(device_data["device_code"])
sess["interval"] = int(device_data["interval"])
sess["expires_at"] = time.time() + int(device_data["expires_in"])
sess["portal_base_url"] = portal_base_url
sess["client_id"] = client_id
sess["scope"] = effective_scope
threading.Thread(
target=_nous_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}"
).start()
@@ -2762,7 +2778,11 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
def _nous_poller(session_id: str) -> None:
"""Background poller that drives a Nous device-code flow to completion."""
from hermes_cli.auth import _poll_for_token, refresh_nous_oauth_from_state
from hermes_cli.auth import (
NOUS_INFERENCE_AUTH_MODE_FRESH,
_poll_for_token,
refresh_nous_oauth_from_state,
)
from datetime import datetime, timezone
import httpx
with _oauth_sessions_lock:
@@ -2773,6 +2793,7 @@ def _nous_poller(session_id: str) -> None:
client_id = sess["client_id"]
device_code = sess["device_code"]
interval = sess["interval"]
scope = sess.get("scope")
expires_in = max(60, int(sess["expires_at"] - time.time()))
try:
with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client:
@@ -2791,7 +2812,7 @@ def _nous_poller(session_id: str) -> None:
"portal_base_url": portal_base_url,
"inference_base_url": token_data.get("inference_base_url"),
"client_id": client_id,
"scope": token_data.get("scope"),
"scope": token_data.get("scope") or scope,
"token_type": token_data.get("token_type", "Bearer"),
"access_token": token_data["access_token"],
"refresh_token": token_data.get("refresh_token"),
@@ -2803,8 +2824,11 @@ def _nous_poller(session_id: str) -> None:
"expires_in": token_ttl,
}
full_state = refresh_nous_oauth_from_state(
auth_state, min_key_ttl_seconds=300, timeout_seconds=15.0,
force_refresh=False, force_mint=True,
auth_state,
min_key_ttl_seconds=300,
timeout_seconds=15.0,
force_refresh=False,
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH,
)
from hermes_cli.auth import persist_nous_credentials
persist_nous_credentials(full_state)
@@ -5381,4 +5405,7 @@ def start_server(
open_browser,
)
print(f" Hermes Web UI → http://{host}:{port}")
uvicorn.run(app, host=host, port=port, log_level="warning")
# proxy_headers=False so _ws_client_is_allowed sees the real connection peer
# rather than X-Forwarded-For's rewritten value (which would defeat the
# loopback gate when behind a reverse proxy).
uvicorn.run(app, host=host, port=port, log_level="warning", proxy_headers=False)