Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/runner.py
Claude Agent c42b95b7db feat(discord-bridge): imagini si fisiere text din Discord ajung la Claude
Puntea trimitea doar `content`-ul mesajului; orice atasament disparea tacut, iar
un mesaj fara text (doar poza) era respins ca "empty". Acum mesajul de utilizator
se construieste ca blocuri, in formatul pe care CLI-ul il accepta pe
`--input-format stream-json` (verificat pe CLI real: Claude descrie corect o
imagine trimisa asa).

- imagini png/jpeg/gif/webp -> blocuri `image` base64, max 4, max 3,5 MB brut
  (base64 umfla cu ~4/3, iar API-ul refuza peste ~5 MB codate)
- fisiere text (mime `text/*`, `application/json`, sau extensie cunoscuta) ->
  continutul intra in prompt, max 4, trunchiat la 100 KB
- restul (PDF, Office, arhive, svg, heic) -> doar numite, cu motivul

Detalii care conteaza:
- `image/jpg` si `image/png; charset=...` se normalizeaza; cand Discord nu
  trimite content_type cadem pe extensie
- marimea se verifica de doua ori: cea declarata (ca sa nu descarcam degeaba) si
  cea reala dupa descarcare
- nimic nu dispare tacut: ce n-a putut fi citit apare in prompt ca
  "Atasamente ignorate: ..."; o imagine stricata nu anuleaza restul mesajului
- merge si mid-tur: o poza trimisa in timpul unui tur intra pe stdin ca steering

31 de teste noi in tests/test_attachments.py (normalizare tipuri, limite,
trunchiere, erori de descarcare, integrare prin punte, steering). Suita: 426 pass.
README: sectiune "Atasamente" + limitarea veche corectata (ramane doar vocea).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
2026-08-31 17:47:48 +00:00

400 lines
14 KiB
Python

"""Proces `claude` PERSISTENT per fir, alimentat pe stdin cu --input-format stream-json.
De ce persistent (T4): asa se poate face steering mid-tur -- un mesaj trimis in timpul
unui tool call lung ajunge la model si schimba raspunsul final (verificat in plan).
Totul e asyncio: NICIODATA readline blocant. stderr are task propriu si buffer circular.
"""
from __future__ import annotations
import asyncio
import collections
import contextlib
import json
import logging
import os
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Awaitable, Callable
import config
import stream as stream_mod
log = logging.getLogger("discord-bridge.runner")
# linii mari (tool results) -> limita generoasa pe StreamReader
_READ_LIMIT = 16 * 1024 * 1024
STDERR_TAIL = 50
class TurnFailed(RuntimeError):
"""Turul nu s-a putut duce la capat (proces mort, EOF inainte de result)."""
class TurnTimeout(TurnFailed):
"""Turul a depasit timpul maxim."""
@dataclass
class SessionRestarted:
"""Eveniment sintetic: procesul murise, l-am repornit cu --resume."""
thread_id: str
sid: str | None
reason: str = "procesul claude murise"
@property
def text(self) -> str:
return f"sesiune repornita ({self.reason})"
@dataclass
class TurnOutcome:
result: stream_mod.Result | None
restarted: bool = False
events: list[Any] = field(default_factory=list)
def build_cmd(
claude_bin: str | list[str],
model: str,
sid: str | None = None,
settings: str | os.PathLike | None = None,
extra: tuple[str, ...] = (),
) -> list[str]:
"""Comanda exacta din plan; `--resume <sid>` doar la respawn."""
base = [claude_bin] if isinstance(claude_bin, str) else list(claude_bin)
cmd = base + [
"-p",
"--input-format", "stream-json",
"--output-format", "stream-json",
"--verbose",
"--permission-mode", "bypassPermissions",
]
if settings:
cmd += ["--settings", os.fspath(settings)]
cmd += ["--model", model, "--autocompact", "auto"]
if sid:
cmd += ["--resume", sid]
cmd += list(extra)
return cmd
def user_message(content: str | list[dict]) -> str:
blocks = [{"type": "text", "text": content}] if isinstance(content, str) else content
return json.dumps(
{"type": "user", "message": {"role": "user", "content": blocks}},
ensure_ascii=False,
)
class ClaudeProcess:
"""Un proces claude viu, legat de un fir Discord."""
def __init__(
self,
thread_id: str,
cwd: str,
model: str,
sid: str | None = None,
*,
claude_bin: str | list[str] | None = None,
settings: str | os.PathLike | None = None,
env: dict[str, str] | None = None,
on_pid: Callable[[str, int | None], Any] | None = None,
):
self.thread_id = str(thread_id)
self.cwd = cwd
self.model = model
self.sid = sid
self.claude_bin = claude_bin or config.get("CLAUDE_BIN", "claude")
self.settings = settings
self.env = env
self.on_pid = on_pid
self.proc: asyncio.subprocess.Process | None = None
self.stderr_buf: collections.deque[str] = collections.deque(maxlen=STDERR_TAIL)
self.last_active = time.monotonic()
self.inflight = False
self.started_turns = 0
self.restarts = 0
self._stderr_task: asyncio.Task | None = None
self._parser = stream_mod.StreamParser()
self._start_lock = asyncio.Lock()
# --------------------------------------------------------------- proces
@property
def alive(self) -> bool:
return self.proc is not None and self.proc.returncode is None
@property
def pid(self) -> int | None:
return self.proc.pid if self.proc is not None else None
async def start(self) -> None:
async with self._start_lock:
if self.alive:
return
cmd = build_cmd(self.claude_bin, self.model, self.sid, self.settings)
log.info("pornesc claude pentru firul %s: %s", self.thread_id, " ".join(cmd))
env = dict(os.environ)
# Lane B: hook-ul PreToolUse afla firul Discord din mediul procesului claude
env["CLAUDE_DISCORD_THREAD_ID"] = self.thread_id
if self.sid:
env["CLAUDE_DISCORD_SESSION_ID"] = self.sid
if self.env:
env.update(self.env)
self.proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self.cwd,
env=env,
limit=_READ_LIMIT,
)
self.last_active = time.monotonic()
self._stderr_task = asyncio.create_task(self._pump_stderr())
if self.on_pid:
res = self.on_pid(self.thread_id, self.proc.pid)
if asyncio.iscoroutine(res):
await res
async def _pump_stderr(self) -> None:
assert self.proc is not None and self.proc.stderr is not None
try:
async for raw in self.proc.stderr:
line = raw.decode("utf-8", "replace").rstrip("\n")
if line:
self.stderr_buf.append(line)
except (asyncio.CancelledError, ValueError):
raise
except Exception as exc: # pragma: no cover
log.debug("stderr pump s-a oprit: %s", exc)
def stderr_tail(self, n: int = STDERR_TAIL) -> list[str]:
"""Ultimele linii de stderr, pentru `!status`."""
return list(self.stderr_buf)[-n:]
async def stop(self, grace: float = 5.0) -> None:
task, self._stderr_task = self._stderr_task, None
proc, self.proc = self.proc, None
if proc is not None and proc.returncode is None:
with contextlib.suppress(ProcessLookupError):
if proc.stdin and not proc.stdin.is_closing():
proc.stdin.close()
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), grace)
except (asyncio.TimeoutError, ProcessLookupError):
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await proc.wait()
if task is not None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
if self.on_pid:
res = self.on_pid(self.thread_id, None)
if asyncio.iscoroutine(res):
await res
# ------------------------------------------------------------- mesaje
async def send(self, content: str | list[dict]) -> None:
"""Trimite un mesaj de utilizator pe stdin (si mid-tur: asta e steering-ul)."""
if not self.alive or self.proc is None or self.proc.stdin is None:
raise TurnFailed("procesul claude nu e viu")
self.proc.stdin.write((user_message(content) + "\n").encode("utf-8"))
await self.proc.stdin.drain()
self.last_active = time.monotonic()
async def _stdout_lines(self) -> AsyncIterator[str]:
assert self.proc is not None and self.proc.stdout is not None
reader = self.proc.stdout
while True:
try:
raw = await reader.readline()
except ValueError: # linie peste limita: nu doboram turul
log.warning("linie de stdout peste limita, ignorata")
continue
if not raw:
return
yield raw.decode("utf-8", "replace")
async def run_turn(
self,
prompt: str | list[dict],
on_event: Callable[[Any], Awaitable[None]] | None = None,
timeout: float | None = None,
) -> TurnOutcome:
"""Un tur complet: (re)porneste procesul daca trebuie, trimite promptul,
consuma stream-ul pana la `result`."""
restarted = False
if not self.alive:
if self.started_turns:
restarted = True
self.restarts += 1
await self.start()
if timeout is None:
timeout = config.get_float("TURN_TIMEOUT_S", 900.0)
self.started_turns += 1
self.inflight = True
events: list[Any] = []
try:
if restarted:
ev = SessionRestarted(self.thread_id, self.sid)
events.append(ev)
if on_event:
await on_event(ev)
await self.send(prompt)
outcome = await asyncio.wait_for(self._consume(on_event, events), timeout)
return TurnOutcome(result=outcome, restarted=restarted, events=events)
except asyncio.TimeoutError:
await self.stop()
raise TurnTimeout(f"turul a depasit {timeout:.0f}s si a fost oprit") from None
except stream_mod.StreamEOFError as exc:
tail = "\n".join(self.stderr_tail(10))
await self.stop()
raise TurnFailed(f"{exc}; stderr:\n{tail}") from None
finally:
self.inflight = False
self.last_active = time.monotonic()
async def _consume(self, on_event, events: list) -> stream_mod.Result:
parser = stream_mod.StreamParser()
self._parser = parser
async for ev in parser.aiter_events(self._stdout_lines()):
if isinstance(ev, stream_mod.SystemInit) and ev.session_id:
self.sid = ev.session_id
events.append(ev)
if on_event:
await on_event(ev)
if isinstance(ev, stream_mod.Result):
return ev
raise stream_mod.StreamEOFError("stream inchis fara result") # pragma: no cover
class RunnerManager:
"""Registrul de procese vii + reaper-ul de inactivitate (20 min implicit)."""
def __init__(
self,
store=None,
*,
claude_bin: str | list[str] | None = None,
settings: str | os.PathLike | None = None,
idle_s: float | None = None,
poll_s: float = 30.0,
is_inflight: Callable[[str], bool] | None = None,
):
self.store = store
self.claude_bin = claude_bin or config.get("CLAUDE_BIN", "claude")
self.settings = settings
self.idle_s = idle_s if idle_s is not None else config.get_float("IDLE_REAP_S", 1200.0)
self.poll_s = poll_s
self._is_inflight = is_inflight
self.procs: dict[str, ClaudeProcess] = {}
self._reaper: asyncio.Task | None = None
# ------------------------------------------------------------ procese
def _on_pid(self, thread_id: str, pid: int | None):
if self.store is not None:
with contextlib.suppress(Exception):
self.store.set_pid(thread_id, pid)
def get(self, thread_id: str, cwd: str | None = None, model: str | None = None,
sid: str | None = None) -> ClaudeProcess:
key = str(thread_id)
proc = self.procs.get(key)
if proc is None:
proc = self.procs[key] = ClaudeProcess(
key,
cwd or config.get("DEFAULT_CWD", "/workspace"),
model or config.get("MODEL_DEFAULT", "sonnet"),
sid=sid,
claude_bin=self.claude_bin,
settings=self.settings,
on_pid=self._on_pid,
)
return proc
async def set_options(self, thread_id: str, cwd: str | None = None, model: str | None = None) -> bool:
"""`!cd` / `!model`: procesul curent se opreste, urmatorul tur il reia cu --resume."""
proc = self.get(thread_id)
changed = False
if cwd and cwd != proc.cwd:
proc.cwd, changed = cwd, True
if model and model != proc.model:
proc.model, changed = model, True
if changed and proc.alive:
await proc.stop()
return changed
async def reset(self, thread_id: str, fork: bool = False) -> None:
"""`!new`: sesiune noua (fork=True pastreaza sid-ul pentru un fir nou)."""
proc = self.procs.get(str(thread_id))
if proc is not None:
await proc.stop()
if not fork:
proc.sid = None
proc.started_turns = 0
def inflight(self, thread_id: str) -> bool:
proc = self.procs.get(str(thread_id))
if proc is not None and proc.inflight:
return True
if self._is_inflight is not None:
with contextlib.suppress(Exception):
return bool(self._is_inflight(str(thread_id)))
if self.store is not None:
with contextlib.suppress(Exception):
return bool(self.store.is_inflight(str(thread_id)))
return False
def live_count(self) -> int:
return sum(1 for p in self.procs.values() if p.alive)
# ------------------------------------------------------------- reaper
async def reap_once(self, now: float | None = None) -> list[str]:
"""Omoara procesele inactive. NICIODATA un fir cu tur in zbor."""
now = time.monotonic() if now is None else now
killed: list[str] = []
for key, proc in list(self.procs.items()):
if not proc.alive:
continue
if self.inflight(key):
continue
if now - proc.last_active >= self.idle_s:
log.info("reaper: opresc claude pentru firul %s (inactiv)", key)
await proc.stop()
killed.append(key)
return killed
async def _reaper_loop(self) -> None:
while True:
try:
await asyncio.sleep(self.poll_s)
await self.reap_once()
except asyncio.CancelledError:
raise
except Exception: # pragma: no cover
log.exception("reaper a esuat, continui")
def start_reaper(self) -> asyncio.Task:
if self._reaper is None or self._reaper.done():
self._reaper = asyncio.create_task(self._reaper_loop())
return self._reaper
async def stop_all(self) -> None:
if self._reaper is not None:
self._reaper.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._reaper
self._reaper = None
for proc in list(self.procs.values()):
await proc.stop()
def new_turn_id() -> str:
return uuid.uuid4().hex[:12]