mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
Merge remote-tracking branch 'origin/main' into pr48275-rebase
# Conflicts: # cron/scheduler.py
This commit is contained in:
commit
a58287afcb
162 changed files with 8521 additions and 634 deletions
|
|
@ -272,6 +272,28 @@ def _git_env(
|
|||
return env
|
||||
|
||||
|
||||
def _repair_bare_repo_dirs(store: Path) -> None:
|
||||
"""Recreate refs/ and branches/ dirs that ``git gc`` may have removed.
|
||||
|
||||
``git gc --prune=now`` on a bare repo with only packed refs can remove
|
||||
the empty ``refs/heads/`` directory. Git 2.34+ requires ``refs/`` (and
|
||||
some versions require ``branches/``) to exist even when all refs are
|
||||
packed in ``packed-refs``. Without them, ``git add -A`` returns
|
||||
``fatal: not a git repository`` and all checkpoint operations fail
|
||||
silently.
|
||||
"""
|
||||
for subdir in ("refs/heads", "branches"):
|
||||
path = store / subdir
|
||||
if not path.exists():
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug("Repaired missing %s in checkpoint store", subdir)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Cannot create %s in checkpoint store: %s", subdir, exc,
|
||||
)
|
||||
|
||||
|
||||
def _run_git(
|
||||
args: List[str],
|
||||
store: Path,
|
||||
|
|
@ -1086,6 +1108,7 @@ class CheckpointManager:
|
|||
["gc", "--prune=now", "--quiet"],
|
||||
store, working_dir, timeout=_GIT_TIMEOUT * 3,
|
||||
)
|
||||
_repair_bare_repo_dirs(store)
|
||||
|
||||
def _enforce_size_cap(self, store: Path) -> None:
|
||||
"""If total store size exceeds ``max_total_size_mb``, drop oldest
|
||||
|
|
@ -1173,6 +1196,7 @@ class CheckpointManager:
|
|||
["gc", "--prune=now", "--quiet"],
|
||||
store, str(store.parent), timeout=_GIT_TIMEOUT * 3,
|
||||
)
|
||||
_repair_bare_repo_dirs(store)
|
||||
|
||||
|
||||
def format_checkpoint_list(checkpoints: List[Dict], directory: str) -> str:
|
||||
|
|
@ -1384,6 +1408,7 @@ def prune_checkpoints(
|
|||
["gc", "--prune=now", "--quiet"],
|
||||
store, str(base), timeout=_GIT_TIMEOUT * 3,
|
||||
)
|
||||
_repair_bare_repo_dirs(store)
|
||||
|
||||
# Size-cap pass across remaining projects.
|
||||
if max_total_size_mb > 0:
|
||||
|
|
@ -1455,6 +1480,7 @@ def prune_checkpoints(
|
|||
["gc", "--prune=now", "--quiet"],
|
||||
store, str(base), timeout=_GIT_TIMEOUT * 3,
|
||||
)
|
||||
_repair_bare_repo_dirs(store)
|
||||
|
||||
size_after = _dir_size_bytes(base)
|
||||
delta = size_before - size_after
|
||||
|
|
|
|||
|
|
@ -20,6 +20,39 @@ from typing import List, Optional, Callable
|
|||
MAX_CHOICES = 4
|
||||
|
||||
|
||||
def _flatten_choice(c) -> str:
|
||||
"""Coerce a single choice into its user-facing display string.
|
||||
|
||||
The schema declares choices as bare strings, but LLMs sometimes emit
|
||||
dict-shaped choices like ``[{"description": "..."}]``. A naive ``str(c)``
|
||||
turns the whole dict into its Python repr — ``{'description': '...'}`` —
|
||||
which then leaks onto every surface that renders the choice (CLI panel,
|
||||
Discord buttons, Telegram numbered list) AND is returned verbatim as the
|
||||
user's answer. Normalising here, at the one platform-agnostic entry point,
|
||||
fixes the whole class in one place instead of per-adapter.
|
||||
|
||||
Dict unwrap order is the canonical LLM tool-call user-facing keys:
|
||||
``label`` → ``description`` → ``text`` → ``title``. ``name`` and ``value``
|
||||
are deliberately excluded — they're component-shaped fields that could
|
||||
carry raw enum values or short identifiers, not human-readable labels. A
|
||||
dict with none of the canonical keys is dropped (returns ""), since a
|
||||
garbage label is worse than no choice at all.
|
||||
"""
|
||||
if c is None:
|
||||
return ""
|
||||
if isinstance(c, str):
|
||||
return c.strip()
|
||||
if isinstance(c, dict):
|
||||
for key in ("label", "description", "text", "title"):
|
||||
v = c.get(key)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return ""
|
||||
if isinstance(c, (list, tuple)):
|
||||
return " ".join(_flatten_choice(x) for x in c).strip()
|
||||
return str(c).strip()
|
||||
|
||||
|
||||
def clarify_tool(
|
||||
question: str,
|
||||
choices: Optional[List[str]] = None,
|
||||
|
|
@ -48,7 +81,12 @@ def clarify_tool(
|
|||
if choices is not None:
|
||||
if not isinstance(choices, list):
|
||||
return tool_error("choices must be a list of strings.")
|
||||
choices = [str(c).strip() for c in choices if str(c).strip()]
|
||||
# LLMs sometimes emit dict-shaped choices (e.g. [{"description": "..."}])
|
||||
# instead of bare strings. _flatten_choice unwraps them to their
|
||||
# user-facing text here — the single platform-agnostic entry point —
|
||||
# so the CLI panel, Discord buttons, and Telegram list all render clean
|
||||
# text and the resolved answer is never a raw Python dict repr.
|
||||
choices = [s for s in (_flatten_choice(c) for c in choices) if s]
|
||||
if len(choices) > MAX_CHOICES:
|
||||
choices = choices[:MAX_CHOICES]
|
||||
if not choices:
|
||||
|
|
@ -93,6 +131,12 @@ CLARIFY_SCHEMA = {
|
|||
"or types their own answer via a 5th 'Other' option.\n"
|
||||
"2. **Open-ended** — omit choices entirely. The user types a free-form "
|
||||
"response.\n\n"
|
||||
"CRITICAL: when you are offering options, put each option ONLY in the "
|
||||
"`choices` array — NEVER enumerate the options inside the `question` "
|
||||
"text. The UI renders `choices` as selectable rows; options written "
|
||||
"into the question string render as dead prose the user can't pick. "
|
||||
"Right: question='Which deployment target?', choices=['staging', "
|
||||
"'prod']. Wrong: question='Which target? 1) staging 2) prod', choices=[].\n\n"
|
||||
"Use this tool when:\n"
|
||||
"- The task is ambiguous and you need the user to choose an approach\n"
|
||||
"- You want post-task feedback ('How did that work out?')\n"
|
||||
|
|
@ -107,16 +151,22 @@ CLARIFY_SCHEMA = {
|
|||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "The question to present to the user.",
|
||||
"description": (
|
||||
"The question itself, and ONLY the question (e.g. 'Which "
|
||||
"deployment target?'). Do NOT embed the answer options here "
|
||||
"— pass them as separate elements in `choices`."
|
||||
),
|
||||
},
|
||||
"choices": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": MAX_CHOICES,
|
||||
"description": (
|
||||
"Up to 4 answer choices. Omit this parameter entirely to "
|
||||
"ask an open-ended question. When provided, the UI "
|
||||
"automatically appends an 'Other (type your answer)' option."
|
||||
"REQUIRED whenever you are presenting selectable options: "
|
||||
"each distinct option is its own array element (up to 4). "
|
||||
"The UI renders these as pickable rows and auto-appends an "
|
||||
"'Other (type your answer)' option. Omit this parameter "
|
||||
"entirely ONLY for a genuinely open-ended free-text question."
|
||||
),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
|||
"fastapi==0.133.1",
|
||||
"uvicorn[standard]==0.41.0",
|
||||
"starlette==1.0.1", # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web]
|
||||
"python-multipart==0.0.20", # FastAPI UploadFile/Form for streaming uploads (NS-501)
|
||||
"python-multipart==0.0.27", # FastAPI UploadFile/Form for streaming uploads (NS-501)
|
||||
),
|
||||
# Vision image-resize recovery (Pillow). Pillow is now a CORE dependency
|
||||
# (pyproject `dependencies`), so this entry is a belt-and-suspenders fallback
|
||||
|
|
|
|||
|
|
@ -2662,10 +2662,19 @@ def _interrupted_call_result() -> str:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _interpolate_env_vars(value):
|
||||
"""Recursively resolve ``${VAR}`` placeholders from ``os.environ``."""
|
||||
"""Recursively resolve ``${VAR}`` placeholders.
|
||||
|
||||
Resolves from the active profile's secret scope when multiplexing is on
|
||||
(so an MCP server config's ``${API_KEY}`` picks up the routed profile's
|
||||
value, not the process-global ``os.environ`` which may hold another
|
||||
profile's), falling back to ``os.environ`` otherwise. Unset vars keep the
|
||||
literal ``${VAR}`` placeholder, as before.
|
||||
"""
|
||||
from agent.secret_scope import get_secret as _get_secret
|
||||
|
||||
if isinstance(value, str):
|
||||
def _replace(m):
|
||||
return os.environ.get(m.group(1), m.group(0))
|
||||
return _get_secret(m.group(1), m.group(0)) or m.group(0)
|
||||
return _ENV_VAR_PATTERN.sub(_replace, value)
|
||||
if isinstance(value, dict):
|
||||
return {k: _interpolate_env_vars(v) for k, v in value.items()}
|
||||
|
|
|
|||
|
|
@ -2058,6 +2058,29 @@ def terminal_tool(
|
|||
env = new_env
|
||||
logger.info("%s environment ready for task %s", env_type, effective_task_id[:8])
|
||||
|
||||
# Hard-block: gateway lifecycle commands (systemctl/launchctl/hermes
|
||||
# restart|stop targeting hermes-gateway) must never run inside the
|
||||
# gateway process itself. The restart would SIGTERM the gateway, which
|
||||
# kills this very subprocess before it can complete — the service may
|
||||
# never restart. This mirrors the `hermes gateway restart` guard in
|
||||
# hermes_cli/gateway.py and the cron-path guard in hermes_cli/cron.py,
|
||||
# but applies unconditionally (force=True cannot help here).
|
||||
if os.environ.get("_HERMES_GATEWAY") == "1":
|
||||
from hermes_cli.cron import _contains_gateway_lifecycle_command
|
||||
if _contains_gateway_lifecycle_command(command):
|
||||
return json.dumps({
|
||||
"output": "",
|
||||
"exit_code": 1,
|
||||
"error": (
|
||||
"Blocked: cannot restart or stop the gateway from inside the "
|
||||
"gateway process. The gateway would kill this command before "
|
||||
"it could complete (SIGTERM propagates to child processes). "
|
||||
"Run `hermes gateway restart` from a separate shell outside "
|
||||
"the running gateway."
|
||||
),
|
||||
"status": "error",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# Pre-exec security checks (tirith + dangerous command detection)
|
||||
# Skip check if force=True (user has confirmed they want to run it)
|
||||
approval_note = None
|
||||
|
|
|
|||
|
|
@ -187,6 +187,13 @@ DEFAULT_XAI_SAMPLE_RATE = 24000
|
|||
DEFAULT_XAI_BIT_RATE = 128000
|
||||
DEFAULT_XAI_AUTO_SPEECH_TAGS = False
|
||||
DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
|
||||
# xAI TTS `speed` accepts 0.7..1.5; 1.0 is the API default (omitted => default).
|
||||
DEFAULT_XAI_SPEED_MIN = 0.7
|
||||
DEFAULT_XAI_SPEED_MAX = 1.5
|
||||
DEFAULT_XAI_SPEED_DEFAULT = 1.0
|
||||
# xAI TTS `optimize_streaming_latency` accepts 0, 1, or 2; 0 (best quality) is
|
||||
# the API default (omitted => default). Values >0 trade quality for time-to-first-audio.
|
||||
DEFAULT_XAI_OPTIMIZE_STREAMING_LATENCY_DEFAULT = 0
|
||||
DEFAULT_GEMINI_TTS_MODEL = "gemini-2.5-flash-preview-tts"
|
||||
DEFAULT_GEMINI_TTS_VOICE = "Kore"
|
||||
DEFAULT_GEMINI_TTS_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
|
@ -1092,22 +1099,71 @@ def _xai_bool_config(value: Any, default: bool = False) -> bool:
|
|||
|
||||
|
||||
def _apply_xai_auto_speech_tags(text: str) -> str:
|
||||
"""Add light xAI speech tags for more natural voice-mode replies.
|
||||
"""Add xAI speech tags for more natural voice-mode replies.
|
||||
|
||||
The transform is intentionally conservative: it only inserts pauses. It
|
||||
never fabricates laughter or whispering, and it leaves explicit user/model
|
||||
speech tags untouched.
|
||||
First applies a conservative local transform (inserts [pause] between
|
||||
paragraphs and after the first sentence). Then, if the result contains
|
||||
no explicit user/model speech tags, asks the configured auxiliary model
|
||||
to rewrite the transcript with a richer set of xAI-supported tags
|
||||
(laughs, sighs, whispers, soft/loud, slow/fast, etc.) so the voice
|
||||
output sounds more expressive. Falls back to the local result on any
|
||||
auxiliary-model failure.
|
||||
"""
|
||||
clean = text.strip()
|
||||
if not clean or _XAI_SPEECH_TAG_RE.search(clean):
|
||||
if not clean:
|
||||
return text
|
||||
|
||||
clean = re.sub(r"\n\s*\n+", " [pause] ", clean)
|
||||
clean = re.sub(r"\s*\n\s*", " ", clean)
|
||||
if not _XAI_SPEECH_TAG_RE.search(clean):
|
||||
clean = _XAI_FIRST_SENTENCE_RE.sub(r"\1 [pause] ", clean, count=1)
|
||||
clean = re.sub(r"\s{2,}", " ", clean).strip()
|
||||
return clean
|
||||
# Local conservative pass: pauses only.
|
||||
local = clean
|
||||
local = re.sub(r"\n\s*\n+", " [pause] ", local)
|
||||
local = re.sub(r"\s*\n\s*", " ", local)
|
||||
if not _XAI_SPEECH_TAG_RE.search(local):
|
||||
local = _XAI_FIRST_SENTENCE_RE.sub(r"\1 [pause] ", local, count=1)
|
||||
local = re.sub(r"\s{2,}", " ", local).strip()
|
||||
|
||||
# If the user/model already supplied explicit speech tags, trust them
|
||||
# and don't re-rewrite.
|
||||
if _XAI_SPEECH_TAG_RE.search(clean):
|
||||
return local
|
||||
|
||||
# Auxiliary rewrite for richer emotion tags (mirrors the Gemini path).
|
||||
inline = ", ".join(_XAI_INLINE_SPEECH_TAGS)
|
||||
wrapping = ", ".join(_XAI_WRAPPING_SPEECH_TAGS)
|
||||
system_prompt = (
|
||||
"You rewrite transcripts for the xAI /v1/tts endpoint by inserting "
|
||||
"expressive speech tags.\n\n"
|
||||
"Valid inline tags (use as `[tag]`): " + inline + ".\n"
|
||||
"Valid wrapping tags (use as `[tag]...[/tag]`): " + wrapping + ".\n\n"
|
||||
"Rules:\n"
|
||||
"- Preserve the spoken words, order, and meaning.\n"
|
||||
"- Do not add new spoken sentences or remove existing spoken words.\n"
|
||||
"- Use inline `[tag]` for short modifiers (laughs, sighs, pause, etc.).\n"
|
||||
"- Use wrapping `[tag]...[/tag]` for sustained effects (whisper, soft, slow, fast, loud, etc.).\n"
|
||||
"- Do not use angle-bracket tags like `<tag>...</tag>` — xAI uses BBCode-style closing tags with `[/tag]`.\n"
|
||||
"- Do not use SSML.\n"
|
||||
"- Do not explain or comment.\n"
|
||||
"- Return only the tagged TTS script."
|
||||
)
|
||||
try:
|
||||
from agent.auxiliary_client import call_llm
|
||||
|
||||
response = call_llm(
|
||||
task="tts_audio_tags",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"TRANSCRIPT TO TAG:\n{local}"},
|
||||
],
|
||||
temperature=0.7,
|
||||
)
|
||||
tagged = _extract_auxiliary_message_content(response).strip()
|
||||
# Strip markdown fences if the LLM wrapped the response.
|
||||
fence = re.fullmatch(r"```(?:[A-Za-z0-9_-]+)?\s*(.*?)\s*```", tagged, flags=re.DOTALL)
|
||||
if fence:
|
||||
tagged = fence.group(1).strip()
|
||||
return tagged or local
|
||||
except Exception as exc:
|
||||
logger.debug("xAI TTS audio tag rewrite failed; using locally-tagged text: %s", exc)
|
||||
return local
|
||||
|
||||
|
||||
def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
||||
|
|
@ -1135,6 +1191,31 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
|
|||
xai_config.get("auto_speech_tags", xai_config.get("speech_tags")),
|
||||
DEFAULT_XAI_AUTO_SPEECH_TAGS,
|
||||
)
|
||||
# ``tts.xai.speed`` overrides global ``tts.speed``; the xAI TTS API
|
||||
# accepts 0.7..1.5 (1.0 = normal). Out-of-range values are clamped so a
|
||||
# misconfigured agent can't 400 the request — the API would reject
|
||||
# anything outside the band.
|
||||
speed = xai_config.get("speed", tts_config.get("speed"))
|
||||
if speed is not None and speed != "":
|
||||
try:
|
||||
speed = float(speed)
|
||||
except (TypeError, ValueError):
|
||||
speed = None
|
||||
if speed is not None:
|
||||
speed = max(DEFAULT_XAI_SPEED_MIN, min(DEFAULT_XAI_SPEED_MAX, speed))
|
||||
# ``tts.xai.optimize_streaming_latency`` is 0, 1, or 2 (xAI-specific;
|
||||
# trades chunk-boundary quality for time-to-first-audio).
|
||||
optimize_streaming_latency = xai_config.get(
|
||||
"optimize_streaming_latency",
|
||||
tts_config.get("optimize_streaming_latency"),
|
||||
)
|
||||
if optimize_streaming_latency is not None and optimize_streaming_latency != "":
|
||||
try:
|
||||
optimize_streaming_latency = int(optimize_streaming_latency)
|
||||
except (TypeError, ValueError):
|
||||
optimize_streaming_latency = None
|
||||
if optimize_streaming_latency is not None:
|
||||
optimize_streaming_latency = max(0, min(2, optimize_streaming_latency))
|
||||
if auto_speech_tags:
|
||||
text = _apply_xai_auto_speech_tags(text)
|
||||
base_url = str(
|
||||
|
|
@ -1163,6 +1244,18 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
|
|||
if codec == "mp3" and bit_rate:
|
||||
output_format["bit_rate"] = bit_rate
|
||||
payload["output_format"] = output_format
|
||||
# Only attach `speed` when the caller asked for something other than the
|
||||
# API default (1.0). Keeps the existing minimal-payload contract for
|
||||
# users who never touch the knob.
|
||||
if speed is not None and speed != DEFAULT_XAI_SPEED_DEFAULT:
|
||||
payload["speed"] = speed
|
||||
# Only attach `optimize_streaming_latency` when the caller explicitly
|
||||
# opts in to a non-default value (anything other than 0).
|
||||
if (
|
||||
optimize_streaming_latency is not None
|
||||
and optimize_streaming_latency != DEFAULT_XAI_OPTIMIZE_STREAMING_LATENCY_DEFAULT
|
||||
):
|
||||
payload["optimize_streaming_latency"] = optimize_streaming_latency
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/tts",
|
||||
|
|
@ -1889,6 +1982,18 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
|
|||
|
||||
model_path = _resolve_piper_voice_path(voice_name, download_dir)
|
||||
|
||||
# Tolerant speaker_id parse: drop bad input (non-int strings, lists, dicts)
|
||||
# to 0 (Piper's own default). Booleans are rejected outright — True/False
|
||||
# would silently coerce to 1/0 and hide a config mistake.
|
||||
_raw_speaker = piper_config.get("speaker_id", 0)
|
||||
if isinstance(_raw_speaker, bool) or not isinstance(_raw_speaker, int):
|
||||
speaker_id = 0
|
||||
else:
|
||||
speaker_id = _raw_speaker
|
||||
|
||||
# speaker_id is applied per-call via syn_config.speaker_id — the same
|
||||
# PiperVoice instance serves all speakers, so it stays out of the cache
|
||||
# key. Multi-speaker workflows share one model load.
|
||||
cache_key = f"{model_path}::cuda={use_cuda}"
|
||||
global _piper_voice_cache
|
||||
if cache_key not in _piper_voice_cache:
|
||||
|
|
@ -1903,7 +2008,14 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
|
|||
syn_config = None
|
||||
has_advanced = any(
|
||||
k in piper_config
|
||||
for k in ("length_scale", "noise_scale", "noise_w_scale", "volume", "normalize_audio")
|
||||
for k in (
|
||||
"length_scale",
|
||||
"noise_scale",
|
||||
"noise_w_scale",
|
||||
"volume",
|
||||
"normalize_audio",
|
||||
"speaker_id",
|
||||
)
|
||||
)
|
||||
if has_advanced:
|
||||
try:
|
||||
|
|
@ -1914,6 +2026,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
|
|||
noise_w_scale=float(piper_config.get("noise_w_scale", 0.8)),
|
||||
volume=float(piper_config.get("volume", 1.0)),
|
||||
normalize_audio=bool(piper_config.get("normalize_audio", True)),
|
||||
speaker_id=speaker_id,
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue