Files
hermes-agent/tools/process_registry_notifications.py
T
Teknium c89f3b8800 fix(delegation): one completion per call by default; queued units no longer stalled; tell the model results land between turns
Three orchestrator failures traced through the Sep 7 gpt-6-astra campaign sessions:

1. delegation.independent_completions (new, default false). #104299 made every
   ungrouped task its own completion message, so a 15-task call woke the
   orchestrator up to 15 times; one chain received 132 notices and answered
   130 of them with "already incorporated". A multi-task call now returns as
   ONE consolidated message unless the flag is on; `group` is inert until then.

2. Queued units were killed before they started. Units of one call share a
   pool slot but the executor was still sized by slots, so with 15 units live
   a new unit queued behind a full pool; the stale monitor's clock ran from
   dispatch, interrupted it at 450 s, and the child exited `interrupted 0.02s`
   when its thread finally came up (13 such lanes in one session). The
   executor now grows to the number of live units and the stall clock arms
   when the runner actually starts.

3. The tool text said "do not wait or poll — just continue" without saying
   that completions are delivered only BETWEEN turns. A model that never ends
   its turn (one 203-minute turn, 717 API calls) never received 40 finished
   results. Tool description, dispatch note and completion header now say to
   finish independent work, give a one-line status, and end the turn.
2026-09-07 06:46:54 -07:00

318 lines
16 KiB
Python

"""Human-readable rendering of ``ProcessRegistry.completion_queue`` events (completion,
watch_match, watch_disabled, watch_overflow_*, async_delegation) into the
``[IMPORTANT: ...]`` / ``[ASYNC DELEGATION ...]`` text the CLI drain loop, gateway and
TUI inject into the agent conversation."""
import time
from contextlib import suppress
_DONE = ("completed", "success")
def _format_age(seconds: float) -> str:
"""Human-friendly elapsed string ('18m', '2h3m', '45s')."""
try:
s = int(max(0, seconds))
except (TypeError, ValueError):
return "?"
if s < 60:
return f"{s}s"
m, s = divmod(s, 60)
if m < 60:
return f"{m}m" + (f"{s}s" if s else "")
h, m = divmod(m, 60)
return f"{h}h" + (f"{m}m" if m else "")
def _model_not_found_patterns() -> "list[str]":
"""Model-not-found phrases from ``agent.error_classifier`` (the failover path's
own list, so nothing drifts); a minimal built-in set if the import fails.
Imported from ``agent.error_classifier`` so the batch renderer applies the SAME classification the
failover path consumes — no hand-copied pattern list to drift. Fails open to a minimal built-in set so a
classifier import problem never hides the per-task blocks. See #97667.
"""
try:
from agent.error_classifier import _MODEL_NOT_FOUND_PATTERNS
return list(_MODEL_NOT_FOUND_PATTERNS)
except Exception:
return ["is not a valid model", "model not found", "model_not_found"]
def _delegation_config() -> dict:
"""Active delegation config; ``{}`` on any error. Lazy: delegate_tool is heavy."""
try:
from tools.delegate_tool import _load_config as _cfg
return _cfg() or {}
except Exception:
return {}
def _delegation_model_not_found(results, config) -> bool:
"""True when a result reflects a config-level model_not_found rejection: needs a
model-not-found phrase AND the currently-configured model name in the same text,
so a stale task failing on a removed model is not mis-attributed to the config."""
model = str((config or {}).get("model") or "").lower()
if not model:
return False
patterns = _model_not_found_patterns()
texts = (" ".join(str(x) for x in (r.get("error"), r.get("summary")) if x).lower() for r in results or [])
return any(model in text and any(p in text for p in patterns) for text in texts)
def _delegation_model_not_found_notice(results) -> "list[str] | None":
"""Config-level model_not_found notice lines, or None (fail-open) — once per batch."""
config = _delegation_config()
if not _delegation_model_not_found(results, config):
return None
model = config.get("model") or "?"
provider = config.get("provider") or "configured provider"
lines = [
"⚠ SUBAGENT MODEL REJECTED: the configured Subagent Model "
f'"{model}" was rejected by provider "{provider}" '
"(HTTP 400: not a valid model ID).",
"Every task in this batch failed for this reason before doing any work.",
"Check Settings → Advanced → Subagent Model (or: hermes config get delegation.model)."]
with suppress(Exception):
from hermes_cli.fallback_config import get_fallback_chain
if not get_fallback_chain(config):
lines.append("No fallback chain is configured, so no failover was attempted.")
return lines
_TRUNCATED_SUMMARY_NOTE = (
"[TRUNCATED — subagent hit its iteration cap; the summary below "
"may be incomplete. Verify before relying on it, or re-dispatch "
"the unfinished part.]")
def _is_truncated(entry: dict) -> bool:
return bool(entry.get("truncated") or entry.get("exit_reason") == "max_iterations")
def _notice_lines(results) -> "list[str]":
"""Blank + model_not_found notice block, or [] when the notice does not apply."""
notice = _delegation_model_not_found_notice(results)
return ["", *notice] if notice else []
def _preamble(evt: dict, title: str, intro: str, completed_at: float, *, with_goal: bool) -> "list[str]":
"""Shared preamble: title, intro, blank, dispatch time, [goal], context/toolsets, role+model."""
lines = [title, intro, ""]
dispatched_at = evt.get("dispatched_at")
if isinstance(dispatched_at, (int, float)):
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(dispatched_at))
lines.append(f"Dispatched: {ts} ({_format_age(completed_at - dispatched_at)} ago)")
if with_goal:
lines.append(f"Original goal: {evt.get('goal', '') or ''}")
if evt.get("context"):
lines.append(f"Context you provided: {evt['context']}")
if evt.get("toolsets"):
lines.append(f"Toolsets: {', '.join(evt['toolsets'])}")
lines.append(f"Role: {evt.get('role') or 'leaf'} Model: {evt.get('model') or '?'}")
return lines
def _format_task_failure_notice(evt: dict, deleg_id: str) -> str:
"""One child of a still-running fan-out failed: say which, why, and that the batch goes on."""
(r,) = (evt.get("results") or [{}])[:1] or [{}]
goals, idx, n = evt.get("goals") or [], r.get("task_index", 0), evt.get("n_tasks") or len(evt.get("goals") or [])
goal = goals[idx] if idx < len(goals) else r.get("goal", "")
err = str(r.get("error") or "").strip().replace("\n", " ")[:400]
lines = [
f"[ASYNC DELEGATION TASK FAILED — {deleg_id}, task {idx + 1}/{n}]",
"One subagent in a background fan-out you dispatched has failed while its siblings are still running. "
"The batch's consolidated results will still arrive when the last sibling finishes; this is an early "
"warning so you can re-dispatch or investigate now instead of then.",
f"Task: {goal}" if goal else "",
f"Status: {r.get('status', '?')} Duration: {r.get('duration_seconds', '?')}s" + (f"\nError: {err}" if err else ""),
]
if r.get("live_transcript"):
lines.append(f"Live transcript: {r['live_transcript']}")
return "\n".join(line for line in lines if line)
def _format_batch_delegation(evt: dict, deleg_id: str, completed_at: float) -> str:
"""Consolidated block for a delegate_task fan-out that finished as one unit."""
results, goals = evt.get("results") or [], evt.get("goals") or []
# ``goals`` is the whole delegate_task call (task_index indexes it); ``results`` is this unit's subset.
n, n_unit = len(goals) or len(results), len(results) or len(goals)
group = evt.get("group")
unit = f"group '{group}' ({n_unit} subagent(s))" if group is not None else f"{n_unit} subagent(s)"
lines = _preamble(
evt,
f"[ASYNC DELEGATION BATCH COMPLETE — {deleg_id}]",
f"A background fan-out unit you dispatched earlier — {unit} — has finished; its consolidated results are "
"below. Any other units from the same delegate_task call report separately as they finish. You may have "
"moved on since dispatching — act on these or re-dispatch if things have changed. If you are still waiting "
"on siblings, end your turn after acting on this one.",
completed_at, with_goal=False)
lines[-1] += f" Total duration: {evt.get('total_duration_seconds', evt.get('duration_seconds', '?'))}s"
if evt.get("error") and not results:
lines += ["--- ERROR ---", f"The batch did not complete successfully: {evt['error']}"]
return "\n".join(lines)
# Config-level rejection notice BEFORE the per-task wall — a rejected
# delegation model fails every task identically and must not stay buried.
lines += _notice_lines(results)
for r in sorted(results, key=lambda x: x.get("task_index", 0)):
idx, r_truncated = r.get("task_index", 0), _is_truncated(r)
r_status, r_summary, r_error = r.get("status", "?"), r.get("summary"), r.get("error")
r_goal = goals[idx] if idx < len(goals) else r.get("goal", "")
icon = "⚠" if r_truncated else ("✓" if r_status in _DONE else "✗")
header = (f"--- {icon} TASK {idx + 1}/{n}" + (f": {r_goal}" if r_goal else "") + f" (status={r_status}"
+ (f", api_calls={r['api_calls']}" if r.get("api_calls") else "")
+ (f", {r['duration_seconds']}s" if r.get("duration_seconds") is not None else "")
+ (", TRUNCATED: hit max_iterations — work may be incomplete" if r_truncated else ""))
lines += ["", header + ") ---"]
if r_status in _DONE and r_summary:
if r_truncated:
lines.append(_TRUNCATED_SUMMARY_NOTE)
lines.append(r_summary)
elif r_summary:
if r_error:
lines.append(f"({r_status}: {r_error})")
lines += ["Partial output:", r_summary]
else:
lines.append(f"(no summary — status={r_status}" + (f": {r_error}" if r_error else "") + ")")
if r.get("live_transcript"):
lines.append(f"Full live transcript (complete tool/assistant trace): {r['live_transcript']}")
return "\n".join(lines)
def _format_async_delegation(evt: dict) -> str:
"""Self-contained re-injection for an async-delegation completion: the FULL
original task source (goal, context, toolsets, role, model), dispatch time, status
and result, so an agent deep in unrelated context can act on it or re-dispatch."""
deleg_id = evt.get("delegation_id", "unknown")
completed_at = evt.get("completed_at") or time.time()
if evt.get("task_failure_notice"):
return _format_task_failure_notice(evt, deleg_id)
if evt.get("is_batch") or isinstance(evt.get("results"), list):
return _format_batch_delegation(evt, deleg_id, completed_at)
status, summary, error = evt.get("status") or "completed", evt.get("summary"), evt.get("error")
truncated = _is_truncated(evt)
lines = _preamble(
evt,
f"[ASYNC DELEGATION COMPLETE — {deleg_id}]",
"A background subagent you dispatched earlier has finished. You may "
"have moved on since dispatching it; the full task source is below so "
"you can act on the result or re-dispatch if things have changed.",
completed_at, with_goal=True)
lines += _notice_lines([evt]) + [
f"Status: {status} API calls: {evt.get('api_calls', 0)} Duration: {evt.get('duration_seconds', '?')}s"
+ (" [TRUNCATED: hit max_iterations — work may be incomplete]" if truncated else ""),
"--- RESULT ---"]
if status in _DONE and summary:
if truncated:
lines.append(_TRUNCATED_SUMMARY_NOTE)
lines.append(summary)
else:
if status == "interrupted":
lines.append("The subagent was interrupted before completing" + (f": {error}" if error else "."))
else: # error / timeout / failed
lines.append(
f"The subagent did not complete successfully (status={status})." + (f"\n{error}" if error else ""))
if summary:
lines += ["Partial output:", summary]
return "\n".join(lines)
def async_delegation_display_text(evt: dict) -> str:
"""Compact UI title; the separate model notification retains all task evidence."""
raw_results = evt.get("results")
results = [r for r in raw_results if isinstance(r, dict)] if isinstance(raw_results, list) else []
results = results or [evt]
goals = evt.get("goals") or []
labels, titles = [], []
status_labels = {"failed": "Failed", "error": "Failed", "cancelled": "Cancelled",
"interrupted": "Interrupted", "timeout": "Timed Out", "stalled": "Stalled",
"unknown": "Unknown", "rejected": "Failed"}
for result in results:
status = result.get("status") or ("failed" if result.get("error") else "completed")
label = ("Incomplete" if _is_truncated(result) else "Completed") if status in _DONE else (
status_labels.get(status, "Incomplete"))
labels.append(label)
index = result.get("task_index", 0)
goal = goals[index] if 0 <= index < len(goals) else result.get("goal", "")
titles.append(" ".join(str(goal or "Background task").split()))
if len(results) == 1:
return f"Subagent Task {labels[0]}: {titles[0]}"
outcome = labels[0] if len(set(labels)) == 1 else "Finished with Issues"
title = " ".join(str(evt.get("group") or "").split()) or "; ".join(titles)
return f"Subagent Tasks {outcome}: {title} ({len(results)} tasks)"
class SubagentNotification(str):
"""Keep queued model text string-compatible, with a separate human preview."""
display_text: str
def __new__(cls, text: str, event: dict):
instance = super().__new__(cls, text)
instance.display_text = async_delegation_display_text(event)
return instance
def _delegation_attribution_line(evt: dict) -> "str | None":
"""One-line provenance for a subagent-owned process event, else None. Such a process
outlives the child and lands in the PARENT conversation, which would otherwise see an
anonymous output wall. Keyed on ``owner_task_id`` — ``task_id`` may be the session key."""
task_id = str(evt.get("owner_task_id") or evt.get("task_id") or "")
if not task_id.startswith("sa-"):
return None
info = None
with suppress(Exception):
from tools.delegate_tool import get_subagent_attribution
info = get_subagent_attribution(task_id)
if not info:
# Registry entry aged out — still attribute generically, not anonymously.
return f"Started by subagent {task_id} (delegate_task)."
goal, deleg = str(info.get("goal") or "").strip(), info.get("delegation_id")
goal = goal[:117] + "..." if len(goal) > 120 else goal
return (f"Started by subagent {task_id}" + (f" of delegation {deleg}" if deleg else "") + "."
+ (f' Task: "{goal}"' if goal else ""))
_REASON_STATUS = {"lost": "marked lost because the process backend disappeared", "failed_start": "failed to start"}
def _completion_status(evt: dict) -> str:
reason = evt.get("completion_reason") or "exited"
if reason == "killed":
return f"terminated by {evt.get('termination_source') or 'Hermes'}"
return _REASON_STATUS.get(reason) or ("completed normally" if evt.get("exit_code", "?") == 0 else "exited")
def format_process_notification(evt: dict) -> "str | None":
"""Format a completion_queue event into an ``[IMPORTANT: ...]`` message."""
evt_type = evt.get("type", "completion")
# watch_disabled and overflow events carry their own human-readable `message`;
# otherwise overflow events would fall through to the completion formatter as a
# phantom "process exited (exit code ?)".
if evt_type in ("watch_disabled", "watch_overflow_tripped", "watch_overflow_released"):
return f"[IMPORTANT: {evt.get('message', '')}]"
if evt_type == "async_delegation":
return _format_async_delegation(evt)
_sid, _cmd = evt.get("session_id", "unknown"), evt.get("command", "unknown")
_attribution = _delegation_attribution_line(evt)
attribution = f"{_attribution}\n" if _attribution else ""
if evt_type == "watch_match":
_sup = evt.get("suppressed", 0)
return (
f"[IMPORTANT: Background process {_sid} matched watch pattern \"{evt.get('pattern', '?')}\".\n"
f"{attribution}Command: {_cmd}\nMatched output:\n{evt.get('output', '')}"
+ (f"\n({_sup} earlier matches were suppressed by rate limit)" if _sup else "") + "]")
_exit = evt.get("exit_code", "?")
_out = evt.get("output", "")
_signal = ", SIGTERM" if _exit in {-15, 143, "-15", "143"} else ""
# A subagent-owned process's full output belongs in the child's transcript, not as
# a raw wall in the parent — trim hard but keep enough tail to recognise failures.
if _attribution and isinstance(_out, str) and len(_out) > 600:
_out = (
"...(output trimmed — subagent-owned process; see the "
"delegation's live transcript for full output)\n"
+ _out[-600:])
return (
f"[IMPORTANT: Background process {_sid} {_completion_status(evt)} (exit code {_exit}{_signal}).\n"
f"{attribution}Command: {_cmd}\nOutput:\n{_out}]")