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:
+797
-90
File diff suppressed because it is too large
Load Diff
@@ -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, []
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user