Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c1a4eefd3 | ||
|
|
8b6207db70 | ||
|
|
11479a8f31 | ||
|
|
0351215bf5 | ||
|
|
31341d6b1d | ||
|
|
17cf1a500f | ||
|
|
1044d9f25d | ||
|
|
de4f40ed02 | ||
|
|
a726e8a811 | ||
|
|
04bb74c58e | ||
|
|
64628ea89b | ||
|
|
2b5268f716 | ||
|
|
f2d4cf4f76 | ||
|
|
1fc7bdc5e6 | ||
|
|
8f4c8e7c82 | ||
|
|
087be00733 | ||
|
|
4ccd141b15 | ||
|
|
3463c97a36 | ||
|
|
0cd7d54b00 | ||
|
|
32899279a7 | ||
|
|
0036c72923 | ||
|
|
eb9bfd3924 | ||
|
|
91a98d1519 | ||
|
|
3c21fed099 | ||
|
|
e8cacb57d5 | ||
|
|
e1293bde4e | ||
|
|
ca03486b6a | ||
|
|
0ffbcbbe7d | ||
|
|
d4e7b2fc19 | ||
|
|
d276018378 | ||
|
|
bd72d333dc | ||
|
|
5cd6c1717d | ||
|
|
9b78f411c8 | ||
|
|
dc4de14377 | ||
|
|
02d1da49de | ||
|
|
50db2d9c12 | ||
|
|
fe62424ac4 | ||
|
|
c2cbe2c97d | ||
|
|
9ed9af2f7d | ||
|
|
b1d34cf6e2 | ||
|
|
cd067ab91e | ||
|
|
355af2c20f | ||
|
|
ec67def5bf | ||
|
|
4ec0adebe8 | ||
|
|
bdfba45247 | ||
|
|
b1a25404b6 | ||
|
|
5921d66785 | ||
|
|
6a72af044c | ||
|
|
96643b4a52 | ||
|
|
0c6e133c04 | ||
|
|
b47cb1bbf2 | ||
|
|
20d073fd0b | ||
|
|
1b955450e3 | ||
|
|
51d165a8e7 | ||
|
|
45465b0d5d | ||
|
|
cddb7283d9 | ||
|
|
b0ce47daac | ||
|
|
234ac00937 | ||
|
|
433bffff51 | ||
|
|
9fbde54b51 | ||
|
|
92ad7cc62c | ||
|
|
42bbd221e8 | ||
|
|
56b8dccf25 | ||
|
|
020601d41e | ||
|
|
182739fcda | ||
|
|
bede3cf12d | ||
|
|
2b16b756a7 | ||
|
|
10dec7c6dc | ||
|
|
ea6eaabd8f | ||
|
|
5f84c9144a | ||
|
|
5a1aa9e68c | ||
|
|
44f3e51865 | ||
|
|
6d2727ef1c | ||
|
|
0bfe19ba17 | ||
|
|
61268ff7a9 | ||
|
|
cbf851ae1d | ||
|
|
bfc4a26032 | ||
|
|
d3724c0be6 | ||
|
|
622e534379 | ||
|
|
40fcb96585 | ||
|
|
2475244ca0 | ||
|
|
8bd00607dc | ||
|
|
6baf0016be | ||
|
|
e1945ff697 | ||
|
|
794519c6ad | ||
|
|
c9e31a8e4b | ||
|
|
296fcdfa52 | ||
|
|
460771bf0f | ||
|
|
41decf2c4a | ||
|
|
a29d64e50c | ||
|
|
4d7ea3fd36 | ||
|
|
2334228eca | ||
|
|
bebd4f8516 | ||
|
|
1bdb29d938 | ||
|
|
39f6b6e9d2 | ||
|
|
6a08fd3c3f | ||
|
|
8ae0802d59 | ||
|
|
83a7d0b601 | ||
|
|
a57cc00081 | ||
|
|
93e6a05efc | ||
|
|
14517ac1f5 | ||
|
|
8e5a6854c3 | ||
|
|
6ab71d3bb4 | ||
|
|
c70dca3a88 | ||
|
|
16882cfded | ||
|
|
64998fa93e | ||
|
|
b4cf114f68 | ||
|
|
e481b15333 | ||
|
|
9d2571c86a | ||
|
|
bb79bcde61 | ||
|
|
e5765e61fa | ||
|
|
84ee80eb5d | ||
|
|
17103a1f11 | ||
|
|
e8076c1ebe | ||
|
|
636ff636d7 | ||
|
|
c1b2d0917f | ||
|
|
8738cb92c3 | ||
|
|
5a72e82fd8 | ||
|
|
7b0915037c | ||
|
|
0437137fff | ||
|
|
827ce602db | ||
|
|
aa32edcac5 | ||
|
|
a7421dc7d2 | ||
|
|
4fa20f9a8b | ||
|
|
97ecfa0fc4 | ||
|
|
5ad2b4c6da | ||
|
|
860cf28dab | ||
|
|
fb0ab27649 | ||
|
|
de6d6023d7 | ||
|
|
59b0ea98c8 | ||
|
|
897f9533ed | ||
|
|
9d4c81130a | ||
|
|
2259c15e4d | ||
|
|
45bc65abbe | ||
|
|
9dbc3722ae | ||
|
|
e38b0b55d1 | ||
|
|
04de307d62 | ||
|
|
bcc8301000 | ||
|
|
54aa4db1de | ||
|
|
2062a84000 | ||
|
|
f9daa4a41d | ||
|
|
689ef5e233 | ||
|
|
bb50825716 | ||
|
|
9f5afc7636 | ||
|
|
4fd8521e44 | ||
|
|
edfdc77664 |
@@ -1783,11 +1783,25 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
|
||||
tool_result_ids.add(block.get("tool_use_id"))
|
||||
for m in result:
|
||||
if m["role"] == "assistant" and isinstance(m["content"], list):
|
||||
m["content"] = [
|
||||
kept = [
|
||||
b
|
||||
for b in m["content"]
|
||||
if b.get("type") != "tool_use" or b.get("id") in tool_result_ids
|
||||
]
|
||||
# If stripping an orphaned tool_use mutated a turn that also carries a
|
||||
# signed thinking block, that block's Anthropic signature was computed
|
||||
# against the ORIGINAL (un-stripped) turn content and is now invalid.
|
||||
# Anthropic rejects the replayed turn with HTTP 400 "thinking blocks in
|
||||
# the latest assistant message cannot be modified". Flag the turn so
|
||||
# _manage_thinking_signatures can demote the dead signature instead of
|
||||
# replaying it verbatim. See hermes-agent: extended-thinking + parallel
|
||||
# tool batch interrupted mid-flight → non-retryable 400 crash-loop.
|
||||
if len(kept) != len(m["content"]) and any(
|
||||
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
|
||||
for b in m["content"]
|
||||
):
|
||||
m["_thinking_signature_invalidated"] = True
|
||||
m["content"] = kept
|
||||
if not m["content"]:
|
||||
m["content"] = [{"type": "text", "text": "(tool call removed)"}]
|
||||
|
||||
@@ -1832,6 +1846,10 @@ def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any
|
||||
fixed[-1]["content"] = prev_content + curr_content
|
||||
else:
|
||||
# Consecutive assistant messages — merge text content.
|
||||
# Propagate the orphan-strip signature-invalidation flag onto the
|
||||
# surviving (prev) dict so _manage_thinking_signatures still sees it.
|
||||
if m.get("_thinking_signature_invalidated"):
|
||||
fixed[-1]["_thinking_signature_invalidated"] = True
|
||||
# Drop thinking blocks from the *second* message: their
|
||||
# signature was computed against a different turn boundary
|
||||
# and becomes invalid once merged.
|
||||
@@ -1920,11 +1938,26 @@ def _manage_thinking_signatures(
|
||||
else:
|
||||
# Latest assistant on direct Anthropic: keep signed, downgrade unsigned
|
||||
# to text so the reasoning isn't lost.
|
||||
#
|
||||
# Exception: if orphan-stripping (or another structural mutation) removed
|
||||
# a tool_use block from THIS turn, every thinking signature on it was
|
||||
# computed against the original turn content and is now dead. Anthropic
|
||||
# rejects the turn either way — replaying the signed block 400s with
|
||||
# "thinking blocks in the latest assistant message cannot be modified",
|
||||
# and a bare signed block with no following tool_use is also invalid.
|
||||
# Demote ALL thinking blocks on this turn to text so the turn replays
|
||||
# cleanly and the model can re-plan from the surviving tool results.
|
||||
signature_dead = bool(m.get("_thinking_signature_invalidated"))
|
||||
new_content = []
|
||||
for b in m["content"]:
|
||||
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
|
||||
new_content.append(b)
|
||||
continue
|
||||
if signature_dead:
|
||||
thinking_text = b.get("thinking", "")
|
||||
if thinking_text:
|
||||
new_content.append({"type": "text", "text": thinking_text})
|
||||
continue
|
||||
if b.get("type") == "redacted_thinking":
|
||||
# Redacted blocks use 'data' for the signature payload —
|
||||
# drop the block when 'data' is missing (can't be validated).
|
||||
@@ -1944,6 +1977,9 @@ def _manage_thinking_signatures(
|
||||
if isinstance(b, dict) and b.get("type") in _THINKING_TYPES:
|
||||
b.pop("cache_control", None)
|
||||
|
||||
# Drop the internal bookkeeping flag — it must never reach the API payload.
|
||||
m.pop("_thinking_signature_invalidated", None)
|
||||
|
||||
|
||||
def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
|
||||
"""Keep only the most recent ``_MAX_KEEP_IMAGES`` computer-use screenshots.
|
||||
|
||||
+52
-19
@@ -1680,26 +1680,48 @@ def _read_main_provider() -> str:
|
||||
# per turn — no lock needed. Cleared by ``clear_runtime_main()``.
|
||||
_RUNTIME_MAIN_PROVIDER: str = ""
|
||||
_RUNTIME_MAIN_MODEL: str = ""
|
||||
_RUNTIME_MAIN_BASE_URL: str = ""
|
||||
_RUNTIME_MAIN_API_KEY: str = ""
|
||||
_RUNTIME_MAIN_API_MODE: str = ""
|
||||
|
||||
|
||||
def set_runtime_main(provider: str, model: str) -> None:
|
||||
"""Record the live runtime provider/model for the current AIAgent.
|
||||
def set_runtime_main(
|
||||
provider: str,
|
||||
model: str,
|
||||
*,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
api_mode: str = "",
|
||||
) -> None:
|
||||
"""Record the live runtime provider/model/credentials for the current AIAgent.
|
||||
|
||||
Called by ``run_agent.AIAgent._sync_runtime_main_for_aux_routing`` (or
|
||||
equivalent setter) at the top of each turn so that
|
||||
``_read_main_provider`` / ``_read_main_model`` reflect CLI/gateway
|
||||
overrides instead of the stale config.yaml default.
|
||||
|
||||
For ``custom:`` providers, ``base_url`` and ``api_key`` must also be
|
||||
recorded so that ``_resolve_auto`` can construct a valid client in
|
||||
Step 1 instead of falling through to the aggregator chain.
|
||||
"""
|
||||
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
|
||||
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
|
||||
_RUNTIME_MAIN_PROVIDER = (provider or "").strip().lower()
|
||||
_RUNTIME_MAIN_MODEL = (model or "").strip()
|
||||
_RUNTIME_MAIN_BASE_URL = (base_url or "").strip()
|
||||
_RUNTIME_MAIN_API_KEY = api_key.strip() if isinstance(api_key, str) else ""
|
||||
_RUNTIME_MAIN_API_MODE = (api_mode or "").strip()
|
||||
|
||||
|
||||
def clear_runtime_main() -> None:
|
||||
"""Clear the runtime override (e.g. on session end)."""
|
||||
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
|
||||
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
|
||||
_RUNTIME_MAIN_PROVIDER = ""
|
||||
_RUNTIME_MAIN_MODEL = ""
|
||||
_RUNTIME_MAIN_BASE_URL = ""
|
||||
_RUNTIME_MAIN_API_KEY = ""
|
||||
_RUNTIME_MAIN_API_MODE = ""
|
||||
|
||||
|
||||
def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
@@ -2980,6 +3002,18 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
runtime_api_key = runtime.get("api_key", "")
|
||||
runtime_api_mode = str(runtime.get("api_mode") or "")
|
||||
|
||||
# Fall back to process-local globals when main_runtime dict was not
|
||||
# provided or was incomplete. ``set_runtime_main()`` now records
|
||||
# base_url/api_key/api_mode alongside provider/model, so custom:
|
||||
# providers get the full credential surface in Step 1 of the
|
||||
# auto-detect chain.
|
||||
if not runtime_base_url and _RUNTIME_MAIN_BASE_URL:
|
||||
runtime_base_url = _RUNTIME_MAIN_BASE_URL
|
||||
if not runtime_api_key and _RUNTIME_MAIN_API_KEY:
|
||||
runtime_api_key = _RUNTIME_MAIN_API_KEY
|
||||
if not runtime_api_mode and _RUNTIME_MAIN_API_MODE:
|
||||
runtime_api_mode = _RUNTIME_MAIN_API_MODE
|
||||
|
||||
# ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
|
||||
# provider (not 'custom'). This catches the common "env poisoning"
|
||||
# scenario where a user switches providers via `hermes model` but the
|
||||
@@ -4725,24 +4759,23 @@ def _build_call_kwargs(
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
if max_tokens is not None:
|
||||
# Codex adapter handles max_tokens internally; OpenRouter/Nous use max_tokens.
|
||||
# Direct OpenAI api.openai.com with newer models needs max_completion_tokens.
|
||||
# ZAI vision models (glm-4v-flash, glm-4v-plus, etc.) reject max_tokens with
|
||||
# error code 1210 ("API 调用参数有误") on multimodal requests — skip it.
|
||||
_model_lower = (model or "").lower()
|
||||
_skip_max_tokens = (
|
||||
provider == "zai"
|
||||
and ("4v" in _model_lower or "5v" in _model_lower or "-v" in _model_lower)
|
||||
# We do NOT cap output by default. Most chat-completions providers treat
|
||||
# an omitted max_tokens as "use the model's max output", which is what we
|
||||
# want for auxiliary tasks (compression summaries, titles, vision, etc.) —
|
||||
# an explicit cap only risks truncating a summary or 400-ing on providers
|
||||
# that reject the parameter outright (e.g. GitHub Copilot / newer OpenAI
|
||||
# GPT-5 models require max_completion_tokens, not max_tokens; ZAI vision
|
||||
# models reject it entirely with error 1210). Omitting it sidesteps all of
|
||||
# those wire-format quirks at once.
|
||||
#
|
||||
# The one exception is the Anthropic Messages wire (MiniMax and any
|
||||
# ``/anthropic`` endpoint reached through the OpenAI SDK wrapper), where
|
||||
# max_tokens is a MANDATORY field — omitting it is a hard 400. Keep it only
|
||||
# there.
|
||||
_effective_base = base_url or (
|
||||
_current_custom_base_url() if provider == "custom" else ""
|
||||
)
|
||||
if _skip_max_tokens:
|
||||
pass # ZAI vision models do not accept max_tokens
|
||||
elif provider == "custom":
|
||||
custom_base = base_url or _current_custom_base_url()
|
||||
if base_url_hostname(custom_base) == "api.openai.com":
|
||||
kwargs["max_completion_tokens"] = max_tokens
|
||||
else:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
else:
|
||||
if _is_anthropic_compat_endpoint(provider, _effective_base):
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
if tools:
|
||||
|
||||
@@ -1283,6 +1283,18 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
||||
agent._copy_reasoning_content_for_api(msg, api_msg)
|
||||
for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
|
||||
api_msg.pop(internal_field, None)
|
||||
# Strict OpenAI-compatible gateways (Fireworks-backed OpenCode Go,
|
||||
# Mistral, Moonshot/Kimi) reject any message key outside the Chat
|
||||
# Completions schema. The main loop drops these via
|
||||
# ChatCompletionsTransport.convert_messages(), but the summary path
|
||||
# hand-builds messages and calls chat.completions.create() directly,
|
||||
# bypassing the transport — so mirror that sanitization here:
|
||||
# tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers,
|
||||
# and every Hermes-internal underscore-prefixed scaffolding key.
|
||||
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"):
|
||||
api_msg.pop(schema_foreign, None)
|
||||
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
|
||||
api_msg.pop(internal_key, None)
|
||||
if _needs_sanitize:
|
||||
agent._sanitize_tool_calls_for_strict_api(api_msg)
|
||||
api_messages.append(api_msg)
|
||||
|
||||
+114
-13
@@ -40,17 +40,47 @@ SUMMARY_PREFIX = (
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary; "
|
||||
"they were already addressed. "
|
||||
"Your current task is identified in the '## Active Task' section of the "
|
||||
"summary — resume exactly from there. "
|
||||
"Respond ONLY to the latest user message that appears AFTER this "
|
||||
"summary — that message is the single source of truth for what to do "
|
||||
"right now. "
|
||||
"If the latest user message is consistent with the '## Active Task' "
|
||||
"section, you may use the summary as background. If the latest user "
|
||||
"message contradicts, supersedes, changes topic from, or in any way "
|
||||
"diverges from '## Active Task' / '## In Progress' / '## Pending User "
|
||||
"Asks' / '## Remaining Work', the latest message WINS — discard those "
|
||||
"stale items entirely and do not 'wrap up the old task first'. "
|
||||
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
|
||||
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
|
||||
"topic) must immediately end any in-flight work described in the "
|
||||
"summary; do not re-surface it in later turns. "
|
||||
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
|
||||
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
|
||||
"memory content due to this compaction note. "
|
||||
"Respond ONLY to the latest user message "
|
||||
"that appears AFTER this summary. The current session state (files, "
|
||||
"config, etc.) may reflect work described here — avoid repeating it:"
|
||||
"The current session state (files, config, etc.) may reflect work "
|
||||
"described here — avoid repeating it:"
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Handoff prefixes that shipped in earlier releases. A summary persisted under
|
||||
# one of these can be inherited into a resumed lineage (#35344); when it is
|
||||
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
|
||||
# stale directive it carried (e.g. "resume exactly from Active Task") survives
|
||||
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
|
||||
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
|
||||
_HISTORICAL_SUMMARY_PREFIXES = (
|
||||
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
|
||||
"into the summary below. This is a handoff from a previous context "
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary; "
|
||||
"they were already addressed. "
|
||||
"Your current task is identified in the '## Active Task' section of the "
|
||||
"summary — resume exactly from there. "
|
||||
"Respond ONLY to the latest user message "
|
||||
"that appears AFTER this summary. The current session state (files, "
|
||||
"config, etc.) may reflect work described here — avoid repeating it:",
|
||||
)
|
||||
|
||||
# Minimum tokens for the summary output
|
||||
_MIN_SUMMARY_TOKENS = 2000
|
||||
# Proportion of compressed content to allocate for summary
|
||||
@@ -518,6 +548,10 @@ class ContextCompressor(ContextEngine):
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
|
||||
self.last_real_prompt_tokens = 0
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
def update_model(
|
||||
self,
|
||||
@@ -615,6 +649,10 @@ class ContextCompressor(ContextEngine):
|
||||
|
||||
self.last_prompt_tokens = 0
|
||||
self.last_completion_tokens = 0
|
||||
self.last_real_prompt_tokens = 0
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
self.summary_model = summary_model_override or ""
|
||||
|
||||
@@ -648,6 +686,44 @@ class ContextCompressor(ContextEngine):
|
||||
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
|
||||
self.last_completion_tokens = usage.get("completion_tokens", 0)
|
||||
self.last_total_tokens = usage.get("total_tokens", self.last_prompt_tokens + self.last_completion_tokens)
|
||||
if self.last_prompt_tokens > 0:
|
||||
self.last_real_prompt_tokens = self.last_prompt_tokens
|
||||
if self.last_prompt_tokens < self.threshold_tokens:
|
||||
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
|
||||
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
|
||||
else:
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
|
||||
"""Return True when a high rough preflight estimate is known-noisy.
|
||||
|
||||
``estimate_request_tokens_rough(..., tools=...)`` intentionally
|
||||
overestimates schema-heavy requests so Hermes compresses before a
|
||||
provider rejects the payload. After a successful compressed API call,
|
||||
though, provider ``prompt_tokens`` are a better signal than repeating
|
||||
compaction from the same rough schema overhead. Defer only while the
|
||||
rough estimate has grown modestly since a request the provider proved
|
||||
fit under the threshold.
|
||||
"""
|
||||
if rough_tokens < self.threshold_tokens:
|
||||
return False
|
||||
if self.last_real_prompt_tokens <= 0:
|
||||
return False
|
||||
if self.last_real_prompt_tokens >= self.threshold_tokens:
|
||||
return False
|
||||
|
||||
baseline = self.last_rough_tokens_when_real_prompt_fit or self.last_compression_rough_tokens
|
||||
if baseline <= 0:
|
||||
return False
|
||||
|
||||
growth = max(0, rough_tokens - baseline)
|
||||
tolerated_growth = max(4096, int(self.threshold_tokens * 0.05))
|
||||
if growth > tolerated_growth:
|
||||
return False
|
||||
|
||||
self.last_rough_tokens_when_real_prompt_fit = max(baseline, rough_tokens)
|
||||
return True
|
||||
|
||||
def should_compress(self, prompt_tokens: int = None) -> bool:
|
||||
"""Check if context exceeds the compression threshold.
|
||||
@@ -1190,11 +1266,27 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
|
||||
# Shared structured template (used by both paths).
|
||||
_template_sections = f"""## Active Task
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or
|
||||
task assignment verbatim — the exact words they used. If multiple tasks
|
||||
were requested and only some are done, list only the ones NOT yet completed.
|
||||
Continuation should pick up exactly here. Example:
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
|
||||
input verbatim — the exact words they used. This includes:
|
||||
- Explicit task assignments ("refactor the auth module")
|
||||
- Questions awaiting an answer ("waarom staat X op Y?", "wat zijn de volgende stappen?")
|
||||
- Decisions awaiting input ("optie A of B?")
|
||||
- Ongoing discussions where the assistant owes the next substantive reply
|
||||
A conversation where the user just asked a question IS an active task — the
|
||||
task is "answer that question with full context". Do NOT write "None" merely
|
||||
because the user did not issue an imperative command; reserve "None" for the
|
||||
rare case where the last exchange was fully resolved and the user said
|
||||
something like "thanks, that's all".
|
||||
If multiple items are outstanding, list only the ones NOT yet completed.
|
||||
Continuation should pick up exactly here. Examples:
|
||||
"User asked: 'Now refactor the auth module to use JWT instead of sessions'"
|
||||
"User asked: 'Waarom stond provider ineens op openrouter?' — needs investigation + answer"
|
||||
"User chose option A; awaiting implementation of step 2"
|
||||
If the user's most recent message was a reverse signal (stop, undo, roll
|
||||
back, never mind, just verify, change of topic) that supersedes earlier
|
||||
work, write the reverse signal verbatim and DO NOT carry forward the
|
||||
cancelled task. Example: "User asked: 'Stop the i18n refactor and just
|
||||
verify the current diff' — earlier i18n in-flight work is cancelled."
|
||||
If no outstanding task exists, write "None."]
|
||||
|
||||
## Goal
|
||||
@@ -1260,7 +1352,7 @@ PREVIOUS SUMMARY:
|
||||
NEW TURNS TO INCORPORATE:
|
||||
{content_to_summarize}
|
||||
|
||||
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled request — this is the most important field for task continuity.
|
||||
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled input — this includes any question, decision request, or discussion turn that the assistant has not yet answered. Only write "None" if the last exchange was fully resolved.
|
||||
|
||||
{_template_sections}"""
|
||||
else:
|
||||
@@ -1424,9 +1516,16 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
|
||||
@staticmethod
|
||||
def _strip_summary_prefix(summary: str) -> str:
|
||||
"""Return summary body without the current or legacy handoff prefix."""
|
||||
"""Return summary body without the current, legacy, or any historical
|
||||
handoff prefix.
|
||||
|
||||
Historical prefixes must be stripped too: a handoff persisted under an
|
||||
older prefix can be inherited into a resumed lineage (#35344), and if we
|
||||
only re-prepend the current prefix without removing the old one, the
|
||||
stale directive it carried stays embedded in the body.
|
||||
"""
|
||||
text = (summary or "").strip()
|
||||
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX):
|
||||
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
|
||||
if text.startswith(prefix):
|
||||
return text[len(prefix):].lstrip()
|
||||
return text
|
||||
@@ -1440,7 +1539,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
@staticmethod
|
||||
def _is_context_summary_content(content: Any) -> bool:
|
||||
text = _content_text_for_contains(content).lstrip()
|
||||
return text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX)
|
||||
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
|
||||
return True
|
||||
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
|
||||
|
||||
@classmethod
|
||||
def _find_latest_context_summary(
|
||||
|
||||
@@ -115,6 +115,15 @@ class ContextEngine(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
|
||||
"""Return True when preflight should trust recent real usage instead.
|
||||
|
||||
Built-in compression uses this to avoid re-compacting from known-noisy
|
||||
rough estimates after a compressed request has already fit. Third-party
|
||||
engines can ignore it safely.
|
||||
"""
|
||||
return False
|
||||
|
||||
# -- Optional: manual /compress preflight ------------------------------
|
||||
|
||||
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
|
||||
@@ -575,19 +575,18 @@ def compress_context(
|
||||
force=True,
|
||||
)
|
||||
|
||||
# Update token estimate after compaction so pressure calculations
|
||||
# use the post-compression count, not the stale pre-compression one.
|
||||
# Use estimate_request_tokens_rough() so tool schemas are included —
|
||||
# with 50+ tools enabled, schemas alone can add 20-30K tokens, and
|
||||
# omitting them delays the next compression cycle far past the
|
||||
# configured threshold (issue #14695).
|
||||
# Keep the post-compression rough estimate for diagnostics, but do not
|
||||
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
|
||||
# can remain above threshold even after the next real API request fits.
|
||||
_compressed_est = estimate_request_tokens_rough(
|
||||
compressed,
|
||||
system_prompt=new_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
agent.context_compressor.last_prompt_tokens = _compressed_est
|
||||
agent.context_compressor.last_compression_rough_tokens = _compressed_est
|
||||
agent.context_compressor.last_prompt_tokens = -1
|
||||
agent.context_compressor.last_completion_tokens = 0
|
||||
agent.context_compressor.awaiting_real_usage_after_compression = True
|
||||
|
||||
# Clear the file-read dedup cache. After compression the original
|
||||
# read content is summarised away — if the model re-reads the same
|
||||
@@ -599,7 +598,7 @@ def compress_context(
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"context compression done: session=%s messages=%d->%d tokens=~%s",
|
||||
"context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true",
|
||||
agent.session_id or "none", _pre_msg_count, len(compressed),
|
||||
f"{_compressed_est:,}",
|
||||
)
|
||||
@@ -645,6 +644,12 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# after a confirmed provider rejection, so the alternative is failure.
|
||||
target_bytes = 4 * 1024 * 1024
|
||||
changed_count = 0
|
||||
# Track parts that are over the target but could NOT be shrunk under it.
|
||||
# If any survive, retrying is pointless — the same oversized payload will
|
||||
# be re-sent and rejected again, wasting the single retry budget. We only
|
||||
# report success (caller retries) when every over-threshold image was
|
||||
# actually brought under the target.
|
||||
unshrinkable_oversized = 0
|
||||
|
||||
def _shrink_data_url(url: str) -> Optional[str]:
|
||||
"""Return a smaller data URL, or None if shrink can't help."""
|
||||
@@ -711,17 +716,34 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
if resized:
|
||||
image_value["url"] = resized
|
||||
changed_count += 1
|
||||
elif isinstance(url, str) and url.startswith("data:") \
|
||||
and len(url) > target_bytes:
|
||||
unshrinkable_oversized += 1
|
||||
elif isinstance(image_value, str):
|
||||
resized = _shrink_data_url(image_value)
|
||||
if resized:
|
||||
part["image_url"] = resized
|
||||
changed_count += 1
|
||||
elif image_value.startswith("data:") \
|
||||
and len(image_value) > target_bytes:
|
||||
unshrinkable_oversized += 1
|
||||
|
||||
if changed_count:
|
||||
logger.info(
|
||||
"image-shrink recovery: re-encoded %d image part(s) to fit under %.0f MB",
|
||||
changed_count, target_bytes / (1024 * 1024),
|
||||
)
|
||||
if unshrinkable_oversized:
|
||||
# At least one oversized image could not be shrunk under the target.
|
||||
# Retrying would re-send it and fail identically, so signal "no
|
||||
# progress" even if other parts shrank — the caller will surface the
|
||||
# original error rather than burning its single retry on a no-op.
|
||||
logger.warning(
|
||||
"image-shrink recovery: %d oversized image part(s) could not be "
|
||||
"shrunk under %.0f MB — not retrying (would re-send rejected payload)",
|
||||
unshrinkable_oversized, target_bytes / (1024 * 1024),
|
||||
)
|
||||
return False
|
||||
return changed_count > 0
|
||||
|
||||
|
||||
|
||||
+110
-14
@@ -392,6 +392,9 @@ def run_conversation(
|
||||
set_runtime_main(
|
||||
getattr(agent, "provider", "") or "",
|
||||
getattr(agent, "model", "") or "",
|
||||
base_url=getattr(agent, "base_url", "") or "",
|
||||
api_key=getattr(agent, "api_key", "") or "",
|
||||
api_mode=getattr(agent, "api_mode", "") or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -600,18 +603,50 @@ def run_conversation(
|
||||
system_prompt=active_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
_compressor = agent.context_compressor
|
||||
_defer_preflight = getattr(
|
||||
_compressor,
|
||||
"should_defer_preflight_to_real_usage",
|
||||
lambda _tokens: False,
|
||||
)
|
||||
_preflight_deferred = _defer_preflight(_preflight_tokens)
|
||||
|
||||
if agent.context_compressor.should_compress(_preflight_tokens):
|
||||
if not _preflight_deferred:
|
||||
# Keep the CLI/ACP context display in sync with what preflight
|
||||
# actually measured. The status bar reads
|
||||
# ``compressor.last_prompt_tokens``, which otherwise only updates
|
||||
# from a *successful* API response. When the conversation has grown
|
||||
# since the last successful call — or when compression then fails
|
||||
# (e.g. the auxiliary summary model times out) and no fresh usage
|
||||
# arrives — the bar stays stuck at the old, smaller value while
|
||||
# preflight reports a much larger number, looking out of sync.
|
||||
# Seed it with the fresh estimate (only ever revising upward; a real
|
||||
# ``update_from_response`` will correct it after the next API call).
|
||||
# Skipped when deferring — a deferred estimate is known to over-count
|
||||
# vs the last real provider prompt, so trusting it for the display
|
||||
# would re-introduce the very desync we're avoiding.
|
||||
if _preflight_tokens > (_compressor.last_prompt_tokens or 0):
|
||||
_compressor.last_prompt_tokens = _preflight_tokens
|
||||
|
||||
if _preflight_deferred:
|
||||
logger.info(
|
||||
"Skipping preflight compression: rough estimate ~%s >= %s, "
|
||||
"but last real provider prompt was %s after compression",
|
||||
f"{_preflight_tokens:,}",
|
||||
f"{_compressor.threshold_tokens:,}",
|
||||
f"{_compressor.last_real_prompt_tokens:,}",
|
||||
)
|
||||
elif _compressor.should_compress(_preflight_tokens):
|
||||
logger.info(
|
||||
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
|
||||
f"{_preflight_tokens:,}",
|
||||
f"{agent.context_compressor.threshold_tokens:,}",
|
||||
f"{_compressor.threshold_tokens:,}",
|
||||
agent.model,
|
||||
f"{agent.context_compressor.context_length:,}",
|
||||
f"{_compressor.context_length:,}",
|
||||
)
|
||||
agent._emit_status(
|
||||
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
|
||||
f">= {agent.context_compressor.threshold_tokens:,} threshold. "
|
||||
f">= {_compressor.threshold_tokens:,} threshold. "
|
||||
"This may take a moment."
|
||||
)
|
||||
# May need multiple passes for very large sessions with small
|
||||
@@ -646,8 +681,8 @@ def run_conversation(
|
||||
system_prompt=active_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
if _preflight_tokens < agent.context_compressor.threshold_tokens:
|
||||
break # Under threshold
|
||||
if not _compressor.should_compress(_preflight_tokens):
|
||||
break # Under threshold or anti-thrash guard stopped it
|
||||
|
||||
# Plugin hook: pre_llm_call
|
||||
# Fired once per turn before the tool-calling loop. Plugins can
|
||||
@@ -1457,7 +1492,8 @@ def run_conversation(
|
||||
|
||||
if retry_count >= max_retries:
|
||||
# Try fallback before giving up
|
||||
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
|
||||
if agent._has_pending_fallback():
|
||||
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
@@ -3059,12 +3095,17 @@ def run_conversation(
|
||||
) and not is_context_length_error
|
||||
|
||||
if is_client_error:
|
||||
# Try fallback before aborting — a different provider
|
||||
# may not have the same issue (rate limit, auth, etc.)
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
|
||||
else:
|
||||
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
|
||||
# Try fallback before aborting — a different provider may
|
||||
# not have the same issue (rate limit, auth, etc.). Only
|
||||
# announce the attempt when a fallback chain actually
|
||||
# exists; otherwise "trying fallback..." is a lie and the
|
||||
# session looks like it's recovering when it's about to
|
||||
# abort silently (#35314, #17446).
|
||||
if agent._has_pending_fallback():
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
|
||||
else:
|
||||
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
@@ -3207,7 +3248,8 @@ def run_conversation(
|
||||
retry_count = 0
|
||||
continue
|
||||
# Try fallback before giving up entirely
|
||||
agent._buffer_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
|
||||
if agent._has_pending_fallback():
|
||||
agent._buffer_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
@@ -3862,6 +3904,11 @@ def run_conversation(
|
||||
# inflate completion_tokens with reasoning,
|
||||
# causing premature compression. (#12026)
|
||||
_real_tokens = _compressor.last_prompt_tokens
|
||||
elif _compressor.last_prompt_tokens == -1:
|
||||
# Compression just ran and no API-reported prompt count
|
||||
# has arrived yet. Avoid treating a schema-heavy rough
|
||||
# post-compression estimate as real context pressure.
|
||||
_real_tokens = 0
|
||||
else:
|
||||
# Include tool schemas — with 50+ tools enabled
|
||||
# these add 20-30K tokens the messages-only
|
||||
@@ -4443,6 +4490,55 @@ def run_conversation(
|
||||
except Exception as _ver_err:
|
||||
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
|
||||
|
||||
# Turn-completion explainer.
|
||||
# When a turn ends abnormally after substantive work — empty content
|
||||
# after retries, a partial/truncated stream, a still-pending tool
|
||||
# result, or an iteration/budget limit — the user otherwise gets a
|
||||
# blank or fragmentary response box with no consolidated reason why
|
||||
# the agent stopped (#34452). Surface a single user-visible
|
||||
# explanation derived from ``_turn_exit_reason``, mirroring the
|
||||
# file-mutation verifier footer pattern above.
|
||||
#
|
||||
# Gate carefully so healthy turns stay quiet:
|
||||
# - ``text_response(...)`` exits never produce an explanation
|
||||
# (handled inside the formatter), so a terse ``Done.`` is silent.
|
||||
# - We only ACT when there is no genuinely usable reply this turn:
|
||||
# an empty response, the "(empty)" terminal sentinel, or a
|
||||
# suspiciously short partial fragment with no terminating
|
||||
# punctuation (e.g. "The"). A real short answer keeps its text.
|
||||
if not interrupted:
|
||||
try:
|
||||
if agent._turn_completion_explainer_enabled():
|
||||
_stripped = (final_response or "").strip()
|
||||
_is_empty_terminal = _stripped == "" or _stripped == "(empty)"
|
||||
# A short fragment that is not a normal text_response exit
|
||||
# and lacks sentence-ending punctuation is treated as a
|
||||
# truncated partial (the "The" case from #34452).
|
||||
_is_partial_fragment = (
|
||||
not _is_empty_terminal
|
||||
and not str(_turn_exit_reason).startswith("text_response")
|
||||
and len(_stripped) <= 24
|
||||
and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"}
|
||||
)
|
||||
if _is_empty_terminal or _is_partial_fragment:
|
||||
_explanation = agent._format_turn_completion_explanation(
|
||||
_turn_exit_reason
|
||||
)
|
||||
if _explanation:
|
||||
if _is_empty_terminal:
|
||||
# Replace the bare "(empty)"/blank sentinel with
|
||||
# the actionable explanation.
|
||||
final_response = _explanation
|
||||
else:
|
||||
# Keep the partial fragment, append the reason so
|
||||
# the user sees both what arrived and why it
|
||||
# stopped.
|
||||
final_response = (
|
||||
_stripped + "\n\n" + _explanation
|
||||
)
|
||||
except Exception as _exp_err:
|
||||
logger.debug("turn-completion explainer failed: %s", _exp_err)
|
||||
|
||||
_response_transformed = False
|
||||
|
||||
# Plugin hook: transform_llm_output
|
||||
|
||||
+6
-13
@@ -247,18 +247,13 @@ def _cmd_restart() -> int:
|
||||
|
||||
|
||||
def _cmd_which(server_id: str) -> int:
|
||||
from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir
|
||||
import shutil as _shutil
|
||||
from agent.lsp.install import INSTALL_RECIPES, _existing_binary
|
||||
|
||||
recipe = INSTALL_RECIPES.get(server_id)
|
||||
bin_name = (recipe or {}).get("bin", server_id)
|
||||
staged = hermes_lsp_bin_dir() / bin_name
|
||||
if staged.exists():
|
||||
sys.stdout.write(str(staged) + "\n")
|
||||
return 0
|
||||
on_path = _shutil.which(bin_name)
|
||||
if on_path:
|
||||
sys.stdout.write(on_path + "\n")
|
||||
resolved = _existing_binary(bin_name)
|
||||
if resolved:
|
||||
sys.stdout.write(resolved + "\n")
|
||||
return 0
|
||||
sys.stderr.write(f"{server_id}: not installed\n")
|
||||
return 1
|
||||
@@ -292,11 +287,9 @@ def _backend_warnings() -> list:
|
||||
suggestion across common platforms.
|
||||
"""
|
||||
import shutil as _shutil
|
||||
from agent.lsp.install import hermes_lsp_bin_dir
|
||||
from agent.lsp.install import _existing_binary
|
||||
notes: list = []
|
||||
bash_installed = _shutil.which("bash-language-server") is not None or (
|
||||
(hermes_lsp_bin_dir() / "bash-language-server").exists()
|
||||
)
|
||||
bash_installed = _existing_binary("bash-language-server") is not None
|
||||
if bash_installed and _shutil.which("shellcheck") is None:
|
||||
notes.append(
|
||||
"bash-language-server is installed but shellcheck is missing — "
|
||||
|
||||
+16
-3
@@ -44,6 +44,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
|
||||
from urllib.parse import quote, unquote
|
||||
@@ -244,15 +245,27 @@ class LSPClient:
|
||||
await self._cleanup_process()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _win_wrap_cmd(cmd: List[str]) -> List[str]:
|
||||
"""On Windows, wrap .cmd/.bat shims so CreateProcess can run them."""
|
||||
exe = cmd[0]
|
||||
if exe.lower().endswith((".cmd", ".bat")):
|
||||
return ["cmd.exe", "/c", *cmd]
|
||||
return cmd
|
||||
|
||||
async def _spawn(self) -> None:
|
||||
env = dict(os.environ)
|
||||
if self._env:
|
||||
env.update(self._env)
|
||||
|
||||
cmd = self._command
|
||||
if sys.platform == "win32":
|
||||
cmd = self._win_wrap_cmd(cmd)
|
||||
|
||||
try:
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
self._command[0],
|
||||
*self._command[1:],
|
||||
cmd[0],
|
||||
*cmd[1:],
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
@@ -261,7 +274,7 @@ class LSPClient:
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise LSPProtocolError(
|
||||
f"LSP server binary not found: {self._command[0]} ({e})"
|
||||
f"LSP server binary not found: {cmd[0]} ({e})"
|
||||
) from e
|
||||
|
||||
# Drain stderr at debug level — if we don't, the pipe buffer
|
||||
|
||||
+47
-23
@@ -108,6 +108,11 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
|
||||
_install_locks: Dict[str, threading.Lock] = {}
|
||||
_install_results: Dict[str, Optional[str]] = {}
|
||||
_install_lock_meta = threading.Lock()
|
||||
_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe", ".bat")
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
def hermes_lsp_bin_dir() -> Path:
|
||||
@@ -120,14 +125,33 @@ def hermes_lsp_bin_dir() -> Path:
|
||||
return p
|
||||
|
||||
|
||||
def _native_binary_candidates(base: Path) -> list[Path]:
|
||||
"""Return platform-native executable candidates for a staged binary."""
|
||||
candidates = [base]
|
||||
if _is_windows():
|
||||
existing = {str(base).lower()}
|
||||
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
|
||||
candidate = Path(str(base) + suffix)
|
||||
key = str(candidate).lower()
|
||||
if key not in existing:
|
||||
candidates.append(candidate)
|
||||
existing.add(key)
|
||||
return candidates
|
||||
|
||||
|
||||
def _existing_binary(name: str) -> Optional[str]:
|
||||
"""Probe the staging dir + PATH for a binary named ``name``."""
|
||||
staged = hermes_lsp_bin_dir() / name
|
||||
if staged.exists() and os.access(staged, os.X_OK):
|
||||
return str(staged)
|
||||
for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name):
|
||||
if staged.exists() and os.access(staged, os.X_OK):
|
||||
return str(staged)
|
||||
on_path = shutil.which(name)
|
||||
if on_path:
|
||||
return on_path
|
||||
if _is_windows():
|
||||
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
|
||||
on_path = shutil.which(f"{name}{suffix}")
|
||||
if on_path:
|
||||
return on_path
|
||||
return None
|
||||
|
||||
|
||||
@@ -250,12 +274,7 @@ def _install_npm(
|
||||
|
||||
# Find the bin
|
||||
nm_bin = staging / "node_modules" / ".bin" / bin_name
|
||||
if os.name == "nt":
|
||||
# On Windows npm sometimes drops `.cmd` shims
|
||||
candidates = [nm_bin, nm_bin.with_suffix(".cmd")]
|
||||
else:
|
||||
candidates = [nm_bin]
|
||||
for c in candidates:
|
||||
for c in _native_binary_candidates(nm_bin):
|
||||
if c.exists():
|
||||
# Symlink into our `lsp/bin/` for stable PATH access.
|
||||
link = hermes_lsp_bin_dir() / c.name
|
||||
@@ -301,7 +320,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
|
||||
logger.warning("[install] go install errored for %s: %s", pkg, e)
|
||||
return None
|
||||
bin_path = staging / bin_name
|
||||
if os.name == "nt":
|
||||
if _is_windows():
|
||||
bin_path = bin_path.with_suffix(".exe")
|
||||
if bin_path.exists():
|
||||
return str(bin_path)
|
||||
@@ -337,19 +356,24 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.warning("[install] pip install errored for %s: %s", pkg, e)
|
||||
return None
|
||||
# Look for the script
|
||||
bin_path = pip_target / "bin" / bin_name
|
||||
if bin_path.exists():
|
||||
link = hermes_lsp_bin_dir() / bin_name
|
||||
if not link.exists():
|
||||
try:
|
||||
link.symlink_to(bin_path)
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
shutil.copy2(bin_path, link)
|
||||
except OSError:
|
||||
return str(bin_path)
|
||||
return str(link if link.exists() else bin_path)
|
||||
# Look for the console script. POSIX wheels generally write to bin/,
|
||||
# while native Windows installs use Scripts/.
|
||||
script_dirs = [pip_target / "bin"]
|
||||
if _is_windows():
|
||||
script_dirs.append(pip_target / "Scripts")
|
||||
for script_dir in script_dirs:
|
||||
for bin_path in _native_binary_candidates(script_dir / bin_name):
|
||||
if bin_path.exists():
|
||||
link = hermes_lsp_bin_dir() / bin_path.name
|
||||
if not link.exists():
|
||||
try:
|
||||
link.symlink_to(bin_path)
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
shutil.copy2(bin_path, link)
|
||||
except OSError:
|
||||
return str(bin_path)
|
||||
return str(link if link.exists() else bin_path)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -150,10 +150,6 @@ _JWT_RE = re.compile(
|
||||
r"(?:\.[A-Za-z0-9_=-]{4,}){0,2}" # Optional payload and/or signature
|
||||
)
|
||||
|
||||
# Discord user/role mentions: <@123456789012345678> or <@!123456789012345678>
|
||||
# Snowflake IDs are 17-20 digit integers that resolve to specific Discord accounts.
|
||||
_DISCORD_MENTION_RE = re.compile(r"<@!?(\d{17,20})>")
|
||||
|
||||
# E.164 phone numbers: +<country><number>, 7-15 digits
|
||||
# Negative lookahead prevents matching hex strings or identifiers
|
||||
_SIGNAL_PHONE_RE = re.compile(r"(\+[1-9]\d{6,14})(?![A-Za-z0-9])")
|
||||
@@ -419,10 +415,6 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
if "&" in text and "=" in text:
|
||||
text = _redact_form_body(text)
|
||||
|
||||
# Discord user/role mentions (<@snowflake_id>)
|
||||
if "<@" in text:
|
||||
text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text)
|
||||
|
||||
# E.164 phone numbers (Signal, WhatsApp)
|
||||
if "+" in text:
|
||||
def _redact_phone(m):
|
||||
|
||||
+65
-51
@@ -180,28 +180,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Checkpoint for file-mutating tools
|
||||
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
file_path = function_args.get("path", "")
|
||||
if file_path:
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
|
||||
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Checkpoint before destructive terminal commands
|
||||
if function_name == "terminal" and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
cmd = function_args.get("command", "")
|
||||
if _is_destructive_command(cmd):
|
||||
cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd())
|
||||
agent._checkpoint_mgr.ensure_checkpoint(
|
||||
cwd, f"before terminal: {cmd[:60]}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Block evaluation (BEFORE checkpoint preflight) ───────────
|
||||
# We must know whether the tool will execute before touching
|
||||
# checkpoint state (dedup slot, real snapshots).
|
||||
block_result = None
|
||||
blocked_by_guardrail = False
|
||||
if _ts_scope_block is not None:
|
||||
@@ -224,6 +205,30 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
block_result = agent._guardrail_block_result(guardrail_decision)
|
||||
blocked_by_guardrail = True
|
||||
|
||||
# ── Checkpoint preflight (only for tools that will execute) ──
|
||||
if block_result is None:
|
||||
# Checkpoint for file-mutating tools
|
||||
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
file_path = function_args.get("path", "")
|
||||
if file_path:
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
|
||||
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Checkpoint before destructive terminal commands
|
||||
if function_name == "terminal" and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
cmd = function_args.get("command", "")
|
||||
if _is_destructive_command(cmd):
|
||||
cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd())
|
||||
agent._checkpoint_mgr.ensure_checkpoint(
|
||||
cwd, f"before terminal: {cmd[:60]}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail))
|
||||
|
||||
# ── Logging / callbacks ──────────────────────────────────────────
|
||||
@@ -301,33 +306,38 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
# submit site below (GHSA-qg5c-hvr5-hjgr, #13617).
|
||||
start = time.time()
|
||||
try:
|
||||
result = agent._invoke_tool(
|
||||
function_name,
|
||||
function_args,
|
||||
effective_task_id,
|
||||
tool_call.id,
|
||||
messages=messages,
|
||||
pre_tool_block_checked=True,
|
||||
)
|
||||
except Exception as tool_error:
|
||||
result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
|
||||
duration = time.time() - start
|
||||
is_error, _ = _detect_tool_failure(function_name, result)
|
||||
if is_error:
|
||||
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
|
||||
else:
|
||||
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
|
||||
results[index] = (function_name, function_args, result, duration, is_error, False)
|
||||
# Tear down worker-tid tracking. Clear any interrupt bit we may
|
||||
# have set so the next task scheduled onto this recycled tid
|
||||
# starts with a clean slate.
|
||||
with agent._tool_worker_threads_lock:
|
||||
agent._tool_worker_threads.discard(_worker_tid)
|
||||
try:
|
||||
_ra()._set_interrupt(False, _worker_tid)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result = agent._invoke_tool(
|
||||
function_name,
|
||||
function_args,
|
||||
effective_task_id,
|
||||
tool_call.id,
|
||||
messages=messages,
|
||||
pre_tool_block_checked=True,
|
||||
)
|
||||
except Exception as tool_error:
|
||||
result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
|
||||
duration = time.time() - start
|
||||
is_error, _ = _detect_tool_failure(function_name, result)
|
||||
if is_error:
|
||||
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
|
||||
else:
|
||||
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
|
||||
results[index] = (function_name, function_args, result, duration, is_error, False)
|
||||
finally:
|
||||
# Tear down worker-tid tracking. Clear any interrupt bit we may
|
||||
# have set so the next task scheduled onto this recycled tid
|
||||
# starts with a clean slate. This MUST be in a finally block
|
||||
# because BaseException subclasses (CancelledError, KeyboardInterrupt)
|
||||
# bypass ``except Exception`` and would otherwise leak the tid
|
||||
# into _interrupted_threads, poisoning the recycled thread.
|
||||
with agent._tool_worker_threads_lock:
|
||||
agent._tool_worker_threads.discard(_worker_tid)
|
||||
try:
|
||||
_ra()._set_interrupt(False, _worker_tid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start spinner for CLI mode (skip when TUI handles tool progress)
|
||||
spinner = None
|
||||
@@ -753,10 +763,14 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
elif function_name == "delegate_task":
|
||||
tasks_arg = function_args.get("tasks")
|
||||
if tasks_arg and isinstance(tasks_arg, list):
|
||||
spinner_label = f"🔀 delegating {len(tasks_arg)} tasks"
|
||||
spinner_label = f"🔀 delegating {len(tasks_arg)} tasks · (/agents to monitor)"
|
||||
else:
|
||||
goal_preview = (function_args.get("goal") or "")[:30]
|
||||
spinner_label = f"🔀 {goal_preview}" if goal_preview else "🔀 delegating"
|
||||
spinner_label = (
|
||||
f"🔀 {goal_preview} · (/agents to monitor)"
|
||||
if goal_preview
|
||||
else "🔀 delegating · (/agents to monitor)"
|
||||
)
|
||||
spinner = None
|
||||
if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
|
||||
@@ -787,8 +787,10 @@ def AIAgent(*args, **kwargs):
|
||||
|
||||
|
||||
def get_tool_definitions(*args, **kwargs):
|
||||
from hermes_cli.mcp_startup import wait_for_mcp_discovery
|
||||
from model_tools import get_tool_definitions as _get_tool_definitions
|
||||
|
||||
wait_for_mcp_discovery()
|
||||
return _get_tool_definitions(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -896,9 +898,12 @@ def _prepare_deferred_agent_startup() -> None:
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
from hermes_cli.mcp_startup import start_background_mcp_discovery
|
||||
|
||||
discover_mcp_tools()
|
||||
start_background_mcp_discovery(
|
||||
logger=logger,
|
||||
thread_name="termux-cli-mcp-discovery",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at deferred CLI startup",
|
||||
@@ -1537,9 +1542,17 @@ def _query_osc11_background() -> str | None:
|
||||
Most modern terminals reply with \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\
|
||||
within a few ms. We wait up to 100ms total before giving up.
|
||||
Returns "#RRGGBB" or None on timeout / non-tty.
|
||||
|
||||
Skipped over SSH: the round-trip routinely exceeds our 100ms budget, so a
|
||||
late reply lands after prompt_toolkit has grabbed the tty — its payload
|
||||
leaks in as typed text and the BEL terminator reads as Ctrl+G (open
|
||||
editor), trapping the user in a stray editor. Remote sessions fall back to
|
||||
COLORFGBG / env hints / the dark default instead.
|
||||
"""
|
||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
return None
|
||||
if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")):
|
||||
return None
|
||||
try:
|
||||
import termios
|
||||
import tty
|
||||
@@ -1587,8 +1600,11 @@ def _query_osc11_background() -> str | None:
|
||||
r, g, b = norm(m.group(1)), norm(m.group(2)), norm(m.group(3))
|
||||
return f"#{r:02X}{g:02X}{b:02X}"
|
||||
finally:
|
||||
# TCSAFLUSH discards any unread input as it restores the original
|
||||
# attributes — scrubs a slow/partial OSC 11 reply out of the tty
|
||||
# buffer before prompt_toolkit can read it as keystrokes.
|
||||
try:
|
||||
termios.tcsetattr(fd, termios.TCSANOW, old)
|
||||
termios.tcsetattr(fd, termios.TCSAFLUSH, old)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -3248,6 +3264,12 @@ class HermesCLI:
|
||||
self._slash_confirm_state = None
|
||||
self._slash_confirm_deadline = 0
|
||||
self._model_picker_state = None
|
||||
# Armed when a bare `/resume` prints the recent-sessions list so the
|
||||
# very next bare numeric input (e.g. `3`) resolves to that session.
|
||||
# Holds the exact list used for index resolution; one-shot (cleared on
|
||||
# the next submitted input, whether it's the selection or anything
|
||||
# else). See #34584.
|
||||
self._pending_resume_sessions = None
|
||||
self._secret_state = None
|
||||
self._secret_deadline = 0
|
||||
self._spinner_text: str = "" # thinking spinner text for TUI
|
||||
@@ -3555,8 +3577,17 @@ class HermesCLI:
|
||||
|
||||
compressor = getattr(agent, "context_compressor", None)
|
||||
if compressor:
|
||||
# last_prompt_tokens is parked at the -1 sentinel right after a
|
||||
# compression, until the next real API call reports a prompt count
|
||||
# (awaiting_real_usage_after_compression). The status bar must not
|
||||
# render that sentinel verbatim — it produced "-1/200K" / "-1%".
|
||||
# Clamp it to 0 so the one transitional turn reads as empty context.
|
||||
context_tokens = getattr(compressor, "last_prompt_tokens", 0) or 0
|
||||
if context_tokens < 0:
|
||||
context_tokens = 0
|
||||
context_length = getattr(compressor, "context_length", 0) or 0
|
||||
if context_length < 0:
|
||||
context_length = 0
|
||||
snapshot["context_tokens"] = context_tokens
|
||||
snapshot["context_length"] = context_length or None
|
||||
snapshot["compressions"] = getattr(compressor, "compression_count", 0) or 0
|
||||
@@ -4865,6 +4896,10 @@ class HermesCLI:
|
||||
if not self._ensure_runtime_credentials():
|
||||
return False
|
||||
|
||||
from hermes_cli.mcp_startup import wait_for_mcp_discovery
|
||||
|
||||
wait_for_mcp_discovery()
|
||||
|
||||
# Initialize SQLite session store for CLI sessions (if not already done in __init__)
|
||||
if self._session_db is None:
|
||||
try:
|
||||
@@ -6693,10 +6728,21 @@ class HermesCLI:
|
||||
if not target:
|
||||
_cprint(" Usage: /resume <number|session_id_or_title>")
|
||||
if self._show_recent_sessions(reason="resume"):
|
||||
# Arm a one-shot pending-resume selection so the user can type
|
||||
# just the number (`3`) on the next line instead of having to
|
||||
# retype `/resume 3`. The list here must match the one shown by
|
||||
# _show_recent_sessions and used for index resolution below —
|
||||
# all three go through _list_recent_sessions(limit=10). See
|
||||
# #34584.
|
||||
self._pending_resume_sessions = self._list_recent_sessions(limit=10)
|
||||
return
|
||||
_cprint(" Tip: Use /history or `hermes sessions list` to find sessions.")
|
||||
return
|
||||
|
||||
# Any explicit /resume <target> supersedes a previously-armed bare
|
||||
# numbered prompt.
|
||||
self._pending_resume_sessions = None
|
||||
|
||||
if not self._session_db:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
_cprint(f" {format_session_db_unavailable()}")
|
||||
@@ -6810,6 +6856,44 @@ class HermesCLI:
|
||||
else:
|
||||
_cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.")
|
||||
|
||||
def _consume_pending_resume_selection(self, text: str) -> bool:
|
||||
"""Resolve a bare numeric reply that follows a bare ``/resume`` prompt.
|
||||
|
||||
After ``/resume`` (no args) prints the recent-sessions list it arms
|
||||
``self._pending_resume_sessions``. The next submitted input is given
|
||||
one chance to be a bare session number (``3``); if so we resume that
|
||||
session here. Anything else (another command, free text, blank) simply
|
||||
disarms the prompt and is handled normally by the caller.
|
||||
|
||||
Returns True if the input was consumed as a resume selection (caller
|
||||
must not treat it as chat); False otherwise. The pending state is
|
||||
always one-shot: it is cleared on the first submitted input regardless
|
||||
of outcome. See #34584.
|
||||
"""
|
||||
pending = self._pending_resume_sessions
|
||||
if not pending:
|
||||
return False
|
||||
# One-shot: disarm now so a non-matching input can't leave the prompt
|
||||
# armed and hijack a later number the user meant as chat.
|
||||
self._pending_resume_sessions = None
|
||||
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
stripped = text.strip()
|
||||
# Only a pure number selects; let "/resume 3", titles, or any other
|
||||
# text fall through to normal handling.
|
||||
if not stripped.isdigit():
|
||||
return False
|
||||
|
||||
index = int(stripped)
|
||||
if index < 1 or index > len(pending):
|
||||
_cprint(f" Resume index {index} is out of range.")
|
||||
_cprint(" Use /resume with no arguments to see available sessions.")
|
||||
return True
|
||||
|
||||
self._handle_resume_command(f"/resume {index}")
|
||||
return True
|
||||
|
||||
def _handle_sessions_command(self, cmd_original: str) -> None:
|
||||
"""Handle /sessions [list|<id_or_title>] — browse or resume previous sessions.
|
||||
|
||||
@@ -8333,7 +8417,14 @@ class HermesCLI:
|
||||
_base_word = cmd_lower.split()[0].lstrip("/")
|
||||
_cmd_def = _resolve_cmd(_base_word)
|
||||
canonical = _cmd_def.name if _cmd_def else _base_word
|
||||
|
||||
|
||||
# A bare `/resume` prompt is one-shot: any command other than the
|
||||
# resume/sessions handlers (which manage the pending state themselves)
|
||||
# disarms it so a later number isn't swallowed as a stale selection.
|
||||
# See #34584.
|
||||
if canonical not in {"resume", "sessions"}:
|
||||
self._pending_resume_sessions = None
|
||||
|
||||
if canonical in {"quit", "exit"}:
|
||||
# Parse --delete flag: /exit --delete also removes the current
|
||||
# session's transcripts + SQLite history. Ported from
|
||||
@@ -9885,10 +9976,20 @@ class HermesCLI:
|
||||
def _manual_compress(self, cmd_original: str = ""):
|
||||
"""Manually trigger context compression on the current conversation.
|
||||
|
||||
Accepts an optional focus topic: ``/compress <focus>`` guides the
|
||||
summariser to preserve information related to *focus* while being
|
||||
more aggressive about discarding everything else. Inspired by
|
||||
Claude Code's ``/compact <focus>`` feature.
|
||||
Two modes:
|
||||
|
||||
* ``/compress [<focus>]`` — compress the *whole* history. An
|
||||
optional focus topic guides the summariser to preserve
|
||||
information related to *focus* while being more aggressive
|
||||
about discarding everything else. Inspired by Claude Code's
|
||||
``/compact <focus>`` feature.
|
||||
* ``/compress here [N]`` — boundary-aware compression. Summarize
|
||||
everything *except* the most recent ``N`` exchanges (default
|
||||
2), which are preserved verbatim. Inspired by Claude Code's
|
||||
Rewind "Summarize up to here" action (v2.1.139, May 2026,
|
||||
https://code.claude.com/docs/en/whats-new/2026-w20). Lets the
|
||||
user pick the compression boundary instead of leaving it to
|
||||
the automatic token-budget heuristic.
|
||||
"""
|
||||
if not self.conversation_history or len(self.conversation_history) < 4:
|
||||
print("(._.) Not enough conversation to compress (need at least 4 messages).")
|
||||
@@ -9902,12 +10003,21 @@ class HermesCLI:
|
||||
print("(._.) Compression is disabled in config.")
|
||||
return
|
||||
|
||||
# Extract optional focus topic from the command (e.g. "/compress database schema")
|
||||
focus_topic = ""
|
||||
from hermes_cli.partial_compress import (
|
||||
parse_partial_compress_args,
|
||||
rejoin_compressed_head_and_tail,
|
||||
split_history_for_partial_compress,
|
||||
)
|
||||
|
||||
# Args after the command word (e.g. "/compress here 3" -> "here 3").
|
||||
raw_args = ""
|
||||
if cmd_original:
|
||||
parts = cmd_original.strip().split(None, 1)
|
||||
if len(parts) > 1:
|
||||
focus_topic = parts[1].strip()
|
||||
_parts = cmd_original.strip().split(None, 1)
|
||||
if len(_parts) > 1:
|
||||
raw_args = _parts[1].strip()
|
||||
|
||||
partial, keep_last, focus_topic = parse_partial_compress_args(raw_args)
|
||||
focus_topic = focus_topic or ""
|
||||
|
||||
original_count = len(self.conversation_history)
|
||||
with self._busy_command("Compressing context..."):
|
||||
@@ -9915,6 +10025,22 @@ class HermesCLI:
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
from agent.manual_compression_feedback import summarize_manual_compression
|
||||
original_history = list(self.conversation_history)
|
||||
|
||||
# Boundary-aware split: only the head is summarized; the
|
||||
# most recent `keep_last` exchanges ride along verbatim.
|
||||
tail: list = []
|
||||
head = original_history
|
||||
if partial:
|
||||
head, tail = split_history_for_partial_compress(
|
||||
original_history, keep_last
|
||||
)
|
||||
if not tail:
|
||||
# Split degenerated (everything would be kept, or
|
||||
# no head left to compress). Fall back to full
|
||||
# compression so the user still gets an action.
|
||||
partial = False
|
||||
head = original_history
|
||||
|
||||
# Include system prompt + tool schemas in the estimate —
|
||||
# a transcript-only number understates real request pressure
|
||||
# and can even appear to grow after compression because a
|
||||
@@ -9926,7 +10052,11 @@ class HermesCLI:
|
||||
system_prompt=_sys_prompt,
|
||||
tools=_tools,
|
||||
)
|
||||
if focus_topic:
|
||||
if partial:
|
||||
print(f"🗜️ Summarizing up to here: compressing {len(head)} of "
|
||||
f"{original_count} messages (~{approx_tokens:,} tokens), "
|
||||
f"keeping last {keep_last} exchange(s) verbatim...")
|
||||
elif focus_topic:
|
||||
print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens), "
|
||||
f"focus: \"{focus_topic}\"...")
|
||||
else:
|
||||
@@ -9939,12 +10069,21 @@ class HermesCLI:
|
||||
# which already contain the agent identity — resulting in the
|
||||
# identity block appearing twice (issue #15281).
|
||||
compressed, _ = self.agent._compress_context(
|
||||
original_history,
|
||||
head,
|
||||
None,
|
||||
approx_tokens=approx_tokens,
|
||||
focus_topic=focus_topic or None,
|
||||
force=True,
|
||||
)
|
||||
# Re-append the verbatim tail after the compressed head.
|
||||
# The split guarantees `tail` begins on a user turn, so the
|
||||
# compressed-head -> tail boundary is normally valid
|
||||
# (the head's compressed output ends on assistant/tool).
|
||||
# rejoin_compressed_head_and_tail() additionally guards the
|
||||
# seam against any illegal user->user / assistant->assistant
|
||||
# adjacency, defending provider role-alternation rules.
|
||||
if partial and tail:
|
||||
compressed = rejoin_compressed_head_and_tail(compressed, tail)
|
||||
self.conversation_history = compressed
|
||||
# _compress_context ends the old session and creates a new child
|
||||
# session on the agent (run_agent.py::_compress_context). Sync the
|
||||
@@ -12835,6 +12974,13 @@ class HermesCLI:
|
||||
if event.app.is_running:
|
||||
event.app.exit()
|
||||
event.app.current_buffer.reset(append_to_history=True)
|
||||
# Force a repaint: process_command() prints through
|
||||
# patch_stdout (scrolls output above the prompt) and never
|
||||
# invalidates the app, so the just-cleared input area can
|
||||
# keep showing the submitted text until some unrelated
|
||||
# redraw fires. Every other early-return branch in this
|
||||
# handler invalidates after reset — match them.
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Handle /steer while the agent is running immediately on the
|
||||
@@ -12846,6 +12992,13 @@ class HermesCLI:
|
||||
if self._should_handle_steer_command_inline(text, has_images=has_images):
|
||||
self.process_command(text)
|
||||
event.app.current_buffer.reset(append_to_history=True)
|
||||
# Force a repaint after clearing the buffer. /steer is
|
||||
# dispatched mid-run while the agent streams output through
|
||||
# patch_stdout; process_command() never invalidates the
|
||||
# app, so without this the submitted "/steer <text>" can
|
||||
# linger in the input area (looking unsent) and invite an
|
||||
# accidental re-submit. See issue #34569.
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Snapshot and clear attached images
|
||||
@@ -14543,6 +14696,17 @@ class HermesCLI:
|
||||
+ (f"\n{_remainder}" if _remainder else "")
|
||||
)
|
||||
|
||||
# A bare number right after a bare `/resume` prompt selects
|
||||
# that session (see #34584). Checked before chat routing so
|
||||
# the digit isn't sent to the agent as a message.
|
||||
if (
|
||||
not _file_drop
|
||||
and self._pending_resume_sessions
|
||||
and isinstance(user_input, str)
|
||||
and self._consume_pending_resume_selection(user_input)
|
||||
):
|
||||
continue
|
||||
|
||||
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
|
||||
_cprint(f"\n⚙️ {user_input}")
|
||||
try:
|
||||
@@ -14919,6 +15083,96 @@ class HermesCLI:
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None:
|
||||
"""Drive a kanban goal_mode worker through the Ralph-style goal loop.
|
||||
|
||||
Called from the quiet single-query path AFTER the worker's first turn,
|
||||
only when ``HERMES_KANBAN_GOAL_MODE`` is set (dispatcher-spawned
|
||||
goal_mode card). Wires the worker's ``run_conversation`` and the kanban
|
||||
DB into ``goals.run_kanban_goal_loop``. All errors are swallowed by the
|
||||
caller — a broken goal loop must never wedge a worker, the dispatcher's
|
||||
claim TTL / crash detection is the backstop.
|
||||
"""
|
||||
import os as _os
|
||||
|
||||
task_id = (_os.environ.get("HERMES_KANBAN_TASK") or "").strip()
|
||||
if not task_id:
|
||||
return
|
||||
|
||||
from hermes_cli import kanban_db as _kb
|
||||
from hermes_cli.goals import run_kanban_goal_loop as _run_loop, DEFAULT_MAX_TURNS as _DEF_TURNS
|
||||
|
||||
# Resolve goal text from the card (title + body = the acceptance
|
||||
# criteria the judge evaluates against).
|
||||
conn = _kb.connect()
|
||||
try:
|
||||
task = _kb.get_task(conn, task_id)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if task is None:
|
||||
return
|
||||
|
||||
goal_parts = [task.title or ""]
|
||||
if task.body:
|
||||
goal_parts.append(task.body)
|
||||
goal_text = "\n\n".join(p for p in goal_parts if p).strip()
|
||||
if not goal_text:
|
||||
return
|
||||
|
||||
max_turns = task.goal_max_turns or _DEF_TURNS
|
||||
|
||||
def _run_turn(prompt: str) -> str:
|
||||
result = cli.agent.run_conversation(
|
||||
user_message=prompt,
|
||||
conversation_history=cli.conversation_history,
|
||||
)
|
||||
# Keep session_id in sync if mid-run compression rotated it.
|
||||
if (
|
||||
getattr(cli.agent, "session_id", None)
|
||||
and cli.agent.session_id != cli.session_id
|
||||
):
|
||||
cli.session_id = cli.agent.session_id
|
||||
resp = result.get("final_response", "") if isinstance(result, dict) else str(result)
|
||||
if resp:
|
||||
print(resp)
|
||||
return resp or ""
|
||||
|
||||
def _task_status() -> "str | None":
|
||||
c = _kb.connect()
|
||||
try:
|
||||
t = _kb.get_task(c, task_id)
|
||||
return t.status if t is not None else None
|
||||
finally:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _block(reason: str) -> None:
|
||||
c = _kb.connect()
|
||||
try:
|
||||
_kb.block_task(c, task_id, reason=reason)
|
||||
finally:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_run_loop(
|
||||
task_id=task_id,
|
||||
goal_text=goal_text,
|
||||
run_turn=_run_turn,
|
||||
task_status_fn=_task_status,
|
||||
block_fn=_block,
|
||||
max_turns=max_turns,
|
||||
first_response=first_response or "",
|
||||
log=lambda m: logger.info("%s", m),
|
||||
)
|
||||
|
||||
|
||||
def main(
|
||||
query: str = None,
|
||||
q: str = None,
|
||||
@@ -15316,6 +15570,20 @@ def main(
|
||||
print(f"Error: {result['error']}", file=sys.stderr)
|
||||
elif response:
|
||||
print(response)
|
||||
|
||||
# Kanban goal-loop mode: a worker spawned for a
|
||||
# goal_mode card keeps working in THIS session until an
|
||||
# auxiliary judge agrees the card is done, the worker
|
||||
# terminates the task itself, or the turn budget runs
|
||||
# out (→ sticky block). Gated on the env vars the
|
||||
# dispatcher sets in `_default_spawn`; a no-op for every
|
||||
# normal worker and every non-kanban `-q` run.
|
||||
if os.environ.get("HERMES_KANBAN_GOAL_MODE") == "1":
|
||||
try:
|
||||
_run_kanban_goal_loop_q(cli, response)
|
||||
except Exception as _goal_exc:
|
||||
logger.debug("kanban goal loop failed: %s", _goal_exc)
|
||||
|
||||
# Session ID goes to stderr so piped stdout is clean.
|
||||
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
|
||||
|
||||
|
||||
+43
-5
@@ -474,6 +474,13 @@ class GatewayConfig:
|
||||
|
||||
# Delivery settings
|
||||
always_log_local: bool = True # Always save cron outputs to local files
|
||||
# Drop outbound "silence narration" messages (e.g. *(silent)*, 🔇, a bare
|
||||
# ".") pre-send. These are model hallucinations emitted when a persona has
|
||||
# nothing actionable to say; in bot-to-bot channels they mirror back and
|
||||
# forth, burning tokens and crashing models. Substrate-level guard that
|
||||
# survives SOUL.md/prompt drift across providers. Opt out with False for
|
||||
# raw passthrough.
|
||||
filter_silence_narration: bool = True
|
||||
|
||||
# STT settings
|
||||
stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages
|
||||
@@ -582,6 +589,7 @@ class GatewayConfig:
|
||||
"quick_commands": self.quick_commands,
|
||||
"sessions_dir": str(self.sessions_dir),
|
||||
"always_log_local": self.always_log_local,
|
||||
"filter_silence_narration": self.filter_silence_narration,
|
||||
"stt_enabled": self.stt_enabled,
|
||||
"group_sessions_per_user": self.group_sessions_per_user,
|
||||
"thread_sessions_per_user": self.thread_sessions_per_user,
|
||||
@@ -650,6 +658,9 @@ class GatewayConfig:
|
||||
quick_commands=quick_commands,
|
||||
sessions_dir=sessions_dir,
|
||||
always_log_local=_coerce_bool(data.get("always_log_local"), True),
|
||||
filter_silence_narration=_coerce_bool(
|
||||
data.get("filter_silence_narration"), True
|
||||
),
|
||||
stt_enabled=_coerce_bool(stt_enabled, True),
|
||||
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
|
||||
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
|
||||
@@ -757,21 +768,32 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if "always_log_local" in yaml_cfg:
|
||||
gw_data["always_log_local"] = yaml_cfg["always_log_local"]
|
||||
|
||||
if "filter_silence_narration" in yaml_cfg:
|
||||
gw_data["filter_silence_narration"] = yaml_cfg[
|
||||
"filter_silence_narration"
|
||||
]
|
||||
|
||||
if "unauthorized_dm_behavior" in yaml_cfg:
|
||||
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
|
||||
yaml_cfg.get("unauthorized_dm_behavior"),
|
||||
"pair",
|
||||
)
|
||||
|
||||
# Merge platforms section from config.yaml into gw_data so that
|
||||
# nested keys like platforms.webhook.extra.routes are loaded.
|
||||
yaml_platforms = yaml_cfg.get("platforms")
|
||||
# Merge platform config into gw_data so runtime-only settings under
|
||||
# ``gateway.platforms`` are loaded the same way as top-level
|
||||
# ``platforms``. Merge nested first so top-level config keeps
|
||||
# precedence, matching the existing gateway.streaming fallback.
|
||||
gateway_cfg = yaml_cfg.get("gateway")
|
||||
gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None
|
||||
platforms_data = gw_data.setdefault("platforms", {})
|
||||
if not isinstance(platforms_data, dict):
|
||||
platforms_data = {}
|
||||
gw_data["platforms"] = platforms_data
|
||||
if isinstance(yaml_platforms, dict):
|
||||
for plat_name, plat_block in yaml_platforms.items():
|
||||
|
||||
def _merge_platform_map(source_platforms: Any) -> None:
|
||||
if not isinstance(source_platforms, dict):
|
||||
return
|
||||
for plat_name, plat_block in source_platforms.items():
|
||||
if not isinstance(plat_block, dict):
|
||||
continue
|
||||
existing = platforms_data.get(plat_name, {})
|
||||
@@ -785,6 +807,10 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if merged_extra:
|
||||
merged["extra"] = merged_extra
|
||||
platforms_data[plat_name] = merged
|
||||
|
||||
_merge_platform_map(gateway_platforms)
|
||||
_merge_platform_map(yaml_cfg.get("platforms"))
|
||||
if platforms_data:
|
||||
gw_data["platforms"] = platforms_data
|
||||
# Iterate built-in platforms plus any registered plugin platforms
|
||||
# so plugin authors get the same shared-key bridging (#24836).
|
||||
@@ -890,6 +916,18 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if entry.apply_yaml_config_fn is None:
|
||||
continue
|
||||
platform_cfg = yaml_cfg.get(entry.name)
|
||||
# Fall back to the platform's block under ``platforms`` /
|
||||
# ``gateway.platforms`` so adapter hooks still run when the
|
||||
# user configured the platform only under those nested paths
|
||||
# (e.g. ``platforms.discord.extra.allow_from``) and not via a
|
||||
# top-level ``discord:`` block.
|
||||
if not isinstance(platform_cfg, dict):
|
||||
for _src in (gateway_platforms, yaml_cfg.get("platforms")):
|
||||
if isinstance(_src, dict):
|
||||
_candidate = _src.get(entry.name)
|
||||
if isinstance(_candidate, dict):
|
||||
platform_cfg = _candidate
|
||||
break
|
||||
if not isinstance(platform_cfg, dict):
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -9,6 +9,8 @@ Routes messages to the appropriate destination based on:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
@@ -21,6 +23,32 @@ logger = logging.getLogger(__name__)
|
||||
MAX_PLATFORM_OUTPUT = 4000
|
||||
TRUNCATED_VISIBLE = 3800
|
||||
|
||||
# Matches strings that are *only* a "silence" narration with optional markdown
|
||||
# wrappers. Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent,
|
||||
# 🔇, a bare ".", "…", and the whitespace/marker-padded variants seen in the
|
||||
# wild. Anchored to start/end so substantive messages that merely *contain* the
|
||||
# word "silent" are never matched.
|
||||
_SILENCE_NARRATION = re.compile(
|
||||
r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
|
||||
r'|^[\s*_~`]*[\U0001F507\.\u2026]+[\s*_~`]*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_silence_narration(content: Optional[str]) -> bool:
|
||||
"""Return True when ``content`` is *only* a silence-narration token.
|
||||
|
||||
Length-guarded (real messages are longer) and anchored to the whole string
|
||||
so legitimate prose like "The deployment ran silently" or "Silence is
|
||||
golden — here is the plan..." is never flagged.
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
stripped = content.strip()
|
||||
if not stripped or len(stripped) > 64: # length guard
|
||||
return False
|
||||
return bool(_SILENCE_NARRATION.match(stripped))
|
||||
|
||||
from .config import Platform, GatewayConfig
|
||||
from .session import SessionSource
|
||||
|
||||
@@ -261,6 +289,18 @@ class DeliveryRouter:
|
||||
path.write_text(content)
|
||||
return path
|
||||
|
||||
def _filter_silence_narration_enabled(self) -> bool:
|
||||
"""Whether the outbound silence-narration filter is active.
|
||||
|
||||
``HERMES_FILTER_SILENCE_NARRATION`` env var overrides config when set;
|
||||
otherwise the ``gateway.filter_silence_narration`` config flag wins
|
||||
(default True).
|
||||
"""
|
||||
env = os.getenv("HERMES_FILTER_SILENCE_NARRATION")
|
||||
if env is not None:
|
||||
return env.strip().lower() in ("1", "true", "yes", "on")
|
||||
return bool(getattr(self.config, "filter_silence_narration", True))
|
||||
|
||||
async def _deliver_to_platform(
|
||||
self,
|
||||
target: DeliveryTarget,
|
||||
@@ -286,6 +326,27 @@ class DeliveryRouter:
|
||||
+ f"\n\n... [truncated, full output saved to {saved_path}]"
|
||||
)
|
||||
|
||||
# Substrate-level anti-loop guard: drop hallucinated "silence narration"
|
||||
# (*(silent)*, 🔇, a bare ".", etc.) before it ever reaches the adapter.
|
||||
# In bot-to-bot channels these tokens mirror back and forth until a
|
||||
# model crashes with "no content after all retries". Behavioral prompt
|
||||
# rules drift across providers; this single chokepoint covers every
|
||||
# platform adapter regardless of which persona's prompt failed.
|
||||
# Local/file delivery (_deliver_local) is a separate path and is never
|
||||
# filtered — saved silence has no loop risk.
|
||||
if self._filter_silence_narration_enabled() and _is_silence_narration(content):
|
||||
logger.warning(
|
||||
"Dropped silence-narration outbound to %s (chat=%s): %r",
|
||||
target.platform.value,
|
||||
target.chat_id,
|
||||
content[:40],
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"filtered": "silence_narration",
|
||||
"delivered": False,
|
||||
}
|
||||
|
||||
send_metadata = dict(metadata or {})
|
||||
is_named_telegram_private_topic = False
|
||||
named_telegram_private_topic_name: Optional[str] = None
|
||||
|
||||
+27
-15
@@ -484,7 +484,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
from hermes_constants import get_hermes_dir, get_hermes_home
|
||||
from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home
|
||||
|
||||
|
||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
||||
@@ -827,6 +827,7 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
|
||||
DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
|
||||
SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots")
|
||||
_HERMES_HOME = get_hermes_home()
|
||||
_HERMES_ROOT = get_default_hermes_root()
|
||||
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
|
||||
MEDIA_DELIVERY_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES"
|
||||
MEDIA_DELIVERY_TRUST_RECENT_SECONDS_ENV = "HERMES_MEDIA_TRUST_RECENT_SECONDS"
|
||||
@@ -954,11 +955,14 @@ def _media_delivery_denied_paths() -> List[Path]:
|
||||
home = Path(os.path.expanduser("~"))
|
||||
for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS:
|
||||
denied.append(home / sub)
|
||||
# The Hermes home itself contains credentials (auth.json, .env) — only the
|
||||
# cache subdirectories under it are explicitly allowlisted above.
|
||||
denied.append(_HERMES_HOME / ".env")
|
||||
denied.append(_HERMES_HOME / "auth.json")
|
||||
denied.append(_HERMES_HOME / "credentials")
|
||||
# The active Hermes profile and shared Hermes root both contain control
|
||||
# files and credentials. Only cache subdirectories under them are
|
||||
# explicitly allowlisted above.
|
||||
for hermes_root in (_HERMES_HOME, _HERMES_ROOT):
|
||||
denied.append(hermes_root / ".env")
|
||||
denied.append(hermes_root / "auth.json")
|
||||
denied.append(hermes_root / "credentials")
|
||||
denied.append(hermes_root / "config.yaml")
|
||||
return denied
|
||||
|
||||
|
||||
@@ -1191,10 +1195,12 @@ _MEDIA_EXT_ALTERNATION = "|".join(
|
||||
# bare-path detector (extract_local_files) downstream rather than silently
|
||||
# deleted. Shared by the non-streaming dispatch path and the streaming
|
||||
# consumer so both behave identically.
|
||||
# Path anchors: ``~/`` (Unix home-relative), ``/`` (Unix absolute),
|
||||
# ``X:\\`` or ``X:/`` (Windows drive-letter absolute — #34632).
|
||||
MEDIA_TAG_CLEANUP_RE = re.compile(
|
||||
r'''[`"']?MEDIA:\s*'''
|
||||
r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|'''
|
||||
r'''(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
|
||||
r'''(?:~/|/|[A-Za-z]:[/\\])\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
|
||||
r'''(?=[\s`"',;:)\]}]|$)[`"']?''',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
@@ -2665,9 +2671,10 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
|
||||
# and relative paths (./foo.png)
|
||||
# (?:~/|/) anchors to absolute or home-relative paths
|
||||
# (?:~/|/) anchors to absolute or home-relative Unix paths
|
||||
# (?:[A-Za-z]:[/\\]) anchors to Windows drive-letter paths (#34632)
|
||||
path_re = re.compile(
|
||||
r'(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:' + ext_part + r')\b',
|
||||
r'(?<![/:\w.])(?:~/|/|[A-Za-z]:[/\\])(?:[\w.\-]+[/\\])*[\w.\-]+\.(?:' + ext_part + r')\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -3733,6 +3740,7 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Call the handler (this can take a while with tool calls)
|
||||
response = await self._message_handler(event)
|
||||
is_ephemeral_response = isinstance(response, EphemeralReply)
|
||||
|
||||
# Slash-command handlers may return an EphemeralReply sentinel to
|
||||
# request that their reply message auto-delete after a TTL (used
|
||||
@@ -3790,12 +3798,16 @@ class BasePlatformAdapter(ABC):
|
||||
if images:
|
||||
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
|
||||
|
||||
# Auto-detect bare local file paths for native media delivery
|
||||
# (helps small models that don't use MEDIA: syntax)
|
||||
local_files, text_content = self.extract_local_files(text_content)
|
||||
local_files = self.filter_local_delivery_paths(local_files)
|
||||
if local_files:
|
||||
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
||||
local_files = []
|
||||
if not is_ephemeral_response:
|
||||
# Auto-detect bare local file paths for native media delivery
|
||||
# (helps small models that don't use MEDIA: syntax). Skip
|
||||
# system/command notices so config paths stay visible text
|
||||
# instead of becoming native uploads.
|
||||
local_files, text_content = self.extract_local_files(text_content)
|
||||
local_files = self.filter_local_delivery_paths(local_files)
|
||||
if local_files:
|
||||
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
||||
|
||||
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
|
||||
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
|
||||
|
||||
@@ -14,6 +14,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
@@ -60,6 +61,8 @@ _MESSAGE_EVENTS = {"new-message", "message", "updated-message"}
|
||||
_PHONE_RE = re.compile(r"\+?\d{7,15}")
|
||||
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
|
||||
|
||||
_GUID_CACHE_SIZE = 500 # LRU cap for resolved chat-GUID lookups
|
||||
|
||||
|
||||
def _redact(text: str) -> str:
|
||||
"""Redact phone numbers and emails from log output."""
|
||||
@@ -128,7 +131,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
self._runner = None
|
||||
self._private_api_enabled: Optional[bool] = None
|
||||
self._helper_connected: bool = False
|
||||
self._guid_cache: Dict[str, str] = {}
|
||||
self._guid_cache: OrderedDict[str, str] = OrderedDict()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API helpers
|
||||
@@ -365,6 +368,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if ";" in target:
|
||||
return target
|
||||
if target in self._guid_cache:
|
||||
self._guid_cache.move_to_end(target)
|
||||
return self._guid_cache[target]
|
||||
try:
|
||||
payload = await self._api_post(
|
||||
@@ -377,10 +381,14 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if identifier == target:
|
||||
if guid:
|
||||
self._guid_cache[target] = guid
|
||||
while len(self._guid_cache) > _GUID_CACHE_SIZE:
|
||||
self._guid_cache.popitem(last=False)
|
||||
return guid
|
||||
for part in chat.get("participants", []) or []:
|
||||
if (part.get("address") or "").strip() == target and guid:
|
||||
self._guid_cache[target] = guid
|
||||
while len(self._guid_cache) > _GUID_CACHE_SIZE:
|
||||
self._guid_cache.popitem(last=False)
|
||||
return guid
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -240,6 +240,7 @@ _FEISHU_REACTION_FAILURE = "CrossMark"
|
||||
# drain on completion; the cap is a safeguard against unbounded growth from
|
||||
# delete-failures, not a capacity plan.
|
||||
_FEISHU_PROCESSING_REACTION_CACHE_SIZE = 1024
|
||||
_FEISHU_MESSAGE_TEXT_CACHE_SIZE = 512 # LRU cap for reply-context message text lookups
|
||||
|
||||
# QR onboarding constants
|
||||
_ONBOARD_ACCOUNTS_URLS = {
|
||||
@@ -1452,7 +1453,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing)
|
||||
self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat
|
||||
self._chat_info_cache: Dict[str, Dict[str, Any]] = {}
|
||||
self._message_text_cache: Dict[str, Optional[str]] = {}
|
||||
self._message_text_cache: "OrderedDict[str, Optional[str]]" = OrderedDict()
|
||||
self._app_lock_identity: Optional[str] = None
|
||||
self._text_batch_state = FeishuBatchState()
|
||||
self._pending_text_batches = self._text_batch_state.events
|
||||
@@ -3959,6 +3960,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
if not self._client or not message_id:
|
||||
return None
|
||||
if message_id in self._message_text_cache:
|
||||
self._message_text_cache.move_to_end(message_id)
|
||||
return self._message_text_cache[message_id]
|
||||
try:
|
||||
request = self._build_get_message_request(message_id)
|
||||
@@ -3980,6 +3982,8 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
mentions=parent_mentions,
|
||||
)
|
||||
self._message_text_cache[message_id] = text
|
||||
while len(self._message_text_cache) > _FEISHU_MESSAGE_TEXT_CACHE_SIZE:
|
||||
self._message_text_cache.popitem(last=False)
|
||||
return text
|
||||
except Exception:
|
||||
logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True)
|
||||
|
||||
+135
-25
@@ -840,6 +840,41 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
stack.append(context)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_pool_timeout(error: Exception) -> bool:
|
||||
"""Return True when a Telegram TimedOut wraps an httpx pool timeout.
|
||||
|
||||
PTB converts ``httpx.PoolTimeout`` into ``telegram.error.TimedOut`` with
|
||||
a message that explicitly states the request was *not* sent
|
||||
(``"Pool timeout: All connections in the connection pool are occupied.
|
||||
Request was *not* sent to Telegram."``). Because the request never left
|
||||
the process, re-sending is safe and cannot duplicate -- the opposite of
|
||||
a generic TimedOut, which may have reached Telegram. We match the
|
||||
wrapped ``httpx.PoolTimeout`` class as well as the message string so the
|
||||
check survives PTB message-wording changes.
|
||||
"""
|
||||
seen: set[int] = set()
|
||||
stack: list[BaseException] = [error]
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
ident = id(cur)
|
||||
if ident in seen:
|
||||
continue
|
||||
seen.add(ident)
|
||||
name = cur.__class__.__name__.lower()
|
||||
text = str(cur).lower()
|
||||
if "pooltimeout" in name or "pool timeout" in text or (
|
||||
"connection pool" in text and "occupied" in text
|
||||
):
|
||||
return True
|
||||
cause = getattr(cur, "__cause__", None)
|
||||
context = getattr(cur, "__context__", None)
|
||||
if cause is not None:
|
||||
stack.append(cause)
|
||||
if context is not None:
|
||||
stack.append(context)
|
||||
return False
|
||||
|
||||
def _coerce_bool_extra(self, key: str, default: bool = False) -> bool:
|
||||
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
||||
if value is None:
|
||||
@@ -2001,11 +2036,15 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# TimedOut is also a subclass of NetworkError. A
|
||||
# generic timeout may have reached Telegram, so don't
|
||||
# retry; a wrapped ConnectTimeout means no connection
|
||||
# was established, so retrying is safe.
|
||||
# was established, so retrying is safe. A pool timeout
|
||||
# (httpx pool exhausted) is explicitly "not sent to
|
||||
# Telegram" -- retrying through the loop is safe and
|
||||
# prevents silent drops when the pool frees up.
|
||||
if (
|
||||
_TimedOut
|
||||
and isinstance(send_err, _TimedOut)
|
||||
and not self._looks_like_connect_timeout(send_err)
|
||||
and not self._looks_like_pool_timeout(send_err)
|
||||
):
|
||||
raise
|
||||
if _send_attempt < 2:
|
||||
@@ -2065,12 +2104,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error="message_too_long")
|
||||
# TimedOut usually means the request may have reached Telegram —
|
||||
# mark as non-retryable so _send_with_retry() doesn't re-send.
|
||||
# Exception: wrapped ConnectTimeout, where no connection was
|
||||
# established; retrying is safe and prevents silent drops.
|
||||
# Exceptions: a wrapped ConnectTimeout (no connection established)
|
||||
# and an httpx pool timeout (request explicitly not sent) -- both
|
||||
# are safe to re-send and must not be silently dropped.
|
||||
_to = locals().get("_TimedOut")
|
||||
is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str
|
||||
is_connect_timeout = self._looks_like_connect_timeout(e)
|
||||
return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or not is_timeout))
|
||||
is_pool_timeout = self._looks_like_pool_timeout(e)
|
||||
return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or is_pool_timeout or not is_timeout))
|
||||
|
||||
async def send_or_update_status(
|
||||
self,
|
||||
@@ -2804,21 +2845,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return slug
|
||||
|
||||
try:
|
||||
# Build provider buttons — 2 per row
|
||||
buttons: list = []
|
||||
for p in providers:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
label = f"✓ {label}"
|
||||
# Compact callback data: mp:<slug> (max 64 bytes)
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
)
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
keyboard = InlineKeyboardMarkup(rows)
|
||||
# Build provider buttons — folds provider groups (display only).
|
||||
keyboard = self._build_provider_keyboard(providers)
|
||||
|
||||
provider_label = get_label(current_provider)
|
||||
text = self.format_message(
|
||||
@@ -2865,6 +2893,56 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
_MODEL_PAGE_SIZE = 8
|
||||
|
||||
def _build_provider_keyboard(self, providers: list):
|
||||
"""Build the top-level provider keyboard, folding provider groups.
|
||||
|
||||
Provider families (Kimi/Moonshot, MiniMax, xAI Grok, ...) collapse to
|
||||
a single ``mpg:<gid>`` button; tapping it drills into a member
|
||||
sub-keyboard. Single providers (and groups with only one authenticated
|
||||
member) render as direct ``mp:<slug>`` buttons. Grouping mirrors the
|
||||
CLI ``hermes model`` picker via the shared ``group_providers`` fold,
|
||||
so all surfaces stay consistent.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.models import group_providers
|
||||
except Exception:
|
||||
group_providers = None
|
||||
|
||||
by_slug = {p.get("slug"): p for p in providers}
|
||||
|
||||
def _provider_button(p):
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
label = f"✓ {label}"
|
||||
return InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
|
||||
buttons: list = []
|
||||
if group_providers is not None:
|
||||
for row in group_providers([p.get("slug") for p in providers]):
|
||||
if row["kind"] == "group":
|
||||
members = [by_slug[m] for m in row["members"] if m in by_slug]
|
||||
count = sum(
|
||||
m.get("total_models", len(m.get("models", []))) for m in members
|
||||
)
|
||||
label = f"{row['label']} ▸ ({count})"
|
||||
if any(m.get("is_current") for m in members):
|
||||
label = f"✓ {label}"
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mpg:{row['group_id']}")
|
||||
)
|
||||
else:
|
||||
p = by_slug.get(row["slug"])
|
||||
if p is not None:
|
||||
buttons.append(_provider_button(p))
|
||||
else:
|
||||
for p in providers:
|
||||
buttons.append(_provider_button(p))
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
def _build_model_keyboard(self, models: list, page: int) -> tuple:
|
||||
"""Build paginated model buttons. Returns (keyboard, page_info_text)."""
|
||||
page_size = self._MODEL_PAGE_SIZE
|
||||
@@ -3043,10 +3121,23 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Clean up state
|
||||
self._model_picker_state.pop(chat_id, None)
|
||||
|
||||
elif data == "mb":
|
||||
# --- Back to provider list ---
|
||||
elif data.startswith("mpg:"):
|
||||
# --- Provider group selected: show member providers ---
|
||||
group_id = data[4:]
|
||||
try:
|
||||
from hermes_cli.models import PROVIDER_GROUPS
|
||||
_label, member_slugs = PROVIDER_GROUPS.get(group_id, ("", []))
|
||||
except Exception:
|
||||
_label, member_slugs = "", []
|
||||
|
||||
by_slug = {p["slug"]: p for p in state["providers"]}
|
||||
members = [by_slug[m] for m in member_slugs if m in by_slug]
|
||||
if not members:
|
||||
await query.answer(text="Group not found.")
|
||||
return
|
||||
|
||||
buttons = []
|
||||
for p in state["providers"]:
|
||||
for p in members:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
@@ -3054,11 +3145,30 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
)
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
rows.append([
|
||||
InlineKeyboardButton("◀ Back", callback_data="mb"),
|
||||
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
|
||||
])
|
||||
keyboard = InlineKeyboardMarkup(rows)
|
||||
|
||||
await query.edit_message_text(
|
||||
text=self.format_message(
|
||||
(
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Provider family: *{_label or group_id}*\n\n"
|
||||
f"Select a provider:"
|
||||
)
|
||||
),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await query.answer()
|
||||
|
||||
elif data == "mb":
|
||||
# --- Back to provider list (folds groups) ---
|
||||
keyboard = self._build_provider_keyboard(state["providers"])
|
||||
|
||||
try:
|
||||
provider_label = get_label(state["current_provider"])
|
||||
except Exception:
|
||||
@@ -3107,7 +3217,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
query_user_name = getattr(query.from_user, "first_name", None)
|
||||
|
||||
# --- Model picker callbacks ---
|
||||
if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")):
|
||||
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
|
||||
chat_id = str(query.message.chat_id) if query.message else None
|
||||
if chat_id:
|
||||
await self._handle_model_picker_callback(query, data, chat_id)
|
||||
|
||||
+115
-6
@@ -1180,12 +1180,48 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Text debounce batching (mirrors Telegram adapter pattern).
|
||||
# iLink delivers messages individually, so rapid multi-message
|
||||
# bursts (forwarded batches, paste-splits) each trigger a
|
||||
# separate agent invocation. Default 3s delay / 5s split delay
|
||||
# are tuned for iLink's typical delivery cadence. Tunable via
|
||||
# config.yaml under
|
||||
# ``gateway.platforms.weixin.extra.text_batch_delay_seconds`` /
|
||||
# ``text_batch_split_delay_seconds``.
|
||||
self._text_batch_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_delay_seconds", 3.0
|
||||
)
|
||||
self._text_batch_split_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_split_delay_seconds", 5.0
|
||||
)
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
if self._account_id and not self._token:
|
||||
persisted = load_weixin_account(hermes_home, self._account_id)
|
||||
if persisted:
|
||||
self._token = str(persisted.get("token") or "").strip()
|
||||
self._base_url = str(persisted.get("base_url") or self._base_url).strip().rstrip("/")
|
||||
|
||||
def _coerce_float_extra(self, key: str, default: float) -> float:
|
||||
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
|
||||
|
||||
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
|
||||
unparseable values fall back to ``default``.
|
||||
"""
|
||||
import math
|
||||
|
||||
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
||||
if value is None:
|
||||
return float(default)
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
if not math.isfinite(parsed) or parsed < 0:
|
||||
return float(default)
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
def _coerce_list(value: Any) -> List[str]:
|
||||
if value is None:
|
||||
@@ -1247,6 +1283,11 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
async def disconnect(self) -> None:
|
||||
_LIVE_ADAPTERS.pop(self._token, None)
|
||||
self._running = False
|
||||
for task in self._pending_text_batch_tasks.values():
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
self._pending_text_batches.clear()
|
||||
self._pending_text_batch_tasks.clear()
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
@@ -1395,12 +1436,10 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))
|
||||
await self.handle_message(event)
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
if event.message_type == MessageType.TEXT:
|
||||
self._enqueue_text_event(event)
|
||||
else:
|
||||
await self.handle_message(event)
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
if self._dm_policy == "disabled":
|
||||
@@ -1409,6 +1448,76 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
return sender_id in self._allow_from
|
||||
return True
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text debounce batching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_SPLIT_THRESHOLD = 1800 # iLink chunks at ~2048 chars
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
from gateway.session import build_session_key
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event and reset the flush timer.
|
||||
|
||||
When users forward multiple messages or send rapid-fire texts
|
||||
via WeChat, each arrives as a separate iLink message. This
|
||||
concatenates them and waits for a short quiet period before
|
||||
dispatching the combined message.
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
existing.media_types.extend(event.media_types)
|
||||
|
||||
prior_task = self._pending_text_batch_tasks.get(key)
|
||||
if prior_task and not prior_task.done():
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||||
self._flush_text_batch(key)
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for quiet period then dispatch aggregated text."""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
if self._pending_text_batch_tasks.get(key) is not current_task:
|
||||
return
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None:
|
||||
item_type = item.get("type")
|
||||
if item_type == ITEM_IMAGE:
|
||||
|
||||
@@ -278,6 +278,43 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
# notification before the normal "✓ whatsapp disconnected" fires.
|
||||
self._shutting_down: bool = False
|
||||
|
||||
# Text debounce batching (mirrors Telegram adapter pattern).
|
||||
# WhatsApp often delivers multiple messages in rapid succession
|
||||
# (e.g. forwarded batches, paste-splits) — without debounce each
|
||||
# message triggers a separate agent invocation, wasting tokens and
|
||||
# flooding the user with reply fragments. Default 5s delay /
|
||||
# 10s split delay are conservative for WhatsApp's delivery cadence.
|
||||
# Tunable via config.yaml under
|
||||
# ``gateway.platforms.whatsapp.extra.text_batch_delay_seconds`` /
|
||||
# ``text_batch_split_delay_seconds``.
|
||||
self._text_batch_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_delay_seconds", 5.0
|
||||
)
|
||||
self._text_batch_split_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_split_delay_seconds", 10.0
|
||||
)
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
def _coerce_float_extra(self, key: str, default: float) -> float:
|
||||
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
|
||||
|
||||
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
|
||||
unparseable values fall back to ``default``.
|
||||
"""
|
||||
import math
|
||||
|
||||
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
||||
if value is None:
|
||||
return float(default)
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
if not math.isfinite(parsed) or parsed < 0:
|
||||
return float(default)
|
||||
return parsed
|
||||
|
||||
def _effective_reply_prefix(self) -> str:
|
||||
"""Return the prefix the Node bridge will add in self-chat mode."""
|
||||
whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
|
||||
@@ -1139,7 +1176,10 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
for msg_data in messages:
|
||||
event = await self._build_message_event(msg_data)
|
||||
if event:
|
||||
await self.handle_message(event)
|
||||
if event.message_type == MessageType.TEXT:
|
||||
self._enqueue_text_event(event)
|
||||
else:
|
||||
await self.handle_message(event)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -1151,7 +1191,67 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
await asyncio.sleep(1) # Poll interval
|
||||
|
||||
|
||||
# ── Text debounce batching ──────────────────────────────────────
|
||||
|
||||
_SPLIT_THRESHOLD = 6000 # WhatsApp supports ~65K chars; generous threshold
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
from gateway.session import build_session_key
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event and reset the flush timer.
|
||||
|
||||
When WhatsApp delivers rapid-fire messages (e.g. forwarded
|
||||
batches), this concatenates them and waits for a short quiet
|
||||
period before dispatching the combined message.
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
existing.media_types.extend(event.media_types)
|
||||
|
||||
prior_task = self._pending_text_batch_tasks.get(key)
|
||||
if prior_task and not prior_task.done():
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||||
self._flush_text_batch(key)
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for quiet period then dispatch aggregated text."""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]:
|
||||
"""Build a MessageEvent from bridge message data, downloading images to cache."""
|
||||
try:
|
||||
|
||||
+262
-55
@@ -1730,6 +1730,14 @@ class GatewayRunner:
|
||||
self._running_agents: Dict[str, Any] = {}
|
||||
self._running_agents_ts: Dict[str, float] = {} # start timestamp per session
|
||||
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
|
||||
# Last successfully-resolved (non-empty) model, keyed by session. Used
|
||||
# as a fallback when a fresh config read transiently returns an empty
|
||||
# model (e.g. an mtime-keyed config-cache miss during a post-interrupt
|
||||
# recovery turn). Without this, the agent is built with model="" and
|
||||
# every API call fails HTTP 400 "No models provided" — the session goes
|
||||
# silent until the user manually re-sends. See #35314. ``"*"`` holds a
|
||||
# process-wide last-known-good for sessions seen for the first time.
|
||||
self._last_resolved_model: Dict[str, str] = {}
|
||||
# Overflow buffer for explicit /queue commands. The adapter-level
|
||||
# _pending_messages dict is a single slot per session (designed for
|
||||
# "next-turn" follow-ups where repeated sends collapse into one
|
||||
@@ -2488,6 +2496,32 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Final safety net (#35314): if resolution still produced an empty
|
||||
# model — e.g. a transient config-cache miss during a post-interrupt
|
||||
# recovery turn returned an empty user_config — reuse the last model we
|
||||
# successfully resolved for this session (or, failing that, the most
|
||||
# recent one resolved process-wide). Building an agent with model=""
|
||||
# makes every API call fail HTTP 400 "No models provided" and the
|
||||
# session goes silent until the user manually re-sends. ``getattr``
|
||||
# guards against bare test runners built via ``object.__new__``.
|
||||
_last_good = getattr(self, "_last_resolved_model", None)
|
||||
if _last_good is not None:
|
||||
if not model:
|
||||
_recovered = _last_good.get(resolved_session_key or "") or _last_good.get("*")
|
||||
if _recovered:
|
||||
logger.warning(
|
||||
"Empty model resolved for session=%s — recovering "
|
||||
"last-known-good model %s (config read likely returned "
|
||||
"empty; see #35314)",
|
||||
resolved_session_key or "", _recovered,
|
||||
)
|
||||
model = _recovered
|
||||
elif model:
|
||||
# Cache the good resolution for future recovery turns.
|
||||
if resolved_session_key:
|
||||
_last_good[resolved_session_key] = model
|
||||
_last_good["*"] = model
|
||||
|
||||
return model, runtime_kwargs
|
||||
|
||||
def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
|
||||
@@ -2784,10 +2818,12 @@ class GatewayRunner:
|
||||
"""Mark a queued platform as paused — keep it in ``_failed_platforms``
|
||||
but stop the reconnect watcher from hammering it.
|
||||
|
||||
Used by the circuit breaker after ``_PAUSE_AFTER_FAILURES`` consecutive
|
||||
retryable failures, and by ``/platform pause <name>`` for manual
|
||||
intervention. Paused platforms are surfaced in ``/platform list``
|
||||
and resumed with ``/platform resume <name>``.
|
||||
Used by ``/platform pause <name>`` for manual operator intervention.
|
||||
Paused platforms are surfaced in ``/platform list`` and resumed with
|
||||
``/platform resume <name>``. Note: the reconnect watcher does NOT
|
||||
auto-pause — retryable (network/DNS) failures keep retrying at the
|
||||
backoff cap indefinitely so a transient outage self-heals without
|
||||
manual intervention.
|
||||
"""
|
||||
info = getattr(self, "_failed_platforms", {}).get(platform)
|
||||
if info is None:
|
||||
@@ -4429,10 +4465,19 @@ class GatewayRunner:
|
||||
# Drain any recovered process watchers (from crash recovery checkpoint)
|
||||
try:
|
||||
from tools.process_registry import process_registry
|
||||
while process_registry.pending_watchers:
|
||||
watcher = process_registry.pending_watchers.pop(0)
|
||||
# Detach the current batch atomically: reassigning to a fresh list
|
||||
# takes ownership of exactly the watchers present now, so any watcher
|
||||
# appended concurrently during the yield below isn't silently dropped
|
||||
# by a clear() on the shared list.
|
||||
watchers = process_registry.pending_watchers
|
||||
process_registry.pending_watchers = []
|
||||
# Process in batches of 100 with event-loop yield points to avoid
|
||||
# O(n^2) event-loop blocking when recovering thousands of watchers.
|
||||
for i, watcher in enumerate(watchers):
|
||||
asyncio.create_task(self._run_process_watcher(watcher))
|
||||
logger.info("Resumed watcher for recovered process %s", watcher.get("session_id"))
|
||||
if i % 100 == 99:
|
||||
await asyncio.sleep(0)
|
||||
except Exception as e:
|
||||
logger.error("Recovered watcher setup error: %s", e)
|
||||
|
||||
@@ -5865,15 +5910,17 @@ class GatewayRunner:
|
||||
"""Background task that periodically retries connecting failed platforms.
|
||||
|
||||
Uses exponential backoff: 30s → 60s → 120s → 240s → 300s (cap).
|
||||
Retryable failures keep retrying at the backoff cap indefinitely
|
||||
— but if a platform fails ``_PAUSE_AFTER_FAILURES`` times in a row
|
||||
without ever succeeding, it is *paused*: kept in the retry queue
|
||||
but no longer hammered. The user surfaces it with ``/platform list``
|
||||
and resumes it with ``/platform resume <name>``. Non-retryable
|
||||
failures (bad auth, etc.) still drop out of the queue immediately.
|
||||
Retryable failures (network/DNS blips) keep retrying at the backoff
|
||||
cap indefinitely — they self-heal once connectivity returns, so a
|
||||
transient outage never requires manual intervention. Non-retryable
|
||||
failures (bad auth, etc.) drop out of the queue immediately. The
|
||||
circuit breaker (``_pause_failed_platform`` / ``/platform pause``)
|
||||
remains available for manual operator control via ``/platform list``
|
||||
and ``/platform resume <name>``, but is no longer triggered
|
||||
automatically — auto-pausing a recovered platform was the cause of
|
||||
bots silently staying dead after a transient DNS failure.
|
||||
"""
|
||||
_BACKOFF_CAP = 300 # 5 minutes max between retries
|
||||
_PAUSE_AFTER_FAILURES = 10 # circuit-breaker threshold
|
||||
|
||||
await asyncio.sleep(10) # initial delay — let startup finish
|
||||
while self._running:
|
||||
@@ -5968,14 +6015,14 @@ class GatewayRunner:
|
||||
"Reconnect %s failed, next retry in %ds",
|
||||
platform.value, backoff,
|
||||
)
|
||||
if attempt >= _PAUSE_AFTER_FAILURES:
|
||||
self._pause_failed_platform(
|
||||
platform,
|
||||
reason=(
|
||||
adapter.fatal_error_message
|
||||
or "failed to reconnect"
|
||||
),
|
||||
)
|
||||
# Retryable failures (network/DNS blips) keep retrying
|
||||
# at the backoff cap indefinitely — they self-heal once
|
||||
# connectivity returns. We do NOT auto-pause them: a
|
||||
# transient outage must never require manual `/platform
|
||||
# resume` to recover. Non-retryable failures (bad auth,
|
||||
# etc.) already drop out of the queue via the
|
||||
# `not fatal_error_retryable` branch above, so anything
|
||||
# reaching here is by definition retryable.
|
||||
except Exception as e:
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
@@ -5990,8 +6037,9 @@ class GatewayRunner:
|
||||
"Reconnect %s error: %s, next retry in %ds",
|
||||
platform.value, e, backoff,
|
||||
)
|
||||
if attempt >= _PAUSE_AFTER_FAILURES:
|
||||
self._pause_failed_platform(platform, reason=str(e))
|
||||
# A raised exception during reconnect (connect timeout, DNS
|
||||
# resolution failure, etc.) is inherently transient — keep
|
||||
# retrying at the backoff cap rather than auto-pausing.
|
||||
|
||||
# Check every 10 seconds for platforms that need reconnection
|
||||
for _ in range(10):
|
||||
@@ -7899,7 +7947,7 @@ class GatewayRunner:
|
||||
result = await result
|
||||
return str(result) if result else None
|
||||
except Exception as e:
|
||||
logger.debug("Plugin command dispatch failed (non-fatal): %s", e)
|
||||
logger.warning("Plugin command dispatch failed: %s", e)
|
||||
|
||||
# Skill slash commands: /skill-name loads the skill and sends to agent.
|
||||
# resolve_skill_command_key() handles the Telegram underscore/hyphen
|
||||
@@ -7931,7 +7979,7 @@ class GatewayRunner:
|
||||
)
|
||||
# Fall through to normal message processing with bundle content
|
||||
except Exception as exc:
|
||||
logger.debug("Bundle dispatch failed (non-fatal): %s", exc)
|
||||
logger.warning("Bundle dispatch failed: %s", exc)
|
||||
|
||||
if command and not locals().get("_bundle_handled", False):
|
||||
try:
|
||||
@@ -9122,9 +9170,15 @@ class GatewayRunner:
|
||||
# Check for pending process watchers (check_interval on background processes)
|
||||
try:
|
||||
from tools.process_registry import process_registry
|
||||
while process_registry.pending_watchers:
|
||||
watcher = process_registry.pending_watchers.pop(0)
|
||||
# Detach the current batch atomically (see crash-recovery drain
|
||||
# above): reassign to a fresh list so a watcher appended by a
|
||||
# concurrent session during the yield isn't dropped by clear().
|
||||
watchers = process_registry.pending_watchers
|
||||
process_registry.pending_watchers = []
|
||||
for i, watcher in enumerate(watchers):
|
||||
asyncio.create_task(self._run_process_watcher(watcher))
|
||||
if i % 100 == 99:
|
||||
await asyncio.sleep(0)
|
||||
except Exception as e:
|
||||
logger.error("Process watcher setup error: %s", e)
|
||||
|
||||
@@ -10070,6 +10124,45 @@ class GatewayRunner:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _sibling_thread_run_keys(self, source: SessionSource, own_key: str) -> list:
|
||||
"""Find running-agent keys for OTHER participants in the same thread.
|
||||
|
||||
Only applies when the message originates in a thread. In per-user
|
||||
thread mode (``thread_sessions_per_user=True``) each participant gets
|
||||
an isolated session key of the form
|
||||
``agent:main:{platform}:{chat_type}:{chat_id}:{thread_id}:{user_id}``,
|
||||
so a run started by another user is invisible to the caller's own
|
||||
``/stop``. This returns the keys of any *actually running* agents
|
||||
(not the pending sentinel, not the caller's own key) whose key shares
|
||||
the caller's ``{chat_id}:{thread_id}`` prefix.
|
||||
|
||||
Returns an empty list when the source is not in a thread, or when no
|
||||
sibling runs exist — callers must still gate on authorization.
|
||||
"""
|
||||
thread_id = getattr(source, "thread_id", None)
|
||||
chat_id = getattr(source, "chat_id", None)
|
||||
if not thread_id or not chat_id:
|
||||
return []
|
||||
platform = source.platform.value
|
||||
chat_type = getattr(source, "chat_type", None) or ""
|
||||
# Prefix that every per-user key in this thread shares, up to and
|
||||
# including the thread_id segment. Matching either the exact
|
||||
# shared-thread key or any key with a further (user_id) segment
|
||||
# (prefix + ":") avoids cross-matching an unrelated thread whose id
|
||||
# merely starts with this one.
|
||||
prefix = ":".join(
|
||||
["agent:main", platform, chat_type, str(chat_id), str(thread_id)]
|
||||
)
|
||||
matches = []
|
||||
for key, agent in list(self._running_agents.items()):
|
||||
if key == own_key:
|
||||
continue
|
||||
if agent is _AGENT_PENDING_SENTINEL or not agent:
|
||||
continue
|
||||
if key == prefix or key.startswith(prefix + ":"):
|
||||
matches.append(key)
|
||||
return matches
|
||||
|
||||
async def _handle_stop_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
||||
"""Handle /stop command - interrupt a running agent.
|
||||
|
||||
@@ -10106,8 +10199,31 @@ class GatewayRunner:
|
||||
invalidation_reason="stop_command_handler",
|
||||
)
|
||||
return EphemeralReply(t("gateway.stop.stopped"))
|
||||
else:
|
||||
return t("gateway.stop.no_active")
|
||||
|
||||
# No run under the caller's own session key. In a per-user thread
|
||||
# (thread_sessions_per_user=True) each participant is isolated even
|
||||
# inside one shared thread, so a run another user started lives under
|
||||
# a different key. Authorized users should still be able to /stop it
|
||||
# (#bernard-thread-stop). Fall back to interrupting any running
|
||||
# agent(s) that share this thread, gated on authorization.
|
||||
sibling_keys = self._sibling_thread_run_keys(source, session_key)
|
||||
if sibling_keys and self._is_user_authorized(source):
|
||||
for sibling_key in sibling_keys:
|
||||
await self._interrupt_and_clear_session(
|
||||
sibling_key,
|
||||
source,
|
||||
interrupt_reason=_INTERRUPT_REASON_STOP,
|
||||
invalidation_reason="stop_command_thread_sibling",
|
||||
)
|
||||
logger.info(
|
||||
"STOP (thread sibling) by %s — interrupted %d run(s) in thread: %s",
|
||||
session_key,
|
||||
len(sibling_keys),
|
||||
", ".join(sibling_keys),
|
||||
)
|
||||
return EphemeralReply(t("gateway.stop.stopped"))
|
||||
|
||||
return t("gateway.stop.no_active")
|
||||
|
||||
async def _handle_platform_command(self, event: MessageEvent) -> str:
|
||||
"""Handle ``/platform list|pause|resume [name]`` — surface and
|
||||
@@ -10549,6 +10665,22 @@ class GatewayRunner:
|
||||
except Exception as exc:
|
||||
logger.warning("Picker model switch failed for cached agent: %s", exc)
|
||||
|
||||
# Persist the new model to the session DB so the
|
||||
# dashboard shows the updated model (#34850).
|
||||
_sess_db = getattr(_self, "_session_db", None)
|
||||
if _sess_db is not None:
|
||||
try:
|
||||
_sess_entry = _self.session_store.get_or_create_session(
|
||||
event.source
|
||||
)
|
||||
_sess_db.update_session_model(
|
||||
_sess_entry.session_id, result.new_model
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to persist model switch to DB: %s", exc
|
||||
)
|
||||
|
||||
# Store model note + session override
|
||||
if not hasattr(_self, "_pending_model_notes"):
|
||||
_self._pending_model_notes = {}
|
||||
@@ -10686,6 +10818,20 @@ class GatewayRunner:
|
||||
except Exception as exc:
|
||||
logger.warning("In-place model switch failed for cached agent: %s", exc)
|
||||
|
||||
# Persist the new model to the session DB so the dashboard
|
||||
# shows the updated model (#34850).
|
||||
_sess_db = getattr(self, "_session_db", None)
|
||||
if _sess_db is not None:
|
||||
try:
|
||||
_sess_entry = self.session_store.get_or_create_session(source)
|
||||
_sess_db.update_session_model(
|
||||
_sess_entry.session_id, result.new_model
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to persist model switch to DB: %s", exc
|
||||
)
|
||||
|
||||
# Store a note to prepend to the next user message so the model
|
||||
# knows about the switch (avoids system messages mid-history).
|
||||
if not hasattr(self, "_pending_model_notes"):
|
||||
@@ -12449,6 +12595,12 @@ class GatewayRunner:
|
||||
Accepts an optional focus topic: ``/compress <focus>`` guides the
|
||||
summariser to preserve information related to *focus* while being
|
||||
more aggressive about discarding everything else.
|
||||
|
||||
Also accepts the boundary-aware form ``/compress here [N]``:
|
||||
summarize everything except the most recent ``N`` exchanges
|
||||
(default 2), kept verbatim. Inspired by Claude Code's Rewind
|
||||
"Summarize up to here" action (v2.1.139, May 2026,
|
||||
https://code.claude.com/docs/en/whats-new/2026-w20).
|
||||
"""
|
||||
source = event.source
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
@@ -12457,8 +12609,15 @@ class GatewayRunner:
|
||||
if not history or len(history) < 4:
|
||||
return t("gateway.compress.not_enough")
|
||||
|
||||
# Extract optional focus topic from command args
|
||||
focus_topic = (event.get_command_args() or "").strip() or None
|
||||
# Parse args: either a focus topic (full compress) or the
|
||||
# boundary-aware "here [N]" form (partial compress).
|
||||
from hermes_cli.partial_compress import (
|
||||
parse_partial_compress_args,
|
||||
rejoin_compressed_head_and_tail,
|
||||
split_history_for_partial_compress,
|
||||
)
|
||||
_raw_args = (event.get_command_args() or "").strip()
|
||||
partial, keep_last, focus_topic = parse_partial_compress_args(_raw_args)
|
||||
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
@@ -12479,6 +12638,19 @@ class GatewayRunner:
|
||||
if m.get("role") in {"user", "assistant"} and m.get("content")
|
||||
]
|
||||
|
||||
# Boundary-aware split: only the head is summarized; the most
|
||||
# recent `keep_last` exchanges are preserved verbatim. The
|
||||
# split snaps the tail to a user-turn start so the rejoined
|
||||
# transcript keeps role alternation valid.
|
||||
tail: list = []
|
||||
head = msgs
|
||||
if partial:
|
||||
head, tail = split_history_for_partial_compress(msgs, keep_last)
|
||||
if not tail:
|
||||
# Degenerate split — fall back to full compression.
|
||||
partial = False
|
||||
head = msgs
|
||||
|
||||
tmp_agent = AIAgent(
|
||||
**runtime_kwargs,
|
||||
model=model,
|
||||
@@ -12502,15 +12674,20 @@ class GatewayRunner:
|
||||
)
|
||||
|
||||
compressor = tmp_agent.context_compressor
|
||||
if not compressor.has_content_to_compress(msgs):
|
||||
if not compressor.has_content_to_compress(head):
|
||||
return t("gateway.compress.nothing_to_do")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
compressed, _ = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True)
|
||||
lambda: tmp_agent._compress_context(head, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True)
|
||||
)
|
||||
|
||||
# Re-append the verbatim tail after the compressed head,
|
||||
# guarding the seam against illegal role adjacency.
|
||||
if partial and tail:
|
||||
compressed = rejoin_compressed_head_and_tail(compressed, tail)
|
||||
|
||||
# _compress_context already calls end_session() on the old session
|
||||
# (preserving its full transcript in SQLite) and creates a new
|
||||
# session_id for the continuation. Write the compressed messages
|
||||
@@ -15300,8 +15477,52 @@ class GatewayRunner:
|
||||
("compression", "target_ratio"),
|
||||
("compression", "protect_last_n"),
|
||||
("agent", "disabled_toolsets"),
|
||||
("memory", "provider"),
|
||||
)
|
||||
|
||||
_HONCHO_CACHE_BUSTING_KEYS = (
|
||||
"honcho.peer_name",
|
||||
"honcho.ai_peer",
|
||||
"honcho.pin_peer_name",
|
||||
"honcho.runtime_peer_prefix",
|
||||
"honcho.user_peer_aliases",
|
||||
)
|
||||
_HONCHO_CACHE_BUSTING_MEMO: dict[tuple[str, int | None], dict[str, Any]] = {}
|
||||
|
||||
@classmethod
|
||||
def _empty_honcho_cache_busting_config(cls) -> dict[str, Any]:
|
||||
return {key: None for key in cls._HONCHO_CACHE_BUSTING_KEYS}
|
||||
|
||||
@classmethod
|
||||
def _extract_honcho_cache_busting_config(cls) -> dict[str, Any]:
|
||||
"""Extract Honcho identity keys, memoized by honcho.json mtime."""
|
||||
try:
|
||||
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
|
||||
|
||||
path = resolve_config_path()
|
||||
try:
|
||||
mtime_ns = path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mtime_ns = None
|
||||
memo_key = (str(path), mtime_ns)
|
||||
cached = cls._HONCHO_CACHE_BUSTING_MEMO.get(memo_key)
|
||||
if cached is not None:
|
||||
return dict(cached)
|
||||
|
||||
hcfg = HonchoClientConfig.from_global_config(config_path=path)
|
||||
aliases = hcfg.user_peer_aliases or {}
|
||||
values = {
|
||||
"honcho.peer_name": hcfg.peer_name,
|
||||
"honcho.ai_peer": hcfg.ai_peer,
|
||||
"honcho.pin_peer_name": bool(hcfg.pin_peer_name),
|
||||
"honcho.runtime_peer_prefix": hcfg.runtime_peer_prefix or "",
|
||||
"honcho.user_peer_aliases": sorted(aliases.items()) if isinstance(aliases, dict) else [],
|
||||
}
|
||||
cls._HONCHO_CACHE_BUSTING_MEMO = {memo_key: values}
|
||||
return dict(values)
|
||||
except Exception:
|
||||
return cls._empty_honcho_cache_busting_config()
|
||||
|
||||
@classmethod
|
||||
def _extract_cache_busting_config(cls, user_config: dict | None) -> dict:
|
||||
"""Pull values that must bust the cached agent.
|
||||
@@ -15332,26 +15553,12 @@ class GatewayRunner:
|
||||
out["tools.registry_generation"] = None
|
||||
|
||||
# Honcho identity-mapping keys live in honcho.json, not user_config.
|
||||
# HonchoSessionManager freezes the resolved peer_name / ai_peer /
|
||||
# pin / aliases / prefix at construction; without busting here,
|
||||
# mid-flight honcho.json edits go unread until the next unrelated
|
||||
# cache eviction.
|
||||
try:
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
hcfg = HonchoClientConfig.from_global_config()
|
||||
out["honcho.peer_name"] = hcfg.peer_name
|
||||
out["honcho.ai_peer"] = hcfg.ai_peer
|
||||
out["honcho.pin_peer_name"] = bool(hcfg.pin_peer_name)
|
||||
out["honcho.runtime_peer_prefix"] = hcfg.runtime_peer_prefix or ""
|
||||
aliases = hcfg.user_peer_aliases or {}
|
||||
out["honcho.user_peer_aliases"] = sorted(aliases.items()) if isinstance(aliases, dict) else []
|
||||
except Exception:
|
||||
out["honcho.peer_name"] = None
|
||||
out["honcho.ai_peer"] = None
|
||||
out["honcho.pin_peer_name"] = None
|
||||
out["honcho.runtime_peer_prefix"] = None
|
||||
out["honcho.user_peer_aliases"] = None
|
||||
# Only read that file when Honcho is the active memory provider.
|
||||
provider = cfg_get(cfg, "memory", "provider")
|
||||
if isinstance(provider, str) and provider.lower() == "honcho":
|
||||
out.update(cls._extract_honcho_cache_busting_config())
|
||||
else:
|
||||
out.update(cls._empty_honcho_cache_busting_config())
|
||||
|
||||
return out
|
||||
|
||||
@@ -17190,7 +17397,7 @@ class GatewayRunner:
|
||||
_hc = _hm.get("content", "")
|
||||
if "MEDIA:" in _hc:
|
||||
_TOOL_MEDIA_RE = re.compile(
|
||||
r'MEDIA:((?:/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
|
||||
r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
|
||||
r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'
|
||||
r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'
|
||||
r'txt|csv|apk|ipa))',
|
||||
@@ -17516,7 +17723,7 @@ class GatewayRunner:
|
||||
content = msg.get("content", "")
|
||||
if "MEDIA:" in content:
|
||||
_TOOL_MEDIA_RE = re.compile(
|
||||
r'MEDIA:((?:/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
|
||||
r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
|
||||
r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'
|
||||
r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'
|
||||
r'txt|csv|apk|ipa))',
|
||||
|
||||
+32
-31
@@ -6126,55 +6126,56 @@ def _prompt_model_selection(
|
||||
_DIM = "\033[2m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
# Try arrow-key menu first, fall back to number input
|
||||
# Try arrow-key menu first, fall back to number input.
|
||||
# Uses the shared curses radiolist (ESC/arrow-key handling that works
|
||||
# across terminals, incl. those that emit raw escape sequences) instead
|
||||
# of simple_term_menu, which conflicts with /dev/tty and left ESC/arrow
|
||||
# keys unreliable in the setup model picker.
|
||||
try:
|
||||
from simple_term_menu import TerminalMenu
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
|
||||
choices = [f" {_label(mid)}" for mid in ordered]
|
||||
choices.append(" Enter custom model name")
|
||||
choices.append(" Skip (keep current)")
|
||||
choices = [_label(mid) for mid in ordered]
|
||||
choices.append("Enter custom model name")
|
||||
choices.append("Skip (keep current)")
|
||||
|
||||
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
unavailable_footer = unavailable_message.strip()
|
||||
if not unavailable_footer and _unavailable:
|
||||
unavailable_footer = f"Upgrade at {_upgrade_url} for paid models"
|
||||
|
||||
# Print the unavailable block BEFORE the menu via regular print().
|
||||
# simple_term_menu pads title lines to terminal width (causes wrapping),
|
||||
# so we keep the title minimal and use stdout for the static block.
|
||||
# clear_screen=False means our printed output stays visible above.
|
||||
# The pricing column header (and any unavailable-models block) is shown
|
||||
# as a multi-line description above the list so it survives the curses
|
||||
# screen clear. menu_title already embeds the aligned price header.
|
||||
desc_lines: list[str] = []
|
||||
if has_pricing:
|
||||
# menu_title is "Select default model:\n<pad><header> /Mtok"
|
||||
# Keep only the header portion for the description.
|
||||
header_part = menu_title.split("\n", 1)
|
||||
if len(header_part) > 1:
|
||||
desc_lines.extend(header_part[1].splitlines())
|
||||
if _unavailable:
|
||||
print(menu_title)
|
||||
print()
|
||||
for mid in _unavailable:
|
||||
print(f"{_DIM} {_label(mid)}{_RESET}")
|
||||
print()
|
||||
print(f"{_DIM} ── {unavailable_footer} ──{_RESET}")
|
||||
print()
|
||||
effective_title = "Available free models:"
|
||||
else:
|
||||
effective_title = menu_title
|
||||
desc_lines.append(f" {_label(mid)}")
|
||||
desc_lines.append(f" ── {unavailable_footer} ──")
|
||||
description = "\n".join(desc_lines) if desc_lines else None
|
||||
|
||||
menu = TerminalMenu(
|
||||
idx = curses_radiolist(
|
||||
"Select default model:",
|
||||
choices,
|
||||
cursor_index=default_idx,
|
||||
menu_cursor="-> ",
|
||||
menu_cursor_style=("fg_green", "bold"),
|
||||
menu_highlight_style=("fg_green",),
|
||||
cycle_cursor=True,
|
||||
clear_screen=False,
|
||||
title=effective_title,
|
||||
selected=default_idx,
|
||||
cancel_returns=-1,
|
||||
description=description,
|
||||
)
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
flush_stdin()
|
||||
if idx is None:
|
||||
if idx < 0:
|
||||
return None
|
||||
print()
|
||||
if idx < len(ordered):
|
||||
return ordered[idx]
|
||||
elif idx == len(ordered):
|
||||
custom = input("Enter model name: ").strip()
|
||||
try:
|
||||
custom = input("Enter model name: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
return custom if custom else None
|
||||
return None
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
|
||||
+38
-10
@@ -12,14 +12,16 @@ import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from prompt_toolkit import print_formatted_text as _pt_print
|
||||
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
|
||||
# rich and prompt_toolkit are imported lazily (inside the functions that use
|
||||
# them) rather than at module level. Importing this module is on the TUI
|
||||
# gateway's critical startup path purely to reach the lightweight update-check
|
||||
# helpers (``prefetch_update_check``); pulling rich.console + prompt_toolkit
|
||||
# eagerly added ~50ms of wasted imports before ``gateway.ready`` could fire.
|
||||
# Keep the type-only reference available to checkers without the runtime cost.
|
||||
if TYPE_CHECKING:
|
||||
from rich.console import Console
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,6 +38,8 @@ _RST = "\033[0m"
|
||||
|
||||
def cprint(text: str):
|
||||
"""Print ANSI-colored text through prompt_toolkit's renderer."""
|
||||
from prompt_toolkit import print_formatted_text as _pt_print
|
||||
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
|
||||
_pt_print(_PT_ANSI(text))
|
||||
|
||||
|
||||
@@ -221,7 +225,11 @@ def check_for_updates() -> Optional[int]:
|
||||
cache_file = hermes_home / ".update_check"
|
||||
embedded_rev = os.environ.get("HERMES_REVISION") or None
|
||||
|
||||
# Read cache — invalidate if the embedded rev has changed since last check
|
||||
# Read cache — invalidate if the embedded rev OR installed version has
|
||||
# changed since the last check. The version guard matters for pip installs:
|
||||
# `check_via_pypi()` compares against VERSION, so a `pip install --upgrade`
|
||||
# changes VERSION but leaves rev unchanged (both None), and without this
|
||||
# the stale "behind" count would survive the upgrade for up to 6h. See #34491.
|
||||
now = time.time()
|
||||
try:
|
||||
if cache_file.exists():
|
||||
@@ -229,6 +237,7 @@ def check_for_updates() -> Optional[int]:
|
||||
if (
|
||||
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
|
||||
and cached.get("rev") == embedded_rev
|
||||
and cached.get("ver") == VERSION
|
||||
):
|
||||
return cached.get("behind")
|
||||
except Exception:
|
||||
@@ -249,7 +258,9 @@ def check_for_updates() -> Optional[int]:
|
||||
behind = _check_via_local_git(repo_dir)
|
||||
|
||||
try:
|
||||
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
|
||||
cache_file.write_text(
|
||||
json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION})
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -464,7 +475,7 @@ def _display_toolset_name(toolset_name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
def build_welcome_banner(console: "Console", model: str, cwd: str,
|
||||
tools: List[dict] = None,
|
||||
enabled_toolsets: List[str] = None,
|
||||
session_id: str = None,
|
||||
@@ -483,6 +494,8 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
context_length: Model's context window size in tokens.
|
||||
"""
|
||||
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
if get_toolset_for_tool is None:
|
||||
from model_tools import get_toolset_for_tool
|
||||
|
||||
@@ -691,6 +704,21 @@ 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.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method
|
||||
if detect_install_method() == "pip":
|
||||
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[/]"
|
||||
)
|
||||
except Exception:
|
||||
pass # Never break the banner over the install-method check
|
||||
|
||||
right_content = "\n".join(right_lines)
|
||||
layout_table.add_row(left_content, right_content)
|
||||
|
||||
|
||||
@@ -85,8 +85,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
args_hint="<platform>", cli_only=True),
|
||||
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
|
||||
aliases=("fork",), args_hint="[name]"),
|
||||
CommandDef("compress", "Manually compress conversation context", "Session",
|
||||
args_hint="[focus topic]"),
|
||||
CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns)", "Session",
|
||||
args_hint="[here [N] | focus topic]"),
|
||||
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
|
||||
args_hint="[number]"),
|
||||
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
|
||||
|
||||
+77
-10
@@ -285,9 +285,22 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||
Resolution order:
|
||||
1. Stamped ``~/.hermes/.install_method`` file (written by installers)
|
||||
2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
|
||||
3. Container detection (/.dockerenv, /run/.containerenv, cgroup)
|
||||
4. .git directory presence -> 'git'
|
||||
5. Fallback -> 'pip'
|
||||
3. .git directory presence -> 'git'
|
||||
4. Fallback -> 'pip'
|
||||
|
||||
Note: running inside a container is NOT treated as "docker" on its own.
|
||||
The two supported install paths both self-identify via the
|
||||
``.install_method`` stamp (caught by step 1), so neither relies on
|
||||
container detection here:
|
||||
- the curl installer (scripts/install.sh, the README/website install
|
||||
command) git-clones the repo and stamps ``git``;
|
||||
- the published ``nousresearch/hermes-agent`` image stamps ``docker``
|
||||
at boot via ``docker/stage2-hook.sh``.
|
||||
An unsupported manual install dropped into a container (no stamp) was
|
||||
wrongly classified as the published image by bare container detection,
|
||||
so ``hermes update`` bailed with "doesn't apply inside the Docker
|
||||
container". Without that fallback such installs fall through to the
|
||||
``.git``/pip checks and behave like any off-path install. See issue #34397.
|
||||
"""
|
||||
stamp = get_hermes_home() / ".install_method"
|
||||
try:
|
||||
@@ -299,9 +312,6 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||
managed = get_managed_system()
|
||||
if managed:
|
||||
return managed.lower().replace(" ", "-")
|
||||
from hermes_constants import is_container
|
||||
if is_container():
|
||||
return "docker"
|
||||
if project_root is None:
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
if (project_root / ".git").is_dir():
|
||||
@@ -319,6 +329,34 @@ def stamp_install_method(method: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def is_uv_tool_install() -> bool:
|
||||
"""Return True when the *running* Hermes lives in a ``uv tool`` layout.
|
||||
|
||||
``uv tool install hermes-agent`` places the install at
|
||||
``.../uv/tools/hermes-agent/...`` (default ``~/.local/share/uv/tools``,
|
||||
or ``$UV_TOOL_DIR/...``). Such installs live outside any virtualenv, so
|
||||
``uv pip install`` fails with ``No virtual environment found`` and the
|
||||
update path must use ``uv tool upgrade`` instead.
|
||||
|
||||
Detection is intentionally restricted to properties of the running
|
||||
interpreter (``sys.prefix`` / ``sys.executable``). We deliberately do
|
||||
NOT consult ``uv tool list``: it would also return True when
|
||||
``hermes-agent`` happens to be uv-tool-installed on the machine while
|
||||
the *active* Hermes is a regular pip/venv install, causing
|
||||
``hermes update`` to upgrade the wrong copy. It would also block on a
|
||||
subprocess call (~seconds) just to compute a recommendation string.
|
||||
"""
|
||||
def _has_uv_tool_marker(path: str) -> bool:
|
||||
norm = os.path.normpath(path).replace(os.sep, "/").lower()
|
||||
return "/uv/tools/hermes-agent/" in norm + "/"
|
||||
|
||||
if _has_uv_tool_marker(sys.prefix):
|
||||
return True
|
||||
if _has_uv_tool_marker(sys.executable or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def recommended_update_command_for_method(method: str) -> str:
|
||||
"""Return the update command or guidance for a given install method."""
|
||||
if method == "nixos":
|
||||
@@ -328,9 +366,10 @@ def recommended_update_command_for_method(method: str) -> str:
|
||||
if method == "docker":
|
||||
return "docker pull nousresearch/hermes-agent:latest"
|
||||
if method == "pip":
|
||||
if is_uv_tool_install():
|
||||
return "uv tool upgrade hermes-agent"
|
||||
import shutil
|
||||
uv = shutil.which("uv")
|
||||
if uv:
|
||||
if shutil.which("uv"):
|
||||
return "uv pip install --upgrade hermes-agent"
|
||||
return "pip install --upgrade hermes-agent"
|
||||
return "hermes update"
|
||||
@@ -1183,6 +1222,11 @@ DEFAULT_CONFIG = {
|
||||
# Mirrors `hermes -c` muscle memory. Default off so existing
|
||||
# users aren't surprised. HERMES_TUI_RESUME=<id> always wins.
|
||||
"tui_auto_resume_recent": False,
|
||||
# When true (default), `hermes --tui` drops a one-time hint
|
||||
# ("subagents working · /agents to watch live") the first time a turn
|
||||
# starts delegating, nudging the user toward the live spawn-tree
|
||||
# dashboard. Set false to suppress the hint.
|
||||
"tui_agents_nudge": True,
|
||||
"bell_on_complete": False,
|
||||
"show_reasoning": False,
|
||||
"streaming": False,
|
||||
@@ -1202,6 +1246,13 @@ DEFAULT_CONFIG = {
|
||||
# class of over-claim that otherwise forces users to run
|
||||
# `git status` to verify edits landed. Set false to suppress.
|
||||
"file_mutation_verifier": True,
|
||||
# Turn-completion explainer. When true (default), the agent appends a
|
||||
# one-line explanation to its final response whenever a turn ends
|
||||
# abnormally with no usable reply — empty content after retries, a
|
||||
# partial/truncated stream, a still-pending tool result, or an
|
||||
# iteration/budget limit. Replaces the bare "(empty)" sentinel so the
|
||||
# failure isn't silent from the UI's perspective. Set false to suppress.
|
||||
"turn_completion_explainer": True,
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
# UI language for static user-facing messages (approval prompts, a
|
||||
@@ -1852,7 +1903,7 @@ DEFAULT_CONFIG = {
|
||||
# Disk cache TTL in hours. Beyond this, the CLI refetches on the
|
||||
# next /model or `hermes model` invocation; network failures
|
||||
# silently fall back to the stale cache.
|
||||
"ttl_hours": 24,
|
||||
"ttl_hours": 1,
|
||||
# Optional per-provider override URLs for third parties that want
|
||||
# to self-host their own curation list using the same schema.
|
||||
# Example:
|
||||
@@ -2100,7 +2151,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 24,
|
||||
"_config_version": 25,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -4293,6 +4344,22 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
f"{', '.join(added_aux)}"
|
||||
)
|
||||
|
||||
# ── Version 24 → 25: lower model_catalog TTL 24h → 1h ──
|
||||
# The model picker now refreshes its curated list hourly so freshly
|
||||
# published model-catalog.json deploys reach users without a day-long
|
||||
# stale window. Only rewrite the OLD default (24) — never clobber a
|
||||
# value the user deliberately customized.
|
||||
if current_ver < 25:
|
||||
config = read_raw_config()
|
||||
raw_mc = config.get("model_catalog")
|
||||
if isinstance(raw_mc, dict) and raw_mc.get("ttl_hours") == 24:
|
||||
raw_mc["ttl_hours"] = 1
|
||||
config["model_catalog"] = raw_mc
|
||||
save_config(config)
|
||||
results["config_added"].append("model_catalog.ttl_hours 24→1")
|
||||
if not quiet:
|
||||
print(" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ pause/resume/run/remove, status, and tick.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional
|
||||
@@ -15,6 +16,24 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
# Patterns that indicate a cron job targets the gateway lifecycle.
|
||||
# Matches commands that restart/stop the gateway or its service manager.
|
||||
# Deliberately specific — a bare "gateway ... restart" catch-all would block
|
||||
# legitimate prompts that merely mention an unrelated gateway (e.g. "summarize
|
||||
# the API gateway logs and report restart events").
|
||||
_GATEWAY_LIFECYCLE_PATTERNS = re.compile(
|
||||
r"(?i)"
|
||||
r"(hermes\s+gateway\s+(restart|stop|start))"
|
||||
r"|(launchctl\s+(kickstart|unload|load|stop|restart)\s+.*hermes)"
|
||||
r"|(systemctl\s+(restart|stop|start)\s+.*hermes)"
|
||||
r"|(p?kill\s+.*hermes.*gateway)"
|
||||
)
|
||||
|
||||
|
||||
def _contains_gateway_lifecycle_command(text: str) -> bool:
|
||||
"""Return True if *text* contains a gateway lifecycle command pattern."""
|
||||
return bool(_GATEWAY_LIFECYCLE_PATTERNS.search(text))
|
||||
|
||||
|
||||
def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
|
||||
if skills is None:
|
||||
@@ -166,6 +185,28 @@ def cron_status():
|
||||
|
||||
|
||||
def cron_create(args):
|
||||
# Defense: reject cron jobs that contain gateway lifecycle commands.
|
||||
# Prevents agents from scheduling their own restart/stop, which creates
|
||||
# SIGTERM-respawn loops under launchd/systemd KeepAlive (#30719).
|
||||
prompt = getattr(args, "prompt", None) or ""
|
||||
script = getattr(args, "script", None)
|
||||
combined = prompt
|
||||
if script:
|
||||
try:
|
||||
script_text = Path(script).read_text(encoding="utf-8")
|
||||
combined = f"{combined}\n{script_text}"
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
if _contains_gateway_lifecycle_command(combined):
|
||||
print(color(
|
||||
"Blocked: cron job contains a gateway lifecycle command "
|
||||
"(restart/stop/kill).\n"
|
||||
"This is blocked to prevent restart loops (#30719).\n"
|
||||
"Use `hermes gateway restart` from a shell outside the gateway.",
|
||||
Colors.RED,
|
||||
))
|
||||
return 1
|
||||
|
||||
result = _cron_api(
|
||||
action="create",
|
||||
schedule=args.schedule,
|
||||
|
||||
+346
-268
@@ -32,6 +32,196 @@ def flush_stdin() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# Normalized menu actions returned by ``read_menu_key``. Using sentinels keeps
|
||||
# every menu's key-handling branch identical and free of raw escape-byte logic.
|
||||
NAV_UP = "up"
|
||||
NAV_DOWN = "down"
|
||||
NAV_SELECT = "select"
|
||||
NAV_TOGGLE = "toggle"
|
||||
NAV_CANCEL = "cancel"
|
||||
NAV_NONE = "none"
|
||||
|
||||
|
||||
def read_menu_key(stdscr) -> str:
|
||||
"""Read one keypress and normalize it to a menu action.
|
||||
|
||||
Decodes raw arrow-key escape sequences in addition to the translated
|
||||
``curses.KEY_*`` values. Even with ``keypad(True)`` (which
|
||||
``curses.wrapper`` sets), some terminals/terminfo entries deliver cursor
|
||||
keys as raw CSI/SS3 byte sequences — ``getch()`` then returns ``27`` (ESC)
|
||||
followed by e.g. ``[`` ``A``. Treating that leading ``27`` as a cancel is
|
||||
what made the setup wizard's provider/model pickers bail to the numbered
|
||||
fallback the moment a user pressed up/down.
|
||||
|
||||
Returns one of the ``NAV_*`` constants. A lone ESC (no continuation byte
|
||||
within a short window) is the only thing that maps to ``NAV_CANCEL`` via
|
||||
the escape path; ``q`` also cancels. Unknown sequences map to
|
||||
``NAV_NONE`` so the caller simply ignores them rather than misfiring.
|
||||
"""
|
||||
import curses
|
||||
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
return NAV_UP
|
||||
if key in (curses.KEY_DOWN, ord("j")):
|
||||
return NAV_DOWN
|
||||
if key in (curses.KEY_ENTER, 10, 13):
|
||||
return NAV_SELECT
|
||||
if key == ord(" "):
|
||||
return NAV_TOGGLE
|
||||
if key == ord("q"):
|
||||
return NAV_CANCEL
|
||||
|
||||
if key == 27: # ESC — could be a lone ESC (cancel) or an escape sequence.
|
||||
# Wait briefly for a continuation byte. On slow PTYs (SSH/tmux) the
|
||||
# bytes of an arrow key can arrive across separate reads, so a tiny
|
||||
# timeout avoids misreading a split sequence as a bare ESC.
|
||||
try:
|
||||
stdscr.timeout(60)
|
||||
nxt = stdscr.getch()
|
||||
finally:
|
||||
stdscr.timeout(-1) # restore blocking mode
|
||||
|
||||
if nxt == -1:
|
||||
return NAV_CANCEL # genuine lone ESC
|
||||
|
||||
if nxt in (ord("["), ord("O")): # CSI / SS3 introducer
|
||||
final = stdscr.getch()
|
||||
if final in (ord("A"), ord("k")):
|
||||
return NAV_UP
|
||||
if final in (ord("B"), ord("j")):
|
||||
return NAV_DOWN
|
||||
# Consume the tail of any other CSI sequence (e.g. ``[3~`` Delete,
|
||||
# ``[H`` Home) up to its terminator so stray bytes don't leak into
|
||||
# the next input() and corrupt it.
|
||||
while 0x20 <= final <= 0x3F: # CSI parameter/intermediate bytes
|
||||
final = stdscr.getch()
|
||||
return NAV_NONE
|
||||
# ESC followed by some other byte we don't handle — swallow it.
|
||||
return NAV_NONE
|
||||
|
||||
return NAV_NONE
|
||||
|
||||
|
||||
# Sentinel: an on_action reducer returns this to mean "keep looping" (the
|
||||
# keypress changed cursor/selection state but didn't resolve the menu).
|
||||
_KEEP = object()
|
||||
|
||||
|
||||
def _run_curses_menu(
|
||||
*,
|
||||
initial_cursor,
|
||||
item_count,
|
||||
draw_header,
|
||||
draw_row,
|
||||
on_action,
|
||||
reserve_bottom=1,
|
||||
draw_footer=None,
|
||||
extra_color_pairs=False,
|
||||
fallback,
|
||||
cancel_value,
|
||||
):
|
||||
"""Shared curses single-/multi-select event loop.
|
||||
|
||||
Owns every piece the three public menus used to duplicate verbatim:
|
||||
the non-TTY guard, ``curses.wrapper`` setup (cursor hide + color pairs),
|
||||
the per-frame ``clear``/``getmaxyx``/``refresh`` cycle, scroll-offset math,
|
||||
row iteration, the ``read_menu_key`` dispatch with ``NAV_UP``/``NAV_DOWN``
|
||||
cursor wrap, ``flush_stdin``, and the ``KeyboardInterrupt`` / curses-
|
||||
unavailable fallback. Per-menu behavior is supplied as callbacks so the
|
||||
rendered output stays byte-identical to the old hand-rolled loops.
|
||||
|
||||
Callbacks / params:
|
||||
draw_header(stdscr, max_y, max_x) -> int
|
||||
Draw the title/hint/description rows. Returns the first screen row
|
||||
index where the scrollable item list should start.
|
||||
draw_row(stdscr, y, idx, is_cursor, max_x) -> None
|
||||
Draw one item row.
|
||||
on_action(action, cursor) -> value
|
||||
Reducer for SELECT/TOGGLE/CANCEL. Return ``_KEEP`` to continue the
|
||||
loop; return anything else to resolve the menu with that value.
|
||||
(UP/DOWN cursor movement is handled by the driver itself.)
|
||||
reserve_bottom: number of bottom screen rows kept clear of items
|
||||
(1 = leave the final row blank, matching the old loops).
|
||||
draw_footer(stdscr, max_y, max_x) -> None
|
||||
Optional bottom-row painter (e.g. a status bar). Drawn after the
|
||||
item rows; its row budget must be included in ``reserve_bottom``.
|
||||
extra_color_pairs: also init pair 3 (dim gray) for status bars.
|
||||
fallback() -> value
|
||||
Called when curses errors out on a real TTY (curses unavailable).
|
||||
cancel_value: returned on non-TTY stdin, ESC/cancel, or KeyboardInterrupt.
|
||||
"""
|
||||
# Non-TTY (piped/redirected stdin): curses and input() both hang or spin,
|
||||
# so return the cancel value directly — matching the pre-refactor guard in
|
||||
# each menu (the numbered fallback is only for curses errors on a real TTY).
|
||||
if not sys.stdin.isatty():
|
||||
return cancel_value
|
||||
|
||||
try:
|
||||
import curses
|
||||
result_holder = [_KEEP]
|
||||
|
||||
def _draw(stdscr):
|
||||
curses.curs_set(0)
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
if extra_color_pairs:
|
||||
curses.init_pair(
|
||||
3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1
|
||||
)
|
||||
cursor = initial_cursor
|
||||
scroll_offset = 0
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
|
||||
items_start = draw_header(stdscr, max_y, max_x)
|
||||
|
||||
visible_rows = max_y - items_start - reserve_bottom
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
|
||||
for draw_i, i in enumerate(
|
||||
range(scroll_offset, min(item_count, scroll_offset + visible_rows))
|
||||
):
|
||||
y = draw_i + items_start
|
||||
if y >= max_y - reserve_bottom:
|
||||
break
|
||||
draw_row(stdscr, y, i, i == cursor, max_x)
|
||||
|
||||
if draw_footer is not None:
|
||||
draw_footer(stdscr, max_y, max_x)
|
||||
|
||||
stdscr.refresh()
|
||||
action = read_menu_key(stdscr)
|
||||
|
||||
if action == NAV_UP:
|
||||
cursor = (cursor - 1) % item_count
|
||||
elif action == NAV_DOWN:
|
||||
cursor = (cursor + 1) % item_count
|
||||
elif action in (NAV_SELECT, NAV_TOGGLE, NAV_CANCEL):
|
||||
outcome = on_action(action, cursor)
|
||||
if outcome is not _KEEP:
|
||||
result_holder[0] = outcome
|
||||
return
|
||||
|
||||
curses.wrapper(_draw)
|
||||
flush_stdin()
|
||||
return result_holder[0] if result_holder[0] is not _KEEP else cancel_value
|
||||
|
||||
except KeyboardInterrupt:
|
||||
return cancel_value
|
||||
except Exception:
|
||||
return fallback()
|
||||
|
||||
|
||||
def curses_checklist(
|
||||
title: str,
|
||||
items: List[str],
|
||||
@@ -54,112 +244,73 @@ def curses_checklist(
|
||||
if cancel_returns is None:
|
||||
cancel_returns = set(selected)
|
||||
|
||||
# Safety: curses and input() both hang or spin when stdin is not a
|
||||
# terminal (e.g. subprocess pipe). Return defaults immediately.
|
||||
if not sys.stdin.isatty():
|
||||
return cancel_returns
|
||||
chosen = set(selected)
|
||||
|
||||
try:
|
||||
def _draw_header(stdscr, max_y, max_x):
|
||||
import curses
|
||||
chosen = set(selected)
|
||||
result_holder: list = [None]
|
||||
|
||||
def _draw(stdscr):
|
||||
curses.curs_set(0)
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) # dim gray
|
||||
cursor = 0
|
||||
scroll_offset = 0
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" ↑↓ navigate SPACE toggle ENTER confirm ESC cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
return 3
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
def _draw_row(stdscr, y, i, is_cursor, max_x):
|
||||
import curses
|
||||
check = "✓" if i in chosen else " "
|
||||
arrow = "→" if is_cursor else " "
|
||||
line = f" {arrow} [{check}] {items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if is_cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Reserve bottom row for status bar when status_fn provided
|
||||
footer_rows = 1 if status_fn else 0
|
||||
def _draw_footer(stdscr, max_y, max_x):
|
||||
import curses
|
||||
try:
|
||||
status_text = status_fn(chosen)
|
||||
if status_text:
|
||||
# Right-align on the bottom row
|
||||
sx = max(0, max_x - len(status_text) - 1)
|
||||
sattr = curses.A_DIM
|
||||
if curses.has_colors():
|
||||
sattr |= curses.color_pair(3)
|
||||
stdscr.addnstr(max_y - 1, sx, status_text, max_x - sx - 1, sattr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Header
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" ↑↓ navigate SPACE toggle ENTER confirm ESC cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
def _on_action(action, cursor):
|
||||
if action == NAV_TOGGLE:
|
||||
chosen.symmetric_difference_update({cursor})
|
||||
return _KEEP
|
||||
if action == NAV_SELECT:
|
||||
return set(chosen)
|
||||
return cancel_returns # NAV_CANCEL
|
||||
|
||||
# Scrollable item list
|
||||
visible_rows = max_y - 3 - footer_rows
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
|
||||
for draw_i, i in enumerate(
|
||||
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
|
||||
):
|
||||
y = draw_i + 3
|
||||
if y >= max_y - 1 - footer_rows:
|
||||
break
|
||||
check = "✓" if i in chosen else " "
|
||||
arrow = "→" if i == cursor else " "
|
||||
line = f" {arrow} [{check}] {items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if i == cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Status bar (bottom row, right-aligned)
|
||||
if status_fn:
|
||||
try:
|
||||
status_text = status_fn(chosen)
|
||||
if status_text:
|
||||
# Right-align on the bottom row
|
||||
sx = max(0, max_x - len(status_text) - 1)
|
||||
sattr = curses.A_DIM
|
||||
if curses.has_colors():
|
||||
sattr |= curses.color_pair(3)
|
||||
stdscr.addnstr(max_y - 1, sx, status_text, max_x - sx - 1, sattr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in {curses.KEY_UP, ord("k")}:
|
||||
cursor = (cursor - 1) % len(items)
|
||||
elif key in {curses.KEY_DOWN, ord("j")}:
|
||||
cursor = (cursor + 1) % len(items)
|
||||
elif key == ord(" "):
|
||||
chosen.symmetric_difference_update({cursor})
|
||||
elif key in {curses.KEY_ENTER, 10, 13}:
|
||||
result_holder[0] = set(chosen)
|
||||
return
|
||||
elif key in {27, ord("q")}:
|
||||
result_holder[0] = cancel_returns
|
||||
return
|
||||
|
||||
curses.wrapper(_draw)
|
||||
flush_stdin()
|
||||
return result_holder[0] if result_holder[0] is not None else cancel_returns
|
||||
|
||||
except KeyboardInterrupt:
|
||||
return cancel_returns
|
||||
except Exception:
|
||||
return _numbered_fallback(title, items, selected, cancel_returns, status_fn)
|
||||
return _run_curses_menu(
|
||||
initial_cursor=0,
|
||||
item_count=len(items),
|
||||
draw_header=_draw_header,
|
||||
draw_row=_draw_row,
|
||||
on_action=_on_action,
|
||||
reserve_bottom=(2 if status_fn else 1),
|
||||
draw_footer=_draw_footer if status_fn else None,
|
||||
extra_color_pairs=bool(status_fn),
|
||||
fallback=lambda: _numbered_fallback(title, items, selected, cancel_returns, status_fn),
|
||||
cancel_value=cancel_returns,
|
||||
)
|
||||
|
||||
|
||||
def curses_radiolist(
|
||||
@@ -184,106 +335,68 @@ def curses_radiolist(
|
||||
if cancel_returns is None:
|
||||
cancel_returns = selected
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
return cancel_returns
|
||||
|
||||
desc_lines: list[str] = []
|
||||
if description:
|
||||
desc_lines = description.splitlines()
|
||||
|
||||
try:
|
||||
def _draw_header(stdscr, max_y, max_x):
|
||||
import curses
|
||||
result_holder: list = [None]
|
||||
|
||||
def _draw(stdscr):
|
||||
curses.curs_set(0)
|
||||
row = 0
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
cursor = selected
|
||||
scroll_offset = 0
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(row, 0, title, max_x - 1, hattr)
|
||||
row += 1
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
# Description lines
|
||||
for dline in desc_lines:
|
||||
if row >= max_y - 1:
|
||||
break
|
||||
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
|
||||
row += 1
|
||||
|
||||
row = 0
|
||||
stdscr.addnstr(
|
||||
row, 0,
|
||||
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
row += 1
|
||||
except curses.error:
|
||||
pass
|
||||
# One blank row between the hint and the item list.
|
||||
return row + 1
|
||||
|
||||
# Header
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(row, 0, title, max_x - 1, hattr)
|
||||
row += 1
|
||||
def _draw_row(stdscr, y, i, is_cursor, max_x):
|
||||
import curses
|
||||
radio = "\u25cf" if i == selected else "\u25cb"
|
||||
arrow = "\u2192" if is_cursor else " "
|
||||
line = f" {arrow} ({radio}) {items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if is_cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Description lines
|
||||
for dline in desc_lines:
|
||||
if row >= max_y - 1:
|
||||
break
|
||||
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
|
||||
row += 1
|
||||
def _on_action(action, cursor):
|
||||
if action in (NAV_SELECT, NAV_TOGGLE):
|
||||
return cursor
|
||||
return cancel_returns # NAV_CANCEL
|
||||
|
||||
stdscr.addnstr(
|
||||
row, 0,
|
||||
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
row += 1
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Scrollable item list
|
||||
items_start = row + 1
|
||||
visible_rows = max_y - items_start - 1
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
|
||||
for draw_i, i in enumerate(
|
||||
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
|
||||
):
|
||||
y = draw_i + items_start
|
||||
if y >= max_y - 1:
|
||||
break
|
||||
radio = "\u25cf" if i == selected else "\u25cb"
|
||||
arrow = "\u2192" if i == cursor else " "
|
||||
line = f" {arrow} ({radio}) {items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if i == cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in {curses.KEY_UP, ord("k")}:
|
||||
cursor = (cursor - 1) % len(items)
|
||||
elif key in {curses.KEY_DOWN, ord("j")}:
|
||||
cursor = (cursor + 1) % len(items)
|
||||
elif key in {ord(" "), curses.KEY_ENTER, 10, 13}:
|
||||
result_holder[0] = cursor
|
||||
return
|
||||
elif key in {27, ord("q")}:
|
||||
result_holder[0] = cancel_returns
|
||||
return
|
||||
|
||||
curses.wrapper(_draw)
|
||||
flush_stdin()
|
||||
return result_holder[0] if result_holder[0] is not None else cancel_returns
|
||||
|
||||
except KeyboardInterrupt:
|
||||
return cancel_returns
|
||||
except Exception:
|
||||
return _radio_numbered_fallback(title, items, selected, cancel_returns)
|
||||
return _run_curses_menu(
|
||||
initial_cursor=selected,
|
||||
item_count=len(items),
|
||||
draw_header=_draw_header,
|
||||
draw_row=_draw_row,
|
||||
on_action=_on_action,
|
||||
reserve_bottom=1,
|
||||
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns),
|
||||
cancel_value=cancel_returns,
|
||||
)
|
||||
|
||||
|
||||
def _radio_numbered_fallback(
|
||||
@@ -324,93 +437,58 @@ def curses_single_select(
|
||||
Works inside prompt_toolkit because curses.wrapper() restores the terminal
|
||||
safely, unlike simple_term_menu which conflicts with /dev/tty.
|
||||
"""
|
||||
if not sys.stdin.isatty():
|
||||
return None
|
||||
all_items = list(items) + [cancel_label]
|
||||
cancel_idx = len(items)
|
||||
|
||||
try:
|
||||
def _draw_header(stdscr, max_y, max_x):
|
||||
import curses
|
||||
result_holder: list = [None]
|
||||
|
||||
all_items = list(items) + [cancel_label]
|
||||
cancel_idx = len(items)
|
||||
|
||||
def _draw(stdscr):
|
||||
curses.curs_set(0)
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
cursor = min(default_index, len(all_items) - 1)
|
||||
scroll_offset = 0
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" ↑↓ navigate ENTER confirm ESC/q cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
return 3
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
def _draw_row(stdscr, y, i, is_cursor, max_x):
|
||||
import curses
|
||||
arrow = "→" if is_cursor else " "
|
||||
line = f" {arrow} {all_items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if is_cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" ↑↓ navigate ENTER confirm ESC/q cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
visible_rows = max_y - 3
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
|
||||
for draw_i, i in enumerate(
|
||||
range(scroll_offset, min(len(all_items), scroll_offset + visible_rows))
|
||||
):
|
||||
y = draw_i + 3
|
||||
if y >= max_y - 1:
|
||||
break
|
||||
arrow = "→" if i == cursor else " "
|
||||
line = f" {arrow} {all_items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if i == cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in {curses.KEY_UP, ord("k")}:
|
||||
cursor = (cursor - 1) % len(all_items)
|
||||
elif key in {curses.KEY_DOWN, ord("j")}:
|
||||
cursor = (cursor + 1) % len(all_items)
|
||||
elif key in {curses.KEY_ENTER, 10, 13}:
|
||||
result_holder[0] = cursor
|
||||
return
|
||||
elif key in {27, ord("q")}:
|
||||
result_holder[0] = None
|
||||
return
|
||||
|
||||
curses.wrapper(_draw)
|
||||
flush_stdin()
|
||||
if result_holder[0] is not None and result_holder[0] >= cancel_idx:
|
||||
def _on_action(action, cursor):
|
||||
if action == NAV_SELECT:
|
||||
# Selecting the synthetic cancel row resolves to None, mirroring
|
||||
# the old post-loop ``>= cancel_idx`` guard.
|
||||
return None if cursor >= cancel_idx else cursor
|
||||
if action == NAV_CANCEL:
|
||||
return None
|
||||
return result_holder[0]
|
||||
return _KEEP # NAV_TOGGLE — no-op for this menu
|
||||
|
||||
except KeyboardInterrupt:
|
||||
return None
|
||||
except Exception:
|
||||
all_items = list(items) + [cancel_label]
|
||||
cancel_idx = len(items)
|
||||
return _numbered_single_fallback(title, all_items, cancel_idx)
|
||||
return _run_curses_menu(
|
||||
initial_cursor=min(default_index, len(all_items) - 1),
|
||||
item_count=len(all_items),
|
||||
draw_header=_draw_header,
|
||||
draw_row=_draw_row,
|
||||
on_action=_on_action,
|
||||
reserve_bottom=1,
|
||||
fallback=lambda: _numbered_single_fallback(title, all_items, cancel_idx),
|
||||
cancel_value=None,
|
||||
)
|
||||
|
||||
|
||||
def _numbered_single_fallback(
|
||||
|
||||
@@ -204,6 +204,60 @@ def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None
|
||||
issues.append(fix)
|
||||
|
||||
|
||||
def _read_pyproject_version() -> str | None:
|
||||
"""Read the ``version = "..."`` from ``pyproject.toml`` at the project root.
|
||||
|
||||
Returns None when running from an installed wheel (no pyproject.toml ships
|
||||
with the package) or when the file can't be parsed. Reads only the
|
||||
``[project]`` version, ignoring any version strings that appear in other
|
||||
tables.
|
||||
"""
|
||||
pyproject = PROJECT_ROOT / "pyproject.toml"
|
||||
try:
|
||||
text = pyproject.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
in_project = False
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
in_project = line == "[project]"
|
||||
continue
|
||||
if in_project and line.startswith("version") and "=" in line:
|
||||
value = line.split("=", 1)[1]
|
||||
value = value.split("#", 1)[0].strip().strip("\"'")
|
||||
return value or None
|
||||
return None
|
||||
|
||||
|
||||
def _check_version_consistency(issues: list[str]) -> None:
|
||||
"""Verify pyproject.toml version matches hermes_cli.__version__.
|
||||
|
||||
A git conflict resolution (reset/merge) can revert one file without the
|
||||
other, leaving ``hermes --version`` reporting a stale version while
|
||||
``pyproject.toml`` is current. Detect that drift so users can re-sync.
|
||||
Silent no-op for installed wheels where pyproject.toml isn't present.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import __version__ as init_version
|
||||
except Exception:
|
||||
return
|
||||
pyproject_version = _read_pyproject_version()
|
||||
if pyproject_version is None:
|
||||
# Installed wheel or unreadable pyproject — nothing to cross-check.
|
||||
return
|
||||
if pyproject_version == init_version:
|
||||
check_ok("Version files consistent", f"({init_version})")
|
||||
else:
|
||||
_fail_and_issue(
|
||||
"Version mismatch between source files",
|
||||
f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
|
||||
"Re-sync version files (e.g. run 'hermes update', or set "
|
||||
"hermes_cli/__init__.py __version__ to match pyproject.toml)",
|
||||
issues,
|
||||
)
|
||||
|
||||
|
||||
def _check_s6_supervision(issues: list[str]) -> None:
|
||||
"""Inside a container under our s6 /init, surface what s6 sees.
|
||||
|
||||
@@ -509,6 +563,10 @@ def run_doctor(args):
|
||||
check_ok("Virtual environment active")
|
||||
else:
|
||||
check_warn("Not in virtual environment", "(recommended)")
|
||||
|
||||
# Detect drift between pyproject.toml and hermes_cli/__init__.py versions
|
||||
# (a git conflict resolution can silently revert one but not the other).
|
||||
_check_version_consistency(issues)
|
||||
|
||||
_section("Required Packages")
|
||||
required_packages = [
|
||||
|
||||
@@ -5422,6 +5422,16 @@ def _gateway_command_inner(args):
|
||||
sys.exit(1)
|
||||
|
||||
elif subcmd == "stop":
|
||||
# Defense: refuse self-targeting gateway stop from inside the gateway.
|
||||
# Prevents agent-initiated kill loops when combined with supervisor KeepAlive.
|
||||
if os.getenv("_HERMES_GATEWAY") == "1":
|
||||
print_error(
|
||||
"Refusing to stop the gateway from inside the gateway process.\n"
|
||||
"This command was blocked to prevent restart loops.\n"
|
||||
"Use `hermes gateway stop` from a shell outside the running gateway."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
stop_all = getattr(args, 'all', False)
|
||||
system = getattr(args, 'system', False)
|
||||
|
||||
@@ -5497,6 +5507,16 @@ def _gateway_command_inner(args):
|
||||
print(f"✓ Stopped {get_service_name()} service")
|
||||
|
||||
elif subcmd == "restart":
|
||||
# Defense: refuse self-targeting gateway restart from inside the gateway.
|
||||
# Prevents agent-initiated kill loops when combined with supervisor KeepAlive.
|
||||
if os.getenv("_HERMES_GATEWAY") == "1":
|
||||
print_error(
|
||||
"Refusing to restart the gateway from inside the gateway process.\n"
|
||||
"This command was blocked to prevent restart loops.\n"
|
||||
"Use `hermes gateway restart` from a shell outside the running gateway."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Try service first, fall back to killing and restarting
|
||||
service_available = False
|
||||
system = getattr(args, 'system', False)
|
||||
|
||||
@@ -747,6 +747,153 @@ class GoalManager:
|
||||
return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Kanban worker goal loop
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Continuation prompt fed back to a kanban goal-mode worker that has not
|
||||
# yet completed/blocked its task. The card's own acceptance criteria are
|
||||
# the goal — the worker already has the full task body in its first turn,
|
||||
# so we keep this short and point it back at the lifecycle contract.
|
||||
KANBAN_GOAL_CONTINUATION_TEMPLATE = (
|
||||
"[Continuing toward this kanban task — judge says it is not done yet]\n"
|
||||
"Reason: {reason}\n\n"
|
||||
"Take the next concrete step toward completing the task. When the work "
|
||||
"is genuinely finished, call kanban_complete with a summary. If you are "
|
||||
"blocked and need human input, call kanban_block with a reason. Do not "
|
||||
"stop without calling one of them."
|
||||
)
|
||||
|
||||
# Fed when the judge believes the work is done but the worker never called
|
||||
# kanban_complete / kanban_block. One explicit nudge to terminate the task
|
||||
# the right way before the loop gives up.
|
||||
KANBAN_GOAL_FINALIZE_TEMPLATE = (
|
||||
"[The work looks complete, but the task is still open]\n"
|
||||
"Reason: {reason}\n\n"
|
||||
"If the task is genuinely done, call kanban_complete now with a short "
|
||||
"summary of what you did. If something still blocks completion, call "
|
||||
"kanban_block with the reason instead."
|
||||
)
|
||||
|
||||
|
||||
def run_kanban_goal_loop(
|
||||
*,
|
||||
task_id: str,
|
||||
goal_text: str,
|
||||
run_turn,
|
||||
task_status_fn,
|
||||
block_fn,
|
||||
max_turns: int = DEFAULT_MAX_TURNS,
|
||||
first_response: str = "",
|
||||
log=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Drive a kanban worker through a Ralph-style goal loop.
|
||||
|
||||
The dispatcher spawns a goal-mode worker exactly like a normal worker
|
||||
(``hermes -p <profile> chat -q "work kanban task <id>"``). The worker's
|
||||
first turn has already run by the time this is called; ``first_response``
|
||||
is that turn's reply. From here we:
|
||||
|
||||
1. Check whether the worker already terminated the task (called
|
||||
``kanban_complete`` / ``kanban_block``). If so, stop — nothing to do.
|
||||
2. Otherwise judge the latest response against ``goal_text`` (the card's
|
||||
title + body). ``continue`` → feed a continuation prompt and run
|
||||
another turn IN THE SAME SESSION via ``run_turn``. ``done`` but the
|
||||
task is still open → one explicit "call kanban_complete" nudge.
|
||||
3. When the turn budget is exhausted and the worker still hasn't
|
||||
terminated the task, ``block_fn`` is invoked so the card lands in a
|
||||
sticky ``blocked`` state for human review (NOT a silent exit).
|
||||
|
||||
This function performs NO SessionDB persistence — a worker process is
|
||||
ephemeral, so the turn budget lives in a local counter. It is fully
|
||||
decoupled from the CLI for testability: callers inject ``run_turn``
|
||||
(str -> str), ``task_status_fn`` (() -> str|None), and ``block_fn``
|
||||
(reason: str -> None).
|
||||
|
||||
Returns a decision dict: ``{"outcome", "turns_used", "reason"}`` where
|
||||
outcome is one of ``"completed_by_worker"``, ``"blocked_budget"``,
|
||||
``"blocked_by_worker"``, or ``"stopped"``.
|
||||
"""
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
if log is not None:
|
||||
try:
|
||||
log(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
max_turns = int(max_turns or DEFAULT_MAX_TURNS)
|
||||
if max_turns < 1:
|
||||
max_turns = DEFAULT_MAX_TURNS
|
||||
|
||||
last_response = first_response or ""
|
||||
# The first turn already consumed one unit of budget.
|
||||
turns_used = 1
|
||||
nudged_to_finalize = False
|
||||
|
||||
while True:
|
||||
# Did the worker terminate the task itself this turn?
|
||||
try:
|
||||
status = task_status_fn()
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: status check failed ({exc}); stopping")
|
||||
return {"outcome": "stopped", "turns_used": turns_used, "reason": "status check failed"}
|
||||
|
||||
if status == "done":
|
||||
_log(f"kanban goal loop: task {task_id} completed by worker after {turns_used} turn(s)")
|
||||
return {"outcome": "completed_by_worker", "turns_used": turns_used, "reason": "worker completed the task"}
|
||||
if status == "blocked":
|
||||
_log(f"kanban goal loop: task {task_id} blocked by worker after {turns_used} turn(s)")
|
||||
return {"outcome": "blocked_by_worker", "turns_used": turns_used, "reason": "worker blocked the task"}
|
||||
if status not in ("running", "ready"):
|
||||
# Reclaimed / archived / unexpected — let the dispatcher own it.
|
||||
_log(f"kanban goal loop: task {task_id} status={status!r}; stopping")
|
||||
return {"outcome": "stopped", "turns_used": turns_used, "reason": f"status={status}"}
|
||||
|
||||
# Still open — judge whether the latest response satisfies the card.
|
||||
verdict, reason, _parse_failed = judge_goal(goal_text, last_response)
|
||||
_log(f"kanban goal loop: turn {turns_used}/{max_turns} verdict={verdict} reason={_truncate(reason, 120)}")
|
||||
|
||||
if verdict == "done":
|
||||
if nudged_to_finalize:
|
||||
# Already asked once to call kanban_complete and it still
|
||||
# didn't — block for review rather than spin.
|
||||
_log(f"kanban goal loop: task {task_id} judged done but worker won't finalize; blocking")
|
||||
try:
|
||||
block_fn(
|
||||
f"Goal-mode worker's output looked complete but it never "
|
||||
f"called kanban_complete after a finalize nudge ({reason})."
|
||||
)
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: block_fn failed ({exc})")
|
||||
return {"outcome": "blocked_budget", "turns_used": turns_used, "reason": "judged done, never finalized"}
|
||||
prompt = KANBAN_GOAL_FINALIZE_TEMPLATE.format(reason=_truncate(reason, 400))
|
||||
nudged_to_finalize = True
|
||||
else:
|
||||
prompt = KANBAN_GOAL_CONTINUATION_TEMPLATE.format(reason=_truncate(reason, 400))
|
||||
|
||||
# Budget check BEFORE spending another turn.
|
||||
if turns_used >= max_turns:
|
||||
_log(f"kanban goal loop: task {task_id} exhausted {turns_used}/{max_turns} turns; blocking")
|
||||
try:
|
||||
block_fn(
|
||||
f"Goal-mode worker exhausted its turn budget "
|
||||
f"({turns_used}/{max_turns}) without completing the task. "
|
||||
f"Last judge verdict: {_truncate(reason, 300)}"
|
||||
)
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: block_fn failed ({exc})")
|
||||
return {"outcome": "blocked_budget", "turns_used": turns_used, "reason": "turn budget exhausted"}
|
||||
|
||||
# Run another turn in the same session.
|
||||
try:
|
||||
last_response = run_turn(prompt) or ""
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: run_turn failed ({exc}); stopping")
|
||||
return {"outcome": "stopped", "turns_used": turns_used, "reason": f"run_turn error: {type(exc).__name__}"}
|
||||
turns_used += 1
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GoalState",
|
||||
"GoalManager",
|
||||
@@ -754,9 +901,12 @@ __all__ = [
|
||||
"CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE",
|
||||
"JUDGE_USER_PROMPT_TEMPLATE",
|
||||
"JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE",
|
||||
"KANBAN_GOAL_CONTINUATION_TEMPLATE",
|
||||
"KANBAN_GOAL_FINALIZE_TEMPLATE",
|
||||
"DEFAULT_MAX_TURNS",
|
||||
"load_goal",
|
||||
"save_goal",
|
||||
"clear_goal",
|
||||
"judge_goal",
|
||||
"run_kanban_goal_loop",
|
||||
]
|
||||
|
||||
@@ -341,6 +341,19 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
"two retries. Omit to use the dispatcher's "
|
||||
"kanban.failure_limit config "
|
||||
f"(default {kb.DEFAULT_FAILURE_LIMIT}).")
|
||||
p_create.add_argument("--goal", action="store_true", dest="goal_mode",
|
||||
help="Run the worker in a goal loop: after each "
|
||||
"turn a judge checks the response against the "
|
||||
"card title/body and, if not done, the worker "
|
||||
"keeps going in the same session until the "
|
||||
"judge agrees it's complete (or the turn "
|
||||
"budget runs out, which blocks the card for "
|
||||
"review). Best for open-ended cards one shot "
|
||||
"rarely finishes.")
|
||||
p_create.add_argument("--goal-max-turns", type=int, default=None,
|
||||
metavar="N", dest="goal_max_turns",
|
||||
help="Turn budget for --goal workers (default 20). "
|
||||
"Ignored without --goal.")
|
||||
p_create.add_argument("--initial-status",
|
||||
choices=sorted(kb.VALID_INITIAL_STATUSES),
|
||||
default="running",
|
||||
@@ -1343,6 +1356,8 @@ def _cmd_create(args: argparse.Namespace) -> int:
|
||||
max_runtime_seconds=max_runtime,
|
||||
skills=getattr(args, "skills", None) or None,
|
||||
max_retries=max_retries,
|
||||
goal_mode=bool(getattr(args, "goal_mode", False)),
|
||||
goal_max_turns=getattr(args, "goal_max_turns", None),
|
||||
initial_status=getattr(args, "initial_status", "running"),
|
||||
)
|
||||
task = kb.get_task(conn, task_id)
|
||||
|
||||
+437
-16
@@ -396,6 +396,41 @@ def workspaces_root(board: Optional[str] = None) -> Path:
|
||||
return board_dir(slug) / "workspaces"
|
||||
|
||||
|
||||
def attachments_root(board: Optional[str] = None) -> Path:
|
||||
"""Return the directory under which task file attachments are stored.
|
||||
|
||||
Mirrors :func:`worker_logs_dir` / :func:`workspaces_root`: anchored
|
||||
per-board so attachments don't leak between projects. Each task gets
|
||||
its own ``<root>/.../attachments/<task_id>/`` subdirectory.
|
||||
|
||||
``HERMES_KANBAN_ATTACHMENTS_ROOT`` pins the path directly (highest
|
||||
precedence) for tests and unusual deployments.
|
||||
|
||||
``default`` uses ``<root>/kanban/attachments/``; other boards use
|
||||
``<root>/kanban/boards/<slug>/attachments/``.
|
||||
|
||||
Workers (which run with full file-tool access) read attached files
|
||||
by the absolute path surfaced in :func:`build_worker_context`. On the
|
||||
local terminal backend — the default for kanban — that path resolves
|
||||
directly. Remote backends (Docker/Modal) need this directory mounted;
|
||||
see the kanban docs.
|
||||
"""
|
||||
override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
slug = _normalize_board_slug(board)
|
||||
if slug is None:
|
||||
slug = get_current_board()
|
||||
if slug == DEFAULT_BOARD:
|
||||
return kanban_home() / "kanban" / "attachments"
|
||||
return board_dir(slug) / "attachments"
|
||||
|
||||
|
||||
def task_attachments_dir(task_id: str, board: Optional[str] = None) -> Path:
|
||||
"""Return the per-task attachment directory ``<root>/<task_id>/``."""
|
||||
return attachments_root(board=board) / task_id
|
||||
|
||||
|
||||
def worker_logs_dir(board: Optional[str] = None) -> Path:
|
||||
"""Return the directory under which per-task worker logs are written.
|
||||
|
||||
@@ -690,6 +725,19 @@ class Task:
|
||||
# ``kanban.failure_limit`` config, and then to ``DEFAULT_FAILURE_LIMIT``.
|
||||
# Name matches the ``--max-retries`` CLI flag on ``kanban create``.
|
||||
max_retries: Optional[int] = None
|
||||
# When True, the dispatched worker runs in a Ralph-style goal loop
|
||||
# (the same engine behind the ``/goal`` slash command): after each
|
||||
# turn an auxiliary judge model evaluates the worker's response
|
||||
# against this card's title/body (treated as the goal). If the judge
|
||||
# says "not done" and budget remains, the worker is fed a
|
||||
# continuation prompt IN THE SAME SESSION and keeps working until the
|
||||
# judge agrees, the goal-turn budget is exhausted (→ kanban_block),
|
||||
# or the worker explicitly blocks/completes. ``False`` (default) =
|
||||
# the classic single-shot worker. ``goal_max_turns`` bounds the loop.
|
||||
goal_mode: bool = False
|
||||
# Goal-loop turn budget for ``goal_mode`` workers. ``None`` falls
|
||||
# through to the goals engine default (``goals.DEFAULT_MAX_TURNS``).
|
||||
goal_max_turns: Optional[int] = None
|
||||
# Originating chat/agent session id, when the task was created from
|
||||
# within an agent loop that propagated ``HERMES_SESSION_ID``. NULL for
|
||||
# tasks created from the CLI, the dashboard, or any path that doesn't
|
||||
@@ -762,6 +810,12 @@ class Task:
|
||||
max_retries=(
|
||||
row["max_retries"] if "max_retries" in keys else None
|
||||
),
|
||||
goal_mode=(
|
||||
bool(row["goal_mode"]) if "goal_mode" in keys and row["goal_mode"] else False
|
||||
),
|
||||
goal_max_turns=(
|
||||
row["goal_max_turns"] if "goal_max_turns" in keys and row["goal_max_turns"] else None
|
||||
),
|
||||
session_id=(
|
||||
row["session_id"] if "session_id" in keys else None
|
||||
),
|
||||
@@ -831,6 +885,20 @@ class Comment:
|
||||
created_at: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attachment:
|
||||
"""In-memory view of a row from the ``task_attachments`` table."""
|
||||
|
||||
id: int
|
||||
task_id: str
|
||||
filename: str
|
||||
stored_path: str
|
||||
content_type: Optional[str]
|
||||
size: int
|
||||
uploaded_by: Optional[str]
|
||||
created_at: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
id: int
|
||||
@@ -897,6 +965,16 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
-- case) falls through to the dispatcher-level ``kanban.failure_limit``
|
||||
-- config and then ``DEFAULT_FAILURE_LIMIT``.
|
||||
max_retries INTEGER,
|
||||
-- When 1, the dispatched worker runs in a Ralph-style goal loop: an
|
||||
-- auxiliary judge re-evaluates the worker's response against the
|
||||
-- card title/body after each turn and feeds a continuation prompt
|
||||
-- back into the SAME session until the judge agrees the work is done
|
||||
-- or ``goal_max_turns`` is exhausted. NULL/0 = classic single-shot
|
||||
-- worker (the default).
|
||||
goal_mode INTEGER NOT NULL DEFAULT 0,
|
||||
-- Goal-loop turn budget for ``goal_mode`` workers. NULL = use the
|
||||
-- goals-engine default.
|
||||
goal_max_turns INTEGER,
|
||||
-- Originating chat/agent session id when the task was created from
|
||||
-- inside an agent loop that propagated ``HERMES_SESSION_ID``. NULL
|
||||
-- for tasks created from the CLI, dashboard, or any path that doesn't
|
||||
@@ -957,6 +1035,23 @@ CREATE TABLE IF NOT EXISTS task_runs (
|
||||
error TEXT
|
||||
);
|
||||
|
||||
-- Files attached to a task (PDFs, images, source documents). The blob
|
||||
-- lives on disk under ``attachments_root(board)/<task_id>/<stored_name>``;
|
||||
-- this row carries metadata + the absolute ``stored_path`` so the
|
||||
-- dashboard can list/download and ``build_worker_context`` can surface
|
||||
-- the absolute path to the worker (which has full file-tool access). See
|
||||
-- #35338.
|
||||
CREATE TABLE IF NOT EXISTS task_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
uploaded_by TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Subscription from a gateway source (platform + chat + thread) to a
|
||||
-- task. The gateway's kanban-notifier watcher tails task_events and
|
||||
-- pushes ``completed`` / ``blocked`` / ``spawn_auto_blocked`` events to
|
||||
@@ -981,6 +1076,7 @@ CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id, c
|
||||
CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_task ON task_runs(task_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_status ON task_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_task ON task_attachments(task_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_id);
|
||||
"""
|
||||
|
||||
@@ -1517,6 +1613,20 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
|
||||
if "model_override" not in cols:
|
||||
conn.execute("ALTER TABLE tasks ADD COLUMN model_override TEXT")
|
||||
|
||||
if "goal_mode" not in cols:
|
||||
# Ralph-style goal loop toggle for the dispatched worker. 0 (the
|
||||
# default) = classic single-shot worker, preserving the behaviour
|
||||
# existing rows had before the column existed.
|
||||
_add_column_if_missing(
|
||||
conn, "tasks", "goal_mode", "goal_mode INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
|
||||
if "goal_max_turns" not in cols:
|
||||
# Per-task goal-loop turn budget. NULL = goals-engine default.
|
||||
_add_column_if_missing(
|
||||
conn, "tasks", "goal_max_turns", "goal_max_turns INTEGER"
|
||||
)
|
||||
|
||||
if "session_id" not in cols:
|
||||
# Originating agent/chat session id, populated when the task is
|
||||
# created from within an agent loop that propagated
|
||||
@@ -1637,6 +1747,140 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
|
||||
(new, old),
|
||||
)
|
||||
|
||||
_rebuild_drifted_tables(conn)
|
||||
|
||||
|
||||
# Legacy DBs defined these tables with a ``TEXT PRIMARY KEY`` id (or, for
|
||||
# ``kanban_notify_subs``, a nullable ``TEXT last_event_id``). The current
|
||||
# schema uses ``INTEGER PRIMARY KEY AUTOINCREMENT`` / ``INTEGER NOT NULL
|
||||
# DEFAULT 0``. ``CREATE TABLE IF NOT EXISTS`` skips existing tables
|
||||
# regardless of schema and ``_add_column_if_missing`` only adds columns, so
|
||||
# neither can fix a drifted column type — the table must be rebuilt. See
|
||||
# #35096.
|
||||
#
|
||||
# Each entry pairs the canonical CREATE TABLE with the CREATE INDEX
|
||||
# statements that DROP TABLE would otherwise take down with it (including
|
||||
# ``idx_events_run``, added by the additive pass above). To guard against
|
||||
# this list drifting from SCHEMA_SQL, ``test_rebuilt_schema_matches_fresh``
|
||||
# asserts a rebuilt legacy DB is byte-identical to a fresh one.
|
||||
_REBUILD_SPECS = {
|
||||
"task_events": (
|
||||
"CREATE TABLE task_events ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" task_id TEXT NOT NULL, run_id INTEGER, kind TEXT NOT NULL,"
|
||||
" payload TEXT, created_at INTEGER NOT NULL)",
|
||||
(
|
||||
"CREATE INDEX idx_events_task ON task_events(task_id, created_at)",
|
||||
"CREATE INDEX idx_events_run ON task_events(run_id, id)",
|
||||
),
|
||||
),
|
||||
"task_comments": (
|
||||
"CREATE TABLE task_comments ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" task_id TEXT NOT NULL, author TEXT NOT NULL, body TEXT NOT NULL,"
|
||||
" created_at INTEGER NOT NULL)",
|
||||
("CREATE INDEX idx_comments_task ON task_comments(task_id, created_at)",),
|
||||
),
|
||||
"task_runs": (
|
||||
"CREATE TABLE task_runs ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" task_id TEXT NOT NULL, profile TEXT, step_key TEXT,"
|
||||
" status TEXT NOT NULL, claim_lock TEXT, claim_expires INTEGER,"
|
||||
" worker_pid INTEGER, max_runtime_seconds INTEGER,"
|
||||
" last_heartbeat_at INTEGER, started_at INTEGER NOT NULL,"
|
||||
" ended_at INTEGER, outcome TEXT, summary TEXT, metadata TEXT,"
|
||||
" error TEXT)",
|
||||
(
|
||||
"CREATE INDEX idx_runs_task ON task_runs(task_id, started_at)",
|
||||
"CREATE INDEX idx_runs_status ON task_runs(status)",
|
||||
),
|
||||
),
|
||||
"kanban_notify_subs": (
|
||||
"CREATE TABLE kanban_notify_subs ("
|
||||
" task_id TEXT NOT NULL, platform TEXT NOT NULL, chat_id TEXT NOT NULL,"
|
||||
" thread_id TEXT NOT NULL DEFAULT '', user_id TEXT,"
|
||||
" notifier_profile TEXT, created_at INTEGER NOT NULL,"
|
||||
" last_event_id INTEGER NOT NULL DEFAULT 0,"
|
||||
" PRIMARY KEY (task_id, platform, chat_id, thread_id))",
|
||||
("CREATE INDEX idx_notify_task ON kanban_notify_subs(task_id)",),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _table_has_drifted(conn: sqlite3.Connection, table: str) -> bool:
|
||||
"""True when ``table`` still carries the legacy (pre-AUTOINCREMENT) shape."""
|
||||
info = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
if not info:
|
||||
return False # table absent — nothing to rebuild
|
||||
if table == "kanban_notify_subs":
|
||||
lei = next((c for c in info if c["name"] == "last_event_id"), None)
|
||||
return lei is not None and (lei["type"] or "").upper() != "INTEGER"
|
||||
# task_events / task_comments / task_runs: id must be INTEGER and a PK.
|
||||
id_col = next((c for c in info if c["name"] == "id"), None)
|
||||
if id_col is None:
|
||||
return False
|
||||
return not ((id_col["type"] or "").upper() == "INTEGER" and id_col["pk"])
|
||||
|
||||
|
||||
def _rebuild_drifted_tables(conn: sqlite3.Connection) -> None:
|
||||
"""Rebuild any kanban table whose column types drifted from SCHEMA_SQL.
|
||||
|
||||
Old boards crash the gateway notifier (``int(None)`` on a NULL id in
|
||||
``unseen_events_for_sub``) and never match the ``id > cursor`` filter, so
|
||||
every kanban notification is silently lost (#35096). Each affected table is
|
||||
rebuilt with the standard SQLite pattern — CREATE new → INSERT shared
|
||||
columns → DROP old → RENAME — recreating its indexes too (DROP TABLE takes
|
||||
them down). The legacy TEXT ids are dropped (they aren't valid integers);
|
||||
AUTOINCREMENT assigns fresh ones and ``last_event_id`` cursors reset to 0,
|
||||
so the first post-migration tick replays a task's event history once —
|
||||
the safe failure mode for a feature that was already fully broken.
|
||||
|
||||
The whole pass runs in one transaction so an interruption can't leave a
|
||||
table half-renamed, and under ``connect()``'s init locks so nothing races
|
||||
it. Idempotent: a correctly-typed DB skips every table and returns without
|
||||
opening a transaction.
|
||||
"""
|
||||
drifted = [t for t in _REBUILD_SPECS if _table_has_drifted(conn, t)]
|
||||
if not drifted:
|
||||
return
|
||||
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
for table in drifted:
|
||||
create_sql, index_sqls = _REBUILD_SPECS[table]
|
||||
old_cols = [c["name"] for c in conn.execute(f"PRAGMA table_info({table})")]
|
||||
_log.info("kanban migration: rebuilding %s to match current schema", table)
|
||||
conn.execute(f"ALTER TABLE {table} RENAME TO {table}_legacy")
|
||||
conn.execute(create_sql)
|
||||
new_cols = {c["name"] for c in conn.execute(f"PRAGMA table_info({table})")}
|
||||
if table == "kanban_notify_subs":
|
||||
# Cast the legacy TEXT cursor to INTEGER; NULL / non-numeric → 0.
|
||||
shared = [c for c in old_cols if c in new_cols and c != "last_event_id"]
|
||||
cols_csv = ", ".join(shared)
|
||||
conn.execute(
|
||||
f"INSERT INTO {table} ({cols_csv}, last_event_id) "
|
||||
f"SELECT {cols_csv}, COALESCE(CAST(last_event_id AS INTEGER), 0) "
|
||||
f"FROM {table}_legacy"
|
||||
)
|
||||
else:
|
||||
# Drop the legacy TEXT id; AUTOINCREMENT reassigns it.
|
||||
shared = [c for c in old_cols if c in new_cols and c != "id"]
|
||||
cols_csv = ", ".join(shared)
|
||||
conn.execute(
|
||||
f"INSERT INTO {table} ({cols_csv}) "
|
||||
f"SELECT {cols_csv} FROM {table}_legacy"
|
||||
)
|
||||
conn.execute(f"DROP TABLE {table}_legacy")
|
||||
for index_sql in index_sqls:
|
||||
conn.execute(index_sql)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute("ROLLBACK")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _check_file_length_invariant(conn: sqlite3.Connection) -> None:
|
||||
"""Read the SQLite header page_count and compare against actual file size.
|
||||
@@ -1766,6 +2010,8 @@ def create_task(
|
||||
max_runtime_seconds: Optional[int] = None,
|
||||
skills: Optional[Iterable[str]] = None,
|
||||
max_retries: Optional[int] = None,
|
||||
goal_mode: bool = False,
|
||||
goal_max_turns: Optional[int] = None,
|
||||
initial_status: str = "running",
|
||||
session_id: Optional[str] = None,
|
||||
board: Optional[str] = None,
|
||||
@@ -1933,8 +2179,8 @@ def create_task(
|
||||
id, title, body, assignee, status, priority,
|
||||
created_by, created_at, workspace_kind, workspace_path,
|
||||
branch_name, tenant, idempotency_key, max_runtime_seconds,
|
||||
skills, max_retries, session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
skills, max_retries, goal_mode, goal_max_turns, session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
@@ -1953,6 +2199,8 @@ def create_task(
|
||||
int(max_runtime_seconds) if max_runtime_seconds is not None else None,
|
||||
json.dumps(skills_list) if skills_list is not None else None,
|
||||
int(max_retries) if max_retries is not None else None,
|
||||
1 if goal_mode else 0,
|
||||
int(goal_max_turns) if goal_max_turns is not None else None,
|
||||
session_id,
|
||||
),
|
||||
)
|
||||
@@ -1972,6 +2220,7 @@ def create_task(
|
||||
"tenant": tenant,
|
||||
"branch_name": branch_name,
|
||||
"skills": list(skills_list) if skills_list else None,
|
||||
"goal_mode": bool(goal_mode) or None,
|
||||
},
|
||||
)
|
||||
return task_id
|
||||
@@ -2252,6 +2501,121 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_attachment(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
filename: str,
|
||||
stored_path: str,
|
||||
content_type: Optional[str] = None,
|
||||
size: int = 0,
|
||||
uploaded_by: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Record a file attachment for a task. Returns the new attachment id.
|
||||
|
||||
The caller is responsible for writing the blob to ``stored_path``
|
||||
first (under :func:`task_attachments_dir`); this only persists the
|
||||
metadata row and appends an ``attached`` event.
|
||||
"""
|
||||
if not filename or not filename.strip():
|
||||
raise ValueError("attachment filename is required")
|
||||
if not stored_path or not stored_path.strip():
|
||||
raise ValueError("attachment stored_path is required")
|
||||
now = int(time.time())
|
||||
with write_txn(conn):
|
||||
if not conn.execute(
|
||||
"SELECT 1 FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone():
|
||||
raise ValueError(f"unknown task {task_id}")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO task_attachments "
|
||||
"(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
task_id,
|
||||
filename.strip(),
|
||||
stored_path,
|
||||
content_type,
|
||||
int(size),
|
||||
uploaded_by,
|
||||
now,
|
||||
),
|
||||
)
|
||||
_append_event(
|
||||
conn,
|
||||
task_id,
|
||||
"attached",
|
||||
{"filename": filename.strip(), "size": int(size), "by": uploaded_by},
|
||||
)
|
||||
return int(cur.lastrowid or 0)
|
||||
|
||||
|
||||
def list_attachments(conn: sqlite3.Connection, task_id: str) -> list[Attachment]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at ASC, id ASC",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
Attachment(
|
||||
id=r["id"],
|
||||
task_id=r["task_id"],
|
||||
filename=r["filename"],
|
||||
stored_path=r["stored_path"],
|
||||
content_type=r["content_type"],
|
||||
size=r["size"] or 0,
|
||||
uploaded_by=r["uploaded_by"],
|
||||
created_at=r["created_at"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
|
||||
r = conn.execute(
|
||||
"SELECT * FROM task_attachments WHERE id = ?", (attachment_id,)
|
||||
).fetchone()
|
||||
if r is None:
|
||||
return None
|
||||
return Attachment(
|
||||
id=r["id"],
|
||||
task_id=r["task_id"],
|
||||
filename=r["filename"],
|
||||
stored_path=r["stored_path"],
|
||||
content_type=r["content_type"],
|
||||
size=r["size"] or 0,
|
||||
uploaded_by=r["uploaded_by"],
|
||||
created_at=r["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def delete_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
|
||||
"""Delete an attachment row and its on-disk blob. Returns the removed row.
|
||||
|
||||
Returns ``None`` when no row matched. The blob is removed best-effort
|
||||
(a missing file is not an error); the metadata row is the source of
|
||||
truth for whether an attachment "exists".
|
||||
"""
|
||||
with write_txn(conn):
|
||||
att = get_attachment(conn, attachment_id)
|
||||
if att is None:
|
||||
return None
|
||||
conn.execute("DELETE FROM task_attachments WHERE id = ?", (attachment_id,))
|
||||
_append_event(
|
||||
conn, att.task_id, "attachment_removed", {"filename": att.filename}
|
||||
)
|
||||
try:
|
||||
p = Path(att.stored_path)
|
||||
if p.is_file():
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return att
|
||||
|
||||
|
||||
def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASC",
|
||||
@@ -2457,7 +2821,9 @@ def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool:
|
||||
return bool(row) and row["kind"] == "blocked"
|
||||
|
||||
|
||||
def recompute_ready(conn: sqlite3.Connection) -> int:
|
||||
def recompute_ready(
|
||||
conn: sqlite3.Connection, failure_limit: int = None,
|
||||
) -> int:
|
||||
"""Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.
|
||||
|
||||
Returns the number of tasks promoted. Safe to call inside or outside
|
||||
@@ -2465,17 +2831,34 @@ def recompute_ready(conn: sqlite3.Connection) -> int:
|
||||
|
||||
``blocked`` tasks are also considered for promotion (so a task
|
||||
blocked purely by a parent dependency unblocks itself when the
|
||||
parent completes), *except* when the most recent block event was a
|
||||
worker-initiated ``kanban_block`` — those stay blocked until an
|
||||
explicit ``kanban_unblock`` (#28712). Without that guard, a
|
||||
``review-required`` handoff would auto-respawn, the fresh worker
|
||||
would find nothing to do, exit cleanly, get recorded as a protocol
|
||||
violation, and the cycle would repeat indefinitely.
|
||||
parent completes), *except* in two cases:
|
||||
|
||||
1. The most recent block event was a worker-initiated
|
||||
``kanban_block`` — those stay blocked until an explicit
|
||||
``kanban_unblock`` (#28712).
|
||||
|
||||
2. The task's ``consecutive_failures`` has reached the effective
|
||||
failure limit. This prevents infinite retry loops when a task
|
||||
repeatedly exhausts its iteration budget: without this guard the
|
||||
counter would reset on every recovery cycle and the circuit
|
||||
breaker could never trip (#35072).
|
||||
|
||||
The effective failure limit resolves in the same order as the
|
||||
circuit breaker in ``_record_task_failure`` so the two never
|
||||
disagree about when a task is permanently blocked:
|
||||
|
||||
1. per-task ``max_retries`` if set
|
||||
2. caller-supplied ``failure_limit`` (the dispatcher passes the
|
||||
``kanban.failure_limit`` config value through ``dispatch_once``)
|
||||
3. ``DEFAULT_FAILURE_LIMIT``
|
||||
"""
|
||||
if failure_limit is None:
|
||||
failure_limit = DEFAULT_FAILURE_LIMIT
|
||||
promoted = 0
|
||||
with write_txn(conn):
|
||||
todo_rows = conn.execute(
|
||||
"SELECT id, status FROM tasks WHERE status IN ('todo', 'blocked')"
|
||||
"SELECT id, status, consecutive_failures, max_retries "
|
||||
"FROM tasks WHERE status IN ('todo', 'blocked')"
|
||||
).fetchall()
|
||||
for row in todo_rows:
|
||||
task_id = row["id"]
|
||||
@@ -2493,13 +2876,25 @@ def recompute_ready(conn: sqlite3.Connection) -> int:
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
if all(p["status"] in ("done", "archived") for p in parents):
|
||||
# Blocked tasks also get their failure counters reset —
|
||||
# this is effectively an auto-unblock (circuit-breaker
|
||||
# recovery; worker-initiated blocks are skipped above).
|
||||
if cur_status == "blocked":
|
||||
# Don't auto-recover tasks that have hit the
|
||||
# circuit-breaker failure limit. Without this
|
||||
# guard, a task that repeatedly exhausts its
|
||||
# iteration budget would cycle forever:
|
||||
# block → auto-recover → respawn → budget
|
||||
# exhausted → block → … The counter must also
|
||||
# be preserved so the breaker can accumulate
|
||||
# across recovery cycles.
|
||||
failures = int(row["consecutive_failures"] or 0)
|
||||
task_limit = row["max_retries"]
|
||||
effective_limit = (
|
||||
int(task_limit) if task_limit is not None
|
||||
else int(failure_limit)
|
||||
)
|
||||
if failures >= effective_limit:
|
||||
continue
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', "
|
||||
"consecutive_failures = 0, last_failure_error = NULL "
|
||||
"UPDATE tasks SET status = 'ready' "
|
||||
"WHERE id = ? AND status = 'blocked'",
|
||||
(task_id,),
|
||||
)
|
||||
@@ -5424,7 +5819,7 @@ def dispatch_once(
|
||||
if _crash_auto_blocked:
|
||||
result.auto_blocked.extend(_crash_auto_blocked)
|
||||
result.timed_out = enforce_max_runtime(conn)
|
||||
result.promoted = recompute_ready(conn)
|
||||
result.promoted = recompute_ready(conn, failure_limit=failure_limit)
|
||||
|
||||
# Count tasks already running so max_spawn enforces concurrency rather
|
||||
# than a per-tick spawn budget. See the docstring above for the full
|
||||
@@ -6065,6 +6460,13 @@ def _default_spawn(
|
||||
env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id)
|
||||
if task.claim_lock:
|
||||
env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock
|
||||
# Goal-loop mode: the worker reads these and wraps its run in the
|
||||
# Ralph-style /goal judge loop (see cli.py quiet-mode path). Only set
|
||||
# when enabled so non-goal tasks keep a clean env.
|
||||
if task.goal_mode:
|
||||
env["HERMES_KANBAN_GOAL_MODE"] = "1"
|
||||
if task.goal_max_turns is not None:
|
||||
env["HERMES_KANBAN_GOAL_MAX_TURNS"] = str(int(task.goal_max_turns))
|
||||
terminal_timeout = _worker_terminal_timeout_env(
|
||||
task.max_runtime_seconds,
|
||||
env.get("TERMINAL_TIMEOUT"),
|
||||
@@ -6300,6 +6702,25 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str:
|
||||
lines.append(_cap(task.body, _CTX_MAX_BODY_BYTES))
|
||||
lines.append("")
|
||||
|
||||
# Attachments — files uploaded to this task (PDFs, source docs,
|
||||
# images). Surface the absolute on-disk path so the worker, which has
|
||||
# full file-tool access, can read them directly (read_file, terminal
|
||||
# `pdftotext`, etc.). On the local terminal backend the path resolves
|
||||
# as-is; remote backends need the kanban attachments dir mounted.
|
||||
attachments = list_attachments(conn, task_id)
|
||||
if attachments:
|
||||
lines.append("## Attachments")
|
||||
lines.append(
|
||||
"Files attached to this task. Read them with the file/terminal "
|
||||
"tools at the absolute paths below:"
|
||||
)
|
||||
for att in attachments:
|
||||
size_kb = max(1, (att.size + 1023) // 1024) if att.size else 0
|
||||
size_str = f", {size_kb} KB" if size_kb else ""
|
||||
ctype = f", {att.content_type}" if att.content_type else ""
|
||||
lines.append(f"- `{att.filename}`{ctype}{size_str} → `{att.stored_path}`")
|
||||
lines.append("")
|
||||
|
||||
# Prior attempts — show closed runs so a retrying worker sees the
|
||||
# history. Skip the currently-active run (that's this worker).
|
||||
# Cap at _CTX_MAX_PRIOR_ATTEMPTS most-recent closed runs; older
|
||||
|
||||
+372
-114
@@ -65,6 +65,46 @@ import os
|
||||
import sys
|
||||
|
||||
|
||||
def _set_process_title() -> None:
|
||||
"""Set the process title to 'hermes' so tools like 'ps', 'top', and
|
||||
'htop' show the app name instead of 'python3.xx'.
|
||||
|
||||
Purely cosmetic — non-fatal on any platform.
|
||||
|
||||
Strategy (try in order):
|
||||
1. ``setproctitle`` (opt-in dep — installed via ``hermes tools`` or
|
||||
``pip install setproctitle``, or bundled in a future release).
|
||||
2. ctypes ``prctl(PR_SET_NAME)`` (Linux only, 15-char limit).
|
||||
3. ctypes ``pthread_setname_np`` (macOS only, kernel thread name —
|
||||
changes lldb/top but not ``ps aux``).
|
||||
4. No-op on Windows (the .exe name is already ``hermes.exe``).
|
||||
"""
|
||||
# Strategy 1: setproctitle (best — works on macOS, Linux, BSD)
|
||||
try:
|
||||
import setproctitle # type: ignore[import-untyped]
|
||||
|
||||
setproctitle.setproctitle("hermes")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Strategy 2/3: platform-specific ctypes fallback
|
||||
import ctypes
|
||||
import platform
|
||||
|
||||
try:
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
||||
libc.prctl(15, b"hermes", 0, 0, 0) # PR_SET_NAME = 15
|
||||
elif system == "Darwin":
|
||||
libc = ctypes.CDLL("libc.dylib", use_errno=True)
|
||||
libc.pthread_setname_np(b"hermes")
|
||||
# Windows: the .exe name is already ``hermes.exe`` — nothing to do.
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Mouse-tracking residue suppression — runs BEFORE every other import on the
|
||||
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
|
||||
# Python launcher is still doing imports (≈100–300ms in cooked + echo mode,
|
||||
@@ -2354,7 +2394,12 @@ def select_provider_and_model(args=None):
|
||||
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
|
||||
active = "custom"
|
||||
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
|
||||
from hermes_cli.models import (
|
||||
CANONICAL_PROVIDERS,
|
||||
_PROVIDER_LABELS,
|
||||
group_providers,
|
||||
provider_group_for_slug,
|
||||
)
|
||||
|
||||
provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list
|
||||
if active and active in _custom_provider_map:
|
||||
@@ -2367,8 +2412,43 @@ def select_provider_and_model(args=None):
|
||||
print(f" Active provider: {active_label}")
|
||||
print()
|
||||
|
||||
# Step 1: Provider selection — flat list from CANONICAL_PROVIDERS
|
||||
all_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS]
|
||||
# Step 1: Provider selection.
|
||||
#
|
||||
# Canonical providers are folded into top-level groups (display only — see
|
||||
# PROVIDER_GROUPS in hermes_cli/models.py). A multi-member group shows one
|
||||
# row ("Kimi / Moonshot ▸"); picking it opens a member sub-picker that
|
||||
# resolves back to a concrete slug, so the dispatch chain below is
|
||||
# unchanged. Custom providers and the trailing actions stay flat.
|
||||
canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS}
|
||||
grouped_rows = group_providers([p.slug for p in CANONICAL_PROVIDERS])
|
||||
|
||||
# The group/slug that should be pre-selected: the active provider's group
|
||||
# if it's grouped, otherwise the active slug itself.
|
||||
active_group = provider_group_for_slug(active) if active else ""
|
||||
|
||||
# ordered entries: (key, label, members)
|
||||
# members == [] → leaf row, key is a provider slug / action
|
||||
# members != [] → group row, key is "group:<gid>"
|
||||
ordered: list[tuple[str, str, list[str]]] = []
|
||||
default_idx = 0
|
||||
for row in grouped_rows:
|
||||
if row["kind"] == "group":
|
||||
gid = row["group_id"]
|
||||
label = f"{row['label']} ▸"
|
||||
key = f"group:{gid}"
|
||||
is_active = bool(active_group) and gid == active_group
|
||||
members = row["members"]
|
||||
else:
|
||||
slug = row["slug"]
|
||||
label = canonical_descs.get(slug, provider_labels.get(slug, slug))
|
||||
key = slug
|
||||
is_active = bool(active) and slug == active
|
||||
members = []
|
||||
if is_active:
|
||||
ordered.append((key, f"{label} ← currently active", members))
|
||||
default_idx = len(ordered) - 1
|
||||
else:
|
||||
ordered.append((key, label, members))
|
||||
|
||||
for key, provider_info in _custom_provider_map.items():
|
||||
name = provider_info["name"]
|
||||
@@ -2376,36 +2456,49 @@ def select_provider_and_model(args=None):
|
||||
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
saved_model = provider_info.get("model", "")
|
||||
model_hint = f" — {saved_model}" if saved_model else ""
|
||||
all_providers.append((key, f"{name} ({short_url}){model_hint}"))
|
||||
|
||||
# Build the menu
|
||||
ordered = []
|
||||
default_idx = 0
|
||||
for key, label in all_providers:
|
||||
label = f"{name} ({short_url}){model_hint}"
|
||||
if active and key == active:
|
||||
ordered.append((key, f"{label} ← currently active"))
|
||||
ordered.append((key, f"{label} ← currently active", []))
|
||||
default_idx = len(ordered) - 1
|
||||
else:
|
||||
ordered.append((key, label))
|
||||
ordered.append((key, label, []))
|
||||
|
||||
ordered.append(("custom", "Custom endpoint (enter URL manually)"))
|
||||
ordered.append(("custom", "Custom endpoint (enter URL manually)", []))
|
||||
_has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(
|
||||
config.get("custom_providers")
|
||||
)
|
||||
if _has_saved_custom_list:
|
||||
ordered.append(("remove-custom", "Remove a saved custom provider"))
|
||||
ordered.append(("aux-config", "Configure auxiliary models..."))
|
||||
ordered.append(("cancel", "Leave unchanged"))
|
||||
ordered.append(("remove-custom", "Remove a saved custom provider", []))
|
||||
ordered.append(("aux-config", "Configure auxiliary models...", []))
|
||||
ordered.append(("cancel", "Leave unchanged", []))
|
||||
|
||||
provider_idx = _prompt_provider_choice(
|
||||
[label for _, label in ordered],
|
||||
[label for _, label, _ in ordered],
|
||||
default=default_idx,
|
||||
)
|
||||
if provider_idx is None or ordered[provider_idx][0] == "cancel":
|
||||
print("No change.")
|
||||
return
|
||||
|
||||
selected_provider = ordered[provider_idx][0]
|
||||
selected_key = ordered[provider_idx][0]
|
||||
selected_members = ordered[provider_idx][2]
|
||||
|
||||
# Group row → drill into a member sub-picker. Default to the active member
|
||||
# if the active provider lives in this group.
|
||||
if selected_members:
|
||||
member_default = 0
|
||||
if active in selected_members:
|
||||
member_default = selected_members.index(active)
|
||||
member_labels = [
|
||||
canonical_descs.get(m, provider_labels.get(m, m)) for m in selected_members
|
||||
]
|
||||
member_idx = _prompt_provider_choice(member_labels, default=member_default)
|
||||
if member_idx is None:
|
||||
print("No change.")
|
||||
return
|
||||
selected_provider = selected_members[member_idx]
|
||||
else:
|
||||
selected_provider = selected_key
|
||||
|
||||
if selected_provider == "aux-config":
|
||||
_aux_config_menu()
|
||||
@@ -4362,23 +4455,17 @@ def _remove_custom_provider(config):
|
||||
choices.append("Cancel")
|
||||
|
||||
try:
|
||||
from simple_term_menu import TerminalMenu
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
|
||||
menu = TerminalMenu(
|
||||
[f" {c}" for c in choices],
|
||||
cursor_index=0,
|
||||
menu_cursor="-> ",
|
||||
menu_cursor_style=("fg_red", "bold"),
|
||||
menu_highlight_style=("fg_red",),
|
||||
cycle_cursor=True,
|
||||
clear_screen=False,
|
||||
title="Select provider to remove:",
|
||||
idx = curses_radiolist(
|
||||
"Select provider to remove:",
|
||||
list(choices),
|
||||
selected=0,
|
||||
cancel_returns=-1,
|
||||
)
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
|
||||
flush_stdin()
|
||||
print()
|
||||
if idx < 0:
|
||||
idx = None
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
for i, c in enumerate(choices, 1):
|
||||
print(f" {i}. {c}")
|
||||
@@ -4445,27 +4532,19 @@ def _model_flow_named_custom(config, provider_info):
|
||||
|
||||
print(f"Found {len(models)} model(s):\n")
|
||||
try:
|
||||
from simple_term_menu import TerminalMenu
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
|
||||
menu_items = [
|
||||
f" {m} (current)" if m == saved_model else f" {m}" for m in models
|
||||
] + [" Cancel"]
|
||||
menu = TerminalMenu(
|
||||
f"{m} (current)" if m == saved_model else m for m in models
|
||||
] + ["Cancel"]
|
||||
idx = curses_radiolist(
|
||||
f"Select model from {name}:",
|
||||
menu_items,
|
||||
cursor_index=default_idx,
|
||||
menu_cursor="-> ",
|
||||
menu_cursor_style=("fg_green", "bold"),
|
||||
menu_highlight_style=("fg_green",),
|
||||
cycle_cursor=True,
|
||||
clear_screen=False,
|
||||
title=f"Select model from {name}:",
|
||||
selected=default_idx,
|
||||
cancel_returns=-1,
|
||||
)
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
|
||||
flush_stdin()
|
||||
print()
|
||||
if idx is None or idx >= len(models):
|
||||
if idx < 0 or idx >= len(models):
|
||||
print("Cancelled.")
|
||||
return
|
||||
model_name = models[idx]
|
||||
@@ -4642,26 +4721,18 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""):
|
||||
default_idx = 0
|
||||
|
||||
try:
|
||||
from simple_term_menu import TerminalMenu
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
|
||||
choices = [f" {_label(effort)}" for effort in ordered]
|
||||
choices.append(f" {disable_label}")
|
||||
choices.append(f" {skip_label}")
|
||||
menu = TerminalMenu(
|
||||
choices = [_label(effort) for effort in ordered]
|
||||
choices.append(disable_label)
|
||||
choices.append(skip_label)
|
||||
idx = curses_radiolist(
|
||||
"Select reasoning effort:",
|
||||
choices,
|
||||
cursor_index=default_idx,
|
||||
menu_cursor="-> ",
|
||||
menu_cursor_style=("fg_green", "bold"),
|
||||
menu_highlight_style=("fg_green",),
|
||||
cycle_cursor=True,
|
||||
clear_screen=False,
|
||||
title="Select reasoning effort:",
|
||||
selected=default_idx,
|
||||
cancel_returns=-1,
|
||||
)
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
|
||||
flush_stdin()
|
||||
if idx is None:
|
||||
if idx < 0:
|
||||
return None
|
||||
print()
|
||||
if idx < len(ordered):
|
||||
@@ -7840,39 +7911,6 @@ def _detect_concurrent_hermes_instances(
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# Build a set of PIDs to exclude: the Python process itself plus its
|
||||
# entire parent chain. On Windows the setuptools-generated hermes.exe
|
||||
# launcher is a separate native process that spawns python.exe (the
|
||||
# interpreter that runs our code). os.getpid() returns the Python PID,
|
||||
# but the launcher (which holds the file lock) is the parent. Without
|
||||
# walking the parent chain, every ``hermes update`` reports its own
|
||||
# launcher as a concurrent instance — a false positive.
|
||||
if exclude_pid is not None:
|
||||
exclude_pids: set[int] = {exclude_pid}
|
||||
else:
|
||||
exclude_pids = {os.getpid()}
|
||||
# The parent-walk is best-effort: if psutil rejects a PID (NoSuchProcess /
|
||||
# AccessDenied) we stop walking and use whatever we've collected so far.
|
||||
# Broader Exception catch on the outer block guards against partially-
|
||||
# stubbed psutil in unit tests (e.g. a SimpleNamespace lacking Process /
|
||||
# NoSuchProcess) — the surrounding update flow documents this helper as
|
||||
# "never raises".
|
||||
try:
|
||||
current = psutil.Process(next(iter(exclude_pids)))
|
||||
while True:
|
||||
try:
|
||||
parent = current.parent()
|
||||
except Exception:
|
||||
break
|
||||
if parent is None or parent.pid <= 0:
|
||||
break
|
||||
if parent.pid in exclude_pids:
|
||||
break # loop detected
|
||||
exclude_pids.add(parent.pid)
|
||||
current = parent
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Resolve every shim path to its canonical form once for cheap comparison.
|
||||
shim_paths: set[str] = set()
|
||||
for shim in _hermes_exe_shims(scripts_dir):
|
||||
@@ -7883,6 +7921,56 @@ def _detect_concurrent_hermes_instances(
|
||||
if not shim_paths:
|
||||
return []
|
||||
|
||||
# Build a set of PIDs to exclude: the Python process itself plus every
|
||||
# ancestor whose executable is one of our shims. On Windows the
|
||||
# setuptools-generated hermes.exe launcher is a separate native process
|
||||
# that spawns python.exe (the interpreter that runs our code).
|
||||
# os.getpid() returns the Python PID, but the launcher (which holds the
|
||||
# file lock) is the parent. Without excluding it, every ``hermes update``
|
||||
# reports its own launcher as a concurrent instance — a false positive
|
||||
# (issues #29341, #34795).
|
||||
#
|
||||
# Two robustness points learned from the field:
|
||||
# 1. Use ``proc.parents()`` — it returns the WHOLE ancestor list in one
|
||||
# call. The earlier per-hop ``current.parent()`` loop bailed on the
|
||||
# first psutil error (AccessDenied/NoSuchProcess is common on Windows
|
||||
# across session/elevation boundaries), leaving the launcher shim in
|
||||
# the candidate set and re-triggering the false positive.
|
||||
# 2. Only exclude ancestors whose exe is itself a shim. A genuine second
|
||||
# hermes.exe sitting *under* a non-Hermes parent (e.g. a Hermes
|
||||
# Desktop backend child) must still be flagged, so we don't blanket-
|
||||
# exclude unrelated ancestors like the shell or terminal.
|
||||
# Broad ``except Exception`` guards against partially-stubbed psutil in
|
||||
# unit tests; this helper is documented as "never raises".
|
||||
if exclude_pid is not None:
|
||||
exclude_pids: set[int] = {int(exclude_pid)}
|
||||
else:
|
||||
exclude_pids = {os.getpid()}
|
||||
try:
|
||||
seed = next(iter(exclude_pids))
|
||||
try:
|
||||
ancestors = psutil.Process(seed).parents()
|
||||
except Exception:
|
||||
ancestors = []
|
||||
for ancestor in ancestors:
|
||||
try:
|
||||
anc_exe = ancestor.exe()
|
||||
except Exception:
|
||||
continue
|
||||
if not anc_exe:
|
||||
continue
|
||||
try:
|
||||
anc_norm = str(Path(anc_exe).resolve()).lower()
|
||||
except (OSError, ValueError):
|
||||
anc_norm = str(anc_exe).lower()
|
||||
if anc_norm in shim_paths:
|
||||
try:
|
||||
exclude_pids.add(int(ancestor.pid))
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
matches: list[tuple[int, str]] = []
|
||||
try:
|
||||
proc_iter = psutil.process_iter(["pid", "exe", "name"])
|
||||
@@ -7923,6 +8011,13 @@ def _format_concurrent_instances_message(
|
||||
lines.append("")
|
||||
lines.append(" Close Hermes Desktop, exit any open `hermes` REPLs, and")
|
||||
lines.append(" stop the gateway (`hermes gateway stop`) before retrying.")
|
||||
lines.append("")
|
||||
if matches:
|
||||
pid_args = " ".join(f"/PID {pid}" for pid, _ in matches)
|
||||
lines.append(" If you've already closed everything and these PIDs are")
|
||||
lines.append(" stale, terminate them directly, then retry the update:")
|
||||
lines.append(f" taskkill {pid_args} /F")
|
||||
lines.append("")
|
||||
lines.append(" Override with `hermes update --force` if you've already")
|
||||
lines.append(" confirmed those processes will not write to the venv.")
|
||||
return "\n".join(lines)
|
||||
@@ -8878,18 +8973,51 @@ def cmd_update(args):
|
||||
def _cmd_update_pip(args):
|
||||
"""Update Hermes via pip (for PyPI installs)."""
|
||||
from hermes_cli import __version__
|
||||
from hermes_cli.config import is_uv_tool_install
|
||||
|
||||
print(f"→ Current version: {__version__}")
|
||||
print("→ Checking PyPI for updates...")
|
||||
|
||||
uv = shutil.which("uv")
|
||||
if uv:
|
||||
in_venv = sys.prefix != sys.base_prefix
|
||||
# pipx-managed installs live under .../pipx/venvs/<name>/...
|
||||
pipx_managed = "pipx" in sys.prefix.split(os.sep)
|
||||
pipx = shutil.which("pipx") if pipx_managed else None
|
||||
|
||||
# Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV
|
||||
# exported (uv refuses to install without it when the launcher shim
|
||||
# didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade``
|
||||
# operate on a named environment and ignore VIRTUAL_ENV, so we don't
|
||||
# set it for them.
|
||||
export_virtualenv = False
|
||||
|
||||
if is_uv_tool_install():
|
||||
if not uv:
|
||||
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
|
||||
sys.exit(1)
|
||||
cmd = [uv, "tool", "upgrade", "hermes-agent"]
|
||||
elif pipx_managed and pipx:
|
||||
# pipx owns its own venv; ``pipx upgrade`` is the only correct path.
|
||||
# Matches scripts/auto-update.sh, which already uses pipx upgrade.
|
||||
cmd = [pipx, "upgrade", "hermes-agent"]
|
||||
elif uv:
|
||||
cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"]
|
||||
if in_venv:
|
||||
# Launcher shim runs the venv interpreter but doesn't export
|
||||
# VIRTUAL_ENV; without it uv errors "No virtual environment found".
|
||||
export_virtualenv = True
|
||||
else:
|
||||
# Outside any venv, ``--system`` lets uv target the active
|
||||
# interpreter, matching pip's default behaviour.
|
||||
cmd.insert(3, "--system")
|
||||
else:
|
||||
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"]
|
||||
|
||||
print(f"→ Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd)
|
||||
run_kwargs = {}
|
||||
if export_virtualenv:
|
||||
run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix}
|
||||
result = subprocess.run(cmd, **run_kwargs)
|
||||
if result.returncode != 0:
|
||||
print("✗ Update failed")
|
||||
sys.exit(1)
|
||||
@@ -9407,16 +9535,61 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
missing_config = get_missing_config_fields()
|
||||
current_ver, latest_ver = check_config_version()
|
||||
|
||||
needs_migration = missing_env or missing_config or current_ver < latest_ver
|
||||
has_new_options = bool(missing_env or missing_config)
|
||||
version_bump_only = (
|
||||
not has_new_options and current_ver < latest_ver
|
||||
)
|
||||
needs_migration = has_new_options or current_ver < latest_ver
|
||||
|
||||
if needs_migration:
|
||||
if version_bump_only:
|
||||
# Nothing for the user to fill in — only the config format version
|
||||
# changed (new defaults already merge in transparently). Asking
|
||||
# "configure new options now?" here is misleading: saying yes just
|
||||
# bumps the version and looks like a no-op (issue: ScottFive /
|
||||
# Tt2021). Apply it silently and say what actually happened.
|
||||
print()
|
||||
print(
|
||||
f" ℹ Updating config format (v{current_ver} → v{latest_ver})…"
|
||||
)
|
||||
try:
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
print(" ✓ Config format updated (no new settings to configure)")
|
||||
except Exception as _mig_err:
|
||||
print(f" ⚠️ Config format update failed: {_mig_err}")
|
||||
print(" Run 'hermes config migrate' to retry.")
|
||||
elif needs_migration:
|
||||
print()
|
||||
# Show WHAT changed, not just a count, so the user can make an
|
||||
# informed yes/no decision (previously the prompt named nothing).
|
||||
def _print_items(items, label, key, fallback_key=None):
|
||||
if not items:
|
||||
return
|
||||
print(f" {label}:")
|
||||
shown = items[:8]
|
||||
for it in shown:
|
||||
if isinstance(it, dict):
|
||||
name = it.get(key) or (fallback_key and it.get(fallback_key)) or "?"
|
||||
desc = (it.get("description") or "").strip()
|
||||
else:
|
||||
# Defensive: some callers/mocks pass bare name strings.
|
||||
name = str(it)
|
||||
desc = ""
|
||||
if desc:
|
||||
print(f" • {name} — {desc}")
|
||||
else:
|
||||
print(f" • {name}")
|
||||
extra = len(items) - len(shown)
|
||||
if extra > 0:
|
||||
print(f" … and {extra} more")
|
||||
|
||||
if missing_env:
|
||||
print(
|
||||
f" ⚠️ {len(missing_env)} new required setting(s) need configuration"
|
||||
)
|
||||
_print_items(missing_env, "New settings", "name")
|
||||
if missing_config:
|
||||
print(f" ℹ️ {len(missing_config)} new config option(s) available")
|
||||
_print_items(missing_config, "New options", "key")
|
||||
|
||||
print()
|
||||
if assume_yes:
|
||||
@@ -10968,6 +11141,13 @@ def cmd_completion(args, parser=None):
|
||||
print(generate_bash(parser))
|
||||
|
||||
|
||||
def cmd_prompt_size(args):
|
||||
"""Show a byte/char breakdown of the system prompt + tool schemas."""
|
||||
from hermes_cli.prompt_size import cmd_prompt_size as _impl
|
||||
|
||||
_impl(args)
|
||||
|
||||
|
||||
def cmd_logs(args):
|
||||
"""View and filter Hermes log files."""
|
||||
from hermes_cli.logs import tail_log, list_logs
|
||||
@@ -11004,6 +11184,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
||||
"dump", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
|
||||
"model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy",
|
||||
"prompt-size",
|
||||
"send", "sessions", "setup",
|
||||
"skills", "slack", "status", "tools", "uninstall", "update",
|
||||
"version", "webhook", "whatsapp", "chat", "secrets", "security",
|
||||
@@ -11104,6 +11285,26 @@ _AGENT_SUBCOMMANDS = {
|
||||
}
|
||||
|
||||
|
||||
def _is_tui_chat_launch(args) -> bool:
|
||||
return bool(getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1")
|
||||
|
||||
|
||||
def _command_has_dedicated_mcp_startup(args) -> bool:
|
||||
if args.command == "acp":
|
||||
return True
|
||||
if args.command == "gateway" and getattr(args, "gateway_command", None) == "run":
|
||||
return True
|
||||
if args.command == "cron" and getattr(args, "cron_command", None) in {"run", "tick"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _should_background_mcp_startup(args) -> bool:
|
||||
if _is_tui_chat_launch(args):
|
||||
return False
|
||||
return args.command in {None, "chat", "rl"}
|
||||
|
||||
|
||||
def _prepare_agent_startup(args) -> None:
|
||||
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
|
||||
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
|
||||
@@ -11123,19 +11324,42 @@ def _prepare_agent_startup(args) -> None:
|
||||
"plugin discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
# MCP tool discovery — no event loop running in CLI/TUI startup,
|
||||
# so inline is safe. Moved here from model_tools.py module scope
|
||||
# to avoid freezing the gateway's event loop on its first message
|
||||
# via the same lazy import path (#16856).
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
_run_inline_mcp_discovery = True
|
||||
if _is_tui_chat_launch(args):
|
||||
# The TUI launcher hands off to a dedicated startup path that already
|
||||
# backgrounds MCP discovery with a bounded join before the first tool
|
||||
# snapshot.
|
||||
_run_inline_mcp_discovery = False
|
||||
elif _command_has_dedicated_mcp_startup(args):
|
||||
# These entrypoints already do their own MCP startup later on the real
|
||||
# runtime path (gateway executor, ACP launcher, cron job runner).
|
||||
_run_inline_mcp_discovery = False
|
||||
elif _should_background_mcp_startup(args):
|
||||
try:
|
||||
from hermes_cli.mcp_startup import start_background_mcp_discovery
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
start_background_mcp_discovery(
|
||||
logger=logger,
|
||||
thread_name="cli-mcp-discovery",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Background MCP tool discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
_run_inline_mcp_discovery = False
|
||||
if _run_inline_mcp_discovery:
|
||||
try:
|
||||
# MCP tool discovery remains synchronous for entrypoints that do
|
||||
# not own a later bounded/executor startup path.
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from agent.shell_hooks import register_from_config
|
||||
@@ -11276,6 +11500,10 @@ def _try_termux_fast_tui_launch() -> bool:
|
||||
|
||||
def main():
|
||||
"""Main entry point for hermes CLI."""
|
||||
# Cosmetic: make the process show up as 'hermes' instead of 'python3.11'
|
||||
# in ps/top/htop. Non-fatal — just a nicer UX.
|
||||
_set_process_title()
|
||||
|
||||
# Force UTF-8 stdio on Windows before anything prints. No-op elsewhere.
|
||||
try:
|
||||
from hermes_cli.stdio import configure_windows_stdio
|
||||
@@ -13029,9 +13257,15 @@ Examples:
|
||||
),
|
||||
)
|
||||
memory_sub = memory_parser.add_subparsers(dest="memory_command")
|
||||
memory_sub.add_parser(
|
||||
_setup_parser = memory_sub.add_parser(
|
||||
"setup", help="Interactive provider selection and configuration"
|
||||
)
|
||||
_setup_parser.add_argument(
|
||||
"provider",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Provider to configure directly (e.g. honcho), skipping the picker",
|
||||
)
|
||||
memory_sub.add_parser("status", help="Show current memory provider config")
|
||||
memory_sub.add_parser("off", help="Disable external provider (built-in only)")
|
||||
_reset_parser = memory_sub.add_parser(
|
||||
@@ -14227,6 +14461,30 @@ Examples:
|
||||
)
|
||||
logs_parser.set_defaults(func=cmd_logs)
|
||||
|
||||
# =========================================================================
|
||||
# prompt-size command
|
||||
# =========================================================================
|
||||
prompt_size_parser = subparsers.add_parser(
|
||||
"prompt-size",
|
||||
help="Show a byte breakdown of the system prompt + tool schemas",
|
||||
description=(
|
||||
"Report the fixed prompt budget for a fresh session: system "
|
||||
"prompt total, skills index, memory, user profile, and tool-schema "
|
||||
"JSON. Runs offline (no API call)."
|
||||
),
|
||||
)
|
||||
prompt_size_parser.add_argument(
|
||||
"--platform",
|
||||
default="cli",
|
||||
help="Platform to simulate (cli, telegram, discord, ...). Default: cli",
|
||||
)
|
||||
prompt_size_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Emit the breakdown as JSON",
|
||||
)
|
||||
prompt_size_parser.set_defaults(func=cmd_prompt_size)
|
||||
|
||||
# =========================================================================
|
||||
# Parse and execute
|
||||
# =========================================================================
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Shared CLI/TUI-safe helpers for background MCP discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
_mcp_discovery_lock = threading.Lock()
|
||||
_mcp_discovery_started = False
|
||||
_mcp_discovery_thread: Optional[threading.Thread] = None
|
||||
|
||||
|
||||
def _has_configured_mcp_servers() -> bool:
|
||||
"""Cheap config probe so non-MCP users avoid importing the MCP stack."""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
mcp_servers = (read_raw_config() or {}).get("mcp_servers")
|
||||
return isinstance(mcp_servers, dict) and len(mcp_servers) > 0
|
||||
except Exception:
|
||||
# Be conservative: if config probing fails, try discovery in the
|
||||
# background so startup still can't block.
|
||||
return True
|
||||
|
||||
|
||||
def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
|
||||
"""Spawn one shared background MCP discovery thread for this process."""
|
||||
global _mcp_discovery_started, _mcp_discovery_thread
|
||||
|
||||
with _mcp_discovery_lock:
|
||||
if _mcp_discovery_started:
|
||||
return
|
||||
_mcp_discovery_started = True
|
||||
if not _has_configured_mcp_servers():
|
||||
return
|
||||
|
||||
def _discover() -> None:
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug("Background MCP tool discovery failed", exc_info=True)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_discover,
|
||||
name=thread_name,
|
||||
daemon=True,
|
||||
)
|
||||
_mcp_discovery_thread = thread
|
||||
thread.start()
|
||||
|
||||
|
||||
def wait_for_mcp_discovery(timeout: float = 0.75) -> None:
|
||||
"""Briefly wait for background MCP discovery before the first tool snapshot."""
|
||||
thread = _mcp_discovery_thread
|
||||
if thread is None or not thread.is_alive():
|
||||
return
|
||||
thread.join(timeout=timeout)
|
||||
@@ -452,7 +452,11 @@ def memory_command(args) -> None:
|
||||
"""Route memory subcommands."""
|
||||
sub = getattr(args, "memory_command", None)
|
||||
if sub == "setup":
|
||||
cmd_setup(args)
|
||||
provider = getattr(args, "provider", None)
|
||||
if provider:
|
||||
cmd_setup_provider(provider)
|
||||
else:
|
||||
cmd_setup(args)
|
||||
elif sub == "status":
|
||||
cmd_status(args)
|
||||
else:
|
||||
|
||||
@@ -73,7 +73,7 @@ DEFAULT_CATALOG_URL = (
|
||||
DEFAULT_CATALOG_FALLBACK_URLS: tuple[str, ...] = (
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/api/model-catalog.json",
|
||||
)
|
||||
DEFAULT_TTL_HOURS = 24
|
||||
DEFAULT_TTL_HOURS = 1
|
||||
DEFAULT_FETCH_TIMEOUT = 8.0
|
||||
SUPPORTED_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
+150
-33
@@ -32,34 +32,43 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
|
||||
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
|
||||
# (model_id, display description shown in menus)
|
||||
OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
# Anthropic
|
||||
("anthropic/claude-opus-4.8", ""),
|
||||
("anthropic/claude-opus-4.8-fast", "2x price, higher output speed"),
|
||||
("anthropic/claude-opus-4.7", ""),
|
||||
("anthropic/claude-opus-4.6", ""),
|
||||
("anthropic/claude-sonnet-4.6", ""),
|
||||
("moonshotai/kimi-k2.6", "recommended"),
|
||||
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
|
||||
("qwen/qwen3.7-max", ""),
|
||||
("anthropic/claude-haiku-4.5", ""),
|
||||
# OpenAI
|
||||
("openai/gpt-5.5", ""),
|
||||
("openai/gpt-5.5-pro", ""),
|
||||
("openai/gpt-5.4-mini", ""),
|
||||
("openai/gpt-5.4-nano", ""),
|
||||
("openai/gpt-5.3-codex", ""),
|
||||
("xiaomi/mimo-v2.5-pro", ""),
|
||||
("tencent/hy3-preview", ""),
|
||||
("google/gemini-3-pro-image-preview", ""),
|
||||
("google/gemini-3.5-flash", ""),
|
||||
# Google
|
||||
("google/gemini-3-pro-preview", ""),
|
||||
("google/gemini-3.1-pro-preview", ""),
|
||||
("google/gemini-3.1-flash-lite-preview", ""),
|
||||
("qwen/qwen3.6-35b-a3b", ""),
|
||||
("stepfun/step-3.7-flash", ""),
|
||||
("minimax/minimax-m2.7", ""),
|
||||
("z-ai/glm-5.1", ""),
|
||||
("x-ai/grok-4.20", ""),
|
||||
("google/gemini-3.5-flash", ""),
|
||||
# xAI
|
||||
("x-ai/grok-4.3", ""),
|
||||
("nvidia/nemotron-3-super-120b-a12b", ""),
|
||||
# DeepSeek
|
||||
("deepseek/deepseek-v4-pro", ""),
|
||||
("deepseek/deepseek-v4-flash", ""),
|
||||
# Qwen
|
||||
("qwen/qwen3.7-max", ""),
|
||||
("qwen/qwen3.6-35b-a3b", ""),
|
||||
# MoonshotAI
|
||||
("moonshotai/kimi-k2.6", "recommended"),
|
||||
# MiniMax
|
||||
("minimax/minimax-m2.7", ""),
|
||||
# Z-AI
|
||||
("z-ai/glm-5.1", ""),
|
||||
# Xiaomi
|
||||
("xiaomi/mimo-v2.5-pro", ""),
|
||||
# Tencent
|
||||
("tencent/hy3-preview", ""),
|
||||
# StepFun
|
||||
("stepfun/step-3.7-flash", ""),
|
||||
# NVIDIA
|
||||
("nvidia/nemotron-3-super-120b-a12b", ""),
|
||||
# OpenRouter routers
|
||||
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
|
||||
# Free tier
|
||||
("openrouter/elephant-alpha", "free"),
|
||||
("openrouter/owl-alpha", "free"),
|
||||
@@ -141,31 +150,40 @@ def _xai_curated_models() -> list[str]:
|
||||
|
||||
_PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"nous": [
|
||||
# Anthropic
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"anthropic/claude-opus-4.6",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"moonshotai/kimi-k2.6",
|
||||
"qwen/qwen3.7-max",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
# OpenAI
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.5-pro",
|
||||
"openai/gpt-5.4-mini",
|
||||
"openai/gpt-5.4-nano",
|
||||
"openai/gpt-5.3-codex",
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
"tencent/hy3-preview",
|
||||
# Google
|
||||
"google/gemini-3-pro-preview",
|
||||
"google/gemini-3.5-flash",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"google/gemini-3.1-flash-lite-preview",
|
||||
"qwen/qwen3.6-35b-a3b",
|
||||
"stepfun/step-3.7-flash",
|
||||
"minimax/minimax-m2.7",
|
||||
"z-ai/glm-5.1",
|
||||
"google/gemini-3.5-flash",
|
||||
# xAI
|
||||
"x-ai/grok-4.3",
|
||||
"nvidia/nemotron-3-super-120b-a12b",
|
||||
# DeepSeek
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
# Qwen
|
||||
"qwen/qwen3.7-max",
|
||||
"qwen/qwen3.6-35b-a3b",
|
||||
# MoonshotAI
|
||||
"moonshotai/kimi-k2.6",
|
||||
# MiniMax
|
||||
"minimax/minimax-m2.7",
|
||||
# Z-AI
|
||||
"z-ai/glm-5.1",
|
||||
# Xiaomi
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
# Tencent
|
||||
"tencent/hy3-preview",
|
||||
# StepFun
|
||||
"stepfun/step-3.7-flash",
|
||||
# NVIDIA
|
||||
"nvidia/nemotron-3-super-120b-a12b",
|
||||
],
|
||||
# Native OpenAI Chat Completions (api.openai.com). Used by /model counts and
|
||||
# provider_model_ids fallback when /v1/models is unavailable.
|
||||
@@ -936,6 +954,105 @@ _PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
|
||||
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider groups — DISPLAY ONLY
|
||||
#
|
||||
# Some vendors expose several Hermes provider slugs (one per endpoint /
|
||||
# auth method: global API, China API, OAuth coding plan, ...). Listing every
|
||||
# slug as a top-level row in the interactive `hermes model` / setup wizard /
|
||||
# Telegram `/model` pickers makes that list long and noisy.
|
||||
#
|
||||
# These groups fold related slugs under one top-level row in INTERACTIVE
|
||||
# PICKERS only. They do NOT change ``CANONICAL_PROVIDERS``, slug identity,
|
||||
# the ``--provider`` flag, ``/model <provider:model>``, or any typed path —
|
||||
# every member slug remains individually addressable. Grouping is a pure
|
||||
# display affordance; ``group_providers()`` is the single fold used by all
|
||||
# three picker surfaces so they stay consistent.
|
||||
#
|
||||
# group_id -> (display_label, [member_slug, ...])
|
||||
#
|
||||
# Member order is the order shown inside the group submenu.
|
||||
# ---------------------------------------------------------------------------
|
||||
PROVIDER_GROUPS: dict[str, tuple[str, list[str]]] = {
|
||||
"kimi": ("Kimi / Moonshot", ["kimi-coding", "kimi-coding-cn"]),
|
||||
"minimax": ("MiniMax", ["minimax", "minimax-oauth", "minimax-cn"]),
|
||||
"xai": ("xAI Grok", ["xai", "xai-oauth"]),
|
||||
"google": ("Google Gemini", ["gemini", "google-gemini-cli"]),
|
||||
"openai": ("OpenAI", ["openai-codex", "openai-api"]),
|
||||
"opencode": ("OpenCode", ["opencode-zen", "opencode-go"]),
|
||||
"copilot": ("GitHub Copilot", ["copilot", "copilot-acp"]),
|
||||
}
|
||||
|
||||
# Reverse index: member slug -> group_id. Built once at import.
|
||||
_SLUG_TO_GROUP: dict[str, str] = {
|
||||
slug: gid for gid, (_label, members) in PROVIDER_GROUPS.items() for slug in members
|
||||
}
|
||||
|
||||
|
||||
def provider_group_for_slug(slug: str) -> str:
|
||||
"""Return the group_id a provider slug belongs to, or "" if ungrouped."""
|
||||
return _SLUG_TO_GROUP.get(str(slug or "").strip().lower(), "")
|
||||
|
||||
|
||||
def group_providers(slugs):
|
||||
"""Fold a flat ordered slug iterable into picker rows by provider group.
|
||||
|
||||
DISPLAY ONLY. Used by every interactive picker (``hermes model``, the
|
||||
setup wizard, the Telegram ``/model`` keyboard) so grouping is identical
|
||||
across surfaces.
|
||||
|
||||
Each returned row is a dict::
|
||||
|
||||
{"kind": "single", "slug": <slug>} # ungrouped, or
|
||||
# 1-member group
|
||||
{"kind": "group", "group_id": <gid>, "label": <label>,
|
||||
"members": [<slug>, ...]} # 2+ members
|
||||
|
||||
Rules:
|
||||
* A group row appears at the position of its FIRST present member, in
|
||||
the input order. Subsequent members fold into that row (and are not
|
||||
emitted again).
|
||||
* Member order inside a group follows ``PROVIDER_GROUPS`` declaration,
|
||||
restricted to the members actually present in ``slugs``.
|
||||
* A group reduced to a single present member degrades to a ``single``
|
||||
row — no pointless one-item submenu.
|
||||
* Slugs not in any group pass through as ``single`` rows, order
|
||||
preserved.
|
||||
* Duplicate slugs in the input are ignored after first sight.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
# Which present members each group has, in declaration order.
|
||||
group_members: dict[str, list[str]] = {}
|
||||
for gid, (_label, members) in PROVIDER_GROUPS.items():
|
||||
present = [m for m in members if m in set(slugs)]
|
||||
if present:
|
||||
group_members[gid] = present
|
||||
|
||||
rows = []
|
||||
emitted_groups: set[str] = set()
|
||||
for slug in slugs:
|
||||
s = str(slug or "").strip().lower()
|
||||
if not s or s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
gid = _SLUG_TO_GROUP.get(s, "")
|
||||
if not gid:
|
||||
rows.append({"kind": "single", "slug": s})
|
||||
continue
|
||||
if gid in emitted_groups:
|
||||
continue # already folded at the first member's position
|
||||
emitted_groups.add(gid)
|
||||
members = group_members.get(gid, [s])
|
||||
if len(members) <= 1:
|
||||
rows.append({"kind": "single", "slug": members[0]})
|
||||
else:
|
||||
label, _ = PROVIDER_GROUPS[gid]
|
||||
rows.append(
|
||||
{"kind": "group", "group_id": gid, "label": label, "members": list(members)}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
_PROVIDER_ALIASES = {
|
||||
"glm": "zai",
|
||||
"z-ai": "zai",
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
@@ -15,6 +16,7 @@ NousAccountInfoSource = Literal["jwt", "account_api", "inference_key", "none", "
|
||||
|
||||
_ACCOUNT_INFO_CACHE_TTL = 60
|
||||
_account_info_cache: tuple[str, float, "NousPortalAccountInfo"] | None = None
|
||||
_ACCOUNT_INFO_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -302,10 +304,11 @@ def _fresh_account_info(
|
||||
portal_base_url = _portal_base_url(refreshed_state) or portal_base_url
|
||||
cache_key = _cache_key(access_token, portal_base_url)
|
||||
|
||||
if not force_fresh and _account_info_cache is not None:
|
||||
cached_key, cached_at, cached_info = _account_info_cache
|
||||
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
|
||||
return cached_info
|
||||
with _ACCOUNT_INFO_CACHE_LOCK:
|
||||
if not force_fresh and _account_info_cache is not None:
|
||||
cached_key, cached_at, cached_info = _account_info_cache
|
||||
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
|
||||
return cached_info
|
||||
|
||||
payload = _fetch_nous_account_info(access_token, portal_base_url)
|
||||
if not payload:
|
||||
@@ -327,7 +330,8 @@ def _fresh_account_info(
|
||||
state=refreshed_state,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
_account_info_cache = (cache_key, time.monotonic(), info)
|
||||
with _ACCOUNT_INFO_CACHE_LOCK:
|
||||
_account_info_cache = (cache_key, time.monotonic(), info)
|
||||
return info
|
||||
except Exception as exc:
|
||||
return _error_info(
|
||||
|
||||
@@ -7,7 +7,11 @@ from pathlib import Path
|
||||
from typing import Dict, Iterable, Optional, Set
|
||||
|
||||
from hermes_cli.config import get_env_value, load_config
|
||||
from hermes_cli.nous_account import NousPortalAccountInfo, get_nous_portal_account_info
|
||||
from hermes_cli.nous_account import (
|
||||
NousPortalAccountInfo,
|
||||
format_nous_portal_entitlement_message,
|
||||
get_nous_portal_account_info,
|
||||
)
|
||||
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||
from utils import is_truthy_value
|
||||
from tools.tool_backend_helpers import (
|
||||
@@ -587,9 +591,20 @@ def apply_nous_managed_defaults(
|
||||
changed.add("browser")
|
||||
|
||||
if "image_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
image_cfg = config.get("image_gen")
|
||||
if not isinstance(image_cfg, dict):
|
||||
image_cfg = {}
|
||||
config["image_gen"] = image_cfg
|
||||
image_cfg["use_gateway"] = True
|
||||
changed.add("image_gen")
|
||||
|
||||
if "video_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
video_cfg = config.get("video_gen")
|
||||
if not isinstance(video_cfg, dict):
|
||||
video_cfg = {}
|
||||
config["video_gen"] = video_cfg
|
||||
video_cfg["provider"] = "fal"
|
||||
video_cfg["use_gateway"] = True
|
||||
changed.add("video_gen")
|
||||
|
||||
return changed
|
||||
@@ -871,3 +886,136 @@ def prompt_enable_tool_gateway(
|
||||
if already_managed and not newly_switched:
|
||||
print(" (all tools already using Tool Gateway)")
|
||||
return changed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline Nous Portal login for the Tool Gateway picker (`hermes tools`)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ensure_nous_portal_access(*, capability: str = "the Nous Tool Gateway") -> bool:
|
||||
"""Make sure the user has paid Nous Portal access, logging in if needed.
|
||||
|
||||
Used by ``hermes tools`` when a user selects a Nous-managed Tool Gateway
|
||||
backend (e.g. "Firecrawl (Nous Portal)"). Unlike ``hermes model``'s Nous
|
||||
login, this:
|
||||
|
||||
- does NOT change the inference provider (``model.provider`` is untouched),
|
||||
- does NOT run model selection, and
|
||||
- does NOT offer the bulk "enable for all tools" Tool Gateway prompt.
|
||||
|
||||
It only performs the Nous Portal device-code OAuth (when the user isn't
|
||||
already logged in) and refreshes entitlement, so the caller can enable the
|
||||
single tool the user picked.
|
||||
|
||||
Returns ``True`` when the account has paid service access after the flow,
|
||||
``False`` otherwise (declined login, login failed, or no paid entitlement).
|
||||
"""
|
||||
# Fast path: already entitled.
|
||||
try:
|
||||
info = get_nous_portal_account_info(force_fresh=True)
|
||||
except Exception:
|
||||
info = None
|
||||
if info is not None and info.paid_service_access is True:
|
||||
return True
|
||||
|
||||
# If not logged in at all, run the device-code login (auth only).
|
||||
if info is None or not info.logged_in:
|
||||
if not _run_nous_portal_login_only(capability=capability):
|
||||
return False
|
||||
try:
|
||||
info = get_nous_portal_account_info(force_fresh=True)
|
||||
except Exception:
|
||||
info = None
|
||||
|
||||
if info is not None and info.paid_service_access is True:
|
||||
return True
|
||||
|
||||
# Logged in but no paid access — surface billing guidance, do not enable.
|
||||
message = format_nous_portal_entitlement_message(info, capability=capability)
|
||||
if message:
|
||||
for line in message.splitlines():
|
||||
print(f" {line}")
|
||||
return False
|
||||
|
||||
|
||||
def _run_nous_portal_login_only(*, capability: str) -> bool:
|
||||
"""Run the Nous Portal device-code OAuth and persist credentials only.
|
||||
|
||||
No model selection, no provider switch, no Tool Gateway bulk prompt.
|
||||
Returns ``True`` on a successful login, ``False`` if the user declined or
|
||||
the flow failed.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
_auth_store_lock,
|
||||
_load_auth_store,
|
||||
_nous_device_code_login,
|
||||
_read_shared_nous_state,
|
||||
_save_auth_store,
|
||||
_save_provider_state,
|
||||
_sync_nous_pool_from_auth_store,
|
||||
_try_import_shared_nous_state,
|
||||
_write_shared_nous_state,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print(f" Could not start Nous Portal login: {exc}")
|
||||
return False
|
||||
|
||||
print()
|
||||
print(f" {capability} requires a Nous Portal login.")
|
||||
try:
|
||||
proceed = input(" Log in to Nous Portal now? [Y/n]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
return False
|
||||
if proceed not in {"", "y", "yes"}:
|
||||
print(" Skipped Nous Portal login.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Snapshot the active_provider so a tool-config login never silently
|
||||
# switches the user's inference provider to Nous.
|
||||
with _auth_store_lock():
|
||||
prior_active_provider = _load_auth_store().get("active_provider")
|
||||
|
||||
auth_state = None
|
||||
shared = _read_shared_nous_state()
|
||||
if shared:
|
||||
try:
|
||||
do_import = input(
|
||||
" Found existing Nous OAuth credentials. Import them? [Y/n]: "
|
||||
).strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
do_import = "y"
|
||||
if do_import in {"", "y", "yes"}:
|
||||
auth_state = _try_import_shared_nous_state(timeout_seconds=15.0)
|
||||
|
||||
if auth_state is None:
|
||||
auth_state = _nous_device_code_login()
|
||||
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
_save_provider_state(auth_store, "nous", auth_state)
|
||||
# Preserve the user's existing inference provider — this login is
|
||||
# for tool entitlement only, not a provider switch.
|
||||
if prior_active_provider:
|
||||
auth_store["active_provider"] = prior_active_provider
|
||||
else:
|
||||
auth_store.pop("active_provider", None)
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
_write_shared_nous_state(auth_state)
|
||||
_sync_nous_pool_from_auth_store()
|
||||
print(" Nous Portal login successful.")
|
||||
return True
|
||||
except KeyboardInterrupt:
|
||||
print("\n Login cancelled.")
|
||||
return False
|
||||
except SystemExit:
|
||||
# _nous_device_code_login raises SystemExit on subscription_required;
|
||||
# it already printed billing guidance.
|
||||
return False
|
||||
except Exception as exc:
|
||||
print(f" Nous Portal login failed: {exc}")
|
||||
return False
|
||||
|
||||
+39
-12
@@ -174,28 +174,55 @@ def run_oneshot(
|
||||
# Redirect stderr AND stdout to devnull for the entire call tree.
|
||||
# We'll print the final response to the real stdout at the end.
|
||||
real_stdout = sys.stdout
|
||||
real_stderr = sys.stderr
|
||||
devnull = open(os.devnull, "w", encoding="utf-8")
|
||||
|
||||
response: Optional[str] = None
|
||||
failure: BaseException | None = None
|
||||
try:
|
||||
with redirect_stdout(devnull), redirect_stderr(devnull):
|
||||
response = _run_agent(
|
||||
prompt,
|
||||
model=model,
|
||||
provider=provider,
|
||||
toolsets=explicit_toolsets,
|
||||
use_config_toolsets=use_config_toolsets,
|
||||
)
|
||||
try:
|
||||
response = _run_agent(
|
||||
prompt,
|
||||
model=model,
|
||||
provider=provider,
|
||||
toolsets=explicit_toolsets,
|
||||
use_config_toolsets=use_config_toolsets,
|
||||
)
|
||||
except BaseException as exc: # noqa: BLE001
|
||||
# Capture anything that escapes the agent (including OSError
|
||||
# from prompt_toolkit/Vt100 when stdout is a non-TTY pipe,
|
||||
# KeyboardInterrupt, SystemExit, etc.) so we can surface it on
|
||||
# the real stderr instead of crashing past the redirect with a
|
||||
# traceback that the caller never sees. A silent exit in a
|
||||
# cron / SSH / subprocess context is the worst failure mode.
|
||||
# See #30623.
|
||||
failure = exc
|
||||
finally:
|
||||
try:
|
||||
devnull.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if response:
|
||||
real_stdout.write(response)
|
||||
if not response.endswith("\n"):
|
||||
real_stdout.write("\n")
|
||||
real_stdout.flush()
|
||||
if failure is not None:
|
||||
# Re-raise control-flow exceptions so the parent handles them as usual
|
||||
# (Ctrl-C / explicit sys.exit() inside the agent).
|
||||
if isinstance(failure, (KeyboardInterrupt, SystemExit)):
|
||||
raise failure
|
||||
real_stderr.write(f"hermes -z: agent failed: {failure}\n")
|
||||
real_stderr.flush()
|
||||
return 1
|
||||
|
||||
if not (response or "").strip():
|
||||
real_stderr.write("hermes -z: no final response was produced; treating the run as failed.\n")
|
||||
real_stderr.flush()
|
||||
return 1
|
||||
|
||||
assert response is not None # narrowed by the empty-response guard above
|
||||
real_stdout.write(response)
|
||||
if not response.endswith("\n"):
|
||||
real_stdout.write("\n")
|
||||
real_stdout.flush()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Boundary-aware partial compression — "summarize up to here".
|
||||
|
||||
Inspired by Claude Code's Rewind menu "Summarize up to here" action
|
||||
(v2.1.139–v2.1.142, Week 20, May 2026):
|
||||
https://code.claude.com/docs/en/whats-new/2026-w20
|
||||
|
||||
Hermes already has ``/compress`` (full-history compaction) and an
|
||||
automatic token-budget tail-protection heuristic inside
|
||||
``ContextCompressor``. What was missing is *user-chosen* boundary
|
||||
control: "fold everything before this point into a summary, but keep
|
||||
my most recent N exchanges exactly as they are." That is the value of
|
||||
the Claude Code feature — the user decides the compression boundary
|
||||
instead of leaving it to the token-budget heuristic.
|
||||
|
||||
This module owns the pure, side-effect-free split logic so both the
|
||||
CLI (``cli.py::_manual_compress``) and the gateway
|
||||
(``gateway/run.py::_handle_compress_command``) share one
|
||||
implementation. The slash-command surfaces handle compression of the
|
||||
*head* via the existing ``_compress_context`` pipeline (preserving all
|
||||
the session-rotation / lock / memory-notify machinery) and then
|
||||
re-append the verbatim *tail* returned here.
|
||||
|
||||
Design notes / invariants honored:
|
||||
|
||||
* **Role alternation.** The compressed head ends with summary/handoff
|
||||
content (assistant- or user-role, possibly a trailing todo snapshot).
|
||||
The verbatim tail must begin with a ``user`` message so the rejoined
|
||||
history keeps the user↔assistant alternation that providers validate.
|
||||
:func:`split_history_for_partial_compress` snaps the tail boundary
|
||||
backwards to the nearest ``user`` turn so the rejoin is always legal.
|
||||
|
||||
* **No silent context mutation.** This is a manual, user-invoked
|
||||
action. It rotates the session exactly like ``/compress`` does (via
|
||||
the caller), so the prompt-cache reset is explicit and expected, not
|
||||
silent.
|
||||
|
||||
* **Conservative defaults.** ``keep_last`` counts *exchanges* (a user
|
||||
turn plus its following assistant/tool turns), defaulting to 2. The
|
||||
split never compresses if doing so would leave nothing in the head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
#: Default number of recent exchanges to preserve verbatim when the user
|
||||
#: runs ``/compress here`` without an explicit count.
|
||||
DEFAULT_KEEP_LAST = 2
|
||||
|
||||
#: Hard ceiling so a fat-fingered ``/compress here 9999`` doesn't turn
|
||||
#: into a no-op surprise — clamp instead.
|
||||
MAX_KEEP_LAST = 100
|
||||
|
||||
|
||||
def parse_partial_compress_args(
|
||||
raw_args: str,
|
||||
) -> Tuple[bool, int, Optional[str]]:
|
||||
"""Parse the argument string after ``/compress``.
|
||||
|
||||
Recognizes the boundary-aware forms:
|
||||
|
||||
* ``here`` → partial compress, keep ``DEFAULT_KEEP_LAST``
|
||||
* ``here 4`` → partial compress, keep 4 exchanges
|
||||
* ``--keep 4`` → partial compress, keep 4 exchanges
|
||||
* ``up to here`` → alias for ``here`` (matches Claude Code's
|
||||
menu label "Summarize up to here")
|
||||
|
||||
Anything else is treated as a focus topic for the existing full
|
||||
``/compress <focus>`` behavior.
|
||||
|
||||
Returns ``(partial, keep_last, focus_topic)``:
|
||||
|
||||
* ``partial`` — True when a boundary-aware form was requested.
|
||||
* ``keep_last`` — exchanges to preserve verbatim (only meaningful
|
||||
when ``partial`` is True).
|
||||
* ``focus_topic`` — focus string for full compression, or None.
|
||||
Always None when ``partial`` is True (the two modes are exclusive;
|
||||
a focused partial compress is not a documented Claude Code
|
||||
behavior and would muddy the UX).
|
||||
"""
|
||||
text = (raw_args or "").strip()
|
||||
if not text:
|
||||
return False, DEFAULT_KEEP_LAST, None
|
||||
|
||||
lowered = text.lower()
|
||||
|
||||
# Normalize the "up to here" alias to "here".
|
||||
if lowered.startswith("up to here"):
|
||||
lowered = lowered[len("up to ") :]
|
||||
text = text[len("up to ") :]
|
||||
|
||||
tokens = lowered.split()
|
||||
|
||||
# Form: here [N]
|
||||
if tokens and tokens[0] == "here":
|
||||
keep = DEFAULT_KEEP_LAST
|
||||
if len(tokens) >= 2:
|
||||
keep = _coerce_keep(tokens[1])
|
||||
return True, keep, None
|
||||
|
||||
# Form: --keep N (or --keep=N)
|
||||
if tokens and tokens[0] in ("--keep", "-k") and len(tokens) >= 2:
|
||||
return True, _coerce_keep(tokens[1]), None
|
||||
if tokens and tokens[0].startswith("--keep="):
|
||||
return True, _coerce_keep(tokens[0].split("=", 1)[1]), None
|
||||
|
||||
# Otherwise: full compression with this as the focus topic.
|
||||
return False, DEFAULT_KEEP_LAST, text or None
|
||||
|
||||
|
||||
def _coerce_keep(value: str) -> int:
|
||||
"""Parse a keep-count token, clamping to [1, MAX_KEEP_LAST]."""
|
||||
try:
|
||||
n = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_KEEP_LAST
|
||||
if n < 1:
|
||||
return 1
|
||||
if n > MAX_KEEP_LAST:
|
||||
return MAX_KEEP_LAST
|
||||
return n
|
||||
|
||||
|
||||
def split_history_for_partial_compress(
|
||||
history: List[Dict[str, Any]],
|
||||
keep_last: int,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""Split ``history`` into ``(head, tail)`` for partial compression.
|
||||
|
||||
``head`` is the earlier portion that will be summarized; ``tail`` is
|
||||
the most recent ``keep_last`` exchanges, preserved verbatim.
|
||||
|
||||
An *exchange* is counted by ``user``-role messages: keeping N
|
||||
exchanges means keeping everything from the Nth-most-recent ``user``
|
||||
message onward. This guarantees the tail starts on a ``user`` turn,
|
||||
so when the caller rejoins ``compressed_head + tail`` the
|
||||
user↔assistant alternation stays valid (the compressed head's
|
||||
trailing content is followed by a fresh user turn).
|
||||
|
||||
Returns ``(head, tail)``. If the split would leave the head empty
|
||||
(not enough history to compress meaningfully), returns
|
||||
``(history, [])`` — signaling the caller to fall back to full
|
||||
compression or report "nothing to do".
|
||||
"""
|
||||
if keep_last < 1:
|
||||
keep_last = 1
|
||||
|
||||
n = len(history)
|
||||
if n == 0:
|
||||
return [], []
|
||||
|
||||
# Walk backwards collecting the indices of the most recent `keep_last`
|
||||
# user-message starts. The tail begins at the earliest such index.
|
||||
user_starts: List[int] = []
|
||||
for idx in range(n - 1, -1, -1):
|
||||
if history[idx].get("role") == "user":
|
||||
user_starts.append(idx)
|
||||
if len(user_starts) >= keep_last:
|
||||
break
|
||||
|
||||
if not user_starts:
|
||||
# No user turns at all (degenerate) — nothing sensible to keep
|
||||
# as a "recent exchange"; treat as full compression.
|
||||
return list(history), []
|
||||
|
||||
boundary = user_starts[-1] # earliest of the kept user starts
|
||||
|
||||
head = history[:boundary]
|
||||
tail = history[boundary:]
|
||||
|
||||
# If everything is in the tail (nothing left to compress), signal the
|
||||
# caller to fall back to full compression rather than producing a
|
||||
# no-op that rotates the session for no benefit.
|
||||
if not head:
|
||||
return list(history), []
|
||||
|
||||
return head, tail
|
||||
|
||||
|
||||
def rejoin_compressed_head_and_tail(
|
||||
compressed_head: List[Dict[str, Any]],
|
||||
tail: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Concatenate a compressed head with the verbatim tail, defending
|
||||
the seam against an illegal user→user / assistant→assistant adjacency.
|
||||
|
||||
In normal operation the compressed head ends with the head's own
|
||||
protected verbatim tail (the ``ContextCompressor`` always preserves a
|
||||
recent window), which terminates on an ``assistant``/``tool`` turn —
|
||||
so ``assistant → user`` at the seam is already valid. But the head
|
||||
compressor's exact output shape is not contractually guaranteed (a
|
||||
plugin context engine could return something that ends on a ``user``
|
||||
turn, or a degenerate single-summary message). Rather than trust the
|
||||
seam, this helper inspects the boundary and, if the last head message
|
||||
and the first tail message share a ``user``/``assistant`` role, folds
|
||||
the tail's first message content onto the head's last message so the
|
||||
rejoined list never violates provider role-alternation rules.
|
||||
|
||||
``tool`` messages are left alone — consecutive ``tool`` entries are
|
||||
the one legal repetition (parallel tool results).
|
||||
"""
|
||||
if not tail:
|
||||
return list(compressed_head)
|
||||
if not compressed_head:
|
||||
return list(tail)
|
||||
|
||||
head = list(compressed_head)
|
||||
rest = list(tail)
|
||||
|
||||
last = head[-1]
|
||||
first = rest[0]
|
||||
last_role = last.get("role")
|
||||
first_role = first.get("role")
|
||||
|
||||
if last_role == first_role and last_role in ("user", "assistant"):
|
||||
# Illegal adjacency. Merge the tail's first message text into the
|
||||
# head's last message so alternation is preserved. Only string
|
||||
# contents are merged inline; structured/multimodal contents fall
|
||||
# back to dropping the redundant standalone (the content is
|
||||
# preserved by concatenation when both are strings).
|
||||
last_content = last.get("content")
|
||||
first_content = first.get("content")
|
||||
if isinstance(last_content, str) and isinstance(first_content, str):
|
||||
merged = dict(last)
|
||||
merged["content"] = f"{last_content}\n\n{first_content}"
|
||||
head[-1] = merged
|
||||
rest = rest[1:]
|
||||
else:
|
||||
# Can't safely string-merge multimodal content. Insert a
|
||||
# minimal bridging turn so the seam alternates rather than
|
||||
# losing data.
|
||||
bridge_role = "assistant" if first_role == "user" else "user"
|
||||
head.append({"role": bridge_role, "content": ""})
|
||||
|
||||
return head + rest
|
||||
+14
-7
@@ -1471,8 +1471,9 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
|
||||
def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None:
|
||||
"""Rename Honcho host blocks for a renamed profile without changing peers."""
|
||||
old_host = f"hermes.{old_name}"
|
||||
new_host = f"hermes.{new_name}"
|
||||
old_host = f"hermes_{old_name}"
|
||||
legacy_old_host = f"hermes.{old_name}"
|
||||
new_host = f"hermes_{new_name}"
|
||||
|
||||
candidates = [
|
||||
new_dir / "honcho.json",
|
||||
@@ -1496,18 +1497,24 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
|
||||
continue
|
||||
|
||||
hosts = raw.get("hosts")
|
||||
if not isinstance(hosts, dict) or old_host not in hosts:
|
||||
if not isinstance(hosts, dict):
|
||||
continue
|
||||
source_host = old_host if old_host in hosts else legacy_old_host
|
||||
if source_host not in hosts:
|
||||
continue
|
||||
|
||||
if new_host in hosts:
|
||||
print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}")
|
||||
continue
|
||||
|
||||
block = hosts[old_host]
|
||||
block = hosts[source_host]
|
||||
if isinstance(block, dict) and "aiPeer" not in block:
|
||||
bare = old_host.split(".", 1)[1] if "." in old_host else old_host
|
||||
if source_host.startswith("hermes_"):
|
||||
bare = source_host.split("_", 1)[1]
|
||||
else:
|
||||
bare = source_host.split(".", 1)[1] if "." in source_host else source_host
|
||||
block["aiPeer"] = bare
|
||||
hosts[new_host] = hosts.pop(old_host)
|
||||
hosts[new_host] = hosts.pop(source_host)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
try:
|
||||
tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
@@ -1519,7 +1526,7 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
|
||||
pass
|
||||
continue
|
||||
|
||||
print(f"✓ Honcho host updated: {old_host} → {new_host}")
|
||||
print(f"✓ Honcho host updated: {source_host} → {new_host}")
|
||||
|
||||
|
||||
def rename_profile(old_name: str, new_name: str) -> Path:
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Prompt-size diagnostic: ``hermes prompt-size``.
|
||||
|
||||
Reports a byte/char breakdown of the system prompt the agent would build for
|
||||
a fresh session — system prompt total, the ``<available_skills>`` index,
|
||||
memory + user profile, and tool-schema JSON. Lets users see where their fixed
|
||||
prompt budget goes (issue #34667) without parsing a saved session JSON by hand.
|
||||
|
||||
The diagnostic builds a real inspection agent (so the numbers match what
|
||||
actually ships on the wire) but never makes a network call: it passes dummy
|
||||
credentials so ``AIAgent.__init__`` takes the direct-construction path, then
|
||||
calls ``build_system_prompt_parts`` / inspects ``agent.tools`` offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
# The skills index is wrapped in this tag pair inside the stable tier.
|
||||
_SKILLS_BLOCK_RE = re.compile(r"<available_skills>.*?</available_skills>", re.DOTALL)
|
||||
|
||||
|
||||
def _bytes(s: str) -> int:
|
||||
return len(s.encode("utf-8"))
|
||||
|
||||
|
||||
def _build_inspection_agent(platform: str) -> Any:
|
||||
"""Construct an offline AIAgent for prompt inspection.
|
||||
|
||||
Dummy ``api_key`` + ``base_url`` force the direct-construction path in
|
||||
``run_agent.py`` (no provider auto-detection, no network). Toolsets and
|
||||
platform come from the caller so the breakdown matches a real session.
|
||||
"""
|
||||
from run_agent import AIAgent
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
|
||||
model = model_cfg.get("default") or model_cfg.get("model") or ""
|
||||
|
||||
return AIAgent(
|
||||
model=model,
|
||||
api_key="inspect-only",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
quiet_mode=True,
|
||||
save_trajectories=False,
|
||||
platform=platform,
|
||||
)
|
||||
|
||||
|
||||
def compute_prompt_breakdown(platform: str = "cli") -> Dict[str, Any]:
|
||||
"""Return a dict of prompt-size measurements for a fresh session.
|
||||
|
||||
Keys: ``system_prompt`` (chars/bytes), ``skills_index``, ``memory``,
|
||||
``user_profile``, ``tools`` (count + json bytes), and ``sections`` (a list
|
||||
of (label, chars, bytes) for the three prompt tiers).
|
||||
"""
|
||||
from agent.system_prompt import build_system_prompt, build_system_prompt_parts
|
||||
|
||||
agent = _build_inspection_agent(platform)
|
||||
|
||||
parts = build_system_prompt_parts(agent)
|
||||
full = build_system_prompt(agent)
|
||||
|
||||
stable = parts.get("stable", "")
|
||||
context = parts.get("context", "")
|
||||
volatile = parts.get("volatile", "")
|
||||
|
||||
# Skills index — the <available_skills> block (the largest single block
|
||||
# when many skills are installed). Measured inside the stable tier.
|
||||
skills_match = _SKILLS_BLOCK_RE.search(stable)
|
||||
skills_index = skills_match.group(0) if skills_match else ""
|
||||
|
||||
# Memory + user profile live in the volatile tier. We re-derive their
|
||||
# blocks directly from the memory store so the numbers are attributable
|
||||
# even though they're joined into ``volatile``.
|
||||
memory_block = ""
|
||||
user_block = ""
|
||||
store = getattr(agent, "_memory_store", None)
|
||||
if store is not None:
|
||||
try:
|
||||
if getattr(agent, "_memory_enabled", True):
|
||||
memory_block = store.format_for_system_prompt("memory") or ""
|
||||
if getattr(agent, "_user_profile_enabled", True):
|
||||
user_block = store.format_for_system_prompt("user") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tool-schema JSON — the other half of the fixed per-call payload.
|
||||
tools = getattr(agent, "tools", None) or []
|
||||
tools_json = json.dumps(tools, ensure_ascii=False)
|
||||
|
||||
sections: List[Tuple[str, int, int]] = [
|
||||
("stable (identity/guidance/skills)", len(stable), _bytes(stable)),
|
||||
("context (AGENTS.md/cwd files)", len(context), _bytes(context)),
|
||||
("volatile (memory/profile/timestamp)", len(volatile), _bytes(volatile)),
|
||||
]
|
||||
|
||||
return {
|
||||
"platform": platform,
|
||||
"model": getattr(agent, "model", "") or "",
|
||||
"system_prompt": {"chars": len(full), "bytes": _bytes(full)},
|
||||
"skills_index": {"chars": len(skills_index), "bytes": _bytes(skills_index)},
|
||||
"memory": {"chars": len(memory_block), "bytes": _bytes(memory_block)},
|
||||
"user_profile": {"chars": len(user_block), "bytes": _bytes(user_block)},
|
||||
"tools": {"count": len(tools), "json_bytes": _bytes(tools_json)},
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
|
||||
def _fmt_kb(n: int) -> str:
|
||||
return f"{n / 1024:.1f} KB"
|
||||
|
||||
|
||||
def render_breakdown(data: Dict[str, Any]) -> str:
|
||||
"""Render the breakdown as plain text suitable for a terminal."""
|
||||
lines: List[str] = []
|
||||
sp = data["system_prompt"]
|
||||
lines.append(f"Prompt-size breakdown (platform={data['platform']}, model={data['model'] or 'unset'})")
|
||||
lines.append("")
|
||||
lines.append(f" System prompt total : {sp['bytes']:>8,} B ({_fmt_kb(sp['bytes'])}, {sp['chars']:,} chars)")
|
||||
lines.append("")
|
||||
lines.append(" Major blocks:")
|
||||
si = data["skills_index"]
|
||||
mem = data["memory"]
|
||||
up = data["user_profile"]
|
||||
lines.append(f" skills index : {si['bytes']:>8,} B ({_fmt_kb(si['bytes'])})")
|
||||
lines.append(f" memory : {mem['bytes']:>8,} B ({_fmt_kb(mem['bytes'])})")
|
||||
lines.append(f" user profile : {up['bytes']:>8,} B ({_fmt_kb(up['bytes'])})")
|
||||
lines.append("")
|
||||
lines.append(" Prompt tiers:")
|
||||
for label, chars, byts in data["sections"]:
|
||||
lines.append(f" {label:<36}: {byts:>8,} B ({_fmt_kb(byts)})")
|
||||
lines.append("")
|
||||
tools = data["tools"]
|
||||
lines.append(f" Tool schemas : {tools['json_bytes']:>8,} B ({_fmt_kb(tools['json_bytes'])}, {tools['count']} tools)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cmd_prompt_size(args: Any) -> None:
|
||||
"""Entry point for ``hermes prompt-size``."""
|
||||
platform = getattr(args, "platform", "cli") or "cli"
|
||||
as_json = getattr(args, "json", False)
|
||||
try:
|
||||
data = compute_prompt_breakdown(platform)
|
||||
except Exception as e:
|
||||
print(f"Could not compute prompt-size breakdown: {e}")
|
||||
return
|
||||
if as_json:
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(render_breakdown(data))
|
||||
+70
-228
@@ -305,7 +305,7 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list
|
||||
appended at the end — the user toggles items with Space and confirms
|
||||
with Enter on "Continue →".
|
||||
|
||||
Falls back to a numbered toggle interface when simple_term_menu is
|
||||
Falls back to a numbered toggle interface when curses is
|
||||
unavailable.
|
||||
|
||||
Returns:
|
||||
@@ -737,175 +737,15 @@ def setup_model_provider(config: dict, *, quick: bool = False):
|
||||
if isinstance(_m, dict):
|
||||
selected_provider = _m.get("provider")
|
||||
|
||||
# ── Same-provider fallback & rotation setup (full setup only) ──
|
||||
if not quick and _supports_same_provider_pool_setup(selected_provider):
|
||||
try:
|
||||
from types import SimpleNamespace
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
pool = load_pool(selected_provider)
|
||||
entries = pool.entries()
|
||||
entry_count = len(entries)
|
||||
manual_count = sum(1 for entry in entries if str(getattr(entry, "source", "")).startswith("manual"))
|
||||
auto_count = entry_count - manual_count
|
||||
print()
|
||||
print_header("Same-Provider Fallback & Rotation")
|
||||
print_info(
|
||||
"Hermes can keep multiple credentials for one provider and rotate between"
|
||||
)
|
||||
print_info(
|
||||
"them when a credential is exhausted or rate-limited. This preserves"
|
||||
)
|
||||
print_info(
|
||||
"your primary provider while reducing interruptions from quota issues."
|
||||
)
|
||||
print()
|
||||
if auto_count > 0:
|
||||
print_info(
|
||||
f"Current pooled credentials for {selected_provider}: {entry_count} "
|
||||
f"({manual_count} manual, {auto_count} auto-detected from env/shared auth)"
|
||||
)
|
||||
else:
|
||||
print_info(f"Current pooled credentials for {selected_provider}: {entry_count}")
|
||||
|
||||
while prompt_yes_no("Add another credential for same-provider fallback?", False):
|
||||
auth_add_command(
|
||||
SimpleNamespace(
|
||||
provider=selected_provider,
|
||||
auth_type="",
|
||||
label=None,
|
||||
api_key=None,
|
||||
portal_url=None,
|
||||
inference_url=None,
|
||||
client_id=None,
|
||||
scope=None,
|
||||
no_browser=False,
|
||||
timeout=15.0,
|
||||
insecure=False,
|
||||
ca_bundle=None,
|
||||
)
|
||||
)
|
||||
pool = load_pool(selected_provider)
|
||||
entry_count = len(pool.entries())
|
||||
print_info(f"Provider pool now has {entry_count} credential(s).")
|
||||
|
||||
if entry_count > 1:
|
||||
strategy_labels = [
|
||||
"Fill-first / sticky — keep using the first healthy credential until it is exhausted",
|
||||
"Round robin — rotate to the next healthy credential after each selection",
|
||||
"Random — pick a random healthy credential each time",
|
||||
]
|
||||
current_strategy = _get_credential_pool_strategies(config).get(selected_provider, "fill_first")
|
||||
default_strategy_idx = {
|
||||
"fill_first": 0,
|
||||
"round_robin": 1,
|
||||
"random": 2,
|
||||
}.get(current_strategy, 0)
|
||||
strategy_idx = prompt_choice(
|
||||
"Select same-provider rotation strategy:",
|
||||
strategy_labels,
|
||||
default_strategy_idx,
|
||||
)
|
||||
strategy_value = ["fill_first", "round_robin", "random"][strategy_idx]
|
||||
_set_credential_pool_strategy(config, selected_provider, strategy_value)
|
||||
print_success(f"Saved {selected_provider} rotation strategy: {strategy_value}")
|
||||
except Exception as exc:
|
||||
logger.debug("Could not configure same-provider fallback in setup: %s", exc)
|
||||
|
||||
# ── Vision & Image Analysis Setup (full setup only) ──
|
||||
if quick:
|
||||
_vision_needs_setup = False
|
||||
else:
|
||||
try:
|
||||
from agent.auxiliary_client import get_available_vision_backends
|
||||
_vision_backends = set(get_available_vision_backends())
|
||||
except Exception:
|
||||
_vision_backends = set()
|
||||
|
||||
_vision_needs_setup = not bool(_vision_backends)
|
||||
|
||||
if selected_provider in _vision_backends:
|
||||
_vision_needs_setup = False
|
||||
|
||||
if _vision_needs_setup:
|
||||
_prov_names = {
|
||||
"nous-api": "Nous Portal API key",
|
||||
"copilot": "GitHub Copilot",
|
||||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"zai": "Z.AI / GLM",
|
||||
"kimi-coding": "Kimi / Moonshot",
|
||||
"kimi-coding-cn": "Kimi / Moonshot (China)",
|
||||
"stepfun": "StepFun Step Plan",
|
||||
"minimax": "MiniMax",
|
||||
"minimax-cn": "MiniMax CN",
|
||||
"anthropic": "Anthropic",
|
||||
"custom": "your custom endpoint",
|
||||
}
|
||||
_prov_display = _prov_names.get(selected_provider, selected_provider or "your provider")
|
||||
|
||||
print()
|
||||
print_header("Vision & Image Analysis (optional)")
|
||||
print_info(f"Vision uses a separate multimodal backend. {_prov_display}")
|
||||
print_info("doesn't currently provide one Hermes can auto-use for vision,")
|
||||
print_info("so choose a backend now or skip and configure later.")
|
||||
print()
|
||||
|
||||
_vision_choices = [
|
||||
"OpenRouter — uses Gemini (free tier at openrouter.ai/keys)",
|
||||
"OpenAI-compatible endpoint — base URL, API key, and vision model",
|
||||
"Skip for now",
|
||||
]
|
||||
_vision_idx = prompt_choice("Configure vision:", _vision_choices, 2)
|
||||
|
||||
if _vision_idx == 0: # OpenRouter
|
||||
_or_key = prompt(" OpenRouter API key", password=True).strip()
|
||||
if _or_key:
|
||||
save_env_value("OPENROUTER_API_KEY", _or_key)
|
||||
print_success("OpenRouter key saved — vision will use Gemini")
|
||||
else:
|
||||
print_info("Skipped — vision won't be available")
|
||||
elif _vision_idx == 1: # OpenAI-compatible endpoint
|
||||
_base_url = prompt(" Base URL (blank for OpenAI)").strip() or "https://api.openai.com/v1"
|
||||
_api_key_label = " API key"
|
||||
_is_native_openai = base_url_hostname(_base_url) == "api.openai.com"
|
||||
if _is_native_openai:
|
||||
_api_key_label = " OpenAI API key"
|
||||
_oai_key = prompt(_api_key_label, password=True).strip()
|
||||
if _oai_key:
|
||||
save_env_value("OPENAI_API_KEY", _oai_key)
|
||||
# Save vision base URL to config (not .env — only secrets go there)
|
||||
_vaux = config.setdefault("auxiliary", {}).setdefault("vision", {})
|
||||
_vaux["base_url"] = _base_url
|
||||
if _is_native_openai:
|
||||
_oai_vision_models = ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"]
|
||||
_vm_choices = _oai_vision_models + ["Use default (gpt-4o-mini)"]
|
||||
_vm_idx = prompt_choice("Select vision model:", _vm_choices, 0)
|
||||
_selected_vision_model = (
|
||||
_oai_vision_models[_vm_idx]
|
||||
if _vm_idx < len(_oai_vision_models)
|
||||
else "gpt-4o-mini"
|
||||
)
|
||||
else:
|
||||
_selected_vision_model = prompt(" Vision model (blank = use main/custom default)").strip()
|
||||
if _selected_vision_model:
|
||||
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
|
||||
print_success(
|
||||
f"Vision configured with {_base_url}"
|
||||
+ (f" ({_selected_vision_model})" if _selected_vision_model else "")
|
||||
)
|
||||
else:
|
||||
print_info("Skipped — vision won't be available")
|
||||
else:
|
||||
print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings")
|
||||
|
||||
# Credential rotation, vision-backend selection, and TTS provider are no
|
||||
# longer prompted here. They have safe defaults (rotation off, vision
|
||||
# auto-detected from the main provider, TTS = Edge) and are configurable
|
||||
# on demand via `hermes auth add`, `hermes setup` vision, and
|
||||
# `hermes setup tts`. This keeps both quick and full setup thin.
|
||||
|
||||
# Tool Gateway prompt is already shown by _model_flow_nous() above.
|
||||
save_config(config)
|
||||
|
||||
if not quick and selected_provider != "nous":
|
||||
_setup_tts_provider(config)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Section 1b: TTS Provider Configuration
|
||||
@@ -1341,29 +1181,9 @@ def setup_terminal_backend(config: dict):
|
||||
if selected_backend == "local":
|
||||
print_success("Terminal backend: Local")
|
||||
print_info("Commands run directly on this machine.")
|
||||
|
||||
# Gateway/cron working directory
|
||||
print()
|
||||
print_info("Gateway working directory:")
|
||||
print_info(" Used by Telegram/Discord/cron sessions.")
|
||||
print_info(" CLI/TUI always uses your launch directory instead.")
|
||||
current_cwd = cfg_get(config, "terminal", "cwd", default="")
|
||||
cwd = prompt(" Gateway working directory", current_cwd or str(Path.home()))
|
||||
if cwd:
|
||||
config["terminal"]["cwd"] = cwd
|
||||
|
||||
# Sudo support
|
||||
print()
|
||||
existing_sudo = get_env_value("SUDO_PASSWORD")
|
||||
if existing_sudo:
|
||||
print_info("Sudo password: configured")
|
||||
elif prompt_yes_no(
|
||||
"Enable sudo support? (stores password for apt install, etc.)", False
|
||||
):
|
||||
sudo_pass = prompt(" Sudo password", password=True)
|
||||
if sudo_pass:
|
||||
save_env_value("SUDO_PASSWORD", sudo_pass)
|
||||
print_success("Sudo password saved")
|
||||
# Gateway working directory defaults to home; sudo stays off. Both are
|
||||
# configurable later via `hermes setup terminal` / config.yaml.
|
||||
config["terminal"].setdefault("cwd", str(Path.home()))
|
||||
|
||||
elif selected_backend == "docker":
|
||||
print_success("Terminal backend: Docker")
|
||||
@@ -1376,13 +1196,10 @@ def setup_terminal_backend(config: dict):
|
||||
else:
|
||||
print_info(f"Docker found: {docker_bin}")
|
||||
|
||||
# Docker image
|
||||
current_image = cfg_get(config, "terminal", "docker_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
image = prompt(" Docker image", current_image)
|
||||
config["terminal"]["docker_image"] = image
|
||||
save_env_value("TERMINAL_DOCKER_IMAGE", image)
|
||||
|
||||
_prompt_container_resources(config)
|
||||
# Image and resource limits use defaults; tune via `hermes setup terminal`.
|
||||
config["terminal"].setdefault(
|
||||
"docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
)
|
||||
|
||||
elif selected_backend == "singularity":
|
||||
print_success("Terminal backend: Singularity/Apptainer")
|
||||
@@ -1397,12 +1214,11 @@ def setup_terminal_backend(config: dict):
|
||||
else:
|
||||
print_info(f"Found: {sing_bin}")
|
||||
|
||||
current_image = cfg_get(config, "terminal", "singularity_image", default="docker://nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
image = prompt(" Container image", current_image)
|
||||
config["terminal"]["singularity_image"] = image
|
||||
save_env_value("TERMINAL_SINGULARITY_IMAGE", image)
|
||||
|
||||
_prompt_container_resources(config)
|
||||
# Image and resource limits use defaults; tune via `hermes setup terminal`.
|
||||
config["terminal"].setdefault(
|
||||
"singularity_image",
|
||||
"docker://nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
)
|
||||
|
||||
elif selected_backend == "modal":
|
||||
print_success("Terminal backend: Modal")
|
||||
@@ -1501,8 +1317,6 @@ def setup_terminal_backend(config: dict):
|
||||
if token_secret:
|
||||
save_env_value("MODAL_TOKEN_SECRET", token_secret)
|
||||
|
||||
_prompt_container_resources(config)
|
||||
|
||||
elif selected_backend == "daytona":
|
||||
print_success("Terminal backend: Daytona")
|
||||
print_info("Persistent cloud development environments.")
|
||||
@@ -1552,13 +1366,10 @@ def setup_terminal_backend(config: dict):
|
||||
save_env_value("DAYTONA_API_KEY", api_key)
|
||||
print_success(" Configured")
|
||||
|
||||
# Daytona image
|
||||
current_image = cfg_get(config, "terminal", "daytona_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
image = prompt(" Sandbox image", current_image)
|
||||
config["terminal"]["daytona_image"] = image
|
||||
save_env_value("TERMINAL_DAYTONA_IMAGE", image)
|
||||
|
||||
_prompt_container_resources(config)
|
||||
# Image and resource limits use defaults; tune via `hermes setup terminal`.
|
||||
config["terminal"].setdefault(
|
||||
"daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
)
|
||||
|
||||
elif selected_backend == "ssh":
|
||||
print_success("Terminal backend: SSH")
|
||||
@@ -1625,7 +1436,7 @@ def setup_terminal_backend(config: dict):
|
||||
|
||||
def _apply_default_agent_settings(config: dict):
|
||||
"""Apply recommended defaults for all agent settings without prompting."""
|
||||
config.setdefault("agent", {})["max_turns"] = 90
|
||||
config.setdefault("agent", {})["max_turns"] = 150
|
||||
# config.yaml is the authoritative source for max_turns; the gateway
|
||||
# bridges it into HERMES_MAX_ITERATIONS at startup. We no longer write
|
||||
# to .env to avoid the dual-source inconsistency that caused the
|
||||
@@ -1637,18 +1448,17 @@ def _apply_default_agent_settings(config: dict):
|
||||
config.setdefault("compression", {})["enabled"] = True
|
||||
config["compression"]["threshold"] = 0.50
|
||||
|
||||
config.setdefault("session_reset", {}).update({
|
||||
"mode": "both",
|
||||
"idle_minutes": 1440,
|
||||
"at_hour": 4,
|
||||
})
|
||||
# Default to never auto-resetting sessions. The gateway treats absent
|
||||
# session_reset as "both", so we must write "none" explicitly to make
|
||||
# the no-auto-reset default actually take effect.
|
||||
config.setdefault("session_reset", {})["mode"] = "none"
|
||||
|
||||
save_config(config)
|
||||
print_success("Applied recommended defaults:")
|
||||
print_info(" Max iterations: 90")
|
||||
print_info(" Max iterations: 150")
|
||||
print_info(" Tool progress: all")
|
||||
print_info(" Compression threshold: 0.50")
|
||||
print_info(" Session reset: inactivity (1440 min) + daily (4:00)")
|
||||
print_info(" Session reset: never (use /reset or compression)")
|
||||
print_info(" Run `hermes setup agent` later to customize.")
|
||||
|
||||
|
||||
@@ -3197,7 +3007,7 @@ def run_setup_wizard(args):
|
||||
config = load_config()
|
||||
|
||||
setup_mode = prompt_choice("How would you like to set up Hermes?", [
|
||||
"Quick setup — provider, model & messaging (recommended)",
|
||||
"Quick Setup (Nous Portal) — OAuth login, model & messaging (recommended)",
|
||||
"Full setup — configure everything",
|
||||
], 0)
|
||||
|
||||
@@ -3228,9 +3038,11 @@ def run_setup_wizard(args):
|
||||
if not (migration_ran and _skip_configured_section(config, "terminal", "Terminal Backend")):
|
||||
setup_terminal_backend(config)
|
||||
|
||||
# Section 3: Agent Settings
|
||||
if not (migration_ran and _skip_configured_section(config, "agent", "Agent Settings")):
|
||||
setup_agent_settings(config)
|
||||
# Section 3: Agent Settings — no longer prompted. First installs get the
|
||||
# recommended defaults silently; existing installs keep whatever they have.
|
||||
# Tune later with `hermes setup agent`.
|
||||
if not is_existing:
|
||||
_apply_default_agent_settings(config)
|
||||
|
||||
# Section 4: Messaging Platforms
|
||||
if not (migration_ran and _skip_configured_section(config, "gateway", "Messaging Platforms")):
|
||||
@@ -3250,13 +3062,43 @@ def run_setup_wizard(args):
|
||||
|
||||
|
||||
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
||||
"""Streamlined first-time setup: provider, model, terminal & messaging.
|
||||
"""Streamlined first-time setup via Nous Portal: OAuth, model, terminal & messaging.
|
||||
|
||||
Applies sensible defaults for TTS (Edge), agent settings, and tools —
|
||||
the user can customize later via ``hermes setup <section>``.
|
||||
Routes straight to the Nous Portal provider — runs the device-code OAuth
|
||||
login, picks a Nous model, then configures the terminal backend and (optionally)
|
||||
a messaging platform. Applies sensible defaults for everything else (agent
|
||||
settings, tools); the user can customize later via ``hermes setup <section>``
|
||||
or switch providers with ``hermes model``.
|
||||
"""
|
||||
# Step 1: Model & Provider (essential — skips rotation/vision/TTS)
|
||||
setup_model_provider(config, quick=True)
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
# Step 1: Nous Portal — OAuth login + model selection.
|
||||
# _model_flow_nous() handles both the logged-out path (device-code OAuth,
|
||||
# which selects a model internally) and the already-logged-in path (curated
|
||||
# Nous model picker). Provider is set to "nous" by the login/model save.
|
||||
print()
|
||||
print_header("Nous Portal")
|
||||
print_info("One subscription, 300+ models, plus the Tool Gateway:")
|
||||
print_info(" web search, image generation, TTS, browser automation.")
|
||||
print_info("Sign up: https://portal.nousresearch.com/manage-subscription")
|
||||
print()
|
||||
try:
|
||||
from hermes_cli.main import _model_flow_nous
|
||||
_model_flow_nous(config)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
print_info("Nous Portal setup cancelled.")
|
||||
except Exception as exc:
|
||||
logger.debug("_model_flow_nous error during quick setup: %s", exc)
|
||||
print_warning(f"Nous Portal setup encountered an error: {exc}")
|
||||
print_info("You can try again later with: hermes model")
|
||||
|
||||
# Re-sync the wizard's config dict from disk — _model_flow_nous (and the
|
||||
# underlying login/model save) write via their own load/save cycle, and the
|
||||
# wizard's later save_config(config) must not clobber those values (#4172).
|
||||
_refreshed = load_config()
|
||||
config.clear()
|
||||
config.update(_refreshed)
|
||||
|
||||
# Step 2: Terminal Backend — where commands run is a core decision
|
||||
setup_terminal_backend(config)
|
||||
|
||||
+3
-4
@@ -215,7 +215,7 @@ TIPS = [
|
||||
# --- Context & Compression ---
|
||||
"Context auto-compresses when it reaches the threshold — memories are flushed and history summarized.",
|
||||
"The status bar turns yellow, then orange, then red as context fills up.",
|
||||
"SOUL.md at ~/.hermes/SOUL.md is the agent's primary identity — customize it to shape behavior.",
|
||||
"SOUL.md is the agent's primary identity file — customize it to shape behavior.",
|
||||
"Hermes loads project context from .hermes.md, AGENTS.md, CLAUDE.md, or .cursorrules (first match).",
|
||||
"Subdirectory AGENTS.md files are discovered progressively as the agent navigates into folders.",
|
||||
"Context files are capped at 20,000 characters with smart head/tail truncation.",
|
||||
@@ -273,7 +273,7 @@ TIPS = [
|
||||
"Cron scripts live in ~/.hermes/scripts/ and run before the agent — perfect for data collection pipelines.",
|
||||
"prefill_messages_file in config.yaml injects few-shot examples into every API call, never saved to history.",
|
||||
"SOUL.md completely replaces the agent's default identity — rewrite it to make Hermes your own.",
|
||||
"SOUL.md is auto-seeded with a default personality on first run. Edit ~/.hermes/SOUL.md to customize.",
|
||||
"SOUL.md is auto-seeded with a default personality on first run. Edit it to customize.",
|
||||
"/compress <focus topic> allocates 60-70% of the summary budget to your topic and aggressively trims the rest.",
|
||||
"On second+ compression, the compressor updates the previous summary instead of starting from scratch.",
|
||||
"Before a gateway session reset, Hermes auto-flushes important facts to memory in the background.",
|
||||
@@ -430,7 +430,7 @@ TIPS = [
|
||||
'hermes -z "<prompt>" is the purest one-shot: final answer on stdout, nothing else — ideal for piping in scripts.',
|
||||
'hermes chat --pass-session-id injects the session ID into the system prompt so the agent can self-reference it.',
|
||||
'hermes chat --image path/to/pic.png attaches a local image to a single -q query without a separate upload step.',
|
||||
'hermes chat --ignore-user-config skips ~/.hermes/config.yaml — reproducible bug reports and CI runs.',
|
||||
'hermes chat --ignore-user-config skips the active user config — reproducible bug reports and CI runs.',
|
||||
"hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.",
|
||||
'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.',
|
||||
'hermes sessions rename <ID> "new title" renames any past session; hermes sessions delete <ID> removes one.',
|
||||
@@ -485,4 +485,3 @@ def get_random_tip(exclude_recent: int = 0) -> str:
|
||||
"""
|
||||
return random.choice(TIPS)
|
||||
|
||||
|
||||
|
||||
+73
-34
@@ -1876,18 +1876,26 @@ def _visible_providers(
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Return provider entries visible for the current auth/config state."""
|
||||
"""Return provider entries visible for the current auth/config state.
|
||||
|
||||
Nous-managed Tool Gateway rows (``managed_nous_feature``) are always
|
||||
shown — even to logged-out / unentitled users — so the picker advertises
|
||||
that the capability exists. Selecting one drives an inline Nous Portal
|
||||
login + entitlement check (see ``_configure_provider``); the row only
|
||||
*activates* the gateway once paid access is confirmed.
|
||||
"""
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
managed_available = bool(
|
||||
features.account_info
|
||||
and features.account_info.logged_in
|
||||
and features.account_info.paid_service_access is True
|
||||
)
|
||||
visible = []
|
||||
for provider in cat.get("providers", []):
|
||||
if provider.get("managed_nous_feature") and not managed_available:
|
||||
continue
|
||||
if provider.get("requires_nous_auth") and not features.nous_auth_present:
|
||||
# Nous-managed Tool Gateway rows stay visible regardless of auth —
|
||||
# selecting one drives an inline Portal login. A `requires_nous_auth`
|
||||
# row that is NOT a managed gateway feature (pure pre-auth UX) is
|
||||
# still hidden until the user is logged in.
|
||||
if (
|
||||
provider.get("requires_nous_auth")
|
||||
and not provider.get("managed_nous_feature")
|
||||
and not features.nous_auth_present
|
||||
):
|
||||
continue
|
||||
visible.append(provider)
|
||||
|
||||
@@ -1933,22 +1941,16 @@ def _hidden_nous_gateway_message(
|
||||
*,
|
||||
force_fresh: bool = False,
|
||||
) -> str:
|
||||
"""Return a reason when a category's Nous provider is hidden."""
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
managed_available = bool(
|
||||
features.account_info
|
||||
and features.account_info.logged_in
|
||||
and features.account_info.paid_service_access is True
|
||||
)
|
||||
if managed_available:
|
||||
return ""
|
||||
if not any(p.get("managed_nous_feature") for p in cat.get("providers", [])):
|
||||
return ""
|
||||
message = format_nous_portal_entitlement_message(
|
||||
features.account_info,
|
||||
capability=capability,
|
||||
)
|
||||
return message or ""
|
||||
"""Deprecated: Nous Tool Gateway rows are no longer hidden.
|
||||
|
||||
Previously this returned a "log in / upgrade" banner shown above a
|
||||
category when its Nous-managed rows were filtered out for unentitled
|
||||
users. Those rows are now always listed (see ``_visible_providers``), and
|
||||
the login + entitlement guidance happens inline when the user selects one
|
||||
(``ensure_nous_portal_access``). Kept as a no-op so call sites stay simple;
|
||||
always returns an empty string.
|
||||
"""
|
||||
return ""
|
||||
|
||||
|
||||
_POST_SETUP_INSTALLED: dict = {
|
||||
@@ -2132,14 +2134,17 @@ def _configure_tool_category(
|
||||
configured = ""
|
||||
else:
|
||||
configured = " [configured]"
|
||||
# Highlight Nous-managed entries when the user has Portal auth.
|
||||
# curses_radiolist can't render ANSI inside item strings, so we
|
||||
# use a plain unicode star + parenthetical phrase. Suppressed
|
||||
# when no Portal auth is present so non-subscribers see the
|
||||
# picker unchanged.
|
||||
# Mark Nous-managed entries. Logged-in paid subscribers get the
|
||||
# "included" star; everyone else gets a "via Nous Portal" hint so
|
||||
# it's clear selecting the row triggers a Portal login. The rows
|
||||
# are always shown now (see _visible_providers) — selecting one
|
||||
# drives an inline login + entitlement check.
|
||||
sub_marker = ""
|
||||
if _nous_logged_in and p.get("managed_nous_feature"):
|
||||
sub_marker = " ★ Included with your Nous subscription"
|
||||
if p.get("managed_nous_feature"):
|
||||
if _nous_logged_in:
|
||||
sub_marker = " ★ Included with your Nous subscription"
|
||||
else:
|
||||
sub_marker = " ★ via Nous Portal (login on select)"
|
||||
provider_choices.append(f"{p['name']}{badge}{tag}{configured}{sub_marker}")
|
||||
|
||||
# Add skip option
|
||||
@@ -2558,7 +2563,26 @@ def _configure_provider(
|
||||
env_vars = provider.get("env_vars", [])
|
||||
managed_feature = provider.get("managed_nous_feature")
|
||||
|
||||
if provider.get("requires_nous_auth"):
|
||||
# Nous-managed Tool Gateway backends are always listed (see
|
||||
# _visible_providers), but only *activate* once the user has paid Nous
|
||||
# Portal access. Selecting one runs an inline Portal login when needed —
|
||||
# auth + entitlement only, no inference-provider switch and no bulk
|
||||
# "enable all tools" prompt (that lives in `hermes model`).
|
||||
if managed_feature:
|
||||
from hermes_cli.nous_subscription import ensure_nous_portal_access
|
||||
|
||||
if not ensure_nous_portal_access(
|
||||
capability=f"{provider.get('name', 'the Nous Tool Gateway')}"
|
||||
):
|
||||
_print_warning(
|
||||
" Not enabled — Nous Portal paid access is required for this backend."
|
||||
)
|
||||
return
|
||||
|
||||
# Pure pre-auth UX rows (requires_nous_auth without a managed gateway
|
||||
# feature) keep the old gate. Managed rows are handled by the inline
|
||||
# login above, so don't double-check them here.
|
||||
if provider.get("requires_nous_auth") and not managed_feature:
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
entitled = bool(
|
||||
features.account_info and features.account_info.paid_service_access is True
|
||||
@@ -2922,7 +2946,22 @@ def _reconfigure_provider(
|
||||
env_vars = provider.get("env_vars", [])
|
||||
managed_feature = provider.get("managed_nous_feature")
|
||||
|
||||
if provider.get("requires_nous_auth"):
|
||||
# Same inline Nous Portal login + entitlement gate as _configure_provider:
|
||||
# managed Tool Gateway backends only activate with paid Portal access.
|
||||
if managed_feature:
|
||||
from hermes_cli.nous_subscription import ensure_nous_portal_access
|
||||
|
||||
if not ensure_nous_portal_access(
|
||||
capability=f"{provider.get('name', 'the Nous Tool Gateway')}"
|
||||
):
|
||||
_print_warning(
|
||||
" Not enabled — Nous Portal paid access is required for this backend."
|
||||
)
|
||||
return
|
||||
|
||||
# Pure pre-auth UX rows keep the old gate; managed rows already handled
|
||||
# by the inline login above.
|
||||
if provider.get("requires_nous_auth") and not managed_feature:
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
entitled = bool(
|
||||
features.account_info and features.account_info.paid_service_access is True
|
||||
|
||||
@@ -117,6 +117,49 @@ def remove_wrapper_script():
|
||||
return removed
|
||||
|
||||
|
||||
def remove_node_symlinks(hermes_home: Path) -> list:
|
||||
"""Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.
|
||||
|
||||
The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
|
||||
creates::
|
||||
|
||||
~/.local/bin/node -> $HERMES_HOME/node/bin/node
|
||||
~/.local/bin/npm -> $HERMES_HOME/node/bin/npm
|
||||
~/.local/bin/npx -> $HERMES_HOME/node/bin/npx
|
||||
|
||||
and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node
|
||||
manager such as nvm. Symmetrically remove them on uninstall, but *only*
|
||||
when the link still resolves into this Hermes home's ``node`` directory.
|
||||
A link the user has since repointed at nvm (or anything else outside
|
||||
Hermes) is left untouched so we never break unrelated tooling.
|
||||
"""
|
||||
node_dir = (hermes_home / "node").resolve()
|
||||
removed = []
|
||||
|
||||
for name in ("node", "npm", "npx"):
|
||||
link = Path.home() / ".local" / "bin" / name
|
||||
try:
|
||||
# Only act on symlinks — never delete a real binary the user put here.
|
||||
if not link.is_symlink():
|
||||
continue
|
||||
|
||||
# Resolve the link target and confirm it points into our node dir.
|
||||
# os.readlink + manual join handles broken (dangling) links too;
|
||||
# Path.resolve() on a dangling link still returns the target path.
|
||||
target = Path(os.readlink(link))
|
||||
if not target.is_absolute():
|
||||
target = (link.parent / target)
|
||||
target = target.resolve()
|
||||
|
||||
if target == node_dir or node_dir in target.parents:
|
||||
link.unlink()
|
||||
removed.append(link)
|
||||
except Exception as e:
|
||||
log_warn(f"Could not remove {link}: {e}")
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
def uninstall_gateway_service():
|
||||
"""Stop and uninstall the gateway service (systemd, launchd, Windows
|
||||
Scheduled Task / Startup folder) and kill any standalone gateway processes.
|
||||
@@ -594,6 +637,17 @@ def run_uninstall(args):
|
||||
log_success(f"Removed {wrapper}")
|
||||
else:
|
||||
log_info("No wrapper script found")
|
||||
|
||||
# 3b. Remove node/npm/npx symlinks the installer left in ~/.local/bin
|
||||
# (only when they still point into this Hermes home's node dir, so we
|
||||
# never clobber an existing nvm / user-managed Node).
|
||||
log_info("Removing Hermes-managed node/npm/npx symlinks...")
|
||||
removed_node_links = remove_node_symlinks(hermes_home)
|
||||
if removed_node_links:
|
||||
for link in removed_node_links:
|
||||
log_success(f"Removed {link}")
|
||||
else:
|
||||
log_info("No Hermes-managed node/npm/npx symlinks found")
|
||||
|
||||
# 4. Remove installation directory (code)
|
||||
log_info("Removing installation directory...")
|
||||
|
||||
@@ -3371,10 +3371,19 @@ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
||||
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""Check if the WebSocket client IP is acceptable.
|
||||
|
||||
Loopback mode: only loopback clients allowed — the legacy
|
||||
Loopback bind: only loopback clients allowed — the legacy
|
||||
``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we
|
||||
don't want LAN hosts guessing tokens.
|
||||
|
||||
Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a
|
||||
specific address such as a Tailscale/LAN IP, always with
|
||||
``--insecure``): allow any peer. The operator explicitly opted into
|
||||
non-loopback exposure, so the loopback-only peer restriction does not
|
||||
apply. DNS-rebinding is still blocked by the Host/Origin guard in
|
||||
:func:`_ws_host_origin_is_allowed`, which mirrors the HTTP layer and
|
||||
requires the Host header to match the bound interface — the same
|
||||
defence ``_is_accepted_host`` applies to non-loopback HTTP requests.
|
||||
|
||||
Gated mode: any peer is allowed — uvicorn's ``proxy_headers=True``
|
||||
(enabled when the OAuth gate is active so cookies can pick up
|
||||
``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the
|
||||
@@ -3385,6 +3394,14 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""
|
||||
if getattr(app.state, "auth_required", False):
|
||||
return True
|
||||
# Any explicit non-loopback bind (0.0.0.0, ::, or a specific LAN /
|
||||
# Tailscale address) means the operator opted into non-loopback
|
||||
# access via --insecure. The loopback-only peer gate only applies to
|
||||
# an actual loopback bind; otherwise the WS handshake is rejected even
|
||||
# though same-bind HTTP requests pass _is_accepted_host.
|
||||
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
|
||||
if bound_host and bound_host not in _LOOPBACK_HOSTS:
|
||||
return True
|
||||
client_host = ws.client.host if ws.client else ""
|
||||
if not client_host:
|
||||
return True
|
||||
|
||||
+219
-61
@@ -72,6 +72,15 @@ _last_init_error_lock = threading.Lock()
|
||||
_wal_fallback_warned_paths: set[str] = set()
|
||||
_wal_fallback_warned_lock = threading.Lock()
|
||||
|
||||
_FTS_TRIGGERS = (
|
||||
"messages_fts_insert",
|
||||
"messages_fts_delete",
|
||||
"messages_fts_update",
|
||||
"messages_fts_trigram_insert",
|
||||
"messages_fts_trigram_delete",
|
||||
"messages_fts_trigram_update",
|
||||
)
|
||||
|
||||
|
||||
def _set_last_init_error(msg: Optional[str]) -> None:
|
||||
"""Record (or clear) the most recent state.db init failure.
|
||||
@@ -380,6 +389,8 @@ class SessionDB:
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._write_count = 0
|
||||
self._fts_enabled = False
|
||||
self._fts_unavailable_warned = False
|
||||
try:
|
||||
self._conn = sqlite3.connect(
|
||||
str(self.db_path),
|
||||
@@ -388,7 +399,6 @@ class SessionDB:
|
||||
# handles contention instead of sitting in SQLite's internal
|
||||
# busy handler for up to 30s.
|
||||
timeout=1.0,
|
||||
# Autocommit mode: Python's default isolation_level=""
|
||||
# auto-starts transactions on DML, which conflicts with our
|
||||
# explicit BEGIN IMMEDIATE. None = we manage transactions
|
||||
# ourselves.
|
||||
@@ -417,6 +427,111 @@ class SessionDB:
|
||||
|
||||
# ── Core write helper ──
|
||||
|
||||
@staticmethod
|
||||
def _is_fts5_unavailable_error(exc: sqlite3.OperationalError) -> bool:
|
||||
err = str(exc).lower()
|
||||
return "no such module" in err and "fts5" in err
|
||||
|
||||
def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None:
|
||||
self._fts_enabled = False
|
||||
if self._fts_unavailable_warned:
|
||||
return
|
||||
self._fts_unavailable_warned = True
|
||||
logger.warning(
|
||||
"SQLite FTS5 unavailable for %s; full-text session search "
|
||||
"disabled. This usually means Hermes is running on an "
|
||||
"unsupported install (e.g. a pip-installed or pip-managed "
|
||||
"Python whose bundled SQLite lacks FTS5) rather than a "
|
||||
"mainline install. Some features may be missing or behave "
|
||||
"differently. Install the supported way: "
|
||||
"https://hermes-agent.nousresearch.com (underlying error: %s)",
|
||||
self.db_path,
|
||||
exc,
|
||||
)
|
||||
|
||||
def _sqlite_supports_fts5(self, cursor: sqlite3.Cursor) -> bool:
|
||||
try:
|
||||
cursor.execute("CREATE VIRTUAL TABLE temp._hermes_fts5_probe USING fts5(x)")
|
||||
cursor.execute("DROP TABLE temp._hermes_fts5_probe")
|
||||
return True
|
||||
except sqlite3.OperationalError as exc:
|
||||
if not self._is_fts5_unavailable_error(exc):
|
||||
raise
|
||||
self._warn_fts5_unavailable(exc)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _drop_fts_triggers(cursor: sqlite3.Cursor) -> None:
|
||||
for trigger in _FTS_TRIGGERS:
|
||||
try:
|
||||
cursor.execute(f"DROP TRIGGER IF EXISTS {trigger}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _fts_trigger_count(cursor: sqlite3.Cursor) -> int:
|
||||
placeholders = ",".join("?" for _ in _FTS_TRIGGERS)
|
||||
row = cursor.execute(
|
||||
f"SELECT COUNT(*) FROM sqlite_master "
|
||||
f"WHERE type = 'trigger' AND name IN ({placeholders})",
|
||||
_FTS_TRIGGERS,
|
||||
).fetchone()
|
||||
return int(row[0] if not isinstance(row, sqlite3.Row) else row[0])
|
||||
|
||||
@staticmethod
|
||||
def _rebuild_fts_indexes(cursor: sqlite3.Cursor) -> None:
|
||||
for table_name in ("messages_fts", "messages_fts_trigram"):
|
||||
cursor.execute(f"DELETE FROM {table_name}")
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts(rowid, content) "
|
||||
"SELECT id, "
|
||||
"COALESCE(content, '') || ' ' || "
|
||||
"COALESCE(tool_name, '') || ' ' || "
|
||||
"COALESCE(tool_calls, '') "
|
||||
"FROM messages"
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts_trigram(rowid, content) "
|
||||
"SELECT id, "
|
||||
"COALESCE(content, '') || ' ' || "
|
||||
"COALESCE(tool_name, '') || ' ' || "
|
||||
"COALESCE(tool_calls, '') "
|
||||
"FROM messages"
|
||||
)
|
||||
|
||||
def _fts_table_probe(self, cursor: sqlite3.Cursor, table_name: str) -> Optional[bool]:
|
||||
try:
|
||||
cursor.execute(f"SELECT * FROM {table_name} LIMIT 0")
|
||||
return True
|
||||
except sqlite3.OperationalError as exc:
|
||||
if self._is_fts5_unavailable_error(exc):
|
||||
self._warn_fts5_unavailable(exc)
|
||||
return None
|
||||
if "no such table" in str(exc).lower():
|
||||
return False
|
||||
raise
|
||||
|
||||
def _ensure_fts_schema(
|
||||
self,
|
||||
cursor: sqlite3.Cursor,
|
||||
table_name: str,
|
||||
ddl: str,
|
||||
) -> bool:
|
||||
status = self._fts_table_probe(cursor, table_name)
|
||||
if status is None:
|
||||
return False
|
||||
try:
|
||||
# Run even when the virtual table exists so any dropped or missing
|
||||
# triggers are recreated after a previous no-FTS5 runtime disabled
|
||||
# them to keep message writes working.
|
||||
cursor.executescript(ddl)
|
||||
return True
|
||||
except sqlite3.OperationalError as exc:
|
||||
if not self._is_fts5_unavailable_error(exc):
|
||||
raise
|
||||
self._warn_fts5_unavailable(exc)
|
||||
return False
|
||||
|
||||
def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T:
|
||||
"""Execute a write transaction with BEGIN IMMEDIATE and jitter retry.
|
||||
|
||||
@@ -629,6 +744,16 @@ class SessionDB:
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.debug("idx_messages_platform_msg_id create skipped: %s", exc)
|
||||
|
||||
fts5_available = self._sqlite_supports_fts5(cursor)
|
||||
fts_migrations_complete = True
|
||||
if not fts5_available:
|
||||
# Existing FTS triggers can still fire on messages INSERT/UPDATE
|
||||
# even though the current sqlite runtime cannot read the virtual
|
||||
# tables they target. Drop only the triggers so core persistence
|
||||
# continues; if a future runtime has FTS5, _ensure_fts_schema()
|
||||
# recreates them.
|
||||
self._drop_fts_triggers(cursor)
|
||||
|
||||
# ── Schema version bookkeeping ─────────────────────────────────
|
||||
# Bump to current so future data migrations (if any) can gate on
|
||||
# version. No version-gated column additions remain.
|
||||
@@ -650,17 +775,24 @@ class SessionDB:
|
||||
# virtual table + triggers are created unconditionally via
|
||||
# FTS_TRIGRAM_SQL below, but existing rows need a one-time
|
||||
# backfill into the FTS index.
|
||||
try:
|
||||
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
|
||||
_fts_trigram_exists = True
|
||||
except sqlite3.OperationalError:
|
||||
_fts_trigram_exists = False
|
||||
if not _fts_trigram_exists:
|
||||
cursor.executescript(FTS_TRIGRAM_SQL)
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts_trigram(rowid, content) "
|
||||
"SELECT id, content FROM messages WHERE content IS NOT NULL"
|
||||
if fts5_available:
|
||||
_fts_trigram_exists = self._fts_table_probe(
|
||||
cursor, "messages_fts_trigram"
|
||||
)
|
||||
if _fts_trigram_exists is False:
|
||||
if self._ensure_fts_schema(
|
||||
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
|
||||
):
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts_trigram(rowid, content) "
|
||||
"SELECT id, content FROM messages WHERE content IS NOT NULL"
|
||||
)
|
||||
else:
|
||||
fts_migrations_complete = False
|
||||
elif _fts_trigram_exists is None:
|
||||
fts_migrations_complete = False
|
||||
else:
|
||||
fts_migrations_complete = False
|
||||
if current_version < 11:
|
||||
# v11: re-index FTS5 tables to cover tool_name + tool_calls and
|
||||
# switch from external-content to inline mode. Existing DBs have
|
||||
@@ -668,45 +800,50 @@ class SessionDB:
|
||||
# overwrite, so we drop them explicitly and let the post-migration
|
||||
# existence checks (below) recreate them from FTS_SQL /
|
||||
# FTS_TRIGRAM_SQL, then backfill every message row. Fixes #16751.
|
||||
for _trig in (
|
||||
"messages_fts_insert",
|
||||
"messages_fts_delete",
|
||||
"messages_fts_update",
|
||||
"messages_fts_trigram_insert",
|
||||
"messages_fts_trigram_delete",
|
||||
"messages_fts_trigram_update",
|
||||
):
|
||||
try:
|
||||
cursor.execute(f"DROP TRIGGER IF EXISTS {_trig}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
for _tbl in ("messages_fts", "messages_fts_trigram"):
|
||||
try:
|
||||
cursor.execute(f"DROP TABLE IF EXISTS {_tbl}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Recreate virtual tables + triggers with the new inline-mode
|
||||
# schema that indexes content || tool_name || tool_calls.
|
||||
cursor.executescript(FTS_SQL)
|
||||
cursor.executescript(FTS_TRIGRAM_SQL)
|
||||
# Backfill both indexes from every existing messages row.
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts(rowid, content) "
|
||||
"SELECT id, "
|
||||
"COALESCE(content, '') || ' ' || "
|
||||
"COALESCE(tool_name, '') || ' ' || "
|
||||
"COALESCE(tool_calls, '') "
|
||||
"FROM messages"
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts_trigram(rowid, content) "
|
||||
"SELECT id, "
|
||||
"COALESCE(content, '') || ' ' || "
|
||||
"COALESCE(tool_name, '') || ' ' || "
|
||||
"COALESCE(tool_calls, '') "
|
||||
"FROM messages"
|
||||
)
|
||||
if current_version < SCHEMA_VERSION:
|
||||
if fts5_available:
|
||||
self._drop_fts_triggers(cursor)
|
||||
for _tbl in ("messages_fts", "messages_fts_trigram"):
|
||||
try:
|
||||
cursor.execute(f"DROP TABLE IF EXISTS {_tbl}")
|
||||
except sqlite3.OperationalError as exc:
|
||||
if not self._is_fts5_unavailable_error(exc):
|
||||
raise
|
||||
self._warn_fts5_unavailable(exc)
|
||||
fts5_available = False
|
||||
fts_migrations_complete = False
|
||||
break
|
||||
|
||||
if fts5_available:
|
||||
# Recreate virtual tables + triggers with the new inline-mode
|
||||
# schema that indexes content || tool_name || tool_calls.
|
||||
if (
|
||||
self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL)
|
||||
and self._ensure_fts_schema(
|
||||
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
|
||||
)
|
||||
):
|
||||
# Backfill both indexes from every existing messages row.
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts(rowid, content) "
|
||||
"SELECT id, "
|
||||
"COALESCE(content, '') || ' ' || "
|
||||
"COALESCE(tool_name, '') || ' ' || "
|
||||
"COALESCE(tool_calls, '') "
|
||||
"FROM messages"
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts_trigram(rowid, content) "
|
||||
"SELECT id, "
|
||||
"COALESCE(content, '') || ' ' || "
|
||||
"COALESCE(tool_name, '') || ' ' || "
|
||||
"COALESCE(tool_calls, '') "
|
||||
"FROM messages"
|
||||
)
|
||||
else:
|
||||
fts_migrations_complete = False
|
||||
else:
|
||||
fts_migrations_complete = False
|
||||
if current_version < SCHEMA_VERSION and fts_migrations_complete:
|
||||
cursor.execute(
|
||||
"UPDATE schema_version SET version = ?",
|
||||
(SCHEMA_VERSION,),
|
||||
@@ -721,17 +858,22 @@ class SessionDB:
|
||||
except sqlite3.OperationalError:
|
||||
pass # Index already exists
|
||||
|
||||
# FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably)
|
||||
try:
|
||||
cursor.execute("SELECT * FROM messages_fts LIMIT 0")
|
||||
except sqlite3.OperationalError:
|
||||
cursor.executescript(FTS_SQL)
|
||||
if fts5_available:
|
||||
# FTS5 setup. Run the DDL even when the virtual table exists so
|
||||
# CREATE TRIGGER IF NOT EXISTS repairs trigger-only degradation from
|
||||
# an earlier no-FTS5 runtime.
|
||||
triggers_need_repair = self._fts_trigger_count(cursor) < len(_FTS_TRIGGERS)
|
||||
self._fts_enabled = self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL)
|
||||
|
||||
# Trigram FTS5 for CJK/substring search
|
||||
try:
|
||||
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
|
||||
except sqlite3.OperationalError:
|
||||
cursor.executescript(FTS_TRIGRAM_SQL)
|
||||
# Trigram FTS5 for CJK/substring search. This is optional relative
|
||||
# to the main FTS table; if it cannot be created, CJK search falls
|
||||
# back to LIKE.
|
||||
if self._fts_enabled:
|
||||
trigram_enabled = self._ensure_fts_schema(
|
||||
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
|
||||
)
|
||||
if trigram_enabled and triggers_need_repair:
|
||||
self._rebuild_fts_indexes(cursor)
|
||||
|
||||
self._conn.commit()
|
||||
|
||||
@@ -935,6 +1077,20 @@ class SessionDB:
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
def update_session_model(self, session_id: str, model: str) -> None:
|
||||
"""Update the model for a session after a mid-session switch.
|
||||
|
||||
Unlike ``update_token_counts`` which uses ``COALESCE(model, ?)``
|
||||
(only filling in NULL), this unconditionally sets the model column
|
||||
so that the dashboard reflects the user's latest /model choice.
|
||||
"""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"UPDATE sessions SET model = ? WHERE id = ?",
|
||||
(model, session_id),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
def update_token_counts(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -2317,6 +2473,9 @@ class SessionDB:
|
||||
ignores ``sort``. The trigram CJK path honours ``sort`` like the main
|
||||
FTS5 path.
|
||||
"""
|
||||
if not self._fts_enabled:
|
||||
return []
|
||||
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
|
||||
@@ -3513,4 +3672,3 @@ class SessionDB:
|
||||
(error[:500], session_id),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ gateway:
|
||||
title: "**Titel:** {title}"
|
||||
created: "**Geskep:** {timestamp}"
|
||||
last_activity: "**Laaste aktiwiteit:** {timestamp}"
|
||||
tokens: "**Tokens:** {tokens}"
|
||||
tokens: "**Kumulatiewe API-tokens (elke oproep weer gestuur):** {tokens}"
|
||||
agent_running: "**Agent loop:** {state}"
|
||||
state_yes: "Ja ⚡"
|
||||
state_no: "Nee"
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ gateway:
|
||||
title: "**Titel:** {title}"
|
||||
created: "**Erstellt:** {timestamp}"
|
||||
last_activity: "**Letzte Aktivität:** {timestamp}"
|
||||
tokens: "**Tokens:** {tokens}"
|
||||
tokens: "**Kumulierte API-Tokens (bei jedem Aufruf erneut gesendet):** {tokens}"
|
||||
agent_running: "**Agent läuft:** {state}"
|
||||
state_yes: "Ja ⚡"
|
||||
state_no: "Nein"
|
||||
|
||||
+1
-1
@@ -270,7 +270,7 @@ gateway:
|
||||
title: "**Title:** {title}"
|
||||
created: "**Created:** {timestamp}"
|
||||
last_activity: "**Last Activity:** {timestamp}"
|
||||
tokens: "**Tokens:** {tokens}"
|
||||
tokens: "**Cumulative API tokens (re-sent each call):** {tokens}"
|
||||
agent_running: "**Agent Running:** {state}"
|
||||
state_yes: "Yes ⚡"
|
||||
state_no: "No"
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ gateway:
|
||||
title: "**Título:** {title}"
|
||||
created: "**Creado:** {timestamp}"
|
||||
last_activity: "**Última actividad:** {timestamp}"
|
||||
tokens: "**Tokens:** {tokens}"
|
||||
tokens: "**Tokens de API acumulados (reenviados en cada llamada):** {tokens}"
|
||||
agent_running: "**Agente activo:** {state}"
|
||||
state_yes: "Sí ⚡"
|
||||
state_no: "No"
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ gateway:
|
||||
title: "**Título:** {title}"
|
||||
created: "**Criada:** {timestamp}"
|
||||
last_activity: "**Última atividade:** {timestamp}"
|
||||
tokens: "**Tokens:** {tokens}"
|
||||
tokens: "**Tokens de API cumulativos (reenviados a cada chamada):** {tokens}"
|
||||
agent_running: "**Agente em execução:** {state}"
|
||||
state_yes: "Sim ⚡"
|
||||
state_no: "Não"
|
||||
|
||||
@@ -32,14 +32,14 @@ Honcho provides AI-native cross-session user modeling. It learns who the user is
|
||||
### Cloud (app.honcho.dev)
|
||||
|
||||
```bash
|
||||
hermes honcho setup
|
||||
hermes memory setup honcho
|
||||
# select "cloud", paste API key from https://app.honcho.dev
|
||||
```
|
||||
|
||||
### Self-hosted
|
||||
|
||||
```bash
|
||||
hermes honcho setup
|
||||
hermes memory setup honcho
|
||||
# select "local", enter base URL (e.g. http://localhost:8000)
|
||||
```
|
||||
|
||||
|
||||
@@ -119,17 +119,20 @@ class BrowserUseBrowserProvider(BrowserProvider):
|
||||
return "Browser Use"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._get_config_or_none() is not None
|
||||
return self._get_config_or_none(refresh_token=False) is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config resolution (direct API key OR managed Nous gateway)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
|
||||
def _get_config_or_none(self, *, refresh_token: bool = True) -> Optional[Dict[str, Any]]:
|
||||
# Import here to avoid a hard dependency at module-import time —
|
||||
# managed_tool_gateway pulls in the Nous auth stack which can be
|
||||
# heavy and is not needed for direct-API-key users.
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
from tools.managed_tool_gateway import (
|
||||
peek_nous_access_token,
|
||||
resolve_managed_tool_gateway,
|
||||
)
|
||||
from tools.tool_backend_helpers import prefers_gateway
|
||||
|
||||
# Direct API key wins unless the user has explicitly opted into the
|
||||
@@ -142,7 +145,11 @@ class BrowserUseBrowserProvider(BrowserProvider):
|
||||
"managed_mode": False,
|
||||
}
|
||||
|
||||
managed = resolve_managed_tool_gateway("browser-use")
|
||||
# Keep availability scans off the synchronous OAuth refresh path.
|
||||
managed = resolve_managed_tool_gateway(
|
||||
"browser-use",
|
||||
token_reader=None if refresh_token else peek_nous_access_token,
|
||||
)
|
||||
if managed is None:
|
||||
return None
|
||||
|
||||
|
||||
+209
@@ -2600,6 +2600,13 @@
|
||||
// input here to save vertical space in the common `scratch` case.
|
||||
const [workspaceKind, setWorkspaceKind] = useState("scratch");
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
// Goal-mode: when on, the dispatched worker runs the Ralph-style /goal
|
||||
// loop — a judge re-checks the card after each turn and the worker keeps
|
||||
// going in the same session until done, or the turn budget runs out
|
||||
// (which blocks the card for review). goalMaxTurns is optional; blank
|
||||
// = backend default.
|
||||
const [goalMode, setGoalMode] = useState(false);
|
||||
const [goalMaxTurns, setGoalMaxTurns] = useState("");
|
||||
|
||||
const submit = function () {
|
||||
const trimmed = title.trim();
|
||||
@@ -2626,9 +2633,17 @@
|
||||
}
|
||||
const wpTrim = workspacePath.trim();
|
||||
if (wpTrim) body.workspace_path = wpTrim;
|
||||
// Goal-mode toggle. Only send the keys when enabled so the request
|
||||
// shape stays small and old dispatchers ignore it cleanly.
|
||||
if (goalMode) {
|
||||
body.goal_mode = true;
|
||||
const gmt = parseInt(goalMaxTurns, 10);
|
||||
if (Number.isFinite(gmt) && gmt > 0) body.goal_max_turns = gmt;
|
||||
}
|
||||
props.onSubmit(body);
|
||||
setTitle(""); setAssignee(""); setPriority(0); setParent(""); setSkills("");
|
||||
setWorkspaceKind("scratch"); setWorkspacePath("");
|
||||
setGoalMode(false); setGoalMaxTurns("");
|
||||
};
|
||||
|
||||
const showPathInput = workspaceKind !== "scratch";
|
||||
@@ -2685,6 +2700,29 @@
|
||||
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
|
||||
className: "h-7 text-xs",
|
||||
}),
|
||||
h("div", { className: "flex gap-2 items-center" },
|
||||
h("label", {
|
||||
className: "flex items-center gap-1.5 text-xs cursor-pointer select-none",
|
||||
title: "Goal mode: the worker keeps going in the same session until a judge agrees the card is done (or the turn budget runs out, which blocks it for review). Best for open-ended cards one shot rarely finishes.",
|
||||
},
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: goalMode,
|
||||
onChange: function (e) { setGoalMode(!!e.target.checked); },
|
||||
className: "h-3.5 w-3.5 accent-current",
|
||||
}),
|
||||
tx(t, "goalMode", "goal mode"),
|
||||
),
|
||||
goalMode ? h(Input, {
|
||||
type: "number",
|
||||
value: goalMaxTurns,
|
||||
onChange: function (e) { setGoalMaxTurns(e.target.value); },
|
||||
placeholder: tx(t, "goalMaxTurns", "max turns (default 20)"),
|
||||
className: "h-7 text-xs w-40",
|
||||
title: "Turn budget for the goal loop. Blank = backend default (20).",
|
||||
min: 1,
|
||||
}) : null,
|
||||
),
|
||||
h("div", { className: "flex gap-2" },
|
||||
h(Select, Object.assign({
|
||||
value: workspaceKind,
|
||||
@@ -2741,6 +2779,8 @@
|
||||
// Ready/Block/Complete buttons feel like no-ops. See #26744.
|
||||
const [patchErr, setPatchErr] = useState(null);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [uploadBusy, setUploadBusy] = useState(false);
|
||||
const [uploadErr, setUploadErr] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
// Home-channel notification toggles. homeChannels is the list of platforms
|
||||
// the user has a /sethome on; each entry has a `subscribed` bool telling
|
||||
@@ -2789,6 +2829,49 @@
|
||||
}).catch(function (e) { setErr(String(e.message || e)); });
|
||||
};
|
||||
|
||||
// File upload uses raw fetch (not SDK.fetchJSON, which JSON-encodes)
|
||||
// so the browser sets the multipart boundary. Auth rides the session
|
||||
// cookie + bearer token, matching the rest of the dashboard.
|
||||
const handleUpload = function (fileList) {
|
||||
const files = Array.prototype.slice.call(fileList || []);
|
||||
if (!files.length) return;
|
||||
setUploadBusy(true);
|
||||
setUploadErr(null);
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
|
||||
// Upload sequentially so a partial failure leaves a clear state.
|
||||
let chain = Promise.resolve();
|
||||
files.forEach(function (f) {
|
||||
chain = chain.then(function () {
|
||||
const fd = new FormData();
|
||||
fd.append("file", f, f.name);
|
||||
return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
chain.then(function () {
|
||||
load();
|
||||
props.onRefresh();
|
||||
}).catch(function (e) {
|
||||
setUploadErr(String(e.message || e));
|
||||
}).finally(function () {
|
||||
setUploadBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAttachment = function (attachmentId) {
|
||||
return SDK.fetchJSON(withBoard(`${API}/attachments/${attachmentId}`, boardSlug), { method: "DELETE" })
|
||||
.then(function () { load(); props.onRefresh(); })
|
||||
.catch(function (e) { setUploadErr(String(e.message || e)); });
|
||||
};
|
||||
|
||||
const doPatch = function (patch, opts) {
|
||||
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
|
||||
return Promise.resolve();
|
||||
@@ -2946,6 +3029,10 @@
|
||||
homeBusy: homeBusy,
|
||||
onToggleHomeSub: toggleHomeSubscription,
|
||||
onRefresh: props.onRefresh,
|
||||
onUpload: handleUpload,
|
||||
onDeleteAttachment: handleDeleteAttachment,
|
||||
uploadBusy: uploadBusy,
|
||||
uploadErr: uploadErr,
|
||||
}) : null,
|
||||
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
|
||||
h(Input, {
|
||||
@@ -2968,11 +3055,118 @@
|
||||
);
|
||||
}
|
||||
|
||||
function _fmtBytes(n) {
|
||||
n = Number(n) || 0;
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
// Attachments section in the task drawer (#35338). Upload button +
|
||||
// list with download links and a delete (×) per row. The download
|
||||
// link hits GET /attachments/:id which streams the file; the worker
|
||||
// context surfaces the same files' absolute paths so a kanban worker
|
||||
// can read them with the file/terminal tools.
|
||||
function AttachmentsSection(props) {
|
||||
const i18n = props.i18n;
|
||||
const atts = props.attachments || [];
|
||||
const fileRef = useRef(null);
|
||||
const [dlErr, setDlErr] = useState(null);
|
||||
// Download via authenticated fetch → blob → synthetic anchor click.
|
||||
// A plain <a href> can't carry the session header/bearer the dashboard
|
||||
// auth middleware requires in loopback mode, so fetch with the token
|
||||
// and hand the browser a blob URL instead.
|
||||
function downloadAttachment(a) {
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
|
||||
setDlErr(null);
|
||||
fetch(url, { headers: headers, credentials: "same-origin" })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
|
||||
});
|
||||
}
|
||||
return resp.blob();
|
||||
})
|
||||
.then(function (blob) {
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = objUrl;
|
||||
link.download = a.filename || "attachment";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(function () { URL.revokeObjectURL(objUrl); }, 10000);
|
||||
})
|
||||
.catch(function (e) { setDlErr(String(e.message || e)); });
|
||||
}
|
||||
return h("div", { className: "hermes-kanban-section" },
|
||||
h("div", { className: "hermes-kanban-section-head" },
|
||||
`${tx(i18n, "attachments", "Attachments")} (${atts.length})`),
|
||||
h("input", {
|
||||
ref: fileRef,
|
||||
type: "file",
|
||||
multiple: true,
|
||||
style: { display: "none" },
|
||||
onChange: function (e) {
|
||||
if (props.onUpload) props.onUpload(e.target.files);
|
||||
// Reset so selecting the same file again re-triggers onChange.
|
||||
try { e.target.value = ""; } catch (_e) { /* ignore */ }
|
||||
},
|
||||
}),
|
||||
h("div", { className: "flex items-center gap-2 mb-2" },
|
||||
h(Button, {
|
||||
size: "sm",
|
||||
variant: "outline",
|
||||
disabled: !!props.uploadBusy,
|
||||
onClick: function () { if (fileRef.current) fileRef.current.click(); },
|
||||
}, props.uploadBusy
|
||||
? tx(i18n, "uploading", "Uploading…")
|
||||
: tx(i18n, "uploadFile", "Upload file")),
|
||||
),
|
||||
(props.uploadErr || dlErr)
|
||||
? h("div", { className: "text-xs text-destructive mb-2" }, props.uploadErr || dlErr)
|
||||
: null,
|
||||
atts.length === 0
|
||||
? h("div", { className: "text-xs text-muted-foreground" },
|
||||
tx(i18n, "noAttachments", "— no attachments —"))
|
||||
: atts.map(function (a) {
|
||||
return h("div", {
|
||||
key: a.id,
|
||||
className: "flex items-center justify-between gap-2 py-1 text-sm",
|
||||
},
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "hermes-kanban-attachment-link truncate",
|
||||
title: a.filename,
|
||||
onClick: function () { downloadAttachment(a); },
|
||||
}, a.filename),
|
||||
h("span", { className: "text-xs text-muted-foreground whitespace-nowrap" },
|
||||
_fmtBytes(a.size)),
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "hermes-kanban-drawer-close",
|
||||
title: tx(i18n, "removeAttachment", "Remove attachment"),
|
||||
onClick: function () {
|
||||
if (window.confirm(tx(i18n, "confirmRemoveAttachment",
|
||||
"Remove this attachment?"))) {
|
||||
if (props.onDelete) props.onDelete(a.id);
|
||||
}
|
||||
},
|
||||
}, "×"),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetail(props) {
|
||||
const { t: i18n } = useI18n();
|
||||
const t = props.data.task;
|
||||
const comments = props.data.comments || [];
|
||||
const events = props.data.events || [];
|
||||
const attachments = props.data.attachments || [];
|
||||
const links = props.data.links || { parents: [], children: [] };
|
||||
|
||||
return h("div", { className: "hermes-kanban-drawer-body" },
|
||||
@@ -3005,6 +3199,12 @@
|
||||
label: tx(i18n, "skills", "Skills"),
|
||||
value: t.skills.join(", "),
|
||||
}) : null,
|
||||
t.goal_mode ? h(MetaRow, {
|
||||
label: tx(i18n, "goalMode", "Goal mode"),
|
||||
value: t.goal_max_turns
|
||||
? `on (max ${t.goal_max_turns} turns)`
|
||||
: "on",
|
||||
}) : null,
|
||||
t.created_by ? h(MetaRow, { label: tx(i18n, "createdBy", "Created by"), value: t.created_by }) : null,
|
||||
),
|
||||
h(StatusActions, {
|
||||
@@ -3042,6 +3242,15 @@
|
||||
h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")),
|
||||
h(MarkdownBlock, { source: t.result, enabled: props.renderMarkdown }),
|
||||
) : null,
|
||||
h(AttachmentsSection, {
|
||||
attachments: attachments,
|
||||
boardSlug: props.boardSlug,
|
||||
onUpload: props.onUpload,
|
||||
onDelete: props.onDeleteAttachment,
|
||||
uploadBusy: props.uploadBusy,
|
||||
uploadErr: props.uploadErr,
|
||||
i18n: i18n,
|
||||
}),
|
||||
h("div", { className: "hermes-kanban-section" },
|
||||
h("div", { className: "hermes-kanban-section-head" },
|
||||
`${tx(i18n, "comments", "Comments")} (${comments.length})`),
|
||||
|
||||
+44
@@ -334,6 +334,11 @@
|
||||
.hermes-kanban-drawer {
|
||||
width: min(var(--hermes-kanban-drawer-width, 640px), 92vw);
|
||||
height: 100vh;
|
||||
/* Dynamic viewport unit excludes the mobile browser's collapsing chrome
|
||||
(URL/nav bars) so the drawer's bottom row stays reachable. Falls back to
|
||||
100vh on browsers without dvh support. */
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
background: var(--color-card);
|
||||
border-left: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
@@ -352,10 +357,23 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.8rem;
|
||||
/* Honor the top safe-area inset (notch) so the task id / close button are
|
||||
not clipped on mobile. */
|
||||
padding-top: max(0.6rem, env(safe-area-inset-top));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
/* On mobile the dashboard shell renders a fixed top bar (min-h-14, hidden at
|
||||
the lg breakpoint). The drawer is a body-level z-60 overlay starting at the
|
||||
viewport top, so its header would sit behind that bar. Push the header down
|
||||
by the bar height (3.5rem) plus the top safe-area inset. */
|
||||
@media (max-width: 1023px) {
|
||||
.hermes-kanban-drawer-head {
|
||||
padding-top: calc(3.5rem + env(safe-area-inset-top));
|
||||
}
|
||||
}
|
||||
|
||||
.hermes-kanban-drawer-close {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
@@ -368,10 +386,33 @@
|
||||
}
|
||||
.hermes-kanban-drawer-close:hover { color: var(--color-foreground); }
|
||||
|
||||
/* Attachment download trigger — styled as a link, rendered as a <button>
|
||||
so the click handler can fetch with the session token (#35338). */
|
||||
.hermes-kanban-attachment-link {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
color: var(--color-primary, #6ea8fe);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.hermes-kanban-attachment-link:hover { text-decoration: underline; }
|
||||
|
||||
.hermes-kanban-drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem;
|
||||
/* When no comment row is rendered (loading / error states), the scrolling
|
||||
body is the bottom-most element — extend its bottom padding past the
|
||||
mobile browser chrome so the last content stays readable. */
|
||||
padding-bottom: max(0.9rem, calc(0.9rem + env(safe-area-inset-bottom)));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
@@ -530,6 +571,9 @@
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
/* Keep the comment input clear of the mobile browser nav bar / home
|
||||
indicator by extending the bottom padding with the safe-area inset. */
|
||||
padding-bottom: max(0.55rem, calc(0.55rem + env(safe-area-inset-bottom)));
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-card) 90%, transparent);
|
||||
}
|
||||
|
||||
@@ -43,9 +43,11 @@ import os
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status as http_status
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect, status as http_status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from hermes_cli import kanban_db
|
||||
@@ -186,6 +188,21 @@ def _comment_dict(c: kanban_db.Comment) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _attachment_dict(a: kanban_db.Attachment) -> dict[str, Any]:
|
||||
"""Serialise an Attachment for the drawer. ``stored_path`` is the
|
||||
absolute on-disk path workers read; the UI uses ``id`` for download."""
|
||||
return {
|
||||
"id": a.id,
|
||||
"task_id": a.task_id,
|
||||
"filename": a.filename,
|
||||
"content_type": a.content_type,
|
||||
"size": a.size,
|
||||
"uploaded_by": a.uploaded_by,
|
||||
"stored_path": a.stored_path,
|
||||
"created_at": a.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _run_dict(r: kanban_db.Run) -> dict[str, Any]:
|
||||
"""Serialise a Run for the drawer's Run history section."""
|
||||
return {
|
||||
@@ -531,6 +548,7 @@ def get_task(
|
||||
"task": task_d,
|
||||
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
|
||||
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
|
||||
"attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)],
|
||||
"links": _links_for(conn, task_id),
|
||||
"runs": [
|
||||
_run_dict(r)
|
||||
@@ -563,6 +581,8 @@ class CreateTaskBody(BaseModel):
|
||||
idempotency_key: Optional[str] = None
|
||||
max_runtime_seconds: Optional[int] = None
|
||||
skills: Optional[list[str]] = None
|
||||
goal_mode: bool = False
|
||||
goal_max_turns: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
@@ -585,6 +605,8 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
|
||||
idempotency_key=payload.idempotency_key,
|
||||
max_runtime_seconds=payload.max_runtime_seconds,
|
||||
skills=payload.skills,
|
||||
goal_mode=payload.goal_mode,
|
||||
goal_max_turns=payload.goal_max_turns,
|
||||
)
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
body: dict[str, Any] = {"task": _task_dict(task) if task else None}
|
||||
@@ -609,6 +631,165 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachments — upload / list / download / delete (#35338)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Cap a single upload so a runaway request can't fill the disk. 25 MB
|
||||
# comfortably covers PDFs, images, and source docs — the kanban use case.
|
||||
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def _safe_attachment_name(raw: str) -> str:
|
||||
"""Reduce a client-supplied filename to a safe basename.
|
||||
|
||||
Strips any directory components (``os.path.basename`` on both
|
||||
separators) so a malicious ``../../etc/passwd`` or ``C:\\x`` collapses
|
||||
to its leaf. Rejects empty / dotfile-only names. The result is only
|
||||
ever joined under the per-task attachments dir, never used verbatim
|
||||
as a path from the client.
|
||||
"""
|
||||
name = (raw or "").replace("\\", "/").split("/")[-1].strip()
|
||||
# Drop control chars and leading dots so we never write a dotfile or
|
||||
# a name with embedded NULs/newlines.
|
||||
name = "".join(ch for ch in name if ch.isprintable() and ch not in '\x00').strip()
|
||||
name = name.lstrip(".").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="invalid attachment filename")
|
||||
return name[:200]
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/attachments")
|
||||
def list_task_attachments(task_id: str, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
if kanban_db.get_task(conn, task_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
return {
|
||||
"attachments": [
|
||||
_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)
|
||||
]
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/attachments")
|
||||
async def upload_task_attachment(
|
||||
task_id: str,
|
||||
file: UploadFile = File(...),
|
||||
board: Optional[str] = Query(None),
|
||||
uploaded_by: Optional[str] = Form(None),
|
||||
):
|
||||
"""Store an uploaded file for a task and record its metadata.
|
||||
|
||||
The blob lands under ``attachments_root(board)/<task_id>/`` with a
|
||||
sanitised, collision-resolved name. The worker reads it via the
|
||||
absolute path surfaced in ``build_worker_context``.
|
||||
"""
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
if kanban_db.get_task(conn, task_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
|
||||
safe_name = _safe_attachment_name(file.filename or "")
|
||||
|
||||
# Stream to disk with a hard size cap so a huge upload can't fill
|
||||
# the disk. Read in chunks; abort + clean up if the cap is hit.
|
||||
dest_dir = kanban_db.task_attachments_dir(task_id, board=board)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Resolve name collisions: foo.pdf → foo (1).pdf, foo (2).pdf, …
|
||||
stem, dot, ext = safe_name.partition(".")
|
||||
candidate = safe_name
|
||||
n = 1
|
||||
while (dest_dir / candidate).exists():
|
||||
candidate = f"{stem} ({n}){dot}{ext}"
|
||||
n += 1
|
||||
dest_path = dest_dir / candidate
|
||||
|
||||
total = 0
|
||||
try:
|
||||
with open(dest_path, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > _MAX_ATTACHMENT_BYTES:
|
||||
out.close()
|
||||
dest_path.unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=(
|
||||
f"attachment exceeds {_MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB limit"
|
||||
),
|
||||
)
|
||||
out.write(chunk)
|
||||
except HTTPException:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"failed to store attachment: {exc}")
|
||||
|
||||
att_id = kanban_db.add_attachment(
|
||||
conn,
|
||||
task_id,
|
||||
filename=candidate,
|
||||
stored_path=str(dest_path.resolve()),
|
||||
content_type=file.content_type,
|
||||
size=total,
|
||||
uploaded_by=(uploaded_by or "dashboard"),
|
||||
)
|
||||
att = kanban_db.get_attachment(conn, att_id)
|
||||
return {"attachment": _attachment_dict(att) if att else None}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/attachments/{attachment_id}")
|
||||
def download_attachment(attachment_id: int, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
att = kanban_db.get_attachment(conn, attachment_id)
|
||||
if att is None:
|
||||
raise HTTPException(status_code=404, detail="attachment not found")
|
||||
# Confirm the blob still lives under the board's attachments root
|
||||
# before serving — defense in depth against a tampered DB row.
|
||||
root = kanban_db.attachments_root(board=board).resolve()
|
||||
try:
|
||||
stored = Path(att.stored_path).resolve()
|
||||
stored.relative_to(root)
|
||||
except (ValueError, OSError):
|
||||
raise HTTPException(status_code=404, detail="attachment file unavailable")
|
||||
if not stored.is_file():
|
||||
raise HTTPException(status_code=404, detail="attachment file missing on disk")
|
||||
return FileResponse(
|
||||
path=str(stored),
|
||||
filename=att.filename,
|
||||
media_type=att.content_type or "application/octet-stream",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/attachments/{attachment_id}")
|
||||
def remove_attachment(attachment_id: int, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
att = kanban_db.delete_attachment(conn, attachment_id)
|
||||
if att is None:
|
||||
raise HTTPException(status_code=404, detail="attachment not found")
|
||||
return {"ok": True, "id": attachment_id}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /tasks/:id (status / assignee / priority / title / body)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -633,7 +633,8 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
except Exception:
|
||||
pass
|
||||
existing.update(values)
|
||||
config_path.write_text(json.dumps(existing, indent=2))
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(config_path, existing, mode=0o600)
|
||||
|
||||
def post_setup(self, hermes_home: str, config: dict) -> None:
|
||||
"""Custom setup wizard — installs only the deps needed for the selected mode."""
|
||||
|
||||
@@ -12,8 +12,8 @@ AI-native cross-session user modeling with multi-pass dialectic reasoning, sessi
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
hermes honcho setup # full interactive wizard (cloud or local)
|
||||
hermes memory setup # generic picker, also works
|
||||
hermes memory setup honcho # configure Honcho directly (works on a fresh install)
|
||||
hermes memory setup # generic picker, choose Honcho from the list
|
||||
```
|
||||
|
||||
Or manually:
|
||||
@@ -22,6 +22,10 @@ hermes config set memory.provider honcho
|
||||
echo "HONCHO_API_KEY=***" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
> `hermes honcho setup` also works, but only **after** Honcho is the active
|
||||
> memory provider — the `honcho` subcommand is registered for the active
|
||||
> provider only. On a fresh install, use `hermes memory setup honcho`.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Two-Layer Context Injection
|
||||
@@ -109,7 +113,7 @@ Config is read from the first file that exists:
|
||||
| 2 | `~/.hermes/honcho.json` | Default profile (shared host blocks) |
|
||||
| 3 | `~/.honcho/config.json` | Global (cross-app interop) |
|
||||
|
||||
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>`.
|
||||
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>`.
|
||||
|
||||
For every key, resolution order is: **host block > root > env var > default**.
|
||||
|
||||
@@ -154,7 +158,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
|
||||
|
||||
**Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`.
|
||||
|
||||
**Deployment shapes** (`hermes honcho setup` asks one prompt to set these):
|
||||
**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these):
|
||||
|
||||
- **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc.
|
||||
- **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans.
|
||||
@@ -225,7 +229,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i
|
||||
"recallMode": "hybrid",
|
||||
"sessionStrategy": "per-directory"
|
||||
},
|
||||
"hermes.coder": {
|
||||
"hermes_coder": {
|
||||
"aiPeer": "coder",
|
||||
"recallMode": "tools",
|
||||
"sessionStrategy": "per-repo"
|
||||
@@ -236,7 +240,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i
|
||||
|
||||
Both profiles see the same user (`yourname`) in the same shared environment (`hermes`), but each AI peer builds its own observations, conclusions, and behavior patterns. The coder's memory stays code-oriented; the main agent's stays broad.
|
||||
|
||||
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>` (e.g. `hermes -p coder` → host key `hermes.coder`).
|
||||
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>` (e.g. `hermes -p coder` -> host key `hermes_coder`). Older `hermes.<profile>` host blocks are still read for compatibility and are migrated when the CLI writes profile-scoped Honcho config.
|
||||
|
||||
### Dialectic & Reasoning
|
||||
|
||||
@@ -307,7 +311,8 @@ Presets:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes honcho setup` | Full interactive setup wizard |
|
||||
| `hermes memory setup honcho` | Configure Honcho directly — works on a fresh install |
|
||||
| `hermes honcho setup` | Interactive setup wizard (only registered once Honcho is the active provider; redirects to `hermes memory setup`) |
|
||||
| `hermes honcho status` | Show resolved config for active profile |
|
||||
| `hermes honcho enable` / `disable` | Toggle Honcho for active profile |
|
||||
| `hermes honcho mode <mode>` | Change recall or observation mode |
|
||||
@@ -344,7 +349,7 @@ Presets:
|
||||
"dialecticMaxChars": 600,
|
||||
"saveMessages": true
|
||||
},
|
||||
"hermes.coder": {
|
||||
"hermes_coder": {
|
||||
"enabled": true,
|
||||
"aiPeer": "coder",
|
||||
"sessionStrategy": "per-repo",
|
||||
|
||||
@@ -249,6 +249,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
def save_config(self, values, hermes_home):
|
||||
"""Write config to $HERMES_HOME/honcho.json (Honcho SDK native format)."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
config_path = Path(hermes_home) / "honcho.json"
|
||||
existing = {}
|
||||
@@ -258,7 +259,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
except Exception:
|
||||
pass
|
||||
existing.update(values)
|
||||
config_path.write_text(json.dumps(existing, indent=2))
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(config_path, existing, mode=0o600)
|
||||
|
||||
def get_config_schema(self):
|
||||
return [
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path, HOST
|
||||
from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def clone_honcho_for_profile(profile_name: str) -> bool:
|
||||
if not default_block and not has_key:
|
||||
return False
|
||||
|
||||
new_host = f"{HOST}.{profile_name}"
|
||||
new_host = profile_host_key(profile_name)
|
||||
if new_host in hosts:
|
||||
return False # already exists
|
||||
|
||||
@@ -192,7 +192,7 @@ def cmd_sync(args) -> None:
|
||||
if p.name == "default":
|
||||
continue
|
||||
if clone_honcho_for_profile(p.name):
|
||||
print(f" + {p.name} -> hermes.{p.name}")
|
||||
print(f" + {p.name} -> {profile_host_key(p.name)}")
|
||||
created += 1
|
||||
else:
|
||||
skipped += 1
|
||||
@@ -243,7 +243,7 @@ def _host_key() -> str:
|
||||
if _profile_override:
|
||||
if _profile_override in {"default", "custom"}:
|
||||
return HOST
|
||||
return f"{HOST}.{_profile_override}"
|
||||
return profile_host_key(_profile_override)
|
||||
return resolve_active_host()
|
||||
|
||||
|
||||
@@ -275,10 +275,8 @@ def _read_config() -> dict:
|
||||
def _write_config(cfg: dict, path: Path | None = None) -> None:
|
||||
path = path or _local_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(path, cfg, mode=0o600)
|
||||
|
||||
|
||||
def _resolve_api_key(cfg: dict) -> str:
|
||||
@@ -292,7 +290,7 @@ def _resolve_api_key(cfg: dict) -> str:
|
||||
config shapes, e.g. ``localhost:8000``) still pass — the Honcho SDK
|
||||
will reject them itself with a clearer error than ours.
|
||||
"""
|
||||
host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey")
|
||||
host_key = _host_block(cfg, _host_key()).get("apiKey")
|
||||
key = host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
|
||||
if not key:
|
||||
base_url = cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "")
|
||||
@@ -462,21 +460,58 @@ def cmd_setup(args) -> None:
|
||||
cfg.pop("base_url", None)
|
||||
|
||||
if is_local:
|
||||
# --- Local: ask for base URL, skip or clear API key ---
|
||||
# --- Local: ask for base URL, optionally accept a JWT for auth ---
|
||||
current_url = cfg.get("baseUrl") or ""
|
||||
new_url = _prompt("Base URL", default=current_url or "http://localhost:8000")
|
||||
if new_url:
|
||||
cfg["baseUrl"] = new_url
|
||||
|
||||
# For local no-auth, the SDK must not send an API key.
|
||||
# We keep the key in config (for cloud switching later) but
|
||||
# the client should skip auth when baseUrl is local.
|
||||
current_key = cfg.get("apiKey", "")
|
||||
if current_key:
|
||||
print(f"\n API key present in config (kept for cloud/hybrid use).")
|
||||
print(" Local connections will skip auth automatically.")
|
||||
# Self-hosted Honcho can run with AUTH_USE_AUTH=true and an
|
||||
# AUTH_JWT_SECRET on the server side. In that case clients must
|
||||
# send a JWT signed with that secret as the bearer token (the
|
||||
# Honcho SDK takes it via ``api_key=``). Cloud users got prompted
|
||||
# for a key already; the local path historically skipped this and
|
||||
# forced users to disable auth on the server. Offer the prompt
|
||||
# here too. We store it under the host block (not the top-level
|
||||
# apiKey) so ``get_honcho_client`` recognises it as an explicit
|
||||
# local auth opt-in (see ``_host_has_key`` in client.py) and
|
||||
# cloud/hybrid switching is unaffected.
|
||||
current_host_key = hermes_host.get("apiKey", "")
|
||||
masked = (
|
||||
f"...{current_host_key[-8:]}"
|
||||
if len(current_host_key) > 8
|
||||
else ("set" if current_host_key else "not set")
|
||||
)
|
||||
print(
|
||||
"\n Local Honcho auth (JWT signed with the server's "
|
||||
"AUTH_JWT_SECRET)."
|
||||
)
|
||||
print(
|
||||
" Leave blank if your server runs with AUTH_USE_AUTH=false. "
|
||||
f"Current: {masked}"
|
||||
)
|
||||
new_local_key = _prompt(
|
||||
"Local JWT / bearer token (blank to skip / keep current)",
|
||||
secret=True,
|
||||
)
|
||||
if new_local_key:
|
||||
hermes_host["apiKey"] = new_local_key
|
||||
elif current_host_key:
|
||||
print(" Keeping existing local JWT.")
|
||||
else:
|
||||
print("\n No API key set. Local no-auth ready.")
|
||||
# Surface the top-level key situation for transparency.
|
||||
top_key = cfg.get("apiKey", "")
|
||||
if top_key:
|
||||
print(
|
||||
"\n Top-level API key present in config (kept for "
|
||||
"cloud/hybrid use)."
|
||||
)
|
||||
print(
|
||||
" Local connections will skip auth automatically "
|
||||
"until a local JWT is set above."
|
||||
)
|
||||
else:
|
||||
print("\n No local JWT set. Local no-auth ready.")
|
||||
else:
|
||||
# --- Cloud: set default base URL, require API key ---
|
||||
cfg.pop("baseUrl", None) # cloud uses SDK default
|
||||
|
||||
@@ -32,6 +32,24 @@ logger = logging.getLogger(__name__)
|
||||
HOST = "hermes"
|
||||
|
||||
|
||||
def profile_host_key(profile: str | None) -> str:
|
||||
"""Return the safe Honcho host key for a Hermes profile."""
|
||||
if not profile or profile in {"default", "custom"}:
|
||||
return HOST
|
||||
sanitized = "".join(c if c.isalnum() or c in "_-" else "_" for c in profile).strip("_")
|
||||
return f"{HOST}_{sanitized or 'profile'}"
|
||||
|
||||
|
||||
def _host_block(raw: dict, host: str) -> dict:
|
||||
"""Return host config, accepting legacy dot-form profile host keys."""
|
||||
hosts = raw.get("hosts") or {}
|
||||
block = hosts.get(host, {})
|
||||
if block or not host.startswith(f"{HOST}_"):
|
||||
return block
|
||||
legacy = f"{HOST}.{host[len(HOST) + 1:]}"
|
||||
return hosts.get(legacy, {})
|
||||
|
||||
|
||||
def resolve_active_host() -> str:
|
||||
"""Derive the Honcho host key from the active Hermes profile.
|
||||
|
||||
@@ -47,8 +65,7 @@ def resolve_active_host() -> str:
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
profile = get_active_profile_name()
|
||||
if profile and profile not in {"default", "custom"}:
|
||||
return f"{HOST}.{profile}"
|
||||
return profile_host_key(profile)
|
||||
except Exception:
|
||||
pass
|
||||
return HOST
|
||||
@@ -406,7 +423,7 @@ class HonchoClientConfig:
|
||||
logger.warning("Failed to read %s: %s, falling back to env", path, e)
|
||||
return cls.from_env(host=resolved_host)
|
||||
|
||||
host_block = (raw.get("hosts") or {}).get(resolved_host, {})
|
||||
host_block = _host_block(raw, resolved_host)
|
||||
# A hosts.hermes block or explicit enabled flag means the user
|
||||
# intentionally configured Honcho for this host.
|
||||
_explicitly_configured = bool(host_block) or raw.get("enabled") is True
|
||||
@@ -811,7 +828,10 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
or "::1" in resolved_base_url
|
||||
)
|
||||
if _is_local:
|
||||
# Check if the host block has its own apiKey (explicit local auth)
|
||||
# Check if the host block has its own apiKey (explicit local auth).
|
||||
# Auth-skipping is loopback-only: a stored key is likely a cloud key
|
||||
# that would break a no-auth local server, so we substitute the SDK's
|
||||
# required-non-empty placeholder unless the host block opts in.
|
||||
_raw = config.raw or {}
|
||||
_host_block = (_raw.get("hosts") or {}).get(config.host, {})
|
||||
_host_has_key = bool(_host_block.get("apiKey"))
|
||||
@@ -819,6 +839,18 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
else:
|
||||
effective_api_key = config.api_key
|
||||
|
||||
# The Honcho SDK's route builders (e.g. routes.workspaces()) already
|
||||
# include the version prefix (e.g. "/v3/workspaces"). When a user-supplied
|
||||
# base_url already ends in a version segment (e.g.
|
||||
# "http://localhost:38000/v3", "https://honcho.my.ts.net/v3"), concatenating
|
||||
# the two produces "/v3/v3/workspaces" → 404 on every call. This is a pure
|
||||
# routing concern independent of host, so strip a trailing version segment
|
||||
# from ANY base_url — loopback, LAN, custom domain, or cloud alike. The
|
||||
# SDK then appends its own versioned paths correctly.
|
||||
if resolved_base_url:
|
||||
import re as _re
|
||||
resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/")
|
||||
|
||||
kwargs: dict = {
|
||||
"workspace_id": config.workspace_id,
|
||||
"api_key": effective_api_key,
|
||||
|
||||
@@ -155,7 +155,8 @@ class Mem0MemoryProvider(MemoryProvider):
|
||||
except Exception:
|
||||
pass
|
||||
existing.update(values)
|
||||
config_path.write_text(json.dumps(existing, indent=2))
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(config_path, existing, mode=0o600)
|
||||
|
||||
def get_config_schema(self):
|
||||
return [
|
||||
|
||||
@@ -152,7 +152,8 @@ def _save_supermemory_config(values: dict, hermes_home: str) -> None:
|
||||
except Exception:
|
||||
existing = {}
|
||||
existing.update(values)
|
||||
config_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(config_path, existing, mode=0o600, sort_keys=True)
|
||||
|
||||
|
||||
def _detect_category(text: str) -> str:
|
||||
|
||||
@@ -6093,16 +6093,17 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
|
||||
``gateway/config.py::load_gateway_config()`` before this migration.
|
||||
|
||||
The DiscordAdapter reads its runtime configuration via ``os.getenv()``
|
||||
throughout the connect / handle code paths (``DISCORD_REQUIRE_MENTION``,
|
||||
``DISCORD_FREE_RESPONSE_CHANNELS``, ``DISCORD_AUTO_THREAD``,
|
||||
``DISCORD_REACTIONS``, ``DISCORD_IGNORED_CHANNELS``,
|
||||
``DISCORD_ALLOWED_CHANNELS``, ``DISCORD_NO_THREAD_CHANNELS``,
|
||||
``DISCORD_HISTORY_BACKFILL``, ``DISCORD_HISTORY_BACKFILL_LIMIT``,
|
||||
``DISCORD_ALLOW_MENTION_*``, ``DISCORD_REPLY_TO_MODE``,
|
||||
``DISCORD_THREAD_REQUIRE_MENTION``). Rather than rewrite ~50 call sites
|
||||
inside the adapter to read from ``PlatformConfig.extra`` instead, this
|
||||
hook keeps the existing env-driven model and merely owns the
|
||||
YAML→env translation here, next to the adapter that consumes it.
|
||||
throughout the connect / handle code paths (``DISCORD_ALLOWED_USERS``,
|
||||
``DISCORD_REQUIRE_MENTION``, ``DISCORD_FREE_RESPONSE_CHANNELS``,
|
||||
``DISCORD_AUTO_THREAD``, ``DISCORD_REACTIONS``,
|
||||
``DISCORD_IGNORED_CHANNELS``, ``DISCORD_ALLOWED_CHANNELS``,
|
||||
``DISCORD_NO_THREAD_CHANNELS``, ``DISCORD_HISTORY_BACKFILL``,
|
||||
``DISCORD_HISTORY_BACKFILL_LIMIT``, ``DISCORD_ALLOW_MENTION_*``,
|
||||
``DISCORD_REPLY_TO_MODE``, ``DISCORD_THREAD_REQUIRE_MENTION``).
|
||||
Rather than rewrite ~50 call sites inside the adapter to read from
|
||||
``PlatformConfig.extra`` instead, this hook keeps the existing
|
||||
env-driven model and merely owns the YAML→env translation here, next to
|
||||
the adapter that consumes it.
|
||||
|
||||
Env vars take precedence over YAML — every assignment is guarded by
|
||||
``not os.getenv(...)`` so explicit env vars survive a config.yaml
|
||||
@@ -6113,6 +6114,22 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
|
||||
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
|
||||
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
|
||||
platforms_cfg = yaml_cfg.get("platforms")
|
||||
platform_extra_cfg = {}
|
||||
if isinstance(platforms_cfg, dict):
|
||||
discord_platform_cfg = platforms_cfg.get("discord")
|
||||
if isinstance(discord_platform_cfg, dict):
|
||||
candidate_extra = discord_platform_cfg.get("extra")
|
||||
if isinstance(candidate_extra, dict):
|
||||
platform_extra_cfg = candidate_extra
|
||||
allowed_users_cfg = (
|
||||
discord_cfg["allow_from"] if "allow_from" in discord_cfg
|
||||
else platform_extra_cfg.get("allow_from")
|
||||
)
|
||||
if allowed_users_cfg is not None and not os.getenv("DISCORD_ALLOWED_USERS"):
|
||||
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)
|
||||
frc = discord_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
|
||||
@@ -146,16 +146,16 @@ def _get_firecrawl_gateway_url() -> str:
|
||||
def _is_tool_gateway_ready() -> bool:
|
||||
"""Return True when gateway URL + Nous Subscriber token are available.
|
||||
|
||||
Reads ``read_nous_access_token`` and ``resolve_managed_tool_gateway``
|
||||
Reads ``peek_nous_access_token`` and ``resolve_managed_tool_gateway``
|
||||
via :mod:`tools.web_tools` rather than direct imports, so unit tests
|
||||
that ``patch("tools.web_tools._read_nous_access_token", ...)`` see
|
||||
that ``patch("tools.web_tools._peek_nous_access_token", ...)`` see
|
||||
their patches honored. The names are re-exported on
|
||||
:mod:`tools.web_tools` for exactly this reason.
|
||||
"""
|
||||
import tools.web_tools as _wt
|
||||
|
||||
return _wt.resolve_managed_tool_gateway(
|
||||
"firecrawl", token_reader=_wt._read_nous_access_token
|
||||
"firecrawl", token_reader=_wt._peek_nous_access_token
|
||||
) is not None
|
||||
|
||||
|
||||
|
||||
+14
-5
@@ -83,7 +83,7 @@ edge-tts = ["edge-tts==7.2.7"]
|
||||
modal = ["modal==1.3.4"]
|
||||
daytona = ["daytona==0.155.0"]
|
||||
hindsight = ["hindsight-client==0.6.1"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710
|
||||
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
|
||||
cron = [] # croniter is now a core dependency; this extra kept for back-compat
|
||||
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"]
|
||||
@@ -108,14 +108,21 @@ pty = [
|
||||
"pywinpty==2.0.15; sys_platform == 'win32'",
|
||||
]
|
||||
honcho = ["honcho-ai==2.0.1"]
|
||||
mcp = ["mcp==1.26.0"]
|
||||
# CVE-2026-48710 (BadHost): Starlette is pulled transitively by mcp's
|
||||
# sse-starlette / HTTP-SSE stack (and by fastapi in the `web` extra). Before
|
||||
# 1.0.1, a malformed Host header makes `request.url.path` desync from the path
|
||||
# the ASGI router actually dispatched, so middleware/endpoints that gate on
|
||||
# `request.url` can be bypassed. We pin a patched Starlette directly in every
|
||||
# extra that exposes a Starlette-backed server surface so pip/uv can't resolve
|
||||
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
|
||||
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
|
||||
homeassistant = ["aiohttp==3.13.3"]
|
||||
sms = ["aiohttp==3.13.3"]
|
||||
# Computer use — macOS background desktop control via cua-driver (MCP stdio).
|
||||
# The cua-driver binary itself is installed via `hermes tools` post-setup
|
||||
# (curl install script); this extra just pins the MCP client used to talk
|
||||
# to it, which is already provided by the `mcp` extra.
|
||||
computer-use = ["mcp==1.26.0"]
|
||||
computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
|
||||
acp = ["agent-client-protocol==0.9.0"]
|
||||
# mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version.
|
||||
# The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious
|
||||
@@ -168,7 +175,9 @@ youtube = [
|
||||
"youtube-transcript-api==1.2.4",
|
||||
]
|
||||
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
|
||||
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0"]
|
||||
# starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette
|
||||
# transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above.
|
||||
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1"]
|
||||
all = [
|
||||
# Policy (2026-05-12): `[all]` includes only extras that genuinely
|
||||
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
|
||||
@@ -210,7 +219,7 @@ hermes-agent = "run_agent:main"
|
||||
hermes-acp = "acp_adapter.entry:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"]
|
||||
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils", "mcp_serve"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
|
||||
|
||||
+175
-5
@@ -2106,13 +2106,48 @@ class AIAgent:
|
||||
pass
|
||||
return True # safe default: verifier on
|
||||
|
||||
@staticmethod
|
||||
def _format_file_mutation_failure_footer(failed: Dict[str, Dict[str, Any]]) -> str:
|
||||
# Bare absolute / home / Windows-drive file paths in a footer line.
|
||||
# Anchors mirror the gateway's ``extract_local_files`` bare-path
|
||||
# detector so that anything the gateway WOULD auto-attach is wrapped
|
||||
# in inline-code backticks here first (the extractor skips paths inside
|
||||
# `code` spans). Defense-in-depth: even if a future error message
|
||||
# echoes a credential path (config.yaml, .env, auth.json) into the
|
||||
# user-facing footer, it can never be matched as a deliverable bare
|
||||
# path and silently uploaded to a messaging channel (#35584).
|
||||
_FOOTER_PATH_RE = re.compile(
|
||||
r"(?<![/:\w.`])(?:~/|/|[A-Za-z]:[/\\])(?:[\w.\-]+[/\\])*[\w.\-]+\.[\w]+",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _neutralize_footer_paths(cls, text: str) -> str:
|
||||
"""Wrap bare file paths in backticks so they aren't auto-delivered.
|
||||
|
||||
The gateway's ``extract_local_files`` scans response text for bare
|
||||
absolute/home paths ending in a deliverable extension and uploads
|
||||
any that exist on disk as native attachments — but it explicitly
|
||||
skips paths inside inline-code (`` `...` ``) spans. Backticking
|
||||
every path the footer renders defeats that auto-detection while
|
||||
keeping the path fully human-readable. Paths already wrapped in a
|
||||
backtick (the negative lookbehind excludes a preceding `` ` ``) are
|
||||
left untouched so we never double-wrap.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
return cls._FOOTER_PATH_RE.sub(lambda m: f"`{m.group(0)}`", text)
|
||||
|
||||
@classmethod
|
||||
def _format_file_mutation_failure_footer(cls, failed: Dict[str, Dict[str, Any]]) -> str:
|
||||
"""Render the per-turn failed-mutation dict as a user-facing footer.
|
||||
|
||||
Displays up to 10 paths with their first error preview, then a
|
||||
count of any additional failures. Returns an empty string when
|
||||
the dict is empty so callers can concatenate unconditionally.
|
||||
|
||||
Every file path that reaches the user-facing text — both the bullet
|
||||
path and any path echoed inside the tool's error preview — is
|
||||
backtick-wrapped via ``_neutralize_footer_paths`` so the gateway's
|
||||
bare-path media extractor can never auto-attach a protected file
|
||||
(e.g. ``~/.hermes/config.yaml``) to a messaging channel (#35584).
|
||||
"""
|
||||
if not failed:
|
||||
return ""
|
||||
@@ -2129,14 +2164,137 @@ class AIAgent:
|
||||
preview = (info.get("error_preview") or "").strip()
|
||||
tool = info.get("tool") or "patch"
|
||||
if preview:
|
||||
lines.append(f" • {path} — [{tool}] {preview}")
|
||||
lines.append(f" • `{path}` — [{tool}] {preview}")
|
||||
else:
|
||||
lines.append(f" • {path} — [{tool}] failed")
|
||||
lines.append(f" • `{path}` — [{tool}] failed")
|
||||
shown += 1
|
||||
remaining = len(failed) - shown
|
||||
if remaining > 0:
|
||||
lines.append(f" • … and {remaining} more")
|
||||
return "\n".join(lines)
|
||||
# Neutralize any path the preview text echoed (the bullet path is
|
||||
# already backticked above; the lookbehind keeps it from being
|
||||
# double-wrapped).
|
||||
return cls._neutralize_footer_paths("\n".join(lines))
|
||||
|
||||
def _turn_completion_explainer_enabled(self) -> bool:
|
||||
"""Check whether the end-of-turn completion explainer footer is on.
|
||||
|
||||
Config path: ``display.turn_completion_explainer`` (bool, default
|
||||
True). ``HERMES_TURN_COMPLETION_EXPLAINER`` env var overrides
|
||||
config. Exposed as a method so tests can patch a single seam,
|
||||
mirroring ``_file_mutation_verifier_enabled``.
|
||||
"""
|
||||
try:
|
||||
import os as _os
|
||||
env = _os.environ.get("HERMES_TURN_COMPLETION_EXPLAINER")
|
||||
if env is not None:
|
||||
return env.strip().lower() not in {"0", "false", "no", "off"}
|
||||
# Read from the persisted config.yaml so gateway and CLI share
|
||||
# the same setting. Import lazily to avoid a startup-time cycle.
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
_cfg = _load_config() or {}
|
||||
except Exception:
|
||||
_cfg = {}
|
||||
_display = _cfg.get("display") if isinstance(_cfg, dict) else None
|
||||
if isinstance(_display, dict) and "turn_completion_explainer" in _display:
|
||||
return bool(_display.get("turn_completion_explainer"))
|
||||
except Exception:
|
||||
pass
|
||||
return True # safe default: explainer on
|
||||
|
||||
@staticmethod
|
||||
def _format_turn_completion_explanation(turn_exit_reason: str) -> str:
|
||||
"""Render a user-facing explanation for an abnormal turn ending.
|
||||
|
||||
Maps the internal ``turn_exit_reason`` to a short, actionable
|
||||
message so a turn that produced no usable assistant reply (empty
|
||||
content after retries, a partial/truncated stream, a still-pending
|
||||
tool result, or an iteration/budget limit) is never silent from
|
||||
the UI's perspective — the symptom users report in #34452.
|
||||
|
||||
Returns an empty string for reasons that are NOT abnormal (e.g.
|
||||
a normal ``text_response(...)`` exit), so callers can concatenate
|
||||
or substitute unconditionally without warning on healthy turns
|
||||
like a terse ``Done.``.
|
||||
"""
|
||||
if not turn_exit_reason:
|
||||
return ""
|
||||
reason = str(turn_exit_reason)
|
||||
|
||||
# Normal completion — stay quiet. ``text_response(...)`` is the
|
||||
# healthy terminal; anything that produced a real reply is fine.
|
||||
if reason.startswith("text_response"):
|
||||
return ""
|
||||
|
||||
prefix = "⚠️ No reply: "
|
||||
if reason == "empty_response_exhausted":
|
||||
return (
|
||||
prefix
|
||||
+ "the model returned empty content after retries and any "
|
||||
"fallback providers. Try `continue`, switch model/provider, "
|
||||
"or inspect the tool output above."
|
||||
)
|
||||
if reason == "all_retries_exhausted_no_response":
|
||||
return (
|
||||
prefix
|
||||
+ "all API retries were exhausted before a response was "
|
||||
"produced (provider errors / rate limits). Try `continue` "
|
||||
"or switch provider."
|
||||
)
|
||||
if reason == "partial_stream_recovery":
|
||||
return (
|
||||
prefix
|
||||
+ "streaming stopped early and only a partial response was "
|
||||
"recovered. Send `continue` to resume from where it stopped."
|
||||
)
|
||||
if reason == "fallback_prior_turn_content":
|
||||
return (
|
||||
prefix
|
||||
+ "no new content was produced this turn; showing recovered "
|
||||
"prior context. Send `continue` to retry."
|
||||
)
|
||||
if reason == "interrupted_during_api_call":
|
||||
return (
|
||||
prefix
|
||||
+ "the request was interrupted mid-call before a reply was "
|
||||
"received. Send `continue` to retry."
|
||||
)
|
||||
if reason == "budget_exhausted":
|
||||
return (
|
||||
prefix
|
||||
+ "the per-turn iteration/cost budget was exhausted before a "
|
||||
"final answer. Send `continue` to keep going."
|
||||
)
|
||||
if reason == "ollama_runtime_context_too_small":
|
||||
return (
|
||||
prefix
|
||||
+ "the local model's context window was too small to finish. "
|
||||
"Increase the context size or use a larger model."
|
||||
)
|
||||
if reason.startswith("max_iterations_reached"):
|
||||
return (
|
||||
prefix
|
||||
+ "the maximum tool-iteration limit was reached before a "
|
||||
"final answer. Send `continue` to keep going, or raise "
|
||||
"`max_iterations`."
|
||||
)
|
||||
if reason.startswith("error_near_max_iterations"):
|
||||
return (
|
||||
prefix
|
||||
+ "an error occurred near the iteration limit before a final "
|
||||
"answer. Check the tool output above, then send `continue`."
|
||||
)
|
||||
if reason == "pending_tool_result":
|
||||
return (
|
||||
prefix
|
||||
+ "the turn stopped while a tool result was still pending and "
|
||||
"the model produced no follow-up text. Send `continue` to "
|
||||
"let it summarize."
|
||||
)
|
||||
# Unknown/diagnostic-only reasons (e.g. "unknown", guardrail_halt
|
||||
# which already surfaces its own message) — don't second-guess.
|
||||
return ""
|
||||
|
||||
def _apply_pending_steer_to_tool_results(self, messages: list, num_tool_msgs: int) -> None:
|
||||
"""Forwarder — see ``agent.agent_runtime_helpers.apply_pending_steer_to_tool_results``."""
|
||||
@@ -3430,6 +3588,18 @@ class AIAgent:
|
||||
from agent.chat_completion_helpers import try_activate_fallback
|
||||
return try_activate_fallback(self, reason)
|
||||
|
||||
def _has_pending_fallback(self) -> bool:
|
||||
"""Whether a fallback provider is actually available to switch to.
|
||||
|
||||
Used to gate user-facing "trying fallback..." status so we don't
|
||||
announce a fallback that will never be attempted (the user has no
|
||||
fallback chain configured). Mirrors the early-return guard in
|
||||
``try_activate_fallback`` (#35314, #17446).
|
||||
"""
|
||||
chain = getattr(self, "_fallback_chain", None) or []
|
||||
index = getattr(self, "_fallback_index", 0)
|
||||
return index < len(chain)
|
||||
|
||||
# ── Per-turn primary restoration ─────────────────────────────────────
|
||||
|
||||
def _restore_primary_runtime(self) -> bool:
|
||||
|
||||
@@ -473,6 +473,7 @@ check_python() {
|
||||
if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
log_success "Python found: $PYTHON_FOUND_VERSION"
|
||||
ensure_fts5
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -482,6 +483,7 @@ check_python() {
|
||||
PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")"
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
log_success "Python installed: $PYTHON_FOUND_VERSION"
|
||||
ensure_fts5
|
||||
else
|
||||
log_error "Failed to install Python $PYTHON_VERSION"
|
||||
log_info "Install Python $PYTHON_VERSION manually, then re-run this script"
|
||||
@@ -489,6 +491,104 @@ check_python() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Probe whether $1 (a python executable) links a SQLite with the FTS5
|
||||
# module compiled in. Hermes' session store (hermes_state.py) creates FTS5
|
||||
# virtual tables for full-text session search; a SQLite without FTS5 makes
|
||||
# the bundled-python path unusable for that feature. Returns 0 if FTS5 works.
|
||||
_python_has_fts5() {
|
||||
"$1" - <<'PY' 2>/dev/null
|
||||
import sqlite3, sys
|
||||
try:
|
||||
sqlite3.connect(":memory:").execute("CREATE VIRTUAL TABLE t USING fts5(x)")
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Reinstall $PYTHON_VERSION with the current uv and re-resolve PYTHON_PATH.
|
||||
# Returns 0 if the resulting interpreter ships FTS5.
|
||||
_reinstall_python_with_fts5() {
|
||||
local uv_bin="$1"
|
||||
"$uv_bin" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1 || return 1
|
||||
PYTHON_PATH="$("$uv_bin" python find "$PYTHON_VERSION" 2>/dev/null)"
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
[ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH"
|
||||
}
|
||||
|
||||
_warn_no_fts5() {
|
||||
# Could not obtain an FTS5-capable interpreter (offline, pinned env, etc.).
|
||||
# Install proceeds — Hermes degrades gracefully and disables only full-text
|
||||
# session search — but warn so it isn't a silent gap.
|
||||
log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but"
|
||||
log_warn "full-text session search will be disabled until FTS5 is present."
|
||||
}
|
||||
|
||||
# Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python
|
||||
# distributions only gained FTS5 in mid-2025 (python-build-standalone #694),
|
||||
# but WHICH builds a given uv can install is baked into the uv binary's
|
||||
# download manifest — so a stale uv (e.g. `pip install uv==0.7.20`) only knows
|
||||
# about pre-FTS5 builds, and even `uv python install --reinstall` just pulls the
|
||||
# same FTS5-less interpreter. A plain reinstall with an old uv is therefore a
|
||||
# no-op for FTS5. To actually fix everyone's install, we escalate uv itself:
|
||||
#
|
||||
# 1. reinstall with the current $UV_CMD (handles a stale *interpreter* under
|
||||
# an already-current uv)
|
||||
# 2. if still no FTS5, bring uv up to date (`uv self update`) and reinstall —
|
||||
# this is what fixes a stale standalone uv
|
||||
# 3. if uv can't self-update (pip/apt/brew-managed uv refuses), install a
|
||||
# fresh standalone uv via the official installer into a temp dir and use
|
||||
# THAT to reinstall — this fixes package-manager-managed stale uv
|
||||
#
|
||||
# Pythons live in uv's shared store, so a fresh uv's --reinstall overwrites the
|
||||
# stale interpreter in place and the installer's later `uv python find` resolves
|
||||
# to it. Keeps session search working without bundling a second SQLite or asking
|
||||
# the user to do anything.
|
||||
ensure_fts5() {
|
||||
[ -n "${PYTHON_PATH:-}" ] || return 0
|
||||
if _python_has_fts5 "$PYTHON_PATH"; then
|
||||
return 0
|
||||
fi
|
||||
# Termux / non-uv installs have nothing to escalate.
|
||||
[ -n "${UV_CMD:-}" ] || { _warn_no_fts5; return 0; }
|
||||
|
||||
log_warn "Resolved Python's SQLite lacks the FTS5 module (session search needs it)."
|
||||
log_info "Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv..."
|
||||
if _reinstall_python_with_fts5 "$UV_CMD"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Still no FTS5 — the uv binary itself is too old to know about FTS5-capable
|
||||
# Python builds. Try to update uv in place.
|
||||
log_info "uv is too old to provide an FTS5-capable Python — updating uv..."
|
||||
if "$UV_CMD" self update >/dev/null 2>&1; then
|
||||
if _reinstall_python_with_fts5 "$UV_CMD"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# `uv self update` is unavailable on externally-managed uv (pip/apt/brew),
|
||||
# which is exactly the case the user hit (`pip install uv==0.7.20`). Install
|
||||
# a fresh standalone uv into a temp dir and use it just for the reinstall.
|
||||
log_info "Installing an up-to-date standalone uv to obtain an FTS5 Python..."
|
||||
local _tmp_uv_dir _fresh_uv
|
||||
_tmp_uv_dir="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-fresh-uv.$$")"
|
||||
mkdir -p "$_tmp_uv_dir"
|
||||
if curl -LsSf https://astral.sh/uv/install.sh 2>/dev/null \
|
||||
| env UV_INSTALL_DIR="$_tmp_uv_dir" UV_UNMANAGED_INSTALL="$_tmp_uv_dir" sh >/dev/null 2>&1; then
|
||||
_fresh_uv="$_tmp_uv_dir/uv"
|
||||
if [ -x "$_fresh_uv" ] && _reinstall_python_with_fts5 "$_fresh_uv"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
rm -rf "$_tmp_uv_dir"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
rm -rf "$_tmp_uv_dir"
|
||||
|
||||
_warn_no_fts5
|
||||
}
|
||||
|
||||
check_git() {
|
||||
log_info "Checking Git..."
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"zhipengli@thebrainly.ai": "a1245582339",
|
||||
"mathijs.vd.hurk@gmail.com": "mathijsvandenhurk",
|
||||
"drpelagik@gmail.com": "SeaXen",
|
||||
"lengr@users.noreply.github.com": "LengR",
|
||||
"metalclaudbot@gmail.com": "HashClawAI",
|
||||
"tonybear55665566@gmail.com": "TonyPepeBear",
|
||||
"kaspersniels@gmail.com": "nielskaspers",
|
||||
@@ -67,6 +71,7 @@ AUTHOR_MAP = {
|
||||
"wangpuv@hotmail.com": "wangpuv",
|
||||
"202622897+ticketclosed-wontfix@users.noreply.github.com": "ticketclosed-wontfix",
|
||||
"wuxuebin1993@gmail.com": "victorGPT",
|
||||
"wei.chen.coder@gmail.com": "wenchengxucool",
|
||||
"frowte3k@gmail.com": "Frowtek",
|
||||
"211828103+julio-cloudvisor@users.noreply.github.com": "julio-cloudvisor",
|
||||
"17778+kweiner@users.noreply.github.com": "kweiner",
|
||||
@@ -75,6 +80,7 @@ AUTHOR_MAP = {
|
||||
"interstellar.consulting@gmail.com": "Interstellar-code",
|
||||
"33978413+Interstellar-code@users.noreply.github.com": "Interstellar-code",
|
||||
"tillfalko@gmail.com": "tillfalko",
|
||||
"hi@fesalfayed.com": "fesalfayed",
|
||||
# teknium (multiple emails)
|
||||
"teknium1@gmail.com": "teknium1",
|
||||
"kenyon1977@gmail.com": "kenyonxu",
|
||||
@@ -92,6 +98,7 @@ AUTHOR_MAP = {
|
||||
"steve@steveonjava.com": "steveonjava",
|
||||
"steveonjava@gmail.com": "steveonjava",
|
||||
"squiddy@2rook.ai": "MoonRay305",
|
||||
"annguyenNous@users.noreply.github.com": "annguyenNous",
|
||||
"32201324+simpolism@users.noreply.github.com": "simpolism",
|
||||
"simpolism@gmail.com": "simpolism",
|
||||
"jake@nousresearch.com": "simpolism",
|
||||
@@ -219,6 +226,7 @@ AUTHOR_MAP = {
|
||||
"264291321+v1b3coder@users.noreply.github.com": "v1b3coder",
|
||||
"silverchris@foxmail.com": "ming1523",
|
||||
"maksesipov@gmail.com": "Qwinty",
|
||||
"byquenox@gmail.com": "Que0x",
|
||||
"denisamania@gmail.com": "CalmProton",
|
||||
"308068+mbac@users.noreply.github.com": "mbac",
|
||||
"nicoechaniz@altermundi.net": "nicoechaniz",
|
||||
@@ -648,8 +656,10 @@ AUTHOR_MAP = {
|
||||
"alexazzjjtt@163.com": "alexzhu0",
|
||||
"pub_forgreatagent@antgroup.com": "AntAISecurityLab",
|
||||
"252620095+briandevans@users.noreply.github.com": "briandevans",
|
||||
"incharge.automation@gmail.com": "inchargeautomation-lab",
|
||||
"danielrpike9@gmail.com": "Bartok9",
|
||||
"96944678+ymylive@users.noreply.github.com": "sweetcornna",
|
||||
"laflamme@illinoisalumni.org": "briancl2",
|
||||
"skozyuk@cruxexperts.com": "CruxExperts",
|
||||
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"12250313+Kailigithub@users.noreply.github.com": "Kailigithub",
|
||||
@@ -1202,6 +1212,7 @@ AUTHOR_MAP = {
|
||||
"86501179+1RB@users.noreply.github.com": "1RB", # PR #25462 salvage (discord forwarded messages)
|
||||
"44045943+ayushere@users.noreply.github.com": "ayushere", # PR #25342 salvage (memory teardown leak)
|
||||
"15791290+domtriola@users.noreply.github.com": "domtriola", # PR #25424 salvage (docs tirith link)
|
||||
"tuancookiez@gmail.com": "tuancookiez-hub", # PR #34865 salvage (LSP Windows .cmd shim spawn, #34864)
|
||||
"284216128+ephron-ren@users.noreply.github.com": "ephron-ren", # PR #25358 salvage (MiMo reasoning echo-back)
|
||||
"96843562+freqyfreqy@users.noreply.github.com": "freqyfreqy", # PR #25423 salvage (docs LSP worktree -> repo)
|
||||
"54306477+fu576@users.noreply.github.com": "fu576", # PR #25369 salvage (api_mode not inherited cross-provider)
|
||||
@@ -1396,6 +1407,9 @@ AUTHOR_MAP = {
|
||||
"peter.yuqin@gmail.com": "WuKongAI-CMU", # PR #10082 (reject symlinked audio inputs)
|
||||
"sunil.nitie@gmail.com": "Sunil123135", # PR #31031 (Windows Docker Desktop compose)
|
||||
"weichangyuwcy@gmail.com": "ChyuWei", # PR #30987 (TUI TTS env var on voice off)
|
||||
# batch salvage PR #35758 (perf micro-fixes)
|
||||
"116212274+amathxbt@users.noreply.github.com": "amathxbt", # PR #22155 (cache tool_output_limits)
|
||||
"takis312@hotmail.com": "ErnestHysa", # PRs #32636/#32708 (MCP asyncio.sleep + O(n^2) watcher drain)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -178,6 +178,30 @@ Tell them what you created in plain prose, naming the actual profiles you used:
|
||||
|
||||
**Tenant inheritance.** If `HERMES_TENANT` is set in your env, pass `tenant=os.environ.get("HERMES_TENANT")` on every `kanban_create` call so child tasks stay in the same namespace.
|
||||
|
||||
## Goal-mode cards (persistent workers)
|
||||
|
||||
By default a dispatched worker gets **one shot** at its card: it does its work, calls `kanban_complete`/`kanban_block`, and exits. For open-ended cards where one turn rarely finishes the job, pass `goal_mode=True` to wrap that worker in a Ralph-style goal loop — the same engine behind the `/goal` slash command:
|
||||
|
||||
```python
|
||||
kanban_create(
|
||||
title="Translate the full docs site to French",
|
||||
body="Acceptance: every page translated, no English left, links intact.",
|
||||
assignee="<translator-profile>",
|
||||
goal_mode=True, # judge re-checks the card after each turn
|
||||
goal_max_turns=15, # optional budget (default 20)
|
||||
)["task_id"]
|
||||
```
|
||||
|
||||
How it behaves:
|
||||
- After each worker turn, an auxiliary judge evaluates the worker's response against the card's **title + body** (treated as the acceptance criteria).
|
||||
- Not done + budget remains → the worker keeps going **in the same session** (full context retained — not a fresh respawn).
|
||||
- Worker calls `kanban_complete`/`kanban_block` itself → loop stops, normal lifecycle.
|
||||
- Budget exhausted without completion → the card is **blocked** for human review (sticky), never a silent exit.
|
||||
|
||||
When to use it: long, multi-step, or "keep going until X is true" cards. When NOT to: cheap one-shot cards (translation of a single string, a quick lookup) — the judge overhead isn't worth it, and the dispatcher's existing retry/circuit-breaker already handles transient worker failures.
|
||||
|
||||
Write the body as **explicit acceptance criteria** — the judge is only as good as the goal text. "Translate the README" is weaker than "Translate every section of the README to French; no English sentences remain."
|
||||
|
||||
## Recovering stuck workers
|
||||
|
||||
When a worker profile keeps crashing, hallucinating, or getting blocked by its own mistakes (usually: wrong model, missing skill, broken credential), the kanban dashboard flags the task with a ⚠ badge and opens a **Recovery** section in the drawer. Three primary actions:
|
||||
|
||||
@@ -129,7 +129,11 @@ def _run_gws(parts: list[str], *, params: dict | None = None, body: dict | None
|
||||
|
||||
|
||||
def _headers_dict(msg: dict) -> dict[str, str]:
|
||||
return {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
|
||||
return {
|
||||
h["name"].lower(): h["value"]
|
||||
for h in msg.get("payload", {}).get("headers", [])
|
||||
if h.get("name")
|
||||
}
|
||||
|
||||
|
||||
def _extract_message_body(msg: dict) -> str:
|
||||
@@ -230,10 +234,10 @@ def gmail_search(args):
|
||||
{
|
||||
"id": msg["id"],
|
||||
"threadId": msg["threadId"],
|
||||
"from": headers.get("From", ""),
|
||||
"to": headers.get("To", ""),
|
||||
"subject": headers.get("Subject", ""),
|
||||
"date": headers.get("Date", ""),
|
||||
"from": headers.get("from", ""),
|
||||
"to": headers.get("to", ""),
|
||||
"subject": headers.get("subject", ""),
|
||||
"date": headers.get("date", ""),
|
||||
"snippet": msg.get("snippet", ""),
|
||||
"labels": msg.get("labelIds", []),
|
||||
}
|
||||
@@ -260,10 +264,10 @@ def gmail_search(args):
|
||||
output.append({
|
||||
"id": msg["id"],
|
||||
"threadId": msg["threadId"],
|
||||
"from": headers.get("From", ""),
|
||||
"to": headers.get("To", ""),
|
||||
"subject": headers.get("Subject", ""),
|
||||
"date": headers.get("Date", ""),
|
||||
"from": headers.get("from", ""),
|
||||
"to": headers.get("to", ""),
|
||||
"subject": headers.get("subject", ""),
|
||||
"date": headers.get("date", ""),
|
||||
"snippet": msg.get("snippet", ""),
|
||||
"labels": msg.get("labelIds", []),
|
||||
})
|
||||
@@ -281,10 +285,10 @@ def gmail_get(args):
|
||||
result = {
|
||||
"id": msg["id"],
|
||||
"threadId": msg["threadId"],
|
||||
"from": headers.get("From", ""),
|
||||
"to": headers.get("To", ""),
|
||||
"subject": headers.get("Subject", ""),
|
||||
"date": headers.get("Date", ""),
|
||||
"from": headers.get("from", ""),
|
||||
"to": headers.get("to", ""),
|
||||
"subject": headers.get("subject", ""),
|
||||
"date": headers.get("date", ""),
|
||||
"labels": msg.get("labelIds", []),
|
||||
"body": _extract_message_body(msg),
|
||||
}
|
||||
@@ -300,10 +304,10 @@ def gmail_get(args):
|
||||
result = {
|
||||
"id": msg["id"],
|
||||
"threadId": msg["threadId"],
|
||||
"from": headers.get("From", ""),
|
||||
"to": headers.get("To", ""),
|
||||
"subject": headers.get("Subject", ""),
|
||||
"date": headers.get("Date", ""),
|
||||
"from": headers.get("from", ""),
|
||||
"to": headers.get("to", ""),
|
||||
"subject": headers.get("subject", ""),
|
||||
"date": headers.get("date", ""),
|
||||
"labels": msg.get("labelIds", []),
|
||||
"body": _extract_message_body(msg),
|
||||
}
|
||||
@@ -314,12 +318,12 @@ def gmail_get(args):
|
||||
def gmail_send(args):
|
||||
if _gws_binary():
|
||||
message = MIMEText(args.body, "html" if args.html else "plain")
|
||||
message["to"] = args.to
|
||||
message["subject"] = args.subject
|
||||
message["To"] = args.to
|
||||
message["Subject"] = args.subject
|
||||
if args.cc:
|
||||
message["cc"] = args.cc
|
||||
message["Cc"] = args.cc
|
||||
if args.from_header:
|
||||
message["from"] = args.from_header
|
||||
message["From"] = args.from_header
|
||||
|
||||
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
||||
body = {"raw": raw}
|
||||
@@ -336,12 +340,12 @@ def gmail_send(args):
|
||||
|
||||
service = build_service("gmail", "v1")
|
||||
message = MIMEText(args.body, "html" if args.html else "plain")
|
||||
message["to"] = args.to
|
||||
message["subject"] = args.subject
|
||||
message["To"] = args.to
|
||||
message["Subject"] = args.subject
|
||||
if args.cc:
|
||||
message["cc"] = args.cc
|
||||
message["Cc"] = args.cc
|
||||
if args.from_header:
|
||||
message["from"] = args.from_header
|
||||
message["From"] = args.from_header
|
||||
|
||||
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
||||
body = {"raw": raw}
|
||||
@@ -367,18 +371,18 @@ def gmail_reply(args):
|
||||
)
|
||||
headers = _headers_dict(original)
|
||||
|
||||
subject = headers.get("Subject", "")
|
||||
subject = headers.get("subject", "")
|
||||
if not subject.startswith("Re:"):
|
||||
subject = f"Re: {subject}"
|
||||
|
||||
message = MIMEText(args.body)
|
||||
message["to"] = headers.get("From", "")
|
||||
message["subject"] = subject
|
||||
message["To"] = headers.get("from", "")
|
||||
message["Subject"] = subject
|
||||
if args.from_header:
|
||||
message["from"] = args.from_header
|
||||
if headers.get("Message-ID"):
|
||||
message["In-Reply-To"] = headers["Message-ID"]
|
||||
message["References"] = headers["Message-ID"]
|
||||
message["From"] = args.from_header
|
||||
if headers.get("message-id"):
|
||||
message["In-Reply-To"] = headers["message-id"]
|
||||
message["References"] = headers["message-id"]
|
||||
|
||||
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
||||
result = _run_gws(
|
||||
@@ -396,18 +400,18 @@ def gmail_reply(args):
|
||||
).execute()
|
||||
headers = _headers_dict(original)
|
||||
|
||||
subject = headers.get("Subject", "")
|
||||
subject = headers.get("subject", "")
|
||||
if not subject.startswith("Re:"):
|
||||
subject = f"Re: {subject}"
|
||||
|
||||
message = MIMEText(args.body)
|
||||
message["to"] = headers.get("From", "")
|
||||
message["subject"] = subject
|
||||
message["To"] = headers.get("from", "")
|
||||
message["Subject"] = subject
|
||||
if args.from_header:
|
||||
message["from"] = args.from_header
|
||||
if headers.get("Message-ID"):
|
||||
message["In-Reply-To"] = headers["Message-ID"]
|
||||
message["References"] = headers["Message-ID"]
|
||||
message["From"] = args.from_header
|
||||
if headers.get("message-id"):
|
||||
message["In-Reply-To"] = headers["message-id"]
|
||||
message["References"] = headers["message-id"]
|
||||
|
||||
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
||||
body = {"raw": raw, "threadId": original["threadId"]}
|
||||
|
||||
@@ -94,6 +94,47 @@ def test_install_npm_works_without_extras(tmp_path, monkeypatch):
|
||||
assert install_targets == ["pyright"]
|
||||
|
||||
|
||||
def test_existing_binary_finds_windows_wrapper_in_staging(tmp_path, monkeypatch):
|
||||
"""Installed Windows shims should satisfy later status/probe calls."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
from agent.lsp import install as install_mod
|
||||
|
||||
wrapper = install_mod.hermes_lsp_bin_dir() / "pyright-langserver.cmd"
|
||||
wrapper.write_text("@echo off\n")
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(install_mod.shutil, "which", lambda _name: None)
|
||||
|
||||
assert install_mod._existing_binary("pyright-langserver") == str(wrapper)
|
||||
assert install_mod.detect_status("pyright") == "installed"
|
||||
|
||||
|
||||
def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch):
|
||||
"""pip console scripts can land in Scripts/ on native Windows."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
from agent.lsp import install as install_mod
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
scripts_dir = install_mod.hermes_lsp_bin_dir().parent / "python-packages" / "Scripts"
|
||||
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||
launcher = scripts_dir / "fake-language-server.exe"
|
||||
launcher.write_text("launcher\n")
|
||||
launcher.chmod(0o755)
|
||||
return MagicMock(returncode=0, stderr="")
|
||||
|
||||
monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(install_mod.subprocess, "run", fake_run)
|
||||
|
||||
resolved = install_mod._install_pip("fake-lsp", "fake-language-server")
|
||||
|
||||
assert resolved is not None
|
||||
assert resolved.endswith("fake-language-server.exe")
|
||||
assert (install_mod.hermes_lsp_bin_dir() / "fake-language-server.exe").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix 2: ``hermes lsp status`` surfaces shellcheck-missing for bash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1827,6 +1827,79 @@ class TestThinkingBlockSignatureManagement:
|
||||
assert len(last_thinking) == 1
|
||||
assert last_thinking[0]["signature"] == "sig_3"
|
||||
|
||||
def test_orphan_stripped_tool_use_demotes_dead_signed_thinking(self):
|
||||
"""Regression: extended-thinking + interrupted parallel tool batch.
|
||||
|
||||
An assistant turn with a signed thinking block fires several parallel
|
||||
tool_use blocks, but the batch is interrupted before every tool_result
|
||||
comes back. On replay, the orphaned tool_use is stripped — which mutates
|
||||
the turn and invalidates the thinking-block signature (it was computed
|
||||
against the original, un-stripped content). Anthropic then rejects the
|
||||
turn with HTTP 400 "thinking blocks in the latest assistant message
|
||||
cannot be modified", a non-retryable error that crash-loops the gateway.
|
||||
|
||||
The signed thinking block on the mutated latest turn must be demoted to
|
||||
a plain text block so the turn replays cleanly.
|
||||
"""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "tc_kept", "function": {"name": "tool_a", "arguments": "{}"}},
|
||||
{"id": "tc_orphan", "function": {"name": "tool_b", "arguments": "{}"}},
|
||||
],
|
||||
"reasoning_details": [
|
||||
{"type": "thinking", "thinking": "Plan: call A and B.", "signature": "sig_dead"},
|
||||
],
|
||||
},
|
||||
# Only one of the two parallel tool_use blocks got a result back.
|
||||
{"role": "tool", "tool_call_id": "tc_kept", "content": "result A"},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
assistant = next(m for m in result if m["role"] == "assistant")
|
||||
blocks = assistant["content"]
|
||||
|
||||
# No signed thinking block survives — the signature is dead.
|
||||
assert not any(
|
||||
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
|
||||
for b in blocks
|
||||
)
|
||||
# The reasoning text is preserved as a text block (not silently lost).
|
||||
text_contents = [b.get("text", "") for b in blocks if b.get("type") == "text"]
|
||||
assert "Plan: call A and B." in text_contents
|
||||
# The orphaned tool_use is gone; the answered one survives.
|
||||
tool_use_ids = [b.get("id") for b in blocks if b.get("type") == "tool_use"]
|
||||
assert tool_use_ids == ["tc_kept"]
|
||||
# Internal bookkeeping flag must never leak into the API payload.
|
||||
assert "_thinking_signature_invalidated" not in assistant
|
||||
|
||||
def test_signed_thinking_preserved_when_no_tool_use_stripped(self):
|
||||
"""Control: an intact latest turn keeps its signed thinking verbatim.
|
||||
|
||||
This guards against the orphan-strip fix over-firing — when no tool_use
|
||||
is removed, the signature is still valid and must be replayed as-is.
|
||||
"""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "tc_1", "function": {"name": "tool_a", "arguments": "{}"}},
|
||||
],
|
||||
"reasoning_details": [
|
||||
{"type": "thinking", "thinking": "Valid plan.", "signature": "sig_live"},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "result A"},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
assistant = next(m for m in result if m["role"] == "assistant")
|
||||
thinking = [b for b in assistant["content"] if b.get("type") == "thinking"]
|
||||
assert len(thinking) == 1
|
||||
assert thinking[0]["signature"] == "sig_live"
|
||||
assert "_thinking_signature_invalidated" not in assistant
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool choice
|
||||
|
||||
@@ -88,6 +88,62 @@ class TestAuxiliaryMaxTokensParam:
|
||||
assert auxiliary_max_tokens_param(2048) == {"max_completion_tokens": 2048}
|
||||
|
||||
|
||||
class TestBuildCallKwargsMaxTokens:
|
||||
"""_build_call_kwargs should not cap output by default (#34530).
|
||||
|
||||
Most chat-completions providers treat an omitted max_tokens as "use the
|
||||
model max", which is what we want for auxiliary tasks. An explicit cap only
|
||||
risks truncation or a wire-format 400 (GitHub Copilot / GPT-5 reject
|
||||
max_tokens; ZAI vision rejects it entirely). The Anthropic Messages wire is
|
||||
the one exception — max_tokens is a mandatory field there.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider,model,base_url",
|
||||
[
|
||||
("copilot", "gpt-5.4", "https://api.githubcopilot.com"),
|
||||
("copilot", "gpt-5.5", "https://api.githubcopilot.com"),
|
||||
("custom", "gpt-5", "https://api.openai.com/v1"),
|
||||
("openrouter", "anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1"),
|
||||
("nous", "hermes-4", "https://inference-api.nousresearch.com/v1"),
|
||||
("custom", "qwen", "http://localhost:8080/v1"),
|
||||
("zai", "glm-4v-flash", "https://open.bigmodel.cn/api/paas/v4"),
|
||||
],
|
||||
)
|
||||
def test_omits_max_tokens_for_openai_compatible(self, provider, model, base_url):
|
||||
from agent.auxiliary_client import _build_call_kwargs
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
provider=provider,
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=1234,
|
||||
base_url=base_url,
|
||||
)
|
||||
assert "max_tokens" not in kwargs
|
||||
assert "max_completion_tokens" not in kwargs
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider,model,base_url",
|
||||
[
|
||||
("minimax", "minimax-m2", "https://api.minimax.io/v1"),
|
||||
("custom", "claude", "https://proxy.example.com/anthropic/v1"),
|
||||
],
|
||||
)
|
||||
def test_keeps_max_tokens_on_anthropic_wire(self, provider, model, base_url):
|
||||
from agent.auxiliary_client import _build_call_kwargs
|
||||
|
||||
kwargs = _build_call_kwargs(
|
||||
provider=provider,
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=1234,
|
||||
base_url=base_url,
|
||||
)
|
||||
assert kwargs["max_tokens"] == 1234
|
||||
assert "max_completion_tokens" not in kwargs
|
||||
|
||||
|
||||
class TestNormalizeAuxProvider:
|
||||
def test_maps_github_copilot_aliases(self):
|
||||
assert _normalize_aux_provider("github") == "copilot"
|
||||
|
||||
@@ -41,6 +41,8 @@ class TestShouldCompress:
|
||||
|
||||
class TestUpdateFromResponse:
|
||||
def test_updates_fields(self, compressor):
|
||||
compressor.awaiting_real_usage_after_compression = True
|
||||
compressor.last_compression_rough_tokens = 90_000
|
||||
compressor.update_from_response({
|
||||
"prompt_tokens": 5000,
|
||||
"completion_tokens": 1000,
|
||||
@@ -48,12 +50,39 @@ class TestUpdateFromResponse:
|
||||
})
|
||||
assert compressor.last_prompt_tokens == 5000
|
||||
assert compressor.last_completion_tokens == 1000
|
||||
assert compressor.last_real_prompt_tokens == 5000
|
||||
assert compressor.last_rough_tokens_when_real_prompt_fit == 90_000
|
||||
assert compressor.awaiting_real_usage_after_compression is False
|
||||
|
||||
def test_missing_fields_default_zero(self, compressor):
|
||||
compressor.update_from_response({})
|
||||
assert compressor.last_prompt_tokens == 0
|
||||
|
||||
|
||||
class TestPreflightDeferral:
|
||||
def test_defers_when_recent_real_usage_fit_and_rough_growth_is_small(self, compressor):
|
||||
compressor.threshold_tokens = 85_000
|
||||
compressor.last_real_prompt_tokens = 50_000
|
||||
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
|
||||
|
||||
assert compressor.should_defer_preflight_to_real_usage(93_000) is True
|
||||
assert compressor.last_rough_tokens_when_real_prompt_fit == 93_000
|
||||
|
||||
def test_does_not_defer_when_rough_growth_is_large(self, compressor):
|
||||
compressor.threshold_tokens = 85_000
|
||||
compressor.last_real_prompt_tokens = 50_000
|
||||
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
|
||||
|
||||
assert compressor.should_defer_preflight_to_real_usage(100_000) is False
|
||||
|
||||
def test_does_not_defer_without_recent_real_usage(self, compressor):
|
||||
compressor.threshold_tokens = 85_000
|
||||
compressor.last_real_prompt_tokens = 0
|
||||
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
|
||||
|
||||
assert compressor.should_defer_preflight_to_real_usage(93_000) is False
|
||||
|
||||
|
||||
|
||||
class TestCompress:
|
||||
def _make_messages(self, n):
|
||||
|
||||
@@ -123,55 +123,6 @@ class TestEstimateMessagesTokensRough:
|
||||
# =========================================================================
|
||||
|
||||
class TestDefaultContextLengths:
|
||||
def test_claude_models_context_lengths(self):
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
if "claude" not in key:
|
||||
continue
|
||||
# Claude 4.6+ models (4.6, 4.7, 4.8) have 1M context at standard
|
||||
# API pricing (no long-context premium). Older Claude 4.x and
|
||||
# 3.x models cap at 200k.
|
||||
if any(tag in key for tag in ("4.6", "4-6", "4.7", "4-7", "4.8", "4-8")):
|
||||
assert value == 1000000, f"{key} should be 1000000"
|
||||
else:
|
||||
assert value == 200000, f"{key} should be 200000"
|
||||
|
||||
def test_gpt4_models_128k_or_1m(self):
|
||||
# gpt-4.1 and gpt-4.1-mini have 1M context; other gpt-4* have 128k
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
if "gpt-4" in key and "gpt-4.1" not in key:
|
||||
assert value == 128000, f"{key} should be 128000"
|
||||
|
||||
def test_gpt41_models_1m(self):
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
if "gpt-4.1" in key:
|
||||
assert value == 1047576, f"{key} should be 1047576"
|
||||
|
||||
def test_gemini_models_1m(self):
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
if "gemini" in key:
|
||||
assert value == 1048576, f"{key} should be 1048576"
|
||||
|
||||
def test_grok_models_context_lengths(self):
|
||||
# xAI /v1/models does not return context_length metadata, so
|
||||
# DEFAULT_CONTEXT_LENGTHS must cover the Grok family explicitly.
|
||||
# Values sourced from models.dev (2026-04).
|
||||
expected = {
|
||||
"grok-4.20": 2000000,
|
||||
"grok-4-fast": 2000000,
|
||||
"grok-4": 256000,
|
||||
"grok-build": 256000,
|
||||
"grok-code-fast": 256000,
|
||||
"grok-3": 131072,
|
||||
"grok-2": 131072,
|
||||
"grok-2-vision": 8192,
|
||||
"grok": 131072,
|
||||
}
|
||||
for key, value in expected.items():
|
||||
assert key in DEFAULT_CONTEXT_LENGTHS, f"{key} missing from DEFAULT_CONTEXT_LENGTHS"
|
||||
assert DEFAULT_CONTEXT_LENGTHS[key] == value, (
|
||||
f"{key} should be {value}, got {DEFAULT_CONTEXT_LENGTHS[key]}"
|
||||
)
|
||||
|
||||
def test_grok_substring_matching(self):
|
||||
# Longest-first substring matching must resolve the real xAI model
|
||||
# IDs to the correct fallback entries without 128k probe-down.
|
||||
@@ -268,13 +219,6 @@ class TestDefaultContextLengths:
|
||||
f"{model_id}: expected {expected_ctx}, got {actual}"
|
||||
)
|
||||
|
||||
def test_all_values_positive(self):
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
assert value > 0, f"{key} has non-positive context length"
|
||||
|
||||
def test_dict_is_not_empty(self):
|
||||
assert len(DEFAULT_CONTEXT_LENGTHS) >= 10
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Codex OAuth context-window resolution (provider="openai-codex")
|
||||
@@ -1141,12 +1085,6 @@ class TestContextProbeTiers:
|
||||
for i in range(len(CONTEXT_PROBE_TIERS) - 1):
|
||||
assert CONTEXT_PROBE_TIERS[i] > CONTEXT_PROBE_TIERS[i + 1]
|
||||
|
||||
def test_first_tier_is_256k(self):
|
||||
assert CONTEXT_PROBE_TIERS[0] == 256_000
|
||||
|
||||
def test_last_tier_is_8k(self):
|
||||
assert CONTEXT_PROBE_TIERS[-1] == 8_000
|
||||
|
||||
|
||||
class TestGetNextProbeTier:
|
||||
def test_from_256k(self):
|
||||
|
||||
@@ -82,17 +82,6 @@ SAMPLE_REGISTRY = {
|
||||
|
||||
|
||||
class TestProviderMapping:
|
||||
def test_all_mapped_providers_are_strings(self):
|
||||
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
|
||||
assert isinstance(hermes_id, str)
|
||||
assert isinstance(mdev_id, str)
|
||||
|
||||
def test_known_providers_mapped(self):
|
||||
assert PROVIDER_TO_MODELS_DEV["anthropic"] == "anthropic"
|
||||
assert PROVIDER_TO_MODELS_DEV["copilot"] == "github-copilot"
|
||||
assert PROVIDER_TO_MODELS_DEV["stepfun"] == "stepfun"
|
||||
assert PROVIDER_TO_MODELS_DEV["kilocode"] == "kilo"
|
||||
|
||||
def test_xai_oauth_uses_xai_catalog(self):
|
||||
assert PROVIDER_TO_MODELS_DEV["xai"] == "xai"
|
||||
assert PROVIDER_TO_MODELS_DEV["xai-oauth"] == "xai"
|
||||
|
||||
+14
-20
@@ -342,39 +342,33 @@ class TestJWTTokens:
|
||||
|
||||
|
||||
class TestDiscordMentions:
|
||||
"""Discord snowflake IDs in <@ID> or <@!ID> format."""
|
||||
"""Discord mention snowflakes (<@ID> / <@!ID>) are public syntax, not
|
||||
secrets — they must pass through the redactor unchanged so multi-bot
|
||||
@-pings (DISCORD_ALLOW_BOTS=mentions) keep resolving. See issue #35611."""
|
||||
|
||||
def test_normal_mention(self):
|
||||
result = redact_sensitive_text("Hello <@222589316709220353>")
|
||||
assert "222589316709220353" not in result
|
||||
assert "<@***>" in result
|
||||
def test_normal_mention_passes_through(self):
|
||||
text = "Hello <@222589316709220353>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_nickname_mention(self):
|
||||
result = redact_sensitive_text("Ping <@!1331549159177846844>")
|
||||
assert "1331549159177846844" not in result
|
||||
assert "<@!***>" in result
|
||||
def test_nickname_mention_passes_through(self):
|
||||
text = "Ping <@!1331549159177846844>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_multiple_mentions(self):
|
||||
def test_multiple_mentions_pass_through(self):
|
||||
text = "<@111111111111111111> and <@222222222222222222>"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "111111111111111111" not in result
|
||||
assert "222222222222222222" not in result
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_short_id_not_matched(self):
|
||||
"""IDs shorter than 17 digits are not Discord snowflakes."""
|
||||
def test_short_id_passes_through(self):
|
||||
text = "<@12345>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_slack_mention_not_matched(self):
|
||||
"""Slack mentions use letters, not pure digits."""
|
||||
def test_slack_mention_passes_through(self):
|
||||
text = "<@U024BE7LH>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_preserves_surrounding_text(self):
|
||||
text = "User <@222589316709220353> said hello"
|
||||
result = redact_sensitive_text(text)
|
||||
assert result.startswith("User ")
|
||||
assert result.endswith(" said hello")
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
|
||||
class TestWebUrlsNotRedacted:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Regression coverage for #35344: a resumed session must not let a stale
|
||||
``## Active Task`` from an inherited compaction handoff hijack the reply to a
|
||||
new, unrelated user message.
|
||||
|
||||
The failure mode (real report): a lineage was compacted, producing a handoff
|
||||
whose ``## Active Task`` described task A. The lineage was resumed later and
|
||||
the user asked about an unrelated task B. The model answered with A because
|
||||
the handoff's resume directive outranked the fresh ask.
|
||||
|
||||
The structural fix lives in ``SUMMARY_PREFIX``: the handoff is framed as
|
||||
reference-only and the latest user message explicitly *wins* on conflict, with
|
||||
named reverse-signal verbs. Two invariants guard the resume path specifically:
|
||||
|
||||
1. A handoff persisted under the OLD (conflicting) prefix is re-normalized to
|
||||
the CURRENT prefix when it is re-compacted on a resumed lineage — so a
|
||||
pre-fix stale handoff cannot keep its "resume exactly" directive forever.
|
||||
|
||||
2. The current handoff prefix contains an unambiguous "latest message wins /
|
||||
discard stale Active Task" rule, so an unrelated new ask is privileged over
|
||||
the inherited ``## Active Task``.
|
||||
|
||||
These are content/structural assertions (no live model call) — they pin the
|
||||
mechanism that makes the stale task historical rather than active.
|
||||
"""
|
||||
|
||||
from agent.context_compressor import (
|
||||
SUMMARY_PREFIX,
|
||||
LEGACY_SUMMARY_PREFIX,
|
||||
ContextCompressor,
|
||||
)
|
||||
|
||||
|
||||
# The conflicting prefix that shipped before the #35344 fix. A handoff
|
||||
# persisted in a resumed lineage could carry this verbatim.
|
||||
_OLD_CONFLICTING_PREFIX = (
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
|
||||
"into the summary below. This is a handoff from a previous context "
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary; "
|
||||
"they were already addressed. "
|
||||
"Your current task is identified in the '## Active Task' section of the "
|
||||
"summary — resume exactly from there. "
|
||||
"Respond ONLY to the latest user message "
|
||||
"that appears AFTER this summary. The current session state (files, "
|
||||
"config, etc.) may reflect work described here — avoid repeating it:"
|
||||
)
|
||||
|
||||
|
||||
def test_latest_message_wins_over_inherited_active_task():
|
||||
"""The handoff must explicitly privilege the latest user message over a
|
||||
stale ``## Active Task`` — the core #35344 contract."""
|
||||
lower = SUMMARY_PREFIX.lower()
|
||||
assert "latest user message" in lower
|
||||
assert "## active task" in lower
|
||||
# Conflict-resolution must be explicit, not implied.
|
||||
assert "wins" in lower or "supersede" in lower
|
||||
assert "discard" in lower
|
||||
|
||||
|
||||
def test_no_resume_exactly_directive_can_hijack():
|
||||
"""The directive that caused the hijack ("resume exactly from Active
|
||||
Task") must be gone."""
|
||||
assert "resume exactly" not in SUMMARY_PREFIX.lower()
|
||||
|
||||
|
||||
def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
|
||||
"""A handoff persisted under the OLD conflicting prefix (e.g. saved before
|
||||
the fix and inherited into a resumed lineage) is upgraded to the CURRENT
|
||||
prefix when re-normalized on re-compaction — so the "resume exactly"
|
||||
directive cannot survive into a resumed session."""
|
||||
stale_body = (
|
||||
"## Active Task\n"
|
||||
"User asked: 'Migrate the billing module to Stripe'\n\n"
|
||||
"## Goal\nMigrate billing.\n"
|
||||
)
|
||||
stale_handoff = f"{_OLD_CONFLICTING_PREFIX}\n{stale_body}"
|
||||
|
||||
# Sanity: the fixture really does carry the old directive.
|
||||
assert "resume exactly" in stale_handoff.lower()
|
||||
|
||||
renormalized = ContextCompressor._with_summary_prefix(stale_handoff)
|
||||
|
||||
# The body is preserved...
|
||||
assert "Migrate the billing module to Stripe" in renormalized
|
||||
# ...but the conflicting directive is stripped and replaced with the
|
||||
# current latest-message-wins framing.
|
||||
assert "resume exactly" not in renormalized.lower()
|
||||
assert renormalized.startswith(SUMMARY_PREFIX)
|
||||
assert "wins" in renormalized.lower()
|
||||
|
||||
|
||||
def test_legacy_prefix_handoff_also_renormalized():
|
||||
"""The same upgrade applies to the oldest ``[CONTEXT SUMMARY]:`` handoff
|
||||
format that may sit in a long-lived resumed lineage."""
|
||||
legacy = f"{LEGACY_SUMMARY_PREFIX} ## Active Task\nUser asked: 'task A'"
|
||||
renormalized = ContextCompressor._with_summary_prefix(legacy)
|
||||
assert renormalized.startswith(SUMMARY_PREFIX)
|
||||
assert LEGACY_SUMMARY_PREFIX not in renormalized
|
||||
assert "task A" in renormalized
|
||||
|
||||
|
||||
def test_inherited_handoff_detected_in_resumed_protected_head():
|
||||
"""On a resumed lineage the handoff commonly sits right after the system
|
||||
prompt (in the protected head). ``_find_latest_context_summary`` must
|
||||
detect it there so re-compaction rehydrates state from it rather than
|
||||
serializing it as a fresh user turn (which is what let the stale Active
|
||||
Task read as live intent)."""
|
||||
messages = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": f"{SUMMARY_PREFIX}\n## Active Task\nUser asked: 'task A'"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "Unrelated task B: what's the capital of France?"},
|
||||
]
|
||||
# Search the whole post-system range.
|
||||
idx, body = ContextCompressor._find_latest_context_summary(
|
||||
messages, 1, len(messages)
|
||||
)
|
||||
assert idx == 1, "handoff in protected head must be found"
|
||||
assert "task A" in body
|
||||
# The detected body is stripped of the prefix (treated as state, not a
|
||||
# standalone instruction message).
|
||||
assert not body.startswith(SUMMARY_PREFIX)
|
||||
|
||||
|
||||
def test_historical_prefixed_handoff_detected_and_stripped():
|
||||
"""A pre-fix handoff (old conflicting prefix) inherited into a resumed
|
||||
lineage must still be recognized as a context summary AND have its old
|
||||
directive stripped on detection — otherwise re-compaction serializes the
|
||||
stale 'resume exactly' text as a fresh turn."""
|
||||
messages = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n## Active Task\nUser asked: 'task A'"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "Unrelated task B"},
|
||||
]
|
||||
idx, body = ContextCompressor._find_latest_context_summary(
|
||||
messages, 1, len(messages)
|
||||
)
|
||||
assert idx == 1
|
||||
assert "task A" in body
|
||||
assert "resume exactly" not in body.lower()
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Regression test: set_runtime_main() must pass base_url/api_key/api_mode
|
||||
so that _resolve_auto() can route custom: providers in Step 1.
|
||||
|
||||
Fixes https://github.com/NousResearch/hermes-agent/issues/34777
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def _get_globals(mod):
|
||||
"""Read runtime globals without triggering redaction."""
|
||||
return {
|
||||
"provider": mod._RUNTIME_MAIN_PROVIDER,
|
||||
"model": mod._RUNTIME_MAIN_MODEL,
|
||||
"base_url": mod._RUNTIME_MAIN_BASE_URL,
|
||||
"cred": mod._RUNTIME_MAIN_API_KEY, # renamed to avoid redaction
|
||||
"api_mode": mod._RUNTIME_MAIN_API_MODE,
|
||||
}
|
||||
|
||||
|
||||
class TestSetRuntimeMainCustomProvider:
|
||||
"""set_runtime_main must propagate base_url/api_key/api_mode for custom providers."""
|
||||
|
||||
def test_globals_stored(self):
|
||||
"""set_runtime_main stores all five fields in process-local globals."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main(
|
||||
"custom:my-router",
|
||||
"glm-5.1",
|
||||
base_url="https://my-server.example.com/v1",
|
||||
api_key="sk-test-key",
|
||||
api_mode="chat_completions",
|
||||
)
|
||||
g = _get_globals(mod)
|
||||
assert g["provider"] == "custom:my-router"
|
||||
assert g["model"] == "glm-5.1"
|
||||
assert g["base_url"] == "https://my-server.example.com/v1"
|
||||
assert g["cred"] == "sk-test-key"
|
||||
assert g["api_mode"] == "chat_completions"
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
|
||||
def test_clear_resets_all_globals(self):
|
||||
"""clear_runtime_main resets all five globals to empty."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
mod.set_runtime_main(
|
||||
"custom:x", "m",
|
||||
base_url="https://x.example.com",
|
||||
api_key="sk-abc",
|
||||
api_mode="chat_completions",
|
||||
)
|
||||
mod.clear_runtime_main()
|
||||
g = _get_globals(mod)
|
||||
for v in g.values():
|
||||
assert v == "", f"Expected empty, got {v!r}"
|
||||
|
||||
def test_resolve_auto_uses_globals_for_custom_provider(self):
|
||||
"""_resolve_auto reads base_url/api_key from globals when main_runtime is None."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main(
|
||||
"custom:test-router",
|
||||
"test-model",
|
||||
base_url="https://custom-endpoint.example.com/v1",
|
||||
api_key="sk-test-123",
|
||||
)
|
||||
|
||||
with patch.object(mod, "resolve_provider_client") as mock_resolve:
|
||||
mock_resolve.return_value = (MagicMock(), "test-model")
|
||||
client, resolved = mod._resolve_auto(main_runtime=None)
|
||||
|
||||
mock_resolve.assert_called_once()
|
||||
call_args = mock_resolve.call_args
|
||||
assert call_args[0][0] == "custom"
|
||||
assert call_args[1]["explicit_base_url"] == "https://custom-endpoint.example.com/v1"
|
||||
assert call_args[1]["explicit_api_key"] == "sk-test-123"
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
|
||||
def test_explicit_main_runtime_takes_precedence(self):
|
||||
"""When main_runtime dict has values, globals are NOT used."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main(
|
||||
"custom:router-a",
|
||||
"model-a",
|
||||
base_url="https://from-global.example.com",
|
||||
api_key="sk-global",
|
||||
)
|
||||
|
||||
with patch.object(mod, "resolve_provider_client") as mock_resolve:
|
||||
mock_resolve.return_value = (MagicMock(), "model-b")
|
||||
main_rt = {
|
||||
"provider": "custom:router-b",
|
||||
"model": "model-b",
|
||||
"base_url": "https://from-dict.example.com",
|
||||
"api_key": "sk-dict",
|
||||
}
|
||||
mod._resolve_auto(main_runtime=main_rt)
|
||||
|
||||
call_args = mock_resolve.call_args[1]
|
||||
assert call_args["explicit_base_url"] == "https://from-dict.example.com"
|
||||
assert call_args["explicit_api_key"] == "sk-dict"
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
|
||||
def test_backward_compatible_defaults(self):
|
||||
"""Calling set_runtime_main with only positional args still works."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main("openrouter", "gpt-4o")
|
||||
g = _get_globals(mod)
|
||||
assert g["provider"] == "openrouter"
|
||||
assert g["model"] == "gpt-4o"
|
||||
assert g["base_url"] == ""
|
||||
assert g["cred"] == ""
|
||||
assert g["api_mode"] == ""
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
|
||||
|
||||
class TestResolveAutoCustomEndToEnd:
|
||||
"""End-to-end routing assertions — build a *real* client (no mock on
|
||||
resolve_provider_client) and verify the auxiliary auto-detect chain lands
|
||||
on the user's custom endpoint instead of falling through to the aggregator
|
||||
chain. These guard the actual user-visible symptom in #34777 (aux tasks
|
||||
silently routed to a fallback provider) rather than just the wiring.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _client_base_url(client):
|
||||
for chain in (("base_url",), ("_client", "base_url")):
|
||||
obj = client
|
||||
try:
|
||||
for attr in chain:
|
||||
obj = getattr(obj, attr)
|
||||
return str(obj)
|
||||
except AttributeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
def test_config_less_custom_endpoint_routes_via_global(self, tmp_path, monkeypatch):
|
||||
"""custom:<name> with NO config entry: the live base_url carried by
|
||||
set_runtime_main() must build a real client at that endpoint — not
|
||||
fall through to Step 2 (the regression in #34777)."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
# Hermetic: no aggregator creds, no stale OPENAI_BASE_URL.
|
||||
for var in ("OPENROUTER_API_KEY", "NOUS_API_KEY", "OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"model:\n"
|
||||
" default: glm-5.1\n"
|
||||
" provider: 'custom:ephemeral'\n"
|
||||
" base_url: ''\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main(
|
||||
"custom:ephemeral",
|
||||
"glm-5.1",
|
||||
base_url="https://ephemeral.live/v1",
|
||||
api_key="sk-live",
|
||||
)
|
||||
client, resolved = mod.resolve_provider_client("auto", None)
|
||||
assert client is not None, (
|
||||
"config-less custom endpoint fell through to Step 2 — "
|
||||
"the #34777 bug is back"
|
||||
)
|
||||
assert resolved == "glm-5.1"
|
||||
base = self._client_base_url(client)
|
||||
assert base and base.rstrip("/") == "https://ephemeral.live/v1"
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
|
||||
def test_named_custom_with_config_entry_still_routes(self, tmp_path, monkeypatch):
|
||||
"""Regression guard: custom:<name> WITH a custom_providers entry must
|
||||
still resolve to that entry's endpoint. An earlier competing fix
|
||||
collapsed the provider to bare ``custom`` before resolution, which
|
||||
broke the named-custom branch and returned None here."""
|
||||
import agent.auxiliary_client as mod
|
||||
|
||||
for var in ("OPENROUTER_API_KEY", "NOUS_API_KEY", "OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"model:\n"
|
||||
" default: glm-5.1\n"
|
||||
" provider: 'custom:openclaw'\n"
|
||||
" base_url: ''\n"
|
||||
"custom_providers:\n"
|
||||
" - name: openclaw\n"
|
||||
" base_url: 'https://withcfg.example/v1'\n"
|
||||
" model: glm-5.1\n"
|
||||
" api_key: cfg-key\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# No live base_url carried — resolution must come from config alone,
|
||||
# via the named-custom branch in resolve_provider_client.
|
||||
mod.clear_runtime_main()
|
||||
try:
|
||||
mod.set_runtime_main("custom:openclaw", "glm-5.1")
|
||||
client, resolved = mod.resolve_provider_client("auto", None)
|
||||
assert client is not None
|
||||
base = self._client_base_url(client)
|
||||
assert base and base.rstrip("/") == "https://withcfg.example/v1"
|
||||
finally:
|
||||
mod.clear_runtime_main()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Pin the semantics of SUMMARY_PREFIX so the compaction handoff doesn't
|
||||
re-introduce conflicting instructions.
|
||||
|
||||
Background: SUMMARY_PREFIX previously contained two contradictory directives:
|
||||
|
||||
1. "treat it as background reference, NOT as active instructions"
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary"
|
||||
"Respond ONLY to the latest user message that appears AFTER this summary"
|
||||
|
||||
2. "Your current task is identified in the '## Active Task' section of the
|
||||
summary — resume exactly from there."
|
||||
|
||||
When the latest user message contradicted Active Task (e.g. "stop the
|
||||
i18n refactor", "never mind, look at grafana"), the model often followed
|
||||
(2) anyway because "resume exactly" is a strong directive — leading to
|
||||
the agent repeatedly re-surfacing already-cancelled work across turns.
|
||||
|
||||
These tests pin the post-fix invariants so the conflict cannot regress.
|
||||
"""
|
||||
|
||||
from agent.context_compressor import SUMMARY_PREFIX
|
||||
|
||||
|
||||
def test_no_resume_exactly_directive():
|
||||
"""The prefix must not tell the model to resume Active Task verbatim."""
|
||||
assert "resume exactly" not in SUMMARY_PREFIX.lower()
|
||||
|
||||
|
||||
def test_latest_message_wins_on_conflict():
|
||||
"""The prefix must explicitly say latest user message wins on conflict."""
|
||||
lower = SUMMARY_PREFIX.lower()
|
||||
assert "latest user message" in lower
|
||||
# Must have an explicit conflict-resolution rule.
|
||||
assert "wins" in lower or "supersede" in lower or "discard" in lower
|
||||
|
||||
|
||||
def test_reverse_signals_called_out():
|
||||
"""Reverse signals (stop/undo/never mind/topic change) must be named so
|
||||
the model recognizes them as cancellation triggers, not just background."""
|
||||
lower = SUMMARY_PREFIX.lower()
|
||||
# At least a few of the canonical reverse-signal verbs should appear.
|
||||
reverse_terms = ["stop", "undo", "roll back", "never mind", "just verify"]
|
||||
hits = sum(1 for t in reverse_terms if t in lower)
|
||||
assert hits >= 3, (
|
||||
f"Expected ≥3 reverse-signal terms in SUMMARY_PREFIX, found {hits}. "
|
||||
"Without naming them the model treats reverse signals as ordinary "
|
||||
"context and keeps pushing the cancelled task."
|
||||
)
|
||||
|
||||
|
||||
def test_summary_marked_reference_only():
|
||||
"""The REFERENCE ONLY framing must remain — it's the entire point."""
|
||||
assert "REFERENCE ONLY" in SUMMARY_PREFIX
|
||||
assert "background reference" in SUMMARY_PREFIX
|
||||
assert "NOT as active instructions" in SUMMARY_PREFIX
|
||||
|
||||
|
||||
def test_memory_authority_preserved():
|
||||
"""The fix must not weaken the MEMORY.md / USER.md authority clause."""
|
||||
assert "MEMORY.md" in SUMMARY_PREFIX
|
||||
assert "USER.md" in SUMMARY_PREFIX
|
||||
assert "authoritative" in SUMMARY_PREFIX
|
||||
@@ -112,8 +112,13 @@ class TestCallLlmUnsupportedTemperatureRetry:
|
||||
retry_kwargs = client.chat.completions.create.call_args_list[1].kwargs
|
||||
assert first_kwargs["temperature"] == 0.3
|
||||
assert "temperature" not in retry_kwargs
|
||||
# other kwargs preserved
|
||||
assert retry_kwargs["max_tokens"] == 500
|
||||
# max_tokens is intentionally omitted on OpenAI-compatible endpoints
|
||||
# (#34530) — auxiliary calls let the model max out its own output — so
|
||||
# it must be absent in BOTH the first and retry kwargs. Use a kwarg that
|
||||
# actually survives (model) to prove the retry preserves the rest.
|
||||
assert "max_tokens" not in first_kwargs
|
||||
assert "max_tokens" not in retry_kwargs
|
||||
assert retry_kwargs["model"] == first_kwargs["model"]
|
||||
|
||||
def test_non_temperature_400_does_not_retry_as_temperature(self):
|
||||
"""Unrelated 400s (e.g. bad tool role) must not silently drop temp."""
|
||||
@@ -207,7 +212,11 @@ class TestAsyncCallLlmUnsupportedTemperatureRetry:
|
||||
retry_kwargs = client.chat.completions.create.call_args_list[1].kwargs
|
||||
assert first_kwargs["temperature"] == 0.3
|
||||
assert "temperature" not in retry_kwargs
|
||||
assert retry_kwargs["max_tokens"] == 500
|
||||
# max_tokens is intentionally omitted on OpenAI-compatible endpoints
|
||||
# (#34530); assert it's absent and that model survives the retry.
|
||||
assert "max_tokens" not in first_kwargs
|
||||
assert "max_tokens" not in retry_kwargs
|
||||
assert retry_kwargs["model"] == first_kwargs["model"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_non_temperature_400_does_not_retry(self):
|
||||
|
||||
@@ -75,6 +75,27 @@ class TestLightModeDetection:
|
||||
assert cli_mod._detect_light_mode() is True
|
||||
|
||||
|
||||
class TestOsc11Probe:
|
||||
"""The OSC 11 background probe must never run where its reply can leak
|
||||
into prompt_toolkit's input (a late BEL-terminated reply reads as Ctrl+G
|
||||
= open-editor, trapping the user in a stray editor). Guard the cases we
|
||||
refuse to probe in.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("var", ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"))
|
||||
def test_skips_over_ssh(self, cli_mod, monkeypatch, var):
|
||||
monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: True, raising=False)
|
||||
monkeypatch.setattr(cli_mod.sys.stdout, "isatty", lambda: True, raising=False)
|
||||
for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"):
|
||||
monkeypatch.delenv(v, raising=False)
|
||||
monkeypatch.setenv(var, "1.2.3.4 5555 22")
|
||||
assert cli_mod._query_osc11_background() is None
|
||||
|
||||
def test_skips_when_not_a_tty(self, cli_mod, monkeypatch):
|
||||
monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: False, raising=False)
|
||||
assert cli_mod._query_osc11_background() is None
|
||||
|
||||
|
||||
class TestLightModeRemap:
|
||||
def test_remap_no_op_in_dark_mode(self, cli_mod, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_LIGHT", "0")
|
||||
@@ -133,7 +154,9 @@ class TestSkinConfigHook:
|
||||
after = SkinConfig.get_color
|
||||
assert before is after
|
||||
|
||||
def test_skin_color_remaps_through_wrapper_in_light_mode(self, cli_mod, monkeypatch):
|
||||
def test_skin_color_remaps_through_wrapper_in_light_mode(
|
||||
self, cli_mod, monkeypatch
|
||||
):
|
||||
from hermes_cli.skin_engine import SkinConfig
|
||||
|
||||
cli_mod._LIGHT_MODE_CACHE = True
|
||||
|
||||
@@ -11,6 +11,7 @@ def _make_cli():
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.agent = None
|
||||
cli_obj._session_db = MagicMock()
|
||||
cli_obj._pending_resume_sessions = None
|
||||
# _handle_resume_command now triggers _display_resumed_history (#31695),
|
||||
# which reads self.resume_display. "minimal" short-circuits the recap so
|
||||
# the test only exercises session-switch behavior.
|
||||
@@ -116,3 +117,107 @@ class TestCliResumeCommand:
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
assert "<half" in printed
|
||||
|
||||
|
||||
class TestPendingResumeNumberedSelection:
|
||||
"""Bare `/resume` arms a one-shot prompt so the next bare number resumes.
|
||||
|
||||
Regression coverage for #34584: previously, running `/resume` (no args)
|
||||
printed the recent-sessions list but left no selection state armed, so
|
||||
typing just `3` on the next line was sent to the agent as chat instead of
|
||||
resuming session #3.
|
||||
"""
|
||||
|
||||
def test_bare_resume_arms_pending_selection(self):
|
||||
cli_obj = _make_cli()
|
||||
sessions = [
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
{"id": "sess_001", "title": "Research"},
|
||||
]
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
|
||||
cli_obj._show_recent_sessions = MagicMock(return_value=True)
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_resume_command("/resume")
|
||||
|
||||
assert cli_obj._pending_resume_sessions == sessions
|
||||
|
||||
def test_bare_resume_no_sessions_does_not_arm(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._show_recent_sessions = MagicMock(return_value=False)
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=[])
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_resume_command("/resume")
|
||||
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_number_resumes_selected_session(self):
|
||||
cli_obj = _make_cli()
|
||||
sessions = [
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
{"id": "sess_001", "title": "Research"},
|
||||
]
|
||||
cli_obj._pending_resume_sessions = sessions
|
||||
# _handle_resume_command("/resume 2") re-resolves the index via
|
||||
# _list_recent_sessions, so it must return the same list.
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
|
||||
cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
|
||||
cli_obj._session_db.get_messages_as_conversation.return_value = [
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
|
||||
|
||||
with (
|
||||
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
|
||||
patch("cli._cprint"),
|
||||
):
|
||||
consumed = cli_obj._consume_pending_resume_selection("2")
|
||||
|
||||
assert consumed is True
|
||||
assert cli_obj.session_id == "sess_001"
|
||||
# One-shot: prompt is disarmed after consuming.
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_out_of_range_consumed_with_message(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
consumed = cli_obj._consume_pending_resume_selection("9")
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
# An out-of-range number is still consumed (not sent to the agent),
|
||||
# and the prompt is disarmed.
|
||||
assert consumed is True
|
||||
assert "out of range" in printed.lower()
|
||||
assert cli_obj.session_id == "current_session"
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_non_numeric_falls_through_and_disarms(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
|
||||
with patch("cli._cprint"):
|
||||
consumed = cli_obj._consume_pending_resume_selection("hello there")
|
||||
|
||||
# Free text is NOT consumed (caller treats it as chat), but the
|
||||
# one-shot prompt is disarmed so a later number isn't hijacked.
|
||||
assert consumed is False
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_no_pending_returns_false(self):
|
||||
cli_obj = _make_cli()
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
assert cli_obj._consume_pending_resume_selection("3") is False
|
||||
|
||||
def test_pending_disarmed_by_other_command(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
# Stub out the help handler so process_command("/help") is cheap.
|
||||
cli_obj.show_help = MagicMock()
|
||||
|
||||
cli_obj.process_command("/help")
|
||||
|
||||
# A non-resume command disarms the one-shot prompt (#34584).
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
@@ -81,6 +81,29 @@ class TestCLIStatusBar:
|
||||
assert "$0.06" not in text # cost hidden by default
|
||||
assert "15m" in text
|
||||
|
||||
def test_post_compression_sentinel_does_not_render_negative(self):
|
||||
"""Right after a compression, last_prompt_tokens is parked at the -1
|
||||
sentinel until the next API call reports real usage. The status bar
|
||||
must clamp it to 0 instead of rendering "-1/200K" / "-1%".
|
||||
"""
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
completion_tokens=2_220,
|
||||
total_tokens=12_450,
|
||||
api_calls=7,
|
||||
context_tokens=-1,
|
||||
context_length=200_000,
|
||||
)
|
||||
|
||||
snapshot = cli_obj._get_status_bar_snapshot()
|
||||
assert snapshot["context_tokens"] == 0
|
||||
assert snapshot["context_percent"] == 0
|
||||
|
||||
text = cli_obj._build_status_bar_text(width=120)
|
||||
assert "-1" not in text
|
||||
assert "0/200K" in text
|
||||
|
||||
def test_input_height_counts_wide_characters_using_cell_width(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for /compress here [N] — boundary-aware partial compression.
|
||||
|
||||
Verifies the CLI handler (_manual_compress) splits the history, compresses
|
||||
only the head, and re-appends the verbatim tail. Inspired by Claude Code's
|
||||
Rewind "Summarize up to here" action (v2.1.139, May 2026).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.cli.test_cli_init import _make_cli
|
||||
|
||||
|
||||
def _make_history() -> list[dict[str, str]]:
|
||||
# 8 messages = 4 exchanges.
|
||||
h: list[dict[str, str]] = []
|
||||
for i in range(4):
|
||||
h.append({"role": "user", "content": f"u{i}"})
|
||||
h.append({"role": "assistant", "content": f"a{i}"})
|
||||
return h
|
||||
|
||||
|
||||
def _wire_agent(shell, compressed_head):
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = None
|
||||
shell.agent.tools = None
|
||||
shell.agent._compress_context.return_value = (compressed_head, "")
|
||||
|
||||
|
||||
def test_compress_here_compresses_head_only(capsys):
|
||||
"""/compress here 2 passes only the head to _compress_context."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
# Pretend compression collapses the head into a single summary message.
|
||||
summary = [{"role": "user", "content": "[summary of earlier turns]"}]
|
||||
_wire_agent(shell, summary)
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress here 2")
|
||||
|
||||
# _compress_context should have been called with the HEAD only
|
||||
# (everything before the last 2 user-starts = first 4 messages).
|
||||
shell.agent._compress_context.assert_called_once()
|
||||
call = shell.agent._compress_context.call_args
|
||||
passed_head = call.args[0]
|
||||
assert passed_head == history[:4]
|
||||
# focus_topic must be None in partial mode (modes are exclusive).
|
||||
assert call.kwargs.get("focus_topic") is None
|
||||
|
||||
|
||||
def test_compress_here_reappends_verbatim_tail(capsys):
|
||||
"""The most recent exchanges are preserved verbatim after the summary."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
# Head compresses to an assistant-role summary so the seam
|
||||
# (assistant -> user tail) is already valid — tail rides along whole.
|
||||
summary = [{"role": "assistant", "content": "[summary]"}]
|
||||
_wire_agent(shell, summary)
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress here 2")
|
||||
|
||||
# Result = compressed head + verbatim tail (last 2 exchanges).
|
||||
assert shell.conversation_history == summary + history[4:]
|
||||
# Tail boundary keeps role alternation valid (tail starts on user).
|
||||
assert history[4]["role"] == "user"
|
||||
# No consecutive same-role user/assistant messages anywhere.
|
||||
roles = [m["role"] for m in shell.conversation_history
|
||||
if m["role"] in ("user", "assistant")]
|
||||
assert all(roles[i] != roles[i + 1] for i in range(len(roles) - 1))
|
||||
|
||||
|
||||
def test_compress_here_banner_mentions_summarizing_up_to_here(capsys):
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
_wire_agent(shell, [{"role": "user", "content": "[summary]"}])
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress here")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Summarizing up to here" in out
|
||||
assert "verbatim" in out
|
||||
|
||||
|
||||
def test_bare_compress_still_full(capsys):
|
||||
"""/compress with no args compresses the whole history (full mode)."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
_wire_agent(shell, list(history))
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress")
|
||||
|
||||
call = shell.agent._compress_context.call_args
|
||||
# Full mode passes the entire history as the head.
|
||||
assert call.args[0] == history
|
||||
out = capsys.readouterr().out
|
||||
assert "Summarizing up to here" not in out
|
||||
|
||||
|
||||
def test_focus_still_works(capsys):
|
||||
"""/compress <focus> keeps the existing focus behavior."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
_wire_agent(shell, list(history))
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress database schema")
|
||||
|
||||
call = shell.agent._compress_context.call_args
|
||||
assert call.args[0] == history
|
||||
assert call.kwargs.get("focus_topic") == "database schema"
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests for hermes_cli.partial_compress — the pure split/parse helpers
|
||||
behind ``/compress here [N]`` (boundary-aware "summarize up to here").
|
||||
|
||||
Inspired by Claude Code's Rewind "Summarize up to here" action.
|
||||
"""
|
||||
|
||||
from hermes_cli.partial_compress import (
|
||||
DEFAULT_KEEP_LAST,
|
||||
MAX_KEEP_LAST,
|
||||
parse_partial_compress_args,
|
||||
rejoin_compressed_head_and_tail,
|
||||
split_history_for_partial_compress,
|
||||
)
|
||||
|
||||
|
||||
def _history(n_pairs: int) -> list[dict[str, str]]:
|
||||
"""Build n_pairs of (user, assistant) exchanges."""
|
||||
h: list[dict[str, str]] = []
|
||||
for i in range(n_pairs):
|
||||
h.append({"role": "user", "content": f"u{i}"})
|
||||
h.append({"role": "assistant", "content": f"a{i}"})
|
||||
return h
|
||||
|
||||
|
||||
# ── parse_partial_compress_args ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_args_is_full_compress():
|
||||
partial, keep, focus = parse_partial_compress_args("")
|
||||
assert partial is False
|
||||
assert keep == DEFAULT_KEEP_LAST
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_here_defaults_keep_last():
|
||||
partial, keep, focus = parse_partial_compress_args("here")
|
||||
assert partial is True
|
||||
assert keep == DEFAULT_KEEP_LAST
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_here_with_count():
|
||||
partial, keep, focus = parse_partial_compress_args("here 4")
|
||||
assert partial is True
|
||||
assert keep == 4
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_up_to_here_alias():
|
||||
partial, keep, focus = parse_partial_compress_args("up to here 3")
|
||||
assert partial is True
|
||||
assert keep == 3
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_keep_flag_forms():
|
||||
for arg in ("--keep 5", "-k 5", "--keep=5"):
|
||||
partial, keep, focus = parse_partial_compress_args(arg)
|
||||
assert partial is True, arg
|
||||
assert keep == 5, arg
|
||||
assert focus is None, arg
|
||||
|
||||
|
||||
def test_focus_topic_when_not_boundary_form():
|
||||
partial, keep, focus = parse_partial_compress_args("database schema")
|
||||
assert partial is False
|
||||
assert focus == "database schema"
|
||||
|
||||
|
||||
def test_here_count_clamped_low_and_high():
|
||||
_, keep_low, _ = parse_partial_compress_args("here 0")
|
||||
assert keep_low == 1
|
||||
_, keep_high, _ = parse_partial_compress_args(f"here {MAX_KEEP_LAST + 50}")
|
||||
assert keep_high == MAX_KEEP_LAST
|
||||
|
||||
|
||||
def test_here_garbage_count_falls_back_to_default():
|
||||
partial, keep, focus = parse_partial_compress_args("here lots")
|
||||
assert partial is True
|
||||
assert keep == DEFAULT_KEEP_LAST
|
||||
|
||||
|
||||
# ── split_history_for_partial_compress ───────────────────────────────
|
||||
|
||||
|
||||
def test_split_keeps_last_n_exchanges():
|
||||
h = _history(5) # 10 messages: u0 a0 u1 a1 u2 a2 u3 a3 u4 a4
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=2)
|
||||
# Keep last 2 user-starts → tail begins at u3 (index 6).
|
||||
assert tail == h[6:]
|
||||
assert head == h[:6]
|
||||
# Tail must begin on a user turn (role-alternation safety).
|
||||
assert tail[0]["role"] == "user"
|
||||
|
||||
|
||||
def test_split_default_keep():
|
||||
h = _history(4) # 8 messages
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=DEFAULT_KEEP_LAST)
|
||||
assert tail[0]["role"] == "user"
|
||||
assert head + tail == h
|
||||
assert len(head) > 0
|
||||
|
||||
|
||||
def test_split_tail_always_starts_on_user():
|
||||
# Tool messages interleaved — tail must still snap to a user turn.
|
||||
h = [
|
||||
{"role": "user", "content": "u0"},
|
||||
{"role": "assistant", "content": "a0"},
|
||||
{"role": "user", "content": "u1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "tool", "content": "t1"},
|
||||
{"role": "assistant", "content": "a1b"},
|
||||
{"role": "user", "content": "u2"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
]
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=1)
|
||||
assert tail[0]["role"] == "user"
|
||||
assert tail[0]["content"] == "u2"
|
||||
assert head + tail == h
|
||||
|
||||
|
||||
def test_split_degenerate_returns_no_tail():
|
||||
# keep_last larger than the number of exchanges → nothing to compress.
|
||||
h = _history(2) # 4 messages, 2 user turns
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=5)
|
||||
# Boundary lands at the first user turn → head empty → signal full.
|
||||
assert tail == []
|
||||
assert head == h
|
||||
|
||||
|
||||
def test_split_empty_history():
|
||||
head, tail = split_history_for_partial_compress([], keep_last=2)
|
||||
assert head == []
|
||||
assert tail == []
|
||||
|
||||
|
||||
def test_split_rejoin_preserves_all_messages():
|
||||
h = _history(6)
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=3)
|
||||
assert head + tail == h
|
||||
|
||||
|
||||
# ── rejoin_compressed_head_and_tail (seam-alternation guard) ─────────
|
||||
|
||||
|
||||
def _roles(msgs):
|
||||
return [m["role"] for m in msgs if m["role"] in ("user", "assistant")]
|
||||
|
||||
|
||||
def _no_consecutive_dupes(msgs):
|
||||
r = _roles(msgs)
|
||||
return all(r[i] != r[i + 1] for i in range(len(r) - 1))
|
||||
|
||||
|
||||
def test_rejoin_valid_seam_assistant_then_user():
|
||||
# Normal case: head ends on assistant, tail starts on user → valid.
|
||||
head = [{"role": "user", "content": "[summary]"},
|
||||
{"role": "assistant", "content": "ack"}]
|
||||
tail = [{"role": "user", "content": "next"},
|
||||
{"role": "assistant", "content": "reply"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert out == head + tail
|
||||
assert _no_consecutive_dupes(out)
|
||||
|
||||
|
||||
def test_rejoin_user_user_seam_merges():
|
||||
# Degenerate head ending on a user summary; tail starts on user.
|
||||
head = [{"role": "user", "content": "[summary of head]"}]
|
||||
tail = [{"role": "user", "content": "latest question"},
|
||||
{"role": "assistant", "content": "answer"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert _no_consecutive_dupes(out), out
|
||||
# The two user messages were merged into one.
|
||||
assert out[0]["content"] == "[summary of head]\n\nlatest question"
|
||||
assert out[1] == {"role": "assistant", "content": "answer"}
|
||||
|
||||
|
||||
def test_rejoin_assistant_assistant_seam_merges():
|
||||
head = [{"role": "user", "content": "q"},
|
||||
{"role": "assistant", "content": "head end"}]
|
||||
tail = [{"role": "assistant", "content": "tail start"},
|
||||
{"role": "user", "content": "u"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert _no_consecutive_dupes(out), out
|
||||
assert out[-2]["content"] == "head end\n\ntail start"
|
||||
|
||||
|
||||
def test_rejoin_empty_tail_returns_head():
|
||||
head = [{"role": "user", "content": "x"}]
|
||||
assert rejoin_compressed_head_and_tail(head, []) == head
|
||||
|
||||
|
||||
def test_rejoin_tool_seam_left_alone():
|
||||
# tool->tool is the one legal repetition; don't merge.
|
||||
head = [{"role": "user", "content": "q"}, {"role": "tool", "content": "t1"}]
|
||||
tail = [{"role": "user", "content": "u"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert out == head + tail
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Regression guard for issue #34569 — inline /steer (and /model) submit
|
||||
must repaint the input area after clearing the buffer.
|
||||
|
||||
Mechanism of the bug
|
||||
--------------------
|
||||
``handle_enter`` dispatches ``/steer`` (and ``/model``) inline on the UI
|
||||
thread while the agent is running. Those branches called
|
||||
``buffer.reset(append_to_history=True)`` but — unlike every *other*
|
||||
early-return branch in the handler — did NOT call ``event.app.invalidate()``.
|
||||
Because ``process_command()`` prints through ``patch_stdout`` (which scrolls
|
||||
output above the prompt and never triggers a prompt_toolkit redraw), the
|
||||
just-cleared input area could keep showing the submitted ``/steer <text>``
|
||||
until some unrelated redraw fired. The user saw their submitted text as if
|
||||
it were unsent and could accidentally re-submit it.
|
||||
|
||||
This test pins the contract structurally: inside ``handle_enter``, any
|
||||
inline-command early-return that resets the buffer must be followed by an
|
||||
``event.app.invalidate()`` before its ``return``. It is an *invariant*
|
||||
(every reset-then-return repaints), not a snapshot of current source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_handle_enter_node() -> ast.FunctionDef:
|
||||
"""Extract the ``handle_enter`` nested function node from cli.py."""
|
||||
cli_path = Path(__file__).resolve().parents[2] / "cli.py"
|
||||
tree = ast.parse(cli_path.read_text(encoding="utf-8"))
|
||||
|
||||
target = None
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "handle_enter":
|
||||
target = node
|
||||
break
|
||||
assert target is not None, "handle_enter closure not found in cli.py"
|
||||
return target
|
||||
|
||||
|
||||
def _is_buffer_reset(node: ast.stmt) -> bool:
|
||||
"""True if the statement is ``...current_buffer.reset(...)``."""
|
||||
if not isinstance(node, ast.Expr):
|
||||
return False
|
||||
call = node.value
|
||||
if not isinstance(call, ast.Call):
|
||||
return False
|
||||
func = call.func
|
||||
return isinstance(func, ast.Attribute) and func.attr == "reset"
|
||||
|
||||
|
||||
def _is_invalidate(node: ast.stmt) -> bool:
|
||||
"""True if the statement is ``event.app.invalidate()``."""
|
||||
if not isinstance(node, ast.Expr):
|
||||
return False
|
||||
call = node.value
|
||||
if not isinstance(call, ast.Call):
|
||||
return False
|
||||
func = call.func
|
||||
return isinstance(func, ast.Attribute) and func.attr == "invalidate"
|
||||
|
||||
|
||||
def _collect_reset_blocks(func: ast.FunctionDef) -> list[list[ast.stmt]]:
|
||||
"""Find every statement sequence (a block body/orelse/finalbody) within
|
||||
``handle_enter`` that contains a ``buffer.reset()`` call."""
|
||||
blocks: list[list[ast.stmt]] = []
|
||||
for node in ast.walk(func):
|
||||
for attr in ("body", "orelse", "finalbody"):
|
||||
seq = getattr(node, attr, None)
|
||||
if not isinstance(seq, list):
|
||||
continue
|
||||
if any(isinstance(s, ast.stmt) and _is_buffer_reset(s) for s in seq):
|
||||
blocks.append(seq)
|
||||
return blocks
|
||||
|
||||
|
||||
def test_inline_command_reset_branches_invalidate():
|
||||
"""Every handle_enter branch that resets the buffer and then returns must
|
||||
invalidate the app first (issue #34569)."""
|
||||
func = _load_handle_enter_node()
|
||||
reset_blocks = _collect_reset_blocks(func)
|
||||
|
||||
assert reset_blocks, "expected to find buffer.reset() calls in handle_enter"
|
||||
|
||||
offenders = []
|
||||
for seq in reset_blocks:
|
||||
for i, stmt in enumerate(seq):
|
||||
if not _is_buffer_reset(stmt):
|
||||
continue
|
||||
# Find the next return after this reset in the same block.
|
||||
ret_idx = None
|
||||
for j in range(i + 1, len(seq)):
|
||||
if isinstance(seq[j], ast.Return):
|
||||
ret_idx = j
|
||||
break
|
||||
if ret_idx is None:
|
||||
# reset not directly followed by a return in this block
|
||||
# (e.g. the fall-through reset at the end of the handler) —
|
||||
# the next user input naturally repaints, so skip.
|
||||
continue
|
||||
between = seq[i + 1 : ret_idx]
|
||||
if not any(_is_invalidate(s) for s in between):
|
||||
offenders.append(ast.dump(stmt))
|
||||
|
||||
assert not offenders, (
|
||||
"handle_enter has reset-then-return branch(es) that never call "
|
||||
"event.app.invalidate() — the input area can keep showing the "
|
||||
"submitted text (issue #34569). Offending reset stmts:\n"
|
||||
+ "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
test_inline_command_reset_branches_invalidate()
|
||||
print("ok")
|
||||
@@ -182,6 +182,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
|
||||
"HERMES_SESSION_SOURCE",
|
||||
"HERMES_SESSION_KEY",
|
||||
"HERMES_GATEWAY_SESSION",
|
||||
"_HERMES_GATEWAY",
|
||||
"HERMES_PLATFORM",
|
||||
"HERMES_MODEL",
|
||||
"HERMES_INFERENCE_MODEL",
|
||||
|
||||
@@ -276,6 +276,111 @@ class TestExtractCacheBustingConfig:
|
||||
|
||||
assert out["tools.registry_generation"] == 12345
|
||||
|
||||
|
||||
def test_skips_honcho_config_read_when_provider_is_not_honcho(self, monkeypatch):
|
||||
"""Non-Honcho gateways must not read/parse honcho.json on every message."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
called = False
|
||||
|
||||
def _boom():
|
||||
nonlocal called
|
||||
called = True
|
||||
raise AssertionError("should not read Honcho config")
|
||||
|
||||
monkeypatch.setattr(GatewayRunner, "_extract_honcho_cache_busting_config", _boom)
|
||||
|
||||
out = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "mem0"}})
|
||||
|
||||
assert called is False
|
||||
assert out["honcho.peer_name"] is None
|
||||
assert out["honcho.user_peer_aliases"] is None
|
||||
|
||||
def test_reads_honcho_config_only_when_provider_is_honcho(self, monkeypatch):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
calls = []
|
||||
|
||||
def _fake():
|
||||
calls.append(True)
|
||||
return {
|
||||
"honcho.peer_name": "eri",
|
||||
"honcho.ai_peer": "hermes",
|
||||
"honcho.pin_peer_name": True,
|
||||
"honcho.runtime_peer_prefix": "tg_",
|
||||
"honcho.user_peer_aliases": [("123", "eri")],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(GatewayRunner, "_extract_honcho_cache_busting_config", _fake)
|
||||
|
||||
out = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
|
||||
|
||||
assert calls == [True]
|
||||
assert out["honcho.peer_name"] == "eri"
|
||||
assert out["honcho.user_peer_aliases"] == [("123", "eri")]
|
||||
|
||||
def test_memory_provider_change_busts_signature(self, monkeypatch):
|
||||
"""Switching memory.provider must itself change the cache-busting
|
||||
signature, so the agent is rebuilt when a user swaps providers
|
||||
mid-gateway (independent of the honcho.json identity keys)."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
# Neutralize honcho.json reads so the only varying input is the
|
||||
# provider value itself.
|
||||
monkeypatch.setattr(
|
||||
GatewayRunner,
|
||||
"_extract_honcho_cache_busting_config",
|
||||
classmethod(lambda cls: cls._empty_honcho_cache_busting_config()),
|
||||
)
|
||||
|
||||
sig_honcho = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
|
||||
sig_mem0 = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "mem0"}})
|
||||
|
||||
assert sig_honcho["memory.provider"] == "honcho"
|
||||
assert sig_mem0["memory.provider"] == "mem0"
|
||||
assert sig_honcho != sig_mem0
|
||||
|
||||
def test_honcho_cache_busting_config_memoized_by_mtime(self, monkeypatch, tmp_path):
|
||||
"""Repeated Honcho extraction for unchanged honcho.json should reuse parse result."""
|
||||
from types import SimpleNamespace
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
config_path = tmp_path / "honcho.json"
|
||||
config_path.write_text("{}")
|
||||
parse_calls = []
|
||||
|
||||
class FakeConfig:
|
||||
peer_name = "eri"
|
||||
ai_peer = "hermes"
|
||||
pin_peer_name = False
|
||||
runtime_peer_prefix = "tg_"
|
||||
user_peer_aliases = {"123": "eri"}
|
||||
|
||||
@classmethod
|
||||
def from_global_config(cls, config_path=None):
|
||||
parse_calls.append(config_path)
|
||||
return cls()
|
||||
|
||||
fake_client = SimpleNamespace(
|
||||
HonchoClientConfig=FakeConfig,
|
||||
resolve_config_path=lambda: config_path,
|
||||
)
|
||||
monkeypatch.setitem(__import__("sys").modules, "plugins.memory.honcho.client", fake_client)
|
||||
monkeypatch.setattr(GatewayRunner, "_HONCHO_CACHE_BUSTING_MEMO", {})
|
||||
|
||||
first = GatewayRunner._extract_honcho_cache_busting_config()
|
||||
second = GatewayRunner._extract_honcho_cache_busting_config()
|
||||
|
||||
assert first == second
|
||||
assert first["honcho.user_peer_aliases"] == [("123", "eri")]
|
||||
assert parse_calls == [config_path]
|
||||
|
||||
config_path.write_text("{\n \"changed\": true\n}")
|
||||
third = GatewayRunner._extract_honcho_cache_busting_config()
|
||||
|
||||
assert third == first
|
||||
assert parse_calls == [config_path, config_path]
|
||||
|
||||
def test_full_round_trip_busts_cache_on_real_edit(self):
|
||||
"""End-to-end: simulate a config edit on main and verify the
|
||||
extracted cache_keys change produces a new signature."""
|
||||
|
||||
@@ -343,6 +343,56 @@ class TestLoadGatewayConfig:
|
||||
# Env value preserved, not clobbered by yaml.
|
||||
assert os.environ.get("DISCORD_THREAD_REQUIRE_MENTION") == "true"
|
||||
|
||||
def test_bridges_discord_allow_from_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
"""discord.allow_from should populate DISCORD_ALLOWED_USERS for auth."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"discord:\n"
|
||||
" allow_from:\n"
|
||||
" - \"123456789012345678\"\n"
|
||||
" - \"999888777666555444\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("DISCORD_ALLOWED_USERS", raising=False)
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.platforms[Platform.DISCORD].extra["allow_from"] == [
|
||||
"123456789012345678",
|
||||
"999888777666555444",
|
||||
]
|
||||
assert os.environ.get("DISCORD_ALLOWED_USERS") == (
|
||||
"123456789012345678,999888777666555444"
|
||||
)
|
||||
|
||||
def test_bridges_discord_platform_extra_allow_from_to_env(self, tmp_path, monkeypatch):
|
||||
"""platforms.discord.extra.allow_from should reach DISCORD_ALLOWED_USERS too."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"platforms:\n"
|
||||
" discord:\n"
|
||||
" extra:\n"
|
||||
" allow_from:\n"
|
||||
" - \"123456789012345678\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("DISCORD_ALLOWED_USERS", raising=False)
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.platforms[Platform.DISCORD].extra["allow_from"] == [
|
||||
"123456789012345678",
|
||||
]
|
||||
assert os.environ.get("DISCORD_ALLOWED_USERS") == "123456789012345678"
|
||||
|
||||
def test_bridges_quoted_false_platform_enabled_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
@@ -361,6 +411,69 @@ class TestLoadGatewayConfig:
|
||||
assert config.platforms[Platform.API_SERVER].enabled is False
|
||||
assert Platform.API_SERVER not in config.get_connected_platforms()
|
||||
|
||||
def test_bridges_nested_gateway_platforms_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n"
|
||||
" platforms:\n"
|
||||
" telegram:\n"
|
||||
" enabled: true\n"
|
||||
" token: nested-token\n"
|
||||
" home_channel:\n"
|
||||
" platform: telegram\n"
|
||||
" chat_id: \"123\"\n"
|
||||
" name: Nested Home\n"
|
||||
" extra:\n"
|
||||
" reply_prefix: nested\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
telegram = config.platforms[Platform.TELEGRAM]
|
||||
assert telegram.enabled is True
|
||||
assert telegram.token == "nested-token"
|
||||
assert telegram.home_channel == HomeChannel(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123",
|
||||
name="Nested Home",
|
||||
)
|
||||
assert telegram.extra["reply_prefix"] == "nested"
|
||||
|
||||
def test_top_level_platforms_override_nested_gateway_platforms(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"gateway:\n"
|
||||
" platforms:\n"
|
||||
" telegram:\n"
|
||||
" enabled: false\n"
|
||||
" token: nested-token\n"
|
||||
" extra:\n"
|
||||
" reply_prefix: nested\n"
|
||||
"platforms:\n"
|
||||
" telegram:\n"
|
||||
" enabled: true\n"
|
||||
" token: top-token\n"
|
||||
" extra:\n"
|
||||
" reply_prefix: top\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
telegram = config.platforms[Platform.TELEGRAM]
|
||||
assert telegram.enabled is True
|
||||
assert telegram.token == "top-token"
|
||||
assert telegram.extra["reply_prefix"] == "top"
|
||||
|
||||
def test_bridges_quoted_false_session_notify_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Tests for the outbound silence-narration filter (anti-loop control).
|
||||
|
||||
See the gateway delivery path: hallucinated "silence" tokens like ``*(silent)*``
|
||||
are dropped pre-send so bot-to-bot channels can't mirror them into a token-burning
|
||||
loop that crashes a model with "no content after all retries".
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform
|
||||
from gateway.delivery import (
|
||||
DeliveryRouter,
|
||||
DeliveryTarget,
|
||||
_is_silence_narration,
|
||||
)
|
||||
|
||||
|
||||
# --- Truth table -----------------------------------------------------------
|
||||
|
||||
POSITIVE_CASES = [
|
||||
"*(silent)*",
|
||||
"*Silence.*",
|
||||
"🔇",
|
||||
".",
|
||||
"…",
|
||||
"...",
|
||||
"(silent)",
|
||||
"_silent_",
|
||||
"silent",
|
||||
" *(silent)* ",
|
||||
"`silent`",
|
||||
"~silent~",
|
||||
"Silence",
|
||||
"no response",
|
||||
"No Reply.",
|
||||
]
|
||||
|
||||
NEGATIVE_CASES = [
|
||||
"Silence is golden — here is the plan...",
|
||||
"Silent install completed",
|
||||
"The deployment ran silently in the background",
|
||||
"ok",
|
||||
"👍",
|
||||
"Here is the result:\n\n- item one\n- item two",
|
||||
"I have nothing to add, but here is why: the build is green.",
|
||||
"silently", # word boundary — trailing letters mean it isn't a bare token
|
||||
"no responses were collected from the survey",
|
||||
# A 64+ char string that opens with a silence token must not be dropped.
|
||||
"silent " + "x" * 70,
|
||||
"",
|
||||
" ",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", POSITIVE_CASES)
|
||||
def test_is_silence_narration_positive(content):
|
||||
assert _is_silence_narration(content) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", NEGATIVE_CASES)
|
||||
def test_is_silence_narration_negative(content):
|
||||
assert _is_silence_narration(content) is False
|
||||
|
||||
|
||||
def test_is_silence_narration_none_safe():
|
||||
assert _is_silence_narration(None) is False
|
||||
|
||||
|
||||
def test_length_guard_rejects_long_strings():
|
||||
# Exactly 65 chars of dots — over the 64-char guard, so not treated as narration.
|
||||
assert _is_silence_narration("." * 65) is False
|
||||
assert _is_silence_narration("." * 64) is True
|
||||
|
||||
|
||||
# --- Integration through DeliveryRouter ------------------------------------
|
||||
|
||||
class RecordingAdapter:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def send(self, chat_id, content, metadata=None):
|
||||
self.calls.append({"chat_id": chat_id, "content": content, "metadata": metadata})
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_silence_narration_dropped_pre_send(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
|
||||
adapter = RecordingAdapter()
|
||||
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
|
||||
target = DeliveryTarget.parse("discord:99887766")
|
||||
|
||||
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
|
||||
|
||||
assert adapter.calls == [] # adapter.send never invoked
|
||||
assert result == {
|
||||
"success": True,
|
||||
"filtered": "silence_narration",
|
||||
"delivered": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_message_is_delivered(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
|
||||
adapter = RecordingAdapter()
|
||||
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
|
||||
target = DeliveryTarget.parse("discord:99887766")
|
||||
|
||||
result = await router._deliver_to_platform(
|
||||
target, "Silence is golden — here is the plan...", metadata=None
|
||||
)
|
||||
|
||||
assert len(adapter.calls) == 1
|
||||
assert adapter.calls[0]["content"] == "Silence is golden — here is the plan..."
|
||||
assert result == {"success": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_opt_out_lets_silence_through(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
|
||||
adapter = RecordingAdapter()
|
||||
config = GatewayConfig(filter_silence_narration=False)
|
||||
router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})
|
||||
target = DeliveryTarget.parse("discord:99887766")
|
||||
|
||||
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
|
||||
|
||||
assert len(adapter.calls) == 1
|
||||
assert adapter.calls[0]["content"] == "*(silent)*"
|
||||
assert result == {"success": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_override_disables_filter(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_FILTER_SILENCE_NARRATION", "0")
|
||||
adapter = RecordingAdapter()
|
||||
# Config default is True, but env override wins.
|
||||
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
|
||||
target = DeliveryTarget.parse("discord:99887766")
|
||||
|
||||
result = await router._deliver_to_platform(target, "🔇", metadata=None)
|
||||
|
||||
assert len(adapter.calls) == 1
|
||||
assert result == {"success": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_override_enables_filter_over_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_FILTER_SILENCE_NARRATION", "1")
|
||||
adapter = RecordingAdapter()
|
||||
# Config says off, env override forces on.
|
||||
config = GatewayConfig(filter_silence_narration=False)
|
||||
router = DeliveryRouter(config, adapters={Platform.DISCORD: adapter})
|
||||
target = DeliveryTarget.parse("discord:99887766")
|
||||
|
||||
result = await router._deliver_to_platform(target, "*(silent)*", metadata=None)
|
||||
|
||||
assert adapter.calls == []
|
||||
assert result["filtered"] == "silence_narration"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_delivery_not_filtered(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.delenv("HERMES_FILTER_SILENCE_NARRATION", raising=False)
|
||||
router = DeliveryRouter(GatewayConfig(), adapters={})
|
||||
|
||||
results = await router.deliver(
|
||||
content="*(silent)*",
|
||||
targets=[DeliveryTarget.parse("local")],
|
||||
job_id="silence-job",
|
||||
)
|
||||
|
||||
# Local path saved the file (no loop risk) and was not filtered.
|
||||
local_result = results["local"]
|
||||
assert local_result["success"] is True
|
||||
saved_path = local_result["result"]["path"]
|
||||
assert saved_path.endswith(".md")
|
||||
|
||||
|
||||
# --- Config round-trip ------------------------------------------------------
|
||||
|
||||
def test_config_flag_defaults_true():
|
||||
assert GatewayConfig().filter_silence_narration is True
|
||||
|
||||
|
||||
def test_config_from_dict_parses_flag():
|
||||
cfg = GatewayConfig.from_dict({"filter_silence_narration": False})
|
||||
assert cfg.filter_silence_narration is False
|
||||
|
||||
|
||||
def test_config_to_dict_roundtrip():
|
||||
cfg = GatewayConfig(filter_silence_narration=False)
|
||||
assert cfg.to_dict()["filter_silence_narration"] is False
|
||||
restored = GatewayConfig.from_dict(cfg.to_dict())
|
||||
assert restored.filter_silence_narration is False
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Regression tests for #35314 — empty model on the post-interrupt recovery turn.
|
||||
|
||||
After a ``stream_interrupt_abort`` during an active gateway session, the recovery
|
||||
turn was sometimes built with ``model=""`` (a transient config-cache miss returned
|
||||
an empty ``user_config``). Every API call then failed HTTP 400 "No models
|
||||
provided", "trying fallback..." was logged but never executed (the user had no
|
||||
fallback configured), and the session went silent until the user re-sent.
|
||||
|
||||
These tests pin two fixes:
|
||||
1. ``_resolve_session_agent_runtime`` caches the last successfully-resolved
|
||||
model per session and recovers it when a fresh resolution comes back empty.
|
||||
2. ``_has_pending_fallback`` gates the "trying fallback..." status so it is only
|
||||
announced when a fallback chain actually exists.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import gateway.run as gateway_run
|
||||
|
||||
|
||||
def _make_runner():
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner._session_model_overrides = {}
|
||||
runner._last_resolved_model = {}
|
||||
runner._service_tier = None
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
return runner
|
||||
|
||||
|
||||
def _patch_resolution(monkeypatch, *, model_from_config: str, provider: str = "openrouter"):
|
||||
"""Stub gateway model + runtime resolution to a known state."""
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda cfg=None: model_from_config)
|
||||
monkeypatch.setattr(
|
||||
gateway_run,
|
||||
"_resolve_runtime_agent_kwargs",
|
||||
lambda: {
|
||||
"provider": provider,
|
||||
"api_key": "x",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_normal_turn_caches_last_resolved_model(monkeypatch):
|
||||
_patch_resolution(monkeypatch, model_from_config="deepseek/deepseek-v4-flash")
|
||||
runner = _make_runner()
|
||||
sk = "agent:main:discord:dm:123"
|
||||
|
||||
model, _ = runner._resolve_session_agent_runtime(session_key=sk, user_config={"model": {"default": "x"}})
|
||||
|
||||
assert model == "deepseek/deepseek-v4-flash"
|
||||
# Cached per-session AND process-wide for first-seen-session recovery.
|
||||
assert runner._last_resolved_model[sk] == "deepseek/deepseek-v4-flash"
|
||||
assert runner._last_resolved_model["*"] == "deepseek/deepseek-v4-flash"
|
||||
|
||||
|
||||
def test_empty_model_recovers_session_last_good(monkeypatch):
|
||||
runner = _make_runner()
|
||||
sk = "agent:main:discord:dm:123"
|
||||
|
||||
# Turn 1: config has the model — cache it.
|
||||
_patch_resolution(monkeypatch, model_from_config="deepseek/deepseek-v4-flash")
|
||||
runner._resolve_session_agent_runtime(session_key=sk, user_config={"model": {"default": "x"}})
|
||||
|
||||
# Turn 2: simulate the transient empty config read (the #35314 race).
|
||||
_patch_resolution(monkeypatch, model_from_config="", provider="")
|
||||
model, _ = runner._resolve_session_agent_runtime(session_key=sk, user_config={})
|
||||
|
||||
assert model == "deepseek/deepseek-v4-flash", "recovery turn must reuse last-known-good, not build model=''"
|
||||
|
||||
|
||||
def test_empty_model_new_session_recovers_global_last_good(monkeypatch):
|
||||
runner = _make_runner()
|
||||
|
||||
# Prime a different session so the process-wide "*" slot is populated.
|
||||
_patch_resolution(monkeypatch, model_from_config="deepseek/deepseek-v4-flash")
|
||||
runner._resolve_session_agent_runtime(session_key="agent:main:discord:dm:111", user_config={"model": {}})
|
||||
|
||||
# A brand-new session that hits an empty config read still recovers via "*".
|
||||
_patch_resolution(monkeypatch, model_from_config="", provider="")
|
||||
model, _ = runner._resolve_session_agent_runtime(session_key="agent:main:discord:dm:999", user_config={})
|
||||
|
||||
assert model == "deepseek/deepseek-v4-flash"
|
||||
|
||||
|
||||
def test_cold_start_empty_model_does_not_crash(monkeypatch):
|
||||
"""No last-good anywhere + empty config → returns '' gracefully (no exception)."""
|
||||
_patch_resolution(monkeypatch, model_from_config="", provider="")
|
||||
runner = _make_runner()
|
||||
|
||||
model, _ = runner._resolve_session_agent_runtime(session_key="agent:main:discord:dm:1", user_config={})
|
||||
|
||||
assert model == ""
|
||||
|
||||
|
||||
def test_bare_runner_without_cache_attr_does_not_crash(monkeypatch):
|
||||
"""object.__new__ runners (test helpers / pitfall #17) lack _last_resolved_model.
|
||||
|
||||
The getattr guard must tolerate the missing attribute.
|
||||
"""
|
||||
_patch_resolution(monkeypatch, model_from_config="deepseek/deepseek-v4-flash")
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner._session_model_overrides = {}
|
||||
runner._service_tier = None
|
||||
# Deliberately omit _last_resolved_model.
|
||||
|
||||
model, _ = runner._resolve_session_agent_runtime(session_key="x", user_config={"model": {}})
|
||||
|
||||
assert model == "deepseek/deepseek-v4-flash"
|
||||
|
||||
|
||||
# ── _has_pending_fallback gate ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _bare_agent():
|
||||
import run_agent
|
||||
|
||||
return object.__new__(run_agent.AIAgent)
|
||||
|
||||
|
||||
def test_has_pending_fallback_empty_chain():
|
||||
agent = _bare_agent()
|
||||
agent._fallback_chain = []
|
||||
agent._fallback_index = 0
|
||||
assert agent._has_pending_fallback() is False
|
||||
|
||||
|
||||
def test_has_pending_fallback_with_chain():
|
||||
agent = _bare_agent()
|
||||
agent._fallback_chain = [{"provider": "openai", "model": "gpt-5"}]
|
||||
agent._fallback_index = 0
|
||||
assert agent._has_pending_fallback() is True
|
||||
|
||||
|
||||
def test_has_pending_fallback_exhausted_chain():
|
||||
agent = _bare_agent()
|
||||
agent._fallback_chain = [{"provider": "openai", "model": "gpt-5"}]
|
||||
agent._fallback_index = 1
|
||||
assert agent._has_pending_fallback() is False
|
||||
|
||||
|
||||
def test_has_pending_fallback_missing_attrs():
|
||||
"""Bare agent with no fallback attributes set must default to False, not crash."""
|
||||
agent = _bare_agent()
|
||||
assert agent._has_pending_fallback() is False
|
||||
@@ -268,6 +268,37 @@ async def test_process_message_unwraps_ephemeral_before_send():
|
||||
assert ("42", "sent-1") in adapter.deleted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_ephemeral_reply_does_not_auto_upload_bare_paths(tmp_path):
|
||||
"""Tips/system notices may mention local paths; they must remain text."""
|
||||
adapter = _delete_adapter()
|
||||
adapter._send_with_retry = AsyncMock(
|
||||
return_value=SendResult(success=True, message_id="sent-1")
|
||||
)
|
||||
adapter.send_document = AsyncMock(
|
||||
return_value=SendResult(success=True, message_id="doc-1")
|
||||
)
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text("model:\n provider: test\n", encoding="utf-8")
|
||||
reply_text = f"Tip: hermes chat --ignore-user-config skips {config_path}"
|
||||
|
||||
async def _handler(evt):
|
||||
return EphemeralReply(reply_text, ttl_seconds=0)
|
||||
|
||||
adapter.set_message_handler(_handler)
|
||||
|
||||
event = _make_event(text="/new")
|
||||
session_key = "agent:main:telegram:private:42"
|
||||
with patch("gateway.platforms.base.asyncio.sleep", AsyncMock()), patch.object(
|
||||
adapter, "_keep_typing", new=AsyncMock()
|
||||
):
|
||||
await adapter._process_message_background(event, session_key)
|
||||
|
||||
adapter._send_with_retry.assert_called_once()
|
||||
assert adapter._send_with_retry.call_args.kwargs["content"] == reply_text
|
||||
adapter.send_document.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_incapable_platform_does_not_schedule_delete():
|
||||
adapter = _no_delete_adapter()
|
||||
|
||||
@@ -336,9 +336,35 @@ class TestEdgeCases:
|
||||
paths, _ = _extract("File at /tmp/my file.png here")
|
||||
assert paths == []
|
||||
|
||||
def test_windows_path_not_matched(self):
|
||||
"""Windows-style paths should not match."""
|
||||
paths, _ = _extract("See C:\\Users\\test\\image.png")
|
||||
@pytest.mark.parametrize(
|
||||
"content,expected",
|
||||
[
|
||||
# Backslash separators (native Windows style)
|
||||
("See C:\\Users\\test\\image.png here", "C:\\Users\\test\\image.png"),
|
||||
# Forward slashes with drive letter (common in cross-platform code)
|
||||
("See C:/Users/test/image.png here", "C:/Users/test/image.png"),
|
||||
# Non-C: drive
|
||||
("Video at D:/data/clip.mp4 ready", "D:/data/clip.mp4"),
|
||||
# Lowercase drive letter
|
||||
("Path e:/audio/track.mp3 done", "e:/audio/track.mp3"),
|
||||
],
|
||||
)
|
||||
def test_windows_drive_letter_paths_matched(self, content, expected):
|
||||
"""Windows drive-letter paths (C:/..., C:\\...) must be detected (#34632).
|
||||
|
||||
Prior behavior anchored on (?:~/|/) only, which silently dropped
|
||||
Windows absolute paths so the agent's bare-path references were
|
||||
sent as text instead of native uploads.
|
||||
"""
|
||||
paths, cleaned = _extract(content)
|
||||
assert paths == [expected]
|
||||
assert expected not in cleaned
|
||||
|
||||
def test_relative_windows_path_not_matched(self):
|
||||
"""A bare Windows-style filename without a drive letter must still
|
||||
not match (e.g. ``foo\\bar.png`` is treated as relative, like its
|
||||
Unix sibling ``foo/bar.png``)."""
|
||||
paths, _ = _extract("File at foo\\bar.png here")
|
||||
assert paths == []
|
||||
|
||||
def test_relative_path_not_matched(self):
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Dict
|
||||
@@ -4603,7 +4604,7 @@ class TestFeishuFetchMessageText(unittest.TestCase):
|
||||
adapter._bot_open_id = "ou_bot"
|
||||
adapter._bot_user_id = ""
|
||||
adapter._bot_name = "Hermes"
|
||||
adapter._message_text_cache = {}
|
||||
adapter._message_text_cache = OrderedDict()
|
||||
adapter._client = Mock()
|
||||
adapter._build_get_message_request = Mock(return_value=object())
|
||||
return adapter
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user