Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8f462bc9a | |||
| 42f9234da3 | |||
| 7190e20e0b | |||
| 83c23e8861 | |||
| 617ac0535b | |||
| 5fa493a2ca | |||
| 80775d7585 | |||
| b32461f6e8 | |||
| 486b14b423 | |||
| 81928f03ab | |||
| 5d1bdf11b6 | |||
| 7338e5d9ba | |||
| faa13e49f8 | |||
| 1bdacb697c | |||
| 34f7297359 | |||
| 307c85e5c1 | |||
| 03ddff8897 | |||
| 7d66d30d77 | |||
| 7f92e5506e | |||
| b0393af38c | |||
| 7f369bfe55 | |||
| c80fa728bd | |||
| 292f468366 | |||
| d87c7b99e2 | |||
| cff821e2dc | |||
| 65c762b2e8 | |||
| 09a491464c |
@@ -2141,6 +2141,20 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
||||
)
|
||||
elif base_url_host_matches(sync_base_url, "api.kimi.com"):
|
||||
async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
|
||||
else:
|
||||
# Fall back to profile.default_headers for providers that declare
|
||||
# client-level headers on their ProviderProfile (e.g. attribution
|
||||
# User-Agent strings). Provider is inferred from the hostname.
|
||||
try:
|
||||
from agent.model_metadata import _infer_provider_from_url
|
||||
from providers import get_provider_profile as _gpf_async
|
||||
_inferred = _infer_provider_from_url(sync_base_url)
|
||||
if _inferred:
|
||||
_ph_async = _gpf_async(_inferred)
|
||||
if _ph_async and _ph_async.default_headers:
|
||||
async_kwargs["default_headers"] = dict(_ph_async.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
return AsyncOpenAI(**async_kwargs), model
|
||||
|
||||
|
||||
@@ -2368,6 +2382,16 @@ def resolve_provider_client(
|
||||
extra["default_headers"] = copilot_request_headers(
|
||||
is_agent_turn=True, is_vision=is_vision
|
||||
)
|
||||
else:
|
||||
# Fall back to profile.default_headers for providers that
|
||||
# declare client-level attribution headers on their profile.
|
||||
try:
|
||||
from providers import get_provider_profile as _gpf_custom
|
||||
_ph_custom = _gpf_custom(provider)
|
||||
if _ph_custom and _ph_custom.default_headers:
|
||||
extra["default_headers"] = dict(_ph_custom.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra)
|
||||
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
@@ -2556,6 +2580,18 @@ def resolve_provider_client(
|
||||
headers.update(copilot_request_headers(
|
||||
is_agent_turn=True, is_vision=is_vision
|
||||
))
|
||||
else:
|
||||
# Fall back to profile.default_headers for providers that declare
|
||||
# client-level attribution headers on their profile (e.g. GMI
|
||||
# User-Agent for traffic identification, Vercel AI Gateway
|
||||
# Referer/Title for analytics).
|
||||
try:
|
||||
from providers import get_provider_profile as _gpf_main
|
||||
_ph_main = _gpf_main(provider)
|
||||
if _ph_main and _ph_main.default_headers:
|
||||
headers.update(_ph_main.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
client = OpenAI(api_key=api_key, base_url=base_url,
|
||||
**({"default_headers": headers} if headers else {}))
|
||||
|
||||
|
||||
+159
-14
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
@@ -82,6 +83,121 @@ _UTC_NOW = lambda: datetime.now(timezone.utc)
|
||||
# Official docs snapshot entries. Models whose published pricing and cache
|
||||
# semantics are stable enough to encode exactly.
|
||||
_OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
# ── Anthropic Claude 4.7 ─────────────────────────────────────────────
|
||||
# Opus 4.5/4.6/4.7 share $5/$25 pricing (new tokenizer, up to 35% more
|
||||
# tokens for the same text).
|
||||
# Source: https://platform.claude.com/docs/en/about-claude/pricing
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-7",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-7-20250507",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic Claude 4.6 ─────────────────────────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-6",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-6-20250414",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-sonnet-4-6",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-sonnet-4-6-20250414",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic Claude 4.5 ─────────────────────────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-5",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("5.00"),
|
||||
output_cost_per_million=Decimal("25.00"),
|
||||
cache_read_cost_per_million=Decimal("0.50"),
|
||||
cache_write_cost_per_million=Decimal("6.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-sonnet-4-5",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("3.00"),
|
||||
output_cost_per_million=Decimal("15.00"),
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-haiku-4-5",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("1.00"),
|
||||
output_cost_per_million=Decimal("5.00"),
|
||||
cache_read_cost_per_million=Decimal("0.10"),
|
||||
cache_write_cost_per_million=Decimal("1.25"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# ── Anthropic Claude 4 / 4.1 ─────────────────────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-4-20250514",
|
||||
@@ -91,8 +207,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
cache_read_cost_per_million=Decimal("1.50"),
|
||||
cache_write_cost_per_million=Decimal("18.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
|
||||
pricing_version="anthropic-prompt-caching-2026-03-16",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
@@ -103,8 +219,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
|
||||
pricing_version="anthropic-prompt-caching-2026-03-16",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# OpenAI
|
||||
(
|
||||
@@ -184,7 +300,7 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
source_url="https://openai.com/api/pricing/",
|
||||
pricing_version="openai-pricing-2026-03-16",
|
||||
),
|
||||
# Anthropic older models (pre-4.6 generation)
|
||||
# ── Anthropic older models (pre-4.5 generation) ────────────────────────
|
||||
(
|
||||
"anthropic",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
@@ -194,8 +310,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
cache_read_cost_per_million=Decimal("0.30"),
|
||||
cache_write_cost_per_million=Decimal("3.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
|
||||
pricing_version="anthropic-pricing-2026-03-16",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
@@ -206,8 +322,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
cache_read_cost_per_million=Decimal("0.08"),
|
||||
cache_write_cost_per_million=Decimal("1.00"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
|
||||
pricing_version="anthropic-pricing-2026-03-16",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
@@ -218,8 +334,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
cache_read_cost_per_million=Decimal("1.50"),
|
||||
cache_write_cost_per_million=Decimal("18.75"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
|
||||
pricing_version="anthropic-pricing-2026-03-16",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
@@ -230,8 +346,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
||||
cache_read_cost_per_million=Decimal("0.03"),
|
||||
cache_write_cost_per_million=Decimal("0.30"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
|
||||
pricing_version="anthropic-pricing-2026-03-16",
|
||||
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
||||
pricing_version="anthropic-pricing-2026-05",
|
||||
),
|
||||
# DeepSeek
|
||||
(
|
||||
@@ -426,8 +542,37 @@ def resolve_billing_route(
|
||||
return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown")
|
||||
|
||||
|
||||
def _normalize_anthropic_model_name(model: str) -> str:
|
||||
"""Normalize Anthropic model name variants to canonical form.
|
||||
|
||||
Handles:
|
||||
- Dot notation: claude-opus-4.7 → claude-opus-4-7
|
||||
- Short aliases: claude-opus-4.7 → claude-opus-4-7
|
||||
- Strips anthropic/ prefix if present
|
||||
"""
|
||||
name = model.lower().strip()
|
||||
if name.startswith("anthropic/"):
|
||||
name = name[len("anthropic/"):]
|
||||
# Normalize dots to dashes in version numbers (e.g. 4.7 → 4-7, 4.6 → 4-6)
|
||||
# But preserve the rest of the name structure
|
||||
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
|
||||
return name
|
||||
|
||||
|
||||
def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry]:
|
||||
return _OFFICIAL_DOCS_PRICING.get((route.provider, route.model.lower()))
|
||||
model = route.model.lower()
|
||||
# Direct lookup first
|
||||
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, model))
|
||||
if entry:
|
||||
return entry
|
||||
# Try normalized name for Anthropic (handles dot-notation like opus-4.7)
|
||||
if route.provider == "anthropic":
|
||||
normalized = _normalize_anthropic_model_name(model)
|
||||
if normalized != model:
|
||||
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized))
|
||||
if entry:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _openrouter_pricing_entry(route: BillingRoute) -> Optional[PricingEntry]:
|
||||
|
||||
@@ -5804,12 +5804,15 @@ class HermesCLI:
|
||||
self.model = result.new_model
|
||||
self.provider = result.target_provider
|
||||
self.requested_provider = result.target_provider
|
||||
# Always overwrite explicit overrides so stale credentials from the
|
||||
# previous provider (e.g. Ollama api_key/base_url) don't leak into
|
||||
# the new provider's credential resolution on the next turn.
|
||||
self._explicit_api_key = result.api_key
|
||||
self._explicit_base_url = result.base_url
|
||||
if result.api_key:
|
||||
self.api_key = result.api_key
|
||||
self._explicit_api_key = result.api_key
|
||||
if result.base_url:
|
||||
self.base_url = result.base_url
|
||||
self._explicit_base_url = result.base_url
|
||||
if result.api_mode:
|
||||
self.api_mode = result.api_mode
|
||||
|
||||
@@ -6027,12 +6030,15 @@ class HermesCLI:
|
||||
self.model = result.new_model
|
||||
self.provider = result.target_provider
|
||||
self.requested_provider = result.target_provider
|
||||
# Always overwrite explicit overrides so stale credentials from the
|
||||
# previous provider (e.g. Ollama api_key/base_url) don't leak into
|
||||
# the new provider's credential resolution on the next turn.
|
||||
self._explicit_api_key = result.api_key
|
||||
self._explicit_base_url = result.base_url
|
||||
if result.api_key:
|
||||
self.api_key = result.api_key
|
||||
self._explicit_api_key = result.api_key
|
||||
if result.base_url:
|
||||
self.base_url = result.base_url
|
||||
self._explicit_base_url = result.base_url
|
||||
if result.api_mode:
|
||||
self.api_mode = result.api_mode
|
||||
|
||||
@@ -7991,6 +7997,7 @@ class HermesCLI:
|
||||
output_tokens = getattr(agent, "session_output_tokens", 0) or 0
|
||||
cache_read_tokens = getattr(agent, "session_cache_read_tokens", 0) or 0
|
||||
cache_write_tokens = getattr(agent, "session_cache_write_tokens", 0) or 0
|
||||
reasoning_tokens = getattr(agent, "session_reasoning_tokens", 0) or 0
|
||||
prompt = agent.session_prompt_tokens
|
||||
completion = agent.session_completion_tokens
|
||||
total = agent.session_total_tokens
|
||||
@@ -8022,6 +8029,8 @@ class HermesCLI:
|
||||
print(f" Cache read tokens: {cache_read_tokens:>10,}")
|
||||
print(f" Cache write tokens: {cache_write_tokens:>10,}")
|
||||
print(f" Output tokens: {output_tokens:>10,}")
|
||||
if reasoning_tokens:
|
||||
print(f" ↳ Reasoning (subset): {reasoning_tokens:>10,}")
|
||||
print(f" Prompt tokens (total): {prompt:>10,}")
|
||||
print(f" Completion tokens: {completion:>10,}")
|
||||
print(f" Total tokens: {total:>10,}")
|
||||
@@ -10440,7 +10449,11 @@ class HermesCLI:
|
||||
|
||||
# --- /model picker modal ---
|
||||
if self._model_picker_state:
|
||||
self._handle_model_picker_selection()
|
||||
try:
|
||||
self._handle_model_picker_selection()
|
||||
except Exception as _exc:
|
||||
_cprint(f" ✗ Model selection failed: {_exc}")
|
||||
self._close_model_picker()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
+42
-2
@@ -360,12 +360,52 @@ def _normalize_deliver_value(deliver) -> str:
|
||||
return str(deliver)
|
||||
|
||||
|
||||
# Routing intent tokens — resolved at fire time, not create time, so a
|
||||
# job created before Telegram was wired up will pick up Telegram once it
|
||||
# comes online. ``all`` expands into the set of connected platforms
|
||||
# (those with a configured home chat_id) in _expand_routing_tokens.
|
||||
_ROUTING_TOKENS = frozenset({"all"})
|
||||
|
||||
|
||||
def _expand_routing_tokens(part: str) -> List[str]:
|
||||
"""Expand a routing-intent token to concrete platform names.
|
||||
|
||||
``all`` expands to every platform in ``_iter_home_target_platforms()``
|
||||
that has a configured home chat_id right now. Unknown / non-token
|
||||
values pass through unchanged as a single-element list, so the caller
|
||||
can treat every token uniformly.
|
||||
"""
|
||||
token = part.lower()
|
||||
if token not in _ROUTING_TOKENS:
|
||||
return [part]
|
||||
expanded: List[str] = []
|
||||
for platform_name in _iter_home_target_platforms():
|
||||
if _get_home_target_chat_id(platform_name):
|
||||
expanded.append(platform_name)
|
||||
return expanded
|
||||
|
||||
|
||||
def _resolve_delivery_targets(job: dict) -> List[dict]:
|
||||
"""Resolve all concrete auto-delivery targets for a cron job (supports comma-separated deliver)."""
|
||||
"""Resolve all concrete auto-delivery targets for a cron job.
|
||||
|
||||
Accepts the legacy comma-separated ``deliver`` string plus the
|
||||
``all`` routing-intent token, which expands to every platform with
|
||||
a configured home channel. Tokens may be combined with explicit
|
||||
targets: ``origin,all`` and ``all,telegram:-100:17`` both work.
|
||||
Duplicate (platform, chat_id, thread_id) tuples are collapsed by the
|
||||
existing dedup pass.
|
||||
"""
|
||||
deliver = _normalize_deliver_value(job.get("deliver", "local"))
|
||||
if deliver == "local":
|
||||
return []
|
||||
parts = [p.strip() for p in deliver.split(",") if p.strip()]
|
||||
|
||||
raw_parts = [p.strip() for p in deliver.split(",") if p.strip()]
|
||||
|
||||
# Expand routing intents.
|
||||
parts: List[str] = []
|
||||
for raw in raw_parts:
|
||||
parts.extend(_expand_routing_tokens(raw))
|
||||
|
||||
seen = set()
|
||||
targets = []
|
||||
for part in parts:
|
||||
|
||||
@@ -3146,7 +3146,9 @@ class BasePlatformAdapter(ABC):
|
||||
_post_cb = getattr(self, "_post_delivery_callbacks", {}).pop(session_key, None)
|
||||
if callable(_post_cb):
|
||||
try:
|
||||
_post_cb()
|
||||
_post_result = _post_cb()
|
||||
if inspect.isawaitable(_post_result):
|
||||
await _post_result
|
||||
except Exception:
|
||||
pass
|
||||
# Stop typing indicator
|
||||
|
||||
+150
-35
@@ -1903,6 +1903,59 @@ class GatewayRunner:
|
||||
depth += 1
|
||||
return depth
|
||||
|
||||
@staticmethod
|
||||
def _is_goal_continuation_event(event_or_text: Any) -> bool:
|
||||
"""Return True for synthetic /goal continuation turns.
|
||||
|
||||
Goal continuations are normal queued user-role events, so pause/clear
|
||||
must distinguish them from real user /queue messages before removing or
|
||||
suppressing them.
|
||||
"""
|
||||
text = getattr(event_or_text, "text", event_or_text) or ""
|
||||
return str(text).startswith("[Continuing toward your standing goal]\nGoal:")
|
||||
|
||||
def _clear_goal_pending_continuations(self, session_key: str, adapter: Any) -> int:
|
||||
"""Remove queued synthetic /goal continuations for one session.
|
||||
|
||||
User-issued /goal pause/clear can race with a continuation already
|
||||
queued by the judge. Remove only synthetic goal continuations while
|
||||
preserving normal /queue and user follow-up events.
|
||||
"""
|
||||
removed = 0
|
||||
pending_slot = getattr(adapter, "_pending_messages", None) if adapter is not None else None
|
||||
if isinstance(pending_slot, dict):
|
||||
pending_event = pending_slot.get(session_key)
|
||||
if self._is_goal_continuation_event(pending_event):
|
||||
pending_slot.pop(session_key, None)
|
||||
removed += 1
|
||||
|
||||
queued_events = getattr(self, "_queued_events", None)
|
||||
if isinstance(queued_events, dict):
|
||||
overflow = queued_events.get(session_key) or []
|
||||
if overflow:
|
||||
kept = []
|
||||
for queued_event in overflow:
|
||||
if self._is_goal_continuation_event(queued_event):
|
||||
removed += 1
|
||||
else:
|
||||
kept.append(queued_event)
|
||||
if kept:
|
||||
queued_events[session_key] = kept
|
||||
else:
|
||||
queued_events.pop(session_key, None)
|
||||
return removed
|
||||
|
||||
def _goal_still_active_for_session(self, session_id: str) -> bool:
|
||||
"""Best-effort fresh DB check before running a queued continuation."""
|
||||
if not session_id:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.goals import GoalManager
|
||||
return GoalManager(session_id=session_id).is_active()
|
||||
except Exception as exc:
|
||||
logger.debug("goal continuation: active-state recheck failed: %s", exc)
|
||||
return False
|
||||
|
||||
def _update_runtime_status(self, gateway_state: Optional[str] = None, exit_reason: Optional[str] = None) -> None:
|
||||
try:
|
||||
from gateway.status import write_runtime_status
|
||||
@@ -5836,7 +5889,7 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
session_entry = None
|
||||
if session_entry is not None:
|
||||
self._post_turn_goal_continuation(
|
||||
await self._post_turn_goal_continuation(
|
||||
session_entry=session_entry,
|
||||
source=source,
|
||||
final_response=_final_text,
|
||||
@@ -8404,6 +8457,13 @@ class GatewayRunner:
|
||||
state = mgr.pause(reason="user-paused")
|
||||
if state is None:
|
||||
return "No goal set."
|
||||
try:
|
||||
adapter = self.adapters.get(event.source.platform) if event.source else None
|
||||
_quick_key = self._session_key_for_source(event.source) if event.source else None
|
||||
if adapter and _quick_key:
|
||||
self._clear_goal_pending_continuations(_quick_key, adapter)
|
||||
except Exception as exc:
|
||||
logger.debug("goal pause: pending continuation cleanup failed: %s", exc)
|
||||
return f"⏸ Goal paused: {state.goal}"
|
||||
|
||||
if lower == "resume":
|
||||
@@ -8418,6 +8478,13 @@ class GatewayRunner:
|
||||
if lower in ("clear", "stop", "done"):
|
||||
had = mgr.has_goal()
|
||||
mgr.clear()
|
||||
try:
|
||||
adapter = self.adapters.get(event.source.platform) if event.source else None
|
||||
_quick_key = self._session_key_for_source(event.source) if event.source else None
|
||||
if adapter and _quick_key:
|
||||
self._clear_goal_pending_continuations(_quick_key, adapter)
|
||||
except Exception as exc:
|
||||
logger.debug("goal clear: pending continuation cleanup failed: %s", exc)
|
||||
return t("gateway.goal_cleared") if had else t("gateway.no_active_goal")
|
||||
|
||||
# Otherwise — treat the remaining text as the new goal.
|
||||
@@ -8449,7 +8516,69 @@ class GatewayRunner:
|
||||
"Controls: /goal status · /goal pause · /goal resume · /goal clear"
|
||||
)
|
||||
|
||||
def _post_turn_goal_continuation(
|
||||
async def _send_goal_status_notice(self, source: Any, message: str) -> None:
|
||||
"""Send a /goal judge status line back to the originating chat/thread."""
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if not adapter:
|
||||
logger.debug("goal continuation: no adapter for %s", getattr(source, "platform", None))
|
||||
return
|
||||
|
||||
try:
|
||||
metadata = self._thread_metadata_for_source(source)
|
||||
except Exception:
|
||||
metadata = {"thread_id": source.thread_id} if getattr(source, "thread_id", None) else None
|
||||
|
||||
result = await adapter.send(source.chat_id, message, metadata=metadata)
|
||||
if result is not None and not getattr(result, "success", True):
|
||||
logger.warning(
|
||||
"goal continuation: status send failed: %s",
|
||||
getattr(result, "error", "unknown error"),
|
||||
)
|
||||
|
||||
async def _defer_goal_status_notice_after_delivery(self, source: Any, message: str) -> None:
|
||||
"""Send a /goal status line after the main response is delivered.
|
||||
|
||||
The gateway message handler returns the agent response to the platform
|
||||
adapter, which sends it after this method's caller has returned. For a
|
||||
natural Discord/Telegram reading order, goal status belongs after that
|
||||
send. Platform adapters provide a one-shot post-delivery callback for
|
||||
exactly this boundary; when unavailable, fall back to direct awaited
|
||||
delivery rather than silently dropping the notice.
|
||||
"""
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if not adapter:
|
||||
logger.debug("goal continuation: no adapter for %s", getattr(source, "platform", None))
|
||||
return
|
||||
|
||||
async def _deliver() -> None:
|
||||
try:
|
||||
await self._send_goal_status_notice(source, message)
|
||||
except Exception as exc:
|
||||
logger.warning("goal continuation: status send failed: %s", exc, exc_info=True)
|
||||
|
||||
try:
|
||||
session_key = self._session_key_for_source(source)
|
||||
except Exception:
|
||||
session_key = None
|
||||
|
||||
if session_key and hasattr(adapter, "register_post_delivery_callback"):
|
||||
try:
|
||||
generation = None
|
||||
active = getattr(adapter, "_active_sessions", {}).get(session_key)
|
||||
if active is not None:
|
||||
generation = getattr(active, "_hermes_run_generation", None)
|
||||
adapter.register_post_delivery_callback(
|
||||
session_key,
|
||||
_deliver,
|
||||
generation=generation,
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.debug("goal continuation: post-delivery callback registration failed: %s", exc)
|
||||
|
||||
await _deliver()
|
||||
|
||||
async def _post_turn_goal_continuation(
|
||||
self,
|
||||
*,
|
||||
session_entry: Any,
|
||||
@@ -8485,38 +8614,14 @@ class GatewayRunner:
|
||||
decision = mgr.evaluate_after_turn(final_response or "", user_initiated=True)
|
||||
msg = decision.get("message") or ""
|
||||
|
||||
# Send the status line back to the user so they see the judge's
|
||||
# verdict. Fire-and-forget via the adapter's ``send()`` method —
|
||||
# adapters expose ``send(chat_id, content, reply_to, metadata)``,
|
||||
# not a ``send_message(source, msg)`` wrapper, so an earlier
|
||||
# ``hasattr(adapter, "send_message")`` gate here was dead code and
|
||||
# users never saw ``✓ Goal achieved`` / ``⏸ budget exhausted``
|
||||
# verdicts.
|
||||
# Defer the status line until after the adapter has delivered the
|
||||
# agent's visible final response. The judge runs after the response is
|
||||
# produced but before BasePlatformAdapter sends it, so sending here
|
||||
# would show "✓ Goal achieved" before the answer itself. Registering
|
||||
# an awaited post-delivery callback preserves delivery reliability
|
||||
# without reversing the user-visible ordering.
|
||||
if msg and source is not None:
|
||||
try:
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if adapter is not None and hasattr(adapter, "send"):
|
||||
import asyncio as _asyncio
|
||||
thread_meta = (
|
||||
{"thread_id": source.thread_id} if source.thread_id else None
|
||||
)
|
||||
coro = adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=msg,
|
||||
metadata=thread_meta,
|
||||
)
|
||||
if _asyncio.iscoroutine(coro):
|
||||
try:
|
||||
loop = _asyncio.get_running_loop()
|
||||
loop.create_task(coro)
|
||||
except RuntimeError:
|
||||
# No running loop in this thread — best effort.
|
||||
try:
|
||||
_asyncio.run(coro)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug("goal continuation: status send failed: %s", exc)
|
||||
await self._defer_goal_status_notice_after_delivery(source, msg)
|
||||
|
||||
if not decision.get("should_continue"):
|
||||
return
|
||||
@@ -14768,14 +14873,18 @@ class GatewayRunner:
|
||||
)
|
||||
if callable(_bg_cb):
|
||||
try:
|
||||
_bg_cb()
|
||||
_bg_result = _bg_cb()
|
||||
if inspect.isawaitable(_bg_result):
|
||||
await _bg_result
|
||||
except Exception:
|
||||
pass
|
||||
elif adapter and hasattr(adapter, "_post_delivery_callbacks"):
|
||||
_bg_cb = adapter._post_delivery_callbacks.pop(session_key, None)
|
||||
if callable(_bg_cb):
|
||||
try:
|
||||
_bg_cb()
|
||||
_bg_result = _bg_cb()
|
||||
if inspect.isawaitable(_bg_result):
|
||||
await _bg_result
|
||||
except Exception:
|
||||
pass
|
||||
# else: interrupted — discard the interrupted response ("Operation
|
||||
@@ -14789,6 +14898,12 @@ class GatewayRunner:
|
||||
next_channel_prompt = None
|
||||
if pending_event is not None:
|
||||
next_source = getattr(pending_event, "source", None) or source
|
||||
if self._is_goal_continuation_event(pending_event) and not self._goal_still_active_for_session(session_id):
|
||||
logger.info(
|
||||
"Discarding stale goal continuation for session %s — goal is no longer active",
|
||||
session_key or "?",
|
||||
)
|
||||
return result
|
||||
next_message = await self._prepare_inbound_message_text(
|
||||
event=pending_event,
|
||||
source=next_source,
|
||||
|
||||
+1
-1
@@ -3117,10 +3117,10 @@ def _refresh_access_token(
|
||||
) -> Dict[str, Any]:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/api/oauth/token",
|
||||
headers={"x-nous-refresh-token": refresh_token},
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": client_id,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -109,6 +109,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("resume", "Resume a previously-named session", "Session",
|
||||
args_hint="[name]"),
|
||||
|
||||
# Configuration
|
||||
CommandDef("sessions", "Browse and resume previous sessions", "Session"),
|
||||
|
||||
# Configuration
|
||||
CommandDef("config", "Show current configuration", "Configuration",
|
||||
cli_only=True),
|
||||
|
||||
+102
-90
@@ -21,6 +21,7 @@ import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
@@ -42,6 +43,14 @@ _LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
|
||||
# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
|
||||
# the user's on-disk values without defaults merged in.
|
||||
_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
|
||||
# Serializes all config read/write paths. libyaml's C extension is not
|
||||
# thread-safe for concurrent safe_load() on the same file, and multiple
|
||||
# tool threads (approval.py, browser_tool.py, setup flows) hit
|
||||
# load_config / read_raw_config / save_config from different threads
|
||||
# during long agent runs. RLock (not Lock) because save_config internally
|
||||
# calls read_raw_config. Also covers mutation of the module-level cache
|
||||
# dicts above.
|
||||
_CONFIG_LOCK = threading.RLock()
|
||||
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
|
||||
# (managed by setup/provider flows directly).
|
||||
_EXTRA_ENV_KEYS = frozenset({
|
||||
@@ -3941,28 +3950,29 @@ def read_raw_config() -> Dict[str, Any]:
|
||||
``load_config()``. Returns a deepcopy on every call since some callers
|
||||
mutate the result before passing to ``save_config()``.
|
||||
"""
|
||||
try:
|
||||
config_path = get_config_path()
|
||||
st = config_path.stat()
|
||||
cache_key = (st.st_mtime_ns, st.st_size)
|
||||
except (FileNotFoundError, OSError):
|
||||
return {}
|
||||
with _CONFIG_LOCK:
|
||||
try:
|
||||
config_path = get_config_path()
|
||||
st = config_path.stat()
|
||||
cache_key = (st.st_mtime_ns, st.st_size)
|
||||
except (FileNotFoundError, OSError):
|
||||
return {}
|
||||
|
||||
path_key = str(config_path)
|
||||
cached = _RAW_CONFIG_CACHE.get(path_key)
|
||||
if cached is not None and cached[:2] == cache_key:
|
||||
return copy.deepcopy(cached[2])
|
||||
path_key = str(config_path)
|
||||
cached = _RAW_CONFIG_CACHE.get(path_key)
|
||||
if cached is not None and cached[:2] == cache_key:
|
||||
return copy.deepcopy(cached[2])
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
_RAW_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(data))
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
_RAW_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(data))
|
||||
return data
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
@@ -3975,46 +3985,47 @@ def load_config() -> Dict[str, Any]:
|
||||
(which change ``HERMES_HOME`` and therefore ``get_config_path()``)
|
||||
don't collide.
|
||||
"""
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
path_key = str(config_path)
|
||||
with _CONFIG_LOCK:
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
path_key = str(config_path)
|
||||
|
||||
try:
|
||||
st = config_path.stat()
|
||||
cache_key: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
|
||||
except FileNotFoundError:
|
||||
cache_key = None
|
||||
|
||||
cached = _LOAD_CONFIG_CACHE.get(path_key)
|
||||
if cached is not None and cache_key is not None and cached[:2] == cache_key:
|
||||
return copy.deepcopy(cached[2])
|
||||
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
if cache_key is not None:
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
st = config_path.stat()
|
||||
cache_key: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
|
||||
except FileNotFoundError:
|
||||
cache_key = None
|
||||
|
||||
if "max_turns" in user_config:
|
||||
agent_user_config = dict(user_config.get("agent") or {})
|
||||
if agent_user_config.get("max_turns") is None:
|
||||
agent_user_config["max_turns"] = user_config["max_turns"]
|
||||
user_config["agent"] = agent_user_config
|
||||
user_config.pop("max_turns", None)
|
||||
cached = _LOAD_CONFIG_CACHE.get(path_key)
|
||||
if cached is not None and cache_key is not None and cached[:2] == cache_key:
|
||||
return copy.deepcopy(cached[2])
|
||||
|
||||
config = _deep_merge(config, user_config)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load config: {e}")
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||
expanded = _expand_env_vars(normalized)
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded)
|
||||
if cache_key is not None:
|
||||
_LOAD_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(expanded))
|
||||
else:
|
||||
_LOAD_CONFIG_CACHE.pop(path_key, None)
|
||||
return expanded
|
||||
if cache_key is not None:
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
|
||||
if "max_turns" in user_config:
|
||||
agent_user_config = dict(user_config.get("agent") or {})
|
||||
if agent_user_config.get("max_turns") is None:
|
||||
agent_user_config["max_turns"] = user_config["max_turns"]
|
||||
user_config["agent"] = agent_user_config
|
||||
user_config.pop("max_turns", None)
|
||||
|
||||
config = _deep_merge(config, user_config)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load config: {e}")
|
||||
|
||||
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||
expanded = _expand_env_vars(normalized)
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded)
|
||||
if cache_key is not None:
|
||||
_LOAD_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(expanded))
|
||||
else:
|
||||
_LOAD_CONFIG_CACHE.pop(path_key, None)
|
||||
return expanded
|
||||
|
||||
|
||||
_SECURITY_COMMENT = """
|
||||
@@ -4094,45 +4105,46 @@ _COMMENTED_SECTIONS = """
|
||||
|
||||
def save_config(config: Dict[str, Any]):
|
||||
"""Save configuration to ~/.hermes/config.yaml."""
|
||||
if is_managed():
|
||||
managed_error("save configuration")
|
||||
return
|
||||
from utils import atomic_yaml_write
|
||||
with _CONFIG_LOCK:
|
||||
if is_managed():
|
||||
managed_error("save configuration")
|
||||
return
|
||||
from utils import atomic_yaml_write
|
||||
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||
normalized = current_normalized
|
||||
raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config()))
|
||||
if raw_existing:
|
||||
normalized = _preserve_env_ref_templates(
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||
normalized = current_normalized
|
||||
raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config()))
|
||||
if raw_existing:
|
||||
normalized = _preserve_env_ref_templates(
|
||||
normalized,
|
||||
raw_existing,
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH.get(str(config_path)),
|
||||
)
|
||||
|
||||
# Build optional commented-out sections for features that are off by
|
||||
# default or only relevant when explicitly configured.
|
||||
parts = []
|
||||
sec = normalized.get("security", {})
|
||||
if not sec or sec.get("redact_secrets") is None:
|
||||
parts.append(_SECURITY_COMMENT)
|
||||
fb = normalized.get("fallback_model", {})
|
||||
fb_is_valid = False
|
||||
if isinstance(fb, list):
|
||||
fb_is_valid = any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb)
|
||||
elif isinstance(fb, dict):
|
||||
fb_is_valid = bool(fb.get("provider") and fb.get("model"))
|
||||
if not fb_is_valid:
|
||||
parts.append(_FALLBACK_COMMENT)
|
||||
|
||||
atomic_yaml_write(
|
||||
config_path,
|
||||
normalized,
|
||||
raw_existing,
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH.get(str(config_path)),
|
||||
extra_content="".join(parts) if parts else None,
|
||||
)
|
||||
|
||||
# Build optional commented-out sections for features that are off by
|
||||
# default or only relevant when explicitly configured.
|
||||
parts = []
|
||||
sec = normalized.get("security", {})
|
||||
if not sec or sec.get("redact_secrets") is None:
|
||||
parts.append(_SECURITY_COMMENT)
|
||||
fb = normalized.get("fallback_model", {})
|
||||
fb_is_valid = False
|
||||
if isinstance(fb, list):
|
||||
fb_is_valid = any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb)
|
||||
elif isinstance(fb, dict):
|
||||
fb_is_valid = bool(fb.get("provider") and fb.get("model"))
|
||||
if not fb_is_valid:
|
||||
parts.append(_FALLBACK_COMMENT)
|
||||
|
||||
atomic_yaml_write(
|
||||
config_path,
|
||||
normalized,
|
||||
extra_content="".join(parts) if parts else None,
|
||||
)
|
||||
_secure_file(config_path)
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)
|
||||
_secure_file(config_path)
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)
|
||||
|
||||
|
||||
def load_env() -> Dict[str, str]:
|
||||
|
||||
+79
-21
@@ -47,6 +47,14 @@ DEFAULT_MAX_TURNS = 20
|
||||
DEFAULT_JUDGE_TIMEOUT = 30.0
|
||||
# Cap how much of the last response + recent messages we send to the judge.
|
||||
_JUDGE_RESPONSE_SNIPPET_CHARS = 4000
|
||||
# After this many consecutive judge *parse* failures (empty output / non-JSON),
|
||||
# the loop auto-pauses and points the user at the goal_judge config. API /
|
||||
# transport errors do NOT count toward this — those are transient. This guards
|
||||
# against small models (e.g. deepseek-v4-flash) that cannot follow the strict
|
||||
# JSON reply contract; without it the loop runs until the turn budget is
|
||||
# exhausted with every reply shaped like `judge returned empty response` or
|
||||
# `judge reply was not JSON`.
|
||||
DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES = 3
|
||||
|
||||
|
||||
CONTINUATION_PROMPT_TEMPLATE = (
|
||||
@@ -99,6 +107,7 @@ class GoalState:
|
||||
last_verdict: Optional[str] = None # "done" | "continue" | "skipped"
|
||||
last_reason: Optional[str] = None
|
||||
paused_reason: Optional[str] = None # why we auto-paused (budget, etc.)
|
||||
consecutive_parse_failures: int = 0 # judge-output parse failures in a row
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(asdict(self), ensure_ascii=False)
|
||||
@@ -116,6 +125,7 @@ class GoalState:
|
||||
last_verdict=data.get("last_verdict"),
|
||||
last_reason=data.get("last_reason"),
|
||||
paused_reason=data.get("paused_reason"),
|
||||
consecutive_parse_failures=int(data.get("consecutive_parse_failures", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
@@ -220,13 +230,17 @@ def _truncate(text: str, limit: int) -> str:
|
||||
_JSON_OBJECT_RE = re.compile(r"\{.*?\}", re.DOTALL)
|
||||
|
||||
|
||||
def _parse_judge_response(raw: str) -> Tuple[bool, str]:
|
||||
"""Parse the judge's reply. Fail-open to ``(False, "<reason>")``.
|
||||
def _parse_judge_response(raw: str) -> Tuple[bool, str, bool]:
|
||||
"""Parse the judge's reply. Fail-open to ``(False, "<reason>", parse_failed)``.
|
||||
|
||||
Returns ``(done, reason)``.
|
||||
Returns ``(done, reason, parse_failed)``. ``parse_failed`` is True when the
|
||||
judge returned output that couldn't be interpreted as the expected JSON
|
||||
verdict (empty body, prose, malformed JSON). Callers use that flag to
|
||||
auto-pause after N consecutive parse failures so a weak judge model
|
||||
doesn't silently burn the turn budget.
|
||||
"""
|
||||
if not raw:
|
||||
return False, "judge returned empty response"
|
||||
return False, "judge returned empty response", True
|
||||
|
||||
text = raw.strip()
|
||||
|
||||
@@ -252,7 +266,7 @@ def _parse_judge_response(raw: str) -> Tuple[bool, str]:
|
||||
data = None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return False, f"judge reply was not JSON: {_truncate(raw, 200)!r}"
|
||||
return False, f"judge reply was not JSON: {_truncate(raw, 200)!r}", True
|
||||
|
||||
done_val = data.get("done")
|
||||
if isinstance(done_val, str):
|
||||
@@ -262,7 +276,7 @@ def _parse_judge_response(raw: str) -> Tuple[bool, str]:
|
||||
reason = str(data.get("reason") or "").strip()
|
||||
if not reason:
|
||||
reason = "no reason provided"
|
||||
return done, reason
|
||||
return done, reason, False
|
||||
|
||||
|
||||
def judge_goal(
|
||||
@@ -270,36 +284,42 @@ def judge_goal(
|
||||
last_response: str,
|
||||
*,
|
||||
timeout: float = DEFAULT_JUDGE_TIMEOUT,
|
||||
) -> Tuple[str, str]:
|
||||
) -> Tuple[str, str, bool]:
|
||||
"""Ask the auxiliary model whether the goal is satisfied.
|
||||
|
||||
Returns ``(verdict, reason)`` where verdict is ``"done"``, ``"continue"``,
|
||||
or ``"skipped"`` (when the judge couldn't be reached).
|
||||
Returns ``(verdict, reason, parse_failed)`` where verdict is ``"done"``,
|
||||
``"continue"``, or ``"skipped"`` (when the judge couldn't be reached).
|
||||
|
||||
This is deliberately fail-open: any error returns ``("continue", "...")``
|
||||
so a broken judge doesn't wedge progress — the turn budget is the
|
||||
backstop.
|
||||
``parse_failed`` is True only when the judge call succeeded but its output
|
||||
was unusable (empty or non-JSON). API/transport errors return False — they
|
||||
are transient and should fail-open silently. Callers use this flag to
|
||||
auto-pause after N consecutive parse failures (see
|
||||
``DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES``).
|
||||
|
||||
This is deliberately fail-open: any error returns ``("continue", "...", False)``
|
||||
so a broken judge doesn't wedge progress — the turn budget and the
|
||||
consecutive-parse-failures auto-pause are the backstops.
|
||||
"""
|
||||
if not goal.strip():
|
||||
return "skipped", "empty goal"
|
||||
return "skipped", "empty goal", False
|
||||
if not last_response.strip():
|
||||
# No substantive reply this turn — almost certainly not done yet.
|
||||
return "continue", "empty response (nothing to evaluate)"
|
||||
return "continue", "empty response (nothing to evaluate)", False
|
||||
|
||||
try:
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
except Exception as exc:
|
||||
logger.debug("goal judge: auxiliary client import failed: %s", exc)
|
||||
return "continue", "auxiliary client unavailable"
|
||||
return "continue", "auxiliary client unavailable", False
|
||||
|
||||
try:
|
||||
client, model = get_text_auxiliary_client("goal_judge")
|
||||
except Exception as exc:
|
||||
logger.debug("goal judge: get_text_auxiliary_client failed: %s", exc)
|
||||
return "continue", "auxiliary client unavailable"
|
||||
return "continue", "auxiliary client unavailable", False
|
||||
|
||||
if client is None or not model:
|
||||
return "continue", "no auxiliary client configured"
|
||||
return "continue", "no auxiliary client configured", False
|
||||
|
||||
prompt = JUDGE_USER_PROMPT_TEMPLATE.format(
|
||||
goal=_truncate(goal, 2000),
|
||||
@@ -319,17 +339,17 @@ def judge_goal(
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info("goal judge: API call failed (%s) — falling through to continue", exc)
|
||||
return "continue", f"judge error: {type(exc).__name__}"
|
||||
return "continue", f"judge error: {type(exc).__name__}", False
|
||||
|
||||
try:
|
||||
raw = resp.choices[0].message.content or ""
|
||||
except Exception:
|
||||
raw = ""
|
||||
|
||||
done, reason = _parse_judge_response(raw)
|
||||
done, reason, parse_failed = _parse_judge_response(raw)
|
||||
verdict = "done" if done else "continue"
|
||||
logger.info("goal judge: verdict=%s reason=%s", verdict, _truncate(reason, 120))
|
||||
return verdict, reason
|
||||
return verdict, reason, parse_failed
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
@@ -473,10 +493,18 @@ class GoalManager:
|
||||
state.turns_used += 1
|
||||
state.last_turn_at = time.time()
|
||||
|
||||
verdict, reason = judge_goal(state.goal, last_response)
|
||||
verdict, reason, parse_failed = judge_goal(state.goal, last_response)
|
||||
state.last_verdict = verdict
|
||||
state.last_reason = reason
|
||||
|
||||
# Track consecutive judge parse failures. Reset on any usable reply,
|
||||
# including API / transport errors (parse_failed=False) so a flaky
|
||||
# network doesn't trip the auto-pause meant for bad judge models.
|
||||
if parse_failed:
|
||||
state.consecutive_parse_failures += 1
|
||||
else:
|
||||
state.consecutive_parse_failures = 0
|
||||
|
||||
if verdict == "done":
|
||||
state.status = "done"
|
||||
save_goal(self.session_id, state)
|
||||
@@ -489,6 +517,36 @@ class GoalManager:
|
||||
"message": f"✓ Goal achieved: {reason}",
|
||||
}
|
||||
|
||||
# Auto-pause when the judge model can't produce the expected JSON
|
||||
# verdict N turns in a row. Points the user at the goal_judge config
|
||||
# so they can route this side task to a model that follows the
|
||||
# contract (e.g. google/gemini-3-flash-preview). Without this guard,
|
||||
# weak judge models burn the entire turn budget returning prose or
|
||||
# empty strings.
|
||||
if state.consecutive_parse_failures >= DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES:
|
||||
state.status = "paused"
|
||||
state.paused_reason = (
|
||||
f"judge model returned unparseable output {state.consecutive_parse_failures} turns in a row"
|
||||
)
|
||||
save_goal(self.session_id, state)
|
||||
return {
|
||||
"status": "paused",
|
||||
"should_continue": False,
|
||||
"continuation_prompt": None,
|
||||
"verdict": "continue",
|
||||
"reason": reason,
|
||||
"message": (
|
||||
f"⏸ Goal paused — the judge model ({state.consecutive_parse_failures} turns) "
|
||||
"isn't returning the required JSON verdict. Route the judge to a stricter "
|
||||
"model in ~/.hermes/config.yaml:\n"
|
||||
" auxiliary:\n"
|
||||
" goal_judge:\n"
|
||||
" provider: openrouter\n"
|
||||
" model: google/gemini-3-flash-preview\n"
|
||||
"Then /goal resume to continue."
|
||||
),
|
||||
}
|
||||
|
||||
if state.turns_used >= state.max_turns:
|
||||
state.status = "paused"
|
||||
state.paused_reason = f"turn budget exhausted ({state.turns_used}/{state.max_turns})"
|
||||
|
||||
+8
-7
@@ -3240,22 +3240,23 @@ def _offer_launch_chat():
|
||||
|
||||
|
||||
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
||||
"""Streamlined first-time setup: provider + model only.
|
||||
"""Streamlined first-time setup: provider, model, terminal & messaging.
|
||||
|
||||
Applies sensible defaults for TTS (Edge), terminal (local), agent
|
||||
settings, and tools — the user can customize later via
|
||||
``hermes setup <section>``.
|
||||
Applies sensible defaults for TTS (Edge), agent settings, and tools —
|
||||
the user can customize later via ``hermes setup <section>``.
|
||||
"""
|
||||
# Step 1: Model & Provider (essential — skips rotation/vision/TTS)
|
||||
setup_model_provider(config, quick=True)
|
||||
|
||||
# Step 2: Apply defaults for everything else
|
||||
# Step 2: Terminal Backend — where commands run is a core decision
|
||||
setup_terminal_backend(config)
|
||||
|
||||
# Step 3: Apply defaults for everything else
|
||||
_apply_default_agent_settings(config)
|
||||
config.setdefault("terminal", {}).setdefault("backend", "local")
|
||||
|
||||
save_config(config)
|
||||
|
||||
# Step 3: Offer messaging gateway setup
|
||||
# Step 4: Offer messaging gateway setup
|
||||
print()
|
||||
gateway_choice = prompt_choice(
|
||||
"Connect a messaging platform? (Telegram, Discord, etc.)",
|
||||
|
||||
@@ -612,6 +612,11 @@ class SessionDB:
|
||||
the caller already holds cumulative totals (gateway path, where the
|
||||
cached agent accumulates across messages).
|
||||
"""
|
||||
# Ensure the session row exists so the UPDATE doesn't silently affect
|
||||
# 0 rows. Under concurrent load (cron + kanban + delegate_task) the
|
||||
# initial create_session() may have failed due to SQLite locking.
|
||||
# INSERT OR IGNORE is cheap and idempotent.
|
||||
self._insert_session_row(session_id, "unknown", model=model)
|
||||
if absolute:
|
||||
sql = """UPDATE sessions SET
|
||||
input_tokens = ?,
|
||||
|
||||
+1
-1
@@ -802,7 +802,7 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
|
||||
return json.dumps({"count": len(targets), "channels": targets}, indent=2)
|
||||
|
||||
channels = []
|
||||
for plat, entries_list in directory.items():
|
||||
for plat, entries_list in directory.get("platforms", {}).items():
|
||||
if platform and plat.lower() != platform.lower():
|
||||
continue
|
||||
if isinstance(entries_list, list):
|
||||
|
||||
+66
-15
@@ -97,6 +97,12 @@
|
||||
const API = "/api/plugins/kanban";
|
||||
const MIME_TASK = "text/x-hermes-task";
|
||||
|
||||
// Docs link — surfaced as a `?` icon next to the board switcher and as
|
||||
// `title=` hints on unlabelled controls. Kept in one place so rebrands or
|
||||
// path changes are a single edit.
|
||||
const DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban";
|
||||
const DOCS_TUTORIAL_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban-tutorial";
|
||||
|
||||
// localStorage key for the user's selected board. Independent of the
|
||||
// CLI's on-disk ``<root>/kanban/current`` pointer so browser users
|
||||
// can inspect any board without shifting the CLI's active board out
|
||||
@@ -1128,6 +1134,20 @@
|
||||
// Board switcher (multi-project)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Small `?` affordance next to the board controls. Opens the kanban docs
|
||||
// page in a new tab so users can look up what any of the widgets mean
|
||||
// without losing the current board view.
|
||||
function DocsLink() {
|
||||
return h("a", {
|
||||
href: DOCS_URL,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
className: "hermes-kanban-docs-link",
|
||||
title: "Open Hermes Kanban docs in a new tab",
|
||||
"aria-label": "Hermes Kanban documentation",
|
||||
}, "?");
|
||||
}
|
||||
|
||||
function BoardSwitcher(props) {
|
||||
const list = props.boardList || [];
|
||||
const current = list.find(function (b) { return b.slug === props.board; });
|
||||
@@ -1152,6 +1172,7 @@
|
||||
size: "sm",
|
||||
className: "h-7 text-xs",
|
||||
}, "+ New board"),
|
||||
h(DocsLink, null),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1165,6 +1186,7 @@
|
||||
value: props.board,
|
||||
className: "h-8 min-w-[220px]",
|
||||
"aria-label": "Switch kanban board",
|
||||
title: "Boards are independent work streams. Each board has its own tasks, tenants, and assignees.",
|
||||
}, selectChangeHandler(function (v) { if (v) props.onSwitch(v); })),
|
||||
list.map(function (b) {
|
||||
const label = b.total > 0
|
||||
@@ -1178,10 +1200,12 @@
|
||||
),
|
||||
),
|
||||
h("div", { className: "flex-1" }),
|
||||
h(DocsLink, null),
|
||||
h(Button, {
|
||||
onClick: props.onNewClick,
|
||||
size: "sm",
|
||||
className: "h-8",
|
||||
title: "Create a new board. Useful when you want an unrelated work stream (different project, different team, isolated scratch area).",
|
||||
}, "+ New board"),
|
||||
props.board !== "default"
|
||||
? h(Button, {
|
||||
@@ -1326,7 +1350,8 @@
|
||||
const tenants = (props.board && props.board.tenants) || [];
|
||||
const assignees = (props.board && props.board.assignees) || [];
|
||||
return h("div", { className: "flex flex-wrap items-end gap-3" },
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h("div", { className: "flex flex-col gap-1",
|
||||
title: "Fuzzy-match tasks by id, title, or description. Matches across all columns." },
|
||||
h(Label, { className: "text-xs text-muted-foreground" }, "Search"),
|
||||
h(Input, {
|
||||
placeholder: "Filter cards…",
|
||||
@@ -1335,7 +1360,8 @@
|
||||
className: "w-56 h-8",
|
||||
}),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h("div", { className: "flex flex-col gap-1",
|
||||
title: "Tenants are free-form tags on a task (e.g. customer, project, team). Set them via the task drawer or kanban_create." },
|
||||
h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"),
|
||||
h(Select, Object.assign({
|
||||
value: props.tenantFilter,
|
||||
@@ -1347,7 +1373,8 @@
|
||||
}),
|
||||
),
|
||||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h("div", { className: "flex flex-col gap-1",
|
||||
title: "Filter by assigned Hermes profile. Profiles are the named agent identities that claim and work on tasks." },
|
||||
h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"),
|
||||
h(Select, Object.assign({
|
||||
value: props.assigneeFilter,
|
||||
@@ -1359,7 +1386,8 @@
|
||||
}),
|
||||
),
|
||||
),
|
||||
h("label", { className: "flex items-center gap-2 text-xs" },
|
||||
h("label", { className: "flex items-center gap-2 text-xs",
|
||||
title: "Include archived tasks in the board view. Archived tasks are hidden by default." },
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: props.includeArchived,
|
||||
@@ -1380,10 +1408,12 @@
|
||||
h(Button, {
|
||||
onClick: props.onNudgeDispatch,
|
||||
size: "sm",
|
||||
title: "Wake the dispatcher to claim ready tasks now instead of waiting for the next tick. Use this after adding tasks if you want them picked up immediately.",
|
||||
}, "Nudge dispatcher"),
|
||||
h(Button, {
|
||||
onClick: props.onRefresh,
|
||||
size: "sm",
|
||||
title: "Reload the board from the database. The board auto-refreshes on task events; this is for forcing a re-read.",
|
||||
}, "Refresh"),
|
||||
);
|
||||
}
|
||||
@@ -1400,6 +1430,7 @@
|
||||
h(Button, {
|
||||
onClick: function () { props.onApply({ status: "ready" }); },
|
||||
size: "sm",
|
||||
title: "Move selected tasks to Ready. Ready tasks are picked up by the dispatcher on the next tick.",
|
||||
}, "→ ready"),
|
||||
h(Button, {
|
||||
onClick: function () {
|
||||
@@ -1407,6 +1438,7 @@
|
||||
`Mark ${props.count} task(s) as done?`);
|
||||
},
|
||||
size: "sm",
|
||||
title: "Mark selected tasks as done. Releases any claims and unblocks dependent children. You'll be asked for a completion summary.",
|
||||
}, "Complete"),
|
||||
h(Button, {
|
||||
onClick: function () {
|
||||
@@ -1414,8 +1446,10 @@
|
||||
`Archive ${props.count} task(s)?`);
|
||||
},
|
||||
size: "sm",
|
||||
title: "Archive selected tasks. They disappear from the default board view but remain in the database.",
|
||||
}, "Archive"),
|
||||
h("div", { className: "hermes-kanban-bulk-reassign" },
|
||||
h("div", { className: "hermes-kanban-bulk-reassign",
|
||||
title: "Reassign selected tasks to a different Hermes profile. Pick a profile (or unassign) and click Apply." },
|
||||
h(Select, {
|
||||
value: assignee,
|
||||
onChange: function (e) { setAssignee(e.target.value); },
|
||||
@@ -1435,12 +1469,14 @@
|
||||
},
|
||||
disabled: !assignee,
|
||||
size: "sm",
|
||||
title: "Apply the selected assignee to all selected tasks.",
|
||||
}, "Apply"),
|
||||
),
|
||||
h("div", { className: "flex-1" }),
|
||||
h(Button, {
|
||||
onClick: props.onClear,
|
||||
size: "sm",
|
||||
title: "Deselect all tasks and hide this bar.",
|
||||
}, "Clear"),
|
||||
);
|
||||
}
|
||||
@@ -1521,11 +1557,13 @@
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
h("div", { className: "hermes-kanban-column-header" },
|
||||
h("div", { className: "hermes-kanban-column-header",
|
||||
title: COLUMN_HELP[props.column.name] || "" },
|
||||
h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[props.column.name]) }),
|
||||
h("span", { className: "hermes-kanban-column-label" },
|
||||
COLUMN_LABEL[props.column.name] || props.column.name),
|
||||
h("span", { className: "hermes-kanban-column-count" },
|
||||
h("span", { className: "hermes-kanban-column-count",
|
||||
title: `${props.column.tasks.length} task${props.column.tasks.length === 1 ? "" : "s"} in this column` },
|
||||
props.column.tasks.length),
|
||||
h("button", {
|
||||
type: "button",
|
||||
@@ -1652,7 +1690,8 @@
|
||||
onClick: function (e) { e.stopPropagation(); },
|
||||
title: "Select for bulk actions",
|
||||
}),
|
||||
h("span", { className: "hermes-kanban-card-id" }, t.id),
|
||||
h("span", { className: "hermes-kanban-card-id",
|
||||
title: `Task id: ${t.id}. Use this id with kanban_show, /kanban show, or hermes kanban show.` }, t.id),
|
||||
t.warnings && t.warnings.count > 0
|
||||
? h("span", {
|
||||
className: cn(
|
||||
@@ -1669,10 +1708,12 @@
|
||||
t.warnings.highest_severity === "error" ? "!!" : "⚠")
|
||||
: null,
|
||||
t.priority > 0
|
||||
? h(Badge, { className: "hermes-kanban-priority" }, `P${t.priority}`)
|
||||
? h(Badge, { className: "hermes-kanban-priority",
|
||||
title: `Priority ${t.priority}. Higher-priority tasks are claimed first by the dispatcher.` }, `P${t.priority}`)
|
||||
: null,
|
||||
t.tenant
|
||||
? h(Badge, { variant: "outline", className: "hermes-kanban-tag" }, t.tenant)
|
||||
? h(Badge, { variant: "outline", className: "hermes-kanban-tag",
|
||||
title: `Tenant: ${t.tenant}. Free-form tag for grouping tasks (customer, project, team).` }, t.tenant)
|
||||
: null,
|
||||
progress
|
||||
? h("span", {
|
||||
@@ -1687,16 +1728,21 @@
|
||||
h("div", { className: "hermes-kanban-card-title" }, t.title || "(untitled)"),
|
||||
h("div", { className: "hermes-kanban-card-row hermes-kanban-card-meta" },
|
||||
t.assignee
|
||||
? h("span", { className: "hermes-kanban-assignee" }, "@", t.assignee)
|
||||
: h("span", { className: "hermes-kanban-unassigned" }, "unassigned"),
|
||||
? h("span", { className: "hermes-kanban-assignee",
|
||||
title: `Assigned to Hermes profile @${t.assignee}` }, "@", t.assignee)
|
||||
: h("span", { className: "hermes-kanban-unassigned",
|
||||
title: "No profile assigned. The dispatcher will pick one from available profiles when the task is Ready." }, "unassigned"),
|
||||
t.comment_count > 0
|
||||
? h("span", { className: "hermes-kanban-count" }, "💬 ", t.comment_count)
|
||||
? h("span", { className: "hermes-kanban-count",
|
||||
title: `${t.comment_count} comment${t.comment_count === 1 ? "" : "s"} on this task` }, "💬 ", t.comment_count)
|
||||
: null,
|
||||
t.link_counts && (t.link_counts.parents + t.link_counts.children) > 0
|
||||
? h("span", { className: "hermes-kanban-count" },
|
||||
? h("span", { className: "hermes-kanban-count",
|
||||
title: `${t.link_counts.parents} parent${t.link_counts.parents === 1 ? "" : "s"}, ${t.link_counts.children} child${t.link_counts.children === 1 ? "" : "ren"}. Children stay blocked until their parent is done.` },
|
||||
"↔ ", t.link_counts.parents + t.link_counts.children)
|
||||
: null,
|
||||
h("span", { className: "hermes-kanban-ago" },
|
||||
h("span", { className: "hermes-kanban-ago",
|
||||
title: t.created_at ? `Created ${t.created_at}` : "" },
|
||||
timeAgo ? timeAgo(t.created_at) : ""),
|
||||
),
|
||||
),
|
||||
@@ -1777,6 +1823,9 @@
|
||||
onChange: function (e) { setAssignee(e.target.value); },
|
||||
placeholder: props.columnName === "triage" ? "specifier" : "assignee",
|
||||
className: "h-7 text-xs flex-1",
|
||||
title: props.columnName === "triage"
|
||||
? "Hermes profile that will spec this task (default: the dispatcher's configured specifier). Leave blank to let the dispatcher pick."
|
||||
: "Hermes profile to assign. Leave blank and the dispatcher will pick from available profiles when the task is Ready.",
|
||||
}),
|
||||
h(Input, {
|
||||
type: "number",
|
||||
@@ -1784,6 +1833,7 @@
|
||||
onChange: function (e) { setPriority(e.target.value); },
|
||||
placeholder: "pri",
|
||||
className: "h-7 text-xs w-16",
|
||||
title: "Priority. Higher-priority tasks are claimed first by the dispatcher. 0 = default.",
|
||||
}),
|
||||
),
|
||||
h(Input, {
|
||||
@@ -1815,6 +1865,7 @@
|
||||
value: parent,
|
||||
onChange: function (e) { setParent(e.target.value); },
|
||||
className: "h-7 text-xs",
|
||||
title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.",
|
||||
},
|
||||
h(SelectOption, { value: "" }, "— no parent —"),
|
||||
(props.allTasks || []).map(function (t) {
|
||||
|
||||
+26
@@ -891,6 +891,32 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 0.25rem;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.hermes-kanban-docs-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--color-muted-foreground, rgba(180, 180, 200, 0.8));
|
||||
background: var(--color-card-subtle, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25));
|
||||
text-decoration: none;
|
||||
cursor: help;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.hermes-kanban-docs-link:hover,
|
||||
.hermes-kanban-docs-link:focus-visible {
|
||||
color: var(--color-foreground, #e7e7ee);
|
||||
background: var(--color-card, rgba(255, 255, 255, 0.08));
|
||||
border-color: var(--color-border, rgba(160, 160, 190, 0.45));
|
||||
outline: none;
|
||||
}
|
||||
.hermes-kanban-dialog-backdrop {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""GMI Cloud provider profile."""
|
||||
|
||||
from hermes_cli import __version__ as _HERMES_VERSION
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
@@ -12,6 +13,10 @@ gmi = ProviderProfile(
|
||||
env_vars=("GMI_API_KEY", "GMI_BASE_URL"),
|
||||
base_url="https://api.gmi-serving.com/v1",
|
||||
auth_type="api_key",
|
||||
# Attribution so GMI can identify traffic from Hermes Agent.
|
||||
# The generic profile.default_headers fallback in run_agent.py and
|
||||
# agent/auxiliary_client.py picks this up at client construction time.
|
||||
default_headers={"User-Agent": f"HermesAgent/{_HERMES_VERSION}"},
|
||||
default_aux_model="google/gemini-3.1-flash-lite-preview",
|
||||
fallback_models=(
|
||||
"zai-org/GLM-5.1-FP8",
|
||||
|
||||
+23
-3
@@ -2386,7 +2386,13 @@ class AIAgent:
|
||||
# ── Swap core runtime fields ──
|
||||
self.model = new_model
|
||||
self.provider = new_provider
|
||||
self.base_url = base_url or self.base_url
|
||||
# Use new base_url when provided; only fall back to current when the
|
||||
# new provider genuinely has no endpoint (e.g. native SDK providers).
|
||||
# Without this guard the old provider's URL (e.g. Ollama's localhost
|
||||
# address) would persist silently after switching to a cloud provider
|
||||
# that returns an empty base_url string.
|
||||
if base_url:
|
||||
self.base_url = base_url
|
||||
self.api_mode = api_mode
|
||||
# Invalidate transport cache — new api_mode may need a different transport
|
||||
if hasattr(self, "_transport_cache"):
|
||||
@@ -12131,6 +12137,14 @@ class AIAgent:
|
||||
# deltas instead of double-counting them.
|
||||
if self._session_db and self.session_id:
|
||||
try:
|
||||
# Ensure the session row exists before attempting UPDATE.
|
||||
# Under concurrent load (cron/kanban), the initial
|
||||
# _ensure_db_session() may have failed due to SQLite
|
||||
# locking. Retry here so per-call token deltas are
|
||||
# not silently lost (UPDATE on a non-existent row
|
||||
# affects 0 rows without error).
|
||||
if not self._session_db_created:
|
||||
self._ensure_db_session()
|
||||
self._session_db.update_token_counts(
|
||||
self.session_id,
|
||||
input_tokens=canonical_usage.input_tokens,
|
||||
@@ -12149,8 +12163,14 @@ class AIAgent:
|
||||
model=self.model,
|
||||
api_call_count=1,
|
||||
)
|
||||
except Exception:
|
||||
pass # never block the agent loop
|
||||
except Exception as e:
|
||||
# Log token persistence failures so they're
|
||||
# visible in agent.log — silent loss here is
|
||||
# the root cause of undercounted analytics.
|
||||
logger.debug(
|
||||
"Token persistence failed (session=%s, tokens=%d): %s",
|
||||
self.session_id, total_tokens, e,
|
||||
)
|
||||
|
||||
if self.verbose_logging:
|
||||
logging.debug(f"Token usage: prompt={usage_dict['prompt_tokens']:,}, completion={usage_dict['completion_tokens']:,}, total={usage_dict['total_tokens']:,}")
|
||||
|
||||
@@ -28,6 +28,10 @@ if [ -n "${PYTHONHOME:-}" ]; then
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
|
||||
# wrong user's home directory when running under sudo -u <user>. See #21269.
|
||||
export UV_NO_CONFIG=1
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
|
||||
@@ -47,6 +47,7 @@ AUTHOR_MAP = {
|
||||
"qiyin.zuo@pcitc.com": "qiyin-code",
|
||||
"oleksii.lisikh@gmail.com": "olisikh",
|
||||
"leone.parise@gmail.com": "leoneparise",
|
||||
"buraysandro9@gmail.com": "ygd58",
|
||||
"teknium@nousresearch.com": "teknium1",
|
||||
"piyushvp1@gmail.com": "thelumiereguy",
|
||||
"harish.kukreja@gmail.com": "counterposition",
|
||||
@@ -58,6 +59,7 @@ AUTHOR_MAP = {
|
||||
"223003280+Abd0r@users.noreply.github.com": "Abd0r",
|
||||
"abdielv@proton.me": "AJV20",
|
||||
"mason@growagainorchids.com": "masonjames",
|
||||
"ytchen0719@gmail.com": "liquidchen",
|
||||
"am@studio1.tailb672fe.ts.net": "subtract0",
|
||||
"axmaiqiu@gmail.com": "qWaitCrypto",
|
||||
"159539633+MottledShadow@users.noreply.github.com": "MottledShadow",
|
||||
@@ -78,6 +80,7 @@ AUTHOR_MAP = {
|
||||
"dengtaoyuan@dengtaoyuandeMac-mini.local": "dengtaoyuan450-a11y",
|
||||
"ysfalweshcan@gmail.com": "Junass1",
|
||||
"bartokmagic@proton.me": "Bartok9",
|
||||
"androidhtml@yandex.com": "hllqkb",
|
||||
"25840394+Bongulielmi@users.noreply.github.com": "Bongulielmi",
|
||||
"jonathan.troyer@overmatch.com": "JTroyerOvermatch",
|
||||
"harryykyle1@gmail.com": "hharry11",
|
||||
@@ -428,6 +431,7 @@ AUTHOR_MAP = {
|
||||
"johnsonblake1@gmail.com": "voteblake",
|
||||
"hcn518@gmail.com": "pedh",
|
||||
"haileymarshall005@gmail.com": "haileymarshall",
|
||||
"bennet.yr.wang@gmail.com": "BennetYrWang",
|
||||
"greer.guthrie@gmail.com": "g-guthrie",
|
||||
"kennyx102@gmail.com": "bobashopcashier",
|
||||
"77253505+bobashopcashier@users.noreply.github.com": "bobashopcashier",
|
||||
@@ -693,6 +697,7 @@ AUTHOR_MAP = {
|
||||
"mike@mikewaters.net": "mikewaters",
|
||||
"65117428+WadydX@users.noreply.github.com": "WadydX",
|
||||
"216480837+isaachuangGMICLOUD@users.noreply.github.com": "isaachuangGMICLOUD",
|
||||
"isaac.h@gmicloud.ai": "isaachuangGMICLOUD",
|
||||
"nukuom976228@gmail.com": "hsy5571616",
|
||||
"11462216+Nan93@users.noreply.github.com": "Nan93",
|
||||
"l973401489@126.com": "zhouxiaoya12",
|
||||
|
||||
@@ -29,6 +29,10 @@ NC='\033[0m'
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
|
||||
# wrong user's home directory when running under sudo -u <user>. See #21269.
|
||||
export UV_NO_CONFIG=1
|
||||
|
||||
PYTHON_VERSION="3.11"
|
||||
|
||||
is_termux() {
|
||||
|
||||
@@ -130,7 +130,33 @@ def _ensure_deps():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_auth():
|
||||
def check_auth_live():
|
||||
"""Check auth with a real API call to detect disabled_client/account issues."""
|
||||
# quiet=True suppresses the "AUTHENTICATED" print from check_auth so the
|
||||
# final status line reflects the live-call outcome (OK or FAILED).
|
||||
if not check_auth(quiet=True):
|
||||
return False
|
||||
try:
|
||||
from googleapiclient.discovery import build
|
||||
from google.oauth2.credentials import Credentials
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH))
|
||||
service = build("calendar", "v3", credentials=creds)
|
||||
service.calendarList().list(maxResults=1).execute()
|
||||
print("LIVE_CHECK_OK: Real API call succeeded.")
|
||||
return True
|
||||
except Exception as e:
|
||||
err_str = str(e).lower()
|
||||
if "disabled_client" in err_str or "invalid_client" in err_str:
|
||||
print(f"LIVE_CHECK_FAILED: OAuth client or account disabled: {e}")
|
||||
print(" 1. Check Google Cloud Console for disabled OAuth client")
|
||||
print(" 2. Check myaccount.google.com for account status")
|
||||
print(" 3. Do NOT retry with a disabled account")
|
||||
else:
|
||||
print(f"LIVE_CHECK_FAILED: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_auth(quiet: bool = False):
|
||||
"""Check if stored credentials are valid. Prints status, exits 0 or 1."""
|
||||
if not TOKEN_PATH.exists():
|
||||
print(f"NOT_AUTHENTICATED: No token at {TOKEN_PATH}")
|
||||
@@ -157,7 +183,8 @@ def check_auth():
|
||||
print(f"AUTHENTICATED (partial): Token valid but missing {len(missing_scopes)} scopes:")
|
||||
for s in missing_scopes:
|
||||
print(f" - {s}")
|
||||
print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}")
|
||||
if not quiet:
|
||||
print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}")
|
||||
return True
|
||||
|
||||
if creds.expired and creds.refresh_token:
|
||||
@@ -174,10 +201,25 @@ def check_auth():
|
||||
print(f"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes:")
|
||||
for s in missing_scopes:
|
||||
print(f" - {s}")
|
||||
print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}")
|
||||
if not quiet:
|
||||
print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"REFRESH_FAILED: {e}")
|
||||
err_str = str(e).lower()
|
||||
if "disabled_client" in err_str or "invalid_client" in err_str:
|
||||
print(f"OAUTH_CLIENT_DISABLED: {e}")
|
||||
print(" The OAuth client or Google account has been disabled.")
|
||||
print(" Steps to resolve:")
|
||||
print(" 1. Check your Google Cloud Console — verify the OAuth client is not disabled")
|
||||
print(" 2. Check if your Google account itself has been disabled at myaccount.google.com")
|
||||
print(" 3. If the account is disabled, you can appeal at accounts.google.com/signin/recovery")
|
||||
print(" 4. Do NOT retry API calls with a disabled account — this may worsen the situation")
|
||||
print(" 5. If the OAuth client is disabled, create a new one in Google Cloud Console")
|
||||
elif "token_revoked" in err_str or "invalid_grant" in err_str:
|
||||
print(f"TOKEN_REVOKED: {e}")
|
||||
print(" Re-run setup to re-authenticate.")
|
||||
else:
|
||||
print(f"REFRESH_FAILED: {e}")
|
||||
return False
|
||||
|
||||
print("TOKEN_INVALID: Re-run setup.")
|
||||
@@ -384,6 +426,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(description="Google Workspace OAuth setup for Hermes")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--check", action="store_true", help="Check if auth is valid (exit 0=yes, 1=no)")
|
||||
group.add_argument("--check-live", action="store_true", help="Check auth with a real API call (detects disabled_client)")
|
||||
group.add_argument("--client-secret", metavar="PATH", help="Store OAuth client_secret.json")
|
||||
group.add_argument("--auth-url", action="store_true", help="Print OAuth URL for user to visit")
|
||||
group.add_argument("--auth-code", metavar="CODE", help="Exchange auth code for token")
|
||||
@@ -393,6 +436,8 @@ def main():
|
||||
|
||||
if args.check:
|
||||
sys.exit(0 if check_auth() else 1)
|
||||
if getattr(args, "check_live", False):
|
||||
sys.exit(0 if check_auth_live() else 1)
|
||||
elif args.client_secret:
|
||||
store_client_secret(args.client_secret)
|
||||
elif args.auth_url:
|
||||
|
||||
@@ -351,6 +351,95 @@ class TestResolveDeliveryTarget:
|
||||
assert _resolve_delivery_targets({"deliver": []}) == []
|
||||
|
||||
|
||||
class TestRoutingIntents:
|
||||
"""``all`` routing intent expands at fire time."""
|
||||
|
||||
def test_all_expands_to_every_connected_home_channel(self, monkeypatch):
|
||||
"""deliver='all' fans out to every platform with a configured home channel."""
|
||||
from cron.scheduler import _resolve_delivery_targets
|
||||
|
||||
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111")
|
||||
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222")
|
||||
monkeypatch.setenv("SLACK_HOME_CHANNEL", "C333")
|
||||
# Sanity: platforms without the env var must NOT appear in the expansion.
|
||||
monkeypatch.delenv("SIGNAL_HOME_CHANNEL", raising=False)
|
||||
monkeypatch.delenv("MATRIX_HOME_ROOM", raising=False)
|
||||
|
||||
targets = _resolve_delivery_targets({"deliver": "all", "origin": None})
|
||||
platforms = sorted(t["platform"] for t in targets)
|
||||
|
||||
assert "telegram" in platforms
|
||||
assert "discord" in platforms
|
||||
assert "slack" in platforms
|
||||
assert "signal" not in platforms
|
||||
assert "matrix" not in platforms
|
||||
|
||||
def test_all_combines_with_explicit_target_and_dedups(self, monkeypatch):
|
||||
"""'telegram:-999,all' yields every home channel + the explicit target without dupes."""
|
||||
from cron.scheduler import _resolve_delivery_targets
|
||||
|
||||
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111")
|
||||
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222")
|
||||
|
||||
# Explicit telegram target precedes 'all'. Expansion adds discord;
|
||||
# the dedup pass collapses any (platform, chat_id, thread_id) repeats.
|
||||
job = {"deliver": "telegram:-999,all", "origin": None}
|
||||
targets = _resolve_delivery_targets(job)
|
||||
|
||||
platforms = sorted(t["platform"].lower() for t in targets)
|
||||
assert "telegram" in platforms
|
||||
assert "discord" in platforms
|
||||
# Every target is unique on (platform, chat_id, thread_id).
|
||||
keys = [(t["platform"].lower(), str(t["chat_id"]), t.get("thread_id")) for t in targets]
|
||||
assert len(keys) == len(set(keys))
|
||||
|
||||
def test_all_with_no_connected_channels_returns_empty(self, monkeypatch):
|
||||
"""deliver='all' with nothing connected returns [] — delivery is recorded as failed upstream."""
|
||||
from cron.scheduler import _resolve_delivery_targets
|
||||
|
||||
for var in ("TELEGRAM_HOME_CHANNEL", "DISCORD_HOME_CHANNEL", "SLACK_HOME_CHANNEL",
|
||||
"SIGNAL_HOME_CHANNEL", "MATRIX_HOME_ROOM", "MATTERMOST_HOME_CHANNEL",
|
||||
"SMS_HOME_CHANNEL", "EMAIL_HOME_ADDRESS", "DINGTALK_HOME_CHANNEL",
|
||||
"FEISHU_HOME_CHANNEL", "WECOM_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL",
|
||||
"BLUEBUBBLES_HOME_CHANNEL", "QQBOT_HOME_CHANNEL", "QQ_HOME_CHANNEL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
assert _resolve_delivery_targets({"deliver": "all", "origin": None}) == []
|
||||
|
||||
def test_origin_comma_all_preserves_origin_first(self, monkeypatch):
|
||||
"""'origin,all' delivers to the origin platform plus every other home channel."""
|
||||
from cron.scheduler import _resolve_delivery_targets
|
||||
|
||||
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111")
|
||||
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222")
|
||||
|
||||
job = {
|
||||
"deliver": "origin,all",
|
||||
"origin": {"platform": "discord", "chat_id": "888"},
|
||||
}
|
||||
targets = _resolve_delivery_targets(job)
|
||||
platforms = sorted(t["platform"].lower() for t in targets)
|
||||
assert "telegram" in platforms
|
||||
assert "discord" in platforms
|
||||
|
||||
# The origin's explicit chat_id (888) wins the dedup race over the
|
||||
# discord home channel (-222) because origin is resolved first.
|
||||
discord = next(t for t in targets if t["platform"].lower() == "discord")
|
||||
assert discord["chat_id"] == "888"
|
||||
|
||||
def test_all_token_case_insensitive(self, monkeypatch):
|
||||
"""'ALL' / 'All' / 'all' are all recognized."""
|
||||
from cron.scheduler import _resolve_delivery_targets
|
||||
|
||||
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111")
|
||||
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222")
|
||||
|
||||
for token in ("ALL", "All", "all"):
|
||||
targets = _resolve_delivery_targets({"deliver": token, "origin": None})
|
||||
platforms = sorted(t["platform"].lower() for t in targets)
|
||||
assert platforms == ["discord", "telegram"], f"token={token!r} -> {platforms}"
|
||||
|
||||
|
||||
class TestDeliverResultWrapping:
|
||||
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
from hermes_cli.goals import CONTINUATION_PROMPT_TEMPLATE
|
||||
|
||||
|
||||
class FakeAdapter:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.callbacks = {}
|
||||
self._active_sessions = {}
|
||||
|
||||
async def send(self, chat_id, content, reply_to=None, metadata=None):
|
||||
self.calls.append(
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"content": content,
|
||||
"reply_to": reply_to,
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(success=True)
|
||||
|
||||
def register_post_delivery_callback(self, session_key, callback, *, generation=None):
|
||||
self.callbacks[session_key] = (generation, callback)
|
||||
|
||||
|
||||
def _goal_continuation_event(source, goal="finish the task"):
|
||||
return MessageEvent(
|
||||
text=CONTINUATION_PROMPT_TEMPLATE.format(goal=goal),
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_status_notice_uses_adapter_send_with_thread_metadata():
|
||||
"""Regression: /goal judge status must use BasePlatformAdapter.send().
|
||||
|
||||
The old implementation checked for a non-existent send_message() method,
|
||||
so the goal could be marked done in state_meta without the visible
|
||||
"✓ Goal achieved" status line being delivered to Discord/Telegram.
|
||||
"""
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
adapter = FakeAdapter()
|
||||
runner.adapters = {Platform.DISCORD: adapter}
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="parent-channel",
|
||||
thread_id="thread-123",
|
||||
)
|
||||
|
||||
await runner._send_goal_status_notice(source, "✓ Goal achieved: done")
|
||||
|
||||
assert adapter.calls == [
|
||||
{
|
||||
"chat_id": "parent-channel",
|
||||
"content": "✓ Goal achieved: done",
|
||||
"reply_to": None,
|
||||
"metadata": {"thread_id": "thread-123"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_status_notice_defers_until_post_delivery_callback():
|
||||
"""Regression: goal status must appear after the agent's visible reply.
|
||||
|
||||
_post_turn_goal_continuation runs before BasePlatformAdapter sends the
|
||||
returned final response. It should therefore register a post-delivery
|
||||
callback, not send the judge status immediately.
|
||||
"""
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
adapter = FakeAdapter()
|
||||
runner.adapters = {Platform.DISCORD: adapter}
|
||||
runner.config = SimpleNamespace(group_sessions_per_user=True, thread_sessions_per_user=False)
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="parent-channel",
|
||||
thread_id="thread-123",
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
await runner._defer_goal_status_notice_after_delivery(source, "✓ Goal achieved: done")
|
||||
|
||||
assert adapter.calls == []
|
||||
assert len(adapter.callbacks) == 1
|
||||
|
||||
_, callback = next(iter(adapter.callbacks.values()))
|
||||
result = callback()
|
||||
if hasattr(result, "__await__"):
|
||||
await result
|
||||
|
||||
assert adapter.calls == [
|
||||
{
|
||||
"chat_id": "parent-channel",
|
||||
"content": "✓ Goal achieved: done",
|
||||
"reply_to": None,
|
||||
"metadata": {"thread_id": "thread-123"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_clear_goal_pending_continuations_removes_slot_and_overflow_only():
|
||||
"""Regression: /goal pause/clear must cancel queued self-continuations.
|
||||
|
||||
A user-issued /goal pause can arrive after the judge queued the next
|
||||
continuation but before that queued turn runs. The queued synthetic goal
|
||||
continuation should be removed without dropping normal user /queue items.
|
||||
"""
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
adapter = FakeAdapter()
|
||||
adapter._pending_messages = {}
|
||||
runner._queued_events = {}
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="parent-channel",
|
||||
thread_id="thread-123",
|
||||
)
|
||||
session_key = "discord:parent-channel:thread-123"
|
||||
normal_event = MessageEvent(
|
||||
text="normal queued user message",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
)
|
||||
|
||||
adapter._pending_messages[session_key] = _goal_continuation_event(source)
|
||||
runner._queued_events[session_key] = [
|
||||
normal_event,
|
||||
_goal_continuation_event(source, goal="second continuation"),
|
||||
]
|
||||
|
||||
removed = runner._clear_goal_pending_continuations(session_key, adapter)
|
||||
|
||||
assert removed == 2
|
||||
assert adapter._pending_messages.get(session_key) is None
|
||||
assert runner._queued_events[session_key] == [normal_event]
|
||||
@@ -61,8 +61,9 @@ class _RecordingAdapter:
|
||||
return _R()
|
||||
|
||||
|
||||
def _make_runner_with_adapter():
|
||||
def _make_runner_with_adapter(session_id: str = None):
|
||||
from gateway.run import GatewayRunner
|
||||
import uuid
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
@@ -74,9 +75,12 @@ def _make_runner_with_adapter():
|
||||
runner._queued_events = {}
|
||||
|
||||
src = _make_source()
|
||||
# Default to a unique session_id so xdist parallel runs on the same worker
|
||||
# don't see each other's GoalManager state (DEFAULT_DB_PATH gets frozen at
|
||||
# module-import time, defeating per-test HERMES_HOME monkeypatches).
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(src),
|
||||
session_id="goal-sess-1",
|
||||
session_id=session_id or f"goal-sess-{uuid.uuid4().hex[:8]}",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
@@ -103,8 +107,8 @@ async def test_goal_verdict_done_sent_via_adapter_send(hermes_home):
|
||||
mgr = GoalManager(session_entry.session_id)
|
||||
mgr.set("ship the feature")
|
||||
|
||||
with patch("hermes_cli.goals.judge_goal", return_value=("done", "the feature shipped")):
|
||||
runner._post_turn_goal_continuation(
|
||||
with patch("hermes_cli.goals.judge_goal", return_value=("done", "the feature shipped", False)):
|
||||
await runner._post_turn_goal_continuation(
|
||||
session_entry=session_entry,
|
||||
source=src,
|
||||
final_response="I shipped the feature.",
|
||||
@@ -132,8 +136,8 @@ async def test_goal_verdict_continue_enqueues_continuation(hermes_home):
|
||||
mgr = GoalManager(session_entry.session_id)
|
||||
mgr.set("polish the docs")
|
||||
|
||||
with patch("hermes_cli.goals.judge_goal", return_value=("continue", "still needs work")):
|
||||
runner._post_turn_goal_continuation(
|
||||
with patch("hermes_cli.goals.judge_goal", return_value=("continue", "still needs work", False)):
|
||||
await runner._post_turn_goal_continuation(
|
||||
session_entry=session_entry,
|
||||
source=src,
|
||||
final_response="here's a partial edit",
|
||||
@@ -160,8 +164,8 @@ async def test_goal_verdict_budget_exhausted_sends_pause(hermes_home):
|
||||
state.turns_used = 2
|
||||
save_goal(session_entry.session_id, state)
|
||||
|
||||
with patch("hermes_cli.goals.judge_goal", return_value=("continue", "keep going")):
|
||||
runner._post_turn_goal_continuation(
|
||||
with patch("hermes_cli.goals.judge_goal", return_value=("continue", "keep going", False)):
|
||||
await runner._post_turn_goal_continuation(
|
||||
session_entry=session_entry,
|
||||
source=src,
|
||||
final_response="still partial",
|
||||
@@ -181,7 +185,7 @@ async def test_goal_verdict_skipped_when_no_active_goal(hermes_home):
|
||||
"""No goal set → the hook is a no-op. Nothing is sent, nothing enqueued."""
|
||||
runner, adapter, session_entry, src = _make_runner_with_adapter()
|
||||
|
||||
runner._post_turn_goal_continuation(
|
||||
await runner._post_turn_goal_continuation(
|
||||
session_entry=session_entry,
|
||||
source=src,
|
||||
final_response="anything",
|
||||
@@ -207,9 +211,9 @@ async def test_goal_verdict_survives_adapter_without_send(hermes_home):
|
||||
|
||||
runner.adapters[Platform.TELEGRAM] = _NoSendAdapter()
|
||||
|
||||
with patch("hermes_cli.goals.judge_goal", return_value=("done", "ok")):
|
||||
with patch("hermes_cli.goals.judge_goal", return_value=("done", "ok", False)):
|
||||
# must not raise
|
||||
runner._post_turn_goal_continuation(
|
||||
await runner._post_turn_goal_continuation(
|
||||
session_entry=session_entry,
|
||||
source=src,
|
||||
final_response="whatever",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Regression tests for Nous OAuth refresh + agent-key mint interactions."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -862,6 +861,46 @@ def test_refresh_token_reuse_detection_surfaces_actionable_message():
|
||||
assert exc_info.value.relogin_required is True
|
||||
|
||||
|
||||
def test_refresh_token_exchange_sends_refresh_token_header():
|
||||
"""Nous refresh tokens must be sent in a header so sandbox proxies can
|
||||
substitute placeholder credentials without parsing form bodies.
|
||||
"""
|
||||
from hermes_cli.auth import _refresh_access_token
|
||||
|
||||
class _FakeResponse:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return {"access_token": "access-2", "refresh_token": "refresh-2"}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self):
|
||||
self.kwargs = None
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
del args
|
||||
self.kwargs = kwargs
|
||||
return _FakeResponse()
|
||||
|
||||
client = _FakeClient()
|
||||
|
||||
payload = _refresh_access_token(
|
||||
client=client,
|
||||
portal_base_url="https://portal.nousresearch.com",
|
||||
client_id="hermes-cli",
|
||||
refresh_token="refresh-1",
|
||||
)
|
||||
|
||||
assert payload["access_token"] == "access-2"
|
||||
assert payload["refresh_token"] == "refresh-2"
|
||||
assert client.kwargs is not None
|
||||
assert client.kwargs["headers"]["x-nous-refresh-token"] == "refresh-1"
|
||||
assert client.kwargs["data"] == {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": "hermes-cli",
|
||||
}
|
||||
|
||||
|
||||
def test_refresh_non_reuse_error_keeps_original_description():
|
||||
"""Non-reuse invalid_grant errors must keep their original description untouched.
|
||||
|
||||
|
||||
@@ -284,6 +284,22 @@ class TestGmiAuxiliary:
|
||||
assert model == "google/gemini-3.1-flash-lite-preview"
|
||||
assert mock_openai.call_args.kwargs["api_key"] == "gmi-test-key"
|
||||
assert mock_openai.call_args.kwargs["base_url"] == "https://api.gmi-serving.com/v1"
|
||||
# GMI profile declares default_headers with a HermesAgent User-Agent
|
||||
# for traffic attribution. The generic profile-fallback branch in
|
||||
# resolve_provider_client should carry it through to the OpenAI client.
|
||||
headers = mock_openai.call_args.kwargs.get("default_headers", {})
|
||||
assert headers.get("User-Agent", "").startswith("HermesAgent/")
|
||||
|
||||
def test_gmi_profile_declares_hermes_user_agent(self):
|
||||
"""The GMI plugin sets a HermesAgent/<ver> User-Agent on its profile."""
|
||||
from providers import get_provider_profile
|
||||
|
||||
profile = get_provider_profile("gmi")
|
||||
assert profile is not None
|
||||
ua = profile.default_headers.get("User-Agent", "")
|
||||
assert ua.startswith("HermesAgent/"), (
|
||||
f"expected GMI profile User-Agent to start with 'HermesAgent/', got {ua!r}"
|
||||
)
|
||||
|
||||
def test_resolve_provider_client_accepts_gmi_alias(self, monkeypatch):
|
||||
monkeypatch.setenv("GMI_API_KEY", "gmi-test-key")
|
||||
|
||||
+175
-17
@@ -40,14 +40,14 @@ class TestParseJudgeResponse:
|
||||
def test_clean_json_done(self):
|
||||
from hermes_cli.goals import _parse_judge_response
|
||||
|
||||
done, reason = _parse_judge_response('{"done": true, "reason": "all good"}')
|
||||
done, reason, _ = _parse_judge_response('{"done": true, "reason": "all good"}')
|
||||
assert done is True
|
||||
assert reason == "all good"
|
||||
|
||||
def test_clean_json_continue(self):
|
||||
from hermes_cli.goals import _parse_judge_response
|
||||
|
||||
done, reason = _parse_judge_response('{"done": false, "reason": "more work needed"}')
|
||||
done, reason, _ = _parse_judge_response('{"done": false, "reason": "more work needed"}')
|
||||
assert done is False
|
||||
assert reason == "more work needed"
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestParseJudgeResponse:
|
||||
from hermes_cli.goals import _parse_judge_response
|
||||
|
||||
raw = '```json\n{"done": true, "reason": "done"}\n```'
|
||||
done, reason = _parse_judge_response(raw)
|
||||
done, reason, _ = _parse_judge_response(raw)
|
||||
assert done is True
|
||||
assert "done" in reason
|
||||
|
||||
@@ -64,7 +64,7 @@ class TestParseJudgeResponse:
|
||||
from hermes_cli.goals import _parse_judge_response
|
||||
|
||||
raw = 'Looking at this... the agent says X. Verdict: {"done": false, "reason": "partial"}'
|
||||
done, reason = _parse_judge_response(raw)
|
||||
done, reason, _ = _parse_judge_response(raw)
|
||||
assert done is False
|
||||
assert reason == "partial"
|
||||
|
||||
@@ -72,24 +72,24 @@ class TestParseJudgeResponse:
|
||||
from hermes_cli.goals import _parse_judge_response
|
||||
|
||||
for s in ("true", "yes", "done", "1"):
|
||||
done, _ = _parse_judge_response(f'{{"done": "{s}", "reason": "r"}}')
|
||||
done, _, _ = _parse_judge_response(f'{{"done": "{s}", "reason": "r"}}')
|
||||
assert done is True
|
||||
for s in ("false", "no", "not yet"):
|
||||
done, _ = _parse_judge_response(f'{{"done": "{s}", "reason": "r"}}')
|
||||
done, _, _ = _parse_judge_response(f'{{"done": "{s}", "reason": "r"}}')
|
||||
assert done is False
|
||||
|
||||
def test_malformed_json_fails_open(self):
|
||||
"""Non-JSON → not done, with error-ish reason (so judge_goal can map to continue)."""
|
||||
from hermes_cli.goals import _parse_judge_response
|
||||
|
||||
done, reason = _parse_judge_response("this is not json at all")
|
||||
done, reason, _ = _parse_judge_response("this is not json at all")
|
||||
assert done is False
|
||||
assert reason # non-empty
|
||||
|
||||
def test_empty_response(self):
|
||||
from hermes_cli.goals import _parse_judge_response
|
||||
|
||||
done, reason = _parse_judge_response("")
|
||||
done, reason, _ = _parse_judge_response("")
|
||||
assert done is False
|
||||
assert reason
|
||||
|
||||
@@ -103,13 +103,13 @@ class TestJudgeGoal:
|
||||
def test_empty_goal_skipped(self):
|
||||
from hermes_cli.goals import judge_goal
|
||||
|
||||
verdict, _ = judge_goal("", "some response")
|
||||
verdict, _, _ = judge_goal("", "some response")
|
||||
assert verdict == "skipped"
|
||||
|
||||
def test_empty_response_continues(self):
|
||||
from hermes_cli.goals import judge_goal
|
||||
|
||||
verdict, _ = judge_goal("ship the thing", "")
|
||||
verdict, _, _ = judge_goal("ship the thing", "")
|
||||
assert verdict == "continue"
|
||||
|
||||
def test_no_aux_client_continues(self):
|
||||
@@ -120,7 +120,7 @@ class TestJudgeGoal:
|
||||
"agent.auxiliary_client.get_text_auxiliary_client",
|
||||
return_value=(None, None),
|
||||
):
|
||||
verdict, _ = goals.judge_goal("my goal", "my response")
|
||||
verdict, _, _ = goals.judge_goal("my goal", "my response")
|
||||
assert verdict == "continue"
|
||||
|
||||
def test_api_error_continues(self):
|
||||
@@ -133,7 +133,7 @@ class TestJudgeGoal:
|
||||
"agent.auxiliary_client.get_text_auxiliary_client",
|
||||
return_value=(fake_client, "judge-model"),
|
||||
):
|
||||
verdict, reason = goals.judge_goal("goal", "response")
|
||||
verdict, reason, _ = goals.judge_goal("goal", "response")
|
||||
assert verdict == "continue"
|
||||
assert "judge error" in reason.lower()
|
||||
|
||||
@@ -152,7 +152,7 @@ class TestJudgeGoal:
|
||||
"agent.auxiliary_client.get_text_auxiliary_client",
|
||||
return_value=(fake_client, "judge-model"),
|
||||
):
|
||||
verdict, reason = goals.judge_goal("goal", "agent response")
|
||||
verdict, reason, _ = goals.judge_goal("goal", "agent response")
|
||||
assert verdict == "done"
|
||||
assert reason == "achieved"
|
||||
|
||||
@@ -171,7 +171,7 @@ class TestJudgeGoal:
|
||||
"agent.auxiliary_client.get_text_auxiliary_client",
|
||||
return_value=(fake_client, "judge-model"),
|
||||
):
|
||||
verdict, reason = goals.judge_goal("goal", "agent response")
|
||||
verdict, reason, _ = goals.judge_goal("goal", "agent response")
|
||||
assert verdict == "continue"
|
||||
assert reason == "not yet"
|
||||
|
||||
@@ -260,7 +260,7 @@ class TestGoalManager:
|
||||
mgr = GoalManager(session_id="eval-sid-1")
|
||||
mgr.set("ship it")
|
||||
|
||||
with patch.object(goals, "judge_goal", return_value=("done", "shipped")):
|
||||
with patch.object(goals, "judge_goal", return_value=("done", "shipped", False)):
|
||||
decision = mgr.evaluate_after_turn("I shipped the feature.")
|
||||
|
||||
assert decision["verdict"] == "done"
|
||||
@@ -276,7 +276,7 @@ class TestGoalManager:
|
||||
mgr = GoalManager(session_id="eval-sid-2", default_max_turns=5)
|
||||
mgr.set("a long goal")
|
||||
|
||||
with patch.object(goals, "judge_goal", return_value=("continue", "more work")):
|
||||
with patch.object(goals, "judge_goal", return_value=("continue", "more work", False)):
|
||||
decision = mgr.evaluate_after_turn("made some progress")
|
||||
|
||||
assert decision["verdict"] == "continue"
|
||||
@@ -294,7 +294,7 @@ class TestGoalManager:
|
||||
mgr = GoalManager(session_id="eval-sid-3", default_max_turns=2)
|
||||
mgr.set("hard goal")
|
||||
|
||||
with patch.object(goals, "judge_goal", return_value=("continue", "not yet")):
|
||||
with patch.object(goals, "judge_goal", return_value=("continue", "not yet", False)):
|
||||
d1 = mgr.evaluate_after_turn("step 1")
|
||||
assert d1["should_continue"] is True
|
||||
assert mgr.state.turns_used == 1
|
||||
@@ -356,3 +356,161 @@ def test_goal_command_dispatches_in_cli_registry_helpers():
|
||||
assert "/goal" in COMMANDS
|
||||
session_cmds = COMMANDS_BY_CATEGORY.get("Session", {})
|
||||
assert "/goal" in session_cmds
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Auto-pause on consecutive judge parse failures
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestJudgeParseFailureAutoPause:
|
||||
"""Regression: weak judge models (e.g. deepseek-v4-flash) that return
|
||||
empty strings or non-JSON prose must auto-pause the loop after N turns
|
||||
instead of burning the whole turn budget."""
|
||||
|
||||
def test_parse_response_flags_empty_as_parse_failure(self):
|
||||
from hermes_cli.goals import _parse_judge_response
|
||||
|
||||
done, reason, parse_failed = _parse_judge_response("")
|
||||
assert done is False
|
||||
assert parse_failed is True
|
||||
assert "empty" in reason.lower()
|
||||
|
||||
def test_parse_response_flags_non_json_as_parse_failure(self):
|
||||
from hermes_cli.goals import _parse_judge_response
|
||||
|
||||
done, reason, parse_failed = _parse_judge_response(
|
||||
"Let me analyze whether the goal is fully satisfied based on the agent's response..."
|
||||
)
|
||||
assert done is False
|
||||
assert parse_failed is True
|
||||
assert "not json" in reason.lower()
|
||||
|
||||
def test_parse_response_clean_json_is_not_parse_failure(self):
|
||||
from hermes_cli.goals import _parse_judge_response
|
||||
|
||||
done, _, parse_failed = _parse_judge_response(
|
||||
'{"done": false, "reason": "more work"}'
|
||||
)
|
||||
assert done is False
|
||||
assert parse_failed is False
|
||||
|
||||
def test_api_error_does_not_count_as_parse_failure(self):
|
||||
"""Transient network/API errors must not trip the auto-pause guard."""
|
||||
from hermes_cli import goals
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.chat.completions.create.side_effect = RuntimeError("connection reset")
|
||||
with patch(
|
||||
"agent.auxiliary_client.get_text_auxiliary_client",
|
||||
return_value=(fake_client, "judge-model"),
|
||||
):
|
||||
verdict, _, parse_failed = goals.judge_goal("goal", "response")
|
||||
assert verdict == "continue"
|
||||
assert parse_failed is False
|
||||
|
||||
def test_empty_judge_reply_flagged_as_parse_failure(self):
|
||||
"""End-to-end: judge returns empty content → parse_failed=True."""
|
||||
from hermes_cli import goals
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=""))]
|
||||
)
|
||||
with patch(
|
||||
"agent.auxiliary_client.get_text_auxiliary_client",
|
||||
return_value=(fake_client, "judge-model"),
|
||||
):
|
||||
verdict, _, parse_failed = goals.judge_goal("goal", "response")
|
||||
assert verdict == "continue"
|
||||
assert parse_failed is True
|
||||
|
||||
def test_auto_pause_after_three_consecutive_parse_failures(self, hermes_home):
|
||||
"""N=3 consecutive parse failures → auto-pause with config pointer."""
|
||||
from hermes_cli import goals
|
||||
from hermes_cli.goals import GoalManager, DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES
|
||||
|
||||
assert DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES == 3
|
||||
mgr = GoalManager(session_id="parse-fail-sid-1", default_max_turns=20)
|
||||
mgr.set("do a thing")
|
||||
|
||||
with patch.object(
|
||||
goals, "judge_goal", return_value=("continue", "judge returned empty response", True)
|
||||
):
|
||||
d1 = mgr.evaluate_after_turn("step 1")
|
||||
assert d1["should_continue"] is True
|
||||
assert mgr.state.consecutive_parse_failures == 1
|
||||
|
||||
d2 = mgr.evaluate_after_turn("step 2")
|
||||
assert d2["should_continue"] is True
|
||||
assert mgr.state.consecutive_parse_failures == 2
|
||||
|
||||
d3 = mgr.evaluate_after_turn("step 3")
|
||||
assert d3["should_continue"] is False
|
||||
assert d3["status"] == "paused"
|
||||
assert mgr.state.consecutive_parse_failures == 3
|
||||
# Message points at the config surface so the user can fix it.
|
||||
assert "auxiliary" in d3["message"]
|
||||
assert "goal_judge" in d3["message"]
|
||||
assert "config.yaml" in d3["message"]
|
||||
|
||||
def test_parse_failure_counter_resets_on_good_reply(self, hermes_home):
|
||||
"""A single good judge reply resets the counter — transient flakes don't pause."""
|
||||
from hermes_cli import goals
|
||||
from hermes_cli.goals import GoalManager
|
||||
|
||||
mgr = GoalManager(session_id="parse-fail-sid-2", default_max_turns=20)
|
||||
mgr.set("another goal")
|
||||
|
||||
# Two parse failures…
|
||||
with patch.object(
|
||||
goals, "judge_goal", return_value=("continue", "not json", True)
|
||||
):
|
||||
mgr.evaluate_after_turn("step 1")
|
||||
mgr.evaluate_after_turn("step 2")
|
||||
assert mgr.state.consecutive_parse_failures == 2
|
||||
|
||||
# …then one clean reply resets the counter.
|
||||
with patch.object(
|
||||
goals, "judge_goal", return_value=("continue", "making progress", False)
|
||||
):
|
||||
d = mgr.evaluate_after_turn("step 3")
|
||||
assert d["should_continue"] is True
|
||||
assert mgr.state.consecutive_parse_failures == 0
|
||||
|
||||
def test_parse_failure_counter_not_incremented_by_api_errors(self, hermes_home):
|
||||
"""API/transport errors must NOT count toward the auto-pause threshold."""
|
||||
from hermes_cli import goals
|
||||
from hermes_cli.goals import GoalManager
|
||||
|
||||
mgr = GoalManager(session_id="parse-fail-sid-3", default_max_turns=20)
|
||||
mgr.set("goal")
|
||||
|
||||
with patch.object(
|
||||
goals, "judge_goal", return_value=("continue", "judge error: RuntimeError", False)
|
||||
):
|
||||
for _ in range(5):
|
||||
d = mgr.evaluate_after_turn("still going")
|
||||
assert d["should_continue"] is True
|
||||
assert mgr.state.consecutive_parse_failures == 0
|
||||
assert mgr.state.status == "active"
|
||||
|
||||
def test_consecutive_parse_failures_persists_across_goalmanager_reloads(
|
||||
self, hermes_home
|
||||
):
|
||||
"""The counter must be durable so cross-session resumes see it."""
|
||||
from hermes_cli import goals
|
||||
from hermes_cli.goals import GoalManager, load_goal
|
||||
|
||||
mgr = GoalManager(session_id="parse-fail-sid-4", default_max_turns=20)
|
||||
mgr.set("persistent goal")
|
||||
|
||||
with patch.object(
|
||||
goals, "judge_goal", return_value=("continue", "empty", True)
|
||||
):
|
||||
mgr.evaluate_after_turn("r")
|
||||
mgr.evaluate_after_turn("r")
|
||||
|
||||
reloaded = load_goal("parse-fail-sid-4")
|
||||
assert reloaded is not None
|
||||
assert reloaded.consecutive_parse_failures == 2
|
||||
|
||||
@@ -65,6 +65,31 @@ def test_routermint_base_url_applies_user_agent_header(mock_openai):
|
||||
assert headers["User-Agent"].startswith("HermesAgent/")
|
||||
|
||||
|
||||
@patch("run_agent.OpenAI")
|
||||
def test_gmi_base_url_picks_up_profile_user_agent(mock_openai):
|
||||
"""GMI declares User-Agent on its ProviderProfile.default_headers.
|
||||
|
||||
The ``_apply_client_headers_for_base_url`` else-branch looks up the
|
||||
provider profile and applies its default_headers, so no GMI-specific
|
||||
branch is needed in run_agent.
|
||||
"""
|
||||
mock_openai.return_value = MagicMock()
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://api.gmi-serving.com/v1",
|
||||
model="test/model",
|
||||
provider="gmi",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
|
||||
agent._apply_client_headers_for_base_url("https://api.gmi-serving.com/v1")
|
||||
|
||||
headers = agent._client_kwargs["default_headers"]
|
||||
assert headers["User-Agent"].startswith("HermesAgent/")
|
||||
|
||||
|
||||
@patch("run_agent.OpenAI")
|
||||
def test_unknown_base_url_clears_default_headers(mock_openai):
|
||||
mock_openai.return_value = MagicMock()
|
||||
|
||||
+36
-9
@@ -828,18 +828,45 @@ class TestE2EChannelsList:
|
||||
assert result["channels"][0]["target"] == "slack:C1234"
|
||||
|
||||
def test_channels_with_directory(self, mcp_server_e2e, _event_loop, monkeypatch):
|
||||
"""Populated channel_directory.json should be unwrapped via the 'platforms' key.
|
||||
|
||||
Regression test for issue #21474: the writer wraps platforms under
|
||||
{"updated_at": ..., "platforms": {...}} but the reader was iterating
|
||||
directory.items() directly, so channels_list always returned 0.
|
||||
"""
|
||||
import mcp_serve
|
||||
monkeypatch.setattr(mcp_serve, "_load_channel_directory", lambda: {
|
||||
"telegram": [
|
||||
{"id": "123456", "name": "Alice", "type": "dm"},
|
||||
{"id": "-100999", "name": "Dev Group", "type": "group"},
|
||||
],
|
||||
"updated_at": "2026-05-07T12:00:00",
|
||||
"platforms": {
|
||||
"telegram": [
|
||||
{"id": "123456", "name": "Alice", "type": "dm"},
|
||||
{"id": "-100999", "name": "Dev Group", "type": "group"},
|
||||
],
|
||||
"discord": [
|
||||
{"id": "789", "name": "general", "type": "text"},
|
||||
],
|
||||
},
|
||||
})
|
||||
# Need to recreate server to pick up the new mock
|
||||
server, bridge = mcp_server_e2e
|
||||
# The tool closure already captured the old mock, so test the function directly
|
||||
directory = mcp_serve._load_channel_directory()
|
||||
assert len(directory["telegram"]) == 2
|
||||
server, _ = mcp_server_e2e
|
||||
result = _run_tool(server, "channels_list")
|
||||
assert result["count"] == 3
|
||||
targets = {c["target"] for c in result["channels"]}
|
||||
assert targets == {"telegram:123456", "telegram:-100999", "discord:789"}
|
||||
|
||||
def test_channels_with_directory_platform_filter(self, mcp_server_e2e, _event_loop, monkeypatch):
|
||||
"""Platform filter should work against the wrapped 'platforms' payload."""
|
||||
import mcp_serve
|
||||
monkeypatch.setattr(mcp_serve, "_load_channel_directory", lambda: {
|
||||
"updated_at": "2026-05-07T12:00:00",
|
||||
"platforms": {
|
||||
"telegram": [{"id": "123456", "name": "Alice", "type": "dm"}],
|
||||
"discord": [{"id": "789", "name": "general", "type": "text"}],
|
||||
},
|
||||
})
|
||||
server, _ = mcp_server_e2e
|
||||
result = _run_tool(server, "channels_list", {"platform": "discord"})
|
||||
assert result["count"] == 1
|
||||
assert result["channels"][0]["target"] == "discord:789"
|
||||
|
||||
|
||||
class TestE2EPermissions:
|
||||
|
||||
@@ -1863,13 +1863,15 @@ def test_config_set_personality_rejects_unknown_name(monkeypatch):
|
||||
assert "Unknown personality" in resp["error"]["message"]
|
||||
|
||||
|
||||
def test_config_set_personality_resets_history_and_returns_info(monkeypatch):
|
||||
def test_config_set_personality_preserves_history_and_returns_info(monkeypatch):
|
||||
agent = types.SimpleNamespace(
|
||||
ephemeral_system_prompt=None, _cached_system_prompt="old"
|
||||
)
|
||||
session = _session(
|
||||
agent=types.SimpleNamespace(),
|
||||
agent=agent,
|
||||
history=[{"role": "user", "text": "hi"}],
|
||||
history_version=4,
|
||||
)
|
||||
new_agent = types.SimpleNamespace(model="x")
|
||||
emits = []
|
||||
|
||||
server._sessions["sid"] = session
|
||||
@@ -1878,13 +1880,9 @@ def test_config_set_personality_resets_history_and_returns_info(monkeypatch):
|
||||
"_available_personalities",
|
||||
lambda cfg=None: {"helpful": "You are helpful."},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server, "_make_agent", lambda sid, key, session_id=None: new_agent
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")}
|
||||
)
|
||||
monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args))
|
||||
monkeypatch.setattr(server, "_write_config_key", lambda path, value: None)
|
||||
|
||||
@@ -1896,11 +1894,19 @@ def test_config_set_personality_resets_history_and_returns_info(monkeypatch):
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["history_reset"] is True
|
||||
assert resp["result"]["info"] == {"model": "x"}
|
||||
assert session["history"] == []
|
||||
assert resp["result"]["history_reset"] is False
|
||||
assert resp["result"]["info"] == {"model": "?"}
|
||||
# History is preserved with a pivot marker appended
|
||||
assert len(session["history"]) == 2
|
||||
assert session["history"][0] == {"role": "user", "text": "hi"}
|
||||
assert session["history"][1]["role"] == "user"
|
||||
assert "personality" in session["history"][1]["content"].lower()
|
||||
assert "You are helpful." in session["history"][1]["content"]
|
||||
assert session["history_version"] == 5
|
||||
assert ("session.info", "sid", {"model": "x"}) in emits
|
||||
# Agent's system prompt was updated in-place; cached prompt untouched
|
||||
assert agent.ephemeral_system_prompt == "You are helpful."
|
||||
assert agent._cached_system_prompt == "old"
|
||||
assert ("session.info", "sid", {"model": "?"}) in emits
|
||||
|
||||
|
||||
def test_session_compress_uses_compress_helper(monkeypatch):
|
||||
|
||||
@@ -541,7 +541,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
|
||||
},
|
||||
"deliver": {
|
||||
"type": "string",
|
||||
"description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), or platform:chat_id:thread_id for a specific destination. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering', 'sms:+15551234567'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting."
|
||||
"description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), 'all' (fan out to every connected home channel), or platform:chat_id:thread_id for a specific destination. Combine with comma: 'origin,all' delivers to the origin plus every other connected channel. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering', 'sms:+15551234567', 'all'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting. 'all' resolves at fire time, so a job created before a channel was wired up will pick it up automatically once connected."
|
||||
},
|
||||
"skills": {
|
||||
"type": "array",
|
||||
|
||||
@@ -5,10 +5,11 @@ It implements ``WebSearchProvider`` only — there is no extract capability.
|
||||
|
||||
Configuration::
|
||||
|
||||
# ~/.hermes/config.yaml (SEARXNG_URL is a URL, not a secret — use config.yaml not .env)
|
||||
SEARXNG_URL: http://localhost:8080
|
||||
# ~/.hermes/.env
|
||||
SEARXNG_URL=http://localhost:8080
|
||||
|
||||
# Use SearXNG for search, pair with any extract provider:
|
||||
# ~/.hermes/config.yaml
|
||||
web:
|
||||
search_backend: "searxng"
|
||||
extract_backend: "firecrawl"
|
||||
|
||||
+38
-12
@@ -1280,6 +1280,7 @@ def _get_usage(agent) -> dict:
|
||||
"output": g("session_output_tokens", "session_completion_tokens"),
|
||||
"cache_read": g("session_cache_read_tokens"),
|
||||
"cache_write": g("session_cache_write_tokens"),
|
||||
"reasoning": g("session_reasoning_tokens"),
|
||||
"prompt": g("session_prompt_tokens"),
|
||||
"completion": g("session_completion_tokens"),
|
||||
"total": g("session_total_tokens"),
|
||||
@@ -1725,21 +1726,46 @@ def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str
|
||||
def _apply_personality_to_session(
|
||||
sid: str, session: dict, new_prompt: str
|
||||
) -> tuple[bool, dict | None]:
|
||||
"""Apply a personality change to an existing session without resetting history.
|
||||
|
||||
Updates the agent's ephemeral system prompt in-place so the new personality
|
||||
takes effect on the next turn. The cached base system prompt is left intact
|
||||
(ephemeral_system_prompt is appended at API-call time, not baked into the
|
||||
cache), which preserves prompt-cache hits.
|
||||
|
||||
Also injects a system-role marker into the conversation history so the model
|
||||
knows to pivot its style from this point forward (without this, LLMs tend to
|
||||
continue the tone established by earlier messages in the transcript).
|
||||
|
||||
Returns (history_reset, info) — history_reset is always False since we
|
||||
preserve the conversation.
|
||||
"""
|
||||
if not session:
|
||||
return False, None
|
||||
|
||||
try:
|
||||
info = _reset_session_agent(sid, session)
|
||||
return True, info
|
||||
except Exception:
|
||||
if session.get("agent"):
|
||||
agent = session["agent"]
|
||||
agent.ephemeral_system_prompt = new_prompt or None
|
||||
agent._cached_system_prompt = None
|
||||
info = _session_info(agent)
|
||||
_emit("session.info", sid, info)
|
||||
return False, info
|
||||
return False, None
|
||||
agent = session.get("agent")
|
||||
if agent:
|
||||
agent.ephemeral_system_prompt = new_prompt or None
|
||||
# Inject a pivot marker into history so the model sees the change point.
|
||||
# This prevents it from pattern-matching its prior style.
|
||||
if new_prompt:
|
||||
marker = (
|
||||
"[System: The user has changed the assistant's personality. "
|
||||
"From this point forward, adopt the following persona and respond "
|
||||
f"accordingly: {new_prompt}]"
|
||||
)
|
||||
else:
|
||||
marker = (
|
||||
"[System: The user has cleared the personality overlay. "
|
||||
"From this point forward, respond in your normal default style.]"
|
||||
)
|
||||
with session["history_lock"]:
|
||||
session["history"].append({"role": "user", "content": marker})
|
||||
session["history_version"] = int(session.get("history_version", 0)) + 1
|
||||
info = _session_info(agent)
|
||||
_emit("session.info", sid, info)
|
||||
return False, info
|
||||
return False, None
|
||||
|
||||
|
||||
def _cfg_max_turns(cfg: dict, default: int) -> int:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DURATION_PAD_LEN, padTickerDuration, padVerb, VERB_PAD_LEN } from '../components/appChrome.js'
|
||||
import { padVerb, VERB_PAD_LEN } from '../components/appChrome.js'
|
||||
import { VERBS } from '../content/verbs.js'
|
||||
|
||||
describe('FaceTicker verb padding', () => {
|
||||
@@ -16,12 +16,3 @@ describe('FaceTicker verb padding', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('FaceTicker duration padding', () => {
|
||||
it('keeps elapsed segment width stable across second/minute boundaries', () => {
|
||||
const samples = [9000, 10000, 59000, 60000, 61000, 3599000]
|
||||
const lens = samples.map(ms => padTickerDuration(ms).length)
|
||||
|
||||
expect(new Set(lens)).toEqual(new Set([DURATION_PAD_LEN]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,4 +31,12 @@ describe('virtual height estimates', () => {
|
||||
estimatedMsgHeight(msg, 80, { compact: false, details: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('reserves two extra rows for the inter-turn separator on non-first user messages', () => {
|
||||
const msg: Msg = { role: 'user', text: 'follow-up question' }
|
||||
const base = estimatedMsgHeight(msg, 80, { compact: false, details: false })
|
||||
const withSep = estimatedMsgHeight(msg, 80, { compact: false, details: false, withSeparator: true })
|
||||
|
||||
expect(withSep).toBe(base + 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,6 +92,19 @@ export const sessionCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'browse and resume previous sessions',
|
||||
name: 'sessions',
|
||||
run: (arg, ctx) => {
|
||||
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
|
||||
return
|
||||
}
|
||||
if (!arg.trim()) {
|
||||
return patchOverlayState({ picker: true })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'attach an image',
|
||||
name: 'image',
|
||||
@@ -109,7 +122,7 @@ export const sessionCommands: SlashCommand[] = [
|
||||
},
|
||||
|
||||
{
|
||||
help: 'switch or reset personality (history reset on set)',
|
||||
help: 'switch personality for this session',
|
||||
name: 'personality',
|
||||
run: (arg, ctx) => {
|
||||
if (!arg) {
|
||||
|
||||
@@ -264,15 +264,21 @@ export function useMainApp(gw: GatewayClient) {
|
||||
return cache
|
||||
}, [heightCacheKey])
|
||||
|
||||
// Index of the first user-role message — separator-rendering in
|
||||
// appLayout.tsx skips this row, so the height estimator must skip it
|
||||
// too. -1 when no user message exists yet (no row will gate true).
|
||||
const firstUserIdx = useMemo(() => virtualRows.findIndex(r => r.msg.role === 'user'), [virtualRows])
|
||||
|
||||
const estimateRowHeight = useCallback(
|
||||
(index: number) =>
|
||||
estimatedMsgHeight(virtualRows[index]!.msg, cols, {
|
||||
compact: ui.compact,
|
||||
details: detailsVisible,
|
||||
limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS,
|
||||
userPrompt: ui.theme.brand.prompt
|
||||
userPrompt: ui.theme.brand.prompt,
|
||||
withSeparator: virtualRows[index]!.msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx
|
||||
}),
|
||||
[cols, detailsVisible, ui.compact, ui.theme.brand.prompt, virtualRows]
|
||||
[cols, detailsVisible, firstUserIdx, ui.compact, ui.theme.brand.prompt, virtualRows]
|
||||
)
|
||||
|
||||
const syncHeightCache = useCallback(
|
||||
|
||||
@@ -23,9 +23,7 @@ const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
|
||||
// Keep verb segment width stable so status-bar content to the right doesn't
|
||||
// jitter when the ticker rotates between short/long verbs.
|
||||
export const VERB_PAD_LEN = VERBS.reduce((max, v) => Math.max(max, v.length), 0) + 1 // + ellipsis
|
||||
export const DURATION_PAD_LEN = 7 // e.g. " 9s", "1m 05s", "59m 59s"
|
||||
export const padVerb = (verb: string) => `${verb}…`.padEnd(VERB_PAD_LEN, ' ')
|
||||
export const padTickerDuration = (ms: number) => fmtDuration(ms).padStart(DURATION_PAD_LEN, ' ')
|
||||
|
||||
// Compact alternates for the `emoji` and `ascii` indicator styles.
|
||||
// Each entry is a fixed-width (display-width) glyph.
|
||||
@@ -114,7 +112,7 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu
|
||||
// verb segment is hidden (e.g. `unicode` spinner style). When the verb
|
||||
// IS shown, its trailing padding already provides the gap, so the extra
|
||||
// space is harmless.
|
||||
const durationSegment = startedAt ? ` · ${padTickerDuration(now - startedAt)}` : ''
|
||||
const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
|
||||
@@ -76,6 +76,15 @@ const TranscriptPane = memo(function TranscriptPane({
|
||||
return -1
|
||||
}, [transcript.historyItems])
|
||||
|
||||
// Index of the first user-role message; every later user message gets a
|
||||
// small dash above it so multi-turn transcripts visually segment by
|
||||
// turn. -1 when no user message has been sent yet → no separator ever
|
||||
// renders.
|
||||
const firstUserIdx = useMemo(
|
||||
() => transcript.historyItems.findIndex(m => m.role === 'user'),
|
||||
[transcript.historyItems]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollBox
|
||||
@@ -95,6 +104,12 @@ const TranscriptPane = memo(function TranscriptPane({
|
||||
|
||||
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
|
||||
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
||||
{row.msg.role === 'user' && firstUserIdx >= 0 && row.index > firstUserIdx && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={ui.theme.color.border}>───</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{row.msg.kind === 'intro' ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Banner t={ui.theme} />
|
||||
|
||||
@@ -43,8 +43,15 @@ export const estimatedMsgHeight = (
|
||||
compact,
|
||||
details,
|
||||
limitHistory = false,
|
||||
userPrompt = ''
|
||||
}: { compact: boolean; details: boolean; limitHistory?: boolean; userPrompt?: string }
|
||||
userPrompt = '',
|
||||
withSeparator = false
|
||||
}: {
|
||||
compact: boolean
|
||||
details: boolean
|
||||
limitHistory?: boolean
|
||||
userPrompt?: string
|
||||
withSeparator?: boolean
|
||||
}
|
||||
) => {
|
||||
if (msg.kind === 'intro') {
|
||||
return msg.info?.version ? 9 : 5
|
||||
@@ -80,5 +87,12 @@ export const estimatedMsgHeight = (
|
||||
h++
|
||||
}
|
||||
|
||||
// Inter-turn separator above non-first user messages (1 rule row + 1
|
||||
// top-margin row). The render-side gate is in appLayout.tsx; we trust
|
||||
// the caller to pass `withSeparator` only when it matches that gate.
|
||||
if (withSeparator) {
|
||||
h += 2
|
||||
}
|
||||
|
||||
return Math.max(1, h)
|
||||
}
|
||||
|
||||
@@ -164,9 +164,11 @@ export interface Usage {
|
||||
context_max?: number
|
||||
context_percent?: number
|
||||
context_used?: number
|
||||
cost_status?: string
|
||||
cost_usd?: number
|
||||
input: number
|
||||
output: number
|
||||
reasoning?: number
|
||||
total: number
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
sidebar_position: 20
|
||||
title: "Backup & Transfer to Another Machine"
|
||||
description: "Back up your Hermes install and restore it on a new machine — config, API keys, skills, sessions, memory, and profiles."
|
||||
---
|
||||
|
||||
# Backup & Transfer to Another Machine
|
||||
|
||||
Everything about your Hermes install — config, API keys, skills, memory, sessions, cron jobs, pairings — lives under a single directory: `~/.hermes/` (or whatever `HERMES_HOME` points at). Moving to a new machine is two commands:
|
||||
|
||||
```bash
|
||||
# On the old machine
|
||||
hermes backup
|
||||
|
||||
# On the new machine (after installing hermes)
|
||||
hermes import hermes-backup-*.zip
|
||||
```
|
||||
|
||||
That's the whole flow. The rest of this page covers what's actually in the zip, what's deliberately left out, and the gotchas when you restore.
|
||||
|
||||
## What's in a backup
|
||||
|
||||
`hermes backup` creates a zip of your entire `HERMES_HOME`, minus things that don't port cleanly. Concretely it includes:
|
||||
|
||||
- `config.yaml`, `.env`, `auth.json` — all your settings and credentials
|
||||
- `state.db` — session metadata, tool-output history, memory, titles
|
||||
- `skills/`, `plugins/`, `profiles/` — everything you've installed or customized
|
||||
- `cron/jobs.json` — scheduled jobs
|
||||
- `pairing/`, `platforms/pairing/` — approved users for messaging platforms
|
||||
- `sessions/`, `logs/`, cached documents/images/audio, `gateway_state.json`, `channel_directory.json`, `processes.json`
|
||||
- Per-platform state like `feishu_comment_pairing.json`
|
||||
|
||||
What's excluded (and why):
|
||||
|
||||
- **`hermes-agent/`** — the code itself. You reinstall `hermes` on the new machine; the repo isn't user data.
|
||||
- **`checkpoints/`** — session-hash-keyed trajectory caches. They're tied to specific sessions and regenerated on demand; they wouldn't resolve to anything on the new machine.
|
||||
- **`backups/`** — prior `hermes backup` zips. Don't nest backups exponentially.
|
||||
- **`*.db-wal`, `*.db-shm`, `*.db-journal`** — SQLite sidecar files. The `*.db` itself gets a consistent snapshot via `sqlite3.backup()` (WAL-safe, works while Hermes is running). Shipping the live sidecars alongside would pair a fresh snapshot with stale transient state and produce a torn restore.
|
||||
- **`__pycache__/`, `.git/`, `node_modules/`** — regeneratable or irrelevant.
|
||||
- **`gateway.pid`, `cron.pid`** — runtime PID files, meaningless on a different host.
|
||||
|
||||
## Transferring to a new machine
|
||||
|
||||
### 1. On the old machine — create the backup
|
||||
|
||||
```bash
|
||||
hermes backup
|
||||
```
|
||||
|
||||
Output looks like:
|
||||
|
||||
```
|
||||
Scanning ~/.hermes/ ...
|
||||
Backing up 3142 files ...
|
||||
500/3142 files ...
|
||||
...
|
||||
Backup complete: /home/you/hermes-backup-2026-05-08-051630.zip
|
||||
Files: 3142
|
||||
Original: 412.7 MB
|
||||
Compressed: 187.3 MB
|
||||
Time: 8.4s
|
||||
|
||||
Restore with: hermes import hermes-backup-2026-05-08-051630.zip
|
||||
```
|
||||
|
||||
Custom output path:
|
||||
|
||||
```bash
|
||||
hermes backup -o /mnt/usb/hermes-move.zip
|
||||
hermes backup -o /mnt/usb/ # directory → auto-names the file inside
|
||||
```
|
||||
|
||||
### 2. Move the zip
|
||||
|
||||
scp, USB, Dropbox, whatever works. The zip contains credentials (`auth.json`, `.env`) — treat it like a password file. On restore, those files get `0600` permissions automatically.
|
||||
|
||||
### 3. On the new machine — install Hermes first
|
||||
|
||||
The backup doesn't include the codebase. Install Hermes normally before importing:
|
||||
|
||||
```bash
|
||||
# See getting-started/installation for the full install flow
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/install.sh | bash
|
||||
```
|
||||
|
||||
### 4. Import the backup
|
||||
|
||||
```bash
|
||||
hermes import hermes-backup-2026-05-08-051630.zip
|
||||
```
|
||||
|
||||
If `HERMES_HOME` on the new machine already has a `config.yaml` or `.env` (e.g., you ran `hermes setup` before importing), you'll get a confirmation prompt. Pass `--force` / `-f` to skip it:
|
||||
|
||||
```bash
|
||||
hermes import hermes-backup-*.zip --force
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Backup contains 3142 files
|
||||
Target: ~/.hermes/
|
||||
Importing 3142 files ...
|
||||
Import complete: 3142 files restored in 4.1s
|
||||
Target: ~/.hermes/
|
||||
|
||||
Profile aliases restored: work, personal
|
||||
|
||||
Done. Your Hermes configuration has been restored.
|
||||
```
|
||||
|
||||
### 5. Verify and start
|
||||
|
||||
```bash
|
||||
hermes doctor # sanity check config + dependencies
|
||||
hermes chat -q "hello" # quick live test
|
||||
```
|
||||
|
||||
If you ran gateways on the old machine, you'll need to re-enable them per profile on the new machine:
|
||||
|
||||
```bash
|
||||
hermes gateway install
|
||||
hermes -p work gateway install
|
||||
```
|
||||
|
||||
`import` will remind you which profiles need this based on what it restored.
|
||||
|
||||
## Restore on the same machine
|
||||
|
||||
Same command — `hermes import` overlays the zip onto the current `HERMES_HOME`. Useful for rolling back after a bad config change or a corrupted session DB.
|
||||
|
||||
```bash
|
||||
hermes import ~/hermes-backup-2026-05-01-120000.zip
|
||||
```
|
||||
|
||||
## Quick snapshots (`--quick`)
|
||||
|
||||
For "just-in-case" pre-change snapshots — much smaller and faster than a full backup. Captures only critical state: `config.yaml`, `.env`, `auth.json`, `state.db`, `cron/jobs.json`, pairing stores, and a few platform-specific JSON blobs.
|
||||
|
||||
```bash
|
||||
hermes backup --quick --label pre-upgrade
|
||||
```
|
||||
|
||||
Snapshots are stored under `~/.hermes/state-snapshots/<timestamp>-<label>/` and auto-pruned to the last 20. These are NOT transferable zips — they're for local rollback. `hermes update` automatically takes one before pulling, so approved-user lists and pairing data are recoverable if anything goes sideways.
|
||||
|
||||
## Security notes
|
||||
|
||||
- **The backup zip contains plaintext credentials** (`.env`, `auth.json`). Store it like a password vault — encrypted disk, restricted share, or `gpg --symmetric` before upload.
|
||||
- Restored secret files (`.env`, `auth.json`, `state.db`) get mode `0600` automatically.
|
||||
- Path traversal in malicious zips is blocked on import — all extracted paths must resolve inside `HERMES_HOME`.
|
||||
|
||||
## What doesn't transfer cleanly
|
||||
|
||||
A few things in your install are machine-local and won't "just work" after import:
|
||||
|
||||
- **Gateway services** — systemd / launchd unit files live outside `HERMES_HOME`. Re-run `hermes gateway install` per profile on the new machine.
|
||||
- **Absolute paths in config** — if you've set `terminal.workdir` or similar to an absolute path (e.g. `/home/old-user/projects`), fix those up for the new machine.
|
||||
- **Docker containers / volumes** — if you use the Docker terminal backend, the container itself isn't in the backup. Re-pull the image.
|
||||
- **Checkpoints** — intentional (see above). `/rollback` history doesn't port.
|
||||
- **OS-specific integrations** — iMessage/BlueBubbles on macOS, Home Assistant local paths, etc. Re-test platform adapters.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"zip does not appear to be a Hermes backup"** — the validator looks for `config.yaml`, `.env`, or `state.db` somewhere in the archive. If you zipped a sub-directory or renamed the zip, unpack and re-zip from the `HERMES_HOME` root.
|
||||
|
||||
**Archive prefix detected** — if someone zipped the directory itself (creating `.hermes/config.yaml` entries instead of `config.yaml`), `hermes import` strips the `.hermes/` or `hermes/` prefix automatically. No action needed.
|
||||
|
||||
**Profile aliases not on PATH** — restored profiles create wrapper scripts in `~/.local/bin/`. If that's not in your PATH, `hermes import` prints the shell config snippet to add.
|
||||
|
||||
**"SQLite safe copy failed"** — extremely rare; usually means the source DB is locked by another process with an exclusive lock. The backup falls back to a raw copy and logs a warning. If the restored DB won't open, the backup captured a torn state — take a fresh one with Hermes idle.
|
||||
|
||||
## Related
|
||||
|
||||
- [`hermes backup` / `hermes import` reference](../reference/cli-commands.md#hermes-backup) — full flag list
|
||||
- [Profiles](../user-guide/profiles.md) — multiple isolated installs, each backed up together
|
||||
- [Updating & Uninstalling](../getting-started/updating.md) — `hermes update --backup` takes a pre-pull snapshot
|
||||
@@ -784,6 +784,7 @@ $ hermes model
|
||||
[ ] title_generation currently: openrouter / google/gemini-3-flash-preview
|
||||
[ ] compression currently: auto / main model
|
||||
[ ] approval currently: auto / main model
|
||||
[ ] triage_specifier currently: auto / main model
|
||||
```
|
||||
|
||||
Select a task, pick a provider (OAuth flows open a browser; API-key providers prompt), pick a model. The change persists to `auxiliary.<task>.*` in `config.yaml`. Same machinery as the main-model picker — no extra syntax to learn.
|
||||
@@ -880,6 +881,18 @@ auxiliary:
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
timeout: 30
|
||||
|
||||
# Kanban triage specifier — `hermes kanban specify <id>` (or the
|
||||
# dashboard's ✨ Specify button on Triage-column cards) uses this
|
||||
# slot to expand a one-liner into a concrete spec and promote the
|
||||
# task to `todo`. Cheap fast models work well here; spec expansion
|
||||
# is short and doesn't need reasoning depth.
|
||||
triage_specifier:
|
||||
provider: "auto"
|
||||
model: ""
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
timeout: 120
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
||||
@@ -240,9 +240,20 @@ When scheduling jobs, you specify where the output goes:
|
||||
| `"weixin"` | Weixin (WeChat) | |
|
||||
| `"bluebubbles"` | BlueBubbles (iMessage) | |
|
||||
| `"qqbot"` | QQ Bot (Tencent QQ) | |
|
||||
| `"all"` | Fan out to every connected home channel | Resolved at fire time |
|
||||
| `"telegram,discord"` | Fan out to a specific set of channels | Comma-separated list |
|
||||
| `"origin,all"` | Deliver to the origin **plus** every other connected channel | Combine any tokens |
|
||||
|
||||
The agent's final response is automatically delivered. You do not need to call `send_message` in the cron prompt.
|
||||
|
||||
### Routing intent (`all`)
|
||||
|
||||
`all` lets you ship one cron job to every messaging channel you have configured, without having to enumerate them by name. It is **resolved at fire time**, so a job created before you wired up Telegram will pick up Telegram on the next tick after you set `TELEGRAM_HOME_CHANNEL`.
|
||||
|
||||
Semantics: `all` expands to every platform with a configured home channel. Zero is fine; the job simply produces no delivery targets and is recorded as a delivery failure upstream.
|
||||
|
||||
`all` composes with explicit targets. `origin,all` delivers to the origin chat *plus* every other connected home channel, de-duplicating by `(platform, chat_id, thread_id)`.
|
||||
|
||||
### Response wrapping
|
||||
|
||||
By default, delivered cron output is wrapped with a header and footer so the recipient knows it came from a scheduled task:
|
||||
|
||||
@@ -192,6 +192,7 @@ Hermes uses separate lightweight models for side tasks. Each task has its own pr
|
||||
| MCP | MCP helper operations | `auxiliary.mcp` |
|
||||
| Approval | Smart command-approval classification | `auxiliary.approval` |
|
||||
| Title Generation | Session title summaries | `auxiliary.title_generation` |
|
||||
| Triage Specifier | `hermes kanban specify` / dashboard ✨ button — fleshes out a one-liner triage task into a real spec | `auxiliary.triage_specifier` |
|
||||
|
||||
### Auto-Detection Chain
|
||||
|
||||
@@ -384,5 +385,6 @@ See [Scheduled Tasks (Cron)](/docs/user-guide/features/cron) for full configurat
|
||||
| MCP helpers | Auto-detection chain | `auxiliary.mcp` |
|
||||
| Approval classification | Auto-detection chain | `auxiliary.approval` |
|
||||
| Title generation | Auto-detection chain | `auxiliary.title_generation` |
|
||||
| Triage specifier | Auto-detection chain | `auxiliary.triage_specifier` |
|
||||
| Delegation | Provider override only (no automatic fallback) | `delegation.provider` / `delegation.model` |
|
||||
| Cron jobs | Per-job provider override only (no automatic fallback) | Per-job `provider` / `model` |
|
||||
|
||||
@@ -22,7 +22,7 @@ Throughout the tutorial, **code blocks labelled `bash` are commands *you* run.**
|
||||
|
||||
Six columns, left to right:
|
||||
|
||||
- **Triage** — raw ideas, a specifier will flesh out the spec before anyone works on them.
|
||||
- **Triage** — raw ideas, a specifier will flesh out the spec before anyone works on them. Click the **✨ Specify** button on any triage card (or run `hermes kanban specify <id>` / `/kanban specify <id>` from a chat) to have the auxiliary LLM turn a one-liner into a full spec (goal, approach, acceptance criteria) and promote it to `todo` in one shot. Configure which model runs it under `auxiliary.triage_specifier` in `config.yaml`.
|
||||
- **Todo** — created but waiting on dependencies, or not yet assigned.
|
||||
- **Ready** — assigned and waiting for the dispatcher to claim.
|
||||
- **In progress** — a worker is actively running the task. With "Lanes by profile" on (the default), this column sub-groups by assignee so you can see at a glance what each worker is doing.
|
||||
|
||||
@@ -148,8 +148,15 @@ You should see something like `10 results`. If you get a `403 Forbidden`, JSON f
|
||||
**7. Configure Hermes:**
|
||||
|
||||
```bash
|
||||
# ~/.hermes/config.yaml
|
||||
SEARXNG_URL: http://localhost:8888
|
||||
# ~/.hermes/.env
|
||||
SEARXNG_URL=http://localhost:8888
|
||||
```
|
||||
|
||||
Then select SearXNG as the search backend in `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
search_backend: "searxng"
|
||||
```
|
||||
|
||||
Or set via `hermes tools` → Web Search & Extract → SearXNG.
|
||||
@@ -161,8 +168,8 @@ Or set via `hermes tools` → Web Search & Extract → SearXNG.
|
||||
Public SearXNG instances are listed at [searx.space](https://searx.space/). Filter by instances that have **JSON format enabled** (shown in the table).
|
||||
|
||||
```bash
|
||||
# ~/.hermes/config.yaml
|
||||
SEARXNG_URL: https://searx.example.com
|
||||
# ~/.hermes/.env
|
||||
SEARXNG_URL=https://searx.example.com
|
||||
```
|
||||
|
||||
:::caution Public instances
|
||||
|
||||
@@ -178,6 +178,7 @@ const sidebars: SidebarsConfig = {
|
||||
'guides/delegation-patterns',
|
||||
'guides/github-pr-review-agent',
|
||||
'guides/webhook-github-pr-review',
|
||||
'guides/backup-and-transfer',
|
||||
'guides/migrate-from-openclaw',
|
||||
'guides/aws-bedrock',
|
||||
'guides/azure-foundry',
|
||||
|
||||
Reference in New Issue
Block a user