Merge branch 'simp/r3-31-D' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 23:11:55 -07:00
6 changed files with 525 additions and 1079 deletions
+313 -605
View File
File diff suppressed because it is too large Load Diff
+87 -189
View File
@@ -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
View File
@@ -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
+2 -9
View File
@@ -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}
+1 -2
View File
@@ -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
View File
@@ -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):