Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,25 @@ def _read_main_model() -> str:
return ""


def _read_main_provider() -> str:
"""Read the user's configured main provider from config.yaml.

config.yaml model.provider is the single source of truth for the active
provider. Returns an empty string if not set or on any error.
"""
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
provider = str(model_cfg.get("provider") or "").strip().lower()
if provider:
return provider
except Exception:
pass
return ""


def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
"""Resolve the active custom/main endpoint the same way the main CLI does.

Expand Down Expand Up @@ -1345,6 +1364,7 @@ def auxiliary_max_tokens_param(value: int) -> dict:
# Client cache: (provider, async_mode, base_url, api_key) -> (client, default_model)
_client_cache: Dict[tuple, tuple] = {}
_client_cache_lock = threading.Lock()
MAX_CLIENT_CACHE_SIZE = 32 # prevent fd exhaustion from stale async clients in long-running gateways


def neuter_async_httpx_del() -> None:
Expand Down Expand Up @@ -1505,6 +1525,14 @@ def _get_cached_client(
with _client_cache_lock:
if cache_key not in _client_cache:
_client_cache[cache_key] = (client, default_model, bound_loop)
# Evict oldest entries if cache exceeds the size cap.
# Prevents fd exhaustion when ThreadPoolExecutor recycles threads,
# each getting a new loop_id and thus a new cache entry that never
# gets evicted by cleanup_stale_async_clients() (loop stays open).
while len(_client_cache) > MAX_CLIENT_CACHE_SIZE:
oldest_key = next(iter(_client_cache))
oldest_client, _, _ = _client_cache.pop(oldest_key)
_force_close_async_httpx(oldest_client)
else:
client, default_model, _ = _client_cache[cache_key]
return client, model or default_model
Expand Down Expand Up @@ -1584,6 +1612,16 @@ def _resolve_task_provider_model(
return "custom", resolved_model, cfg_base_url, cfg_api_key
if cfg_provider and cfg_provider != "auto":
return cfg_provider, resolved_model, None, None

# For compression with auto provider and no explicit model,
# use the main provider/model instead of falling through to
# OpenRouter which may not be configured or may 404.
if task == "compression" and not resolved_model:
main_prov = _read_main_provider()
main_mod = _read_main_model()
if main_prov and main_prov not in ("auto", "openrouter", "nous", ""):
return main_prov, main_mod, None, None

return "auto", resolved_model, None, None

return "auto", resolved_model, None, None
Expand Down
5 changes: 4 additions & 1 deletion agent/skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,11 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu

skill_name = str(loaded_skill.get("name") or normalized)
skill_path = str(loaded_skill.get("path") or "")
skill_dir_str = loaded_skill.get("skill_dir") or ""
skill_dir = None
if skill_path:
if skill_dir_str:
skill_dir = Path(skill_dir_str)
elif skill_path:
try:
skill_dir = SKILLS_DIR / Path(skill_path).parent
except Exception:
Expand Down
8 changes: 6 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,8 +335,12 @@ def load_cli_config() -> Dict[str, Any]:
if terminal_config.get("cwd") in (".", "auto", "cwd"):
effective_backend = terminal_config.get("env_type", "local")
if effective_backend == "local":
terminal_config["cwd"] = os.getcwd()
defaults["terminal"]["cwd"] = terminal_config["cwd"]
# Respect an already-set TERMINAL_CWD (e.g. set by gateway from
# MESSAGING_CWD) instead of clobbering it with os.getcwd().
existing_cwd = os.environ.get("TERMINAL_CWD", "").strip()
resolved_cwd = existing_cwd if existing_cwd else os.getcwd()
terminal_config["cwd"] = resolved_cwd
defaults["terminal"]["cwd"] = resolved_cwd
else:
# Remove so TERMINAL_CWD stays unset → tool picks backend default
terminal_config.pop("cwd", None)
Expand Down
5 changes: 3 additions & 2 deletions gateway/platforms/dingtalk.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,9 @@ def _extract_text(message: "ChatbotMessage") -> str:
def _is_duplicate(self, msg_id: str) -> bool:
"""Check and record a message ID. Returns True if already seen."""
now = time.time()
if len(self._seen_messages) > DEDUP_MAX_SIZE:
cutoff = now - DEDUP_WINDOW_SECONDS
cutoff = now - DEDUP_WINDOW_SECONDS

if len(self._seen_messages) > DEDUP_MAX_SIZE or msg_id in self._seen_messages:
self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff}

if msg_id in self._seen_messages:
Expand Down
6 changes: 4 additions & 2 deletions gateway/platforms/wecom.py
Original file line number Diff line number Diff line change
Expand Up @@ -734,8 +734,10 @@ def _resolve_group_cfg(self, chat_id: str) -> Dict[str, Any]:

def _is_duplicate(self, msg_id: str) -> bool:
now = time.time()
if len(self._seen_messages) > DEDUP_MAX_SIZE:
cutoff = now - DEDUP_WINDOW_SECONDS
cutoff = now - DEDUP_WINDOW_SECONDS

# Prune expired entries (always, not just when over max_size)
if len(self._seen_messages) > DEDUP_MAX_SIZE or msg_id in self._seen_messages:
self._seen_messages = {
key: ts for key, ts in self._seen_messages.items() if ts > cutoff
}
Expand Down
27 changes: 24 additions & 3 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5419,6 +5419,27 @@ async def _run_agent(

def progress_callback(tool_name: str, preview: str = None, args: dict = None):
"""Callback invoked by agent when a tool is called."""
# Emit dashboard event for live ship movement
try:
import json as _j, urllib.request as _ur, uuid as _uuid, time as _time
_meta = {
"cli": ("glados","GLaDOS","#00FFD1"),
"telegram": ("glados","GLaDOS","#00FFD1"),
}.get(source.platform.value if source.platform else "cli",
("glados","GLaDOS","#00FFD1"))
_payload = _j.dumps({
"type": "tool.started", "agentId": _meta[0],
"sessionId": session_id, "id": str(_uuid.uuid4()), "seq": 1,
"ts": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
"tool_name": tool_name, "preview": str(preview or "")[:80],
"agent_name": _meta[1], "agent_color": _meta[2],
}).encode()
_req = _ur.Request("http://localhost:8642/api/emit", data=_payload,
headers={"Content-Type":"application/json"}, method="POST")
_ur.urlopen(_req, timeout=0.3)
except Exception:
pass

if not progress_queue:
return

Expand Down Expand Up @@ -5734,7 +5755,7 @@ def run_sync():

# Per-message state — callbacks and reasoning config change every
# turn and must not be baked into the cached agent constructor.
agent.tool_progress_callback = progress_callback if tool_progress_enabled else None
agent.tool_progress_callback = progress_callback # always set — dashboard needs it
agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None
agent.stream_delta_callback = _stream_delta_cb
agent.status_callback = _status_callback_sync
Expand Down Expand Up @@ -6394,9 +6415,9 @@ def main():

config = None
if args.config:
import json
import yaml
with open(args.config, encoding="utf-8") as f:
data = json.load(f)
data = yaml.safe_load(f) or {}
config = GatewayConfig.from_dict(data)

# Run the gateway - exit with code 1 if no platforms connected,
Expand Down
29 changes: 28 additions & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2930,7 +2930,28 @@ def cmd_update(args):
if is_managed():
managed_error("update Hermes Agent")
return


if getattr(args, 'check', False):
import subprocess
git_dir = PROJECT_ROOT / '.git'
if not git_dir.exists():
print('Cannot check for updates: not a git repository.')
return
try:
subprocess.run(['git', 'fetch', 'origin'], cwd=PROJECT_ROOT,
capture_output=True, check=False)
result = subprocess.run(
['git', 'rev-list', 'HEAD..origin/main', '--count'],
cwd=PROJECT_ROOT, capture_output=True, text=True, check=False)
count = int(result.stdout.strip() or '0')
if count == 0:
print('✓ Hermes Agent is up to date.')
else:
print(f'↑ {count} update(s) available. Run `hermes update` to install.')
except Exception as e:
print(f'Update check failed: {e}')
return

print("⚕ Updating Hermes Agent...")
print()

Expand Down Expand Up @@ -4965,6 +4986,12 @@ def cmd_claw(args):
help="Update Hermes Agent to the latest version",
description="Pull the latest changes from git and reinstall dependencies"
)
update_parser.add_argument(
"--check",
action="store_true",
default=False,
help="Check for available updates without installing them",
)
update_parser.set_defaults(func=cmd_update)

# =========================================================================
Expand Down
21 changes: 20 additions & 1 deletion run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3537,7 +3537,26 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo
self._client_log_context(),
)
return client
client = OpenAI(**client_kwargs)
# Add TCP keepalive so the kernel detects dead connections
# (CLOSE-WAIT) and wakes epoll_wait instead of hanging forever.
# Keepalive probes start after 30s idle, retry every 10s, give up
# after 3 failures (~60s worst-case detection window).
try:
import socket as _socket
import httpx as _httpx
_keepalive_transport = _httpx.HTTPTransport(
socket_options=[
(_socket.SOL_SOCKET, _socket.SO_KEEPALIVE, 1),
(_socket.IPPROTO_TCP, _socket.TCP_KEEPIDLE, 30),
(_socket.IPPROTO_TCP, _socket.TCP_KEEPINTVL, 10),
(_socket.IPPROTO_TCP, _socket.TCP_KEEPCNT, 3),
]
)
_http_client = _httpx.Client(transport=_keepalive_transport)
client = OpenAI(**client_kwargs, http_client=_http_client)
except Exception:
# Fallback if TCP keepalive options are unavailable (non-Linux)
client = OpenAI(**client_kwargs)
logger.info(
"OpenAI client created (%s, shared=%s) %s",
reason,
Expand Down
Loading