mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
Merge branch 'main' into bb/gui
This commit is contained in:
commit
046f0c01cb
45 changed files with 1528 additions and 171 deletions
|
|
@ -182,6 +182,7 @@ scripts/run_tests.sh
|
|||
- 💬 [Discord](https://discord.gg/NousResearch)
|
||||
- 📚 [Skills Hub](https://agentskills.io)
|
||||
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
|
||||
- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — Linux desktop-control MCP server for Hermes and other MCP hosts, with AT-SPI accessibility trees, Wayland/X11 input, screenshots, and compositor window targeting.
|
||||
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -129,6 +129,9 @@ class PooledCredential:
|
|||
def from_dict(cls, provider: str, payload: Dict[str, Any]) -> "PooledCredential":
|
||||
field_names = {f.name for f in fields(cls) if f.name != "provider"}
|
||||
data = {k: payload.get(k) for k in field_names if k in payload}
|
||||
# Rehydrated last_status_at may be an ISO string from to_dict() — normalize to float epoch
|
||||
if "last_status_at" in data and isinstance(data["last_status_at"], str):
|
||||
data["last_status_at"] = _parse_absolute_timestamp(data["last_status_at"])
|
||||
extra = {k: payload[k] for k in _EXTRA_KEYS if k in payload and payload[k] is not None}
|
||||
data["extra"] = extra
|
||||
data.setdefault("id", uuid.uuid4().hex[:6])
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ def build_skill_invocation_message(
|
|||
|
||||
loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id)
|
||||
if not loaded:
|
||||
return f"[Failed to load skill: {skill_info['name']}]"
|
||||
return None
|
||||
|
||||
loaded_skill, skill_dir, skill_name = loaded
|
||||
|
||||
|
|
|
|||
2
cli.py
2
cli.py
|
|
@ -12546,6 +12546,7 @@ class HermesCLI:
|
|||
paste_dir.mkdir(parents=True, exist_ok=True)
|
||||
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
|
||||
paste_file.write_text(pasted_text, encoding="utf-8")
|
||||
logger.info("Collapsed paste #%d: %d lines, %d chars -> %s", _paste_counter[0], line_count + 1, len(pasted_text), paste_file)
|
||||
placeholder = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
|
||||
prefix = ""
|
||||
if buf.cursor_position > 0 and buf.text[buf.cursor_position - 1] != '\n':
|
||||
|
|
@ -12713,6 +12714,7 @@ class HermesCLI:
|
|||
paste_dir.mkdir(parents=True, exist_ok=True)
|
||||
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
|
||||
paste_file.write_text(text, encoding="utf-8")
|
||||
logger.info("Collapsed paste #%d: %d lines, %d chars -> %s (fallback)", _paste_counter[0], line_count + 1, len(text), paste_file)
|
||||
_paste_just_collapsed[0] = True
|
||||
buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
|
||||
buf.cursor_position = len(buf.text)
|
||||
|
|
|
|||
|
|
@ -829,6 +829,9 @@ SUPPORTED_DOCUMENT_TYPES = {
|
|||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".ts": "text/plain",
|
||||
".py": "text/plain",
|
||||
".sh": "text/plain",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3564,6 +3564,43 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
return bool(configured)
|
||||
return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"}
|
||||
|
||||
def _discord_allow_any_attachment(self) -> bool:
|
||||
"""Return whether Discord attachments bypass the SUPPORTED_DOCUMENT_TYPES allowlist.
|
||||
|
||||
When True, any uploaded file is cached to disk and surfaced to the
|
||||
agent as a local path so it can be inspected via terminal / read_file
|
||||
/ ffprobe / etc. Default False preserves the historical behaviour of
|
||||
dropping unsupported types with a warning log.
|
||||
"""
|
||||
configured = self.config.extra.get("allow_any_attachment")
|
||||
if configured is not None:
|
||||
if isinstance(configured, str):
|
||||
return configured.lower() not in {"false", "0", "no", "off", ""}
|
||||
return bool(configured)
|
||||
return os.getenv("DISCORD_ALLOW_ANY_ATTACHMENT", "false").lower() in {"true", "1", "yes", "on"}
|
||||
|
||||
def _discord_max_attachment_bytes(self) -> int:
|
||||
"""Return the per-attachment byte cap. 0 means unlimited.
|
||||
|
||||
The whole attachment is held in memory while being written to the
|
||||
cache, so unlimited carries a real memory cost. Default 32 MiB
|
||||
matches the historical hardcoded value.
|
||||
"""
|
||||
configured = self.config.extra.get("max_attachment_bytes")
|
||||
if configured is None:
|
||||
configured = os.getenv("DISCORD_MAX_ATTACHMENT_BYTES")
|
||||
if configured is None or configured == "":
|
||||
return 32 * 1024 * 1024
|
||||
try:
|
||||
value = int(configured)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"[Discord] Invalid max_attachment_bytes value %r, falling back to 32 MiB",
|
||||
configured,
|
||||
)
|
||||
return 32 * 1024 * 1024
|
||||
return max(0, value)
|
||||
|
||||
def _discord_free_response_channels(self) -> set:
|
||||
"""Return Discord channel IDs where no bot mention is required.
|
||||
|
||||
|
|
@ -4495,6 +4532,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
if normalized_content.startswith("/"):
|
||||
msg_type = MessageType.COMMAND
|
||||
elif all_attachments:
|
||||
_allow_any = self._discord_allow_any_attachment()
|
||||
# Check attachment types
|
||||
for att in all_attachments:
|
||||
if att.content_type:
|
||||
|
|
@ -4509,9 +4547,15 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
if att.filename:
|
||||
_, doc_ext = os.path.splitext(att.filename)
|
||||
doc_ext = doc_ext.lower()
|
||||
if doc_ext in SUPPORTED_DOCUMENT_TYPES:
|
||||
if doc_ext in SUPPORTED_DOCUMENT_TYPES or _allow_any:
|
||||
msg_type = MessageType.DOCUMENT
|
||||
break
|
||||
elif _allow_any:
|
||||
# No content_type at all (rare — discord usually fills it
|
||||
# in). Treat as a document so downstream pipelines surface
|
||||
# the path to the agent.
|
||||
msg_type = MessageType.DOCUMENT
|
||||
break
|
||||
|
||||
# When auto-threading kicked in, route responses to the new thread
|
||||
effective_channel = auto_threaded_channel or message.channel
|
||||
|
|
@ -4594,31 +4638,48 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
if not ext and content_type:
|
||||
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
|
||||
ext = mime_to_ext.get(content_type, "")
|
||||
if ext not in SUPPORTED_DOCUMENT_TYPES:
|
||||
allow_any_attachment = self._discord_allow_any_attachment()
|
||||
in_allowlist = ext in SUPPORTED_DOCUMENT_TYPES
|
||||
if not in_allowlist and not allow_any_attachment:
|
||||
logger.warning(
|
||||
"[Discord] Unsupported document type '%s' (%s), skipping",
|
||||
ext or "unknown", content_type,
|
||||
)
|
||||
else:
|
||||
MAX_DOC_BYTES = 32 * 1024 * 1024
|
||||
if att.size and att.size > MAX_DOC_BYTES:
|
||||
max_doc_bytes = self._discord_max_attachment_bytes()
|
||||
if max_doc_bytes and att.size and att.size > max_doc_bytes:
|
||||
logger.warning(
|
||||
"[Discord] Document too large (%s bytes), skipping: %s",
|
||||
att.size, att.filename,
|
||||
"[Discord] Document too large (%s bytes > cap %s), skipping: %s",
|
||||
att.size, max_doc_bytes, att.filename,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
raw_bytes = await self._cache_discord_document(att, ext)
|
||||
cached_path = cache_document_from_bytes(
|
||||
raw_bytes, att.filename or f"document{ext}"
|
||||
raw_bytes, att.filename or f"document{ext or '.bin'}"
|
||||
)
|
||||
doc_mime = SUPPORTED_DOCUMENT_TYPES[ext]
|
||||
if in_allowlist:
|
||||
doc_mime = SUPPORTED_DOCUMENT_TYPES[ext]
|
||||
else:
|
||||
# allow_any_attachment path: untyped file. Use the
|
||||
# source content_type if discord gave us one,
|
||||
# otherwise fall back to octet-stream so the agent
|
||||
# knows it's binary and reaches for terminal tools.
|
||||
doc_mime = (
|
||||
content_type
|
||||
if content_type and content_type != "unknown"
|
||||
else "application/octet-stream"
|
||||
)
|
||||
media_urls.append(cached_path)
|
||||
media_types.append(doc_mime)
|
||||
logger.info("[Discord] Cached user document: %s", cached_path)
|
||||
logger.info(
|
||||
"[Discord] Cached user %s: %s",
|
||||
"document" if in_allowlist else "attachment",
|
||||
cached_path,
|
||||
)
|
||||
# Inject text content for plain-text documents (capped at 100 KB)
|
||||
MAX_TEXT_INJECT_BYTES = 100 * 1024
|
||||
if ext in {".md", ".txt", ".log"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
|
||||
if in_allowlist and ext in {".md", ".txt", ".log"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
|
||||
try:
|
||||
text_content = raw_bytes.decode("utf-8")
|
||||
display_name = att.filename or f"document{ext}"
|
||||
|
|
@ -4630,6 +4691,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
pending_text_injection = injection
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
# NOTE: for the allow_any_attachment path we deliberately
|
||||
# do NOT inject a path string here. ``gateway/run.py``
|
||||
# already detects DOCUMENT-typed events with
|
||||
# ``application/octet-stream`` MIME and emits a context
|
||||
# note with the sandbox-translated cache path via
|
||||
# ``to_agent_visible_cache_path()`` (important for
|
||||
# Docker/Modal terminal backends).
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[Discord] Failed to cache document %s: %s",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,13 @@ from gateway.platforms.base import (
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BUILTIN_DELIVER_PLATFORMS = {
|
||||
"telegram", "discord", "slack", "signal", "sms", "whatsapp",
|
||||
"matrix", "mattermost", "homeassistant", "email", "dingtalk",
|
||||
"feishu", "wecom", "wecom_callback", "weixin", "bluebubbles",
|
||||
"qqbot", "yuanbao",
|
||||
}
|
||||
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8644
|
||||
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
|
||||
|
|
@ -238,12 +245,6 @@ class WebhookAdapter(BasePlatformAdapter):
|
|||
|
||||
# Cross-platform delivery — any platform with a gateway adapter.
|
||||
# Check both built-in names and plugin-registered platforms.
|
||||
_BUILTIN_DELIVER_PLATFORMS = {
|
||||
"telegram", "discord", "slack", "signal", "sms", "whatsapp",
|
||||
"matrix", "mattermost", "homeassistant", "email", "dingtalk",
|
||||
"feishu", "wecom", "wecom_callback", "weixin", "bluebubbles",
|
||||
"qqbot", "yuanbao",
|
||||
}
|
||||
_is_known_platform = deliver_type in _BUILTIN_DELIVER_PLATFORMS
|
||||
if not _is_known_platform:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1313,6 +1313,18 @@ DEFAULT_CONFIG = {
|
|||
# list_roles, member_info, search_members, fetch_messages, list_pins,
|
||||
# pin_message, unpin_message, create_thread, add_role, remove_role.
|
||||
"server_actions": "",
|
||||
# Accept arbitrary attachment file types (not just SUPPORTED_DOCUMENT_TYPES).
|
||||
# When True, any uploaded file is cached to disk with mime
|
||||
# application/octet-stream and the path is surfaced to the agent so it
|
||||
# can use terminal/read_file/etc. against it. Default False preserves
|
||||
# the historical allowlist behaviour.
|
||||
# Env override: DISCORD_ALLOW_ANY_ATTACHMENT.
|
||||
"allow_any_attachment": False,
|
||||
# Maximum bytes per attachment the gateway will cache. The whole file
|
||||
# is held in memory while being written, so unlimited uploads carry a
|
||||
# real memory cost. Default 32 MiB matches the historical hardcoded
|
||||
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
|
||||
"max_attachment_bytes": 33554432,
|
||||
},
|
||||
|
||||
# WhatsApp platform settings (gateway mode)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Handles: hermes gateway [run|start|stop|restart|status|install|uninstall|setup]
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
|
|
@ -38,6 +39,7 @@ from hermes_cli.setup import (
|
|||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# Process Management (for manual gateway runs)
|
||||
|
|
|
|||
|
|
@ -1403,7 +1403,7 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int:
|
|||
sev = getattr(args, "severity", None)
|
||||
if sev:
|
||||
for tid in list(diags_by_task.keys()):
|
||||
kept = [d for d in diags_by_task[tid] if d.severity == sev]
|
||||
kept = [d for d in diags_by_task[tid] if kd.SEVERITY_ORDER.index(d.severity) >= kd.SEVERITY_ORDER.index(sev)]
|
||||
if kept:
|
||||
diags_by_task[tid] = kept
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1089,7 +1089,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
|||
return [node, str(bundled)], bundled.parent
|
||||
|
||||
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
|
||||
# --dev flow: npm install if needed, then tsx src/entry.tsx (no build).
|
||||
# --dev flow: npm install if needed, then tsx src/entry.tsx.
|
||||
if _tui_need_npm_install(tui_dir):
|
||||
npm = _node_bin("npm")
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
|
|
@ -1111,10 +1111,30 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
|||
sys.exit(1)
|
||||
|
||||
if tui_dev:
|
||||
# Keep the local @hermes/ink package exports in sync with source.
|
||||
# --dev runs src/entry.tsx directly, but @hermes/ink resolves through
|
||||
# packages/hermes-ink/dist/entry-exports.js. If that dist bundle is
|
||||
# stale after a pull, newer hooks/components can exist in src while
|
||||
# being missing at runtime (e.g. useCursorAdvance). Prebuild it here.
|
||||
npm = _node_bin("npm")
|
||||
ink_dir = tui_dir / "packages" / "hermes-ink"
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=str(ink_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("TUI dev prebuild failed.")
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
|
||||
tsx = tui_dir / "node_modules" / ".bin" / "tsx"
|
||||
if tsx.exists():
|
||||
return [str(tsx), "src/entry.tsx"], tui_dir
|
||||
npm = _node_bin("npm")
|
||||
return [npm, "start"], tui_dir
|
||||
|
||||
# Always rebuild — esbuild is fast and this avoids staleness-edge-case bugs.
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ def _read_message_body(
|
|||
if file_path == "-":
|
||||
return sys.stdin.read()
|
||||
try:
|
||||
return Path(file_path).read_text()
|
||||
except OSError as exc:
|
||||
return Path(file_path).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(f"hermes send: cannot read {file_path}: {exc}", file=sys.stderr)
|
||||
sys.exit(_USAGE_EXIT)
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ deepseek = DeepSeekProfile(
|
|||
"deepseek-reasoner",
|
||||
),
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
default_aux_model="deepseek-chat",
|
||||
)
|
||||
|
||||
register_provider(deepseek)
|
||||
|
|
|
|||
|
|
@ -116,6 +116,13 @@ def _parse_bool(value: Any, *, default: bool = False) -> bool:
|
|||
return default
|
||||
|
||||
|
||||
def _coerce_port(value: Any, *, default: int = _DEFAULT_PORT) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
class _StaticAccessTokenProvider:
|
||||
"""Minimal token-provider shim so outbound Graph delivery can reuse the shared client."""
|
||||
|
||||
|
|
@ -623,7 +630,9 @@ class TeamsAdapter(BasePlatformAdapter):
|
|||
self._client_id = extra.get("client_id") or os.getenv("TEAMS_CLIENT_ID", "")
|
||||
self._client_secret = extra.get("client_secret") or os.getenv("TEAMS_CLIENT_SECRET", "")
|
||||
self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "")
|
||||
self._port = int(extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT)))
|
||||
self._port = _coerce_port(
|
||||
extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT))
|
||||
)
|
||||
self._app: Optional["App"] = None
|
||||
self._runner: Optional["web.AppRunner"] = None
|
||||
self._dedup = MessageDeduplicator(max_size=1000)
|
||||
|
|
|
|||
24
run_agent.py
24
run_agent.py
|
|
@ -2022,7 +2022,7 @@ class AIAgent:
|
|||
try:
|
||||
_mem_provider_name = mem_config.get("provider", "") if mem_config else ""
|
||||
|
||||
if _mem_provider_name:
|
||||
if _mem_provider_name and _mem_provider_name.strip():
|
||||
from agent.memory_manager import MemoryManager as _MemoryManager
|
||||
from plugins.memory import load_memory_provider as _load_mem
|
||||
self._memory_manager = _MemoryManager()
|
||||
|
|
@ -4347,6 +4347,21 @@ class AIAgent:
|
|||
# owns the loop and the agent-loop tools dispatch.
|
||||
if _parent_api_mode == "codex_app_server":
|
||||
_parent_api_mode = "codex_responses"
|
||||
# skip_memory=True keeps the review fork from
|
||||
# touching external memory plugins (honcho, mem0,
|
||||
# supermemory, etc.). Without it, the fork's
|
||||
# __init__ rebuilds its own _memory_manager from
|
||||
# config, scoped to the parent's session_id, and
|
||||
# run_conversation() then leaks the harness prompt
|
||||
# into the user's real memory namespace via three
|
||||
# ingestion sites: on_turn_start (cadence + turn
|
||||
# message), prefetch_all (recall query), and
|
||||
# sync_all (harness prompt + review output recorded
|
||||
# as a (user, assistant) turn pair). Built-in
|
||||
# MEMORY.md / USER.md state is re-bound from the
|
||||
# parent below so memory(action="add") writes from
|
||||
# the review still land on disk; the review just
|
||||
# has zero side effects on external providers.
|
||||
review_agent = AIAgent(
|
||||
model=self.model,
|
||||
max_iterations=16,
|
||||
|
|
@ -4358,6 +4373,7 @@ class AIAgent:
|
|||
api_key=_parent_runtime.get("api_key") or None,
|
||||
credential_pool=getattr(self, "_credential_pool", None),
|
||||
parent_session_id=self.session_id,
|
||||
skip_memory=True,
|
||||
)
|
||||
review_agent._memory_write_origin = "background_review"
|
||||
review_agent._memory_write_context = "background_review"
|
||||
|
|
@ -9169,6 +9185,7 @@ class AIAgent:
|
|||
self.model, base_url=self.base_url,
|
||||
api_key=self.api_key, provider=self.provider,
|
||||
config_context_length=getattr(self, "_config_context_length", None),
|
||||
custom_providers=self._custom_providers,
|
||||
)
|
||||
self.context_compressor.update_model(
|
||||
model=self.model,
|
||||
|
|
@ -10096,6 +10113,7 @@ class AIAgent:
|
|||
"openai/",
|
||||
"x-ai/",
|
||||
"google/gemini-2",
|
||||
"google/gemma-4",
|
||||
"qwen/qwen3",
|
||||
"tencent/hy3-preview",
|
||||
"xiaomi/",
|
||||
|
|
@ -10393,12 +10411,16 @@ class AIAgent:
|
|||
Kimi ``/coding`` and Moonshot thinking mode both require
|
||||
``reasoning_content`` on every assistant tool-call message; omitting
|
||||
it causes the next replay to fail with HTTP 400.
|
||||
|
||||
Also detects Kimi models served through third-party providers (e.g.
|
||||
ollama-cloud) by matching ``kimi`` in the model name.
|
||||
"""
|
||||
return (
|
||||
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")
|
||||
or "kimi" in (self.model or "").lower()
|
||||
)
|
||||
|
||||
def _needs_deepseek_tool_reasoning(self) -> bool:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1086,6 +1086,33 @@ AUTHOR_MAP = {
|
|||
"nightcityblade@gmail.com": "nightcityblade", # PR #24138 (docs voice/tts table)
|
||||
"pol.kuijken@gmail.com": "polkn", # PR #6136 salvage (skill_view collision refusal)
|
||||
"robin@soal.org": "rewbs",
|
||||
# batch salvage (May 2026 LHF run)
|
||||
"sauravsejal40@gmail.com": "Saurav0989", # PR #27071 (docs: hermes-eval community link)
|
||||
"220110965+Saurav0989@users.noreply.github.com": "Saurav0989",
|
||||
"aviarchi1994@gmail.com": "avifenesh", # PR #25902 (docs: computer-use-linux MCP)
|
||||
"55848801+avifenesh@users.noreply.github.com": "avifenesh",
|
||||
"279959838+BROCCOLO1D@users.noreply.github.com": "BROCCOLO1D", # PR #26796 (docs: spotify + HA)
|
||||
"m@matthewlai.ca": "matthewlai", # PR #25293 (feat: gemma 4 reasoning allowlist)
|
||||
"4296245+matthewlai@users.noreply.github.com": "matthewlai",
|
||||
"109617724+0xchainer@users.noreply.github.com": "0xchainer", # PR #27154/27138/27147 salvage
|
||||
"201800237+kronexoi@users.noreply.github.com": "kronexoi", # PR #27167 salvage (Teams port fallback)
|
||||
# batch salvage (May 2026 LHF run, group 2)
|
||||
"shellybotmoyer@example.com": "shellybotmoyer", # PR #26661 (kanban --severity >=)
|
||||
"coulson@shellybotmoyer.com": "shellybotmoyer", # PR #25576 (credential_pool ISO rehydrate)
|
||||
"258858106+shellybotmoyer@users.noreply.github.com": "shellybotmoyer",
|
||||
"33156212+ether-btc@users.noreply.github.com": "ether-btc", # PR #26632 (memory provider whitespace guard)
|
||||
"Bloomtonjovish@gmail.com": "LifeJiggy", # PR #26516 (paste collapse logging)
|
||||
"141562589+LifeJiggy@users.noreply.github.com": "LifeJiggy",
|
||||
"beastant1@gmail.com": "nekwo", # PR #26481 (PS5.1 UTF-8 BOM)
|
||||
"43717185+nekwo@users.noreply.github.com": "nekwo",
|
||||
"67979730+flooryyyy@users.noreply.github.com": "flooryyyy", # PR #26374 (tool_trace error detection)
|
||||
"188585318+dgians@users.noreply.github.com": "dgians", # PR #26034 (.ts/.py/.sh docs types)
|
||||
"zealy@tz.co": "dgians", # PR #26034 (bot-committed by zealy-tzco under dgians' PR)
|
||||
"mottei.survive@gmail.com": "flanny7", # PR #27030 (setup_open_webui python var)
|
||||
"20530505+flanny7@users.noreply.github.com": "flanny7",
|
||||
"hermesagent26@gmail.com": "hermesagent26", # PR #26438 (kimi model-name reasoning pad)
|
||||
"276067471+hermesagent26@users.noreply.github.com": "hermesagent26",
|
||||
"71590782+kriscolab@users.noreply.github.com": "kriscolab", # PR #26926 (deepseek default_aux_model)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -163,8 +163,8 @@ install_open_webui() {
|
|||
"$py" -m venv "$OPEN_WEBUI_VENV"
|
||||
# shellcheck disable=SC1090
|
||||
source "$OPEN_WEBUI_VENV/bin/activate"
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
python -m pip install open-webui
|
||||
"$py" -m pip install --upgrade pip setuptools wheel
|
||||
"$py" -m pip install open-webui
|
||||
}
|
||||
|
||||
write_launcher() {
|
||||
|
|
|
|||
134
scripts/tests/test-install-ps1-stage-protocol.ps1
Normal file
134
scripts/tests/test-install-ps1-stage-protocol.ps1
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Smoke tests for the install.ps1 stage protocol.
|
||||
#
|
||||
# Run from a PowerShell prompt:
|
||||
#
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-stage-protocol.ps1
|
||||
#
|
||||
# These tests only exercise the metadata surface (-ProtocolVersion, -Manifest,
|
||||
# unknown -Stage handling). They DO NOT actually run any install stages --
|
||||
# those have heavy side effects (winget, git clone, pip install, PATH writes)
|
||||
# and are out of scope for a unit smoke test. All three metadata commands
|
||||
# below return without invoking Main / Invoke-AllStages.
|
||||
#
|
||||
# To exercise real install stages, drive the script from a clean VM.
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
|
||||
$installScript = Join-Path $repoRoot "scripts\install.ps1"
|
||||
|
||||
if (-not (Test-Path $installScript)) {
|
||||
throw "Could not locate install.ps1 at $installScript"
|
||||
}
|
||||
|
||||
$failures = 0
|
||||
function Assert-Equal {
|
||||
param([Parameter(Mandatory=$true)] $Expected,
|
||||
[Parameter(Mandatory=$true)] $Actual,
|
||||
[Parameter(Mandatory=$true)] [string]$Label)
|
||||
if ($Expected -ne $Actual) {
|
||||
Write-Host "FAIL: $Label" -ForegroundColor Red
|
||||
Write-Host " expected: $Expected"
|
||||
Write-Host " actual: $Actual"
|
||||
$script:failures++
|
||||
} else {
|
||||
Write-Host "OK: $Label" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
function Assert-True {
|
||||
param([Parameter(Mandatory=$true)] $Condition,
|
||||
[Parameter(Mandatory=$true)] [string]$Label)
|
||||
if (-not $Condition) {
|
||||
Write-Host "FAIL: $Label" -ForegroundColor Red
|
||||
$script:failures++
|
||||
} else {
|
||||
Write-Host "OK: $Label" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: -ProtocolVersion emits a single integer
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -ProtocolVersion --"
|
||||
$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -ProtocolVersion
|
||||
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-ProtocolVersion exits 0"
|
||||
Assert-True ($output -match '^\d+$') -Label "-ProtocolVersion emits an integer (got: $output)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: -Manifest emits valid JSON with expected shape
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -Manifest --"
|
||||
$manifestJson = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Manifest
|
||||
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-Manifest exits 0"
|
||||
|
||||
$manifest = $null
|
||||
try {
|
||||
$manifest = $manifestJson | ConvertFrom-Json
|
||||
Assert-True $true -Label "-Manifest output parses as JSON"
|
||||
} catch {
|
||||
Assert-True $false -Label "-Manifest output parses as JSON (parse error: $_)"
|
||||
}
|
||||
|
||||
if ($manifest) {
|
||||
Assert-True ($manifest.protocol_version -is [int] -or $manifest.protocol_version -is [long]) `
|
||||
-Label "manifest.protocol_version is an integer"
|
||||
Assert-True ($manifest.stages.Count -gt 0) -Label "manifest.stages is non-empty"
|
||||
|
||||
# Every stage has the four required fields
|
||||
$allValid = $true
|
||||
foreach ($stage in $manifest.stages) {
|
||||
foreach ($field in @("name", "title", "category", "needs_user_input")) {
|
||||
if (-not ($stage.PSObject.Properties.Name -contains $field)) {
|
||||
Write-Host " stage missing field '$field': $($stage | ConvertTo-Json -Compress)" -ForegroundColor Red
|
||||
$allValid = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert-True $allValid -Label "every stage has name/title/category/needs_user_input"
|
||||
|
||||
# Specific stage names that the GUI driver will rely on
|
||||
$names = $manifest.stages | ForEach-Object { $_.name }
|
||||
foreach ($expected in @("uv", "python", "git", "venv", "dependencies", "configure", "gateway")) {
|
||||
Assert-True ($names -contains $expected) -Label "manifest contains stage '$expected'"
|
||||
}
|
||||
|
||||
# The two known-interactive stages must declare needs_user_input
|
||||
$interactive = $manifest.stages | Where-Object { $_.needs_user_input } | ForEach-Object { $_.name }
|
||||
Assert-True ($interactive -contains "configure") -Label "'configure' stage flagged needs_user_input"
|
||||
Assert-True ($interactive -contains "gateway") -Label "'gateway' stage flagged needs_user_input"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: unknown stage name -> exit 2, structured JSON error
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -Stage with unknown name --"
|
||||
$errOutput = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Stage "does-not-exist"
|
||||
Assert-Equal -Expected 2 -Actual $LASTEXITCODE -Label "unknown -Stage exits 2"
|
||||
|
||||
$errFrame = $null
|
||||
try {
|
||||
$errFrame = $errOutput | ConvertFrom-Json
|
||||
Assert-True $true -Label "unknown-stage output parses as JSON"
|
||||
} catch {
|
||||
Assert-True $false -Label "unknown-stage output parses as JSON (parse error: $_)"
|
||||
}
|
||||
|
||||
if ($errFrame) {
|
||||
Assert-Equal -Expected $false -Actual $errFrame.ok -Label "unknown-stage frame has ok=false"
|
||||
Assert-Equal -Expected "does-not-exist" -Actual $errFrame.stage -Label "unknown-stage frame echoes stage name"
|
||||
Assert-True ($errFrame.reason -match "unknown stage") -Label "unknown-stage frame explains why"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Summary
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
if ($failures -gt 0) {
|
||||
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "All smoke tests passed." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
|
@ -466,6 +466,14 @@ Generate some audio.
|
|||
msg = build_skill_invocation_message("/nonexistent")
|
||||
assert msg is None
|
||||
|
||||
def test_returns_none_when_skill_load_fails(self, tmp_path):
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
||||
_make_skill(tmp_path, "broken-skill")
|
||||
scan_skill_commands()
|
||||
with patch("agent.skill_commands._load_skill_payload", return_value=None):
|
||||
msg = build_skill_invocation_message("/broken-skill", "do stuff")
|
||||
assert msg is None
|
||||
|
||||
def test_uses_shared_skill_loader_for_secure_setup(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("TENOR_API_KEY", raising=False)
|
||||
calls = []
|
||||
|
|
|
|||
|
|
@ -384,3 +384,148 @@ class TestIncomingDocumentHandling:
|
|||
assert event.message_type == MessageType.PHOTO
|
||||
assert event.media_urls == ["/tmp/cached_image.png"]
|
||||
assert event.media_types == ["image/png"]
|
||||
|
||||
|
||||
class TestAllowAnyAttachment:
|
||||
"""Cover the discord.allow_any_attachment config flag.
|
||||
|
||||
With the flag off (default), unknown file types are dropped. With it on,
|
||||
they get cached and surfaced to the agent as DOCUMENT events with
|
||||
application/octet-stream MIME so gateway/run.py emits a path-pointing
|
||||
context note.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_type_skipped_by_default(self, adapter):
|
||||
"""Default (flag off): unknown extension is dropped.
|
||||
|
||||
With no text + no cached media, the adapter may legitimately decline
|
||||
to dispatch the event at all, so we don't assert on call_args here —
|
||||
we just verify the file wasn't cached.
|
||||
"""
|
||||
with _mock_aiohttp_download(b"should not be cached"):
|
||||
msg = make_message([
|
||||
make_attachment(filename="weird.xyz", content_type="application/x-custom")
|
||||
])
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
if adapter.handle_message.call_args is not None:
|
||||
event = adapter.handle_message.call_args[0][0]
|
||||
assert event.media_urls == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_type_cached_when_flag_on(self, adapter):
|
||||
"""Flag on: unknown extension is cached as application/octet-stream."""
|
||||
adapter.config.extra["allow_any_attachment"] = True
|
||||
|
||||
with _mock_aiohttp_download(b"\x00\x01\x02 binary payload"):
|
||||
msg = make_message([
|
||||
make_attachment(filename="weird.xyz", content_type="application/x-custom")
|
||||
])
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
event = adapter.handle_message.call_args[0][0]
|
||||
assert len(event.media_urls) == 1
|
||||
assert os.path.exists(event.media_urls[0])
|
||||
# Falls back to the source content_type when we have one.
|
||||
assert event.media_types == ["application/x-custom"]
|
||||
assert event.message_type == MessageType.DOCUMENT
|
||||
# We deliberately do NOT inline arbitrary bytes — run.py emits the
|
||||
# path-pointing note based on DOCUMENT + octet-stream MIME.
|
||||
assert "[Content of" not in (event.text or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_type_no_content_type_becomes_octet_stream(self, adapter):
|
||||
"""Flag on + no content_type from discord: MIME falls back to octet-stream."""
|
||||
adapter.config.extra["allow_any_attachment"] = True
|
||||
|
||||
with _mock_aiohttp_download(b"raw bytes"):
|
||||
msg = make_message([
|
||||
make_attachment(filename="mystery.bin", content_type=None)
|
||||
])
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
event = adapter.handle_message.call_args[0][0]
|
||||
assert event.message_type == MessageType.DOCUMENT
|
||||
assert event.media_types == ["application/octet-stream"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_attachment_bytes_caps_uploads(self, adapter):
|
||||
"""discord.max_attachment_bytes overrides the historical 32 MiB cap."""
|
||||
adapter.config.extra["allow_any_attachment"] = True
|
||||
adapter.config.extra["max_attachment_bytes"] = 1024 # 1 KiB
|
||||
|
||||
msg = make_message([
|
||||
make_attachment(
|
||||
filename="too_big.xyz",
|
||||
content_type="application/x-custom",
|
||||
size=2048,
|
||||
)
|
||||
])
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
event = adapter.handle_message.call_args[0][0]
|
||||
assert event.media_urls == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_attachment_bytes_zero_means_unlimited(self, adapter):
|
||||
"""max_attachment_bytes=0 disables the size cap entirely."""
|
||||
adapter.config.extra["allow_any_attachment"] = True
|
||||
adapter.config.extra["max_attachment_bytes"] = 0
|
||||
|
||||
# 64 MiB — would normally exceed the historical 32 MiB hardcoded cap.
|
||||
with _mock_aiohttp_download(b"x" * 16):
|
||||
msg = make_message([
|
||||
make_attachment(
|
||||
filename="huge.xyz",
|
||||
content_type="application/x-custom",
|
||||
size=64 * 1024 * 1024,
|
||||
)
|
||||
])
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
event = adapter.handle_message.call_args[0][0]
|
||||
assert len(event.media_urls) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowlisted_doc_unchanged_when_flag_on(self, adapter):
|
||||
"""Flag on must not change handling of types already in SUPPORTED_DOCUMENT_TYPES.
|
||||
|
||||
A .txt should still get its content inlined (the historical behavior),
|
||||
and the MIME should still be the canonical text/plain — not whatever
|
||||
discord guessed.
|
||||
"""
|
||||
adapter.config.extra["allow_any_attachment"] = True
|
||||
file_content = b"still a text file"
|
||||
|
||||
with _mock_aiohttp_download(file_content):
|
||||
msg = make_message(
|
||||
attachments=[make_attachment(filename="notes.txt", content_type="text/plain")],
|
||||
content="check this",
|
||||
)
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
event = adapter.handle_message.call_args[0][0]
|
||||
assert "[Content of notes.txt]:" in event.text
|
||||
assert "still a text file" in event.text
|
||||
assert event.media_types == ["text/plain"]
|
||||
|
||||
def test_helper_reads_env_fallback(self, adapter, monkeypatch):
|
||||
"""Helper falls back to DISCORD_ALLOW_ANY_ATTACHMENT env var."""
|
||||
assert adapter._discord_allow_any_attachment() is False
|
||||
monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true")
|
||||
assert adapter._discord_allow_any_attachment() is True
|
||||
monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "no")
|
||||
assert adapter._discord_allow_any_attachment() is False
|
||||
|
||||
def test_helper_config_overrides_env(self, adapter, monkeypatch):
|
||||
"""config.yaml setting wins over env var."""
|
||||
monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true")
|
||||
adapter.config.extra["allow_any_attachment"] = False
|
||||
assert adapter._discord_allow_any_attachment() is False
|
||||
|
||||
def test_max_bytes_helper_invalid_value_falls_back(self, adapter):
|
||||
"""Garbage in max_attachment_bytes config falls back to 32 MiB."""
|
||||
adapter.config.extra["max_attachment_bytes"] = "not-a-number"
|
||||
assert adapter._discord_max_attachment_bytes() == 32 * 1024 * 1024
|
||||
|
||||
|
|
|
|||
|
|
@ -283,6 +283,17 @@ class TestTeamsAdapterInit:
|
|||
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
|
||||
assert adapter._port == 5000
|
||||
|
||||
def test_invalid_port_from_extra_falls_back_to_default(self):
|
||||
adapter = TeamsAdapter(
|
||||
_make_config(client_id="id", client_secret="secret", tenant_id="tenant", port="abc")
|
||||
)
|
||||
assert adapter._port == 3978
|
||||
|
||||
def test_invalid_port_from_env_falls_back_to_default(self, monkeypatch):
|
||||
monkeypatch.setenv("TEAMS_PORT", "abc")
|
||||
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
|
||||
assert adapter._port == 3978
|
||||
|
||||
def test_platform_value(self):
|
||||
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
|
||||
assert adapter.platform.value == "teams"
|
||||
|
|
|
|||
|
|
@ -559,3 +559,9 @@ class TestStopProfileGateway:
|
|||
assert calls["kill"] == 1 # one SIGTERM
|
||||
assert calls["alive_probes"] == 20 # 20 liveness polls over the 2s window
|
||||
assert calls["remove"] == 0
|
||||
|
||||
|
||||
def test_module_has_logger():
|
||||
"""Verify module has a logger instance (regression guard for #27154)."""
|
||||
assert hasattr(gateway, "logger")
|
||||
assert gateway.logger.name == "hermes_cli.gateway"
|
||||
|
|
|
|||
|
|
@ -173,6 +173,19 @@ def test_file_not_found_is_usage_error(fake_tool, capsys, monkeypatch):
|
|||
assert "cannot read" in err.lower()
|
||||
|
||||
|
||||
def test_file_decode_error_is_usage_error(fake_tool, capsys, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
|
||||
bad = tmp_path / "bad-bytes.bin"
|
||||
bad.write_bytes(b"\xff\xfe\x00")
|
||||
|
||||
args = _parse(["--to", "telegram", "--file", str(bad)])
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
send_cmd.cmd_send(args)
|
||||
assert exc.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "cannot read" in err.lower()
|
||||
|
||||
|
||||
def test_tool_error_returns_failure_exit(monkeypatch, capsys):
|
||||
import sys as _sys
|
||||
import types as _types
|
||||
|
|
|
|||
|
|
@ -523,6 +523,34 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
|
|||
assert env["NODE_ENV"] == "production"
|
||||
|
||||
|
||||
def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path):
|
||||
tui_dir = tmp_path / "ui-tui"
|
||||
tsx = tui_dir / "node_modules" / ".bin" / "tsx"
|
||||
ink_dir = tui_dir / "packages" / "hermes-ink"
|
||||
tsx.parent.mkdir(parents=True)
|
||||
ink_dir.mkdir(parents=True)
|
||||
tsx.write_text("#!/usr/bin/env node\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None)
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _tui_dir: False)
|
||||
monkeypatch.delenv("HERMES_TUI_DIR", raising=False)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda bin_name: f"/usr/bin/{bin_name}")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, cwd=None, **_kwargs):
|
||||
calls.append((cmd, cwd))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
argv, cwd = main_mod._make_tui_argv(tui_dir, tui_dev=True)
|
||||
|
||||
assert argv == [str(tsx), "src/entry.tsx"]
|
||||
assert cwd == tui_dir
|
||||
assert calls == [(["/usr/bin/npm", "run", "build"], str(ink_dir))]
|
||||
|
||||
|
||||
def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
|
|
|
|||
|
|
@ -182,3 +182,26 @@ class TestDeepSeekFullKwargsIntegration:
|
|||
)
|
||||
assert "reasoning_effort" not in kwargs
|
||||
assert "extra_body" not in kwargs or "thinking" not in kwargs.get("extra_body", {})
|
||||
|
||||
|
||||
class TestDeepSeekAuxModel:
|
||||
"""DeepSeek aux model is set on the profile so users stop seeing the
|
||||
bogus 'No auxiliary LLM provider configured' warning (#26924).
|
||||
|
||||
Pinned at the profile layer rather than the legacy
|
||||
`_API_KEY_PROVIDER_AUX_MODELS_FALLBACK` dict — new providers are
|
||||
expected to set `default_aux_model` on `ProviderProfile`, and the
|
||||
fallback dict only exists for providers that predate the profiles
|
||||
system.
|
||||
"""
|
||||
|
||||
def test_profile_advertises_deepseek_chat(self, deepseek_profile):
|
||||
assert deepseek_profile.default_aux_model == "deepseek-chat"
|
||||
|
||||
def test_consumer_api_returns_deepseek_chat(self):
|
||||
from agent.auxiliary_client import _get_aux_model_for_provider
|
||||
assert _get_aux_model_for_provider("deepseek") == "deepseek-chat"
|
||||
|
||||
def test_consumer_api_returns_non_empty(self):
|
||||
from agent.auxiliary_client import _get_aux_model_for_provider
|
||||
assert _get_aux_model_for_provider("deepseek") != ""
|
||||
|
|
|
|||
|
|
@ -193,3 +193,51 @@ def test_background_review_summary_is_attributed_to_self_improvement_loop(monkey
|
|||
assert captured_bg_callback[0].startswith("💾 Self-improvement review:"), (
|
||||
captured_bg_callback[0]
|
||||
)
|
||||
|
||||
|
||||
def test_background_review_fork_skips_external_memory_plugins(monkeypatch):
|
||||
"""The background review fork must NOT touch external memory plugins.
|
||||
|
||||
Without skip_memory=True on the fork constructor, AIAgent.__init__
|
||||
rebuilds its own _memory_manager from config, scoped to the parent's
|
||||
session_id. The review fork's run_conversation() then leaks the
|
||||
harness prompt into the user's real memory namespace via three
|
||||
ingestion sites: on_turn_start (cadence + turn message),
|
||||
prefetch_all (recall query), and sync_all (harness prompt + review
|
||||
output recorded as a (user, assistant) turn pair). The fix is a
|
||||
single kwarg on the fork constructor — this test guards it.
|
||||
"""
|
||||
captured_kwargs: dict = {}
|
||||
|
||||
class FakeReviewAgent:
|
||||
def __init__(self, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
self._session_messages = []
|
||||
|
||||
def run_conversation(self, **kwargs):
|
||||
pass
|
||||
|
||||
def shutdown_memory_provider(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent)
|
||||
monkeypatch.setattr(run_agent_module.threading, "Thread", ImmediateThread)
|
||||
|
||||
agent = _bare_agent()
|
||||
|
||||
AIAgent._spawn_background_review(
|
||||
agent,
|
||||
messages_snapshot=[{"role": "user", "content": "hello"}],
|
||||
review_memory=True,
|
||||
)
|
||||
|
||||
assert captured_kwargs.get("skip_memory") is True, (
|
||||
"Background review fork must be constructed with skip_memory=True "
|
||||
"so AIAgent.__init__ does not rebuild a _memory_manager wired to "
|
||||
"external plugins (honcho, mem0, supermemory, ...). Without this "
|
||||
"the fork leaks harness prompts into the user's real memory "
|
||||
"namespace via on_turn_start / prefetch_all / sync_all."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1649,7 +1649,7 @@ def _run_single_child(
|
|||
trace_by_id[tc_id] = entry_t
|
||||
elif msg.get("role") == "tool":
|
||||
content = msg.get("content", "")
|
||||
is_error = bool(content and "error" in content[:80].lower())
|
||||
is_error = _looks_like_error_output(content)
|
||||
result_meta = {
|
||||
"result_bytes": len(content),
|
||||
"status": "error" if is_error else "ok",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import queue
|
|||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
|
|
|
|||
|
|
@ -52,6 +52,50 @@ describe('forceTruecolor', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('downgrades Apple Terminal when truecolor is only advertised by env', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.TERM_PROGRAM = 'Apple_Terminal'
|
||||
process.env.COLORTERM = 'truecolor'
|
||||
process.env.FORCE_COLOR = '3'
|
||||
},
|
||||
async () => {
|
||||
const mod = await import('../lib/forceTruecolor.js?t=downgrade-' + importId++)
|
||||
expect(
|
||||
mod.shouldDowngradeAppleTerminalTruecolor({
|
||||
TERM_PROGRAM: 'Apple_Terminal',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(true)
|
||||
expect(process.env.COLORTERM).toBeUndefined()
|
||||
expect(process.env.FORCE_COLOR).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps non-Apple terminals untouched when they advertise truecolor', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.TERM_PROGRAM = 'vscode'
|
||||
process.env.COLORTERM = 'truecolor'
|
||||
process.env.FORCE_COLOR = '3'
|
||||
},
|
||||
async () => {
|
||||
const mod = await import('../lib/forceTruecolor.js?t=keep-non-apple-' + importId++)
|
||||
expect(
|
||||
mod.shouldDowngradeAppleTerminalTruecolor({
|
||||
TERM_PROGRAM: 'vscode',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(false)
|
||||
expect(process.env.COLORTERM).toBe('truecolor')
|
||||
expect(process.env.FORCE_COLOR).toBe('3')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('sets COLORTERM=truecolor and FORCE_COLOR=3 when explicitly enabled', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
|
|
@ -79,6 +123,30 @@ describe('forceTruecolor', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('lets explicit opt-in keep Apple truecolor advertisement', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.TERM_PROGRAM = 'Apple_Terminal'
|
||||
process.env.COLORTERM = 'truecolor'
|
||||
process.env.FORCE_COLOR = '3'
|
||||
process.env.HERMES_TUI_TRUECOLOR = '1'
|
||||
},
|
||||
async () => {
|
||||
const mod = await import('../lib/forceTruecolor.js?t=apple-explicit-on-' + importId++)
|
||||
expect(
|
||||
mod.shouldDowngradeAppleTerminalTruecolor({
|
||||
TERM_PROGRAM: 'Apple_Terminal',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3',
|
||||
HERMES_TUI_TRUECOLOR: '1'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(false)
|
||||
expect(process.env.COLORTERM).toBe('truecolor')
|
||||
expect(process.env.FORCE_COLOR).toBe('3')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('respects NO_COLOR', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ import {
|
|||
estimateRows,
|
||||
estimateTokensRough,
|
||||
fmtK,
|
||||
hasAnsi,
|
||||
isToolTrailResultLine,
|
||||
lastCotTrailIndex,
|
||||
parseToolTrailResultLine,
|
||||
pasteTokenLabel,
|
||||
sanitizeAnsiForRender,
|
||||
sameToolTrailGroup,
|
||||
splitToolDuration,
|
||||
stripAnsi,
|
||||
thinkingPreview
|
||||
} from '../lib/text.js'
|
||||
|
||||
|
|
@ -84,6 +87,46 @@ describe('estimateTokensRough', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('ANSI sanitizers', () => {
|
||||
const ESC = String.fromCharCode(27)
|
||||
const BEL = String.fromCharCode(7)
|
||||
|
||||
it('strips CSI/OSC/control bytes from plain previews', () => {
|
||||
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}C${ESC}[?25lD`
|
||||
|
||||
expect(stripAnsi(sample)).toBe('ABCD')
|
||||
})
|
||||
|
||||
it('strips incomplete CSI prefixes and carriage returns', () => {
|
||||
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[CD\rE`
|
||||
|
||||
expect(stripAnsi(sample)).toBe('ABDE')
|
||||
})
|
||||
|
||||
it('keeps SGR color spans but removes cursor controls for Ansi rendering', () => {
|
||||
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}${ESC}[?25lC`
|
||||
|
||||
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mC`)
|
||||
})
|
||||
|
||||
it('keeps valid SGR while removing dangling CSI and carriage returns', () => {
|
||||
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[39mC\rD`
|
||||
|
||||
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mCD`)
|
||||
})
|
||||
|
||||
it('strips multi-byte non-CSI ESC sequences without leaving trailing bytes', () => {
|
||||
const sample = `A${ESC}(0B${ESC}%GC${ESC})0D`
|
||||
|
||||
expect(stripAnsi(sample)).toBe('ABCD')
|
||||
expect(sanitizeAnsiForRender(sample)).toBe('ABCD')
|
||||
})
|
||||
|
||||
it('detects non-CSI escape prefixes too', () => {
|
||||
expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinkingPreview', () => {
|
||||
it('adds paragraph breaks before markdown thinking headings', () => {
|
||||
const raw =
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { canFastAppendShape, canFastBackspaceShape } from '../components/textInput.js'
|
||||
import { canFastAppendShape, canFastBackspaceShape, supportsFastEchoTerminal } from '../components/textInput.js'
|
||||
|
||||
// The fast-echo path bypasses Ink and writes characters directly to stdout
|
||||
// for the common case of typing plain English at the end of the line. These
|
||||
|
|
@ -172,3 +172,14 @@ describe('canFastBackspaceShape', () => {
|
|||
expect(canFastBackspaceShape('hello ', 'hello '.length)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsFastEchoTerminal', () => {
|
||||
it('disables fast-echo in Apple Terminal', () => {
|
||||
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps fast-echo enabled in VS Code and unknown terminals', () => {
|
||||
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
compactPreview,
|
||||
hasAnsi,
|
||||
isPasteBackedText,
|
||||
sanitizeAnsiForRender,
|
||||
stripAnsi
|
||||
} from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
|
@ -85,13 +86,14 @@ export const MessageLine = memo(function MessageLine({
|
|||
if (msg.role === 'tool') {
|
||||
const maxChars = Math.max(24, cols - 14)
|
||||
const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text
|
||||
const safeAnsi = hasAnsi(msg.text) ? sanitizeAnsiForRender(msg.text) : msg.text
|
||||
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
|
||||
|
||||
return (
|
||||
<Box alignSelf="flex-start" borderColor={t.color.muted} borderStyle="round" marginLeft={3} paddingX={1}>
|
||||
{hasAnsi(msg.text) ? (
|
||||
<Text wrap="truncate-end">
|
||||
<Ansi>{msg.text}</Ansi>
|
||||
<Ansi>{safeAnsi}</Ansi>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
|
|
@ -129,13 +131,13 @@ export const MessageLine = memo(function MessageLine({
|
|||
{msg.text.length.toLocaleString()} chars
|
||||
</Text>
|
||||
</Box>
|
||||
{systemOpen && <Ansi>{msg.text}</Ansi>}
|
||||
{systemOpen && <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (msg.role !== 'user' && hasAnsi(msg.text)) {
|
||||
return <Ansi>{msg.text}</Ansi>
|
||||
return <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
|
|
|
|||
|
|
@ -283,6 +283,12 @@ export function canFastBackspaceShape(current: string, cursor: number, columns?:
|
|||
return ASCII_PRINTABLE_RE.test(removed)
|
||||
}
|
||||
|
||||
export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
// Terminal.app still shows paint/cursor artifacts under the fast-echo
|
||||
// bypass path. Fall back to the normal Ink render path there.
|
||||
return (env.TERM_PROGRAM ?? '').trim() !== 'Apple_Terminal'
|
||||
}
|
||||
|
||||
function renderWithCursor(value: string, cursor: number) {
|
||||
const pos = Math.max(0, Math.min(cursor, value.length))
|
||||
|
||||
|
|
@ -559,7 +565,7 @@ export function TextInput({
|
|||
}, 16)
|
||||
}
|
||||
|
||||
const canFastEchoBase = () => focus && termFocus && !selected && !mask && !!stdout?.isTTY
|
||||
const canFastEchoBase = () => supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY
|
||||
|
||||
const canFastAppend = (current: string, cursor: number, text: string) =>
|
||||
canFastEchoBase() && canFastAppendShape(current, cursor, text, columns, lineWidthRef.current)
|
||||
|
|
|
|||
|
|
@ -19,12 +19,42 @@ export function shouldForceTruecolor(env: NodeJS.ProcessEnv = process.env): bool
|
|||
return TRUE_RE.test(override)
|
||||
}
|
||||
|
||||
const isAppleTerminal = (env: NodeJS.ProcessEnv = process.env) => (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal'
|
||||
|
||||
const isAdvertisedTruecolor = (env: NodeJS.ProcessEnv = process.env) => {
|
||||
const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase()
|
||||
const forceColor = (env.FORCE_COLOR ?? '').trim()
|
||||
|
||||
return colorTerm === 'truecolor' || colorTerm === '24bit' || forceColor === '3'
|
||||
}
|
||||
|
||||
export function shouldDowngradeAppleTerminalTruecolor(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (!isAppleTerminal(env)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (shouldForceTruecolor(env)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isAdvertisedTruecolor(env)
|
||||
}
|
||||
|
||||
if (shouldForceTruecolor()) {
|
||||
if (!process.env.COLORTERM) {
|
||||
process.env.COLORTERM = 'truecolor'
|
||||
}
|
||||
|
||||
process.env.FORCE_COLOR = '3'
|
||||
} else if (shouldDowngradeAppleTerminalTruecolor()) {
|
||||
// Terminal.app may advertise truecolor even when RGB SGR paths render
|
||||
// incorrectly. Keep Hermes on the safer TERM-driven 256-color path unless
|
||||
// users explicitly opt back in via HERMES_TUI_TRUECOLOR=1.
|
||||
delete process.env.COLORTERM
|
||||
|
||||
if ((process.env.FORCE_COLOR ?? '').trim() === '3') {
|
||||
delete process.env.FORCE_COLOR
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,40 @@ import { VERBS } from '../content/verbs.js'
|
|||
import type { ThinkingMode } from '../types.js'
|
||||
|
||||
const ESC = String.fromCharCode(27)
|
||||
const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g')
|
||||
const BEL = String.fromCharCode(7)
|
||||
const ANSI_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
|
||||
const ANSI_CSI_WITH_CMD_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*([@-~])`, 'g')
|
||||
const ANSI_INCOMPLETE_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*(?=${ESC}|\\n|$)`, 'g')
|
||||
const ANSI_OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
|
||||
const ANSI_STRING_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
|
||||
const ANSI_NON_CSI_ESC_SEQ_RE = new RegExp(`${ESC}(?!\\[|\\]|P|X|\\^|_)[ -/]*[0-~]`, 'g')
|
||||
const ANSI_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g')
|
||||
const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0D\x0E-\x1A\x1C-\x1F\x7F]/g
|
||||
const WS_RE = /\s+/g
|
||||
|
||||
export const stripAnsi = (s: string) => s.replace(ANSI_RE, '')
|
||||
export const stripAnsi = (s: string) =>
|
||||
s
|
||||
.replace(ANSI_OSC_RE, '')
|
||||
.replace(ANSI_STRING_RE, '')
|
||||
.replace(ANSI_INCOMPLETE_CSI_RE, '')
|
||||
.replace(ANSI_CSI_RE, '')
|
||||
.replace(ANSI_INCOMPLETE_CSI_RE, '')
|
||||
.replace(ANSI_NON_CSI_ESC_SEQ_RE, '')
|
||||
.replace(ANSI_STRAY_ESC_RE, '')
|
||||
.replace(CONTROL_RE, '')
|
||||
|
||||
export const hasAnsi = (s: string) => s.includes(`${ESC}[`) || s.includes(`${ESC}]`)
|
||||
export const sanitizeAnsiForRender = (s: string) =>
|
||||
s
|
||||
.replace(ANSI_OSC_RE, '')
|
||||
.replace(ANSI_STRING_RE, '')
|
||||
.replace(ANSI_INCOMPLETE_CSI_RE, '')
|
||||
.replace(ANSI_CSI_WITH_CMD_RE, (seq, cmd: string) => (cmd === 'm' ? seq : ''))
|
||||
.replace(ANSI_INCOMPLETE_CSI_RE, '')
|
||||
.replace(ANSI_NON_CSI_ESC_SEQ_RE, '')
|
||||
.replace(ANSI_STRAY_ESC_RE, '')
|
||||
.replace(CONTROL_RE, '')
|
||||
|
||||
export const hasAnsi = (s: string) => s.includes(ESC)
|
||||
|
||||
const renderEstimateLine = (line: string) => {
|
||||
const trimmed = line.trim()
|
||||
|
|
|
|||
|
|
@ -1106,13 +1106,17 @@ hermes claw migrate --source /home/user/old-openclaw
|
|||
hermes dashboard [options]
|
||||
```
|
||||
|
||||
Launch the web dashboard — a browser-based UI for managing configuration, API keys, and monitoring sessions. Requires `pip install hermes-agent[web]` (FastAPI + Uvicorn). See [Web Dashboard](/docs/user-guide/features/web-dashboard) for full documentation.
|
||||
Launch the web dashboard — a browser-based UI for managing configuration, API keys, and monitoring sessions. Requires `pip install hermes-agent[web]` (FastAPI + Uvicorn). The embedded browser Chat tab requires `--tui` plus the `pty` extra. See [Web Dashboard](/docs/user-guide/features/web-dashboard) for full documentation.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `--port` | `9119` | Port to run the web server on |
|
||||
| `--host` | `127.0.0.1` | Bind address |
|
||||
| `--no-open` | — | Don't auto-open the browser |
|
||||
| `--tui` | off | Enable the in-browser Chat tab by running `hermes --tui` behind a PTY/WebSocket bridge. Requires `pip install 'hermes-agent[web,pty]'` and a POSIX PTY environment such as Linux, macOS, or WSL2. |
|
||||
| `--insecure` | off | Allow binding to non-localhost hosts. Exposes dashboard credentials on the network; use only behind trusted network controls. |
|
||||
| `--stop` | — | Stop running `hermes dashboard` processes and exit. |
|
||||
| `--status` | — | List running `hermes dashboard` processes and exit. |
|
||||
|
||||
```bash
|
||||
# Default — opens browser to http://127.0.0.1:9119
|
||||
|
|
@ -1120,6 +1124,9 @@ hermes dashboard
|
|||
|
||||
# Custom port, no browser
|
||||
hermes dashboard --port 8080 --no-open
|
||||
|
||||
# Enable the browser Chat tab
|
||||
hermes dashboard --tui
|
||||
```
|
||||
|
||||
## `hermes profile`
|
||||
|
|
|
|||
|
|
@ -258,6 +258,8 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
|
|||
| `DISCORD_REQUIRE_MENTION` | Require an @mention before responding in server channels |
|
||||
| `DISCORD_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where mention is not required |
|
||||
| `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported |
|
||||
| `DISCORD_ALLOW_ANY_ATTACHMENT` | When `true`, accept attachments of any file type (not just the built-in PDF/text/zip/office allowlist). Unknown types are cached and surfaced to the agent as a local path so it can inspect them via `terminal` / `read_file` / `ffprobe`. Default `false`. |
|
||||
| `DISCORD_MAX_ATTACHMENT_BYTES` | Maximum bytes per attachment the gateway will cache. Default `33554432` (32 MiB). Set to `0` for no cap (attachments are held in memory while being written). |
|
||||
| `DISCORD_REACTIONS` | Enable emoji reactions on messages during processing (default: `true`) |
|
||||
| `DISCORD_IGNORED_CHANNELS` | Comma-separated channel IDs where the bot never responds |
|
||||
| `DISCORD_NO_THREAD_CHANNELS` | Comma-separated channel IDs where bot responds without auto-threading |
|
||||
|
|
|
|||
|
|
@ -1667,6 +1667,7 @@ delegation:
|
|||
# provider: "openrouter" # Override provider (empty = inherit parent)
|
||||
# base_url: "http://localhost:1234/v1" # Direct OpenAI-compatible endpoint (takes precedence over provider)
|
||||
# api_key: "local-key" # API key for base_url (falls back to OPENAI_API_KEY)
|
||||
# api_mode: "" # Wire protocol for base_url: "chat_completions", "codex_responses", or "anthropic_messages". Empty = auto-detect from URL (e.g. /anthropic suffix → anthropic_messages). Set explicitly for non-standard endpoints the heuristic can't detect.
|
||||
max_concurrent_children: 3 # Parallel children per batch (floor 1, no ceiling). Also via DELEGATION_MAX_CONCURRENT_CHILDREN env var.
|
||||
max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3 = three levels.
|
||||
orchestrator_enabled: true # Global kill switch. When false, role="orchestrator" is ignored and every child is forced to leaf regardless of max_spawn_depth.
|
||||
|
|
@ -1676,6 +1677,8 @@ delegation:
|
|||
|
||||
**Direct endpoint override:** If you want the obvious custom-endpoint path, set `delegation.base_url`, `delegation.api_key`, and `delegation.model`. That sends subagents directly to that OpenAI-compatible endpoint and takes precedence over `delegation.provider`. If `delegation.api_key` is omitted, Hermes falls back to `OPENAI_API_KEY` only.
|
||||
|
||||
**Wire protocol (`api_mode`):** Hermes auto-detects the wire protocol from `delegation.base_url` (e.g. paths ending in `/anthropic` → `anthropic_messages`; Codex / native Anthropic / Kimi-coding hostnames keep their existing detection). For endpoints the heuristic can't classify — for example Azure AI Foundry, MiniMax, Zhipu GLM, or LiteLLM proxies fronting an Anthropic-shaped backend — set `delegation.api_mode` explicitly to one of `chat_completions`, `codex_responses`, or `anthropic_messages`. Leave it empty (the default) to keep auto-detection.
|
||||
|
||||
The delegation provider uses the same credential resolution as CLI/gateway startup. All configured providers are supported: `openrouter`, `nous`, `copilot`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. When a provider is set, the system automatically resolves the correct base URL, API key, and API mode — no manual credential wiring needed.
|
||||
|
||||
**Precedence:** `delegation.base_url` in config → `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter).
|
||||
|
|
|
|||
|
|
@ -125,6 +125,10 @@ Jobs with a `workdir` run sequentially on the scheduler tick, not in the paralle
|
|||
|
||||
You do not need to delete and recreate jobs just to change them.
|
||||
|
||||
:::tip Job reference
|
||||
The `<job_id>` placeholder below (and in [Lifecycle actions](#lifecycle-actions)) also accepts the job's name (case-insensitive) — handy when you remember `morning-digest` but not the hex ID. An exact job ID takes precedence over name matches; if the reference is not an ID and a name matches more than one job, the command refuses and prints the candidate IDs so you can disambiguate.
|
||||
:::
|
||||
|
||||
### Chat
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ delegation:
|
|||
model: "qwen2.5-coder"
|
||||
base_url: "http://localhost:1234/v1"
|
||||
api_key: "local-key"
|
||||
# api_mode: "anthropic_messages" # Optional. Wire protocol override for base_url ("chat_completions", "codex_responses", or "anthropic_messages"). Empty = auto-detect from URL (e.g. /anthropic suffix). Set explicitly for endpoints the heuristic can't classify (Azure AI Foundry, MiniMax, Zhipu GLM, LiteLLM proxies, …).
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Unlike Hermes' built-in OAuth integrations (Google, GitHub Copilot, Codex), Spot
|
|||
|
||||
## Setup
|
||||
|
||||
### One-shot: `hermes tools`
|
||||
### One-shot: `hermes tools` or first-run setup
|
||||
|
||||
The fastest path. Run:
|
||||
|
||||
|
|
@ -20,7 +20,9 @@ The fastest path. Run:
|
|||
hermes tools
|
||||
```
|
||||
|
||||
Scroll to `🎵 Spotify`, press space to toggle it on, then `s` to save. Hermes drops you straight into the OAuth flow — if you don't have a Spotify app yet, it walks you through creating one inline. Once you finish, the toolset is enabled AND authenticated in one pass.
|
||||
Scroll to `🎵 Spotify`, press space to toggle it on, then `s` to save. The same toggle is also available during the first-run `hermes setup` / `hermes setup tools` flow. Spotify stays opt-in, so enabling it there runs the same provider-aware configuration as `hermes tools`.
|
||||
|
||||
Hermes drops you straight into the OAuth flow — if you don't have a Spotify app yet, it walks you through creating one inline. Once you finish, the toolset is enabled AND authenticated in one pass.
|
||||
|
||||
If you prefer to do the steps separately (or you're re-authing later), use the two-step flow below.
|
||||
|
||||
|
|
@ -125,6 +127,12 @@ Control and inspect playback, plus fetch recently played history.
|
|||
| `list` | Every Spotify Connect device visible to your account |
|
||||
| `transfer` | Move playback to `device_id`. Optional `play: true` starts playback on transfer |
|
||||
|
||||
### Home Assistant-managed speakers
|
||||
|
||||
If Home Assistant manages speakers that already support Spotify Connect (for example Sonos, Echo, Nest, or other Connect-capable speakers), they appear in `spotify_devices list` automatically whenever Spotify can see them. Hermes does not need a Home Assistant ↔ Spotify bridge for this path — Spotify handles the device routing natively.
|
||||
|
||||
Ask Hermes to transfer playback by the speaker's display name (for example, “transfer Spotify to the kitchen speaker”), or call `spotify_devices list` and pass the exact `device_id` to `spotify_devices transfer` when scripting. If the speaker is missing, open the Spotify app or the speaker's Spotify integration once so Spotify registers it as an active Connect target.
|
||||
|
||||
#### `spotify_queue`
|
||||
| Action | Purpose | Premium? |
|
||||
|--------|---------|----------|
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ hermes dashboard --host 0.0.0.0
|
|||
|
||||
# Start without opening browser
|
||||
hermes dashboard --no-open
|
||||
|
||||
# Enable the in-browser Chat tab
|
||||
hermes dashboard --tui
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
|
@ -49,6 +52,8 @@ The `web` extra pulls in FastAPI/Uvicorn; `pty` pulls in `ptyprocess` (POSIX) or
|
|||
|
||||
When you run `hermes dashboard` without the dependencies, it will tell you what to install. If the frontend hasn't been built yet and `npm` is available, it builds automatically on first launch.
|
||||
|
||||
The Chat tab is intentionally off for a plain `hermes dashboard` launch. Start the dashboard with `hermes dashboard --tui` or set `HERMES_DASHBOARD_TUI=1` when you want the embedded browser chat pane.
|
||||
|
||||
## Pages
|
||||
|
||||
### Status
|
||||
|
|
|
|||
|
|
@ -294,6 +294,8 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede
|
|||
| `DISCORD_ALLOW_MENTION_USERS` | No | `true` | When `true` (default), the bot can ping individual users by ID. |
|
||||
| `DISCORD_ALLOW_MENTION_REPLIED_USER` | No | `true` | When `true` (default), replying to a message pings the original author. |
|
||||
| `DISCORD_PROXY` | No | — | Proxy URL for Discord connections (HTTP, WebSocket, REST). Overrides `HTTPS_PROXY`/`ALL_PROXY`. Supports `http://`, `https://`, and `socks5://` schemes. |
|
||||
| `DISCORD_ALLOW_ANY_ATTACHMENT` | No | `false` | When `true`, the bot accepts attachments of any file type (not just the built-in PDF/text/zip/office allowlist). Unknown types are cached to disk and surfaced to the agent as a local path with `application/octet-stream` MIME so it can inspect them with `terminal` / `read_file` / `ffprobe` / etc. |
|
||||
| `DISCORD_MAX_ATTACHMENT_BYTES` | No | `33554432` | Maximum bytes per attachment the gateway will download and cache. Default 32 MiB. Set to `0` for no cap (attachments are held in memory while being written, so unlimited carries a real memory cost). |
|
||||
| `HERMES_DISCORD_TEXT_BATCH_DELAY_SECONDS` | No | `0.6` | Grace window the adapter waits before flushing a queued text chunk. Useful for smoothing streamed output. |
|
||||
| `HERMES_DISCORD_TEXT_BATCH_SPLIT_DELAY_SECONDS` | No | `2.0` | Delay between split chunks when a single message exceeds Discord's length limit. |
|
||||
|
||||
|
|
@ -613,6 +615,25 @@ The Discord adapter supports native file uploads for every common media type via
|
|||
|
||||
Discord's per-upload size limit depends on the server's boost tier (25 MB free, up to 500 MB). If Hermes gets an HTTP 413, the adapter falls back to a link pointing at the local cache path rather than failing silently.
|
||||
|
||||
## Receiving Arbitrary File Types
|
||||
|
||||
By default the bot caches uploads that match a built-in allowlist — images, audio, video, PDF, text/markdown/csv/log, JSON/XML/YAML/TOML, zip, docx/xlsx/pptx. Anything else (a `.wav`, a `.bin`, a custom-extension dump) gets logged as `Unsupported document type` and dropped before the agent sees it.
|
||||
|
||||
To accept arbitrary file types, enable `discord.allow_any_attachment`:
|
||||
|
||||
```yaml
|
||||
discord:
|
||||
allow_any_attachment: true
|
||||
# Optional — raise/disable the per-file size cap. Default is 32 MiB.
|
||||
# The whole file is held in memory while being cached, so unlimited
|
||||
# uploads carry a real memory cost.
|
||||
max_attachment_bytes: 33554432 # bytes; 0 = unlimited
|
||||
```
|
||||
|
||||
When the flag is on, any uploaded file is downloaded, cached under `~/.hermes/cache/documents/`, and surfaced to the agent as a `DOCUMENT`-typed message event with `application/octet-stream` MIME. The agent receives a context note pointing at the local path (auto-translated for Docker/Modal sandboxed terminals via `to_agent_visible_cache_path`) and can inspect the file with `terminal` (`ffprobe`, `unzip`, `file`, `strings`, etc.) or `read_file`. The file body is **not** inlined into the prompt — only the path — so binary uploads don't blow up the context window.
|
||||
|
||||
Known-text formats already in the allowlist (`.txt`, `.md`, `.log`) continue to have their contents auto-injected up to 100 KiB; that behavior is unchanged when the flag is on.
|
||||
|
||||
## Home Channel
|
||||
|
||||
You can designate a "home channel" where the bot sends proactive messages (such as cron job output, reminders, and notifications). There are two ways to set it:
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ The `/yolo` command is a **toggle** — each use flips the mode on or off:
|
|||
|
||||
YOLO mode is available in both CLI and gateway sessions. Internally, it sets the `HERMES_YOLO_MODE` environment variable which is checked before every command execution.
|
||||
|
||||
When YOLO is active, Hermes shows two persistent visual reminders so it's hard to forget that approval prompts are bypassed:
|
||||
|
||||
- A red banner line at session start when YOLO is already active: `⚠ YOLO mode — all approval prompts bypassed`. Hidden when YOLO is off so the default banner stays uncluttered.
|
||||
- A `⚠ YOLO` fragment in the status bar across all width tiers, updated live as you toggle YOLO on or off (rich-text renderer and plain-text fallback).
|
||||
|
||||
:::danger
|
||||
YOLO mode disables **all** dangerous command safety checks for the session — **except** the hardline blocklist (see below). Use only when you fully trust the commands being generated (e.g., well-tested automation scripts in disposable environments).
|
||||
:::
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue