Merge branch 'simp/r3-31-D' into simp/integration3
This commit is contained in:
+313
-605
File diff suppressed because it is too large
Load Diff
+87
-189
@@ -1,15 +1,11 @@
|
||||
"""Gateway-side clarify primitive (blocking event-based queue).
|
||||
|
||||
In gateway mode the agent runs on a worker thread while the event loop handles
|
||||
the user's reply, so a pending clarify is stored module-level (same shape as
|
||||
``tools.approval``) and the agent thread blocks on an ``Event`` until an
|
||||
adapter button callback or the gateway text-intercept resolves it, or the
|
||||
timeout fires. Adapters render either inline buttons (with an "Other" row that
|
||||
flips the entry into text-capture mode) or a numbered-list text fallback.
|
||||
"""
|
||||
"""Gateway-side clarify primitive (blocking event-based queue). The agent runs on a worker
|
||||
thread while the event loop handles the user's reply, so a pending clarify is stored
|
||||
module-level (same shape as ``tools.approval``) and the agent thread blocks on an ``Event``
|
||||
until an adapter button callback or the gateway text-intercept resolves it, or the timeout
|
||||
fires. Adapters render inline buttons (an "Other" row flips the entry into text-capture
|
||||
mode) or a numbered-list text fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
@@ -36,7 +32,7 @@ class _ClarifyEntry:
|
||||
_lock = threading.RLock()
|
||||
_entries: Dict[str, _ClarifyEntry] = {} # clarify_id -> entry (button callbacks)
|
||||
_session_index: Dict[str, List[str]] = {} # session_key -> [clarify_id] FIFO (text intercept, cleanup)
|
||||
# Per-session notify callbacks (gateway -> adapter bridge); mirrors tools.approval.
|
||||
# Per-session notify callbacks (gateway -> adapter bridge); mirrors tools.approval. Tests clear it.
|
||||
_notify_cbs: Dict[str, Callable[[_ClarifyEntry], None]] = {}
|
||||
|
||||
# Outcomes for typed clarify replies. Gateway cancels the pending prompt on
|
||||
@@ -47,25 +43,12 @@ TEXT_REJECTED_SELECTION = "rejected_selection"
|
||||
TEXT_NO_PENDING = "no_pending"
|
||||
|
||||
|
||||
# --- agent-thread side ---------------------------------------------------
|
||||
|
||||
def register(
|
||||
clarify_id: str,
|
||||
session_key: str,
|
||||
question: str,
|
||||
choices: Optional[List[str]],
|
||||
multi_select: bool = False,
|
||||
) -> _ClarifyEntry:
|
||||
"""Register a pending clarify request; caller then blocks on ``wait_for_response``."""
|
||||
entry = _ClarifyEntry(
|
||||
clarify_id=clarify_id,
|
||||
session_key=session_key,
|
||||
question=question,
|
||||
choices=list(choices) if choices else None,
|
||||
multi_select=bool(multi_select) and bool(choices),
|
||||
# Open-ended (no choices) -> next message IS the response, no buttons needed.
|
||||
awaiting_text=not bool(choices),
|
||||
)
|
||||
def register(clarify_id: str, session_key: str, question: str, choices: Optional[List[str]],
|
||||
multi_select: bool = False) -> _ClarifyEntry:
|
||||
"""Register a pending clarify request; caller then blocks on ``wait_for_response``.
|
||||
Open-ended (no choices) entries start in text mode: the next message IS the response."""
|
||||
entry = _ClarifyEntry(clarify_id, session_key, question, list(choices) if choices else None,
|
||||
bool(multi_select) and bool(choices), awaiting_text=not bool(choices))
|
||||
with _lock:
|
||||
_entries[clarify_id] = entry
|
||||
_session_index.setdefault(session_key, []).append(clarify_id)
|
||||
@@ -73,51 +56,35 @@ def register(
|
||||
|
||||
|
||||
def wait_for_response(clarify_id: str, timeout: float) -> Optional[str]:
|
||||
"""Block until the entry resolves or ``timeout`` elapses; None on timeout/unknown id.
|
||||
|
||||
Polls in 1s slices so the inactivity heartbeat keeps firing (a single long
|
||||
``Event.wait`` would let the gateway watchdog kill a live prompt).
|
||||
``timeout <= 0`` means an unlimited wait.
|
||||
"""
|
||||
"""Block until the entry resolves or ``timeout`` (``<= 0`` = unlimited) elapses; None on
|
||||
timeout/unknown id. Polls in 1s slices so the inactivity heartbeat keeps firing (a
|
||||
single long ``Event.wait`` would let the gateway watchdog kill a live prompt)."""
|
||||
with _lock:
|
||||
entry = _entries.get(clarify_id)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
from tools.environments.base import touch_activity_if_due
|
||||
except Exception: # pragma: no cover - optional
|
||||
touch_activity_if_due = None
|
||||
|
||||
unlimited = timeout is None or float(timeout) <= 0.0
|
||||
deadline = None if unlimited else time.monotonic() + float(timeout)
|
||||
deadline = None if timeout is None or float(timeout) <= 0.0 else time.monotonic() + float(timeout)
|
||||
activity_state = {"last_touch": time.monotonic(), "start": time.monotonic()}
|
||||
while True:
|
||||
if deadline is None:
|
||||
slice_s = 1.0
|
||||
else:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
slice_s = min(1.0, remaining)
|
||||
if entry.event.wait(timeout=slice_s):
|
||||
remaining = 1.0 if deadline is None else deadline - time.monotonic()
|
||||
if remaining <= 0 or entry.event.wait(timeout=min(1.0, remaining)):
|
||||
break
|
||||
if touch_activity_if_due is not None:
|
||||
touch_activity_if_due(activity_state, "waiting for user clarify response")
|
||||
|
||||
with _lock:
|
||||
_entries.pop(clarify_id, None) # regardless of outcome
|
||||
ids = _session_index.get(entry.session_key)
|
||||
if ids and clarify_id in ids:
|
||||
ids = _session_index.get(entry.session_key) or []
|
||||
if clarify_id in ids:
|
||||
ids.remove(clarify_id)
|
||||
if not ids:
|
||||
_session_index.pop(entry.session_key, None)
|
||||
|
||||
return entry.response
|
||||
|
||||
|
||||
# --- gateway / adapter side -----------------------------------------------
|
||||
|
||||
def resolve_gateway_clarify(clarify_id: str, response: str) -> bool:
|
||||
"""Unblock the waiter on ``clarify_id``; False if already resolved/expired/unknown."""
|
||||
with _lock:
|
||||
@@ -129,17 +96,10 @@ def resolve_gateway_clarify(clarify_id: str, response: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def get_pending_for_session(
|
||||
session_key: str,
|
||||
*,
|
||||
include_choice_prompts: bool = False,
|
||||
) -> Optional[_ClarifyEntry]:
|
||||
"""Oldest pending entry awaiting free text (open-ended, or after "Other").
|
||||
|
||||
``include_choice_prompts=True`` returns the oldest unresolved entry of any
|
||||
kind — for when the user typed directly at an active multi-choice prompt,
|
||||
so the text resolves it instead of queueing as an unrelated follow-up turn.
|
||||
"""
|
||||
def get_pending_for_session(session_key: str, *, include_choice_prompts: bool = False) -> Optional[_ClarifyEntry]:
|
||||
"""Oldest pending entry awaiting free text (open-ended, or after "Other");
|
||||
``include_choice_prompts=True`` returns the oldest unresolved entry of any kind (user
|
||||
typed at an active choice prompt: resolve it rather than queue a follow-up turn)."""
|
||||
with _lock:
|
||||
for cid in _session_index.get(session_key) or []:
|
||||
entry = _entries.get(cid)
|
||||
@@ -148,18 +108,13 @@ def get_pending_for_session(
|
||||
return None
|
||||
|
||||
|
||||
def _label_matches(text: str, choice: object) -> bool:
|
||||
"""Case-insensitive label match ignoring the '(Recommended)' suffix the
|
||||
first choice carries by the time it reaches adapters."""
|
||||
from tools.clarify_tool import strip_recommended
|
||||
|
||||
return strip_recommended(text).casefold() == strip_recommended(str(choice)).casefold()
|
||||
|
||||
|
||||
def _match_label(text: str, choices: List[str]) -> Optional[str]:
|
||||
"""Canonical (stripped) choice text matching ``text``, or None."""
|
||||
"""Stripped choice text matching ``text`` case-insensitively, ignoring the '(Recommended)'
|
||||
suffix the first choice carries by the time it reaches adapters; None if no match."""
|
||||
from tools.clarify_tool import strip_recommended
|
||||
wanted = strip_recommended(text).casefold()
|
||||
for choice in choices:
|
||||
if _label_matches(text, choice):
|
||||
if strip_recommended(str(choice)).casefold() == wanted:
|
||||
return str(choice).strip()
|
||||
return None
|
||||
|
||||
@@ -169,38 +124,32 @@ def _split_tokens(text: str) -> Optional[List[str]]:
|
||||
if "," in text:
|
||||
return [t.strip() for t in text.split(",") if t.strip()]
|
||||
parts = text.split()
|
||||
if len(parts) > 1 and all(p.isdigit() for p in parts):
|
||||
return parts
|
||||
return None
|
||||
return parts if len(parts) > 1 and all(p.isdigit() for p in parts) else None
|
||||
|
||||
|
||||
def _selection_attempt_tokens(
|
||||
text: str,
|
||||
choices: Optional[List[str]] = None,
|
||||
) -> Optional[List[str]]:
|
||||
def _is_int(text: str) -> bool:
|
||||
try:
|
||||
int(text)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _selection_attempt_tokens(text: str, choices: Optional[List[str]] = None) -> Optional[List[str]]:
|
||||
"""Tokens when ``text`` looks like a typed selection (bare int, comma list,
|
||||
all-numeric space list); None for free prose so the gateway can release the
|
||||
clarify. Comma-list labels may span up to the longest choice's word count."""
|
||||
stripped = str(text).strip()
|
||||
if not stripped:
|
||||
return None
|
||||
|
||||
tokens = _split_tokens(stripped)
|
||||
if tokens is not None:
|
||||
if "," not in stripped or not tokens:
|
||||
return tokens or None
|
||||
max_words = max(1, max((len(str(c).split()) for c in choices or []), default=1))
|
||||
if all(t.isdigit() or len(t.split()) <= max_words for t in tokens):
|
||||
return tokens
|
||||
return None
|
||||
|
||||
if stripped.isdigit() or (stripped.startswith("-") and stripped[1:].isdigit()):
|
||||
return [stripped]
|
||||
try:
|
||||
int(stripped)
|
||||
return [stripped]
|
||||
except ValueError:
|
||||
return None
|
||||
if tokens is None:
|
||||
digits = stripped[1:] if stripped.startswith("-") else stripped
|
||||
return [stripped] if digits.isdigit() or _is_int(stripped) else None
|
||||
if "," not in stripped or not tokens:
|
||||
return tokens or None
|
||||
max_words = max(1, max((len(str(c).split()) for c in choices or []), default=1))
|
||||
return tokens if all(t.isdigit() or len(t.split()) <= max_words for t in tokens) else None
|
||||
|
||||
|
||||
def _coerce_text_response(entry: _ClarifyEntry, response: str) -> Optional[str]:
|
||||
@@ -208,81 +157,48 @@ def _coerce_text_response(entry: _ClarifyEntry, response: str) -> Optional[str]:
|
||||
return _coerce_text_response_detailed(entry, response)[0]
|
||||
|
||||
|
||||
def _coerce_text_response_detailed(
|
||||
entry: _ClarifyEntry,
|
||||
response: str,
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Map a typed reply to ``(value, None)`` or ``(None, reason)``.
|
||||
|
||||
Reasons: ``"invalid_selection"`` (selection-shaped but out of range /
|
||||
unrecognised — keep the clarify armed for a retry) or ``"prose"`` (free
|
||||
text on a native choice prompt — gateway may cancel and route normally so a
|
||||
redirect-to-steer path cannot deadlock behind the waiting tool).
|
||||
Open-ended entries and ``awaiting_text`` mode accept any text; numeric
|
||||
picks and exact labels always resolve; multi-select returns a JSON array
|
||||
string that ``_parse_multi_select_response`` decodes on the tool side, and
|
||||
one bad token rejects the whole reply rather than resolving a partial pick.
|
||||
"""
|
||||
def _coerce_text_response_detailed(entry: _ClarifyEntry, response: str) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Map a typed reply to ``(value, None)`` or ``(None, reason)``: ``"invalid_selection"``
|
||||
(selection-shaped but out of range/unrecognised — keep the clarify armed for a retry) or
|
||||
``"prose"`` (free text on a native choice prompt — the gateway may cancel and route normally
|
||||
so a redirect-to-steer path cannot deadlock behind the waiting tool). Open-ended entries and
|
||||
``awaiting_text`` accept any text; numeric picks and exact labels always resolve; multi-select
|
||||
returns a JSON array string (decoded tool-side); one bad token rejects the whole reply."""
|
||||
text = str(response).strip()
|
||||
|
||||
if not entry.choices:
|
||||
return text, None
|
||||
|
||||
if entry.multi_select:
|
||||
coerced = _coerce_multi_select_text(entry, text)
|
||||
if coerced is not None:
|
||||
return coerced, None
|
||||
if entry.awaiting_text:
|
||||
return text, None
|
||||
if _selection_attempt_tokens(text, entry.choices) is not None:
|
||||
return None, "invalid_selection"
|
||||
return None, "prose"
|
||||
|
||||
try:
|
||||
idx = int(text) - 1
|
||||
is_int = True
|
||||
except ValueError:
|
||||
idx = -1
|
||||
is_int = False
|
||||
|
||||
if is_int and 0 <= idx < len(entry.choices):
|
||||
return entry.choices[idx], None
|
||||
|
||||
matched = _match_label(text, entry.choices)
|
||||
if matched is not None:
|
||||
return matched, None
|
||||
|
||||
selection_shaped = _selection_attempt_tokens(text, entry.choices) is not None
|
||||
else:
|
||||
# Out-of-range / non-canonical integer is a failed selection, not prose.
|
||||
selection_shaped = _is_int(text)
|
||||
idx = int(text) - 1 if selection_shaped else -1
|
||||
coerced = entry.choices[idx] if 0 <= idx < len(entry.choices) else _match_label(text, entry.choices)
|
||||
if coerced is not None:
|
||||
return coerced, None
|
||||
if entry.awaiting_text:
|
||||
return text, None
|
||||
|
||||
# Out-of-range / non-canonical integer is a failed selection, not prose.
|
||||
return None, "invalid_selection" if is_int else "prose"
|
||||
return None, "invalid_selection" if selection_shaped else "prose"
|
||||
|
||||
|
||||
def _coerce_multi_select_text(entry: _ClarifyEntry, text: str) -> Optional[str]:
|
||||
"""Parse "1,3" / "1 3" / "staging, prod" into a JSON array of choice labels;
|
||||
None when any token is out of range or unrecognised (reject the whole reply)."""
|
||||
choices, selected = entry.choices or [], []
|
||||
if not text:
|
||||
return None
|
||||
choices = entry.choices or []
|
||||
tokens = _split_tokens(text)
|
||||
if tokens is None:
|
||||
tokens = [text]
|
||||
|
||||
selected: List[str] = []
|
||||
for token in tokens:
|
||||
for token in [text] if tokens is None else tokens:
|
||||
if token.isdigit():
|
||||
idx = int(token) - 1
|
||||
if not 0 <= idx < len(choices):
|
||||
return None
|
||||
label = str(choices[idx]).strip()
|
||||
label = str(choices[idx]).strip() if 0 <= idx < len(choices) else None
|
||||
else:
|
||||
label = _match_label(token, choices)
|
||||
if label is None:
|
||||
return None
|
||||
if label is None:
|
||||
return None
|
||||
if label not in selected:
|
||||
selected.append(label)
|
||||
|
||||
return json.dumps(selected, ensure_ascii=False) if selected else None
|
||||
|
||||
|
||||
@@ -291,15 +207,12 @@ def attempt_text_response_for_session(session_key: str, response: str) -> str:
|
||||
entry = get_pending_for_session(session_key, include_choice_prompts=True)
|
||||
if entry is None:
|
||||
return TEXT_NO_PENDING
|
||||
|
||||
coerced, reason = _coerce_text_response_detailed(entry, response)
|
||||
if coerced is None:
|
||||
return TEXT_REJECTED_SELECTION if reason == "invalid_selection" else TEXT_REJECTED_PROSE
|
||||
|
||||
if resolve_gateway_clarify(entry.clarify_id, coerced):
|
||||
return TEXT_RESOLVED
|
||||
# Lost a race with a button/callback resolution — treat as no work left.
|
||||
return TEXT_NO_PENDING
|
||||
return TEXT_NO_PENDING # lost a race with a button/callback resolution — no work left
|
||||
|
||||
|
||||
def resolve_text_response_for_session(session_key: str, response: str) -> bool:
|
||||
@@ -311,37 +224,28 @@ def mark_awaiting_text(clarify_id: str) -> bool:
|
||||
"""Flip an entry into text-capture mode (user picked 'Other'); False if unknown."""
|
||||
with _lock:
|
||||
entry = _entries.get(clarify_id)
|
||||
if entry is None:
|
||||
return False
|
||||
entry.awaiting_text = True
|
||||
return True
|
||||
if entry is not None:
|
||||
entry.awaiting_text = True
|
||||
return entry is not None
|
||||
|
||||
|
||||
def has_pending(session_key: str) -> bool:
|
||||
"""True when this session has at least one pending clarify entry."""
|
||||
with _lock:
|
||||
ids = _session_index.get(session_key) or []
|
||||
return any(_entries.get(cid) is not None for cid in ids)
|
||||
return any(_entries.get(cid) is not None for cid in _session_index.get(session_key) or [])
|
||||
|
||||
|
||||
def clear_session(session_key: str) -> int:
|
||||
"""Drop every pending clarify for a session; returns how many were cancelled.
|
||||
|
||||
Session-boundary cleanup (``/new``, shutdown, cached-agent eviction) so
|
||||
blocked agent threads don't outlive their session; cancelled waiters see "".
|
||||
First-writer-wins: an entry whose event is already set was answered for
|
||||
real, so it is dropped from the registry but its response is preserved
|
||||
rather than overwritten with the "" cancellation sentinel. The whole loop
|
||||
stays inside the lock so a button callback cannot slip between pop and check.
|
||||
Entries are removed from the registry regardless of state — a cleared
|
||||
session must not be resurrected by late callbacks. Callers distinguish the
|
||||
"" sentinel from a real reply only by their own timeout bookkeeping; most
|
||||
treat any falsy result as "user did not respond".
|
||||
"""
|
||||
"""Drop every pending clarify for a session (``/new``, shutdown, cached-agent eviction) so
|
||||
blocked agent threads don't outlive it; returns how many were cancelled. Cancelled waiters
|
||||
see "" (callers tell it from a real reply only via their own timeout bookkeeping; most treat
|
||||
any falsy result as no response). First-writer-wins: an already-set entry was answered for
|
||||
real, so it is dropped but its response preserved. The loop stays inside the lock so a button
|
||||
callback cannot slip between pop and check; entries go regardless of state so a cleared
|
||||
session is never resurrected by late callbacks."""
|
||||
with _lock:
|
||||
ids = list(_session_index.pop(session_key, []) or [])
|
||||
cancelled = 0
|
||||
for entry in (_entries.pop(cid, None) for cid in ids):
|
||||
for entry in (_entries.pop(cid, None) for cid in list(_session_index.pop(session_key, []) or [])):
|
||||
if entry is None or entry.event.is_set():
|
||||
continue
|
||||
entry.response = ""
|
||||
@@ -350,13 +254,10 @@ def clear_session(session_key: str) -> int:
|
||||
return cancelled
|
||||
|
||||
|
||||
# --- config ---------------------------------------------------------------
|
||||
|
||||
def resolve_clarify_timeout(config: dict) -> int:
|
||||
"""Clarify timeout (seconds) from a loaded config: legacy ``clarify.timeout``
|
||||
if explicitly set, else ``agent.clarify_timeout``, else 3600. Single source
|
||||
of truth for every surface (gateway, CLI, TUI) so the value can't drift.
|
||||
``<= 0`` is preserved verbatim (unlimited); non-numeric falls back to 3600."""
|
||||
"""Clarify timeout (seconds): legacy ``clarify.timeout`` if explicitly set, else
|
||||
``agent.clarify_timeout``, else 3600 — the single source of truth for every surface
|
||||
(gateway, CLI, TUI). ``<= 0`` is kept verbatim (unlimited); non-numeric -> 3600."""
|
||||
raw = (config.get("clarify") or {}).get("timeout")
|
||||
if raw is None:
|
||||
raw = (config.get("agent") or {}).get("clarify_timeout", 3600)
|
||||
@@ -367,12 +268,9 @@ def resolve_clarify_timeout(config: dict) -> int:
|
||||
|
||||
|
||||
def get_clarify_timeout() -> int:
|
||||
"""Read the clarify timeout from config.yaml; 0/negative = unlimited.
|
||||
|
||||
Default 3600: long enough that a user who stepped away still finds a live
|
||||
entry when they tap, short enough that an abandoned prompt eventually
|
||||
unblocks the agent thread instead of pinning the running-agent guard.
|
||||
"""
|
||||
"""Clarify timeout from config.yaml; 0/negative = unlimited. Default 3600: long enough
|
||||
that a user who stepped away still finds a live entry when they tap, short enough that
|
||||
an abandoned prompt eventually unblocks the agent thread instead of pinning the guard."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
return resolve_clarify_timeout(load_config() or {})
|
||||
|
||||
+76
-164
@@ -1,62 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Clarify tool: structured multiple-choice / open-ended questions to the user.
|
||||
|
||||
Schema, validation and a thin dispatcher; the UI lives in a platform-provided
|
||||
callback (cli.py, gateway/run.py, tui_gateway).
|
||||
"""
|
||||
callback (cli.py, gateway/run.py, tui_gateway)."""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from typing import Dict, List, Optional, Callable
|
||||
|
||||
|
||||
MAX_CHOICES = 4 # the UI always appends an "Other (type your answer)" row
|
||||
MAX_QUESTIONS = 5 # independent questions per batch call
|
||||
|
||||
# Canonical timeout sentinel. The CLI returns this exact text; the batch loop
|
||||
# treats it (like ``None``) as "the user walked away" and aborts remaining questions.
|
||||
TIMEOUT_RESPONSE = (
|
||||
"The user did not provide a response within the time limit. "
|
||||
"Use your best judgement to make the choice and proceed."
|
||||
)
|
||||
|
||||
TIMEOUT_RESPONSE = ("The user did not provide a response within the time limit. "
|
||||
"Use your best judgement to make the choice and proceed.")
|
||||
# Applied to the first choice here (not per-surface) so every adapter renders it identically.
|
||||
RECOMMENDED_LABEL = "(Recommended)"
|
||||
_UNAVAILABLE = "Clarify tool is not available in this execution context."
|
||||
|
||||
|
||||
def _flatten_choice(c) -> str:
|
||||
"""Coerce one choice to display text.
|
||||
|
||||
LLMs sometimes emit dict-shaped choices; ``str(c)`` would leak the dict repr
|
||||
onto every surface and back as the answer, so normalise once here. Unwrap
|
||||
order ``label`` > ``description`` > ``text`` > ``title``; ``name``/``value``
|
||||
are excluded (component fields carrying raw enums, not labels). A dict with
|
||||
none of these becomes "" and is dropped — no choice beats a garbage label.
|
||||
"""
|
||||
if c is None:
|
||||
return ""
|
||||
"""Coerce one choice to display text. LLMs sometimes emit dict-shaped choices and ``str(c)``
|
||||
would leak the repr onto every surface and back as the answer; unwrap order ``label`` >
|
||||
``description`` > ``text`` > ``title`` (``name``/``value`` excluded: raw component enums,
|
||||
not labels). No match -> "" and dropped: no choice beats a garbage label."""
|
||||
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 ""
|
||||
return next((v.strip() for k in ("label", "description", "text", "title")
|
||||
if isinstance(v := c.get(k), str) and v.strip()), "")
|
||||
if isinstance(c, (list, tuple)):
|
||||
return " ".join(_flatten_choice(x) for x in c).strip()
|
||||
return str(c).strip()
|
||||
return "" if c is None else str(c).strip()
|
||||
|
||||
|
||||
def mark_recommended(choices: List[str]) -> List[str]:
|
||||
"""Suffix the first choice (the schema says best-first) with RECOMMENDED_LABEL.
|
||||
|
||||
Idempotent, and a lone choice is left untouched (nothing to prefer it over).
|
||||
"""
|
||||
if len(choices) < 2:
|
||||
return choices
|
||||
first = str(choices[0]).strip()
|
||||
if first != strip_recommended(first):
|
||||
"""Suffix the first choice (schema says best-first) with RECOMMENDED_LABEL; idempotent,
|
||||
and a lone choice is left untouched (nothing to prefer it over)."""
|
||||
first = str(choices[0]).strip() if choices else ""
|
||||
if len(choices) < 2 or first != strip_recommended(first):
|
||||
return choices
|
||||
return [f"{first} {RECOMMENDED_LABEL}"] + list(choices[1:])
|
||||
|
||||
@@ -70,16 +50,13 @@ def strip_recommended(text: str) -> str:
|
||||
|
||||
|
||||
def _accepts_kwarg(callback, name: str) -> bool:
|
||||
"""Signature-inspect (never a TypeError retry, which could re-prompt the user)
|
||||
whether ``callback`` takes ``name`` or ``**kwargs``. Non-introspectable
|
||||
callables are conservatively treated as legacy."""
|
||||
"""Signature-inspect (never a TypeError retry, which could re-prompt the user) whether
|
||||
``callback`` takes ``name`` or ``**kwargs``; non-introspectable callables are legacy."""
|
||||
try:
|
||||
params = inspect.signature(callback).parameters
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return name in params or any(
|
||||
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
|
||||
)
|
||||
return name in params or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
|
||||
|
||||
|
||||
def _invoke_callback(callback, question, choices, multi_select):
|
||||
@@ -89,21 +66,29 @@ def _invoke_callback(callback, question, choices, multi_select):
|
||||
return callback(question, choices)
|
||||
|
||||
|
||||
def _json_as(raw: str, kind):
|
||||
"""``json.loads(raw)`` when it decodes to an instance of ``kind``, else None."""
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return parsed if isinstance(parsed, kind) else None
|
||||
|
||||
|
||||
def _parse_multi_select_response(raw_response) -> List[str]:
|
||||
"""Parse a list / JSON array / comma-separated reply into stripped non-empty strings."""
|
||||
if isinstance(raw_response, list):
|
||||
return [str(r).strip() for r in raw_response if str(r).strip()]
|
||||
items = raw_response
|
||||
if not isinstance(items, list):
|
||||
raw = str(items).strip()
|
||||
items = _json_as(raw, list) if raw.startswith("[") else None
|
||||
if items is None:
|
||||
items = raw.split(",")
|
||||
return [str(r).strip() for r in items if str(r).strip()]
|
||||
|
||||
raw = str(raw_response).strip()
|
||||
|
||||
if raw.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, list):
|
||||
return [str(p).strip() for p in parsed if str(p).strip()]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return [s.strip() for s in raw.split(",") if s.strip()]
|
||||
def _clean_answer(raw, multi: bool):
|
||||
"""Strip presentation (the label, multi-select JSON) from a locked answer."""
|
||||
return [strip_recommended(r) for r in _parse_multi_select_response(raw)] if multi else strip_recommended(raw)
|
||||
|
||||
|
||||
def _clean_choices(choices: list) -> Optional[List[str]]:
|
||||
@@ -116,78 +101,49 @@ def _is_timeout(raw) -> bool:
|
||||
return raw is None or (isinstance(raw, str) and raw.strip() == TIMEOUT_RESPONSE)
|
||||
|
||||
|
||||
# --- batch (multi-question) support -----------------------------------------
|
||||
|
||||
def _normalize_questions(questions) -> tuple:
|
||||
"""Validate the ``questions`` batch param -> ``(normalized, error)``.
|
||||
|
||||
An empty list returns ``(None, None)`` (fall back to the single-question
|
||||
path). Each entry carries ``qid`` (stable wire id ``q<index>`` surfaces key
|
||||
answers by; the model's ``id`` is unvalidated text so it is only echoed in
|
||||
results), ``question``, decorated ``choices``, bare ``choices_offered``,
|
||||
and ``multi_select`` (honored only with choices).
|
||||
"""
|
||||
"""Validate the ``questions`` batch param -> ``(normalized, error)``; an empty list gives
|
||||
``(None, None)`` (fall back to the single-question path). Entries carry ``qid`` (stable
|
||||
wire id ``q<index>`` surfaces key answers by; the model's ``id`` is unvalidated text, only
|
||||
echoed), ``question``, decorated ``choices``, bare ``choices_offered``, ``multi_select``."""
|
||||
if not isinstance(questions, list):
|
||||
return None, "questions must be an array of question objects."
|
||||
if not questions:
|
||||
return None, None
|
||||
if len(questions) > MAX_QUESTIONS:
|
||||
return None, f"questions supports at most {MAX_QUESTIONS} items."
|
||||
|
||||
normalized = []
|
||||
for index, item in enumerate(questions):
|
||||
if isinstance(item, str):
|
||||
# Tolerate bare-string items: LLMs sometimes send ["Q1?", "Q2?"].
|
||||
if isinstance(item, str): # tolerate bare-string items: LLMs sometimes send ["Q1?", "Q2?"]
|
||||
item = {"question": item}
|
||||
if not isinstance(item, dict):
|
||||
return None, f"questions[{index}] must be an object with a 'question'."
|
||||
|
||||
text = str(item.get("question") or "").strip()
|
||||
if not text:
|
||||
return None, f"questions[{index}].question must be non-empty text."
|
||||
|
||||
choices = item.get("choices")
|
||||
if choices is not None:
|
||||
if not isinstance(choices, list):
|
||||
return None, f"questions[{index}].choices must be a list."
|
||||
choices = _clean_choices(choices)
|
||||
|
||||
model_id = str(item.get("id") or "").strip() or None
|
||||
|
||||
normalized.append({
|
||||
"qid": f"q{index}",
|
||||
"id": model_id,
|
||||
"question": text,
|
||||
"qid": f"q{index}", "id": str(item.get("id") or "").strip() or None, "question": text,
|
||||
"choices": mark_recommended(list(choices)) if choices else None,
|
||||
"choices_offered": list(choices) if choices else None,
|
||||
"multi_select": bool(item.get("multi_select")) and bool(choices),
|
||||
})
|
||||
|
||||
"multi_select": bool(item.get("multi_select")) and bool(choices)})
|
||||
return normalized, None
|
||||
|
||||
|
||||
def _clean_batch_answer(entry: dict, raw) -> object:
|
||||
"""Strip presentation from one locked answer (label, multi-select JSON)."""
|
||||
if entry["multi_select"]:
|
||||
return [strip_recommended(r) for r in _parse_multi_select_response(raw)]
|
||||
return strip_recommended(raw)
|
||||
|
||||
|
||||
def _batch_result(normalized: List[dict], answers: dict, timed_out: bool) -> str:
|
||||
"""Batch result JSON; unanswered -> "" — the top-level ``timed_out`` flag
|
||||
(present only when true) tells the agent whether blanks are deliberate
|
||||
skips or the user walking away."""
|
||||
"""Batch result JSON; unanswered -> "". The top-level ``timed_out`` flag (present only when
|
||||
true) tells the agent whether blanks are deliberate skips or the user walking away."""
|
||||
responses = []
|
||||
for entry in normalized:
|
||||
row = {}
|
||||
if entry["id"]:
|
||||
row["id"] = entry["id"]
|
||||
row["question"] = entry["question"]
|
||||
row["choices_offered"] = entry["choices_offered"]
|
||||
raw = answers.get(entry["qid"])
|
||||
row["user_response"] = _clean_batch_answer(entry, raw) if raw else ""
|
||||
responses.append(row)
|
||||
|
||||
responses.append({
|
||||
**({"id": entry["id"]} if entry["id"] else {}),
|
||||
"question": entry["question"], "choices_offered": entry["choices_offered"],
|
||||
"user_response": _clean_answer(raw, entry["multi_select"]) if raw else ""})
|
||||
result: Dict[str, object] = {"responses": responses}
|
||||
if timed_out:
|
||||
result["timed_out"] = True
|
||||
@@ -195,39 +151,25 @@ def _batch_result(normalized: List[dict], answers: dict, timed_out: bool) -> str
|
||||
|
||||
|
||||
def _run_batch(normalized: List[dict], callback, question: str) -> str:
|
||||
"""Dispatch a validated batch to the platform callback.
|
||||
|
||||
Batch-capable callbacks (``questions`` kwarg) get the whole list once and
|
||||
reply ``{"answers": {qid: raw}, "timed_out"?}`` as a dict or JSON string
|
||||
(the tui_gateway bridge only carries strings). Legacy callbacks are looped
|
||||
per question; an empty answer is a skip, a timeout (``None`` or the
|
||||
sentinel) means the user walked away so the loop aborts instead of pestering
|
||||
them — answers collected before the abort are kept either way.
|
||||
"""
|
||||
"""Dispatch a validated batch. Batch-capable callbacks (``questions`` kwarg) get the
|
||||
whole list once and reply ``{"answers": {qid: raw}, "timed_out"?}`` as a dict or JSON
|
||||
string (the tui_gateway bridge only carries strings); any other falsy/unparseable reply
|
||||
is a cancel-all (mirrors the single-question skip). Legacy callbacks are looped per
|
||||
question: an empty answer is a skip, a timeout (``None`` or the sentinel) means the user
|
||||
walked away so the loop aborts instead of pestering them; earlier answers are kept."""
|
||||
answers: dict = {}
|
||||
timed_out = False
|
||||
if _accepts_kwarg(callback, "questions"):
|
||||
raw = callback(question, None, questions=normalized)
|
||||
if _is_timeout(raw):
|
||||
timed_out = True
|
||||
elif isinstance(raw, dict):
|
||||
timed_out = _is_timeout(raw)
|
||||
if isinstance(raw, str):
|
||||
raw = _json_as(raw, dict) # the sentinel is not JSON -> None, timed_out stays True
|
||||
if isinstance(raw, dict):
|
||||
answers = dict(raw.get("answers") or {})
|
||||
timed_out = bool(raw.get("timed_out"))
|
||||
elif isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
answers = dict(parsed.get("answers") or {})
|
||||
timed_out = bool(parsed.get("timed_out"))
|
||||
# Any other falsy/unparseable reply is a cancel-all (mirrors the single-question skip).
|
||||
return _batch_result(normalized, answers, timed_out)
|
||||
|
||||
for entry in normalized:
|
||||
raw = _invoke_callback(
|
||||
callback, entry["question"], entry["choices"], entry["multi_select"],
|
||||
)
|
||||
raw = _invoke_callback(callback, entry["question"], entry["choices"], entry["multi_select"])
|
||||
if _is_timeout(raw):
|
||||
timed_out = True
|
||||
break
|
||||
@@ -235,72 +177,43 @@ def _run_batch(normalized: List[dict], callback, question: str) -> str:
|
||||
return _batch_result(normalized, answers, timed_out)
|
||||
|
||||
|
||||
def clarify_tool(
|
||||
question: str,
|
||||
choices: Optional[List[str]] = None,
|
||||
multi_select: bool = False,
|
||||
questions: Optional[List[dict]] = None,
|
||||
callback: Optional[Callable] = None,
|
||||
) -> str:
|
||||
"""Ask the user one question (``question``/``choices``/``multi_select``) or a
|
||||
batch (``questions``, which takes precedence when non-empty).
|
||||
|
||||
``callback(question, choices, multi_select=False) -> str`` is platform
|
||||
injected; batch-capable callbacks also accept ``questions=``. Returns the
|
||||
result JSON (``{"responses": [...]}`` for batches).
|
||||
"""
|
||||
def clarify_tool(question: str, choices: Optional[List[str]] = None, multi_select: bool = False,
|
||||
questions: Optional[List[dict]] = None, callback: Optional[Callable] = None) -> str:
|
||||
"""Ask one question (``question``/``choices``/``multi_select``) or a batch (``questions``
|
||||
wins when non-empty). ``callback(question, choices, multi_select=False) -> str`` is
|
||||
platform injected (batch-capable ones also take ``questions=``). Returns result JSON."""
|
||||
if questions is not None:
|
||||
normalized, error = _normalize_questions(questions)
|
||||
if error:
|
||||
return tool_error(error)
|
||||
if normalized:
|
||||
if callback is None:
|
||||
return tool_error(
|
||||
"Clarify tool is not available in this execution context."
|
||||
)
|
||||
return tool_error(_UNAVAILABLE)
|
||||
try:
|
||||
return _run_batch(normalized, callback, str(question or "").strip())
|
||||
except Exception as exc:
|
||||
return tool_error(f"Failed to get user input: {exc}")
|
||||
# Empty questions array → fall through to the single-question path.
|
||||
|
||||
if not question or not question.strip():
|
||||
return tool_error(
|
||||
"No question provided. Pass questions=[{question: '...', "
|
||||
"choices?: [...], multi_select?: bool}, ...] — a single question "
|
||||
"is a one-entry array."
|
||||
)
|
||||
|
||||
return tool_error("No question provided. Pass questions=[{question: '...', "
|
||||
"choices?: [...], multi_select?: bool}, ...] — a single question "
|
||||
"is a one-entry array.")
|
||||
question = question.strip()
|
||||
|
||||
if choices is not None:
|
||||
if not isinstance(choices, list):
|
||||
return tool_error("choices must be a list of strings.")
|
||||
choices = _clean_choices(choices)
|
||||
|
||||
if callback is None:
|
||||
return tool_error("Clarify tool is not available in this execution context.")
|
||||
|
||||
return tool_error(_UNAVAILABLE)
|
||||
# The bare list goes back to the agent; the "(Recommended)" label is presentation only.
|
||||
offered = choices
|
||||
if choices is not None:
|
||||
choices = mark_recommended(choices)
|
||||
|
||||
shown = mark_recommended(choices) if choices is not None else None
|
||||
try:
|
||||
raw_response = _invoke_callback(callback, question, choices, multi_select)
|
||||
raw_response = _invoke_callback(callback, question, shown, multi_select)
|
||||
except Exception as exc:
|
||||
return tool_error(f"Failed to get user input: {exc}")
|
||||
|
||||
if multi_select and choices is not None:
|
||||
user_response = [strip_recommended(r) for r in _parse_multi_select_response(raw_response)]
|
||||
else:
|
||||
user_response = strip_recommended(raw_response)
|
||||
|
||||
return json.dumps({
|
||||
"question": question,
|
||||
"choices_offered": offered,
|
||||
"user_response": user_response,
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps({"question": question, "choices_offered": choices,
|
||||
"user_response": _clean_answer(raw_response, multi_select and choices is not None)},
|
||||
ensure_ascii=False)
|
||||
|
||||
|
||||
def check_clarify_requirements() -> bool:
|
||||
@@ -366,7 +279,6 @@ CLARIFY_SCHEMA = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --- Registry ---
|
||||
from tools.registry import registry, tool_error
|
||||
|
||||
|
||||
@@ -32,10 +32,8 @@ def _origin_from_env() -> Optional[Dict[str, str]]:
|
||||
"Cron origin captured thread_id=%s for %s:%s",
|
||||
thread_id, origin_platform, origin_chat_id)
|
||||
return {
|
||||
"platform": origin_platform,
|
||||
"chat_id": origin_chat_id,
|
||||
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
|
||||
"thread_id": thread_id,
|
||||
"platform": origin_platform, "chat_id": origin_chat_id,
|
||||
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None, "thread_id": thread_id,
|
||||
# Lets a delivery mirror resolve the participant's session in per-user-isolated groups.
|
||||
"user_id": get_session_env("HERMES_SESSION_USER_ID") or None,
|
||||
# Workspace/server scope (Slack team, Discord guild...): Slack session keys embed it,
|
||||
@@ -52,7 +50,6 @@ def _local_delivery_notice(job: Dict[str, Any], user_deliver: Optional[str]) ->
|
||||
return None
|
||||
try:
|
||||
from cron.scheduler import _resolve_delivery_targets
|
||||
|
||||
if _resolve_delivery_targets(job):
|
||||
return None
|
||||
except Exception: # resolution unavailable — fall back to the origin signal
|
||||
@@ -211,7 +208,6 @@ def _resolve_cron_context_deliver(deliver: Optional[str]) -> Optional[str]:
|
||||
elements pass through. Otherwise the scheduler would guess a home channel."""
|
||||
from gateway.session_context import get_session_env
|
||||
from utils import is_truthy_value
|
||||
|
||||
if not is_truthy_value(get_session_env("HERMES_CRON_SESSION", "")):
|
||||
return deliver
|
||||
|
||||
@@ -291,7 +287,6 @@ def _validate_cron_script_path(script: Optional[str]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
raw = script.strip()
|
||||
if raw.startswith(("/", "~")) or (len(raw) >= 2 and raw[1] == ":"):
|
||||
return (
|
||||
@@ -300,7 +295,6 @@ def _validate_cron_script_path(script: Optional[str]) -> Optional[str]:
|
||||
f"Place scripts in ~/.hermes/scripts/ and use just the filename.")
|
||||
|
||||
from tools.path_security import validate_within_dir
|
||||
|
||||
scripts_dir = get_hermes_home() / "scripts"
|
||||
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||
if validate_within_dir(scripts_dir / raw, scripts_dir):
|
||||
@@ -392,7 +386,6 @@ def _gateway_liveness_notice(plural: bool = False) -> dict:
|
||||
on "scheduler active". False -> warning (no gateway process), None -> probe failed."""
|
||||
try:
|
||||
from hermes_cli.cron import _builtin_gateway_liveness
|
||||
|
||||
_gw = _builtin_gateway_liveness()
|
||||
except Exception:
|
||||
return {"gateway_running": None}
|
||||
|
||||
@@ -19,8 +19,7 @@ _CRON_THREAT_PATTERNS = [
|
||||
(r'system\s+prompt\s+override', "sys_prompt_override"),
|
||||
(r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"),
|
||||
(r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass|id_rsa|id_ed25519|id_ecdsa)', "read_secrets"),
|
||||
(r'authorized_keys', "ssh_backdoor"),
|
||||
(r'/etc/sudoers|visudo', "sudoers_mod"),
|
||||
(r'authorized_keys', "ssh_backdoor"), (r'/etc/sudoers|visudo', "sudoers_mod"),
|
||||
(r'rm\s+-rf\s+/', "destructive_root_rm"),
|
||||
]
|
||||
|
||||
|
||||
+46
-110
@@ -99,7 +99,6 @@ def _api_server_base_url() -> str:
|
||||
"""``http://host:port`` of the local api_server, mirroring its bind resolution
|
||||
(extra.host -> API_SERVER_HOST -> 127.0.0.1); a wildcard bind listens on loopback too."""
|
||||
import os
|
||||
|
||||
port_raw = os.getenv("API_SERVER_PORT", "").strip()
|
||||
try:
|
||||
port = int(port_raw) if port_raw else 8642
|
||||
@@ -119,17 +118,13 @@ def _api_server_base_url() -> str:
|
||||
|
||||
|
||||
def _forward_relay_fronted_run(job: Dict[str, Any], extra_prompt: Optional[str] = None) -> Optional[str]:
|
||||
"""Forward a manual run to the gateway when it targets a relay-fronted platform.
|
||||
|
||||
Relay-fronted delivery has no standalone sender — the gateway's live relay adapter is
|
||||
the only path, reached via ``POST /api/jobs/{id}/run`` (marks the job due for the
|
||||
gateway ticker; ``extra_prompt`` rides in the body). Returns a JSON result string when
|
||||
forwarding engages, else None to fall through to the normal in-process run.
|
||||
"""
|
||||
"""Forward a manual run to the gateway when it targets a relay-fronted platform: such delivery
|
||||
has no standalone sender — the gateway's live relay adapter is the only path, reached via
|
||||
``POST /api/jobs/{id}/run`` (marks the job due; ``extra_prompt`` rides in the body). Returns a
|
||||
JSON result string when forwarding engages, else None (normal in-process run)."""
|
||||
if not _relay_fronted_delivery_platforms(job):
|
||||
return None
|
||||
from agent.secret_scope import get_secret
|
||||
|
||||
key = get_secret("API_SERVER_KEY", "") or ""
|
||||
try:
|
||||
import httpx
|
||||
@@ -175,12 +170,9 @@ _ALREADY_RUNNING_ERROR = (
|
||||
|
||||
|
||||
def _claim_for_manual_run(job_id: str, log_label: str):
|
||||
"""At-most-once claim shared by the sync and background run paths.
|
||||
|
||||
Returns ``(claimed_job, None)`` or ``(None, error_dict)`` in the ``_execute_job_now``
|
||||
result shape. A lost claim is labelled precisely: claim_job_for_fire also returns False
|
||||
for paused / disabled / missing jobs, which must not read as "already being fired".
|
||||
"""
|
||||
"""At-most-once claim shared by the sync and background run paths: ``(claimed_job, None)`` or
|
||||
``(None, error_dict)`` in the ``_execute_job_now`` shape. A lost claim is labelled precisely —
|
||||
claim_job_for_fire also returns False for paused/disabled/missing jobs, not just in-flight ones."""
|
||||
try:
|
||||
claimed_job = claim_job_for_fire(job_id, return_job=True)
|
||||
if isinstance(claimed_job, dict):
|
||||
@@ -310,7 +302,6 @@ def _latest_job_output_excerpt(job_id: str, max_chars: int = 2000) -> Optional[s
|
||||
block (parent sees what the job produced). Never raises."""
|
||||
try:
|
||||
from cron.jobs import get_cron_output_dir
|
||||
|
||||
files = sorted((get_cron_output_dir() / job_id).glob("*.md"))
|
||||
text = files[-1].read_text(encoding="utf-8", errors="replace").strip() if files else ""
|
||||
if not text:
|
||||
@@ -329,7 +320,6 @@ def _reap_stale_executions(job_name: str) -> None:
|
||||
Best-effort self-heal: must not block dispatch."""
|
||||
try:
|
||||
from cron.executions import recover_interrupted_executions
|
||||
|
||||
_reclaimed = recover_interrupted_executions()
|
||||
if _reclaimed:
|
||||
logger.warning(
|
||||
@@ -344,7 +334,6 @@ def _background_session_key(session_id: Optional[str]) -> str:
|
||||
cross the pool). Empty string = no durable consumer."""
|
||||
try:
|
||||
from tools.approval import get_current_session_key
|
||||
|
||||
session_key = get_current_session_key(default="")
|
||||
except Exception:
|
||||
session_key = ""
|
||||
@@ -371,35 +360,24 @@ def _manual_run_completion(
|
||||
if excerpt:
|
||||
lines += ["--- JOB OUTPUT ---", excerpt]
|
||||
return {
|
||||
"status": "completed" if res.get("success") else "error",
|
||||
"summary": "\n".join(lines),
|
||||
"error": res.get("error"),
|
||||
"api_calls": 0,
|
||||
"duration_seconds": duration,
|
||||
"status": "completed" if res.get("success") else "error", "summary": "\n".join(lines),
|
||||
"error": res.get("error"), "api_calls": 0, "duration_seconds": duration,
|
||||
}
|
||||
|
||||
|
||||
def _try_dispatch_background_run(
|
||||
job: Dict[str, Any], session_id: Optional[str] = None, extra_prompt: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Claim ``job`` now, then fire it on the async-delegation daemon executor.
|
||||
|
||||
A cron job is a full agent run (minutes to hours); inline it made the parent turn
|
||||
uninterruptible. Dispatches like ``delegate_task``'s background mode: the tool returns a
|
||||
handle and a ``type="async_delegation"`` completion re-enters the conversation as a
|
||||
fresh turn (role alternation legal, prompt cache intact). The claim is taken
|
||||
SYNCHRONOUSLY so unrunnable jobs report immediately.
|
||||
|
||||
Returns None when background delivery is unavailable (caller falls back to the sync
|
||||
path); ``{"claimed": False, ...}`` on a lost claim; ``{"claimed": True, "dispatched":
|
||||
True, "delegation_id"}`` when running in the background; ``{"claimed": True,
|
||||
"dispatched": False, ...}`` when the pool was full and the run executed inline.
|
||||
"""
|
||||
# Finite sessions cannot route a detached result back after the turn ends — mirror
|
||||
# delegate_task's gate and fall back to sync execution.
|
||||
"""Claim ``job`` now (SYNCHRONOUSLY, so unrunnable jobs report immediately), then fire it
|
||||
on the async-delegation executor like ``delegate_task``'s background mode: the tool returns
|
||||
a handle and a ``type="async_delegation"`` completion re-enters as a fresh turn (role
|
||||
alternation legal, prompt cache intact) instead of blocking the parent turn for minutes.
|
||||
Returns None when background delivery is unavailable (caller runs sync); ``{"claimed":
|
||||
False}`` on a lost claim; ``{"claimed": True, "dispatched": True, "delegation_id"}``; or
|
||||
``{"claimed": True, "dispatched": False, ...}`` when the pool was full and it ran inline."""
|
||||
# Finite sessions cannot route a detached result back after the turn ends (delegate_task's gate).
|
||||
try:
|
||||
from gateway.session_context import async_delivery_supported
|
||||
|
||||
if not async_delivery_supported():
|
||||
return None
|
||||
except Exception:
|
||||
@@ -409,18 +387,16 @@ def _try_dispatch_background_run(
|
||||
job_name = str(job.get("name") or job_id)
|
||||
_reap_stale_executions(job_name)
|
||||
|
||||
# Routing capture BEFORE the claim: with no routable session there is no durable
|
||||
# consumer for a detached completion, so we must not claim-and-dispatch. Direct Python
|
||||
# callers (`hermes cron run`, tests) exit right after the tool returns -> run sync.
|
||||
# Routing capture BEFORE the claim: no routable session = no durable consumer for a detached
|
||||
# completion, so don't claim-and-dispatch (direct callers like `hermes cron run` exit right after).
|
||||
session_key = _background_session_key(session_id)
|
||||
if not session_key:
|
||||
return None
|
||||
|
||||
# Best-effort early dedupe so a mid-run job reports in THIS tool response instead of
|
||||
# as a delayed error completion (authoritative check: try_register_running_job).
|
||||
# Early dedupe so a mid-run job reports in THIS response, not as a delayed error completion
|
||||
# (authoritative check: try_register_running_job).
|
||||
try:
|
||||
from cron.scheduler import get_running_job_ids
|
||||
|
||||
if job_id in get_running_job_ids():
|
||||
return {"claimed": False, "success": False, "error": _ALREADY_RUNNING_ERROR}
|
||||
except Exception:
|
||||
@@ -435,14 +411,12 @@ def _try_dispatch_background_run(
|
||||
origin_ui_session_id = ""
|
||||
try:
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
origin_ui_session_id = get_session_env("HERMES_UI_SESSION_ID", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from tools.async_delegation import _current_origin_session_id, dispatch_async_delegation
|
||||
|
||||
origin_session_id = _current_origin_session_id()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -453,16 +427,13 @@ def _try_dispatch_background_run(
|
||||
|
||||
try:
|
||||
from tools.delegate_tool import _get_max_async_children
|
||||
|
||||
max_async = _get_max_async_children()
|
||||
except Exception:
|
||||
max_async = 3
|
||||
|
||||
started_at = time.time()
|
||||
# Canonicalize with the scheduler's own normalizer (falsy -> "local", list -> comma
|
||||
# string), reading the claimed snapshot the run actually executes.
|
||||
# Scheduler's own normalizer (falsy -> "local", list -> comma string) on the claimed snapshot.
|
||||
from cron.scheduler import _normalize_deliver_value
|
||||
|
||||
deliver = _normalize_deliver_value(claimed_job.get("deliver", "local"))
|
||||
|
||||
def _runner() -> Dict[str, Any]:
|
||||
@@ -471,23 +442,16 @@ def _try_dispatch_background_run(
|
||||
|
||||
dispatch = dispatch_async_delegation(
|
||||
goal=f"Manual run of cron job '{job_name}' ({job_id})",
|
||||
context=(
|
||||
"Triggered via cronjob(action='run'). The job executed in its own "
|
||||
"fresh cron session; this block reports its outcome."),
|
||||
toolsets=None,
|
||||
role="cron_run",
|
||||
model=job.get("model"),
|
||||
session_key=session_key,
|
||||
parent_session_id=str(session_id) if session_id else None,
|
||||
runner=_runner,
|
||||
origin_ui_session_id=origin_ui_session_id,
|
||||
origin_session_id=origin_session_id,
|
||||
context=("Triggered via cronjob(action='run'). The job executed in its own "
|
||||
"fresh cron session; this block reports its outcome."),
|
||||
toolsets=None, role="cron_run", model=job.get("model"), session_key=session_key,
|
||||
parent_session_id=str(session_id) if session_id else None, runner=_runner,
|
||||
origin_ui_session_id=origin_ui_session_id, origin_session_id=origin_session_id,
|
||||
max_async_children=max_async)
|
||||
if dispatch.get("status") == "dispatched":
|
||||
return {"claimed": True, "dispatched": True, "delegation_id": dispatch.get("delegation_id")}
|
||||
|
||||
# Pool at capacity (or submit failure): the claim is already taken and must not be
|
||||
# stranded — run inline exactly as the legacy path did.
|
||||
# Pool at capacity (or submit failure): the claim is already taken and must not be stranded.
|
||||
logger.info(
|
||||
"cronjob run: background pool unavailable (%s); running job '%s' inline.",
|
||||
dispatch.get("error", "rejected"), job_name)
|
||||
@@ -549,53 +513,31 @@ def _action_create(a: Dict[str, Any]) -> str:
|
||||
context_from = _apply_continuity(context_from, a["continuity"])
|
||||
|
||||
from cron.scheduler import CronSchedulerRegistrationError, create_job_with_scheduler_registration
|
||||
|
||||
try:
|
||||
job = create_job_with_scheduler_registration(
|
||||
prompt=prompt or "",
|
||||
schedule=a["schedule"],
|
||||
name=a["name"],
|
||||
repeat=a["repeat"],
|
||||
deliver=_resolve_cron_context_deliver(deliver),
|
||||
origin=_origin_from_env(),
|
||||
skills=canonical_skills,
|
||||
model=_normalize_optional_job_value(a["model"]),
|
||||
provider=_normalize_optional_job_value(a["provider"]),
|
||||
prompt=prompt or "", schedule=a["schedule"], name=a["name"], repeat=a["repeat"],
|
||||
deliver=_resolve_cron_context_deliver(deliver), origin=_origin_from_env(), skills=canonical_skills,
|
||||
model=_normalize_optional_job_value(a["model"]), provider=_normalize_optional_job_value(a["provider"]),
|
||||
base_url=_normalize_optional_job_value(a["base_url"], strip_trailing_slash=True),
|
||||
script=_normalize_optional_job_value(script),
|
||||
context_from=context_from,
|
||||
enabled_toolsets=a["enabled_toolsets"] or None,
|
||||
workdir=_normalize_optional_job_value(a["workdir"]),
|
||||
no_agent=_no_agent,
|
||||
attach_to_session=a["attach_to_session"],
|
||||
script=_normalize_optional_job_value(script), context_from=context_from,
|
||||
enabled_toolsets=a["enabled_toolsets"] or None, workdir=_normalize_optional_job_value(a["workdir"]),
|
||||
no_agent=_no_agent, attach_to_session=a["attach_to_session"],
|
||||
monitor_script=_normalize_optional_job_value(a["monitor_script"]),
|
||||
monitor_url=_normalize_optional_job_value(a["monitor_url"]),
|
||||
# CLI-only lane: absent from CRONJOB_SCHEMA and the model dispatch — models
|
||||
# do not make model-config decisions.
|
||||
# CLI-only lane: absent from CRONJOB_SCHEMA and the model dispatch (models don't pick models).
|
||||
reasoning_effort=a["reasoning_effort"],
|
||||
failure_deliver=_resolve_cron_context_deliver(_normalize_deliver_param(a["failure_deliver"])))
|
||||
except CronSchedulerRegistrationError as exc:
|
||||
_partial = exc.to_dict()
|
||||
return tool_error(_partial.pop("error"), success=False, **_partial)
|
||||
_create_message = f"Cron job '{job['name']}' created."
|
||||
_local_notice = _local_delivery_notice(job, deliver)
|
||||
if _local_notice:
|
||||
_create_message = f"{_create_message} {_local_notice}"
|
||||
# The builtin ticker lives in the gateway process: a job created with no gateway
|
||||
# running is stored but never fires — tell the model (the CLI already warns).
|
||||
_create_message = " ".join(filter(None, (f"Cron job '{job['name']}' created.", _local_delivery_notice(job, deliver))))
|
||||
# The builtin ticker lives in the gateway process: with no gateway running the job is stored
|
||||
# but never fires — tell the model (the CLI already warns).
|
||||
_result = {
|
||||
"success": True,
|
||||
"job_id": job["id"],
|
||||
"name": job["name"],
|
||||
"skill": job.get("skill"),
|
||||
"skills": job.get("skills", []),
|
||||
"schedule": job["schedule_display"],
|
||||
"repeat": _repeat_display(job),
|
||||
"deliver": job.get("deliver", "local"),
|
||||
"next_run_at": job["next_run_at"],
|
||||
"job": _format_job(job),
|
||||
"message": _create_message,
|
||||
**_gateway_liveness_notice(),
|
||||
"success": True, "job_id": job["id"], "name": job["name"], "skill": job.get("skill"),
|
||||
"skills": job.get("skills", []), "schedule": job["schedule_display"], "repeat": _repeat_display(job),
|
||||
"deliver": job.get("deliver", "local"), "next_run_at": job["next_run_at"], "job": _format_job(job),
|
||||
"message": _create_message, **_gateway_liveness_notice(),
|
||||
}
|
||||
return _dumps(_with_guidance(_result, job, deliver))
|
||||
|
||||
@@ -827,13 +769,10 @@ def _action_update(job: Dict[str, Any], a: Dict[str, Any]) -> str:
|
||||
# Actions that need no job_id, and job-bound actions (job resolved first).
|
||||
_JOBLESS_ACTIONS = {"create": _action_create, "list": _action_list}
|
||||
_JOB_ACTIONS = {
|
||||
"remove": _action_remove,
|
||||
"remove": _action_remove, "update": _action_update,
|
||||
"run": _action_run, "run_now": _action_run, "trigger": _action_run,
|
||||
"pause": lambda job, a: _job_state_result(pause_job(job["id"], reason=a["reason"])),
|
||||
"resume": lambda job, a: _job_state_result(resume_job(job["id"])),
|
||||
"run": _action_run,
|
||||
"run_now": _action_run,
|
||||
"trigger": _action_run,
|
||||
"update": _action_update,
|
||||
}
|
||||
|
||||
|
||||
@@ -999,7 +938,6 @@ def check_cronjob_requirements() -> bool:
|
||||
"""Available in interactive CLI mode and gateway/messaging platforms (the scheduler is
|
||||
internal; no crontab needed). Flags must be explicitly truthy via ``env_var_enabled``."""
|
||||
from utils import env_var_enabled
|
||||
|
||||
return (
|
||||
env_var_enabled("HERMES_INTERACTIVE")
|
||||
or env_var_enabled("HERMES_GATEWAY_SESSION")
|
||||
@@ -1012,10 +950,8 @@ def check_cronjob_requirements() -> bool:
|
||||
# create/edit --model`, hand-edited jobs) — the agent must not point unattended spend at a
|
||||
# different model. Programmatic callers of cronjob() itself retain the parameters.
|
||||
_HANDLER_FORWARDED_ARGS = (
|
||||
"job_id", "prompt", "schedule", "name", "repeat", "deliver", "failure_deliver", "skill", "skills",
|
||||
"reason", "script", "context_from", "continuity", "enabled_toolsets", "workdir",
|
||||
"no_agent", "attach_to_session",
|
||||
)
|
||||
"job_id", "prompt", "schedule", "name", "repeat", "deliver", "failure_deliver", "skill", "skills", "reason",
|
||||
"script", "context_from", "continuity", "enabled_toolsets", "workdir", "no_agent", "attach_to_session")
|
||||
|
||||
|
||||
def _cronjob_handler(args, **kw):
|
||||
|
||||
Reference in New Issue
Block a user