"""Live, tail-able transcripts for delegated subagents. Every ``delegate_task`` dispatch creates one append-only, human-readable log per child under:: /cache/delegation/live//task-.log The files are pre-created with a header at dispatch time (so ``tail -f`` attaches immediately) and then stream one line per child event: assistant text, thinking, tool calls, tool results, and lifecycle markers. The paths are returned from ``delegate_task`` so the parent agent (or the user) can watch a child work instead of waiting blind for the consolidated summary. Placement under ``cache/delegation`` is deliberate: that directory is mounted read-only into remote terminal backends (Docker/Modal/SSH) via ``credential_files._CACHE_DIRS``, so the logs are readable from any backend. Design constraints: * **Never raise into the agent loop.** Every write is wrapped; the first failure disables the writer and degrades to a debug log. * **Survive child crashes.** Files are opened in append mode per write — no long-lived handle to lose, every event is flushed when written. * **Side-channel only.** Nothing here touches message content, so prompt caching is unaffected. * **No config knobs.** Retention is a module constant (7 days), pruned opportunistically on each new dispatch. """ from __future__ import annotations import json import logging import shutil import threading import time import uuid from pathlib import Path from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) # Live transcript directories older than this are pruned on new dispatches. LIVE_RETENTION_DAYS = 7 # Per-line truncation budgets (chars). The .log is a compact operational # view, not the full-fidelity record — the child's SessionDB transcript and # the summary spill files carry complete text. _ASSISTANT_MAX = 600 _THINKING_MAX = 300 _ARGS_MAX = 220 _RESULT_MAX = 400 _KICKOFF_MAX = 500 # Stream deltas are buffered and flushed as one assistant line when another # event type arrives (or on completion). Cap the buffer so a huge streamed # reply can't hold memory hostage. _STREAM_BUFFER_FLUSH_CHARS = 4000 def live_transcript_root() -> Path: """Root directory for live transcripts (profile-safe, never ~/.hermes).""" from hermes_constants import get_hermes_dir return get_hermes_dir("cache/delegation", "delegation_cache") / "live" def new_live_delegation_id() -> str: """Same shape as async_delegation's ids so the dir name matches the handle.""" return f"deleg_{uuid.uuid4().hex[:8]}" def _one_line(text: Any, limit: int) -> str: """Collapse to a single line and truncate with an elided-chars note.""" s = str(text or "") s = " ".join(s.split()) # collapse newlines/runs of whitespace if len(s) > limit: omitted = len(s) - limit s = s[:limit] + f" …(+{omitted} chars)" return s def _redact(text: str) -> str: """Mask credentials before anything reaches the transcript file. These logs live under ``cache/delegation``, which ``delegate_tool`` mounts READ-ONLY into remote terminal backends — so every line written here is readable from inside the sandbox. The events rendered here carry exactly the data that tends to hold secrets: tool args (a bearer header on a curl), tool results (a ``.env`` dump, a provider error echoing the key back) and streamed assistant text. Every other sink for that data already routes through this same redactor — search results via ``redact_sensitive_text``, terminal output via ``redact_terminal_output`` — so a transcript that skipped it is the one place the operator's keys land in plaintext. ``force=True``: this is a safety boundary, so it must redact even when the global toggle is off. Withholds the line rather than emitting raw text if the redactor is somehow unavailable — losing a debug line costs less than writing a live credential into a sandbox-readable file. """ if not text: return text try: from agent.redact import redact_sensitive_text return redact_sensitive_text(text, force=True) or "" except Exception: # pragma: no cover - core module; never leak on failure return "[line withheld: redaction unavailable]" class LiveTranscriptWriter: """Append-only human-readable event log for ONE subagent task. All methods are best-effort: the first write failure flips ``_ok`` off and subsequent calls become no-ops (debug-logged). Never raises. """ def __init__(self, delegation_id: str, task_index: int, goal: str, context: Optional[str] = None, root: Optional[Path] = None): self.delegation_id = delegation_id self.task_index = task_index self._ok = True self._lock = threading.Lock() self._stream_buf: List[str] = [] self._stream_len = 0 try: base = (root if root is not None else live_transcript_root()) d = base / delegation_id d.mkdir(parents=True, exist_ok=True) self.path: Optional[Path] = d / f"task-{task_index}.log" header = [ "=== Hermes subagent live transcript ===", f"delegation: {delegation_id} task: {task_index}", # Header bypasses event(), so redact here too — a goal string # can carry a key the caller pasted into the task. f"goal: {_redact(_one_line(goal, _KICKOFF_MAX))}", f"started: {time.strftime('%Y-%m-%d %H:%M:%S')}", "(append-only; streams while the subagent runs — tail -f me)", "=" * 40, ] self.path.write_text("\n".join(header) + "\n", encoding="utf-8") self.event("user", "kickoff: " + _one_line(goal, _KICKOFF_MAX) + (f" | context: {_one_line(context, _KICKOFF_MAX)}" if context else "")) except Exception as exc: logger.debug("Live transcript init failed (%s task %s): %s", delegation_id, task_index, exc) self._ok = False self.path = None # ── low-level ──────────────────────────────────────────────────────── def event(self, role: str, text: str) -> None: """Append one ``HH:MM:SS role ⟩ text`` line. Flushed per event.""" if not self._ok or self.path is None: return # Single choke point: every typed helper funnels through here, so # redacting once covers args, results, thinking and streamed text — # and a helper added later can't bypass it. line = f"{time.strftime('%H:%M:%S')} {role:<9}| {_redact(text)}\n" try: with self._lock: # Append mode per write: no held handle, survives child crash, # and the close() acts as the flush. with open(self.path, "a", encoding="utf-8") as fh: fh.write(line) except Exception as exc: self._ok = False logger.debug("Live transcript write failed (%s): %s", self.path, exc) # ── typed helpers ──────────────────────────────────────────────────── def assistant_text(self, text: str) -> None: t = _one_line(text, _ASSISTANT_MAX) if t: self.event("assistant", t) def thinking(self, text: str) -> None: t = _one_line(text, _THINKING_MAX) if t: self.event("think", t) def tool_start(self, name: str, args_preview: Any = None) -> None: self.flush_stream() args = _one_line(args_preview, _ARGS_MAX) self.event("tool", f"-> {name or '?'}({args})") def tool_result(self, name: str, result: Any = None, duration: Any = None, is_error: bool = False) -> None: status = "ERROR" if is_error else "ok" dur = "" try: if duration is not None: dur = f" {float(duration):.1f}s" except (TypeError, ValueError): pass self.event("result", f"{name or '?'} {status}{dur}: " f"{_one_line(result, _RESULT_MAX)}") def marker(self, text: str) -> None: """Lifecycle marker: start / final / error / interrupt / budget.""" self.flush_stream() self.event("final", _one_line(text, _ASSISTANT_MAX)) # ── streamed reply buffering ───────────────────────────────────────── def add_stream_delta(self, delta: str) -> None: """Buffer streamed assistant reply text; flushed as one line.""" if not delta or not self._ok: return self._stream_buf.append(delta) self._stream_len += len(delta) if self._stream_len >= _STREAM_BUFFER_FLUSH_CHARS: self.flush_stream() def flush_stream(self) -> None: if not self._stream_buf: return text = "".join(self._stream_buf) self._stream_buf = [] self._stream_len = 0 self.assistant_text(text) # ── event demux (the tool_progress_callback surface) ───────────────── def observe(self, event_type: Any, tool_name: Any = None, preview: Any = None, args: Any = None, **kwargs: Any) -> None: """Map a child tool_progress_callback event onto transcript lines. Mirrors the shapes emitted by agent/tool_executor.py, agent/conversation_loop.py, and tools/delegate_tool._run_single_child. Unknown events are ignored. Never raises (event() swallows I/O). """ et = str(event_type or "") if et == "tool.started": self.tool_start(str(tool_name or ""), preview if preview else args) elif et == "tool.completed": self.tool_result( str(tool_name or ""), result=kwargs.get("result"), duration=kwargs.get("duration"), is_error=bool(kwargs.get("is_error")), ) elif et == "_thinking": # Fired as cb("_thinking", ) — the text rides in the # tool_name positional slot (see conversation_loop.py). self.thinking(str(tool_name or preview or "")) elif et == "reasoning.available": # cb("reasoning.available", "_thinking", , None) self.thinking(str(preview or "")) elif et == "subagent.text": self.add_stream_delta(str(preview or "")) elif et == "subagent.start": self.event("start", _one_line(preview, _KICKOFF_MAX)) elif et == "subagent.complete": self.flush_stream() status = kwargs.get("status", "?") dur = kwargs.get("duration_seconds") parts = [f"status={status}"] if dur is not None: parts.append(f"duration={dur}s") summary = kwargs.get("summary") or preview if summary: parts.append(f"summary: {_one_line(summary, _RESULT_MAX)}") self.marker(" ".join(parts)) def finalize(self, entry: Dict[str, Any]) -> None: """Terminal marker from the aggregated result entry. Adds exit-reason detail the subagent.complete event doesn't carry (budget exhaustion via exit_reason=max_iterations, errors, etc.). """ parts = [f"end status={entry.get('status', '?')}"] exit_reason = entry.get("exit_reason") if exit_reason: parts.append(f"exit_reason={exit_reason}") if exit_reason == "max_iterations": parts.append("(iteration budget exhausted)") if entry.get("error"): parts.append(f"error: {_one_line(entry['error'], _RESULT_MAX)}") self.marker(" ".join(parts)) def wrap_progress_callback(inner_cb, writer: LiveTranscriptWriter): """Wrap a child's tool_progress_callback so events also land in the log. ``inner_cb`` may be None (no parent display) — the wrapper still records. Writer failures never propagate; inner callback behavior is unchanged (its own exceptions are handled by callers exactly as before). Preserves the ``_flush`` attribute contract used by _run_single_child. """ def _cb(event_type, tool_name=None, preview=None, args=None, **kwargs): try: writer.observe(event_type, tool_name, preview, args, **kwargs) except Exception as exc: # noqa: BLE001 — must never hit the agent loop logger.debug("Live transcript observe failed: %s", exc) if inner_cb is not None: inner_cb(event_type, tool_name, preview, args, **kwargs) def _flush(): try: writer.flush_stream() except Exception: pass inner_flush = getattr(inner_cb, "_flush", None) if callable(inner_flush): inner_flush() _cb._flush = _flush return _cb # ── dispatch-time helpers ──────────────────────────────────────────────── def create_live_transcripts( task_list: List[Dict[str, Any]], context: Optional[str] = None, delegation_id: Optional[str] = None, ) -> tuple[Optional[str], List[Optional[LiveTranscriptWriter]], List[str]]: """Create one pre-headered writer per task + a manifest.json. Returns ``(delegation_id, writers, paths)``. On any top-level failure returns ``(None, [None]*n, [])`` so delegation proceeds untouched. Also opportunistically prunes stale live dirs (retention). """ n = len(task_list) try: prune_stale_live_dirs() except Exception: pass try: deleg_id = delegation_id or new_live_delegation_id() writers: List[Optional[LiveTranscriptWriter]] = [] paths: List[str] = [] for i, t in enumerate(task_list): w = LiveTranscriptWriter( deleg_id, i, str(t.get("goal", "")), context=t.get("context") or context, ) writers.append(w if w.path is not None else None) if w.path is not None: paths.append(str(w.path)) if not paths: return None, [None] * n, [] _write_manifest(deleg_id, task_list, paths) return deleg_id, writers, paths except Exception as exc: logger.debug("Live transcript creation failed: %s", exc) return None, [None] * n, [] def _manifest_path(delegation_id: str) -> Path: return live_transcript_root() / delegation_id / "manifest.json" def _write_manifest(delegation_id: str, task_list: List[Dict[str, Any]], paths: List[str]) -> None: try: manifest = { "delegation_id": delegation_id, "started": time.strftime("%Y-%m-%d %H:%M:%S"), "task_count": len(task_list), "tasks": [ { "index": i, # manifest.json sits in the same mounted # cache/delegation/live// directory as the .log files, # so it needs the same treatment — redacting the header # while serialising the goal verbatim here would leave the # credential exposed one file over. "goal": _redact(str(t.get("goal", ""))[:500]), "log": paths[i] if i < len(paths) else None, "status": "running", } for i, t in enumerate(task_list) ], } _manifest_path(delegation_id).write_text( json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8" ) except Exception as exc: logger.debug("Live transcript manifest write failed: %s", exc) def update_manifest_statuses(delegation_id: Optional[str], results: List[Dict[str, Any]]) -> None: """Best-effort per-task status update once the batch has aggregated.""" if not delegation_id: return try: mp = _manifest_path(delegation_id) manifest = json.loads(mp.read_text(encoding="utf-8")) by_index = {r.get("task_index"): r for r in results if isinstance(r, dict)} for task in manifest.get("tasks", []): r = by_index.get(task.get("index")) if r is not None: task["status"] = r.get("status", task.get("status")) if r.get("exit_reason"): task["exit_reason"] = r["exit_reason"] manifest["completed"] = time.strftime("%Y-%m-%d %H:%M:%S") mp.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8") except Exception as exc: logger.debug("Live transcript manifest update failed: %s", exc) def prune_stale_live_dirs(max_age_days: int = LIVE_RETENTION_DAYS) -> int: """Remove live/ dirs older than the retention window. Returns how many were removed. Fully best-effort. """ removed = 0 try: root = live_transcript_root() if not root.is_dir(): return 0 cutoff = time.time() - max_age_days * 86400 for child in root.iterdir(): try: if child.is_dir() and child.stat().st_mtime < cutoff: shutil.rmtree(child, ignore_errors=True) removed += 1 except OSError: continue except Exception as exc: logger.debug("Live transcript pruning failed: %s", exc) return removed