Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -352,19 +352,6 @@ def load_permanent(patterns: set):
|
||||
_permanent_approved.update(patterns)
|
||||
|
||||
|
||||
def clear_session(session_key: str):
|
||||
"""Clear all approvals and pending requests for a session."""
|
||||
with _lock:
|
||||
_session_approved.pop(session_key, None)
|
||||
_session_yolo.discard(session_key)
|
||||
_pending.pop(session_key, None)
|
||||
_gateway_notify_cbs.pop(session_key, None)
|
||||
# Signal ALL blocked threads so they don't hang forever
|
||||
entries = _gateway_queues.pop(session_key, [])
|
||||
for entry in entries:
|
||||
entry.event.set()
|
||||
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Config persistence for permanent allowlist
|
||||
|
||||
@@ -382,42 +382,6 @@ def cronjob(
|
||||
return tool_error(str(e), success=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compatibility wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def schedule_cronjob(
|
||||
prompt: str,
|
||||
schedule: str,
|
||||
name: Optional[str] = None,
|
||||
repeat: Optional[int] = None,
|
||||
deliver: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
task_id: str = None,
|
||||
) -> str:
|
||||
return cronjob(
|
||||
action="create",
|
||||
prompt=prompt,
|
||||
schedule=schedule,
|
||||
name=name,
|
||||
repeat=repeat,
|
||||
deliver=deliver,
|
||||
model=model,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
|
||||
def list_cronjobs(include_disabled: bool = False, task_id: str = None) -> str:
|
||||
return cronjob(action="list", include_disabled=include_disabled, task_id=task_id)
|
||||
|
||||
|
||||
def remove_cronjob(job_id: str, task_id: str = None) -> str:
|
||||
return cronjob(action="remove", job_id=job_id, task_id=task_id)
|
||||
|
||||
|
||||
CRONJOB_SCHEMA = {
|
||||
"name": "cronjob",
|
||||
|
||||
@@ -20,9 +20,7 @@ Both ``code_execution_tool.py`` and ``tools/environments/local.py`` consult
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -449,38 +449,6 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
|
||||
return tool_error(str(e))
|
||||
|
||||
|
||||
def get_read_files_summary(task_id: str = "default") -> list:
|
||||
"""Return a list of files read in this session for the given task.
|
||||
|
||||
Used by context compression to preserve file-read history across
|
||||
compression boundaries.
|
||||
"""
|
||||
with _read_tracker_lock:
|
||||
task_data = _read_tracker.get(task_id, {})
|
||||
read_history = task_data.get("read_history", set())
|
||||
seen_paths: dict = {}
|
||||
for (path, offset, limit) in read_history:
|
||||
if path not in seen_paths:
|
||||
seen_paths[path] = []
|
||||
seen_paths[path].append(f"lines {offset}-{offset + limit - 1}")
|
||||
return [
|
||||
{"path": p, "regions": regions}
|
||||
for p, regions in sorted(seen_paths.items())
|
||||
]
|
||||
|
||||
|
||||
def clear_read_tracker(task_id: str = None):
|
||||
"""Clear the read tracker.
|
||||
|
||||
Call with a task_id to clear just that task, or without to clear all.
|
||||
Should be called when a session is destroyed to prevent memory leaks
|
||||
in long-running gateway processes.
|
||||
"""
|
||||
with _read_tracker_lock:
|
||||
if task_id:
|
||||
_read_tracker.pop(task_id, None)
|
||||
else:
|
||||
_read_tracker.clear()
|
||||
|
||||
|
||||
def reset_file_dedup(task_id: str = None):
|
||||
@@ -719,12 +687,6 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
|
||||
return tool_error(str(e))
|
||||
|
||||
|
||||
FILE_TOOLS = [
|
||||
{"name": "read_file", "function": read_file_tool},
|
||||
{"name": "write_file", "function": write_file_tool},
|
||||
{"name": "patch", "function": patch_tool},
|
||||
{"name": "search_files", "function": search_tool}
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -61,7 +61,6 @@ ASPECT_RATIO_MAP = {
|
||||
"square": "square_hd",
|
||||
"portrait": "portrait_16_9"
|
||||
}
|
||||
VALID_ASPECT_RATIOS = list(ASPECT_RATIO_MAP.keys())
|
||||
|
||||
# Configuration for automatic upscaling
|
||||
UPSCALER_MODEL = "fal-ai/clarity-upscaler"
|
||||
@@ -564,15 +563,6 @@ def check_image_generation_requirements() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def get_debug_session_info() -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about the current debug session.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Dictionary containing debug session information
|
||||
"""
|
||||
return _debug.get_session_info()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""
|
||||
|
||||
@@ -162,6 +162,7 @@ if _MCP_AVAILABLE and not _MCP_MESSAGE_HANDLER_SUPPORTED:
|
||||
_DEFAULT_TOOL_TIMEOUT = 120 # seconds for tool calls
|
||||
_DEFAULT_CONNECT_TIMEOUT = 60 # seconds for initial connection per server
|
||||
_MAX_RECONNECT_RETRIES = 5
|
||||
_MAX_INITIAL_CONNECT_RETRIES = 3 # retries for the very first connection attempt
|
||||
_MAX_BACKOFF_SECONDS = 60
|
||||
|
||||
# Environment variables that are safe to pass to stdio subprocesses
|
||||
@@ -984,6 +985,7 @@ class MCPServerTask:
|
||||
self.name,
|
||||
)
|
||||
retries = 0
|
||||
initial_retries = 0
|
||||
backoff = 1.0
|
||||
|
||||
while True:
|
||||
@@ -997,11 +999,37 @@ class MCPServerTask:
|
||||
except Exception as exc:
|
||||
self.session = None
|
||||
|
||||
# If this is the first connection attempt, report the error
|
||||
# If this is the first connection attempt, retry with backoff
|
||||
# before giving up. A transient DNS/network blip at startup
|
||||
# should not permanently kill the server.
|
||||
# (Ported from Kilo Code's MCP resilience fix.)
|
||||
if not self._ready.is_set():
|
||||
self._error = exc
|
||||
self._ready.set()
|
||||
return
|
||||
initial_retries += 1
|
||||
if initial_retries > _MAX_INITIAL_CONNECT_RETRIES:
|
||||
logger.warning(
|
||||
"MCP server '%s' failed initial connection after "
|
||||
"%d attempts, giving up: %s",
|
||||
self.name, _MAX_INITIAL_CONNECT_RETRIES, exc,
|
||||
)
|
||||
self._error = exc
|
||||
self._ready.set()
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"MCP server '%s' initial connection failed "
|
||||
"(attempt %d/%d), retrying in %.0fs: %s",
|
||||
self.name, initial_retries,
|
||||
_MAX_INITIAL_CONNECT_RETRIES, backoff, exc,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, _MAX_BACKOFF_SECONDS)
|
||||
|
||||
# Check if shutdown was requested during the sleep
|
||||
if self._shutdown_event.is_set():
|
||||
self._error = exc
|
||||
self._ready.set()
|
||||
return
|
||||
continue
|
||||
|
||||
# If shutdown was requested, don't reconnect
|
||||
if self._shutdown_event.is_set():
|
||||
|
||||
@@ -44,11 +44,6 @@ def get_memory_dir() -> Path:
|
||||
"""Return the profile-scoped memories directory."""
|
||||
return get_hermes_home() / "memories"
|
||||
|
||||
# Backward-compatible alias — gateway/run.py imports this at runtime inside
|
||||
# a function body, so it gets the correct snapshot for that process. New code
|
||||
# should prefer get_memory_dir().
|
||||
MEMORY_DIR = get_memory_dir()
|
||||
|
||||
ENTRY_DELIMITER = "\n§\n"
|
||||
|
||||
|
||||
|
||||
@@ -416,29 +416,6 @@ def check_moa_requirements() -> bool:
|
||||
return check_openrouter_api_key()
|
||||
|
||||
|
||||
def get_debug_session_info() -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about the current debug session.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Dictionary containing debug session information
|
||||
"""
|
||||
return _debug.get_session_info()
|
||||
|
||||
|
||||
def get_available_models() -> Dict[str, List[str]]:
|
||||
"""
|
||||
Get information about available models for MoA processing.
|
||||
|
||||
Returns:
|
||||
Dict[str, List[str]]: Dictionary with reference and aggregator models
|
||||
"""
|
||||
return {
|
||||
"reference_models": REFERENCE_MODELS,
|
||||
"aggregator_models": [AGGREGATOR_MODEL],
|
||||
"supported_models": REFERENCE_MODELS + [AGGREGATOR_MODEL]
|
||||
}
|
||||
|
||||
|
||||
def get_moa_configuration() -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -872,55 +872,6 @@ def _unicode_char_name(char: str) -> str:
|
||||
return names.get(char, f"U+{ord(char):04X}")
|
||||
|
||||
|
||||
def _parse_llm_response(text: str, skill_name: str) -> List[Finding]:
|
||||
"""Parse the LLM's JSON response into Finding objects."""
|
||||
import json as json_mod
|
||||
|
||||
# Extract JSON from the response (handle markdown code blocks)
|
||||
text = text.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.split("\n")
|
||||
text = "\n".join(lines[1:-1] if lines[-1].startswith("```") else lines[1:])
|
||||
|
||||
try:
|
||||
data = json_mod.loads(text)
|
||||
except json_mod.JSONDecodeError:
|
||||
return []
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
|
||||
findings = []
|
||||
for item in data.get("findings", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
desc = item.get("description", "")
|
||||
severity = item.get("severity", "medium")
|
||||
if severity not in ("critical", "high", "medium", "low"):
|
||||
severity = "medium"
|
||||
if desc:
|
||||
findings.append(Finding(
|
||||
pattern_id="llm_audit",
|
||||
severity=severity,
|
||||
category="llm-detected",
|
||||
file="(LLM analysis)",
|
||||
line=0,
|
||||
match=desc[:120],
|
||||
description=f"LLM audit: {desc}",
|
||||
))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def _get_configured_model() -> str:
|
||||
"""Load the user's configured model from ~/.hermes/config.yaml."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
return config.get("model", "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
|
||||
@@ -447,10 +447,6 @@ def _get_category_from_path(skill_path: Path) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
# Token estimation — use the shared implementation from model_metadata.
|
||||
from agent.model_metadata import estimate_tokens_rough as _estimate_tokens
|
||||
|
||||
|
||||
def _parse_tags(tags_value) -> List[str]:
|
||||
"""
|
||||
Parse tags from frontmatter value.
|
||||
@@ -629,85 +625,6 @@ def _load_category_description(category_dir: Path) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def skills_categories(verbose: bool = False, task_id: str = None) -> str:
|
||||
"""
|
||||
List available skill categories with descriptions (progressive disclosure tier 0).
|
||||
|
||||
Returns category names and descriptions for efficient discovery before drilling down.
|
||||
Categories can have a DESCRIPTION.md file with a description frontmatter field
|
||||
or first paragraph to explain what skills are in that category.
|
||||
|
||||
Args:
|
||||
verbose: If True, include skill counts per category (default: False, but currently always included)
|
||||
task_id: Optional task identifier used to probe the active backend
|
||||
|
||||
Returns:
|
||||
JSON string with list of categories and their descriptions
|
||||
"""
|
||||
try:
|
||||
# Use module-level SKILLS_DIR (respects monkeypatching) + external dirs
|
||||
all_dirs = [SKILLS_DIR] if SKILLS_DIR.exists() else []
|
||||
try:
|
||||
from agent.skill_utils import get_external_skills_dirs
|
||||
all_dirs.extend(d for d in get_external_skills_dirs() if d.exists())
|
||||
except Exception:
|
||||
pass
|
||||
if not all_dirs:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"categories": [],
|
||||
"message": "No skills directory found.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
category_dirs = {}
|
||||
category_counts: Dict[str, int] = {}
|
||||
for scan_dir in all_dirs:
|
||||
for skill_md in scan_dir.rglob("SKILL.md"):
|
||||
if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts):
|
||||
continue
|
||||
|
||||
try:
|
||||
frontmatter, _ = _parse_frontmatter(
|
||||
skill_md.read_text(encoding="utf-8")[:4000]
|
||||
)
|
||||
except Exception:
|
||||
frontmatter = {}
|
||||
|
||||
if not skill_matches_platform(frontmatter):
|
||||
continue
|
||||
|
||||
category = _get_category_from_path(skill_md)
|
||||
if category:
|
||||
category_counts[category] = category_counts.get(category, 0) + 1
|
||||
if category not in category_dirs:
|
||||
category_dirs[category] = skill_md.parent.parent
|
||||
|
||||
categories = []
|
||||
for name in sorted(category_dirs.keys()):
|
||||
category_dir = category_dirs[name]
|
||||
description = _load_category_description(category_dir)
|
||||
|
||||
cat_entry = {"name": name, "skill_count": category_counts[name]}
|
||||
if description:
|
||||
cat_entry["description"] = description
|
||||
categories.append(cat_entry)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"categories": categories,
|
||||
"hint": "If a category is relevant to your task, use skills_list with that category to see available skills",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return tool_error(str(e), success=False)
|
||||
|
||||
|
||||
def skills_list(category: str = None, task_id: str = None) -> str:
|
||||
"""
|
||||
List all available skills (progressive disclosure tier 1 - minimal metadata).
|
||||
@@ -1240,19 +1157,6 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||
return tool_error(str(e), success=False)
|
||||
|
||||
|
||||
# Tool description for model_tools.py
|
||||
SKILLS_TOOL_DESCRIPTION = """Access skill documents providing specialized instructions, guidelines, and executable knowledge.
|
||||
|
||||
Progressive disclosure workflow:
|
||||
1. skills_list() - Returns metadata (name, description, tags, linked_file_count) for all skills
|
||||
2. skill_view(name) - Loads full SKILL.md content + shows available linked_files
|
||||
3. skill_view(name, file_path) - Loads specific linked file (e.g., 'references/api.md', 'scripts/train.py')
|
||||
|
||||
Skills may include:
|
||||
- references/: Additional documentation, API specs, examples
|
||||
- templates/: Output formats, config files, boilerplate code
|
||||
- assets/: Supplementary files (agentskills.io standard)
|
||||
- scripts/: Executable helpers (Python, shell scripts)"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -56,9 +56,6 @@ from tools.interrupt import is_interrupted, _interrupt_event # noqa: F401 — r
|
||||
# display_hermes_home imported lazily at call site (stale-module safety during hermes update)
|
||||
|
||||
|
||||
def ensure_minisweagent_on_path(_repo_root: Path | None = None) -> None:
|
||||
"""Backward-compatible no-op after minisweagent_path.py removal."""
|
||||
return
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -140,7 +137,6 @@ def set_approval_callback(cb):
|
||||
|
||||
# Dangerous command detection + approval now consolidated in tools/approval.py
|
||||
from tools.approval import (
|
||||
check_dangerous_command as _check_dangerous_command_impl,
|
||||
check_all_command_guards as _check_all_guards_impl,
|
||||
)
|
||||
|
||||
@@ -937,29 +933,6 @@ def is_persistent_env(task_id: str) -> bool:
|
||||
return bool(getattr(env, "_persistent", False))
|
||||
|
||||
|
||||
def get_active_environments_info() -> Dict[str, Any]:
|
||||
"""Get information about currently active environments."""
|
||||
info = {
|
||||
"count": len(_active_environments),
|
||||
"task_ids": list(_active_environments.keys()),
|
||||
"workdirs": {},
|
||||
}
|
||||
|
||||
# Calculate total disk usage (per-task to avoid double-counting)
|
||||
total_size = 0
|
||||
for task_id in _active_environments:
|
||||
scratch_dir = _get_scratch_dir()
|
||||
pattern = f"hermes-*{task_id[:8]}*"
|
||||
import glob
|
||||
for path in glob.glob(str(scratch_dir / pattern)):
|
||||
try:
|
||||
size = sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
|
||||
total_size += size
|
||||
except OSError as e:
|
||||
logger.debug("Could not stat path %s: %s", path, e)
|
||||
|
||||
info["total_disk_usage_mb"] = round(total_size / (1024 * 1024), 2)
|
||||
return info
|
||||
|
||||
|
||||
def cleanup_all_environments():
|
||||
|
||||
@@ -37,8 +37,6 @@ from utils import is_truthy_value
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -93,35 +91,6 @@ _local_model_name: Optional[str] = None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_stt_model_from_config() -> Optional[str]:
|
||||
"""Read the STT model name from ~/.hermes/config.yaml.
|
||||
|
||||
Provider-aware: reads from the correct provider-specific section
|
||||
(``stt.local.model``, ``stt.openai.model``, etc.). Falls back to
|
||||
the legacy flat ``stt.model`` key only for cloud providers — if the
|
||||
resolved provider is ``local`` the legacy key is ignored to prevent
|
||||
OpenAI model names (e.g. ``whisper-1``) from being fed to
|
||||
faster-whisper.
|
||||
|
||||
Silently returns ``None`` on any error (missing file, bad YAML, etc.).
|
||||
"""
|
||||
try:
|
||||
stt_cfg = _load_stt_config()
|
||||
provider = stt_cfg.get("provider", DEFAULT_PROVIDER)
|
||||
# Read from the provider-specific section first
|
||||
provider_model = stt_cfg.get(provider, {}).get("model")
|
||||
if provider_model:
|
||||
return provider_model
|
||||
# Legacy flat key — only honour for non-local providers to avoid
|
||||
# feeding OpenAI model names (whisper-1) to faster-whisper.
|
||||
if provider not in ("local", "local_command"):
|
||||
legacy = stt_cfg.get("model")
|
||||
if legacy:
|
||||
return legacy
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _load_stt_config() -> dict:
|
||||
"""Load the ``stt`` section from user config, falling back to defaults."""
|
||||
|
||||
@@ -689,15 +689,6 @@ def check_vision_requirements() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def get_debug_session_info() -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about the current debug session.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Dictionary containing debug session information
|
||||
"""
|
||||
return _debug.get_session_info()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""
|
||||
|
||||
@@ -63,11 +63,6 @@ def _termux_microphone_command() -> Optional[str]:
|
||||
return shutil.which("termux-microphone-record")
|
||||
|
||||
|
||||
def _termux_media_player_command() -> Optional[str]:
|
||||
if not _is_termux_environment():
|
||||
return None
|
||||
return shutil.which("termux-media-player")
|
||||
|
||||
|
||||
def _termux_api_app_installed() -> bool:
|
||||
if not _is_termux_environment():
|
||||
|
||||
@@ -1932,9 +1932,6 @@ def check_auxiliary_model() -> bool:
|
||||
return client is not None
|
||||
|
||||
|
||||
def get_debug_session_info() -> Dict[str, Any]:
|
||||
"""Get information about the current debug session."""
|
||||
return _debug.get_session_info()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user