Files
hermes-agent/tests/tools/test_delegate_liveness_timeout.py
teknium1 97beaeeeb3 feat(delegate): warn the child at 80% of its inactivity window before abandoning it
A child that stalls under a configured delegation.child_timeout_seconds used to
learn about the budget only by dying, losing its whole context. The liveness
wait now queues a one-line "[delegation budget warning]" through the child's
steer channel once the idle window is 80% spent (delivered at the child's next
iteration boundary), so a slow-but-recoverable child can wrap up and return its
summary. The warning fires once per idle window and re-arms when progress
resets the window; a progressing child never sees it.

Part of #116001 (atom 2A). Semantics of child_timeout_seconds are unchanged.
2026-09-20 15:50:20 -07:00

130 lines
5.3 KiB
Python

"""``delegation.child_timeout_seconds`` bounds INACTIVITY, not total runtime (#116001).
A configured cap used to be a dispatch-to-death stopwatch: every child that ran longer than the budget was
abandoned even while the provider was actively serving it. Measured over 219 tasks / 75 deaths, 0 of 75 deaths
happened mid-tool — all of them were waiting on an in-flight LLM completion, so the cap converted transient
provider slowness into lost work (a nearly-finished context discarded every time).
These tests pin both directions of the fix:
- a child whose activity signals keep advancing outlives a cap far shorter than its runtime;
- a child with no progress at all is still abandoned when the cap elapses (the knob keeps its teeth).
"""
from __future__ import annotations
import threading
import time
from types import SimpleNamespace
from tools import delegate_tool
_CAP_SECONDS = 0.4
class _SlowButLiveChild:
"""A provider serving multi-minute completions: the runtime outlasts the cap, but progress never stops.
``advance=False`` models the opposite case — a genuinely frozen child (no completed call, no tool change,
no activity-clock tick).
"""
def __init__(self, *, total_seconds: float = 1.2, advance: bool = True, initial_calls: int = 0) -> None:
self.tool_progress_callback = None
self._credential_pool = None
self._delegate_saved_tool_names = []
self._delegate_role = "leaf"
self._delegate_depth = 1
self._subagent_id = None
self.session_id = "live-child"
self.model = "test/model"
self._total_seconds = total_seconds
self._advance = advance
self._calls = initial_calls
self._activity_ts = time.time()
self.interrupted = threading.Event()
self.steers: list[tuple[float, str]] = [] # (monotonic time, text) of every budget warning received
def steer(self, text: str) -> bool:
self.steers.append((time.monotonic(), text))
return True
def run_conversation(self, **_kwargs):
deadline = time.monotonic() + self._total_seconds
while time.monotonic() < deadline:
time.sleep(0.05)
if self._advance:
self._calls += 1
self._activity_ts = time.time()
return {
"final_response": "FINISHED AFTER OUTLIVING THE CAP",
"completed": True,
"api_calls": self._calls,
"messages": [],
}
def get_activity_summary(self):
return {
"api_call_count": self._calls,
"current_tool": None,
"last_activity_ts": self._activity_ts,
"max_iterations": 50,
}
def hard_interrupt(self, *_args, **_kwargs):
self.interrupted.set()
def close(self):
pass
def _run(child, monkeypatch, cap=_CAP_SECONDS):
parent = SimpleNamespace(
session_id="parent", _current_task_id=None, _active_children=[child],
_active_children_lock=threading.Lock(), _touch_activity=lambda _d: None, _interrupt_requested=False,
)
monkeypatch.setattr(delegate_tool, "_get_child_timeout", lambda: cap)
monkeypatch.setattr(delegate_tool, "_get_worktree_isolation", lambda: False)
return delegate_tool._run_single_child(0, "watch the slow provider", child=child, parent_agent=parent)
def test_progressing_child_outlives_a_cap_shorter_than_its_runtime(monkeypatch):
child = _SlowButLiveChild(total_seconds=1.2, advance=True)
entry = _run(child, monkeypatch)
assert entry["status"] == "completed", entry
assert entry["summary"] == "FINISHED AFTER OUTLIVING THE CAP", entry
# The cap really was armed and the child really did run past it: the budget reset on progress.
assert entry["duration_seconds"] > _CAP_SECONDS, entry
assert not child.interrupted.is_set()
# Progress kept resetting the window, so the 80% budget warning never had cause to fire.
assert child.steers == [], child.steers
def test_frozen_child_is_still_abandoned_when_the_cap_elapses(monkeypatch):
"""The reported death shape: calls completed earlier, then the child stops moving entirely."""
child = _SlowButLiveChild(total_seconds=1.2, advance=False, initial_calls=49)
entry = _run(child, monkeypatch)
assert entry["status"] == "timeout", entry
assert "no progress in that window" in entry["error"], entry["error"]
assert entry["timeout_seconds"] == _CAP_SECONDS
assert entry["last_event_age"] is not None and entry["last_event_age"] > 0.3, entry
assert child.interrupted.is_set()
def test_frozen_child_is_warned_once_at_80_percent_of_the_window(monkeypatch):
"""Atom 2A of #116001: a stalling child hears about the closing window while it can still wrap up."""
child = _SlowButLiveChild(total_seconds=1.2, advance=False, initial_calls=3)
started = time.monotonic()
entry = _run(child, monkeypatch)
assert entry["status"] == "timeout", entry
assert len(child.steers) == 1, child.steers
warned_at, text = child.steers[0]
assert "[delegation budget warning]" in text and f"{_CAP_SECONDS:.0f}s inactivity window" in text, text
# Fired inside the window (after ~80% of it, before the kill), not at the timeout itself.
assert 0.8 * _CAP_SECONDS - 0.05 <= warned_at - started < _CAP_SECONDS, (warned_at - started, _CAP_SECONDS)