debut illustration

This commit is contained in:
pporcheret
2026-11-02 08:50:05 +01:00
parent e2340807aa
commit c2a4f7eb26
44 changed files with 6856 additions and 19 deletions
+248
View File
@@ -0,0 +1,248 @@
"""Dona CLI streaming and timing bridge (Python standard library only).
Does not access Hermes sessions or modify MCP config. Retains the terminal
JSONL result envelope for the existing _parse_jsonl implementation.
"""
from __future__ import annotations
import json
import logging
import os
import signal
import subprocess
import threading
import time
import uuid
_LOG = logging.getLogger("dona.hermes.performance")
_EMITTER = None
_EMITTER_LOCK = threading.Lock()
_FALSE = {"0", "false", "no", "off", "disabled"}
def configure_emitter(emitter):
"""Register Flask-SocketIO event emitter once, after socketio is created."""
global _EMITTER
with _EMITTER_LOCK:
_EMITTER = emitter
def _enabled(name: str, default: str = "on") -> bool:
return os.environ.get(name, default).strip().lower() not in _FALSE
def _publish(msg):
if not _enabled("DONA_HERMES_STREAM_UI", "on"):
return
with _EMITTER_LOCK:
fn = _EMITTER
if fn is not None:
try:
fn(msg)
except Exception:
# A browser disconnect must never corrupt an Hermes turn/session.
_LOG.debug("Dona UI emitter failed", exc_info=True)
def run_jsonl_command(cmd, prompt, timeout):
"""Compatibility replacement for subprocess.run used only by _call_hermes.
Publishes deltas/tool names while keeping exact final JSONL parsing behavior.
Fallback to the historical subprocess.run with DONA_HERMES_STREAM_CLI=off.
"""
if not _enabled("DONA_HERMES_STREAM_CLI", "on"):
return subprocess.run(
cmd, input=prompt, capture_output=True, text=True,
timeout=timeout, check=False,
)
request_id = uuid.uuid4().hex[:12]
start = time.monotonic()
first_text_ms = None
first_event_ms = None
first_tool_ms = None
last_tool_done_ms = None
active_tools = []
finished_tools = 0
# Report only name/timing/outcome. Never log MCP arguments, outputs or prompts.
tool_count = 0
text_fragments = 0
emitted_characters = 0
pending_text = ""
last_emit = start
final_seen = False
# Keep only records the existing parser consumes, rather than potentially
# huge tool inputs/outputs and all intermediate assistant deltas.
output_records = []
stderr_records = []
deadline_hit = threading.Event()
proc = None
timer = None
_publish({"type": "start", "id": request_id})
def expire():
deadline_hit.set()
if proc is not None and proc.poll() is None:
try:
# subprocess in its own session: children of this CLI turn
# cannot linger after an actual timeout.
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except OSError:
pass
def collect_stderr():
try:
data = proc.stderr.read()
if data:
stderr_records.append(data)
except Exception:
pass
try:
proc = subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, bufsize=1,
start_new_session=True,
)
timer = threading.Timer(float(timeout), expire)
timer.daemon = True
timer.start()
stderr_thread = threading.Thread(target=collect_stderr, daemon=True)
stderr_thread.start()
try:
proc.stdin.write(prompt)
except BrokenPipeError:
pass
finally:
try:
proc.stdin.close()
except OSError:
pass
for line in proc.stdout:
try:
event = json.loads(line)
except (ValueError, TypeError):
# Preserve unrecognized lines to aid existing error diagnostics.
continue
if not isinstance(event, dict):
continue
kind = event.get("type")
now = time.monotonic()
if first_event_ms is None:
first_event_ms = round((now - start) * 1000, 1)
if kind == "system" and event.get("subtype") == "init":
output_records.append(line)
elif kind == "text":
delta = event.get("text")
if isinstance(delta, str) and delta:
text_fragments += 1
if first_text_ms is None:
first_text_ms = round((now - start) * 1000, 1)
_publish({"type": "first_text", "id": request_id,
"first_text_ms": first_text_ms})
# Emit batched, rather than one websocket event/token.
pending_text += delta
if (now - last_emit) >= .12 or len(pending_text) >= 240:
_publish({"type": "delta", "id": request_id,
"text": pending_text})
emitted_characters += len(pending_text)
pending_text = ""
last_emit = now
elif kind == "tool_use":
tool_count += 1
if first_tool_ms is None:
first_tool_ms = round((now - start) * 1000, 1)
name = str(event.get("name") or "outil")[:90]
# Events may use either a tool_call_id, id or no ID at all.
tool_id = event.get("tool_call_id") or event.get("id")
active_tools.append({"name": name, "id": tool_id,
"start": now, "sequence": tool_count})
_LOG.warning("[DONA-TOOL] id=%s index=%s name=%s phase=start at_ms=%.1f",
request_id, tool_count, name, (now-start)*1000)
# Do not send tool arguments, user tokens, or raw tool outputs.
_publish({"type": "tool", "id": request_id, "name": name})
elif kind == "tool_result":
name = str(event.get("name") or "outil")[:90]
tool_id = event.get("tool_call_id") or event.get("tool_use_id") or event.get("id")
matched = None
# Prefer a matching tool ID; fallback to matching name, then oldest.
if tool_id is not None:
matched = next((i for i, t in enumerate(active_tools)
if t["id"] == tool_id), None)
if matched is None:
matched = next((i for i, t in enumerate(active_tools)
if t["name"] == name), None)
if matched is None and active_tools:
matched = 0
record = active_tools.pop(matched) if matched is not None else None
if name == "outil" and record is not None:
name = record["name"]
measured_ms = round((now-record["start"])*1000, 1) if record else None
reported_ms = event.get("duration_ms")
if isinstance(reported_ms, bool) or not isinstance(reported_ms, (float, int)):
reported_ms = None
failed = bool(event.get("is_error"))
last_tool_done_ms = round((now-start)*1000, 1)
finished_tools += 1
_LOG.warning("[DONA-TOOL] id=%s index=%s name=%s phase=end at_ms=%.1f elapsed_ms=%s server_ms=%s error=%s",
request_id, record["sequence"] if record else "?", name,
last_tool_done_ms, measured_ms, reported_ms, failed)
_publish({"type": "tool_done", "id": request_id,
"name": name,
"duration_ms": reported_ms if reported_ms is not None else measured_ms,
"error": failed})
elif kind == "result":
final_seen = True
output_records.append(line)
rc = proc.wait()
stderr_thread.join(timeout=1.0)
total_ms = round((time.monotonic() - start) * 1000, 1)
if deadline_hit.is_set():
_publish({"type": "error", "id": request_id, "reason": "timeout"})
raise subprocess.TimeoutExpired(cmd, timeout, output="".join(output_records),
stderr="".join(stderr_records))
if pending_text:
_publish({"type": "delta", "id": request_id, "text": pending_text})
emitted_characters += len(pending_text)
_publish({"type": "end", "id": request_id,
"total_ms": total_ms, "first_text_ms": first_text_ms,
"ok": rc == 0 and final_seen})
_LOG.warning(
"[DONA-PERF] hermes_cli id=%s total_ms=%s first_event_ms=%s "
"first_tool_ms=%s last_tool_done_ms=%s first_text_ms=%s "
"tools=%s tool_results=%s unfinished_tools=%s "
"fragments=%s ui_chars=%s exit=%s final=%s",
request_id, total_ms, first_event_ms, first_tool_ms,
last_tool_done_ms, first_text_ms, tool_count, finished_tools,
len(active_tools), text_fragments, emitted_characters, rc, final_seen,
)
# Keep final result + init events exactly as _parse_jsonl expects.
return subprocess.CompletedProcess(
cmd, rc, "".join(output_records), "".join(stderr_records)
)
except Exception:
if not deadline_hit.is_set():
_publish({"type": "error", "id": request_id, "reason": "process_error"})
raise
finally:
if timer:
timer.cancel()
if proc is not None:
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except OSError:
pass
for pipe in (proc.stdin, proc.stdout, proc.stderr):
try:
pipe.close()
except (OSError, ValueError):
pass