Files
hermes-agent/evals/heartbeat_idle_wire.py
T
Teknium 59eb509f4c fix: refund heartbeat admissions that never enter agent execution
Bind settlement to the exact adapter task and event. Rejected or cancelled preparation refunds the existing claim; cancellation after the agent runner starts remains counted. Keep profile-scoped callback context and the manager replacement guard. A fire count is not outbound delivery proof. Drop departed routes rather than executing their stale schedule.

Slim accounting-invariant salvage of #93174; preserve current direct adapter dispatch instead of reviving its FIFO/inflight implementation. Prior art #92858.

Co-authored-by: Finn763 <165816600+Finn763@users.noreply.github.com>

Co-authored-by: fangliquanflq <fangliquan@qq.com>
2026-09-07 14:16:57 -07:00

152 lines
7.1 KiB
Python

"""Wire-contract probe: real poller, SQLite, adapter lifecycle; fake model/transport.
Run from the repo with a clean environment and a temporary HERMES_HOME:
.venv/bin/python evals/heartbeat_idle_wire.py
Pass --base-poller /tmp/run_goals_base.py to compare the old poller. No network.
"""
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from gateway.config import GatewayConfig, Platform, PlatformConfig # noqa: E402
from gateway.platforms.base import BasePlatformAdapter, SendResult # noqa: E402
from gateway.platforms.event import MessageEvent # noqa: E402
from gateway.run import GatewayRunner # noqa: E402
from gateway.session import SessionSource, SessionStore, build_session_key # noqa: E402
from hermes_cli import heartbeat # noqa: E402
class WireAdapter(BasePlatformAdapter):
async def connect(self, *, is_reconnect=False):
return True
async def disconnect(self):
pass
async def get_chat_info(self, chat_id):
return {}
async def send(self, chat_id, content, reply_to=None, metadata=None):
self.wire.append(content)
return SendResult(success=True, message_id=str(len(self.wire)))
async def main(base_poller):
assert os.environ.get("HERMES_HOME"), "Use a temporary HERMES_HOME"
runner = object.__new__(GatewayRunner)
runner._running_agents = {}
runner._run_in_executor_with_context = asyncio.to_thread
adapter = WireAdapter(PlatformConfig(enabled=True, typing_indicator=False), Platform.TELEGRAM)
adapter.wire = []
runner._adapter_for_source = lambda source: adapter
source = SessionSource(platform=Platform.TELEGRAM, chat_id="42", user_id="42", chat_type="dm")
key = build_session_key(source)
watch = {key: (source, "wire-session")}
if base_poller:
scope = {"__name__": "heartbeat_base"}
exec(compile(Path(base_poller).read_text(encoding="utf-8"), base_poller, "exec"), scope)
runner._heartbeat_poll_once = scope["GatewayGoalsMixin"]._heartbeat_poll_once.__get__(runner)
await runner._warm_goals_session_db("wire-contract")
clock = SimpleNamespace(now=1000.0)
received = []
release = asyncio.Event()
async def handler(event):
event._heartbeat_execution_started = True # fake agent execution boundary
received.append(event.text)
await release.wait()
return "wire reply"
async def drain():
while adapter._background_tasks:
await asyncio.gather(*list(adapter._background_tasks))
def snapshot():
return {"turns": len(received), "queue_depth": runner._queue_depth(key, adapter=adapter),
"fire_count": heartbeat.HeartbeatManager("wire-session").state.fire_count}
adapter.set_message_handler(handler)
await adapter.connect()
with patch.object(heartbeat, "time", SimpleNamespace(time=lambda: clock.now)):
heartbeat.HeartbeatManager("wire-session").set("check status", 60)
for _ in range(15):
clock.now += 60
await runner._heartbeat_poll_once(watch)
await asyncio.sleep(0)
print(json.dumps({"phase": "15 minute polls, first turn held", **snapshot()}))
release.set()
await adapter.handle_message(MessageEvent(text="real-user-wire-input", source=source))
await drain()
print(json.dumps({"phase": "user wake drain", "wire_sends": len(adapter.wire), **snapshot()}))
if not base_poller:
assert len(received) == 2 and len(adapter.wire) == 2
assert snapshot()["queue_depth"] == 0
# A genuinely idle 15-minute gap coalesces to one, even with repeated polls.
heartbeat.HeartbeatManager("wire-session").set("check status", 60)
before = len(received)
clock.now += 900
for _ in range(5):
await runner._heartbeat_poll_once(watch)
await drain()
assert len(received) - before == 1
assert heartbeat.HeartbeatManager("wire-session").state.fire_count == 1
print(json.dumps({"phase": "idle 15-minute gap + 5 same-time polls", "new_turns": 1,
"queue_depth": snapshot()["queue_depth"], "fire_count": 1}))
# A pinned route must not enter the pre-claim executor recovery gap.
# The callback represents competing traffic changing the last active topic.
recovery = []
adapter._topic_recovery_fn = lambda source: recovery.append(source) or "foreign-topic"
clock.now += 60
await runner._heartbeat_poll_once(watch)
assert key in adapter._active_sessions and recovery == []
await drain()
print(json.dumps({"phase": "pinned route", "recovery_calls": len(recovery)}))
# Admission can be cancelled before the fake agent boundary is reached.
clock.now += 60
before = heartbeat.HeartbeatManager("wire-session").state.to_json()
turns_before = len(received)
await runner._heartbeat_poll_once(watch)
await adapter.cancel_session_processing(key)
await drain()
assert len(received) == turns_before
assert heartbeat.HeartbeatManager("wire-session").state.to_json() == before
print(json.dumps({"phase": "cancelled admission", "claim_refunded": True}))
runner.config = GatewayConfig()
runner.session_store = SessionStore(
sessions_dir=Path(os.environ["HERMES_HOME"]) / "sessions", config=runner.config)
runner.adapters = {Platform.TELEGRAM: adapter}
runner._profile_adapters = {}
runner._recover_telegram_topic_thread_id = adapter._topic_recovery_fn
event = MessageEvent(text="pinned heartbeat", source=source,
metadata={"gateway_session_key": key})
resolved = await runner._hmwa_resolve_session(event, source)
assert resolved is not None and resolved[2] == key and recovery == []
print(json.dumps({"phase": "runner pinned route", "session_key": resolved[2],
"recovery_calls": len(recovery)}))
# Route mismatch is a real adapter rejection, not a fabricated return value.
adapter._session_key_profile = lambda source: "different-profile"
route_sid = resolved[1].session_id
heartbeat.HeartbeatManager(route_sid).set("check status", 60)
clock.now += 60
before = heartbeat.HeartbeatManager(route_sid).state.to_json()
await runner._heartbeat_poll_once(watch)
assert watch[key][1] == route_sid
assert heartbeat.HeartbeatManager(route_sid).state.to_json() == before
assert snapshot()["queue_depth"] == 0
print(json.dumps({"phase": "rejected route", "claim_refunded": True}))
await adapter.disconnect()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--base-poller")
asyncio.run(main(parser.parse_args().base_poller))