feat(steering): mesaje mid-tur + /stop pe turul în zbor

Un al doilea mesaj trimis cât Claude încă lucra aștepta până se termina
turul 1 — corecția „stai, nu în master" ajungea după ce greșeala era gata.
Verificat în producție înainte de commit: mesajul 2 stătea 25s blocat în
lock, apoi pornea ca tur separat.

Acum canalele de chat pot ține un proces `claude` viu per canal, cu stdin
deschis, și al doilea mesaj intră în ACELAȘI tur.

- `src/claude_runner.py` — ClaudeProcess (steering, respawn cu --resume,
  drenare stderr, respawn la comutarea OpenRouter) + RunnerRegistry
  (max_live, reaper pe inactivitate, stop_all la shutdown)
- `src/stream_json.py` — parser stream-json partajat cu `_run_claude`;
  pur, nu aruncă niciodată pe is_error (PlanningSession retrimite pe
  error_max_turns și depinde de asta)
- `src/sentinels.py` — un singur loc pentru __AUDIO__/__STEERED__, în loc
  de 4 verificări copiate; repară și bug-ul preexistent prin care
  WhatsApp posta literal `__AUDIO__:/cale`
- dispecer în `send_message`: lock.acquire(blocking=False) — eșecul de a
  lua lock-ul ESTE „rulează un tur", ceea ce elimină flagul inflight din
  decizie și cursa TOCTOU odată cu el
- `/stop` oprește turul, nu sesiunea — active.json rămâne valid
- rate limit prin proces persistent vine ca result.is_error, nu ca exit
  code; convertit înapoi în același RuntimeError, altfel fallback-ul
  local nu s-ar mai declanșa niciodată, în tăcere

Steering-ul nu face niciodată cross-adapter (un mesaj text nu intră
într-un tur voice: împart același channel_id). Mesajele steered dintr-un
tur care pică sunt re-livrate, nu pierdute.

Testat live cu CLI-ul real: corecție la secunda 10 dintr-un tur de 24s,
un singur result, num_turns=2. Notă: mesajele steered sunt împachetate în
[EXTERNAL CONTENT], deci o corecție formulată ca override agresiv poate
fi refuzată ca prompt injection — pentru oprire folosește /stop.

Suită: 1199 passed, 12 failed (toate pre-existente pe HEAD curat).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SiJGsZVSEGjRHZEJiXaxCC
This commit is contained in:
2026-09-02 11:05:58 +00:00
parent 2d8ee9b581
commit 747afbaf9d
23 changed files with 4183 additions and 95 deletions

View File

@@ -29,6 +29,7 @@ from src.router import (
start_planning_session,
)
from src.adapters._text_chunks import split_message
from src.sentinels import audio_path as _sentinel_audio_path, is_steered as _sentinel_is_steered
from src.adapters.discord_views import (
RalphRootView,
PlanningActiveView,
@@ -1036,8 +1037,8 @@ def create_bot(config: Config) -> discord.Client:
total = len(chunks)
for i, chunk in enumerate(chunks, 1):
result = await asyncio.to_thread(fast_dispatch, "audio", [voice, chunk])
if result and result.startswith("__AUDIO__:"):
wav_path = result[len("__AUDIO__:"):]
wav_path = _sentinel_audio_path(result)
if wav_path:
ogg_path = await asyncio.to_thread(_wav_to_ogg, wav_path)
try:
ext = "ogg" if ogg_path.endswith(".ogg") else "wav"
@@ -1066,8 +1067,8 @@ def create_bot(config: Config) -> discord.Client:
if rezumat:
args.append("rezumat")
result = await asyncio.to_thread(fast_dispatch, "audio", args)
if result and result.startswith("__AUDIO__:"):
wav_path = result[len("__AUDIO__:"):]
wav_path = _sentinel_audio_path(result)
if wav_path:
ogg_path = await asyncio.to_thread(_wav_to_ogg, wav_path)
try:
ext = "ogg" if ogg_path.endswith(".ogg") else "wav"
@@ -1298,11 +1299,18 @@ def create_bot(config: Config) -> discord.Client:
adapter_name="discord",
)
# Steered: message was injected into an in-flight turn — react,
# post nothing.
if _sentinel_is_steered(response):
try:
await message.add_reaction("➡️")
except Exception:
logger.warning("steered reaction failed", exc_info=True)
# Only send the final combined response if no intermediates
# were delivered (avoids duplicating content).
if sent_count == 0:
if response.startswith("__AUDIO__:"):
wav_path = response[len("__AUDIO__:"):]
elif sent_count == 0:
wav_path = _sentinel_audio_path(response)
if wav_path:
await message.channel.send(
file=discord.File(wav_path, filename="echo-audio.wav")
)

View File

@@ -47,6 +47,7 @@ from src.router import (
start_planning_session,
)
from src.planning_session import is_in_planning
from src.sentinels import audio_path as _sentinel_audio_path, is_steered as _sentinel_is_steered
WORKSPACE_DIR = Path("/home/moltbot/workspace")
ADAPTER_NAME = "telegram"
@@ -747,8 +748,6 @@ async def callback_ralph(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
# --- Audio helpers ---
_AUDIO_PREFIX = "__AUDIO__:"
async def _send_voice_telegram(update: Update, wav_path: str) -> None:
"""Convertește WAV→OGG (ffmpeg) și trimite ca voice note Telegram."""
@@ -787,8 +786,8 @@ async def _fast_cmd(update: Update, name: str, args: list[str]) -> None:
await update.message.chat.send_action(ChatAction.TYPING)
result = await asyncio.to_thread(fast_dispatch, name, args)
if result:
if result.startswith(_AUDIO_PREFIX):
wav_path = result[len(_AUDIO_PREFIX):]
wav_path = _sentinel_audio_path(result)
if wav_path:
await _send_voice_telegram(update, wav_path)
else:
for chunk in split_message(result):
@@ -1034,18 +1033,22 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
adapter_name=ADAPTER_NAME,
)
# Only send combined response if no intermediates were delivered
if sent_count == 0:
chunks = split_message(response)
for chunk in chunks:
await message.reply_text(chunk)
if _sentinel_is_steered(response):
# Injected into an in-flight turn — react, post nothing.
reaction = "➡️"
else:
# Only send combined response if no intermediates were delivered
if sent_count == 0:
chunks = split_message(response)
for chunk in chunks:
await message.reply_text(chunk)
reaction = "✅"
# Emoji reaction: ✅ = răspuns trimis
try:
await context.bot.set_message_reaction(
chat_id=chat_id,
message_id=message.message_id,
reaction=[ReactionTypeEmoji(emoji="✅")],
reaction=[ReactionTypeEmoji(emoji=reaction)],
)
except Exception:
pass

View File

@@ -1,13 +1,16 @@
"""WhatsApp adapter for Echo Core — connects to Node.js bridge."""
import asyncio
import base64
import logging
import os
import httpx
from src.config import Config
from src.router import route_message
from src.claude_session import clear_session, get_active_session
from src.sentinels import audio_path as _sentinel_audio_path, is_steered as _sentinel_is_steered
log = logging.getLogger("echo-core.whatsapp")
_security_log = logging.getLogger("echo-core.security")
@@ -104,6 +107,32 @@ async def send_whatsapp(client: httpx.AsyncClient, to: str, text: str) -> bool:
return False
async def send_whatsapp_audio(client: httpx.AsyncClient, to: str, wav_path: str) -> bool:
"""Send a WAV file via the bridge's /send-document endpoint.
The Baileys bridge has no dedicated voice-note (PTT) route, only
generic document upload — so this arrives as a playable attachment,
not a mic-bubble voice note. Good enough to not lose the audio.
"""
try:
with open(wav_path, "rb") as f:
data_b64 = base64.b64encode(f.read()).decode()
resp = await client.post(
f"{_bridge_url}/send-document",
json={
"to": to,
"filename": "echo-audio.wav",
"mimetype": "audio/wav",
"data_base64": data_b64,
},
timeout=30,
)
return resp.status_code == 200 and resp.json().get("ok", False)
except Exception as e:
log.error("Send audio error: %s", e)
return False
async def react_whatsapp(
client: httpx.AsyncClient, to: str, message_id: str, emoji: str,
*, from_me: bool = False, participant: str | None = None,
@@ -222,23 +251,36 @@ async def handle_incoming(msg: dict, client: httpx.AsyncClient) -> None:
)
sent_count += 1
steered = False
try:
response, _is_cmd = await asyncio.to_thread(
route_message, channel_id, user_id, text,
on_text=on_text,
adapter_name="whatsapp",
)
# Only send combined response if no intermediates were delivered
if sent_count == 0:
await send_whatsapp(client, sender, response)
if _sentinel_is_steered(response):
# Injected into an in-flight turn — react, post nothing.
steered = True
elif sent_count == 0:
# Only send combined response if no intermediates were delivered
wav_path = _sentinel_audio_path(response)
if wav_path:
if not await send_whatsapp_audio(client, sender, wav_path):
await send_whatsapp(client, sender, "Nu am putut trimite audio.")
try:
os.unlink(wav_path)
except OSError:
pass
else:
await send_whatsapp(client, sender, response)
except Exception as e:
log.error("Error handling message from %s: %s", user_id, e)
await send_whatsapp(client, sender, "Sorry, an error occurred.")
finally:
# Remove eyes reaction after responding
# Swap eyes for an arrow when steered, otherwise just clear it.
if message_id:
await react_whatsapp(
client, sender, message_id, "",
client, sender, message_id, "➡️" if steered else "",
from_me=from_me,
participant=msg.get("participant"),
)