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:
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user