Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc01bcd1da | ||
|
|
17f7e5878e | ||
|
|
20435c57d3 | ||
|
|
8e734810df | ||
|
|
ae5e39005b | ||
|
|
0cf2e39c41 | ||
|
|
75efd73961 | ||
|
|
d39c62409b | ||
|
|
b848fcbf11 | ||
|
|
63c4100fe6 | ||
|
|
58e1647b49 | ||
|
|
efb226b586 | ||
|
|
e10c8eba00 | ||
|
|
7e3986ae68 | ||
|
|
c0fbee990e | ||
|
|
65372395eb | ||
|
|
f75f3cd713 | ||
|
|
5057f03bfd | ||
|
|
ee0b54e16c | ||
|
|
b64b802131 | ||
|
|
4b27be1114 | ||
|
|
aab351bfa6 | ||
|
|
ac6dd598a4 | ||
|
|
465cbe8bb5 | ||
|
|
5413c42f2c | ||
|
|
98804dbeef | ||
|
|
7ecc822e11 | ||
|
|
1192f29450 | ||
|
|
6eeed3f1e8 | ||
|
|
c75e1d1b87 | ||
|
|
832c5f9bc9 | ||
|
|
f64e4f4f57 | ||
|
|
48788032da | ||
|
|
f5ef7ee9da | ||
|
|
ecc6725855 | ||
|
|
8a573bb6e7 | ||
|
|
24e9ed73c2 | ||
|
|
e6077af279 | ||
|
|
862aee4956 | ||
|
|
a208b7eeb4 | ||
|
|
6695640c1d | ||
|
|
2e1982f83d | ||
|
|
4d7f8ade3e | ||
|
|
9de9c25f62 | ||
|
|
c30c9753b6 | ||
|
|
f9eca7e15f | ||
|
|
4f620a0bbc | ||
|
|
6015ee5d2a | ||
|
|
543f069093 | ||
|
|
75de0057bc | ||
|
|
d9a4b5a5e5 | ||
|
|
4b184cbe54 | ||
|
|
4e4a69cbf7 | ||
|
|
8d66e78844 | ||
|
|
5633fa19b8 | ||
|
|
bf7639138e | ||
|
|
0263f1d12e | ||
|
|
6ca3d701fc | ||
|
|
db9e3e4ef9 | ||
|
|
3e7ade418d | ||
|
|
c3808cfc14 | ||
|
|
551f00109d | ||
|
|
8a726e91ba | ||
|
|
b062083d0a | ||
|
|
838d50495f | ||
|
|
2d4fd1d52f | ||
|
|
a6203839b4 | ||
|
|
bae3954f4f | ||
|
|
ea0b42c43a | ||
|
|
6c731fe591 | ||
|
|
86c5febdd1 | ||
|
|
5089c84dbf | ||
|
|
4638f3b433 | ||
|
|
1f6836cd81 | ||
|
|
743c116fb2 | ||
|
|
f99e9f0d27 | ||
|
|
086596ca2b | ||
|
|
79f4f78fa4 | ||
|
|
c3d2be073a | ||
|
|
e10e4bca82 | ||
|
|
41166bbe0d | ||
|
|
e5ac169c28 | ||
|
|
0ecfbc9890 | ||
|
|
d5a5ea8640 | ||
|
|
117f49b7d4 | ||
|
|
36308f0667 | ||
|
|
f304f41266 | ||
|
|
a0b14a3126 | ||
|
|
37a4cf9000 | ||
|
|
0e04d14209 | ||
|
|
5e51b123f3 | ||
|
|
b4289200ba | ||
|
|
b1500af277 | ||
|
|
94b4ac118a | ||
|
|
e0176cbd47 | ||
|
|
f76899facf | ||
|
|
b172e03c20 | ||
|
|
ab07e06521 | ||
|
|
4bd6fce1c1 | ||
|
|
271130af56 | ||
|
|
a730156626 | ||
|
|
49dd0b1cb5 | ||
|
|
a80e5e72bc | ||
|
|
b598f8e69b | ||
|
|
9a322726ae | ||
|
|
53edf6f983 | ||
|
|
2a14205ff4 | ||
|
|
b2d6a512d5 | ||
|
|
e9da629800 | ||
|
|
fa651375be | ||
|
|
d43863f005 | ||
|
|
437052f039 | ||
|
|
2985d16be0 | ||
|
|
985e19c110 | ||
|
|
ee66ff2790 | ||
|
|
8ce3c2f991 | ||
|
|
4c3a388cba | ||
|
|
b9d9b8aad6 | ||
|
|
acfefa4fda | ||
|
|
51dd5695ec | ||
|
|
f3c27e30eb | ||
|
|
91885a32b3 | ||
|
|
2e42bb2da5 | ||
|
|
4ca61869cb |
@@ -1,4 +1,4 @@
|
||||
watch_file pyproject.toml uv.lock
|
||||
watch_file pyproject.toml uv.lock hermes
|
||||
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
|
||||
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 295 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 275 KiB |
+1
-1
@@ -74,7 +74,7 @@ Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimi
|
||||
| Requisito | Notas |
|
||||
|-----------|-------|
|
||||
| **Git** | Con la extensión `git-lfs` instalada |
|
||||
| **Python 3.11+** | uv lo instalará si falta |
|
||||
| **Python 3.11–3.13** | uv lo instalará si falta |
|
||||
| **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) |
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.18.0",
|
||||
"version": "0.18.2",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
@@ -9,7 +9,7 @@
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.18.0",
|
||||
"package": "hermes-agent[acp]==0.18.2",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1275,6 +1275,12 @@ def restore_primary_runtime(agent) -> bool:
|
||||
agent._fallback_activated = False
|
||||
agent._fallback_index = 0
|
||||
|
||||
# Reset the stale-call circuit breaker (#58962): the streak measured
|
||||
# the FALLBACK provider we're leaving; the restored primary deserves
|
||||
# a fresh stream attempt before the breaker can trip again.
|
||||
from agent.chat_completion_helpers import _reset_stale_streak
|
||||
_reset_stale_streak(agent)
|
||||
|
||||
# Undo the fallback's identity rewrite so the prompt is
|
||||
# byte-identical to the stored copy again (prefix cache match).
|
||||
from agent.chat_completion_helpers import rewrite_prompt_model_identity
|
||||
@@ -1992,6 +1998,14 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
# ── Invalidate cached system prompt so it rebuilds next turn ──
|
||||
agent._cached_system_prompt = None
|
||||
|
||||
# ── Reset the cross-turn stale-call circuit breaker (#58962) ──
|
||||
# The breaker's error text tells the user to "switch models ... then
|
||||
# retry"; without this reset the streak stays latched and the freshly
|
||||
# selected (healthy) provider would keep short-circuiting before any
|
||||
# stream is even attempted.
|
||||
from agent.chat_completion_helpers import _reset_stale_streak
|
||||
_reset_stale_streak(agent)
|
||||
|
||||
# ── Update _primary_runtime so the change persists across turns ──
|
||||
_cc = agent.context_compressor if hasattr(agent, "context_compressor") and agent.context_compressor else None
|
||||
agent._primary_runtime = {
|
||||
@@ -2101,12 +2115,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
except Exception as _mw_err:
|
||||
logger.debug("tool_request middleware error: %s", _mw_err)
|
||||
|
||||
# Check plugin hooks for a block directive before executing anything.
|
||||
# Check plugin hooks for a block or approval directive before executing.
|
||||
block_message: Optional[str] = None
|
||||
if not pre_tool_block_checked:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
block_message = resolve_pre_tool_block(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
@@ -2117,7 +2131,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
block_message = None
|
||||
if block_message is not None:
|
||||
result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
try:
|
||||
|
||||
+121
-14
@@ -1356,6 +1356,96 @@ class AsyncAnthropicAuxiliaryClient:
|
||||
self._real_client = sync_wrapper._real_client
|
||||
|
||||
|
||||
class _BedrockCompletionsAdapter:
|
||||
"""Translates ``chat.completions.create(**kwargs)`` into Bedrock Converse."""
|
||||
|
||||
def __init__(self, region: str, model: str):
|
||||
self._region = region
|
||||
self._model = model
|
||||
|
||||
def create(self, **kwargs) -> Any:
|
||||
from agent.bedrock_adapter import call_converse
|
||||
|
||||
messages = kwargs.get("messages", [])
|
||||
model = kwargs.get("model", self._model)
|
||||
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens")
|
||||
# OpenAI accepts ``stop`` as str or list; Converse requires a list.
|
||||
stop = kwargs.get("stop")
|
||||
if isinstance(stop, str):
|
||||
stop = [stop]
|
||||
if kwargs.get("tool_choice") is not None:
|
||||
# Converse's toolChoice isn't wired through call_converse();
|
||||
# no in-tree auxiliary caller passes tool_choice today. Surface
|
||||
# the drop instead of silently ignoring it.
|
||||
logger.debug(
|
||||
"BedrockAuxiliaryClient: tool_choice=%r not supported by the "
|
||||
"Converse shim — ignored.", kwargs.get("tool_choice"),
|
||||
)
|
||||
if kwargs.get("stream"):
|
||||
# Converse streaming isn't wired through this shim. Return a
|
||||
# complete response instead — call_llm's streaming consumer
|
||||
# detects a final object and downgrades to non-live output.
|
||||
logger.debug(
|
||||
"BedrockAuxiliaryClient: stream=True requested for %s — "
|
||||
"returning a complete response (Converse shim does not "
|
||||
"stream); caller downgrades to non-streaming.",
|
||||
model,
|
||||
)
|
||||
return call_converse(
|
||||
region=self._region,
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=kwargs.get("tools"),
|
||||
max_tokens=int(max_tokens) if max_tokens else 4096,
|
||||
temperature=kwargs.get("temperature"),
|
||||
top_p=kwargs.get("top_p"),
|
||||
stop_sequences=stop,
|
||||
)
|
||||
|
||||
|
||||
class _BedrockChatShim:
|
||||
def __init__(self, adapter: "_BedrockCompletionsAdapter"):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class BedrockAuxiliaryClient:
|
||||
"""OpenAI-client-compatible wrapper over AWS Bedrock Converse API."""
|
||||
|
||||
def __init__(self, region: str, model: str):
|
||||
self._region = region
|
||||
self._model = model
|
||||
adapter = _BedrockCompletionsAdapter(region, model)
|
||||
self.chat = _BedrockChatShim(adapter)
|
||||
self.api_key = "aws-sdk"
|
||||
self.base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _AsyncBedrockCompletionsAdapter:
|
||||
def __init__(self, sync_adapter: _BedrockCompletionsAdapter):
|
||||
self._sync = sync_adapter
|
||||
|
||||
async def create(self, **kwargs) -> Any:
|
||||
import asyncio
|
||||
return await asyncio.to_thread(self._sync.create, **kwargs)
|
||||
|
||||
|
||||
class _AsyncBedrockChatShim:
|
||||
def __init__(self, adapter: _AsyncBedrockCompletionsAdapter):
|
||||
self.completions = adapter
|
||||
|
||||
|
||||
class AsyncBedrockAuxiliaryClient:
|
||||
def __init__(self, sync_wrapper: "BedrockAuxiliaryClient"):
|
||||
sync_adapter = sync_wrapper.chat.completions
|
||||
async_adapter = _AsyncBedrockCompletionsAdapter(sync_adapter)
|
||||
self.chat = _AsyncBedrockChatShim(async_adapter)
|
||||
self.api_key = sync_wrapper.api_key
|
||||
self.base_url = sync_wrapper.base_url
|
||||
|
||||
|
||||
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
|
||||
protocol instead of OpenAI chat.completions.
|
||||
@@ -1411,6 +1501,8 @@ def _maybe_wrap_anthropic(
|
||||
# Already wrapped — don't double-wrap.
|
||||
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
|
||||
return client_obj
|
||||
if _safe_isinstance(client_obj, BedrockAuxiliaryClient):
|
||||
return client_obj
|
||||
# Other specialized adapters we should never re-dispatch.
|
||||
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
|
||||
return client_obj
|
||||
@@ -4204,6 +4296,8 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
||||
return AsyncCodexAuxiliaryClient(sync_client), model
|
||||
if isinstance(sync_client, AnthropicAuxiliaryClient):
|
||||
return AsyncAnthropicAuxiliaryClient(sync_client), model
|
||||
if isinstance(sync_client, BedrockAuxiliaryClient):
|
||||
return AsyncBedrockAuxiliaryClient(sync_client), model
|
||||
try:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
|
||||
|
||||
@@ -4943,10 +5037,14 @@ def resolve_provider_client(
|
||||
else (client, final_model))
|
||||
|
||||
elif pconfig.auth_type == "aws_sdk":
|
||||
# AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
|
||||
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
|
||||
# AWS SDK providers (Bedrock) — Claude models use the Anthropic Bedrock
|
||||
# SDK (prompt caching, thinking); non-Claude models use Converse API.
|
||||
try:
|
||||
from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
|
||||
from agent.bedrock_adapter import (
|
||||
has_aws_credentials,
|
||||
is_anthropic_bedrock_model,
|
||||
resolve_bedrock_region,
|
||||
)
|
||||
from agent.anthropic_adapter import build_anthropic_bedrock_client
|
||||
except ImportError:
|
||||
logger.warning("resolve_provider_client: bedrock requested but "
|
||||
@@ -4961,17 +5059,26 @@ def resolve_provider_client(
|
||||
region = resolve_bedrock_region()
|
||||
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
try:
|
||||
real_client = build_anthropic_bedrock_client(region)
|
||||
except ImportError as exc:
|
||||
logger.warning("resolve_provider_client: cannot create Bedrock "
|
||||
"client: %s", exc)
|
||||
return None, None
|
||||
client = AnthropicAuxiliaryClient(
|
||||
real_client, final_model, api_key="aws-sdk",
|
||||
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
|
||||
)
|
||||
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
|
||||
base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
|
||||
|
||||
if is_anthropic_bedrock_model(final_model):
|
||||
try:
|
||||
real_client = build_anthropic_bedrock_client(region)
|
||||
except ImportError as exc:
|
||||
logger.warning("resolve_provider_client: cannot create Bedrock "
|
||||
"client: %s", exc)
|
||||
return None, None
|
||||
client = AnthropicAuxiliaryClient(
|
||||
real_client, final_model, api_key="aws-sdk",
|
||||
base_url=base_url,
|
||||
)
|
||||
logger.debug("resolve_provider_client: bedrock anthropic (%s, %s)",
|
||||
final_model, region)
|
||||
else:
|
||||
client = BedrockAuxiliaryClient(region, final_model)
|
||||
logger.debug("resolve_provider_client: bedrock converse (%s, %s)",
|
||||
final_model, region)
|
||||
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
|
||||
@@ -171,6 +171,52 @@ def _env_float(name: str, default: float) -> float:
|
||||
return default
|
||||
|
||||
|
||||
# ── Cross-turn stale-call circuit breaker (#58962) ─────────────────────
|
||||
# A session wedged against an unresponsive provider hits the stale detector
|
||||
# on every call and loops forever (observed: 494 consecutive failures over
|
||||
# 3+ days, each burning the full stale timeout × retries with no response).
|
||||
# The agent carries ``_consecutive_stale_streams``: incremented on every
|
||||
# stale kill, reset only when a call actually completes (or when the
|
||||
# provider is swapped — switch_model / try_activate_fallback /
|
||||
# restore_primary_runtime — since the streak measured the OLD provider).
|
||||
# Past the give-up threshold, calls abort immediately with an actionable
|
||||
# error instead of re-waiting out the stale timeout.
|
||||
|
||||
def _stale_streak(agent) -> int:
|
||||
try:
|
||||
return int(getattr(agent, "_consecutive_stale_streams", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _bump_stale_streak(agent) -> None:
|
||||
try:
|
||||
agent._consecutive_stale_streams = _stale_streak(agent) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _reset_stale_streak(agent) -> None:
|
||||
try:
|
||||
agent._consecutive_stale_streams = 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _check_stale_giveup(agent) -> None:
|
||||
"""Raise immediately when the consecutive-stale streak is past the
|
||||
give-up threshold — no network attempt, no stale-timeout wait."""
|
||||
_giveup = env_int("HERMES_STREAM_STALE_GIVEUP", 5)
|
||||
_streak = _stale_streak(agent)
|
||||
if _giveup > 0 and _streak >= _giveup:
|
||||
raise RuntimeError(
|
||||
"Provider has been unresponsive (no response received) for "
|
||||
f"{_streak} consecutive stale attempts — aborting this call to "
|
||||
"avoid an indefinite stall. Switch models or start a new "
|
||||
"session, then retry."
|
||||
)
|
||||
|
||||
|
||||
def interruptible_api_call(agent, api_kwargs: dict):
|
||||
"""
|
||||
Run the API call in a background thread so the main conversation loop
|
||||
@@ -186,6 +232,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
provider fallback.
|
||||
"""
|
||||
result = {"response": None, "error": None}
|
||||
|
||||
# Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling
|
||||
# of the guard in interruptible_streaming_api_call. Quiet-mode /
|
||||
# subagent / no-stream-consumer sessions take THIS path, and a wedged
|
||||
# unattended session here has the same infinite stale-retry class.
|
||||
_check_stale_giveup(agent)
|
||||
|
||||
request_client_holder = {"client": None, "owner_tid": None}
|
||||
request_client_lock = threading.Lock()
|
||||
# Request-local cancellation flag. Distinct from agent._interrupt_requested
|
||||
@@ -557,6 +610,9 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
_close_request_client_once("stale_call_kill")
|
||||
except Exception:
|
||||
pass
|
||||
# Circuit breaker (#58962): count the stale kill. See the
|
||||
# canonical comment block above ``_stale_streak()``.
|
||||
_bump_stale_streak(agent)
|
||||
agent._touch_activity(
|
||||
f"stale non-streaming call killed after {int(_elapsed)}s"
|
||||
)
|
||||
@@ -599,6 +655,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
raise InterruptedError("Agent interrupted during API call")
|
||||
if result["error"] is not None:
|
||||
raise result["error"]
|
||||
# Success — clear the circuit breaker (#58962): the provider proved
|
||||
# responsive. See the canonical comment block above ``_stale_streak()``.
|
||||
if result["response"] is not None:
|
||||
_reset_stale_streak(agent)
|
||||
return result["response"]
|
||||
|
||||
|
||||
@@ -1488,6 +1548,11 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
"Fallback activated: %s → %s (%s)",
|
||||
old_model, fb_model, fb_provider,
|
||||
)
|
||||
# Reset the stale-call circuit breaker (#58962): the streak measured
|
||||
# the OLD provider's unresponsiveness. Carrying it over would
|
||||
# short-circuit the freshly activated fallback before it gets a
|
||||
# single stream attempt.
|
||||
_reset_stale_streak(agent)
|
||||
return True
|
||||
except Exception as e:
|
||||
if fb_provider == "nous":
|
||||
@@ -1896,6 +1961,12 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
return result["response"]
|
||||
|
||||
result = {"response": None, "error": None, "partial_tool_names": []}
|
||||
|
||||
# Cross-turn stale-stream circuit breaker (#58962) — see the canonical
|
||||
# comment block above ``_stale_streak()``. Raises past the give-up
|
||||
# threshold instead of burning another stale-timeout×retries cycle.
|
||||
_check_stale_giveup(agent)
|
||||
|
||||
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
|
||||
request_client_lock = threading.Lock()
|
||||
# Request-local cancellation flag — see interruptible_api_call for the full
|
||||
@@ -2873,6 +2944,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_close_request_client_once("stale_stream_kill")
|
||||
except Exception:
|
||||
pass
|
||||
# Circuit breaker (#58962): count the stale kill. See the
|
||||
# canonical comment block above ``_stale_streak()``.
|
||||
_bump_stale_streak(agent)
|
||||
# Rebuild the primary client too — its connection pool
|
||||
# may hold dead sockets from the same provider outage.
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
@@ -3002,8 +3076,16 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
)
|
||||
if _content_filter_terminated:
|
||||
_stub._content_filter_terminated = True
|
||||
# Partial-stream stub: chunks WERE received (deltas fired), so
|
||||
# the provider is demonstrably responsive — clear the circuit
|
||||
# breaker (#58962) just like the full-success return below.
|
||||
_reset_stale_streak(agent)
|
||||
return _stub
|
||||
raise result["error"]
|
||||
# Success — clear the circuit breaker (#58962): the provider proved
|
||||
# responsive. See the canonical comment block above ``_stale_streak()``.
|
||||
if result["response"] is not None:
|
||||
_reset_stale_streak(agent)
|
||||
return result["response"]
|
||||
|
||||
# ── Provider fallback ──────────────────────────────────────────────────
|
||||
|
||||
@@ -305,6 +305,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# OpenRouter live metadata reports 262144 (256 × 1024); align the
|
||||
# static fallback so cache and offline both agree (issue #22268).
|
||||
"hy3-preview": 262144,
|
||||
# Tencent — Hy3 (GA successor to Hy3 Preview), same 256K window.
|
||||
"hy3": 262144,
|
||||
# Nemotron — NVIDIA's open-weights series (128K context across all sizes)
|
||||
"nemotron": 131072,
|
||||
# Arcee
|
||||
|
||||
+11
-16
@@ -743,6 +743,17 @@ PLATFORM_HINTS = {
|
||||
"or 'all'). Do not promise the user that a deliver='origin' or "
|
||||
"default-deliver cron job will message them in this session."
|
||||
),
|
||||
"desktop": (
|
||||
"You are chatting inside the Hermes desktop app — a graphical chat "
|
||||
"surface, not a terminal. Use markdown freely: it renders with full "
|
||||
"GitHub flavor (tables, code blocks with syntax highlighting, math "
|
||||
"via $...$, task lists, blockquote callouts). "
|
||||
"You can deliver files natively — include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.png, .jpg, .webp) appear inline, audio and "
|
||||
"video play inline, and other files arrive as download links. You can "
|
||||
"also include image URLs in markdown format  and they "
|
||||
"render inline as photos."
|
||||
),
|
||||
"sms": (
|
||||
"You are communicating via SMS. Keep responses concise and use plain text "
|
||||
"only — no markdown, no formatting. SMS messages are limited to ~1600 "
|
||||
@@ -1127,22 +1138,6 @@ def build_environment_hints() -> str:
|
||||
f"`uname -a && whoami && pwd`."
|
||||
)
|
||||
|
||||
# Hermes desktop GUI — any agent running under the desktop app should know
|
||||
# it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
|
||||
# marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
|
||||
_truthy = ("1", "true", "yes")
|
||||
_in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
|
||||
_in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
|
||||
if _in_desktop or _in_desktop_term:
|
||||
_desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
|
||||
if _in_desktop_term:
|
||||
_desktop_hint += (
|
||||
" You're in its embedded terminal pane, beside the GUI chat — the user can "
|
||||
"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
|
||||
"⌘/Ctrl+L to send it to the chat composer."
|
||||
)
|
||||
hints.append(_desktop_hint)
|
||||
|
||||
if is_wsl():
|
||||
hints.append(WSL_ENVIRONMENT_HINT)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.prompt_builder import (
|
||||
@@ -44,6 +45,7 @@ from agent.prompt_builder import (
|
||||
drain_truncation_warnings,
|
||||
)
|
||||
from agent.runtime_cwd import resolve_context_cwd
|
||||
from utils import is_truthy_value
|
||||
|
||||
|
||||
def _ra():
|
||||
@@ -110,6 +112,36 @@ def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) ->
|
||||
return base
|
||||
|
||||
|
||||
_TUI_EMBEDDED_PANE_CLARIFIER = (
|
||||
" You're in its embedded terminal pane, beside the GUI chat — the user can "
|
||||
"select your output (Option-drag on macOS, Shift-drag elsewhere) and press "
|
||||
"Cmd/Ctrl+L to send it to the chat composer."
|
||||
)
|
||||
|
||||
|
||||
def _tui_embedded_pane_clarifier(hint: str) -> str:
|
||||
"""Append the desktop-embedded-terminal-pane clarifier to a tui hint.
|
||||
|
||||
Triggered by ``HERMES_DESKTOP_TERMINAL=1`` (set by ``main.cjs`` only on the
|
||||
shell env of the desktop's embedded TUI PTY — never on the chat backend).
|
||||
This is a runtime-surface qualifier, not a config override, so it lives at
|
||||
the resolution site rather than inside ``_resolve_platform_hint`` (which
|
||||
is purely the config-platform_hints override applier). Byte-stable for the
|
||||
cache: called once per session build, deterministically from env state.
|
||||
|
||||
Idempotent and empty-safe: re-applying on an already-augmented hint is a
|
||||
no-op, and an empty input returns empty (we never synthesize the
|
||||
clarifier without its tui framing).
|
||||
"""
|
||||
if not hint:
|
||||
return hint
|
||||
if _TUI_EMBEDDED_PANE_CLARIFIER in hint:
|
||||
return hint
|
||||
if not is_truthy_value(os.getenv("HERMES_DESKTOP_TERMINAL")):
|
||||
return hint
|
||||
return hint + _TUI_EMBEDDED_PANE_CLARIFIER
|
||||
|
||||
|
||||
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
|
||||
"""Assemble the system prompt as three ordered parts.
|
||||
|
||||
@@ -398,6 +430,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
pass
|
||||
|
||||
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
|
||||
if platform_key == "tui" and _effective_hint:
|
||||
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)
|
||||
if _effective_hint:
|
||||
stable_parts.append(_effective_hint)
|
||||
|
||||
|
||||
@@ -415,8 +415,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
)
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
block_message = resolve_pre_tool_block(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
@@ -1034,8 +1034,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
_block_error_type = "tool_scope_block"
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
_block_msg = get_pre_tool_call_block_message(
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
_block_msg = resolve_pre_tool_block(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Upload a Hermes session transcript to Hugging Face as an agent trace.
|
||||
|
||||
Hermes stores sessions in its own SQLite store (``hermes_state.SessionDB``),
|
||||
so we reconstruct the conversation and emit it in the **Claude Code JSONL**
|
||||
shape — one of the three formats the Hugging Face Agent Trace Viewer
|
||||
auto-detects (Claude Code / Codex / Pi). No dataset-side preprocessing is
|
||||
needed; the Hub tags the dataset ``agent-traces`` and opens it in the viewer.
|
||||
|
||||
Docs: https://huggingface.co/docs/hub/agent-traces
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* **Zero LLM turn.** This is a deterministic export — it never spends a
|
||||
model call. The ``hermes trace upload`` subcommand calls
|
||||
:func:`upload_session_trace` directly.
|
||||
* **Private by default.** Traces can contain prompts, tool output, local
|
||||
paths, and secrets. The dataset is created private and every text body
|
||||
is passed through Hermes' secret redactor (``force=True``) unless the
|
||||
caller explicitly opts out with ``redact=False``.
|
||||
* **Never raises.** Returns a user-facing status string so command
|
||||
handlers can echo it straight back to the user. Programmatic callers
|
||||
that need the URL can use :func:`build_trace_jsonl` + :func:`_do_upload`
|
||||
directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DATASET_NAME = "hermes-traces"
|
||||
_HERMES_VERSION = "hermes-agent"
|
||||
_REDACTION_BLOCKED_MESSAGE = (
|
||||
"Trace upload blocked: secret redaction failed, so the transcript may "
|
||||
"still contain credentials or other sensitive data. Fix the redactor or "
|
||||
"rerun with --no-redact only after manually reviewing the transcript."
|
||||
)
|
||||
|
||||
|
||||
class TraceRedactionError(RuntimeError):
|
||||
"""Raised when a trace cannot be safely redacted before upload."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversion: Hermes OpenAI-format messages -> Claude Code JSONL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
def _redact(text: Any, enabled: bool) -> Any:
|
||||
"""Redact secrets from a string body when redaction is enabled.
|
||||
|
||||
Non-strings pass through untouched. Uses Hermes' shared redactor with
|
||||
``force=True`` so an upload always scrubs known secret shapes even if
|
||||
the user disabled log redaction globally.
|
||||
"""
|
||||
if not enabled or not isinstance(text, str) or not text:
|
||||
return text
|
||||
try:
|
||||
from agent.redact import redact_sensitive_text
|
||||
return redact_sensitive_text(text, force=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Trace upload redaction failed; refusing upload", exc_info=True)
|
||||
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE) from exc
|
||||
|
||||
|
||||
def _content_to_blocks(content: Any, redact: bool) -> List[Dict[str, Any]]:
|
||||
"""Normalize a message ``content`` field into Anthropic content blocks."""
|
||||
if content is None:
|
||||
return []
|
||||
if isinstance(content, str):
|
||||
return [{"type": "text", "text": _redact(content, redact)}]
|
||||
if isinstance(content, list):
|
||||
blocks: List[Dict[str, Any]] = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
ptype = part.get("type")
|
||||
if ptype == "text":
|
||||
blocks.append({"type": "text", "text": _redact(part.get("text", ""), redact)})
|
||||
elif ptype in ("image_url", "image"):
|
||||
# Keep a placeholder; the viewer renders text turns and we
|
||||
# don't want to inline base64 blobs into a trace.
|
||||
blocks.append({"type": "text", "text": "[image omitted]"})
|
||||
else:
|
||||
blocks.append({"type": "text", "text": _redact(json.dumps(part), redact)})
|
||||
else:
|
||||
blocks.append({"type": "text", "text": _redact(str(part), redact)})
|
||||
return blocks
|
||||
return [{"type": "text", "text": _redact(json.dumps(content), redact)}]
|
||||
|
||||
|
||||
def _tool_calls_to_blocks(tool_calls: Any, redact: bool) -> List[Dict[str, Any]]:
|
||||
"""Convert OpenAI tool_calls into Anthropic ``tool_use`` content blocks."""
|
||||
blocks: List[Dict[str, Any]] = []
|
||||
if not isinstance(tool_calls, list):
|
||||
return blocks
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function") or {}
|
||||
name = fn.get("name") or tc.get("name") or "tool"
|
||||
raw_args = fn.get("arguments")
|
||||
if isinstance(raw_args, str):
|
||||
try:
|
||||
parsed = json.loads(raw_args) if raw_args.strip() else {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed = {"_raw": raw_args}
|
||||
elif isinstance(raw_args, dict):
|
||||
parsed = raw_args
|
||||
else:
|
||||
parsed = {}
|
||||
if redact:
|
||||
try:
|
||||
parsed = json.loads(_redact(json.dumps(parsed), redact))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
logger.warning("Trace upload redacted tool arguments are not valid JSON; refusing upload")
|
||||
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE)
|
||||
blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": tc.get("id") or f"toolu_{uuid.uuid4().hex[:16]}",
|
||||
"name": name,
|
||||
"input": parsed,
|
||||
})
|
||||
return blocks
|
||||
|
||||
|
||||
def build_trace_jsonl(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
session_id: str,
|
||||
model: str = "",
|
||||
cwd: str = "",
|
||||
redact: bool = True,
|
||||
) -> str:
|
||||
"""Render Hermes conversation messages as Claude Code JSONL text.
|
||||
|
||||
Each non-system message becomes one JSONL line in the Claude Code
|
||||
transcript shape the HF Agent Trace Viewer auto-detects:
|
||||
|
||||
* ``user`` / ``tool`` -> ``{"type": "user", "message": {...}}``
|
||||
* ``assistant`` -> ``{"type": "assistant", "message": {...}}``
|
||||
with ``content`` blocks (text + ``tool_use``).
|
||||
|
||||
Tool results are emitted as user turns carrying a ``tool_result``
|
||||
block keyed by ``tool_call_id`` — the same way Claude Code records
|
||||
them. Turns are linked via ``uuid`` / ``parentUuid``.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
parent: Optional[str] = None
|
||||
base_ts = _now_iso()
|
||||
git_branch = ""
|
||||
try:
|
||||
import subprocess
|
||||
if cwd:
|
||||
r = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True, text=True, timeout=3, cwd=cwd,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
git_branch = r.stdout.strip()
|
||||
except Exception:
|
||||
git_branch = ""
|
||||
|
||||
def _common(turn_uuid: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"parentUuid": parent,
|
||||
"isSidechain": False,
|
||||
"userType": "external",
|
||||
"cwd": cwd or os.getcwd(),
|
||||
"sessionId": session_id,
|
||||
"version": _HERMES_VERSION,
|
||||
"gitBranch": git_branch,
|
||||
"uuid": turn_uuid,
|
||||
"timestamp": base_ts,
|
||||
}
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
if role == "system":
|
||||
continue
|
||||
turn_uuid = str(uuid.uuid4())
|
||||
|
||||
if role == "assistant":
|
||||
blocks = _content_to_blocks(msg.get("content"), redact)
|
||||
blocks.extend(_tool_calls_to_blocks(msg.get("tool_calls"), redact))
|
||||
if not blocks:
|
||||
blocks = [{"type": "text", "text": ""}]
|
||||
entry = _common(turn_uuid)
|
||||
entry["type"] = "assistant"
|
||||
entry["message"] = {
|
||||
"role": "assistant",
|
||||
"model": model or "unknown",
|
||||
"content": blocks,
|
||||
}
|
||||
lines.append(json.dumps(entry, ensure_ascii=False))
|
||||
parent = turn_uuid
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
tool_use_id = msg.get("tool_call_id") or msg.get("tool_name") or "tool"
|
||||
result_content = _redact(
|
||||
msg.get("content") if isinstance(msg.get("content"), str)
|
||||
else json.dumps(msg.get("content")),
|
||||
redact,
|
||||
)
|
||||
entry = _common(turn_uuid)
|
||||
entry["type"] = "user"
|
||||
entry["message"] = {
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": result_content,
|
||||
}],
|
||||
}
|
||||
lines.append(json.dumps(entry, ensure_ascii=False))
|
||||
parent = turn_uuid
|
||||
continue
|
||||
|
||||
# Default: user (and any unknown role) -> user turn.
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
message_content: Any = _redact(content, redact)
|
||||
else:
|
||||
message_content = _content_to_blocks(content, redact)
|
||||
entry = _common(turn_uuid)
|
||||
entry["type"] = "user"
|
||||
entry["message"] = {"role": "user", "content": message_content}
|
||||
lines.append(json.dumps(entry, ensure_ascii=False))
|
||||
parent = turn_uuid
|
||||
|
||||
return "\n".join(lines) + ("\n" if lines else "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_hf_token() -> Optional[str]:
|
||||
"""Return the user's Hugging Face token from the usual env vars."""
|
||||
for var in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"):
|
||||
val = os.getenv(var)
|
||||
if val and val.strip():
|
||||
return val.strip()
|
||||
return None
|
||||
|
||||
|
||||
_NO_TOKEN_MESSAGE = (
|
||||
"Can't upload — no Hugging Face token is available. To set it up:\n"
|
||||
"\n"
|
||||
"1. Create a token with WRITE access at https://huggingface.co/settings/tokens\n"
|
||||
" (New token -> type \"Write\" -> copy it).\n"
|
||||
"2. Add it to your environment as HF_TOKEN (e.g. in ~/.hermes/.env):\n"
|
||||
" HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx\n"
|
||||
"3. Run /upload-trace again (or `hermes trace upload`)."
|
||||
)
|
||||
|
||||
|
||||
def _do_upload(
|
||||
jsonl: str,
|
||||
*,
|
||||
token: str,
|
||||
session_id: str,
|
||||
dataset_name: str = DEFAULT_DATASET_NAME,
|
||||
private: bool = True,
|
||||
) -> str:
|
||||
"""Create (idempotently) the private dataset and push the trace file.
|
||||
|
||||
Returns a user-facing status string. Never raises.
|
||||
"""
|
||||
try:
|
||||
from tools import lazy_deps
|
||||
lazy_deps.ensure("tool.trace_upload", prompt=False)
|
||||
except Exception:
|
||||
# lazy-install unavailable/declined — fall through to the import,
|
||||
# which surfaces the install hint below if the package is missing.
|
||||
pass
|
||||
try:
|
||||
from huggingface_hub import HfApi
|
||||
except ImportError:
|
||||
return ("Hugging Face upload needs the `huggingface_hub` package "
|
||||
"(`pip install huggingface_hub`).")
|
||||
|
||||
api = HfApi(token=token)
|
||||
try:
|
||||
who = api.whoami()
|
||||
user = who.get("name") if isinstance(who, dict) else None
|
||||
except Exception as e:
|
||||
logger.warning("HF whoami failed: %s", e)
|
||||
return ("Your Hugging Face token was rejected (whoami failed). "
|
||||
"Make sure it has WRITE access and isn't expired.")
|
||||
if not user:
|
||||
return "Could not resolve your Hugging Face username from the token."
|
||||
|
||||
repo_id = f"{user}/{dataset_name}"
|
||||
try:
|
||||
api.create_repo(
|
||||
repo_id=repo_id, repo_type="dataset", private=private, exist_ok=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("HF create_repo failed for %s: %s", repo_id, e)
|
||||
return f"Could not create/access dataset {repo_id}: {e}"
|
||||
|
||||
path_in_repo = f"sessions/{session_id}.jsonl"
|
||||
try:
|
||||
api.upload_file(
|
||||
path_or_fileobj=jsonl.encode("utf-8"),
|
||||
path_in_repo=path_in_repo,
|
||||
repo_id=repo_id,
|
||||
repo_type="dataset",
|
||||
commit_message=f"add session trace {session_id}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("HF upload_file failed for %s: %s", repo_id, e)
|
||||
return f"Upload to Hugging Face failed: {e}"
|
||||
|
||||
return (f"Uploaded -> https://huggingface.co/datasets/{repo_id}/blob/main/{path_in_repo}\n"
|
||||
f"View in the trace viewer: https://huggingface.co/datasets/{repo_id}")
|
||||
|
||||
|
||||
def load_session_messages(
|
||||
session_id: str, db_path=None
|
||||
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||||
"""Load a session's conversation + metadata from the SQLite store.
|
||||
|
||||
Returns ``(messages, meta)``. ``meta`` is ``{}`` when the session row is
|
||||
missing (messages may still be present for a live, untitled session).
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB(db_path=db_path) if db_path else SessionDB()
|
||||
resolved = db.resolve_session_id(session_id) or session_id
|
||||
meta = db.get_session(resolved) or {}
|
||||
messages = db.get_messages_as_conversation(resolved)
|
||||
return messages, meta
|
||||
|
||||
|
||||
def upload_session_trace(
|
||||
session_id: str,
|
||||
*,
|
||||
model: str = "",
|
||||
cwd: str = "",
|
||||
redact: bool = True,
|
||||
private: bool = True,
|
||||
dataset_name: str = DEFAULT_DATASET_NAME,
|
||||
db_path=None,
|
||||
token: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Top-level entry point used by the CLI/gateway/subcommand.
|
||||
|
||||
Loads the session, converts it to Claude Code JSONL, and uploads it to
|
||||
the user's private ``{user}/hermes-traces`` dataset. Returns a
|
||||
user-facing status string and never raises.
|
||||
"""
|
||||
if not session_id:
|
||||
return "No active session to upload."
|
||||
|
||||
token = token or _resolve_hf_token()
|
||||
if not token:
|
||||
return _NO_TOKEN_MESSAGE
|
||||
|
||||
try:
|
||||
messages, meta = load_session_messages(session_id, db_path=db_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load session %s for trace upload: %s", session_id, e)
|
||||
return f"Could not load session {session_id}: {e}"
|
||||
|
||||
if not messages:
|
||||
return "No transcript to upload for this session yet."
|
||||
|
||||
resolved_model = model or meta.get("model") or ""
|
||||
try:
|
||||
jsonl = build_trace_jsonl(
|
||||
messages,
|
||||
session_id=session_id,
|
||||
model=resolved_model,
|
||||
cwd=cwd,
|
||||
redact=redact,
|
||||
)
|
||||
except TraceRedactionError:
|
||||
return _REDACTION_BLOCKED_MESSAGE
|
||||
if not jsonl.strip():
|
||||
return "No transcript content to upload for this session."
|
||||
|
||||
return _do_upload(
|
||||
jsonl,
|
||||
token=token,
|
||||
session_id=session_id,
|
||||
dataset_name=dataset_name,
|
||||
private=private,
|
||||
)
|
||||
@@ -4167,17 +4167,15 @@ function installPreviewShortcut(window) {
|
||||
// survives reloads/restarts) rather than a main-process JSON file. The main
|
||||
// process owns setZoomLevel, so we mirror each change into localStorage and
|
||||
// read it back on did-finish-load to re-apply after reloads or crash recovery.
|
||||
const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
|
||||
|
||||
function clampZoomLevel(value) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(Math.max(value, -9), 9)
|
||||
}
|
||||
const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs')
|
||||
|
||||
function setAndPersistZoomLevel(window, zoomLevel) {
|
||||
if (!window || window.isDestroyed()) return
|
||||
const next = clampZoomLevel(zoomLevel)
|
||||
window.webContents.setZoomLevel(next)
|
||||
// Keep any open settings UI in sync, including changes made via the
|
||||
// keyboard shortcuts or the View menu.
|
||||
window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) })
|
||||
window.webContents
|
||||
.executeJavaScript(
|
||||
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
|
||||
@@ -6158,6 +6156,21 @@ ipcMain.handle('hermes:window:openNewSession', async () => {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// --- Text size (zoom) -------------------------------------------------------
|
||||
// The settings UI drives the same clamped zoom scale as the Ctrl/Cmd
|
||||
// shortcuts and the View menu. Reads and writes target the asking window.
|
||||
ipcMain.handle('hermes:zoom:get', event => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
const level = window && !window.isDestroyed() ? window.webContents.getZoomLevel() : 0
|
||||
|
||||
return { level, percent: zoomLevelToPercent(level) }
|
||||
})
|
||||
ipcMain.on('hermes:zoom:set-percent', (event, percent) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window || window.isDestroyed()) return
|
||||
setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent)))
|
||||
})
|
||||
|
||||
// --- Pet overlay (pop-out mascot) -----------------------------------------
|
||||
// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space
|
||||
// bounds (screen=false): convert to screen space by adding the main window's
|
||||
|
||||
@@ -78,6 +78,18 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
||||
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
|
||||
},
|
||||
zoom: {
|
||||
// Current zoom of this window, as { level, percent }.
|
||||
get: () => ipcRenderer.invoke('hermes:zoom:get'),
|
||||
setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent),
|
||||
// Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts,
|
||||
// so the settings UI can stay in sync with the keyboard.
|
||||
onChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:zoom:changed', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)
|
||||
}
|
||||
},
|
||||
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Pure helpers for window zoom. The main process owns webContents.setZoomLevel,
|
||||
* so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel
|
||||
* through this one clamped scale. Percent is the user-facing unit (100 = the
|
||||
* default size); Chromium's internal unit is the zoom level, where
|
||||
* factor = 1.2 ^ level.
|
||||
*/
|
||||
|
||||
const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
|
||||
|
||||
const ZOOM_FACTOR_BASE = 1.2
|
||||
const MIN_ZOOM_LEVEL = -9
|
||||
const MAX_ZOOM_LEVEL = 9
|
||||
|
||||
function clampZoomLevel(value) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
|
||||
}
|
||||
|
||||
function zoomLevelToPercent(level) {
|
||||
return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100)
|
||||
}
|
||||
|
||||
function percentToZoomLevel(percent) {
|
||||
if (!Number.isFinite(percent) || percent <= 0) return 0
|
||||
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ZOOM_STORAGE_KEY,
|
||||
clampZoomLevel,
|
||||
percentToZoomLevel,
|
||||
zoomLevelToPercent
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Unit tests for the pure zoom helpers: clamping garbage input, the
|
||||
* percent <-> zoom-level conversion the settings UI relies on, and the
|
||||
* roundtrip stability of the preset percentages.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { ZOOM_STORAGE_KEY, clampZoomLevel, percentToZoomLevel, zoomLevelToPercent } = require('./zoom.cjs')
|
||||
|
||||
test('storage key stays stable so persisted zoom survives upgrades', () => {
|
||||
assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')
|
||||
})
|
||||
|
||||
test('clampZoomLevel rejects garbage and enforces bounds', () => {
|
||||
assert.equal(clampZoomLevel(NaN), 0)
|
||||
assert.equal(clampZoomLevel(Infinity), 0)
|
||||
assert.equal(clampZoomLevel(undefined), 0)
|
||||
assert.equal(clampZoomLevel('2'), 0)
|
||||
assert.equal(clampZoomLevel(0.3), 0.3)
|
||||
assert.equal(clampZoomLevel(-42), -9)
|
||||
assert.equal(clampZoomLevel(42), 9)
|
||||
})
|
||||
|
||||
test('level 0 is exactly 100 percent', () => {
|
||||
assert.equal(zoomLevelToPercent(0), 100)
|
||||
assert.equal(percentToZoomLevel(100), 0)
|
||||
})
|
||||
|
||||
test('percentToZoomLevel rejects garbage', () => {
|
||||
assert.equal(percentToZoomLevel(NaN), 0)
|
||||
assert.equal(percentToZoomLevel(0), 0)
|
||||
assert.equal(percentToZoomLevel(-50), 0)
|
||||
assert.equal(percentToZoomLevel(undefined), 0)
|
||||
})
|
||||
|
||||
test('preset percentages roundtrip within rounding', () => {
|
||||
for (const percent of [90, 100, 110, 125, 150, 175]) {
|
||||
assert.equal(zoomLevelToPercent(percentToZoomLevel(percent)), percent)
|
||||
}
|
||||
})
|
||||
|
||||
test('conversion is monotonic across the preset range', () => {
|
||||
const levels = [90, 100, 110, 125, 150, 175].map(percentToZoomLevel)
|
||||
for (let i = 1; i < levels.length; i++) {
|
||||
assert.ok(levels[i] > levels[i - 1])
|
||||
}
|
||||
})
|
||||
|
||||
test('extreme percentages clamp to the level bounds', () => {
|
||||
assert.equal(percentToZoomLevel(1), -9)
|
||||
assert.equal(percentToZoomLevel(1_000_000), 9)
|
||||
})
|
||||
@@ -37,7 +37,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/zoom.test.cjs electron/windows-hermes-resolution.test.cjs electron/oauth-session-request.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -358,7 +358,10 @@ export function ChatView({
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
|
||||
return gateway.request<ModelOptionsResponse>('model.options', {
|
||||
session_id: activeSessionId,
|
||||
explicit_only: true
|
||||
})
|
||||
},
|
||||
enabled: gatewayOpen
|
||||
})
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { clearActiveSessionTodos } from '@/store/todos'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import { reportInstallMethodWarning } from '@/store/updates'
|
||||
import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
@@ -216,6 +217,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
requestDesktopOnboarding(payload.credential_warning)
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
reportInstallMethodWarning(payload?.install_warning)
|
||||
}
|
||||
|
||||
void refreshHermesConfig()
|
||||
|
||||
if (modelChanged || providerChanged) {
|
||||
|
||||
@@ -59,7 +59,9 @@ function Harness({
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
seedMessages,
|
||||
storedSessionId
|
||||
storedSessionId,
|
||||
activeSessionId,
|
||||
createBackendSessionForSend
|
||||
}: {
|
||||
busyRef?: MutableRefObject<boolean>
|
||||
onReady: (handle: HarnessHandle) => void
|
||||
@@ -70,8 +72,12 @@ function Harness({
|
||||
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||
seedMessages?: unknown[]
|
||||
storedSessionId?: null | string
|
||||
activeSessionId?: null | string
|
||||
createBackendSessionForSend?: () => Promise<null | string>
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = {
|
||||
current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId
|
||||
}
|
||||
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
|
||||
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
|
||||
@@ -87,11 +93,11 @@ function Harness({
|
||||
} as never)
|
||||
|
||||
const actions = usePromptActions({
|
||||
activeSessionId: RUNTIME_SESSION_ID,
|
||||
activeSessionId: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: async () => true,
|
||||
busyRef: localBusyRef,
|
||||
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
|
||||
createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID),
|
||||
handleSkinCommand: () => '',
|
||||
openMemoryGraph: openMemoryGraph ?? (() => undefined),
|
||||
refreshSessions,
|
||||
@@ -1084,7 +1090,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
expect(ok).toBe(true)
|
||||
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
|
||||
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
|
||||
})
|
||||
|
||||
@@ -1127,7 +1133,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
|
||||
expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
|
||||
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
|
||||
})
|
||||
|
||||
@@ -1190,6 +1196,124 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
expect(await handle!.submitText('message')).toBe(false)
|
||||
expect(calls).not.toContain('session.resume')
|
||||
})
|
||||
|
||||
it('recovers via session.resume when prompt.submit TIMES OUT and a stored session is selected (#55578)', async () => {
|
||||
// A starved gateway loop rejects with "request timed out: prompt.submit".
|
||||
// With a stored session selected, that must recover exactly like
|
||||
// "session not found" — resume + retry — not surface an error that leaves
|
||||
// activeSessionId null and lets the next send mint a new session.
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let submitAttempts = 0
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'prompt.submit') {
|
||||
submitAttempts += 1
|
||||
|
||||
if (submitAttempts === 1) {
|
||||
throw new Error('request timed out: prompt.submit')
|
||||
}
|
||||
|
||||
return {} as never
|
||||
}
|
||||
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
|
||||
const ok = await handle!.submitText('message during starved loop')
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||
expect(calls[2]?.params).toEqual({
|
||||
session_id: RECOVERED_SESSION_ID,
|
||||
text: 'message during starved loop'
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes the SELECTED stored session instead of minting a new one when activeSessionId is null (#55578 split)', async () => {
|
||||
// The exact split path from #55578 symptom (b): the runtime binding is
|
||||
// gone (orphan-reaped / cleared by a timeout) but a stored session is
|
||||
// still selected in the sidebar. A follow-up submit must continue that
|
||||
// conversation via session.resume — createBackendSessionForSend would
|
||||
// silently fork the user's chat in two.
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG')
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId={null}
|
||||
createBackendSessionForSend={createBackendSessionForSend}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
|
||||
const ok = await handle!.submitText('follow-up in the selected chat')
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(createBackendSessionForSend).not.toHaveBeenCalled()
|
||||
expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit'])
|
||||
expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||
expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID })
|
||||
})
|
||||
|
||||
it('still creates a new session for a genuine new-chat draft (no stored session selected)', async () => {
|
||||
const createBackendSessionForSend = vi.fn(async () => RUNTIME_SESSION_ID)
|
||||
const calls: string[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
calls.push(method)
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId={null}
|
||||
createBackendSessionForSend={createBackendSessionForSend}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={null}
|
||||
/>
|
||||
)
|
||||
|
||||
const ok = await handle!.submitText('first message of a new chat')
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(createBackendSessionForSend).toHaveBeenCalledTimes(1)
|
||||
expect(calls).not.toContain('session.resume')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions eager attachment upload (drop-time)', () => {
|
||||
|
||||
@@ -546,7 +546,8 @@ export function usePromptActions({
|
||||
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
|
||||
try {
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
session_id: selectedStoredSessionIdRef.current,
|
||||
source: 'desktop'
|
||||
})
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
inlineErrorMessage,
|
||||
isProviderSetupError,
|
||||
isSessionBusyError,
|
||||
isGatewayTimeoutError,
|
||||
isSessionNotFoundError,
|
||||
type SubmitTextOptions,
|
||||
withSessionBusyRetry
|
||||
@@ -213,6 +214,34 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
setMessages(current => [...current, buildUserMessage()])
|
||||
}
|
||||
|
||||
if (!sessionId && selectedStoredSessionIdRef.current) {
|
||||
// A stored session is SELECTED but its runtime binding is gone (the
|
||||
// live session was orphan-reaped, or a timeout/reconnect cleared
|
||||
// activeSessionId). Continuing the selected conversation must mean
|
||||
// resuming it — minting a brand-new backend session here silently
|
||||
// splits the user's chat in two (#55578 symptom b). Only fall through
|
||||
// to session creation when NO stored session is selected (a genuine
|
||||
// new-chat draft).
|
||||
try {
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
})
|
||||
|
||||
if (resumed?.session_id) {
|
||||
sessionId = resumed.session_id
|
||||
activeSessionIdRef.current = sessionId
|
||||
}
|
||||
} catch {
|
||||
// Resume failed (session gone from state.db, gateway hiccup) —
|
||||
// fall through to creating a fresh session rather than dead-ending
|
||||
// the user's message.
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
seedOptimistic(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
try {
|
||||
sessionId = await createBackendSessionForSend(visibleText)
|
||||
@@ -257,10 +286,18 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
)
|
||||
} catch (firstErr) {
|
||||
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
|
||||
if (
|
||||
(isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) &&
|
||||
selectedStoredSessionIdRef.current
|
||||
) {
|
||||
// Re-register the session in the gateway and get a fresh live ID.
|
||||
// Timeouts recover the same way as "session not found": a starved
|
||||
// backend loop (#55578 symptom d) rejects the submit even though
|
||||
// the stored session is fine — resume + retry instead of erroring
|
||||
// out and losing the session binding.
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
session_id: selectedStoredSessionIdRef.current,
|
||||
source: 'desktop'
|
||||
})
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
|
||||
@@ -52,6 +52,18 @@ export function isSessionNotFoundError(error: unknown): boolean {
|
||||
return /session not found/i.test(message)
|
||||
}
|
||||
|
||||
// Gateway JSON-RPC calls reject with "request timed out: <method>" when the
|
||||
// backend event loop is starved (e.g. a poller spin or a heavy async-injected
|
||||
// turn). For prompt.submit this is indistinguishable from a dead runtime
|
||||
// session on the client side — recovery must treat it like one (#55578):
|
||||
// resume the SELECTED stored session and retry, instead of surfacing an error
|
||||
// that leads to a null activeSessionId and a silently minted new session.
|
||||
export function isGatewayTimeoutError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return /request timed out/i.test(message)
|
||||
}
|
||||
|
||||
// The gateway refuses prompt.submit while a turn is running (4009 "session
|
||||
// busy"). It's a transient concurrency guard, never a user-facing error: a
|
||||
// submit racing the settle edge (or a rewind interrupting mid-turn) just waits
|
||||
|
||||
@@ -148,6 +148,12 @@ describe('createBackendSessionForSend profile routing', () => {
|
||||
expect(params).toMatchObject({ profile: 'default' })
|
||||
})
|
||||
|
||||
it('tags new desktop chats as desktop sessions', async () => {
|
||||
const params = await createWith(() => {})
|
||||
|
||||
expect(params).toMatchObject({ source: 'desktop' })
|
||||
})
|
||||
|
||||
it('passes the current workspace cwd into session.create', async () => {
|
||||
const params = await createWith(() => {
|
||||
$currentCwd.set('/remote/worktree')
|
||||
@@ -328,6 +334,7 @@ describe('resumeSession failure recovery', () => {
|
||||
|
||||
expect(resumeParams).not.toHaveProperty('lazy')
|
||||
expect(resumeParams).not.toHaveProperty('eager_build')
|
||||
expect(resumeParams).toMatchObject({ source: 'desktop' })
|
||||
})
|
||||
|
||||
it('arms the failure latch when resume succeeds with an empty transcript for a non-empty stored session', async () => {
|
||||
@@ -412,6 +419,78 @@ describe('resumeSession failure recovery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function BranchHarness({
|
||||
onReady,
|
||||
requestGateway
|
||||
}: {
|
||||
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
|
||||
|
||||
const actions = useSessionActions({
|
||||
activeSessionId: null,
|
||||
activeSessionIdRef: ref<string | null>(null),
|
||||
busyRef: ref(false),
|
||||
creatingSessionRef: ref(false),
|
||||
ensureSessionState: () => ({}) as ClientSessionState,
|
||||
getRouteToken: () => 'token',
|
||||
navigate: vi.fn() as never,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
|
||||
selectedStoredSessionId: null,
|
||||
selectedStoredSessionIdRef: ref<string | null>(null),
|
||||
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
|
||||
syncSessionStateToView: vi.fn(),
|
||||
updateSessionState: () => ({}) as ClientSessionState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onReady(actions.branchStoredSession)
|
||||
}, [actions.branchStoredSession, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('branchStoredSession desktop source tagging', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setSessions([])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('tags desktop branch sessions as desktop sessions', async () => {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
|
||||
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(branchStoredSession).not.toBeNull())
|
||||
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
expect(createParams).toMatchObject({
|
||||
parent_session_id: 'stored-parent',
|
||||
source: 'desktop'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
|
||||
// resumeSession's warm fast-path maps storedSessionId -> runtimeId -> cached
|
||||
// state. A reaped/respawned pooled backend re-mints runtime ids, so a recycled
|
||||
|
||||
@@ -176,6 +176,7 @@ export function useSessionActions({
|
||||
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
...(newChatProfile ? { profile: newChatProfile } : {}),
|
||||
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
|
||||
@@ -460,6 +461,7 @@ export function useSessionActions({
|
||||
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
// Watch windows attach lazily (live mirror). Every other cold resume
|
||||
// gets the gateway's default deferred build: the RPC returns the
|
||||
// transcript immediately instead of blocking the switch on _make_agent
|
||||
@@ -642,6 +644,7 @@ export function useSessionActions({
|
||||
// No title: the backend auto-names the branch from its parent's lineage.
|
||||
const branched = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
||||
...(parentStoredId && { parent_session_id: parentStoredId })
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
|
||||
|
||||
import type { ClientSessionState } from '../../../types'
|
||||
@@ -270,6 +270,8 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
|
||||
requestDesktopOnboarding(info.credential_warning)
|
||||
}
|
||||
|
||||
reportInstallMethodWarning(info.install_warning)
|
||||
|
||||
if (typeof info.model === 'string') {
|
||||
setCurrentModel(info.model)
|
||||
sessionState.model = info.model
|
||||
|
||||
@@ -16,6 +16,7 @@ import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedM
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { $translucency, setTranslucency } from '@/store/translucency'
|
||||
import { $zoomPercent, setZoomPercent } from '@/store/zoom'
|
||||
import { getBaseColors, useTheme } from '@/themes/context'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import type { DesktopTheme } from '@/themes/types'
|
||||
@@ -62,6 +63,18 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' })
|
||||
)
|
||||
}
|
||||
|
||||
// UI scale presets, as zoom percentages. 100 is the browser-default size;
|
||||
// the ids double as the percent values sent to the main process. A Cmd/Ctrl
|
||||
// +/- step landing between presets highlights nothing, and the row
|
||||
// description keeps showing the exact current percent.
|
||||
const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const
|
||||
|
||||
type UiScalePreset = (typeof UI_SCALE_PRESETS)[number]
|
||||
|
||||
function matchUiScalePreset(percent: number): UiScalePreset | null {
|
||||
return UI_SCALE_PRESETS.find(preset => Number(preset) === percent) ?? null
|
||||
}
|
||||
|
||||
function useDebounced<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
@@ -231,6 +244,7 @@ export function AppearanceSettings() {
|
||||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const zoomPercent = useStore($zoomPercent)
|
||||
const embedMode = useStore($embedMode)
|
||||
const embedAllowed = useStore($embedAllowed)
|
||||
const translucency = useStore($translucency)
|
||||
@@ -277,6 +291,10 @@ export function AppearanceSettings() {
|
||||
{ id: 'off', label: a.embedsOff }
|
||||
] as const satisfies readonly { id: EmbedMode; label: string }[]
|
||||
|
||||
const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` }))
|
||||
|
||||
const matchedScalePreset = matchUiScalePreset(zoomPercent)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div>
|
||||
@@ -392,6 +410,21 @@ export function AppearanceSettings() {
|
||||
wide
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('selection')
|
||||
setZoomPercent(Number(id))
|
||||
}}
|
||||
options={uiScaleOptions}
|
||||
value={matchedScalePreset ?? ('' as UiScalePreset)}
|
||||
/>
|
||||
}
|
||||
description={a.uiScaleDesc(zoomPercent)}
|
||||
title={a.uiScaleTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -13,8 +13,10 @@ beforeAll(() => {
|
||||
const getGlobalModelInfo = vi.fn()
|
||||
const getGlobalModelOptions = vi.fn()
|
||||
const getAuxiliaryModels = vi.fn()
|
||||
const getMoaModels = vi.fn()
|
||||
const setModelAssignment = vi.fn()
|
||||
const getRecommendedDefaultModel = vi.fn()
|
||||
const saveMoaModels = vi.fn()
|
||||
const setEnvVar = vi.fn()
|
||||
const getHermesConfigRecord = vi.fn()
|
||||
const saveHermesConfig = vi.fn()
|
||||
@@ -24,8 +26,10 @@ vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: () => getGlobalModelInfo(),
|
||||
getGlobalModelOptions: () => getGlobalModelOptions(),
|
||||
getAuxiliaryModels: () => getAuxiliaryModels(),
|
||||
getMoaModels: () => getMoaModels(),
|
||||
setModelAssignment: (body: unknown) => setModelAssignment(body),
|
||||
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
|
||||
saveMoaModels: (body: unknown) => saveMoaModels(body),
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
|
||||
getHermesConfigRecord: () => getHermesConfigRecord(),
|
||||
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
|
||||
@@ -45,15 +49,6 @@ beforeEach(() => {
|
||||
models: ['hermes-4', 'hermes-4-mini'],
|
||||
authenticated: true,
|
||||
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
|
||||
},
|
||||
// An unconfigured api_key provider — surfaced by the full-universe payload.
|
||||
{
|
||||
name: 'DeepSeek',
|
||||
slug: 'deepseek',
|
||||
models: [],
|
||||
authenticated: false,
|
||||
auth_type: 'api_key',
|
||||
key_env: 'DEEPSEEK_API_KEY'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -61,8 +56,9 @@ beforeEach(() => {
|
||||
main: { provider: 'nous', model: 'hermes-4' },
|
||||
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
|
||||
})
|
||||
getMoaModels.mockResolvedValue(null)
|
||||
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
|
||||
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
|
||||
getRecommendedDefaultModel.mockResolvedValue({ provider: 'nous', model: 'hermes-4', free_tier: null })
|
||||
setEnvVar.mockResolvedValue({ ok: true })
|
||||
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
|
||||
saveHermesConfig.mockResolvedValue({ ok: true })
|
||||
@@ -80,43 +76,19 @@ async function renderModelSettings() {
|
||||
}
|
||||
|
||||
describe('ModelSettings', () => {
|
||||
it('loads the current main model and lists the full provider universe', async () => {
|
||||
it('loads the current main model and lists configured providers only', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
|
||||
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
|
||||
|
||||
// Open the provider Select — every provider from the full payload should be
|
||||
// listed, including the unconfigured one with its "set up" hint.
|
||||
// Open the provider Select — only configured providers should be listed.
|
||||
const triggers = await screen.findAllByRole('combobox')
|
||||
fireEvent.click(triggers[0])
|
||||
|
||||
// "Nous" shows in both the trigger and the open list; the unconfigured
|
||||
// provider + its setup hint are the unique signal of the full universe.
|
||||
// "Nous" shows in both the trigger and the open list.
|
||||
expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0)
|
||||
expect(await screen.findByText(/DeepSeek/)).toBeTruthy()
|
||||
expect(await screen.findByText(/set up/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('activates an unconfigured api_key provider inline by saving its key', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
|
||||
|
||||
// Open the provider Select and pick the unconfigured provider.
|
||||
const triggers = screen.getAllByRole('combobox')
|
||||
fireEvent.click(triggers[0])
|
||||
const deepseekOption = await screen.findByText(/DeepSeek/)
|
||||
fireEvent.click(deepseekOption)
|
||||
|
||||
// The inline key input appears for an api_key provider that needs setup.
|
||||
const keyInput = await screen.findByPlaceholderText(/Paste DEEPSEEK_API_KEY/)
|
||||
fireEvent.change(keyInput, { target: { value: 'sk-test-123' } })
|
||||
|
||||
const activate = await screen.findByRole('button', { name: /Activate/ })
|
||||
fireEvent.click(activate)
|
||||
|
||||
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
|
||||
expect(screen.queryByText(/DeepSeek/)).toBeNull()
|
||||
})
|
||||
|
||||
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
|
||||
|
||||
@@ -45,7 +45,10 @@ export function ModelVisibilityDialog({
|
||||
queryKey: ['model-options', sessionId || 'global'],
|
||||
queryFn: (): Promise<ModelOptionsResponse> => {
|
||||
if (gw && sessionId) {
|
||||
return gw.request<ModelOptionsResponse>('model.options', { session_id: sessionId })
|
||||
return gw.request<ModelOptionsResponse>('model.options', {
|
||||
session_id: sessionId,
|
||||
explicit_only: true
|
||||
})
|
||||
}
|
||||
|
||||
return getGlobalModelOptions()
|
||||
|
||||
@@ -237,7 +237,7 @@ function ConfirmingModelPanel({
|
||||
// shows the same $/Mtok + Free/Pro info the picker and CLI do.
|
||||
const options = useQuery({
|
||||
queryKey: ['onboarding-model-options', flow.providerSlug],
|
||||
queryFn: () => getGlobalModelOptions()
|
||||
queryFn: () => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })
|
||||
})
|
||||
|
||||
const providerRow = options.data?.providers?.find(
|
||||
|
||||
@@ -99,7 +99,7 @@ function useApiKeyCatalog(): ApiKeyOption[] {
|
||||
// Promise.resolve().then so a synchronous throw (e.g. no desktop bridge in
|
||||
// tests) is funneled into the same .catch instead of escaping.
|
||||
void Promise.resolve()
|
||||
.then(() => getGlobalModelOptions())
|
||||
.then(() => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false }))
|
||||
.then(res => {
|
||||
if (!cancelled) {
|
||||
setRows(res.providers ?? [])
|
||||
|
||||
Vendored
+5
@@ -89,6 +89,11 @@ declare global {
|
||||
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
|
||||
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
|
||||
}
|
||||
zoom?: {
|
||||
get: () => Promise<{ level: number; percent: number }>
|
||||
setPercent: (percent: number) => void
|
||||
onChanged: (callback: (payload: { level: number; percent: number }) => void) => () => void
|
||||
}
|
||||
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
|
||||
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
|
||||
readDir: (path: string) => Promise<HermesReadDirResult>
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('Hermes REST session helpers', () => {
|
||||
[getHermesConfig, '/api/config'],
|
||||
[getHermesConfigDefaults, '/api/config/defaults'],
|
||||
[getGlobalModelInfo, '/api/model/info'],
|
||||
[() => getGlobalModelOptions(), '/api/model/options'],
|
||||
[() => getGlobalModelOptions(), '/api/model/options?explicit_only=1'],
|
||||
[getCronJobs, '/api/cron/jobs']
|
||||
]
|
||||
|
||||
@@ -134,4 +134,24 @@ describe('Hermes REST session helpers', () => {
|
||||
profile: 'xiaoxuxu'
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults model options to configured providers only', async () => {
|
||||
await getGlobalModelOptions()
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/api/model/options?explicit_only=1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('can opt into unconfigured providers for onboarding flows', async () => {
|
||||
await getGlobalModelOptions({ includeUnconfigured: true, refresh: true, explicitOnly: false })
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/api/model/options?refresh=1&include_unconfigured=1'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -869,10 +869,28 @@ export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise<ModelOptionsResponse> {
|
||||
export function getGlobalModelOptions(opts?: {
|
||||
refresh?: boolean
|
||||
includeUnconfigured?: boolean
|
||||
explicitOnly?: boolean
|
||||
}): Promise<ModelOptionsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (opts?.refresh) {
|
||||
params.set('refresh', '1')
|
||||
}
|
||||
|
||||
if (opts?.includeUnconfigured) {
|
||||
params.set('include_unconfigured', '1')
|
||||
}
|
||||
|
||||
if (opts?.explicitOnly !== false) {
|
||||
params.set('explicit_only', '1')
|
||||
}
|
||||
|
||||
return window.hermesDesktop.api<ModelOptionsResponse>({
|
||||
...profileScoped(),
|
||||
path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options',
|
||||
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ export const en: Translations = {
|
||||
backendOutOfDateTitle: 'Backend out of date',
|
||||
backendOutOfDateMessage:
|
||||
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
||||
installMethodUnsupportedTitle: 'Unsupported install method',
|
||||
updateHermes: 'Update Hermes',
|
||||
updateReadyTitle: 'Update ready',
|
||||
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
|
||||
@@ -381,6 +382,9 @@ export const en: Translations = {
|
||||
colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.',
|
||||
toolViewTitle: 'Tool Call Display',
|
||||
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
|
||||
uiScaleTitle: 'UI Scale',
|
||||
uiScaleDesc: (percent: number) =>
|
||||
`Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`,
|
||||
translucencyTitle: 'Window Translucency',
|
||||
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
|
||||
embedsTitle: 'Inline Embeds',
|
||||
|
||||
@@ -120,6 +120,7 @@ export const ja = defineLocale({
|
||||
backendOutOfDateTitle: 'バックエンドが古いです',
|
||||
backendOutOfDateMessage:
|
||||
'Hermes バックエンドがこのデスクトップビルドより古く、正常に動作しない場合があります。更新して揃えてください。',
|
||||
installMethodUnsupportedTitle: 'サポート対象外のインストール方法',
|
||||
updateHermes: 'Hermes を更新',
|
||||
updateReadyTitle: '更新の準備ができました',
|
||||
updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`,
|
||||
@@ -287,6 +288,9 @@ export const ja = defineLocale({
|
||||
colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。',
|
||||
toolViewTitle: 'ツール呼び出しの表示',
|
||||
toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',
|
||||
uiScaleTitle: 'UI スケール',
|
||||
uiScaleDesc: (percent: number) =>
|
||||
`アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`,
|
||||
translucencyTitle: 'ウィンドウの透過',
|
||||
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
|
||||
embedsTitle: 'インライン埋め込み',
|
||||
|
||||
@@ -161,6 +161,7 @@ export interface Translations {
|
||||
copyDetailFailed: string
|
||||
backendOutOfDateTitle: string
|
||||
backendOutOfDateMessage: string
|
||||
installMethodUnsupportedTitle: string
|
||||
updateHermes: string
|
||||
updateReadyTitle: string
|
||||
updateReadyMessage: (count: number) => string
|
||||
@@ -302,6 +303,8 @@ export interface Translations {
|
||||
colorModeDesc: string
|
||||
toolViewTitle: string
|
||||
toolViewDesc: string
|
||||
uiScaleTitle: string
|
||||
uiScaleDesc: (percent: number) => string
|
||||
translucencyTitle: string
|
||||
translucencyDesc: string
|
||||
embedsTitle: string
|
||||
|
||||
@@ -116,6 +116,7 @@ export const zhHant = defineLocale({
|
||||
copyDetailFailed: '無法複製通知詳情',
|
||||
backendOutOfDateTitle: '後端版本過舊',
|
||||
backendOutOfDateMessage: '您的 Hermes 後端早於目前的桌面版本,可能無法正常運作。請更新以保持一致。',
|
||||
installMethodUnsupportedTitle: '不受支援的安裝方式',
|
||||
updateHermes: '更新 Hermes',
|
||||
updateReadyTitle: '有可用更新',
|
||||
updateReadyMessage: count => `有 ${count} 項新變更可用。`,
|
||||
@@ -279,6 +280,8 @@ export const zhHant = defineLocale({
|
||||
colorModeDesc: '選擇固定模式,或讓 Hermes 跟隨系統設定。',
|
||||
toolViewTitle: '工具呼叫顯示',
|
||||
toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。',
|
||||
uiScaleTitle: '介面縮放',
|
||||
uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`,
|
||||
translucencyTitle: '視窗透明',
|
||||
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
|
||||
embedsTitle: '內嵌預覽',
|
||||
|
||||
@@ -116,6 +116,7 @@ export const zh: Translations = {
|
||||
copyDetailFailed: '无法复制通知详情',
|
||||
backendOutOfDateTitle: '后端版本过旧',
|
||||
backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。',
|
||||
installMethodUnsupportedTitle: '不受支持的安装方式',
|
||||
updateHermes: '更新 Hermes',
|
||||
updateReadyTitle: '有可用更新',
|
||||
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
|
||||
@@ -370,6 +371,8 @@ export const zh: Translations = {
|
||||
colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。',
|
||||
toolViewTitle: '工具调用显示',
|
||||
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
|
||||
uiScaleTitle: '界面缩放',
|
||||
uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`,
|
||||
translucencyTitle: '窗口透明',
|
||||
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
|
||||
embedsTitle: '内嵌预览',
|
||||
|
||||
@@ -51,6 +51,7 @@ export type GatewayEventPayload = {
|
||||
cwd?: string
|
||||
branch?: string
|
||||
credential_warning?: string
|
||||
install_warning?: string
|
||||
personality?: string
|
||||
usage?: Partial<UsageStats>
|
||||
// agent.terminal.output — live chunk for a read-only agent terminal tab
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { getGlobalModelOptions, type HermesGateway, type ModelOptionsResponse } from '@/hermes'
|
||||
|
||||
interface ModelOptionsRequest {
|
||||
/** When false, include ambient/unconfigured providers (onboarding/setup
|
||||
* surfaces). Chat pickers default to true so only explicitly configured
|
||||
* providers are listed (#56974). */
|
||||
explicitOnly?: boolean
|
||||
gateway?: HermesGateway
|
||||
refresh?: boolean
|
||||
sessionId?: null | string
|
||||
}
|
||||
|
||||
export function requestModelOptions({
|
||||
explicitOnly = true,
|
||||
gateway,
|
||||
refresh = false,
|
||||
sessionId
|
||||
@@ -22,8 +27,12 @@ export function requestModelOptions({
|
||||
params.refresh = true
|
||||
}
|
||||
|
||||
if (explicitOnly) {
|
||||
params.explicit_only = true
|
||||
}
|
||||
|
||||
return gateway.request<ModelOptionsResponse>('model.options', params)
|
||||
}
|
||||
|
||||
return getGlobalModelOptions(refresh ? { refresh: true } : undefined)
|
||||
return getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) })
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ async function fetchProviderDefaultModel(
|
||||
let options
|
||||
|
||||
try {
|
||||
options = await getGlobalModelOptions()
|
||||
options = await getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -111,6 +111,24 @@ function isSkewToastSnoozed(): boolean {
|
||||
return Number.isFinite(until) && Date.now() < until
|
||||
}
|
||||
|
||||
const INSTALL_METHOD_TOAST_ID = 'install-method-not-supported'
|
||||
// Same time-based snooze pattern as the update/skew toasts: the warning is
|
||||
// re-derived from every session.info (session.create/resume/activate all
|
||||
// route through applyRuntimeInfo), so without a snooze it would re-pop on
|
||||
// every session switch even right after the user dismissed it.
|
||||
const INSTALL_METHOD_TOAST_SNOOZE_KEY = 'hermes:install-method-toast-snooze-until'
|
||||
const INSTALL_METHOD_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function snoozeInstallMethodToast(): void {
|
||||
persistString(INSTALL_METHOD_TOAST_SNOOZE_KEY, String(Date.now() + INSTALL_METHOD_TOAST_COOLDOWN_MS))
|
||||
}
|
||||
|
||||
function isInstallMethodToastSnoozed(): boolean {
|
||||
const until = Number(storedString(INSTALL_METHOD_TOAST_SNOOZE_KEY) || 0)
|
||||
|
||||
return Number.isFinite(until) && Date.now() < until
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard against a desktop GUI talking to a backend that predates its contract
|
||||
* (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing
|
||||
@@ -151,6 +169,27 @@ export function reportBackendContract(contract: number | undefined): void {
|
||||
})
|
||||
}
|
||||
|
||||
export function reportInstallMethodWarning(message: string | undefined): void {
|
||||
if (!message) {
|
||||
dismissNotification(INSTALL_METHOD_TOAST_ID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isInstallMethodToastSnoozed()) {
|
||||
return
|
||||
}
|
||||
|
||||
notify({
|
||||
durationMs: 0,
|
||||
id: INSTALL_METHOD_TOAST_ID,
|
||||
kind: 'warning',
|
||||
message,
|
||||
onDismiss: () => snoozeInstallMethodToast(),
|
||||
title: translateNow('notifications.installMethodUnsupportedTitle')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a toast when an update is available, at most once per cooldown window.
|
||||
* Closing the toast — dismissing it or opening the updates window from it —
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Window text size (zoom).
|
||||
*
|
||||
* The main process owns the zoom level and persists it (see electron/zoom.cjs
|
||||
* for the scale). The renderer only mirrors the current percent for the
|
||||
* settings UI: preset clicks go to the main process over IPC, and every
|
||||
* change comes back through onChanged, including ones made with the
|
||||
* Ctrl/Cmd +/-/0 shortcuts or the View menu, so the UI never drifts.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
export const $zoomPercent = atom<number>(100)
|
||||
|
||||
export function setZoomPercent(percent: number): void {
|
||||
window.hermesDesktop?.zoom?.setPercent(percent)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.hermesDesktop?.zoom) {
|
||||
void window.hermesDesktop.zoom.get().then(({ percent }) => $zoomPercent.set(percent))
|
||||
window.hermesDesktop.zoom.onChanged(({ percent }) => $zoomPercent.set(percent))
|
||||
}
|
||||
@@ -399,6 +399,7 @@ export interface SessionRuntimeInfo {
|
||||
cwd?: string
|
||||
desktop_contract?: number
|
||||
fast?: boolean
|
||||
install_warning?: string
|
||||
model?: string
|
||||
personality?: string
|
||||
provider?: string
|
||||
|
||||
@@ -846,6 +846,11 @@ platform_toolsets:
|
||||
# priority_mode: prepend
|
||||
# priority:
|
||||
# - my_plugin_command
|
||||
# webhook:
|
||||
# extra:
|
||||
# # Route scripts default to a 30 second timeout. Scripts must live under
|
||||
# # the active profile's scripts directory and receive webhook JSON on stdin.
|
||||
# script_timeout_seconds: 30
|
||||
#
|
||||
# Discord-specific settings (config.yaml top-level, not under platforms:):
|
||||
#
|
||||
|
||||
@@ -12745,19 +12745,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Could not persist active CLI session before close: %s", e)
|
||||
|
||||
def _print_exit_summary(self):
|
||||
"""Print session resume info on exit, similar to Claude Code."""
|
||||
# Clear the screen + scrollback before printing the summary so the
|
||||
# live bottom chrome (status bar, input box, separator rules) and the
|
||||
# rest of the session transcript don't get stranded above the exit
|
||||
# summary (#38252). By this point app.run() has returned and
|
||||
# prompt_toolkit has restored terminal modes, so writing raw escapes
|
||||
# to stdout is safe. ESC[3J clears scrollback, ESC[2J clears the
|
||||
# visible screen, ESC[H homes the cursor — so the summary prints at a
|
||||
# clean top-left. Falls back to the platform clear command if stdout
|
||||
# isn't a TTY-capable stream. Honors NO_COLOR/dumb terminals by
|
||||
# skipping silently when there's no real console.
|
||||
self._clear_terminal_on_exit()
|
||||
def _print_exit_summary(self, clear_screen: bool = True):
|
||||
"""Print session resume info on exit, similar to Claude Code.
|
||||
|
||||
Args:
|
||||
clear_screen: When True (default), clear the terminal screen and
|
||||
scrollback before printing the summary. This is appropriate for
|
||||
interactive TUI teardown (#38252). Single-query (-q) mode should
|
||||
pass False to preserve the printed answer (#53009).
|
||||
"""
|
||||
if clear_screen:
|
||||
# Clear the screen + scrollback before printing the summary so the
|
||||
# live bottom chrome (status bar, input box, separator rules) and the
|
||||
# rest of the session transcript don't get stranded above the exit
|
||||
# summary (#38252). By this point app.run() has returned and
|
||||
# prompt_toolkit has restored terminal modes, so writing raw escapes
|
||||
# to stdout is safe. ESC[3J clears scrollback, ESC[2J clears the
|
||||
# visible screen, ESC[H homes the cursor — so the summary prints at a
|
||||
# clean top-left. Falls back to the platform clear command if stdout
|
||||
# isn't a TTY-capable stream. Honors NO_COLOR/dumb terminals by
|
||||
# skipping silently when there's no real console.
|
||||
self._clear_terminal_on_exit()
|
||||
print()
|
||||
msg_count = len(self.conversation_history)
|
||||
if msg_count > 0:
|
||||
@@ -15082,7 +15090,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
# and watch pattern matches) while agent is idle.
|
||||
try:
|
||||
from tools.process_registry import process_registry
|
||||
for _evt, _synth in process_registry.drain_notifications():
|
||||
from tools.approval import get_current_session_key
|
||||
_drain_sk = get_current_session_key(default="")
|
||||
for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk):
|
||||
self._pending_input.put(_synth)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -16169,7 +16179,7 @@ def main(
|
||||
# banner, doesn't depend on the welcome banner being shown.
|
||||
cli._show_security_advisories()
|
||||
cli.chat(query, images=single_query_images or None)
|
||||
cli._print_exit_summary()
|
||||
cli._print_exit_summary(clear_screen=False)
|
||||
finally:
|
||||
_finalize_single_query(cli)
|
||||
return
|
||||
|
||||
+58
-3
@@ -84,6 +84,15 @@ TICKER_INTERVAL_SECONDS = 60
|
||||
# concurrent mark_job_run / advance_next_run calls can clobber each other.
|
||||
_jobs_file_lock = threading.RLock()
|
||||
_jobs_lock_state = threading.local()
|
||||
|
||||
# Upper bound on waiting for the cross-process .jobs.lock flock (#60703).
|
||||
# Every cron function in the process funnels through _jobs_lock(), and the
|
||||
# flock is taken while holding the process-wide RLock — so an unbounded wait
|
||||
# on a lock held by a wedged sibling process silently freezes the ticker
|
||||
# heartbeat and every job forever. 30s is orders of magnitude above any
|
||||
# legitimate critical section (field updates only) while keeping the ticker's
|
||||
# worst-case stall well under one status-alarm threshold.
|
||||
_JOBS_LOCK_TIMEOUT_SECONDS = 30.0
|
||||
OUTPUT_DIR = CRON_DIR / "output"
|
||||
ONESHOT_GRACE_SECONDS = 120
|
||||
|
||||
@@ -177,7 +186,42 @@ def _jobs_lock():
|
||||
lock_fd = open(_jobs_lock_file(), "a+", encoding="utf-8")
|
||||
lock_fd.seek(0)
|
||||
if fcntl is not None:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
# Bounded acquisition (#60703): a plain blocking
|
||||
# fcntl.flock(LOCK_EX) here has NO timeout, and it is
|
||||
# taken while holding the process-wide _jobs_file_lock
|
||||
# RLock above. If another process wedges while holding
|
||||
# .jobs.lock (e.g. an old gateway draining through a
|
||||
# restart), a single blocked acquirer freezes EVERY cron
|
||||
# function in this process — including the ticker's
|
||||
# get_due_jobs() — silently and forever: the heartbeat
|
||||
# file stops updating and all jobs stop firing with no
|
||||
# error logged. Poll LOCK_NB against a deadline instead;
|
||||
# on timeout, log loudly and fall through to the same
|
||||
# in-process-only degraded mode used when locking is
|
||||
# unavailable. A briefly-torn cross-process write is
|
||||
# strictly better than a permanently dead scheduler.
|
||||
_deadline = time.monotonic() + _JOBS_LOCK_TIMEOUT_SECONDS
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except (OSError, IOError):
|
||||
if time.monotonic() >= _deadline:
|
||||
logger.error(
|
||||
"Timed out after %.0fs waiting for the cron "
|
||||
"jobs lock (%s) — another process is holding "
|
||||
"it. Proceeding with in-process locking only "
|
||||
"so the scheduler stays alive (#60703).",
|
||||
_JOBS_LOCK_TIMEOUT_SECONDS,
|
||||
_jobs_lock_file(),
|
||||
)
|
||||
try:
|
||||
lock_fd.close()
|
||||
except OSError:
|
||||
pass
|
||||
lock_fd = None
|
||||
break
|
||||
time.sleep(0.1)
|
||||
elif msvcrt is not None:
|
||||
getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_LOCK"), 1)
|
||||
except (OSError, IOError) as e:
|
||||
@@ -1565,7 +1609,14 @@ def claim_job_for_fire(job_id: str, *, claim_ttl_seconds: int = 300) -> bool:
|
||||
if existing:
|
||||
try:
|
||||
claimed_at = _ensure_aware(datetime.fromisoformat(existing["at"]))
|
||||
if (now - claimed_at).total_seconds() < claim_ttl_seconds:
|
||||
# Bounded on BOTH sides (#60703): a claim stamped in the
|
||||
# future (clock/TZ skew across a restart, or a corrupted
|
||||
# timestamp) would otherwise have a negative age and stay
|
||||
# "fresh" forever — the job becomes permanently unfireable
|
||||
# and every manual `cron run` reports "already being
|
||||
# fired". Treat future-dated claims as stale/overwritable.
|
||||
_age = (now - claimed_at).total_seconds()
|
||||
if 0 <= _age < claim_ttl_seconds:
|
||||
return False # someone holds a fresh claim
|
||||
except Exception:
|
||||
pass # malformed claim → overwrite
|
||||
@@ -1626,7 +1677,11 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]:
|
||||
claimed_at = _ensure_aware(
|
||||
datetime.fromisoformat(existing_claim["at"])
|
||||
)
|
||||
if (now - claimed_at).total_seconds() < _run_claim_ttl:
|
||||
# 0 <= age: a future-dated claim (clock/TZ skew across a
|
||||
# restart) must be treated as stale, not eternally fresh,
|
||||
# or the one-shot is skipped forever (#60703).
|
||||
_age = (now - claimed_at).total_seconds()
|
||||
if 0 <= _age < _run_claim_ttl:
|
||||
continue # a fresh claim is held by an in-flight run
|
||||
except (KeyError, ValueError, TypeError):
|
||||
pass # malformed claim → fall through and (re)claim
|
||||
|
||||
+114
-2
@@ -298,6 +298,102 @@ _parallel_pool_max_workers: Optional[int] = None
|
||||
_running_job_ids: set = set()
|
||||
_running_lock = threading.Lock()
|
||||
|
||||
# Job IDs the gateway shutdown path force-killed the tool subprocess of
|
||||
# while still in ``_running_job_ids`` (see ``mark_running_jobs_interrupted``
|
||||
# below). ``run_one_job``'s own completion path checks this set before
|
||||
# writing its own ``last_status`` so a cron agent thread that keeps running
|
||||
# in-process after its tool was killed out from under it — and produces a
|
||||
# plausible-looking final response from truncated output — can never
|
||||
# overwrite the interrupted status with a false "ok" (#60432).
|
||||
_interrupted_job_ids: set = set()
|
||||
|
||||
|
||||
def get_running_job_ids() -> "frozenset[str]":
|
||||
"""Thread-safe snapshot of cron job IDs currently executing.
|
||||
|
||||
A job ID is a member from the moment ``_submit_with_guard`` dispatches
|
||||
it onto the parallel/sequential pool until ``_process_job`` returns —
|
||||
i.e. for the job's *entire* run, tool calls included, not just the
|
||||
ticker's dispatch instant.
|
||||
|
||||
The gateway shutdown path (``gateway/run.py::GatewayRunner.
|
||||
_drain_active_agents``) reads this to treat in-flight cron work as
|
||||
active the same way it already treats in-flight chat sessions via
|
||||
``_running_agents`` — cron jobs run through their own thread pool here,
|
||||
entirely outside that dict, so without this the drain is structurally
|
||||
blind to them (#60432).
|
||||
"""
|
||||
with _running_lock:
|
||||
return frozenset(_running_job_ids)
|
||||
|
||||
|
||||
def mark_running_jobs_interrupted(reason: str) -> list:
|
||||
"""Best-effort: mark every currently in-flight cron job interrupted.
|
||||
|
||||
Called by the gateway shutdown path immediately after it force-kills
|
||||
tool subprocesses (``process_registry.kill_all()``). A job whose tool
|
||||
subprocess was just killed out from under it must never be allowed to
|
||||
report success — even though its agent thread is still alive in this
|
||||
same process and may go on to produce a plausible-looking final
|
||||
response from the now-truncated tool output.
|
||||
|
||||
Records the job IDs in ``_interrupted_job_ids`` BEFORE writing
|
||||
``last_status`` so ``run_one_job``'s own eventual completion for the
|
||||
same job (racing in its own thread) sees the flag and skips its normal
|
||||
write instead of clobbering this one — see the check near the end of
|
||||
``run_one_job``. This does not attempt to correlate the killed
|
||||
subprocess PID to a specific job ID (the process registry tracks PIDs,
|
||||
not cron job IDs); any job still dispatched at the moment of a forced
|
||||
kill is treated as interrupted, matching the coarser precedent already
|
||||
set by ``GatewayRunner._interrupt_running_agents``, which interrupts
|
||||
every entry in ``_running_agents`` on a drain timeout without
|
||||
per-agent correlation either.
|
||||
|
||||
Returns the list of job IDs marked, for the caller to log.
|
||||
"""
|
||||
with _running_lock:
|
||||
job_ids = list(_running_job_ids)
|
||||
_interrupted_job_ids.update(job_ids)
|
||||
marked = []
|
||||
for job_id in job_ids:
|
||||
try:
|
||||
mark_job_run(job_id, False, reason)
|
||||
marked.append(job_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to mark job %s interrupted: %s", job_id, e)
|
||||
return marked
|
||||
|
||||
|
||||
def _is_interrupted(job_id: str) -> bool:
|
||||
"""Non-destructive peek at whether the shutdown path has marked
|
||||
``job_id`` interrupted (see ``mark_running_jobs_interrupted``).
|
||||
|
||||
Called by ``run_one_job`` BEFORE it decides what to deliver — a job
|
||||
whose tool subprocess was killed mid-flight may still produce a
|
||||
plausible-looking ``final_response`` from the truncated output, and
|
||||
that must not go out to the user as if it were a normal result.
|
||||
Unlike ``_consume_interrupted_flag`` below, this does not clear the
|
||||
flag: the later, authoritative check (right before ``last_status`` is
|
||||
written) still needs to see it."""
|
||||
with _running_lock:
|
||||
return job_id in _interrupted_job_ids
|
||||
|
||||
|
||||
def _consume_interrupted_flag(job_id: str) -> bool:
|
||||
"""Return True and clear the flag if the shutdown path already marked
|
||||
``job_id`` interrupted (see ``mark_running_jobs_interrupted``).
|
||||
|
||||
Called by ``run_one_job`` right before it would otherwise write its own
|
||||
``last_status``. Consuming (discarding) rather than just checking keeps
|
||||
the flag from leaking across a later, unrelated run of the same job ID
|
||||
(recurring jobs reuse their ID every fire)."""
|
||||
with _running_lock:
|
||||
if job_id in _interrupted_job_ids:
|
||||
_interrupted_job_ids.discard(job_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Sequential (env-mutating) cron jobs — workdir jobs that touch
|
||||
# process-global runtime state — must run one at a time, but must NOT block the
|
||||
# ticker thread. A persistent single-thread executor preserves ordering across
|
||||
@@ -3332,6 +3428,20 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) -
|
||||
if verbose:
|
||||
logger.info("Output saved to: %s", output_file)
|
||||
|
||||
# If the gateway shutdown killed this job's tool subprocess
|
||||
# mid-flight (#60432), the agent may still have produced a
|
||||
# plausible-looking final_response from the truncated output --
|
||||
# force the failure path so the delivered message is an honest
|
||||
# "this run was interrupted" summary instead of that response.
|
||||
# Peek-only: the flag stays set for the authoritative check
|
||||
# right before mark_job_run below.
|
||||
if success and _is_interrupted(job["id"]):
|
||||
success = False
|
||||
error = (
|
||||
"Interrupted by gateway shutdown before the run finished "
|
||||
"(tool subprocess was killed mid-flight)."
|
||||
)
|
||||
|
||||
# Deliver the final response to the origin/target chat.
|
||||
# If the agent responded with [SILENT], skip delivery (but
|
||||
# output is already saved above). Failed jobs always deliver.
|
||||
@@ -3370,12 +3480,14 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) -
|
||||
success = False
|
||||
error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"
|
||||
|
||||
mark_job_run(job["id"], success, error, delivery_error=delivery_error)
|
||||
if not _consume_interrupted_flag(job["id"]):
|
||||
mark_job_run(job["id"], success, error, delivery_error=delivery_error)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing job %s: %s", job['id'], e)
|
||||
mark_job_run(job["id"], False, str(e))
|
||||
if not _consume_interrupted_flag(job["id"]):
|
||||
mark_job_run(job["id"], False, str(e))
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -128,21 +128,34 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
|
||||
|
||||
# Platforms that don't support direct channel enumeration get session-based
|
||||
# discovery automatically. Skip infrastructure entries that aren't messaging
|
||||
# platforms — everything else falls through to _build_from_sessions().
|
||||
# discovery automatically, but only for platforms connected in THIS gateway
|
||||
# process. Historical session origins for disabled/decommissioned platforms
|
||||
# must not be resurrected into the active send-target directory (stale
|
||||
# targets make send_message route to platforms that can no longer deliver).
|
||||
_SKIP_SESSION_DISCOVERY = frozenset({"local", "api_server", "webhook"})
|
||||
adapter_platform_names = {getattr(p, "value", str(p)) for p in adapters}
|
||||
for plat in Platform:
|
||||
plat_name = plat.value
|
||||
if plat_name in _SKIP_SESSION_DISCOVERY or plat_name in platforms:
|
||||
if (
|
||||
plat_name in _SKIP_SESSION_DISCOVERY
|
||||
or plat_name in platforms
|
||||
or plat_name not in adapter_platform_names
|
||||
):
|
||||
continue
|
||||
platforms[plat_name] = _build_from_sessions(plat_name)
|
||||
|
||||
# Include plugin-registered platforms (dynamic enum members aren't in
|
||||
# Platform.__members__, so the loop above misses them).
|
||||
# Platform.__members__, so the loop above misses them). Same
|
||||
# connected-only rule: don't expose stale session targets for plugins
|
||||
# that are not loaded.
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
if entry.name not in _SKIP_SESSION_DISCOVERY and entry.name not in platforms:
|
||||
if (
|
||||
entry.name not in _SKIP_SESSION_DISCOVERY
|
||||
and entry.name not in platforms
|
||||
and entry.name in adapter_platform_names
|
||||
):
|
||||
platforms[entry.name] = _build_from_sessions(entry.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -37,6 +37,43 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||
return is_truthy_value(value, default=default)
|
||||
|
||||
|
||||
# Recognized truthy / falsy tokens for the GATEWAY_MULTIPLEX_PROFILES operator
|
||||
# override. Anything not in either set — and a blank/whitespace value — is
|
||||
# treated as "unset" so it falls through to config.yaml rather than silently
|
||||
# forcing the flag off.
|
||||
_MULTIPLEX_TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"})
|
||||
_MULTIPLEX_FALSY_STRINGS = frozenset({"0", "false", "no", "off"})
|
||||
|
||||
|
||||
def _env_multiplex_profiles_override() -> "bool | None":
|
||||
"""Resolve the GATEWAY_MULTIPLEX_PROFILES operator override.
|
||||
|
||||
Returns ``True``/``False`` when the env var is set to a recognized truthy/
|
||||
falsy token, or ``None`` when it is unset, blank, or unrecognized — in which
|
||||
case the caller keeps the config.yaml value (env > config > default). Blank
|
||||
is deliberately ``None``, not ``False``: a provisioned-but-unpopulated Fly
|
||||
secret arrives as ``""`` and must NOT shadow a config.yaml opt-in.
|
||||
"""
|
||||
raw = os.getenv("GATEWAY_MULTIPLEX_PROFILES")
|
||||
if raw is None:
|
||||
return None
|
||||
token = raw.strip().lower()
|
||||
if not token:
|
||||
return None
|
||||
if token in _MULTIPLEX_TRUTHY_STRINGS:
|
||||
return True
|
||||
if token in _MULTIPLEX_FALSY_STRINGS:
|
||||
return False
|
||||
logger.warning(
|
||||
"Ignoring unrecognized GATEWAY_MULTIPLEX_PROFILES=%r "
|
||||
"(expected one of %s or %s); falling back to config.yaml.",
|
||||
raw,
|
||||
sorted(_MULTIPLEX_TRUTHY_STRINGS),
|
||||
sorted(_MULTIPLEX_FALSY_STRINGS),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_float(value: Any, default: float) -> float:
|
||||
"""Coerce numeric config values, falling back on malformed input."""
|
||||
if value is None:
|
||||
@@ -837,6 +874,18 @@ class GatewayConfig:
|
||||
# Also honor gateway.multiplex_profiles written by
|
||||
# ``hermes config set gateway.multiplex_profiles true``.
|
||||
multiplex_profiles = nested_gateway.get("multiplex_profiles")
|
||||
# Operator override: GATEWAY_MULTIPLEX_PROFILES wins over config.yaml when
|
||||
# set to a recognized value. Hosted deployments (Nous Portal / Fly) stamp
|
||||
# it on the container so the single multiplexed gateway — which the
|
||||
# connector now depends on for per-profile relay routing — is forced on at
|
||||
# every boot regardless of the image's config.yaml, while self-hosted
|
||||
# users keep setting gateway.multiplex_profiles in config.yaml. A blank or
|
||||
# unrecognized env value falls through to config (the empty-secret trap:
|
||||
# a provisioned-but-unpopulated Fly secret must not shadow config), so
|
||||
# this is a genuine 3-tier chain: env > config.yaml > default False.
|
||||
env_multiplex = _env_multiplex_profiles_override()
|
||||
if env_multiplex is not None:
|
||||
multiplex_profiles = env_multiplex
|
||||
if "max_concurrent_sessions" in data:
|
||||
max_concurrent_raw = data.get("max_concurrent_sessions")
|
||||
max_concurrent_key = "max_concurrent_sessions"
|
||||
|
||||
@@ -58,6 +58,10 @@ from gateway.platforms.base import (
|
||||
MessageType,
|
||||
SendResult,
|
||||
)
|
||||
from gateway.platforms.webhook_filters import (
|
||||
DEFAULT_SCRIPT_TIMEOUT_SECONDS,
|
||||
WebhookRouteProcessor,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -78,7 +82,6 @@ DEFAULT_PORT = 8644
|
||||
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
|
||||
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
|
||||
_RATE_WINDOW_SECONDS = 60.0
|
||||
|
||||
# Hostnames/IP literals that only serve connections originating on the same
|
||||
# machine. Anything else is treated as a public bind for safety-rail purposes.
|
||||
_LOOPBACK_HOSTS = frozenset({
|
||||
@@ -155,6 +158,15 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
self._max_body_bytes: int = int(
|
||||
config.extra.get("max_body_bytes", 1_048_576)
|
||||
) # 1MB
|
||||
self._script_timeout_seconds: int = int(
|
||||
config.extra.get(
|
||||
"script_timeout_seconds",
|
||||
DEFAULT_SCRIPT_TIMEOUT_SECONDS,
|
||||
)
|
||||
)
|
||||
self._route_processor = WebhookRouteProcessor(
|
||||
script_timeout_seconds=self._script_timeout_seconds
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
@@ -571,6 +583,45 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
{"status": "ignored", "event": event_type}
|
||||
)
|
||||
|
||||
if not self._route_processor.route_filters_match(
|
||||
route_config, payload, event_type, request.headers
|
||||
):
|
||||
logger.info(
|
||||
"[webhook] filtered event=%s route=%s",
|
||||
event_type,
|
||||
route_name,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ignored",
|
||||
"reason": "filter",
|
||||
"route": route_name,
|
||||
}
|
||||
)
|
||||
|
||||
if route_config.get("script"):
|
||||
# run_route_script shells out (subprocess.run, up to its timeout);
|
||||
# run it in a worker thread so it can't block the gateway event loop.
|
||||
keep, transformed_payload = await asyncio.to_thread(
|
||||
self._route_processor.run_route_script,
|
||||
route_config.get("script"),
|
||||
payload,
|
||||
)
|
||||
if not keep:
|
||||
logger.info(
|
||||
"[webhook] script ignored event=%s route=%s",
|
||||
event_type,
|
||||
route_name,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ignored",
|
||||
"reason": "script",
|
||||
"route": route_name,
|
||||
}
|
||||
)
|
||||
payload = transformed_payload or payload
|
||||
|
||||
# Format prompt from template
|
||||
prompt_template = route_config.get("prompt", "")
|
||||
prompt = self._render_prompt(
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Route-local filters and script transforms for the webhook adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SCRIPT_TIMEOUT_SECONDS = 30
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _stringify_filter_value(value: Any) -> str:
|
||||
if value is _MISSING:
|
||||
return ""
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, sort_keys=True)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _resolve_profile_path(path_value: Any) -> Optional[Path]:
|
||||
"""Resolve a user path, mapping ~/.hermes to the active profile home."""
|
||||
if not isinstance(path_value, str):
|
||||
return None
|
||||
raw = os.path.expandvars(path_value.strip())
|
||||
if not raw:
|
||||
return None
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
hermes_home = get_hermes_home()
|
||||
if raw == "~/.hermes":
|
||||
return hermes_home
|
||||
if raw.startswith("~/.hermes/"):
|
||||
return hermes_home / raw.removeprefix("~/.hermes/")
|
||||
path = Path(raw).expanduser()
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return hermes_home / path
|
||||
|
||||
|
||||
def _resolve_script_path(script_value: Any) -> tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve a route script under HERMES_HOME/scripts."""
|
||||
if not isinstance(script_value, str) or not script_value.strip():
|
||||
return None, "script path is empty"
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
scripts_root = (get_hermes_home() / "scripts").resolve()
|
||||
raw_text = os.path.expandvars(script_value.strip())
|
||||
if raw_text == "~/.hermes" or raw_text.startswith("~/.hermes/"):
|
||||
mapped = _resolve_profile_path(raw_text)
|
||||
candidate = mapped.resolve() if mapped is not None else scripts_root
|
||||
else:
|
||||
raw = Path(raw_text).expanduser()
|
||||
candidate = raw.resolve() if raw.is_absolute() else (scripts_root / raw).resolve()
|
||||
try:
|
||||
candidate.relative_to(scripts_root)
|
||||
except ValueError:
|
||||
return None, f"script path resolves outside {scripts_root}"
|
||||
if not candidate.exists():
|
||||
return None, f"script not found: {candidate}"
|
||||
if not candidate.is_file():
|
||||
return None, f"script path is not a file: {candidate}"
|
||||
return candidate, None
|
||||
|
||||
|
||||
def _load_filter_file_values(path_value: Any) -> list[Any]:
|
||||
path = _resolve_profile_path(path_value)
|
||||
if path is None:
|
||||
return []
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.warning("[webhook] filter in_file read failed for %s: %s", path, exc)
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return list(data.keys())
|
||||
return [data]
|
||||
|
||||
|
||||
class WebhookRouteProcessor:
|
||||
"""Evaluate declarative filters and optional script transforms."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
script_timeout_seconds: int = DEFAULT_SCRIPT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
self.script_timeout_seconds = max(1, int(script_timeout_seconds))
|
||||
|
||||
def resolve_filter_field(
|
||||
self,
|
||||
field: Any,
|
||||
payload: dict,
|
||||
event_type: str,
|
||||
headers: Any,
|
||||
) -> Any:
|
||||
"""Resolve a dotted filter field against payload/event/headers context."""
|
||||
if not isinstance(field, str) or not field.strip():
|
||||
return _MISSING
|
||||
parts = [part for part in field.strip().split(".") if part]
|
||||
if not parts:
|
||||
return _MISSING
|
||||
header_dict = dict(headers or {})
|
||||
context = {
|
||||
"payload": payload.get("payload", payload),
|
||||
"event": event_type,
|
||||
"event_type": event_type,
|
||||
"headers": header_dict,
|
||||
}
|
||||
if parts[0] in context:
|
||||
value: Any = context[parts[0]]
|
||||
parts = parts[1:]
|
||||
else:
|
||||
value = payload
|
||||
for part in parts:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(part, _MISSING)
|
||||
elif isinstance(value, list) and part.isdigit():
|
||||
idx = int(part)
|
||||
value = value[idx] if 0 <= idx < len(value) else _MISSING
|
||||
else:
|
||||
return _MISSING
|
||||
if value is _MISSING:
|
||||
return _MISSING
|
||||
return value
|
||||
|
||||
def filter_matches(
|
||||
self,
|
||||
spec: Any,
|
||||
payload: dict,
|
||||
event_type: str,
|
||||
headers: Any,
|
||||
) -> bool:
|
||||
"""Evaluate one declarative webhook filter spec."""
|
||||
if not isinstance(spec, dict):
|
||||
logger.warning("[webhook] Ignoring invalid filter spec: %r", spec)
|
||||
return False
|
||||
|
||||
if "all" in spec:
|
||||
items = spec.get("all")
|
||||
return isinstance(items, list) and all(
|
||||
self.filter_matches(item, payload, event_type, headers)
|
||||
for item in items
|
||||
)
|
||||
if "any" in spec:
|
||||
items = spec.get("any")
|
||||
return isinstance(items, list) and any(
|
||||
self.filter_matches(item, payload, event_type, headers)
|
||||
for item in items
|
||||
)
|
||||
if "not" in spec:
|
||||
return not self.filter_matches(spec.get("not"), payload, event_type, headers)
|
||||
|
||||
value = self.resolve_filter_field(
|
||||
spec.get("field"), payload, event_type, headers
|
||||
)
|
||||
|
||||
if "exists" in spec:
|
||||
exists = value is not _MISSING
|
||||
return exists is bool(spec.get("exists"))
|
||||
if spec.get("missing") is True:
|
||||
return value is _MISSING
|
||||
if "equals" in spec:
|
||||
return value is not _MISSING and value == spec.get("equals")
|
||||
if "not_equals" in spec:
|
||||
return value is _MISSING or value != spec.get("not_equals")
|
||||
if "contains" in spec:
|
||||
needle = spec.get("contains")
|
||||
if value is _MISSING:
|
||||
return False
|
||||
if isinstance(value, (list, tuple, set, dict)):
|
||||
return needle in value
|
||||
return str(needle) in _stringify_filter_value(value)
|
||||
if "in" in spec:
|
||||
haystack = spec.get("in")
|
||||
return isinstance(haystack, list) and value in haystack
|
||||
if "in_file" in spec:
|
||||
return value in _load_filter_file_values(spec.get("in_file"))
|
||||
if "regex" in spec:
|
||||
if value is _MISSING:
|
||||
return False
|
||||
try:
|
||||
return (
|
||||
re.search(str(spec.get("regex")), _stringify_filter_value(value))
|
||||
is not None
|
||||
)
|
||||
except re.error as exc:
|
||||
logger.warning("[webhook] Invalid webhook filter regex: %s", exc)
|
||||
return False
|
||||
|
||||
logger.warning("[webhook] Filter spec has no supported operator: %r", spec)
|
||||
return False
|
||||
|
||||
def route_filters_match(
|
||||
self,
|
||||
route_config: dict,
|
||||
payload: dict,
|
||||
event_type: str,
|
||||
headers: Any,
|
||||
) -> bool:
|
||||
filters = route_config.get("filters") or []
|
||||
if not filters:
|
||||
return True
|
||||
if isinstance(filters, dict):
|
||||
return self.filter_matches(filters, payload, event_type, headers)
|
||||
if not isinstance(filters, list):
|
||||
logger.warning("[webhook] filters must be a list or object")
|
||||
return False
|
||||
return all(
|
||||
self.filter_matches(spec, payload, event_type, headers)
|
||||
for spec in filters
|
||||
)
|
||||
|
||||
def run_route_script(self, script_value: Any, payload: dict) -> tuple[bool, Optional[dict]]:
|
||||
"""Run a route script and return (should_continue, transformed_payload)."""
|
||||
path, error = _resolve_script_path(script_value)
|
||||
if error or path is None:
|
||||
logger.warning("[webhook] script ignored webhook: %s", error)
|
||||
return False, None
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in {".sh", ".bash"}:
|
||||
bash = shutil.which("bash") or (
|
||||
"/bin/bash" if os.path.isfile("/bin/bash") else None
|
||||
)
|
||||
if bash is None:
|
||||
logger.warning("[webhook] script ignored webhook: bash not found")
|
||||
return False, None
|
||||
argv = [bash, str(path)]
|
||||
else:
|
||||
argv = [sys.executable, str(path)]
|
||||
|
||||
try:
|
||||
from tools.environments.local import _sanitize_subprocess_env
|
||||
|
||||
popen_kwargs = {"creationflags": 0x08000000} if sys.platform == "win32" else {}
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
input=json.dumps(payload),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.script_timeout_seconds,
|
||||
cwd=str(path.parent),
|
||||
env=_sanitize_subprocess_env(os.environ.copy()),
|
||||
**popen_kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[webhook] script timed out: %s", path)
|
||||
return False, None
|
||||
except Exception as exc:
|
||||
logger.warning("[webhook] script execution failed: %s", exc)
|
||||
return False, None
|
||||
|
||||
stdout = (result.stdout or "").strip()
|
||||
stderr = (result.stderr or "").strip()
|
||||
try:
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
stdout = redact_sensitive_text(stdout)
|
||||
stderr = redact_sensitive_text(stderr)
|
||||
except Exception as exc:
|
||||
logger.warning("[webhook] Failed to redact script output: %s", exc)
|
||||
stdout = "[REDACTED - redaction failed]"
|
||||
stderr = "[REDACTED - redaction failed]"
|
||||
if result.returncode != 0:
|
||||
logger.info(
|
||||
"[webhook] script ignored webhook path=%s code=%s stderr=%s",
|
||||
path.name,
|
||||
result.returncode,
|
||||
stderr[:200],
|
||||
)
|
||||
return False, None
|
||||
if not stdout or stdout == "[SILENT]":
|
||||
return False, None
|
||||
|
||||
try:
|
||||
transformed = json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
transformed = {**payload, "script_output": stdout}
|
||||
if not isinstance(transformed, dict):
|
||||
logger.warning("[webhook] script stdout must be a JSON object or text")
|
||||
return False, None
|
||||
if (
|
||||
transformed.get("[SILENT]") is True
|
||||
or transformed.get("__hermes_ignore__") is True
|
||||
):
|
||||
return False, None
|
||||
return True, transformed
|
||||
+147
-37
@@ -179,6 +179,16 @@ OBSERVED_MEDIA_BACKFILL_LOOKBACK = 50
|
||||
# Max number of resource references to resolve per inbound turn
|
||||
OBSERVED_MEDIA_BACKFILL_MAX_RESOLVE_PER_TURN = 12
|
||||
|
||||
# Bounded concurrency for inbound media resolve/download.
|
||||
# - 1 = sequential (legacy behavior, safe rollback knob)
|
||||
# - 6 = default; aligns with the per-origin HTTP/1.1 ceiling browsers use,
|
||||
# balances first-token latency vs. backend pressure
|
||||
# - 12 = upper clamp; matches OBSERVED_MEDIA_BACKFILL_MAX_RESOLVE_PER_TURN
|
||||
# Configured via config.yaml: platforms.yuanbao.extra.media_resolve_concurrency.
|
||||
_DEFAULT_RESOLVE_CONCURRENCY = 6
|
||||
_MIN_RESOLVE_CONCURRENCY = 1
|
||||
_MAX_RESOLVE_CONCURRENCY = 12
|
||||
|
||||
class MarkdownProcessor:
|
||||
"""Encapsulates all Markdown-related utilities for the Yuanbao platform.
|
||||
|
||||
@@ -2813,45 +2823,87 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
|
||||
Yuanbao COS hostnames resolve to private IPs, tripping the SSRF guard
|
||||
in vision_tools. We download ourselves and return local cache paths.
|
||||
|
||||
Resolution runs with bounded concurrency
|
||||
(``adapter.media_resolve_concurrency``); see :meth:`_resolve_ybres_refs`
|
||||
for the same order-preserving / exception-isolated contract.
|
||||
"""
|
||||
# Pre-filter resolvable refs, preserving input order.
|
||||
media_urls: List[str] = []
|
||||
media_types: List[str] = []
|
||||
|
||||
active: List[Tuple[str, str, str, str]] = []
|
||||
for ref in media_refs:
|
||||
kind = str(ref.get("kind") or "").strip().lower()
|
||||
url = str(ref.get("url") or "").strip()
|
||||
filename = str(ref.get("name") or "").strip()
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS or not url:
|
||||
continue
|
||||
|
||||
# Extract resourceId from the placeholder URL for cache dedup.
|
||||
rid = ExtractContentMiddleware._parse_resource_id(url)
|
||||
if rid and cls._append_cached_resource(adapter, rid, media_urls, media_types):
|
||||
continue
|
||||
active.append((kind, url, filename, rid or ""))
|
||||
|
||||
try:
|
||||
fetch_url = await cls._resolve_download_url(adapter, url)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] inbound media resolve failed: kind=%s url=%s err=%s",
|
||||
adapter.name, kind, url, exc,
|
||||
if not active:
|
||||
return media_urls, media_types
|
||||
|
||||
semaphore = asyncio.Semaphore(adapter.media_resolve_concurrency)
|
||||
|
||||
async def _resolve_one(
|
||||
kind: str, url: str, filename: str, rid: str,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
async with semaphore:
|
||||
try:
|
||||
fetch_url = await cls._resolve_download_url(adapter, url)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] inbound media resolve failed: kind=%s url=%s err=%s",
|
||||
adapter.name, kind, url, exc,
|
||||
)
|
||||
return None
|
||||
return await cls._download_and_cache(
|
||||
adapter,
|
||||
fetch_url=fetch_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"placeholder_url={url[:80]}",
|
||||
resource_id=rid,
|
||||
)
|
||||
continue
|
||||
|
||||
cached = await cls._download_and_cache(
|
||||
adapter,
|
||||
fetch_url=fetch_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"placeholder_url={url[:80]}",
|
||||
resource_id=rid,
|
||||
)
|
||||
if cached is None:
|
||||
_t0 = time.monotonic()
|
||||
results = await asyncio.gather(
|
||||
*(_resolve_one(kind, url, filename, rid)
|
||||
for kind, url, filename, rid in active),
|
||||
return_exceptions=True,
|
||||
)
|
||||
_elapsed_ms = int((time.monotonic() - _t0) * 1000)
|
||||
|
||||
_failed = 0
|
||||
for (kind, url, _filename, _rid), result in zip(active, results):
|
||||
if isinstance(result, BaseException):
|
||||
logger.warning(
|
||||
"[%s] inbound media resolve crashed: kind=%s url=%s err=%s",
|
||||
adapter.name, kind, url[:80], result,
|
||||
)
|
||||
_failed += 1
|
||||
continue
|
||||
local_path, mime = cached
|
||||
if result is None:
|
||||
_failed += 1
|
||||
continue
|
||||
local_path, mime = result
|
||||
media_urls.append(local_path)
|
||||
media_types.append(mime)
|
||||
|
||||
# Batch summary: keep fields stable for offline aggregation
|
||||
# (concurrency vs elapsed_ms is the core knob-tuning view).
|
||||
logger.info(
|
||||
"[%s] media resolve batch: scope=media concurrency=%d total=%d ok=%d failed=%d elapsed_ms=%d",
|
||||
adapter.name,
|
||||
adapter.media_resolve_concurrency,
|
||||
len(active),
|
||||
len(media_urls),
|
||||
_failed,
|
||||
_elapsed_ms,
|
||||
)
|
||||
return media_urls, media_types
|
||||
|
||||
@classmethod
|
||||
@@ -2862,36 +2914,81 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
*,
|
||||
log_prefix: str,
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Resolve a list of ``(rid, kind, filename)`` ybres tuples to local paths.
|
||||
"""Resolve ``(rid, kind, filename)`` ybres tuples to local paths.
|
||||
|
||||
Runs with bounded concurrency (``adapter.media_resolve_concurrency``)
|
||||
so cold-start turns with many anchors don't pay ``N × (RPC + download)``
|
||||
sequentially. Output order matches input; per-rid failures are isolated.
|
||||
"""
|
||||
# Pre-filter resolvable kinds, preserving input order.
|
||||
# Cache-hit refs are served immediately and excluded from the gather.
|
||||
media_paths: List[str] = []
|
||||
mimes: List[str] = []
|
||||
active: List[Tuple[str, str, str]] = []
|
||||
for rid, kind, filename in refs:
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS:
|
||||
continue
|
||||
if cls._append_cached_resource(adapter, rid, media_paths, mimes):
|
||||
continue
|
||||
try:
|
||||
fresh_url = await cls._fetch_resource_url(adapter, rid)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] %s resolve failed: rid=%s kind=%s err=%s",
|
||||
adapter.name, log_prefix, rid, kind, exc,
|
||||
active.append((rid, kind, filename))
|
||||
if not active:
|
||||
return media_paths, mimes
|
||||
|
||||
semaphore = asyncio.Semaphore(adapter.media_resolve_concurrency)
|
||||
|
||||
async def _resolve_one(rid: str, kind: str, filename: str) -> Optional[Tuple[str, str]]:
|
||||
async with semaphore:
|
||||
try:
|
||||
fresh_url = await cls._fetch_resource_url(adapter, rid)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] %s resolve failed: rid=%s kind=%s err=%s",
|
||||
adapter.name, log_prefix, rid, kind, exc,
|
||||
)
|
||||
return None
|
||||
return await cls._download_and_cache(
|
||||
adapter,
|
||||
fetch_url=fresh_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"{log_prefix} rid={rid}",
|
||||
resource_id=rid,
|
||||
)
|
||||
|
||||
# return_exceptions=True isolates per-coroutine failures.
|
||||
_t0 = time.monotonic()
|
||||
results = await asyncio.gather(
|
||||
*(_resolve_one(rid, kind, filename) for rid, kind, filename in active),
|
||||
return_exceptions=True,
|
||||
)
|
||||
_elapsed_ms = int((time.monotonic() - _t0) * 1000)
|
||||
|
||||
_failed = 0
|
||||
for (rid, kind, filename), result in zip(active, results):
|
||||
if isinstance(result, BaseException):
|
||||
logger.warning(
|
||||
"[%s] %s resolve crashed: rid=%s kind=%s err=%s",
|
||||
adapter.name, log_prefix, rid, kind, result,
|
||||
)
|
||||
_failed += 1
|
||||
continue
|
||||
cached = await cls._download_and_cache(
|
||||
adapter,
|
||||
fetch_url=fresh_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"{log_prefix} rid={rid}",
|
||||
resource_id=rid,
|
||||
)
|
||||
if cached is None:
|
||||
if result is None:
|
||||
_failed += 1
|
||||
continue
|
||||
path, mime = cached
|
||||
path, mime = result
|
||||
media_paths.append(path)
|
||||
mimes.append(mime)
|
||||
|
||||
# Batch summary: stable fields for offline aggregation.
|
||||
logger.info(
|
||||
"[%s] media resolve batch: scope=ybres concurrency=%d total=%d ok=%d failed=%d elapsed_ms=%d",
|
||||
adapter.name,
|
||||
adapter.media_resolve_concurrency,
|
||||
len(active),
|
||||
len(media_paths),
|
||||
_failed,
|
||||
_elapsed_ms,
|
||||
)
|
||||
return media_paths, mimes
|
||||
|
||||
@classmethod
|
||||
@@ -5071,6 +5168,19 @@ class YuanbaoAdapter(BasePlatformAdapter):
|
||||
self._api_domain: str = (_extra.get("api_domain") or DEFAULT_API_DOMAIN).rstrip("/")
|
||||
self._route_env: str = (_extra.get("route_env") or "").strip()
|
||||
|
||||
# Bounded concurrency for inbound media resolve/download.
|
||||
# See _DEFAULT_RESOLVE_CONCURRENCY for rationale; clamped to [min, max]
|
||||
# so a misconfigured value cannot hammer the resource backend nor
|
||||
# accidentally drop below sequential behavior.
|
||||
try:
|
||||
_raw_concurrency = int(_extra.get("media_resolve_concurrency", _DEFAULT_RESOLVE_CONCURRENCY))
|
||||
except (TypeError, ValueError):
|
||||
_raw_concurrency = _DEFAULT_RESOLVE_CONCURRENCY
|
||||
self.media_resolve_concurrency: int = max(
|
||||
_MIN_RESOLVE_CONCURRENCY,
|
||||
min(_MAX_RESOLVE_CONCURRENCY, _raw_concurrency),
|
||||
)
|
||||
|
||||
# Core managers (UML composition)
|
||||
self._connection: ConnectionManager = ConnectionManager(self)
|
||||
self._outbound: OutboundManager = OutboundManager(self)
|
||||
|
||||
@@ -436,6 +436,80 @@ def _post_provision(
|
||||
return payload
|
||||
|
||||
|
||||
def _resolve_relay_identity_token() -> str:
|
||||
"""Resolve the caller-identity bearer token the connector introspects to a tenant.
|
||||
|
||||
Canonical resolver shared by the runtime self-provision path and the
|
||||
``hermes gateway enroll`` CLI. Two modes, in precedence order:
|
||||
|
||||
1. **Generic OIDC client-credentials** (air-gapped / self-hosted-IdP, NO
|
||||
Nous Portal): when ``gateway.idp.token_url`` (or
|
||||
``GATEWAY_RELAY_IDP_TOKEN_URL``) is configured, obtain a workload access
|
||||
token via the OAuth2 ``client_credentials`` grant against the operator's
|
||||
own IdP (Entra; Authentik in the sandbox). The connector's Seam-A OIDC
|
||||
verifier reads a claim (default ``tid``) off it as the tenant.
|
||||
2. **Nous Portal** (default): ``resolve_nous_access_token()`` — existing
|
||||
managed/hosted behaviour.
|
||||
|
||||
Raises on failure; callers decide whether that's fatal (enroll CLI) or a
|
||||
graceful boot no-op (self-provision).
|
||||
"""
|
||||
token_url = os.environ.get("GATEWAY_RELAY_IDP_TOKEN_URL", "").strip()
|
||||
client_id = os.environ.get("GATEWAY_RELAY_IDP_CLIENT_ID", "").strip()
|
||||
client_secret = os.environ.get("GATEWAY_RELAY_IDP_CLIENT_SECRET", "").strip()
|
||||
scope = os.environ.get("GATEWAY_RELAY_IDP_SCOPE", "").strip()
|
||||
if not token_url:
|
||||
try:
|
||||
from gateway.run import _load_gateway_config # late import to avoid cycle
|
||||
|
||||
idp = ((_load_gateway_config().get("gateway") or {}).get("idp") or {})
|
||||
token_url = str(idp.get("token_url", "") or "").strip()
|
||||
client_id = client_id or str(idp.get("client_id", "") or "").strip()
|
||||
client_secret = client_secret or str(idp.get("client_secret", "") or "").strip()
|
||||
scope = scope or str(idp.get("scope", "") or "").strip()
|
||||
except Exception: # noqa: BLE001 - config absence must not crash
|
||||
token_url = token_url or ""
|
||||
|
||||
if not token_url:
|
||||
# Mode 2 — Nous Portal (default, unchanged behaviour).
|
||||
from hermes_cli.auth import resolve_nous_access_token
|
||||
|
||||
return resolve_nous_access_token()
|
||||
|
||||
# Mode 1 — generic OAuth2 client_credentials grant.
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
if not client_id or not client_secret:
|
||||
raise RuntimeError(
|
||||
"gateway.idp.token_url configured but client_id/client_secret missing"
|
||||
)
|
||||
form = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
}
|
||||
if scope:
|
||||
form["scope"] = scope
|
||||
req = urllib.request.Request(
|
||||
token_url,
|
||||
data=urllib.parse.urlencode(form).encode("utf-8"),
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15.0) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
access_token = (payload or {}).get("access_token")
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
raise RuntimeError("IdP client_credentials response had no access_token")
|
||||
return access_token.strip()
|
||||
|
||||
|
||||
def self_provision_relay() -> bool:
|
||||
"""Boot-time relay self-provision: mint relay creds in-process, no human, no disk.
|
||||
|
||||
@@ -489,13 +563,11 @@ def self_provision_relay() -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import resolve_nous_access_token
|
||||
|
||||
access_token = resolve_nous_access_token()
|
||||
access_token = _resolve_relay_identity_token()
|
||||
except Exception as exc: # noqa: BLE001 - boot must survive a token failure
|
||||
# No resolvable NAS identity (e.g. a self-hosted box that hasn't enrolled)
|
||||
# -> nothing to provision with; skip quietly and let the gateway boot.
|
||||
logger.warning("relay self-provision skipped: could not resolve Nous token (%s)", exc)
|
||||
# No resolvable identity (e.g. a self-hosted box that hasn't enrolled and
|
||||
# configured no IdP) -> nothing to provision with; skip quietly and boot.
|
||||
logger.warning("relay self-provision skipped: could not resolve identity token (%s)", exc)
|
||||
return False
|
||||
|
||||
identities = relay_platform_identities()
|
||||
|
||||
@@ -120,6 +120,15 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
|
||||
scope_id=src.get("scope_id"),
|
||||
parent_chat_id=src.get("parent_chat_id"),
|
||||
message_id=src.get("message_id"),
|
||||
# The HERMES profile this event is routed to (multiplex mode). The
|
||||
# connector stamps it on the wire source when NAS resolves the target
|
||||
# profile for a Team-Gateway message; absent for a single-profile
|
||||
# gateway, where it stays None and session keys keep the legacy
|
||||
# ``agent:main`` namespace (SessionStore._resolve_profile_for_key).
|
||||
# Consumed by build_session_key's profile namespacing + the per-turn
|
||||
# config/credential scope — the same field the /p/<profile>/ HTTP
|
||||
# prefix and per-credential polling adapters already set.
|
||||
profile=src.get("profile"),
|
||||
# Authentic upstream-trust signal: this event arrived over the
|
||||
# per-instance-authenticated relay WS, so the connector already resolved
|
||||
# it to this instance's owner-bound author. ``platform`` is the
|
||||
|
||||
+128
-12
@@ -4080,6 +4080,26 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
def _running_agent_count(self) -> int:
|
||||
return len(self._running_agents)
|
||||
|
||||
def _active_cron_job_count(self) -> int:
|
||||
"""Count of cron jobs currently executing, from the cron scheduler's
|
||||
own in-flight tracking (``cron.scheduler._running_job_ids``).
|
||||
|
||||
Cron jobs run through a standalone ``AIAgent`` on the scheduler's own
|
||||
thread pool (``cron/scheduler.py::run_job``), entirely outside
|
||||
``self._running_agents`` — the dict every OTHER active-work check on
|
||||
this class (``_running_agent_count``, ``_drain_active_agents``) reads.
|
||||
Without this, the shutdown drain is structurally blind to in-flight
|
||||
cron work: it can report ``active_at_start=0`` and proceed straight
|
||||
to killing tool subprocesses while a cron job's terminal command is
|
||||
still running (#60432). Best-effort: returns 0 if the cron module
|
||||
can't be imported (e.g. a minimal test double for this class).
|
||||
"""
|
||||
try:
|
||||
from cron.scheduler import get_running_job_ids
|
||||
return len(get_running_job_ids())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
# ── scale-to-zero idle detection / dormant-quiesce (Phase 0) ──────────────
|
||||
# The gateway-side BEHAVIOUR that consumes the relay scale-to-zero primitives
|
||||
# (gateway-gateway Phase 5). Pure logic lives in gateway/scale_to_zero.py; the
|
||||
@@ -5550,18 +5570,31 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
async def _drain_active_agents(self, timeout: float) -> tuple[Dict[str, Any], bool]:
|
||||
snapshot = self._snapshot_running_agents()
|
||||
last_active_count = self._running_agent_count()
|
||||
last_cron_count = self._active_cron_job_count()
|
||||
last_status_at = 0.0
|
||||
|
||||
def _maybe_update_status(force: bool = False) -> None:
|
||||
nonlocal last_active_count, last_status_at
|
||||
nonlocal last_active_count, last_cron_count, last_status_at
|
||||
now = asyncio.get_running_loop().time()
|
||||
active_count = self._running_agent_count()
|
||||
if force or active_count != last_active_count or (now - last_status_at) >= 1.0:
|
||||
cron_count = self._active_cron_job_count()
|
||||
if (
|
||||
force
|
||||
or active_count != last_active_count
|
||||
or cron_count != last_cron_count
|
||||
or (now - last_status_at) >= 1.0
|
||||
):
|
||||
self._update_runtime_status("draining")
|
||||
last_active_count = active_count
|
||||
last_cron_count = cron_count
|
||||
last_status_at = now
|
||||
|
||||
if not self._running_agents:
|
||||
# Cron jobs run on the scheduler's own thread pool, outside
|
||||
# ``self._running_agents`` — fold their in-flight count into the
|
||||
# same wait/timeout this method already applies to chat sessions,
|
||||
# or a cron job's tool work gets killed with zero warning the
|
||||
# instant it's the only active thing running (#60432).
|
||||
if not self._running_agents and last_cron_count == 0:
|
||||
_maybe_update_status(force=True)
|
||||
return snapshot, False
|
||||
|
||||
@@ -5570,10 +5603,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
return snapshot, True
|
||||
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
while self._running_agents and asyncio.get_running_loop().time() < deadline:
|
||||
while (
|
||||
(self._running_agents or self._active_cron_job_count())
|
||||
and asyncio.get_running_loop().time() < deadline
|
||||
):
|
||||
_maybe_update_status()
|
||||
await asyncio.sleep(0.1)
|
||||
timed_out = bool(self._running_agents)
|
||||
timed_out = bool(self._running_agents) or bool(self._active_cron_job_count())
|
||||
_maybe_update_status(force=True)
|
||||
return snapshot, timed_out
|
||||
|
||||
@@ -7944,6 +7980,27 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug("process_registry.kill_all (%s) error: %s", phase, _e)
|
||||
try:
|
||||
# Any cron job still dispatched at this instant just had
|
||||
# its tool subprocess killed above (kill_all() has no
|
||||
# per-job-ID targeting — it's a global sweep). Its agent
|
||||
# thread is still alive in this process and may go on to
|
||||
# produce a plausible-looking final response from the
|
||||
# now-truncated tool output; mark the run interrupted so
|
||||
# the scheduler can never report that as success (#60432).
|
||||
# No-op when no cron job is in flight.
|
||||
from cron.scheduler import mark_running_jobs_interrupted
|
||||
_interrupted = mark_running_jobs_interrupted(
|
||||
f"Gateway shutdown ({phase}) killed the job's tool "
|
||||
"subprocess before the run finished."
|
||||
)
|
||||
if _interrupted:
|
||||
logger.warning(
|
||||
"Shutdown (%s): marked %d in-flight cron job(s) interrupted: %s",
|
||||
phase, len(_interrupted), ", ".join(_interrupted),
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug("mark_running_jobs_interrupted (%s) error: %s", phase, _e)
|
||||
try:
|
||||
from tools.async_delegation import interrupt_all as _interrupt_async
|
||||
_async_n = _interrupt_async(reason=f"gateway shutdown ({phase})")
|
||||
@@ -8004,16 +8061,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
except Exception as _e:
|
||||
logger.debug("pre-drain mark_resume_pending failed for %s: %s", _sk, _e)
|
||||
|
||||
_cron_at_start = self._active_cron_job_count()
|
||||
_drain_started_at = time.monotonic()
|
||||
active_agents, timed_out = await self._drain_active_agents(timeout)
|
||||
logger.info(
|
||||
"Shutdown phase: drain done at +%.2fs (drain took %.2fs, "
|
||||
"timed_out=%s, active_at_start=%d, active_now=%d)",
|
||||
"timed_out=%s, active_at_start=%d, active_now=%d, "
|
||||
"cron_at_start=%d, cron_now=%d)",
|
||||
_phase_elapsed(),
|
||||
time.monotonic() - _drain_started_at,
|
||||
timed_out,
|
||||
len(active_agents),
|
||||
self._running_agent_count(),
|
||||
_cron_at_start,
|
||||
self._active_cron_job_count(),
|
||||
)
|
||||
|
||||
if not timed_out:
|
||||
@@ -8032,9 +8093,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
if timed_out:
|
||||
logger.warning(
|
||||
"Gateway drain timed out after %.1fs with %d active agent(s); interrupting remaining work.",
|
||||
"Gateway drain timed out after %.1fs with %d active agent(s) "
|
||||
"and %d in-flight cron job(s); interrupting remaining work.",
|
||||
timeout,
|
||||
self._running_agent_count(),
|
||||
self._active_cron_job_count(),
|
||||
)
|
||||
# Mark forcibly-interrupted sessions as resume_pending BEFORE
|
||||
# interrupting the agents. This preserves each session's
|
||||
@@ -10570,6 +10633,46 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
session_key = session_entry.session_key
|
||||
pinned_session_id = str(
|
||||
(getattr(event, "metadata", None) or {}).get("gateway_session_id") or ""
|
||||
).strip()
|
||||
if pinned_session_id and pinned_session_id != session_entry.session_id:
|
||||
# Fail closed (#55578): the spawning session may have ENDED since
|
||||
# dispatch (user /new-reset, compression rotation whose parent was
|
||||
# closed). switch_session() re-opens ended sessions, so pinning
|
||||
# blindly would RESURRECT a conversation the user explicitly
|
||||
# ended and inject into it — the same illicit-revival class as
|
||||
# the ws_orphan_reap loop (#60609). A completion whose spawning
|
||||
# session is dead is dropped from injection; the subagent's
|
||||
# output remains in the delegation records.
|
||||
pinned_row = None
|
||||
try:
|
||||
if self._session_db is not None:
|
||||
# AsyncSessionDB already offloads to a thread.
|
||||
pinned_row = await self._session_db.get_session(pinned_session_id)
|
||||
except Exception:
|
||||
pinned_row = None
|
||||
if pinned_row is None or pinned_row.get("ended_at"):
|
||||
logger.warning(
|
||||
"Async-delegation completion pinned to session %s, which is "
|
||||
"%s — dropping injection instead of resurrecting it "
|
||||
"(#55578 fail-closed; result remains in the delegation "
|
||||
"records).",
|
||||
pinned_session_id,
|
||||
"unknown" if pinned_row is None else "ended",
|
||||
)
|
||||
return
|
||||
prior_session_id = session_entry.session_id
|
||||
switched = self.session_store.switch_session(session_key, pinned_session_id)
|
||||
if switched is not None:
|
||||
session_entry = switched
|
||||
logger.info(
|
||||
"Pinned async-delegation completion to spawning session %s "
|
||||
"(was %s) for routing key %s (#57498)",
|
||||
pinned_session_id,
|
||||
prior_session_id,
|
||||
session_key,
|
||||
)
|
||||
self._cache_session_source(session_key, source)
|
||||
if await asyncio.to_thread(self._is_telegram_topic_lane, source):
|
||||
try:
|
||||
@@ -15080,6 +15183,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
chat_type = str(evt.get("chat_type") or derived_chat_type or "").strip().lower()
|
||||
chat_id = str(evt.get("chat_id") or derived_chat_id or "").strip()
|
||||
if not platform_name or not chat_type or not chat_id:
|
||||
logger.warning(
|
||||
"Synthetic event source unresolvable: "
|
||||
"session_key=%r platform=%r chat_type=%r chat_id=%r "
|
||||
"evt_type=%s",
|
||||
session_key, platform_name, chat_type, chat_id,
|
||||
evt.get("type", "?"),
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -15132,12 +15242,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if not adapter:
|
||||
return
|
||||
try:
|
||||
metadata = {}
|
||||
parent_session_id = str(evt.get("parent_session_id") or "").strip()
|
||||
if parent_session_id:
|
||||
metadata["gateway_session_id"] = parent_session_id
|
||||
synth_event = MessageEvent(
|
||||
text=synth_text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
internal=True,
|
||||
message_id=str(evt.get("message_id") or "").strip() or None,
|
||||
metadata=metadata,
|
||||
)
|
||||
logger.info(
|
||||
"Watch pattern notification — injecting for %s chat=%s thread=%s",
|
||||
@@ -18240,11 +18355,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"conversation context (possible FTS write corruption)",
|
||||
session_key, len(agent_history), len(_selected),
|
||||
)
|
||||
# The live in-memory history bypassed the replay-cleanup
|
||||
# pass inside _build_gateway_agent_history — re-apply the
|
||||
# stale-confirmation expiry (#59607) so a dangerous
|
||||
# confirmation can't slip through this path either.
|
||||
# Idempotent; messages without timestamps are untouched.
|
||||
# The live in-memory history bypassed the
|
||||
# _build_gateway_agent_history cleanup pipeline above —
|
||||
# re-apply the stale-confirmation expiry (#59607) so a
|
||||
# dangerous confirmation can't slip through this path
|
||||
# either. Idempotent; messages without timestamps are
|
||||
# untouched.
|
||||
agent_history = _strip_stale_dangerous_confirmations(
|
||||
_selected, now=time.time()
|
||||
)
|
||||
|
||||
@@ -79,6 +79,13 @@ _SESSION_USER_ID: ContextVar = ContextVar("HERMES_SESSION_USER_ID", default=_UNS
|
||||
_SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET)
|
||||
_SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET)
|
||||
_SESSION_ID: ContextVar = ContextVar("HERMES_SESSION_ID", default=_UNSET)
|
||||
# In-process UI session/window id for multi-session desktop/TUI hosts. This is
|
||||
# intentionally separate from HERMES_SESSION_ID: the latter is the durable
|
||||
# conversation/session-db id, while the UI id is the live frontend tab/window
|
||||
# that commissioned a detached completion. Background completions use it as a
|
||||
# precise return address so a stale/rotated durable session key cannot be
|
||||
# consumed by whichever desktop poller wakes first.
|
||||
_SESSION_UI_SESSION_ID: ContextVar = ContextVar("HERMES_UI_SESSION_ID", default=_UNSET)
|
||||
# ID of the message that triggered the current turn. Used as a reply anchor
|
||||
# so background-process notifications stay inside the originating Telegram
|
||||
# private-chat topic (those lanes route only with thread id + reply anchor).
|
||||
@@ -123,6 +130,7 @@ _VAR_MAP = {
|
||||
"HERMES_SESSION_USER_NAME": _SESSION_USER_NAME,
|
||||
"HERMES_SESSION_KEY": _SESSION_KEY,
|
||||
"HERMES_SESSION_ID": _SESSION_ID,
|
||||
"HERMES_UI_SESSION_ID": _SESSION_UI_SESSION_ID,
|
||||
"HERMES_SESSION_MESSAGE_ID": _SESSION_MESSAGE_ID,
|
||||
"HERMES_SESSION_PROFILE": _SESSION_PROFILE,
|
||||
"HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM,
|
||||
@@ -160,6 +168,7 @@ def set_session_vars(
|
||||
profile: str = "",
|
||||
cwd: str = "",
|
||||
async_delivery: bool = True,
|
||||
ui_session_id: str = "",
|
||||
) -> list:
|
||||
"""Set all session context variables and return reset tokens.
|
||||
|
||||
@@ -191,6 +200,7 @@ def set_session_vars(
|
||||
_SESSION_USER_NAME.set(user_name),
|
||||
_SESSION_KEY.set(session_key),
|
||||
_SESSION_ID.set(session_id),
|
||||
_SESSION_UI_SESSION_ID.set(ui_session_id),
|
||||
_SESSION_MESSAGE_ID.set(message_id),
|
||||
_SESSION_PROFILE.set(profile),
|
||||
_SESSION_ASYNC_DELIVERY.set(bool(async_delivery)),
|
||||
@@ -225,6 +235,7 @@ def clear_session_vars(tokens: list) -> None:
|
||||
_SESSION_USER_NAME,
|
||||
_SESSION_KEY,
|
||||
_SESSION_ID,
|
||||
_SESSION_UI_SESSION_ID,
|
||||
_SESSION_MESSAGE_ID,
|
||||
_SESSION_PROFILE,
|
||||
):
|
||||
|
||||
@@ -167,6 +167,24 @@ class GatewaySlashCommandsMixin:
|
||||
if _qe is not None:
|
||||
_qe.pop(session_key, None)
|
||||
|
||||
# The old conversation's in-flight async delegations end WITH it
|
||||
# (#55578): after the reset rotates the session id, their completions
|
||||
# would have no live owner — a dangling subagent can only burn tokens
|
||||
# and park an orphaned payload on the shared queue. Interrupt by the
|
||||
# expiring durable session id (delegations dispatched from gateway
|
||||
# chats are pinned to it via parent_session_id) and by the routing
|
||||
# key as a fallback for older records.
|
||||
try:
|
||||
from tools.async_delegation import interrupt_for_session
|
||||
|
||||
interrupt_for_session(
|
||||
session_key=session_key,
|
||||
parent_session_id=str(getattr(old_entry, "session_id", "") or ""),
|
||||
reason="session_reset",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from tools.env_passthrough import clear_env_passthrough
|
||||
clear_env_passthrough()
|
||||
|
||||
@@ -14,8 +14,8 @@ Provides subcommands for:
|
||||
import os
|
||||
import sys
|
||||
|
||||
__version__ = "0.18.0"
|
||||
__release_date__ = "2026.7.1"
|
||||
__version__ = "0.18.2"
|
||||
__release_date__ = "2026.7.7.2"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
|
||||
+44
-2
@@ -701,14 +701,29 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) ->
|
||||
if detected and detected.get("base_url"):
|
||||
# Persist the detection result keyed on the API key hash.
|
||||
key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16]
|
||||
state["detected_endpoint"] = {
|
||||
detected_endpoint = {
|
||||
"base_url": detected["base_url"],
|
||||
"endpoint_id": detected.get("id", ""),
|
||||
"model": detected.get("model", ""),
|
||||
"label": detected.get("label", ""),
|
||||
"key_hash": key_hash,
|
||||
}
|
||||
_save_provider_state(auth_store, "zai", state)
|
||||
# Persist failure (disk full, permissions, lock timeout) must not
|
||||
# break resolution — detection already succeeded; worst case the
|
||||
# next start re-probes.
|
||||
try:
|
||||
with _auth_store_lock():
|
||||
# Reload auth_store under lock to avoid overwriting concurrent changes
|
||||
auth_store = _load_auth_store()
|
||||
state_under_lock = _load_provider_state(auth_store, "zai") or {}
|
||||
state_under_lock["detected_endpoint"] = detected_endpoint
|
||||
# set_active=False: this runs from credential-pool env seeding
|
||||
# (agent/credential_pool.py) for ANY user with a Z.AI key in env,
|
||||
# and caching a probe result must not flip their active provider.
|
||||
_store_provider_state(auth_store, "zai", state_under_lock, set_active=False)
|
||||
_save_auth_store(auth_store)
|
||||
except Exception as exc:
|
||||
logger.warning("Z.AI: could not persist detected endpoint (%s); will re-probe next start", exc)
|
||||
logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"])
|
||||
return detected["base_url"]
|
||||
|
||||
@@ -1495,6 +1510,33 @@ def is_provider_explicitly_configured(provider_id: str) -> bool:
|
||||
if has_usable_secret(os.getenv(env_var, "")):
|
||||
return True
|
||||
|
||||
# 4. Check persisted credential-pool entries that came from EXPLICIT flows
|
||||
# the user initiated inside Hermes (manual add / device-code / PKCE), plus
|
||||
# env-backed pool entries. This intentionally excludes ambient borrowed
|
||||
# sources like gh_cli / claude_code / qwen-cli.
|
||||
try:
|
||||
for entry in read_credential_pool(normalized):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
source = str(entry.get("source") or "").strip().lower()
|
||||
if not source:
|
||||
continue
|
||||
if source.startswith("env:"):
|
||||
# A stale env-seeded pool entry survives in auth.json after
|
||||
# the user deletes the env var (#55790) — only count it when
|
||||
# the referenced var still resolves to a usable secret NOW.
|
||||
env_var = entry.get("source", "").split(":", 1)[1].strip()
|
||||
if env_var and has_usable_secret(os.getenv(env_var, "")):
|
||||
return True
|
||||
continue
|
||||
if (
|
||||
source in {"device_code", "loopback_pkce", "hermes_pkce", "manual"}
|
||||
or source.startswith("manual:")
|
||||
):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
||||
+17
-12
@@ -2,7 +2,6 @@
|
||||
|
||||
Pure display functions with no HermesCLI state dependency.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -322,8 +321,8 @@ def check_for_updates() -> Optional[int]:
|
||||
# both the Rich banner (build_welcome_banner) and the Ink badge
|
||||
# (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method
|
||||
if detect_install_method() == "docker":
|
||||
from hermes_cli.config import detect_install_method, get_project_root
|
||||
if detect_install_method(get_project_root()) == "docker":
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
@@ -889,17 +888,23 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
|
||||
except Exception:
|
||||
pass # Never break the banner over an update check
|
||||
|
||||
# Pip-install warning — `pip install hermes-agent` is not the supported
|
||||
# install path (it exists on PyPI for internal/CI reasons, not end users).
|
||||
# Such installs miss the git checkout + installer-managed deps, so updates,
|
||||
# self-update, and issue triage don't behave correctly. Warn, don't block.
|
||||
# Unsupported install-method warning — pip/PyPI and Homebrew are no
|
||||
# longer an officially supported distribution method (see
|
||||
# website/docs/getting-started/platform-support.md). Such installs miss
|
||||
# the git checkout + installer-managed deps, so updates, self-update, and
|
||||
# issue triage don't behave correctly. Warn, don't block. NixOS is fully
|
||||
# supported and never hits this.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method
|
||||
if detect_install_method() == "pip":
|
||||
from hermes_cli.config import (
|
||||
detect_install_method,
|
||||
format_unsupported_install_warning,
|
||||
is_unsupported_install_method,
|
||||
get_project_root
|
||||
)
|
||||
_install_method = detect_install_method(get_project_root())
|
||||
if is_unsupported_install_method(_install_method):
|
||||
right_lines.append(
|
||||
"[bold yellow]⚠ pip install not officially supported[/]"
|
||||
"[dim yellow] — exists for reasons other than user install; "
|
||||
"expect instability and an inability to support issues[/]"
|
||||
f"[bold yellow]⚠ {format_unsupported_install_warning(_install_method)}[/]"
|
||||
)
|
||||
except Exception:
|
||||
pass # Never break the banner over the install-method check
|
||||
|
||||
+63
-2
@@ -441,8 +441,20 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||
managed = get_managed_system()
|
||||
if managed:
|
||||
return managed.lower().replace(" ", "-")
|
||||
if (root / ".git").is_dir():
|
||||
|
||||
# detect git repo installs (normal installer, development env)
|
||||
git_path = root / ".git"
|
||||
if git_path.is_dir():
|
||||
return "git"
|
||||
|
||||
# detect git repo installs from worktrees
|
||||
if git_path.is_file():
|
||||
try:
|
||||
content = git_path.read_text(encoding="utf-8").strip()
|
||||
if content.startswith("gitdir:"):
|
||||
return "git"
|
||||
except OSError:
|
||||
pass
|
||||
return "pip"
|
||||
|
||||
|
||||
@@ -528,10 +540,54 @@ def recommended_update_command() -> str:
|
||||
managed_cmd = get_managed_update_command()
|
||||
if managed_cmd:
|
||||
return managed_cmd
|
||||
method = detect_install_method()
|
||||
method = detect_install_method(get_project_root())
|
||||
return recommended_update_command_for_method(method)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Unsupported install methods (pip, Homebrew) — deprecation notice
|
||||
# =============================================================================
|
||||
#
|
||||
# pip/PyPI and Homebrew are NOT an officially supported distribution method
|
||||
# (see website/docs/getting-started/platform-support.md, "Unsupported"
|
||||
# section). pip exists on PyPI for internal/CI reasons, not end-user installs;
|
||||
# Homebrew is a legacy packaging path. Unlike NixOS/Homebrew "managed mode"
|
||||
# (which hard-blocks config writes), this is a warn-don't-block deprecation
|
||||
# notice surfaced everywhere the user might see install-method state: the CLI
|
||||
# banner, the TUI/desktop session info panel, and ``hermes update``. NixOS
|
||||
# stays fully supported (Tier 2) and must never hit this path.
|
||||
|
||||
PLATFORM_SUPPORT_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/getting-started/platform-support"
|
||||
|
||||
_UNSUPPORTED_INSTALL_METHODS = frozenset({"pip", "homebrew"})
|
||||
|
||||
|
||||
def is_unsupported_install_method(method: str) -> bool:
|
||||
"""Whether ``method`` (from ``detect_install_method()``) is deprecated."""
|
||||
return method in _UNSUPPORTED_INSTALL_METHODS
|
||||
|
||||
|
||||
def unsupported_install_method_label(method: str) -> str:
|
||||
"""Human-readable name for an unsupported install method."""
|
||||
return "pip" if method == "pip" else "Homebrew"
|
||||
|
||||
|
||||
def format_unsupported_install_warning(method: str) -> str:
|
||||
"""Plain-text (no markup) deprecation notice for pip/Homebrew installs.
|
||||
|
||||
Shared verbatim across the CLI banner, TUI/desktop ``session.info``, and
|
||||
``hermes update`` / ``hermes update --check`` so the wording — and the
|
||||
docs link — stays consistent across every surface instead of drifting
|
||||
into three slightly different warnings.
|
||||
"""
|
||||
label = unsupported_install_method_label(method)
|
||||
return (
|
||||
f"{label} installs are no longer an officially supported platform and "
|
||||
f"will not receive further updates. See {PLATFORM_SUPPORT_DOCS_URL} "
|
||||
"for supported install methods."
|
||||
)
|
||||
|
||||
|
||||
# Long-form text for ``hermes update`` / ``--check`` when running inside the
|
||||
# Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in
|
||||
# hermes_cli/main.py; lives here so the wording stays consistent and we
|
||||
@@ -2384,6 +2440,11 @@ DEFAULT_CONFIG = {
|
||||
# real memory cost. Default 32 MiB matches the historical hardcoded
|
||||
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
|
||||
"max_attachment_bytes": 33554432,
|
||||
# When True, Discord approval prompts mention numeric allowed users so
|
||||
# owners notice approval requests in shared channels/threads. Env
|
||||
# override: DISCORD_APPROVAL_MENTIONS. Default false avoids surprise
|
||||
# pings.
|
||||
"approval_mentions": False,
|
||||
# Voice-channel audio effects (the continuous mixer). OFF by default.
|
||||
# When enabled, the bot installs a software mixer on the outgoing voice
|
||||
# stream so a low ambient "thinking" bed, verbal acknowledgements, and
|
||||
|
||||
+23
-10
@@ -4488,16 +4488,29 @@ def _guard_named_profile_under_multiplexer(force: bool = False) -> None:
|
||||
if not pid or not _pid_exists(pid):
|
||||
return
|
||||
|
||||
# (c) default config has multiplexing on
|
||||
cfg_path = default_root / "config.yaml"
|
||||
if not cfg_path.exists():
|
||||
return
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
cfg = _yaml.safe_load(f) or {}
|
||||
multiplex = bool(
|
||||
cfg.get("multiplex_profiles")
|
||||
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
|
||||
)
|
||||
# (c) multiplexing is on for the default gateway. Precedence mirrors
|
||||
# gateway.config: the GATEWAY_MULTIPLEX_PROFILES env override wins over
|
||||
# config.yaml when set to a recognized value, so a hosted gateway that
|
||||
# forces multiplex on via env (with no multiplex_profiles in config.yaml)
|
||||
# still trips this guard. A blank/unrecognized env value falls through
|
||||
# to config.yaml.
|
||||
from gateway.config import _env_multiplex_profiles_override
|
||||
|
||||
env_multiplex = _env_multiplex_profiles_override()
|
||||
if env_multiplex is False:
|
||||
return # explicitly forced OFF by the operator env override
|
||||
if env_multiplex is True:
|
||||
multiplex = True
|
||||
else:
|
||||
cfg_path = default_root / "config.yaml"
|
||||
if not cfg_path.exists():
|
||||
return
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
cfg = _yaml.safe_load(f) or {}
|
||||
multiplex = bool(
|
||||
cfg.get("multiplex_profiles")
|
||||
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
|
||||
)
|
||||
if not multiplex:
|
||||
return
|
||||
except Exception:
|
||||
|
||||
@@ -35,6 +35,7 @@ import os
|
||||
import socket
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
@@ -85,6 +86,20 @@ def _resolve_connector_url(override: Optional[str]) -> Optional[str]:
|
||||
return raw
|
||||
|
||||
|
||||
def _resolve_identity_token() -> str:
|
||||
"""Resolve the caller-identity bearer token (generic-OIDC or Nous Portal).
|
||||
|
||||
Delegates to the canonical resolver in ``gateway.relay`` so the enroll CLI and
|
||||
the runtime self-provision path share ONE implementation (generic OAuth2
|
||||
client-credentials when ``gateway.idp.token_url`` is set — the air-gapped /
|
||||
self-hosted-IdP path; otherwise Nous Portal). Raises RuntimeError on failure.
|
||||
"""
|
||||
from gateway.relay import _resolve_relay_identity_token
|
||||
|
||||
return _resolve_relay_identity_token()
|
||||
|
||||
|
||||
|
||||
def _post_enroll(
|
||||
*,
|
||||
connector_base_url: str,
|
||||
@@ -179,9 +194,11 @@ def cmd_gateway_enroll(args) -> None:
|
||||
|
||||
gateway_id = (getattr(args, "gateway_id", None) or _default_gateway_id()).strip()
|
||||
|
||||
# 1. Resolve a fresh Nous access token (the tenant-proving identity).
|
||||
# 1. Resolve the caller-identity token (the tenant-proving identity). Generic
|
||||
# OIDC client-credentials when an IdP token endpoint is configured (air-
|
||||
# gapped / self-hosted-IdP, NO Nous Portal); otherwise the Nous Portal token.
|
||||
try:
|
||||
access_token = resolve_nous_access_token()
|
||||
access_token = _resolve_identity_token()
|
||||
except AuthError as exc:
|
||||
if getattr(exc, "relogin_required", False):
|
||||
print("✗ You're not logged into Nous Portal.")
|
||||
@@ -190,7 +207,7 @@ def cmd_gateway_enroll(args) -> None:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
print(f"✗ Could not resolve a caller-identity token: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
# 2-3. Redeem the enrollment token at the connector.
|
||||
|
||||
@@ -111,6 +111,7 @@ def load_picker_context() -> ConfigContext:
|
||||
def build_models_payload(
|
||||
ctx: ConfigContext,
|
||||
*,
|
||||
explicit_only: bool = False,
|
||||
include_unconfigured: bool = False,
|
||||
picker_hints: bool = False,
|
||||
canonical_order: bool = False,
|
||||
@@ -126,6 +127,10 @@ def build_models_payload(
|
||||
needs from a single substrate call.
|
||||
|
||||
Flags:
|
||||
- ``explicit_only``: keep only providers the user explicitly configured
|
||||
(current provider, providers from config, or providers backed by
|
||||
provider-specific env vars). This hides ambient / auto-seeded
|
||||
credentials from desktop chat pickers.
|
||||
- ``include_unconfigured``: append ``CANONICAL_PROVIDERS`` rows that
|
||||
``list_authenticated_providers`` didn't emit (TUI uses this to show
|
||||
the full provider universe in the picker).
|
||||
@@ -180,6 +185,9 @@ def build_models_payload(
|
||||
if moa_row is not None:
|
||||
rows = [moa_row] + [r for r in rows if str(r.get("slug", "")).lower() != "moa"]
|
||||
|
||||
if explicit_only:
|
||||
rows = _filter_explicit_provider_rows(rows, ctx)
|
||||
|
||||
# --- Deduplicate: remove models from aggregators that overlap with
|
||||
# user-defined providers. When a local proxy (e.g. litellm-proxy)
|
||||
# serves a model whose name also appears in an aggregator's curated
|
||||
@@ -308,6 +316,37 @@ def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict
|
||||
return extras
|
||||
|
||||
|
||||
def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:
|
||||
"""Keep only rows backed by explicit user configuration.
|
||||
|
||||
``list_authenticated_providers`` intentionally discovers ambient / auto-
|
||||
seeded credentials (for example GitHub CLI -> Copilot). Desktop chat model
|
||||
pickers want the narrower subset the user explicitly configured for Hermes.
|
||||
"""
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
|
||||
current_slug = str(ctx.current_provider or "").strip().lower()
|
||||
kept: list[dict] = []
|
||||
for row in rows:
|
||||
slug = str(row.get("slug", "")).strip().lower()
|
||||
if not slug:
|
||||
continue
|
||||
if row.get("is_user_defined"):
|
||||
kept.append(row)
|
||||
continue
|
||||
if current_slug and slug == current_slug:
|
||||
kept.append(row)
|
||||
continue
|
||||
if slug == "moa":
|
||||
# MoA is a virtual routing mode, not an independently configured
|
||||
# provider. Hide it from explicit-only pickers unless it is the
|
||||
# current provider (handled above).
|
||||
continue
|
||||
if is_provider_explicitly_configured(slug):
|
||||
kept.append(row)
|
||||
return kept
|
||||
|
||||
|
||||
def _apply_picker_hints(rows: list[dict]) -> None:
|
||||
"""Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row.
|
||||
|
||||
|
||||
+507
-41
@@ -229,9 +229,9 @@ def _read_openai_version_fast() -> str | None:
|
||||
def _print_fast_version_info() -> None:
|
||||
from hermes_cli import __release_date__, __version__
|
||||
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
print(f"Hermes Agent v{__version__} ({__release_date__})")
|
||||
print(f"Project: {project_root}")
|
||||
print(f"Install directory: {PROJECT_ROOT}")
|
||||
|
||||
print(f"Python: {sys.version.split()[0]}")
|
||||
|
||||
openai_version = _read_openai_version_fast()
|
||||
@@ -4329,10 +4329,12 @@ def cmd_import(args):
|
||||
|
||||
|
||||
def _print_version_info(*, check_updates: bool = True) -> None:
|
||||
from hermes_cli.config import detect_install_method
|
||||
from hermes_cli.banner import format_banner_version_label
|
||||
|
||||
print(format_banner_version_label())
|
||||
print(f"Project: {PROJECT_ROOT}")
|
||||
print(f"Install directory: {PROJECT_ROOT}")
|
||||
print(f"Install method: {detect_install_method(PROJECT_ROOT)}")
|
||||
|
||||
# Show Python version
|
||||
print(f"Python: {sys.version.split()[0]}")
|
||||
@@ -8371,8 +8373,14 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
|
||||
on a PyPI install we surface a one-line notice instead of silently
|
||||
dropping the flag.
|
||||
"""
|
||||
from hermes_cli.config import detect_install_method
|
||||
from hermes_cli.config import (
|
||||
detect_install_method,
|
||||
format_unsupported_install_warning,
|
||||
is_unsupported_install_method,
|
||||
)
|
||||
method = detect_install_method(PROJECT_ROOT)
|
||||
if is_unsupported_install_method(method):
|
||||
print(f"⚠ {format_unsupported_install_warning(method)}")
|
||||
if method == "docker":
|
||||
# Docker can't ``git fetch`` from within the container. Surface the
|
||||
# same long-form ``docker pull`` guidance ``hermes update`` (apply
|
||||
@@ -9255,10 +9263,21 @@ def cmd_update(args):
|
||||
from hermes_cli.config import (
|
||||
detect_install_method,
|
||||
format_docker_update_message,
|
||||
format_unsupported_install_warning,
|
||||
is_managed,
|
||||
is_unsupported_install_method,
|
||||
managed_error,
|
||||
)
|
||||
|
||||
# Deprecation notice for pip/Homebrew installs — printed before the
|
||||
# managed-mode early-return below so Homebrew users (who are blocked from
|
||||
# applying the update here) still see it. Warn, don't block: the update
|
||||
# itself still proceeds (except Homebrew, which is managed-mode blocked
|
||||
# for an unrelated reason — brew owns its own upgrade path).
|
||||
_install_method_for_warning = detect_install_method(PROJECT_ROOT)
|
||||
if is_unsupported_install_method(_install_method_for_warning):
|
||||
print(f"⚠ {format_unsupported_install_warning(_install_method_for_warning)}")
|
||||
|
||||
if is_managed():
|
||||
managed_error("update Hermes Agent")
|
||||
return
|
||||
@@ -13433,23 +13452,6 @@ def main():
|
||||
"--limit", type=int, default=20, help="Max sessions to show"
|
||||
)
|
||||
|
||||
sessions_export = sessions_subparsers.add_parser(
|
||||
"export", help="Export sessions to a JSONL file"
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"output", help="Output JSONL file path (use - for stdout)"
|
||||
)
|
||||
sessions_export.add_argument("--source", help="Filter by source")
|
||||
sessions_export.add_argument("--session-id", help="Export a specific session")
|
||||
|
||||
sessions_delete = sessions_subparsers.add_parser(
|
||||
"delete", help="Delete a specific session"
|
||||
)
|
||||
sessions_delete.add_argument("session_id", help="Session ID to delete")
|
||||
sessions_delete.add_argument(
|
||||
"--yes", "-y", action="store_true", help="Skip confirmation"
|
||||
)
|
||||
|
||||
def _add_session_filter_args(p, default_older_help):
|
||||
p.add_argument(
|
||||
"--older-than",
|
||||
@@ -13547,6 +13549,93 @@ def main():
|
||||
"--yes", "-y", action="store_true", help="Skip confirmation"
|
||||
)
|
||||
|
||||
sessions_export = sessions_subparsers.add_parser(
|
||||
"export", help="Export sessions to JSONL, Markdown, or QMD"
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"output",
|
||||
nargs="?",
|
||||
help=(
|
||||
"Output path. JSONL: file path (use - for stdout, required). "
|
||||
"md/qmd: output directory (default: <hermes home>/session-exports)"
|
||||
),
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"--format",
|
||||
choices=["jsonl", "md", "qmd", "html", "trace"],
|
||||
default="jsonl",
|
||||
help=(
|
||||
"Export format (default: jsonl). 'trace' emits Claude Code JSONL "
|
||||
"for the Hugging Face Agent Trace Viewer"
|
||||
),
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"--upload",
|
||||
action="store_true",
|
||||
help=(
|
||||
"trace only: upload to your Hugging Face traces dataset instead "
|
||||
"of writing a local file (needs HF_TOKEN)"
|
||||
),
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"--public",
|
||||
action="store_true",
|
||||
help="trace --upload only: create/update a public dataset instead of private",
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"--no-redact",
|
||||
action="store_true",
|
||||
help=(
|
||||
"trace only: skip the forced secret redaction; "
|
||||
"only use after manual review"
|
||||
),
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"--only",
|
||||
choices=["user-prompts"],
|
||||
help=(
|
||||
"Export only a filtered view (user-prompts: one prompt record "
|
||||
"per line for jsonl, headed sections for md)"
|
||||
),
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"--session-id", help="Session ID or unique prefix to export"
|
||||
)
|
||||
_add_session_filter_args(
|
||||
sessions_export,
|
||||
"Only export sessions older than AGE (duration like '5h'/'2d', "
|
||||
"bare number of days, or an ISO timestamp)",
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"--redact",
|
||||
action="store_true",
|
||||
help="Redact secrets (API keys, tokens, credentials) from exported content",
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"--lineage",
|
||||
choices=["single", "logical"],
|
||||
default="single",
|
||||
help="md/qmd only: export one row or its compression lineage",
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"--delete-after-verified",
|
||||
action="store_true",
|
||||
help="md/qmd only: after verified single-session export, delete that session (needs --yes)",
|
||||
)
|
||||
sessions_export.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="md/qmd only: overwrite an existing export file",
|
||||
)
|
||||
|
||||
sessions_delete = sessions_subparsers.add_parser(
|
||||
"delete", help="Delete a specific session"
|
||||
)
|
||||
sessions_delete.add_argument("session_id", help="Session ID to delete")
|
||||
sessions_delete.add_argument(
|
||||
"--yes", "-y", action="store_true", help="Skip confirmation"
|
||||
)
|
||||
|
||||
sessions_prune = sessions_subparsers.add_parser(
|
||||
"prune",
|
||||
help="Delete old sessions (filterable by time window, source, title, ...)",
|
||||
@@ -13717,34 +13806,411 @@ def main():
|
||||
print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}")
|
||||
|
||||
elif action == "export":
|
||||
from hermes_cli.session_filters import (
|
||||
build_prune_filters,
|
||||
describe_filters,
|
||||
)
|
||||
|
||||
_filter_arg_names = (
|
||||
"older_than", "newer_than", "before", "after",
|
||||
"source", "title", "end_reason", "cwd",
|
||||
"min_messages", "max_messages", "model", "provider",
|
||||
"user", "chat_id", "chat_type", "branch",
|
||||
"min_tokens", "max_tokens", "min_cost", "max_cost",
|
||||
"min_tool_calls", "max_tool_calls",
|
||||
)
|
||||
_any_filters = any(
|
||||
getattr(args, a, None) is not None for a in _filter_arg_names
|
||||
)
|
||||
filters = None
|
||||
if _any_filters:
|
||||
try:
|
||||
filters = build_prune_filters(args)
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
return
|
||||
# Unlike prune/archive, export includes archived sessions.
|
||||
filters["archived"] = None
|
||||
|
||||
def _redact(data):
|
||||
if not args.redact or data is None:
|
||||
return data
|
||||
from hermes_cli.session_export_md import redact_session_data
|
||||
|
||||
return redact_session_data(data)
|
||||
|
||||
def _collect_sessions():
|
||||
"""Resolve --session-id / filters / bare export into a list
|
||||
of redacted session dicts, or None after printing an error."""
|
||||
if args.session_id:
|
||||
resolved = db.resolve_session_id(args.session_id)
|
||||
data = _redact(db.export_session(resolved)) if resolved else None
|
||||
if not data:
|
||||
print(f"Session '{args.session_id}' not found.")
|
||||
return None
|
||||
return [data]
|
||||
if filters:
|
||||
candidates = db.list_prune_candidates(**filters)
|
||||
if args.dry_run:
|
||||
print(
|
||||
f"Would export {len(candidates)} session(s) "
|
||||
f"({describe_filters(filters)})."
|
||||
)
|
||||
for row in candidates[:100]:
|
||||
print(f" {row.get('id')} {row.get('source', '')}")
|
||||
if len(candidates) > 100:
|
||||
print(f" ... {len(candidates) - 100} more")
|
||||
return None
|
||||
return [
|
||||
s
|
||||
for s in (
|
||||
_redact(db.export_session(row["id"])) for row in candidates
|
||||
)
|
||||
if s
|
||||
]
|
||||
if args.dry_run:
|
||||
print("--dry-run requires at least one filter.")
|
||||
return None
|
||||
return [_redact(s) for s in db.export_all(source=None)]
|
||||
|
||||
# Prompt-only export (--only user-prompts): one prompt record per
|
||||
# line (jsonl) or headed sections (md). Delegates rendering to
|
||||
# hermes_cli.session_export.
|
||||
if getattr(args, "only", None):
|
||||
if args.format not in ("jsonl", "md"):
|
||||
print("--only user-prompts supports --format jsonl or md.")
|
||||
return
|
||||
from hermes_cli.session_export import (
|
||||
export_record_count,
|
||||
render_sessions_export,
|
||||
)
|
||||
|
||||
sessions = _collect_sessions()
|
||||
if sessions is None:
|
||||
db.close()
|
||||
return
|
||||
rendered = render_sessions_export(
|
||||
sessions,
|
||||
fmt="markdown" if args.format == "md" else "jsonl",
|
||||
only=args.only,
|
||||
)
|
||||
if not args.output or args.output == "-":
|
||||
sys.stdout.write(rendered)
|
||||
db.close()
|
||||
return
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(rendered)
|
||||
count, noun = export_record_count(sessions, only=args.only)
|
||||
suffix = "" if count == 1 else "s"
|
||||
print(f"Exported {count} {noun}{suffix} to {args.output}")
|
||||
db.close()
|
||||
return
|
||||
|
||||
# Standalone HTML export: one self-contained file (single session
|
||||
# or multi-session with sidebar navigation).
|
||||
if args.format == "html":
|
||||
if not args.output or args.output == "-":
|
||||
print("HTML export requires an output file path.")
|
||||
return
|
||||
from hermes_cli.session_export_html import (
|
||||
generate_html_export,
|
||||
generate_multi_session_html_export,
|
||||
)
|
||||
|
||||
sessions = _collect_sessions()
|
||||
if sessions is None:
|
||||
db.close()
|
||||
return
|
||||
if len(sessions) == 1:
|
||||
content = generate_html_export(sessions[0])
|
||||
else:
|
||||
content = generate_multi_session_html_export(sessions)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
suffix = "" if len(sessions) == 1 else "s"
|
||||
print(f"Exported {len(sessions)} session{suffix} to {args.output} (HTML)")
|
||||
db.close()
|
||||
return
|
||||
|
||||
# Claude Code JSONL trace export — local file or HF upload.
|
||||
# Redaction is ON by default for traces (they leave the machine
|
||||
# when --upload is used); --no-redact opts out after review.
|
||||
if args.format == "trace":
|
||||
if getattr(args, "only", None):
|
||||
print("--only user-prompts supports --format jsonl or md.")
|
||||
db.close()
|
||||
return
|
||||
session_id = args.session_id
|
||||
if not session_id and not filters:
|
||||
# Match the shell's common intent: "the last thing I did".
|
||||
rows = db.list_sessions_rich(limit=1, order_by_last_active=True)
|
||||
session_id = rows[0].get("id") if rows else None
|
||||
if not session_id:
|
||||
print("No session found to export. Pass --session-id.")
|
||||
db.close()
|
||||
return
|
||||
if session_id and not db.resolve_session_id(session_id):
|
||||
print(f"Session '{session_id}' not found.")
|
||||
db.close()
|
||||
return
|
||||
|
||||
from agent.trace_upload import (
|
||||
TraceRedactionError,
|
||||
build_trace_jsonl,
|
||||
upload_session_trace,
|
||||
)
|
||||
|
||||
redact_trace = not getattr(args, "no_redact", False)
|
||||
|
||||
if getattr(args, "upload", False):
|
||||
if not session_id:
|
||||
print("--upload exports one session: pass --session-id (or drop filters to use the most recent).")
|
||||
db.close()
|
||||
return
|
||||
resolved = db.resolve_session_id(session_id)
|
||||
db.close()
|
||||
status = upload_session_trace(
|
||||
resolved,
|
||||
cwd="",
|
||||
redact=redact_trace,
|
||||
private=not getattr(args, "public", False),
|
||||
)
|
||||
print(status)
|
||||
return
|
||||
|
||||
# Local trace file(s)
|
||||
def _trace_ids():
|
||||
if session_id:
|
||||
return [db.resolve_session_id(session_id)]
|
||||
candidates = db.list_prune_candidates(**filters)
|
||||
if args.dry_run:
|
||||
print(
|
||||
f"Would export {len(candidates)} session(s) "
|
||||
f"({describe_filters(filters)})."
|
||||
)
|
||||
for row in candidates[:100]:
|
||||
print(f" {row.get('id')} {row.get('source', '')}")
|
||||
if len(candidates) > 100:
|
||||
print(f" ... {len(candidates) - 100} more")
|
||||
return None
|
||||
return [row["id"] for row in candidates]
|
||||
|
||||
ids = _trace_ids()
|
||||
if ids is None:
|
||||
db.close()
|
||||
return
|
||||
|
||||
def _render_trace(sid):
|
||||
meta = db.get_session(sid) or {}
|
||||
messages = db.get_messages_as_conversation(sid)
|
||||
if not messages:
|
||||
return None
|
||||
return build_trace_jsonl(
|
||||
messages,
|
||||
session_id=sid,
|
||||
model=meta.get("model") or "",
|
||||
cwd="",
|
||||
redact=redact_trace,
|
||||
)
|
||||
|
||||
try:
|
||||
if len(ids) == 1:
|
||||
jsonl = _render_trace(ids[0])
|
||||
if not jsonl:
|
||||
print(f"No transcript to export for session '{ids[0]}'.")
|
||||
db.close()
|
||||
return
|
||||
if not args.output or args.output == "-":
|
||||
sys.stdout.write(jsonl)
|
||||
else:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(jsonl)
|
||||
print(f"Exported 1 session trace to {args.output}")
|
||||
else:
|
||||
out_dir = (
|
||||
Path(args.output).expanduser()
|
||||
if args.output and args.output != "-"
|
||||
else get_hermes_home() / "session-exports"
|
||||
)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
exported = 0
|
||||
for sid in ids:
|
||||
jsonl = _render_trace(sid)
|
||||
if not jsonl:
|
||||
continue
|
||||
(out_dir / f"{sid}.trace.jsonl").write_text(
|
||||
jsonl, encoding="utf-8"
|
||||
)
|
||||
exported += 1
|
||||
print(f"Exported {exported} session trace(s) to {out_dir}")
|
||||
except TraceRedactionError:
|
||||
print("Redaction failed; refusing to export unredacted trace content.")
|
||||
db.close()
|
||||
return
|
||||
|
||||
if args.format == "jsonl":
|
||||
if not args.output:
|
||||
print("JSONL export requires an output path (use - for stdout).")
|
||||
return
|
||||
if args.session_id:
|
||||
resolved_session_id = db.resolve_session_id(args.session_id)
|
||||
if not resolved_session_id:
|
||||
print(f"Session '{args.session_id}' not found.")
|
||||
return
|
||||
data = _redact(db.export_session(resolved_session_id))
|
||||
if not data:
|
||||
print(f"Session '{args.session_id}' not found.")
|
||||
return
|
||||
line = _json.dumps(data, ensure_ascii=False) + "\n"
|
||||
if args.output == "-":
|
||||
|
||||
sys.stdout.write(line)
|
||||
else:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
print(f"Exported 1 session to {args.output}")
|
||||
else:
|
||||
if filters:
|
||||
candidates = db.list_prune_candidates(**filters)
|
||||
if args.dry_run:
|
||||
print(
|
||||
f"Would export {len(candidates)} session(s) "
|
||||
f"({describe_filters(filters)})."
|
||||
)
|
||||
for row in candidates[:100]:
|
||||
print(f" {row.get('id')} {row.get('source', '')}")
|
||||
if len(candidates) > 100:
|
||||
print(f" ... {len(candidates) - 100} more")
|
||||
return
|
||||
sessions = [
|
||||
s
|
||||
for s in (
|
||||
db.export_session(row["id"]) for row in candidates
|
||||
)
|
||||
if s
|
||||
]
|
||||
else:
|
||||
if args.dry_run:
|
||||
print("--dry-run requires at least one filter.")
|
||||
return
|
||||
sessions = db.export_all(source=None)
|
||||
if args.output == "-":
|
||||
|
||||
for s in sessions:
|
||||
sys.stdout.write(
|
||||
_json.dumps(_redact(s), ensure_ascii=False) + "\n"
|
||||
)
|
||||
else:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
for s in sessions:
|
||||
f.write(
|
||||
_json.dumps(_redact(s), ensure_ascii=False) + "\n"
|
||||
)
|
||||
print(f"Exported {len(sessions)} sessions to {args.output}")
|
||||
return
|
||||
|
||||
# Markdown / QMD export
|
||||
from hermes_cli.session_export_md import (
|
||||
append_manifest_entry,
|
||||
verify_export_file,
|
||||
write_session_markdown,
|
||||
)
|
||||
|
||||
if args.output == "-":
|
||||
print("Markdown/QMD export writes files; stdout (-) is only supported with --format jsonl.")
|
||||
db.close()
|
||||
return
|
||||
output_dir = Path(args.output).expanduser() if args.output else get_hermes_home() / "session-exports"
|
||||
|
||||
def _export_one(session_id: str):
|
||||
data = (
|
||||
db.export_session_lineage(session_id)
|
||||
if getattr(args, "lineage", "single") == "logical"
|
||||
else db.export_session(session_id)
|
||||
)
|
||||
if not data:
|
||||
return None, None
|
||||
data = _redact(data)
|
||||
path = write_session_markdown(
|
||||
data,
|
||||
output_dir,
|
||||
fmt=args.format,
|
||||
force=args.force,
|
||||
)
|
||||
append_manifest_entry(output_dir, data, path, fmt=args.format)
|
||||
return data, path
|
||||
|
||||
if args.delete_after_verified and not args.yes:
|
||||
print("--delete-after-verified requires --yes.")
|
||||
db.close()
|
||||
return
|
||||
if args.delete_after_verified and not args.session_id:
|
||||
print("--delete-after-verified is only supported with --session-id.")
|
||||
db.close()
|
||||
return
|
||||
|
||||
if args.session_id:
|
||||
resolved_session_id = db.resolve_session_id(args.session_id)
|
||||
if not resolved_session_id:
|
||||
print(f"Session '{args.session_id}' not found.")
|
||||
db.close()
|
||||
return
|
||||
data = db.export_session(resolved_session_id)
|
||||
if not data:
|
||||
try:
|
||||
data, exported_path = _export_one(resolved_session_id)
|
||||
except FileExistsError as e:
|
||||
print(f"Export already exists: {e}. Pass --force to overwrite.")
|
||||
db.close()
|
||||
return
|
||||
if not data or not exported_path:
|
||||
print(f"Session '{args.session_id}' not found.")
|
||||
db.close()
|
||||
return
|
||||
line = _json.dumps(data, ensure_ascii=False) + "\n"
|
||||
if args.output == "-":
|
||||
message_count = len(data.get("messages") or [])
|
||||
suffix = "" if message_count == 1 else "s"
|
||||
print(f"Exported 1 session ({message_count} message{suffix}) to {exported_path}")
|
||||
if args.delete_after_verified:
|
||||
ok, reason = verify_export_file(exported_path, data)
|
||||
if not ok:
|
||||
print(f"Export verification failed; not deleting: {reason}")
|
||||
db.close()
|
||||
return
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
if db.delete_session(resolved_session_id, sessions_dir=sessions_dir):
|
||||
print(f"Deleted exported session '{resolved_session_id}'.")
|
||||
else:
|
||||
print(f"Exported, but session '{resolved_session_id}' was not deleted because it was not found.")
|
||||
db.close()
|
||||
return
|
||||
|
||||
sys.stdout.write(line)
|
||||
else:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
print(f"Exported 1 session to {args.output}")
|
||||
else:
|
||||
sessions = db.export_all(source=args.source)
|
||||
if args.output == "-":
|
||||
|
||||
for s in sessions:
|
||||
sys.stdout.write(_json.dumps(s, ensure_ascii=False) + "\n")
|
||||
else:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
for s in sessions:
|
||||
f.write(_json.dumps(s, ensure_ascii=False) + "\n")
|
||||
print(f"Exported {len(sessions)} sessions to {args.output}")
|
||||
if not filters:
|
||||
print(
|
||||
"Refusing bulk export without a filter. Pass --session-id or "
|
||||
"at least one filter (e.g. --older-than 90, --source telegram)."
|
||||
)
|
||||
db.close()
|
||||
return
|
||||
candidates = db.list_prune_candidates(**filters)
|
||||
if args.dry_run:
|
||||
print(
|
||||
f"Would export {len(candidates)} session(s) "
|
||||
f"({describe_filters(filters)})."
|
||||
)
|
||||
for row in candidates[:100]:
|
||||
print(f" {row.get('id')} {row.get('source', '')}")
|
||||
if len(candidates) > 100:
|
||||
print(f" ... {len(candidates) - 100} more")
|
||||
db.close()
|
||||
return
|
||||
exported = 0
|
||||
for row in candidates:
|
||||
try:
|
||||
data, exported_path = _export_one(row["id"])
|
||||
except FileExistsError as e:
|
||||
print(f"Skipping existing export: {e}. Pass --force to overwrite.")
|
||||
continue
|
||||
if data and exported_path:
|
||||
exported += 1
|
||||
print(f"Exported {exported} session(s) to {output_dir}")
|
||||
|
||||
elif action == "delete":
|
||||
resolved_session_id = db.resolve_session_id(args.session_id)
|
||||
|
||||
@@ -67,7 +67,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
# Xiaomi
|
||||
("xiaomi/mimo-v2.5-pro", ""),
|
||||
# Tencent
|
||||
("tencent/hy3-preview", ""),
|
||||
("tencent/hy3", ""),
|
||||
# StepFun
|
||||
("stepfun/step-3.7-flash", ""),
|
||||
# NVIDIA
|
||||
@@ -78,9 +78,8 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
|
||||
# Free tier
|
||||
("openrouter/elephant-alpha", "free"),
|
||||
("openrouter/owl-alpha", "free"),
|
||||
("poolside/laguna-m.1:free", "free"),
|
||||
("tencent/hy3-preview:free", "free"),
|
||||
("tencent/hy3:free", "free"),
|
||||
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
|
||||
("nvidia/nemotron-3-ultra-550b-a55b:free", "free"),
|
||||
("inclusionai/ring-2.6-1t:free", "free"),
|
||||
@@ -211,7 +210,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
# Xiaomi
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
# Tencent
|
||||
"tencent/hy3-preview",
|
||||
"tencent/hy3",
|
||||
# StepFun
|
||||
"stepfun/step-3.7-flash",
|
||||
# NVIDIA
|
||||
|
||||
+147
-12
@@ -2077,6 +2077,13 @@ def has_hook(hook_name: str) -> bool:
|
||||
_thread_tool_whitelist = threading.local()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PreToolCallDirective:
|
||||
action: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
rule_key: Optional[str] = None
|
||||
|
||||
|
||||
def set_thread_tool_whitelist(
|
||||
allowed: Optional[Set[str]],
|
||||
deny_msg_fmt: str = "Tool '{tool_name}' denied: not in this thread's tool whitelist",
|
||||
@@ -2089,7 +2096,7 @@ def clear_thread_tool_whitelist() -> None:
|
||||
_thread_tool_whitelist.allowed = None
|
||||
|
||||
|
||||
def get_pre_tool_call_block_message(
|
||||
def _get_pre_tool_call_directive_details(
|
||||
tool_name: str,
|
||||
args: Optional[Dict[str, Any]],
|
||||
task_id: str = "",
|
||||
@@ -2098,22 +2105,40 @@ def get_pre_tool_call_block_message(
|
||||
turn_id: str = "",
|
||||
api_request_id: str = "",
|
||||
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Check ``pre_tool_call`` hooks for a blocking directive.
|
||||
) -> _PreToolCallDirective:
|
||||
"""Check ``pre_tool_call`` hooks for a blocking or approval directive.
|
||||
|
||||
Plugins that need to enforce policy (rate limiting, security
|
||||
restrictions, approval workflows) can return::
|
||||
restrictions, approval workflows) can return one of::
|
||||
|
||||
{"action": "block", "message": "Reason the tool was blocked"}
|
||||
{"action": "block", "message": "Reason the tool was blocked"}
|
||||
{"action": "approve", "message": "Why this needs human confirmation"}
|
||||
{"action": "approve", "message": "...", "rule_key": "write_file:ssh"}
|
||||
|
||||
from their ``pre_tool_call`` callback. The first valid block
|
||||
directive wins. Invalid or irrelevant hook return values are
|
||||
silently ignored so existing observer-only hooks are unaffected.
|
||||
from their ``pre_tool_call`` callback.
|
||||
|
||||
- ``block`` vetoes the tool call outright (the message becomes the tool
|
||||
result the model sees).
|
||||
- ``approve`` ESCALATES to the existing human-approval gate
|
||||
(``prompt_dangerous_approval`` on CLI, the approval callback on the
|
||||
gateway) — the same mechanism Tier-2 dangerous shell patterns use.
|
||||
This lets a plugin require a human ``[o]nce/[s]ession/[a]lways/[d]eny``
|
||||
decision on ANY tool, not just terminal command strings. The caller is
|
||||
responsible for invoking the gate (see
|
||||
:func:`tools.approval.request_tool_approval`).
|
||||
- ``rule_key`` is optional and only honored for ``approve`` directives. It
|
||||
lets plugins choose the allowlist grain for `[a]lways` approvals.
|
||||
|
||||
The first valid directive wins. Invalid or irrelevant hook return values
|
||||
are silently ignored so existing observer-only hooks are unaffected.
|
||||
"""
|
||||
allowed = getattr(_thread_tool_whitelist, "allowed", None)
|
||||
if allowed is not None and tool_name not in allowed:
|
||||
fmt = getattr(_thread_tool_whitelist, "fmt", "Tool '{tool_name}' denied")
|
||||
return fmt.format(tool_name=tool_name)
|
||||
return _PreToolCallDirective(
|
||||
action="block",
|
||||
message=fmt.format(tool_name=tool_name),
|
||||
)
|
||||
|
||||
hook_results = invoke_hook(
|
||||
"pre_tool_call",
|
||||
@@ -2130,12 +2155,122 @@ def get_pre_tool_call_block_message(
|
||||
for result in hook_results:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
if result.get("action") != "block":
|
||||
action = result.get("action")
|
||||
if action not in ("block", "approve"):
|
||||
continue
|
||||
message = result.get("message")
|
||||
if isinstance(message, str) and message:
|
||||
return message
|
||||
message = message if isinstance(message, str) and message else None
|
||||
# A block directive requires a message (it becomes the tool result);
|
||||
# an approve directive can carry an optional reason.
|
||||
if action == "block" and not message:
|
||||
continue
|
||||
rule_key = result.get("rule_key") if action == "approve" else None
|
||||
rule_key = rule_key.strip() if isinstance(rule_key, str) else None
|
||||
if not rule_key:
|
||||
rule_key = None
|
||||
return _PreToolCallDirective(action=action, message=message, rule_key=rule_key)
|
||||
|
||||
return _PreToolCallDirective()
|
||||
|
||||
|
||||
def get_pre_tool_call_directive(
|
||||
tool_name: str,
|
||||
args: Optional[Dict[str, Any]],
|
||||
task_id: str = "",
|
||||
session_id: str = "",
|
||||
tool_call_id: str = "",
|
||||
turn_id: str = "",
|
||||
api_request_id: str = "",
|
||||
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Check ``pre_tool_call`` hooks for a blocking or approval directive.
|
||||
|
||||
Backward-compatible public helper: returns ``(directive, message)`` where
|
||||
``directive`` is ``"block"``, ``"approve"``, or ``None``. Internal callers
|
||||
that need approve-specific metadata use
|
||||
:func:`_get_pre_tool_call_directive_details`.
|
||||
"""
|
||||
details = _get_pre_tool_call_directive_details(
|
||||
tool_name, args, task_id=task_id, session_id=session_id,
|
||||
tool_call_id=tool_call_id, turn_id=turn_id,
|
||||
api_request_id=api_request_id, middleware_trace=middleware_trace,
|
||||
)
|
||||
return (details.action, details.message)
|
||||
|
||||
|
||||
def get_pre_tool_call_block_message(
|
||||
tool_name: str,
|
||||
args: Optional[Dict[str, Any]],
|
||||
task_id: str = "",
|
||||
session_id: str = "",
|
||||
tool_call_id: str = "",
|
||||
turn_id: str = "",
|
||||
api_request_id: str = "",
|
||||
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Back-compat shim: return only a ``block`` message (or ``None``).
|
||||
|
||||
Deprecated in favor of :func:`get_pre_tool_call_directive`, which also
|
||||
surfaces the ``approve`` escalation directive. Kept so any external caller
|
||||
importing the old name keeps working; ``approve`` directives are invisible
|
||||
to this shim (it only reports blocks).
|
||||
"""
|
||||
directive, message = get_pre_tool_call_directive(
|
||||
tool_name, args, task_id=task_id, session_id=session_id,
|
||||
tool_call_id=tool_call_id, turn_id=turn_id,
|
||||
api_request_id=api_request_id, middleware_trace=middleware_trace,
|
||||
)
|
||||
return message if directive == "block" else None
|
||||
|
||||
|
||||
def resolve_pre_tool_block(
|
||||
tool_name: str,
|
||||
args: Optional[Dict[str, Any]],
|
||||
task_id: str = "",
|
||||
session_id: str = "",
|
||||
tool_call_id: str = "",
|
||||
turn_id: str = "",
|
||||
api_request_id: str = "",
|
||||
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve the pre_tool_call directive to a final block message (or None).
|
||||
|
||||
Single entry point for every tool-dispatch site: fetches the plugin
|
||||
directive and, for an ``approve`` escalation, invokes the human-approval
|
||||
gate (:func:`tools.approval.request_tool_approval`). Returns the message
|
||||
the tool result should carry when the call is blocked, or ``None`` when
|
||||
the call may proceed.
|
||||
|
||||
Centralizing this keeps the security-critical fail-closed logic in ONE
|
||||
place instead of copy-pasted across the concurrent/sequential/helper
|
||||
dispatch paths: an ``approve`` directive whose gate errors, denies, or
|
||||
times out is fail-closed to a block; ``block`` blocks with its message;
|
||||
anything else proceeds.
|
||||
"""
|
||||
details = _get_pre_tool_call_directive_details(
|
||||
tool_name, args, task_id=task_id, session_id=session_id,
|
||||
tool_call_id=tool_call_id, turn_id=turn_id,
|
||||
api_request_id=api_request_id, middleware_trace=middleware_trace,
|
||||
)
|
||||
if details.action == "block":
|
||||
return details.message
|
||||
if details.action == "approve":
|
||||
try:
|
||||
from tools.approval import request_tool_approval
|
||||
result = request_tool_approval(
|
||||
tool_name,
|
||||
details.message or "",
|
||||
rule_key=details.rule_key or tool_name,
|
||||
)
|
||||
except Exception:
|
||||
# Fail-closed: if the gate itself errors, block rather than
|
||||
# silently execute an action a plugin flagged for approval.
|
||||
return f"BLOCKED: plugin approval gate failed for {tool_name}"
|
||||
if not result.get("approved"):
|
||||
return str(
|
||||
result.get("message")
|
||||
or f"BLOCKED: plugin approval required for {tool_name}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Keep-alive PTY sessions for dashboard terminals.
|
||||
|
||||
A PTY process outlives the WebSocket that created it: a single drain task
|
||||
always reads the PTY into a bounded RingBuffer and forwards to the attached
|
||||
socket when present. Reconnecting with the same opaque token replays the
|
||||
buffer and resumes live. See
|
||||
docs/superpowers/specs/2026-06-20-pty-keepalive-reattach-design.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
WS_CLOSE_PROCESS_EXITED = 4410
|
||||
WS_CLOSE_SUPERSEDED = 4409
|
||||
|
||||
|
||||
class RingBuffer:
|
||||
"""Keeps only the most recent ``capacity`` bytes appended to it."""
|
||||
|
||||
def __init__(self, capacity: int) -> None:
|
||||
self._cap = capacity
|
||||
self._buf = bytearray()
|
||||
self._truncated = False
|
||||
|
||||
def append(self, data: bytes) -> None:
|
||||
self._buf.extend(data)
|
||||
overflow = len(self._buf) - self._cap
|
||||
if overflow > 0:
|
||||
del self._buf[:overflow]
|
||||
self._truncated = True
|
||||
|
||||
def snapshot(self) -> bytes:
|
||||
return bytes(self._buf)
|
||||
|
||||
@property
|
||||
def truncated(self) -> bool:
|
||||
return self._truncated
|
||||
|
||||
|
||||
class PtySession:
|
||||
def __init__(self, key: str, bridge, *, buffer_cap: int, read_timeout: float) -> None:
|
||||
self.key = key
|
||||
self.bridge = bridge
|
||||
self.buffer = RingBuffer(buffer_cap)
|
||||
self.alive = True
|
||||
self.attached = False
|
||||
self.last_detached_at: Optional[float] = None
|
||||
self._read_timeout = read_timeout
|
||||
self._ws = None
|
||||
self._drain_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._drain_task = asyncio.create_task(self._drain())
|
||||
|
||||
async def _drain(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
chunk = await loop.run_in_executor(None, self.bridge.read, self._read_timeout)
|
||||
if chunk is None: # EOF — the agent process exited
|
||||
self.alive = False
|
||||
ws = self._ws
|
||||
if ws is not None:
|
||||
try:
|
||||
await ws.close(code=WS_CLOSE_PROCESS_EXITED)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if not chunk: # idle tick
|
||||
await asyncio.sleep(0)
|
||||
continue
|
||||
self.buffer.append(chunk)
|
||||
ws = self._ws
|
||||
if ws is not None:
|
||||
try:
|
||||
await ws.send_bytes(chunk)
|
||||
except Exception:
|
||||
pass # detached mid-send; keep buffering
|
||||
|
||||
async def attach(self, ws) -> None:
|
||||
old = self._ws
|
||||
if old is not None and old is not ws:
|
||||
try:
|
||||
await old.close(code=WS_CLOSE_SUPERSEDED)
|
||||
except Exception:
|
||||
pass
|
||||
self._ws = ws
|
||||
self.attached = True
|
||||
self.last_detached_at = None
|
||||
snap = self.buffer.snapshot()
|
||||
if snap:
|
||||
await ws.send_bytes(snap)
|
||||
|
||||
def detach(self, ws) -> None:
|
||||
# Only the currently-attached socket may mark the session detached.
|
||||
# A superseded socket's handler also calls detach on its way out
|
||||
# (its ``finally`` runs after the new tab attached); flipping
|
||||
# ``attached`` then would make a session with a live viewer look
|
||||
# idle and reapable.
|
||||
if self._ws is not ws:
|
||||
return
|
||||
self._ws = None
|
||||
self.attached = False
|
||||
self.last_detached_at = time.monotonic()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._drain_task is not None:
|
||||
self._drain_task.cancel()
|
||||
try:
|
||||
await self._drain_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
try:
|
||||
# bridge.close() joins the child — blocking; keep it off the
|
||||
# event loop (#53227).
|
||||
await asyncio.to_thread(self.bridge.close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
from typing import Callable, Dict, Tuple
|
||||
|
||||
|
||||
class RegistryFull(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def run_reaper(registry: "PtySessionRegistry", *, interval: float = 60.0) -> None:
|
||||
"""Periodically reap idle/dead keep-alive sessions. Cancelled on shutdown."""
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
await registry.reap_idle()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class PtySessionRegistry:
|
||||
def __init__(self, *, ttl: float, max_sessions: int,
|
||||
buffer_cap: int, read_timeout: float) -> None:
|
||||
self._ttl = ttl
|
||||
self._max = max_sessions
|
||||
self._buffer_cap = buffer_cap
|
||||
self._read_timeout = read_timeout
|
||||
self._sessions: Dict[str, PtySession] = {}
|
||||
|
||||
async def attach_or_spawn(self, key: str, *, spawn: Callable[[], object]
|
||||
) -> Tuple[PtySession, bool]:
|
||||
await self.reap_idle()
|
||||
existing = self._sessions.get(key)
|
||||
if existing is not None and existing.alive:
|
||||
return existing, False
|
||||
if existing is not None: # dead remnant
|
||||
await existing.close()
|
||||
self._sessions.pop(key, None)
|
||||
if len(self._sessions) >= self._max:
|
||||
self._reap_one_idle_or_raise()
|
||||
# PTY spawn does blocking fork/exec work — keep it off the event
|
||||
# loop (#53227).
|
||||
bridge = await asyncio.to_thread(spawn)
|
||||
session = PtySession(key, bridge, buffer_cap=self._buffer_cap,
|
||||
read_timeout=self._read_timeout)
|
||||
await session.start()
|
||||
self._sessions[key] = session
|
||||
return session, True
|
||||
|
||||
def detach(self, key: str, ws) -> None:
|
||||
s = self._sessions.get(key)
|
||||
if s is not None:
|
||||
s.detach(ws)
|
||||
|
||||
async def reap_idle(self, now: Optional[float] = None) -> None:
|
||||
now = time.monotonic() if now is None else now
|
||||
doomed = [
|
||||
key for key, s in self._sessions.items()
|
||||
if (not s.alive)
|
||||
or (not s.attached and s.last_detached_at is not None
|
||||
and (now - s.last_detached_at) > self._ttl)
|
||||
]
|
||||
for key in doomed:
|
||||
await self._sessions.pop(key).close()
|
||||
|
||||
def _reap_one_idle_or_raise(self) -> None:
|
||||
idle = [s for s in self._sessions.values()
|
||||
if not s.attached and s.last_detached_at is not None]
|
||||
if not idle:
|
||||
raise RegistryFull()
|
||||
oldest = min(idle, key=lambda s: s.last_detached_at or 0.0)
|
||||
self._sessions.pop(oldest.key, None)
|
||||
asyncio.create_task(oldest.close())
|
||||
|
||||
async def close_all(self) -> None:
|
||||
for key in list(self._sessions):
|
||||
await self._sessions.pop(key).close()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Shared renderers for session export commands.
|
||||
|
||||
The CLI, dashboard, and slash-command surfaces all deal with the same
|
||||
session-shaped data: a session dict with a ``messages`` list. Keep filtering
|
||||
and human-readable rendering here so each surface only has to load sessions
|
||||
and write bytes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from html import escape as html_escape
|
||||
import json
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Tuple
|
||||
|
||||
|
||||
ExportFormat = Literal["jsonl", "markdown"]
|
||||
ExportOnly = Literal["user-prompts"]
|
||||
|
||||
|
||||
def normalize_export_format(fmt: str) -> ExportFormat:
|
||||
"""Return the canonical export format name."""
|
||||
value = (fmt or "jsonl").strip().lower()
|
||||
if value == "md":
|
||||
value = "markdown"
|
||||
if value not in {"jsonl", "markdown"}:
|
||||
raise ValueError(f"Unsupported session export format: {fmt}")
|
||||
return value # type: ignore[return-value]
|
||||
|
||||
|
||||
def normalize_export_only(only: Optional[str]) -> Optional[ExportOnly]:
|
||||
"""Return the canonical export filter name."""
|
||||
if only is None:
|
||||
return None
|
||||
value = only.strip().lower()
|
||||
if value in {"user", "prompts", "user-prompts", "user_prompts"}:
|
||||
return "user-prompts"
|
||||
raise ValueError(f"Unsupported session export filter: {only}")
|
||||
|
||||
|
||||
def render_sessions_export(
|
||||
sessions: Iterable[Dict[str, Any]],
|
||||
*,
|
||||
fmt: str = "jsonl",
|
||||
only: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Render exported sessions in a stable, reusable format.
|
||||
|
||||
``fmt=jsonl`` with no filter intentionally preserves the legacy shape:
|
||||
one full session object per line. ``only=user-prompts`` switches the unit
|
||||
of export to one prompt record per line so the output is easy to pipe into
|
||||
review, memory-ingestion, or prompt-library tooling.
|
||||
"""
|
||||
session_list = list(sessions)
|
||||
export_format = normalize_export_format(fmt)
|
||||
export_only = normalize_export_only(only)
|
||||
|
||||
if export_format == "jsonl":
|
||||
return _render_jsonl(session_list, only=export_only)
|
||||
return _render_markdown(session_list, only=export_only)
|
||||
|
||||
|
||||
def export_record_count(
|
||||
sessions: Iterable[Dict[str, Any]], *, only: Optional[str] = None
|
||||
) -> Tuple[int, str]:
|
||||
"""Return ``(count, noun)`` for status messages after an export."""
|
||||
session_list = list(sessions)
|
||||
export_only = normalize_export_only(only)
|
||||
if export_only == "user-prompts":
|
||||
return sum(1 for _ in iter_user_prompt_records(session_list)), "prompt"
|
||||
return len(session_list), "session"
|
||||
|
||||
|
||||
def iter_user_prompt_records(
|
||||
sessions: Iterable[Dict[str, Any]]
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
"""Yield one normalized record for each user-authored prompt."""
|
||||
for session in sessions:
|
||||
session_id = str(session.get("id") or session.get("session_id") or "")
|
||||
index = 0
|
||||
for message in _messages(session):
|
||||
if message.get("role") != "user":
|
||||
continue
|
||||
index += 1
|
||||
record: Dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"index": index,
|
||||
"created_at": _format_timestamp(message.get("timestamp")),
|
||||
"role": "user",
|
||||
"text": _message_text(message.get("content")),
|
||||
}
|
||||
message_id = message.get("id")
|
||||
if message_id is not None:
|
||||
record["message_id"] = message_id
|
||||
event_id = message.get("platform_message_id") or message.get("event_id")
|
||||
if event_id:
|
||||
record["event_id"] = event_id
|
||||
yield record
|
||||
|
||||
|
||||
def _render_jsonl(
|
||||
sessions: List[Dict[str, Any]], *, only: Optional[ExportOnly]
|
||||
) -> str:
|
||||
if only == "user-prompts":
|
||||
rows = iter_user_prompt_records(sessions)
|
||||
else:
|
||||
rows = iter(sessions)
|
||||
lines = [json.dumps(row, ensure_ascii=False) for row in rows]
|
||||
return ("\n".join(lines) + "\n") if lines else ""
|
||||
|
||||
|
||||
def _render_markdown(
|
||||
sessions: List[Dict[str, Any]], *, only: Optional[ExportOnly]
|
||||
) -> str:
|
||||
if only == "user-prompts":
|
||||
return _render_user_prompts_markdown(sessions)
|
||||
return _render_full_markdown(sessions)
|
||||
|
||||
|
||||
def _render_user_prompts_markdown(sessions: List[Dict[str, Any]]) -> str:
|
||||
lines: List[str] = []
|
||||
if len(sessions) == 1:
|
||||
session = sessions[0]
|
||||
lines.append(f"# User prompts for session {_heading_text(_session_id(session))}")
|
||||
lines.extend(_session_metadata_lines(session))
|
||||
lines.append("")
|
||||
_append_prompt_records(lines, session, heading_level=2)
|
||||
else:
|
||||
lines.append("# User prompts export")
|
||||
lines.append("")
|
||||
for session in sessions:
|
||||
lines.append(f"## Session {_heading_text(_session_id(session))}")
|
||||
lines.extend(_session_metadata_lines(session))
|
||||
lines.append("")
|
||||
_append_prompt_records(lines, session, heading_level=3)
|
||||
if not sessions:
|
||||
lines.append("_No user prompts found._")
|
||||
lines.append("")
|
||||
return _finish_markdown(lines)
|
||||
|
||||
|
||||
def _append_prompt_records(
|
||||
lines: List[str], session: Dict[str, Any], *, heading_level: int
|
||||
) -> None:
|
||||
prompts = list(iter_user_prompt_records([session]))
|
||||
if not prompts:
|
||||
lines.append("_No user prompts found._")
|
||||
lines.append("")
|
||||
return
|
||||
marker = "#" * heading_level
|
||||
for prompt in prompts:
|
||||
timestamp = prompt.get("created_at") or "timestamp unavailable"
|
||||
lines.append(f"{marker} {prompt['index']}. {timestamp}")
|
||||
message_id = prompt.get("message_id")
|
||||
if message_id is not None:
|
||||
lines.append(f"Message ID: `{message_id}`")
|
||||
lines.append("")
|
||||
lines.append(str(prompt.get("text") or ""))
|
||||
lines.append("")
|
||||
|
||||
|
||||
def _render_full_markdown(sessions: List[Dict[str, Any]]) -> str:
|
||||
lines: List[str] = []
|
||||
if len(sessions) == 1:
|
||||
session = sessions[0]
|
||||
lines.append(f"# Session: {_heading_text(_session_title_or_id(session))}")
|
||||
lines.extend(_session_metadata_lines(session))
|
||||
lines.append("")
|
||||
_append_session_messages(lines, session, heading_level=2)
|
||||
else:
|
||||
lines.append("# Hermes sessions export")
|
||||
lines.append("")
|
||||
for session in sessions:
|
||||
lines.append(f"## Session: {_heading_text(_session_title_or_id(session))}")
|
||||
lines.extend(_session_metadata_lines(session))
|
||||
lines.append("")
|
||||
_append_session_messages(lines, session, heading_level=3)
|
||||
return _finish_markdown(lines)
|
||||
|
||||
|
||||
def _append_session_messages(
|
||||
lines: List[str], session: Dict[str, Any], *, heading_level: int
|
||||
) -> None:
|
||||
marker = "#" * heading_level
|
||||
visible_messages = [
|
||||
message for message in _messages(session) if message.get("role") != "system"
|
||||
]
|
||||
if not visible_messages:
|
||||
lines.append("_No messages found._")
|
||||
lines.append("")
|
||||
return
|
||||
|
||||
for message in visible_messages:
|
||||
role = str(message.get("role") or "unknown")
|
||||
timestamp = _format_timestamp(message.get("timestamp"))
|
||||
suffix = f" - {timestamp}" if timestamp else ""
|
||||
if role == "tool":
|
||||
tool_name = str(message.get("tool_name") or message.get("name") or "tool")
|
||||
lines.append(f"{marker} Tool: {_heading_text(tool_name)}{suffix}")
|
||||
lines.append("")
|
||||
lines.append(f"<details><summary>{html_escape(tool_name)}</summary>")
|
||||
lines.append("")
|
||||
lines.append(_fenced_text(_message_text(message.get("content"))))
|
||||
lines.append("")
|
||||
lines.append("</details>")
|
||||
lines.append("")
|
||||
continue
|
||||
|
||||
label = {
|
||||
"user": "User",
|
||||
"assistant": "Assistant",
|
||||
}.get(role, role.title())
|
||||
lines.append(f"{marker} {label}{suffix}")
|
||||
lines.append("")
|
||||
lines.append(_message_text(message.get("content")))
|
||||
lines.append("")
|
||||
|
||||
|
||||
def _messages(session: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
messages = session.get("messages") or []
|
||||
return [message for message in messages if isinstance(message, dict)]
|
||||
|
||||
|
||||
def _message_text(content: Any) -> str:
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = [_content_part_text(part) for part in content]
|
||||
return "\n".join(part for part in parts if part)
|
||||
if isinstance(content, dict):
|
||||
for key in ("text", "content"):
|
||||
value = content.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return json.dumps(content, ensure_ascii=False, sort_keys=True)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _content_part_text(part: Any) -> str:
|
||||
if isinstance(part, str):
|
||||
return part
|
||||
if isinstance(part, dict):
|
||||
for key in ("text", "content"):
|
||||
value = part.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return json.dumps(part, ensure_ascii=False, sort_keys=True)
|
||||
return str(part)
|
||||
|
||||
|
||||
def _format_timestamp(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return (
|
||||
datetime.fromtimestamp(float(value), tz=timezone.utc)
|
||||
.isoformat(timespec="seconds")
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
if isinstance(value, datetime):
|
||||
dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat(timespec="seconds").replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _session_metadata_lines(session: Dict[str, Any]) -> List[str]:
|
||||
lines: List[str] = [f"- Session ID: `{_session_id(session)}`"]
|
||||
source = session.get("source")
|
||||
if source:
|
||||
lines.append(f"- Source: `{source}`")
|
||||
model = session.get("model")
|
||||
if model:
|
||||
lines.append(f"- Model: `{model}`")
|
||||
title = session.get("title")
|
||||
if title:
|
||||
lines.append(f"- Title: {_inline_text(str(title))}")
|
||||
started = _format_timestamp(session.get("started_at"))
|
||||
if started:
|
||||
lines.append(f"- Started: {started}")
|
||||
message_count = session.get("message_count")
|
||||
if message_count is not None:
|
||||
lines.append(f"- Messages: {message_count}")
|
||||
return lines
|
||||
|
||||
|
||||
def _session_id(session: Dict[str, Any]) -> str:
|
||||
return str(session.get("id") or session.get("session_id") or "unknown")
|
||||
|
||||
|
||||
def _session_title_or_id(session: Dict[str, Any]) -> str:
|
||||
title = str(session.get("title") or "").strip()
|
||||
return title or _session_id(session)
|
||||
|
||||
|
||||
def _heading_text(value: str) -> str:
|
||||
return " ".join(str(value).splitlines()).strip() or "unknown"
|
||||
|
||||
|
||||
def _inline_text(value: str) -> str:
|
||||
return " ".join(value.splitlines()).strip()
|
||||
|
||||
|
||||
def _fenced_text(text: str, *, language: str = "text") -> str:
|
||||
fence = "```"
|
||||
while fence in text:
|
||||
fence += "`"
|
||||
return f"{fence}{language}\n{text}\n{fence}"
|
||||
|
||||
|
||||
def _finish_markdown(lines: List[str]) -> str:
|
||||
while lines and lines[-1] == "":
|
||||
lines.pop()
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -0,0 +1,849 @@
|
||||
"""
|
||||
HTML Export generator for Hermes sessions.
|
||||
Generates a standalone, beautiful HTML file with all messages embedded.
|
||||
Supports single and multi-session exports with a professional sidebar.
|
||||
No remote dependencies.
|
||||
Enhanced with UI-UX-PRO-MAX design intelligence.
|
||||
"""
|
||||
|
||||
import json
|
||||
import datetime
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# --- Icons (Lucide-style SVGs) ---
|
||||
ICON_USER = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
|
||||
ICON_BOT = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bot"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>'
|
||||
ICON_TERMINAL = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-terminal"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></svg>'
|
||||
ICON_WRENCH = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wrench"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>'
|
||||
ICON_SPARKLES = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/></svg>'
|
||||
ICON_CHEVRON_RIGHT = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>'
|
||||
ICON_SEARCH = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>'
|
||||
ICON_SHIELD = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.5 3.8 17 5 19 5a1 1 0 0 1 1 1z"/></svg>'
|
||||
ICON_HERMES = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FFD700" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>'
|
||||
|
||||
HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{page_title}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {{
|
||||
--bg-color: #F8FAFC;
|
||||
--text-color: #0F172A;
|
||||
--secondary-text: #475569;
|
||||
--user-bg: #FFFFFF;
|
||||
--assistant-bg: #F1F5F9;
|
||||
--border-color: #E2E8F0;
|
||||
--accent-color: #CD7F32;
|
||||
--accent-foreground: #FFFFFF;
|
||||
--code-bg: #1E293B;
|
||||
--code-text: #F8FAFC;
|
||||
--reasoning-bg: #FFFBEB;
|
||||
--reasoning-border: #FEF3C7;
|
||||
--tool-bg: #F0F9FF;
|
||||
--tool-border: #E0F2FE;
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--sidebar-bg: #FFFFFF;
|
||||
--sidebar-width: 320px;
|
||||
}}
|
||||
|
||||
@media (prefers-color-scheme: dark) {{
|
||||
:root {{
|
||||
--bg-color: #000101;
|
||||
--text-color: #FFF8DC;
|
||||
--secondary-text: #94A3B8;
|
||||
--user-bg: #041c1c;
|
||||
--assistant-bg: #0c1a1a;
|
||||
--border-color: #CD7F32;
|
||||
--accent-color: #FFD700;
|
||||
--code-bg: #000000;
|
||||
--reasoning-bg: #1a1a1a;
|
||||
--reasoning-border: #CD7F32;
|
||||
--tool-bg: #0c4a6e;
|
||||
--tool-border: #075985;
|
||||
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
--sidebar-bg: #041c1c;
|
||||
}}
|
||||
}}
|
||||
|
||||
* {{
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}}
|
||||
|
||||
body {{
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}}
|
||||
|
||||
.layout {{
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {{
|
||||
width: var(--sidebar-width);
|
||||
background-color: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
transition: transform 0.3s ease;
|
||||
}}
|
||||
|
||||
@media (max-width: 768px) {{
|
||||
.sidebar {{
|
||||
transform: translateX(-100%);
|
||||
}}
|
||||
.sidebar.open {{
|
||||
transform: translateX(0);
|
||||
}}
|
||||
.main-content {{
|
||||
margin-left: 0 !important;
|
||||
}}
|
||||
}}
|
||||
|
||||
.sidebar-header {{
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}}
|
||||
|
||||
.sidebar-brand {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 1rem;
|
||||
}}
|
||||
|
||||
.search-container {{
|
||||
position: relative;
|
||||
}}
|
||||
|
||||
.search-container input {{
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem 0.5rem 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
.search-container svg {{
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--secondary-text);
|
||||
}}
|
||||
|
||||
.session-list {{
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}}
|
||||
|
||||
.session-item {{
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid transparent;
|
||||
}}
|
||||
|
||||
.session-item:hover {{
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}}
|
||||
|
||||
@media (prefers-color-scheme: dark) {{
|
||||
.session-item:hover {{
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}}
|
||||
}}
|
||||
|
||||
.session-item.active {{
|
||||
background-color: var(--user-bg);
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: var(--shadow);
|
||||
}}
|
||||
|
||||
.session-item-title {{
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 0.25rem;
|
||||
}}
|
||||
|
||||
.session-item-meta {{
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary-text);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {{
|
||||
flex: 1;
|
||||
margin-left: {main_margin};
|
||||
padding: 3rem 2rem;
|
||||
max-width: 100%;
|
||||
transition: margin-left 0.3s ease;
|
||||
}}
|
||||
|
||||
.session-view {{
|
||||
display: none;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}}
|
||||
|
||||
.layout-single .session-view {{
|
||||
max-width: 90%;
|
||||
margin: 0 auto;
|
||||
}}
|
||||
|
||||
.layout-multi .session-view {{
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}}
|
||||
|
||||
.layout-multi .main-content {{
|
||||
width: 0;
|
||||
}}
|
||||
|
||||
.session-view.active {{
|
||||
display: block;
|
||||
}}
|
||||
|
||||
header {{
|
||||
margin-bottom: 3rem;
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
.meta {{
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--secondary-text);
|
||||
}}
|
||||
|
||||
.meta-item strong {{
|
||||
color: var(--text-color);
|
||||
}}
|
||||
|
||||
/* Messages */
|
||||
.message {{
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--user-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}}
|
||||
|
||||
.message-user {{
|
||||
background-color: #f0fdf4;
|
||||
}}
|
||||
|
||||
.message-assistant {{
|
||||
background-color: var(--assistant-bg);
|
||||
border-left: 4px solid var(--accent-color);
|
||||
}}
|
||||
|
||||
.message-system {{
|
||||
background-color: var(--bg-color);
|
||||
border-left: 4px solid var(--secondary-text);
|
||||
opacity: 0.9;
|
||||
}}
|
||||
|
||||
.message-tool {{
|
||||
background-color: #f8fafc;
|
||||
border-style: dotted;
|
||||
}}
|
||||
|
||||
@media (prefers-color-scheme: dark) {{
|
||||
.message-user {{
|
||||
background-color: #0c2121;
|
||||
}}
|
||||
|
||||
.message-assistant {{
|
||||
background-color: #041c1c;
|
||||
}}
|
||||
|
||||
.message-system {{
|
||||
background-color: #020617;
|
||||
border-left: 4px solid var(--secondary-text);
|
||||
}}
|
||||
|
||||
.message-tool {{
|
||||
background-color: #0f172a;
|
||||
}}
|
||||
}}
|
||||
|
||||
.message-header {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
}}
|
||||
|
||||
.message-header:hover {{
|
||||
background-color: rgba(0,0,0,0.02);
|
||||
}}
|
||||
|
||||
.message-header svg.chevron {{
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--secondary-text);
|
||||
}}
|
||||
|
||||
.message.active svg.chevron {{
|
||||
transform: rotate(90deg);
|
||||
}}
|
||||
|
||||
.role-badge {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--secondary-text);
|
||||
}}
|
||||
|
||||
.role-badge svg {{
|
||||
color: var(--accent-color);
|
||||
}}
|
||||
|
||||
.timestamp {{
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}}
|
||||
|
||||
.message-body {{
|
||||
display: none;
|
||||
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 1.5rem;
|
||||
}}
|
||||
|
||||
.message.active .message-body {{
|
||||
display: block;
|
||||
}}
|
||||
|
||||
.content {{
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 1rem;
|
||||
}}
|
||||
|
||||
/* Code Blocks */
|
||||
code {{
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
background-color: rgba(0,0,0,0.05);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.9em;
|
||||
}}
|
||||
|
||||
pre {{
|
||||
background-color: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin: 1.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.1);
|
||||
}}
|
||||
|
||||
pre code {{
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
color: inherit;
|
||||
}}
|
||||
|
||||
/* Tool Calls */
|
||||
.tool-call {{
|
||||
margin: 1rem 0;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--tool-border);
|
||||
background-color: var(--tool-bg);
|
||||
}}
|
||||
|
||||
.tool-call-header {{
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}}
|
||||
|
||||
.tool-call-header:hover {{
|
||||
background-color: rgba(0,0,0,0.03);
|
||||
}}
|
||||
|
||||
.tool-call-header svg.chevron {{
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--secondary-text);
|
||||
}}
|
||||
|
||||
.tool-call.active svg.chevron {{
|
||||
transform: rotate(90deg);
|
||||
}}
|
||||
|
||||
.tool-call-content {{
|
||||
display: none;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
}}
|
||||
|
||||
.tool-call.active .tool-call-content {{
|
||||
display: block;
|
||||
}}
|
||||
|
||||
/* Reasoning */
|
||||
.reasoning {{
|
||||
margin-top: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--reasoning-border);
|
||||
background-color: var(--reasoning-bg);
|
||||
overflow: hidden;
|
||||
}}
|
||||
|
||||
.reasoning-header {{
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}}
|
||||
|
||||
.reasoning-header:hover {{
|
||||
background-color: rgba(0,0,0,0.02);
|
||||
}}
|
||||
|
||||
.reasoning-header svg.chevron {{
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--secondary-text);
|
||||
}}
|
||||
|
||||
.reasoning.active svg.chevron {{
|
||||
transform: rotate(90deg);
|
||||
}}
|
||||
|
||||
.reasoning-content {{
|
||||
display: none;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
font-size: 0.925rem;
|
||||
color: var(--secondary-text);
|
||||
border-top: 1px solid var(--reasoning-border);
|
||||
padding-top: 1rem;
|
||||
}}
|
||||
|
||||
.reasoning.active .reasoning-content {{
|
||||
display: block;
|
||||
}}
|
||||
|
||||
/* System Prompt Section */
|
||||
.system-prompt-section {{
|
||||
margin-top: 2rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--user-bg);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}}
|
||||
|
||||
.system-prompt-header {{
|
||||
padding: 0.75rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-text);
|
||||
}}
|
||||
|
||||
.system-prompt-header:hover {{
|
||||
background-color: rgba(0,0,0,0.02);
|
||||
}}
|
||||
|
||||
.system-prompt-header svg.chevron {{
|
||||
transition: transform 0.2s ease;
|
||||
}}
|
||||
|
||||
.system-prompt-section.active svg.chevron {{
|
||||
transform: rotate(90deg);
|
||||
}}
|
||||
|
||||
.system-prompt-content {{
|
||||
display: none;
|
||||
padding: 0 1.25rem 1.25rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 1rem;
|
||||
}}
|
||||
|
||||
.system-prompt-section.active .system-prompt-content {{
|
||||
display: block;
|
||||
}}
|
||||
|
||||
footer {{
|
||||
margin-top: 5rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--secondary-text);
|
||||
}}
|
||||
|
||||
/* Animation */
|
||||
.fade-in {{
|
||||
animation: fadeIn 0.4s ease-out backwards;
|
||||
}}
|
||||
|
||||
@keyframes fadeIn {{
|
||||
from {{ opacity: 0; transform: translateY(10px); }}
|
||||
to {{ opacity: 1; transform: translateY(0); }}
|
||||
}}
|
||||
|
||||
/* Utilities */
|
||||
.hidden {{ display: none !important; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout {layout_class}">
|
||||
{sidebar_html}
|
||||
|
||||
<div class="main-content">
|
||||
{sessions_html}
|
||||
|
||||
<footer>
|
||||
Built with ☤ Hermes Agent • Generated on {generated_at}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Session Switching
|
||||
function showSession(id) {{
|
||||
// Update Sidebar
|
||||
document.querySelectorAll('.session-item').forEach(el => el.classList.remove('active'));
|
||||
const activeItem = document.querySelector(`.session-item[data-id="${{id}}"]`);
|
||||
if (activeItem) activeItem.classList.add('active');
|
||||
|
||||
// Update View
|
||||
document.querySelectorAll('.session-view').forEach(el => {{
|
||||
el.classList.remove('active');
|
||||
}});
|
||||
const activeView = document.getElementById(`view-${{id}}`);
|
||||
if (activeView) activeView.classList.add('active');
|
||||
|
||||
// Store in URL hash
|
||||
window.location.hash = id;
|
||||
}}
|
||||
|
||||
// Search Filter
|
||||
const searchInput = document.getElementById('session-search');
|
||||
if (searchInput) {{
|
||||
searchInput.addEventListener('input', (e) => {{
|
||||
const term = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.session-item').forEach(item => {{
|
||||
const text = item.innerText.toLowerCase();
|
||||
if (text.includes(term)) {{
|
||||
item.classList.remove('hidden');
|
||||
}} else {{
|
||||
item.classList.add('hidden');
|
||||
}}
|
||||
}});
|
||||
}});
|
||||
}}
|
||||
|
||||
// Card Toggles
|
||||
document.addEventListener('click', function(e) {{
|
||||
const header = e.target.closest('.message-header, .tool-call-header, .reasoning-header, .system-prompt-header');
|
||||
if (header) {{
|
||||
header.parentElement.classList.toggle('active');
|
||||
}}
|
||||
}});
|
||||
|
||||
// Initialization
|
||||
window.addEventListener('load', () => {{
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash) {{
|
||||
showSession(hash);
|
||||
}} else {{
|
||||
// Show first session by default if none selected
|
||||
const first = document.querySelector('.session-item');
|
||||
if (first) showSession(first.getAttribute('data-id'));
|
||||
}}
|
||||
}});
|
||||
|
||||
// Intersection Observer for scroll animations
|
||||
if ('IntersectionObserver' in window) {{
|
||||
var observer = new IntersectionObserver(function(entries) {{
|
||||
entries.forEach(function(entry) {{
|
||||
if (entry.isIntersecting) {{
|
||||
entry.target.classList.add('fade-in');
|
||||
observer.unobserve(entry.target);
|
||||
}}
|
||||
}});
|
||||
}}, {{ threshold: 0.1 }});
|
||||
|
||||
document.querySelectorAll('.message').forEach(function(m) {{ observer.observe(m); }});
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def _escape_html(text: str) -> str:
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """).replace("'", "'")
|
||||
|
||||
def _format_timestamp(ts: float) -> str:
|
||||
if not ts: return "N/A"
|
||||
return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _generate_messages_html(messages: List[Dict[str, Any]]) -> str:
|
||||
html_list = []
|
||||
for i, msg in enumerate(messages):
|
||||
role = msg.get("role", "unknown")
|
||||
|
||||
# Skip internal metadata messages
|
||||
if role == "session_meta":
|
||||
continue
|
||||
|
||||
content = msg.get("content") or ""
|
||||
timestamp = _format_timestamp(msg.get("timestamp", 0))
|
||||
|
||||
# Icon selection
|
||||
role_icon = ICON_TERMINAL
|
||||
if role == "user":
|
||||
role_icon = ICON_USER
|
||||
elif role == "assistant":
|
||||
role_icon = ICON_BOT
|
||||
elif role == "system":
|
||||
role_icon = ICON_SHIELD
|
||||
|
||||
# Handle multimodal or complex content
|
||||
if isinstance(content, list):
|
||||
content_parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
if part.get("type") == "text":
|
||||
content_parts.append(part.get("text", ""))
|
||||
elif part.get("type") == "image_url":
|
||||
content_parts.append("[Image Attachment]")
|
||||
else:
|
||||
content_parts.append(str(part))
|
||||
content = "\n".join(content_parts)
|
||||
|
||||
# Build message HTML
|
||||
msg_class = f"message message-{role} active"
|
||||
# Delay animation for initial items
|
||||
delay_style = f' style="animation-delay: {min(i * 0.05, 1.0)}s"' if i < 10 else ""
|
||||
|
||||
chevron_html = ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')
|
||||
|
||||
html = f'<div class="{msg_class}"{delay_style}>'
|
||||
html += f' <div class="message-header">'
|
||||
html += f' <div class="role-badge">{chevron_html} {role_icon} {role}</div>'
|
||||
html += f' <div class="timestamp">{timestamp}</div>'
|
||||
html += ' </div>'
|
||||
html += ' <div class="message-body">'
|
||||
|
||||
# Tool Calls
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if tool_calls:
|
||||
for tc in tool_calls:
|
||||
fn_name = tc.get("function", {}).get("name", "unknown")
|
||||
args = tc.get("function", {}).get("arguments", "{}")
|
||||
html += f'''
|
||||
<div class="tool-call">
|
||||
<div class="tool-call-header">
|
||||
{ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')}
|
||||
{ICON_WRENCH} Tool Call: {fn_name}
|
||||
</div>
|
||||
<div class="tool-call-content">
|
||||
<pre><code>{_escape_html(args)}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# Content
|
||||
if content:
|
||||
if role == "tool":
|
||||
html += f' <div class="content"><pre><code>{_escape_html(content)}</code></pre></div>'
|
||||
else:
|
||||
html += f' <div class="content">{_escape_html(content)}</div>'
|
||||
|
||||
# Reasoning
|
||||
reasoning = msg.get("reasoning") or msg.get("reasoning_content")
|
||||
if reasoning:
|
||||
html += f'''
|
||||
<div class="reasoning">
|
||||
<div class="reasoning-header">
|
||||
{ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')}
|
||||
{ICON_SPARKLES} Reasoning
|
||||
</div>
|
||||
<div class="reasoning-content">
|
||||
<div class="content">{_escape_html(reasoning)}</div>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
html += ' </div>'
|
||||
html += '</div>'
|
||||
html_list.append(html)
|
||||
return "\n".join(html_list)
|
||||
|
||||
def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str:
|
||||
if not sessions:
|
||||
return "<html><body><h1>No sessions to export.</h1></body></html>"
|
||||
|
||||
is_multi = len(sessions) > 1
|
||||
generated_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Sidebar
|
||||
sidebar_html = ""
|
||||
if is_multi:
|
||||
sidebar_items = []
|
||||
for s in sessions:
|
||||
sid = s.get("id", "N/A")
|
||||
title = s.get("title") or s.get("preview") or "Untitled Session"
|
||||
if len(title) > 50: title = title[:47] + "..."
|
||||
date = _format_timestamp(s.get("started_at", 0)).split(" ")[0]
|
||||
|
||||
item = f'''
|
||||
<a class="session-item" data-id="{sid}" onclick="showSession('{sid}')">
|
||||
<div class="session-item-title">{_escape_html(title)}</div>
|
||||
<div class="session-item-meta">
|
||||
<span>{sid[:8]}</span>
|
||||
<span>{date}</span>
|
||||
</div>
|
||||
</a>
|
||||
'''
|
||||
sidebar_items.append(item)
|
||||
|
||||
sidebar_html = f'''
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-brand">
|
||||
{ICON_HERMES} Hermes History
|
||||
</div>
|
||||
<div class="search-container">
|
||||
{ICON_SEARCH}
|
||||
<input type="text" id="session-search" placeholder="Search sessions...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-list">
|
||||
{"".join(sidebar_items)}
|
||||
</div>
|
||||
</aside>
|
||||
'''
|
||||
|
||||
# Main Content
|
||||
sessions_html_list = []
|
||||
for s in sessions:
|
||||
sid = s.get("id", "N/A")
|
||||
title = s.get("title") or "Hermes Session"
|
||||
model = s.get("model", "Unknown")
|
||||
started_at = _format_timestamp(s.get("started_at", 0))
|
||||
messages = s.get("messages", [])
|
||||
|
||||
messages_html = _generate_messages_html(messages)
|
||||
|
||||
view_class = "session-view"
|
||||
if not is_multi: view_class += " active"
|
||||
|
||||
session_view_id = f"view-{sid}"
|
||||
|
||||
system_prompt = s.get("system_prompt")
|
||||
system_html = ""
|
||||
if system_prompt:
|
||||
system_html = f'''
|
||||
<div class="system-prompt-section active">
|
||||
<div class="system-prompt-header">
|
||||
{ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')}
|
||||
{ICON_SHIELD} System Prompt (Persona)
|
||||
</div>
|
||||
<div class="system-prompt-content">
|
||||
<div class="content">{_escape_html(system_prompt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
session_html = f'''
|
||||
<div class="{view_class}" id="{session_view_id}">
|
||||
<header class="fade-in">
|
||||
<h1>{_escape_html(title)}</h1>
|
||||
<div class="meta">
|
||||
<div class="meta-item"><strong>ID:</strong> {sid}</div>
|
||||
<div class="meta-item"><strong>Model:</strong> {_escape_html(model)}</div>
|
||||
<div class="meta-item"><strong>Started:</strong> {started_at}</div>
|
||||
</div>
|
||||
{system_html}
|
||||
</header>
|
||||
<main>
|
||||
{messages_html}
|
||||
</main>
|
||||
</div>
|
||||
'''
|
||||
sessions_html_list.append(session_html)
|
||||
|
||||
return HTML_TEMPLATE.format(
|
||||
page_title="Hermes Session Export" if is_multi else _escape_html(sessions[0].get("title", "Hermes Session")),
|
||||
sidebar_html=sidebar_html,
|
||||
sessions_html="\n".join(sessions_html_list),
|
||||
main_margin="var(--sidebar-width)" if is_multi else "0",
|
||||
layout_class="layout-multi" if is_multi else "layout-single",
|
||||
generated_at=generated_at
|
||||
)
|
||||
|
||||
def generate_html_export(session_data: Dict[str, Any]) -> str:
|
||||
"""Legacy wrapper for single session export."""
|
||||
return generate_multi_session_html_export([session_data])
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Markdown/QMD export helpers for Hermes sessions.
|
||||
|
||||
This module is intentionally filesystem-only: it formats already-exported
|
||||
SessionDB dictionaries and writes them to user-selected export directories. It
|
||||
must not mutate state.db or call delete/prune/archive APIs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
EXPORTER_VERSION = "hermes sessions export (md/qmd) v1"
|
||||
_SHA_LINE_RE = re.compile(r"- SHA256 of exported body: `([0-9a-f]{64})`")
|
||||
|
||||
|
||||
def _iso_timestamp(value: Any) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
ts = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _frontmatter_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
if isinstance(value, list):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return json.dumps(str(value), ensure_ascii=False)
|
||||
|
||||
|
||||
def _frontmatter_line(key: str, value: Any) -> str:
|
||||
return f"{key}: {_frontmatter_value(value)}"
|
||||
|
||||
|
||||
def _message_heading(message: dict[str, Any]) -> str:
|
||||
role = str(message.get("role") or "message")
|
||||
label = role.capitalize()
|
||||
name = message.get("name") or message.get("tool_name")
|
||||
if role == "tool" and name:
|
||||
label = f"Tool — {name}"
|
||||
timestamp = _iso_timestamp(message.get("created_at") or message.get("timestamp"))
|
||||
return f"### {label}{' — ' + timestamp if timestamp else ''}"
|
||||
|
||||
|
||||
def _render_content(content: Any) -> str:
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content.rstrip()
|
||||
return "```json\n" + json.dumps(content, ensure_ascii=False, indent=2) + "\n```"
|
||||
|
||||
|
||||
def _render_tool_calls(tool_calls: Any) -> str:
|
||||
if not tool_calls:
|
||||
return ""
|
||||
return "\n\n## Tool calls\n\n```json\n" + json.dumps(tool_calls, ensure_ascii=False, indent=2) + "\n```"
|
||||
|
||||
|
||||
def _session_id(session: dict[str, Any]) -> str:
|
||||
return str(session.get("id") or session.get("session_id") or "unknown-session")
|
||||
|
||||
|
||||
def _segments(session: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
segments = session.get("segments")
|
||||
if isinstance(segments, list) and segments:
|
||||
return [s for s in segments if isinstance(s, dict)]
|
||||
return [session]
|
||||
|
||||
|
||||
def _message_count(session: dict[str, Any]) -> int:
|
||||
return sum(len(seg.get("messages") or []) for seg in _segments(session))
|
||||
|
||||
|
||||
def _render_messages(session: dict[str, Any]) -> str:
|
||||
parts: list[str] = ["## Messages\n"]
|
||||
segments = _segments(session)
|
||||
total_messages = _message_count(session)
|
||||
if total_messages == 0:
|
||||
parts.append("_No messages in this session._\n")
|
||||
return "\n".join(parts).rstrip() + "\n"
|
||||
|
||||
multi_segment = len(segments) > 1
|
||||
for segment in segments:
|
||||
if multi_segment:
|
||||
parts.append(f"## Compression segment: {_session_id(segment)}\n")
|
||||
for message in list(segment.get("messages") or []):
|
||||
parts.append(_message_heading(message) + "\n")
|
||||
rendered_content = _render_content(message.get("content"))
|
||||
if rendered_content:
|
||||
parts.append(rendered_content + "\n")
|
||||
tool_calls = _render_tool_calls(message.get("tool_calls"))
|
||||
if tool_calls:
|
||||
parts.append(tool_calls + "\n")
|
||||
parts.append("")
|
||||
return "\n".join(parts).rstrip() + "\n"
|
||||
|
||||
|
||||
def _export_body_without_hash(session: dict[str, Any], *, fmt: str, exported_at: float) -> str:
|
||||
session_id = _session_id(session)
|
||||
title = session.get("title") or session_id
|
||||
provider = session.get("billing_provider") or session.get("provider")
|
||||
started_at = _iso_timestamp(session.get("started_at") or session.get("created_at"))
|
||||
last_active = _iso_timestamp(session.get("last_active") or session.get("updated_at"))
|
||||
ended_at = _iso_timestamp(session.get("ended_at"))
|
||||
exported_iso = _iso_timestamp(exported_at)
|
||||
message_count = _message_count(session)
|
||||
|
||||
frontmatter = [
|
||||
"---",
|
||||
_frontmatter_line("session_id", session_id),
|
||||
_frontmatter_line("title", session.get("title")),
|
||||
_frontmatter_line("source", session.get("source")),
|
||||
_frontmatter_line("created_at", started_at),
|
||||
_frontmatter_line("updated_at", last_active),
|
||||
_frontmatter_line("ended_at", ended_at),
|
||||
_frontmatter_line("model", session.get("model")),
|
||||
_frontmatter_line("provider", provider),
|
||||
_frontmatter_line("cwd", session.get("cwd")),
|
||||
_frontmatter_line("archived", bool(session.get("archived"))),
|
||||
_frontmatter_line("message_count", message_count),
|
||||
_frontmatter_line("tool_call_count", session.get("tool_call_count") or 0),
|
||||
]
|
||||
if session.get("lineage_session_ids"):
|
||||
frontmatter.append(_frontmatter_line("lineage_session_ids", session.get("lineage_session_ids")))
|
||||
frontmatter.extend([
|
||||
_frontmatter_line("format", fmt),
|
||||
_frontmatter_line("exported_at", exported_iso),
|
||||
_frontmatter_line("exporter", EXPORTER_VERSION),
|
||||
"---",
|
||||
"",
|
||||
])
|
||||
|
||||
parts = ["\n".join(frontmatter), f"# {title}\n"]
|
||||
parts.append(f"Session ID: `{session_id}`\n")
|
||||
if session.get("source"):
|
||||
parts.append(f"Source: `{session.get('source')}`\n")
|
||||
if session.get("cwd"):
|
||||
parts.append(f"Working directory: `{session.get('cwd')}`\n")
|
||||
|
||||
parts.append(_render_messages(session))
|
||||
parts.append("## Export verification\n")
|
||||
parts.append(f"- Session id: `{session_id}`")
|
||||
parts.append(f"- Exported messages: `{message_count}`")
|
||||
parts.append(f"- Source DB message count at export: `{session.get('message_count', message_count)}`")
|
||||
parts.append(f"- Exported at: `{exported_iso}`")
|
||||
parts.append("- SHA256 of exported body: `__SHA256_PLACEHOLDER__`")
|
||||
return "\n".join(parts).rstrip() + "\n"
|
||||
|
||||
|
||||
def _body_for_digest(text: str) -> str:
|
||||
return _SHA_LINE_RE.sub("- SHA256 of exported body: `pending`", text)
|
||||
|
||||
|
||||
def render_session_markdown(
|
||||
session: dict[str, Any], *, fmt: str = "md", include_verification: bool = True
|
||||
) -> str:
|
||||
"""Render a SessionDB export dictionary as Markdown/QMD text."""
|
||||
if fmt not in {"md", "qmd"}:
|
||||
raise ValueError("fmt must be 'md' or 'qmd'")
|
||||
exported_at = time.time()
|
||||
body = _export_body_without_hash(session, fmt=fmt, exported_at=exported_at)
|
||||
digest_body = body.replace("`__SHA256_PLACEHOLDER__`", "`pending`")
|
||||
digest = hashlib.sha256(digest_body.encode("utf-8")).hexdigest()
|
||||
if include_verification:
|
||||
return body.replace("__SHA256_PLACEHOLDER__", digest)
|
||||
before_verification = body.split("\n## Export verification\n", 1)[0].rstrip() + "\n"
|
||||
return before_verification
|
||||
|
||||
|
||||
def safe_session_filename(session: dict[str, Any], *, fmt: str = "md") -> str:
|
||||
"""Return a deterministic, path-safe filename for a session export."""
|
||||
if fmt not in {"md", "qmd"}:
|
||||
raise ValueError("fmt must be 'md' or 'qmd'")
|
||||
session_id = _session_id(session)
|
||||
title = str(session.get("title") or "session")
|
||||
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip(".-_").lower()
|
||||
if not slug:
|
||||
slug = "session"
|
||||
slug = slug[:60]
|
||||
return f"{session_id}-{slug}.{fmt}"
|
||||
|
||||
|
||||
def file_sha256(path: Path | str) -> str:
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def verify_export_file(path: Path | str, session: dict[str, Any]) -> tuple[bool, str]:
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return False, "file missing"
|
||||
text = p.read_text(encoding="utf-8")
|
||||
match = _SHA_LINE_RE.search(text)
|
||||
if not match:
|
||||
return False, "sha256 marker missing"
|
||||
actual = hashlib.sha256(_body_for_digest(text).encode("utf-8")).hexdigest()
|
||||
if actual != match.group(1):
|
||||
return False, "sha256 mismatch"
|
||||
expected_count = _message_count(session)
|
||||
if f"- Exported messages: `{expected_count}`" not in text:
|
||||
return False, "message count mismatch"
|
||||
if f"- Session id: `{_session_id(session)}`" not in text:
|
||||
return False, "session id mismatch"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def redact_session_data(session: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a deep copy of a session export dict with secrets redacted.
|
||||
|
||||
Runs every message's content and tool-call arguments through the
|
||||
force-mode redaction pass (``agent.redact.redact_sensitive_text``), so
|
||||
API keys, tokens, and credentials that appeared in tool output never
|
||||
land in plaintext export files. Force mode ignores the user's global
|
||||
``security.redact_secrets`` preference — an explicit ``--redact`` export
|
||||
must never emit raw secrets.
|
||||
"""
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
def _clean(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return redact_sensitive_text(value, force=True)
|
||||
if isinstance(value, list):
|
||||
return [_clean(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: _clean(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
redacted = dict(session)
|
||||
for key in ("messages", "segments"):
|
||||
if key in redacted and redacted[key] is not None:
|
||||
redacted[key] = _clean(redacted[key])
|
||||
return redacted
|
||||
|
||||
|
||||
def write_session_markdown(
|
||||
session: dict[str, Any], output_dir: Path | str, *, fmt: str = "md", force: bool = False
|
||||
) -> Path:
|
||||
"""Write a Markdown/QMD export file and return its path.
|
||||
|
||||
Raises FileExistsError when the destination exists and force=False.
|
||||
"""
|
||||
out_dir = Path(output_dir).expanduser()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / safe_session_filename(session, fmt=fmt)
|
||||
if path.exists() and not force:
|
||||
raise FileExistsError(str(path))
|
||||
path.write_text(render_session_markdown(session, fmt=fmt), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def append_manifest_entry(output_dir: Path | str, session: dict[str, Any], path: Path | str, *, fmt: str) -> Path:
|
||||
out_dir = Path(output_dir).expanduser()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
export_path = Path(path)
|
||||
entry = {
|
||||
"session_id": _session_id(session),
|
||||
"lineage_session_ids": session.get("lineage_session_ids") or [_session_id(session)],
|
||||
"path": str(export_path),
|
||||
"format": fmt,
|
||||
"message_count": _message_count(session),
|
||||
"sha256": file_sha256(export_path),
|
||||
"exported_at": time.time(),
|
||||
}
|
||||
manifest = out_dir / "manifest.jsonl"
|
||||
with manifest.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
return manifest
|
||||
@@ -55,6 +55,13 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None:
|
||||
"message. Zero LLM cost. Requires --deliver to be a real target "
|
||||
"(not 'log').",
|
||||
)
|
||||
wh_sub.add_argument(
|
||||
"--script",
|
||||
default="",
|
||||
help="Filter/transform script under ~/.hermes/scripts/. The route "
|
||||
"payload is passed as JSON on stdin; empty stdout, [SILENT], or a "
|
||||
"nonzero exit code ignores the webhook.",
|
||||
)
|
||||
|
||||
webhook_subparsers.add_parser(
|
||||
"list", aliases=["ls"], help="List all dynamic subscriptions"
|
||||
|
||||
@@ -1612,6 +1612,28 @@ def enabled_mcp_server_names(config: dict) -> Set[str]:
|
||||
}
|
||||
|
||||
|
||||
def _exempt_explicit_platform_native(
|
||||
default_off: Set[str], platform: str, *, explicitly_configured: bool
|
||||
) -> None:
|
||||
"""Let platform-native default-off toolsets through on explicit config.
|
||||
|
||||
Toolsets that are both in ``_DEFAULT_OFF_TOOLSETS`` and restricted to
|
||||
``platform`` via ``_TOOLSET_PLATFORM_RESTRICTIONS`` (currently
|
||||
``discord``/``discord_admin`` on the discord platform) are the platform's
|
||||
own native tools. They are kept off for *unconfigured* platforms (security
|
||||
opt-in), but once a user explicitly saves a toolset list for the platform
|
||||
the composite they chose (e.g. ``hermes-discord``, which contains those
|
||||
tools) is an opt-in — stripping them silently defeats the explicit
|
||||
configuration (#35527). Mutates ``default_off`` in place.
|
||||
"""
|
||||
if not explicitly_configured:
|
||||
return
|
||||
for ts in list(default_off):
|
||||
allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts)
|
||||
if allowed is not None and platform in allowed:
|
||||
default_off.discard(ts)
|
||||
|
||||
|
||||
def _get_platform_tools(
|
||||
config: dict,
|
||||
platform: str,
|
||||
@@ -1623,6 +1645,11 @@ def _get_platform_tools(
|
||||
|
||||
platform_toolsets = config.get("platform_toolsets") or {}
|
||||
toolset_names = platform_toolsets.get(platform)
|
||||
# Track whether the user explicitly saved a toolset list for this platform
|
||||
# (vs. falling back to the platform default). An explicit composite (e.g.
|
||||
# ``hermes-discord``) is an opt-in to the platform's native default-off
|
||||
# toolsets — see _exempt_explicit_platform_native (#35527).
|
||||
explicitly_configured = isinstance(toolset_names, list)
|
||||
|
||||
if toolset_names is None or not isinstance(toolset_names, list):
|
||||
plat_info = PLATFORMS.get(platform)
|
||||
@@ -1686,6 +1713,9 @@ def _get_platform_tools(
|
||||
default_off.remove(platform)
|
||||
if "homeassistant" in default_off and os.getenv("HASS_TOKEN"):
|
||||
default_off.remove("homeassistant")
|
||||
_exempt_explicit_platform_native(
|
||||
default_off, platform, explicitly_configured=explicitly_configured
|
||||
)
|
||||
expanded -= default_off
|
||||
|
||||
enabled_toolsets |= expanded
|
||||
@@ -1748,6 +1778,9 @@ def _get_platform_tools(
|
||||
# strip the entry we just added.
|
||||
if x_search_auto_enabled and "x_search" in default_off:
|
||||
default_off.remove("x_search")
|
||||
_exempt_explicit_platform_native(
|
||||
default_off, platform, explicitly_configured=explicitly_configured
|
||||
)
|
||||
enabled_toolsets -= default_off
|
||||
|
||||
# Recover non-configurable platform toolsets (e.g. discord, feishu_doc,
|
||||
|
||||
+1730
-312
File diff suppressed because it is too large
Load Diff
@@ -189,6 +189,10 @@ def _cmd_subscribe(args):
|
||||
return
|
||||
route["deliver_only"] = True
|
||||
|
||||
script = getattr(args, "script", "") or ""
|
||||
if script.strip():
|
||||
route["script"] = script.strip()
|
||||
|
||||
if args.deliver_chat_id:
|
||||
route["deliver_extra"] = {"chat_id": args.deliver_chat_id}
|
||||
|
||||
@@ -212,6 +216,8 @@ def _cmd_subscribe(args):
|
||||
prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "")
|
||||
label = "Message" if route.get("deliver_only") else "Prompt"
|
||||
print(f" {label}: {prompt_preview}")
|
||||
if route.get("script"):
|
||||
print(f" Script: {route['script']}")
|
||||
print("\n Configure your service to POST to the URL above.")
|
||||
print(" Use the secret for HMAC-SHA256 signature validation.")
|
||||
print(" The gateway must be running to receive events (hermes gateway run).\n")
|
||||
@@ -238,6 +244,8 @@ def _cmd_list(args):
|
||||
print(f" URL: {base_url}/webhooks/{name}")
|
||||
print(f" Events: {events}")
|
||||
print(f" Deliver: {deliver}")
|
||||
if route.get("script"):
|
||||
print(f" Script: {route['script']}")
|
||||
print()
|
||||
|
||||
|
||||
|
||||
@@ -4958,6 +4958,64 @@ class SessionDB:
|
||||
# Export and cleanup
|
||||
# =========================================================================
|
||||
|
||||
def _is_branch_child_row(self, session: Dict[str, Any]) -> bool:
|
||||
raw = session.get("model_config")
|
||||
if not raw:
|
||||
return False
|
||||
try:
|
||||
cfg = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return False
|
||||
return isinstance(cfg, dict) and cfg.get("_branched_from") is not None
|
||||
|
||||
def _is_compression_child_row(self, child: Dict[str, Any]) -> bool:
|
||||
parent_id = child.get("parent_session_id")
|
||||
if not parent_id or self._is_branch_child_row(child):
|
||||
return False
|
||||
parent = self.get_session(parent_id)
|
||||
return bool(parent and parent.get("end_reason") == "compression")
|
||||
|
||||
def get_compression_lineage(self, session_id: str) -> List[str]:
|
||||
"""Return compression ancestors through tip in chronological order."""
|
||||
session = self.get_session(session_id)
|
||||
if not session or self._is_branch_child_row(session):
|
||||
return [session_id] if session else []
|
||||
|
||||
root = session
|
||||
while self._is_compression_child_row(root):
|
||||
parent = self.get_session(root["parent_session_id"])
|
||||
if not parent:
|
||||
break
|
||||
root = parent
|
||||
|
||||
lineage = [root["id"]]
|
||||
current = root
|
||||
while current.get("end_reason") == "compression":
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT * FROM sessions
|
||||
WHERE parent_session_id = ?
|
||||
ORDER BY started_at ASC
|
||||
""",
|
||||
(current["id"],),
|
||||
).fetchall()
|
||||
next_child = None
|
||||
for row in rows:
|
||||
candidate = dict(row)
|
||||
if not self._is_branch_child_row(candidate):
|
||||
next_child = candidate
|
||||
break
|
||||
if not next_child:
|
||||
break
|
||||
lineage.append(next_child["id"])
|
||||
current = next_child
|
||||
if current["id"] == session_id:
|
||||
# Continue to include later compression tips only when the
|
||||
# requested session itself was compacted.
|
||||
continue
|
||||
return lineage if session_id in lineage else [session_id]
|
||||
|
||||
def export_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Export a single session with all its messages as a dict."""
|
||||
session = self.get_session(session_id)
|
||||
@@ -4966,6 +5024,26 @@ class SessionDB:
|
||||
messages = self.get_messages(session_id)
|
||||
return {**session, "messages": messages}
|
||||
|
||||
def export_session_lineage(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Export a compression lineage as one logical session dict."""
|
||||
lineage_ids = self.get_compression_lineage(session_id)
|
||||
if not lineage_ids:
|
||||
return None
|
||||
segments = []
|
||||
for sid in lineage_ids:
|
||||
segment = self.export_session(sid)
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
if not segments:
|
||||
return None
|
||||
base = dict(segments[-1])
|
||||
total_messages = sum(len(seg.get("messages") or []) for seg in segments)
|
||||
base["segments"] = segments
|
||||
base["lineage_session_ids"] = [seg["id"] for seg in segments]
|
||||
base["message_count"] = total_messages
|
||||
base["messages"] = [msg for seg in segments for msg in (seg.get("messages") or [])]
|
||||
return base
|
||||
|
||||
def export_all(self, source: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Export all sessions (with messages) as a list of dicts.
|
||||
|
||||
+10
-9
@@ -1161,21 +1161,22 @@ def handle_function_call(
|
||||
if function_name in _AGENT_LOOP_TOOLS:
|
||||
return json.dumps({"error": f"{function_name} must be handled by the agent loop"})
|
||||
|
||||
# Check plugin hooks for a block directive (unless caller already
|
||||
# checked — e.g. run_agent._invoke_tool passes skip=True to
|
||||
# Check plugin hooks for a block/approve directive (unless caller
|
||||
# already checked — e.g. run_agent._invoke_tool passes skip=True to
|
||||
# avoid double-firing the hook).
|
||||
#
|
||||
# Single-fire contract: pre_tool_call fires exactly once per tool
|
||||
# execution. get_pre_tool_call_block_message() internally calls
|
||||
# invoke_hook("pre_tool_call", ...) and returns the first block
|
||||
# directive (if any), so observer plugins see the hook on that same
|
||||
# pass. When skip=True, the caller already fired it — do nothing
|
||||
# here.
|
||||
# execution. resolve_pre_tool_block() internally calls
|
||||
# invoke_hook("pre_tool_call", ...) once and returns the block message
|
||||
# for a `block` directive OR for an `approve` directive whose human
|
||||
# gate denied/timed-out/errored (fail-closed). Observer plugins see
|
||||
# the hook on that same pass. When skip=True, the caller already
|
||||
# fired it — do nothing here.
|
||||
if not skip_pre_tool_call_hook:
|
||||
block_message: Optional[str] = None
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
block_message = resolve_pre_tool_block(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=task_id or "",
|
||||
|
||||
+8
-1
@@ -28,13 +28,20 @@
|
||||
packages =
|
||||
with pkgs;
|
||||
[
|
||||
(pkgs.runCommand "hermes" { } ''
|
||||
mkdir -p $out/bin
|
||||
install -Dm755 ${../hermes} $out/bin/hermes
|
||||
'')
|
||||
uv
|
||||
]
|
||||
++ self'.packages.default.passthru.devDeps;
|
||||
shellHook = ''
|
||||
echo "Hermes Agent dev shell"
|
||||
${combinedNonNpm}
|
||||
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths}
|
||||
|
||||
# for the devshell to pick up the src
|
||||
export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel)
|
||||
echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT"
|
||||
echo "Ready. Run 'hermes' to start."
|
||||
'';
|
||||
};
|
||||
|
||||
+29
-25
@@ -44,7 +44,7 @@ let
|
||||
dependency-groups = [ "all" ] ++ extraDependencyGroups;
|
||||
};
|
||||
|
||||
hermesVenv = mkHermesVenv extraDependencyGroups;
|
||||
hermesVenv = (mkHermesVenv extraDependencyGroups).venv;
|
||||
|
||||
hermesNpmLib = callPackage ./lib.nix {
|
||||
inherit npm-lockfile-fix nodejs;
|
||||
@@ -200,33 +200,37 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit
|
||||
hermesTui
|
||||
hermesWeb
|
||||
hermesNpmLib
|
||||
hermesVenv
|
||||
;
|
||||
passthru =
|
||||
let
|
||||
devPython = (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])).editableVenv;
|
||||
in
|
||||
{
|
||||
inherit
|
||||
hermesTui
|
||||
hermesWeb
|
||||
hermesNpmLib
|
||||
hermesVenv
|
||||
;
|
||||
|
||||
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
|
||||
# derivation, after all overrides are applied) so the desktop wrapper
|
||||
# can prepend its `/bin` to PATH. The desktop's resolver step 4
|
||||
# ("existing hermes on PATH") then picks up the fully wrapped
|
||||
# `hermes` binary — venv with all deps, bundled skills/plugins,
|
||||
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
|
||||
# of the agent resolution in the desktop wrapper.
|
||||
hermesDesktop = callPackage ./desktop.nix {
|
||||
inherit hermesNpmLib electron;
|
||||
hermesAgent = finalAttrs.finalPackage;
|
||||
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
|
||||
# derivation, after all overrides are applied) so the desktop wrapper
|
||||
# can prepend its `/bin` to PATH. The desktop's resolver step 4
|
||||
# ("existing hermes on PATH") then picks up the fully wrapped
|
||||
# `hermes` binary — venv with all deps, bundled skills/plugins,
|
||||
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
|
||||
# of the agent resolution in the desktop wrapper.
|
||||
hermesDesktop = callPackage ./desktop.nix {
|
||||
inherit hermesNpmLib electron;
|
||||
hermesAgent = finalAttrs.finalPackage;
|
||||
};
|
||||
|
||||
devShellHook = ''
|
||||
export HERMES_PYTHON=${devPython}/bin/python3
|
||||
'';
|
||||
|
||||
devDeps = runtimeDeps ++ [ devPython ];
|
||||
};
|
||||
|
||||
devShellHook = ''
|
||||
export HERMES_PYTHON=${hermesVenv}/bin/python3
|
||||
'';
|
||||
|
||||
devDeps = runtimeDeps ++ [ (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])) ];
|
||||
};
|
||||
|
||||
meta = with lib; {
|
||||
description = "AI agent with advanced tool-calling capabilities";
|
||||
homepage = "https://github.com/NousResearch/hermes-agent";
|
||||
|
||||
+84
-47
@@ -27,7 +27,8 @@ let
|
||||
dependency-groups = { };
|
||||
};
|
||||
|
||||
mkPrebuiltOverride = final: from: dependencies:
|
||||
mkPrebuiltOverride =
|
||||
final: from: dependencies:
|
||||
hacks.nixpkgsPrebuilt {
|
||||
inherit from;
|
||||
prev = {
|
||||
@@ -38,64 +39,100 @@ let
|
||||
|
||||
# Legacy alibabacloud packages ship only sdists with setup.py/setup.cfg
|
||||
# and no pyproject.toml, so setuptools isn't declared as a build dep.
|
||||
buildSystemOverrides = final: prev: builtins.mapAttrs
|
||||
(name: _: prev.${name}.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ];
|
||||
}))
|
||||
(lib.genAttrs [
|
||||
"alibabacloud-credentials-api"
|
||||
"alibabacloud-endpoint-util"
|
||||
"alibabacloud-gateway-dingtalk"
|
||||
"alibabacloud-gateway-spi"
|
||||
"alibabacloud-tea"
|
||||
] (_: null));
|
||||
buildSystemOverrides =
|
||||
final: prev:
|
||||
builtins.mapAttrs
|
||||
(
|
||||
name: _:
|
||||
prev.${name}.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ];
|
||||
})
|
||||
)
|
||||
(
|
||||
lib.genAttrs [
|
||||
"alibabacloud-credentials-api"
|
||||
"alibabacloud-endpoint-util"
|
||||
"alibabacloud-gateway-dingtalk"
|
||||
"alibabacloud-gateway-spi"
|
||||
"alibabacloud-tea"
|
||||
] (_: null)
|
||||
);
|
||||
|
||||
pythonPackageOverrides = final: _prev:
|
||||
if isAarch64Darwin then {
|
||||
numpy = mkPrebuiltOverride final python312.pkgs.numpy { };
|
||||
pythonPackageOverrides =
|
||||
final: _prev:
|
||||
if isAarch64Darwin then
|
||||
{
|
||||
numpy = mkPrebuiltOverride final python312.pkgs.numpy { };
|
||||
|
||||
pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { };
|
||||
pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { };
|
||||
|
||||
av = mkPrebuiltOverride final python312.pkgs.av { };
|
||||
av = mkPrebuiltOverride final python312.pkgs.av { };
|
||||
|
||||
humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { };
|
||||
humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { };
|
||||
|
||||
coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs {
|
||||
humanfriendly = [ ];
|
||||
};
|
||||
coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs {
|
||||
humanfriendly = [ ];
|
||||
};
|
||||
|
||||
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
|
||||
coloredlogs = [ ];
|
||||
numpy = [ ];
|
||||
packaging = [ ];
|
||||
};
|
||||
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
|
||||
coloredlogs = [ ];
|
||||
numpy = [ ];
|
||||
packaging = [ ];
|
||||
};
|
||||
|
||||
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
|
||||
numpy = [ ];
|
||||
pyyaml = [ ];
|
||||
};
|
||||
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
|
||||
numpy = [ ];
|
||||
pyyaml = [ ];
|
||||
};
|
||||
|
||||
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
|
||||
av = [ ];
|
||||
ctranslate2 = [ ];
|
||||
huggingface-hub = [ ];
|
||||
onnxruntime = [ ];
|
||||
tokenizers = [ ];
|
||||
tqdm = [ ];
|
||||
};
|
||||
} else {};
|
||||
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
|
||||
av = [ ];
|
||||
ctranslate2 = [ ];
|
||||
huggingface-hub = [ ];
|
||||
onnxruntime = [ ];
|
||||
tokenizers = [ ];
|
||||
tqdm = [ ];
|
||||
};
|
||||
}
|
||||
else
|
||||
{ };
|
||||
|
||||
pythonSet =
|
||||
(callPackage pyproject-nix.build.packages {
|
||||
python = python312;
|
||||
}).overrideScope
|
||||
(lib.composeManyExtensions [
|
||||
pyproject-build-systems.overlays.default
|
||||
overlay
|
||||
buildSystemOverrides
|
||||
pythonPackageOverrides
|
||||
]);
|
||||
(
|
||||
lib.composeManyExtensions [
|
||||
pyproject-build-systems.overlays.default
|
||||
overlay
|
||||
buildSystemOverrides
|
||||
pythonPackageOverrides
|
||||
]
|
||||
);
|
||||
|
||||
editableOverlay = workspace.mkEditablePyprojectOverlay {
|
||||
root = "$HERMES_PYTHON_SRC_ROOT"; # resolved at shellHook time
|
||||
};
|
||||
|
||||
workspaceRoot = ./..;
|
||||
editableSet = pythonSet.overrideScope (
|
||||
lib.composeManyExtensions [
|
||||
editableOverlay
|
||||
(final: prev: {
|
||||
hermes-agent = prev.hermes-agent.overrideAttrs (old: {
|
||||
# point straight at the real source instead of the filtered nix store copy
|
||||
src = workspaceRoot;
|
||||
nativeBuildInputs = old.nativeBuildInputs ++ final.resolveBuildSystem { editables = [ ]; };
|
||||
});
|
||||
})
|
||||
]
|
||||
);
|
||||
in
|
||||
pythonSet.mkVirtualEnv "hermes-agent-env" {
|
||||
hermes-agent = dependency-groups;
|
||||
{
|
||||
venv = pythonSet.mkVirtualEnv "hermes-agent-env" {
|
||||
hermes-agent = dependency-groups;
|
||||
};
|
||||
editableVenv = editableSet.mkVirtualEnv "hermes-agent-editable-env" {
|
||||
hermes-agent = dependency-groups;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,14 +25,50 @@ Behavioral settings live in `$HERMES_HOME/mem0.json` (set them via `hermes memor
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-hosted) |
|
||||
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-managed, in-process) |
|
||||
| `host` | — | Self-hosted Mem0 server URL (the Docker dashboard). When set, connects over HTTP with `X-API-Key`. Don't combine with `mode: oss` |
|
||||
| `user_id` | `hermes-user` | User identifier on Mem0 |
|
||||
| `agent_id` | `hermes` | Agent identifier |
|
||||
| `rerank` | `true` | Rerank search results for relevance (platform mode only) |
|
||||
| `rerank` | `false` | Rerank search results for relevance (platform mode only) |
|
||||
|
||||
The plugin has three connection modes:
|
||||
|
||||
- **Platform** — Mem0's hosted cloud (`api.mem0.ai`). Set `MEM0_API_KEY`. (default)
|
||||
- **Self-hosted dashboard** — a Mem0 server you run yourself via Docker. Set `host`. See below.
|
||||
- **OSS** — run Mem0 in-process with your own LLM + vector store. Set `mode: oss`. See below.
|
||||
|
||||
## Self-Hosted Dashboard (Server) Mode
|
||||
|
||||
Connect the plugin to a standalone Mem0 server you run yourself — the Docker-shipped Mem0 dashboard/server with its own REST API. Unlike OSS mode (which runs `mem0ai` in-process with your own vector store), here the plugin just talks HTTP to your server.
|
||||
|
||||
1. Run the Mem0 server (FastAPI + pgvector) from its Docker image and note its URL and `ADMIN_API_KEY`.
|
||||
2. Point the plugin at it — via the setup wizard:
|
||||
```bash
|
||||
hermes memory setup # select "mem0" → "Self-hosted server"
|
||||
# Or non-interactive:
|
||||
hermes memory setup mem0 --mode selfhosted --host http://localhost:8888 --api-key your-admin-api-key
|
||||
```
|
||||
or via env vars:
|
||||
```bash
|
||||
echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env
|
||||
echo "MEM0_API_KEY=your-admin-api-key" >> ~/.hermes/.env
|
||||
```
|
||||
or in `$HERMES_HOME/mem0.json`:
|
||||
```json
|
||||
{
|
||||
"host": "http://localhost:8888",
|
||||
"api_key": "your-admin-api-key"
|
||||
}
|
||||
```
|
||||
3. Start a fresh Hermes session and call `mem0_search` — it connects to your server.
|
||||
|
||||
The plugin authenticates with `X-API-Key` and uses the server's `/search` and `/memories` routes. `api_key` is optional — omit it only for servers running with `AUTH_DISABLED`.
|
||||
|
||||
> Setting `host` routes to the self-hosted server automatically. Don't set `mode: oss` — OSS takes precedence and ignores `host`.
|
||||
|
||||
## OSS (Self-Hosted) Mode
|
||||
|
||||
Run Mem0 locally with your own LLM, embedder, and vector store.
|
||||
Run Mem0 locally with your own LLM, embedder, and vector store. This is the in-process SDK mode. To instead connect to a Mem0 server you run via Docker, see [Self-Hosted Dashboard (Server) Mode](#self-hosted-dashboard-server-mode) above.
|
||||
|
||||
### Interactive Setup
|
||||
|
||||
@@ -106,7 +142,6 @@ hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `mem0_list` | List all stored memories (paginated) |
|
||||
| `mem0_search` | Semantic search by meaning |
|
||||
| `mem0_add` | Store a fact verbatim (no LLM extraction) |
|
||||
| `mem0_update` | Update a memory's text by ID |
|
||||
|
||||
@@ -9,10 +9,15 @@ Configuration
|
||||
-------------
|
||||
Secret (lives in $HERMES_HOME/.env or the environment):
|
||||
MEM0_API_KEY — Mem0 Platform API key (required for platform mode)
|
||||
MEM0_HOST — Base URL of a self-hosted Mem0 server. When set, the
|
||||
plugin talks to that server directly over HTTP
|
||||
(X-API-Key auth) instead of the cloud API.
|
||||
|
||||
Behavioral settings (live in $HERMES_HOME/mem0.json, set via `hermes memory
|
||||
setup`):
|
||||
mode — Backend mode: "platform" (default) or "oss"
|
||||
host — Self-hosted Mem0 server URL (alt: MEM0_HOST env var).
|
||||
When set, routes to the self-hosted HTTP backend.
|
||||
user_id — Canonical user identifier. When set, it is applied
|
||||
uniformly across every gateway (CLI, Telegram, Slack,
|
||||
Discord, …) so the same human gets one merged memory
|
||||
@@ -44,7 +49,7 @@ logger = logging.getLogger(__name__)
|
||||
# for _BREAKER_COOLDOWN_SECS to avoid hammering a down server.
|
||||
_BREAKER_THRESHOLD = 5
|
||||
_BREAKER_COOLDOWN_SECS = 120
|
||||
_PREFETCH_WAIT_SECS = 1.5
|
||||
_PREFETCH_WAIT_SECS = 3
|
||||
|
||||
_CLIENT_ERROR_TYPES = ("MemoryNotFoundError", "ValidationError")
|
||||
|
||||
@@ -81,6 +86,7 @@ def _load_config() -> dict:
|
||||
config = {
|
||||
"mode": os.environ.get("MEM0_MODE", "platform"),
|
||||
"api_key": os.environ.get("MEM0_API_KEY", ""),
|
||||
"host": os.environ.get("MEM0_HOST", ""),
|
||||
"agent_id": os.environ.get("MEM0_AGENT_ID", "hermes"),
|
||||
"oss": {},
|
||||
}
|
||||
@@ -107,41 +113,22 @@ def _load_config() -> dict:
|
||||
# Tool schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LIST_SCHEMA = {
|
||||
"name": "mem0_list",
|
||||
"description": (
|
||||
"List ALL stored memories about the user, unranked and paginated. "
|
||||
"Use for a full overview/audit at conversation start, or to browse "
|
||||
"everything when you don't have a specific query. For answering a "
|
||||
"specific question, prefer mem0_search."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {"type": "integer", "description": "Page number (default: 1)."},
|
||||
"page_size": {"type": "integer", "description": "Results per page (default: 100, max: 200)."},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
SEARCH_SCHEMA = {
|
||||
"name": "mem0_search",
|
||||
"description": (
|
||||
"Search the user's memories by meaning; returns facts ranked by "
|
||||
"relevance. Use this BEFORE answering any question that may depend on "
|
||||
"relevance. Use this before answering any question that may depend on "
|
||||
"what you know about the user (preferences, facts, history, people, "
|
||||
"projects, past decisions). For multi-part or multi-hop questions, "
|
||||
"call it MULTIPLE times — vary the wording and run follow-up searches "
|
||||
"on what earlier results reveal; one search is rarely enough. Set "
|
||||
"rerank=true for higher accuracy on important queries."
|
||||
"call it several times — vary the wording and run follow-up searches "
|
||||
"on what earlier results reveal; one search is rarely enough."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "What to search for."},
|
||||
"top_k": {"type": "integer", "description": "Max results (default: 10, max: 50)."},
|
||||
"rerank": {"type": "boolean", "description": "Rerank results for relevance (default: true, platform mode only)."},
|
||||
"rerank": {"type": "boolean", "description": "Rerank results for relevance (default: false, platform mode only)."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
@@ -169,7 +156,7 @@ UPDATE_SCHEMA = {
|
||||
"name": "mem0_update",
|
||||
"description": (
|
||||
"Replace the text of an existing memory by its ID (take the ID from a "
|
||||
"mem0_search or mem0_list result). Use when a stored fact has changed "
|
||||
"mem0_search result). Use when a stored fact has changed "
|
||||
"or was wrong — correct it in place instead of adding a duplicate."
|
||||
),
|
||||
"parameters": {
|
||||
@@ -185,7 +172,7 @@ UPDATE_SCHEMA = {
|
||||
DELETE_SCHEMA = {
|
||||
"name": "mem0_delete",
|
||||
"description": (
|
||||
"Delete a memory by its ID (take the ID from a mem0_search or mem0_list "
|
||||
"Delete a memory by its ID (take the ID from a mem0_search "
|
||||
"result). Use when a stored fact is obsolete or the user asks you to "
|
||||
"forget it; prefer mem0_update if the fact merely changed."
|
||||
),
|
||||
@@ -214,8 +201,10 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
self._backend = None
|
||||
self._mode = "platform"
|
||||
self._api_key = ""
|
||||
self._host = ""
|
||||
self._user_id = _DEFAULT_USER_ID
|
||||
self._agent_id = "hermes"
|
||||
self._rerank_default = False
|
||||
self._channel = "cli" # gateway channel name (cli/telegram/discord/...)
|
||||
self._sync_thread = None
|
||||
self._prefetch_thread = None
|
||||
@@ -239,7 +228,9 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
mode = cfg.get("mode", "platform")
|
||||
if mode == "oss":
|
||||
return bool(cfg.get("oss", {}).get("vector_store"))
|
||||
return bool(cfg.get("api_key"))
|
||||
# Platform needs an api_key; self-hosted needs a host (api_key optional
|
||||
# when the server runs with AUTH_DISABLED).
|
||||
return bool(cfg.get("api_key") or cfg.get("host"))
|
||||
|
||||
def save_config(self, values, hermes_home):
|
||||
"""Write config to $HERMES_HOME/mem0.json."""
|
||||
@@ -262,9 +253,10 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
api_key_required = mode != "oss"
|
||||
return [
|
||||
{"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": api_key_required, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"},
|
||||
{"key": "host", "description": "Self-hosted Mem0 server URL (leave blank for cloud)", "required": False, "env_var": "MEM0_HOST"},
|
||||
{"key": "user_id", "description": "User identifier", "default": "hermes-user"},
|
||||
{"key": "agent_id", "description": "Agent identifier", "default": "hermes"},
|
||||
{"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]},
|
||||
{"key": "rerank", "description": "Enable reranking for recall", "default": "false", "choices": ["true", "false"]},
|
||||
]
|
||||
|
||||
def post_setup(self, hermes_home: str, config: dict) -> None:
|
||||
@@ -288,6 +280,9 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
if self._mode == "oss":
|
||||
from ._backend import OSSBackend
|
||||
return OSSBackend(self._config.get("oss", {}))
|
||||
if self._host:
|
||||
from ._backend import SelfHostedBackend
|
||||
return SelfHostedBackend(self._api_key, self._host)
|
||||
from ._backend import PlatformBackend
|
||||
return PlatformBackend(self._api_key)
|
||||
except Exception as e:
|
||||
@@ -342,6 +337,7 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
self._config = _load_config()
|
||||
self._mode = self._config.get("mode", "platform")
|
||||
self._api_key = self._config.get("api_key", "")
|
||||
self._host = self._config.get("host", "")
|
||||
# Resolution order for user_id:
|
||||
# 1. Operator-configured MEM0_USER_ID (env or $HERMES_HOME/mem0.json) —
|
||||
# the canonical principal, applied across every gateway so the same
|
||||
@@ -358,6 +354,14 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
configured = None
|
||||
self._user_id = configured or kwargs.get("user_id") or _DEFAULT_USER_ID
|
||||
self._agent_id = self._config.get("agent_id", "hermes")
|
||||
# Persisted rerank preference (setup wizard / mem0.json). Used as the
|
||||
# DEFAULT for mem0_search when the model doesn't pass ``rerank``
|
||||
# explicitly; per-call args still win. Platform-only feature — other
|
||||
# backends accept-and-ignore the flag.
|
||||
_rr = self._config.get("rerank", False)
|
||||
self._rerank_default = (
|
||||
_rr.lower() in ("true", "1", "yes") if isinstance(_rr, str) else bool(_rr)
|
||||
)
|
||||
self._channel = kwargs.get("platform") or "cli"
|
||||
self._backend = self._create_backend()
|
||||
if self._backend and not self._atexit_registered:
|
||||
@@ -378,22 +382,32 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
return {"channel": self._channel} if self._channel else {}
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
mode_label = "platform (cloud API)" if self._mode == "platform" else "OSS (self-hosted)"
|
||||
rerank_note = " Rerank is available on search." if self._mode == "platform" else ""
|
||||
# Mirror the precedence in _create_backend (oss > host > platform) so
|
||||
# the label always names the backend that actually runs. Checking
|
||||
# ``host`` first here would mislabel an ``oss``+``host`` config as
|
||||
# self-hosted HTTP even though OSS wins the routing.
|
||||
if self._mode == "oss":
|
||||
mode_label = "OSS (self-hosted)"
|
||||
elif self._host:
|
||||
mode_label = "self-hosted (HTTP API)"
|
||||
else:
|
||||
mode_label = "platform (cloud API)"
|
||||
# Rerank is a Mem0 Platform feature only.
|
||||
rerank_note = " Rerank is available on search." if (self._mode == "platform" and not self._host) else ""
|
||||
return (
|
||||
"# Mem0 Memory\n"
|
||||
f"Active. Mode: {mode_label}. User: {self._user_id}.\n"
|
||||
"You have persistent memory of this user from past conversations. "
|
||||
"ALWAYS call mem0_search before answering anything that could depend "
|
||||
"You should call mem0_search before answering anything that could depend "
|
||||
"on prior context (the user's preferences, facts, history, people, "
|
||||
"projects, or earlier decisions) — do not rely on the chat window "
|
||||
"alone, and do not assume you have no memory.\n"
|
||||
"For multi-part or multi-hop questions, run SEVERAL searches with "
|
||||
"For multi-part or multi-hop questions, run several searches with "
|
||||
"different wording/angles and follow-up searches on what the first "
|
||||
"results surface; one search is rarely enough. Keep searching until "
|
||||
"you have every fact the question needs before you answer.\n"
|
||||
"Tools: mem0_search to find memories, mem0_add to store facts, "
|
||||
f"mem0_list for a full overview, mem0_update and mem0_delete to manage by ID.{rerank_note}"
|
||||
f"mem0_update and mem0_delete to manage by ID.{rerank_note}"
|
||||
)
|
||||
|
||||
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
|
||||
@@ -426,7 +440,7 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
body = ""
|
||||
try:
|
||||
results = backend.search(
|
||||
query, filters=self._read_filters(), top_k=10, rerank=True,
|
||||
query, filters=self._read_filters(), top_k=10, rerank=False,
|
||||
)
|
||||
lines = [r.get("memory", "") for r in (results or []) if r.get("memory")]
|
||||
if lines:
|
||||
@@ -497,7 +511,7 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
self._sync_thread.start()
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [LIST_SCHEMA, SEARCH_SCHEMA, ADD_SCHEMA, UPDATE_SCHEMA, DELETE_SCHEMA]
|
||||
return [SEARCH_SCHEMA, ADD_SCHEMA, UPDATE_SCHEMA, DELETE_SCHEMA]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
if self._backend is None:
|
||||
@@ -516,36 +530,13 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
msg += f" Check that your {vs.get('provider', 'vector store')} is running."
|
||||
return json.dumps({"error": msg})
|
||||
|
||||
if tool_name == "mem0_list":
|
||||
try:
|
||||
page = max(1, int(args.get("page", 1)))
|
||||
page_size = min(max(1, int(args.get("page_size", 100))), 200)
|
||||
response = self._backend.get_all(
|
||||
filters=self._read_filters(), page=page, page_size=page_size,
|
||||
)
|
||||
self._record_success()
|
||||
results = response.get("results", [])
|
||||
if not results:
|
||||
return json.dumps({"result": "No memories stored yet."})
|
||||
items = [{"id": m.get("id"), "memory": m.get("memory", "")}
|
||||
for m in results]
|
||||
return json.dumps({
|
||||
"results": items,
|
||||
"count": response.get("count", len(items)),
|
||||
"page": page, "page_size": page_size,
|
||||
})
|
||||
except Exception as e:
|
||||
if not _is_client_error(e):
|
||||
self._record_failure()
|
||||
return tool_error(self._format_error("Failed to list memories", e))
|
||||
|
||||
elif tool_name == "mem0_search":
|
||||
if tool_name == "mem0_search":
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return tool_error("Missing required parameter: query")
|
||||
try:
|
||||
top_k = max(1, min(int(args.get("top_k", 10)), 50))
|
||||
rerank_raw = args.get("rerank", True)
|
||||
rerank_raw = args.get("rerank", getattr(self, "_rerank_default", False))
|
||||
if isinstance(rerank_raw, str):
|
||||
rerank = rerank_raw.lower() not in ("false", "0", "no")
|
||||
else:
|
||||
@@ -576,7 +567,8 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
)
|
||||
self._record_success()
|
||||
event_id = result.get("event_id") if isinstance(result, dict) else None
|
||||
msg = "Fact stored." if self._mode == "oss" else "Fact queued for storage."
|
||||
# Cloud add is async (server-side extraction); OSS and self-hosted store synchronously.
|
||||
msg = "Fact stored." if (self._mode == "oss" or self._host) else "Fact queued for storage."
|
||||
return json.dumps({"result": msg, "event_id": event_id})
|
||||
except Exception as e:
|
||||
self._record_failure()
|
||||
|
||||
@@ -10,11 +10,7 @@ class Mem0Backend(ABC):
|
||||
"""Unified interface over Platform (MemoryClient) and OSS (Memory) backends."""
|
||||
|
||||
@abstractmethod
|
||||
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict:
|
||||
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
@@ -57,16 +53,10 @@ class PlatformBackend(Mem0Backend):
|
||||
from mem0 import MemoryClient
|
||||
self._client = MemoryClient(api_key=api_key)
|
||||
|
||||
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]:
|
||||
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
|
||||
response = self._client.search(query, filters=filters, top_k=top_k, rerank=rerank)
|
||||
return _unwrap_results(response)
|
||||
|
||||
def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict:
|
||||
response = self._client.get_all(filters=filters, page=page, page_size=page_size)
|
||||
results = response.get("results", []) if isinstance(response, dict) else response
|
||||
count = response.get("count", len(results)) if isinstance(response, dict) else len(results)
|
||||
return {"results": results, "count": count}
|
||||
|
||||
def add(
|
||||
self,
|
||||
messages: list,
|
||||
@@ -90,6 +80,79 @@ class PlatformBackend(Mem0Backend):
|
||||
return {"result": "Memory deleted.", "memory_id": memory_id}
|
||||
|
||||
|
||||
class SelfHostedBackend(Mem0Backend):
|
||||
"""Direct HTTP backend for a self-hosted Mem0 server (the FastAPI ``server/``).
|
||||
|
||||
mem0.MemoryClient can't be reused for self-hosted: it is hardwired to the
|
||||
cloud API — ``Authorization: Token`` auth and a ``GET /v1/ping/`` validation
|
||||
call in ``__init__`` that the self-hosted server does not expose (it would
|
||||
404 before any real request). This client talks to that server directly,
|
||||
using its actual contract: ``X-API-Key`` auth and the ``/memories`` /
|
||||
``/search`` routes.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, host: str, transport=None):
|
||||
import httpx
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key # omitted only for AUTH_DISABLED servers
|
||||
# Connect-level retries smooth over transient blips so a single
|
||||
# dropped SYN doesn't count toward the provider failure breaker.
|
||||
# ``transport`` is injectable for tests (httpx.MockTransport).
|
||||
if transport is None:
|
||||
transport = httpx.HTTPTransport(retries=2)
|
||||
self._client = httpx.Client(
|
||||
base_url=host.rstrip("/"), headers=headers, timeout=30.0,
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
def _json(self, method: str, path: str, **kwargs) -> Any:
|
||||
resp = self._client.request(method, path, **kwargs)
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.content else {}
|
||||
|
||||
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
|
||||
# rerank is a platform-only feature; the self-hosted /search ignores it.
|
||||
body: dict[str, Any] = {"query": query, "top_k": top_k}
|
||||
if filters:
|
||||
body["filters"] = filters # user_id belongs in filters (top-level is deprecated)
|
||||
return _unwrap_results(self._json("POST", "/search", json=body))
|
||||
|
||||
def add(
|
||||
self,
|
||||
messages: list,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
infer: bool = False,
|
||||
metadata: dict | None = None,
|
||||
) -> dict:
|
||||
body: dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"infer": infer,
|
||||
}
|
||||
if metadata:
|
||||
body["metadata"] = metadata
|
||||
return self._json("POST", "/memories", json=body)
|
||||
|
||||
def update(self, memory_id: str, text: str) -> dict:
|
||||
self._json("PUT", f"/memories/{memory_id}", json={"text": text})
|
||||
return {"result": "Memory updated.", "memory_id": memory_id}
|
||||
|
||||
def delete(self, memory_id: str) -> dict:
|
||||
self._json("DELETE", f"/memories/{memory_id}")
|
||||
return {"result": "Memory deleted.", "memory_id": memory_id}
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class OSSBackend(Mem0Backend):
|
||||
"""Wraps mem0.Memory for self-hosted (OSS) mode."""
|
||||
|
||||
@@ -189,18 +252,10 @@ class OSSBackend(Mem0Backend):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]:
|
||||
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
|
||||
response = self._memory.search(query, filters=filters, top_k=top_k)
|
||||
return _unwrap_results(response)
|
||||
|
||||
def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict:
|
||||
response = self._memory.get_all(filters=filters)
|
||||
all_results = _unwrap_results(response)
|
||||
total = len(all_results)
|
||||
start = (page - 1) * page_size
|
||||
results = all_results[start : start + page_size]
|
||||
return {"results": results, "count": total}
|
||||
|
||||
def add(
|
||||
self,
|
||||
messages: list,
|
||||
|
||||
@@ -67,6 +67,7 @@ def parse_flags(argv: list[str] | None = None) -> dict[str, str]:
|
||||
flags: dict[str, str] = {
|
||||
"mode": "",
|
||||
"api_key": "",
|
||||
"host": "",
|
||||
"oss_llm": "openai",
|
||||
"oss_llm_key": "",
|
||||
"oss_llm_model": "",
|
||||
@@ -90,6 +91,7 @@ def parse_flags(argv: list[str] | None = None) -> dict[str, str]:
|
||||
flag_map = {
|
||||
"--mode": "mode",
|
||||
"--api-key": "api_key",
|
||||
"--host": "host",
|
||||
"--oss-llm": "oss_llm",
|
||||
"--oss-llm-key": "oss_llm_key",
|
||||
"--oss-llm-model": "oss_llm_model",
|
||||
@@ -230,7 +232,7 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
|
||||
{"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": True, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"},
|
||||
{"key": "user_id", "description": "User identifier", "default": "hermes-user"},
|
||||
{"key": "agent_id", "description": "Agent identifier", "default": "hermes"},
|
||||
{"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]},
|
||||
{"key": "rerank", "description": "Enable reranking for recall", "default": "false", "choices": ["true", "false"]},
|
||||
]
|
||||
|
||||
existing_config = {}
|
||||
@@ -293,6 +295,24 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
|
||||
return
|
||||
|
||||
provider_config["mode"] = "platform"
|
||||
# Clear any stale self-hosted host: routing checks ``host`` before platform
|
||||
# (see _create_backend), so leaving it would silently keep routing to the
|
||||
# self-hosted server even though the user just chose platform mode. Set it
|
||||
# to "" rather than pop() — save_config merges into the existing mem0.json
|
||||
# (existing.update), so a popped key would survive; an empty value overwrites
|
||||
# it and reads as falsy at routing time.
|
||||
provider_config["host"] = ""
|
||||
# The json-file clear above can't help when the host comes from the
|
||||
# environment: _load_config() seeds ``host`` from MEM0_HOST, and the
|
||||
# docs tell self-hosted users to put MEM0_HOST in ~/.hermes/.env. Warn
|
||||
# so the user knows platform mode won't take effect until it's removed.
|
||||
if os.environ.get("MEM0_HOST", "").strip():
|
||||
print(
|
||||
"\n ⚠ MEM0_HOST is set in your environment "
|
||||
f"({os.environ['MEM0_HOST']}). It overrides platform mode — "
|
||||
"remove it from ~/.hermes/.env (or unset it) or Hermes will keep "
|
||||
"routing to the self-hosted server."
|
||||
)
|
||||
|
||||
from hermes_cli.config import save_config
|
||||
config["memory"]["provider"] = "mem0"
|
||||
@@ -313,6 +333,102 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
|
||||
print("\n Start a new session to activate.\n")
|
||||
|
||||
|
||||
def _check_selfhosted_server(host: str) -> None:
|
||||
"""Best-effort reachability check for a self-hosted Mem0 server (non-fatal)."""
|
||||
import urllib.error
|
||||
import urllib.request as _urlreq
|
||||
|
||||
try:
|
||||
req = _urlreq.Request(f"{host.rstrip('/')}/docs", method="GET")
|
||||
_urlreq.urlopen(req, timeout=5)
|
||||
print(f" ✓ Mem0 server reachable at {host}")
|
||||
except urllib.error.HTTPError:
|
||||
# Any HTTP response (401/403/404) still means something is listening.
|
||||
print(f" ✓ Mem0 server responding at {host}")
|
||||
except Exception:
|
||||
print(f" ⚠ Could not reach {host} — check the URL and that the server is running.")
|
||||
|
||||
|
||||
def _setup_selfhosted(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
|
||||
"""Self-hosted mode setup — point at an existing Mem0 dashboard server.
|
||||
|
||||
For users already running the Dockerized Mem0 FastAPI server: stores the
|
||||
server URL (behavioral -> mem0.json) and an optional API key
|
||||
(secret -> .env as MEM0_API_KEY).
|
||||
"""
|
||||
existing_config = {}
|
||||
config_path = Path(hermes_home) / "mem0.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
existing_config = json.loads(config_path.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
provider_config = dict(existing_config)
|
||||
|
||||
print("\n Configuring mem0 (self-hosted server):\n")
|
||||
|
||||
host = flags.get("host") or _prompt(
|
||||
"Mem0 server URL (e.g. http://localhost:8888)",
|
||||
default=provider_config.get("host") or None,
|
||||
)
|
||||
if not host:
|
||||
print(" Error: a server URL is required for self-hosted mode.", file=sys.stderr)
|
||||
return
|
||||
host = host.rstrip("/")
|
||||
|
||||
env_writes: dict[str, str] = {}
|
||||
if flags.get("api_key"):
|
||||
env_writes["MEM0_API_KEY"] = flags["api_key"]
|
||||
else:
|
||||
existing_key = os.environ.get("MEM0_API_KEY", "")
|
||||
if existing_key:
|
||||
masked = f"...{existing_key[-4:]}" if len(existing_key) > 4 else "set"
|
||||
val = _prompt(f"Server API key (current: {masked}, blank to keep)", secret=True)
|
||||
else:
|
||||
val = _prompt("Server API key (blank if AUTH_DISABLED)", secret=True)
|
||||
if val:
|
||||
env_writes["MEM0_API_KEY"] = val
|
||||
|
||||
user_id = flags.get("user_id") or _prompt(
|
||||
"User identifier", default=provider_config.get("user_id") or "hermes-user"
|
||||
)
|
||||
agent_id = _prompt("Agent identifier", default=provider_config.get("agent_id") or "hermes")
|
||||
|
||||
if flags.get("dry_run"):
|
||||
print(f"\n [dry-run] Would save config: host={host}, user_id={user_id}, agent_id={agent_id}")
|
||||
if env_writes:
|
||||
print(" [dry-run] Would write API key to .env")
|
||||
_check_selfhosted_server(host)
|
||||
print(" [dry-run] No files written.\n")
|
||||
return
|
||||
|
||||
provider_config["mode"] = "platform" # routing: oss > host > platform; host wins
|
||||
provider_config["host"] = host
|
||||
provider_config["user_id"] = user_id
|
||||
provider_config["agent_id"] = agent_id
|
||||
|
||||
from hermes_cli.config import save_config
|
||||
config["memory"]["provider"] = "mem0"
|
||||
save_config(config)
|
||||
|
||||
from plugins.memory.mem0 import Mem0MemoryProvider
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.save_config(provider_config, hermes_home)
|
||||
|
||||
if env_writes:
|
||||
_write_env(Path(hermes_home) / ".env", env_writes)
|
||||
|
||||
_check_selfhosted_server(host)
|
||||
print("\n Memory provider: mem0 (self-hosted)")
|
||||
print(f" Server: {host}")
|
||||
print(" Activation saved to config.yaml")
|
||||
print(" Provider config saved")
|
||||
if env_writes:
|
||||
print(" API key saved to .env")
|
||||
print("\n Start a new session to activate.\n")
|
||||
|
||||
|
||||
def _setup_oss(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
|
||||
"""OSS mode setup — build config from flags or interactive prompts.
|
||||
|
||||
@@ -828,10 +944,10 @@ def _check_min_dep_version() -> None:
|
||||
def post_setup(hermes_home: str, config: dict) -> None:
|
||||
"""Entry point called by hermes memory setup framework.
|
||||
|
||||
Only intercepts when OSS mode is requested (via --mode oss flag or
|
||||
interactive picker). For platform mode, returns without action so the
|
||||
framework's schema-based flow handles it (preserving the original
|
||||
platform onboarding experience).
|
||||
Routes on --mode (platform / selfhosted / oss); with no flag it shows an
|
||||
interactive picker with all three modes. Platform keeps the framework's
|
||||
original schema-based onboarding; selfhosted points at an existing Mem0
|
||||
server; oss builds a local SDK config.
|
||||
"""
|
||||
_check_min_dep_version()
|
||||
flags = parse_flags(sys.argv[1:])
|
||||
@@ -841,6 +957,10 @@ def post_setup(hermes_home: str, config: dict) -> None:
|
||||
_setup_oss(hermes_home, config, flags)
|
||||
return
|
||||
|
||||
if flags["mode"] in ("selfhosted", "self-hosted"):
|
||||
_setup_selfhosted(hermes_home, config, flags)
|
||||
return
|
||||
|
||||
if flags["mode"] == "platform":
|
||||
_setup_platform(hermes_home, config, flags)
|
||||
return
|
||||
@@ -848,10 +968,13 @@ def post_setup(hermes_home: str, config: dict) -> None:
|
||||
# No --mode flag: show interactive picker
|
||||
mode_items = [
|
||||
("Platform", "Mem0 Cloud API (lightweight, just needs an API key)"),
|
||||
("Self-hosted server", "Connect to an existing self-hosted Mem0 server (Docker/FastAPI)"),
|
||||
("Open Source", "Run Mem0 locally (self-hosted LLM + vector store)"),
|
||||
]
|
||||
mode_idx = _curses_select(" Select mode", mode_items, 0)
|
||||
if mode_idx == 1:
|
||||
_setup_selfhosted(hermes_home, config, flags)
|
||||
elif mode_idx == 2:
|
||||
flags["_mode_from_flag"] = False
|
||||
_setup_oss(hermes_home, config, flags)
|
||||
else:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: mem0
|
||||
version: 1.2.0
|
||||
description: "Mem0 — server-side LLM fact extraction with semantic search, reranking, and automatic deduplication."
|
||||
version: 1.3.0
|
||||
description: "Mem0 — server-side LLM fact extraction with semantic search, automatic deduplication, and opt-in reranking (platform mode)."
|
||||
pip_dependencies:
|
||||
- mem0ai>=2.0.7,<3
|
||||
- mem0ai>=2.0.10,<3
|
||||
|
||||
@@ -754,6 +754,13 @@ _DISCORD_PROMPT_TIMEOUT_MIN = 30
|
||||
_DISCORD_PROMPT_TIMEOUT_MAX = 900
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(name, "").strip().lower()
|
||||
if not raw:
|
||||
return default
|
||||
return raw in {"true", "1", "yes", "on"}
|
||||
|
||||
|
||||
def _read_discord_prompt_timeout() -> int:
|
||||
"""Return the timeout (in seconds) for Discord button views.
|
||||
|
||||
@@ -893,6 +900,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# rate limit (~1 edit per stream tick for the rest of a long reply).
|
||||
# Mirrors the Telegram #58563 fix. Entries are dropped on finalize.
|
||||
self._last_overflow_preview: Dict[tuple, str] = {}
|
||||
self._warned_fail_closed_default = False
|
||||
|
||||
def _handle_bot_task_done(self, task: asyncio.Task) -> None:
|
||||
"""Surface post-startup discord.py task exits to the gateway supervisor.
|
||||
@@ -1153,6 +1161,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
is_dm=_is_dm,
|
||||
channel_ids=_msg_channel_ids,
|
||||
):
|
||||
self._warn_if_fail_closed_default()
|
||||
return
|
||||
_role_authorized = bool(getattr(self, "_allowed_role_ids", set()))
|
||||
|
||||
@@ -1626,6 +1635,31 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_discord_unknown_interaction(exc: BaseException) -> bool:
|
||||
"""True for Discord's expired interaction token error."""
|
||||
code = getattr(exc, "code", None)
|
||||
if code is None:
|
||||
data = getattr(exc, "data", None)
|
||||
if isinstance(data, dict):
|
||||
code = data.get("code")
|
||||
try:
|
||||
code = int(code)
|
||||
except (TypeError, ValueError):
|
||||
code = None
|
||||
|
||||
status = getattr(exc, "status", None)
|
||||
response = getattr(exc, "response", None)
|
||||
if status is None and response is not None:
|
||||
status = getattr(response, "status", None) or getattr(response, "status_code", None)
|
||||
try:
|
||||
status = int(status)
|
||||
except (TypeError, ValueError):
|
||||
status = None
|
||||
|
||||
message = str(exc).lower()
|
||||
return code == 10062 or (status == 404 and "unknown interaction" in message)
|
||||
|
||||
def _command_sync_mutation_interval_seconds(self) -> float:
|
||||
return _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS
|
||||
|
||||
@@ -3195,6 +3229,18 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
return True
|
||||
return bool(channel_ids & allowed)
|
||||
|
||||
def _is_pairing_approved_user(self, user_id: str) -> bool:
|
||||
"""True when the Discord user has an explicit Hermes pairing grant."""
|
||||
user_id = str(user_id or "").strip()
|
||||
if not user_id:
|
||||
return False
|
||||
try:
|
||||
from gateway.pairing import PairingStore
|
||||
|
||||
return bool(PairingStore().is_approved("discord", user_id))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _is_allowed_user(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -3235,6 +3281,14 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
allowed_roles = getattr(self, "_allowed_role_ids", set())
|
||||
has_users = bool(allowed_users)
|
||||
has_roles = bool(allowed_roles)
|
||||
|
||||
# Pairing is a first-class auth grant in the gateway auth union and in
|
||||
# Discord component buttons. Honor it here too so normal guild/DM text
|
||||
# messages do not get dropped at the adapter before the pairing-aware
|
||||
# gateway layer can see them.
|
||||
if self._is_pairing_approved_user(user_id):
|
||||
return True
|
||||
|
||||
if not has_users and not has_roles:
|
||||
if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
|
||||
return True
|
||||
@@ -3303,6 +3357,28 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
m_roles = getattr(m, "roles", None) or []
|
||||
return any(getattr(r, "id", None) in allowed_roles for r in m_roles)
|
||||
|
||||
def _warn_if_fail_closed_default(self) -> None:
|
||||
"""Log once when Discord is rejecting traffic with no allowlist set."""
|
||||
if getattr(self, "_warned_fail_closed_default", False):
|
||||
return
|
||||
allowed_users = getattr(self, "_allowed_user_ids", set()) or set()
|
||||
allowed_roles = getattr(self, "_allowed_role_ids", set()) or set()
|
||||
if allowed_users or allowed_roles:
|
||||
return
|
||||
if os.getenv("DISCORD_ALLOWED_CHANNELS", "").strip():
|
||||
return
|
||||
if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
|
||||
return
|
||||
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
|
||||
return
|
||||
self._warned_fail_closed_default = True
|
||||
logger.warning(
|
||||
"[%s] Discord messages are being denied because no allowlist is configured. "
|
||||
"Set DISCORD_ALLOWED_USERS, DISCORD_ALLOWED_ROLES, or "
|
||||
"DISCORD_ALLOWED_CHANNELS, or set DISCORD_ALLOW_ALL_USERS=true for open access.",
|
||||
self.name,
|
||||
)
|
||||
|
||||
# ── Slash command authorization ─────────────────────────────────────
|
||||
# Slash commands (``_run_simple_slash`` and ``_handle_thread_create_slash``)
|
||||
# are a separate Discord interaction surface from regular messages and
|
||||
@@ -3964,9 +4040,22 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if not await self._check_slash_authorization(interaction, command_text):
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
deferred_response = False
|
||||
try:
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
deferred_response = True
|
||||
except Exception as e:
|
||||
if not self._is_discord_unknown_interaction(e):
|
||||
raise
|
||||
logger.warning(
|
||||
"[Discord] slash %s: interaction expired before defer. "
|
||||
"Executing command anyway, skipping interaction followup.",
|
||||
command_text,
|
||||
)
|
||||
event = self._build_slash_event(interaction, command_text)
|
||||
await self.handle_message(event)
|
||||
if not deferred_response:
|
||||
return
|
||||
try:
|
||||
if followup_msg:
|
||||
await interaction.edit_original_response(content=followup_msg)
|
||||
@@ -4562,7 +4651,17 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
"""Create a Discord thread from a slash command and start a session in it."""
|
||||
if not await self._check_slash_authorization(interaction, "/thread"):
|
||||
return
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
deferred_response = False
|
||||
try:
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
deferred_response = True
|
||||
except Exception as e:
|
||||
if not self._is_discord_unknown_interaction(e):
|
||||
raise
|
||||
logger.warning(
|
||||
"[Discord] /thread: interaction expired before defer. "
|
||||
"Creating the thread anyway, skipping interaction followups.",
|
||||
)
|
||||
result = await self._create_thread(
|
||||
interaction,
|
||||
name=name,
|
||||
@@ -4572,7 +4671,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
|
||||
if not result.get("success"):
|
||||
error = result.get("error", "unknown error")
|
||||
await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True)
|
||||
if deferred_response:
|
||||
await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True)
|
||||
return
|
||||
|
||||
thread_id = result.get("thread_id")
|
||||
@@ -4580,7 +4680,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
|
||||
# Tell the user where the thread is
|
||||
link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**"
|
||||
await interaction.followup.send(f"Created thread {link}", ephemeral=True)
|
||||
if deferred_response:
|
||||
await interaction.followup.send(f"Created thread {link}", ephemeral=True)
|
||||
|
||||
# Track thread participation so follow-ups don't require @mention
|
||||
if thread_id:
|
||||
@@ -5452,6 +5553,20 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
body = body[: max(0, budget - len(truncated_suffix))] + truncated_suffix
|
||||
return f"{prefix}{body}{suffix}"
|
||||
|
||||
def _approval_mention_content(self) -> Optional[str]:
|
||||
"""Return user mentions for approval prompts when explicitly enabled.
|
||||
|
||||
Gated on ``discord.approval_mentions`` in config.yaml (bridged to the
|
||||
``DISCORD_APPROVAL_MENTIONS`` env var). Only numeric allowlist entries
|
||||
can be mentioned; default off avoids surprise pings.
|
||||
"""
|
||||
if not _env_bool("DISCORD_APPROVAL_MENTIONS", False):
|
||||
return None
|
||||
user_ids = sorted(uid for uid in self._allowed_user_ids if str(uid).isdigit())
|
||||
if not user_ids:
|
||||
return None
|
||||
return " ".join(f"<@{uid}>" for uid in user_ids)
|
||||
|
||||
async def send_exec_approval(
|
||||
self, chat_id: str, command: str, session_key: str,
|
||||
description: str = "dangerous command",
|
||||
@@ -5491,6 +5606,9 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
"Do you want Hermes to run this command?\n\n"
|
||||
"**Requested command:**\n```bash\n"
|
||||
)
|
||||
mention_content = self._approval_mention_content()
|
||||
if mention_content:
|
||||
prompt_prefix = f"{mention_content}\n{prompt_prefix}"
|
||||
prompt_tail = f"\n```\n**Reason:** {reason_display}"
|
||||
truncated_suffix = "\n... [truncated]"
|
||||
command_budget = max(0, self.MAX_MESSAGE_LENGTH - len(prompt_prefix) - len(prompt_tail))
|
||||
@@ -5526,7 +5644,17 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
admin_user_ids=admin_user_ids,
|
||||
)
|
||||
|
||||
msg = await channel.send(content=content, embed=embed, view=view)
|
||||
send_kwargs: Dict[str, Any] = {"content": content, "embed": embed, "view": view}
|
||||
if mention_content:
|
||||
allowed_mentions_cls = getattr(discord, "AllowedMentions", None)
|
||||
if allowed_mentions_cls is not None:
|
||||
send_kwargs["allowed_mentions"] = allowed_mentions_cls(
|
||||
users=True,
|
||||
roles=False,
|
||||
everyone=False,
|
||||
replied_user=False,
|
||||
)
|
||||
msg = await channel.send(**send_kwargs)
|
||||
view._message = msg # store for on_timeout expiration editing
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
@@ -8017,7 +8145,11 @@ def interactive_setup() -> None:
|
||||
print_info("Discord: already configured")
|
||||
if not prompt_yes_no("Reconfigure Discord?", False):
|
||||
if not get_env_value("DISCORD_ALLOWED_USERS"):
|
||||
print_info("⚠️ Discord has no user allowlist - anyone can use your bot!")
|
||||
print_info(
|
||||
"⚠️ Discord has no user allowlist. With the fail-closed default, "
|
||||
"messages are denied unless you configure allowed users, roles, "
|
||||
"or channels, or set DISCORD_ALLOW_ALL_USERS=true."
|
||||
)
|
||||
if prompt_yes_no("Add allowed users now?", True):
|
||||
print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID")
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated)")
|
||||
@@ -8050,7 +8182,11 @@ def interactive_setup() -> None:
|
||||
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
|
||||
print_success("Discord allowlist configured")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!")
|
||||
print_info(
|
||||
"⚠️ No allowlist set. Discord will deny messages until you set "
|
||||
"DISCORD_ALLOWED_USERS, DISCORD_ALLOWED_ROLES, DISCORD_ALLOWED_CHANNELS, "
|
||||
"or DISCORD_ALLOW_ALL_USERS=true for open access."
|
||||
)
|
||||
|
||||
print()
|
||||
print_info("📬 Home Channel: where Hermes delivers cron job results,")
|
||||
@@ -8111,6 +8247,12 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
|
||||
if isinstance(allowed_users_cfg, list):
|
||||
allowed_users_cfg = ",".join(str(v) for v in allowed_users_cfg)
|
||||
os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg)
|
||||
approval_mentions_cfg = (
|
||||
discord_cfg["approval_mentions"] if "approval_mentions" in discord_cfg
|
||||
else platform_extra_cfg.get("approval_mentions")
|
||||
)
|
||||
if approval_mentions_cfg is not None and not os.getenv("DISCORD_APPROVAL_MENTIONS"):
|
||||
os.environ["DISCORD_APPROVAL_MENTIONS"] = str(approval_mentions_cfg).lower()
|
||||
frc = discord_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
|
||||
@@ -27,6 +27,7 @@ _IS_WINDOWS = platform.system() == "Windows"
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
|
||||
from hermes_constants import (
|
||||
find_node_executable,
|
||||
get_hermes_dir,
|
||||
@@ -490,19 +491,19 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
# QR codes to its log file and never reaches status:connected,
|
||||
# so every gateway restart paid the 30s timeout + queued WhatsApp
|
||||
# for indefinite retries. Mark non-retryable so the user gets a
|
||||
# clear "run hermes whatsapp" message instead of the watcher
|
||||
# clear pairing message instead of the watcher
|
||||
# silently hammering an unconfigured platform.
|
||||
creds_path = self._session_path / "creds.json"
|
||||
if not creds_path.exists():
|
||||
logger.warning(
|
||||
"[%s] WhatsApp is enabled but not paired (no creds.json at %s). "
|
||||
"Run `hermes whatsapp` to pair, or remove WHATSAPP_ENABLED from "
|
||||
"your .env to disable.",
|
||||
"Pair from the dashboard or run `hermes whatsapp`; remove "
|
||||
"WHATSAPP_ENABLED from your .env to disable.",
|
||||
self.name, creds_path,
|
||||
)
|
||||
self._set_fatal_error(
|
||||
"whatsapp_not_paired",
|
||||
"WhatsApp enabled but not paired — run `hermes whatsapp` to pair.",
|
||||
"WhatsApp enabled but not paired — pair from the dashboard or run `hermes whatsapp`.",
|
||||
retryable=False,
|
||||
)
|
||||
return False
|
||||
@@ -648,8 +649,8 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
],
|
||||
stdout=bridge_log_fh,
|
||||
stderr=bridge_log_fh,
|
||||
start_new_session=True,
|
||||
env=bridge_env,
|
||||
**windows_detach_popen_kwargs(),
|
||||
)
|
||||
_write_bridge_pidfile(self._session_path, self._bridge_process.pid)
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-agent"
|
||||
version = "0.18.0"
|
||||
version = "0.18.2"
|
||||
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
|
||||
readme = "README.md"
|
||||
# Upper bound is load-bearing, not cosmetic. uv resolves the project's
|
||||
|
||||
+1
-1
@@ -5337,7 +5337,7 @@ class AIAgent:
|
||||
"google/gemini-2",
|
||||
"google/gemma-4",
|
||||
"qwen/qwen3",
|
||||
"tencent/hy3-preview",
|
||||
"tencent/hy3",
|
||||
"xiaomi/",
|
||||
)
|
||||
return any(model.startswith(prefix) for prefix in reasoning_model_prefixes)
|
||||
|
||||
@@ -45,6 +45,10 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"grace@weeb.onl": "evelynburger", # PR #57544 salvage (gateway: webhook payload filters + route scripts; commit under unlinked identity)
|
||||
"contato@siteup.com.br": "SiteupAgencia", # PR #57435 salvage (tui_gateway: back off notification poller when session is busy; #55578)
|
||||
"164521089+rainbowgits@users.noreply.github.com": "rainbowgore", # PR #59405 salvage (mcp: bound stdio initialize handshake to stop subprocess/FD leak; #59349)
|
||||
"sage@Sages-Mac-mini.local": "thestudionorth", # PR #60015 salvage (mcp: parent-death watchdog for stdio children; commit under unlinked local identity)
|
||||
"4087127+vampyren@users.noreply.github.com": "vampyren", # PR #59830 salvage (kanban: grab-to-pan board scrolling; original commit under unlinked local identity)
|
||||
"spiky02plateau@users.noreply.github.com": "spiky02plateau", # PR #32824 salvage (usage: fetch Codex account limits from the credential pool in pool-only setups; superseded by #60028)
|
||||
"taylorhp@gmail.com": "hwrdprkns", # PR #36896 salvage (secrets: 1Password op:// secret source + shared _cache substrate, adapted onto the SecretSource interface)
|
||||
@@ -90,6 +94,7 @@ AUTHOR_MAP = {
|
||||
"aqdrgg19@gmail.com": "VolodymyrBg", # PR #2861 salvage (webhook: drop the unused full request payload from retained _delivery_info entries — up to ~1MB dead weight per delivery for the 1h idempotency TTL)
|
||||
"ohyes9711@gmail.com": "CharmingGroot", # PR #2794 salvage (email: guard msg_data[0][1] against malformed IMAP fetch structures so one bad response can't abort the batch and permanently lose seen-marked messages; Message-ID domain falls back to localhost when EMAIL_ADDRESS lacks '@')
|
||||
"sahibzada@fastino.ai": "sahibzada-allahyar", # PR #39227 salvage (desktop: configured terminal.cwd overrides a stale remembered workspace-cwd localStorage value when no session is active; #38855)
|
||||
"ronaldrj@gmail.com": "rarf", # PR #56966 salvage (desktop chat model picker: hide implicitly discovered providers unless explicitly configured)
|
||||
"jvsantos.cunha@gmail.com": "plcunha", # PR #55300 salvage (gateway: record child gateway peer metadata after a compression session-id rotation and repoint stale sessions.json compression-parent entries to the recovered live child; consolidated in the compression-routing-integrity salvage)
|
||||
"jakepresent1@gmail.com": "jakepresent", # PR #55721 salvage (gateway: identity-guard stale in-flight compression splits — a late run may publish its compressed child only if its run generation is still current and the session key still points at the run's original parent, so an old run can't overwrite a newer /new or moved binding)
|
||||
"gumclaw@gumroad.com": "gumclaw", # PR #57322 salvage (gateway: close per-delivery webhook sessions on completion so prune_sessions can reap them — fixes unbounded state.db growth from unprunable ended_at=NULL webhook rows)
|
||||
@@ -223,6 +228,7 @@ AUTHOR_MAP = {
|
||||
"victor@rocketfueldev.com": "victor-kyriazakos",
|
||||
"87440198+JoaoMarcos44@users.noreply.github.com": "JoaoMarcos44",
|
||||
"joaomarcosdias444@gmail.com": "JoaoMarcos44",
|
||||
"neoguyver@icloud.com": "neoguyverx", # PR #60526 salvage (fail-closed write syntax gate; #60525)
|
||||
"286497132+srojk34@users.noreply.github.com": "srojk34",
|
||||
"srojk34@users.noreply.github.com": "srojk34", # legacy prefix-less noreply (PR #50098 salvage; #38763)
|
||||
"pinkiilqwq@users.noreply.github.com": "PINKIIILQWQ", # PR #45035 salvage (resume-to-tip; #38763)
|
||||
@@ -291,6 +297,8 @@ AUTHOR_MAP = {
|
||||
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
|
||||
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"caztronics@yahoo.com": "doncazper",
|
||||
"30668368+alex107ivanov@users.noreply.github.com": "alex107ivanov",
|
||||
"210088133+rungmc357@users.noreply.github.com": "rungmc357",
|
||||
"florian.rutishauser@outlook.com": "flo1t",
|
||||
"fanyang@microsoft.com": "fanyangCS",
|
||||
@@ -838,6 +846,10 @@ AUTHOR_MAP = {
|
||||
"maks.mir@yahoo.com": "say8hi",
|
||||
"27719690+Mirac1eSky@users.noreply.github.com": "Mirac1eSky",
|
||||
"web3blind@users.noreply.github.com": "web3blind",
|
||||
"264741654+web3blind@users.noreply.github.com": "web3blind",
|
||||
# Sessions export format cluster (July 2026): HTML + prompt-only salvages
|
||||
"840004959@qq.com": "simplast",
|
||||
"catbearlove1@gmail.com": "catbearlove1-lang",
|
||||
"julia@alexland.us": "alexg0bot",
|
||||
"christian@scheid.tech": "scheidti",
|
||||
# Moonshot schema anyOf+enum salvage (May 2026)
|
||||
@@ -880,6 +892,7 @@ AUTHOR_MAP = {
|
||||
"kshitijk4poor@gmail.com": "kshitijk4poor",
|
||||
"1294707+Tosko4@users.noreply.github.com": "Tosko4",
|
||||
"keira.voss94@gmail.com": "keiravoss94",
|
||||
"vadim.veroslavov@mail.ru": "veradim", # PR #41201 salvage (Z.AI endpoint persist)
|
||||
"16443023+stablegenius49@users.noreply.github.com": "stablegenius49",
|
||||
"fqsy1416@gmail.com": "EKKOLearnAI",
|
||||
"octo-patch@github.com": "octo-patch",
|
||||
|
||||
@@ -100,7 +100,9 @@ try {
|
||||
.slice(0, 16);
|
||||
} catch {}
|
||||
const PAIR_ONLY = args.includes('--pair-only');
|
||||
const PAIR_JSON = args.includes('--pair-json');
|
||||
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
|
||||
const WHATSAPP_DM_POLICY = String(process.env.WHATSAPP_DM_POLICY || 'open').trim().toLowerCase();
|
||||
const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
|
||||
const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n';
|
||||
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
|
||||
@@ -197,6 +199,23 @@ function normalizeWhatsAppId(value) {
|
||||
return String(value).replace(':', '@');
|
||||
}
|
||||
|
||||
function redactWhatsAppId(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
const [userPart, domainPart = ''] = raw.split('@', 2);
|
||||
const bare = userPart.split(':', 1)[0];
|
||||
const digits = bare.replace(/\D/g, '');
|
||||
const suffix = digits ? digits.slice(-4) : bare.slice(-4);
|
||||
return `${suffix ? `…${suffix}` : '…'}${domainPart ? `@${domainPart}` : ''}`;
|
||||
}
|
||||
|
||||
function emitDebugEvent(payload) {
|
||||
if (!WHATSAPP_DEBUG) return;
|
||||
try {
|
||||
console.log(JSON.stringify({ event: 'debug', ...payload }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getMessageContent(msg) {
|
||||
const content = msg?.message || {};
|
||||
if (content.ephemeralMessage?.message) return content.ephemeralMessage.message;
|
||||
@@ -360,6 +379,13 @@ function rememberSentId(id) {
|
||||
let sock = null;
|
||||
let connectionState = 'disconnected';
|
||||
|
||||
function emitPairEvent(event) {
|
||||
if (!PAIR_JSON) return;
|
||||
try {
|
||||
console.log(JSON.stringify({ ts: Date.now(), ...event }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function startSocket() {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
@@ -387,9 +413,13 @@ async function startSocket() {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n');
|
||||
qrcode.generate(qr, { small: true });
|
||||
console.log('\nWaiting for scan...\n');
|
||||
if (PAIR_JSON) {
|
||||
emitPairEvent({ event: 'qr', qr });
|
||||
} else {
|
||||
console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n');
|
||||
qrcode.generate(qr, { small: true });
|
||||
console.log('\nWaiting for scan...\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
@@ -397,22 +427,39 @@ async function startSocket() {
|
||||
connectionState = 'disconnected';
|
||||
|
||||
if (reason === DisconnectReason.loggedOut) {
|
||||
console.log('❌ Logged out. Delete session and restart to re-authenticate.');
|
||||
emitPairEvent({ event: 'error', error: 'logged_out', reason });
|
||||
if (!PAIR_JSON) {
|
||||
console.log('❌ Logged out. Delete session and restart to re-authenticate.');
|
||||
}
|
||||
process.exit(1);
|
||||
} else {
|
||||
// 515 = restart requested (common after pairing). Always reconnect.
|
||||
if (reason === 515) {
|
||||
console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
|
||||
} else {
|
||||
console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`);
|
||||
emitPairEvent({ event: 'disconnected', reason });
|
||||
if (!PAIR_JSON) {
|
||||
if (reason === 515) {
|
||||
console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
|
||||
} else {
|
||||
console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`);
|
||||
}
|
||||
}
|
||||
setTimeout(startSocket, reason === 515 ? 1000 : 3000);
|
||||
}
|
||||
} else if (connection === 'open') {
|
||||
connectionState = 'connected';
|
||||
console.log('✅ WhatsApp connected!');
|
||||
const connectedUser = sock?.user
|
||||
? {
|
||||
id: sock.user.id || null,
|
||||
name: sock.user.name || sock.user.verifiedName || null,
|
||||
}
|
||||
: null;
|
||||
emitPairEvent({ event: 'connected', user: connectedUser });
|
||||
if (!PAIR_JSON) {
|
||||
console.log('✅ WhatsApp connected!');
|
||||
}
|
||||
if (PAIR_ONLY) {
|
||||
console.log('✅ Pairing complete. Credentials saved.');
|
||||
if (!PAIR_JSON) {
|
||||
console.log('✅ Pairing complete. Credentials saved.');
|
||||
}
|
||||
// Give Baileys a moment to flush creds, then exit cleanly
|
||||
setTimeout(() => process.exit(0), 2000);
|
||||
}
|
||||
@@ -484,24 +531,29 @@ async function startSocket() {
|
||||
if (!msg.message) continue;
|
||||
|
||||
const chatId = msg.key.remoteJid;
|
||||
if (WHATSAPP_DEBUG) {
|
||||
try {
|
||||
console.log(JSON.stringify({
|
||||
event: 'upsert', type,
|
||||
fromMe: !!msg.key.fromMe, chatId,
|
||||
senderId: msg.key.participant || chatId,
|
||||
messageKeys: Object.keys(msg.message || {}),
|
||||
}));
|
||||
} catch {}
|
||||
}
|
||||
const senderId = msg.key.participant || chatId;
|
||||
const isGroup = chatId.endsWith('@g.us');
|
||||
const senderNumber = senderId.replace(/@.*/, '');
|
||||
emitDebugEvent({
|
||||
stage: 'upsert',
|
||||
type,
|
||||
fromMe: !!msg.key.fromMe,
|
||||
chatId: redactWhatsAppId(chatId),
|
||||
senderId: redactWhatsAppId(senderId),
|
||||
messageKeys: Object.keys(msg.message || {}),
|
||||
});
|
||||
|
||||
// Handle fromMe messages based on mode
|
||||
let fromOwner = false;
|
||||
if (msg.key.fromMe) {
|
||||
if (isGroup || chatId.includes('status')) continue;
|
||||
if (isGroup || chatId.includes('status')) {
|
||||
emitDebugEvent({
|
||||
stage: 'ignored',
|
||||
reason: isGroup ? 'from_me_group' : 'from_me_status',
|
||||
chatId: redactWhatsAppId(chatId),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (WHATSAPP_MODE === 'bot') {
|
||||
// Bot mode: separate bot number. fromMe inbound is either
|
||||
@@ -546,7 +598,22 @@ async function startSocket() {
|
||||
const myLid = (sock.user?.lid || '').replace(/:.*@/, '@').replace(/@.*/, '');
|
||||
const chatNumber = chatId.replace(/@.*/, '');
|
||||
const isSelfChat = (myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid);
|
||||
if (!isSelfChat) continue;
|
||||
emitDebugEvent({
|
||||
stage: 'self_chat_check',
|
||||
matched: !!isSelfChat,
|
||||
chatId: redactWhatsAppId(chatId),
|
||||
accountId: redactWhatsAppId(sock.user?.id),
|
||||
accountLid: redactWhatsAppId(sock.user?.lid),
|
||||
});
|
||||
if (!isSelfChat) {
|
||||
emitDebugEvent({
|
||||
stage: 'ignored',
|
||||
reason: 'self_chat_mismatch',
|
||||
chatId: redactWhatsAppId(chatId),
|
||||
senderId: redactWhatsAppId(senderId),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,7 +634,7 @@ async function startSocket() {
|
||||
} catch {}
|
||||
continue;
|
||||
}
|
||||
if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
|
||||
if (WHATSAPP_DM_POLICY !== 'pairing' && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
|
||||
try {
|
||||
console.log(JSON.stringify({
|
||||
event: 'ignored',
|
||||
@@ -659,25 +726,39 @@ async function startSocket() {
|
||||
// Ignore Hermes' own reply messages in self-chat mode to avoid loops.
|
||||
if (msg.key.fromMe && ((REPLY_PREFIX && event.body.startsWith(REPLY_PREFIX)) || recentlySentIds.has(msg.key.id))) {
|
||||
if (WHATSAPP_DEBUG) {
|
||||
try { console.log(JSON.stringify({ event: 'ignored', reason: 'agent_echo', chatId, messageId: msg.key.id })); } catch {}
|
||||
emitDebugEvent({
|
||||
stage: 'ignored',
|
||||
reason: 'agent_echo',
|
||||
chatId: redactWhatsAppId(chatId),
|
||||
messageId: msg.key.id,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty messages
|
||||
if (!event.body && !event.hasMedia) {
|
||||
if (WHATSAPP_DEBUG) {
|
||||
try {
|
||||
console.log(JSON.stringify({ event: 'ignored', reason: 'empty', chatId, messageKeys: Object.keys(msg.message || {}) }));
|
||||
} catch (err) {
|
||||
console.error('Failed to log empty message event:', err);
|
||||
}
|
||||
}
|
||||
emitDebugEvent({
|
||||
stage: 'ignored',
|
||||
reason: 'empty',
|
||||
chatId: redactWhatsAppId(chatId),
|
||||
messageKeys: Object.keys(msg.message || {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
messageStore.remember(msg);
|
||||
messageQueue.push(event);
|
||||
emitDebugEvent({
|
||||
stage: 'queued',
|
||||
chatId: redactWhatsAppId(chatId),
|
||||
senderId: redactWhatsAppId(senderId),
|
||||
fromOwner: !!fromOwner,
|
||||
bodyLength: event.body.length,
|
||||
hasMedia: event.hasMedia,
|
||||
mediaType: event.mediaType,
|
||||
queueLength: messageQueue.length,
|
||||
});
|
||||
if (messageQueue.length > MAX_QUEUE_SIZE) {
|
||||
messageQueue.shift();
|
||||
}
|
||||
@@ -999,10 +1080,20 @@ app.get('/health', (req, res) => {
|
||||
// Start
|
||||
if (PAIR_ONLY) {
|
||||
// Pair-only mode: just connect, show QR, save creds, exit. No HTTP server.
|
||||
console.log('📱 WhatsApp pairing mode');
|
||||
console.log(`📁 Session: ${SESSION_DIR}`);
|
||||
console.log();
|
||||
startSocket();
|
||||
if (PAIR_JSON) {
|
||||
emitPairEvent({ event: 'started', session: SESSION_DIR });
|
||||
} else {
|
||||
console.log('📱 WhatsApp pairing mode');
|
||||
console.log(`📁 Session: ${SESSION_DIR}`);
|
||||
console.log();
|
||||
}
|
||||
startSocket().catch((err) => {
|
||||
emitPairEvent({ event: 'error', error: err?.message || String(err) });
|
||||
if (!PAIR_JSON) {
|
||||
console.error(err);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
app.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);
|
||||
@@ -1011,6 +1102,8 @@ if (PAIR_ONLY) {
|
||||
console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);
|
||||
} else if (WHATSAPP_MODE === 'self-chat') {
|
||||
console.log(`🔒 Self-chat mode — only your own messages to yourself are processed.`);
|
||||
} else if (WHATSAPP_MODE === 'bot' && WHATSAPP_DM_POLICY === 'pairing') {
|
||||
console.log(`🤝 WHATSAPP_DM_POLICY=pairing — unknown DMs are forwarded for gateway pairing.`);
|
||||
} else {
|
||||
console.log(`🔒 No WHATSAPP_ALLOWED_USERS set — incoming messages are rejected.`);
|
||||
console.log(` Set WHATSAPP_ALLOWED_USERS=<phone> to authorize specific users,`);
|
||||
|
||||
+295
-259
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
"start": "node bridge.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "WhiskeySockets/Baileys#01047debd81beb20da7b7779b08edcb06aa03770",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc13",
|
||||
"express": "^4.21.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"pino": "^9.0.0"
|
||||
|
||||
@@ -590,3 +590,93 @@ class TestAuxiliaryClientBedrockResolution:
|
||||
_, model = resolve_provider_client("bedrock", None)
|
||||
|
||||
assert "haiku" in model.lower()
|
||||
|
||||
def test_bedrock_non_claude_model_uses_converse_client(self, monkeypatch):
|
||||
"""Non-Claude Bedrock models (e.g. gpt-oss) must use Converse, not Anthropic SDK."""
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
|
||||
|
||||
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client") as mock_build:
|
||||
from agent.auxiliary_client import (
|
||||
BedrockAuxiliaryClient,
|
||||
resolve_provider_client,
|
||||
)
|
||||
client, model = resolve_provider_client(
|
||||
"bedrock", "openai.gpt-oss-20b-1:0"
|
||||
)
|
||||
|
||||
mock_build.assert_not_called()
|
||||
assert isinstance(client, BedrockAuxiliaryClient)
|
||||
assert model == "openai.gpt-oss-20b-1:0"
|
||||
|
||||
def test_bedrock_claude_model_still_uses_anthropic_client(self, monkeypatch):
|
||||
"""Claude Bedrock IDs should keep the Anthropic SDK auxiliary path."""
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
|
||||
|
||||
mock_anthropic_bedrock = MagicMock()
|
||||
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client",
|
||||
return_value=mock_anthropic_bedrock):
|
||||
from agent.auxiliary_client import (
|
||||
AnthropicAuxiliaryClient,
|
||||
resolve_provider_client,
|
||||
)
|
||||
client, model = resolve_provider_client(
|
||||
"bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
)
|
||||
|
||||
assert isinstance(client, AnthropicAuxiliaryClient)
|
||||
assert "claude-sonnet" in model
|
||||
|
||||
def test_bedrock_non_claude_async_mode(self, monkeypatch):
|
||||
"""Async mode for non-Claude Bedrock should return AsyncBedrockAuxiliaryClient."""
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
|
||||
|
||||
with patch("agent.anthropic_adapter.build_anthropic_bedrock_client"):
|
||||
from agent.auxiliary_client import (
|
||||
AsyncBedrockAuxiliaryClient,
|
||||
resolve_provider_client,
|
||||
)
|
||||
client, _ = resolve_provider_client(
|
||||
"bedrock", "openai.gpt-oss-20b-1:0", async_mode=True
|
||||
)
|
||||
|
||||
assert isinstance(client, AsyncBedrockAuxiliaryClient)
|
||||
|
||||
def test_bedrock_converse_shim_normalizes_string_stop(self, monkeypatch):
|
||||
"""OpenAI callers may pass stop='STR'; Converse requires a list."""
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIO...MPLE")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
|
||||
|
||||
from agent.auxiliary_client import BedrockAuxiliaryClient
|
||||
|
||||
client = BedrockAuxiliaryClient("us-east-1", "openai.gpt-oss-20b-1:0")
|
||||
with patch("agent.bedrock_adapter.call_converse") as mock_converse:
|
||||
client.chat.completions.create(
|
||||
model="openai.gpt-oss-20b-1:0",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stop="STOP",
|
||||
)
|
||||
assert mock_converse.call_args.kwargs["stop_sequences"] == ["STOP"]
|
||||
|
||||
def test_bedrock_converse_shim_stream_returns_complete_response(self, monkeypatch):
|
||||
"""stream=True is not supported by the shim — a complete response comes
|
||||
back and call_llm's streaming consumer downgrades gracefully."""
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIO...MPLE")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
|
||||
|
||||
from agent.auxiliary_client import BedrockAuxiliaryClient
|
||||
|
||||
client = BedrockAuxiliaryClient("us-east-1", "openai.gpt-oss-20b-1:0")
|
||||
sentinel = object()
|
||||
with patch("agent.bedrock_adapter.call_converse", return_value=sentinel) as mock_converse:
|
||||
resp = client.chat.completions.create(
|
||||
model="openai.gpt-oss-20b-1:0",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
)
|
||||
# Non-streaming call_converse is still used; the caller's
|
||||
# got-final-object downgrade path handles the rest.
|
||||
assert resp is sentinel
|
||||
assert mock_converse.call_count == 1
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""System-prompt assembly for the desktop chat surface.
|
||||
|
||||
Pins the second half of the fix: the new ``PLATFORM_HINTS["desktop"]``
|
||||
entry, the deletion of the standalone desktop-hint block from
|
||||
``build_environment_hints()``, and the lookup-site extension that
|
||||
appends the embedded-terminal-pane clarifier to the ``tui`` platform hint
|
||||
when ``HERMES_DESKTOP_TERMINAL=1``.
|
||||
|
||||
These tests run against the real prompt builders (no mocks) because
|
||||
cache-stability and byte-for-byte text contracts are what we are
|
||||
verifying — mocking the resolver would hide exactly the class of bug
|
||||
this test covers.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.prompt_builder import PLATFORM_HINTS, build_environment_hints
|
||||
from agent.system_prompt import (
|
||||
_tui_embedded_pane_clarifier,
|
||||
build_system_prompt_parts,
|
||||
)
|
||||
|
||||
|
||||
def _stable_prompt(agent):
|
||||
with (
|
||||
patch("run_agent.load_soul_md", return_value=""),
|
||||
patch("run_agent.build_nous_subscription_prompt", return_value=""),
|
||||
patch("run_agent.build_environment_hints", return_value=""),
|
||||
patch("run_agent.build_context_files_prompt", return_value=""),
|
||||
):
|
||||
return build_system_prompt_parts(agent)["stable"]
|
||||
|
||||
|
||||
def _make_agent(platform="", **overrides):
|
||||
base = dict(
|
||||
load_soul_identity=False,
|
||||
skip_context_files=False,
|
||||
valid_tool_names=[],
|
||||
_task_completion_guidance=False,
|
||||
_tool_use_enforcement=False,
|
||||
_environment_probe=False,
|
||||
_kanban_worker_guidance="",
|
||||
_memory_store=None,
|
||||
_memory_manager=None,
|
||||
_platform_hint_overrides={},
|
||||
model="",
|
||||
provider="",
|
||||
pass_session_id=False,
|
||||
session_id="",
|
||||
)
|
||||
base["platform"] = platform
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
class TestDesktopHintEntry:
|
||||
def test_desktop_key_exists(self):
|
||||
"""The map must carry a "desktop" entry — without it the platform
|
||||
hint lookup falls through to an empty string and the agent gets no
|
||||
surface framing at all on the desktop chat surface."""
|
||||
assert "desktop" in PLATFORM_HINTS
|
||||
|
||||
def test_desktop_hint_disambiguates_from_terminal(self):
|
||||
"""The agent must be told it is in a graphical chat surface, NOT a
|
||||
terminal. This is the line that kills the contradiction with the
|
||||
old tui mis-tag."""
|
||||
hint = PLATFORM_HINTS["desktop"]
|
||||
lowered = hint.lower()
|
||||
assert "desktop" in lowered
|
||||
assert "not a terminal" in lowered
|
||||
assert "graphical chat surface" in lowered
|
||||
|
||||
def test_desktop_hint_advertises_markdown(self):
|
||||
"""The desktop renderer supports full GFM (verified via the
|
||||
Streamdown pipeline in apps/desktop). The hint must steer the
|
||||
agent toward markdown, not away from it like the cli/tui hints do."""
|
||||
hint = PLATFORM_HINTS["desktop"]
|
||||
assert "markdown" in hint.lower()
|
||||
|
||||
def test_desktop_hint_advertises_media_delivery(self):
|
||||
"""The desktop chat intercepts MEDIA:/abs/path like telegram — images
|
||||
inline, audio/video inline players, other files as download links.
|
||||
Without this line the agent falls back to the cli/tui "state the
|
||||
path in text" model, which is the wrong UX for the desktop surface."""
|
||||
hint = PLATFORM_HINTS["desktop"]
|
||||
assert "MEDIA:" in hint
|
||||
|
||||
def test_desktop_hint_advertises_inline_image_urls(self):
|
||||
hint = PLATFORM_HINTS["desktop"]
|
||||
assert "" in hint
|
||||
|
||||
def test_desktop_hint_does_not_inherit_tui_cron_local_only_block(self):
|
||||
"""The desktop chat surface's cron delivery semantics differ from
|
||||
the standalone TUI — desktop runs its own cron ticker in-process
|
||||
(hermes_cli/web_server.py under HERMES_DESKTOP=1). We deliberately
|
||||
do NOT parrot the tui "LOCAL-ONLY … no live-delivery channel" block
|
||||
into the desktop hint, since partially-correct cron guidance is
|
||||
exactly the bug class we are fixing. Cron guidance for desktop is
|
||||
deferred to a follow-up issue."""
|
||||
hint = PLATFORM_HINTS["desktop"]
|
||||
assert "LOCAL-ONLY" not in hint
|
||||
|
||||
|
||||
class TestDesktopHintBlockRemoved:
|
||||
"""The standalone desktop-hint block that used to live in
|
||||
``build_environment_hints()`` (lines ~1130-1144) was a band-aid for the
|
||||
missing ``PLATFORM_HINTS["desktop"]`` entry. Once that entry exists, the
|
||||
block is dead code that competes with the platform hint's claim of
|
||||
what surface the agent is on. It must be gone."""
|
||||
|
||||
def test_build_environment_hints_has_no_runtime_surface_line(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DESKTOP", "1")
|
||||
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
|
||||
from agent.prompt_builder import _clear_backend_probe_cache
|
||||
_clear_backend_probe_cache()
|
||||
hints = build_environment_hints()
|
||||
assert "Runtime surface:" not in hints
|
||||
assert "desktop GUI app" not in hints
|
||||
|
||||
def test_build_environment_hints_has_no_embedded_pane_clarifier(self, monkeypatch):
|
||||
"""The ⌥-drag / ⌘+L embedded-pane clarifier moves to the platform-hint
|
||||
resolution site (system_prompt.py), not build_environment_hints()."""
|
||||
monkeypatch.setenv("HERMES_DESKTOP", "1")
|
||||
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
|
||||
from agent.prompt_builder import _clear_backend_probe_cache
|
||||
_clear_backend_probe_cache()
|
||||
hints = build_environment_hints()
|
||||
assert "embedded terminal pane" not in hints
|
||||
assert "Shift-drag" not in hints
|
||||
|
||||
|
||||
class TestPlatformHintResolutionInStablePrompt:
|
||||
"""End-to-end through ``build_system_prompt_parts`` — the platform tag on
|
||||
the agent drives BOTH which PLATFORM_HINTS entry gets appended AND
|
||||
whether the embedded-pane clarifier follows it. The desktop-hint block
|
||||
that used to live in ``build_environment_hints()`` is gone."""
|
||||
|
||||
def test_desktop_platform_yields_desktop_hint_no_tui_framing(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DESKTOP", "1")
|
||||
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
|
||||
stable = _stable_prompt(_make_agent(platform="desktop"))
|
||||
assert PLATFORM_HINTS["desktop"] in stable
|
||||
assert "terminal UI" not in stable
|
||||
assert "Runtime surface:" not in stable
|
||||
assert "embedded terminal pane" not in stable
|
||||
|
||||
def test_standalone_tui_yields_plain_tui_hint_no_clarifier(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
|
||||
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
|
||||
stable = _stable_prompt(_make_agent(platform="tui"))
|
||||
assert PLATFORM_HINTS["tui"] in stable
|
||||
assert "embedded terminal pane" not in stable
|
||||
|
||||
def test_embedded_tui_yields_tui_hint_with_clarifier(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DESKTOP", "1")
|
||||
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
|
||||
stable = _stable_prompt(_make_agent(platform="tui"))
|
||||
assert PLATFORM_HINTS["tui"] in stable
|
||||
assert "embedded terminal pane" in stable
|
||||
assert "Shift-drag" in stable or "Option-drag" in stable or "⌥" in stable
|
||||
|
||||
def test_embedded_clarifier_does_not_attach_to_desktop_platform(self, monkeypatch):
|
||||
"""Critical regression: even when HERMES_DESKTOP_TERMINAL=1, a
|
||||
desktop-tagged session must NOT get the embedded-pane clarifier —
|
||||
the clarifier describes the *embedded terminal pane*, which a
|
||||
desktop chat session is not."""
|
||||
monkeypatch.setenv("HERMES_DESKTOP", "1")
|
||||
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
|
||||
stable = _stable_prompt(_make_agent(platform="desktop"))
|
||||
assert "embedded terminal pane" not in stable
|
||||
|
||||
|
||||
class TestEmbeddedTuiPaneClarifier:
|
||||
"""When ``HERMES_DESKTOP_TERMINAL=1``, a standalone ``hermes --tui`` is
|
||||
running inside the desktop's embedded terminal pane. The user can
|
||||
⌥-drag-select its output and ⌘/Ctrl+L to send it to the chat composer.
|
||||
That clarifier must be appended to the ``tui`` platform hint at the
|
||||
resolution site, NOT baked into the static ``PLATFORM_HINTS["tui"]``
|
||||
string (which is shared with every standalone TUI session and must
|
||||
stay byte-stable)."""
|
||||
|
||||
def test_tui_standalone_hint_byte_stable_without_env(self, monkeypatch):
|
||||
"""Without HERMES_DESKTOP_TERMINAL, the clarifier is a no-op and the
|
||||
resolved tui hint is exactly the static PLATFORM_HINTS["tui"]
|
||||
string. Cache-stable for every standalone TUI session."""
|
||||
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
|
||||
out = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"])
|
||||
assert out == PLATFORM_HINTS["tui"]
|
||||
|
||||
def test_embedded_pane_clarifier_appended_when_env_set(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
|
||||
out = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"])
|
||||
assert out.startswith(PLATFORM_HINTS["tui"])
|
||||
assert "embedded terminal pane" in out
|
||||
assert "Shift-drag" in out or "Option-drag" in out or "⌥" in out
|
||||
|
||||
def test_embedded_pane_clarifier_idempotent(self, monkeypatch):
|
||||
"""Calling the clarifier twice must NOT double-append the sentence.
|
||||
Cache-stability: the resolver is called once per session build, so
|
||||
re-applying on an already-augmented hint is a no-op."""
|
||||
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
|
||||
once = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"])
|
||||
twice = _tui_embedded_pane_clarifier(once)
|
||||
assert once == twice
|
||||
|
||||
def test_embedded_pane_clarifier_does_not_touch_empty_hint(self, monkeypatch):
|
||||
"""Defensive: if the tui hint is somehow empty (e.g. overridden to
|
||||
empty by config), do not synthesize a clarifier-only hint — that
|
||||
would put a desktop-pane reference in the prompt without the tui
|
||||
surface framing it sits under."""
|
||||
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", "1")
|
||||
out = _tui_embedded_pane_clarifier("")
|
||||
assert out == ""
|
||||
|
||||
@pytest.mark.parametrize("val", ["0", "false", "no", "", "0", "False"])
|
||||
def test_falsy_env_does_not_trigger_clarifier(self, monkeypatch, val):
|
||||
monkeypatch.setenv("HERMES_DESKTOP_TERMINAL", val)
|
||||
out = _tui_embedded_pane_clarifier(PLATFORM_HINTS["tui"])
|
||||
assert out == PLATFORM_HINTS["tui"], (
|
||||
f"HERMES_DESKTOP_TERMINAL={val!r} should not trigger clarifier"
|
||||
)
|
||||
|
||||
|
||||
class TestContradictionGone:
|
||||
"""The original contradiction: a single assembled system prompt
|
||||
contained both ``You are running in the Hermes terminal UI (TUI).`` and
|
||||
``Runtime surface: you're running inside the Hermes desktop GUI app.``.
|
||||
After the fix, no single session's prompt can carry both."""
|
||||
|
||||
def test_desktop_chat_session_has_no_tui_framing(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DESKTOP", "1")
|
||||
monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False)
|
||||
assert "tui" in PLATFORM_HINTS
|
||||
assert "desktop" in PLATFORM_HINTS
|
||||
desktop_hint = PLATFORM_HINTS["desktop"]
|
||||
tui_hint = PLATFORM_HINTS["tui"]
|
||||
assert "terminal UI" not in desktop_hint
|
||||
assert "terminal UI" in tui_hint
|
||||
|
||||
def test_tui_hint_does_not_carry_desktop_marker(self):
|
||||
tui_hint = PLATFORM_HINTS["tui"]
|
||||
assert "desktop GUI app" not in tui_hint
|
||||
assert "Runtime surface:" not in tui_hint
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user