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
176 changes: 176 additions & 0 deletions agent/afk_heartbeat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""AFK heartbeat — Patch 13.4.

Implements the 3-cycle heartbeat schedule (option B per Khéri's choice):
HB1 at entered_at + 3 days
HB2 at entered_at + 7 days
HB3 at entered_at + 14 days
STAND_BY at HB3 + 24h with no user reply

Each heartbeat asks Khéri for orientation (continue / pivot / stop).
If three heartbeats pass without a user message arriving (which would
flip the state out of AFK via afk_scheduler.process_user_message), AH
flips to MODE_STAND_BY: stops new commits, just monitors and posts a
final summary daily.

Called from gateway/run.py:_start_cron_ticker every 60s. Uses the
Discord adapter to post if available.
"""

from __future__ import annotations

import logging
from datetime import datetime, timedelta, timezone
from typing import Optional

from agent.afk_state import (
AFKState,
AFK_MODES,
MODE_AFK_AUTO,
MODE_AFK_MANUAL,
MODE_NORMAL,
MODE_STAND_BY,
load_state,
now_iso,
save_state,
)

logger = logging.getLogger(__name__)

# Option B heartbeat schedule (Khéri's choice)
HB_DAYS = (3, 7, 14)
STAND_BY_DELAY_HOURS_AFTER_HB3 = 24


def _parse_iso(ts: Optional[str]) -> Optional[datetime]:
if not ts:
return None
try:
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
return None


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 = (
"\n\n**Que veux-tu que je fasse ?**\n"
"• `continue` — je poursuis sur la même direction\n"
"• `change` puis ta nouvelle consigne — je pivote\n"
"• `stop` ou `arrête` — j'arrête tout, mode normal\n"
"• Pas de réponse — je continue selon mon jugement et je te ping au prochain heartbeat"
)

if hb_cycle == 1:
return (
f"💓 **Heartbeat 1/3 — J+{days_in_afk}** (option B intervals: J3, J7, J14).\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é."
) + common_tail

if hb_cycle == 2:
return (
f"💓 **Heartbeat 2/3 — 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)."
) + common_tail

if hb_cycle == 3:
return (
f"💓 **Heartbeat 3/3 — J+{days_in_afk} — DERNIER PING**.\n\n"
f"Khéri, ça fait {days_in_afk} jours sans nouvelles. Si tu ne réponds "
f"pas dans les **24h**, 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"J'espère que tout va bien de ton côté."
) + common_tail

return f"💓 Heartbeat #{hb_cycle} (unexpected cycle)"


def evaluate_heartbeat(now_utc: Optional[datetime] = None) -> tuple[AFKState, Optional[str]]:
"""Decide whether to send a heartbeat or flip to stand_by.

Returns (new_state, message_to_post_or_None).

State machine:
mode in (afk_manual, afk_auto), heartbeats_sent=0:
if entered_at + 3d <= now → send HB1, heartbeats_sent=1
heartbeats_sent=1:
if entered_at + 7d <= now → send HB2, heartbeats_sent=2
heartbeats_sent=2:
if entered_at + 14d <= now → send HB3, heartbeats_sent=3,
last_heartbeat_at = now
heartbeats_sent=3:
if last_heartbeat_at + 24h <= now → flip to stand_by, send final notice
"""
if now_utc is None:
now_utc = datetime.now(timezone.utc)

state = load_state()

# Only act when in AFK modes (manual or auto)
if state.mode not in AFK_MODES:
return state, None

entered = _parse_iso(state.entered_at)
if entered is None:
# No entered_at recorded — set it now to avoid runaway HB triggers
state.entered_at = now_iso()
save_state(state)
return state, None

days_in_afk = (now_utc - entered).total_seconds() / 86400.0

# Determine which heartbeat is due
sent = state.heartbeats_sent
next_cycle = sent + 1 # cycles are 1-indexed in messages

if next_cycle <= len(HB_DAYS):
target_days = HB_DAYS[next_cycle - 1]
if days_in_afk < target_days:
# Not yet time for the next HB
return state, None

# Fire heartbeat next_cycle
state.heartbeats_sent = next_cycle
state.last_heartbeat_at = now_iso()
save_state(state)
msg = _format_hb_message(next_cycle, int(days_in_afk), state)
logger.info(
"AFK heartbeat %d/%d fired at J+%.1f (state=%s)",
next_cycle, len(HB_DAYS), days_in_afk, state.mode,
)
return state, msg

# Already sent 3 heartbeats — check stand_by transition
last_hb = _parse_iso(state.last_heartbeat_at)
if last_hb is None:
return state, None
hours_since_hb3 = (now_utc - last_hb).total_seconds() / 3600.0
if hours_since_hb3 < STAND_BY_DELAY_HOURS_AFTER_HB3:
return state, None

# Flip to stand_by
old_mode = state.mode
state.mode = MODE_STAND_BY
save_state(state)
final = (
f"🛑 **Bascule en mode `stand_by`** après 3 heartbeats sans réponse.\n\n"
f"AH passe en mode lecture/monitoring uniquement :\n"
f"• Plus de nouveaux commits / missions / labs\n"
f"• Récap final disponible dans ~/AFK_LOG.md\n"
f"• Tu peux me ré-activer à tout moment avec un nouveau message\n\n"
f"Ça fait {(now_utc - entered).days} jours en AFK ({old_mode}). "
f"À très bientôt Khéri."
)
logger.info(
"AFK stand_by triggered: 3 heartbeats sent, last %.1fh ago, no user reply",
hours_since_hb3,
)
return state, final
179 changes: 179 additions & 0 deletions agent/afk_scheduler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""AFK scheduler — Patch 13.3c.

Drives AFK state transitions:

process_user_message(content, now_utc) -> (state, confirm_msg_or_None)
Called from the Discord adapter on every user message. Detects AFK
triggers, revoke tokens, or auto-AFK exit (any user msg during
afk_auto). Updates last_user_msg_at. Returns a Discord-postable
confirmation when the mode flipped.

check_auto_transitions(now_utc) -> (state, notification_or_None)
Called from the gateway cron ticker every 60s. Flips state to
afk_auto if 20:00 GMT-3 (= 23:00 UTC) reached AND the user has been
idle for ≥30 min AND the current mode is normal.

Both functions persist the resulting state to ~/.hermes/afk_state.json
via agent.afk_state.save_state.
"""

from __future__ import annotations

import logging
from datetime import datetime, time, timedelta, timezone
from typing import Optional

from agent.afk_state import (
AFKState,
MODE_AFK_AUTO,
MODE_AFK_MANUAL,
MODE_NORMAL,
is_afk_from_messages,
load_state,
now_iso,
save_state,
)

logger = logging.getLogger(__name__)

# Auto-AFK trigger window: 20:00 GMT-3 = 23:00 UTC.
# Configurable via afk_auto_trigger_hour_utc env-style override later.
AFK_AUTO_TRIGGER_HOUR_UTC = 23 # 20:00 GMT-3
IDLE_THRESHOLD_MINUTES = 30


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def process_user_message(content: str, now_utc: Optional[datetime] = None) -> tuple[AFKState, Optional[str]]:
"""Update AFK state on every incoming user message.

Returns (new_state, confirmation_message_or_None). The caller (Discord
adapter) should post the confirmation to #acos-hermes if non-None.
"""
if now_utc is None:
now_utc = datetime.now(timezone.utc)
now_str = now_utc.strftime("%Y-%m-%dT%H:%M:%SZ")

state = load_state()
old_mode = state.mode
state.last_user_msg_at = now_str

confirm: Optional[str] = None
content_lower = (content or "").lower()

# Lazy import to avoid circular ref; tokens are the source of truth.
from agent.provenance import _USER_AFK_TOKENS, _USER_REVOKE_TOKENS

has_afk_trigger = any(token in content_lower for token in _USER_AFK_TOKENS)
has_revoke = any(token in content_lower for token in _USER_REVOKE_TOKENS)

# 1. Revoke takes precedence: if user says "stop" while in AFK, exit AFK.
if has_revoke and state.is_afk():
state.mode = MODE_NORMAL
state.entered_at = None
state.heartbeats_sent = 0
state.cooldown_level = 0
confirm = (
"🔔 Mode AFK désactivé (revoke détecté). Je reviens en mode normal — "
"publish externe ré-autorisé, MAX_DEPTH redescend à 8."
)

# 2. AFK manual trigger (only if not already in AFK).
elif has_afk_trigger and not state.is_afk():
state.mode = MODE_AFK_MANUAL
state.entered_at = now_str
confirm = (
"🌙 **Mode AFK manuel activé.**\n"
"• MAX_DEPTH levé à 50 → je peux chainer 50 tool calls "
"sans te redemander confirmation\n"
"• Publish externe désactivé : git push, gh pr create, "
"gh issue create, slack/telegram/email, branch protection, "
"writes sur env.list / HERMES.md / SOUL.md → bloqués\n"
"• Travail local autorisé : cargo build/test, qemu, commits "
"locaux, posts sur ce salon\n"
"• Tu peux désactiver à tout moment avec : `stop`, `arrête`, "
"`cancel`, ou simplement un nouveau message qui n'a pas de "
"trigger AFK.\n\n"
"💤 Bonne nuit Khéri. Je continue selon ta direction."
)

# 3. Auto-AFK exit: any user msg flips state back to normal (option C
# default per Khéri's spec — option A "termine la tâche en cours puis
# répond" is signalled separately by the agent runtime, this hook
# only updates persistent state).
elif state.mode == MODE_AFK_AUTO and not has_afk_trigger and not has_revoke:
state.mode = MODE_NORMAL
state.entered_at = None
state.heartbeats_sent = 0
state.cooldown_level = 0
confirm = (
"👋 Je vois que tu es de retour. Je sors du mode AFK auto et "
"reprends le mode normal. Je termine la tâche en cours puis "
"je te réponds — dis-moi `stop` si tu veux que j'interrompe "
"tout maintenant."
)

# Persist if anything changed (mode flip OR last_user_msg_at update).
save_state(state)

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

return state, confirm


def check_auto_transitions(now_utc: Optional[datetime] = None) -> tuple[AFKState, Optional[str]]:
"""Evaluate auto-AFK trigger: 20:00 GMT-3 + idle ≥ 30min + state normal.

Called by the gateway cron ticker every 60s. Returns (state, notification
or None). Notification is a Discord-postable string when auto-AFK fires.
"""
if now_utc is None:
now_utc = datetime.now(timezone.utc)

state = load_state()

# Only flip to afk_auto from normal mode. AFK manual / standby are
# explicit user states and shouldn't be overridden by the clock.
if state.mode != MODE_NORMAL:
return state, None

# Time-of-day trigger: 20:00 GMT-3 == 23:00 UTC. Allow a 1-hour window
# (23:00-23:59 UTC) so the cron tick that lands at 23:01 still triggers.
if now_utc.hour != AFK_AUTO_TRIGGER_HOUR_UTC:
return state, None

# Idle check: last_user_msg_at older than IDLE_THRESHOLD_MINUTES.
if state.last_user_msg_at is None:
# No record of any user msg — assume idle long enough to trigger.
idle_minutes = float("inf")
else:
try:
last = datetime.strptime(state.last_user_msg_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
idle_minutes = (now_utc - last).total_seconds() / 60.0
except (ValueError, TypeError):
idle_minutes = float("inf")

if idle_minutes < IDLE_THRESHOLD_MINUTES:
return state, None

# Conditions met — flip to afk_auto.
state.mode = MODE_AFK_AUTO
state.entered_at = now_iso()
save_state(state)
logger.info(
"AFK auto triggered at %s (idle %.1f min)",
now_utc.isoformat(), idle_minutes,
)
notification = (
"🌙 **Mode AFK auto activé** (20:00 GMT-3 + idle ≥ 30 min).\n"
"• MAX_DEPTH levé à 50, publish externe désactivé\n"
"• Je vais chercher des tâches in_progress dans ~/SMCP_STATUS.md, "
"~/WS*_STATUS.md, ~/acos/docs/ROADMAP.md\n"
"• Je te ping en heartbeat à J+3, J+7, J+14 (option B)\n"
"• Tu peux annuler à tout moment avec `stop` / `arrête` / nouveau msg\n\n"
"💤 Bonne soirée Khéri."
)
return state, notification
Loading