Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
"""Live, tail-able transcripts for delegated subagents.
|
||||
|
||||
Every ``delegate_task`` dispatch creates one append-only, human-readable log
|
||||
per child under::
|
||||
|
||||
<hermes_home>/cache/delegation/live/<delegation_id>/task-<n>.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", <text>) — 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", <text>, 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,
|
||||
model: Optional[str] = None,
|
||||
provider: 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, model=model, provider=provider)
|
||||
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], model: Optional[str] = None,
|
||||
provider: Optional[str] = None) -> None:
|
||||
try:
|
||||
manifest = {
|
||||
"delegation_id": delegation_id,
|
||||
"started": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"task_count": len(task_list),
|
||||
"model": model,
|
||||
"provider": provider,
|
||||
"tasks": [
|
||||
{
|
||||
"index": i,
|
||||
# manifest.json sits in the same mounted
|
||||
# cache/delegation/live/<id>/ 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/<delegation_id> 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
|
||||
Reference in New Issue
Block a user