mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
fix(merge): restore contracts caught by main-target CI
This commit is contained in:
parent
ca3428fe69
commit
da6646a23b
7 changed files with 979 additions and 53 deletions
|
|
@ -259,6 +259,114 @@ def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str:
|
|||
return json.dumps(shrunken, ensure_ascii=False)
|
||||
|
||||
|
||||
_IMAGE_PART_TYPES = frozenset({"image_url", "input_image", "image"})
|
||||
|
||||
|
||||
def _is_image_part(part: Any) -> bool:
|
||||
"""True if ``part`` is a multimodal image content block.
|
||||
|
||||
Recognizes all three shapes the agent handles:
|
||||
- OpenAI chat.completions: ``{"type": "image_url", "image_url": ...}``
|
||||
- OpenAI Responses API: ``{"type": "input_image", "image_url": "..."}``
|
||||
- Anthropic native: ``{"type": "image", "source": {...}}``
|
||||
"""
|
||||
if not isinstance(part, dict):
|
||||
return False
|
||||
return part.get("type") in _IMAGE_PART_TYPES
|
||||
|
||||
|
||||
def _content_has_images(content: Any) -> bool:
|
||||
"""True if a message's ``content`` is a multimodal list with image parts."""
|
||||
if not isinstance(content, list):
|
||||
return False
|
||||
return any(_is_image_part(p) for p in content)
|
||||
|
||||
|
||||
def _strip_images_from_content(content: Any) -> Any:
|
||||
"""Return a copy of ``content`` with every image part replaced by a
|
||||
short text placeholder.
|
||||
|
||||
- String content is returned unchanged.
|
||||
- Non-list, non-string content is returned unchanged.
|
||||
- List content: image parts become ``{"type": "text", "text": "[Attached
|
||||
image — stripped after compression]"}``; other parts are preserved as-is.
|
||||
|
||||
Input is never mutated.
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
if not any(_is_image_part(p) for p in content):
|
||||
return content
|
||||
|
||||
new_parts: List[Any] = []
|
||||
for p in content:
|
||||
if _is_image_part(p):
|
||||
new_parts.append({
|
||||
"type": "text",
|
||||
"text": "[Attached image — stripped after compression]",
|
||||
})
|
||||
else:
|
||||
new_parts.append(p)
|
||||
return new_parts
|
||||
|
||||
|
||||
def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Replace image parts in older messages with placeholder text.
|
||||
|
||||
The anchor is the *last* user message that has any image content. Every
|
||||
message before that anchor gets its image parts replaced with a short
|
||||
placeholder so the outgoing request stops re-shipping the same multi-MB
|
||||
base-64 image blobs on every turn.
|
||||
|
||||
If no user message carries images, the list is returned unchanged.
|
||||
If the only user message with images is the very first one (nothing
|
||||
earlier to strip), the list is returned unchanged.
|
||||
|
||||
Shallow copies of touched messages only; input is never mutated.
|
||||
Port of Kilo-Org/kilocode#9434 (adapted for the OpenAI-style message
|
||||
shape the hermes compressor emits).
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
# Find the newest user message that carries at least one image part.
|
||||
# We anchor on image-bearing user messages (not all user messages) so
|
||||
# a plain text follow-up after a big-image turn still strips the old
|
||||
# image — matching the problem kilocode#9434 set out to solve.
|
||||
anchor = -1
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
msg = messages[i]
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
if _content_has_images(msg.get("content")):
|
||||
anchor = i
|
||||
break
|
||||
|
||||
if anchor <= 0:
|
||||
# No image-bearing user message, or it's the very first message —
|
||||
# nothing before it to strip.
|
||||
return messages
|
||||
|
||||
changed = False
|
||||
result: List[Dict[str, Any]] = []
|
||||
for i, msg in enumerate(messages):
|
||||
if i >= anchor or not isinstance(msg, dict):
|
||||
result.append(msg)
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not _content_has_images(content):
|
||||
result.append(msg)
|
||||
continue
|
||||
new_msg = msg.copy()
|
||||
new_msg["content"] = _strip_images_from_content(content)
|
||||
result.append(new_msg)
|
||||
changed = True
|
||||
|
||||
return result if changed else messages
|
||||
|
||||
|
||||
def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:
|
||||
"""Create an informative 1-line summary of a tool call + result.
|
||||
|
||||
|
|
@ -410,6 +518,10 @@ class ContextCompressor(ContextEngine):
|
|||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
|
||||
self.last_real_prompt_tokens = 0
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
def update_model(
|
||||
self,
|
||||
|
|
@ -507,6 +619,10 @@ class ContextCompressor(ContextEngine):
|
|||
|
||||
self.last_prompt_tokens = 0
|
||||
self.last_completion_tokens = 0
|
||||
self.last_real_prompt_tokens = 0
|
||||
self.last_compression_rough_tokens = 0
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
self.summary_model = summary_model_override or ""
|
||||
|
||||
|
|
@ -540,6 +656,44 @@ class ContextCompressor(ContextEngine):
|
|||
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
|
||||
self.last_completion_tokens = usage.get("completion_tokens", 0)
|
||||
self.last_total_tokens = usage.get("total_tokens", self.last_prompt_tokens + self.last_completion_tokens)
|
||||
if self.last_prompt_tokens > 0:
|
||||
self.last_real_prompt_tokens = self.last_prompt_tokens
|
||||
if self.last_prompt_tokens < self.threshold_tokens:
|
||||
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
|
||||
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
|
||||
else:
|
||||
self.last_rough_tokens_when_real_prompt_fit = 0
|
||||
self.awaiting_real_usage_after_compression = False
|
||||
|
||||
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
|
||||
"""Return True when a high rough preflight estimate is known-noisy.
|
||||
|
||||
``estimate_request_tokens_rough(..., tools=...)`` intentionally
|
||||
overestimates schema-heavy requests so Hermes compresses before a
|
||||
provider rejects the payload. After a successful compressed API call,
|
||||
though, provider ``prompt_tokens`` are a better signal than repeating
|
||||
compaction from the same rough schema overhead. Defer only while the
|
||||
rough estimate has grown modestly since a request the provider proved
|
||||
fit under the threshold.
|
||||
"""
|
||||
if rough_tokens < self.threshold_tokens:
|
||||
return False
|
||||
if self.last_real_prompt_tokens <= 0:
|
||||
return False
|
||||
if self.last_real_prompt_tokens >= self.threshold_tokens:
|
||||
return False
|
||||
|
||||
baseline = self.last_rough_tokens_when_real_prompt_fit or self.last_compression_rough_tokens
|
||||
if baseline <= 0:
|
||||
return False
|
||||
|
||||
growth = max(0, rough_tokens - baseline)
|
||||
tolerated_growth = max(4096, int(self.threshold_tokens * 0.05))
|
||||
if growth > tolerated_growth:
|
||||
return False
|
||||
|
||||
self.last_rough_tokens_when_real_prompt_fit = max(baseline, rough_tokens)
|
||||
return True
|
||||
|
||||
def should_compress(self, prompt_tokens: int = None) -> bool:
|
||||
"""Check if context exceeds the compression threshold.
|
||||
|
|
@ -1837,6 +1991,14 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
|||
|
||||
compressed = self._sanitize_tool_pairs(compressed)
|
||||
|
||||
# Replace image parts in all compressed messages before the newest
|
||||
# image-bearing user turn with a short text placeholder. Without
|
||||
# this, tail messages keep their original multi-MB base-64 image
|
||||
# payloads forever, which can push every subsequent API request
|
||||
# past the provider's body-size limit and wedge the session.
|
||||
# Port of Kilo-Org/kilocode#9434.
|
||||
compressed = _strip_historical_media(compressed)
|
||||
|
||||
new_estimate = estimate_messages_tokens_rough(compressed)
|
||||
saved_estimate = display_tokens - new_estimate
|
||||
|
||||
|
|
|
|||
|
|
@ -218,8 +218,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
|
||||
|
||||
# Exit
|
||||
CommandDef("quit", "Exit the CLI", "Exit",
|
||||
cli_only=True, aliases=("exit",)),
|
||||
CommandDef("quit", "Exit the CLI (use --delete to also remove session history)", "Exit",
|
||||
cli_only=True, aliases=("exit",), args_hint="[--delete]"),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ Usage:
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
|
|
@ -19,6 +21,7 @@ import secrets
|
|||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
|
@ -500,6 +503,55 @@ class EnvVarReveal(BaseModel):
|
|||
key: str
|
||||
|
||||
|
||||
class MessagingPlatformUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
env: Dict[str, str] = {}
|
||||
clear_env: List[str] = []
|
||||
|
||||
|
||||
class AudioTranscriptionRequest(BaseModel):
|
||||
data_url: str
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
|
||||
class ModelAssignment(BaseModel):
|
||||
"""Payload for POST /api/model/set — assign a provider/model to a slot.
|
||||
|
||||
scope="main" → writes model.provider + model.default
|
||||
scope="auxiliary" → writes auxiliary.<task>.provider + auxiliary.<task>.model
|
||||
scope="auxiliary" with task="" → applied to every auxiliary.* slot
|
||||
scope="auxiliary" with task="__reset__" → resets every slot to provider="auto"
|
||||
"""
|
||||
|
||||
scope: str
|
||||
provider: str
|
||||
model: str
|
||||
task: str = ""
|
||||
|
||||
|
||||
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
|
||||
"audio/aac": ".aac",
|
||||
"audio/flac": ".flac",
|
||||
"audio/m4a": ".m4a",
|
||||
"audio/mp3": ".mp3",
|
||||
"audio/mp4": ".mp4",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
"audio/wave": ".wav",
|
||||
"audio/webm": ".webm",
|
||||
"audio/x-m4a": ".m4a",
|
||||
"audio/x-wav": ".wav",
|
||||
"video/webm": ".webm",
|
||||
}
|
||||
_MAX_TRANSCRIPTION_UPLOAD_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def _audio_extension_for_mime(mime_type: str) -> str:
|
||||
normalized = (mime_type or "").split(";", 1)[0].strip().lower()
|
||||
return _AUDIO_MIME_EXTENSIONS.get(normalized, ".webm")
|
||||
|
||||
|
||||
class ModelAssignment(BaseModel):
|
||||
"""Payload for POST /api/model/set — assign a provider/model to a slot.
|
||||
|
||||
|
|
@ -798,6 +850,80 @@ async def update_hermes():
|
|||
}
|
||||
|
||||
|
||||
@app.post("/api/audio/transcribe")
|
||||
async def transcribe_audio_upload(payload: AudioTranscriptionRequest):
|
||||
data_url = (payload.data_url or "").strip()
|
||||
if not data_url.startswith("data:") or "," not in data_url:
|
||||
raise HTTPException(status_code=400, detail="Invalid audio payload")
|
||||
|
||||
header, encoded = data_url.split(",", 1)
|
||||
if ";base64" not in header:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Audio payload must be base64 encoded"
|
||||
)
|
||||
|
||||
mime_type = (
|
||||
payload.mime_type or header[5:].split(";", 1)[0] or "audio/webm"
|
||||
).strip()
|
||||
normalized_mime_type = mime_type.split(";", 1)[0].lower()
|
||||
if not (
|
||||
normalized_mime_type.startswith("audio/")
|
||||
or normalized_mime_type == "video/webm"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Payload must be an audio recording"
|
||||
)
|
||||
|
||||
try:
|
||||
audio_bytes = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Audio payload is not valid base64")
|
||||
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=400, detail="Audio recording is empty")
|
||||
if len(audio_bytes) > _MAX_TRANSCRIPTION_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Audio recording is too large")
|
||||
|
||||
temp_path = ""
|
||||
try:
|
||||
suffix = _audio_extension_for_mime(mime_type)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="hermes-desktop-voice-",
|
||||
suffix=suffix,
|
||||
delete=False,
|
||||
) as tmp:
|
||||
tmp.write(audio_bytes)
|
||||
temp_path = tmp.name
|
||||
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, transcribe_audio, temp_path)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Desktop voice transcription failed")
|
||||
raise HTTPException(status_code=500, detail=f"Transcription failed: {exc}")
|
||||
finally:
|
||||
if temp_path:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=result.get("error") or "Transcription failed",
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"transcript": str(result.get("transcript") or "").strip(),
|
||||
"provider": result.get("provider"),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/actions/{name}/status")
|
||||
async def get_action_status(name: str, lines: int = 200):
|
||||
"""Tail an action log and report whether the process is still running."""
|
||||
|
|
@ -1332,6 +1458,667 @@ async def reveal_env_var(body: EnvVarReveal, request: Request):
|
|||
return {"key": body.key, "value": value}
|
||||
|
||||
|
||||
# Entries omit fields they don't need to override; the catalog builder fills
|
||||
# in env_vars from OPTIONAL_ENV_VARS via prefix matching when not specified,
|
||||
# and pulls required_env from a plugin's PlatformEntry when available.
|
||||
_PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = {
|
||||
"telegram": {
|
||||
"name": "Telegram",
|
||||
"description": "Run Hermes from Telegram DMs, groups, and topics.",
|
||||
"docs_url": "https://core.telegram.org/bots/features#botfather",
|
||||
"env_vars": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS", "TELEGRAM_PROXY"),
|
||||
"required_env": ("TELEGRAM_BOT_TOKEN",),
|
||||
},
|
||||
"discord": {
|
||||
"name": "Discord",
|
||||
"description": "Connect Hermes to Discord DMs, channels, and threads.",
|
||||
"docs_url": "https://discord.com/developers/applications",
|
||||
"env_vars": (
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"DISCORD_ALLOWED_USERS",
|
||||
"DISCORD_REPLY_TO_MODE",
|
||||
),
|
||||
"required_env": ("DISCORD_BOT_TOKEN",),
|
||||
},
|
||||
"slack": {
|
||||
"name": "Slack",
|
||||
"description": "Use Hermes from Slack via Socket Mode.",
|
||||
"docs_url": "https://api.slack.com/apps",
|
||||
"env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
|
||||
"required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
|
||||
},
|
||||
"mattermost": {
|
||||
"name": "Mattermost",
|
||||
"description": "Connect Hermes to Mattermost channels and direct messages.",
|
||||
"docs_url": "https://mattermost.com/deploy/",
|
||||
"env_vars": ("MATTERMOST_URL", "MATTERMOST_TOKEN", "MATTERMOST_ALLOWED_USERS"),
|
||||
"required_env": ("MATTERMOST_URL", "MATTERMOST_TOKEN"),
|
||||
},
|
||||
"matrix": {
|
||||
"name": "Matrix",
|
||||
"description": "Use Hermes in Matrix rooms and direct messages.",
|
||||
"docs_url": "https://matrix.org/ecosystem/servers/",
|
||||
"env_vars": (
|
||||
"MATRIX_HOMESERVER",
|
||||
"MATRIX_ACCESS_TOKEN",
|
||||
"MATRIX_USER_ID",
|
||||
"MATRIX_ALLOWED_USERS",
|
||||
),
|
||||
"required_env": ("MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID"),
|
||||
},
|
||||
"signal": {
|
||||
"name": "Signal",
|
||||
"description": "Connect through a signal-cli REST bridge.",
|
||||
"docs_url": "https://github.com/bbernhard/signal-cli-rest-api",
|
||||
"env_vars": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT", "SIGNAL_ALLOWED_USERS"),
|
||||
"required_env": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT"),
|
||||
},
|
||||
"whatsapp": {
|
||||
"name": "WhatsApp",
|
||||
"description": "Use Hermes through the bundled WhatsApp bridge with QR-based auth.",
|
||||
"docs_url": "https://github.com/tulir/whatsmeow",
|
||||
"env_vars": ("WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS"),
|
||||
"required_env": (),
|
||||
},
|
||||
"homeassistant": {
|
||||
"name": "Home Assistant",
|
||||
"description": "Control your smart home from Hermes via Home Assistant.",
|
||||
"docs_url": "https://www.home-assistant.io/docs/authentication/",
|
||||
"env_vars": ("HASS_URL", "HASS_TOKEN"),
|
||||
"required_env": ("HASS_URL", "HASS_TOKEN"),
|
||||
},
|
||||
"email": {
|
||||
"name": "Email",
|
||||
"description": "Talk to Hermes through an IMAP/SMTP mailbox.",
|
||||
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/",
|
||||
"env_vars": (
|
||||
"EMAIL_ADDRESS",
|
||||
"EMAIL_PASSWORD",
|
||||
"EMAIL_IMAP_HOST",
|
||||
"EMAIL_SMTP_HOST",
|
||||
),
|
||||
"required_env": (
|
||||
"EMAIL_ADDRESS",
|
||||
"EMAIL_PASSWORD",
|
||||
"EMAIL_IMAP_HOST",
|
||||
"EMAIL_SMTP_HOST",
|
||||
),
|
||||
},
|
||||
"sms": {
|
||||
"name": "SMS (Twilio)",
|
||||
"description": "Send and receive text messages via Twilio.",
|
||||
"docs_url": "https://www.twilio.com/console",
|
||||
"env_vars": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"),
|
||||
"required_env": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"),
|
||||
},
|
||||
"dingtalk": {
|
||||
"name": "DingTalk",
|
||||
"description": "Connect Hermes to DingTalk groups (钉钉).",
|
||||
"docs_url": "https://open.dingtalk.com/document/orgapp/the-robot-development-process",
|
||||
"env_vars": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"),
|
||||
"required_env": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"),
|
||||
},
|
||||
"feishu": {
|
||||
"name": "Feishu / Lark",
|
||||
"description": "Use Hermes inside Feishu / Lark.",
|
||||
"docs_url": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/intro",
|
||||
"env_vars": (
|
||||
"FEISHU_APP_ID",
|
||||
"FEISHU_APP_SECRET",
|
||||
"FEISHU_ENCRYPT_KEY",
|
||||
"FEISHU_VERIFICATION_TOKEN",
|
||||
),
|
||||
"required_env": ("FEISHU_APP_ID", "FEISHU_APP_SECRET"),
|
||||
},
|
||||
"wecom": {
|
||||
"name": "WeCom (group bot)",
|
||||
"description": "Send-only WeCom group bot via webhook.",
|
||||
"docs_url": "https://developer.work.weixin.qq.com/document/path/91770",
|
||||
"env_vars": ("WECOM_BOT_ID", "WECOM_SECRET"),
|
||||
"required_env": ("WECOM_BOT_ID",),
|
||||
},
|
||||
"wecom_callback": {
|
||||
"name": "WeCom (app)",
|
||||
"description": "Two-way WeCom integration via callback app.",
|
||||
"docs_url": "https://developer.work.weixin.qq.com/document/path/90930",
|
||||
"env_vars": (
|
||||
"WECOM_CALLBACK_CORP_ID",
|
||||
"WECOM_CALLBACK_CORP_SECRET",
|
||||
"WECOM_CALLBACK_AGENT_ID",
|
||||
"WECOM_CALLBACK_TOKEN",
|
||||
"WECOM_CALLBACK_ENCODING_AES_KEY",
|
||||
),
|
||||
"required_env": (
|
||||
"WECOM_CALLBACK_CORP_ID",
|
||||
"WECOM_CALLBACK_CORP_SECRET",
|
||||
"WECOM_CALLBACK_AGENT_ID",
|
||||
),
|
||||
},
|
||||
"weixin": {
|
||||
"name": "WeChat (Official Account)",
|
||||
"description": "Connect a WeChat Official Account.",
|
||||
"docs_url": "https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html",
|
||||
"env_vars": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL"),
|
||||
"required_env": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN"),
|
||||
},
|
||||
"bluebubbles": {
|
||||
"name": "BlueBubbles (iMessage)",
|
||||
"description": "Use Hermes through iMessage via a BlueBubbles server.",
|
||||
"docs_url": "https://bluebubbles.app/",
|
||||
"env_vars": (
|
||||
"BLUEBUBBLES_SERVER_URL",
|
||||
"BLUEBUBBLES_PASSWORD",
|
||||
"BLUEBUBBLES_ALLOWED_USERS",
|
||||
),
|
||||
"required_env": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"),
|
||||
},
|
||||
"qqbot": {
|
||||
"name": "QQ Bot",
|
||||
"description": "Connect Hermes to a QQ Bot from the QQ Open Platform.",
|
||||
"docs_url": "https://q.qq.com",
|
||||
"env_vars": ("QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_ALLOWED_USERS"),
|
||||
"required_env": ("QQ_APP_ID", "QQ_CLIENT_SECRET"),
|
||||
},
|
||||
"yuanbao": {
|
||||
"name": "Yuanbao (元宝)",
|
||||
"description": "Connect Hermes to Tencent Yuanbao.",
|
||||
"docs_url": "",
|
||||
"required_env": (),
|
||||
},
|
||||
"api_server": {
|
||||
"name": "API server",
|
||||
"description": "Expose Hermes as an OpenAI-compatible HTTP API for tools like Open WebUI.",
|
||||
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/",
|
||||
"env_vars": (
|
||||
"API_SERVER_ENABLED",
|
||||
"API_SERVER_KEY",
|
||||
"API_SERVER_PORT",
|
||||
"API_SERVER_HOST",
|
||||
"API_SERVER_MODEL_NAME",
|
||||
),
|
||||
"required_env": (),
|
||||
},
|
||||
"webhook": {
|
||||
"name": "Webhooks",
|
||||
"description": "Receive events from GitHub, GitLab, and other webhook sources.",
|
||||
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/",
|
||||
"env_vars": ("WEBHOOK_ENABLED", "WEBHOOK_PORT", "WEBHOOK_SECRET"),
|
||||
"required_env": (),
|
||||
},
|
||||
}
|
||||
|
||||
# Display order: well-known platforms surface first; unknown plugins fall to
|
||||
# the end alphabetically.
|
||||
_PLATFORM_ORDER: tuple[str, ...] = (
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"mattermost",
|
||||
"matrix",
|
||||
"whatsapp",
|
||||
"signal",
|
||||
"bluebubbles",
|
||||
"homeassistant",
|
||||
"email",
|
||||
"sms",
|
||||
"dingtalk",
|
||||
"feishu",
|
||||
"wecom",
|
||||
"wecom_callback",
|
||||
"weixin",
|
||||
"qqbot",
|
||||
"yuanbao",
|
||||
"api_server",
|
||||
"webhook",
|
||||
)
|
||||
|
||||
# Display labels for env vars not in OPTIONAL_ENV_VARS (HOME_CHANNEL_*, bridge
|
||||
# toggles, Twilio, HASS, Email, etc.). Anything missing from OPTIONAL_ENV_VARS
|
||||
# falls back here so the UI can still render a friendly label.
|
||||
_MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = {
|
||||
"SIGNAL_HTTP_URL": {
|
||||
"description": "signal-cli REST API base URL, e.g. http://127.0.0.1:8080",
|
||||
"prompt": "Signal bridge URL",
|
||||
"url": "https://github.com/bbernhard/signal-cli-rest-api",
|
||||
},
|
||||
"SIGNAL_ACCOUNT": {
|
||||
"description": "Signal account phone number registered with the bridge",
|
||||
"prompt": "Signal account",
|
||||
},
|
||||
"SIGNAL_ALLOWED_USERS": {
|
||||
"description": "Comma-separated Signal users allowed to use the bot",
|
||||
"prompt": "Allowed Signal users",
|
||||
},
|
||||
"WHATSAPP_ENABLED": {
|
||||
"description": "Enable the WhatsApp gateway adapter",
|
||||
"prompt": "Enable WhatsApp",
|
||||
"advanced": True,
|
||||
},
|
||||
"WHATSAPP_MODE": {
|
||||
"description": "WhatsApp bridge mode",
|
||||
"prompt": "WhatsApp mode",
|
||||
"advanced": True,
|
||||
},
|
||||
"WHATSAPP_ALLOWED_USERS": {
|
||||
"description": "Comma-separated WhatsApp users allowed to use the bot",
|
||||
"prompt": "Allowed WhatsApp users",
|
||||
},
|
||||
"HASS_URL": {
|
||||
"description": "Home Assistant base URL, e.g. https://homeassistant.local:8123",
|
||||
"prompt": "Home Assistant URL",
|
||||
},
|
||||
"HASS_TOKEN": {
|
||||
"description": "Long-lived access token from Home Assistant (Profile → Security)",
|
||||
"prompt": "Home Assistant access token",
|
||||
"password": True,
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"description": "Email address to send and receive from",
|
||||
"prompt": "Email address",
|
||||
},
|
||||
"EMAIL_PASSWORD": {
|
||||
"description": "Email account password or app password",
|
||||
"prompt": "Email password",
|
||||
"password": True,
|
||||
},
|
||||
"EMAIL_IMAP_HOST": {
|
||||
"description": "IMAP server host (e.g. imap.gmail.com)",
|
||||
"prompt": "IMAP host",
|
||||
},
|
||||
"EMAIL_SMTP_HOST": {
|
||||
"description": "SMTP server host (e.g. smtp.gmail.com)",
|
||||
"prompt": "SMTP host",
|
||||
},
|
||||
"TWILIO_ACCOUNT_SID": {
|
||||
"description": "Twilio Account SID",
|
||||
"prompt": "Twilio Account SID",
|
||||
"url": "https://www.twilio.com/console",
|
||||
},
|
||||
"TWILIO_AUTH_TOKEN": {
|
||||
"description": "Twilio Auth Token",
|
||||
"prompt": "Twilio Auth Token",
|
||||
"password": True,
|
||||
},
|
||||
"WECOM_BOT_ID": {"description": "WeCom group bot ID", "prompt": "WeCom Bot ID"},
|
||||
"WECOM_SECRET": {
|
||||
"description": "WeCom group bot secret",
|
||||
"prompt": "WeCom Secret",
|
||||
"password": True,
|
||||
},
|
||||
"WECOM_CALLBACK_CORP_ID": {
|
||||
"description": "WeCom corp ID",
|
||||
"prompt": "WeCom Corp ID",
|
||||
},
|
||||
"WECOM_CALLBACK_CORP_SECRET": {
|
||||
"description": "WeCom app corp secret",
|
||||
"prompt": "WeCom Corp Secret",
|
||||
"password": True,
|
||||
},
|
||||
"WECOM_CALLBACK_AGENT_ID": {
|
||||
"description": "WeCom app agent ID",
|
||||
"prompt": "WeCom Agent ID",
|
||||
},
|
||||
"WECOM_CALLBACK_TOKEN": {
|
||||
"description": "WeCom callback verification token",
|
||||
"prompt": "WeCom Token",
|
||||
},
|
||||
"WECOM_CALLBACK_ENCODING_AES_KEY": {
|
||||
"description": "WeCom callback AES encoding key",
|
||||
"prompt": "WeCom AES Key",
|
||||
"password": True,
|
||||
},
|
||||
"WEIXIN_ACCOUNT_ID": {
|
||||
"description": "WeChat Official Account ID",
|
||||
"prompt": "Account ID",
|
||||
},
|
||||
"WEIXIN_TOKEN": {
|
||||
"description": "WeChat callback token",
|
||||
"prompt": "Token",
|
||||
"password": True,
|
||||
},
|
||||
"WEIXIN_BASE_URL": {
|
||||
"description": "WeChat platform base URL",
|
||||
"prompt": "Base URL",
|
||||
},
|
||||
"FEISHU_APP_ID": {"description": "Feishu / Lark app ID", "prompt": "App ID"},
|
||||
"FEISHU_APP_SECRET": {
|
||||
"description": "Feishu / Lark app secret",
|
||||
"prompt": "App secret",
|
||||
"password": True,
|
||||
},
|
||||
"FEISHU_ENCRYPT_KEY": {
|
||||
"description": "Feishu / Lark encrypt key",
|
||||
"prompt": "Encrypt key",
|
||||
"password": True,
|
||||
},
|
||||
"FEISHU_VERIFICATION_TOKEN": {
|
||||
"description": "Feishu / Lark verification token",
|
||||
"prompt": "Verification token",
|
||||
"password": True,
|
||||
},
|
||||
"DINGTALK_CLIENT_ID": {
|
||||
"description": "DingTalk client ID (App key)",
|
||||
"prompt": "Client ID",
|
||||
},
|
||||
"DINGTALK_CLIENT_SECRET": {
|
||||
"description": "DingTalk client secret (App secret)",
|
||||
"prompt": "Client secret",
|
||||
"password": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _messaging_platform_catalog() -> tuple[dict[str, Any], ...]:
|
||||
"""Build the messaging catalog from the gateway's Platform enum + plugin registry.
|
||||
|
||||
Built-in platforms come from ``gateway.config.Platform`` (LOCAL is excluded).
|
||||
Plugin platforms come from ``gateway.platform_registry.plugin_entries()``,
|
||||
which lets newly installed adapters (e.g. IRC) appear without a code change
|
||||
here. Per-platform UI metadata (description, docs URL, env-var picks) lives
|
||||
in :data:`_PLATFORM_OVERRIDES`; anything not overridden gets reasonable
|
||||
defaults derived from the platform id and required_env.
|
||||
"""
|
||||
from gateway.config import Platform
|
||||
|
||||
seen: set[str] = set()
|
||||
entries: list[dict[str, Any]] = []
|
||||
|
||||
for member in Platform.__members__.values():
|
||||
if member.value == "local":
|
||||
continue
|
||||
if member.value in seen:
|
||||
continue
|
||||
seen.add(member.value)
|
||||
entries.append(_build_catalog_entry(member.value))
|
||||
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
|
||||
for plugin_entry in platform_registry.plugin_entries():
|
||||
if plugin_entry.name in seen:
|
||||
continue
|
||||
seen.add(plugin_entry.name)
|
||||
entries.append(_build_catalog_entry(plugin_entry.name, plugin_entry))
|
||||
except Exception:
|
||||
_log.debug("plugin platform registry unavailable", exc_info=True)
|
||||
|
||||
order = {pid: idx for idx, pid in enumerate(_PLATFORM_ORDER)}
|
||||
entries.sort(
|
||||
key=lambda e: (order.get(e["id"], len(_PLATFORM_ORDER)), e["name"].lower())
|
||||
)
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
def _build_catalog_entry(
|
||||
platform_id: str, plugin_entry: Any | None = None
|
||||
) -> dict[str, Any]:
|
||||
override = _PLATFORM_OVERRIDES.get(platform_id, {})
|
||||
|
||||
if "env_vars" in override:
|
||||
env_vars: tuple[str, ...] = tuple(override["env_vars"])
|
||||
elif plugin_entry is not None and plugin_entry.required_env:
|
||||
env_vars = tuple(plugin_entry.required_env)
|
||||
else:
|
||||
prefix = platform_id.upper() + "_"
|
||||
env_vars = tuple(k for k in OPTIONAL_ENV_VARS if k.startswith(prefix))
|
||||
|
||||
if "required_env" in override:
|
||||
required_env = tuple(override["required_env"])
|
||||
elif plugin_entry is not None:
|
||||
required_env = tuple(plugin_entry.required_env or ())
|
||||
else:
|
||||
required_env = ()
|
||||
|
||||
if override.get("name"):
|
||||
name = override["name"]
|
||||
elif plugin_entry is not None and plugin_entry.label:
|
||||
name = plugin_entry.label
|
||||
else:
|
||||
name = platform_id.replace("_", " ").title()
|
||||
|
||||
description = override.get("description")
|
||||
if not description and plugin_entry is not None:
|
||||
description = plugin_entry.install_hint or ""
|
||||
|
||||
return {
|
||||
"id": platform_id,
|
||||
"name": name,
|
||||
"description": description or "",
|
||||
"docs_url": override.get("docs_url", ""),
|
||||
"env_vars": env_vars,
|
||||
"required_env": required_env,
|
||||
}
|
||||
|
||||
|
||||
def _catalog_lookup(platform_id: str) -> dict[str, Any] | None:
|
||||
for entry in _messaging_platform_catalog():
|
||||
if entry["id"] == platform_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _messaging_env_info(key: str) -> dict[str, Any]:
|
||||
info = OPTIONAL_ENV_VARS.get(key) or _MESSAGING_ENV_FALLBACKS.get(key) or {}
|
||||
return {
|
||||
"description": info.get("description", ""),
|
||||
"prompt": info.get("prompt", key),
|
||||
"url": info.get("url"),
|
||||
"is_password": info.get("password", False),
|
||||
"advanced": info.get("advanced", False),
|
||||
}
|
||||
|
||||
|
||||
def _gateway_platform_config(platform_id: str):
|
||||
from gateway.config import Platform, load_gateway_config
|
||||
|
||||
config = load_gateway_config()
|
||||
platform = Platform(platform_id)
|
||||
platform_config = config.platforms.get(platform)
|
||||
return config, platform, platform_config
|
||||
|
||||
|
||||
def _messaging_platform_payload(
|
||||
entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None
|
||||
) -> dict[str, Any]:
|
||||
platform_id = entry["id"]
|
||||
gateway_running = get_running_pid() is not None
|
||||
runtime_platforms = runtime.get("platforms") if runtime else {}
|
||||
runtime_platform = (
|
||||
runtime_platforms.get(platform_id, {})
|
||||
if isinstance(runtime_platforms, dict)
|
||||
else {}
|
||||
)
|
||||
env_vars = []
|
||||
|
||||
for key in entry["env_vars"]:
|
||||
value = env_on_disk.get(key) or os.getenv(key, "")
|
||||
env_vars.append(
|
||||
{
|
||||
"key": key,
|
||||
"required": key in entry["required_env"],
|
||||
"is_set": bool(value),
|
||||
"redacted_value": redact_key(value) if value else None,
|
||||
**_messaging_env_info(key),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
gateway_config, platform, platform_config = _gateway_platform_config(
|
||||
platform_id
|
||||
)
|
||||
enabled = bool(platform_config and platform_config.enabled)
|
||||
configured = bool(
|
||||
platform_config
|
||||
and gateway_config._is_platform_connected(platform, platform_config)
|
||||
)
|
||||
home_channel = (
|
||||
platform_config.home_channel.to_dict()
|
||||
if platform_config and platform_config.home_channel
|
||||
else None
|
||||
)
|
||||
except Exception:
|
||||
enabled = False
|
||||
configured = all(
|
||||
env_on_disk.get(key) or os.getenv(key, "") for key in entry["required_env"]
|
||||
)
|
||||
home_channel = None
|
||||
|
||||
state = (
|
||||
runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
|
||||
)
|
||||
if not enabled:
|
||||
state = "disabled"
|
||||
elif not configured:
|
||||
state = "not_configured"
|
||||
elif gateway_running and not state:
|
||||
state = "pending_restart"
|
||||
elif not gateway_running and not state:
|
||||
state = "gateway_stopped"
|
||||
|
||||
return {
|
||||
"id": platform_id,
|
||||
"name": entry["name"],
|
||||
"description": entry["description"],
|
||||
"docs_url": entry["docs_url"],
|
||||
"enabled": enabled,
|
||||
"configured": configured,
|
||||
"gateway_running": gateway_running,
|
||||
"state": state,
|
||||
"error_code": (
|
||||
runtime_platform.get("error_code")
|
||||
if isinstance(runtime_platform, dict)
|
||||
else None
|
||||
),
|
||||
"error_message": (
|
||||
runtime_platform.get("error_message")
|
||||
if isinstance(runtime_platform, dict)
|
||||
else None
|
||||
),
|
||||
"updated_at": (
|
||||
runtime_platform.get("updated_at")
|
||||
if isinstance(runtime_platform, dict)
|
||||
else None
|
||||
),
|
||||
"home_channel": home_channel,
|
||||
"env_vars": env_vars,
|
||||
}
|
||||
|
||||
|
||||
def _write_platform_enabled(platform_id: str, enabled: bool) -> None:
|
||||
config = load_config()
|
||||
platforms = config.setdefault("platforms", {})
|
||||
if not isinstance(platforms, dict):
|
||||
platforms = {}
|
||||
config["platforms"] = platforms
|
||||
platform_config = platforms.setdefault(platform_id, {})
|
||||
if not isinstance(platform_config, dict):
|
||||
platform_config = {}
|
||||
platforms[platform_id] = platform_config
|
||||
platform_config["enabled"] = enabled
|
||||
save_config(config)
|
||||
|
||||
|
||||
@app.get("/api/messaging/platforms")
|
||||
async def get_messaging_platforms():
|
||||
env_on_disk = load_env()
|
||||
runtime = read_runtime_status()
|
||||
return {
|
||||
"platforms": [
|
||||
_messaging_platform_payload(entry, env_on_disk, runtime)
|
||||
for entry in _messaging_platform_catalog()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/messaging/platforms/{platform_id}")
|
||||
async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpdate):
|
||||
entry = _catalog_lookup(platform_id)
|
||||
if not entry:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Unknown messaging platform: {platform_id}"
|
||||
)
|
||||
|
||||
allowed_env = set(entry["env_vars"])
|
||||
try:
|
||||
for key in body.clear_env:
|
||||
if key not in allowed_env:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{key} is not configurable for {entry['name']}",
|
||||
)
|
||||
remove_env_value(key)
|
||||
|
||||
for key, value in body.env.items():
|
||||
if key not in allowed_env:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{key} is not configurable for {entry['name']}",
|
||||
)
|
||||
trimmed = value.strip()
|
||||
if trimmed:
|
||||
save_env_value(key, trimmed)
|
||||
|
||||
if body.enabled is not None:
|
||||
_write_platform_enabled(platform_id, body.enabled)
|
||||
|
||||
return {"ok": True, "platform": platform_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("PUT /api/messaging/platforms/%s failed", platform_id)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@app.post("/api/messaging/platforms/{platform_id}/test")
|
||||
async def test_messaging_platform(platform_id: str):
|
||||
entry = _catalog_lookup(platform_id)
|
||||
if not entry:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Unknown messaging platform: {platform_id}"
|
||||
)
|
||||
|
||||
env_on_disk = load_env()
|
||||
payload = _messaging_platform_payload(entry, env_on_disk, read_runtime_status())
|
||||
if not payload["enabled"]:
|
||||
message = f"{entry['name']} is disabled. Enable it, then restart the gateway."
|
||||
return {"ok": False, "state": payload["state"], "message": message}
|
||||
if not payload["configured"]:
|
||||
missing = [
|
||||
field["key"]
|
||||
for field in payload["env_vars"]
|
||||
if field["required"] and not field["is_set"]
|
||||
]
|
||||
message = (
|
||||
f"Missing required setup: {', '.join(missing)}"
|
||||
if missing
|
||||
else "Platform setup is incomplete."
|
||||
)
|
||||
return {"ok": False, "state": payload["state"], "message": message}
|
||||
if not payload["gateway_running"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"state": payload["state"],
|
||||
"message": "Gateway is not running. Restart the gateway to connect this platform.",
|
||||
}
|
||||
if payload["state"] == "connected":
|
||||
return {
|
||||
"ok": True,
|
||||
"state": payload["state"],
|
||||
"message": f"{entry['name']} is connected.",
|
||||
}
|
||||
if payload.get("error_message"):
|
||||
return {
|
||||
"ok": False,
|
||||
"state": payload["state"],
|
||||
"message": payload["error_message"],
|
||||
}
|
||||
return {
|
||||
"ok": False,
|
||||
"state": payload["state"],
|
||||
"message": "Setup looks complete, but the gateway has not reported a connection yet. Restart the gateway.",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth provider endpoints — status + disconnect (Phase 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -3497,6 +4284,10 @@ def _resolve_chat_argv(
|
|||
Appending ``--resume <id>`` to argv doesn't work because ``ui-tui`` does
|
||||
not parse its argv.
|
||||
|
||||
``HERMES_TUI_GATEWAY_URL`` is injected so the PTY child can attach to
|
||||
this process's in-memory ``tui_gateway`` instance instead of spawning
|
||||
its own Python gateway subprocess.
|
||||
|
||||
`sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so
|
||||
the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the
|
||||
dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`).
|
||||
|
|
@ -3524,9 +4315,30 @@ def _resolve_chat_argv(
|
|||
if sidecar_url:
|
||||
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
|
||||
|
||||
if gateway_ws_url := _build_gateway_ws_url():
|
||||
env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url
|
||||
|
||||
return list(argv), str(cwd) if cwd else None, env
|
||||
|
||||
|
||||
def _build_gateway_ws_url() -> Optional[str]:
|
||||
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic."""
|
||||
host = getattr(app.state, "bound_host", None)
|
||||
port = getattr(app.state, "bound_port", None)
|
||||
|
||||
if not host or not port:
|
||||
return None
|
||||
|
||||
netloc = (
|
||||
f"[{host}]:{port}"
|
||||
if ":" in host and not host.startswith("[")
|
||||
else f"{host}:{port}"
|
||||
)
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
|
||||
|
||||
return f"ws://{netloc}/api/ws?{qs}"
|
||||
|
||||
|
||||
def _build_sidecar_url(channel: str) -> Optional[str]:
|
||||
"""ws:// URL the PTY child should publish events to, or None when unbound.
|
||||
|
||||
|
|
|
|||
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -13,7 +13,6 @@
|
|||
"apps/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@askjo/camofox-browser": "^1.5.2",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"agent-browser": "^0.26.0"
|
||||
},
|
||||
|
|
@ -414,25 +413,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@askjo/camofox-browser": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@askjo/camofox-browser/-/camofox-browser-1.5.2.tgz",
|
||||
"integrity": "sha512-SvRCzhWnJaplxHkRVF9l1OWako6pp2eUw2mZKHOERUfLWDO2Xe/IKI+5bB+UT1TNvO45P6XdhgfAtihcTEARCg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camoufox-js": "^0.8.5",
|
||||
"express": "^4.18.2",
|
||||
"playwright": "^1.50.0",
|
||||
"playwright-core": "^1.58.0",
|
||||
"playwright-extra": "^4.3.6",
|
||||
"prom-client": "^15.1.3",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@assistant-ui/core": {
|
||||
"version": "0.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@assistant-ui/core/-/core-0.1.17.tgz",
|
||||
|
|
@ -10334,34 +10314,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camoufox-js": {
|
||||
"version": "0.8.5",
|
||||
"resolved": "https://registry.npmjs.org/camoufox-js/-/camoufox-js-0.8.5.tgz",
|
||||
"integrity": "sha512-20ihPbspAcOVSUTX9Drxxp0C116DON1n8OVA1eUDglWZiHwiHwFVFOMrIEBwAHMZpU11mIEH/kawJtstRIrDPA==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"commander": "^14.0.0",
|
||||
"fingerprint-generator": "^2.1.66",
|
||||
"glob": "^13.0.0",
|
||||
"impit": "^0.7.0",
|
||||
"language-tags": "^2.0.1",
|
||||
"maxmind": "^5.0.0",
|
||||
"progress": "^2.0.3",
|
||||
"ua-parser-js": "^2.0.2",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"bin": {
|
||||
"camoufox-js": "dist/__main__.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright-core": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001787",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
},
|
||||
"homepage": "https://github.com/NousResearch/Hermes-Agent#readme",
|
||||
"dependencies": {
|
||||
"@askjo/camofox-browser": "^1.5.2",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"agent-browser": "^0.26.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ def test_init_session_attaches_background_review_callback(server, monkeypatch):
|
|||
monkeypatch.setattr(server, "_SlashWorker", lambda *a, **kw: object())
|
||||
monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None)
|
||||
monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent: {"model": "m"})
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent, session=None: {"model": "m"})
|
||||
monkeypatch.setattr(server, "_load_show_reasoning", lambda: False)
|
||||
monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all")
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ def test_review_summary_callback_survives_agent_without_attribute(server, monkey
|
|||
monkeypatch.setattr(server, "_SlashWorker", lambda *a, **kw: object())
|
||||
monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None)
|
||||
monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent: {"model": "m"})
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent, session=None: {"model": "m"})
|
||||
monkeypatch.setattr(server, "_load_show_reasoning", lambda: False)
|
||||
monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all")
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue