Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b40b6ec720 | ||
|
|
402d048eb6 | ||
|
|
b52123eb15 | ||
|
|
284e084bcc | ||
|
|
3c54ceb3ca | ||
|
|
66d2d7090e | ||
|
|
51ca575994 | ||
|
|
c96a548bde | ||
|
|
a1d57292af | ||
|
|
83efea661f | ||
|
|
1e8254e599 | ||
|
|
2e5ddf9d2e | ||
|
|
6513138f26 | ||
|
|
44a16c5d9d | ||
|
|
e86acad8f1 | ||
|
|
4ac1c959b2 | ||
|
|
76c454914a | ||
|
|
d6ed35d047 | ||
|
|
ea9ddecc72 | ||
|
|
4107538da8 | ||
|
|
103c71ac36 | ||
|
|
8410ac05a9 | ||
|
|
b49a1b71a7 | ||
|
|
e0d698cfb3 | ||
|
|
ea67e49574 | ||
|
|
b641639e42 | ||
|
|
3ef6992edf | ||
|
|
6fb98f343a | ||
|
|
48f2ac3352 | ||
|
|
1e8cfa9092 | ||
|
|
88993a468f | ||
|
|
a7cc903bf5 | ||
|
|
408fc893e9 | ||
|
|
ea32364c96 | ||
|
|
d55a17bd82 | ||
|
|
7027ce42ef | ||
|
|
88564ad8bc | ||
|
|
81a504a4a0 | ||
|
|
c323217188 | ||
|
|
de849c410d | ||
|
|
8dc936f10e | ||
|
|
61d0a99c11 | ||
|
|
921133cfa5 | ||
|
|
fc3862bdd6 | ||
|
|
bc5da42b2c | ||
|
|
5b0741e986 | ||
|
|
9e1f606f7f | ||
|
|
7eae504d15 | ||
|
|
eda400d8a5 | ||
|
|
82197a87dc | ||
|
|
dee51c1607 | ||
|
|
5e8262da26 | ||
|
|
1f216ecbb4 | ||
|
|
70a33708e7 | ||
|
|
f06adcc1ae | ||
|
|
06ebe34b40 | ||
|
|
7785654ad5 | ||
|
|
04e039f687 | ||
|
|
97a536057d | ||
|
|
2efb0eea21 | ||
|
|
77e04a29d5 | ||
|
|
40619b393f | ||
|
|
3e652f75b2 | ||
|
|
5fb143169b | ||
|
|
be11a75eae | ||
|
|
83cb9a03ee | ||
|
|
cf55c738e7 | ||
|
|
ba7e8b0df9 | ||
|
|
b66644f0ec | ||
|
|
b8663813b6 | ||
|
|
b43524ecab | ||
|
|
3f60a907e1 | ||
|
|
8bcd77a9c2 | ||
|
|
d166716c65 | ||
|
|
a7d78d3bfd | ||
|
|
30ec12970b | ||
|
|
c6b1ef4e58 |
@@ -1083,6 +1083,31 @@ def convert_messages_to_anthropic(
|
||||
"name": fn.get("name", ""),
|
||||
"input": parsed_args,
|
||||
})
|
||||
# Kimi's /coding endpoint (Anthropic protocol) requires assistant
|
||||
# tool-call messages to carry reasoning_content when thinking is
|
||||
# enabled server-side. Preserve it as a thinking block so Kimi
|
||||
# can validate the message history. See hermes-agent#13848.
|
||||
#
|
||||
# Accept empty string "" — _copy_reasoning_content_for_api()
|
||||
# injects "" as a tier-3 fallback for Kimi tool-call messages
|
||||
# that had no reasoning. Kimi requires the field to exist, even
|
||||
# if empty.
|
||||
#
|
||||
# Prepend (not append): Anthropic protocol requires thinking
|
||||
# blocks before text and tool_use blocks.
|
||||
#
|
||||
# Guard: only add when reasoning_details didn't already contribute
|
||||
# thinking blocks. On native Anthropic, reasoning_details produces
|
||||
# signed thinking blocks — adding another unsigned one from
|
||||
# reasoning_content would create a duplicate (same text) that gets
|
||||
# downgraded to a spurious text block on the last assistant message.
|
||||
reasoning_content = m.get("reasoning_content")
|
||||
_already_has_thinking = any(
|
||||
isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking")
|
||||
for b in blocks
|
||||
)
|
||||
if isinstance(reasoning_content, str) and not _already_has_thinking:
|
||||
blocks.insert(0, {"type": "thinking", "thinking": reasoning_content})
|
||||
# Anthropic rejects empty assistant content
|
||||
effective = blocks or content
|
||||
if not effective or effective == "":
|
||||
@@ -1238,6 +1263,7 @@ def convert_messages_to_anthropic(
|
||||
# cache markers can interfere with signature validation.
|
||||
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
|
||||
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
|
||||
_is_kimi = _is_kimi_coding_endpoint(base_url)
|
||||
|
||||
last_assistant_idx = None
|
||||
for i in range(len(result) - 1, -1, -1):
|
||||
@@ -1249,7 +1275,25 @@ def convert_messages_to_anthropic(
|
||||
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
|
||||
continue
|
||||
|
||||
if _is_third_party or idx != last_assistant_idx:
|
||||
if _is_kimi:
|
||||
# Kimi's /coding endpoint enables thinking server-side and
|
||||
# requires unsigned thinking blocks on replayed assistant
|
||||
# tool-call messages. Strip signed Anthropic blocks (Kimi
|
||||
# can't validate signatures) but preserve the unsigned ones
|
||||
# we synthesised from reasoning_content above.
|
||||
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 b.get("signature") or b.get("data"):
|
||||
# Anthropic-signed block — Kimi can't validate, strip
|
||||
continue
|
||||
# Unsigned thinking (synthesised from reasoning_content) —
|
||||
# keep it: Kimi needs it for message-history validation.
|
||||
new_content.append(b)
|
||||
m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
|
||||
elif _is_third_party or idx != last_assistant_idx:
|
||||
# Third-party endpoint: strip ALL thinking blocks from every
|
||||
# assistant message — signatures are Anthropic-proprietary.
|
||||
# Direct Anthropic: strip from non-latest assistant messages only.
|
||||
|
||||
@@ -134,6 +134,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
|
||||
"gemini": "gemini-3-flash-preview",
|
||||
"zai": "glm-4.5-flash",
|
||||
"kimi-coding": "kimi-k2-turbo-preview",
|
||||
"stepfun": "step-3.5-flash",
|
||||
"kimi-coding-cn": "kimi-k2-turbo-preview",
|
||||
"minimax": "MiniMax-M2.7",
|
||||
"minimax-cn": "MiniMax-M2.7",
|
||||
|
||||
@@ -64,6 +64,47 @@ _CHARS_PER_TOKEN = 4
|
||||
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
|
||||
|
||||
|
||||
def _content_text_for_contains(content: Any) -> str:
|
||||
"""Return a best-effort text view of message content.
|
||||
|
||||
Used only for substring checks when we need to know whether we've already
|
||||
appended a note to a message. Keeps multimodal lists intact elsewhere.
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
text = item.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(part for part in parts if part)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _append_text_to_content(content: Any, text: str, *, prepend: bool = False) -> Any:
|
||||
"""Append or prepend plain text to message content safely.
|
||||
|
||||
Compression sometimes needs to add a note or merge a summary into an
|
||||
existing message. Message content may be plain text or a multimodal list of
|
||||
blocks, so direct string concatenation is not always safe.
|
||||
"""
|
||||
if content is None:
|
||||
return text
|
||||
if isinstance(content, str):
|
||||
return text + content if prepend else content + text
|
||||
if isinstance(content, list):
|
||||
text_block = {"type": "text", "text": text}
|
||||
return [text_block, *content] if prepend else [*content, text_block]
|
||||
rendered = str(content)
|
||||
return text + rendered if prepend else rendered + text
|
||||
|
||||
|
||||
def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str:
|
||||
"""Shrink long string values inside a tool-call arguments JSON blob while
|
||||
preserving JSON validity.
|
||||
@@ -1144,10 +1185,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
for i in range(compress_start):
|
||||
msg = messages[i].copy()
|
||||
if i == 0 and msg.get("role") == "system":
|
||||
existing = msg.get("content") or ""
|
||||
existing = msg.get("content")
|
||||
_compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]"
|
||||
if _compression_note not in existing:
|
||||
msg["content"] = existing + "\n\n" + _compression_note
|
||||
if _compression_note not in _content_text_for_contains(existing):
|
||||
msg["content"] = _append_text_to_content(
|
||||
existing,
|
||||
"\n\n" + _compression_note if isinstance(existing, str) and existing else _compression_note,
|
||||
)
|
||||
compressed.append(msg)
|
||||
|
||||
# If LLM summary failed, insert a static fallback so the model
|
||||
@@ -1191,12 +1235,15 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
for i in range(compress_end, n_messages):
|
||||
msg = messages[i].copy()
|
||||
if _merge_summary_into_tail and i == compress_end:
|
||||
original = msg.get("content") or ""
|
||||
msg["content"] = (
|
||||
merged_prefix = (
|
||||
summary
|
||||
+ "\n\n--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---\n\n"
|
||||
+ original
|
||||
)
|
||||
msg["content"] = _append_text_to_content(
|
||||
msg.get("content"),
|
||||
merged_prefix,
|
||||
prepend=True,
|
||||
)
|
||||
_merge_summary_into_tail = False
|
||||
compressed.append(msg)
|
||||
|
||||
+74
-10
@@ -220,12 +220,25 @@ _TRANSPORT_ERROR_TYPES = frozenset({
|
||||
"ConnectionAbortedError", "BrokenPipeError",
|
||||
"TimeoutError", "ReadError",
|
||||
"ServerDisconnectedError",
|
||||
# SSL/TLS transport errors — transient mid-stream handshake/record
|
||||
# failures that should retry rather than surface as a stalled session.
|
||||
# ssl.SSLError subclasses OSError (caught by isinstance) but we list
|
||||
# the type names here so provider-wrapped SSL errors (e.g. when the
|
||||
# SDK re-raises without preserving the exception chain) still classify
|
||||
# as transport rather than falling through to the unknown bucket.
|
||||
"SSLError", "SSLZeroReturnError", "SSLWantReadError",
|
||||
"SSLWantWriteError", "SSLEOFError", "SSLSyscallError",
|
||||
# OpenAI SDK errors (not subclasses of Python builtins)
|
||||
"APIConnectionError",
|
||||
"APITimeoutError",
|
||||
})
|
||||
|
||||
# Server disconnect patterns (no status code, but transport-level)
|
||||
# Server disconnect patterns (no status code, but transport-level).
|
||||
# These are the "ambiguous" patterns — a plain connection close could be
|
||||
# transient transport hiccup OR server-side context overflow rejection
|
||||
# (common when the API gateway disconnects instead of returning an HTTP
|
||||
# error for oversized requests). A large session + one of these patterns
|
||||
# triggers the context-overflow-with-compression recovery path.
|
||||
_SERVER_DISCONNECT_PATTERNS = [
|
||||
"server disconnected",
|
||||
"peer closed connection",
|
||||
@@ -236,6 +249,40 @@ _SERVER_DISCONNECT_PATTERNS = [
|
||||
"incomplete chunked read",
|
||||
]
|
||||
|
||||
# SSL/TLS transient failure patterns — intentionally distinct from
|
||||
# _SERVER_DISCONNECT_PATTERNS above.
|
||||
#
|
||||
# An SSL alert mid-stream is almost always a transport-layer hiccup
|
||||
# (flaky network, mid-session TLS renegotiation failure, load balancer
|
||||
# dropping the connection) — NOT a server-side context overflow signal.
|
||||
# So we want the retry path but NOT the compression path; lumping these
|
||||
# into _SERVER_DISCONNECT_PATTERNS would trigger unnecessary (and
|
||||
# expensive) context compression on any large-session SSL hiccup.
|
||||
#
|
||||
# The OpenSSL library constructs error codes by prepending a format string
|
||||
# to the uppercased alert reason; OpenSSL 3.x changed the separator
|
||||
# (e.g. `SSLV3_ALERT_BAD_RECORD_MAC` → `SSL/TLS_ALERT_BAD_RECORD_MAC`),
|
||||
# which silently stopped matching anything explicit. Matching on the
|
||||
# stable substrings (`bad record mac`, `ssl alert`, `tls alert`, etc.)
|
||||
# survives future OpenSSL format churn without code changes.
|
||||
_SSL_TRANSIENT_PATTERNS = [
|
||||
# Space-separated (human-readable form, Python ssl module, most SDKs)
|
||||
"bad record mac",
|
||||
"ssl alert",
|
||||
"tls alert",
|
||||
"ssl handshake failure",
|
||||
"tlsv1 alert",
|
||||
"sslv3 alert",
|
||||
# Underscore-separated (OpenSSL error code tokens, e.g.
|
||||
# `ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC`, `SSLV3_ALERT_BAD_RECORD_MAC`)
|
||||
"bad_record_mac",
|
||||
"ssl_alert",
|
||||
"tls_alert",
|
||||
"tls_alert_internal_error",
|
||||
# Python ssl module prefix, e.g. "[SSL: BAD_RECORD_MAC]"
|
||||
"[ssl:",
|
||||
]
|
||||
|
||||
|
||||
# ── Classification pipeline ─────────────────────────────────────────────
|
||||
|
||||
@@ -255,9 +302,10 @@ def classify_api_error(
|
||||
2. HTTP status code + message-aware refinement
|
||||
3. Error code classification (from body)
|
||||
4. Message pattern matching (billing vs rate_limit vs context vs auth)
|
||||
5. Transport error heuristics
|
||||
5. SSL/TLS transient alert patterns → retry as timeout
|
||||
6. Server disconnect + large session → context overflow
|
||||
7. Fallback: unknown (retryable with backoff)
|
||||
7. Transport error heuristics
|
||||
8. Fallback: unknown (retryable with backoff)
|
||||
|
||||
Args:
|
||||
error: The exception from the API call.
|
||||
@@ -388,7 +436,18 @@ def classify_api_error(
|
||||
if classified is not None:
|
||||
return classified
|
||||
|
||||
# ── 5. Server disconnect + large session → context overflow ─────
|
||||
# ── 5. SSL/TLS transient errors → retry as timeout (not compression) ──
|
||||
# SSL alerts mid-stream are transport hiccups, not server-side context
|
||||
# overflow signals. Classify before the disconnect check so a large
|
||||
# session doesn't incorrectly trigger context compression when the real
|
||||
# cause is a flaky TLS handshake. Also matches when the error is
|
||||
# wrapped in a generic exception whose message string carries the SSL
|
||||
# alert text but the type isn't ssl.SSLError (happens with some SDKs
|
||||
# that re-raise without chaining).
|
||||
if any(p in error_msg for p in _SSL_TRANSIENT_PATTERNS):
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 6. Server disconnect + large session → context overflow ─────
|
||||
# Must come BEFORE generic transport error catch — a disconnect on
|
||||
# a large session is more likely context overflow than a transient
|
||||
# transport hiccup. Without this ordering, RemoteProtocolError
|
||||
@@ -405,12 +464,12 @@ def classify_api_error(
|
||||
)
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 6. Transport / timeout heuristics ───────────────────────────
|
||||
# ── 7. Transport / timeout heuristics ───────────────────────────
|
||||
|
||||
if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)):
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 7. Fallback: unknown ────────────────────────────────────────
|
||||
# ── 8. Fallback: unknown ────────────────────────────────────────
|
||||
|
||||
return _result(FailoverReason.unknown, retryable=True)
|
||||
|
||||
@@ -470,11 +529,16 @@ def _classify_by_status(
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
# Generic 404 — could be model or endpoint
|
||||
# Generic 404 with no "model not found" signal — could be a wrong
|
||||
# endpoint path (common with local llama.cpp / Ollama / vLLM when
|
||||
# the URL is slightly misconfigured), a proxy routing glitch, or
|
||||
# a transient backend issue. Classifying these as model_not_found
|
||||
# silently falls back to a different provider and tells the model
|
||||
# the model is missing, which is wrong and wastes a turn. Treat
|
||||
# as unknown so the retry loop surfaces the real error instead.
|
||||
return result_fn(
|
||||
FailoverReason.model_not_found,
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
FailoverReason.unknown,
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
if status_code == 413:
|
||||
|
||||
+33
-6
@@ -4,6 +4,7 @@ Pure utility functions with no AIAgent dependency. Used by ContextCompressor
|
||||
and run_agent.py for pre-flight context checks.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
@@ -25,7 +26,7 @@ logger = logging.getLogger(__name__)
|
||||
# are preserved so the full model name reaches cache lookups and server queries.
|
||||
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
|
||||
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "anthropic", "deepseek",
|
||||
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-cn", "anthropic", "deepseek",
|
||||
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba",
|
||||
"qwen-oauth",
|
||||
"xiaomi",
|
||||
@@ -36,7 +37,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
|
||||
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
|
||||
"ollama",
|
||||
"opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
|
||||
"stepfun", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
|
||||
"mimo", "xiaomi-mimo",
|
||||
"arcee-ai", "arceeai",
|
||||
"xai", "x-ai", "x.ai", "grok",
|
||||
@@ -51,6 +52,13 @@ _OLLAMA_TAG_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
# Tailscale's CGNAT range (RFC 6598). `ipaddress.is_private` excludes this
|
||||
# block, so without an explicit check Ollama reached over Tailscale (e.g.
|
||||
# `http://100.77.243.5:11434`) wouldn't be treated as local and its stream
|
||||
# read / stale timeouts wouldn't get auto-bumped. Built once at import time.
|
||||
_TAILSCALE_CGNAT = ipaddress.IPv4Network("100.64.0.0/10")
|
||||
|
||||
|
||||
def _strip_provider_prefix(model: str) -> str:
|
||||
"""Strip a recognised provider prefix from a model string.
|
||||
|
||||
@@ -125,6 +133,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# Google
|
||||
"gemini": 1048576,
|
||||
# Gemma (open models served via AI Studio)
|
||||
"gemma-4": 256000, # Gemma 4 family
|
||||
"gemma4": 256000, # Ollama-style naming (e.g. gemma4:31b-cloud)
|
||||
"gemma-4-31b": 256000,
|
||||
"gemma-3": 131072,
|
||||
"gemma": 8192, # fallback for older gemma models
|
||||
@@ -177,6 +187,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"mimo-v2-pro": 1000000,
|
||||
"mimo-v2-omni": 256000,
|
||||
"mimo-v2-flash": 256000,
|
||||
"mimo-v2.5-pro": 1000000,
|
||||
"mimo-v2.5": 1000000,
|
||||
"zai-org/GLM-5": 202752,
|
||||
}
|
||||
|
||||
@@ -237,6 +249,8 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
||||
"api.moonshot.ai": "kimi-coding",
|
||||
"api.moonshot.cn": "kimi-coding-cn",
|
||||
"api.kimi.com": "kimi-coding",
|
||||
"api.stepfun.ai": "stepfun",
|
||||
"api.stepfun.com": "stepfun",
|
||||
"api.arcee.ai": "arcee",
|
||||
"api.minimax": "minimax",
|
||||
"dashscope.aliyuncs.com": "alibaba",
|
||||
@@ -281,7 +295,15 @@ def _is_known_provider_base_url(base_url: str) -> bool:
|
||||
|
||||
|
||||
def is_local_endpoint(base_url: str) -> bool:
|
||||
"""Return True if base_url points to a local machine (localhost / RFC-1918 / WSL)."""
|
||||
"""Return True if base_url points to a local machine.
|
||||
|
||||
Recognises loopback (``localhost``, ``127.0.0.0/8``, ``::1``),
|
||||
container-internal DNS names (``host.docker.internal`` et al.),
|
||||
RFC-1918 private ranges (``10/8``, ``172.16/12``, ``192.168/16``),
|
||||
link-local, and Tailscale CGNAT (``100.64.0.0/10``). Tailscale CGNAT
|
||||
is included so remote-but-trusted Ollama boxes reached over a
|
||||
Tailscale mesh get the same timeout auto-bumps as localhost Ollama.
|
||||
"""
|
||||
normalized = _normalize_base_url(base_url)
|
||||
if not normalized:
|
||||
return False
|
||||
@@ -296,14 +318,17 @@ def is_local_endpoint(base_url: str) -> bool:
|
||||
# Docker / Podman / Lima internal DNS names (e.g. host.docker.internal)
|
||||
if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES):
|
||||
return True
|
||||
# RFC-1918 private ranges and link-local
|
||||
import ipaddress
|
||||
# RFC-1918 private ranges, link-local, and Tailscale CGNAT
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
return addr.is_private or addr.is_loopback or addr.is_link_local
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local:
|
||||
return True
|
||||
if isinstance(addr, ipaddress.IPv4Address) and addr in _TAILSCALE_CGNAT:
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
# Bare IP that looks like a private range (e.g. 172.26.x.x for WSL)
|
||||
# or Tailscale CGNAT (100.64.x.x–100.127.x.x).
|
||||
parts = host.split(".")
|
||||
if len(parts) == 4:
|
||||
try:
|
||||
@@ -314,6 +339,8 @@ def is_local_endpoint(base_url: str) -> bool:
|
||||
return True
|
||||
if first == 192 and second == 168:
|
||||
return True
|
||||
if first == 100 and 64 <= second <= 127:
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
@@ -146,6 +146,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
|
||||
"openai-codex": "openai",
|
||||
"zai": "zai",
|
||||
"kimi-coding": "kimi-for-coding",
|
||||
"stepfun": "stepfun",
|
||||
"kimi-coding-cn": "kimi-for-coding",
|
||||
"minimax": "minimax",
|
||||
"minimax-cn": "minimax-cn",
|
||||
|
||||
@@ -87,14 +87,20 @@ class AnthropicTransport(ProviderTransport):
|
||||
return normalize_anthropic_response_v2(response, strip_tool_prefix=strip_tool_prefix)
|
||||
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Anthropic response structure is valid."""
|
||||
"""Check Anthropic response structure is valid.
|
||||
|
||||
An empty content list is legitimate when ``stop_reason == "end_turn"``
|
||||
— the model's canonical way of signalling "nothing more to add" after
|
||||
a tool turn that already delivered the user-facing text. Treating it
|
||||
as invalid falsely retries a completed response.
|
||||
"""
|
||||
if response is None:
|
||||
return False
|
||||
content_blocks = getattr(response, "content", None)
|
||||
if not isinstance(content_blocks, list):
|
||||
return False
|
||||
if not content_blocks:
|
||||
return False
|
||||
return getattr(response, "stop_reason", None) == "end_turn"
|
||||
return True
|
||||
|
||||
def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
|
||||
|
||||
@@ -914,6 +914,32 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
|
||||
print(f"\033[32m✓ Worktree cleaned up: {wt_path}\033[0m")
|
||||
|
||||
|
||||
def _run_state_db_auto_maintenance(session_db) -> None:
|
||||
"""Call ``SessionDB.maybe_auto_prune_and_vacuum`` using current config.
|
||||
|
||||
Reads the ``sessions:`` section from config.yaml via
|
||||
:func:`hermes_cli.config.load_config` (the authoritative loader that
|
||||
deep-merges DEFAULT_CONFIG, so unmigrated configs still get default
|
||||
values). Honours ``auto_prune`` / ``retention_days`` /
|
||||
``vacuum_after_prune`` / ``min_interval_hours``, and delegates to the
|
||||
DB. Never raises — maintenance must never block interactive startup.
|
||||
"""
|
||||
if session_db is None:
|
||||
return
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
cfg = (_load_full_config().get("sessions") or {})
|
||||
if not cfg.get("auto_prune", False):
|
||||
return
|
||||
session_db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=int(cfg.get("retention_days", 90)),
|
||||
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
|
||||
vacuum=bool(cfg.get("vacuum_after_prune", True)),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("state.db auto-maintenance skipped: %s", exc)
|
||||
|
||||
|
||||
def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
|
||||
"""Remove stale worktrees and orphaned branches on startup.
|
||||
|
||||
@@ -1961,7 +1987,13 @@ class HermesCLI:
|
||||
self._session_db = SessionDB()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to initialize SessionDB — session will NOT be indexed for search: %s", e)
|
||||
|
||||
|
||||
# Opportunistic state.db maintenance — runs at most once per
|
||||
# min_interval_hours, tracked via state_meta in state.db itself so
|
||||
# it's shared across all Hermes processes for this HERMES_HOME.
|
||||
# Never blocks startup on failure.
|
||||
_run_state_db_auto_maintenance(self._session_db)
|
||||
|
||||
# Deferred title: stored in memory until the session is created in the DB
|
||||
self._pending_title: Optional[str] = None
|
||||
|
||||
|
||||
@@ -616,6 +616,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "reactions" in slack_cfg and not os.getenv("SLACK_REACTIONS"):
|
||||
os.environ["SLACK_REACTIONS"] = str(slack_cfg["reactions"]).lower()
|
||||
|
||||
# Discord settings → env vars (env vars take precedence)
|
||||
discord_cfg = yaml_cfg.get("discord", {})
|
||||
|
||||
+44
-11
@@ -135,9 +135,22 @@ class HookRegistry:
|
||||
except Exception as e:
|
||||
print(f"[hooks] Error loading hook {hook_dir.name}: {e}", flush=True)
|
||||
|
||||
def _resolve_handlers(self, event_type: str) -> List[Callable]:
|
||||
"""Return all handlers that should fire for ``event_type``.
|
||||
|
||||
Exact matches fire first, followed by wildcard matches (e.g.
|
||||
``command:*`` matches ``command:reset``).
|
||||
"""
|
||||
handlers = list(self._handlers.get(event_type, []))
|
||||
if ":" in event_type:
|
||||
base = event_type.split(":")[0]
|
||||
wildcard_key = f"{base}:*"
|
||||
handlers.extend(self._handlers.get(wildcard_key, []))
|
||||
return handlers
|
||||
|
||||
async def emit(self, event_type: str, context: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""
|
||||
Fire all handlers registered for an event.
|
||||
Fire all handlers registered for an event, discarding return values.
|
||||
|
||||
Supports wildcard matching: handlers registered for "command:*" will
|
||||
fire for any "command:..." event. Handlers registered for a base type
|
||||
@@ -151,16 +164,7 @@ class HookRegistry:
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
# Collect handlers: exact match + wildcard match
|
||||
handlers = list(self._handlers.get(event_type, []))
|
||||
|
||||
# Check for wildcard patterns (e.g., "command:*" matches "command:reset")
|
||||
if ":" in event_type:
|
||||
base = event_type.split(":")[0]
|
||||
wildcard_key = f"{base}:*"
|
||||
handlers.extend(self._handlers.get(wildcard_key, []))
|
||||
|
||||
for fn in handlers:
|
||||
for fn in self._resolve_handlers(event_type):
|
||||
try:
|
||||
result = fn(event_type, context)
|
||||
# Support both sync and async handlers
|
||||
@@ -168,3 +172,32 @@ class HookRegistry:
|
||||
await result
|
||||
except Exception as e:
|
||||
print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
|
||||
|
||||
async def emit_collect(
|
||||
self,
|
||||
event_type: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Any]:
|
||||
"""Fire handlers and return their non-None return values in order.
|
||||
|
||||
Like :meth:`emit` but captures each handler's return value. Used for
|
||||
decision-style hooks (e.g. ``command:<name>`` policies that want to
|
||||
allow/deny/rewrite the command before normal dispatch).
|
||||
|
||||
Exceptions from individual handlers are logged but do not abort the
|
||||
remaining handlers.
|
||||
"""
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
results: List[Any] = []
|
||||
for fn in self._resolve_handlers(event_type):
|
||||
try:
|
||||
result = fn(event_type, context)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
if result is not None:
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
|
||||
return results
|
||||
|
||||
@@ -2129,10 +2129,42 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# This ensures new commands added to COMMAND_REGISTRY in
|
||||
# hermes_cli/commands.py automatically appear as Discord slash
|
||||
# commands without needing a manual entry here.
|
||||
def _build_auto_slash_command(_name: str, _description: str, _args_hint: str = ""):
|
||||
"""Build a discord.app_commands.Command that proxies to _run_simple_slash."""
|
||||
discord_name = _name.lower()[:32]
|
||||
desc = (_description or f"Run /{_name}")[:100]
|
||||
has_args = bool(_args_hint)
|
||||
|
||||
if has_args:
|
||||
def _make_args_handler(__name: str, __hint: str):
|
||||
@discord.app_commands.describe(args=f"Arguments: {__hint}"[:100])
|
||||
async def _handler(interaction: discord.Interaction, args: str = ""):
|
||||
await self._run_simple_slash(
|
||||
interaction, f"/{__name} {args}".strip()
|
||||
)
|
||||
_handler.__name__ = f"auto_slash_{__name.replace('-', '_')}"
|
||||
return _handler
|
||||
|
||||
handler = _make_args_handler(_name, _args_hint)
|
||||
else:
|
||||
def _make_simple_handler(__name: str):
|
||||
async def _handler(interaction: discord.Interaction):
|
||||
await self._run_simple_slash(interaction, f"/{__name}")
|
||||
_handler.__name__ = f"auto_slash_{__name.replace('-', '_')}"
|
||||
return _handler
|
||||
|
||||
handler = _make_simple_handler(_name)
|
||||
|
||||
return discord.app_commands.Command(
|
||||
name=discord_name,
|
||||
description=desc,
|
||||
callback=handler,
|
||||
)
|
||||
|
||||
already_registered: set[str] = set()
|
||||
try:
|
||||
from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates
|
||||
|
||||
already_registered = set()
|
||||
try:
|
||||
already_registered = {cmd.name for cmd in tree.get_commands()}
|
||||
except Exception:
|
||||
@@ -2147,38 +2179,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
discord_name = cmd_def.name.lower()[:32]
|
||||
if discord_name in already_registered:
|
||||
continue
|
||||
# Skip aliases that overlap with already-registered names
|
||||
# (aliases for explicitly registered commands are handled above).
|
||||
desc = (cmd_def.description or f"Run /{cmd_def.name}")[:100]
|
||||
has_args = bool(cmd_def.args_hint)
|
||||
|
||||
if has_args:
|
||||
# Command takes optional arguments — create handler with
|
||||
# an optional ``args`` string parameter.
|
||||
def _make_args_handler(_name: str, _hint: str):
|
||||
@discord.app_commands.describe(args=f"Arguments: {_hint}"[:100])
|
||||
async def _handler(interaction: discord.Interaction, args: str = ""):
|
||||
await self._run_simple_slash(
|
||||
interaction, f"/{_name} {args}".strip()
|
||||
)
|
||||
_handler.__name__ = f"auto_slash_{_name.replace('-', '_')}"
|
||||
return _handler
|
||||
|
||||
handler = _make_args_handler(cmd_def.name, cmd_def.args_hint)
|
||||
else:
|
||||
# Parameterless command.
|
||||
def _make_simple_handler(_name: str):
|
||||
async def _handler(interaction: discord.Interaction):
|
||||
await self._run_simple_slash(interaction, f"/{_name}")
|
||||
_handler.__name__ = f"auto_slash_{_name.replace('-', '_')}"
|
||||
return _handler
|
||||
|
||||
handler = _make_simple_handler(cmd_def.name)
|
||||
|
||||
auto_cmd = discord.app_commands.Command(
|
||||
name=discord_name,
|
||||
description=desc,
|
||||
callback=handler,
|
||||
auto_cmd = _build_auto_slash_command(
|
||||
cmd_def.name,
|
||||
cmd_def.description,
|
||||
cmd_def.args_hint,
|
||||
)
|
||||
try:
|
||||
tree.add_command(auto_cmd)
|
||||
@@ -2195,6 +2199,35 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
logger.warning("Discord auto-register from COMMAND_REGISTRY failed: %s", e)
|
||||
|
||||
# ── Plugin-registered slash commands ──
|
||||
# Plugins register via PluginContext.register_command(); we mirror
|
||||
# those into Discord's native slash picker so users get the same
|
||||
# autocomplete UX as for built-in commands. No per-platform plugin
|
||||
# API needed — plugin commands are platform-agnostic.
|
||||
try:
|
||||
from hermes_cli.commands import _iter_plugin_command_entries
|
||||
|
||||
for plugin_name, plugin_desc, plugin_args_hint in _iter_plugin_command_entries():
|
||||
discord_name = plugin_name.lower()[:32]
|
||||
if discord_name in already_registered:
|
||||
continue
|
||||
auto_cmd = _build_auto_slash_command(
|
||||
plugin_name,
|
||||
plugin_desc,
|
||||
plugin_args_hint,
|
||||
)
|
||||
try:
|
||||
tree.add_command(auto_cmd)
|
||||
already_registered.add(discord_name)
|
||||
except Exception:
|
||||
# Silently skip commands that fail registration (e.g.
|
||||
# name conflict with a subcommand group).
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Discord auto-register from plugin commands failed: %s", e
|
||||
)
|
||||
|
||||
# Register skills under a single /skill command group with category
|
||||
# subcommand groups. This uses 1 top-level slot instead of N,
|
||||
# supporting up to 25 categories × 25 skills = 625 skills.
|
||||
|
||||
+298
-78
@@ -35,7 +35,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
@@ -73,7 +73,9 @@ try:
|
||||
UpdateMessageRequest,
|
||||
UpdateMessageRequestBody,
|
||||
)
|
||||
from lark_oapi.core import AccessTokenType, HttpMethod
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
from lark_oapi.core.model import BaseRequest
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
CallBackCard,
|
||||
P2CardActionTriggerResponse,
|
||||
@@ -234,6 +236,8 @@ FALLBACK_ATTACHMENT_TEXT = "[Attachment]"
|
||||
_PREFERRED_LOCALES = ("zh_cn", "en_us")
|
||||
_MARKDOWN_SPECIAL_CHARS_RE = re.compile(r"([\\`*_{}\[\]()#+\-!|>~])")
|
||||
_MENTION_PLACEHOLDER_RE = re.compile(r"@_user_\d+")
|
||||
_MENTION_BOUNDARY_CHARS = frozenset(" \t\n\r.,;:!?、,。;:!?()[]{}<>\"'`")
|
||||
_TRAILING_TERMINAL_PUNCT = frozenset(" \t\n\r.!?。!?")
|
||||
_WHITESPACE_RE = re.compile(r"\s+")
|
||||
_SUPPORTED_CARD_TEXT_KEYS = (
|
||||
"title",
|
||||
@@ -277,12 +281,36 @@ class FeishuPostMediaRef:
|
||||
resource_type: str = "file"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeishuMentionRef:
|
||||
name: str = ""
|
||||
open_id: str = ""
|
||||
is_all: bool = False
|
||||
is_self: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FeishuBotIdentity:
|
||||
open_id: str = ""
|
||||
user_id: str = ""
|
||||
name: str = ""
|
||||
|
||||
def matches(self, *, open_id: str, user_id: str, name: str) -> bool:
|
||||
# Precedence: open_id > user_id > name. IDs are authoritative when both
|
||||
# sides have them; the next tier is only considered when either side
|
||||
# lacks the current one.
|
||||
if open_id and self.open_id:
|
||||
return open_id == self.open_id
|
||||
if user_id and self.user_id:
|
||||
return user_id == self.user_id
|
||||
return bool(self.name) and name == self.name
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeishuPostParseResult:
|
||||
text_content: str
|
||||
image_keys: List[str] = field(default_factory=list)
|
||||
media_refs: List[FeishuPostMediaRef] = field(default_factory=list)
|
||||
mentioned_ids: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -292,7 +320,7 @@ class FeishuNormalizedMessage:
|
||||
preferred_message_type: str = "text"
|
||||
image_keys: List[str] = field(default_factory=list)
|
||||
media_refs: List[FeishuPostMediaRef] = field(default_factory=list)
|
||||
mentioned_ids: List[str] = field(default_factory=list)
|
||||
mentions: List[FeishuMentionRef] = field(default_factory=list)
|
||||
relation_kind: str = "plain"
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@@ -505,14 +533,17 @@ def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]:
|
||||
return rows or [[{"tag": "md", "text": content}]]
|
||||
|
||||
|
||||
def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult:
|
||||
def parse_feishu_post_payload(
|
||||
payload: Any,
|
||||
*,
|
||||
mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
|
||||
) -> FeishuPostParseResult:
|
||||
resolved = _resolve_post_payload(payload)
|
||||
if not resolved:
|
||||
return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT)
|
||||
|
||||
image_keys: List[str] = []
|
||||
media_refs: List[FeishuPostMediaRef] = []
|
||||
mentioned_ids: List[str] = []
|
||||
parts: List[str] = []
|
||||
|
||||
title = _normalize_feishu_text(str(resolved.get("title", "")).strip())
|
||||
@@ -523,7 +554,10 @@ def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult:
|
||||
if not isinstance(row, list):
|
||||
continue
|
||||
row_text = _normalize_feishu_text(
|
||||
"".join(_render_post_element(item, image_keys, media_refs, mentioned_ids) for item in row)
|
||||
"".join(
|
||||
_render_post_element(item, image_keys, media_refs, mentions_map)
|
||||
for item in row
|
||||
)
|
||||
)
|
||||
if row_text:
|
||||
parts.append(row_text)
|
||||
@@ -532,7 +566,6 @@ def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult:
|
||||
text_content="\n".join(parts).strip() or FALLBACK_POST_TEXT,
|
||||
image_keys=image_keys,
|
||||
media_refs=media_refs,
|
||||
mentioned_ids=mentioned_ids,
|
||||
)
|
||||
|
||||
|
||||
@@ -584,7 +617,7 @@ def _render_post_element(
|
||||
element: Any,
|
||||
image_keys: List[str],
|
||||
media_refs: List[FeishuPostMediaRef],
|
||||
mentioned_ids: List[str],
|
||||
mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
|
||||
) -> str:
|
||||
if isinstance(element, str):
|
||||
return element
|
||||
@@ -602,19 +635,21 @@ def _render_post_element(
|
||||
escaped_label = _escape_markdown_text(label)
|
||||
return f"[{escaped_label}]({href})" if href else escaped_label
|
||||
if tag == "at":
|
||||
mentioned_id = (
|
||||
str(element.get("open_id", "")).strip()
|
||||
or str(element.get("user_id", "")).strip()
|
||||
)
|
||||
if mentioned_id and mentioned_id not in mentioned_ids:
|
||||
mentioned_ids.append(mentioned_id)
|
||||
display_name = (
|
||||
str(element.get("user_name", "")).strip()
|
||||
or str(element.get("name", "")).strip()
|
||||
or str(element.get("text", "")).strip()
|
||||
or mentioned_id
|
||||
)
|
||||
return f"@{_escape_markdown_text(display_name)}" if display_name else "@"
|
||||
# Post <at>.user_id is a placeholder ("@_user_N" or "@_all"); look up
|
||||
# the real ref in mentions_map for the display name.
|
||||
placeholder = str(element.get("user_id", "")).strip()
|
||||
if placeholder == "@_all":
|
||||
# Feishu SDK sometimes omits @_all from the top-level mentions
|
||||
# payload; record it here so the caller's mention list stays complete.
|
||||
if mentions_map is not None and "@_all" not in mentions_map:
|
||||
mentions_map["@_all"] = FeishuMentionRef(is_all=True)
|
||||
return "@all"
|
||||
ref = (mentions_map or {}).get(placeholder)
|
||||
if ref is not None:
|
||||
display_name = ref.name or ref.open_id or "user"
|
||||
else:
|
||||
display_name = str(element.get("user_name", "")).strip() or "user"
|
||||
return f"@{_escape_markdown_text(display_name)}"
|
||||
if tag in {"img", "image"}:
|
||||
image_key = str(element.get("image_key", "")).strip()
|
||||
if image_key and image_key not in image_keys:
|
||||
@@ -652,8 +687,7 @@ def _render_post_element(
|
||||
|
||||
nested_parts: List[str] = []
|
||||
for key in ("text", "title", "content", "children", "elements"):
|
||||
value = element.get(key)
|
||||
extracted = _render_nested_post(value, image_keys, media_refs, mentioned_ids)
|
||||
extracted = _render_nested_post(element.get(key), image_keys, media_refs, mentions_map)
|
||||
if extracted:
|
||||
nested_parts.append(extracted)
|
||||
return " ".join(part for part in nested_parts if part)
|
||||
@@ -663,7 +697,7 @@ def _render_nested_post(
|
||||
value: Any,
|
||||
image_keys: List[str],
|
||||
media_refs: List[FeishuPostMediaRef],
|
||||
mentioned_ids: List[str],
|
||||
mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
|
||||
) -> str:
|
||||
if isinstance(value, str):
|
||||
return _escape_markdown_text(value)
|
||||
@@ -671,17 +705,17 @@ def _render_nested_post(
|
||||
return " ".join(
|
||||
part
|
||||
for item in value
|
||||
for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)]
|
||||
for part in [_render_nested_post(item, image_keys, media_refs, mentions_map)]
|
||||
if part
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
direct = _render_post_element(value, image_keys, media_refs, mentioned_ids)
|
||||
direct = _render_post_element(value, image_keys, media_refs, mentions_map)
|
||||
if direct:
|
||||
return direct
|
||||
return " ".join(
|
||||
part
|
||||
for item in value.values()
|
||||
for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)]
|
||||
for part in [_render_nested_post(item, image_keys, media_refs, mentions_map)]
|
||||
if part
|
||||
)
|
||||
return ""
|
||||
@@ -692,31 +726,48 @@ def _render_nested_post(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_feishu_message(*, message_type: str, raw_content: str) -> FeishuNormalizedMessage:
|
||||
def normalize_feishu_message(
|
||||
*,
|
||||
message_type: str,
|
||||
raw_content: str,
|
||||
mentions: Optional[Sequence[Any]] = None,
|
||||
bot: _FeishuBotIdentity = _FeishuBotIdentity(),
|
||||
) -> FeishuNormalizedMessage:
|
||||
normalized_type = str(message_type or "").strip().lower()
|
||||
payload = _load_feishu_payload(raw_content)
|
||||
mentions_map = _build_mentions_map(mentions, bot)
|
||||
|
||||
if normalized_type == "text":
|
||||
text = str(payload.get("text", "") or "")
|
||||
# Feishu SDK sometimes omits @_all from the mentions payload even when
|
||||
# the text literal contains it (confirmed via im.v1.message.get).
|
||||
if "@_all" in text and "@_all" not in mentions_map:
|
||||
mentions_map["@_all"] = FeishuMentionRef(is_all=True)
|
||||
return FeishuNormalizedMessage(
|
||||
raw_type=normalized_type,
|
||||
text_content=_normalize_feishu_text(str(payload.get("text", "") or "")),
|
||||
text_content=_normalize_feishu_text(text, mentions_map),
|
||||
mentions=list(mentions_map.values()),
|
||||
)
|
||||
if normalized_type == "post":
|
||||
parsed_post = parse_feishu_post_payload(payload)
|
||||
# The walker writes back to mentions_map if it encounters
|
||||
# <at user_id="@_all">, so reading .values() after parsing is enough.
|
||||
parsed_post = parse_feishu_post_payload(payload, mentions_map=mentions_map)
|
||||
return FeishuNormalizedMessage(
|
||||
raw_type=normalized_type,
|
||||
text_content=parsed_post.text_content,
|
||||
image_keys=list(parsed_post.image_keys),
|
||||
media_refs=list(parsed_post.media_refs),
|
||||
mentioned_ids=list(parsed_post.mentioned_ids),
|
||||
mentions=list(mentions_map.values()),
|
||||
relation_kind="post",
|
||||
)
|
||||
mention_refs = list(mentions_map.values())
|
||||
if normalized_type == "image":
|
||||
image_key = str(payload.get("image_key", "") or "").strip()
|
||||
alt_text = _normalize_feishu_text(
|
||||
str(payload.get("text", "") or "")
|
||||
or str(payload.get("alt", "") or "")
|
||||
or FALLBACK_IMAGE_TEXT
|
||||
or FALLBACK_IMAGE_TEXT,
|
||||
mentions_map,
|
||||
)
|
||||
return FeishuNormalizedMessage(
|
||||
raw_type=normalized_type,
|
||||
@@ -724,6 +775,7 @@ def normalize_feishu_message(*, message_type: str, raw_content: str) -> FeishuNo
|
||||
preferred_message_type="photo",
|
||||
image_keys=[image_key] if image_key else [],
|
||||
relation_kind="image",
|
||||
mentions=mention_refs,
|
||||
)
|
||||
if normalized_type in {"file", "audio", "media"}:
|
||||
media_ref = _build_media_ref_from_payload(payload, resource_type=normalized_type)
|
||||
@@ -735,6 +787,7 @@ def normalize_feishu_message(*, message_type: str, raw_content: str) -> FeishuNo
|
||||
media_refs=[media_ref] if media_ref.file_key else [],
|
||||
relation_kind=normalized_type,
|
||||
metadata={"placeholder_text": placeholder},
|
||||
mentions=mention_refs,
|
||||
)
|
||||
if normalized_type == "merge_forward":
|
||||
return _normalize_merge_forward_message(payload)
|
||||
@@ -1009,8 +1062,20 @@ def _first_non_empty_text(*values: Any) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _normalize_feishu_text(text: str) -> str:
|
||||
cleaned = _MENTION_PLACEHOLDER_RE.sub(" ", text or "")
|
||||
def _normalize_feishu_text(
|
||||
text: str,
|
||||
mentions_map: Optional[Dict[str, FeishuMentionRef]] = None,
|
||||
) -> str:
|
||||
def _sub(match: "re.Match[str]") -> str:
|
||||
key = match.group(0)
|
||||
ref = (mentions_map or {}).get(key)
|
||||
if ref is None:
|
||||
return " "
|
||||
name = ref.name or ref.open_id or "user"
|
||||
return f"@{name}"
|
||||
|
||||
cleaned = _MENTION_PLACEHOLDER_RE.sub(_sub, text or "")
|
||||
cleaned = cleaned.replace("@_all", "@all")
|
||||
cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n")
|
||||
cleaned = "\n".join(_WHITESPACE_RE.sub(" ", line).strip() for line in cleaned.split("\n"))
|
||||
cleaned = "\n".join(line for line in cleaned.split("\n") if line)
|
||||
@@ -1029,6 +1094,117 @@ def _unique_lines(lines: List[str]) -> List[str]:
|
||||
return unique
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mention helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_mention_ids(mention: Any) -> tuple[str, str]:
|
||||
# Returns (open_id, user_id). im.v1.message.get hands back id as a string
|
||||
# plus id_type discriminator; event payloads hand back a nested UserId
|
||||
# object carrying both fields.
|
||||
mention_id = getattr(mention, "id", None)
|
||||
if isinstance(mention_id, str):
|
||||
id_type = str(getattr(mention, "id_type", "") or "").lower()
|
||||
if id_type == "open_id":
|
||||
return mention_id, ""
|
||||
if id_type == "user_id":
|
||||
return "", mention_id
|
||||
return "", ""
|
||||
if mention_id is None:
|
||||
return "", ""
|
||||
return (
|
||||
str(getattr(mention_id, "open_id", "") or ""),
|
||||
str(getattr(mention_id, "user_id", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _build_mentions_map(
|
||||
mentions: Optional[Sequence[Any]],
|
||||
bot: _FeishuBotIdentity,
|
||||
) -> Dict[str, FeishuMentionRef]:
|
||||
result: Dict[str, FeishuMentionRef] = {}
|
||||
for mention in mentions or []:
|
||||
key = str(getattr(mention, "key", "") or "")
|
||||
if not key:
|
||||
continue
|
||||
if key == "@_all":
|
||||
result[key] = FeishuMentionRef(is_all=True)
|
||||
continue
|
||||
open_id, user_id = _extract_mention_ids(mention)
|
||||
name = str(getattr(mention, "name", "") or "").strip()
|
||||
result[key] = FeishuMentionRef(
|
||||
name=name,
|
||||
open_id=open_id,
|
||||
is_self=bot.matches(open_id=open_id, user_id=user_id, name=name),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _build_mention_hint(mentions: Sequence[FeishuMentionRef]) -> str:
|
||||
parts: List[str] = []
|
||||
seen: set = set()
|
||||
for ref in mentions:
|
||||
if ref.is_self:
|
||||
continue
|
||||
signature = (ref.is_all, ref.open_id, ref.name)
|
||||
if signature in seen:
|
||||
continue
|
||||
seen.add(signature)
|
||||
if ref.is_all:
|
||||
parts.append("@all")
|
||||
elif ref.open_id:
|
||||
parts.append(f"{ref.name or 'unknown'} (open_id={ref.open_id})")
|
||||
else:
|
||||
parts.append(ref.name or "unknown")
|
||||
return f"[Mentioned: {', '.join(parts)}]" if parts else ""
|
||||
|
||||
|
||||
def _strip_edge_self_mentions(
|
||||
text: str,
|
||||
mentions: Sequence[FeishuMentionRef],
|
||||
) -> str:
|
||||
# Leading: strip consecutive self-mentions unconditionally.
|
||||
# Trailing: strip only when followed by whitespace/terminal punct, so
|
||||
# mid-sentence references ("don't @Bot again") stay intact.
|
||||
# Leading word-boundary prevents @Al from eating @Alice.
|
||||
if not text:
|
||||
return text
|
||||
self_names = [
|
||||
f"@{ref.name or ref.open_id or 'user'}"
|
||||
for ref in mentions
|
||||
if ref.is_self
|
||||
]
|
||||
if not self_names:
|
||||
return text
|
||||
|
||||
remaining = text.lstrip()
|
||||
while True:
|
||||
for nm in self_names:
|
||||
if not remaining.startswith(nm):
|
||||
continue
|
||||
after = remaining[len(nm):]
|
||||
if after and after[0] not in _MENTION_BOUNDARY_CHARS:
|
||||
continue
|
||||
remaining = after.lstrip()
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
while True:
|
||||
i = len(remaining)
|
||||
while i > 0 and remaining[i - 1] in _TRAILING_TERMINAL_PUNCT:
|
||||
i -= 1
|
||||
body = remaining[:i]
|
||||
tail = remaining[i:]
|
||||
for nm in self_names:
|
||||
if body.endswith(nm):
|
||||
remaining = body[: -len(nm)].rstrip() + tail
|
||||
break
|
||||
else:
|
||||
return remaining
|
||||
|
||||
|
||||
def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None:
|
||||
"""Run the official Lark WS client in its own thread-local event loop."""
|
||||
import lark_oapi.ws.client as ws_client_module
|
||||
@@ -2470,13 +2646,22 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
chat_type: str,
|
||||
message_id: str,
|
||||
) -> None:
|
||||
text, inbound_type, media_urls, media_types = await self._extract_message_content(message)
|
||||
text, inbound_type, media_urls, media_types, mentions = await self._extract_message_content(message)
|
||||
|
||||
if inbound_type == MessageType.TEXT:
|
||||
text = _strip_edge_self_mentions(text, mentions)
|
||||
if text.startswith("/"):
|
||||
inbound_type = MessageType.COMMAND
|
||||
|
||||
# Guard runs post-strip so a pure "@Bot" message (stripped to "") is dropped.
|
||||
if inbound_type == MessageType.TEXT and not text and not media_urls:
|
||||
logger.debug("[Feishu] Ignoring unsupported or empty message type: %s", getattr(message, "message_type", ""))
|
||||
logger.debug("[Feishu] Ignoring empty text message id=%s", message_id)
|
||||
return
|
||||
|
||||
if inbound_type == MessageType.TEXT and text.startswith("/"):
|
||||
inbound_type = MessageType.COMMAND
|
||||
if inbound_type != MessageType.COMMAND:
|
||||
hint = _build_mention_hint(mentions)
|
||||
if hint:
|
||||
text = f"{hint}\n\n{text}" if text else hint
|
||||
|
||||
reply_to_message_id = (
|
||||
getattr(message, "parent_id", None)
|
||||
@@ -2935,14 +3120,20 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
# Message content extraction and resource download
|
||||
# =========================================================================
|
||||
|
||||
async def _extract_message_content(self, message: Any) -> tuple[str, MessageType, List[str], List[str]]:
|
||||
"""Extract text and cached media from a normalized Feishu message."""
|
||||
async def _extract_message_content(
|
||||
self, message: Any
|
||||
) -> tuple[str, MessageType, List[str], List[str], List[FeishuMentionRef]]:
|
||||
raw_content = getattr(message, "content", "") or ""
|
||||
raw_type = getattr(message, "message_type", "") or ""
|
||||
message_id = str(getattr(message, "message_id", "") or "")
|
||||
logger.info("[Feishu] Received raw message type=%s message_id=%s", raw_type, message_id)
|
||||
|
||||
normalized = normalize_feishu_message(message_type=raw_type, raw_content=raw_content)
|
||||
normalized = normalize_feishu_message(
|
||||
message_type=raw_type,
|
||||
raw_content=raw_content,
|
||||
mentions=getattr(message, "mentions", None),
|
||||
bot=self._bot_identity(),
|
||||
)
|
||||
media_urls, media_types = await self._download_feishu_message_resources(
|
||||
message_id=message_id,
|
||||
normalized=normalized,
|
||||
@@ -2959,7 +3150,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
if injected:
|
||||
text = injected
|
||||
|
||||
return text, inbound_type, media_urls, media_types
|
||||
return text, inbound_type, media_urls, media_types, list(normalized.mentions)
|
||||
|
||||
async def _download_feishu_message_resources(
|
||||
self,
|
||||
@@ -3308,15 +3499,31 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
body = getattr(parent, "body", None)
|
||||
msg_type = getattr(parent, "msg_type", "") or ""
|
||||
raw_content = getattr(body, "content", "") or ""
|
||||
text = self._extract_text_from_raw_content(msg_type=msg_type, raw_content=raw_content)
|
||||
parent_mentions = getattr(parent, "mentions", None) if parent else None
|
||||
text = self._extract_text_from_raw_content(
|
||||
msg_type=msg_type,
|
||||
raw_content=raw_content,
|
||||
mentions=parent_mentions,
|
||||
)
|
||||
self._message_text_cache[message_id] = text
|
||||
return text
|
||||
except Exception:
|
||||
logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True)
|
||||
return None
|
||||
|
||||
def _extract_text_from_raw_content(self, *, msg_type: str, raw_content: str) -> Optional[str]:
|
||||
normalized = normalize_feishu_message(message_type=msg_type, raw_content=raw_content)
|
||||
def _extract_text_from_raw_content(
|
||||
self,
|
||||
*,
|
||||
msg_type: str,
|
||||
raw_content: str,
|
||||
mentions: Optional[Sequence[Any]] = None,
|
||||
) -> Optional[str]:
|
||||
normalized = normalize_feishu_message(
|
||||
message_type=msg_type,
|
||||
raw_content=raw_content,
|
||||
mentions=mentions,
|
||||
bot=self._bot_identity(),
|
||||
)
|
||||
if normalized.text_content:
|
||||
return normalized.text_content
|
||||
placeholder = normalized.metadata.get("placeholder_text") if isinstance(normalized.metadata, dict) else None
|
||||
@@ -3386,10 +3593,10 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
normalized = normalize_feishu_message(
|
||||
message_type=getattr(message, "message_type", "") or "",
|
||||
raw_content=raw_content,
|
||||
mentions=getattr(message, "mentions", None),
|
||||
bot=self._bot_identity(),
|
||||
)
|
||||
if normalized.mentioned_ids:
|
||||
return self._post_mentions_bot(normalized.mentioned_ids)
|
||||
return False
|
||||
return self._post_mentions_bot(normalized.mentions)
|
||||
|
||||
def _is_self_sent_bot_message(self, event: Any) -> bool:
|
||||
"""Return True only for Feishu events emitted by this Hermes bot."""
|
||||
@@ -3409,30 +3616,37 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
def _message_mentions_bot(self, mentions: List[Any]) -> bool:
|
||||
"""Check whether any mention targets the configured or inferred bot identity."""
|
||||
# IDs trump names: when both sides have open_id (or both user_id),
|
||||
# match requires equal IDs. Name fallback only when either side
|
||||
# lacks an ID.
|
||||
for mention in mentions:
|
||||
mention_id = getattr(mention, "id", None)
|
||||
mention_open_id = getattr(mention_id, "open_id", None)
|
||||
mention_user_id = getattr(mention_id, "user_id", None)
|
||||
mention_open_id = (getattr(mention_id, "open_id", None) or "").strip()
|
||||
mention_user_id = (getattr(mention_id, "user_id", None) or "").strip()
|
||||
mention_name = (getattr(mention, "name", None) or "").strip()
|
||||
|
||||
if self._bot_open_id and mention_open_id == self._bot_open_id:
|
||||
return True
|
||||
if self._bot_user_id and mention_user_id == self._bot_user_id:
|
||||
return True
|
||||
if mention_open_id and self._bot_open_id:
|
||||
if mention_open_id == self._bot_open_id:
|
||||
return True
|
||||
continue # IDs differ — not the bot; skip name fallback.
|
||||
if mention_user_id and self._bot_user_id:
|
||||
if mention_user_id == self._bot_user_id:
|
||||
return True
|
||||
continue
|
||||
if self._bot_name and mention_name == self._bot_name:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _post_mentions_bot(self, mentioned_ids: List[str]) -> bool:
|
||||
if not mentioned_ids:
|
||||
return False
|
||||
if self._bot_open_id and self._bot_open_id in mentioned_ids:
|
||||
return True
|
||||
if self._bot_user_id and self._bot_user_id in mentioned_ids:
|
||||
return True
|
||||
return False
|
||||
def _post_mentions_bot(self, mentions: List[FeishuMentionRef]) -> bool:
|
||||
return any(m.is_self for m in mentions)
|
||||
|
||||
def _bot_identity(self) -> _FeishuBotIdentity:
|
||||
return _FeishuBotIdentity(
|
||||
open_id=self._bot_open_id,
|
||||
user_id=self._bot_user_id,
|
||||
name=self._bot_name,
|
||||
)
|
||||
|
||||
async def _hydrate_bot_identity(self) -> None:
|
||||
"""Best-effort discovery of bot identity for precise group mention gating
|
||||
@@ -3457,14 +3671,15 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
# uses via probe_bot().
|
||||
if not self._bot_open_id or not self._bot_name:
|
||||
try:
|
||||
resp = await asyncio.to_thread(
|
||||
self._client.request,
|
||||
method="GET",
|
||||
url="/open-apis/bot/v3/info",
|
||||
body=None,
|
||||
raw_response=True,
|
||||
req = (
|
||||
BaseRequest.builder()
|
||||
.http_method(HttpMethod.GET)
|
||||
.uri("/open-apis/bot/v3/info")
|
||||
.token_types({AccessTokenType.TENANT})
|
||||
.build()
|
||||
)
|
||||
content = getattr(resp, "content", None)
|
||||
resp = await asyncio.to_thread(self._client.request, req)
|
||||
content = getattr(getattr(resp, "raw", None), "content", None)
|
||||
if content:
|
||||
payload = json.loads(content)
|
||||
parsed = _parse_bot_response(payload) or {}
|
||||
@@ -4232,12 +4447,12 @@ def _build_onboard_client(app_id: str, app_secret: str, domain: str) -> Any:
|
||||
|
||||
|
||||
def _parse_bot_response(data: dict) -> Optional[dict]:
|
||||
"""Extract bot_name and bot_open_id from a /bot/v3/info response."""
|
||||
# /bot/v3/info returns bot.app_name; legacy paths used bot_name — accept both.
|
||||
if data.get("code") != 0:
|
||||
return None
|
||||
bot = data.get("bot") or data.get("data", {}).get("bot") or {}
|
||||
return {
|
||||
"bot_name": bot.get("bot_name"),
|
||||
"bot_name": bot.get("app_name") or bot.get("bot_name"),
|
||||
"bot_open_id": bot.get("open_id"),
|
||||
}
|
||||
|
||||
@@ -4246,13 +4461,18 @@ def _probe_bot_sdk(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
|
||||
"""Probe bot info using lark_oapi SDK."""
|
||||
try:
|
||||
client = _build_onboard_client(app_id, app_secret, domain)
|
||||
resp = client.request(
|
||||
method="GET",
|
||||
url="/open-apis/bot/v3/info",
|
||||
body=None,
|
||||
raw_response=True,
|
||||
req = (
|
||||
BaseRequest.builder()
|
||||
.http_method(HttpMethod.GET)
|
||||
.uri("/open-apis/bot/v3/info")
|
||||
.token_types({AccessTokenType.TENANT})
|
||||
.build()
|
||||
)
|
||||
return _parse_bot_response(json.loads(resp.content))
|
||||
resp = client.request(req)
|
||||
content = getattr(getattr(resp, "raw", None), "content", None)
|
||||
if content is None:
|
||||
return None
|
||||
return _parse_bot_response(json.loads(content))
|
||||
except Exception as exc:
|
||||
logger.debug("[Feishu onboard] SDK probe failed: %s", exc)
|
||||
return None
|
||||
|
||||
@@ -26,9 +26,8 @@ from .adapter import ( # noqa: F401
|
||||
# -- Onboard (QR-code scan-to-configure) -----------------------------------
|
||||
from .onboard import ( # noqa: F401
|
||||
BindStatus,
|
||||
create_bind_task,
|
||||
poll_bind_result,
|
||||
build_connect_url,
|
||||
qr_register,
|
||||
)
|
||||
from .crypto import decrypt_secret, generate_bind_key # noqa: F401
|
||||
|
||||
@@ -44,9 +43,8 @@ __all__ = [
|
||||
"_ssrf_redirect_guard",
|
||||
# onboard
|
||||
"BindStatus",
|
||||
"create_bind_task",
|
||||
"poll_bind_result",
|
||||
"build_connect_url",
|
||||
"qr_register",
|
||||
# crypto
|
||||
"decrypt_secret",
|
||||
"generate_bind_key",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""
|
||||
QQBot scan-to-configure (QR code onboard) module.
|
||||
|
||||
Mirrors the Feishu onboarding pattern: synchronous HTTP + a single public
|
||||
entry-point ``qr_register()`` that handles the full flow (create task →
|
||||
display QR code → poll → decrypt credentials).
|
||||
|
||||
Calls the ``q.qq.com`` ``create_bind_task`` / ``poll_bind_result`` APIs to
|
||||
generate a QR-code URL and poll for scan completion. On success the caller
|
||||
receives the bot's *app_id*, *client_secret* (decrypted locally), and the
|
||||
@@ -12,18 +16,20 @@ Reference: https://bot.q.qq.com/wiki/develop/api-v2/
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from enum import IntEnum
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
from .constants import (
|
||||
ONBOARD_API_TIMEOUT,
|
||||
ONBOARD_CREATE_PATH,
|
||||
ONBOARD_POLL_INTERVAL,
|
||||
ONBOARD_POLL_PATH,
|
||||
PORTAL_HOST,
|
||||
QR_URL_TEMPLATE,
|
||||
)
|
||||
from .crypto import generate_bind_key
|
||||
from .crypto import decrypt_secret, generate_bind_key
|
||||
from .utils import get_api_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,7 +41,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BindStatus(IntEnum):
|
||||
"""Status codes returned by ``poll_bind_result``."""
|
||||
"""Status codes returned by ``_poll_bind_result``."""
|
||||
|
||||
NONE = 0
|
||||
PENDING = 1
|
||||
@@ -44,18 +50,40 @@ class BindStatus(IntEnum):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# QR rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
import qrcode as _qrcode_mod
|
||||
except (ImportError, TypeError):
|
||||
_qrcode_mod = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _render_qr(url: str) -> bool:
|
||||
"""Try to render a QR code in the terminal. Returns True if successful."""
|
||||
if _qrcode_mod is None:
|
||||
return False
|
||||
try:
|
||||
qr = _qrcode_mod.QRCode(
|
||||
error_correction=_qrcode_mod.constants.ERROR_CORRECT_M,
|
||||
border=2,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
qr.print_ascii(invert=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synchronous HTTP helpers (mirrors Feishu _post_registration pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_bind_task(
|
||||
timeout: float = ONBOARD_API_TIMEOUT,
|
||||
) -> Tuple[str, str]:
|
||||
def _create_bind_task(timeout: float = ONBOARD_API_TIMEOUT) -> Tuple[str, str]:
|
||||
"""Create a bind task and return *(task_id, aes_key_base64)*.
|
||||
|
||||
The AES key is generated locally and sent to the server so it can
|
||||
encrypt the bot credentials before returning them.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the API returns a non-zero ``retcode``.
|
||||
"""
|
||||
@@ -64,8 +92,8 @@ async def create_bind_task(
|
||||
url = f"https://{PORTAL_HOST}{ONBOARD_CREATE_PATH}"
|
||||
key = generate_bind_key()
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = await client.post(url, json={"key": key}, headers=get_api_headers())
|
||||
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = client.post(url, json={"key": key}, headers=get_api_headers())
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
@@ -80,7 +108,7 @@ async def create_bind_task(
|
||||
return task_id, key
|
||||
|
||||
|
||||
async def poll_bind_result(
|
||||
def _poll_bind_result(
|
||||
task_id: str,
|
||||
timeout: float = ONBOARD_API_TIMEOUT,
|
||||
) -> Tuple[BindStatus, str, str, str]:
|
||||
@@ -89,12 +117,6 @@ async def poll_bind_result(
|
||||
Returns:
|
||||
A 4-tuple of ``(status, bot_appid, bot_encrypt_secret, user_openid)``.
|
||||
|
||||
* ``bot_encrypt_secret`` is AES-256-GCM encrypted — decrypt it with
|
||||
:func:`~gateway.platforms.qqbot.crypto.decrypt_secret` using the
|
||||
key from :func:`create_bind_task`.
|
||||
* ``user_openid`` is the OpenID of the person who scanned the code
|
||||
(available when ``status == COMPLETED``).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the API returns a non-zero ``retcode``.
|
||||
"""
|
||||
@@ -102,8 +124,8 @@ async def poll_bind_result(
|
||||
|
||||
url = f"https://{PORTAL_HOST}{ONBOARD_POLL_PATH}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = await client.post(url, json={"task_id": task_id}, headers=get_api_headers())
|
||||
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = client.post(url, json={"task_id": task_id}, headers=get_api_headers())
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
@@ -122,3 +144,77 @@ async def poll_bind_result(
|
||||
def build_connect_url(task_id: str) -> str:
|
||||
"""Build the QR-code target URL for a given *task_id*."""
|
||||
return QR_URL_TEMPLATE.format(task_id=quote(task_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry-point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAX_REFRESHES = 3
|
||||
|
||||
|
||||
def qr_register(timeout_seconds: int = 600) -> Optional[dict]:
|
||||
"""Run the QQBot scan-to-configure QR registration flow.
|
||||
|
||||
Mirrors ``feishu.qr_register()``: handles create → display → poll →
|
||||
decrypt in one call. Unexpected errors propagate to the caller.
|
||||
|
||||
:returns:
|
||||
``{"app_id": ..., "client_secret": ..., "user_openid": ...}`` on
|
||||
success, or ``None`` on failure / expiry / cancellation.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
|
||||
for refresh_count in range(_MAX_REFRESHES + 1):
|
||||
# ── Create bind task ──
|
||||
try:
|
||||
task_id, aes_key = _create_bind_task()
|
||||
except Exception as exc:
|
||||
logger.warning("[QQBot onboard] Failed to create bind task: %s", exc)
|
||||
return None
|
||||
|
||||
url = build_connect_url(task_id)
|
||||
|
||||
# ── Display QR code + URL ──
|
||||
print()
|
||||
if _render_qr(url):
|
||||
print(f" Scan the QR code above, or open this URL directly:\n {url}")
|
||||
else:
|
||||
print(f" Open this URL in QQ on your phone:\n {url}")
|
||||
print(" Tip: pip install qrcode to display a scannable QR code here")
|
||||
print()
|
||||
|
||||
# ── Poll loop ──
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status, app_id, encrypted_secret, user_openid = _poll_bind_result(task_id)
|
||||
except Exception:
|
||||
time.sleep(ONBOARD_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
if status == BindStatus.COMPLETED:
|
||||
client_secret = decrypt_secret(encrypted_secret, aes_key)
|
||||
print()
|
||||
print(f" QR scan complete! (App ID: {app_id})")
|
||||
if user_openid:
|
||||
print(f" Scanner's OpenID: {user_openid}")
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"client_secret": client_secret,
|
||||
"user_openid": user_openid,
|
||||
}
|
||||
|
||||
if status == BindStatus.EXPIRED:
|
||||
if refresh_count >= _MAX_REFRESHES:
|
||||
logger.warning("[QQBot onboard] QR code expired %d times — giving up", _MAX_REFRESHES)
|
||||
return None
|
||||
print(f"\n QR code expired, refreshing... ({refresh_count + 1}/{_MAX_REFRESHES})")
|
||||
break # next for-loop iteration creates a new task
|
||||
|
||||
time.sleep(ONBOARD_POLL_INTERVAL)
|
||||
else:
|
||||
# deadline reached without completing
|
||||
logger.warning("[QQBot onboard] Poll timed out after %ds", timeout_seconds)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
@@ -38,6 +38,7 @@ from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
safe_url_for_log,
|
||||
@@ -113,6 +114,11 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
# Cache for _fetch_thread_context results: cache_key → _ThreadContextCache
|
||||
self._thread_context_cache: Dict[str, _ThreadContextCache] = {}
|
||||
self._THREAD_CACHE_TTL = 60.0
|
||||
# Track message IDs that should get reaction lifecycle (DMs / @mentions).
|
||||
self._reacting_message_ids: set = set()
|
||||
# Track active assistant thread status indicators so stop_typing can
|
||||
# clear them (chat_id → thread_ts).
|
||||
self._active_status_threads: Dict[str, str] = {}
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Slack via Socket Mode."""
|
||||
@@ -362,6 +368,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not thread_ts:
|
||||
return # Can only set status in a thread context
|
||||
|
||||
self._active_status_threads[chat_id] = thread_ts
|
||||
try:
|
||||
await self._get_client(chat_id).assistant_threads_setStatus(
|
||||
channel_id=chat_id,
|
||||
@@ -373,6 +380,22 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
# in an assistant-enabled context. Falls back to reactions.
|
||||
logger.debug("[Slack] assistant.threads.setStatus failed: %s", e)
|
||||
|
||||
async def stop_typing(self, chat_id: str) -> None:
|
||||
"""Clear the assistant thread status indicator."""
|
||||
if not self._app:
|
||||
return
|
||||
thread_ts = self._active_status_threads.pop(chat_id, None)
|
||||
if not thread_ts:
|
||||
return
|
||||
try:
|
||||
await self._get_client(chat_id).assistant_threads_setStatus(
|
||||
channel_id=chat_id,
|
||||
thread_ts=thread_ts,
|
||||
status="",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("[Slack] assistant.threads.setStatus clear failed: %s", e)
|
||||
|
||||
def _dm_top_level_threads_as_sessions(self) -> bool:
|
||||
"""Whether top-level Slack DMs get per-message session threads.
|
||||
|
||||
@@ -584,6 +607,38 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
logger.debug("[Slack] reactions.remove failed (%s): %s", emoji, e)
|
||||
return False
|
||||
|
||||
def _reactions_enabled(self) -> bool:
|
||||
"""Check if message reactions are enabled via config/env."""
|
||||
return os.getenv("SLACK_REACTIONS", "true").lower() not in ("false", "0", "no")
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
"""Add an in-progress reaction when message processing begins."""
|
||||
if not self._reactions_enabled():
|
||||
return
|
||||
ts = getattr(event, "message_id", None)
|
||||
if not ts or ts not in self._reacting_message_ids:
|
||||
return
|
||||
channel_id = getattr(event.source, "chat_id", None)
|
||||
if channel_id:
|
||||
await self._add_reaction(channel_id, ts, "eyes")
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
|
||||
"""Swap the in-progress reaction for a final success/failure reaction."""
|
||||
if not self._reactions_enabled():
|
||||
return
|
||||
ts = getattr(event, "message_id", None)
|
||||
if not ts or ts not in self._reacting_message_ids:
|
||||
return
|
||||
self._reacting_message_ids.discard(ts)
|
||||
channel_id = getattr(event.source, "chat_id", None)
|
||||
if not channel_id:
|
||||
return
|
||||
await self._remove_reaction(channel_id, ts, "eyes")
|
||||
if outcome == ProcessingOutcome.SUCCESS:
|
||||
await self._add_reaction(channel_id, ts, "white_check_mark")
|
||||
elif outcome == ProcessingOutcome.FAILURE:
|
||||
await self._add_reaction(channel_id, ts, "x")
|
||||
|
||||
# ----- User identity resolution -----
|
||||
|
||||
async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str:
|
||||
@@ -1213,17 +1268,12 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
# Only react when bot is directly addressed (DM or @mention).
|
||||
# In listen-all channels (require_mention=false), reacting to every
|
||||
# casual message would be noisy.
|
||||
_should_react = is_dm or is_mentioned
|
||||
|
||||
_should_react = (is_dm or is_mentioned) and self._reactions_enabled()
|
||||
if _should_react:
|
||||
await self._add_reaction(channel_id, ts, "eyes")
|
||||
self._reacting_message_ids.add(ts)
|
||||
|
||||
await self.handle_message(msg_event)
|
||||
|
||||
if _should_react:
|
||||
await self._remove_reaction(channel_id, ts, "eyes")
|
||||
await self._add_reaction(channel_id, ts, "white_check_mark")
|
||||
|
||||
# ----- Approval button support (Block Kit) -----
|
||||
|
||||
async def send_exec_approval(
|
||||
|
||||
@@ -1464,3 +1464,134 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
"name": chat_id,
|
||||
"type": "group" if chat_id and chat_id.lower().startswith("group") else "dm",
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# QR code scan flow for obtaining bot credentials
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_QR_GENERATE_URL = "https://work.weixin.qq.com/ai/qc/generate"
|
||||
_QR_QUERY_URL = "https://work.weixin.qq.com/ai/qc/query_result"
|
||||
_QR_CODE_PAGE = "https://work.weixin.qq.com/ai/qc/gen?source=hermes&scode="
|
||||
_QR_POLL_INTERVAL = 3 # seconds
|
||||
_QR_POLL_TIMEOUT = 300 # 5 minutes
|
||||
|
||||
|
||||
def qr_scan_for_bot_info(
|
||||
*,
|
||||
timeout_seconds: int = _QR_POLL_TIMEOUT,
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Run the WeCom QR scan flow to obtain bot_id and secret.
|
||||
|
||||
Fetches a QR code from WeCom, renders it in the terminal, and polls
|
||||
until the user scans it or the timeout expires.
|
||||
|
||||
Returns ``{"bot_id": ..., "secret": ...}`` on success, ``None`` on
|
||||
failure or timeout.
|
||||
|
||||
Note: the ``work.weixin.qq.com/ai/qc/{generate,query_result}`` endpoints
|
||||
used here are not part of WeCom's public developer API — they back the
|
||||
admin-console web UI's bot-creation flow and may change without notice.
|
||||
The same pattern is used by the feishu/dingtalk QR setup wizards.
|
||||
"""
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
except ImportError: # pragma: no cover
|
||||
logger.error("urllib is required for WeCom QR scan")
|
||||
return None
|
||||
|
||||
generate_url = f"{_QR_GENERATE_URL}?source=hermes"
|
||||
|
||||
# ── Step 1: Fetch QR code ──
|
||||
print(" Connecting to WeCom...", end="", flush=True)
|
||||
try:
|
||||
req = urllib.request.Request(generate_url, headers={"User-Agent": "HermesAgent/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
raw = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
logger.error("WeCom QR: failed to fetch QR code: %s", exc)
|
||||
print(f" failed: {exc}")
|
||||
return None
|
||||
|
||||
data = raw.get("data") or {}
|
||||
scode = str(data.get("scode") or "").strip()
|
||||
auth_url = str(data.get("auth_url") or "").strip()
|
||||
|
||||
if not scode or not auth_url:
|
||||
logger.error("WeCom QR: unexpected response format: %s", raw)
|
||||
print(" failed: unexpected response format")
|
||||
return None
|
||||
|
||||
print(" done.")
|
||||
|
||||
# ── Step 2: Render QR code in terminal ──
|
||||
print()
|
||||
qr_rendered = False
|
||||
try:
|
||||
import qrcode as _qrcode
|
||||
qr = _qrcode.QRCode()
|
||||
qr.add_data(auth_url)
|
||||
qr.make(fit=True)
|
||||
qr.print_ascii(invert=True)
|
||||
qr_rendered = True
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page_url = f"{_QR_CODE_PAGE}{urllib.parse.quote(scode)}"
|
||||
if qr_rendered:
|
||||
print(f"\n Scan the QR code above, or open this URL directly:\n {page_url}")
|
||||
else:
|
||||
print(f" Open this URL in WeCom on your phone:\n\n {page_url}\n")
|
||||
print(" Tip: pip install qrcode to display a scannable QR code here next time")
|
||||
print()
|
||||
print(" Fetching configuration results...", end="", flush=True)
|
||||
|
||||
# ── Step 3: Poll for result ──
|
||||
import time
|
||||
deadline = time.time() + timeout_seconds
|
||||
query_url = f"{_QR_QUERY_URL}?scode={urllib.parse.quote(scode)}"
|
||||
poll_count = 0
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
req = urllib.request.Request(query_url, headers={"User-Agent": "HermesAgent/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
logger.debug("WeCom QR poll error: %s", exc)
|
||||
time.sleep(_QR_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
poll_count += 1
|
||||
# Print a dot on every poll so progress is visible within 3s.
|
||||
print(".", end="", flush=True)
|
||||
|
||||
result_data = result.get("data") or {}
|
||||
status = str(result_data.get("status") or "").lower()
|
||||
|
||||
if status == "success":
|
||||
print() # newline after "Fetching configuration results..." dots
|
||||
bot_info = result_data.get("bot_info") or {}
|
||||
bot_id = str(bot_info.get("botid") or bot_info.get("bot_id") or "").strip()
|
||||
secret = str(bot_info.get("secret") or "").strip()
|
||||
if bot_id and secret:
|
||||
return {"bot_id": bot_id, "secret": secret}
|
||||
logger.warning(
|
||||
"WeCom QR: scan reported success but bot_info missing or incomplete: %s",
|
||||
result_data,
|
||||
)
|
||||
print(
|
||||
" QR scan reported success but no bot credentials were returned.\n"
|
||||
" This usually means the bot was not actually created on the WeCom side.\n"
|
||||
" Falling back to manual credential entry."
|
||||
)
|
||||
return None
|
||||
|
||||
time.sleep(_QR_POLL_INTERVAL)
|
||||
|
||||
print() # newline after dots
|
||||
print(f" QR scan timed out ({timeout_seconds // 60} minutes). Please try again.")
|
||||
return None
|
||||
|
||||
+112
-17
@@ -710,7 +710,26 @@ class GatewayRunner:
|
||||
self._session_db = SessionDB()
|
||||
except Exception as e:
|
||||
logger.debug("SQLite session store not available: %s", e)
|
||||
|
||||
|
||||
# Opportunistic state.db maintenance: prune ended sessions older
|
||||
# than sessions.retention_days + optional VACUUM. Tracks last-run
|
||||
# in state_meta so it only actually executes once per
|
||||
# sessions.min_interval_hours. Gateway is long-lived so blocking
|
||||
# a few seconds once per day is acceptable; failures are logged
|
||||
# but never raised.
|
||||
if self._session_db is not None:
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
_sess_cfg = (_load_full_config().get("sessions") or {})
|
||||
if _sess_cfg.get("auto_prune", False):
|
||||
self._session_db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=int(_sess_cfg.get("retention_days", 90)),
|
||||
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
|
||||
vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("state.db auto-maintenance skipped: %s", exc)
|
||||
|
||||
# DM pairing store for code-based user authorization
|
||||
from gateway.pairing import PairingStore
|
||||
self.pairing_store = PairingStore()
|
||||
@@ -2668,8 +2687,9 @@ class GatewayRunner:
|
||||
except Exception as _e:
|
||||
logger.debug("SessionDB close error: %s", _e)
|
||||
|
||||
from gateway.status import remove_pid_file
|
||||
from gateway.status import remove_pid_file, release_gateway_runtime_lock
|
||||
remove_pid_file()
|
||||
release_gateway_runtime_lock()
|
||||
|
||||
# Write a clean-shutdown marker so the next startup knows this
|
||||
# wasn't a crash. suspend_recently_active() only needs to run
|
||||
@@ -3466,23 +3486,73 @@ class GatewayRunner:
|
||||
|
||||
# Check for commands
|
||||
command = event.get_command()
|
||||
|
||||
# Emit command:* hook for any recognized slash command.
|
||||
# GATEWAY_KNOWN_COMMANDS is derived from the central COMMAND_REGISTRY
|
||||
# in hermes_cli/commands.py — no hardcoded set to maintain here.
|
||||
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command as _resolve_cmd
|
||||
if command and command in GATEWAY_KNOWN_COMMANDS:
|
||||
await self.hooks.emit(f"command:{command}", {
|
||||
"platform": source.platform.value if source.platform else "",
|
||||
"user_id": source.user_id,
|
||||
"command": command,
|
||||
"args": event.get_command_args().strip(),
|
||||
})
|
||||
|
||||
# Resolve aliases to canonical name so dispatch only checks canonicals.
|
||||
from hermes_cli.commands import (
|
||||
GATEWAY_KNOWN_COMMANDS,
|
||||
is_gateway_known_command,
|
||||
resolve_command as _resolve_cmd,
|
||||
)
|
||||
|
||||
# Resolve aliases to canonical name so dispatch and hook names
|
||||
# don't depend on the exact alias the user typed.
|
||||
_cmd_def = _resolve_cmd(command) if command else None
|
||||
canonical = _cmd_def.name if _cmd_def else command
|
||||
|
||||
# Fire the ``command:<canonical>`` hook for any recognized slash
|
||||
# command — built-in OR plugin-registered. Handlers can return a
|
||||
# dict with ``{"decision": "deny" | "handled" | "rewrite", ...}``
|
||||
# to intercept dispatch before core handling runs. This replaces
|
||||
# the previous fire-and-forget emit(): return values are now
|
||||
# honored, but handlers that return nothing behave exactly as
|
||||
# before (telemetry-style hooks keep working).
|
||||
if command and is_gateway_known_command(canonical):
|
||||
raw_args = event.get_command_args().strip()
|
||||
hook_ctx = {
|
||||
"platform": source.platform.value if source.platform else "",
|
||||
"user_id": source.user_id,
|
||||
"command": canonical,
|
||||
"raw_command": command,
|
||||
"args": raw_args,
|
||||
"raw_args": raw_args,
|
||||
}
|
||||
try:
|
||||
hook_results = await self.hooks.emit_collect(
|
||||
f"command:{canonical}", hook_ctx
|
||||
)
|
||||
except Exception as _hook_err:
|
||||
logger.debug(
|
||||
"command:%s hook dispatch failed (non-fatal): %s",
|
||||
canonical, _hook_err,
|
||||
)
|
||||
hook_results = []
|
||||
|
||||
for hook_result in hook_results:
|
||||
if not isinstance(hook_result, dict):
|
||||
continue
|
||||
decision = str(hook_result.get("decision", "")).strip().lower()
|
||||
if not decision or decision == "allow":
|
||||
continue
|
||||
if decision == "deny":
|
||||
message = hook_result.get("message")
|
||||
if isinstance(message, str) and message:
|
||||
return message
|
||||
return f"Command `/{command}` was blocked by a hook."
|
||||
if decision == "handled":
|
||||
message = hook_result.get("message")
|
||||
return message if isinstance(message, str) and message else None
|
||||
if decision == "rewrite":
|
||||
new_command = str(
|
||||
hook_result.get("command_name", "")
|
||||
).strip().lstrip("/")
|
||||
if not new_command:
|
||||
continue
|
||||
new_args = str(hook_result.get("raw_args", "")).strip()
|
||||
event.text = f"/{new_command} {new_args}".strip()
|
||||
command = event.get_command()
|
||||
_cmd_def = _resolve_cmd(command) if command else None
|
||||
canonical = _cmd_def.name if _cmd_def else command
|
||||
break
|
||||
|
||||
if canonical == "new":
|
||||
return await self._handle_reset_command(event)
|
||||
|
||||
@@ -6456,6 +6526,11 @@ class GatewayRunner:
|
||||
session_id=task_id,
|
||||
platform=platform_key,
|
||||
user_id=source.user_id,
|
||||
user_name=source.user_name,
|
||||
chat_id=source.chat_id,
|
||||
chat_name=source.chat_name,
|
||||
chat_type=source.chat_type,
|
||||
thread_id=source.thread_id,
|
||||
session_db=self._session_db,
|
||||
fallback_model=self._fallback_model,
|
||||
)
|
||||
@@ -7216,6 +7291,7 @@ class GatewayRunner:
|
||||
tool_calls=msg.get("tool_calls"),
|
||||
tool_call_id=msg.get("tool_call_id"),
|
||||
reasoning=msg.get("reasoning"),
|
||||
reasoning_content=msg.get("reasoning_content"),
|
||||
)
|
||||
except Exception:
|
||||
pass # Best-effort copy
|
||||
@@ -7620,13 +7696,14 @@ class GatewayRunner:
|
||||
from hermes_cli.debug import (
|
||||
_capture_dump, collect_debug_report,
|
||||
upload_to_pastebin, _schedule_auto_delete,
|
||||
_GATEWAY_PRIVACY_NOTICE,
|
||||
_GATEWAY_PRIVACY_NOTICE, _best_effort_sweep_expired_pastes,
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Run blocking I/O (dump capture, log reads, uploads) in a thread.
|
||||
def _collect_and_upload():
|
||||
_best_effort_sweep_expired_pastes()
|
||||
dump_text = _capture_dump()
|
||||
report = collect_debug_report(log_lines=200, dump_text=dump_text)
|
||||
|
||||
@@ -9698,6 +9775,11 @@ class GatewayRunner:
|
||||
session_id=session_id,
|
||||
platform=platform_key,
|
||||
user_id=source.user_id,
|
||||
user_name=source.user_name,
|
||||
chat_id=source.chat_id,
|
||||
chat_name=source.chat_name,
|
||||
chat_type=source.chat_type,
|
||||
thread_id=source.thread_id,
|
||||
gateway_session_key=session_key,
|
||||
session_db=self._session_db,
|
||||
fallback_model=self._fallback_model,
|
||||
@@ -10764,7 +10846,13 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
# The PID file is scoped to HERMES_HOME, so future multi-profile
|
||||
# setups (each profile using a distinct HERMES_HOME) will naturally
|
||||
# allow concurrent instances without tripping this guard.
|
||||
from gateway.status import get_running_pid, remove_pid_file, terminate_pid
|
||||
from gateway.status import (
|
||||
acquire_gateway_runtime_lock,
|
||||
get_running_pid,
|
||||
release_gateway_runtime_lock,
|
||||
remove_pid_file,
|
||||
terminate_pid,
|
||||
)
|
||||
existing_pid = get_running_pid()
|
||||
if existing_pid is not None and existing_pid != os.getpid():
|
||||
if replace:
|
||||
@@ -10977,14 +11065,21 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
"Exiting to avoid double-running.", _current_pid
|
||||
)
|
||||
return False
|
||||
if not acquire_gateway_runtime_lock():
|
||||
logger.error(
|
||||
"Gateway runtime lock is already held by another instance. Exiting."
|
||||
)
|
||||
return False
|
||||
try:
|
||||
write_pid_file()
|
||||
except FileExistsError:
|
||||
release_gateway_runtime_lock()
|
||||
logger.error(
|
||||
"PID file race lost to another gateway instance. Exiting."
|
||||
)
|
||||
return False
|
||||
atexit.register(remove_pid_file)
|
||||
atexit.register(release_gateway_runtime_lock)
|
||||
|
||||
# Start the gateway
|
||||
success = await runner.start()
|
||||
|
||||
@@ -1147,6 +1147,10 @@ class SessionStore:
|
||||
tool_name=message.get("tool_name"),
|
||||
tool_calls=message.get("tool_calls"),
|
||||
tool_call_id=message.get("tool_call_id"),
|
||||
reasoning=message.get("reasoning") if message.get("role") == "assistant" else None,
|
||||
reasoning_content=message.get("reasoning_content") if message.get("role") == "assistant" else None,
|
||||
reasoning_details=message.get("reasoning_details") if message.get("role") == "assistant" else None,
|
||||
codex_reasoning_items=message.get("codex_reasoning_items") if message.get("role") == "assistant" else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
@@ -1176,6 +1180,7 @@ class SessionStore:
|
||||
tool_calls=msg.get("tool_calls"),
|
||||
tool_call_id=msg.get("tool_call_id"),
|
||||
reasoning=msg.get("reasoning") if role == "assistant" else None,
|
||||
reasoning_content=msg.get("reasoning_content") if role == "assistant" else None,
|
||||
reasoning_details=msg.get("reasoning_details") if role == "assistant" else None,
|
||||
codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None,
|
||||
)
|
||||
|
||||
+167
-26
@@ -22,11 +22,18 @@ from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Any, Optional
|
||||
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
_GATEWAY_KIND = "hermes-gateway"
|
||||
_RUNTIME_STATUS_FILE = "gateway_state.json"
|
||||
_LOCKS_DIRNAME = "gateway-locks"
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
_UNSET = object()
|
||||
_GATEWAY_LOCK_FILENAME = "gateway.lock"
|
||||
_gateway_lock_handle = None
|
||||
|
||||
|
||||
def _get_pid_path() -> Path:
|
||||
@@ -35,6 +42,14 @@ def _get_pid_path() -> Path:
|
||||
return home / "gateway.pid"
|
||||
|
||||
|
||||
def _get_gateway_lock_path(pid_path: Optional[Path] = None) -> Path:
|
||||
"""Return the path to the runtime gateway lock file."""
|
||||
if pid_path is not None:
|
||||
return pid_path.with_name(_GATEWAY_LOCK_FILENAME)
|
||||
home = get_hermes_home()
|
||||
return home / _GATEWAY_LOCK_FILENAME
|
||||
|
||||
|
||||
def _get_runtime_status_path() -> Path:
|
||||
"""Return the persisted runtime health/status file path."""
|
||||
return _get_pid_path().with_name(_RUNTIME_STATUS_FILE)
|
||||
@@ -212,16 +227,135 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def _read_gateway_lock_record(lock_path: Optional[Path] = None) -> Optional[dict[str, Any]]:
|
||||
return _read_pid_record(lock_path or _get_gateway_lock_path())
|
||||
|
||||
|
||||
def _pid_from_record(record: Optional[dict[str, Any]]) -> Optional[int]:
|
||||
if not record:
|
||||
return None
|
||||
try:
|
||||
return int(record["pid"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _cleanup_invalid_pid_path(pid_path: Path, *, cleanup_stale: bool) -> None:
|
||||
"""Delete a stale gateway PID file (and its sibling lock metadata).
|
||||
|
||||
Called from ``get_running_pid()`` after the runtime lock has already been
|
||||
confirmed inactive, so the on-disk metadata is known to belong to a dead
|
||||
process. Unlike ``remove_pid_file()`` (which defensively refuses to delete
|
||||
a PID file whose ``pid`` field differs from ``os.getpid()`` to protect
|
||||
``--replace`` handoffs), this path force-unlinks both files so the next
|
||||
startup sees a clean slate.
|
||||
"""
|
||||
if not cleanup_stale:
|
||||
return
|
||||
try:
|
||||
if pid_path == _get_pid_path():
|
||||
remove_pid_file()
|
||||
else:
|
||||
pid_path.unlink(missing_ok=True)
|
||||
pid_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_get_gateway_lock_path(pid_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _write_gateway_lock_record(handle) -> None:
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
json.dump(_build_pid_record(), handle)
|
||||
handle.flush()
|
||||
try:
|
||||
os.fsync(handle.fileno())
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _try_acquire_file_lock(handle) -> bool:
|
||||
try:
|
||||
if _IS_WINDOWS:
|
||||
handle.seek(0, os.SEEK_END)
|
||||
if handle.tell() == 0:
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return True
|
||||
except (BlockingIOError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _release_file_lock(handle) -> None:
|
||||
try:
|
||||
if _IS_WINDOWS:
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def acquire_gateway_runtime_lock() -> bool:
|
||||
"""Claim the cross-process runtime lock for the gateway.
|
||||
|
||||
Unlike the PID file, the lock is owned by the live process itself. If the
|
||||
process dies abruptly, the OS releases the lock automatically.
|
||||
"""
|
||||
global _gateway_lock_handle
|
||||
if _gateway_lock_handle is not None:
|
||||
return True
|
||||
|
||||
path = _get_gateway_lock_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = open(path, "a+", encoding="utf-8")
|
||||
if not _try_acquire_file_lock(handle):
|
||||
handle.close()
|
||||
return False
|
||||
_write_gateway_lock_record(handle)
|
||||
_gateway_lock_handle = handle
|
||||
return True
|
||||
|
||||
|
||||
def release_gateway_runtime_lock() -> None:
|
||||
"""Release the gateway runtime lock when owned by this process."""
|
||||
global _gateway_lock_handle
|
||||
handle = _gateway_lock_handle
|
||||
if handle is None:
|
||||
return
|
||||
_gateway_lock_handle = None
|
||||
_release_file_lock(handle)
|
||||
try:
|
||||
handle.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def is_gateway_runtime_lock_active(lock_path: Optional[Path] = None) -> bool:
|
||||
"""Return True when some process currently owns the gateway runtime lock."""
|
||||
global _gateway_lock_handle
|
||||
resolved_lock_path = lock_path or _get_gateway_lock_path()
|
||||
if _gateway_lock_handle is not None and resolved_lock_path == _get_gateway_lock_path():
|
||||
return True
|
||||
|
||||
if not resolved_lock_path.exists():
|
||||
return False
|
||||
|
||||
handle = open(resolved_lock_path, "a+", encoding="utf-8")
|
||||
try:
|
||||
if _try_acquire_file_lock(handle):
|
||||
_release_file_lock(handle)
|
||||
return False
|
||||
return True
|
||||
finally:
|
||||
try:
|
||||
handle.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def write_pid_file() -> None:
|
||||
@@ -583,35 +717,42 @@ def get_running_pid(
|
||||
Cleans up stale PID files automatically.
|
||||
"""
|
||||
resolved_pid_path = pid_path or _get_pid_path()
|
||||
record = _read_pid_record(resolved_pid_path)
|
||||
if not record:
|
||||
resolved_lock_path = _get_gateway_lock_path(resolved_pid_path)
|
||||
lock_active = is_gateway_runtime_lock_active(resolved_lock_path)
|
||||
if not lock_active:
|
||||
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
|
||||
return None
|
||||
|
||||
try:
|
||||
pid = int(record["pid"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
|
||||
return None
|
||||
primary_record = _read_pid_record(resolved_pid_path)
|
||||
fallback_record = _read_gateway_lock_record(resolved_lock_path)
|
||||
|
||||
try:
|
||||
os.kill(pid, 0) # signal 0 = existence check, no actual signal sent
|
||||
except (ProcessLookupError, PermissionError):
|
||||
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
|
||||
return None
|
||||
for record in (primary_record, fallback_record):
|
||||
pid = _pid_from_record(record)
|
||||
if pid is None:
|
||||
continue
|
||||
|
||||
recorded_start = record.get("start_time")
|
||||
current_start = _get_process_start_time(pid)
|
||||
if recorded_start is not None and current_start is not None and current_start != recorded_start:
|
||||
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
|
||||
return None
|
||||
try:
|
||||
os.kill(pid, 0) # signal 0 = existence check, no actual signal sent
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except PermissionError:
|
||||
# The process exists but belongs to another user/service scope.
|
||||
# With the runtime lock still held, prefer keeping it visible
|
||||
# rather than deleting the PID file as "stale".
|
||||
if _record_looks_like_gateway(record):
|
||||
return pid
|
||||
continue
|
||||
|
||||
if not _looks_like_gateway_process(pid):
|
||||
if not _record_looks_like_gateway(record):
|
||||
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
|
||||
return None
|
||||
recorded_start = record.get("start_time")
|
||||
current_start = _get_process_start_time(pid)
|
||||
if recorded_start is not None and current_start is not None and current_start != recorded_start:
|
||||
continue
|
||||
|
||||
return pid
|
||||
if _looks_like_gateway_process(pid) or _record_looks_like_gateway(record):
|
||||
return pid
|
||||
|
||||
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
|
||||
return None
|
||||
|
||||
|
||||
def is_gateway_running(
|
||||
|
||||
@@ -72,6 +72,8 @@ DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
|
||||
DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
|
||||
DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
|
||||
DEFAULT_OLLAMA_CLOUD_BASE_URL = "https://ollama.com/v1"
|
||||
STEPFUN_STEP_PLAN_INTL_BASE_URL = "https://api.stepfun.ai/step_plan/v1"
|
||||
STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1"
|
||||
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||
@@ -182,6 +184,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
inference_base_url="https://api.moonshot.cn/v1",
|
||||
api_key_env_vars=("KIMI_CN_API_KEY",),
|
||||
),
|
||||
"stepfun": ProviderConfig(
|
||||
id="stepfun",
|
||||
name="StepFun Step Plan",
|
||||
auth_type="api_key",
|
||||
inference_base_url=STEPFUN_STEP_PLAN_INTL_BASE_URL,
|
||||
api_key_env_vars=("STEPFUN_API_KEY",),
|
||||
base_url_env_var="STEPFUN_BASE_URL",
|
||||
),
|
||||
"arcee": ProviderConfig(
|
||||
id="arcee",
|
||||
name="Arcee AI",
|
||||
@@ -992,6 +1002,7 @@ def resolve_provider(
|
||||
"x-ai": "xai", "x.ai": "xai", "grok": "xai",
|
||||
"kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding",
|
||||
"kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
|
||||
"step": "stepfun", "stepfun-coding-plan": "stepfun",
|
||||
"arcee-ai": "arcee", "arceeai": "arcee",
|
||||
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
||||
"claude": "anthropic", "claude-code": "anthropic",
|
||||
|
||||
@@ -260,6 +260,26 @@ GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def is_gateway_known_command(name: str | None) -> bool:
|
||||
"""Return True if ``name`` resolves to a gateway-dispatchable slash command.
|
||||
|
||||
This covers both built-in commands (``GATEWAY_KNOWN_COMMANDS`` derived
|
||||
from ``COMMAND_REGISTRY``) and plugin-registered commands, which are
|
||||
looked up lazily so importing this module never forces plugin
|
||||
discovery. Gateway code uses this to decide whether to emit
|
||||
``command:<name>`` hooks — plugin commands get the same lifecycle
|
||||
events as built-ins.
|
||||
"""
|
||||
if not name:
|
||||
return False
|
||||
if name in GATEWAY_KNOWN_COMMANDS:
|
||||
return True
|
||||
for plugin_name, _description, _args_hint in _iter_plugin_command_entries():
|
||||
if plugin_name == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Commands with explicit Level-2 running-agent handlers in gateway/run.py.
|
||||
# Listed here for introspection / tests; semantically a subset of
|
||||
# "all resolvable commands" — which is the real bypass set (see
|
||||
@@ -371,12 +391,47 @@ def gateway_help_lines() -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
|
||||
"""Yield (name, description, args_hint) tuples for all plugin slash commands.
|
||||
|
||||
Plugin commands are registered via
|
||||
:func:`hermes_cli.plugins.PluginContext.register_command`. They behave
|
||||
like ``CommandDef`` entries for gateway surfacing: they appear in the
|
||||
Telegram command menu, in Slack's ``/hermes`` subcommand mapping, and
|
||||
(via :func:`gateway.platforms.discord._register_slash_commands`) in
|
||||
Discord's native slash command picker.
|
||||
|
||||
Lookup is lazy so importing this module never forces plugin discovery
|
||||
(which can trigger filesystem scans and environment-dependent
|
||||
behavior).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_commands
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
commands = get_plugin_commands() or {}
|
||||
except Exception:
|
||||
return []
|
||||
entries: list[tuple[str, str, str]] = []
|
||||
for name, meta in commands.items():
|
||||
if not isinstance(name, str) or not isinstance(meta, dict):
|
||||
continue
|
||||
description = str(meta.get("description") or f"Run /{name}")
|
||||
args_hint = str(meta.get("args_hint") or "").strip()
|
||||
entries.append((name, description, args_hint))
|
||||
return entries
|
||||
|
||||
|
||||
def telegram_bot_commands() -> list[tuple[str, str]]:
|
||||
"""Return (command_name, description) pairs for Telegram setMyCommands.
|
||||
|
||||
Telegram command names cannot contain hyphens, so they are replaced with
|
||||
underscores. Aliases are skipped -- Telegram shows one menu entry per
|
||||
canonical command.
|
||||
|
||||
Plugin-registered slash commands are included so plugins get native
|
||||
autocomplete in Telegram without touching core code.
|
||||
"""
|
||||
overrides = _resolve_config_gates()
|
||||
result: list[tuple[str, str]] = []
|
||||
@@ -386,6 +441,10 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
|
||||
tg_name = _sanitize_telegram_name(cmd.name)
|
||||
if tg_name:
|
||||
result.append((tg_name, cmd.description))
|
||||
for name, description, _args_hint in _iter_plugin_command_entries():
|
||||
tg_name = _sanitize_telegram_name(name)
|
||||
if tg_name:
|
||||
result.append((tg_name, description))
|
||||
return result
|
||||
|
||||
|
||||
@@ -750,6 +809,9 @@ def slack_subcommand_map() -> dict[str, str]:
|
||||
|
||||
Maps both canonical names and aliases so /hermes bg do stuff works
|
||||
the same as /hermes background do stuff.
|
||||
|
||||
Plugin-registered slash commands are included so ``/hermes <plugin-cmd>``
|
||||
routes through the plugin handler.
|
||||
"""
|
||||
overrides = _resolve_config_gates()
|
||||
mapping: dict[str, str] = {}
|
||||
@@ -759,6 +821,9 @@ def slack_subcommand_map() -> dict[str, str]:
|
||||
mapping[cmd.name] = f"/{cmd.name}"
|
||||
for alias in cmd.aliases:
|
||||
mapping[alias] = f"/{alias}"
|
||||
for name, _description, _args_hint in _iter_plugin_command_entries():
|
||||
if name not in mapping:
|
||||
mapping[name] = f"/{name}"
|
||||
return mapping
|
||||
|
||||
|
||||
|
||||
@@ -840,6 +840,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
# Pre-exec security scanning via tirith
|
||||
"security": {
|
||||
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
|
||||
"redact_secrets": True,
|
||||
"tirith_enabled": True,
|
||||
"tirith_path": "tirith",
|
||||
@@ -893,6 +894,34 @@ DEFAULT_CONFIG = {
|
||||
"force_ipv4": False,
|
||||
},
|
||||
|
||||
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
|
||||
# state.db accumulates every session, message, tool call, and FTS5 index
|
||||
# entry forever. Without auto-pruning, a heavy user (gateway + cron)
|
||||
# reports 384MB+ databases with 68K+ messages, which slows down FTS5
|
||||
# inserts, /resume listing, and insights queries.
|
||||
"sessions": {
|
||||
# When true, prune ended sessions older than retention_days once
|
||||
# per (roughly) min_interval_hours at CLI/gateway/cron startup.
|
||||
# Only touches ended sessions — active sessions are always preserved.
|
||||
# Default false: session history is valuable for search recall, and
|
||||
# silently deleting it could surprise users. Opt in explicitly.
|
||||
"auto_prune": False,
|
||||
# How many days of ended-session history to keep. Matches the
|
||||
# default of ``hermes sessions prune``.
|
||||
"retention_days": 90,
|
||||
# VACUUM after a prune that actually deleted rows. SQLite does not
|
||||
# reclaim disk space on DELETE — freed pages are just reused on
|
||||
# subsequent INSERTs — so without VACUUM the file stays bloated
|
||||
# even after pruning. VACUUM blocks writes for a few seconds per
|
||||
# 100MB, so it only runs at startup, and only when prune deleted
|
||||
# ≥1 session.
|
||||
"vacuum_after_prune": True,
|
||||
# Minimum hours between auto-maintenance runs (avoids repeating
|
||||
# the sweep on every CLI invocation). Tracked via state_meta in
|
||||
# state.db itself, so it's shared across all processes.
|
||||
"min_interval_hours": 24,
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 22,
|
||||
}
|
||||
@@ -1050,6 +1079,22 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"STEPFUN_API_KEY": {
|
||||
"description": "StepFun Step Plan API key",
|
||||
"prompt": "StepFun Step Plan API key",
|
||||
"url": "https://platform.stepfun.com/",
|
||||
"password": True,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"STEPFUN_BASE_URL": {
|
||||
"description": "StepFun Step Plan base URL override",
|
||||
"prompt": "StepFun Step Plan base URL (leave empty for default)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"ARCEEAI_API_KEY": {
|
||||
"description": "Arcee AI API key",
|
||||
"prompt": "Arcee AI API key",
|
||||
@@ -2102,6 +2147,7 @@ _KNOWN_ROOT_KEYS = {
|
||||
"fallback_providers", "credential_pool_strategies", "toolsets",
|
||||
"agent", "terminal", "display", "compression", "delegation",
|
||||
"auxiliary", "custom_providers", "context", "memory", "gateway",
|
||||
"sessions",
|
||||
}
|
||||
|
||||
# Valid fields inside a custom_providers list entry
|
||||
|
||||
+128
-48
@@ -13,6 +13,7 @@ import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -147,6 +148,14 @@ def _sweep_expired_pastes(now: Optional[float] = None) -> tuple[int, int]:
|
||||
return (deleted, len(remaining))
|
||||
|
||||
|
||||
def _best_effort_sweep_expired_pastes() -> None:
|
||||
"""Attempt pending-paste cleanup without letting /debug fail offline."""
|
||||
try:
|
||||
_sweep_expired_pastes()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Privacy / delete helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -314,72 +323,128 @@ def upload_to_pastebin(content: str, expiry_days: int = 7) -> str:
|
||||
# Log file reading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_log_path(log_name: str) -> Optional[Path]:
|
||||
"""Find the log file for *log_name*, falling back to the .1 rotation.
|
||||
|
||||
Returns the path if found, or None.
|
||||
"""
|
||||
@dataclass
|
||||
class LogSnapshot:
|
||||
"""Single-read snapshot of a log file used by debug-share."""
|
||||
|
||||
path: Optional[Path]
|
||||
tail_text: str
|
||||
full_text: Optional[str]
|
||||
|
||||
|
||||
def _primary_log_path(log_name: str) -> Optional[Path]:
|
||||
"""Where *log_name* would live if present. Doesn't check existence."""
|
||||
from hermes_cli.logs import LOG_FILES
|
||||
|
||||
filename = LOG_FILES.get(log_name)
|
||||
if not filename:
|
||||
return (get_hermes_home() / "logs" / filename) if filename else None
|
||||
|
||||
|
||||
def _resolve_log_path(log_name: str) -> Optional[Path]:
|
||||
"""Find the log file for *log_name*, falling back to the .1 rotation.
|
||||
|
||||
Returns the first non-empty candidate (primary, then .1), or None.
|
||||
Callers distinguish 'empty primary' from 'truly missing' via
|
||||
:func:`_primary_log_path`.
|
||||
"""
|
||||
primary = _primary_log_path(log_name)
|
||||
if primary is None:
|
||||
return None
|
||||
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
primary = log_dir / filename
|
||||
if primary.exists() and primary.stat().st_size > 0:
|
||||
return primary
|
||||
|
||||
# Fall back to the most recent rotated file (.1).
|
||||
rotated = log_dir / f"{filename}.1"
|
||||
rotated = primary.parent / f"{primary.name}.1"
|
||||
if rotated.exists() and rotated.stat().st_size > 0:
|
||||
return rotated
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_log_tail(log_name: str, num_lines: int) -> str:
|
||||
"""Read the last *num_lines* from a log file, or return a placeholder."""
|
||||
from hermes_cli.logs import _read_last_n_lines
|
||||
def _capture_log_snapshot(
|
||||
log_name: str,
|
||||
*,
|
||||
tail_lines: int,
|
||||
max_bytes: int = _MAX_LOG_BYTES,
|
||||
) -> LogSnapshot:
|
||||
"""Capture a log once and derive summary/full-log views from it.
|
||||
|
||||
log_path = _resolve_log_path(log_name)
|
||||
if log_path is None:
|
||||
return "(file not found)"
|
||||
|
||||
try:
|
||||
lines = _read_last_n_lines(log_path, num_lines)
|
||||
return "".join(lines).rstrip("\n")
|
||||
except Exception as exc:
|
||||
return f"(error reading: {exc})"
|
||||
|
||||
|
||||
def _read_full_log(log_name: str, max_bytes: int = _MAX_LOG_BYTES) -> Optional[str]:
|
||||
"""Read a log file for standalone upload.
|
||||
|
||||
Returns the file content (last *max_bytes* if truncated), or None if the
|
||||
file doesn't exist or is empty.
|
||||
The report tail and standalone log upload must come from the same file
|
||||
snapshot. Otherwise a rotation/truncate between reads can make the report
|
||||
look newer than the uploaded ``agent.log`` paste.
|
||||
"""
|
||||
log_path = _resolve_log_path(log_name)
|
||||
if log_path is None:
|
||||
return None
|
||||
primary = _primary_log_path(log_name)
|
||||
tail = "(file empty)" if primary and primary.exists() else "(file not found)"
|
||||
return LogSnapshot(path=None, tail_text=tail, full_text=None)
|
||||
|
||||
try:
|
||||
size = log_path.stat().st_size
|
||||
if size == 0:
|
||||
return None
|
||||
# race: file was truncated between _resolve_log_path and stat
|
||||
return LogSnapshot(path=log_path, tail_text="(file empty)", full_text=None)
|
||||
|
||||
if size <= max_bytes:
|
||||
return log_path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
# File is larger than max_bytes — read the tail.
|
||||
with open(log_path, "rb") as f:
|
||||
f.seek(size - max_bytes)
|
||||
# Skip partial line at the seek point.
|
||||
f.readline()
|
||||
content = f.read().decode("utf-8", errors="replace")
|
||||
return f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{content}"
|
||||
except Exception:
|
||||
return None
|
||||
if size <= max_bytes:
|
||||
raw = f.read()
|
||||
truncated = False
|
||||
else:
|
||||
# Read from the end until we have enough bytes for the
|
||||
# standalone upload and enough newline context to render the
|
||||
# summary tail from the same snapshot.
|
||||
chunk_size = 8192
|
||||
pos = size
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
newline_count = 0
|
||||
|
||||
while pos > 0 and (total < max_bytes or newline_count <= tail_lines + 1) and total < max_bytes * 2:
|
||||
read_size = min(chunk_size, pos)
|
||||
pos -= read_size
|
||||
f.seek(pos)
|
||||
chunk = f.read(read_size)
|
||||
chunks.insert(0, chunk)
|
||||
total += len(chunk)
|
||||
newline_count += chunk.count(b"\n")
|
||||
chunk_size = min(chunk_size * 2, 65536)
|
||||
|
||||
raw = b"".join(chunks)
|
||||
truncated = pos > 0
|
||||
|
||||
full_raw = raw
|
||||
if truncated and len(full_raw) > max_bytes:
|
||||
cut = len(full_raw) - max_bytes
|
||||
# Check whether the cut lands exactly on a line boundary. If the
|
||||
# byte just before the cut position is a newline the first retained
|
||||
# byte starts a complete line and we should keep it. Only drop a
|
||||
# partial first line when we're genuinely mid-line.
|
||||
on_boundary = cut > 0 and full_raw[cut - 1 : cut] == b"\n"
|
||||
full_raw = full_raw[cut:]
|
||||
if not on_boundary and b"\n" in full_raw:
|
||||
full_raw = full_raw.split(b"\n", 1)[1]
|
||||
|
||||
all_text = raw.decode("utf-8", errors="replace")
|
||||
tail_text = "".join(all_text.splitlines(keepends=True)[-tail_lines:]).rstrip("\n")
|
||||
|
||||
full_text = full_raw.decode("utf-8", errors="replace")
|
||||
if truncated:
|
||||
full_text = f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{full_text}"
|
||||
|
||||
return LogSnapshot(path=log_path, tail_text=tail_text, full_text=full_text)
|
||||
except Exception as exc:
|
||||
return LogSnapshot(path=log_path, tail_text=f"(error reading: {exc})", full_text=None)
|
||||
|
||||
|
||||
def _capture_default_log_snapshots(log_lines: int) -> dict[str, LogSnapshot]:
|
||||
"""Capture all logs used by debug-share exactly once."""
|
||||
errors_lines = min(log_lines, 100)
|
||||
return {
|
||||
"agent": _capture_log_snapshot("agent", tail_lines=log_lines),
|
||||
"errors": _capture_log_snapshot("errors", tail_lines=errors_lines),
|
||||
"gateway": _capture_log_snapshot("gateway", tail_lines=errors_lines),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -405,7 +470,12 @@ def _capture_dump() -> str:
|
||||
return capture.getvalue()
|
||||
|
||||
|
||||
def collect_debug_report(*, log_lines: int = 200, dump_text: str = "") -> str:
|
||||
def collect_debug_report(
|
||||
*,
|
||||
log_lines: int = 200,
|
||||
dump_text: str = "",
|
||||
log_snapshots: Optional[dict[str, LogSnapshot]] = None,
|
||||
) -> str:
|
||||
"""Build the summary debug report: system dump + log tails.
|
||||
|
||||
Parameters
|
||||
@@ -424,19 +494,22 @@ def collect_debug_report(*, log_lines: int = 200, dump_text: str = "") -> str:
|
||||
dump_text = _capture_dump()
|
||||
buf.write(dump_text)
|
||||
|
||||
if log_snapshots is None:
|
||||
log_snapshots = _capture_default_log_snapshots(log_lines)
|
||||
|
||||
# ── Recent log tails (summary only) ──────────────────────────────────
|
||||
buf.write("\n\n")
|
||||
buf.write(f"--- agent.log (last {log_lines} lines) ---\n")
|
||||
buf.write(_read_log_tail("agent", log_lines))
|
||||
buf.write(log_snapshots["agent"].tail_text)
|
||||
buf.write("\n\n")
|
||||
|
||||
errors_lines = min(log_lines, 100)
|
||||
buf.write(f"--- errors.log (last {errors_lines} lines) ---\n")
|
||||
buf.write(_read_log_tail("errors", errors_lines))
|
||||
buf.write(log_snapshots["errors"].tail_text)
|
||||
buf.write("\n\n")
|
||||
|
||||
buf.write(f"--- gateway.log (last {errors_lines} lines) ---\n")
|
||||
buf.write(_read_log_tail("gateway", errors_lines))
|
||||
buf.write(log_snapshots["gateway"].tail_text)
|
||||
buf.write("\n")
|
||||
|
||||
return buf.getvalue()
|
||||
@@ -448,6 +521,8 @@ def collect_debug_report(*, log_lines: int = 200, dump_text: str = "") -> str:
|
||||
|
||||
def run_debug_share(args):
|
||||
"""Collect debug report + full logs, upload each, print URLs."""
|
||||
_best_effort_sweep_expired_pastes()
|
||||
|
||||
log_lines = getattr(args, "lines", 200)
|
||||
expiry = getattr(args, "expire", 7)
|
||||
local_only = getattr(args, "local", False)
|
||||
@@ -459,10 +534,15 @@ def run_debug_share(args):
|
||||
|
||||
# Capture dump once — prepended to every paste for context.
|
||||
dump_text = _capture_dump()
|
||||
log_snapshots = _capture_default_log_snapshots(log_lines)
|
||||
|
||||
report = collect_debug_report(log_lines=log_lines, dump_text=dump_text)
|
||||
agent_log = _read_full_log("agent")
|
||||
gateway_log = _read_full_log("gateway")
|
||||
report = collect_debug_report(
|
||||
log_lines=log_lines,
|
||||
dump_text=dump_text,
|
||||
log_snapshots=log_snapshots,
|
||||
)
|
||||
agent_log = log_snapshots["agent"].full_text
|
||||
gateway_log = log_snapshots["gateway"].full_text
|
||||
|
||||
# Prepend dump header to each full log so every paste is self-contained.
|
||||
if agent_log:
|
||||
|
||||
@@ -912,6 +912,7 @@ def run_doctor(args):
|
||||
_apikey_providers = [
|
||||
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
|
||||
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
|
||||
("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True),
|
||||
("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
|
||||
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
|
||||
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
|
||||
|
||||
@@ -160,6 +160,8 @@ def load_hermes_dotenv(
|
||||
# Fix corrupted .env files before python-dotenv parses them (#8908).
|
||||
if user_env.exists():
|
||||
_sanitize_env_file_if_needed(user_env)
|
||||
if project_env_path and project_env_path.exists():
|
||||
_sanitize_env_file_if_needed(project_env_path)
|
||||
|
||||
if user_env.exists():
|
||||
_load_dotenv_with_fallback(user_env, override=True)
|
||||
|
||||
+313
-148
@@ -333,6 +333,147 @@ def _probe_systemd_service_running(system: bool = False) -> tuple[bool, bool]:
|
||||
return selected_system, result.stdout.strip() == "active"
|
||||
|
||||
|
||||
def _read_systemd_unit_properties(
|
||||
system: bool = False,
|
||||
properties: tuple[str, ...] = (
|
||||
"ActiveState",
|
||||
"SubState",
|
||||
"Result",
|
||||
"ExecMainStatus",
|
||||
),
|
||||
) -> dict[str, str]:
|
||||
"""Return selected ``systemctl show`` properties for the gateway unit."""
|
||||
selected_system = _select_systemd_scope(system)
|
||||
try:
|
||||
result = _run_systemctl(
|
||||
[
|
||||
"show",
|
||||
get_service_name(),
|
||||
"--no-pager",
|
||||
"--property",
|
||||
",".join(properties),
|
||||
],
|
||||
system=selected_system,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (RuntimeError, subprocess.TimeoutExpired, OSError):
|
||||
return {}
|
||||
|
||||
if result.returncode != 0:
|
||||
return {}
|
||||
|
||||
parsed: dict[str, str] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
parsed[key] = value.strip()
|
||||
return parsed
|
||||
|
||||
|
||||
def _wait_for_systemd_service_restart(
|
||||
*,
|
||||
system: bool = False,
|
||||
previous_pid: int | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> bool:
|
||||
"""Wait for the gateway service to become active after a restart handoff."""
|
||||
import time
|
||||
|
||||
svc = get_service_name()
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
deadline = time.time() + timeout
|
||||
|
||||
while time.time() < deadline:
|
||||
props = _read_systemd_unit_properties(system=system)
|
||||
active_state = props.get("ActiveState", "")
|
||||
sub_state = props.get("SubState", "")
|
||||
new_pid = None
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
new_pid = get_running_pid()
|
||||
except Exception:
|
||||
new_pid = None
|
||||
|
||||
if active_state == "active":
|
||||
if new_pid and (previous_pid is None or new_pid != previous_pid):
|
||||
print(f"✓ {scope_label} service restarted (PID {new_pid})")
|
||||
return True
|
||||
if previous_pid is None:
|
||||
print(f"✓ {scope_label} service restarted")
|
||||
return True
|
||||
|
||||
if active_state == "activating" and sub_state == "auto-restart":
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
print(
|
||||
f"⚠ {scope_label} service did not become active within {int(timeout)}s.\n"
|
||||
f" Check status: {'sudo ' if system else ''}hermes gateway status\n"
|
||||
f" Check logs: journalctl {'--user ' if not system else ''}-u {svc} -l --since '2 min ago'"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _recover_pending_systemd_restart(system: bool = False, previous_pid: int | None = None) -> bool:
|
||||
"""Recover a planned service restart that is stuck in systemd state."""
|
||||
props = _read_systemd_unit_properties(system=system)
|
||||
if not props:
|
||||
return False
|
||||
|
||||
try:
|
||||
from gateway.status import read_runtime_status
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
runtime_state = read_runtime_status() or {}
|
||||
if not runtime_state.get("restart_requested"):
|
||||
return False
|
||||
|
||||
active_state = props.get("ActiveState", "")
|
||||
sub_state = props.get("SubState", "")
|
||||
exec_main_status = props.get("ExecMainStatus", "")
|
||||
result = props.get("Result", "")
|
||||
|
||||
if active_state == "activating" and sub_state == "auto-restart":
|
||||
print("⏳ Service restart already pending — waiting for systemd relaunch...")
|
||||
return _wait_for_systemd_service_restart(
|
||||
system=system,
|
||||
previous_pid=previous_pid,
|
||||
)
|
||||
|
||||
if active_state == "failed" and (
|
||||
exec_main_status == str(GATEWAY_SERVICE_RESTART_EXIT_CODE)
|
||||
or result == "exit-code"
|
||||
):
|
||||
svc = get_service_name()
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
print(f"↻ Clearing failed state for pending {scope_label.lower()} service restart...")
|
||||
_run_systemctl(
|
||||
["reset-failed", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
_run_systemctl(
|
||||
["start", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=90,
|
||||
)
|
||||
return _wait_for_systemd_service_restart(
|
||||
system=system,
|
||||
previous_pid=previous_pid,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _probe_launchd_service_running() -> bool:
|
||||
if not get_launchd_plist_path().exists():
|
||||
return False
|
||||
@@ -470,7 +611,8 @@ def stop_profile_gateway() -> bool:
|
||||
except (ProcessLookupError, PermissionError):
|
||||
break
|
||||
|
||||
remove_pid_file()
|
||||
if get_running_pid() is None:
|
||||
remove_pid_file()
|
||||
return True
|
||||
|
||||
|
||||
@@ -1505,14 +1647,9 @@ def systemd_restart(system: bool = False):
|
||||
|
||||
pid = get_running_pid()
|
||||
if pid is not None and _request_gateway_self_restart(pid):
|
||||
# SIGUSR1 sent — the gateway will drain active agents, exit with
|
||||
# code 75, and systemd will restart it after RestartSec (30s).
|
||||
# Wait for the old process to die and the new one to become active
|
||||
# so the CLI doesn't return while the service is still restarting.
|
||||
import time
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
svc = get_service_name()
|
||||
scope_cmd = _systemctl_cmd(system)
|
||||
|
||||
# Phase 1: wait for old process to exit (drain + shutdown)
|
||||
print(f"⏳ {scope_label} service draining active work...")
|
||||
@@ -1526,48 +1663,41 @@ def systemd_restart(system: bool = False):
|
||||
else:
|
||||
print(f"⚠ Old process (PID {pid}) still alive after 90s")
|
||||
|
||||
# Phase 2: wait for systemd to start the new process
|
||||
print(f"⏳ Waiting for {svc} to restart...")
|
||||
deadline = time.time() + 60
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
scope_cmd + ["is-active", svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
# Verify it's a NEW process, not the old one somehow
|
||||
new_pid = get_running_pid()
|
||||
if new_pid and new_pid != pid:
|
||||
print(f"✓ {scope_label} service restarted (PID {new_pid})")
|
||||
return
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
time.sleep(2)
|
||||
|
||||
# Timed out — check final state
|
||||
try:
|
||||
result = subprocess.run(
|
||||
scope_cmd + ["is-active", svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
print(f"✓ {scope_label} service restarted")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
f"⚠ {scope_label} service did not become active within 60s.\n"
|
||||
f" Check status: {'sudo ' if system else ''}hermes gateway status\n"
|
||||
f" Check logs: journalctl {'--user ' if not system else ''}-u {svc} --since '2 min ago'"
|
||||
# The gateway exits with code 75 for a planned service restart.
|
||||
# systemd can sit in the RestartSec window or even wedge itself into a
|
||||
# failed/rate-limited state if the operator asks for another restart in
|
||||
# the middle of that handoff. Clear any stale failed state and kick the
|
||||
# unit immediately so `hermes gateway restart` behaves idempotently.
|
||||
_run_systemctl(
|
||||
["reset-failed", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
_run_systemctl(
|
||||
["start", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=90,
|
||||
)
|
||||
_wait_for_systemd_service_restart(system=system, previous_pid=pid)
|
||||
return
|
||||
|
||||
if _recover_pending_systemd_restart(system=system, previous_pid=pid):
|
||||
return
|
||||
|
||||
_run_systemctl(
|
||||
["reset-failed", get_service_name()],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
_run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90)
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service restarted")
|
||||
|
||||
|
||||
|
||||
def systemd_status(deep: bool = False, system: bool = False):
|
||||
def systemd_status(deep: bool = False, system: bool = False, full: bool = False):
|
||||
system = _select_systemd_scope(system)
|
||||
unit_path = get_systemd_unit_path(system=system)
|
||||
scope_flag = " --system" if system else ""
|
||||
@@ -1590,8 +1720,12 @@ def systemd_status(deep: bool = False, system: bool = False):
|
||||
print(f" Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag} # auto-refreshes the unit")
|
||||
print()
|
||||
|
||||
status_cmd = ["status", get_service_name(), "--no-pager"]
|
||||
if full:
|
||||
status_cmd.append("-l")
|
||||
|
||||
_run_systemctl(
|
||||
["status", get_service_name(), "--no-pager"],
|
||||
status_cmd,
|
||||
system=system,
|
||||
capture_output=False,
|
||||
timeout=10,
|
||||
@@ -1624,6 +1758,19 @@ def systemd_status(deep: bool = False, system: bool = False):
|
||||
for line in runtime_lines:
|
||||
print(f" {line}")
|
||||
|
||||
unit_props = _read_systemd_unit_properties(system=system)
|
||||
active_state = unit_props.get("ActiveState", "")
|
||||
sub_state = unit_props.get("SubState", "")
|
||||
exec_main_status = unit_props.get("ExecMainStatus", "")
|
||||
result_code = unit_props.get("Result", "")
|
||||
if active_state == "activating" and sub_state == "auto-restart":
|
||||
print(" ⏳ Restart pending: systemd is waiting to relaunch the gateway")
|
||||
elif active_state == "failed" and exec_main_status == str(GATEWAY_SERVICE_RESTART_EXIT_CODE):
|
||||
print(" ⚠ Planned restart is stuck in systemd failed state (exit 75)")
|
||||
print(f" Run: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()} && {'sudo ' if system else ''}hermes gateway start{scope_flag}")
|
||||
elif active_state == "failed" and result_code:
|
||||
print(f" ⚠ Systemd unit result: {result_code}")
|
||||
|
||||
if system:
|
||||
print("✓ System service starts at boot without requiring systemd linger")
|
||||
elif deep:
|
||||
@@ -1639,7 +1786,10 @@ def systemd_status(deep: bool = False, system: bool = False):
|
||||
if deep:
|
||||
print()
|
||||
print("Recent logs:")
|
||||
subprocess.run(_journalctl_cmd(system) + ["-u", get_service_name(), "-n", "20", "--no-pager"], timeout=10)
|
||||
log_cmd = _journalctl_cmd(system) + ["-u", get_service_name(), "-n", "20", "--no-pager"]
|
||||
if full:
|
||||
log_cmd.append("-l")
|
||||
subprocess.run(log_cmd, timeout=10)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -2639,9 +2789,120 @@ def _setup_dingtalk():
|
||||
|
||||
|
||||
def _setup_wecom():
|
||||
"""Configure WeCom (Enterprise WeChat) via the standard platform setup."""
|
||||
wecom_platform = next(p for p in _PLATFORMS if p["key"] == "wecom")
|
||||
_setup_standard_platform(wecom_platform)
|
||||
"""Interactive setup for WeCom — scan QR code or manual credential input."""
|
||||
print()
|
||||
print(color(" ─── 💬 WeCom (Enterprise WeChat) Setup ───", Colors.CYAN))
|
||||
|
||||
existing_bot_id = get_env_value("WECOM_BOT_ID")
|
||||
existing_secret = get_env_value("WECOM_SECRET")
|
||||
if existing_bot_id and existing_secret:
|
||||
print()
|
||||
print_success("WeCom is already configured.")
|
||||
if not prompt_yes_no(" Reconfigure WeCom?", False):
|
||||
return
|
||||
|
||||
# ── Choose setup method ──
|
||||
print()
|
||||
method_choices = [
|
||||
"Scan QR code to obtain Bot ID and Secret automatically (recommended)",
|
||||
"Enter existing Bot ID and Secret manually",
|
||||
]
|
||||
method_idx = prompt_choice(" How would you like to set up WeCom?", method_choices, 0)
|
||||
|
||||
bot_id = None
|
||||
secret = None
|
||||
|
||||
if method_idx == 0:
|
||||
# ── QR scan flow ──
|
||||
try:
|
||||
from gateway.platforms.wecom import qr_scan_for_bot_info
|
||||
except Exception as exc:
|
||||
print_error(f" WeCom QR scan import failed: {exc}")
|
||||
qr_scan_for_bot_info = None
|
||||
|
||||
if qr_scan_for_bot_info is not None:
|
||||
try:
|
||||
credentials = qr_scan_for_bot_info()
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print_warning(" WeCom setup cancelled.")
|
||||
return
|
||||
except Exception as exc:
|
||||
print_warning(f" QR scan failed: {exc}")
|
||||
credentials = None
|
||||
if credentials:
|
||||
bot_id = credentials.get("bot_id", "")
|
||||
secret = credentials.get("secret", "")
|
||||
print_success(" ✔ QR scan successful! Bot ID and Secret obtained.")
|
||||
|
||||
if not bot_id or not secret:
|
||||
print_info(" QR scan did not complete. Continuing with manual input.")
|
||||
bot_id = None
|
||||
secret = None
|
||||
|
||||
# ── Manual credential input ──
|
||||
if not bot_id or not secret:
|
||||
print()
|
||||
print_info(" 1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots")
|
||||
print_info(" 2. Select API Mode")
|
||||
print_info(" 3. Copy the Bot ID and Secret from the bot's credentials info")
|
||||
print_info(" 4. The bot connects via WebSocket — no public endpoint needed")
|
||||
print()
|
||||
bot_id = prompt(" Bot ID", password=False)
|
||||
if not bot_id:
|
||||
print_warning(" Skipped — WeCom won't work without a Bot ID.")
|
||||
return
|
||||
secret = prompt(" Secret", password=True)
|
||||
if not secret:
|
||||
print_warning(" Skipped — WeCom won't work without a Secret.")
|
||||
return
|
||||
|
||||
# ── Save core credentials ──
|
||||
save_env_value("WECOM_BOT_ID", bot_id)
|
||||
save_env_value("WECOM_SECRET", secret)
|
||||
|
||||
# ── Allowed users (deny-by-default security) ──
|
||||
print()
|
||||
print_info(" The gateway DENIES all users by default for security.")
|
||||
print_info(" Enter user IDs to create an allowlist, or leave empty.")
|
||||
allowed = prompt(" Allowed user IDs (comma-separated, or empty)", password=False)
|
||||
if allowed:
|
||||
cleaned = allowed.replace(" ", "")
|
||||
save_env_value("WECOM_ALLOWED_USERS", cleaned)
|
||||
print_success(" Saved — only these users can interact with the bot.")
|
||||
else:
|
||||
print()
|
||||
access_choices = [
|
||||
"Enable open access (anyone can message the bot)",
|
||||
"Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')",
|
||||
"Disable direct messages",
|
||||
"Skip for now (bot will deny all users until configured)",
|
||||
]
|
||||
access_idx = prompt_choice(" How should unauthorized users be handled?", access_choices, 1)
|
||||
if access_idx == 0:
|
||||
save_env_value("WECOM_DM_POLICY", "open")
|
||||
save_env_value("GATEWAY_ALLOW_ALL_USERS", "true")
|
||||
print_warning(" Open access enabled — anyone can use your bot!")
|
||||
elif access_idx == 1:
|
||||
save_env_value("WECOM_DM_POLICY", "pairing")
|
||||
print_success(" DM pairing mode — users will receive a code to request access.")
|
||||
print_info(" Approve with: hermes pairing approve <platform> <code>")
|
||||
elif access_idx == 2:
|
||||
save_env_value("WECOM_DM_POLICY", "disabled")
|
||||
print_warning(" Direct messages disabled.")
|
||||
else:
|
||||
print_info(" Skipped — configure later with 'hermes gateway setup'")
|
||||
|
||||
# ── Home channel (optional) ──
|
||||
print()
|
||||
print_info(" Chat ID for scheduled results and notifications.")
|
||||
home = prompt(" Home chat ID (optional, for cron/notifications)", password=False)
|
||||
if home:
|
||||
save_env_value("WECOM_HOME_CHANNEL", home)
|
||||
print_success(f" Home channel set to {home}")
|
||||
|
||||
print()
|
||||
print_success("💬 WeCom configured!")
|
||||
|
||||
|
||||
def _is_service_installed() -> bool:
|
||||
@@ -3021,7 +3282,8 @@ def _setup_qqbot():
|
||||
if method_idx == 0:
|
||||
# ── QR scan-to-configure ──
|
||||
try:
|
||||
credentials = _qqbot_qr_flow()
|
||||
from gateway.platforms.qqbot import qr_register
|
||||
credentials = qr_register()
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print_warning(" QQ Bot setup cancelled.")
|
||||
@@ -3103,106 +3365,6 @@ def _setup_qqbot():
|
||||
print_info(f" App ID: {credentials['app_id']}")
|
||||
|
||||
|
||||
def _qqbot_render_qr(url: str) -> bool:
|
||||
"""Try to render a QR code in the terminal. Returns True if successful."""
|
||||
try:
|
||||
import qrcode as _qr
|
||||
qr = _qr.QRCode(border=1,error_correction=_qr.constants.ERROR_CORRECT_L)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
qr.print_ascii(invert=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _qqbot_qr_flow():
|
||||
"""Run the QR-code scan-to-configure flow.
|
||||
|
||||
Returns a dict with app_id, client_secret, user_openid on success,
|
||||
or None on failure/cancel.
|
||||
"""
|
||||
try:
|
||||
from gateway.platforms.qqbot import (
|
||||
create_bind_task, poll_bind_result, build_connect_url,
|
||||
decrypt_secret, BindStatus,
|
||||
)
|
||||
from gateway.platforms.qqbot.constants import ONBOARD_POLL_INTERVAL
|
||||
except Exception as exc:
|
||||
print_error(f" QQBot onboard import failed: {exc}")
|
||||
return None
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
MAX_REFRESHES = 3
|
||||
refresh_count = 0
|
||||
|
||||
while refresh_count <= MAX_REFRESHES:
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
# ── Create bind task ──
|
||||
try:
|
||||
task_id, aes_key = loop.run_until_complete(create_bind_task())
|
||||
except Exception as e:
|
||||
print_warning(f" Failed to create bind task: {e}")
|
||||
loop.close()
|
||||
return None
|
||||
|
||||
url = build_connect_url(task_id)
|
||||
|
||||
# ── Display QR code + URL ──
|
||||
print()
|
||||
if _qqbot_render_qr(url):
|
||||
print(f" Scan the QR code above, or open this URL directly:\n {url}")
|
||||
else:
|
||||
print(f" Open this URL in QQ on your phone:\n {url}")
|
||||
print_info(" Tip: pip install qrcode to show a scannable QR code here")
|
||||
|
||||
# ── Poll loop (silent — keep QR visible at bottom) ──
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
status, app_id, encrypted_secret, user_openid = loop.run_until_complete(
|
||||
poll_bind_result(task_id)
|
||||
)
|
||||
except Exception:
|
||||
time.sleep(ONBOARD_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
if status == BindStatus.COMPLETED:
|
||||
client_secret = decrypt_secret(encrypted_secret, aes_key)
|
||||
print()
|
||||
print_success(f" QR scan complete! (App ID: {app_id})")
|
||||
if user_openid:
|
||||
print_info(f" Scanner's OpenID: {user_openid}")
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"client_secret": client_secret,
|
||||
"user_openid": user_openid,
|
||||
}
|
||||
|
||||
if status == BindStatus.EXPIRED:
|
||||
refresh_count += 1
|
||||
if refresh_count > MAX_REFRESHES:
|
||||
print()
|
||||
print_warning(f" QR code expired {MAX_REFRESHES} times — giving up.")
|
||||
return None
|
||||
print()
|
||||
print_warning(f" QR code expired, refreshing... ({refresh_count}/{MAX_REFRESHES})")
|
||||
loop.close()
|
||||
break # outer while creates a new task
|
||||
|
||||
time.sleep(ONBOARD_POLL_INTERVAL)
|
||||
except KeyboardInterrupt:
|
||||
loop.close()
|
||||
raise
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _setup_signal():
|
||||
"""Interactive setup for Signal messenger."""
|
||||
import shutil
|
||||
@@ -3390,6 +3552,8 @@ def gateway_setup():
|
||||
_setup_feishu()
|
||||
elif platform["key"] == "qqbot":
|
||||
_setup_qqbot()
|
||||
elif platform["key"] == "wecom":
|
||||
_setup_wecom()
|
||||
else:
|
||||
_setup_standard_platform(platform)
|
||||
|
||||
@@ -3748,12 +3912,13 @@ def gateway_command(args):
|
||||
|
||||
elif subcmd == "status":
|
||||
deep = getattr(args, 'deep', False)
|
||||
full = getattr(args, 'full', False)
|
||||
system = getattr(args, 'system', False)
|
||||
snapshot = get_gateway_runtime_snapshot(system=system)
|
||||
|
||||
# Check for service first
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
systemd_status(deep, system=system)
|
||||
systemd_status(deep, system=system, full=full)
|
||||
_print_gateway_process_mismatch(snapshot)
|
||||
elif is_macos() and get_launchd_plist_path().exists():
|
||||
launchd_status(deep)
|
||||
|
||||
@@ -1566,6 +1566,8 @@ def select_provider_and_model(args=None):
|
||||
_model_flow_anthropic(config, current_model)
|
||||
elif selected_provider == "kimi-coding":
|
||||
_model_flow_kimi(config, current_model)
|
||||
elif selected_provider == "stepfun":
|
||||
_model_flow_stepfun(config, current_model)
|
||||
elif selected_provider == "bedrock":
|
||||
_model_flow_bedrock(config, current_model)
|
||||
elif selected_provider in (
|
||||
@@ -3462,6 +3464,140 @@ def _model_flow_kimi(config, current_model=""):
|
||||
print("No change.")
|
||||
|
||||
|
||||
def _infer_stepfun_region(base_url: str) -> str:
|
||||
"""Infer the current StepFun region from the configured endpoint."""
|
||||
normalized = (base_url or "").strip().lower()
|
||||
if "api.stepfun.com" in normalized:
|
||||
return "china"
|
||||
return "international"
|
||||
|
||||
|
||||
def _stepfun_base_url_for_region(region: str) -> str:
|
||||
from hermes_cli.auth import (
|
||||
STEPFUN_STEP_PLAN_CN_BASE_URL,
|
||||
STEPFUN_STEP_PLAN_INTL_BASE_URL,
|
||||
)
|
||||
|
||||
return (
|
||||
STEPFUN_STEP_PLAN_CN_BASE_URL
|
||||
if region == "china"
|
||||
else STEPFUN_STEP_PLAN_INTL_BASE_URL
|
||||
)
|
||||
|
||||
|
||||
def _model_flow_stepfun(config, current_model=""):
|
||||
"""StepFun Step Plan flow with region-specific endpoints."""
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
_prompt_model_selection,
|
||||
_save_model_choice,
|
||||
deactivate_provider,
|
||||
)
|
||||
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
|
||||
from hermes_cli.models import fetch_api_models
|
||||
|
||||
provider_id = "stepfun"
|
||||
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||
key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
|
||||
base_url_env = pconfig.base_url_env_var or ""
|
||||
|
||||
existing_key = ""
|
||||
for ev in pconfig.api_key_env_vars:
|
||||
existing_key = get_env_value(ev) or os.getenv(ev, "")
|
||||
if existing_key:
|
||||
break
|
||||
|
||||
if not existing_key:
|
||||
print(f"No {pconfig.name} API key configured.")
|
||||
if key_env:
|
||||
try:
|
||||
import getpass
|
||||
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not new_key:
|
||||
print("Cancelled.")
|
||||
return
|
||||
save_env_value(key_env, new_key)
|
||||
existing_key = new_key
|
||||
print("API key saved.")
|
||||
print()
|
||||
else:
|
||||
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
||||
print()
|
||||
|
||||
current_base = ""
|
||||
if base_url_env:
|
||||
current_base = get_env_value(base_url_env) or os.getenv(base_url_env, "")
|
||||
if not current_base:
|
||||
model_cfg = config.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
current_base = str(model_cfg.get("base_url") or "").strip()
|
||||
current_region = _infer_stepfun_region(current_base or pconfig.inference_base_url)
|
||||
|
||||
region_choices = [
|
||||
("international", f"International ({_stepfun_base_url_for_region('international')})"),
|
||||
("china", f"China ({_stepfun_base_url_for_region('china')})"),
|
||||
]
|
||||
ordered_regions = []
|
||||
for region_key, label in region_choices:
|
||||
if region_key == current_region:
|
||||
ordered_regions.insert(0, (region_key, f"{label} ← currently active"))
|
||||
else:
|
||||
ordered_regions.append((region_key, label))
|
||||
ordered_regions.append(("cancel", "Cancel"))
|
||||
|
||||
region_idx = _prompt_provider_choice([label for _, label in ordered_regions])
|
||||
if region_idx is None or ordered_regions[region_idx][0] == "cancel":
|
||||
print("No change.")
|
||||
return
|
||||
|
||||
selected_region = ordered_regions[region_idx][0]
|
||||
effective_base = _stepfun_base_url_for_region(selected_region)
|
||||
if base_url_env:
|
||||
save_env_value(base_url_env, effective_base)
|
||||
|
||||
live_models = fetch_api_models(existing_key, effective_base)
|
||||
if live_models:
|
||||
model_list = live_models
|
||||
print(f" Found {len(model_list)} model(s) from {pconfig.name} API")
|
||||
else:
|
||||
model_list = _PROVIDER_MODELS.get(provider_id, [])
|
||||
if model_list:
|
||||
print(
|
||||
f" Could not auto-detect models from {pconfig.name} API — "
|
||||
"showing Step Plan fallback catalog."
|
||||
)
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
else:
|
||||
try:
|
||||
selected = input("Model name: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
selected = None
|
||||
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
|
||||
cfg = load_config()
|
||||
model = cfg.get("model")
|
||||
if not isinstance(model, dict):
|
||||
model = {"default": model} if model else {}
|
||||
cfg["model"] = model
|
||||
model["provider"] = provider_id
|
||||
model["base_url"] = effective_base
|
||||
model.pop("api_mode", None)
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
config["model"] = dict(model)
|
||||
print(f"Default model set to: {selected} (via {pconfig.name})")
|
||||
else:
|
||||
print("No change.")
|
||||
|
||||
|
||||
def _model_flow_bedrock_api_key(config, region, current_model=""):
|
||||
"""Bedrock API Key mode — uses the OpenAI-compatible bedrock-mantle endpoint.
|
||||
|
||||
@@ -6530,6 +6666,7 @@ For more help on a command:
|
||||
"zai",
|
||||
"kimi-coding",
|
||||
"kimi-coding-cn",
|
||||
"stepfun",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"kilocode",
|
||||
@@ -6751,6 +6888,12 @@ For more help on a command:
|
||||
# gateway status
|
||||
gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status")
|
||||
gateway_status.add_argument("--deep", action="store_true", help="Deep status check")
|
||||
gateway_status.add_argument(
|
||||
"-l",
|
||||
"--full",
|
||||
action="store_true",
|
||||
help="Show full, untruncated service/log output where supported",
|
||||
)
|
||||
gateway_status.add_argument(
|
||||
"--system",
|
||||
action="store_true",
|
||||
|
||||
@@ -143,7 +143,7 @@ MODEL_ALIASES: dict[str, ModelIdentity] = {
|
||||
# Z.AI / GLM
|
||||
"glm": ModelIdentity("z-ai", "glm"),
|
||||
|
||||
# StepFun
|
||||
# Step Plan (StepFun)
|
||||
"step": ModelIdentity("stepfun", "step"),
|
||||
|
||||
# Xiaomi
|
||||
|
||||
+24
-2
@@ -42,7 +42,8 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("openrouter/elephant-alpha", "free"),
|
||||
("openai/gpt-5.4", ""),
|
||||
("openai/gpt-5.4-mini", ""),
|
||||
("xiaomi/mimo-v2-pro", ""),
|
||||
("xiaomi/mimo-v2.5-pro", ""),
|
||||
("xiaomi/mimo-v2.5", ""),
|
||||
("openai/gpt-5.3-codex", ""),
|
||||
("google/gemini-3-pro-image-preview", ""),
|
||||
("google/gemini-3-flash-preview", ""),
|
||||
@@ -108,7 +109,8 @@ def _codex_curated_models() -> list[str]:
|
||||
_PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"nous": [
|
||||
"moonshotai/kimi-k2.6",
|
||||
"xiaomi/mimo-v2-pro",
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
"xiaomi/mimo-v2.5",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"anthropic/claude-opus-4.6",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
@@ -210,6 +212,10 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"kimi-k2-turbo-preview",
|
||||
"kimi-k2-0905-preview",
|
||||
],
|
||||
"stepfun": [
|
||||
"step-3.5-flash",
|
||||
"step-3.5-flash-2603",
|
||||
],
|
||||
"moonshot": [
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
@@ -699,6 +705,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"),
|
||||
ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com) & Moonshot API"),
|
||||
ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"),
|
||||
ProviderEntry("stepfun", "StepFun Step Plan", "StepFun Step Plan (agent/coding models via Step Plan API)"),
|
||||
ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"),
|
||||
ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"),
|
||||
ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
|
||||
@@ -733,6 +740,8 @@ _PROVIDER_ALIASES = {
|
||||
"moonshot": "kimi-coding",
|
||||
"kimi-cn": "kimi-coding-cn",
|
||||
"moonshot-cn": "kimi-coding-cn",
|
||||
"step": "stepfun",
|
||||
"stepfun-coding-plan": "stepfun",
|
||||
"arcee-ai": "arcee",
|
||||
"arceeai": "arcee",
|
||||
"minimax-china": "minimax-cn",
|
||||
@@ -1613,6 +1622,19 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
return live
|
||||
except Exception:
|
||||
pass
|
||||
if normalized == "stepfun":
|
||||
try:
|
||||
from hermes_cli.auth import resolve_api_key_provider_credentials
|
||||
|
||||
creds = resolve_api_key_provider_credentials("stepfun")
|
||||
api_key = str(creds.get("api_key") or "").strip()
|
||||
base_url = str(creds.get("base_url") or "").strip()
|
||||
if api_key and base_url:
|
||||
live = fetch_api_models(api_key, base_url)
|
||||
if live:
|
||||
return live
|
||||
except Exception:
|
||||
pass
|
||||
if normalized == "anthropic":
|
||||
live = _fetch_anthropic_models()
|
||||
if live:
|
||||
|
||||
@@ -283,6 +283,7 @@ class PluginContext:
|
||||
name: str,
|
||||
handler: Callable,
|
||||
description: str = "",
|
||||
args_hint: str = "",
|
||||
) -> None:
|
||||
"""Register a slash command (e.g. ``/lcm``) available in CLI and gateway sessions.
|
||||
|
||||
@@ -293,6 +294,13 @@ class PluginContext:
|
||||
terminal commands), this registers in-session slash commands that users
|
||||
invoke during a conversation.
|
||||
|
||||
``args_hint`` is an optional short string (e.g. ``"<file>"`` or
|
||||
``"dias:7 formato:json"``) used by gateway adapters to surface the
|
||||
command with an argument field — for example Discord's native slash
|
||||
command picker. Plugin commands without ``args_hint`` register as
|
||||
parameterless in Discord and still accept trailing text when invoked
|
||||
as free-form chat.
|
||||
|
||||
Names conflicting with built-in commands are rejected with a warning.
|
||||
"""
|
||||
clean = name.lower().strip().lstrip("/").replace(" ", "-")
|
||||
@@ -320,6 +328,7 @@ class PluginContext:
|
||||
"handler": handler,
|
||||
"description": description or "Plugin command",
|
||||
"plugin": self.manifest.name,
|
||||
"args_hint": (args_hint or "").strip(),
|
||||
}
|
||||
logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean)
|
||||
|
||||
@@ -734,6 +743,30 @@ class PluginManager:
|
||||
)
|
||||
kind = "standalone"
|
||||
|
||||
# Auto-coerce user-installed memory providers to kind="exclusive"
|
||||
# so they're routed to plugins/memory discovery instead of being
|
||||
# loaded by the general PluginManager (which has no
|
||||
# register_memory_provider on PluginContext). Mirrors the
|
||||
# heuristic in plugins/memory/__init__.py:_is_memory_provider_dir.
|
||||
# Bundled memory providers are already skipped via skip_names.
|
||||
if kind == "standalone" and "kind" not in data:
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
if init_file.exists():
|
||||
try:
|
||||
source_text = init_file.read_text(errors="replace")[:8192]
|
||||
if (
|
||||
"register_memory_provider" in source_text
|
||||
or "MemoryProvider" in source_text
|
||||
):
|
||||
kind = "exclusive"
|
||||
logger.debug(
|
||||
"Plugin %s: detected memory provider, "
|
||||
"treating as kind='exclusive'",
|
||||
key,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return PluginManifest(
|
||||
name=name,
|
||||
version=str(data.get("version", "")),
|
||||
|
||||
@@ -94,6 +94,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
transport="openai_chat",
|
||||
base_url_env_var="KIMI_BASE_URL",
|
||||
),
|
||||
"stepfun": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
extra_env_vars=("STEPFUN_API_KEY",),
|
||||
base_url_override="https://api.stepfun.ai/step_plan/v1",
|
||||
base_url_env_var="STEPFUN_BASE_URL",
|
||||
),
|
||||
"minimax": HermesOverlay(
|
||||
transport="anthropic_messages",
|
||||
base_url_env_var="MINIMAX_BASE_URL",
|
||||
@@ -210,6 +216,10 @@ ALIASES: Dict[str, str] = {
|
||||
"kimi-coding-cn": "kimi-for-coding",
|
||||
"moonshot": "kimi-for-coding",
|
||||
|
||||
# stepfun
|
||||
"step": "stepfun",
|
||||
"stepfun-coding-plan": "stepfun",
|
||||
|
||||
# minimax-cn
|
||||
"minimax-china": "minimax-cn",
|
||||
"minimax_cn": "minimax-cn",
|
||||
@@ -294,6 +304,7 @@ _LABEL_OVERRIDES: Dict[str, str] = {
|
||||
"nous": "Nous Portal",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"stepfun": "StepFun Step Plan",
|
||||
"xiaomi": "Xiaomi MiMo",
|
||||
"local": "Local endpoint",
|
||||
"bedrock": "AWS Bedrock",
|
||||
|
||||
@@ -96,6 +96,7 @@ _DEFAULT_PROVIDER_MODELS = {
|
||||
"zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
|
||||
"kimi-coding": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"kimi-coding-cn": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"stepfun": ["step-3.5-flash", "step-3.5-flash-2603"],
|
||||
"arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"],
|
||||
"minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
|
||||
"minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
|
||||
@@ -804,6 +805,7 @@ def setup_model_provider(config: dict, *, quick: bool = False):
|
||||
"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",
|
||||
|
||||
@@ -30,6 +30,14 @@ All fields are optional. Missing values inherit from the ``default`` skin.
|
||||
prompt: "#FFF8DC" # Prompt text color
|
||||
input_rule: "#CD7F32" # Input area horizontal rule
|
||||
response_border: "#FFD700" # Response box border (ANSI)
|
||||
status_bar_bg: "#1a1a2e" # Status bar background
|
||||
status_bar_text: "#C0C0C0" # Status bar default text
|
||||
status_bar_strong: "#FFD700" # Status bar highlighted text
|
||||
status_bar_dim: "#8B8682" # Status bar separators/muted text
|
||||
status_bar_good: "#8FBC8F" # Healthy context usage
|
||||
status_bar_warn: "#FFD700" # Warning context usage
|
||||
status_bar_bad: "#FF8C00" # High context usage
|
||||
status_bar_critical: "#FF6B6B" # Critical context usage
|
||||
session_label: "#DAA520" # Session label color
|
||||
session_border: "#8B8682" # Session ID dim color
|
||||
status_bar_bg: "#1a1a2e" # TUI status/usage bar background
|
||||
@@ -170,6 +178,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"prompt": "#FFF8DC",
|
||||
"input_rule": "#CD7F32",
|
||||
"response_border": "#FFD700",
|
||||
"status_bar_bg": "#1a1a2e",
|
||||
"session_label": "#DAA520",
|
||||
"session_border": "#8B8682",
|
||||
},
|
||||
@@ -203,6 +212,14 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"prompt": "#F1E6CF",
|
||||
"input_rule": "#9F1C1C",
|
||||
"response_border": "#C7A96B",
|
||||
"status_bar_bg": "#2A1212",
|
||||
"status_bar_text": "#F1E6CF",
|
||||
"status_bar_strong": "#C7A96B",
|
||||
"status_bar_dim": "#6E584B",
|
||||
"status_bar_good": "#7BC96F",
|
||||
"status_bar_warn": "#C7A96B",
|
||||
"status_bar_bad": "#DD4A3A",
|
||||
"status_bar_critical": "#EF5350",
|
||||
"session_label": "#C7A96B",
|
||||
"session_border": "#6E584B",
|
||||
},
|
||||
@@ -267,6 +284,14 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"prompt": "#c9d1d9",
|
||||
"input_rule": "#444444",
|
||||
"response_border": "#aaaaaa",
|
||||
"status_bar_bg": "#1F1F1F",
|
||||
"status_bar_text": "#C9D1D9",
|
||||
"status_bar_strong": "#E6EDF3",
|
||||
"status_bar_dim": "#777777",
|
||||
"status_bar_good": "#B5B5B5",
|
||||
"status_bar_warn": "#AAAAAA",
|
||||
"status_bar_bad": "#D0D0D0",
|
||||
"status_bar_critical": "#F0F0F0",
|
||||
"session_label": "#888888",
|
||||
"session_border": "#555555",
|
||||
},
|
||||
@@ -298,6 +323,14 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"prompt": "#c9d1d9",
|
||||
"input_rule": "#4169e1",
|
||||
"response_border": "#7eb8f6",
|
||||
"status_bar_bg": "#151C2F",
|
||||
"status_bar_text": "#C9D1D9",
|
||||
"status_bar_strong": "#7EB8F6",
|
||||
"status_bar_dim": "#4B5563",
|
||||
"status_bar_good": "#63D0A6",
|
||||
"status_bar_warn": "#E6A855",
|
||||
"status_bar_bad": "#F7A072",
|
||||
"status_bar_critical": "#FF7A7A",
|
||||
"session_label": "#7eb8f6",
|
||||
"session_border": "#4b5563",
|
||||
},
|
||||
@@ -403,6 +436,14 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"prompt": "#EAF7FF",
|
||||
"input_rule": "#2A6FB9",
|
||||
"response_border": "#5DB8F5",
|
||||
"status_bar_bg": "#0F2440",
|
||||
"status_bar_text": "#EAF7FF",
|
||||
"status_bar_strong": "#A9DFFF",
|
||||
"status_bar_dim": "#496884",
|
||||
"status_bar_good": "#6ED7B0",
|
||||
"status_bar_warn": "#5DB8F5",
|
||||
"status_bar_bad": "#2A6FB9",
|
||||
"status_bar_critical": "#D94F4F",
|
||||
"session_label": "#A9DFFF",
|
||||
"session_border": "#496884",
|
||||
},
|
||||
@@ -467,6 +508,14 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"prompt": "#F5F5F5",
|
||||
"input_rule": "#656565",
|
||||
"response_border": "#B7B7B7",
|
||||
"status_bar_bg": "#202020",
|
||||
"status_bar_text": "#D3D3D3",
|
||||
"status_bar_strong": "#F5F5F5",
|
||||
"status_bar_dim": "#656565",
|
||||
"status_bar_good": "#B7B7B7",
|
||||
"status_bar_warn": "#D3D3D3",
|
||||
"status_bar_bad": "#E7E7E7",
|
||||
"status_bar_critical": "#F5F5F5",
|
||||
"session_label": "#919191",
|
||||
"session_border": "#656565",
|
||||
},
|
||||
@@ -532,6 +581,14 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
||||
"prompt": "#FFF0D4",
|
||||
"input_rule": "#C75B1D",
|
||||
"response_border": "#F29C38",
|
||||
"status_bar_bg": "#2B160E",
|
||||
"status_bar_text": "#FFF0D4",
|
||||
"status_bar_strong": "#FFD39A",
|
||||
"status_bar_dim": "#6C4724",
|
||||
"status_bar_good": "#6BCB77",
|
||||
"status_bar_warn": "#F29C38",
|
||||
"status_bar_bad": "#E2832B",
|
||||
"status_bar_critical": "#EF5350",
|
||||
"session_label": "#FFD39A",
|
||||
"session_border": "#6C4724",
|
||||
},
|
||||
@@ -770,6 +827,13 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
|
||||
warn = skin.get_color("ui_warn", "#FF8C00")
|
||||
error = skin.get_color("ui_error", "#FF6B6B")
|
||||
status_bg = skin.get_color("status_bar_bg", "#1a1a2e")
|
||||
status_text = skin.get_color("status_bar_text", text)
|
||||
status_strong = skin.get_color("status_bar_strong", title)
|
||||
status_dim = skin.get_color("status_bar_dim", dim)
|
||||
status_good = skin.get_color("status_bar_good", skin.get_color("ui_ok", "#8FBC8F"))
|
||||
status_warn = skin.get_color("status_bar_warn", warn)
|
||||
status_bad = skin.get_color("status_bar_bad", skin.get_color("banner_accent", warn))
|
||||
status_critical = skin.get_color("status_bar_critical", error)
|
||||
voice_bg = skin.get_color("voice_status_bg", status_bg)
|
||||
menu_bg = skin.get_color("completion_menu_bg", "#1a1a2e")
|
||||
menu_current_bg = skin.get_color("completion_menu_current_bg", "#333355")
|
||||
@@ -782,13 +846,13 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
|
||||
"prompt": prompt,
|
||||
"prompt-working": f"{dim} italic",
|
||||
"hint": f"{dim} italic",
|
||||
"status-bar": f"bg:{status_bg} {text}",
|
||||
"status-bar-strong": f"bg:{status_bg} {title} bold",
|
||||
"status-bar-dim": f"bg:{status_bg} {dim}",
|
||||
"status-bar-good": f"bg:{status_bg} {skin.get_color('ui_ok', '#8FBC8F')} bold",
|
||||
"status-bar-warn": f"bg:{status_bg} {warn} bold",
|
||||
"status-bar-bad": f"bg:{status_bg} {skin.get_color('banner_accent', warn)} bold",
|
||||
"status-bar-critical": f"bg:{status_bg} {error} bold",
|
||||
"status-bar": f"bg:{status_bg} {status_text}",
|
||||
"status-bar-strong": f"bg:{status_bg} {status_strong} bold",
|
||||
"status-bar-dim": f"bg:{status_bg} {status_dim}",
|
||||
"status-bar-good": f"bg:{status_bg} {status_good} bold",
|
||||
"status-bar-warn": f"bg:{status_bg} {status_warn} bold",
|
||||
"status-bar-bad": f"bg:{status_bg} {status_bad} bold",
|
||||
"status-bar-critical": f"bg:{status_bg} {status_critical} bold",
|
||||
"input-rule": input_rule,
|
||||
"image-badge": f"{label} bold",
|
||||
"completion-menu": f"bg:{menu_bg} {text}",
|
||||
|
||||
@@ -122,6 +122,7 @@ def show_status(args):
|
||||
"OpenAI": "OPENAI_API_KEY",
|
||||
"Z.AI/GLM": "GLM_API_KEY",
|
||||
"Kimi": "KIMI_API_KEY",
|
||||
"StepFun Step Plan": "STEPFUN_API_KEY",
|
||||
"MiniMax": "MINIMAX_API_KEY",
|
||||
"MiniMax-CN": "MINIMAX_CN_API_KEY",
|
||||
"Firecrawl": "FIRECRAWL_API_KEY",
|
||||
@@ -252,6 +253,7 @@ def show_status(args):
|
||||
apikey_providers = {
|
||||
"Z.AI / GLM": ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
|
||||
"Kimi / Moonshot": ("KIMI_API_KEY",),
|
||||
"StepFun Step Plan": ("STEPFUN_API_KEY",),
|
||||
"MiniMax": ("MINIMAX_API_KEY",),
|
||||
"MiniMax (China)": ("MINIMAX_CN_API_KEY",),
|
||||
}
|
||||
|
||||
@@ -2189,7 +2189,8 @@ async def get_usage_analytics(days: int = 30):
|
||||
SUM(reasoning_tokens) as reasoning_tokens,
|
||||
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
|
||||
COALESCE(SUM(actual_cost_usd), 0) as actual_cost,
|
||||
COUNT(*) as sessions
|
||||
COUNT(*) as sessions,
|
||||
SUM(COALESCE(api_call_count, 0)) as api_calls
|
||||
FROM sessions WHERE started_at > ?
|
||||
GROUP BY day ORDER BY day
|
||||
""", (cutoff,))
|
||||
@@ -2200,7 +2201,8 @@ async def get_usage_analytics(days: int = 30):
|
||||
SUM(input_tokens) as input_tokens,
|
||||
SUM(output_tokens) as output_tokens,
|
||||
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
|
||||
COUNT(*) as sessions
|
||||
COUNT(*) as sessions,
|
||||
SUM(COALESCE(api_call_count, 0)) as api_calls
|
||||
FROM sessions WHERE started_at > ? AND model IS NOT NULL
|
||||
GROUP BY model ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
|
||||
""", (cutoff,))
|
||||
@@ -2213,7 +2215,8 @@ async def get_usage_analytics(days: int = 30):
|
||||
SUM(reasoning_tokens) as total_reasoning,
|
||||
COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost,
|
||||
COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost,
|
||||
COUNT(*) as total_sessions
|
||||
COUNT(*) as total_sessions,
|
||||
SUM(COALESCE(api_call_count, 0)) as total_api_calls
|
||||
FROM sessions WHERE started_at > ?
|
||||
""", (cutoff,))
|
||||
totals = dict(cur3.fetchone())
|
||||
|
||||
+154
-6
@@ -31,7 +31,7 @@ T = TypeVar("T")
|
||||
|
||||
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
||||
|
||||
SCHEMA_VERSION = 6
|
||||
SCHEMA_VERSION = 8
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
@@ -65,6 +65,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
cost_source TEXT,
|
||||
pricing_version TEXT,
|
||||
title TEXT,
|
||||
api_call_count INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
@@ -80,10 +81,16 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
token_count INTEGER,
|
||||
finish_reason TEXT,
|
||||
reasoning TEXT,
|
||||
reasoning_content TEXT,
|
||||
reasoning_details TEXT,
|
||||
codex_reasoning_items TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS state_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
|
||||
@@ -329,6 +336,26 @@ class SessionDB:
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 6")
|
||||
if current_version < 7:
|
||||
# v7: preserve provider-native reasoning_content separately from
|
||||
# normalized reasoning text. Kimi/Moonshot replay can require
|
||||
# this field on assistant tool-call messages when thinking is on.
|
||||
try:
|
||||
cursor.execute('ALTER TABLE messages ADD COLUMN "reasoning_content" TEXT')
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 7")
|
||||
if current_version < 8:
|
||||
# v8: add api_call_count column to sessions — tracks the number
|
||||
# of individual LLM API calls made within a session (as opposed
|
||||
# to the session count itself).
|
||||
try:
|
||||
cursor.execute(
|
||||
'ALTER TABLE sessions ADD COLUMN "api_call_count" INTEGER DEFAULT 0'
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 8")
|
||||
|
||||
# Unique title index — always ensure it exists (safe to run after migrations
|
||||
# since the title column is guaranteed to exist at this point)
|
||||
@@ -435,6 +462,7 @@ class SessionDB:
|
||||
billing_provider: Optional[str] = None,
|
||||
billing_base_url: Optional[str] = None,
|
||||
billing_mode: Optional[str] = None,
|
||||
api_call_count: int = 0,
|
||||
absolute: bool = False,
|
||||
) -> None:
|
||||
"""Update token counters and backfill model if not already set.
|
||||
@@ -464,7 +492,8 @@ class SessionDB:
|
||||
billing_provider = COALESCE(billing_provider, ?),
|
||||
billing_base_url = COALESCE(billing_base_url, ?),
|
||||
billing_mode = COALESCE(billing_mode, ?),
|
||||
model = COALESCE(model, ?)
|
||||
model = COALESCE(model, ?),
|
||||
api_call_count = ?
|
||||
WHERE id = ?"""
|
||||
else:
|
||||
sql = """UPDATE sessions SET
|
||||
@@ -484,7 +513,8 @@ class SessionDB:
|
||||
billing_provider = COALESCE(billing_provider, ?),
|
||||
billing_base_url = COALESCE(billing_base_url, ?),
|
||||
billing_mode = COALESCE(billing_mode, ?),
|
||||
model = COALESCE(model, ?)
|
||||
model = COALESCE(model, ?),
|
||||
api_call_count = COALESCE(api_call_count, 0) + ?
|
||||
WHERE id = ?"""
|
||||
params = (
|
||||
input_tokens,
|
||||
@@ -502,6 +532,7 @@ class SessionDB:
|
||||
billing_base_url,
|
||||
billing_mode,
|
||||
model,
|
||||
api_call_count,
|
||||
session_id,
|
||||
)
|
||||
def _do(conn):
|
||||
@@ -922,6 +953,7 @@ class SessionDB:
|
||||
token_count: int = None,
|
||||
finish_reason: str = None,
|
||||
reasoning: str = None,
|
||||
reasoning_content: str = None,
|
||||
reasoning_details: Any = None,
|
||||
codex_reasoning_items: Any = None,
|
||||
) -> int:
|
||||
@@ -951,8 +983,8 @@ class SessionDB:
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO messages (session_id, role, content, tool_call_id,
|
||||
tool_calls, tool_name, timestamp, token_count, finish_reason,
|
||||
reasoning, reasoning_details, codex_reasoning_items)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
reasoning, reasoning_content, reasoning_details, codex_reasoning_items)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
session_id,
|
||||
role,
|
||||
@@ -964,6 +996,7 @@ class SessionDB:
|
||||
token_count,
|
||||
finish_reason,
|
||||
reasoning,
|
||||
reasoning_content,
|
||||
reasoning_details_json,
|
||||
codex_items_json,
|
||||
),
|
||||
@@ -1014,7 +1047,7 @@ class SessionDB:
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
|
||||
"reasoning, reasoning_details, codex_reasoning_items "
|
||||
"reasoning, reasoning_content, reasoning_details, codex_reasoning_items "
|
||||
"FROM messages WHERE session_id = ? ORDER BY timestamp, id",
|
||||
(session_id,),
|
||||
)
|
||||
@@ -1038,6 +1071,8 @@ class SessionDB:
|
||||
if row["role"] == "assistant":
|
||||
if row["reasoning"]:
|
||||
msg["reasoning"] = row["reasoning"]
|
||||
if row["reasoning_content"] is not None:
|
||||
msg["reasoning_content"] = row["reasoning_content"]
|
||||
if row["reasoning_details"]:
|
||||
try:
|
||||
msg["reasoning_details"] = json.loads(row["reasoning_details"])
|
||||
@@ -1441,3 +1476,116 @@ class SessionDB:
|
||||
return len(session_ids)
|
||||
|
||||
return self._execute_write(_do)
|
||||
|
||||
# ── Meta key/value (for scheduler bookkeeping) ──
|
||||
|
||||
def get_meta(self, key: str) -> Optional[str]:
|
||||
"""Read a value from the state_meta key/value store."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT value FROM state_meta WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return row["value"] if isinstance(row, sqlite3.Row) else row[0]
|
||||
|
||||
def set_meta(self, key: str, value: str) -> None:
|
||||
"""Write a value to the state_meta key/value store."""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
# ── Space reclamation ──
|
||||
|
||||
def vacuum(self) -> None:
|
||||
"""Run VACUUM to reclaim disk space after large deletes.
|
||||
|
||||
SQLite does not shrink the database file when rows are deleted —
|
||||
freed pages just get reused on the next insert. After a prune that
|
||||
removed hundreds of sessions, the file stays bloated unless we
|
||||
explicitly VACUUM.
|
||||
|
||||
VACUUM rewrites the entire DB, so it's expensive (seconds per
|
||||
100MB) and cannot run inside a transaction. It also acquires an
|
||||
exclusive lock, so callers must ensure no other writers are
|
||||
active. Safe to call at startup before the gateway/CLI starts
|
||||
serving traffic.
|
||||
"""
|
||||
# VACUUM cannot be executed inside a transaction.
|
||||
with self._lock:
|
||||
# Best-effort WAL checkpoint first, then VACUUM.
|
||||
try:
|
||||
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except Exception:
|
||||
pass
|
||||
self._conn.execute("VACUUM")
|
||||
|
||||
def maybe_auto_prune_and_vacuum(
|
||||
self,
|
||||
retention_days: int = 90,
|
||||
min_interval_hours: int = 24,
|
||||
vacuum: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Idempotent auto-maintenance: prune old sessions + optional VACUUM.
|
||||
|
||||
Records the last run timestamp in state_meta so subsequent calls
|
||||
within ``min_interval_hours`` no-op. Designed to be called once at
|
||||
startup from long-lived entrypoints (CLI, gateway, cron scheduler).
|
||||
|
||||
Never raises. On any failure, logs a warning and returns a dict
|
||||
with ``"error"`` set.
|
||||
|
||||
Returns a dict with keys:
|
||||
- ``"skipped"`` (bool) — true if within min_interval_hours of last run
|
||||
- ``"pruned"`` (int) — number of sessions deleted
|
||||
- ``"vacuumed"`` (bool) — true if VACUUM ran
|
||||
- ``"error"`` (str, optional) — present only on failure
|
||||
"""
|
||||
result: Dict[str, Any] = {"skipped": False, "pruned": 0, "vacuumed": False}
|
||||
try:
|
||||
# Skip if another process/call did maintenance recently.
|
||||
last_raw = self.get_meta("last_auto_prune")
|
||||
now = time.time()
|
||||
if last_raw:
|
||||
try:
|
||||
last_ts = float(last_raw)
|
||||
if now - last_ts < min_interval_hours * 3600:
|
||||
result["skipped"] = True
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
pass # corrupt meta; treat as no prior run
|
||||
|
||||
pruned = self.prune_sessions(older_than_days=retention_days)
|
||||
result["pruned"] = pruned
|
||||
|
||||
# Only VACUUM if we actually freed rows — VACUUM on a tight DB
|
||||
# is wasted I/O. Threshold keeps small DBs from paying the cost.
|
||||
if vacuum and pruned > 0:
|
||||
try:
|
||||
self.vacuum()
|
||||
result["vacuumed"] = True
|
||||
except Exception as exc:
|
||||
logger.warning("state.db VACUUM failed: %s", exc)
|
||||
|
||||
# Record the attempt even if pruned == 0, so we don't retry
|
||||
# every startup within the min_interval_hours window.
|
||||
self.set_meta("last_auto_prune", str(now))
|
||||
|
||||
if pruned > 0:
|
||||
logger.info(
|
||||
"state.db auto-maintenance: pruned %d session(s) older than %d days%s",
|
||||
pruned,
|
||||
retention_days,
|
||||
" + VACUUM" if result["vacuumed"] else "",
|
||||
)
|
||||
except Exception as exc:
|
||||
# Maintenance must never block startup. Log and return error marker.
|
||||
logger.warning("state.db auto-maintenance failed: %s", exc)
|
||||
result["error"] = str(exc)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
+8
-2
@@ -108,9 +108,15 @@ def _run_async(coro):
|
||||
if loop and loop.is_running():
|
||||
# Inside an async context (gateway, RL env) — run in a fresh thread.
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, coro)
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
future = pool.submit(asyncio.run, coro)
|
||||
try:
|
||||
return future.result(timeout=300)
|
||||
except concurrent.futures.TimeoutError:
|
||||
future.cancel()
|
||||
raise
|
||||
finally:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
# If we're on a worker thread (e.g., parallel tool execution in
|
||||
# delegate_task), use a per-thread persistent loop. This avoids
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Web Development
|
||||
|
||||
Optional skills for client-side web development workflows — embedding agents, copilots, and AI-native UX patterns into user-facing web apps.
|
||||
|
||||
These are distinct from Hermes' own browser automation (Browserbase, Camofox), which operate *on* websites from outside. Web-development skills here help users build *into* their own websites.
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
name: page-agent
|
||||
description: Embed alibaba/page-agent into your own web application — a pure-JavaScript in-page GUI agent that ships as a single <script> tag or npm package and lets end-users of your site drive the UI with natural language ("click login, fill username as John"). No Python, no headless browser, no extension required. Use this skill when the user is a web developer who wants to add an AI copilot to their SaaS / admin panel / B2B tool, make a legacy web app accessible via natural language, or evaluate page-agent against a local (Ollama) or cloud (Qwen / OpenAI / OpenRouter) LLM. NOT for server-side browser automation — point those users to Hermes' built-in browser tool instead.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [web, javascript, agent, browser, gui, alibaba, embed, copilot, saas]
|
||||
category: web-development
|
||||
---
|
||||
|
||||
# page-agent
|
||||
|
||||
alibaba/page-agent (https://github.com/alibaba/page-agent, 17k+ stars, MIT) is an in-page GUI agent written in TypeScript. It lives inside a webpage, reads the DOM as text (no screenshots, no multi-modal LLM), and executes natural-language instructions like "click the login button, then fill username as John" against the current page. Pure client-side — the host site just includes a script and passes an OpenAI-compatible LLM endpoint.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Load this skill when a user wants to:
|
||||
|
||||
- **Ship an AI copilot inside their own web app** (SaaS, admin panel, B2B tool, ERP, CRM) — "users on my dashboard should be able to type 'create invoice for Acme Corp and email it' instead of clicking through five screens"
|
||||
- **Modernize a legacy web app** without rewriting the frontend — page-agent drops on top of existing DOM
|
||||
- **Add accessibility via natural language** — voice / screen-reader users drive the UI by describing what they want
|
||||
- **Demo or evaluate page-agent** against a local (Ollama) or hosted (Qwen, OpenAI, OpenRouter) LLM
|
||||
- **Build interactive training / product demos** — let an AI walk a user through "how to submit an expense report" live in the real UI
|
||||
|
||||
## When NOT to use this skill
|
||||
|
||||
- User wants **Hermes itself to drive a browser** → use Hermes' built-in browser tool (Browserbase / Camofox). page-agent is the *opposite* direction.
|
||||
- User wants **cross-tab automation without embedding** → use Playwright, browser-use, or the page-agent Chrome extension
|
||||
- User needs **visual grounding / screenshots** → page-agent is text-DOM only; use a multimodal browser agent instead
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node 22.13+ or 24+, npm 10+ (docs claim 11+ but 10.9 works fine)
|
||||
- An OpenAI-compatible LLM endpoint: Qwen (DashScope), OpenAI, Ollama, OpenRouter, or anything speaking `/v1/chat/completions`
|
||||
- Browser with devtools (for debugging)
|
||||
|
||||
## Path 1 — 30-second demo via CDN (no install)
|
||||
|
||||
Fastest way to see it work. Uses alibaba's free testing LLM proxy — **for evaluation only**, subject to their terms.
|
||||
|
||||
Add to any HTML page (or paste into the devtools console as a bookmarklet):
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/page-agent@1.8.0/dist/iife/page-agent.demo.js" crossorigin="true"></script>
|
||||
```
|
||||
|
||||
A panel appears. Type an instruction. Done.
|
||||
|
||||
Bookmarklet form (drop into bookmarks bar, click on any page):
|
||||
|
||||
```javascript
|
||||
javascript:(function(){var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/page-agent@1.8.0/dist/iife/page-agent.demo.js';document.head.appendChild(s);})();
|
||||
```
|
||||
|
||||
## Path 2 — npm install into your own web app (production use)
|
||||
|
||||
Inside an existing web project (React / Vue / Svelte / plain):
|
||||
|
||||
```bash
|
||||
npm install page-agent
|
||||
```
|
||||
|
||||
Wire it up with your own LLM endpoint — **never ship the demo CDN to real users**:
|
||||
|
||||
```javascript
|
||||
import { PageAgent } from 'page-agent'
|
||||
|
||||
const agent = new PageAgent({
|
||||
model: 'qwen3.5-plus',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
apiKey: process.env.LLM_API_KEY, // never hardcode
|
||||
language: 'en-US',
|
||||
})
|
||||
|
||||
// Show the panel for end users:
|
||||
agent.panel.show()
|
||||
|
||||
// Or drive it programmatically:
|
||||
await agent.execute('Click submit button, then fill username as John')
|
||||
```
|
||||
|
||||
Provider examples (any OpenAI-compatible endpoint works):
|
||||
|
||||
| Provider | `baseURL` | `model` |
|
||||
|----------|-----------|---------|
|
||||
| Qwen / DashScope | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen3.5-plus` |
|
||||
| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` |
|
||||
| Ollama (local) | `http://localhost:11434/v1` | `qwen3:14b` |
|
||||
| OpenRouter | `https://openrouter.ai/api/v1` | `anthropic/claude-sonnet-4.6` |
|
||||
|
||||
**Key config fields** (passed to `new PageAgent({...})`):
|
||||
|
||||
- `model`, `baseURL`, `apiKey` — LLM connection
|
||||
- `language` — UI language (`en-US`, `zh-CN`, etc.)
|
||||
- Allowlist and data-masking hooks exist for locking down what the agent can touch — see https://alibaba.github.io/page-agent/ for the full option list
|
||||
|
||||
**Security.** Don't put your `apiKey` in client-side code for a real deployment — proxy LLM calls through your backend and point `baseURL` at your proxy. The demo CDN exists because alibaba runs that proxy for evaluation.
|
||||
|
||||
## Path 3 — clone the source repo (contributing, or hacking on it)
|
||||
|
||||
Use this when the user wants to modify page-agent itself, test it against arbitrary sites via a local IIFE bundle, or develop the browser extension.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/alibaba/page-agent.git
|
||||
cd page-agent
|
||||
npm ci # exact lockfile install (or `npm i` to allow updates)
|
||||
```
|
||||
|
||||
Create `.env` in the repo root with an LLM endpoint. Example:
|
||||
|
||||
```
|
||||
LLM_MODEL_NAME=gpt-4o-mini
|
||||
LLM_API_KEY=sk-...
|
||||
LLM_BASE_URL=https://api.openai.com/v1
|
||||
```
|
||||
|
||||
Ollama flavor:
|
||||
|
||||
```
|
||||
LLM_BASE_URL=http://localhost:11434/v1
|
||||
LLM_API_KEY=NA
|
||||
LLM_MODEL_NAME=qwen3:14b
|
||||
```
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
npm start # docs/website dev server
|
||||
npm run build # build every package
|
||||
npm run dev:demo # serve IIFE bundle at http://localhost:5174/page-agent.demo.js
|
||||
npm run dev:ext # develop the browser extension (WXT + React)
|
||||
npm run build:ext # build the extension
|
||||
```
|
||||
|
||||
**Test on any website** using the local IIFE bundle. Add this bookmarklet:
|
||||
|
||||
```javascript
|
||||
javascript:(function(){var s=document.createElement('script');s.src=`http://localhost:5174/page-agent.demo.js?t=${Math.random()}`;s.onload=()=>console.log('PageAgent ready!');document.head.appendChild(s);})();
|
||||
```
|
||||
|
||||
Then: `npm run dev:demo`, click the bookmarklet on any page, and the local build injects. Auto-rebuilds on save.
|
||||
|
||||
**Warning:** your `.env` `LLM_API_KEY` is inlined into the IIFE bundle during dev builds. Don't share the bundle. Don't commit it. Don't paste the URL into Slack. (Verified: grepping the public dev bundle returns the literal values from `.env`.)
|
||||
|
||||
## Repo layout (Path 3)
|
||||
|
||||
Monorepo with npm workspaces. Key packages:
|
||||
|
||||
| Package | Path | Purpose |
|
||||
|---------|------|---------|
|
||||
| `page-agent` | `packages/page-agent/` | Main entry with UI panel |
|
||||
| `@page-agent/core` | `packages/core/` | Core agent logic, no UI |
|
||||
| `@page-agent/mcp` | `packages/mcp/` | MCP server (beta) |
|
||||
| — | `packages/llms/` | LLM client |
|
||||
| — | `packages/page-controller/` | DOM ops + visual feedback |
|
||||
| — | `packages/ui/` | Panel + i18n |
|
||||
| — | `packages/extension/` | Chrome/Firefox extension |
|
||||
| — | `packages/website/` | Docs + landing site |
|
||||
|
||||
## Verifying it works
|
||||
|
||||
After Path 1 or Path 2:
|
||||
1. Open the page in a browser with devtools open
|
||||
2. You should see a floating panel. If not, check the console for errors (most common: CORS on the LLM endpoint, wrong `baseURL`, or a bad API key)
|
||||
3. Type a simple instruction matching something visible on the page ("click the Login link")
|
||||
4. Watch the Network tab — you should see a request to your `baseURL`
|
||||
|
||||
After Path 3:
|
||||
1. `npm run dev:demo` prints `Accepting connections at http://localhost:5174`
|
||||
2. `curl -I http://localhost:5174/page-agent.demo.js` returns `HTTP/1.1 200 OK` with `Content-Type: application/javascript`
|
||||
3. Click the bookmarklet on any site; panel appears
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Demo CDN in production** — don't. It's rate-limited, uses alibaba's free proxy, and their terms forbid production use.
|
||||
- **API key exposure** — any key passed to `new PageAgent({apiKey: ...})` ships in your JS bundle. Always proxy through your own backend for real deployments.
|
||||
- **Non-OpenAI-compatible endpoints** fail silently or with cryptic errors. If your provider needs native Anthropic/Gemini formatting, use an OpenAI-compatibility proxy (LiteLLM, OpenRouter) in front.
|
||||
- **CSP blocks** — sites with strict Content-Security-Policy may refuse to load the CDN script or disallow inline eval. In that case, self-host from your origin.
|
||||
- **Restart dev server** after editing `.env` in Path 3 — Vite only reads env at startup.
|
||||
- **Node version** — the repo declares `^22.13.0 || >=24`. Node 20 will fail `npm ci` with engine errors.
|
||||
- **npm 10 vs 11** — docs say npm 11+; npm 10.9 actually works fine.
|
||||
|
||||
## Reference
|
||||
|
||||
- Repo: https://github.com/alibaba/page-agent
|
||||
- Docs: https://alibaba.github.io/page-agent/
|
||||
- License: MIT (built on browser-use's DOM processing internals, Copyright 2024 Gregor Zunic)
|
||||
Generated
+4
-3347
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -16,8 +16,8 @@
|
||||
},
|
||||
"homepage": "https://github.com/NousResearch/Hermes-Agent#readme",
|
||||
"dependencies": {
|
||||
"agent-browser": "^0.13.0",
|
||||
"@askjo/camofox-browser": "^1.5.2"
|
||||
"@askjo/camofox-browser": "^1.5.2",
|
||||
"agent-browser": "^0.26.0"
|
||||
},
|
||||
"overrides": {
|
||||
"lodash": "4.18.1"
|
||||
|
||||
@@ -84,7 +84,10 @@ Config file: `~/.hermes/hindsight/config.json`
|
||||
| `retain_async` | `true` | Process retain asynchronously on the Hindsight server |
|
||||
| `retain_every_n_turns` | `1` | Retain every N turns (1 = every turn) |
|
||||
| `retain_context` | `conversation between Hermes Agent and the User` | Context label for retained memories |
|
||||
| `tags` | — | Tags applied when storing memories |
|
||||
| `retain_tags` | — | Default tags applied to retained memories; merged with per-call tool tags |
|
||||
| `retain_source` | — | Optional `metadata.source` attached to retained memories |
|
||||
| `retain_user_prefix` | `User` | Label used before user turns in auto-retained transcripts |
|
||||
| `retain_assistant_prefix` | `Assistant` | Label used before assistant turns in auto-retained transcripts |
|
||||
|
||||
### Integration
|
||||
|
||||
@@ -113,7 +116,7 @@ Available in `hybrid` and `tools` memory modes:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `hindsight_retain` | Store information with auto entity extraction |
|
||||
| `hindsight_retain` | Store information with auto entity extraction; supports optional per-call `tags` |
|
||||
| `hindsight_recall` | Multi-strategy search (semantic + entity graph) |
|
||||
| `hindsight_reflect` | Cross-memory synthesis (LLM-powered) |
|
||||
|
||||
|
||||
@@ -6,11 +6,15 @@ retrieval. Supports cloud (API key) and local modes.
|
||||
Original PR #1811 by benfrank241, adapted to MemoryProvider ABC.
|
||||
|
||||
Config via environment variables:
|
||||
HINDSIGHT_API_KEY — API key for Hindsight Cloud
|
||||
HINDSIGHT_BANK_ID — memory bank identifier (default: hermes)
|
||||
HINDSIGHT_BUDGET — recall budget: low/mid/high (default: mid)
|
||||
HINDSIGHT_API_URL — API endpoint
|
||||
HINDSIGHT_MODE — cloud or local (default: cloud)
|
||||
HINDSIGHT_API_KEY — API key for Hindsight Cloud
|
||||
HINDSIGHT_BANK_ID — memory bank identifier (default: hermes)
|
||||
HINDSIGHT_BUDGET — recall budget: low/mid/high (default: mid)
|
||||
HINDSIGHT_API_URL — API endpoint
|
||||
HINDSIGHT_MODE — cloud or local (default: cloud)
|
||||
HINDSIGHT_RETAIN_TAGS — comma-separated tags attached to retained memories
|
||||
HINDSIGHT_RETAIN_SOURCE — metadata source value attached to retained memories
|
||||
HINDSIGHT_RETAIN_USER_PREFIX — label used before user turns in retained transcripts
|
||||
HINDSIGHT_RETAIN_ASSISTANT_PREFIX — label used before assistant turns in retained transcripts
|
||||
|
||||
Or via $HERMES_HOME/hindsight/config.json (profile-scoped), falling back to
|
||||
~/.hindsight/config.json (legacy, shared) for backward compatibility.
|
||||
@@ -24,7 +28,7 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
@@ -99,6 +103,11 @@ RETAIN_SCHEMA = {
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "The information to store."},
|
||||
"context": {"type": "string", "description": "Short label (e.g. 'user preference', 'project decision')."},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional per-call tags to merge with configured default retain tags.",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
@@ -168,6 +177,10 @@ def _load_config() -> dict:
|
||||
return {
|
||||
"mode": os.environ.get("HINDSIGHT_MODE", "cloud"),
|
||||
"apiKey": os.environ.get("HINDSIGHT_API_KEY", ""),
|
||||
"retain_tags": os.environ.get("HINDSIGHT_RETAIN_TAGS", ""),
|
||||
"retain_source": os.environ.get("HINDSIGHT_RETAIN_SOURCE", ""),
|
||||
"retain_user_prefix": os.environ.get("HINDSIGHT_RETAIN_USER_PREFIX", "User"),
|
||||
"retain_assistant_prefix": os.environ.get("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant"),
|
||||
"banks": {
|
||||
"hermes": {
|
||||
"bankId": os.environ.get("HINDSIGHT_BANK_ID", "hermes"),
|
||||
@@ -178,6 +191,48 @@ def _load_config() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _normalize_retain_tags(value: Any) -> List[str]:
|
||||
"""Normalize tag config/tool values to a deduplicated list of strings."""
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
raw_items: list[Any]
|
||||
if isinstance(value, list):
|
||||
raw_items = value
|
||||
elif isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return []
|
||||
if text.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except Exception:
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
raw_items = parsed
|
||||
else:
|
||||
raw_items = text.split(",")
|
||||
else:
|
||||
raw_items = text.split(",")
|
||||
else:
|
||||
raw_items = [value]
|
||||
|
||||
normalized = []
|
||||
seen = set()
|
||||
for item in raw_items:
|
||||
tag = str(item).strip()
|
||||
if not tag or tag in seen:
|
||||
continue
|
||||
seen.add(tag)
|
||||
normalized.append(tag)
|
||||
return normalized
|
||||
|
||||
|
||||
def _utc_timestamp() -> str:
|
||||
"""Return current UTC timestamp in ISO-8601 with milliseconds and Z suffix."""
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryProvider implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -195,6 +250,19 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
self._llm_base_url = ""
|
||||
self._memory_mode = "hybrid" # "context", "tools", or "hybrid"
|
||||
self._prefetch_method = "recall" # "recall" or "reflect"
|
||||
self._retain_tags: List[str] = []
|
||||
self._retain_source = ""
|
||||
self._retain_user_prefix = "User"
|
||||
self._retain_assistant_prefix = "Assistant"
|
||||
self._platform = ""
|
||||
self._user_id = ""
|
||||
self._user_name = ""
|
||||
self._chat_id = ""
|
||||
self._chat_name = ""
|
||||
self._chat_type = ""
|
||||
self._thread_id = ""
|
||||
self._agent_identity = ""
|
||||
self._turn_index = 0
|
||||
self._client = None
|
||||
self._prefetch_result = ""
|
||||
self._prefetch_lock = threading.Lock()
|
||||
@@ -210,6 +278,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
# Retain controls
|
||||
self._auto_retain = True
|
||||
self._retain_every_n_turns = 1
|
||||
self._retain_async = True
|
||||
self._retain_context = "conversation between Hermes Agent and the User"
|
||||
self._turn_counter = 0
|
||||
self._session_turns: list[str] = [] # accumulates ALL turns for the session
|
||||
@@ -224,7 +293,6 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
# Bank
|
||||
self._bank_mission = ""
|
||||
self._bank_retain_mission: str | None = None
|
||||
self._retain_async = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -423,7 +491,10 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
{"key": "recall_budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]},
|
||||
{"key": "memory_mode", "description": "Memory integration mode", "default": "hybrid", "choices": ["hybrid", "context", "tools"]},
|
||||
{"key": "recall_prefetch_method", "description": "Auto-recall method", "default": "recall", "choices": ["recall", "reflect"]},
|
||||
{"key": "tags", "description": "Tags applied when storing memories (comma-separated)", "default": ""},
|
||||
{"key": "retain_tags", "description": "Default tags applied to retained memories (comma-separated)", "default": ""},
|
||||
{"key": "retain_source", "description": "Metadata source value attached to retained memories", "default": ""},
|
||||
{"key": "retain_user_prefix", "description": "Label used before user turns in retained transcripts", "default": "User"},
|
||||
{"key": "retain_assistant_prefix", "description": "Label used before assistant turns in retained transcripts", "default": "Assistant"},
|
||||
{"key": "recall_tags", "description": "Tags to filter when searching memories (comma-separated)", "default": ""},
|
||||
{"key": "recall_tags_match", "description": "Tag matching mode for recall", "default": "any", "choices": ["any", "all", "any_strict", "all_strict"]},
|
||||
{"key": "auto_recall", "description": "Automatically recall memories before each turn", "default": True},
|
||||
@@ -467,7 +538,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
return self._client
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
self._session_id = session_id
|
||||
self._session_id = str(session_id or "").strip()
|
||||
|
||||
# Check client version and auto-upgrade if needed
|
||||
try:
|
||||
@@ -496,6 +567,16 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
pass # packaging not available or other issue — proceed anyway
|
||||
|
||||
self._config = _load_config()
|
||||
self._platform = str(kwargs.get("platform") or "").strip()
|
||||
self._user_id = str(kwargs.get("user_id") or "").strip()
|
||||
self._user_name = str(kwargs.get("user_name") or "").strip()
|
||||
self._chat_id = str(kwargs.get("chat_id") or "").strip()
|
||||
self._chat_name = str(kwargs.get("chat_name") or "").strip()
|
||||
self._chat_type = str(kwargs.get("chat_type") or "").strip()
|
||||
self._thread_id = str(kwargs.get("thread_id") or "").strip()
|
||||
self._agent_identity = str(kwargs.get("agent_identity") or "").strip()
|
||||
self._turn_index = 0
|
||||
self._session_turns = []
|
||||
self._mode = self._config.get("mode", "cloud")
|
||||
# "local" is a legacy alias for "local_embedded"
|
||||
if self._mode == "local":
|
||||
@@ -513,7 +594,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
memory_mode = self._config.get("memory_mode", "hybrid")
|
||||
self._memory_mode = memory_mode if memory_mode in ("context", "tools", "hybrid") else "hybrid"
|
||||
|
||||
prefetch_method = self._config.get("recall_prefetch_method", "recall")
|
||||
prefetch_method = self._config.get("recall_prefetch_method") or self._config.get("prefetch_method", "recall")
|
||||
self._prefetch_method = prefetch_method if prefetch_method in ("recall", "reflect") else "recall"
|
||||
|
||||
# Bank options
|
||||
@@ -521,9 +602,22 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
self._bank_retain_mission = self._config.get("bank_retain_mission") or None
|
||||
|
||||
# Tags
|
||||
self._tags = self._config.get("tags") or None
|
||||
self._retain_tags = _normalize_retain_tags(
|
||||
self._config.get("retain_tags")
|
||||
or os.environ.get("HINDSIGHT_RETAIN_TAGS", "")
|
||||
)
|
||||
self._tags = self._retain_tags or None
|
||||
self._recall_tags = self._config.get("recall_tags") or None
|
||||
self._recall_tags_match = self._config.get("recall_tags_match", "any")
|
||||
self._retain_source = str(
|
||||
self._config.get("retain_source") or os.environ.get("HINDSIGHT_RETAIN_SOURCE", "")
|
||||
).strip()
|
||||
self._retain_user_prefix = str(
|
||||
self._config.get("retain_user_prefix") or os.environ.get("HINDSIGHT_RETAIN_USER_PREFIX", "User")
|
||||
).strip() or "User"
|
||||
self._retain_assistant_prefix = str(
|
||||
self._config.get("retain_assistant_prefix") or os.environ.get("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant")
|
||||
).strip() or "Assistant"
|
||||
|
||||
# Retain controls
|
||||
self._auto_retain = self._config.get("auto_retain", True)
|
||||
@@ -547,11 +641,9 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
logger.info("Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s, client=%s",
|
||||
self._mode, self._api_url, self._bank_id, self._budget, self._memory_mode, self._prefetch_method, _client_version)
|
||||
logger.debug("Hindsight config: auto_retain=%s, auto_recall=%s, retain_every_n=%d, "
|
||||
"retain_async=%s, retain_context=%s, "
|
||||
"recall_max_tokens=%d, recall_max_input_chars=%d, tags=%s, recall_tags=%s",
|
||||
"retain_async=%s, retain_context=%s, recall_max_tokens=%d, recall_max_input_chars=%d, tags=%s, recall_tags=%s",
|
||||
self._auto_retain, self._auto_recall, self._retain_every_n_turns,
|
||||
self._retain_async, self._retain_context,
|
||||
self._recall_max_tokens, self._recall_max_input_chars,
|
||||
self._retain_async, self._retain_context, self._recall_max_tokens, self._recall_max_input_chars,
|
||||
self._tags, self._recall_tags)
|
||||
|
||||
# For local mode, start the embedded daemon in the background so it
|
||||
@@ -712,6 +804,78 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
self._prefetch_thread = threading.Thread(target=_run, daemon=True, name="hindsight-prefetch")
|
||||
self._prefetch_thread.start()
|
||||
|
||||
def _build_turn_messages(self, user_content: str, assistant_content: str) -> List[Dict[str, str]]:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{self._retain_user_prefix}: {user_content}",
|
||||
"timestamp": now,
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": f"{self._retain_assistant_prefix}: {assistant_content}",
|
||||
"timestamp": now,
|
||||
},
|
||||
]
|
||||
|
||||
def _build_metadata(self, *, message_count: int, turn_index: int) -> Dict[str, str]:
|
||||
metadata: Dict[str, str] = {
|
||||
"retained_at": _utc_timestamp(),
|
||||
"message_count": str(message_count),
|
||||
"turn_index": str(turn_index),
|
||||
}
|
||||
if self._retain_source:
|
||||
metadata["source"] = self._retain_source
|
||||
if self._session_id:
|
||||
metadata["session_id"] = self._session_id
|
||||
if self._platform:
|
||||
metadata["platform"] = self._platform
|
||||
if self._user_id:
|
||||
metadata["user_id"] = self._user_id
|
||||
if self._user_name:
|
||||
metadata["user_name"] = self._user_name
|
||||
if self._chat_id:
|
||||
metadata["chat_id"] = self._chat_id
|
||||
if self._chat_name:
|
||||
metadata["chat_name"] = self._chat_name
|
||||
if self._chat_type:
|
||||
metadata["chat_type"] = self._chat_type
|
||||
if self._thread_id:
|
||||
metadata["thread_id"] = self._thread_id
|
||||
if self._agent_identity:
|
||||
metadata["agent_identity"] = self._agent_identity
|
||||
return metadata
|
||||
|
||||
def _build_retain_kwargs(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
context: str | None = None,
|
||||
document_id: str | None = None,
|
||||
metadata: Dict[str, str] | None = None,
|
||||
tags: List[str] | None = None,
|
||||
retain_async: bool | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
kwargs: Dict[str, Any] = {
|
||||
"bank_id": self._bank_id,
|
||||
"content": content,
|
||||
"metadata": metadata or self._build_metadata(message_count=1, turn_index=self._turn_index),
|
||||
}
|
||||
if context is not None:
|
||||
kwargs["context"] = context
|
||||
if document_id:
|
||||
kwargs["document_id"] = document_id
|
||||
if retain_async is not None:
|
||||
kwargs["retain_async"] = retain_async
|
||||
merged_tags = _normalize_retain_tags(self._retain_tags)
|
||||
for tag in _normalize_retain_tags(tags):
|
||||
if tag not in merged_tags:
|
||||
merged_tags.append(tag)
|
||||
if merged_tags:
|
||||
kwargs["tags"] = merged_tags
|
||||
return kwargs
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
"""Retain conversation turn in background (non-blocking).
|
||||
|
||||
@@ -721,19 +885,14 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
logger.debug("sync_turn: skipped (auto_retain disabled)")
|
||||
return
|
||||
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
if session_id:
|
||||
self._session_id = str(session_id).strip()
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": user_content, "timestamp": now},
|
||||
{"role": "assistant", "content": assistant_content, "timestamp": now},
|
||||
]
|
||||
|
||||
turn = json.dumps(messages)
|
||||
turn = json.dumps(self._build_turn_messages(user_content, assistant_content))
|
||||
self._session_turns.append(turn)
|
||||
self._turn_counter += 1
|
||||
self._turn_index = self._turn_counter
|
||||
|
||||
# Only retain every N turns
|
||||
if self._turn_counter % self._retain_every_n_turns != 0:
|
||||
logger.debug("sync_turn: buffered turn %d (will retain at turn %d)",
|
||||
self._turn_counter, self._turn_counter + (self._retain_every_n_turns - self._turn_counter % self._retain_every_n_turns))
|
||||
@@ -741,19 +900,21 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
|
||||
logger.debug("sync_turn: retaining %d turns, total session content %d chars",
|
||||
len(self._session_turns), sum(len(t) for t in self._session_turns))
|
||||
# Send the ENTIRE session as a single JSON array (document_id deduplicates).
|
||||
# Each element in _session_turns is a JSON string of that turn's messages.
|
||||
content = "[" + ",".join(self._session_turns) + "]"
|
||||
|
||||
def _sync():
|
||||
try:
|
||||
client = self._get_client()
|
||||
item: dict = {
|
||||
"content": content,
|
||||
"context": self._retain_context,
|
||||
}
|
||||
if self._tags:
|
||||
item["tags"] = self._tags
|
||||
item = self._build_retain_kwargs(
|
||||
content,
|
||||
context=self._retain_context,
|
||||
metadata=self._build_metadata(
|
||||
message_count=len(self._session_turns) * 2,
|
||||
turn_index=self._turn_index,
|
||||
),
|
||||
)
|
||||
item.pop("bank_id", None)
|
||||
item.pop("retain_async", None)
|
||||
logger.debug("Hindsight retain: bank=%s, doc=%s, async=%s, content_len=%d, num_turns=%d",
|
||||
self._bank_id, self._session_id, self._retain_async, len(content), len(self._session_turns))
|
||||
_run_sync(client.aretain_batch(
|
||||
@@ -789,11 +950,11 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
return tool_error("Missing required parameter: content")
|
||||
context = args.get("context")
|
||||
try:
|
||||
retain_kwargs: dict = {
|
||||
"bank_id": self._bank_id, "content": content, "context": context,
|
||||
}
|
||||
if self._tags:
|
||||
retain_kwargs["tags"] = self._tags
|
||||
retain_kwargs = self._build_retain_kwargs(
|
||||
content,
|
||||
context=context,
|
||||
tags=args.get("tags"),
|
||||
)
|
||||
logger.debug("Tool hindsight_retain: bank=%s, content_len=%d, context=%s",
|
||||
self._bank_id, len(content), context)
|
||||
_run_sync(client.aretain(**retain_kwargs))
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector
|
||||
hermes_cli = ["web_dist/**/*"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
+195
-19
@@ -751,6 +751,11 @@ class AIAgent:
|
||||
prefill_messages: List[Dict[str, Any]] = None,
|
||||
platform: str = None,
|
||||
user_id: str = None,
|
||||
user_name: str = None,
|
||||
chat_id: str = None,
|
||||
chat_name: str = None,
|
||||
chat_type: str = None,
|
||||
thread_id: str = None,
|
||||
gateway_session_key: str = None,
|
||||
skip_context_files: bool = False,
|
||||
skip_memory: bool = False,
|
||||
@@ -820,6 +825,11 @@ class AIAgent:
|
||||
self.ephemeral_system_prompt = ephemeral_system_prompt
|
||||
self.platform = platform # "cli", "telegram", "discord", "whatsapp", etc.
|
||||
self._user_id = user_id # Platform user identifier (gateway sessions)
|
||||
self._user_name = user_name
|
||||
self._chat_id = chat_id
|
||||
self._chat_name = chat_name
|
||||
self._chat_type = chat_type
|
||||
self._thread_id = thread_id
|
||||
self._gateway_session_key = gateway_session_key # Stable per-chat key (e.g. agent:main:telegram:dm:123)
|
||||
# Pluggable print function — CLI replaces this with _cprint so that
|
||||
# raw ANSI status lines are routed through prompt_toolkit's renderer
|
||||
@@ -1471,6 +1481,16 @@ class AIAgent:
|
||||
# Thread gateway user identity for per-user memory scoping
|
||||
if self._user_id:
|
||||
_init_kwargs["user_id"] = self._user_id
|
||||
if self._user_name:
|
||||
_init_kwargs["user_name"] = self._user_name
|
||||
if self._chat_id:
|
||||
_init_kwargs["chat_id"] = self._chat_id
|
||||
if self._chat_name:
|
||||
_init_kwargs["chat_name"] = self._chat_name
|
||||
if self._chat_type:
|
||||
_init_kwargs["chat_type"] = self._chat_type
|
||||
if self._thread_id:
|
||||
_init_kwargs["thread_id"] = self._thread_id
|
||||
# Thread gateway session key for stable per-chat Honcho session isolation
|
||||
if self._gateway_session_key:
|
||||
_init_kwargs["gateway_session_key"] = self._gateway_session_key
|
||||
@@ -2966,6 +2986,7 @@ class AIAgent:
|
||||
tool_call_id=msg.get("tool_call_id"),
|
||||
finish_reason=msg.get("finish_reason"),
|
||||
reasoning=msg.get("reasoning") if role == "assistant" else None,
|
||||
reasoning_content=msg.get("reasoning_content") if role == "assistant" else None,
|
||||
reasoning_details=msg.get("reasoning_details") if role == "assistant" else None,
|
||||
codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None,
|
||||
)
|
||||
@@ -5805,16 +5826,6 @@ class AIAgent:
|
||||
result["response"] = _call_chat_completions()
|
||||
return # success
|
||||
except Exception as e:
|
||||
if deltas_were_sent["yes"]:
|
||||
# Streaming failed AFTER some tokens were already
|
||||
# delivered. Don't retry or fall back — partial
|
||||
# content already reached the user.
|
||||
logger.warning(
|
||||
"Streaming failed after partial delivery, not retrying: %s", e
|
||||
)
|
||||
result["error"] = e
|
||||
return
|
||||
|
||||
_is_timeout = isinstance(
|
||||
e, (_httpx.ReadTimeout, _httpx.ConnectTimeout, _httpx.PoolTimeout)
|
||||
)
|
||||
@@ -5822,6 +5833,123 @@ class AIAgent:
|
||||
e, (_httpx.ConnectError, _httpx.RemoteProtocolError, ConnectionError)
|
||||
)
|
||||
|
||||
# If the stream died AFTER some tokens were delivered:
|
||||
# normally we don't retry (the user already saw text,
|
||||
# retrying would duplicate it). BUT: if a tool call
|
||||
# was in-flight when the stream died, silently aborting
|
||||
# discards the tool call entirely. In that case we
|
||||
# prefer to retry — the user sees a brief
|
||||
# "reconnecting" marker + duplicated preamble text,
|
||||
# which is strictly better than a failed action with
|
||||
# a "retry manually" message. Limit this to transient
|
||||
# connection errors (Clawdbot-style narrow gate): no
|
||||
# tool has executed yet within this API call, so
|
||||
# silent retry is safe wrt side-effects.
|
||||
if deltas_were_sent["yes"]:
|
||||
_partial_tool_in_flight = bool(
|
||||
result.get("partial_tool_names")
|
||||
)
|
||||
_is_sse_conn_err_preview = False
|
||||
if not _is_timeout and not _is_conn_err:
|
||||
from openai import APIError as _APIError
|
||||
if isinstance(e, _APIError) and not getattr(e, "status_code", None):
|
||||
_err_lower_preview = str(e).lower()
|
||||
_SSE_PREVIEW_PHRASES = (
|
||||
"connection lost",
|
||||
"connection reset",
|
||||
"connection closed",
|
||||
"connection terminated",
|
||||
"network error",
|
||||
"network connection",
|
||||
"terminated",
|
||||
"peer closed",
|
||||
"broken pipe",
|
||||
"upstream connect error",
|
||||
)
|
||||
_is_sse_conn_err_preview = any(
|
||||
phrase in _err_lower_preview
|
||||
for phrase in _SSE_PREVIEW_PHRASES
|
||||
)
|
||||
_is_transient = (
|
||||
_is_timeout or _is_conn_err or _is_sse_conn_err_preview
|
||||
)
|
||||
_can_silent_retry = (
|
||||
_partial_tool_in_flight
|
||||
and _is_transient
|
||||
and _stream_attempt < _max_stream_retries
|
||||
)
|
||||
if not _can_silent_retry:
|
||||
# Either no tool call was in-flight (so the
|
||||
# turn was a pure text response — current
|
||||
# stub-with-recovered-text behaviour is
|
||||
# correct), or retries are exhausted, or the
|
||||
# error isn't transient. Fall through to the
|
||||
# stub path.
|
||||
logger.warning(
|
||||
"Streaming failed after partial delivery, not retrying: %s", e
|
||||
)
|
||||
result["error"] = e
|
||||
return
|
||||
# Tool call was in-flight AND error is transient:
|
||||
# retry silently. Clear per-attempt state so the
|
||||
# next stream starts clean. Fire a "reconnecting"
|
||||
# marker so the user sees why the preamble is
|
||||
# about to be re-streamed.
|
||||
logger.info(
|
||||
"Streaming attempt %s/%s died mid tool-call "
|
||||
"(%s: %s) after user-visible text; retrying "
|
||||
"silently to avoid losing the action. "
|
||||
"Preamble will re-stream.",
|
||||
_stream_attempt + 1,
|
||||
_max_stream_retries + 1,
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
try:
|
||||
self._fire_stream_delta(
|
||||
"\n\n⚠ Connection dropped mid tool-call; "
|
||||
"reconnecting…\n\n"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Reset the streamed-text buffer so the retry's
|
||||
# fresh preamble doesn't get double-recorded in
|
||||
# _current_streamed_assistant_text (which would
|
||||
# pollute the interim-visible-text comparison).
|
||||
try:
|
||||
self._reset_stream_delivery_tracking()
|
||||
except Exception:
|
||||
pass
|
||||
# Reset in-memory accumulators so the next
|
||||
# attempt's chunks don't concat onto the dead
|
||||
# stream's partial JSON.
|
||||
result["partial_tool_names"] = []
|
||||
deltas_were_sent["yes"] = False
|
||||
first_delta_fired["done"] = False
|
||||
self._emit_status(
|
||||
f"⚠️ Connection dropped mid tool-call "
|
||||
f"({type(e).__name__}). Reconnecting… "
|
||||
f"(attempt {_stream_attempt + 2}/{_max_stream_retries + 1})"
|
||||
)
|
||||
self._touch_activity(
|
||||
f"stream retry {_stream_attempt + 2}/{_max_stream_retries + 1} "
|
||||
f"mid tool-call after {type(e).__name__}"
|
||||
)
|
||||
stale = request_client_holder.get("client")
|
||||
if stale is not None:
|
||||
self._close_request_openai_client(
|
||||
stale, reason="stream_mid_tool_retry_cleanup"
|
||||
)
|
||||
request_client_holder["client"] = None
|
||||
try:
|
||||
self._replace_primary_openai_client(
|
||||
reason="stream_mid_tool_retry_pool_cleanup"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._emit_status("🔄 Reconnected — resuming…")
|
||||
continue
|
||||
|
||||
# SSE error events from proxies (e.g. OpenRouter sends
|
||||
# {"error":{"message":"Network connection lost."}}) are
|
||||
# raised as APIError by the OpenAI SDK. These are
|
||||
@@ -6132,6 +6260,10 @@ class AIAgent:
|
||||
# falling through to OpenRouter defaults.
|
||||
fb_base_url_hint = (fb.get("base_url") or "").strip() or None
|
||||
fb_api_key_hint = (fb.get("api_key") or "").strip() or None
|
||||
if not fb_api_key_hint:
|
||||
fb_key_env = (fb.get("key_env") or "").strip()
|
||||
if fb_key_env:
|
||||
fb_api_key_hint = os.getenv(fb_key_env, "").strip() or None
|
||||
# For Ollama Cloud endpoints, pull OLLAMA_API_KEY from env
|
||||
# when no explicit key is in the fallback config. Host match
|
||||
# (not substring) — see GHSA-76xc-57q6-vm5m.
|
||||
@@ -7003,6 +7135,11 @@ class AIAgent:
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
|
||||
if hasattr(assistant_message, "reasoning_content"):
|
||||
raw_reasoning_content = getattr(assistant_message, "reasoning_content", None)
|
||||
if raw_reasoning_content is not None:
|
||||
msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content)
|
||||
|
||||
if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details:
|
||||
# Pass reasoning_details back unmodified so providers (OpenRouter,
|
||||
# Anthropic, OpenAI) can maintain reasoning continuity across turns.
|
||||
@@ -7077,6 +7214,30 @@ class AIAgent:
|
||||
|
||||
return msg
|
||||
|
||||
def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> None:
|
||||
"""Copy provider-facing reasoning fields onto an API replay message."""
|
||||
if source_msg.get("role") != "assistant":
|
||||
return
|
||||
|
||||
explicit_reasoning = source_msg.get("reasoning_content")
|
||||
if isinstance(explicit_reasoning, str):
|
||||
api_msg["reasoning_content"] = explicit_reasoning
|
||||
return
|
||||
|
||||
normalized_reasoning = source_msg.get("reasoning")
|
||||
if isinstance(normalized_reasoning, str) and normalized_reasoning:
|
||||
api_msg["reasoning_content"] = normalized_reasoning
|
||||
return
|
||||
|
||||
kimi_requires_reasoning = (
|
||||
self.provider in {"kimi-coding", "kimi-coding-cn"}
|
||||
or base_url_host_matches(self.base_url, "api.kimi.com")
|
||||
or base_url_host_matches(self.base_url, "moonshot.ai")
|
||||
or base_url_host_matches(self.base_url, "moonshot.cn")
|
||||
)
|
||||
if kimi_requires_reasoning and source_msg.get("tool_calls"):
|
||||
api_msg["reasoning_content"] = ""
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_tool_calls_for_strict_api(api_msg: dict) -> dict:
|
||||
"""Strip Codex Responses API fields from tool_calls for strict providers.
|
||||
@@ -7160,10 +7321,7 @@ class AIAgent:
|
||||
api_messages = []
|
||||
for msg in messages:
|
||||
api_msg = msg.copy()
|
||||
if msg.get("role") == "assistant":
|
||||
reasoning = msg.get("reasoning")
|
||||
if reasoning:
|
||||
api_msg["reasoning_content"] = reasoning
|
||||
self._copy_reasoning_content_for_api(msg, api_msg)
|
||||
api_msg.pop("reasoning", None)
|
||||
api_msg.pop("finish_reason", None)
|
||||
api_msg.pop("_flush_sentinel", None)
|
||||
@@ -8923,11 +9081,7 @@ class AIAgent:
|
||||
|
||||
# For ALL assistant messages, pass reasoning back to the API
|
||||
# This ensures multi-turn reasoning context is preserved
|
||||
if msg.get("role") == "assistant":
|
||||
reasoning_text = msg.get("reasoning")
|
||||
if reasoning_text:
|
||||
# Add reasoning_content for API compatibility (Moonshot AI, Novita, OpenRouter)
|
||||
api_msg["reasoning_content"] = reasoning_text
|
||||
self._copy_reasoning_content_for_api(msg, api_msg)
|
||||
|
||||
# Remove 'reasoning' field - it's for trajectory storage only
|
||||
# We've copied it to 'reasoning_content' for the API above
|
||||
@@ -9724,6 +9878,7 @@ class AIAgent:
|
||||
billing_mode="subscription_included"
|
||||
if cost_result.status == "included" else None,
|
||||
model=self.model,
|
||||
api_call_count=1,
|
||||
)
|
||||
except Exception:
|
||||
pass # never block the agent loop
|
||||
@@ -10000,6 +10155,27 @@ class AIAgent:
|
||||
if self._try_refresh_nous_client_credentials(force=True):
|
||||
print(f"{self.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...")
|
||||
continue
|
||||
# Credential refresh didn't help — show diagnostic info.
|
||||
# Most common causes: Portal OAuth expired/revoked,
|
||||
# account out of credits, or agent key blocked.
|
||||
from hermes_constants import display_hermes_home as _dhh_fn
|
||||
_dhh = _dhh_fn()
|
||||
_body_text = ""
|
||||
try:
|
||||
_body = getattr(api_error, "body", None) or getattr(api_error, "response", None)
|
||||
if _body is not None:
|
||||
_body_text = str(_body)[:200]
|
||||
except Exception:
|
||||
pass
|
||||
print(f"{self.log_prefix}🔐 Nous 401 — Portal authentication failed.")
|
||||
if _body_text:
|
||||
print(f"{self.log_prefix} Response: {_body_text}")
|
||||
print(f"{self.log_prefix} Most likely: Portal OAuth expired, account out of credits, or agent key revoked.")
|
||||
print(f"{self.log_prefix} Troubleshooting:")
|
||||
print(f"{self.log_prefix} • Re-authenticate: hermes login --provider nous")
|
||||
print(f"{self.log_prefix} • Check credits / billing: https://portal.nousresearch.com")
|
||||
print(f"{self.log_prefix} • Verify stored credentials: {_dhh}/auth.json")
|
||||
print(f"{self.log_prefix} • Switch providers temporarily: /model <model> --provider openrouter")
|
||||
if (
|
||||
self.api_mode == "anthropic_messages"
|
||||
and status_code == 401
|
||||
|
||||
@@ -50,11 +50,15 @@ AUTHOR_MAP = {
|
||||
"71184274+MassiveMassimo@users.noreply.github.com": "MassiveMassimo",
|
||||
"massivemassimo@users.noreply.github.com": "MassiveMassimo",
|
||||
"82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
|
||||
"keifergu@tencent.com": "keifergu",
|
||||
"kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
|
||||
"abner.the.foreman@agentmail.to": "Abnertheforeman",
|
||||
"harryykyle1@gmail.com": "hharry11",
|
||||
"kshitijk4poor@gmail.com": "kshitijk4poor",
|
||||
"16443023+stablegenius49@users.noreply.github.com": "stablegenius49",
|
||||
"185121704+stablegenius49@users.noreply.github.com": "stablegenius49",
|
||||
"101283333+batuhankocyigit@users.noreply.github.com": "batuhankocyigit",
|
||||
"255305877+ismell0992-afk@users.noreply.github.com": "ismell0992-afk",
|
||||
"valdi.jorge@gmail.com": "jvcl",
|
||||
"francip@gmail.com": "francip",
|
||||
"omni@comelse.com": "omnissiah-comelse",
|
||||
@@ -92,6 +96,8 @@ AUTHOR_MAP = {
|
||||
"135070653+sgaofen@users.noreply.github.com": "sgaofen",
|
||||
"nocoo@users.noreply.github.com": "nocoo",
|
||||
"30841158+n-WN@users.noreply.github.com": "n-WN",
|
||||
"tsuijinglei@gmail.com": "hiddenpuppy",
|
||||
"jerome@clawwork.ai": "HiddenPuppy",
|
||||
"leoyuan0099@gmail.com": "keyuyuan",
|
||||
"bxzt2006@163.com": "Only-Code-A",
|
||||
"i@troy-y.org": "TroyMitchell911",
|
||||
@@ -99,6 +105,7 @@ AUTHOR_MAP = {
|
||||
"hansnow@users.noreply.github.com": "hansnow",
|
||||
"134848055+UNLINEARITY@users.noreply.github.com": "UNLINEARITY",
|
||||
"ben.burtenshaw@gmail.com": "burtenshaw",
|
||||
"roopaknijhara@gmail.com": "rnijhara",
|
||||
# contributors (manual mapping from git names)
|
||||
"ahmedsherif95@gmail.com": "asheriif",
|
||||
"liujinkun@bytedance.com": "liujinkun2025",
|
||||
@@ -175,6 +182,7 @@ AUTHOR_MAP = {
|
||||
"adavyasharma@gmail.com": "adavyas",
|
||||
"acaayush1111@gmail.com": "aayushchaudhary",
|
||||
"jason@outland.art": "jasonoutland",
|
||||
"73175452+Magaav@users.noreply.github.com": "Magaav",
|
||||
"mrflu1918@proton.me": "SPANISHFLU",
|
||||
"morganemoss@gmai.com": "mormio",
|
||||
"kopjop926@gmail.com": "cesareth",
|
||||
@@ -279,6 +287,7 @@ AUTHOR_MAP = {
|
||||
"srhtsrht17@gmail.com": "Sertug17",
|
||||
"stephenschoettler@gmail.com": "stephenschoettler",
|
||||
"tanishq231003@gmail.com": "yyovil",
|
||||
"taosiyuan163@153.com": "taosiyuan163",
|
||||
"tesseracttars@gmail.com": "tesseracttars-creator",
|
||||
"tianliangjay@gmail.com": "xingkongliang",
|
||||
"tranquil_flow@protonmail.com": "Tranquil-Flow",
|
||||
@@ -335,6 +344,7 @@ AUTHOR_MAP = {
|
||||
"shalompmc0505@naver.com": "pinion05",
|
||||
"105142614+VTRiot@users.noreply.github.com": "VTRiot",
|
||||
"vivien000812@gmail.com": "iamagenius00",
|
||||
"89228157+Feranmi10@users.noreply.github.com": "Feranmi10",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -253,6 +253,35 @@ class TestSummaryPrefixNormalization:
|
||||
|
||||
|
||||
class TestCompressWithClient:
|
||||
def test_system_content_list_gets_compression_note_without_crashing(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "summary text"
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
|
||||
|
||||
msgs = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "system prompt"}]},
|
||||
{"role": "user", "content": "msg 1"},
|
||||
{"role": "assistant", "content": "msg 2"},
|
||||
{"role": "user", "content": "msg 3"},
|
||||
{"role": "assistant", "content": "msg 4"},
|
||||
{"role": "user", "content": "msg 5"},
|
||||
{"role": "assistant", "content": "msg 6"},
|
||||
{"role": "user", "content": "msg 7"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
||||
result = c.compress(msgs)
|
||||
|
||||
assert isinstance(result[0]["content"], list)
|
||||
assert any(
|
||||
isinstance(block, dict)
|
||||
and "compacted into a handoff summary" in block.get("text", "")
|
||||
for block in result[0]["content"]
|
||||
)
|
||||
|
||||
def test_summarization_path(self):
|
||||
mock_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
@@ -460,6 +489,41 @@ class TestCompressWithClient:
|
||||
assert len(first_tail) == 1
|
||||
assert "summary text" in first_tail[0]["content"]
|
||||
|
||||
def test_double_collision_merges_summary_into_list_tail_content(self):
|
||||
"""Structured tail content should accept a merged summary without TypeError."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "summary text"
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=3, protect_last_n=3)
|
||||
|
||||
msgs = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": "msg 1"},
|
||||
{"role": "assistant", "content": "msg 2"},
|
||||
{"role": "user", "content": "msg 3"},
|
||||
{"role": "assistant", "content": "msg 4"},
|
||||
{"role": "user", "content": "msg 5"},
|
||||
{"role": "user", "content": [{"type": "text", "text": "msg 6"}]},
|
||||
{"role": "assistant", "content": "msg 7"},
|
||||
{"role": "user", "content": "msg 8"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
||||
result = c.compress(msgs)
|
||||
|
||||
merged_tail = next(
|
||||
m for m in result
|
||||
if m.get("role") == "user" and isinstance(m.get("content"), list)
|
||||
)
|
||||
assert isinstance(merged_tail["content"], list)
|
||||
assert "summary text" in merged_tail["content"][0]["text"]
|
||||
assert any(
|
||||
isinstance(block, dict) and block.get("text") == "msg 6"
|
||||
for block in merged_tail["content"]
|
||||
)
|
||||
|
||||
def test_double_collision_user_head_assistant_tail(self):
|
||||
"""Reverse double collision: head ends with 'user', tail starts with 'assistant'.
|
||||
summary='assistant' collides with tail, 'user' collides with head → merge."""
|
||||
|
||||
@@ -298,9 +298,15 @@ class TestClassifyApiError:
|
||||
assert result.retryable is False
|
||||
|
||||
def test_404_generic(self):
|
||||
# Generic 404 with no "model not found" signal — common for local
|
||||
# llama.cpp/Ollama/vLLM endpoints with slightly wrong paths. Treat
|
||||
# as unknown (retryable) so the real error surfaces, rather than
|
||||
# claiming the model is missing and silently falling back.
|
||||
e = MockAPIError("Not Found", status_code=404)
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.model_not_found
|
||||
assert result.reason == FailoverReason.unknown
|
||||
assert result.retryable is True
|
||||
assert result.should_fallback is False
|
||||
|
||||
# ── Payload too large ──
|
||||
|
||||
@@ -943,3 +949,94 @@ class TestAdversarialEdgeCases:
|
||||
e = MockAPIError("server error", status_code=500, body={"message": None})
|
||||
result = classify_api_error(e)
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ── Test: SSL/TLS transient errors ─────────────────────────────────────
|
||||
|
||||
class TestSSLTransientPatterns:
|
||||
"""SSL/TLS alerts mid-stream should retry as timeout, not unknown, and
|
||||
should NOT trigger context compression even on a large session.
|
||||
|
||||
Motivation: OpenSSL 3.x changed TLS alert error code format
|
||||
(`SSLV3_ALERT_BAD_RECORD_MAC` → `SSL/TLS_ALERT_BAD_RECORD_MAC`),
|
||||
breaking string-exact matching in downstream retry logic. We match
|
||||
stable substrings instead.
|
||||
"""
|
||||
|
||||
def test_bad_record_mac_classifies_as_timeout(self):
|
||||
"""OpenSSL 3.x mid-stream bad record mac alert."""
|
||||
e = Exception("[SSL: BAD_RECORD_MAC] sslv3 alert bad record mac (_ssl.c:2580)")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
assert result.should_compress is False
|
||||
|
||||
def test_openssl_3x_format_classifies_as_timeout(self):
|
||||
"""New format `ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC` still matches
|
||||
because we key on both space- and underscore-separated forms of
|
||||
the stable `bad_record_mac` token."""
|
||||
e = Exception("ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC during streaming")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
assert result.should_compress is False
|
||||
|
||||
def test_tls_alert_internal_error_classifies_as_timeout(self):
|
||||
e = Exception("[SSL: TLSV1_ALERT_INTERNAL_ERROR] tlsv1 alert internal error")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
assert result.should_compress is False
|
||||
|
||||
def test_ssl_handshake_failure_classifies_as_timeout(self):
|
||||
e = Exception("ssl handshake failure during mid-stream")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
|
||||
def test_ssl_prefix_classifies_as_timeout(self):
|
||||
"""Python's generic '[SSL: XYZ]' prefix from the ssl module."""
|
||||
e = Exception("[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
|
||||
def test_ssl_alert_on_large_session_does_not_compress(self):
|
||||
"""Critical: SSL alerts on big contexts must NOT trigger context
|
||||
compression — compression is expensive and won't fix a transport
|
||||
hiccup. This is why _SSL_TRANSIENT_PATTERNS is separate from
|
||||
_SERVER_DISCONNECT_PATTERNS.
|
||||
"""
|
||||
e = Exception("[SSL: BAD_RECORD_MAC] sslv3 alert bad record mac")
|
||||
result = classify_api_error(
|
||||
e,
|
||||
approx_tokens=180000, # 90% of a 200k-context window
|
||||
context_length=200000,
|
||||
num_messages=300,
|
||||
)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.should_compress is False
|
||||
|
||||
def test_plain_disconnect_on_large_session_still_compresses(self):
|
||||
"""Regression guard: the context-overflow-via-disconnect path
|
||||
(non-SSL disconnects on large sessions) must still trigger
|
||||
compression. Only SSL-specific disconnects skip it.
|
||||
"""
|
||||
e = Exception("Server disconnected without sending a response")
|
||||
result = classify_api_error(
|
||||
e,
|
||||
approx_tokens=180000,
|
||||
context_length=200000,
|
||||
num_messages=300,
|
||||
)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
assert result.should_compress is True
|
||||
|
||||
def test_real_ssl_error_type_classifies_as_timeout(self):
|
||||
"""Real ssl.SSLError instance — the type name alone (not message)
|
||||
should route to the transport bucket."""
|
||||
import ssl
|
||||
e = ssl.SSLError("arbitrary ssl error")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
|
||||
@@ -106,3 +106,25 @@ class TestIsLocalEndpoint:
|
||||
])
|
||||
def test_remote_endpoints(self, url):
|
||||
assert is_local_endpoint(url) is False
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://100.64.0.0:11434", # lower bound of CGNAT block
|
||||
"http://100.64.0.1:11434/v1", # lower bound +1
|
||||
"http://100.77.243.5:11434", # representative Tailscale host
|
||||
"https://100.100.100.100:443", # Tailscale MagicDNS anchor
|
||||
"https://100.127.255.254:443", # upper bound -1
|
||||
"http://100.127.255.255:11434", # upper bound of CGNAT block
|
||||
])
|
||||
def test_tailscale_cgnat_is_local(self, url):
|
||||
"""Tailscale 100.64.0.0/10 should be treated as local for timeout bumps."""
|
||||
assert is_local_endpoint(url) is True
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://100.63.255.255:11434", # just below CGNAT block
|
||||
"http://100.128.0.1:11434", # just above CGNAT block
|
||||
"http://100.200.0.1:11434", # well outside CGNAT
|
||||
"http://99.64.0.1:11434", # first octet wrong
|
||||
])
|
||||
def test_near_but_not_cgnat_is_remote(self, url):
|
||||
"""Hosts adjacent to but outside 100.64.0.0/10 must not match."""
|
||||
assert is_local_endpoint(url) is False
|
||||
|
||||
@@ -79,6 +79,28 @@ class TestMemoryManagerUserIdThreading:
|
||||
assert p._init_kwargs.get("platform") == "telegram"
|
||||
assert p._init_session_id == "sess-123"
|
||||
|
||||
def test_chat_context_forwarded_to_provider(self):
|
||||
mgr = MemoryManager()
|
||||
p = RecordingProvider()
|
||||
mgr.add_provider(p)
|
||||
|
||||
mgr.initialize_all(
|
||||
session_id="sess-chat",
|
||||
platform="discord",
|
||||
user_id="discord_u_7",
|
||||
user_name="fakeusername",
|
||||
chat_id="1485316232612941897",
|
||||
chat_name="fakeassistantname-forums",
|
||||
chat_type="thread",
|
||||
thread_id="1491249007475949698",
|
||||
)
|
||||
|
||||
assert p._init_kwargs.get("user_name") == "fakeusername"
|
||||
assert p._init_kwargs.get("chat_id") == "1485316232612941897"
|
||||
assert p._init_kwargs.get("chat_name") == "fakeassistantname-forums"
|
||||
assert p._init_kwargs.get("chat_type") == "thread"
|
||||
assert p._init_kwargs.get("thread_id") == "1491249007475949698"
|
||||
|
||||
def test_no_user_id_when_cli(self):
|
||||
"""CLI sessions should not have user_id in kwargs."""
|
||||
mgr = MemoryManager()
|
||||
@@ -334,3 +356,4 @@ class TestAIAgentUserIdPropagation:
|
||||
agent = object.__new__(AIAgent)
|
||||
agent._user_id = None
|
||||
assert agent._user_id is None
|
||||
|
||||
|
||||
@@ -385,6 +385,7 @@ class TestStripProviderPrefix:
|
||||
assert _strip_provider_prefix("local:my-model") == "my-model"
|
||||
assert _strip_provider_prefix("openrouter:anthropic/claude-sonnet-4") == "anthropic/claude-sonnet-4"
|
||||
assert _strip_provider_prefix("anthropic:claude-sonnet-4") == "claude-sonnet-4"
|
||||
assert _strip_provider_prefix("stepfun:step-3.5-flash") == "step-3.5-flash"
|
||||
|
||||
def test_ollama_model_tag_preserved(self):
|
||||
"""Ollama model:tag format must NOT be stripped."""
|
||||
|
||||
@@ -82,6 +82,7 @@ class TestProviderMapping:
|
||||
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"
|
||||
assert PROVIDER_TO_MODELS_DEV["ai-gateway"] == "vercel"
|
||||
|
||||
|
||||
@@ -114,6 +114,14 @@ class TestAnthropicTransport:
|
||||
r = SimpleNamespace(content=[])
|
||||
assert transport.validate_response(r) is False
|
||||
|
||||
def test_validate_response_empty_content_with_end_turn_is_valid(self, transport):
|
||||
r = SimpleNamespace(content=[], stop_reason="end_turn")
|
||||
assert transport.validate_response(r) is True
|
||||
|
||||
def test_validate_response_empty_content_with_tool_use_is_invalid(self, transport):
|
||||
r = SimpleNamespace(content=[], stop_reason="tool_use")
|
||||
assert transport.validate_response(r) is False
|
||||
|
||||
def test_validate_response_valid(self, transport):
|
||||
r = SimpleNamespace(content=[SimpleNamespace(type="text", text="hello")])
|
||||
assert transport.validate_response(r) is True
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for the gateway /debug command."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_event(text="/debug", platform=Platform.TELEGRAM,
|
||||
user_id="12345", chat_id="67890"):
|
||||
source = SessionSource(
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
user_name="testuser",
|
||||
)
|
||||
return MessageEvent(text=text, source=source)
|
||||
|
||||
|
||||
def _make_runner():
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig()
|
||||
runner.adapters = {}
|
||||
return runner
|
||||
|
||||
|
||||
class TestHandleDebugCommand:
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_sweeps_expired_pastes_before_upload(self):
|
||||
runner = _make_runner()
|
||||
event = _make_event()
|
||||
|
||||
with patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)) as mock_sweep, \
|
||||
patch("hermes_cli.debug._capture_dump", return_value="dump"), \
|
||||
patch("hermes_cli.debug.collect_debug_report", return_value="report"), \
|
||||
patch("hermes_cli.debug.upload_to_pastebin", return_value="https://paste.rs/report"), \
|
||||
patch("hermes_cli.debug._schedule_auto_delete"):
|
||||
result = await runner._handle_debug_command(event)
|
||||
|
||||
mock_sweep.assert_called_once()
|
||||
assert "https://paste.rs/report" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_survives_sweep_failure(self):
|
||||
runner = _make_runner()
|
||||
event = _make_event()
|
||||
|
||||
with patch("hermes_cli.debug._sweep_expired_pastes", side_effect=RuntimeError("offline")), \
|
||||
patch("hermes_cli.debug._capture_dump", return_value="dump"), \
|
||||
patch("hermes_cli.debug.collect_debug_report", return_value="report"), \
|
||||
patch("hermes_cli.debug.upload_to_pastebin", return_value="https://paste.rs/report"), \
|
||||
patch("hermes_cli.debug._schedule_auto_delete"):
|
||||
result = await runner._handle_debug_command(event)
|
||||
|
||||
assert "https://paste.rs/report" in result
|
||||
@@ -199,6 +199,89 @@ async def test_auto_registered_command_with_args(adapter):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_registers_plugin_commands_for_discord(adapter):
|
||||
"""Plugin slash commands should appear as native Discord app commands."""
|
||||
adapter._run_simple_slash = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"hermes_cli.plugins.get_plugin_commands",
|
||||
return_value={
|
||||
"metricas": {
|
||||
"handler": lambda _a: "ok",
|
||||
"description": "Metrics dashboard",
|
||||
"args_hint": "dias:7 formato:json",
|
||||
"plugin": "metrics-plugin",
|
||||
}
|
||||
},
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
tree_names = set(adapter._client.tree.commands.keys())
|
||||
assert "metricas" in tree_names
|
||||
|
||||
metricas_cmd = adapter._client.tree.commands["metricas"]
|
||||
interaction = SimpleNamespace()
|
||||
await metricas_cmd.callback(interaction, args="dias:7 formato:json")
|
||||
adapter._run_simple_slash.assert_awaited_once_with(
|
||||
interaction, "/metricas dias:7 formato:json"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_registered_plugin_command_without_args_hint(adapter):
|
||||
"""Plugin commands without args_hint should register as parameterless."""
|
||||
adapter._run_simple_slash = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"hermes_cli.plugins.get_plugin_commands",
|
||||
return_value={
|
||||
"ping": {
|
||||
"handler": lambda _a: "pong",
|
||||
"description": "Ping the plugin",
|
||||
"args_hint": "",
|
||||
"plugin": "ping-plugin",
|
||||
}
|
||||
},
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
assert "ping" in adapter._client.tree.commands
|
||||
ping_cmd = adapter._client.tree.commands["ping"]
|
||||
interaction = SimpleNamespace()
|
||||
await ping_cmd.callback(interaction)
|
||||
adapter._run_simple_slash.assert_awaited_once_with(interaction, "/ping")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_command_name_conflict_skipped(adapter):
|
||||
"""A plugin command that collides with a built-in must not override it."""
|
||||
adapter._run_simple_slash = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"hermes_cli.plugins.get_plugin_commands",
|
||||
return_value={
|
||||
"status": {
|
||||
"handler": lambda _a: "plugin-status",
|
||||
"description": "Plugin status",
|
||||
"args_hint": "",
|
||||
"plugin": "shadow-plugin",
|
||||
}
|
||||
},
|
||||
):
|
||||
adapter._register_slash_commands()
|
||||
|
||||
# Built-ins are registered via @tree.command as plain functions. A
|
||||
# plugin-registered override would install a _FakeCommand instance
|
||||
# (has .callback) via tree.add_command. If the conflict-skip logic
|
||||
# fires, the slot remains a bare function.
|
||||
status_entry = adapter._client.tree.commands["status"]
|
||||
assert callable(status_entry) and not hasattr(status_entry, "callback"), (
|
||||
"plugin registration overrode the built-in /status command — "
|
||||
"the already_registered skip must prevent this"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _handle_thread_create_slash — success, session dispatch, failure
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
+1070
-32
File diff suppressed because it is too large
Load Diff
@@ -220,3 +220,99 @@ class TestEmit:
|
||||
|
||||
await reg.emit("agent:start") # no context arg
|
||||
assert captured[0] == {}
|
||||
|
||||
|
||||
class TestEmitCollect:
|
||||
"""Tests for emit_collect() — returns handler return values for decision-style hooks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collects_sync_return_values(self):
|
||||
reg = HookRegistry()
|
||||
reg._handlers["command:status"] = [
|
||||
lambda _e, _c: {"decision": "allow"},
|
||||
lambda _e, _c: {"decision": "deny", "message": "nope"},
|
||||
]
|
||||
|
||||
results = await reg.emit_collect("command:status", {})
|
||||
|
||||
assert results == [
|
||||
{"decision": "allow"},
|
||||
{"decision": "deny", "message": "nope"},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collects_async_return_values(self):
|
||||
reg = HookRegistry()
|
||||
|
||||
async def _async_handler(_event_type, _ctx):
|
||||
return {"decision": "handled", "message": "done"}
|
||||
|
||||
reg._handlers["command:ping"] = [_async_handler]
|
||||
|
||||
results = await reg.emit_collect("command:ping", {})
|
||||
|
||||
assert results == [{"decision": "handled", "message": "done"}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drops_none_return_values(self):
|
||||
reg = HookRegistry()
|
||||
reg._handlers["command:x"] = [
|
||||
lambda _e, _c: None, # fire-and-forget, returns nothing
|
||||
lambda _e, _c: {"decision": "deny"},
|
||||
lambda _e, _c: None,
|
||||
]
|
||||
|
||||
results = await reg.emit_collect("command:x", {})
|
||||
|
||||
assert results == [{"decision": "deny"}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_exception_does_not_abort_chain(self):
|
||||
reg = HookRegistry()
|
||||
|
||||
def _raises(_e, _c):
|
||||
raise ValueError("boom")
|
||||
|
||||
reg._handlers["command:x"] = [
|
||||
_raises,
|
||||
lambda _e, _c: {"decision": "allow"},
|
||||
]
|
||||
|
||||
results = await reg.emit_collect("command:x", {})
|
||||
|
||||
# First handler's exception is swallowed; second handler's value still collected.
|
||||
assert results == [{"decision": "allow"}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wildcard_match_also_collected(self):
|
||||
reg = HookRegistry()
|
||||
reg._handlers["command:*"] = [lambda _e, _c: {"decision": "allow"}]
|
||||
reg._handlers["command:reset"] = [lambda _e, _c: {"decision": "deny"}]
|
||||
|
||||
results = await reg.emit_collect("command:reset", {})
|
||||
|
||||
# Exact match fires first, then wildcard.
|
||||
assert results == [{"decision": "deny"}, {"decision": "allow"}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_handlers_returns_empty_list(self):
|
||||
reg = HookRegistry()
|
||||
|
||||
results = await reg.emit_collect("unknown:event", {})
|
||||
|
||||
assert results == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_context(self):
|
||||
reg = HookRegistry()
|
||||
captured = []
|
||||
|
||||
def _handler(event_type, context):
|
||||
captured.append((event_type, context))
|
||||
return None
|
||||
|
||||
reg._handlers["agent:start"] = [_handler]
|
||||
|
||||
await reg.emit_collect("agent:start") # no context arg
|
||||
|
||||
assert captured == [("agent:start", {})]
|
||||
|
||||
@@ -1059,6 +1059,7 @@ class TestRewriteTranscriptPreservesReasoning:
|
||||
role="assistant",
|
||||
content="The answer is 42.",
|
||||
reasoning="I need to think step by step.",
|
||||
reasoning_content="provider scratchpad",
|
||||
reasoning_details=[{"type": "summary", "text": "step by step"}],
|
||||
codex_reasoning_items=[{"id": "r1", "type": "reasoning"}],
|
||||
)
|
||||
@@ -1066,6 +1067,7 @@ class TestRewriteTranscriptPreservesReasoning:
|
||||
# Verify all three were stored
|
||||
before = db.get_messages_as_conversation(session_id)
|
||||
assert before[0].get("reasoning") == "I need to think step by step."
|
||||
assert before[0].get("reasoning_content") == "provider scratchpad"
|
||||
assert before[0].get("reasoning_details") == [{"type": "summary", "text": "step by step"}]
|
||||
assert before[0].get("codex_reasoning_items") == [{"id": "r1", "type": "reasoning"}]
|
||||
|
||||
@@ -1082,5 +1084,6 @@ class TestRewriteTranscriptPreservesReasoning:
|
||||
# Load again — all three reasoning fields must survive
|
||||
after = db.get_messages_as_conversation(session_id)
|
||||
assert after[0].get("reasoning") == "I need to think step by step."
|
||||
assert after[0].get("reasoning_content") == "provider scratchpad"
|
||||
assert after[0].get("reasoning_details") == [{"type": "summary", "text": "step by step"}]
|
||||
assert after[0].get("codex_reasoning_items") == [{"id": "r1", "type": "reasoning"}]
|
||||
|
||||
+135
-3
@@ -1031,7 +1031,7 @@ class TestReactions:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reactions_in_message_flow(self, adapter):
|
||||
"""Reactions should be added on receipt and swapped on completion."""
|
||||
"""Reactions should be bracketed around actual processing via hooks."""
|
||||
adapter._app.client.reactions_add = AsyncMock()
|
||||
adapter._app.client.reactions_remove = AsyncMock()
|
||||
adapter._app.client.users_info = AsyncMock(return_value={
|
||||
@@ -1047,15 +1047,147 @@ class TestReactions:
|
||||
}
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
# Should have added 👀, then removed 👀, then added ✅
|
||||
# _handle_slack_message should register the message for reactions
|
||||
assert "1234567890.000001" in adapter._reacting_message_ids
|
||||
|
||||
# Simulate the base class calling on_processing_start
|
||||
from gateway.platforms.base import MessageEvent, MessageType, SessionSource
|
||||
from gateway.config import Platform
|
||||
source = SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id="C123",
|
||||
chat_type="dm",
|
||||
user_id="U_USER",
|
||||
)
|
||||
msg_event = MessageEvent(
|
||||
text="hello",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id="1234567890.000001",
|
||||
)
|
||||
await adapter.on_processing_start(msg_event)
|
||||
|
||||
add_calls = adapter._app.client.reactions_add.call_args_list
|
||||
assert len(add_calls) == 1
|
||||
assert add_calls[0].kwargs["name"] == "eyes"
|
||||
|
||||
# Simulate the base class calling on_processing_complete
|
||||
from gateway.platforms.base import ProcessingOutcome
|
||||
await adapter.on_processing_complete(msg_event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
add_calls = adapter._app.client.reactions_add.call_args_list
|
||||
remove_calls = adapter._app.client.reactions_remove.call_args_list
|
||||
assert len(add_calls) == 2
|
||||
assert add_calls[0].kwargs["name"] == "eyes"
|
||||
assert add_calls[1].kwargs["name"] == "white_check_mark"
|
||||
assert len(remove_calls) == 1
|
||||
assert remove_calls[0].kwargs["name"] == "eyes"
|
||||
|
||||
# Message ID should be cleaned up
|
||||
assert "1234567890.000001" not in adapter._reacting_message_ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reactions_failure_outcome(self, adapter):
|
||||
"""Failed processing should add :x: instead of :white_check_mark:."""
|
||||
adapter._app.client.reactions_add = AsyncMock()
|
||||
adapter._app.client.reactions_remove = AsyncMock()
|
||||
|
||||
from gateway.platforms.base import MessageEvent, MessageType, SessionSource, ProcessingOutcome
|
||||
from gateway.config import Platform
|
||||
source = SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id="C123",
|
||||
chat_type="dm",
|
||||
user_id="U_USER",
|
||||
)
|
||||
adapter._reacting_message_ids.add("1234567890.000002")
|
||||
msg_event = MessageEvent(
|
||||
text="hello",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id="1234567890.000002",
|
||||
)
|
||||
await adapter.on_processing_complete(msg_event, ProcessingOutcome.FAILURE)
|
||||
|
||||
add_calls = adapter._app.client.reactions_add.call_args_list
|
||||
remove_calls = adapter._app.client.reactions_remove.call_args_list
|
||||
assert len(add_calls) == 1
|
||||
assert add_calls[0].kwargs["name"] == "x"
|
||||
assert len(remove_calls) == 1
|
||||
assert remove_calls[0].kwargs["name"] == "eyes"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reactions_skipped_for_non_dm_non_mention(self, adapter):
|
||||
"""Non-DM, non-mention messages should not get reactions."""
|
||||
adapter._app.client.reactions_add = AsyncMock()
|
||||
adapter._app.client.reactions_remove = AsyncMock()
|
||||
adapter._app.client.users_info = AsyncMock(return_value={
|
||||
"user": {"profile": {"display_name": "Tyler"}}
|
||||
})
|
||||
|
||||
event = {
|
||||
"text": "hello",
|
||||
"user": "U_USER",
|
||||
"channel": "C123",
|
||||
"channel_type": "channel",
|
||||
"ts": "1234567890.000003",
|
||||
}
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
# Should NOT register for reactions when not mentioned in a channel
|
||||
assert "1234567890.000003" not in adapter._reacting_message_ids
|
||||
adapter._app.client.reactions_add.assert_not_called()
|
||||
adapter._app.client.reactions_remove.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reactions_disabled_via_env(self, adapter, monkeypatch):
|
||||
"""SLACK_REACTIONS=false should suppress all reaction lifecycle."""
|
||||
monkeypatch.setenv("SLACK_REACTIONS", "false")
|
||||
adapter._app.client.reactions_add = AsyncMock()
|
||||
adapter._app.client.reactions_remove = AsyncMock()
|
||||
adapter._app.client.users_info = AsyncMock(return_value={
|
||||
"user": {"profile": {"display_name": "Tyler"}}
|
||||
})
|
||||
|
||||
event = {
|
||||
"text": "hello",
|
||||
"user": "U_USER",
|
||||
"channel": "C123",
|
||||
"channel_type": "im",
|
||||
"ts": "1234567890.000004",
|
||||
}
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
# Should NOT register for reactions when toggle is off
|
||||
assert "1234567890.000004" not in adapter._reacting_message_ids
|
||||
|
||||
# Hooks should also be no-ops when disabled
|
||||
from gateway.platforms.base import MessageEvent, MessageType, SessionSource, ProcessingOutcome
|
||||
from gateway.config import Platform
|
||||
source = SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id="C123",
|
||||
chat_type="dm",
|
||||
user_id="U_USER",
|
||||
)
|
||||
msg_event = MessageEvent(
|
||||
text="hello",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id="1234567890.000004",
|
||||
)
|
||||
# Force-add to verify hooks respect the toggle independently
|
||||
adapter._reacting_message_ids.add("1234567890.000004")
|
||||
await adapter.on_processing_start(msg_event)
|
||||
await adapter.on_processing_complete(msg_event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
adapter._app.client.reactions_add.assert_not_called()
|
||||
adapter._app.client.reactions_remove.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reactions_enabled_by_default(self, adapter):
|
||||
"""SLACK_REACTIONS defaults to true (matches existing behavior)."""
|
||||
assert adapter._reactions_enabled() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestThreadReplyHandling
|
||||
|
||||
@@ -65,7 +65,11 @@ class TestGatewayPidState:
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 123)
|
||||
monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: None)
|
||||
|
||||
assert status.get_running_pid() == os.getpid()
|
||||
assert status.acquire_gateway_runtime_lock() is True
|
||||
try:
|
||||
assert status.get_running_pid() == os.getpid()
|
||||
finally:
|
||||
status.release_gateway_runtime_lock()
|
||||
|
||||
def test_get_running_pid_accepts_script_style_gateway_cmdline(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
@@ -85,7 +89,11 @@ class TestGatewayPidState:
|
||||
lambda pid: "/venv/bin/python /repo/hermes_cli/main.py gateway run --replace",
|
||||
)
|
||||
|
||||
assert status.get_running_pid() == os.getpid()
|
||||
assert status.acquire_gateway_runtime_lock() is True
|
||||
try:
|
||||
assert status.get_running_pid() == os.getpid()
|
||||
finally:
|
||||
status.release_gateway_runtime_lock()
|
||||
|
||||
def test_get_running_pid_accepts_explicit_pid_path_without_cleanup(self, tmp_path, monkeypatch):
|
||||
other_home = tmp_path / "profile-home"
|
||||
@@ -102,9 +110,116 @@ class TestGatewayPidState:
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 123)
|
||||
monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: None)
|
||||
|
||||
lock_path = other_home / "gateway.lock"
|
||||
lock_path.write_text(json.dumps({
|
||||
"pid": os.getpid(),
|
||||
"kind": "hermes-gateway",
|
||||
"argv": ["python", "-m", "hermes_cli.main", "gateway"],
|
||||
"start_time": 123,
|
||||
}))
|
||||
monkeypatch.setattr(status, "is_gateway_runtime_lock_active", lambda lock_path=None: True)
|
||||
|
||||
assert status.get_running_pid(pid_path, cleanup_stale=False) == os.getpid()
|
||||
assert pid_path.exists()
|
||||
|
||||
def test_runtime_lock_claims_and_releases_liveness(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
assert status.is_gateway_runtime_lock_active() is False
|
||||
assert status.acquire_gateway_runtime_lock() is True
|
||||
assert status.is_gateway_runtime_lock_active() is True
|
||||
|
||||
status.release_gateway_runtime_lock()
|
||||
|
||||
assert status.is_gateway_runtime_lock_active() is False
|
||||
|
||||
def test_get_running_pid_treats_pid_file_as_stale_without_runtime_lock(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
pid_path = tmp_path / "gateway.pid"
|
||||
pid_path.write_text(json.dumps({
|
||||
"pid": os.getpid(),
|
||||
"kind": "hermes-gateway",
|
||||
"argv": ["python", "-m", "hermes_cli.main", "gateway"],
|
||||
"start_time": 123,
|
||||
}))
|
||||
|
||||
monkeypatch.setattr(status.os, "kill", lambda pid, sig: None)
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 123)
|
||||
monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: None)
|
||||
|
||||
assert status.get_running_pid() is None
|
||||
assert not pid_path.exists()
|
||||
|
||||
def test_get_running_pid_cleans_stale_metadata_from_dead_foreign_pid(self, tmp_path, monkeypatch):
|
||||
"""Stale PID file from a *different* PID (crashed process) must still be cleaned.
|
||||
|
||||
Regression for: ``remove_pid_file()`` defensively refuses to delete a
|
||||
PID file whose pid != ``os.getpid()`` to protect ``--replace``
|
||||
handoffs. Stale-cleanup must not go through that path or real
|
||||
crashed-process PID files never get removed.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
pid_path = tmp_path / "gateway.pid"
|
||||
lock_path = tmp_path / "gateway.lock"
|
||||
|
||||
# PID that is guaranteed not alive and not our own.
|
||||
dead_foreign_pid = 999999
|
||||
assert dead_foreign_pid != os.getpid()
|
||||
|
||||
pid_path.write_text(json.dumps({
|
||||
"pid": dead_foreign_pid,
|
||||
"kind": "hermes-gateway",
|
||||
"argv": ["python", "-m", "hermes_cli.main", "gateway"],
|
||||
"start_time": 123,
|
||||
}))
|
||||
lock_path.write_text(json.dumps({
|
||||
"pid": dead_foreign_pid,
|
||||
"kind": "hermes-gateway",
|
||||
"argv": ["python", "-m", "hermes_cli.main", "gateway"],
|
||||
"start_time": 123,
|
||||
}))
|
||||
|
||||
# No live lock holder → get_running_pid should clean both files.
|
||||
assert status.get_running_pid() is None
|
||||
assert not pid_path.exists()
|
||||
assert not lock_path.exists()
|
||||
|
||||
def test_get_running_pid_falls_back_to_live_lock_record(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
pid_path = tmp_path / "gateway.pid"
|
||||
pid_path.write_text(json.dumps({
|
||||
"pid": 99999,
|
||||
"kind": "hermes-gateway",
|
||||
"argv": ["python", "-m", "hermes_cli.main", "gateway"],
|
||||
"start_time": 123,
|
||||
}))
|
||||
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 123)
|
||||
monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: None)
|
||||
monkeypatch.setattr(
|
||||
status,
|
||||
"_build_pid_record",
|
||||
lambda: {
|
||||
"pid": os.getpid(),
|
||||
"kind": "hermes-gateway",
|
||||
"argv": ["python", "-m", "hermes_cli.main", "gateway"],
|
||||
"start_time": 123,
|
||||
},
|
||||
)
|
||||
assert status.acquire_gateway_runtime_lock() is True
|
||||
|
||||
def fake_kill(pid, sig):
|
||||
if pid == 99999:
|
||||
raise ProcessLookupError
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(status.os, "kill", fake_kill)
|
||||
|
||||
try:
|
||||
assert status.get_running_pid() == os.getpid()
|
||||
finally:
|
||||
status.release_gateway_runtime_lock()
|
||||
|
||||
|
||||
class TestGatewayRuntimeStatus:
|
||||
def test_write_runtime_status_overwrites_stale_pid_on_restart(self, tmp_path, monkeypatch):
|
||||
|
||||
@@ -41,7 +41,11 @@ def _make_runner():
|
||||
adapter.send = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
runner._voice_mode = {}
|
||||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||||
runner.hooks = SimpleNamespace(
|
||||
emit=AsyncMock(),
|
||||
emit_collect=AsyncMock(return_value=[]),
|
||||
loaded_hooks=False,
|
||||
)
|
||||
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source()),
|
||||
@@ -164,3 +168,206 @@ async def test_underscored_alias_for_hyphenated_builtin_not_flagged(monkeypatch)
|
||||
# Whatever /reload_mcp returns, it must not be the unknown-command guard.
|
||||
if result is not None:
|
||||
assert "Unknown command" not in result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# command:<name> decision hook — deny / handled / rewrite
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_hook_can_deny_before_dispatch(monkeypatch):
|
||||
"""A handler returning {"decision": "deny"} blocks a slash command early."""
|
||||
import gateway.run as gateway_run
|
||||
|
||||
runner = _make_runner()
|
||||
runner._run_agent = AsyncMock(
|
||||
side_effect=AssertionError("denied slash command leaked to the agent")
|
||||
)
|
||||
runner._handle_status_command = AsyncMock(
|
||||
side_effect=AssertionError("denied slash command reached its handler")
|
||||
)
|
||||
runner.hooks.emit_collect = AsyncMock(
|
||||
return_value=[{"decision": "deny", "message": "Blocked by ACL"}]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/status"))
|
||||
|
||||
assert result == "Blocked by ACL"
|
||||
runner._run_agent.assert_not_called()
|
||||
# The emit_collect call should use the canonical command name.
|
||||
call_args = runner.hooks.emit_collect.await_args
|
||||
assert call_args.args[0] == "command:status"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_hook_deny_without_message_uses_default(monkeypatch):
|
||||
"""A deny decision with no message falls back to a generic blocked string."""
|
||||
import gateway.run as gateway_run
|
||||
|
||||
runner = _make_runner()
|
||||
runner._handle_status_command = AsyncMock(
|
||||
side_effect=AssertionError("denied slash command reached its handler")
|
||||
)
|
||||
runner.hooks.emit_collect = AsyncMock(return_value=[{"decision": "deny"}])
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/status"))
|
||||
|
||||
assert result is not None
|
||||
assert "blocked" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_hook_can_mark_command_as_handled(monkeypatch):
|
||||
"""A handled decision short-circuits dispatch cleanly with a custom reply."""
|
||||
import gateway.run as gateway_run
|
||||
|
||||
runner = _make_runner()
|
||||
runner._handle_status_command = AsyncMock(
|
||||
side_effect=AssertionError("handled slash command reached its handler")
|
||||
)
|
||||
runner.hooks.emit_collect = AsyncMock(
|
||||
return_value=[{"decision": "handled", "message": "Already handled upstream"}]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/status"))
|
||||
|
||||
assert result == "Already handled upstream"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_hook_allow_decision_is_passthrough(monkeypatch):
|
||||
"""A handler returning {"decision": "allow"} must NOT prevent normal dispatch."""
|
||||
import gateway.run as gateway_run
|
||||
|
||||
runner = _make_runner()
|
||||
runner._handle_status_command = AsyncMock(return_value="status: ok")
|
||||
runner.hooks.emit_collect = AsyncMock(
|
||||
return_value=[{"decision": "allow"}]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/status"))
|
||||
|
||||
assert result == "status: ok"
|
||||
runner._handle_status_command.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_hook_non_dict_return_values_ignored(monkeypatch):
|
||||
"""Hook return values that aren't dicts must not break dispatch."""
|
||||
import gateway.run as gateway_run
|
||||
|
||||
runner = _make_runner()
|
||||
runner._handle_status_command = AsyncMock(return_value="status: ok")
|
||||
runner.hooks.emit_collect = AsyncMock(
|
||||
return_value=["some string", 42, None, {}]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/status"))
|
||||
|
||||
assert result == "status: ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_hook_fires_for_plugin_registered_command(monkeypatch):
|
||||
"""Plugin-registered slash commands should also trigger command:<name> hooks."""
|
||||
import gateway.run as gateway_run
|
||||
|
||||
runner = _make_runner()
|
||||
runner._run_agent = AsyncMock(
|
||||
side_effect=AssertionError("plugin command leaked to the agent")
|
||||
)
|
||||
runner.hooks.emit_collect = AsyncMock(
|
||||
return_value=[{"decision": "handled", "message": "intercepted"}]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
||||
)
|
||||
# Stub plugin command lookup so is_gateway_known_command() recognizes /metricas.
|
||||
from hermes_cli import plugins as _plugins_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
_plugins_mod,
|
||||
"get_plugin_commands",
|
||||
lambda: {"metricas": {"description": "Metrics", "args_hint": "dias:7"}},
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/metricas dias:7"))
|
||||
|
||||
assert result == "intercepted"
|
||||
# Hook event name uses the plugin command as canonical.
|
||||
call_args = runner.hooks.emit_collect.await_args
|
||||
assert call_args.args[0] == "command:metricas"
|
||||
# Args are passed through in both "args" and "raw_args" keys.
|
||||
ctx = call_args.args[1]
|
||||
assert ctx["raw_args"] == "dias:7"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_hook_rewrite_routes_to_plugin(monkeypatch):
|
||||
"""A rewrite decision should re-resolve the command and route to the new one."""
|
||||
import gateway.run as gateway_run
|
||||
|
||||
runner = _make_runner()
|
||||
runner._run_agent = AsyncMock(
|
||||
side_effect=AssertionError("rewritten command leaked to the agent")
|
||||
)
|
||||
|
||||
call_log = []
|
||||
|
||||
async def _emit_collect(event_type, ctx):
|
||||
call_log.append(event_type)
|
||||
if event_type == "command:status":
|
||||
return [
|
||||
{
|
||||
"decision": "rewrite",
|
||||
"command_name": "metricas",
|
||||
"raw_args": "dias:7",
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
runner.hooks.emit_collect = AsyncMock(side_effect=_emit_collect)
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
||||
)
|
||||
from hermes_cli import plugins as _plugins_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
_plugins_mod,
|
||||
"get_plugin_commands",
|
||||
lambda: {"metricas": {"description": "Metrics", "args_hint": "dias:7"}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_plugins_mod,
|
||||
"get_plugin_command_handler",
|
||||
lambda name: (lambda args: f"metrics {args}") if name == "metricas" else None,
|
||||
)
|
||||
|
||||
result = await runner._handle_message(_make_event("/status"))
|
||||
|
||||
assert result == "metrics dias:7"
|
||||
# First emit_collect fires on the original command; after rewrite the
|
||||
# dispatcher does NOT re-fire for the new command (one decision per turn).
|
||||
assert call_log == ["command:status"]
|
||||
|
||||
@@ -15,6 +15,8 @@ from hermes_cli.auth import (
|
||||
get_auth_status,
|
||||
AuthError,
|
||||
KIMI_CODE_BASE_URL,
|
||||
STEPFUN_STEP_PLAN_INTL_BASE_URL,
|
||||
STEPFUN_STEP_PLAN_CN_BASE_URL,
|
||||
_resolve_kimi_base_url,
|
||||
)
|
||||
from hermes_cli.copilot_auth import _try_gh_cli_token
|
||||
@@ -35,6 +37,7 @@ class TestProviderRegistry:
|
||||
("xai", "xAI", "api_key"),
|
||||
("nvidia", "NVIDIA NIM", "api_key"),
|
||||
("kimi-coding", "Kimi / Moonshot", "api_key"),
|
||||
("stepfun", "StepFun Step Plan", "api_key"),
|
||||
("minimax", "MiniMax", "api_key"),
|
||||
("minimax-cn", "MiniMax (China)", "api_key"),
|
||||
("ai-gateway", "Vercel AI Gateway", "api_key"),
|
||||
@@ -83,6 +86,11 @@ class TestProviderRegistry:
|
||||
assert pconfig.api_key_env_vars == ("MINIMAX_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "MINIMAX_BASE_URL"
|
||||
|
||||
def test_stepfun_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["stepfun"]
|
||||
assert pconfig.api_key_env_vars == ("STEPFUN_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "STEPFUN_BASE_URL"
|
||||
|
||||
def test_minimax_cn_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["minimax-cn"]
|
||||
assert pconfig.api_key_env_vars == ("MINIMAX_CN_API_KEY",)
|
||||
@@ -108,6 +116,7 @@ class TestProviderRegistry:
|
||||
assert PROVIDER_REGISTRY["copilot-acp"].inference_base_url == "acp://copilot"
|
||||
assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4"
|
||||
assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1"
|
||||
assert PROVIDER_REGISTRY["stepfun"].inference_base_url == STEPFUN_STEP_PLAN_INTL_BASE_URL
|
||||
assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/anthropic"
|
||||
assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/anthropic"
|
||||
assert PROVIDER_REGISTRY["ai-gateway"].inference_base_url == "https://ai-gateway.vercel.sh/v1"
|
||||
@@ -130,7 +139,8 @@ PROVIDER_ENV_VARS = (
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY",
|
||||
"KIMI_API_KEY", "KIMI_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
|
||||
"KIMI_API_KEY", "KIMI_BASE_URL", "STEPFUN_API_KEY", "STEPFUN_BASE_URL",
|
||||
"MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
|
||||
"AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL",
|
||||
"KILOCODE_API_KEY", "KILOCODE_BASE_URL",
|
||||
"DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
|
||||
@@ -156,6 +166,9 @@ class TestResolveProvider:
|
||||
def test_explicit_kimi_coding(self):
|
||||
assert resolve_provider("kimi-coding") == "kimi-coding"
|
||||
|
||||
def test_explicit_stepfun(self):
|
||||
assert resolve_provider("stepfun") == "stepfun"
|
||||
|
||||
def test_explicit_minimax(self):
|
||||
assert resolve_provider("minimax") == "minimax"
|
||||
|
||||
@@ -180,6 +193,9 @@ class TestResolveProvider:
|
||||
def test_alias_moonshot(self):
|
||||
assert resolve_provider("moonshot") == "kimi-coding"
|
||||
|
||||
def test_alias_step(self):
|
||||
assert resolve_provider("step") == "stepfun"
|
||||
|
||||
def test_alias_minimax_underscore(self):
|
||||
assert resolve_provider("minimax_cn") == "minimax-cn"
|
||||
|
||||
@@ -248,6 +264,10 @@ class TestResolveProvider:
|
||||
monkeypatch.setenv("KIMI_API_KEY", "test-kimi-key")
|
||||
assert resolve_provider("auto") == "kimi-coding"
|
||||
|
||||
def test_auto_detects_stepfun_key(self, monkeypatch):
|
||||
monkeypatch.setenv("STEPFUN_API_KEY", "test-stepfun-key")
|
||||
assert resolve_provider("auto") == "stepfun"
|
||||
|
||||
def test_auto_detects_minimax_key(self, monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "test-mm-key")
|
||||
assert resolve_provider("auto") == "minimax"
|
||||
@@ -312,6 +332,13 @@ class TestApiKeyProviderStatus:
|
||||
status = get_api_key_provider_status("kimi-coding")
|
||||
assert status["base_url"] == "https://custom.kimi.example/v1"
|
||||
|
||||
def test_stepfun_status_uses_configured_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-key")
|
||||
monkeypatch.setenv("STEPFUN_BASE_URL", STEPFUN_STEP_PLAN_CN_BASE_URL)
|
||||
status = get_api_key_provider_status("stepfun")
|
||||
assert status["configured"] is True
|
||||
assert status["base_url"] == STEPFUN_STEP_PLAN_CN_BASE_URL
|
||||
|
||||
def test_copilot_status_uses_gh_cli_token(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_gh_cli_token")
|
||||
status = get_api_key_provider_status("copilot")
|
||||
@@ -429,6 +456,19 @@ class TestResolveApiKeyProviderCredentials:
|
||||
assert creds["api_key"] == "kimi-secret-key"
|
||||
assert creds["base_url"] == "https://api.moonshot.ai/v1"
|
||||
|
||||
def test_resolve_stepfun_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("stepfun")
|
||||
assert creds["provider"] == "stepfun"
|
||||
assert creds["api_key"] == "stepfun-secret-key"
|
||||
assert creds["base_url"] == STEPFUN_STEP_PLAN_INTL_BASE_URL
|
||||
|
||||
def test_resolve_stepfun_custom_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-secret-key")
|
||||
monkeypatch.setenv("STEPFUN_BASE_URL", STEPFUN_STEP_PLAN_CN_BASE_URL)
|
||||
creds = resolve_api_key_provider_credentials("stepfun")
|
||||
assert creds["base_url"] == STEPFUN_STEP_PLAN_CN_BASE_URL
|
||||
|
||||
def test_resolve_minimax_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "mm-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("minimax")
|
||||
@@ -519,6 +559,16 @@ class TestRuntimeProviderResolution:
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "kimi-key"
|
||||
|
||||
def test_runtime_stepfun(self, monkeypatch):
|
||||
monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-key")
|
||||
monkeypatch.setenv("STEPFUN_BASE_URL", STEPFUN_STEP_PLAN_CN_BASE_URL)
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="stepfun")
|
||||
assert result["provider"] == "stepfun"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "stepfun-key"
|
||||
assert result["base_url"] == STEPFUN_STEP_PLAN_CN_BASE_URL
|
||||
|
||||
def test_runtime_minimax(self, monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "mm-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
@@ -1208,3 +1208,119 @@ class TestDiscordSkillCommandsByCategory:
|
||||
assert "axolotl" in names
|
||||
assert "vllm" in names
|
||||
assert len(uncategorized) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin slash command integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPluginCommandEnumeration:
|
||||
"""Plugin commands registered via ctx.register_command() must be surfaced
|
||||
by every gateway enumerator (Telegram menu, Slack subcommand map, etc.).
|
||||
"""
|
||||
|
||||
def _patch_plugin_commands(self, monkeypatch, commands):
|
||||
"""Monkeypatch hermes_cli.plugins.get_plugin_commands() to a fixed dict."""
|
||||
from hermes_cli import plugins as _plugins_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
_plugins_mod, "get_plugin_commands", lambda: dict(commands)
|
||||
)
|
||||
|
||||
def test_plugin_command_appears_in_telegram_menu(self, monkeypatch):
|
||||
"""/metricas registered by a plugin must appear in Telegram BotCommand menu."""
|
||||
self._patch_plugin_commands(monkeypatch, {
|
||||
"metricas": {
|
||||
"handler": lambda _a: "ok",
|
||||
"description": "Metrics dashboard",
|
||||
"args_hint": "dias:7",
|
||||
"plugin": "metrics-plugin",
|
||||
}
|
||||
})
|
||||
names = {name for name, _desc in telegram_bot_commands()}
|
||||
assert "metricas" in names
|
||||
|
||||
def test_plugin_command_appears_in_slack_subcommand_map(self, monkeypatch):
|
||||
"""/hermes metricas must route through the Slack subcommand map."""
|
||||
self._patch_plugin_commands(monkeypatch, {
|
||||
"metricas": {
|
||||
"handler": lambda _a: "ok",
|
||||
"description": "Metrics",
|
||||
"args_hint": "",
|
||||
"plugin": "metrics-plugin",
|
||||
}
|
||||
})
|
||||
mapping = slack_subcommand_map()
|
||||
assert mapping.get("metricas") == "/metricas"
|
||||
|
||||
def test_plugin_command_does_not_shadow_builtin_in_slack(self, monkeypatch):
|
||||
"""If a plugin registers a name that collides with a built-in, the built-in mapping wins."""
|
||||
self._patch_plugin_commands(monkeypatch, {
|
||||
"status": {
|
||||
"handler": lambda _a: "plugin-status",
|
||||
"description": "Plugin status",
|
||||
"args_hint": "",
|
||||
"plugin": "shadow-plugin",
|
||||
}
|
||||
})
|
||||
mapping = slack_subcommand_map()
|
||||
# Built-in /status must still be present and not overwritten.
|
||||
assert mapping.get("status") == "/status"
|
||||
|
||||
def test_plugin_command_with_hyphens_sanitized_for_telegram(self, monkeypatch):
|
||||
"""Plugin names containing hyphens must be underscore-normalized for Telegram."""
|
||||
self._patch_plugin_commands(monkeypatch, {
|
||||
"my-plugin-cmd": {
|
||||
"handler": lambda _a: "ok",
|
||||
"description": "desc",
|
||||
"args_hint": "",
|
||||
"plugin": "p",
|
||||
}
|
||||
})
|
||||
names = {name for name, _desc in telegram_bot_commands()}
|
||||
assert "my_plugin_cmd" in names
|
||||
assert "my-plugin-cmd" not in names
|
||||
|
||||
def test_is_gateway_known_command_recognizes_plugin_commands(self, monkeypatch):
|
||||
"""is_gateway_known_command() must return True for plugin commands."""
|
||||
from hermes_cli.commands import is_gateway_known_command
|
||||
|
||||
self._patch_plugin_commands(monkeypatch, {
|
||||
"metricas": {
|
||||
"handler": lambda _a: "ok",
|
||||
"description": "Metrics",
|
||||
"args_hint": "",
|
||||
"plugin": "p",
|
||||
}
|
||||
})
|
||||
assert is_gateway_known_command("metricas") is True
|
||||
assert is_gateway_known_command("definitely-not-registered") is False
|
||||
|
||||
def test_is_gateway_known_command_still_recognizes_builtins(self, monkeypatch):
|
||||
"""Built-in commands must remain known even when plugin discovery fails."""
|
||||
from hermes_cli import plugins as _plugins_mod
|
||||
from hermes_cli.commands import is_gateway_known_command
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("plugin system down")
|
||||
|
||||
monkeypatch.setattr(_plugins_mod, "get_plugin_commands", _boom)
|
||||
|
||||
assert is_gateway_known_command("status") is True
|
||||
assert is_gateway_known_command(None) is False
|
||||
assert is_gateway_known_command("") is False
|
||||
|
||||
def test_plugin_enumerator_handles_missing_plugin_manager(self, monkeypatch):
|
||||
"""Enumerators must never raise when plugin discovery raises."""
|
||||
from hermes_cli import plugins as _plugins_mod
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("plugin system down")
|
||||
|
||||
monkeypatch.setattr(_plugins_mod, "get_plugin_commands", _boom)
|
||||
|
||||
# Both calls should succeed and just return the built-in set.
|
||||
tg_names = {name for name, _desc in telegram_bot_commands()}
|
||||
slack_names = set(slack_subcommand_map())
|
||||
assert "status" in tg_names
|
||||
assert "status" in slack_names
|
||||
|
||||
+172
-30
@@ -137,50 +137,105 @@ class TestUploadToPastebin:
|
||||
# Log reading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadFullLog:
|
||||
"""Test _read_full_log for standalone log uploads."""
|
||||
class TestCaptureLogSnapshot:
|
||||
"""Test _capture_log_snapshot for log reading and truncation."""
|
||||
|
||||
def test_reads_small_file(self, hermes_home):
|
||||
from hermes_cli.debug import _read_full_log
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
|
||||
content = _read_full_log("agent")
|
||||
assert content is not None
|
||||
assert "session started" in content
|
||||
snap = _capture_log_snapshot("agent", tail_lines=10)
|
||||
assert snap.full_text is not None
|
||||
assert "session started" in snap.full_text
|
||||
assert "session started" in snap.tail_text
|
||||
|
||||
def test_returns_none_for_missing(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
from hermes_cli.debug import _read_full_log
|
||||
assert _read_full_log("agent") is None
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
snap = _capture_log_snapshot("agent", tail_lines=10)
|
||||
assert snap.full_text is None
|
||||
assert snap.tail_text == "(file not found)"
|
||||
|
||||
def test_returns_none_for_empty(self, hermes_home):
|
||||
# Truncate agent.log to empty
|
||||
def test_empty_primary_reports_file_empty(self, hermes_home):
|
||||
"""Empty primary (no .1 fallback) surfaces as '(file empty)', not missing."""
|
||||
(hermes_home / "logs" / "agent.log").write_text("")
|
||||
|
||||
from hermes_cli.debug import _read_full_log
|
||||
assert _read_full_log("agent") is None
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
snap = _capture_log_snapshot("agent", tail_lines=10)
|
||||
assert snap.full_text is None
|
||||
assert snap.tail_text == "(file empty)"
|
||||
|
||||
def test_race_truncate_after_resolve_reports_empty(self, hermes_home, monkeypatch):
|
||||
"""If the log is truncated between resolve and stat, say 'empty', not 'missing'."""
|
||||
log_path = hermes_home / "logs" / "agent.log"
|
||||
from hermes_cli import debug
|
||||
|
||||
monkeypatch.setattr(debug, "_resolve_log_path", lambda _name: log_path)
|
||||
log_path.write_text("")
|
||||
|
||||
snap = debug._capture_log_snapshot("agent", tail_lines=10)
|
||||
assert snap.path == log_path
|
||||
assert snap.full_text is None
|
||||
assert snap.tail_text == "(file empty)"
|
||||
|
||||
def test_truncates_large_file(self, hermes_home):
|
||||
"""Files larger than max_bytes get tail-truncated."""
|
||||
from hermes_cli.debug import _read_full_log
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
|
||||
# Write a file larger than 1KB
|
||||
big_content = "x" * 100 + "\n"
|
||||
(hermes_home / "logs" / "agent.log").write_text(big_content * 200)
|
||||
|
||||
content = _read_full_log("agent", max_bytes=1024)
|
||||
assert content is not None
|
||||
assert "truncated" in content
|
||||
snap = _capture_log_snapshot("agent", tail_lines=10, max_bytes=1024)
|
||||
assert snap.full_text is not None
|
||||
assert "truncated" in snap.full_text
|
||||
|
||||
def test_keeps_first_line_when_truncation_on_boundary(self, hermes_home):
|
||||
"""When truncation lands on a line boundary, keep the first full line."""
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
|
||||
# File must exceed the initial chunk_size (8192) used by the
|
||||
# backward-reading loop so the truncation path actually fires.
|
||||
line = "A" * 99 + "\n" # 100 bytes per line
|
||||
num_lines = 200 # 20000 bytes
|
||||
(hermes_home / "logs" / "agent.log").write_text(line * num_lines)
|
||||
|
||||
# max_bytes = 1000 = 100 * 10 → cut at byte 20000 - 1000 = 19000,
|
||||
# and byte 19000 - 1 is '\n'. Boundary hit → keep all 10 lines.
|
||||
snap = _capture_log_snapshot("agent", tail_lines=5, max_bytes=1000)
|
||||
assert snap.full_text is not None
|
||||
assert "truncated" in snap.full_text
|
||||
raw = snap.full_text.split("\n", 1)[1]
|
||||
kept = [l for l in raw.strip().splitlines() if l.startswith("A")]
|
||||
assert len(kept) == 10
|
||||
|
||||
def test_drops_partial_when_truncation_mid_line(self, hermes_home):
|
||||
"""When truncation lands mid-line, drop the partial fragment."""
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
|
||||
line = "A" * 99 + "\n" # 100 bytes per line
|
||||
num_lines = 200 # 20000 bytes
|
||||
(hermes_home / "logs" / "agent.log").write_text(line * num_lines)
|
||||
|
||||
# max_bytes = 950 doesn't divide evenly into 100 → mid-line cut.
|
||||
snap = _capture_log_snapshot("agent", tail_lines=5, max_bytes=950)
|
||||
assert snap.full_text is not None
|
||||
assert "truncated" in snap.full_text
|
||||
raw = snap.full_text.split("\n", 1)[1]
|
||||
kept = [l for l in raw.strip().splitlines() if l.startswith("A")]
|
||||
# 950 / 100 = 9.5 → 9 complete lines after dropping partial
|
||||
assert len(kept) == 9
|
||||
|
||||
def test_unknown_log_returns_none(self, hermes_home):
|
||||
from hermes_cli.debug import _read_full_log
|
||||
assert _read_full_log("nonexistent") is None
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
snap = _capture_log_snapshot("nonexistent", tail_lines=10)
|
||||
assert snap.full_text is None
|
||||
|
||||
def test_falls_back_to_rotated_file(self, hermes_home):
|
||||
"""When gateway.log doesn't exist, falls back to gateway.log.1."""
|
||||
from hermes_cli.debug import _read_full_log
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
|
||||
logs_dir = hermes_home / "logs"
|
||||
# Remove the primary (if any) and create a .1 rotation
|
||||
@@ -189,33 +244,33 @@ class TestReadFullLog:
|
||||
"2026-04-12 10:00:00 INFO gateway.run: rotated content\n"
|
||||
)
|
||||
|
||||
content = _read_full_log("gateway")
|
||||
assert content is not None
|
||||
assert "rotated content" in content
|
||||
snap = _capture_log_snapshot("gateway", tail_lines=10)
|
||||
assert snap.full_text is not None
|
||||
assert "rotated content" in snap.full_text
|
||||
|
||||
def test_prefers_primary_over_rotated(self, hermes_home):
|
||||
"""Primary log is used when it exists, even if .1 also exists."""
|
||||
from hermes_cli.debug import _read_full_log
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
|
||||
logs_dir = hermes_home / "logs"
|
||||
(logs_dir / "gateway.log").write_text("primary content\n")
|
||||
(logs_dir / "gateway.log.1").write_text("rotated content\n")
|
||||
|
||||
content = _read_full_log("gateway")
|
||||
assert "primary content" in content
|
||||
assert "rotated" not in content
|
||||
snap = _capture_log_snapshot("gateway", tail_lines=10)
|
||||
assert "primary content" in snap.full_text
|
||||
assert "rotated" not in snap.full_text
|
||||
|
||||
def test_falls_back_when_primary_empty(self, hermes_home):
|
||||
"""Empty primary log falls back to .1 rotation."""
|
||||
from hermes_cli.debug import _read_full_log
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
|
||||
logs_dir = hermes_home / "logs"
|
||||
(logs_dir / "agent.log").write_text("")
|
||||
(logs_dir / "agent.log.1").write_text("rotated agent data\n")
|
||||
|
||||
content = _read_full_log("agent")
|
||||
assert content is not None
|
||||
assert "rotated agent data" in content
|
||||
snap = _capture_log_snapshot("agent", tail_lines=10)
|
||||
assert snap.full_text is not None
|
||||
assert "rotated agent data" in snap.full_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -283,6 +338,44 @@ class TestCollectDebugReport:
|
||||
class TestRunDebugShare:
|
||||
"""Test the run_debug_share CLI handler."""
|
||||
|
||||
def test_share_sweeps_expired_pastes(self, hermes_home, capsys):
|
||||
"""Slash-command path should sweep old pending deletes before uploading."""
|
||||
from hermes_cli.debug import run_debug_share
|
||||
|
||||
args = MagicMock()
|
||||
args.lines = 50
|
||||
args.expire = 7
|
||||
args.local = False
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"), \
|
||||
patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)) as mock_sweep, \
|
||||
patch("hermes_cli.debug.upload_to_pastebin",
|
||||
return_value="https://paste.rs/test"):
|
||||
run_debug_share(args)
|
||||
|
||||
mock_sweep.assert_called_once()
|
||||
assert "Debug report uploaded" in capsys.readouterr().out
|
||||
|
||||
def test_share_survives_sweep_failure(self, hermes_home, capsys):
|
||||
"""Expired-paste cleanup is best-effort and must not block sharing."""
|
||||
from hermes_cli.debug import run_debug_share
|
||||
|
||||
args = MagicMock()
|
||||
args.lines = 50
|
||||
args.expire = 7
|
||||
args.local = False
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"), \
|
||||
patch(
|
||||
"hermes_cli.debug._sweep_expired_pastes",
|
||||
side_effect=RuntimeError("offline"),
|
||||
), \
|
||||
patch("hermes_cli.debug.upload_to_pastebin",
|
||||
return_value="https://paste.rs/test"):
|
||||
run_debug_share(args)
|
||||
|
||||
assert "https://paste.rs/test" in capsys.readouterr().out
|
||||
|
||||
def test_local_flag_prints_full_logs(self, hermes_home, capsys):
|
||||
"""--local prints the report plus full log contents."""
|
||||
from hermes_cli.debug import run_debug_share
|
||||
@@ -340,6 +433,55 @@ class TestRunDebugShare:
|
||||
assert "--- hermes dump ---" in gateway_paste
|
||||
assert "--- full gateway.log ---" in gateway_paste
|
||||
|
||||
def test_share_keeps_report_and_full_log_on_same_snapshot(self, hermes_home, capsys):
|
||||
"""A mid-run rotation must not make full agent.log older than the report."""
|
||||
from hermes_cli.debug import run_debug_share, collect_debug_report as real_collect_debug_report
|
||||
|
||||
logs_dir = hermes_home / "logs"
|
||||
(logs_dir / "agent.log").write_text(
|
||||
"2026-04-22 12:00:00 INFO agent: newest line\n"
|
||||
)
|
||||
(logs_dir / "agent.log.1").write_text(
|
||||
"2026-04-10 12:00:00 INFO agent: old rotated line\n"
|
||||
)
|
||||
|
||||
args = MagicMock()
|
||||
args.lines = 50
|
||||
args.expire = 7
|
||||
args.local = False
|
||||
|
||||
uploaded_content = []
|
||||
|
||||
def _mock_upload(content, expiry_days=7):
|
||||
uploaded_content.append(content)
|
||||
return f"https://paste.rs/paste{len(uploaded_content)}"
|
||||
|
||||
def _wrapped_collect_debug_report(*, log_lines=200, dump_text="", log_snapshots=None):
|
||||
report = real_collect_debug_report(
|
||||
log_lines=log_lines,
|
||||
dump_text=dump_text,
|
||||
log_snapshots=log_snapshots,
|
||||
)
|
||||
# Simulate the live log rotating after the report is built but
|
||||
# before the old implementation would have re-read agent.log for
|
||||
# standalone upload.
|
||||
(logs_dir / "agent.log").write_text("")
|
||||
(logs_dir / "agent.log.1").write_text(
|
||||
"2026-04-10 12:00:00 INFO agent: old rotated line\n"
|
||||
)
|
||||
return report
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"), \
|
||||
patch("hermes_cli.debug.collect_debug_report", side_effect=_wrapped_collect_debug_report), \
|
||||
patch("hermes_cli.debug.upload_to_pastebin", side_effect=_mock_upload):
|
||||
run_debug_share(args)
|
||||
|
||||
report_paste = uploaded_content[0]
|
||||
agent_paste = uploaded_content[1]
|
||||
assert "2026-04-22 12:00:00 INFO agent: newest line" in report_paste
|
||||
assert "2026-04-22 12:00:00 INFO agent: newest line" in agent_paste
|
||||
assert "old rotated line" not in agent_paste
|
||||
|
||||
def test_share_skips_missing_logs(self, tmp_path, monkeypatch, capsys):
|
||||
"""Only uploads logs that exist."""
|
||||
home = tmp_path / ".hermes"
|
||||
|
||||
@@ -33,6 +33,25 @@ def test_project_env_overrides_stale_shell_values_when_user_env_missing(tmp_path
|
||||
assert os.getenv("OPENAI_BASE_URL") == "https://project.example/v1"
|
||||
|
||||
|
||||
def test_project_env_is_sanitized_before_loading(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes"
|
||||
project_env = tmp_path / ".env"
|
||||
project_env.write_text(
|
||||
"TELEGRAM_BOT_TOKEN=8356550917:AAGGEkzg06Hrc3Hjb3Sa1jkGVDOdU_lYy2Q"
|
||||
"ANTHROPIC_API_KEY=sk-ant-test123\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
|
||||
loaded = load_hermes_dotenv(hermes_home=home, project_env=project_env)
|
||||
|
||||
assert loaded == [project_env]
|
||||
assert os.getenv("TELEGRAM_BOT_TOKEN") == "8356550917:AAGGEkzg06Hrc3Hjb3Sa1jkGVDOdU_lYy2Q"
|
||||
assert os.getenv("ANTHROPIC_API_KEY") == "sk-ant-test123"
|
||||
|
||||
|
||||
def test_user_env_takes_precedence_over_project_env(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
|
||||
@@ -121,6 +121,12 @@ def test_systemd_status_warns_when_linger_disabled(monkeypatch, tmp_path, capsys
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
if cmd[:3] == ["systemctl", "--user", "is-active"]:
|
||||
return SimpleNamespace(returncode=0, stdout="active\n", stderr="")
|
||||
if cmd[:3] == ["systemctl", "--user", "show"]:
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="ActiveState=active\nSubState=running\nResult=success\nExecMainStatus=0\n",
|
||||
stderr="",
|
||||
)
|
||||
raise AssertionError(f"Unexpected command: {cmd}")
|
||||
|
||||
monkeypatch.setattr(gateway.subprocess, "run", fake_run)
|
||||
@@ -352,3 +358,24 @@ class TestWaitForGatewayExit:
|
||||
|
||||
assert killed == 2
|
||||
assert calls == [(11, True), (22, True)]
|
||||
|
||||
|
||||
class TestStopProfileGateway:
|
||||
def test_stop_profile_gateway_keeps_pid_file_when_process_still_running(self, monkeypatch):
|
||||
calls = {"kill": 0, "remove": 0}
|
||||
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: 12345)
|
||||
monkeypatch.setattr(
|
||||
gateway.os,
|
||||
"kill",
|
||||
lambda pid, sig: calls.__setitem__("kill", calls["kill"] + 1),
|
||||
)
|
||||
monkeypatch.setattr("time.sleep", lambda _: None)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.remove_pid_file",
|
||||
lambda: calls.__setitem__("remove", calls["remove"] + 1),
|
||||
)
|
||||
|
||||
assert gateway.stop_profile_gateway() is True
|
||||
assert calls["kill"] == 21
|
||||
assert calls["remove"] == 0
|
||||
|
||||
@@ -77,8 +77,10 @@ class TestSystemdServiceRefresh:
|
||||
gateway_cli.systemd_restart()
|
||||
|
||||
assert unit_path.read_text(encoding="utf-8") == "new unit\n"
|
||||
assert calls[:2] == [
|
||||
assert calls[:4] == [
|
||||
["systemctl", "--user", "daemon-reload"],
|
||||
["systemctl", "--user", "show", gateway_cli.get_service_name(), "--no-pager", "--property", "ActiveState,SubState,Result,ExecMainStatus"],
|
||||
["systemctl", "--user", "reset-failed", gateway_cli.get_service_name()],
|
||||
["systemctl", "--user", "reload-or-restart", gateway_cli.get_service_name()],
|
||||
]
|
||||
|
||||
@@ -474,13 +476,21 @@ class TestGatewaySystemServiceRouting:
|
||||
raise ProcessLookupError()
|
||||
monkeypatch.setattr(os, "kill", fake_kill)
|
||||
|
||||
# Simulate systemctl is-active returning "active" with a new PID
|
||||
# Simulate systemctl reset-failed/start followed by an active unit
|
||||
new_pid = [None]
|
||||
def fake_subprocess_run(cmd, **kwargs):
|
||||
if "is-active" in cmd:
|
||||
result = SimpleNamespace(stdout="active\n", returncode=0)
|
||||
new_pid[0] = 999 # new PID
|
||||
return result
|
||||
if "reset-failed" in cmd:
|
||||
calls.append(("reset-failed", cmd))
|
||||
return SimpleNamespace(stdout="", returncode=0)
|
||||
if "start" in cmd:
|
||||
calls.append(("start", cmd))
|
||||
return SimpleNamespace(stdout="", returncode=0)
|
||||
if "show" in cmd:
|
||||
new_pid[0] = 999
|
||||
return SimpleNamespace(
|
||||
stdout="ActiveState=active\nSubState=running\nResult=success\nExecMainStatus=0\n",
|
||||
returncode=0,
|
||||
)
|
||||
raise AssertionError(f"Unexpected systemctl call: {cmd}")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_subprocess_run)
|
||||
@@ -494,9 +504,131 @@ class TestGatewaySystemServiceRouting:
|
||||
gateway_cli.systemd_restart()
|
||||
|
||||
assert ("self", 654) in calls
|
||||
assert any(call[0] == "reset-failed" for call in calls)
|
||||
assert any(call[0] == "start" for call in calls)
|
||||
out = capsys.readouterr().out.lower()
|
||||
assert "restarted" in out
|
||||
|
||||
def test_systemd_restart_recovers_failed_planned_restart(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False)
|
||||
monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.read_runtime_status",
|
||||
lambda: {"restart_requested": True, "gateway_state": "stopped"},
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False)
|
||||
|
||||
calls = []
|
||||
started = {"value": False}
|
||||
|
||||
def fake_subprocess_run(cmd, **kwargs):
|
||||
if "show" in cmd:
|
||||
if not started["value"]:
|
||||
return SimpleNamespace(
|
||||
stdout=(
|
||||
"ActiveState=failed\n"
|
||||
"SubState=failed\n"
|
||||
"Result=exit-code\n"
|
||||
f"ExecMainStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}\n"
|
||||
),
|
||||
returncode=0,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
stdout="ActiveState=active\nSubState=running\nResult=success\nExecMainStatus=0\n",
|
||||
returncode=0,
|
||||
)
|
||||
if "reset-failed" in cmd:
|
||||
calls.append(("reset-failed", cmd))
|
||||
return SimpleNamespace(stdout="", returncode=0)
|
||||
if "start" in cmd:
|
||||
started["value"] = True
|
||||
calls.append(("start", cmd))
|
||||
return SimpleNamespace(stdout="", returncode=0)
|
||||
raise AssertionError(f"Unexpected command: {cmd}")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_subprocess_run)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.get_running_pid",
|
||||
lambda: 999 if started["value"] else None,
|
||||
)
|
||||
|
||||
gateway_cli.systemd_restart()
|
||||
|
||||
assert any(call[0] == "reset-failed" for call in calls)
|
||||
assert any(call[0] == "start" for call in calls)
|
||||
out = capsys.readouterr().out.lower()
|
||||
assert "restarted" in out
|
||||
|
||||
def test_systemd_status_surfaces_planned_restart_failure(self, monkeypatch, capsys):
|
||||
unit = SimpleNamespace(exists=lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False)
|
||||
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit)
|
||||
monkeypatch.setattr(gateway_cli, "has_conflicting_systemd_units", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "has_legacy_hermes_units", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "systemd_unit_is_current", lambda system=False: True)
|
||||
monkeypatch.setattr(gateway_cli, "_runtime_health_lines", lambda: ["⚠ Last shutdown reason: Gateway restart requested"])
|
||||
monkeypatch.setattr(gateway_cli, "get_systemd_linger_status", lambda: (True, ""))
|
||||
monkeypatch.setattr(gateway_cli, "_read_systemd_unit_properties", lambda system=False: {
|
||||
"ActiveState": "failed",
|
||||
"SubState": "failed",
|
||||
"Result": "exit-code",
|
||||
"ExecMainStatus": str(GATEWAY_SERVICE_RESTART_EXIT_CODE),
|
||||
})
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run_systemctl(args, **kwargs):
|
||||
calls.append(args)
|
||||
if args[:2] == ["status", gateway_cli.get_service_name()]:
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
if args[:2] == ["is-active", gateway_cli.get_service_name()]:
|
||||
return SimpleNamespace(returncode=3, stdout="failed\n", stderr="")
|
||||
raise AssertionError(f"Unexpected args: {args}")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "_run_systemctl", fake_run_systemctl)
|
||||
|
||||
gateway_cli.systemd_status()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Planned restart is stuck in systemd failed state" in out
|
||||
|
||||
def test_gateway_status_dispatches_full_flag(self, monkeypatch):
|
||||
user_unit = SimpleNamespace(exists=lambda: True)
|
||||
system_unit = SimpleNamespace(exists=lambda: False)
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"get_systemd_unit_path",
|
||||
lambda system=False: system_unit if system else user_unit,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"get_gateway_runtime_snapshot",
|
||||
lambda system=False: gateway_cli.GatewayRuntimeSnapshot(
|
||||
manager="systemd (user)",
|
||||
service_installed=True,
|
||||
service_running=False,
|
||||
gateway_pids=(),
|
||||
service_scope="user",
|
||||
),
|
||||
)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"systemd_status",
|
||||
lambda deep=False, system=False, full=False: calls.append((deep, system, full)),
|
||||
)
|
||||
|
||||
gateway_cli.gateway_command(
|
||||
SimpleNamespace(gateway_command="status", deep=False, system=False, full=True)
|
||||
)
|
||||
|
||||
assert calls == [(False, False, True)]
|
||||
|
||||
def test_gateway_install_passes_system_flags(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
@@ -547,11 +679,15 @@ class TestGatewaySystemServiceRouting:
|
||||
)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(gateway_cli, "systemd_status", lambda deep=False, system=False: calls.append((deep, system)))
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"systemd_status",
|
||||
lambda deep=False, system=False, full=False: calls.append((deep, system, full)),
|
||||
)
|
||||
|
||||
gateway_cli.gateway_command(SimpleNamespace(gateway_command="status", deep=False, system=False))
|
||||
|
||||
assert calls == [(False, False)]
|
||||
assert calls == [(False, False, False)]
|
||||
|
||||
def test_gateway_status_reports_manual_process_when_service_is_stopped(self, monkeypatch, capsys):
|
||||
user_unit = SimpleNamespace(exists=lambda: True)
|
||||
@@ -565,7 +701,11 @@ class TestGatewaySystemServiceRouting:
|
||||
"get_systemd_unit_path",
|
||||
lambda system=False: system_unit if system else user_unit,
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "systemd_status", lambda deep=False, system=False: print("service stopped"))
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"systemd_status",
|
||||
lambda deep=False, system=False, full=False: print("service stopped"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"get_gateway_runtime_snapshot",
|
||||
@@ -1570,6 +1710,23 @@ class TestMigrateLegacyCommand:
|
||||
|
||||
assert called == {"interactive": False, "dry_run": False}
|
||||
|
||||
|
||||
class TestGatewayStatusParser:
|
||||
def test_gateway_status_subparser_accepts_full_flag(self):
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", "gateway", "status", "-l", "--help"],
|
||||
cwd=str(gateway_cli.PROJECT_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "unrecognized arguments" not in result.stderr
|
||||
|
||||
def test_gateway_command_migrate_legacy_dry_run_passes_through(
|
||||
self, monkeypatch
|
||||
):
|
||||
|
||||
@@ -32,6 +32,8 @@ def config_home(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.delenv("STEPFUN_API_KEY", raising=False)
|
||||
monkeypatch.delenv("STEPFUN_BASE_URL", raising=False)
|
||||
return home
|
||||
|
||||
|
||||
@@ -330,3 +332,33 @@ class TestBaseUrlValidation:
|
||||
|
||||
saved = get_env_value("GLM_BASE_URL") or ""
|
||||
assert saved == "", "Empty input should not save a base URL"
|
||||
|
||||
def test_stepfun_provider_saved_with_selected_region(self, config_home, monkeypatch):
|
||||
from hermes_cli.main import _model_flow_stepfun
|
||||
from hermes_cli.config import load_config, get_env_value
|
||||
|
||||
monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-test-key")
|
||||
|
||||
with patch(
|
||||
"hermes_cli.main._prompt_provider_choice",
|
||||
return_value=1,
|
||||
), patch(
|
||||
"hermes_cli.models.fetch_api_models",
|
||||
return_value=["step-3.5-flash", "step-3-agent-lite"],
|
||||
), patch(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
return_value="step-3-agent-lite",
|
||||
), patch(
|
||||
"hermes_cli.auth.deactivate_provider",
|
||||
):
|
||||
_model_flow_stepfun(load_config(), "old-model")
|
||||
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model.get("provider") == "stepfun"
|
||||
assert model.get("default") == "step-3-agent-lite"
|
||||
assert model.get("base_url") == "https://api.stepfun.com/step_plan/v1"
|
||||
assert get_env_value("STEPFUN_BASE_URL") == "https://api.stepfun.com/step_plan/v1"
|
||||
|
||||
@@ -63,6 +63,11 @@ class TestParseModelInput:
|
||||
assert provider == "zai"
|
||||
assert model == "glm-5"
|
||||
|
||||
def test_stepfun_alias_resolved(self):
|
||||
provider, model = parse_model_input("step:step-3.5-flash", "openrouter")
|
||||
assert provider == "stepfun"
|
||||
assert model == "step-3.5-flash"
|
||||
|
||||
def test_no_slash_no_colon_keeps_provider(self):
|
||||
provider, model = parse_model_input("gpt-5.4", "openrouter")
|
||||
assert provider == "openrouter"
|
||||
@@ -154,6 +159,7 @@ class TestNormalizeProvider:
|
||||
assert normalize_provider("glm") == "zai"
|
||||
assert normalize_provider("kimi") == "kimi-coding"
|
||||
assert normalize_provider("moonshot") == "kimi-coding"
|
||||
assert normalize_provider("step") == "stepfun"
|
||||
assert normalize_provider("github-copilot") == "copilot"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
@@ -164,6 +170,7 @@ class TestProviderLabel:
|
||||
def test_known_labels_and_auto(self):
|
||||
assert provider_label("anthropic") == "Anthropic"
|
||||
assert provider_label("kimi") == "Kimi / Kimi Coding Plan"
|
||||
assert provider_label("stepfun") == "StepFun Step Plan"
|
||||
assert provider_label("copilot") == "GitHub Copilot"
|
||||
assert provider_label("copilot-acp") == "GitHub Copilot ACP"
|
||||
assert provider_label("auto") == "Auto"
|
||||
@@ -193,6 +200,16 @@ class TestProviderModelIds:
|
||||
def test_zai_returns_glm_models(self):
|
||||
assert "glm-5" in provider_model_ids("zai")
|
||||
|
||||
def test_stepfun_prefers_live_catalog(self):
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={"api_key": "***", "base_url": "https://api.stepfun.com/step_plan/v1"},
|
||||
), patch(
|
||||
"hermes_cli.models.fetch_api_models",
|
||||
return_value=["step-3.5-flash", "step-3-agent-lite"],
|
||||
):
|
||||
assert provider_model_ids("stepfun") == ["step-3.5-flash", "step-3-agent-lite"]
|
||||
|
||||
def test_copilot_prefers_live_catalog(self):
|
||||
with patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "gh-token"}), \
|
||||
patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):
|
||||
|
||||
@@ -250,6 +250,73 @@ class TestPluginLoading:
|
||||
|
||||
assert "hermes_plugins.ns_plugin" in sys.modules
|
||||
|
||||
def test_user_memory_plugin_auto_coerced_to_exclusive(self, tmp_path, monkeypatch):
|
||||
"""User-installed memory plugins must NOT be loaded by the general
|
||||
PluginManager — they belong to plugins/memory discovery.
|
||||
|
||||
Regression test for the mempalace crash:
|
||||
'PluginContext' object has no attribute 'register_memory_provider'
|
||||
|
||||
A plugin that calls ``ctx.register_memory_provider`` in its
|
||||
``__init__.py`` should be auto-detected and treated as
|
||||
``kind: exclusive`` so the general loader records the manifest but
|
||||
does not import/register() it. The real activation happens through
|
||||
``plugins/memory/__init__.py`` via ``memory.provider`` config.
|
||||
"""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "mempalace"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
# No explicit `kind:` — the heuristic should kick in.
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "mempalace"}))
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"class MemPalaceProvider:\n"
|
||||
" pass\n"
|
||||
"def register(ctx):\n"
|
||||
" ctx.register_memory_provider('mempalace', MemPalaceProvider)\n"
|
||||
)
|
||||
# Even if the user explicitly enables it in config, the loader
|
||||
# should still treat it as exclusive and skip general loading.
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"enabled": ["mempalace"]}})
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert "mempalace" in mgr._plugins
|
||||
entry = mgr._plugins["mempalace"]
|
||||
assert entry.manifest.kind == "exclusive", (
|
||||
f"Expected auto-coerced kind='exclusive', got {entry.manifest.kind}"
|
||||
)
|
||||
# Not loaded by general manager (no register() call, no AttributeError).
|
||||
assert not entry.enabled
|
||||
assert entry.module is None
|
||||
assert "exclusive" in (entry.error or "").lower()
|
||||
|
||||
def test_explicit_standalone_kind_not_coerced(self, tmp_path, monkeypatch):
|
||||
"""If a plugin explicitly declares ``kind: standalone`` in its
|
||||
manifest, the memory-provider heuristic must NOT override it —
|
||||
even if the source happens to mention ``MemoryProvider``.
|
||||
"""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "not_memory"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
yaml.dump({"name": "not_memory", "kind": "standalone"})
|
||||
)
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"# This plugin inspects MemoryProvider docs but isn't one.\n"
|
||||
"def register(ctx):\n pass\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert mgr._plugins["not_memory"].manifest.kind == "standalone"
|
||||
|
||||
|
||||
# ── TestPluginHooks ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -720,6 +787,33 @@ class TestPluginCommands:
|
||||
assert entry["handler"] is handler
|
||||
assert entry["description"] == "My custom command"
|
||||
assert entry["plugin"] == "test-plugin"
|
||||
# args_hint defaults to empty string when not passed.
|
||||
assert entry["args_hint"] == ""
|
||||
|
||||
def test_register_command_with_args_hint(self):
|
||||
"""args_hint is stored and surfaced for gateway-native UI registration."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
ctx.register_command(
|
||||
"metricas",
|
||||
lambda a: a,
|
||||
description="Metrics dashboard",
|
||||
args_hint="dias:7 formato:json",
|
||||
)
|
||||
|
||||
entry = mgr._plugin_commands["metricas"]
|
||||
assert entry["args_hint"] == "dias:7 formato:json"
|
||||
|
||||
def test_register_command_args_hint_whitespace_trimmed(self):
|
||||
"""args_hint leading/trailing whitespace is stripped."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
ctx.register_command("foo", lambda a: a, args_hint=" <file> ")
|
||||
assert mgr._plugin_commands["foo"]["args_hint"] == "<file>"
|
||||
|
||||
def test_register_command_normalizes_name(self):
|
||||
"""Names are lowercased, stripped, and leading slashes removed."""
|
||||
|
||||
@@ -268,7 +268,6 @@ class TestCliBrandingHelpers:
|
||||
|
||||
def test_prompt_toolkit_style_overrides_cover_tui_classes(self):
|
||||
from hermes_cli.skin_engine import set_active_skin, get_prompt_toolkit_style_overrides
|
||||
|
||||
set_active_skin("ares")
|
||||
overrides = get_prompt_toolkit_style_overrides()
|
||||
required = {
|
||||
@@ -277,6 +276,13 @@ class TestCliBrandingHelpers:
|
||||
"prompt",
|
||||
"prompt-working",
|
||||
"hint",
|
||||
"status-bar",
|
||||
"status-bar-strong",
|
||||
"status-bar-dim",
|
||||
"status-bar-good",
|
||||
"status-bar-warn",
|
||||
"status-bar-bad",
|
||||
"status-bar-critical",
|
||||
"input-rule",
|
||||
"image-badge",
|
||||
"completion-menu",
|
||||
@@ -325,6 +331,15 @@ class TestCliBrandingHelpers:
|
||||
overrides = get_prompt_toolkit_style_overrides()
|
||||
assert overrides["prompt"] == skin.get_color("prompt")
|
||||
assert overrides["input-rule"] == skin.get_color("input_rule")
|
||||
assert overrides["status-bar"] == (
|
||||
f"bg:{skin.get_color('status_bar_bg')} {skin.get_color('status_bar_text')}"
|
||||
)
|
||||
assert overrides["status-bar-strong"] == (
|
||||
f"bg:{skin.get_color('status_bar_bg')} {skin.get_color('status_bar_strong')} bold"
|
||||
)
|
||||
assert overrides["status-bar-critical"] == (
|
||||
f"bg:{skin.get_color('status_bar_bg')} {skin.get_color('status_bar_critical')} bold"
|
||||
)
|
||||
assert overrides["clarify-title"] == f"{skin.get_color('banner_title')} bold"
|
||||
assert overrides["sudo-prompt"] == f"{skin.get_color('ui_error')} bold"
|
||||
assert overrides["approval-title"] == f"{skin.get_color('ui_warn')} bold"
|
||||
|
||||
@@ -706,6 +706,7 @@ class TestNewEndpoints:
|
||||
assert "skills" in data
|
||||
assert isinstance(data["daily"], list)
|
||||
assert "total_sessions" in data["totals"]
|
||||
assert "total_api_calls" in data["totals"]
|
||||
assert data["skills"] == {
|
||||
"summary": {
|
||||
"total_skill_loads": 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ turn counting, tags), and schema completeness.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -18,6 +19,7 @@ from plugins.memory.hindsight import (
|
||||
REFLECT_SCHEMA,
|
||||
RETAIN_SCHEMA,
|
||||
_load_config,
|
||||
_normalize_retain_tags,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,14 +34,30 @@ def _clean_env(monkeypatch):
|
||||
for key in (
|
||||
"HINDSIGHT_API_KEY", "HINDSIGHT_API_URL", "HINDSIGHT_BANK_ID",
|
||||
"HINDSIGHT_BUDGET", "HINDSIGHT_MODE", "HINDSIGHT_LLM_API_KEY",
|
||||
"HINDSIGHT_RETAIN_TAGS", "HINDSIGHT_RETAIN_SOURCE",
|
||||
"HINDSIGHT_RETAIN_USER_PREFIX", "HINDSIGHT_RETAIN_ASSISTANT_PREFIX",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def _make_mock_client():
|
||||
"""Create a mock Hindsight client with async methods."""
|
||||
async def _aretain(
|
||||
bank_id,
|
||||
content,
|
||||
timestamp=None,
|
||||
context=None,
|
||||
document_id=None,
|
||||
metadata=None,
|
||||
entities=None,
|
||||
tags=None,
|
||||
update_mode=None,
|
||||
retain_async=None,
|
||||
):
|
||||
return SimpleNamespace(ok=True)
|
||||
|
||||
client = MagicMock()
|
||||
client.aretain = AsyncMock()
|
||||
client.aretain = AsyncMock(side_effect=_aretain)
|
||||
client.arecall = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
results=[
|
||||
@@ -56,6 +74,14 @@ def _make_mock_client():
|
||||
return client
|
||||
|
||||
|
||||
class _FakeSessionDB:
|
||||
def __init__(self, messages=None):
|
||||
self._messages = list(messages or [])
|
||||
|
||||
def get_messages_as_conversation(self, session_id):
|
||||
return list(self._messages)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def provider(tmp_path, monkeypatch):
|
||||
"""Create an initialized HindsightMemoryProvider with a mock client."""
|
||||
@@ -109,6 +135,18 @@ def provider_with_config(tmp_path, monkeypatch):
|
||||
return _make
|
||||
|
||||
|
||||
def test_normalize_retain_tags_accepts_csv_and_dedupes():
|
||||
assert _normalize_retain_tags("agent:fakeassistantname, source_system:hermes-agent, agent:fakeassistantname") == [
|
||||
"agent:fakeassistantname",
|
||||
"source_system:hermes-agent",
|
||||
]
|
||||
|
||||
|
||||
def test_normalize_retain_tags_accepts_json_array_string():
|
||||
value = json.dumps(["agent:fakeassistantname", "source_system:hermes-agent"])
|
||||
assert _normalize_retain_tags(value) == ["agent:fakeassistantname", "source_system:hermes-agent"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -118,6 +156,7 @@ class TestSchemas:
|
||||
def test_retain_schema_has_content(self):
|
||||
assert RETAIN_SCHEMA["name"] == "hindsight_retain"
|
||||
assert "content" in RETAIN_SCHEMA["parameters"]["properties"]
|
||||
assert "tags" in RETAIN_SCHEMA["parameters"]["properties"]
|
||||
assert "content" in RETAIN_SCHEMA["parameters"]["required"]
|
||||
|
||||
def test_recall_schema_has_query(self):
|
||||
@@ -160,7 +199,10 @@ class TestConfig:
|
||||
|
||||
def test_custom_config_values(self, provider_with_config):
|
||||
p = provider_with_config(
|
||||
tags=["tag1", "tag2"],
|
||||
retain_tags=["tag1", "tag2"],
|
||||
retain_source="hermes",
|
||||
retain_user_prefix="User (fakeusername)",
|
||||
retain_assistant_prefix="Assistant (fakeassistantname)",
|
||||
recall_tags=["recall-tag"],
|
||||
recall_tags_match="all",
|
||||
auto_retain=False,
|
||||
@@ -175,6 +217,10 @@ class TestConfig:
|
||||
bank_mission="Test agent mission",
|
||||
)
|
||||
assert p._tags == ["tag1", "tag2"]
|
||||
assert p._retain_tags == ["tag1", "tag2"]
|
||||
assert p._retain_source == "hermes"
|
||||
assert p._retain_user_prefix == "User (fakeusername)"
|
||||
assert p._retain_assistant_prefix == "Assistant (fakeassistantname)"
|
||||
assert p._recall_tags == ["recall-tag"]
|
||||
assert p._recall_tags_match == "all"
|
||||
assert p._auto_retain is False
|
||||
@@ -222,11 +268,20 @@ class TestToolHandlers:
|
||||
assert call_kwargs["content"] == "user likes dark mode"
|
||||
|
||||
def test_retain_with_tags(self, provider_with_config):
|
||||
p = provider_with_config(tags=["pref", "ui"])
|
||||
p = provider_with_config(retain_tags=["pref", "ui"])
|
||||
p.handle_tool_call("hindsight_retain", {"content": "likes dark mode"})
|
||||
call_kwargs = p._client.aretain.call_args.kwargs
|
||||
assert call_kwargs["tags"] == ["pref", "ui"]
|
||||
|
||||
def test_retain_merges_per_call_tags_with_config_tags(self, provider_with_config):
|
||||
p = provider_with_config(retain_tags=["pref", "ui"])
|
||||
p.handle_tool_call(
|
||||
"hindsight_retain",
|
||||
{"content": "likes dark mode", "tags": ["client:x", "ui"]},
|
||||
)
|
||||
call_kwargs = p._client.aretain.call_args.kwargs
|
||||
assert call_kwargs["tags"] == ["pref", "ui", "client:x"]
|
||||
|
||||
def test_retain_without_tags(self, provider):
|
||||
provider.handle_tool_call("hindsight_retain", {"content": "hello"})
|
||||
call_kwargs = provider._client.aretain.call_args.kwargs
|
||||
@@ -389,38 +444,58 @@ class TestPrefetch:
|
||||
|
||||
|
||||
class TestSyncTurn:
|
||||
def _get_retain_kwargs(self, provider):
|
||||
"""Helper to get the kwargs from the aretain_batch call."""
|
||||
return provider._client.aretain_batch.call_args.kwargs
|
||||
def test_sync_turn_retains_metadata_rich_turn(self, provider_with_config):
|
||||
p = provider_with_config(
|
||||
retain_tags=["conv", "session1"],
|
||||
retain_source="hermes",
|
||||
retain_user_prefix="User (fakeusername)",
|
||||
retain_assistant_prefix="Assistant (fakeassistantname)",
|
||||
)
|
||||
p.initialize(
|
||||
session_id="session-1",
|
||||
platform="discord",
|
||||
user_id="fakeusername-123",
|
||||
user_name="fakeusername",
|
||||
chat_id="1485316232612941897",
|
||||
chat_name="fakeassistantname-forums",
|
||||
chat_type="thread",
|
||||
thread_id="1491249007475949698",
|
||||
agent_identity="fakeassistantname",
|
||||
)
|
||||
p._client = _make_mock_client()
|
||||
|
||||
def _get_retain_content(self, provider):
|
||||
"""Helper to get the raw content string from the first item."""
|
||||
kwargs = self._get_retain_kwargs(provider)
|
||||
return kwargs["items"][0]["content"]
|
||||
p.sync_turn("hello", "hi there")
|
||||
p._sync_thread.join(timeout=5.0)
|
||||
|
||||
def _get_retain_messages(self, provider):
|
||||
"""Helper to parse the first turn's messages from retained content.
|
||||
|
||||
Content is a JSON array of turns: [[msgs...], [msgs...], ...]
|
||||
For single-turn tests, returns the first turn's messages.
|
||||
"""
|
||||
content = self._get_retain_content(provider)
|
||||
turns = json.loads(content)
|
||||
return turns[0] if len(turns) == 1 else turns
|
||||
|
||||
def test_sync_turn_retains(self, provider):
|
||||
provider.sync_turn("hello", "hi there")
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=5.0)
|
||||
provider._client.aretain_batch.assert_called_once()
|
||||
messages = self._get_retain_messages(provider)
|
||||
assert len(messages) == 2
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[0]["content"] == "hello"
|
||||
assert "timestamp" in messages[0]
|
||||
assert messages[1]["role"] == "assistant"
|
||||
assert messages[1]["content"] == "hi there"
|
||||
assert "timestamp" in messages[1]
|
||||
p._client.aretain_batch.assert_called_once()
|
||||
call_kwargs = p._client.aretain_batch.call_args.kwargs
|
||||
assert call_kwargs["bank_id"] == "test-bank"
|
||||
assert call_kwargs["document_id"] == "session-1"
|
||||
assert call_kwargs["retain_async"] is True
|
||||
assert len(call_kwargs["items"]) == 1
|
||||
item = call_kwargs["items"][0]
|
||||
assert item["context"] == "conversation between Hermes Agent and the User"
|
||||
assert item["tags"] == ["conv", "session1"]
|
||||
content = json.loads(item["content"])
|
||||
assert len(content) == 1
|
||||
assert content[0][0]["role"] == "user"
|
||||
assert content[0][0]["content"] == "User (fakeusername): hello"
|
||||
assert content[0][1]["role"] == "assistant"
|
||||
assert content[0][1]["content"] == "Assistant (fakeassistantname): hi there"
|
||||
assert item["metadata"]["source"] == "hermes"
|
||||
assert item["metadata"]["session_id"] == "session-1"
|
||||
assert item["metadata"]["platform"] == "discord"
|
||||
assert item["metadata"]["user_id"] == "fakeusername-123"
|
||||
assert item["metadata"]["user_name"] == "fakeusername"
|
||||
assert item["metadata"]["chat_id"] == "1485316232612941897"
|
||||
assert item["metadata"]["chat_name"] == "fakeassistantname-forums"
|
||||
assert item["metadata"]["chat_type"] == "thread"
|
||||
assert item["metadata"]["thread_id"] == "1491249007475949698"
|
||||
assert item["metadata"]["agent_identity"] == "fakeassistantname"
|
||||
assert item["metadata"]["turn_index"] == "1"
|
||||
assert item["metadata"]["message_count"] == "2"
|
||||
assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+00:00", content[0][0]["timestamp"])
|
||||
assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", item["metadata"]["retained_at"])
|
||||
|
||||
def test_sync_turn_skipped_when_auto_retain_off(self, provider_with_config):
|
||||
p = provider_with_config(auto_retain=False)
|
||||
@@ -428,93 +503,33 @@ class TestSyncTurn:
|
||||
assert p._sync_thread is None
|
||||
p._client.aretain_batch.assert_not_called()
|
||||
|
||||
def test_sync_turn_with_tags(self, provider_with_config):
|
||||
p = provider_with_config(tags=["conv", "session1"])
|
||||
p.sync_turn("hello", "hi")
|
||||
if p._sync_thread:
|
||||
p._sync_thread.join(timeout=5.0)
|
||||
item = p._client.aretain_batch.call_args.kwargs["items"][0]
|
||||
assert item["tags"] == ["conv", "session1"]
|
||||
|
||||
def test_sync_turn_uses_aretain_batch(self, provider):
|
||||
"""sync_turn should use aretain_batch with retain_async."""
|
||||
provider.sync_turn("hello", "hi")
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=5.0)
|
||||
provider._client.aretain_batch.assert_called_once()
|
||||
call_kwargs = provider._client.aretain_batch.call_args.kwargs
|
||||
assert call_kwargs["document_id"] == "test-session"
|
||||
assert call_kwargs["retain_async"] is True
|
||||
assert len(call_kwargs["items"]) == 1
|
||||
assert call_kwargs["items"][0]["context"] == "conversation between Hermes Agent and the User"
|
||||
|
||||
def test_sync_turn_custom_context(self, provider_with_config):
|
||||
p = provider_with_config(retain_context="my-agent")
|
||||
p.sync_turn("hello", "hi")
|
||||
if p._sync_thread:
|
||||
p._sync_thread.join(timeout=5.0)
|
||||
item = p._client.aretain_batch.call_args.kwargs["items"][0]
|
||||
assert item["context"] == "my-agent"
|
||||
|
||||
def test_sync_turn_every_n_turns(self, provider_with_config):
|
||||
"""With retain_every_n_turns=3, only retains on every 3rd turn."""
|
||||
p = provider_with_config(retain_every_n_turns=3)
|
||||
|
||||
p = provider_with_config(retain_every_n_turns=3, retain_async=False)
|
||||
p.sync_turn("turn1-user", "turn1-asst")
|
||||
assert p._sync_thread is None # not retained yet
|
||||
|
||||
assert p._sync_thread is None
|
||||
p.sync_turn("turn2-user", "turn2-asst")
|
||||
assert p._sync_thread is None # not retained yet
|
||||
|
||||
assert p._sync_thread is None
|
||||
p.sync_turn("turn3-user", "turn3-asst")
|
||||
assert p._sync_thread is not None # retained!
|
||||
p._sync_thread.join(timeout=5.0)
|
||||
|
||||
p._client.aretain_batch.assert_called_once()
|
||||
content = p._client.aretain_batch.call_args.kwargs["items"][0]["content"]
|
||||
# Should contain all 3 turns
|
||||
assert "turn1-user" in content
|
||||
assert "turn2-user" in content
|
||||
assert "turn3-user" in content
|
||||
|
||||
def test_sync_turn_accumulates_full_session(self, provider_with_config):
|
||||
"""Each retain sends the ENTIRE session, not just the latest batch."""
|
||||
p = provider_with_config(retain_every_n_turns=2)
|
||||
|
||||
p.sync_turn("turn1-user", "turn1-asst")
|
||||
p.sync_turn("turn2-user", "turn2-asst")
|
||||
if p._sync_thread:
|
||||
p._sync_thread.join(timeout=5.0)
|
||||
|
||||
p._client.aretain_batch.reset_mock()
|
||||
|
||||
p.sync_turn("turn3-user", "turn3-asst")
|
||||
p.sync_turn("turn4-user", "turn4-asst")
|
||||
if p._sync_thread:
|
||||
p._sync_thread.join(timeout=5.0)
|
||||
|
||||
content = p._client.aretain_batch.call_args.kwargs["items"][0]["content"]
|
||||
# Should contain ALL turns from the session
|
||||
assert "turn1-user" in content
|
||||
assert "turn2-user" in content
|
||||
assert "turn3-user" in content
|
||||
assert "turn4-user" in content
|
||||
|
||||
def test_sync_turn_passes_document_id(self, provider):
|
||||
"""sync_turn should pass session_id as document_id for dedup."""
|
||||
provider.sync_turn("hello", "hi")
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=5.0)
|
||||
call_kwargs = provider._client.aretain_batch.call_args.kwargs
|
||||
call_kwargs = p._client.aretain_batch.call_args.kwargs
|
||||
assert call_kwargs["document_id"] == "test-session"
|
||||
assert call_kwargs["retain_async"] is False
|
||||
item = call_kwargs["items"][0]
|
||||
content = json.loads(item["content"])
|
||||
assert len(content) == 3
|
||||
assert content[-1][0]["role"] == "user"
|
||||
assert content[-1][0]["content"] == "User: turn3-user"
|
||||
assert content[-1][1]["role"] == "assistant"
|
||||
assert content[-1][1]["content"] == "Assistant: turn3-asst"
|
||||
assert item["metadata"]["turn_index"] == "3"
|
||||
assert item["metadata"]["message_count"] == "6"
|
||||
|
||||
def test_sync_turn_error_does_not_raise(self, provider):
|
||||
"""Errors in sync_turn should be swallowed (non-blocking)."""
|
||||
provider._client.aretain_batch.side_effect = RuntimeError("network error")
|
||||
provider.sync_turn("hello", "hi")
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=5.0)
|
||||
# Should not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -555,10 +570,11 @@ class TestConfigSchema:
|
||||
"mode", "api_url", "api_key", "llm_provider", "llm_api_key",
|
||||
"llm_model", "bank_id", "bank_mission", "bank_retain_mission",
|
||||
"recall_budget", "memory_mode", "recall_prefetch_method",
|
||||
"tags", "recall_tags", "recall_tags_match",
|
||||
"retain_tags", "retain_source",
|
||||
"retain_user_prefix", "retain_assistant_prefix",
|
||||
"recall_tags", "recall_tags_match",
|
||||
"auto_recall", "auto_retain",
|
||||
"retain_every_n_turns", "retain_async",
|
||||
"retain_context",
|
||||
"retain_every_n_turns", "retain_async", "retain_context",
|
||||
"recall_max_tokens", "recall_max_input_chars",
|
||||
"recall_prompt_preamble",
|
||||
}
|
||||
|
||||
@@ -155,3 +155,29 @@ class TestFallbackChainAdvancement:
|
||||
]
|
||||
assert agent._try_activate_fallback() is True
|
||||
assert agent.model == "gpt-4o"
|
||||
|
||||
def test_resolves_key_env_for_fallback_provider(self):
|
||||
fbs = [
|
||||
{
|
||||
"provider": "custom",
|
||||
"model": "fallback-model",
|
||||
"base_url": "https://fallback.example/v1",
|
||||
"key_env": "MY_FALLBACK_KEY",
|
||||
}
|
||||
]
|
||||
agent = _make_agent(fallback_model=fbs)
|
||||
with (
|
||||
patch.dict("os.environ", {"MY_FALLBACK_KEY": "env-secret"}, clear=False),
|
||||
patch(
|
||||
"agent.auxiliary_client.resolve_provider_client",
|
||||
return_value=(
|
||||
_mock_client(
|
||||
base_url="https://fallback.example/v1",
|
||||
api_key="env-secret",
|
||||
),
|
||||
"fallback-model",
|
||||
),
|
||||
) as mock_rpc,
|
||||
):
|
||||
assert agent._try_activate_fallback() is True
|
||||
assert mock_rpc.call_args.kwargs["explicit_api_key"] == "env-secret"
|
||||
|
||||
@@ -1216,6 +1216,15 @@ class TestBuildAssistantMessage:
|
||||
result = agent._build_assistant_message(msg, "stop")
|
||||
assert result["reasoning"] == "thinking"
|
||||
|
||||
def test_reasoning_content_preserved_separately(self, agent):
|
||||
msg = _mock_assistant_msg(
|
||||
content="answer",
|
||||
reasoning="summary",
|
||||
reasoning_content="provider scratchpad",
|
||||
)
|
||||
result = agent._build_assistant_message(msg, "stop")
|
||||
assert result["reasoning_content"] == "provider scratchpad"
|
||||
|
||||
def test_with_tool_calls(self, agent):
|
||||
tc = _mock_tool_call(name="web_search", arguments='{"q":"test"}', call_id="c1")
|
||||
msg = _mock_assistant_msg(content="", tool_calls=[tc])
|
||||
@@ -4188,6 +4197,90 @@ class TestPersistUserMessageOverride:
|
||||
assert first_db_write["content"] == "Hello there"
|
||||
|
||||
|
||||
class TestReasoningReplayForStrictProviders:
|
||||
"""Assistant replay must preserve provider-native reasoning fields."""
|
||||
|
||||
def _setup_agent(self, agent):
|
||||
agent._cached_system_prompt = "You are helpful."
|
||||
agent._use_prompt_caching = False
|
||||
agent.tool_delay = 0
|
||||
agent.compression_enabled = False
|
||||
agent.save_trajectories = False
|
||||
|
||||
def test_kimi_tool_replay_includes_empty_reasoning_content(self, agent):
|
||||
self._setup_agent(agent)
|
||||
agent.base_url = "https://api.kimi.com/coding/v1"
|
||||
agent._base_url_lower = agent.base_url.lower()
|
||||
agent.provider = "kimi-coding"
|
||||
|
||||
prior_assistant = {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {"name": "terminal", "arguments": "{\"command\":\"date\"}"},
|
||||
}
|
||||
],
|
||||
}
|
||||
tool_result = {"role": "tool", "tool_call_id": "c1", "content": "Tue Apr 21"}
|
||||
final_resp = _mock_response(content="done", finish_reason="stop")
|
||||
agent.client.chat.completions.create.return_value = final_resp
|
||||
|
||||
with (
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation(
|
||||
"next step",
|
||||
conversation_history=[prior_assistant, tool_result],
|
||||
)
|
||||
|
||||
assert result["completed"] is True
|
||||
sent_messages = agent.client.chat.completions.create.call_args.kwargs["messages"]
|
||||
replayed_assistant = next(msg for msg in sent_messages if msg.get("role") == "assistant")
|
||||
assert replayed_assistant["role"] == "assistant"
|
||||
assert replayed_assistant["tool_calls"][0]["function"]["name"] == "terminal"
|
||||
assert "reasoning_content" in replayed_assistant
|
||||
assert replayed_assistant["reasoning_content"] == ""
|
||||
|
||||
def test_explicit_reasoning_content_beats_normalized_reasoning_on_replay(self, agent):
|
||||
self._setup_agent(agent)
|
||||
prior_assistant = {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {"name": "web_search", "arguments": "{\"q\":\"test\"}"},
|
||||
}
|
||||
],
|
||||
"reasoning": "summary reasoning",
|
||||
"reasoning_content": "provider-native scratchpad",
|
||||
}
|
||||
tool_result = {"role": "tool", "tool_call_id": "c1", "content": "ok"}
|
||||
final_resp = _mock_response(content="done", finish_reason="stop")
|
||||
agent.client.chat.completions.create.return_value = final_resp
|
||||
|
||||
with (
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation(
|
||||
"next step",
|
||||
conversation_history=[prior_assistant, tool_result],
|
||||
)
|
||||
|
||||
assert result["completed"] is True
|
||||
sent_messages = agent.client.chat.completions.create.call_args.kwargs["messages"]
|
||||
replayed_assistant = next(msg for msg in sent_messages if msg.get("role") == "assistant")
|
||||
assert replayed_assistant["reasoning_content"] == "provider-native scratchpad"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bugfix: _vprint force=True on error messages during TTS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1133,3 +1133,225 @@ class TestPartialToolCallWarning:
|
||||
f"Unexpected warning on text-only partial stream: {content!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestSilentRetryMidToolCall:
|
||||
"""Regression: when the stream dies mid tool-call JSON after text was
|
||||
already delivered, we previously stubbed the turn with a "retry manually"
|
||||
warning. Now: if the error is a transient connection error AND a tool
|
||||
call was in flight, silently retry the stream (the user sees a brief
|
||||
reconnect marker + duplicated preamble, which is strictly better than
|
||||
a lost action). If no tool call was in flight, or the error isn't
|
||||
transient, the existing stub-with-warning behaviour is preserved.
|
||||
"""
|
||||
|
||||
@patch("run_agent.AIAgent._replace_primary_openai_client")
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_silent_retry_recovers_tool_call(
|
||||
self, mock_close, mock_create, mock_replace,
|
||||
):
|
||||
"""First attempt: text + partial tool-call + connection drop.
|
||||
Second attempt: text + complete tool-call. Response should contain
|
||||
the recovered tool call; no warning stub should be returned."""
|
||||
from run_agent import AIAgent
|
||||
import httpx as _httpx
|
||||
|
||||
attempts = {"n": 0}
|
||||
|
||||
def _first_stream():
|
||||
yield _make_stream_chunk(content="Let me write the audit: ")
|
||||
yield _make_stream_chunk(tool_calls=[
|
||||
_make_tool_call_delta(index=0, tc_id="call_1", name="write_file"),
|
||||
])
|
||||
yield _make_stream_chunk(tool_calls=[
|
||||
_make_tool_call_delta(index=0, arguments='{"path": "/tmp/x", '),
|
||||
])
|
||||
raise _httpx.RemoteProtocolError("peer closed connection")
|
||||
|
||||
def _second_stream():
|
||||
yield _make_stream_chunk(content="Let me write the audit: ")
|
||||
yield _make_stream_chunk(tool_calls=[
|
||||
_make_tool_call_delta(index=0, tc_id="call_1", name="write_file"),
|
||||
])
|
||||
yield _make_stream_chunk(tool_calls=[
|
||||
_make_tool_call_delta(
|
||||
index=0, arguments='{"path": "/tmp/x", "content": "hi"}',
|
||||
),
|
||||
])
|
||||
yield _make_stream_chunk(finish_reason="tool_calls")
|
||||
|
||||
def _pick_stream(*a, **kw):
|
||||
attempts["n"] += 1
|
||||
return _first_stream() if attempts["n"] == 1 else _second_stream()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.side_effect = _pick_stream
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model="test/model",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent.api_mode = "chat_completions"
|
||||
agent._interrupt_requested = False
|
||||
|
||||
fired_deltas: list = []
|
||||
agent._fire_stream_delta = lambda text: fired_deltas.append(text)
|
||||
|
||||
import os as _os
|
||||
_prev = _os.environ.get("HERMES_STREAM_RETRIES")
|
||||
_os.environ["HERMES_STREAM_RETRIES"] = "2"
|
||||
try:
|
||||
response = agent._interruptible_streaming_api_call({})
|
||||
finally:
|
||||
if _prev is None:
|
||||
_os.environ.pop("HERMES_STREAM_RETRIES", None)
|
||||
else:
|
||||
_os.environ["HERMES_STREAM_RETRIES"] = _prev
|
||||
|
||||
assert attempts["n"] == 2, (
|
||||
f"Expected silent retry (2 attempts), got {attempts['n']}"
|
||||
)
|
||||
# Response should carry the recovered tool call, not a warning stub.
|
||||
msg = response.choices[0].message
|
||||
tool_calls = getattr(msg, "tool_calls", None)
|
||||
assert tool_calls, (
|
||||
f"Silent retry should recover the tool call, got tool_calls={tool_calls!r} "
|
||||
f"content={getattr(msg, 'content', None)!r}"
|
||||
)
|
||||
_tc0 = tool_calls[0]
|
||||
_name = (
|
||||
_tc0["function"]["name"] if isinstance(_tc0, dict)
|
||||
else _tc0.function.name
|
||||
)
|
||||
assert _name == "write_file"
|
||||
# User saw a reconnect marker between attempts.
|
||||
assert any("reconnecting" in d.lower() for d in fired_deltas), (
|
||||
f"Expected a reconnect marker delta, fired_deltas={fired_deltas}"
|
||||
)
|
||||
# Stub-path warning must NOT appear (this was the whole point).
|
||||
joined = "".join(fired_deltas)
|
||||
assert "Stream stalled" not in joined, (
|
||||
f"Stub-path warning leaked into silent-retry path: {joined!r}"
|
||||
)
|
||||
|
||||
@patch("run_agent.AIAgent._replace_primary_openai_client")
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_silent_retry_exhausted_falls_back_to_stub(
|
||||
self, mock_close, mock_create, mock_replace,
|
||||
):
|
||||
"""When all retry attempts fail with connection errors, fall back
|
||||
to the original stub-with-warning behaviour so the user isn't left
|
||||
with zero signal."""
|
||||
from run_agent import AIAgent
|
||||
import httpx as _httpx
|
||||
|
||||
def _always_fails():
|
||||
yield _make_stream_chunk(content="Let me write the audit: ")
|
||||
yield _make_stream_chunk(tool_calls=[
|
||||
_make_tool_call_delta(index=0, tc_id="call_1", name="write_file"),
|
||||
])
|
||||
raise _httpx.RemoteProtocolError("peer closed connection")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.side_effect = lambda *a, **kw: _always_fails()
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model="test/model",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent.api_mode = "chat_completions"
|
||||
agent._interrupt_requested = False
|
||||
|
||||
fired_deltas: list = []
|
||||
agent._fire_stream_delta = lambda text: fired_deltas.append(text)
|
||||
|
||||
import os as _os
|
||||
_prev = _os.environ.get("HERMES_STREAM_RETRIES")
|
||||
_os.environ["HERMES_STREAM_RETRIES"] = "1"
|
||||
try:
|
||||
response = agent._interruptible_streaming_api_call({})
|
||||
finally:
|
||||
if _prev is None:
|
||||
_os.environ.pop("HERMES_STREAM_RETRIES", None)
|
||||
else:
|
||||
_os.environ["HERMES_STREAM_RETRIES"] = _prev
|
||||
|
||||
# After retries exhaust, the stub-with-warning path must engage.
|
||||
content = response.choices[0].message.content or ""
|
||||
assert "Stream stalled mid tool-call" in content, (
|
||||
f"Exhausted-retry fallback dropped the user-visible warning: {content!r}"
|
||||
)
|
||||
assert response.choices[0].message.tool_calls is None
|
||||
|
||||
@patch("run_agent.AIAgent._replace_primary_openai_client")
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_no_silent_retry_for_text_only_stall(
|
||||
self, mock_close, mock_create, mock_replace,
|
||||
):
|
||||
"""Text-only stall (no tool call in flight) must NOT trigger silent
|
||||
retry — that's the case where the user saw the model's text reply
|
||||
and retrying would duplicate it with no benefit."""
|
||||
from run_agent import AIAgent
|
||||
import httpx as _httpx
|
||||
|
||||
attempts = {"n": 0}
|
||||
|
||||
def _text_stall(*a, **kw):
|
||||
attempts["n"] += 1
|
||||
|
||||
def _gen():
|
||||
yield _make_stream_chunk(content="Here's my answer so far")
|
||||
raise _httpx.RemoteProtocolError("peer closed connection")
|
||||
return _gen()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.side_effect = _text_stall
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="test-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model="test/model",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent.api_mode = "chat_completions"
|
||||
agent._interrupt_requested = False
|
||||
agent._current_streamed_assistant_text = "Here's my answer so far"
|
||||
|
||||
import os as _os
|
||||
_prev = _os.environ.get("HERMES_STREAM_RETRIES")
|
||||
_os.environ["HERMES_STREAM_RETRIES"] = "2"
|
||||
try:
|
||||
response = agent._interruptible_streaming_api_call({})
|
||||
finally:
|
||||
if _prev is None:
|
||||
_os.environ.pop("HERMES_STREAM_RETRIES", None)
|
||||
else:
|
||||
_os.environ["HERMES_STREAM_RETRIES"] = _prev
|
||||
|
||||
# Only one attempt: text-only stall short-circuits retry.
|
||||
assert attempts["n"] == 1, (
|
||||
f"Text-only stall should not silent-retry, got {attempts['n']} attempts"
|
||||
)
|
||||
content = response.choices[0].message.content or ""
|
||||
assert content == "Here's my answer so far", (
|
||||
f"Text-only stall regressed: {content!r}"
|
||||
)
|
||||
assert "Stream stalled" not in content, (
|
||||
f"Text-only stall should not emit tool-call warning: {content!r}"
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +66,9 @@ class TestCliSkinPromptIntegration:
|
||||
assert style_dict["prompt"] == skin.get_color("prompt")
|
||||
assert style_dict["input-rule"] == skin.get_color("input_rule")
|
||||
assert style_dict["prompt-working"] == f"{skin.get_color('banner_dim')} italic"
|
||||
assert style_dict["status-bar"] == (
|
||||
f"bg:{skin.get_color('status_bar_bg')} {skin.get_color('status_bar_text')}"
|
||||
)
|
||||
assert style_dict["approval-title"] == f"{skin.get_color('ui_warn')} bold"
|
||||
|
||||
def test_apply_tui_skin_style_updates_running_app(self):
|
||||
|
||||
+183
-3
@@ -93,6 +93,27 @@ class TestSessionLifecycle:
|
||||
assert session["input_tokens"] == 300
|
||||
assert session["output_tokens"] == 150
|
||||
|
||||
def test_update_token_counts_tracks_api_call_count(self, db):
|
||||
"""api_call_count increments with each update_token_counts call."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.update_token_counts("s1", input_tokens=100, output_tokens=50, api_call_count=1)
|
||||
db.update_token_counts("s1", input_tokens=100, output_tokens=50, api_call_count=1)
|
||||
db.update_token_counts("s1", input_tokens=100, output_tokens=50, api_call_count=1)
|
||||
|
||||
session = db.get_session("s1")
|
||||
assert session["api_call_count"] == 3
|
||||
|
||||
def test_update_token_counts_api_call_count_absolute(self, db):
|
||||
"""absolute mode sets api_call_count directly."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.update_token_counts("s1", input_tokens=100, output_tokens=50, api_call_count=1)
|
||||
db.update_token_counts("s1", input_tokens=300, output_tokens=150,
|
||||
api_call_count=5, absolute=True)
|
||||
|
||||
session = db.get_session("s1")
|
||||
assert session["api_call_count"] == 5
|
||||
assert session["input_tokens"] == 300
|
||||
|
||||
def test_update_token_counts_backfills_model_when_null(self, db):
|
||||
db.create_session(session_id="s1", source="telegram")
|
||||
db.update_token_counts("s1", input_tokens=10, output_tokens=5, model="openai/gpt-5.4")
|
||||
@@ -255,6 +276,38 @@ class TestMessageStorage:
|
||||
assert msg["reasoning"] == "Thinking about what to say"
|
||||
assert msg["reasoning_details"] == details
|
||||
|
||||
def test_reasoning_content_persisted_and_restored(self, db):
|
||||
"""reasoning_content must survive session replay as its own field."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message(
|
||||
"s1",
|
||||
role="assistant",
|
||||
content="Hello",
|
||||
reasoning="Short summary",
|
||||
reasoning_content="Longer provider-native scratchpad",
|
||||
)
|
||||
|
||||
conv = db.get_messages_as_conversation("s1")
|
||||
assert len(conv) == 1
|
||||
assert conv[0]["reasoning"] == "Short summary"
|
||||
assert conv[0]["reasoning_content"] == "Longer provider-native scratchpad"
|
||||
|
||||
def test_reasoning_content_empty_string_restored_for_assistant(self, db):
|
||||
"""Empty reasoning_content still needs to round-trip for strict replays."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message(
|
||||
"s1",
|
||||
role="assistant",
|
||||
content="",
|
||||
tool_calls=[{"id": "c1", "type": "function", "function": {"name": "date", "arguments": "{}"}}],
|
||||
reasoning_content="",
|
||||
)
|
||||
|
||||
conv = db.get_messages_as_conversation("s1")
|
||||
assert len(conv) == 1
|
||||
assert "reasoning_content" in conv[0]
|
||||
assert conv[0]["reasoning_content"] == ""
|
||||
|
||||
def test_reasoning_not_set_for_non_assistant(self, db):
|
||||
"""reasoning is never leaked onto user or tool messages."""
|
||||
db.create_session(session_id="s1", source="telegram")
|
||||
@@ -1120,7 +1173,7 @@ class TestSchemaInit:
|
||||
def test_schema_version(self, db):
|
||||
cursor = db._conn.execute("SELECT version FROM schema_version")
|
||||
version = cursor.fetchone()[0]
|
||||
assert version == 6
|
||||
assert version == 8
|
||||
|
||||
def test_title_column_exists(self, db):
|
||||
"""Verify the title column was created in the sessions table."""
|
||||
@@ -1176,18 +1229,24 @@ class TestSchemaInit:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Open with SessionDB — should migrate to v6
|
||||
# Open with SessionDB — should migrate to v8
|
||||
migrated_db = SessionDB(db_path=db_path)
|
||||
|
||||
# Verify migration
|
||||
cursor = migrated_db._conn.execute("SELECT version FROM schema_version")
|
||||
assert cursor.fetchone()[0] == 6
|
||||
assert cursor.fetchone()[0] == 8
|
||||
|
||||
# Verify title column exists and is NULL for existing sessions
|
||||
session = migrated_db.get_session("existing")
|
||||
assert session is not None
|
||||
assert session["title"] is None
|
||||
|
||||
# Verify api_call_count column was added with default 0
|
||||
cursor = migrated_db._conn.execute(
|
||||
"SELECT api_call_count FROM sessions WHERE id = 'existing'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 0
|
||||
|
||||
# Verify we can set title on migrated session
|
||||
assert migrated_db.set_session_title("existing", "Migrated Title") is True
|
||||
session = migrated_db.get_session("existing")
|
||||
@@ -1732,3 +1791,124 @@ class TestConcurrentWriteSafety:
|
||||
assert "30" in src, (
|
||||
"SQLite timeout should be at least 30s to handle CLI/gateway lock contention"
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Auto-maintenance: state_meta + vacuum + maybe_auto_prune_and_vacuum
|
||||
# =========================================================================
|
||||
|
||||
class TestStateMeta:
|
||||
def test_get_meta_missing_returns_none(self, db):
|
||||
assert db.get_meta("nonexistent") is None
|
||||
|
||||
def test_set_then_get_meta(self, db):
|
||||
db.set_meta("foo", "bar")
|
||||
assert db.get_meta("foo") == "bar"
|
||||
|
||||
def test_set_meta_upsert(self, db):
|
||||
"""set_meta overwrites existing value (ON CONFLICT DO UPDATE)."""
|
||||
db.set_meta("key", "v1")
|
||||
db.set_meta("key", "v2")
|
||||
assert db.get_meta("key") == "v2"
|
||||
|
||||
|
||||
class TestVacuum:
|
||||
def test_vacuum_runs_without_error(self, db):
|
||||
"""VACUUM must succeed on a fresh DB (no rows to reclaim)."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message(session_id="s1", role="user", content="hi")
|
||||
# Should not raise, even though there's nothing significant to reclaim.
|
||||
db.vacuum()
|
||||
|
||||
|
||||
class TestAutoMaintenance:
|
||||
def _make_old_ended(self, db, sid: str, days_old: int = 100):
|
||||
"""Create a session that is ended and was started `days_old` days ago."""
|
||||
db.create_session(session_id=sid, source="cli")
|
||||
db.end_session(sid, end_reason="done")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(time.time() - days_old * 86400, sid),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
def test_first_run_prunes_and_vacuums(self, db):
|
||||
self._make_old_ended(db, "old1", days_old=100)
|
||||
self._make_old_ended(db, "old2", days_old=100)
|
||||
db.create_session(session_id="new", source="cli") # active, must survive
|
||||
|
||||
result = db.maybe_auto_prune_and_vacuum(retention_days=90)
|
||||
assert result["skipped"] is False
|
||||
assert result["pruned"] == 2
|
||||
assert result["vacuumed"] is True
|
||||
assert result.get("error") is None
|
||||
assert db.get_session("old1") is None
|
||||
assert db.get_session("old2") is None
|
||||
assert db.get_session("new") is not None
|
||||
|
||||
def test_second_call_within_interval_skips(self, db):
|
||||
self._make_old_ended(db, "old", days_old=100)
|
||||
first = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90, min_interval_hours=24
|
||||
)
|
||||
assert first["skipped"] is False
|
||||
assert first["pruned"] == 1
|
||||
|
||||
# Create another prunable session; a second call within
|
||||
# min_interval_hours should still skip without touching it.
|
||||
self._make_old_ended(db, "old2", days_old=100)
|
||||
second = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90, min_interval_hours=24
|
||||
)
|
||||
assert second["skipped"] is True
|
||||
assert second["pruned"] == 0
|
||||
assert db.get_session("old2") is not None # untouched
|
||||
|
||||
def test_second_call_after_interval_runs_again(self, db):
|
||||
self._make_old_ended(db, "old", days_old=100)
|
||||
db.maybe_auto_prune_and_vacuum(retention_days=90, min_interval_hours=24)
|
||||
|
||||
# Backdate the last-run marker to force another run.
|
||||
db.set_meta("last_auto_prune", str(time.time() - 48 * 3600))
|
||||
|
||||
self._make_old_ended(db, "old2", days_old=100)
|
||||
result = db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=90, min_interval_hours=24
|
||||
)
|
||||
assert result["skipped"] is False
|
||||
assert result["pruned"] == 1
|
||||
assert db.get_session("old2") is None
|
||||
|
||||
def test_no_prunable_sessions_no_vacuum(self, db):
|
||||
"""When prune deletes 0 rows, VACUUM is skipped (wasted I/O)."""
|
||||
db.create_session(session_id="fresh", source="cli") # too recent
|
||||
result = db.maybe_auto_prune_and_vacuum(retention_days=90)
|
||||
assert result["skipped"] is False
|
||||
assert result["pruned"] == 0
|
||||
assert result["vacuumed"] is False
|
||||
# But last-run is still recorded so we don't retry immediately.
|
||||
assert db.get_meta("last_auto_prune") is not None
|
||||
|
||||
def test_vacuum_disabled_via_flag(self, db):
|
||||
self._make_old_ended(db, "old", days_old=100)
|
||||
result = db.maybe_auto_prune_and_vacuum(retention_days=90, vacuum=False)
|
||||
assert result["pruned"] == 1
|
||||
assert result["vacuumed"] is False
|
||||
|
||||
def test_corrupt_last_run_marker_treated_as_no_prior_run(self, db):
|
||||
"""A non-numeric marker must not break maintenance."""
|
||||
db.set_meta("last_auto_prune", "not-a-timestamp")
|
||||
self._make_old_ended(db, "old", days_old=100)
|
||||
result = db.maybe_auto_prune_and_vacuum(retention_days=90)
|
||||
assert result["skipped"] is False
|
||||
assert result["pruned"] == 1
|
||||
|
||||
def test_state_meta_survives_vacuum(self, db):
|
||||
"""Marker written just before VACUUM must still be readable after."""
|
||||
self._make_old_ended(db, "old", days_old=100)
|
||||
db.maybe_auto_prune_and_vacuum(retention_days=90)
|
||||
marker = db.get_meta("last_auto_prune")
|
||||
assert marker is not None
|
||||
# Should parse as a float timestamp close to now.
|
||||
assert abs(float(marker) - time.time()) < 60
|
||||
|
||||
|
||||
@@ -197,6 +197,68 @@ class TestRunAsyncWithRunningLoop:
|
||||
)
|
||||
assert result == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_uses_nonblocking_executor_shutdown(self, monkeypatch):
|
||||
"""A timeout in the running-loop branch must not wait for the worker.
|
||||
|
||||
ThreadPoolExecutor's context manager performs shutdown(wait=True).
|
||||
If _run_async relies on that path after future.result(timeout=...)
|
||||
times out, the timeout does not bound wall-clock time because the
|
||||
caller still waits for the stuck coroutine's thread to finish.
|
||||
"""
|
||||
import concurrent.futures
|
||||
from model_tools import _run_async
|
||||
|
||||
events = {
|
||||
"cancelled": False,
|
||||
"result_timeout": None,
|
||||
"shutdown_calls": [],
|
||||
}
|
||||
|
||||
class TimeoutFuture:
|
||||
def result(self, timeout=None):
|
||||
events["result_timeout"] = timeout
|
||||
raise concurrent.futures.TimeoutError()
|
||||
|
||||
def cancel(self):
|
||||
events["cancelled"] = True
|
||||
return True
|
||||
|
||||
class FakeExecutor:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.shutdown(wait=True)
|
||||
return False
|
||||
|
||||
def submit(self, fn, *args, **kwargs):
|
||||
if args and hasattr(args[0], "close"):
|
||||
args[0].close()
|
||||
return TimeoutFuture()
|
||||
|
||||
def shutdown(self, wait=True, cancel_futures=False):
|
||||
events["shutdown_calls"].append((wait, cancel_futures))
|
||||
|
||||
async def _never_finishes():
|
||||
await asyncio.sleep(999)
|
||||
|
||||
monkeypatch.setattr(
|
||||
concurrent.futures,
|
||||
"ThreadPoolExecutor",
|
||||
FakeExecutor,
|
||||
)
|
||||
|
||||
with pytest.raises(concurrent.futures.TimeoutError):
|
||||
_run_async(_never_finishes())
|
||||
|
||||
assert events["result_timeout"] == 300
|
||||
assert events["cancelled"] is True
|
||||
assert events["shutdown_calls"] == [(False, True)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: full vision_analyze dispatch chain
|
||||
|
||||
+366
-103
@@ -106,11 +106,23 @@ def test_config_set_yolo_toggles_session_scope():
|
||||
|
||||
server._sessions["sid"] = _session()
|
||||
try:
|
||||
resp_on = server.handle_request({"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}})
|
||||
resp_on = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "yolo"},
|
||||
}
|
||||
)
|
||||
assert resp_on["result"]["value"] == "1"
|
||||
assert is_session_yolo_enabled("session-key") is True
|
||||
|
||||
resp_off = server.handle_request({"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}})
|
||||
resp_off = server.handle_request(
|
||||
{
|
||||
"id": "2",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "yolo"},
|
||||
}
|
||||
)
|
||||
assert resp_off["result"]["value"] == "0"
|
||||
assert is_session_yolo_enabled("session-key") is False
|
||||
finally:
|
||||
@@ -118,6 +130,36 @@ def test_config_set_yolo_toggles_session_scope():
|
||||
server._sessions.clear()
|
||||
|
||||
|
||||
def test_config_get_statusbar_survives_non_dict_display(monkeypatch):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"display": "broken"})
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "statusbar"}}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "top"
|
||||
|
||||
|
||||
def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch):
|
||||
import yaml
|
||||
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.safe_dump({"display": "broken"}))
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"key": "statusbar", "value": "bottom"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "bottom"
|
||||
saved = yaml.safe_load(cfg_path.read_text())
|
||||
assert saved["display"]["tui_statusbar"] == "bottom"
|
||||
|
||||
|
||||
def test_enable_gateway_prompts_sets_gateway_env(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
@@ -144,13 +186,21 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat
|
||||
server._sessions["sid"] = _session(agent=agent)
|
||||
|
||||
resp_effort = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "low"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "reasoning", "value": "low"},
|
||||
}
|
||||
)
|
||||
assert resp_effort["result"]["value"] == "low"
|
||||
assert agent.reasoning_config == {"enabled": True, "effort": "low"}
|
||||
|
||||
resp_show = server.handle_request(
|
||||
{"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "show"}}
|
||||
{
|
||||
"id": "2",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "reasoning", "value": "show"},
|
||||
}
|
||||
)
|
||||
assert resp_show["result"]["value"] == "show"
|
||||
assert server._sessions["sid"]["show_reasoning"] is True
|
||||
@@ -162,7 +212,11 @@ def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch
|
||||
server._sessions["sid"] = _session(agent=agent)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "verbose", "value": "cycle"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "verbose", "value": "cycle"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "verbose"
|
||||
@@ -180,7 +234,11 @@ def test_config_set_model_uses_live_switch_path(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "new/model"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "model", "value": "new/model"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "new/model"
|
||||
@@ -221,7 +279,15 @@ def test_config_set_model_global_persists(monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg))
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "anthropic/claude-sonnet-4.6 --global"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {
|
||||
"session_id": "sid",
|
||||
"key": "model",
|
||||
"value": "anthropic/claude-sonnet-4.6 --global",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6"
|
||||
@@ -241,6 +307,7 @@ def test_config_set_model_syncs_inference_provider_env(monkeypatch):
|
||||
trying openrouter because the env-var-backed resolvers still saw the old
|
||||
provider.
|
||||
"""
|
||||
|
||||
class _Agent:
|
||||
provider = "openrouter"
|
||||
model = "old/model"
|
||||
@@ -262,21 +329,39 @@ def test_config_set_model_syncs_inference_provider_env(monkeypatch):
|
||||
|
||||
server._sessions["sid"] = _session(agent=_Agent())
|
||||
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")
|
||||
monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **_kwargs: result)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_switch.switch_model", lambda **_kwargs: result
|
||||
)
|
||||
monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
|
||||
|
||||
server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "claude-sonnet-4.6 --provider anthropic"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {
|
||||
"session_id": "sid",
|
||||
"key": "model",
|
||||
"value": "claude-sonnet-4.6 --provider anthropic",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert os.environ["HERMES_INFERENCE_PROVIDER"] == "anthropic"
|
||||
|
||||
|
||||
def test_config_set_personality_rejects_unknown_name(monkeypatch):
|
||||
monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."})
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_available_personalities",
|
||||
lambda cfg=None: {"helpful": "You are helpful."},
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"key": "personality", "value": "bogus"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"key": "personality", "value": "bogus"},
|
||||
}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
@@ -284,20 +369,36 @@ def test_config_set_personality_rejects_unknown_name(monkeypatch):
|
||||
|
||||
|
||||
def test_config_set_personality_resets_history_and_returns_info(monkeypatch):
|
||||
session = _session(agent=types.SimpleNamespace(), history=[{"role": "user", "text": "hi"}], history_version=4)
|
||||
session = _session(
|
||||
agent=types.SimpleNamespace(),
|
||||
history=[{"role": "user", "text": "hi"}],
|
||||
history_version=4,
|
||||
)
|
||||
new_agent = types.SimpleNamespace(model="x")
|
||||
emits = []
|
||||
|
||||
server._sessions["sid"] = session
|
||||
monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."})
|
||||
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: new_agent)
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")})
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_available_personalities",
|
||||
lambda cfg=None: {"helpful": "You are helpful."},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server, "_make_agent", lambda sid, key, session_id=None: new_agent
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")}
|
||||
)
|
||||
monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args))
|
||||
monkeypatch.setattr(server, "_write_config_key", lambda path, value: None)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "personality", "value": "helpful"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "personality", "value": "helpful"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["history_reset"] is True
|
||||
@@ -311,11 +412,17 @@ def test_session_compress_uses_compress_helper(monkeypatch):
|
||||
agent = types.SimpleNamespace()
|
||||
server._sessions["sid"] = _session(agent=agent)
|
||||
|
||||
monkeypatch.setattr(server, "_compress_session_history", lambda session, focus_topic=None: (2, {"total": 42}))
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_compress_session_history",
|
||||
lambda session, focus_topic=None: (2, {"total": 42}),
|
||||
)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"})
|
||||
|
||||
with patch("tui_gateway.server._emit") as emit:
|
||||
resp = server.handle_request({"id": "1", "method": "session.compress", "params": {"session_id": "sid"}})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
|
||||
)
|
||||
|
||||
assert resp["result"]["removed"] == 2
|
||||
assert resp["result"]["usage"]["total"] == 42
|
||||
@@ -328,9 +435,14 @@ def test_prompt_submit_sets_approval_session_key(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
captured["session_key"] = get_current_session_key(default="")
|
||||
return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]}
|
||||
return {
|
||||
"final_response": "ok",
|
||||
"messages": [{"role": "assistant", "content": "ok"}],
|
||||
}
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
@@ -345,7 +457,13 @@ def test_prompt_submit_sets_approval_session_key(monkeypatch):
|
||||
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
|
||||
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "ping"}})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "ping"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["status"] == "streaming"
|
||||
assert captured["session_key"] == "session-key"
|
||||
@@ -359,9 +477,14 @@ def test_prompt_submit_expands_context_refs(monkeypatch):
|
||||
base_url = ""
|
||||
api_key = ""
|
||||
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
captured["prompt"] = prompt
|
||||
return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]}
|
||||
return {
|
||||
"final_response": "ok",
|
||||
"messages": [{"role": "assistant", "content": "ok"}],
|
||||
}
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
@@ -371,8 +494,14 @@ def test_prompt_submit_expands_context_refs(monkeypatch):
|
||||
self._target()
|
||||
|
||||
fake_ctx = types.ModuleType("agent.context_references")
|
||||
fake_ctx.preprocess_context_references = lambda message, **kwargs: types.SimpleNamespace(
|
||||
blocked=False, message="expanded prompt", warnings=[], references=[], injected_tokens=0
|
||||
fake_ctx.preprocess_context_references = (
|
||||
lambda message, **kwargs: types.SimpleNamespace(
|
||||
blocked=False,
|
||||
message="expanded prompt",
|
||||
warnings=[],
|
||||
references=[],
|
||||
injected_tokens=0,
|
||||
)
|
||||
)
|
||||
fake_meta = types.ModuleType("agent.model_metadata")
|
||||
fake_meta.get_model_context_length = lambda *args, **kwargs: 100000
|
||||
@@ -385,7 +514,13 @@ def test_prompt_submit_expands_context_refs(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "agent.context_references", fake_ctx)
|
||||
monkeypatch.setitem(sys.modules, "agent.model_metadata", fake_meta)
|
||||
|
||||
server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "@diff"}})
|
||||
server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "@diff"},
|
||||
}
|
||||
)
|
||||
|
||||
assert captured["prompt"] == "expanded prompt"
|
||||
|
||||
@@ -404,7 +539,13 @@ def test_image_attach_appends_local_image(monkeypatch):
|
||||
server._sessions["sid"] = _session()
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "image.attach", "params": {"session_id": "sid", "path": "/tmp/cat.png"}})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "image.attach",
|
||||
"params": {"session_id": "sid", "path": "/tmp/cat.png"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["attached"] is True
|
||||
assert resp["result"]["name"] == "cat.png"
|
||||
@@ -420,14 +561,21 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch):
|
||||
"is_image": True,
|
||||
"remainder": "",
|
||||
}
|
||||
fake_cli._split_path_input = lambda raw: ("/tmp/Screenshot", "2026-04-21 at 1.04.43 PM.png")
|
||||
fake_cli._split_path_input = lambda raw: (
|
||||
"/tmp/Screenshot",
|
||||
"2026-04-21 at 1.04.43 PM.png",
|
||||
)
|
||||
fake_cli._resolve_attachment_path = lambda raw: None
|
||||
|
||||
server._sessions["sid"] = _session()
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "image.attach", "params": {"session_id": "sid", "path": str(screenshot)}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "image.attach",
|
||||
"params": {"session_id": "sid", "path": str(screenshot)},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["attached"] is True
|
||||
@@ -437,20 +585,34 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch):
|
||||
|
||||
|
||||
def test_commands_catalog_surfaces_quick_commands(monkeypatch):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {
|
||||
"build": {"type": "exec", "command": "npm run build"},
|
||||
"git": {"type": "alias", "target": "/shell git"},
|
||||
"notes": {"type": "exec", "command": "cat NOTES.md", "description": "Open design notes"},
|
||||
}})
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_load_cfg",
|
||||
lambda: {
|
||||
"quick_commands": {
|
||||
"build": {"type": "exec", "command": "npm run build"},
|
||||
"git": {"type": "alias", "target": "/shell git"},
|
||||
"notes": {
|
||||
"type": "exec",
|
||||
"command": "cat NOTES.md",
|
||||
"description": "Open design notes",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "commands.catalog", "params": {}})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "commands.catalog", "params": {}}
|
||||
)
|
||||
|
||||
pairs = dict(resp["result"]["pairs"])
|
||||
assert "npm run build" in pairs["/build"]
|
||||
assert pairs["/git"].startswith("alias →")
|
||||
assert pairs["/notes"] == "Open design notes"
|
||||
|
||||
user_cat = next(c for c in resp["result"]["categories"] if c["name"] == "User commands")
|
||||
user_cat = next(
|
||||
c for c in resp["result"]["categories"] if c["name"] == "User commands"
|
||||
)
|
||||
user_pairs = dict(user_cat["pairs"])
|
||||
assert set(user_pairs) == {"/build", "/git", "/notes"}
|
||||
|
||||
@@ -459,14 +621,22 @@ def test_commands_catalog_surfaces_quick_commands(monkeypatch):
|
||||
|
||||
|
||||
def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}})
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_load_cfg",
|
||||
lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: types.SimpleNamespace(returncode=1, stdout="", stderr="failed"),
|
||||
lambda *args, **kwargs: types.SimpleNamespace(
|
||||
returncode=1, stdout="", stderr="failed"
|
||||
),
|
||||
)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "command.dispatch", "params": {"name": "boom"}})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "command.dispatch", "params": {"name": "boom"}}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
assert "failed" in resp["error"]["message"]
|
||||
@@ -474,15 +644,22 @@ def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
|
||||
|
||||
def test_plugins_list_surfaces_loader_error(monkeypatch):
|
||||
with patch("hermes_cli.plugins.get_plugin_manager", side_effect=Exception("boom")):
|
||||
resp = server.handle_request({"id": "1", "method": "plugins.list", "params": {}})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "plugins.list", "params": {}}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
assert "boom" in resp["error"]["message"]
|
||||
|
||||
|
||||
def test_complete_slash_surfaces_completer_error(monkeypatch):
|
||||
with patch("hermes_cli.commands.SlashCommandCompleter", side_effect=Exception("no completer")):
|
||||
resp = server.handle_request({"id": "1", "method": "complete.slash", "params": {"text": "/mo"}})
|
||||
with patch(
|
||||
"hermes_cli.commands.SlashCommandCompleter",
|
||||
side_effect=Exception("no completer"),
|
||||
):
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "complete.slash", "params": {"text": "/mo"}}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
assert "no completer" in resp["error"]["message"]
|
||||
@@ -500,7 +677,11 @@ def test_input_detect_drop_attaches_image(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "input.detect_drop", "params": {"session_id": "sid", "text": "/tmp/cat.png"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "input.detect_drop",
|
||||
"params": {"session_id": "sid", "text": "/tmp/cat.png"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["matched"] is True
|
||||
@@ -521,7 +702,9 @@ def test_rollback_restore_resolves_number_and_file_path():
|
||||
calls["args"] = (cwd, target, file_path)
|
||||
return {"success": True, "message": "done"}
|
||||
|
||||
server._sessions["sid"] = _session(agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[])
|
||||
server._sessions["sid"] = _session(
|
||||
agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[]
|
||||
)
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
@@ -572,7 +755,9 @@ def test_session_steer_calls_agent_steer_when_agent_supports_it():
|
||||
|
||||
|
||||
def test_session_steer_rejects_empty_text():
|
||||
server._sessions["sid"] = _session(agent=types.SimpleNamespace(steer=lambda t: True))
|
||||
server._sessions["sid"] = _session(
|
||||
agent=types.SimpleNamespace(steer=lambda t: True)
|
||||
)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
@@ -632,10 +817,13 @@ def test_session_undo_rejects_while_running():
|
||||
"""Fix for TUI silent-drop #1: /undo must not mutate history
|
||||
while the agent is mid-turn — would either clobber the undo or
|
||||
cause prompt.submit to silently drop the agent's response."""
|
||||
server._sessions["sid"] = _session(running=True, history=[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
])
|
||||
server._sessions["sid"] = _session(
|
||||
running=True,
|
||||
history=[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
],
|
||||
)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.undo", "params": {"session_id": "sid"}}
|
||||
@@ -651,10 +839,13 @@ def test_session_undo_rejects_while_running():
|
||||
|
||||
def test_session_undo_allowed_when_idle():
|
||||
"""Regression guard: when not running, /undo still works."""
|
||||
server._sessions["sid"] = _session(running=False, history=[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
])
|
||||
server._sessions["sid"] = _session(
|
||||
running=False,
|
||||
history=[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
],
|
||||
)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.undo", "params": {"session_id": "sid"}}
|
||||
@@ -683,7 +874,11 @@ def test_rollback_restore_rejects_full_history_while_running(monkeypatch):
|
||||
server._sessions["sid"] = _session(running=True)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "rollback.restore", "params": {"session_id": "sid", "hash": "abc"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "rollback.restore",
|
||||
"params": {"session_id": "sid", "hash": "abc"},
|
||||
}
|
||||
)
|
||||
assert resp.get("error"), "full-history rollback should reject while running"
|
||||
assert resp["error"]["code"] == 4009
|
||||
@@ -701,12 +896,17 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch):
|
||||
session_ref = {"s": None}
|
||||
|
||||
class _RacyAgent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
# Simulate: something external bumped history_version
|
||||
# while we were running.
|
||||
with session_ref["s"]["history_lock"]:
|
||||
session_ref["s"]["history_version"] += 1
|
||||
return {"final_response": "agent reply", "messages": [{"role": "assistant", "content": "agent reply"}]}
|
||||
return {
|
||||
"final_response": "agent reply",
|
||||
"messages": [{"role": "assistant", "content": "agent reply"}],
|
||||
}
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
@@ -725,7 +925,11 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch):
|
||||
monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a))
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hi"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "hi"},
|
||||
}
|
||||
)
|
||||
assert resp.get("result"), f"got error: {resp.get('error')}"
|
||||
|
||||
@@ -742,16 +946,25 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch):
|
||||
"history_version mismatch — otherwise the UI silently "
|
||||
"shows output that was never persisted"
|
||||
)
|
||||
assert "not saved" in payload["warning"].lower() or "changed" in payload["warning"].lower()
|
||||
assert (
|
||||
"not saved" in payload["warning"].lower()
|
||||
or "changed" in payload["warning"].lower()
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_prompt_submit_history_version_match_persists_normally(monkeypatch):
|
||||
"""Regression guard: the backstop does not affect the happy path."""
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
return {"final_response": "reply", "messages": [{"role": "assistant", "content": "reply"}]}
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
return {
|
||||
"final_response": "reply",
|
||||
"messages": [{"role": "assistant", "content": "reply"}],
|
||||
}
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
@@ -769,12 +982,18 @@ def test_prompt_submit_history_version_match_persists_normally(monkeypatch):
|
||||
monkeypatch.setattr(server, "_emit", lambda *a: emits.append(a))
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "hi"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "hi"},
|
||||
}
|
||||
)
|
||||
assert resp.get("result")
|
||||
|
||||
# History was written
|
||||
assert server._sessions["sid"]["history"] == [{"role": "assistant", "content": "reply"}]
|
||||
assert server._sessions["sid"]["history"] == [
|
||||
{"role": "assistant", "content": "reply"}
|
||||
]
|
||||
assert server._sessions["sid"]["history_version"] == 1
|
||||
|
||||
# No warning should be attached
|
||||
@@ -818,7 +1037,11 @@ def test_interrupt_only_clears_own_session_pending():
|
||||
|
||||
# Interrupt session A.
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.interrupt", "params": {"session_id": "sid_a"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.interrupt",
|
||||
"params": {"session_id": "sid_a"},
|
||||
}
|
||||
)
|
||||
assert resp.get("result"), f"got error: {resp.get('error')}"
|
||||
|
||||
@@ -891,8 +1114,11 @@ def test_respond_unpacks_sid_tuple_correctly():
|
||||
server._pending["rid-x"] = ("sid_x", ev)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "clarify.respond",
|
||||
"params": {"request_id": "rid-x", "answer": "the answer"}}
|
||||
{
|
||||
"id": "1",
|
||||
"method": "clarify.respond",
|
||||
"params": {"request_id": "rid-x", "answer": "the answer"},
|
||||
}
|
||||
)
|
||||
assert resp.get("result")
|
||||
assert ev.is_set()
|
||||
@@ -902,7 +1128,6 @@ def test_respond_unpacks_sid_tuple_correctly():
|
||||
server._answers.pop("rid-x", None)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /model switch and other agent-mutating commands must reject while the
|
||||
# session is running. agent.switch_model() mutates self.model, self.provider,
|
||||
@@ -925,10 +1150,17 @@ def test_config_set_model_rejects_while_running(monkeypatch):
|
||||
|
||||
server._sessions["sid"] = _session(running=True)
|
||||
try:
|
||||
resp = server.handle_request({
|
||||
"id": "1", "method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "model", "value": "anthropic/claude-sonnet-4.6"},
|
||||
})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {
|
||||
"session_id": "sid",
|
||||
"key": "model",
|
||||
"value": "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert resp.get("error")
|
||||
assert resp["error"]["code"] == 4009
|
||||
assert "session busy" in resp["error"]["message"]
|
||||
@@ -952,10 +1184,13 @@ def test_config_set_model_allowed_when_idle(monkeypatch):
|
||||
|
||||
server._sessions["sid"] = _session(running=False)
|
||||
try:
|
||||
resp = server.handle_request({
|
||||
"id": "1", "method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "model", "value": "newmodel"},
|
||||
})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "model", "value": "newmodel"},
|
||||
}
|
||||
)
|
||||
assert resp.get("result")
|
||||
assert resp["result"]["value"] == "newmodel"
|
||||
assert seen["called"]
|
||||
@@ -993,9 +1228,9 @@ def test_mirror_slash_side_effects_rejects_mutating_commands_while_running(monke
|
||||
("/compress", "compress"),
|
||||
]:
|
||||
warning = server._mirror_slash_side_effects("sid", session, cmd)
|
||||
assert "session busy" in warning, (
|
||||
f"{cmd} should have returned busy warning, got: {warning!r}"
|
||||
)
|
||||
assert (
|
||||
"session busy" in warning
|
||||
), f"{cmd} should have returned busy warning, got: {warning!r}"
|
||||
assert f"/{expected_name}" in warning
|
||||
|
||||
# None of the mutating side-effect helpers should have fired.
|
||||
@@ -1068,7 +1303,11 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch):
|
||||
# Stub everything _build touches
|
||||
monkeypatch.setattr(server, "_make_agent", _slow_make_agent)
|
||||
monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None))
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_get_db",
|
||||
lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None),
|
||||
)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"})
|
||||
monkeypatch.setattr(server, "_probe_credentials", lambda _a: None)
|
||||
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
|
||||
@@ -1076,25 +1315,36 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch):
|
||||
|
||||
# Shim register/unregister to observe leaks
|
||||
import tools.approval as _approval
|
||||
monkeypatch.setattr(_approval, "register_gateway_notify",
|
||||
lambda key, cb: None)
|
||||
monkeypatch.setattr(_approval, "unregister_gateway_notify",
|
||||
lambda key: unregistered_keys.append(key))
|
||||
|
||||
monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
|
||||
monkeypatch.setattr(
|
||||
_approval,
|
||||
"unregister_gateway_notify",
|
||||
lambda key: unregistered_keys.append(key),
|
||||
)
|
||||
monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)
|
||||
|
||||
# Start: session.create spawns _build thread, returns synchronously
|
||||
resp = server.handle_request({
|
||||
"id": "1", "method": "session.create", "params": {"cols": 80},
|
||||
})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.create",
|
||||
"params": {"cols": 80},
|
||||
}
|
||||
)
|
||||
assert resp.get("result"), f"got error: {resp.get('error')}"
|
||||
sid = resp["result"]["session_id"]
|
||||
|
||||
# Build thread is blocked in _slow_make_agent. Close the session
|
||||
# NOW — this pops _sessions[sid] before _build can install the
|
||||
# worker/notify.
|
||||
close_resp = server.handle_request({
|
||||
"id": "2", "method": "session.close", "params": {"session_id": sid},
|
||||
})
|
||||
close_resp = server.handle_request(
|
||||
{
|
||||
"id": "2",
|
||||
"method": "session.close",
|
||||
"params": {"session_id": sid},
|
||||
}
|
||||
)
|
||||
assert close_resp.get("result", {}).get("closed") is True
|
||||
|
||||
# At this point session.close saw slash_worker=None (not yet
|
||||
@@ -1108,11 +1358,12 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch):
|
||||
if closed_workers:
|
||||
break
|
||||
import time
|
||||
|
||||
time.sleep(0.02)
|
||||
|
||||
assert len(closed_workers) == 1, (
|
||||
f"orphan worker was not cleaned up — closed_workers={closed_workers}"
|
||||
)
|
||||
assert (
|
||||
len(closed_workers) == 1
|
||||
), f"orphan worker was not cleaned up — closed_workers={closed_workers}"
|
||||
# Notify may be unregistered by both session.close (unconditional)
|
||||
# and the orphan-cleanup path; the key guarantee is that the build
|
||||
# thread does at least one unregister call (any prior close
|
||||
@@ -1146,21 +1397,33 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(server, "_make_agent", lambda sid, key: _FakeAgent())
|
||||
monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None))
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_get_db",
|
||||
lambda: types.SimpleNamespace(create_session=lambda *a, **kw: None),
|
||||
)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"})
|
||||
monkeypatch.setattr(server, "_probe_credentials", lambda _a: None)
|
||||
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
|
||||
|
||||
import tools.approval as _approval
|
||||
|
||||
monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
|
||||
monkeypatch.setattr(_approval, "unregister_gateway_notify",
|
||||
lambda key: unregistered_keys.append(key))
|
||||
monkeypatch.setattr(
|
||||
_approval,
|
||||
"unregister_gateway_notify",
|
||||
lambda key: unregistered_keys.append(key),
|
||||
)
|
||||
monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)
|
||||
|
||||
resp = server.handle_request({
|
||||
"id": "1", "method": "session.create", "params": {"cols": 80},
|
||||
})
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.create",
|
||||
"params": {"cols": 80},
|
||||
}
|
||||
)
|
||||
sid = resp["result"]["session_id"]
|
||||
|
||||
# Wait for the build to finish (ready event inside session dict).
|
||||
@@ -1169,12 +1432,12 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch):
|
||||
|
||||
# Build finished without a close race — nothing should have been
|
||||
# cleaned up by the orphan check.
|
||||
assert closed_workers == [], (
|
||||
f"build thread closed its own worker despite no race: {closed_workers}"
|
||||
)
|
||||
assert unregistered_keys == [], (
|
||||
f"build thread unregistered its own notify despite no race: {unregistered_keys}"
|
||||
)
|
||||
assert (
|
||||
closed_workers == []
|
||||
), f"build thread closed its own worker despite no race: {closed_workers}"
|
||||
assert (
|
||||
unregistered_keys == []
|
||||
), f"build thread unregistered its own notify despite no race: {unregistered_keys}"
|
||||
|
||||
# Session should have the live worker installed.
|
||||
assert session.get("slash_worker") is not None
|
||||
|
||||
@@ -19,6 +19,8 @@ from tools.file_operations import (
|
||||
BINARY_EXTENSIONS,
|
||||
IMAGE_EXTENSIONS,
|
||||
MAX_LINE_LENGTH,
|
||||
normalize_read_pagination,
|
||||
normalize_search_pagination,
|
||||
)
|
||||
|
||||
|
||||
@@ -192,6 +194,17 @@ def file_ops(mock_env):
|
||||
|
||||
|
||||
class TestShellFileOpsHelpers:
|
||||
def test_normalize_read_pagination_clamps_invalid_values(self):
|
||||
assert normalize_read_pagination(offset=0, limit=0) == (1, 1)
|
||||
assert normalize_read_pagination(offset=-10, limit=-5) == (1, 1)
|
||||
assert normalize_read_pagination(offset="bad", limit="bad") == (1, 500)
|
||||
assert normalize_read_pagination(offset=2, limit=999999) == (2, 2000)
|
||||
|
||||
def test_normalize_search_pagination_clamps_invalid_values(self):
|
||||
assert normalize_search_pagination(offset=-10, limit=-5) == (0, 1)
|
||||
assert normalize_search_pagination(offset="bad", limit="bad") == (0, 50)
|
||||
assert normalize_search_pagination(offset=3, limit=0) == (3, 1)
|
||||
|
||||
def test_escape_shell_arg_simple(self, file_ops):
|
||||
assert file_ops._escape_shell_arg("hello") == "'hello'"
|
||||
|
||||
|
||||
@@ -146,3 +146,61 @@ class TestCheckLintBracePaths:
|
||||
|
||||
assert result.success is False
|
||||
assert "SyntaxError" in result.output
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Pagination bounds
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestPaginationBounds:
|
||||
"""Invalid pagination inputs should not leak into shell commands."""
|
||||
|
||||
def test_read_file_clamps_offset_and_limit_before_building_sed_range(self):
|
||||
env = MagicMock()
|
||||
env.cwd = "/tmp"
|
||||
ops = ShellFileOperations(env)
|
||||
commands = []
|
||||
|
||||
def fake_exec(command, *args, **kwargs):
|
||||
commands.append(command)
|
||||
if command.startswith("wc -c"):
|
||||
return MagicMock(exit_code=0, stdout="12")
|
||||
if command.startswith("head -c"):
|
||||
return MagicMock(exit_code=0, stdout="line1\nline2\n")
|
||||
if command.startswith("sed -n"):
|
||||
return MagicMock(exit_code=0, stdout="line1\n")
|
||||
if command.startswith("wc -l"):
|
||||
return MagicMock(exit_code=0, stdout="2")
|
||||
return MagicMock(exit_code=0, stdout="")
|
||||
|
||||
with patch.object(ops, "_exec", side_effect=fake_exec):
|
||||
result = ops.read_file("notes.txt", offset=0, limit=0)
|
||||
|
||||
assert result.error is None
|
||||
assert " 1|line1" in result.content
|
||||
sed_commands = [cmd for cmd in commands if cmd.startswith("sed -n")]
|
||||
assert sed_commands == ["sed -n '1,1p' 'notes.txt'"]
|
||||
|
||||
def test_search_clamps_offset_and_limit_before_building_head_pipeline(self):
|
||||
env = MagicMock()
|
||||
env.cwd = "/tmp"
|
||||
ops = ShellFileOperations(env)
|
||||
commands = []
|
||||
|
||||
def fake_exec(command, *args, **kwargs):
|
||||
commands.append(command)
|
||||
if command.startswith("test -e"):
|
||||
return MagicMock(exit_code=0, stdout="exists")
|
||||
if command.startswith("rg --files"):
|
||||
return MagicMock(exit_code=0, stdout="a.py\n")
|
||||
return MagicMock(exit_code=0, stdout="")
|
||||
|
||||
with patch.object(ops, "_has_command", side_effect=lambda cmd: cmd == "rg"), \
|
||||
patch.object(ops, "_exec", side_effect=fake_exec):
|
||||
result = ops.search("*.py", target="files", path=".", offset=-4, limit=-2)
|
||||
|
||||
assert result.files == ["a.py"]
|
||||
rg_commands = [cmd for cmd in commands if cmd.startswith("rg --files")]
|
||||
assert rg_commands
|
||||
assert "| head -n 1" in rg_commands[0]
|
||||
|
||||
@@ -45,6 +45,19 @@ class TestReadFileHandler:
|
||||
read_file_tool("/tmp/big.txt", offset=10, limit=20)
|
||||
mock_ops.read_file.assert_called_once_with("/tmp/big.txt", 10, 20)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_invalid_offset_and_limit_are_normalized_before_dispatch(self, mock_get):
|
||||
mock_ops = MagicMock()
|
||||
result_obj = MagicMock()
|
||||
result_obj.content = "line1"
|
||||
result_obj.to_dict.return_value = {"content": "line1", "total_lines": 1}
|
||||
mock_ops.read_file.return_value = result_obj
|
||||
mock_get.return_value = mock_ops
|
||||
|
||||
from tools.file_tools import read_file_tool
|
||||
read_file_tool("/tmp/big.txt", offset=0, limit=0)
|
||||
mock_ops.read_file.assert_called_once_with("/tmp/big.txt", 1, 1)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_exception_returns_error_json(self, mock_get):
|
||||
mock_get.side_effect = RuntimeError("terminal not available")
|
||||
@@ -191,6 +204,21 @@ class TestSearchHandler:
|
||||
limit=10, offset=5, output_mode="count", context=2,
|
||||
)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_search_normalizes_invalid_pagination_before_dispatch(self, mock_get):
|
||||
mock_ops = MagicMock()
|
||||
result_obj = MagicMock()
|
||||
result_obj.to_dict.return_value = {"files": []}
|
||||
mock_ops.search.return_value = result_obj
|
||||
mock_get.return_value = mock_ops
|
||||
|
||||
from tools.file_tools import search_tool
|
||||
search_tool(pattern="class", target="files", path="/src", limit=-5, offset=-2)
|
||||
mock_ops.search.assert_called_once_with(
|
||||
pattern="class", path="/src", target="files", file_glob=None,
|
||||
limit=1, offset=0, output_mode="content", context=0,
|
||||
)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops")
|
||||
def test_search_exception_returns_error(self, mock_get):
|
||||
mock_get.side_effect = RuntimeError("no terminal")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for _parse_env_var and _get_env_config env-var validation."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -84,3 +85,23 @@ class TestParseEnvVar:
|
||||
with patch.dict("os.environ", {"TERMINAL_DOCKER_VOLUMES": "not json"}):
|
||||
with pytest.raises(ValueError, match="valid JSON"):
|
||||
_parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON")
|
||||
|
||||
|
||||
class TestImportTimeEnvParsing:
|
||||
"""Module-level env parsing should never make terminal_tool unimportable."""
|
||||
|
||||
def test_invalid_foreground_timeout_falls_back_to_default(self):
|
||||
try:
|
||||
with patch.dict("os.environ", {"TERMINAL_MAX_FOREGROUND_TIMEOUT": "5m"}, clear=False):
|
||||
mod = importlib.reload(_tt_mod)
|
||||
assert mod.FOREGROUND_MAX_TIMEOUT == 600
|
||||
finally:
|
||||
importlib.reload(_tt_mod)
|
||||
|
||||
def test_invalid_disk_warning_threshold_falls_back_to_default(self):
|
||||
try:
|
||||
with patch.dict("os.environ", {"TERMINAL_DISK_WARNING_GB": "huge"}, clear=False):
|
||||
mod = importlib.reload(_tt_mod)
|
||||
assert mod.DISK_USAGE_WARNING_THRESHOLD_GB == 500.0
|
||||
finally:
|
||||
importlib.reload(_tt_mod)
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.url_safety import is_safe_url, _is_blocked_ip
|
||||
from tools.url_safety import (
|
||||
is_safe_url,
|
||||
_is_blocked_ip,
|
||||
_global_allow_private_urls,
|
||||
_reset_allow_private_cache,
|
||||
)
|
||||
|
||||
import ipaddress
|
||||
import pytest
|
||||
@@ -202,3 +207,189 @@ class TestIsBlockedIp:
|
||||
def test_allowed_ips(self, ip_str):
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
assert _is_blocked_ip(ip) is False, f"{ip_str} should be allowed"
|
||||
|
||||
|
||||
class TestGlobalAllowPrivateUrls:
|
||||
"""Tests for the security.allow_private_urls config toggle."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_cache(self):
|
||||
"""Reset the module-level toggle cache before and after each test."""
|
||||
_reset_allow_private_cache()
|
||||
yield
|
||||
_reset_allow_private_cache()
|
||||
|
||||
def test_default_is_false(self, monkeypatch):
|
||||
"""Toggle defaults to False when no env var or config is set."""
|
||||
monkeypatch.delenv("HERMES_ALLOW_PRIVATE_URLS", raising=False)
|
||||
with patch("hermes_cli.config.read_raw_config", side_effect=Exception("no config")):
|
||||
assert _global_allow_private_urls() is False
|
||||
|
||||
def test_env_var_true(self, monkeypatch):
|
||||
"""HERMES_ALLOW_PRIVATE_URLS=true enables the toggle."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
assert _global_allow_private_urls() is True
|
||||
|
||||
def test_env_var_1(self, monkeypatch):
|
||||
"""HERMES_ALLOW_PRIVATE_URLS=1 enables the toggle."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "1")
|
||||
assert _global_allow_private_urls() is True
|
||||
|
||||
def test_env_var_yes(self, monkeypatch):
|
||||
"""HERMES_ALLOW_PRIVATE_URLS=yes enables the toggle."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "yes")
|
||||
assert _global_allow_private_urls() is True
|
||||
|
||||
def test_env_var_false(self, monkeypatch):
|
||||
"""HERMES_ALLOW_PRIVATE_URLS=false keeps it disabled."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "false")
|
||||
assert _global_allow_private_urls() is False
|
||||
|
||||
def test_config_security_section(self, monkeypatch):
|
||||
"""security.allow_private_urls in config enables the toggle."""
|
||||
monkeypatch.delenv("HERMES_ALLOW_PRIVATE_URLS", raising=False)
|
||||
cfg = {"security": {"allow_private_urls": True}}
|
||||
with patch("hermes_cli.config.read_raw_config", return_value=cfg):
|
||||
assert _global_allow_private_urls() is True
|
||||
|
||||
def test_config_browser_fallback(self, monkeypatch):
|
||||
"""browser.allow_private_urls works as legacy fallback."""
|
||||
monkeypatch.delenv("HERMES_ALLOW_PRIVATE_URLS", raising=False)
|
||||
cfg = {"browser": {"allow_private_urls": True}}
|
||||
with patch("hermes_cli.config.read_raw_config", return_value=cfg):
|
||||
assert _global_allow_private_urls() is True
|
||||
|
||||
def test_config_security_takes_precedence_over_browser(self, monkeypatch):
|
||||
"""security section is checked before browser section."""
|
||||
monkeypatch.delenv("HERMES_ALLOW_PRIVATE_URLS", raising=False)
|
||||
cfg = {"security": {"allow_private_urls": True}, "browser": {"allow_private_urls": False}}
|
||||
with patch("hermes_cli.config.read_raw_config", return_value=cfg):
|
||||
assert _global_allow_private_urls() is True
|
||||
|
||||
def test_env_var_overrides_config(self, monkeypatch):
|
||||
"""Env var takes priority over config."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "false")
|
||||
cfg = {"security": {"allow_private_urls": True}}
|
||||
with patch("hermes_cli.config.read_raw_config", return_value=cfg):
|
||||
assert _global_allow_private_urls() is False
|
||||
|
||||
def test_result_is_cached(self, monkeypatch):
|
||||
"""Second call uses cached result, doesn't re-read config."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
assert _global_allow_private_urls() is True
|
||||
# Change env after first call — should still be True (cached)
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "false")
|
||||
assert _global_allow_private_urls() is True
|
||||
|
||||
|
||||
class TestAllowPrivateUrlsIntegration:
|
||||
"""Integration tests: is_safe_url respects the global toggle."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_cache(self):
|
||||
_reset_allow_private_cache()
|
||||
yield
|
||||
_reset_allow_private_cache()
|
||||
|
||||
def test_private_ip_allowed_when_toggle_on(self, monkeypatch):
|
||||
"""Private IPs pass is_safe_url when toggle is enabled."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("192.168.1.1", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://router.local") is True
|
||||
|
||||
def test_benchmark_ip_allowed_when_toggle_on(self, monkeypatch):
|
||||
"""198.18.x.x (benchmark/OpenWrt proxy range) passes when toggle is on."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("198.18.23.183", 0)),
|
||||
]):
|
||||
assert is_safe_url("https://nousresearch.com") is True
|
||||
|
||||
def test_cgnat_allowed_when_toggle_on(self, monkeypatch):
|
||||
"""CGNAT range (100.64.0.0/10) passes when toggle is on."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("100.100.100.100", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://tailscale-peer.example/") is True
|
||||
|
||||
def test_localhost_allowed_when_toggle_on(self, monkeypatch):
|
||||
"""Even localhost passes when toggle is on."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("127.0.0.1", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://localhost:8080/api") is True
|
||||
|
||||
# --- Cloud metadata always blocked regardless of toggle ---
|
||||
|
||||
def test_metadata_hostname_blocked_even_with_toggle(self, monkeypatch):
|
||||
"""metadata.google.internal is ALWAYS blocked."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
assert is_safe_url("http://metadata.google.internal/computeMetadata/v1/") is False
|
||||
|
||||
def test_metadata_goog_blocked_even_with_toggle(self, monkeypatch):
|
||||
"""metadata.goog is ALWAYS blocked."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
assert is_safe_url("http://metadata.goog/computeMetadata/v1/") is False
|
||||
|
||||
def test_metadata_ip_blocked_even_with_toggle(self, monkeypatch):
|
||||
"""169.254.169.254 (AWS/GCP metadata IP) is ALWAYS blocked."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("169.254.169.254", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://169.254.169.254/latest/meta-data/") is False
|
||||
|
||||
def test_metadata_ipv6_blocked_even_with_toggle(self, monkeypatch):
|
||||
"""fd00:ec2::254 (AWS IPv6 metadata) is ALWAYS blocked."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(10, 1, 6, "", ("fd00:ec2::254", 0, 0, 0)),
|
||||
]):
|
||||
assert is_safe_url("http://[fd00:ec2::254]/latest/") is False
|
||||
|
||||
def test_ecs_metadata_blocked_even_with_toggle(self, monkeypatch):
|
||||
"""169.254.170.2 (AWS ECS task metadata) is ALWAYS blocked."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("169.254.170.2", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://169.254.170.2/v2/credentials") is False
|
||||
|
||||
def test_alibaba_metadata_blocked_even_with_toggle(self, monkeypatch):
|
||||
"""100.100.100.200 (Alibaba Cloud metadata) is ALWAYS blocked."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("100.100.100.200", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://100.100.100.200/latest/meta-data/") is False
|
||||
|
||||
def test_azure_wire_server_blocked_even_with_toggle(self, monkeypatch):
|
||||
"""169.254.169.253 (Azure IMDS wire server) is ALWAYS blocked."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("169.254.169.253", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://169.254.169.253/") is False
|
||||
|
||||
def test_entire_link_local_blocked_even_with_toggle(self, monkeypatch):
|
||||
"""Any 169.254.x.x address is ALWAYS blocked (entire link-local range)."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("169.254.42.99", 0)),
|
||||
]):
|
||||
assert is_safe_url("http://169.254.42.99/anything") is False
|
||||
|
||||
def test_dns_failure_still_blocked_with_toggle(self, monkeypatch):
|
||||
"""DNS failures are still blocked even with toggle on."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
with patch("socket.getaddrinfo", side_effect=socket.gaierror("fail")):
|
||||
assert is_safe_url("https://nonexistent.example.com") is False
|
||||
|
||||
def test_empty_url_still_blocked_with_toggle(self, monkeypatch):
|
||||
"""Empty URLs are still blocked."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
assert is_safe_url("") is False
|
||||
|
||||
@@ -1182,6 +1182,15 @@ def _run_browser_command(
|
||||
# used during CLI discovery.
|
||||
browser_env["PATH"] = _merge_browser_path(browser_env.get("PATH", ""))
|
||||
browser_env["AGENT_BROWSER_SOCKET_DIR"] = task_socket_dir
|
||||
|
||||
# Tell the agent-browser daemon to self-terminate after being idle
|
||||
# for our configured inactivity timeout. This is the daemon-side
|
||||
# counterpart to our Python-side _cleanup_inactive_browser_sessions
|
||||
# — the daemon kills itself and its Chrome children when no CLI
|
||||
# commands arrive within the window. Added in agent-browser 0.24.
|
||||
if "AGENT_BROWSER_IDLE_TIMEOUT_MS" not in browser_env:
|
||||
idle_ms = str(BROWSER_SESSION_INACTIVITY_TIMEOUT * 1000)
|
||||
browser_env["AGENT_BROWSER_IDLE_TIMEOUT_MS"] = idle_ms
|
||||
|
||||
# Use temp files for stdout/stderr instead of pipes.
|
||||
# agent-browser starts a background daemon that inherits file
|
||||
|
||||
+583
-133
File diff suppressed because it is too large
Load Diff
@@ -271,6 +271,40 @@ LINTERS = {
|
||||
MAX_LINES = 2000
|
||||
MAX_LINE_LENGTH = 2000
|
||||
MAX_FILE_SIZE = 50 * 1024 # 50KB
|
||||
DEFAULT_READ_OFFSET = 1
|
||||
DEFAULT_READ_LIMIT = 500
|
||||
DEFAULT_SEARCH_OFFSET = 0
|
||||
DEFAULT_SEARCH_LIMIT = 50
|
||||
|
||||
|
||||
def _coerce_int(value: Any, default: int) -> int:
|
||||
"""Best-effort integer coercion for tool pagination inputs."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def normalize_read_pagination(offset: Any = DEFAULT_READ_OFFSET,
|
||||
limit: Any = DEFAULT_READ_LIMIT) -> tuple[int, int]:
|
||||
"""Return safe read_file pagination bounds.
|
||||
|
||||
Tool schemas declare minimum/maximum values, but not every caller or
|
||||
provider enforces schemas before dispatch. Clamp here so invalid values
|
||||
cannot leak into sed ranges like ``0,-1p``.
|
||||
"""
|
||||
normalized_offset = max(1, _coerce_int(offset, DEFAULT_READ_OFFSET))
|
||||
normalized_limit = _coerce_int(limit, DEFAULT_READ_LIMIT)
|
||||
normalized_limit = max(1, min(normalized_limit, MAX_LINES))
|
||||
return normalized_offset, normalized_limit
|
||||
|
||||
|
||||
def normalize_search_pagination(offset: Any = DEFAULT_SEARCH_OFFSET,
|
||||
limit: Any = DEFAULT_SEARCH_LIMIT) -> tuple[int, int]:
|
||||
"""Return safe search pagination bounds for shell head/tail pipelines."""
|
||||
normalized_offset = max(0, _coerce_int(offset, DEFAULT_SEARCH_OFFSET))
|
||||
normalized_limit = max(1, _coerce_int(limit, DEFAULT_SEARCH_LIMIT))
|
||||
return normalized_offset, normalized_limit
|
||||
|
||||
|
||||
class ShellFileOperations(FileOperations):
|
||||
@@ -461,8 +495,7 @@ class ShellFileOperations(FileOperations):
|
||||
# Expand ~ and other shell paths
|
||||
path = self._expand_path(path)
|
||||
|
||||
# Clamp limit
|
||||
limit = min(limit, MAX_LINES)
|
||||
offset, limit = normalize_read_pagination(offset, limit)
|
||||
|
||||
# Check if file exists and get size (wc -c is POSIX, works on Linux + macOS)
|
||||
stat_cmd = f"wc -c < {self._escape_shell_arg(path)} 2>/dev/null"
|
||||
@@ -866,6 +899,8 @@ class ShellFileOperations(FileOperations):
|
||||
Returns:
|
||||
SearchResult with matches or file list
|
||||
"""
|
||||
offset, limit = normalize_search_pagination(offset, limit)
|
||||
|
||||
# Expand ~ and other shell paths
|
||||
path = self._expand_path(path)
|
||||
|
||||
|
||||
+9
-1
@@ -11,7 +11,11 @@ from typing import Optional
|
||||
|
||||
from agent.file_safety import get_read_block_error
|
||||
from tools.binary_extensions import has_binary_extension
|
||||
from tools.file_operations import ShellFileOperations
|
||||
from tools.file_operations import (
|
||||
ShellFileOperations,
|
||||
normalize_read_pagination,
|
||||
normalize_search_pagination,
|
||||
)
|
||||
from tools import file_state
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
@@ -351,6 +355,8 @@ def clear_file_ops_cache(task_id: str = None):
|
||||
def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = "default") -> str:
|
||||
"""Read a file with pagination and line numbers."""
|
||||
try:
|
||||
offset, limit = normalize_read_pagination(offset, limit)
|
||||
|
||||
# ── Device path guard ─────────────────────────────────────────
|
||||
# Block paths that would hang the process (infinite output,
|
||||
# blocking on input). Pure path check — no I/O.
|
||||
@@ -762,6 +768,8 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
|
||||
task_id: str = "default") -> str:
|
||||
"""Search for content or files."""
|
||||
try:
|
||||
offset, limit = normalize_search_pagination(offset, limit)
|
||||
|
||||
# Track searches to detect *consecutive* repeated search loops.
|
||||
# Include pagination args so users can page through truncated
|
||||
# results without tripping the repeated-search guard.
|
||||
|
||||
+39
-2
@@ -72,11 +72,48 @@ from tools.tool_backend_helpers import (
|
||||
)
|
||||
|
||||
|
||||
def _safe_parse_import_env(
|
||||
name: str,
|
||||
default: Any,
|
||||
converter,
|
||||
type_label: str,
|
||||
):
|
||||
"""Parse module-level numeric env vars without breaking import.
|
||||
|
||||
Terminal tool is imported by CLI, ACP, tests, and tool discovery. A single
|
||||
malformed env var must not make the whole module unloadable at import time.
|
||||
"""
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
try:
|
||||
return converter(raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid value for %s: %r (expected %s). Falling back to %r.",
|
||||
name,
|
||||
raw,
|
||||
type_label,
|
||||
default,
|
||||
)
|
||||
return default
|
||||
|
||||
|
||||
# Hard cap on foreground timeout; override via TERMINAL_MAX_FOREGROUND_TIMEOUT env var.
|
||||
FOREGROUND_MAX_TIMEOUT = int(os.getenv("TERMINAL_MAX_FOREGROUND_TIMEOUT", "600"))
|
||||
FOREGROUND_MAX_TIMEOUT = _safe_parse_import_env(
|
||||
"TERMINAL_MAX_FOREGROUND_TIMEOUT",
|
||||
600,
|
||||
int,
|
||||
"integer",
|
||||
)
|
||||
|
||||
# Disk usage warning threshold (in GB)
|
||||
DISK_USAGE_WARNING_THRESHOLD_GB = float(os.getenv("TERMINAL_DISK_WARNING_GB", "500"))
|
||||
DISK_USAGE_WARNING_THRESHOLD_GB = _safe_parse_import_env(
|
||||
"TERMINAL_DISK_WARNING_GB",
|
||||
500.0,
|
||||
float,
|
||||
"number",
|
||||
)
|
||||
|
||||
|
||||
def _check_disk_usage_warning():
|
||||
|
||||
+110
-3
@@ -5,6 +5,13 @@ skill could trick the agent into fetching internal resources like cloud
|
||||
metadata endpoints (169.254.169.254), localhost services, or private
|
||||
network hosts.
|
||||
|
||||
The check can be globally disabled via ``security.allow_private_urls: true``
|
||||
in config.yaml for environments where DNS resolves external domains to
|
||||
private/benchmark-range IPs (OpenWrt routers, corporate proxies, VPNs
|
||||
that use 198.18.0.0/15 or 100.64.0.0/10). Even when disabled, cloud
|
||||
metadata hostnames (metadata.google.internal, 169.254.169.254) are
|
||||
**always** blocked — those are never legitimate agent targets.
|
||||
|
||||
Limitations (documented, not fixable at pre-flight level):
|
||||
- DNS rebinding (TOCTOU): an attacker-controlled DNS server with TTL=0
|
||||
can return a public IP for the check, then a private IP for the actual
|
||||
@@ -18,17 +25,35 @@ Limitations (documented, not fixable at pre-flight level):
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hostnames that should always be blocked regardless of IP resolution
|
||||
# or any config toggle. These are cloud metadata endpoints that an
|
||||
# attacker could use to steal instance credentials.
|
||||
_BLOCKED_HOSTNAMES = frozenset({
|
||||
"metadata.google.internal",
|
||||
"metadata.goog",
|
||||
})
|
||||
|
||||
# IPs and networks that should always be blocked regardless of the
|
||||
# allow_private_urls toggle. These are cloud metadata / credential
|
||||
# endpoints — the #1 SSRF target — and the link-local range where
|
||||
# they all live.
|
||||
_ALWAYS_BLOCKED_IPS = frozenset({
|
||||
ipaddress.ip_address("169.254.169.254"), # AWS/GCP/Azure/DO/Oracle metadata
|
||||
ipaddress.ip_address("169.254.170.2"), # AWS ECS task metadata (task IAM creds)
|
||||
ipaddress.ip_address("169.254.169.253"), # Azure IMDS wire server
|
||||
ipaddress.ip_address("fd00:ec2::254"), # AWS metadata (IPv6)
|
||||
ipaddress.ip_address("100.100.100.200"), # Alibaba Cloud metadata
|
||||
})
|
||||
_ALWAYS_BLOCKED_NETWORKS = (
|
||||
ipaddress.ip_network("169.254.0.0/16"), # Entire link-local range (no legit agent target)
|
||||
)
|
||||
|
||||
# Exact HTTPS hostnames allowed to resolve to private/benchmark-space IPs.
|
||||
# This is intentionally narrow: QQ media downloads can legitimately resolve
|
||||
# to 198.18.0.0/15 behind local proxy/benchmark infrastructure.
|
||||
@@ -42,6 +67,67 @@ _TRUSTED_PRIVATE_IP_HOSTS = frozenset({
|
||||
# VPNs, and some cloud internal networks.
|
||||
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global toggle: allow private/internal IP resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cached after first read so we don't hit the filesystem on every URL check.
|
||||
_allow_private_resolved = False
|
||||
_cached_allow_private: bool = False
|
||||
|
||||
|
||||
def _global_allow_private_urls() -> bool:
|
||||
"""Return True when the user has opted out of private-IP blocking.
|
||||
|
||||
Checks (in priority order):
|
||||
1. ``HERMES_ALLOW_PRIVATE_URLS`` env var (``true``/``1``/``yes``)
|
||||
2. ``security.allow_private_urls`` in config.yaml
|
||||
3. ``browser.allow_private_urls`` in config.yaml (legacy / backward compat)
|
||||
|
||||
Result is cached for the process lifetime.
|
||||
"""
|
||||
global _allow_private_resolved, _cached_allow_private
|
||||
if _allow_private_resolved:
|
||||
return _cached_allow_private
|
||||
|
||||
_allow_private_resolved = True
|
||||
_cached_allow_private = False # safe default
|
||||
|
||||
# 1. Env var override (highest priority)
|
||||
env_val = os.getenv("HERMES_ALLOW_PRIVATE_URLS", "").strip().lower()
|
||||
if env_val in ("true", "1", "yes"):
|
||||
_cached_allow_private = True
|
||||
return _cached_allow_private
|
||||
if env_val in ("false", "0", "no"):
|
||||
# Explicit false — don't fall through to config
|
||||
return _cached_allow_private
|
||||
|
||||
# 2. Config file
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
cfg = read_raw_config()
|
||||
# security.allow_private_urls (preferred)
|
||||
sec = cfg.get("security", {})
|
||||
if isinstance(sec, dict) and sec.get("allow_private_urls"):
|
||||
_cached_allow_private = True
|
||||
return _cached_allow_private
|
||||
# browser.allow_private_urls (legacy fallback)
|
||||
browser = cfg.get("browser", {})
|
||||
if isinstance(browser, dict) and browser.get("allow_private_urls"):
|
||||
_cached_allow_private = True
|
||||
return _cached_allow_private
|
||||
except Exception:
|
||||
# Config unavailable (e.g. tests, early import) — keep default
|
||||
pass
|
||||
|
||||
return _cached_allow_private
|
||||
|
||||
|
||||
def _reset_allow_private_cache() -> None:
|
||||
"""Reset the cached toggle — only for tests."""
|
||||
global _allow_private_resolved, _cached_allow_private
|
||||
_allow_private_resolved = False
|
||||
_cached_allow_private = False
|
||||
|
||||
|
||||
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""Return True if the IP should be blocked for SSRF protection."""
|
||||
@@ -65,6 +151,11 @@ def is_safe_url(url: str) -> bool:
|
||||
|
||||
Resolves the hostname to an IP and checks against private ranges.
|
||||
Fails closed: DNS errors and unexpected exceptions block the request.
|
||||
|
||||
When ``security.allow_private_urls`` is enabled (or the env var
|
||||
``HERMES_ALLOW_PRIVATE_URLS=true``), private-IP blocking is skipped.
|
||||
Cloud metadata endpoints (169.254.169.254, metadata.google.internal)
|
||||
remain blocked regardless — they are never legitimate agent targets.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
@@ -73,11 +164,14 @@ def is_safe_url(url: str) -> bool:
|
||||
if not hostname:
|
||||
return False
|
||||
|
||||
# Block known internal hostnames
|
||||
# Block known internal hostnames — ALWAYS, even with toggle on
|
||||
if hostname in _BLOCKED_HOSTNAMES:
|
||||
logger.warning("Blocked request to internal hostname: %s", hostname)
|
||||
return False
|
||||
|
||||
# Check the global toggle AFTER blocking metadata hostnames
|
||||
allow_all_private = _global_allow_private_urls()
|
||||
|
||||
allow_private_ip = _allows_private_ip_resolution(hostname, scheme)
|
||||
|
||||
# Try to resolve and check IP
|
||||
@@ -96,14 +190,27 @@ def is_safe_url(url: str) -> bool:
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not allow_private_ip and _is_blocked_ip(ip):
|
||||
# Always block cloud metadata IPs and link-local, even with toggle on
|
||||
if ip in _ALWAYS_BLOCKED_IPS or any(ip in net for net in _ALWAYS_BLOCKED_NETWORKS):
|
||||
logger.warning(
|
||||
"Blocked request to cloud metadata address: %s -> %s",
|
||||
hostname, ip_str,
|
||||
)
|
||||
return False
|
||||
|
||||
if not allow_all_private and not allow_private_ip and _is_blocked_ip(ip):
|
||||
logger.warning(
|
||||
"Blocked request to private/internal address: %s -> %s",
|
||||
hostname, ip_str,
|
||||
)
|
||||
return False
|
||||
|
||||
if allow_private_ip:
|
||||
if allow_all_private:
|
||||
logger.debug(
|
||||
"Allowing private/internal resolution (security.allow_private_urls=true): %s",
|
||||
hostname,
|
||||
)
|
||||
elif allow_private_ip:
|
||||
logger.debug(
|
||||
"Allowing trusted hostname despite private/internal resolution: %s",
|
||||
hostname,
|
||||
|
||||
+1156
-257
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'r
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import instances from '../instances.js'
|
||||
import { CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from '../termio/csi.js'
|
||||
import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js'
|
||||
import { TerminalWriteContext } from '../useTerminalNotification.js'
|
||||
|
||||
@@ -51,7 +52,9 @@ export function AlternateScreen(t0: Props) {
|
||||
return
|
||||
}
|
||||
|
||||
writeRaw(ENTER_ALT_SCREEN + '\x1B[2J\x1B[H' + (mouseTracking ? ENABLE_MOUSE_TRACKING : ''))
|
||||
writeRaw(
|
||||
ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : '')
|
||||
)
|
||||
ink?.setAltScreenActive(true, mouseTracking)
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -69,6 +69,12 @@ const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
|
||||
flexDirection: 'row',
|
||||
textWrap: 'wrap'
|
||||
},
|
||||
'wrap-char': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'wrap-char'
|
||||
},
|
||||
'wrap-trim': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
|
||||
@@ -343,7 +343,7 @@ function wrapWithSoftWrap(
|
||||
maxWidth: number,
|
||||
textWrap: Parameters<typeof wrapText>[2]
|
||||
): { wrapped: string; softWrap: boolean[] | undefined } {
|
||||
if (textWrap !== 'wrap' && textWrap !== 'wrap-trim') {
|
||||
if (textWrap !== 'wrap' && textWrap !== 'wrap-char' && textWrap !== 'wrap-trim') {
|
||||
return {
|
||||
wrapped: wrapText(plainText, maxWidth, textWrap),
|
||||
softWrap: undefined
|
||||
|
||||
@@ -55,6 +55,7 @@ export type TextStyles = {
|
||||
export type Styles = {
|
||||
readonly textWrap?:
|
||||
| 'wrap'
|
||||
| 'wrap-char'
|
||||
| 'wrap-trim'
|
||||
| 'end'
|
||||
| 'middle'
|
||||
|
||||
@@ -50,6 +50,10 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style
|
||||
})
|
||||
}
|
||||
|
||||
if (wrapType === 'wrap-char') {
|
||||
return wrapAnsi(text, maxWidth, { trim: false, hard: true, wordWrap: false })
|
||||
}
|
||||
|
||||
if (wrapType === 'wrap-trim') {
|
||||
return wrapAnsi(text, maxWidth, {
|
||||
trim: true,
|
||||
|
||||
@@ -30,3 +30,22 @@ describe('platform action modifier', () => {
|
||||
expect(isActionMod({ ctrl: false, meta: false, super: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMacActionFallback', () => {
|
||||
it('routes raw Ctrl+K and Ctrl+W to readline kill-to-end / delete-word on macOS', async () => {
|
||||
const { isMacActionFallback } = await importPlatform('darwin')
|
||||
|
||||
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'k', 'k')).toBe(true)
|
||||
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'w', 'w')).toBe(true)
|
||||
// Must not fire when Cmd (meta/super) is held — those are distinct chords.
|
||||
expect(isMacActionFallback({ ctrl: true, meta: true, super: false }, 'k', 'k')).toBe(false)
|
||||
expect(isMacActionFallback({ ctrl: true, meta: false, super: true }, 'w', 'w')).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op on non-macOS (Linux routes Ctrl+K/W through isActionMod directly)', async () => {
|
||||
const { isMacActionFallback } = await importPlatform('linux')
|
||||
|
||||
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'k', 'k')).toBe(false)
|
||||
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'w', 'w')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSubagentTree,
|
||||
descendantIds,
|
||||
flattenTree,
|
||||
fmtCost,
|
||||
fmtDuration,
|
||||
fmtTokens,
|
||||
formatSummary,
|
||||
hotnessBucket,
|
||||
peakHotness,
|
||||
sparkline,
|
||||
topLevelSubagents,
|
||||
treeTotals,
|
||||
widthByDepth
|
||||
} from '../lib/subagentTree.js'
|
||||
import type { SubagentProgress } from '../types.js'
|
||||
|
||||
const makeItem = (overrides: Partial<SubagentProgress> & Pick<SubagentProgress, 'id' | 'index'>): SubagentProgress => ({
|
||||
depth: 0,
|
||||
goal: overrides.id,
|
||||
notes: [],
|
||||
parentId: null,
|
||||
status: 'running',
|
||||
taskCount: 1,
|
||||
thinking: [],
|
||||
toolCount: 0,
|
||||
tools: [],
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('aggregate: tokens, cost, files, hotness', () => {
|
||||
it('sums tokens and cost across subtree', () => {
|
||||
const items = [
|
||||
makeItem({ costUsd: 0.01, id: 'p', index: 0, inputTokens: 1000, outputTokens: 500 }),
|
||||
makeItem({
|
||||
costUsd: 0.005,
|
||||
depth: 1,
|
||||
id: 'c1',
|
||||
index: 0,
|
||||
inputTokens: 500,
|
||||
outputTokens: 100,
|
||||
parentId: 'p'
|
||||
}),
|
||||
makeItem({
|
||||
costUsd: 0.008,
|
||||
depth: 1,
|
||||
id: 'c2',
|
||||
index: 1,
|
||||
inputTokens: 300,
|
||||
outputTokens: 200,
|
||||
parentId: 'p'
|
||||
})
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate).toMatchObject({
|
||||
costUsd: 0.023,
|
||||
inputTokens: 1800,
|
||||
outputTokens: 800
|
||||
})
|
||||
})
|
||||
|
||||
it('counts files read + written across subtree', () => {
|
||||
const items = [
|
||||
makeItem({ filesRead: ['a.ts', 'b.ts'], id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, filesWritten: ['c.ts'], id: 'c', index: 0, parentId: 'p' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.filesTouched).toBe(3)
|
||||
})
|
||||
|
||||
it('hotness = totalTools / totalDuration', () => {
|
||||
const items = [
|
||||
makeItem({
|
||||
durationSeconds: 10,
|
||||
id: 'p',
|
||||
index: 0,
|
||||
status: 'completed',
|
||||
toolCount: 20
|
||||
})
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.hotness).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
it('hotness is zero when duration is zero', () => {
|
||||
const items = [makeItem({ id: 'p', index: 0, toolCount: 10 })]
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.hotness).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hotnessBucket + peakHotness', () => {
|
||||
it('peakHotness walks subtree', () => {
|
||||
const items = [
|
||||
makeItem({ durationSeconds: 100, id: 'p', index: 0, status: 'completed', toolCount: 1 }),
|
||||
makeItem({
|
||||
depth: 1,
|
||||
durationSeconds: 1,
|
||||
id: 'c',
|
||||
index: 0,
|
||||
parentId: 'p',
|
||||
status: 'completed',
|
||||
toolCount: 5
|
||||
})
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(peakHotness(tree)).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('hotnessBucket clamps and normalizes', () => {
|
||||
expect(hotnessBucket(0, 10, 4)).toBe(0)
|
||||
expect(hotnessBucket(10, 10, 4)).toBe(3)
|
||||
expect(hotnessBucket(5, 10, 4)).toBe(2)
|
||||
expect(hotnessBucket(100, 10, 4)).toBe(3) // clamped
|
||||
expect(hotnessBucket(5, 0, 4)).toBe(0) // guard against divide-by-zero
|
||||
})
|
||||
})
|
||||
|
||||
describe('fmtCost + fmtTokens', () => {
|
||||
it('fmtCost handles ranges', () => {
|
||||
expect(fmtCost(0)).toBe('')
|
||||
expect(fmtCost(0.001)).toBe('<$0.01')
|
||||
expect(fmtCost(0.42)).toBe('$0.42')
|
||||
expect(fmtCost(1.23)).toBe('$1.23')
|
||||
expect(fmtCost(12.5)).toBe('$12.5')
|
||||
})
|
||||
|
||||
it('fmtTokens handles ranges', () => {
|
||||
expect(fmtTokens(0)).toBe('0')
|
||||
expect(fmtTokens(542)).toBe('542')
|
||||
expect(fmtTokens(1234)).toBe('1.2k')
|
||||
expect(fmtTokens(45678)).toBe('46k')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSummary with tokens + cost', () => {
|
||||
it('includes token + cost when present', () => {
|
||||
expect(
|
||||
formatSummary({
|
||||
activeCount: 0,
|
||||
costUsd: 0.42,
|
||||
descendantCount: 3,
|
||||
filesTouched: 0,
|
||||
hotness: 0,
|
||||
inputTokens: 8000,
|
||||
maxDepthFromHere: 2,
|
||||
outputTokens: 2000,
|
||||
totalDuration: 30,
|
||||
totalTools: 14
|
||||
})
|
||||
).toBe('d2 · 3 agents · 14 tools · 30s · 10k tok · $0.42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSubagentTree', () => {
|
||||
it('returns empty list for empty input', () => {
|
||||
expect(buildSubagentTree([])).toEqual([])
|
||||
})
|
||||
|
||||
it('treats flat list as top-level when no parentId is given', () => {
|
||||
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 }), makeItem({ id: 'c', index: 2 })]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree).toHaveLength(3)
|
||||
expect(tree.map(n => n.item.id)).toEqual(['a', 'b', 'c'])
|
||||
expect(tree.every(n => n.children.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('nests children under their parent by subagent_id', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'parent', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'child-1', index: 0, parentId: 'parent' }),
|
||||
makeItem({ depth: 1, id: 'child-2', index: 1, parentId: 'parent' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0]!.children).toHaveLength(2)
|
||||
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['child-1', 'child-2'])
|
||||
})
|
||||
|
||||
it('builds multi-level nesting', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' }),
|
||||
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.children[0]!.children[0]!.item.id).toBe('gc')
|
||||
expect(tree[0]!.aggregate.maxDepthFromHere).toBe(2)
|
||||
expect(tree[0]!.aggregate.descendantCount).toBe(2)
|
||||
})
|
||||
|
||||
it('promotes orphaned children (missing parent) to top level', () => {
|
||||
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree).toHaveLength(2)
|
||||
expect(tree.map(n => n.item.id)).toEqual(['a', 'orphan'])
|
||||
})
|
||||
|
||||
it('stable sort: children ordered by (depth, index) not insert order', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'c3', index: 2, parentId: 'p' }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['c1', 'c2', 'c3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('aggregate', () => {
|
||||
it('sums tool counts and durations across subtree', () => {
|
||||
const items = [
|
||||
makeItem({ durationSeconds: 10, id: 'p', index: 0, status: 'completed', toolCount: 5 }),
|
||||
makeItem({ depth: 1, durationSeconds: 4, id: 'c1', index: 0, parentId: 'p', status: 'completed', toolCount: 3 }),
|
||||
makeItem({ depth: 1, durationSeconds: 2, id: 'c2', index: 1, parentId: 'p', status: 'completed', toolCount: 1 })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate).toMatchObject({
|
||||
activeCount: 0,
|
||||
descendantCount: 2,
|
||||
totalDuration: 16,
|
||||
totalTools: 9
|
||||
})
|
||||
})
|
||||
|
||||
it('counts queued + running as active', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0, status: 'running' }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p', status: 'queued' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p', status: 'completed' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.activeCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('widthByDepth', () => {
|
||||
it('returns empty array for empty tree', () => {
|
||||
expect(widthByDepth([])).toEqual([])
|
||||
})
|
||||
|
||||
it('tallies nodes at each depth', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p1', index: 0 }),
|
||||
makeItem({ id: 'p2', index: 1 }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p1' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p1' }),
|
||||
makeItem({ depth: 1, id: 'c3', index: 0, parentId: 'p2' }),
|
||||
makeItem({ depth: 2, id: 'gc1', index: 0, parentId: 'c1' })
|
||||
]
|
||||
|
||||
expect(widthByDepth(buildSubagentTree(items))).toEqual([2, 3, 1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('treeTotals', () => {
|
||||
it('folds a full tree into a single rollup', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p1', index: 0, toolCount: 5 }),
|
||||
makeItem({ id: 'p2', index: 1, toolCount: 2 }),
|
||||
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p1', toolCount: 3 })
|
||||
]
|
||||
|
||||
const totals = treeTotals(buildSubagentTree(items))
|
||||
expect(totals.descendantCount).toBe(3)
|
||||
expect(totals.totalTools).toBe(10)
|
||||
expect(totals.maxDepthFromHere).toBe(2)
|
||||
})
|
||||
|
||||
it('returns zeros for empty tree', () => {
|
||||
expect(treeTotals([])).toEqual({
|
||||
activeCount: 0,
|
||||
costUsd: 0,
|
||||
descendantCount: 0,
|
||||
filesTouched: 0,
|
||||
hotness: 0,
|
||||
inputTokens: 0,
|
||||
maxDepthFromHere: 0,
|
||||
outputTokens: 0,
|
||||
totalDuration: 0,
|
||||
totalTools: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenTree + descendantIds', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
|
||||
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c1' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
|
||||
]
|
||||
|
||||
it('flattens in visit order (depth-first, pre-order)', () => {
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(flattenTree(tree).map(n => n.item.id)).toEqual(['p', 'c1', 'gc', 'c2'])
|
||||
})
|
||||
|
||||
it('collects descendant ids excluding the node itself', () => {
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(descendantIds(tree[0]!)).toEqual(['c1', 'gc', 'c2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sparkline', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(sparkline([])).toBe('')
|
||||
})
|
||||
|
||||
it('renders zeroes as spaces (not bottom glyph)', () => {
|
||||
expect(sparkline([0, 0])).toBe(' ')
|
||||
})
|
||||
|
||||
it('scales to the max value', () => {
|
||||
const out = sparkline([1, 8])
|
||||
expect(out).toHaveLength(2)
|
||||
expect(out[1]).toBe('█')
|
||||
})
|
||||
|
||||
it('sparse widths render as expected', () => {
|
||||
const out = sparkline([2, 3, 7, 4])
|
||||
expect(out).toHaveLength(4)
|
||||
expect([...out].every(ch => /[\s▁-█]/.test(ch))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSummary', () => {
|
||||
const emptyTotals = {
|
||||
activeCount: 0,
|
||||
costUsd: 0,
|
||||
descendantCount: 0,
|
||||
filesTouched: 0,
|
||||
hotness: 0,
|
||||
inputTokens: 0,
|
||||
maxDepthFromHere: 0,
|
||||
outputTokens: 0,
|
||||
totalDuration: 0,
|
||||
totalTools: 0
|
||||
}
|
||||
|
||||
it('collapses zero-valued components', () => {
|
||||
expect(formatSummary({ ...emptyTotals, descendantCount: 1 })).toBe('d0 · 1 agent')
|
||||
})
|
||||
|
||||
it('emits rich summary with all pieces', () => {
|
||||
expect(
|
||||
formatSummary({
|
||||
...emptyTotals,
|
||||
activeCount: 2,
|
||||
descendantCount: 7,
|
||||
maxDepthFromHere: 3,
|
||||
totalDuration: 134,
|
||||
totalTools: 124
|
||||
})
|
||||
).toBe('d3 · 7 agents · 124 tools · 2m 14s · ⚡2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fmtDuration', () => {
|
||||
it('formats under a minute as plain seconds', () => {
|
||||
expect(fmtDuration(0)).toBe('0s')
|
||||
expect(fmtDuration(42)).toBe('42s')
|
||||
expect(fmtDuration(59.4)).toBe('59s')
|
||||
})
|
||||
|
||||
it('formats whole minutes without trailing seconds', () => {
|
||||
expect(fmtDuration(60)).toBe('1m')
|
||||
expect(fmtDuration(180)).toBe('3m')
|
||||
})
|
||||
|
||||
it('mixes minutes and seconds', () => {
|
||||
expect(fmtDuration(134)).toBe('2m 14s')
|
||||
expect(fmtDuration(605)).toBe('10m 5s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('topLevelSubagents', () => {
|
||||
it('returns items with no parent', () => {
|
||||
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 })]
|
||||
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('excludes children whose parent is present', () => {
|
||||
const items = [makeItem({ id: 'p', index: 0 }), makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })]
|
||||
|
||||
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['p'])
|
||||
})
|
||||
|
||||
it('promotes orphans whose parent is missing', () => {
|
||||
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
|
||||
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'orphan'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cursorLayout, offsetFromPosition } from '../components/textInput.js'
|
||||
|
||||
describe('cursorLayout — char-wrap parity with wrap-ansi', () => {
|
||||
it('places cursor mid-line at its column', () => {
|
||||
expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 })
|
||||
})
|
||||
|
||||
it('places cursor at end of a non-full line', () => {
|
||||
expect(cursorLayout('hi', 2, 10)).toEqual({ column: 2, line: 0 })
|
||||
})
|
||||
|
||||
it('wraps to next line when cursor lands exactly at the right edge', () => {
|
||||
// 8 chars on an 8-col line: text fills the row exactly; the cursor's
|
||||
// inverted-space cell overflows to col 0 of the next row.
|
||||
expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 0, line: 1 })
|
||||
})
|
||||
|
||||
it('tracks a word across a char-wrap boundary without jumping', () => {
|
||||
// With wordWrap:false, "hello world" at cols=8 is "hello wo\nrld" —
|
||||
// typing incremental letters doesn't reshuffle the word across lines.
|
||||
expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 0, line: 1 })
|
||||
expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 1, line: 1 })
|
||||
expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 2, line: 1 })
|
||||
})
|
||||
|
||||
it('honours explicit newlines', () => {
|
||||
expect(cursorLayout('one\ntwo', 5, 40)).toEqual({ column: 1, line: 1 })
|
||||
expect(cursorLayout('one\ntwo', 4, 40)).toEqual({ column: 0, line: 1 })
|
||||
})
|
||||
|
||||
it('does not wrap when cursor is before the right edge', () => {
|
||||
expect(cursorLayout('abcdefg', 7, 8)).toEqual({ column: 7, line: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => {
|
||||
it('returns 0 for empty input', () => {
|
||||
expect(offsetFromPosition('', 0, 0, 10)).toBe(0)
|
||||
})
|
||||
|
||||
it('maps clicks within a single line', () => {
|
||||
expect(offsetFromPosition('hello', 0, 3, 40)).toBe(3)
|
||||
})
|
||||
|
||||
it('maps clicks past end to value length', () => {
|
||||
expect(offsetFromPosition('hi', 0, 10, 40)).toBe(2)
|
||||
})
|
||||
|
||||
it('maps clicks on a wrapped second row at cols boundary', () => {
|
||||
// "abcdefghij" at cols=8 wraps to "abcdefgh\nij" — click at row 1 col 0
|
||||
// should land on 'i' (offset 8).
|
||||
expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8)
|
||||
})
|
||||
|
||||
it('maps clicks past a \\n into the target line', () => {
|
||||
expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user