Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 122 additions & 9 deletions agent/afk_heartbeat.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@

from __future__ import annotations

import json
import logging
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional

from agent.afk_state import (
Expand All @@ -42,6 +45,10 @@
HB_DAYS = (3, 7, 14)
STAND_BY_DELAY_HOURS_AFTER_HB3 = 24

# WS-AUTO-002 — dynamic recap config defaults (mirror afk_worker.DEFAULT_*)
DEFAULT_LOG_JSONL = "~/.hermes/afk_log.jsonl"
DEFAULT_RECAP_N_CYCLES = 20


def _parse_iso(ts: Optional[str]) -> Optional[datetime]:
if not ts:
Expand All @@ -52,6 +59,108 @@ def _parse_iso(ts: Optional[str]) -> Optional[datetime]:
return None


def _build_dynamic_recap(
state: AFKState,
n_recent_cycles: int = DEFAULT_RECAP_N_CYCLES,
cfg: Optional[dict] = None,
) -> str:
"""Build a deterministic recap of the AFK Work Loop activity since the
user entered AFK.

Reads ~/.hermes/afk_log.jsonl (written by agent.afk_worker), filters to
cycles with started_at >= state.entered_at, and produces a markdown
summary suitable for embedding in a Discord heartbeat message.

On any failure (file missing, corrupt JSONL, etc.) returns a fallback
string explicitly stating the data was unavailable — never raises.
"""
if cfg is None:
try:
from hermes_cli.config import load_config
cfg = (load_config() or {}).get("afk_worker") or {}
except Exception:
cfg = {}

jsonl_path = Path(
os.path.expanduser(cfg.get("log_jsonl_path") or DEFAULT_LOG_JSONL)
)
if not jsonl_path.is_file():
return (
"_(Aucun cycle AFK enregistré pour le moment — `~/.hermes/"
"afk_log.jsonl` absent.)_"
)

entered = _parse_iso(state.entered_at) if state.entered_at else None
cycles: list[dict] = []
try:
with jsonl_path.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
ts = _parse_iso(obj.get("started_at") or obj.get("ts"))
if entered is not None and ts is not None and ts < entered:
continue
cycles.append(obj)
except OSError as e:
return f"_(Erreur lecture `{jsonl_path}` : {e}.)_"

if not cycles:
return "_(0 cycle enregistré depuis le début de cet AFK.)_"

cycles = cycles[-n_recent_cycles:]
n_total = len(cycles)
n_completed = sum(1 for c in cycles if c.get("status") == "completed")
n_skip_q = sum(1 for c in cycles if c.get("status") == "skipped_quota")
n_skip_t = sum(1 for c in cycles if c.get("status") == "skipped_no_task")
n_blocked = sum(1 for c in cycles if c.get("status") == "skipped_decision_blocked")
n_errors = sum(1 for c in cycles if c.get("status") == "error")

completed_titles = [
(c.get("task") or {}).get("title", "?")
for c in reversed(cycles)
if c.get("status") == "completed"
][:3]

last = cycles[-1]
qa = last.get("quota_after") or {}
quota_line = ""
pct_5h = qa.get("minimax_5h_pct")
pct_w = qa.get("minimax_weekly_pct")
if pct_5h is not None or pct_w is not None:
quota_line = (
f"\n• Quota MiniMax au dernier tick : "
f"{pct_5h if pct_5h is not None else '?'}% (5h) / "
f"{pct_w if pct_w is not None else '?'}% (semaine)"
)

cooldown_line = ""
if (state.cooldown_level or 0) > 0:
cooldown_line = (
f"\n• ⚠️ Cooldown level **{state.cooldown_level}** actif "
f"(erreurs consécutives sur les délégations)"
)

titles_block = ""
if completed_titles:
titles_block = "\n• Dernières tâches complétées :\n - " + "\n - ".join(
completed_titles
)

return (
f"📊 **Récap AFK** — {n_total} cycle(s) depuis le début "
f"(complétés: {n_completed}, skip quota: {n_skip_q}, "
f"skip no-task: {n_skip_t}, blocked: {n_blocked}, errors: {n_errors})"
f"{titles_block}"
f"{quota_line}"
f"{cooldown_line}"
)


def _format_hb_message(hb_cycle: int, days_in_afk: int, state: AFKState) -> str:
"""Build the Discord message for a heartbeat at the given cycle."""
common_tail = (
Expand All @@ -64,24 +173,27 @@ def _format_hb_message(hb_cycle: int, days_in_afk: int, state: AFKState) -> str:

intervals_str = ", ".join(f"J{int(d)}" if d == int(d) else f"J{d}" for d in HB_DAYS)

# WS-AUTO-002 — replace the aspirational "to be filled by AH later"
# wording with a deterministic recap built from afk_log.jsonl.
recap = _build_dynamic_recap(state)

if hb_cycle == 1:
return (
f"💓 **Heartbeat 1/{len(HB_DAYS)} — J+{days_in_afk}** "
f"(intervals: {intervals_str}).\n\n"
f"Ça fait {days_in_afk} jours que tu es en AFK ({state.mode}). "
f"J'espère que tu vas bien.\n\n"
f"📊 État de mon travail (à compléter par AH lui-même au prochain "
f"démarrage de session — ce ping est juste l'alerte schedule).\n"
f"Voir ~/AFK_LOG.md pour le récap détaillé."
f"{recap}\n\n"
f"Détails complets : `~/AFK_LOG.md`."
) + common_tail

if hb_cycle == 2:
return (
f"💓 **Heartbeat 2/{len(HB_DAYS)} — J+{days_in_afk}**.\n\n"
f"Toujours pas de nouvelles depuis le HB1. Je continue mon travail "
f"selon les missions whitelist.\n\n"
f"📊 Récap à voir dans ~/AFK_LOG.md (mis à jour par AH à chaque "
f"changement d'état)."
f"Toujours pas de nouvelles depuis le HB1. Le worker AFK continue "
f"de tourner sur la backlog whitelist.\n\n"
f"{recap}\n\n"
f"Détails : `~/AFK_LOG.md`."
) + common_tail

if hb_cycle == 3:
Expand All @@ -92,8 +204,9 @@ def _format_hb_message(hb_cycle: int, days_in_afk: int, state: AFKState) -> str:
f"Khéri, ça fait {days_in_afk} jours sans nouvelles. Si tu ne réponds "
f"pas dans les **{sb_str}**, je passe en mode **stand_by** complet :\n"
f"• Plus de nouveaux commits / missions\n"
f"• Lecture / monitoring uniquement\n"
f"• Récap final posté ici\n\n"
f"• Worker AFK arrêté\n"
f"• Lecture / monitoring uniquement\n\n"
f"{recap}\n\n"
f"J'espère que tout va bien de ton côté."
) + common_tail

Expand Down
10 changes: 10 additions & 0 deletions agent/afk_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ def process_user_message(content: str, now_utc: Optional[datetime] = None) -> tu
# Persist if anything changed (mode flip OR last_user_msg_at update).
save_state(state)

# WS-AUTO-002 OQ4 — purge the AFK worker picked-index whenever AH flips
# back to normal. This guarantees the next AFK starts with a clean slate
# (no stale "already completed" entries from a previous session).
if state.mode != old_mode and state.mode == MODE_NORMAL:
try:
from agent.afk_worker import purge_picked_index
purge_picked_index()
except Exception:
logger.debug("afk-worker picked-index purge failed", exc_info=True)

if state.mode != old_mode:
logger.info("AFK state: %s -> %s (user msg: %r)", old_mode, state.mode, content[:80])

Expand Down
Loading