Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
+54
-3
@@ -245,6 +245,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
if job.get("script"):
|
||||
result["script"] = job["script"]
|
||||
if job.get("no_agent"):
|
||||
result["no_agent"] = True
|
||||
if job.get("enabled_toolsets"):
|
||||
result["enabled_toolsets"] = job["enabled_toolsets"]
|
||||
if job.get("workdir"):
|
||||
@@ -271,6 +273,7 @@ def cronjob(
|
||||
context_from: Optional[Union[str, List[str]]] = None,
|
||||
enabled_toolsets: Optional[List[str]] = None,
|
||||
workdir: Optional[str] = None,
|
||||
no_agent: Optional[bool] = None,
|
||||
task_id: str = None,
|
||||
) -> str:
|
||||
"""Unified cron job management tool."""
|
||||
@@ -283,8 +286,22 @@ def cronjob(
|
||||
if not schedule:
|
||||
return tool_error("schedule is required for create", success=False)
|
||||
canonical_skills = _canonical_skills(skill, skills)
|
||||
if not prompt and not canonical_skills:
|
||||
return tool_error("create requires either prompt or at least one skill", success=False)
|
||||
_no_agent = bool(no_agent)
|
||||
# Job-shape validation differs by mode:
|
||||
# - no_agent=True → script is the job; prompt/skills are optional
|
||||
# (and irrelevant to execution).
|
||||
# - no_agent=False (default) → at least one of prompt/skills must
|
||||
# be set, same as before.
|
||||
if _no_agent:
|
||||
if not script:
|
||||
return tool_error(
|
||||
"create with no_agent=True requires a script — "
|
||||
"the script is the job.",
|
||||
success=False,
|
||||
)
|
||||
else:
|
||||
if not prompt and not canonical_skills:
|
||||
return tool_error("create requires either prompt or at least one skill", success=False)
|
||||
if prompt:
|
||||
scan_error = _scan_cron_prompt(prompt)
|
||||
if scan_error:
|
||||
@@ -323,6 +340,7 @@ def cronjob(
|
||||
context_from=context_from,
|
||||
enabled_toolsets=enabled_toolsets or None,
|
||||
workdir=_normalize_optional_job_value(workdir),
|
||||
no_agent=_no_agent,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
@@ -436,6 +454,20 @@ def cronjob(
|
||||
# Empty string clears the field (restores old behaviour);
|
||||
# otherwise pass raw — update_job() validates / normalizes.
|
||||
updates["workdir"] = _normalize_optional_job_value(workdir) or None
|
||||
if no_agent is not None:
|
||||
# Toggling no_agent on/off at update time. If flipping to True,
|
||||
# we need a script to already exist on the job (or be part of
|
||||
# the same update) — otherwise the next tick would error out.
|
||||
target_no_agent = bool(no_agent)
|
||||
if target_no_agent:
|
||||
effective_script = updates.get("script") if "script" in updates else job.get("script")
|
||||
if not effective_script:
|
||||
return tool_error(
|
||||
"Cannot set no_agent=True on a job without a script. "
|
||||
"Set `script` in the same update, or on the job first.",
|
||||
success=False,
|
||||
)
|
||||
updates["no_agent"] = target_no_agent
|
||||
if repeat is not None:
|
||||
# Normalize: treat 0 or negative as None (infinite)
|
||||
normalized_repeat = None if repeat <= 0 else repeat
|
||||
@@ -533,7 +565,25 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
|
||||
},
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": f"Optional path to a Python script that runs before each cron job execution. Its stdout is injected into the prompt as context. Use for data collection and change detection. Relative paths resolve under {display_hermes_home()}/scripts/. On update, pass empty string to clear."
|
||||
"description": f"Optional path to a script that runs each tick. In the default mode its stdout is injected into the agent's prompt as context (data-collection / change-detection pattern). With no_agent=True, the script IS the job and its stdout is delivered verbatim (classic watchdog pattern). Relative paths resolve under {display_hermes_home()}/scripts/. ``.sh``/``.bash`` extensions run via bash, everything else via Python. On update, pass empty string to clear."
|
||||
},
|
||||
"no_agent": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Default: False (LLM-driven job — the agent runs the prompt each tick). "
|
||||
"Set True to skip the LLM entirely: the scheduler just runs ``script`` on schedule and delivers its stdout verbatim. No tokens, no agent loop, no model override honoured. "
|
||||
"\n\n"
|
||||
"REQUIREMENTS when True: ``script`` MUST be set (``prompt`` and ``skills`` are ignored). "
|
||||
"\n\n"
|
||||
"DELIVERY SEMANTICS when True: "
|
||||
"(a) non-empty stdout is sent verbatim as the message; "
|
||||
"(b) EMPTY stdout means SILENT — nothing is sent to the user and they won't see anything happened, so design your script to stay quiet when there's nothing to report (the watchdog pattern); "
|
||||
"(c) non-zero exit / timeout sends an error alert so a broken watchdog can't fail silently. "
|
||||
"\n\n"
|
||||
"WHEN TO USE True: recurring script-only pings where the script itself produces the exact message text (memory/disk/GPU watchdogs, threshold alerts, heartbeats, CI notifications, API pollers with a fixed output shape). "
|
||||
"WHEN TO USE False (default): anything that needs reasoning — summarize a feed, draft a daily briefing, pick interesting items, rephrase data for a human, follow conditional logic based on content."
|
||||
),
|
||||
},
|
||||
"context_from": {
|
||||
"type": "array",
|
||||
@@ -604,6 +654,7 @@ registry.register(
|
||||
context_from=args.get("context_from"),
|
||||
enabled_toolsets=args.get("enabled_toolsets"),
|
||||
workdir=args.get("workdir"),
|
||||
no_agent=args.get("no_agent"),
|
||||
task_id=kw.get("task_id"),
|
||||
))(),
|
||||
check_fn=check_cronjob_requirements,
|
||||
|
||||
+71
-17
@@ -215,6 +215,31 @@ class ExecuteResult:
|
||||
exit_code: int = 0
|
||||
|
||||
|
||||
def _parse_search_context_line(line: str) -> tuple[str, int, str] | None:
|
||||
"""Parse grep/rg context output in ``path-line-content`` format.
|
||||
|
||||
Context lines are ambiguous because filenames may legitimately contain
|
||||
``-<digits>-`` segments. Prefer the rightmost numeric separator so a path
|
||||
like ``dir/file-12-name.py-8-context`` resolves to
|
||||
``dir/file-12-name.py`` line ``8`` instead of truncating at ``file``.
|
||||
"""
|
||||
if not line or line == "--":
|
||||
return None
|
||||
|
||||
match = None
|
||||
for candidate in re.finditer(r'-(\d+)-', line):
|
||||
match = candidate
|
||||
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
path = line[:match.start()]
|
||||
if not path:
|
||||
return None
|
||||
|
||||
return path, int(match.group(1)), line[match.end():]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Abstract Interface
|
||||
# =============================================================================
|
||||
@@ -987,6 +1012,12 @@ class ShellFileOperations(FileOperations):
|
||||
else:
|
||||
search_pattern = pattern.split('/')[-1]
|
||||
|
||||
search_root = Path(path)
|
||||
has_hidden_path_ancestor = any(
|
||||
part not in (".", "..") and part.startswith(".")
|
||||
for part in search_root.parts
|
||||
)
|
||||
|
||||
# Prefer ripgrep: respects .gitignore, excludes hidden dirs by
|
||||
# default, and has parallel directory traversal (~200x faster than
|
||||
# find on wide trees). Mirrors _search_content which already uses rg.
|
||||
@@ -1002,17 +1033,25 @@ class ShellFileOperations(FileOperations):
|
||||
)
|
||||
|
||||
# Exclude hidden directories (matching ripgrep's default behavior).
|
||||
hidden_exclude = "-not -path '*/.*'"
|
||||
hidden_exclude = "-not -path '*/.*'" if not has_hidden_path_ancestor else ""
|
||||
hidden_filter_expr = f" {hidden_exclude}" if hidden_exclude else ""
|
||||
|
||||
cmd = f"find {self._escape_shell_arg(path)} {hidden_exclude} -type f -name {self._escape_shell_arg(search_pattern)} " \
|
||||
f"-printf '%T@ %p\\n' 2>/dev/null | sort -rn | tail -n +{offset + 1} | head -n {limit}"
|
||||
# Use shell pagination for standard roots. For hidden roots, gather full
|
||||
# output so we can re-apply hidden-descendant filtering while allowing
|
||||
# explicit hidden-root searches.
|
||||
pagination_expr = ""
|
||||
if not has_hidden_path_ancestor:
|
||||
pagination_expr = f" | tail -n +{offset + 1} | head -n {limit}"
|
||||
|
||||
cmd = f"find {self._escape_shell_arg(path)}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \
|
||||
f"-printf '%T@ %p\\n' 2>/dev/null | sort -rn{pagination_expr}"
|
||||
|
||||
result = self._exec(cmd, timeout=60)
|
||||
|
||||
if not result.stdout.strip():
|
||||
# Try without -printf (BSD find compatibility -- macOS)
|
||||
cmd_simple = f"find {self._escape_shell_arg(path)} {hidden_exclude} -type f -name {self._escape_shell_arg(search_pattern)} " \
|
||||
f"2>/dev/null | head -n {limit + offset} | tail -n +{offset + 1}"
|
||||
cmd_simple = f"find {self._escape_shell_arg(path)}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \
|
||||
f"2>/dev/null | sort -rn{pagination_expr}"
|
||||
result = self._exec(cmd_simple, timeout=60)
|
||||
|
||||
files = []
|
||||
@@ -1025,6 +1064,23 @@ class ShellFileOperations(FileOperations):
|
||||
else:
|
||||
files.append(line)
|
||||
|
||||
# For explicit hidden roots, find's path-based filtering excludes every
|
||||
# file under the hidden path. Apply descendant filtering after command
|
||||
# execution so only the explicit root ancestry is bypassed.
|
||||
if has_hidden_path_ancestor:
|
||||
normalized_root = search_root.resolve()
|
||||
filtered_files = []
|
||||
for file_path in files:
|
||||
try:
|
||||
rel_parts = Path(file_path).resolve().relative_to(normalized_root).parts
|
||||
except ValueError:
|
||||
rel_parts = Path(file_path).parts
|
||||
if any(part not in (".", "..") and part.startswith(".") for part in rel_parts):
|
||||
continue
|
||||
filtered_files.append(file_path)
|
||||
files = filtered_files[offset:offset + limit]
|
||||
# pagination for standard roots is already applied in shell
|
||||
|
||||
return SearchResult(
|
||||
files=files,
|
||||
total_count=len(files)
|
||||
@@ -1154,7 +1210,6 @@ class ShellFileOperations(FileOperations):
|
||||
# Note: on Windows, paths contain drive letters (e.g. C:\path),
|
||||
# so naive split(":") breaks. Use regex to handle both platforms.
|
||||
_match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$')
|
||||
_ctx_re = re.compile(r'^([A-Za-z]:)?(.*?)-(\d+)-(.*)$')
|
||||
matches = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if not line or line == "--":
|
||||
@@ -1173,12 +1228,12 @@ class ShellFileOperations(FileOperations):
|
||||
# Try context line (dash-separated: file-line-content)
|
||||
# Only attempt if context was requested to avoid false positives
|
||||
if context > 0:
|
||||
m = _ctx_re.match(line)
|
||||
if m:
|
||||
parsed = _parse_search_context_line(line)
|
||||
if parsed:
|
||||
matches.append(SearchMatch(
|
||||
path=(m.group(1) or '') + m.group(2),
|
||||
line_number=int(m.group(3)),
|
||||
content=m.group(4)[:500]
|
||||
path=parsed[0],
|
||||
line_number=parsed[1],
|
||||
content=parsed[2][:500]
|
||||
))
|
||||
|
||||
total = len(matches)
|
||||
@@ -1253,7 +1308,6 @@ class ShellFileOperations(FileOperations):
|
||||
# Note: on Windows, paths contain drive letters (e.g. C:\path),
|
||||
# so naive split(":") breaks. Use regex to handle both platforms.
|
||||
_match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$')
|
||||
_ctx_re = re.compile(r'^([A-Za-z]:)?(.*?)-(\d+)-(.*)$')
|
||||
matches = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if not line or line == "--":
|
||||
@@ -1269,12 +1323,12 @@ class ShellFileOperations(FileOperations):
|
||||
continue
|
||||
|
||||
if context > 0:
|
||||
m = _ctx_re.match(line)
|
||||
if m:
|
||||
parsed = _parse_search_context_line(line)
|
||||
if parsed:
|
||||
matches.append(SearchMatch(
|
||||
path=(m.group(1) or '') + m.group(2),
|
||||
line_number=int(m.group(3)),
|
||||
content=m.group(4)[:500]
|
||||
path=parsed[0],
|
||||
line_number=parsed[1],
|
||||
content=parsed[2][:500]
|
||||
))
|
||||
|
||||
|
||||
|
||||
+27
-33
@@ -136,9 +136,9 @@ DEFAULT_KITTENTTS_VOICE = "Jasper"
|
||||
DEFAULT_PIPER_VOICE = "en_US-lessac-medium" # balanced size/quality
|
||||
DEFAULT_OPENAI_VOICE = "alloy"
|
||||
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
DEFAULT_MINIMAX_MODEL = "speech-2.8-hd"
|
||||
DEFAULT_MINIMAX_VOICE_ID = "English_Graceful_Lady"
|
||||
DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1/t2a_v2"
|
||||
DEFAULT_MINIMAX_MODEL = "speech-01"
|
||||
DEFAULT_MINIMAX_VOICE_ID = "female-shaonv"
|
||||
DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.chat/v1/text_to_speech"
|
||||
DEFAULT_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603"
|
||||
DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral
|
||||
DEFAULT_XAI_VOICE_ID = "eve"
|
||||
@@ -925,10 +925,11 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
|
||||
# ===========================================================================
|
||||
def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Generate audio using MiniMax TTS API.
|
||||
Generate audio using MiniMax TTS API (v1/text_to_speech).
|
||||
|
||||
MiniMax returns hex-encoded audio data. Supports streaming (SSE) and
|
||||
non-streaming modes. This implementation uses non-streaming for simplicity.
|
||||
The current API (api.minimax.chat/v1/text_to_speech) uses a simple payload
|
||||
and returns raw audio bytes directly (Content-Type: audio/mpeg), unlike
|
||||
the deprecated v1/t2a_v2 endpoint which returned JSON with hex-encoded audio.
|
||||
|
||||
Args:
|
||||
text: Text to convert (max 10,000 characters).
|
||||
@@ -947,35 +948,12 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
|
||||
mm_config = tts_config.get("minimax", {})
|
||||
model = mm_config.get("model", DEFAULT_MINIMAX_MODEL)
|
||||
voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID)
|
||||
speed = mm_config.get("speed", tts_config.get("speed", 1))
|
||||
vol = mm_config.get("vol", 1)
|
||||
pitch = mm_config.get("pitch", 0)
|
||||
base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL)
|
||||
|
||||
# Determine audio format from output extension
|
||||
if output_path.endswith(".wav"):
|
||||
audio_format = "wav"
|
||||
elif output_path.endswith(".flac"):
|
||||
audio_format = "flac"
|
||||
else:
|
||||
audio_format = "mp3"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"text": text,
|
||||
"stream": False,
|
||||
"voice_setting": {
|
||||
"voice_id": voice_id,
|
||||
"speed": speed,
|
||||
"vol": vol,
|
||||
"pitch": pitch,
|
||||
},
|
||||
"audio_setting": {
|
||||
"sample_rate": 32000,
|
||||
"bitrate": 128000,
|
||||
"format": audio_format,
|
||||
"channel": 1,
|
||||
},
|
||||
"voice_id": voice_id,
|
||||
}
|
||||
|
||||
headers = {
|
||||
@@ -984,9 +962,25 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
|
||||
}
|
||||
|
||||
response = requests.post(base_url, json=payload, headers=headers, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
if "audio/" in content_type:
|
||||
# New API: returns raw audio directly
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
return output_path
|
||||
|
||||
# Legacy / fallback: try parsing as JSON with hex-encoded audio
|
||||
try:
|
||||
result = response.json()
|
||||
except Exception:
|
||||
response.raise_for_status()
|
||||
raise RuntimeError(
|
||||
f"MiniMax TTS returned unexpected Content-Type '{content_type}' "
|
||||
f"({len(response.content)} bytes)"
|
||||
)
|
||||
|
||||
base_resp = result.get("base_resp", {})
|
||||
status_code = base_resp.get("status_code", -1)
|
||||
|
||||
@@ -998,7 +992,7 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
|
||||
if not hex_audio:
|
||||
raise RuntimeError("MiniMax TTS returned empty audio data")
|
||||
|
||||
# MiniMax returns hex-encoded audio (not base64)
|
||||
# Legacy: hex-encoded audio
|
||||
audio_bytes = bytes.fromhex(hex_audio)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
|
||||
Reference in New Issue
Block a user