Merge remote-tracking branch 'origin/main' into pr48275-rebase

# Conflicts:
#	cron/scheduler.py
This commit is contained in:
teknium1 2026-06-19 07:40:29 -07:00
commit a58287afcb
No known key found for this signature in database
162 changed files with 8521 additions and 634 deletions

View file

@ -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

View file

@ -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."
),
},
},

View file

@ -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

View file

@ -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()}

View file

@ -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

View file

@ -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(