feat: make LLM logger by default on

This commit is contained in:
Richard Tang
2026-03-04 10:30:17 -08:00
parent 4cbd5a4c6c
commit 9102328d1c
3 changed files with 865 additions and 29 deletions
+23 -2
View File
@@ -613,6 +613,8 @@ class EventLoopNode(NodeProtocol):
user_input_requested,
ask_user_prompt,
ask_user_options,
request_system_prompt,
request_messages,
) = await self._run_single_turn(
ctx, conversation, tools, iteration, accumulator
)
@@ -647,6 +649,8 @@ class EventLoopNode(NodeProtocol):
stream_id=stream_id,
execution_id=execution_id,
iteration=iteration,
system_prompt=request_system_prompt,
messages=request_messages,
assistant_text=assistant_text,
tool_calls=logged_tool_calls,
tool_results=real_tool_results,
@@ -1576,11 +1580,22 @@ class EventLoopNode(NodeProtocol):
tools: list[Tool],
iteration: int,
accumulator: OutputAccumulator,
) -> tuple[str, list[dict], list[str], dict[str, int], list[dict], bool, str, list[str] | None]:
) -> tuple[
str,
list[dict],
list[str],
dict[str, int],
list[dict],
bool,
str,
list[str] | None,
str,
list[dict[str, Any]],
]:
"""Run a single LLM turn with streaming and tool execution.
Returns (assistant_text, real_tool_results, outputs_set, token_counts, logged_tool_calls,
user_input_requested, ask_user_prompt, ask_user_options).
user_input_requested, ask_user_prompt, ask_user_options, system_prompt, messages).
``real_tool_results`` contains only results from actual tools (web_search,
etc.), NOT from the synthetic ``set_output`` or ``ask_user`` tools.
@@ -1600,6 +1615,8 @@ class EventLoopNode(NodeProtocol):
token_counts: dict[str, int] = {"input": 0, "output": 0}
tool_call_count = 0
final_text = ""
final_system_prompt = conversation.system_prompt
final_messages: list[dict[str, Any]] = []
# Track output keys set via set_output across all inner iterations
outputs_set_this_turn: list[str] = []
user_input_requested = False
@@ -1635,6 +1652,8 @@ class EventLoopNode(NodeProtocol):
)
await conversation.add_user_message("[Continue working on your current task.]")
messages = conversation.to_llm_messages()
final_system_prompt = conversation.system_prompt
final_messages = messages
accumulated_text = ""
tool_calls: list[ToolCallEvent] = []
@@ -1753,6 +1772,8 @@ class EventLoopNode(NodeProtocol):
user_input_requested,
ask_user_prompt,
ask_user_options,
final_system_prompt,
final_messages,
)
# Execute tool calls — framework tools (set_output, ask_user)
+14 -27
View File
@@ -1,45 +1,30 @@
"""HIVE_LLM_DEBUG — write every LLM turn to a JSONL file for replay/debugging.
"""Write every LLM turn to ~/.hive/llm_logs/<ts>.jsonl for replay/debugging.
Set the env var to enable:
HIVE_LLM_DEBUG=1 writes to ~/.hive/llm_logs/<ts>.jsonl
HIVE_LLM_DEBUG=/some/path writes to that directory
Each line is a JSON object with the full LLM turn: assistant text, tool calls,
tool results, and token counts. The file is opened lazily on first call and
flushed after every write. Errors are silently swallowed this must never
break the agent.
Each line is a JSON object with the full LLM turn: the request payload
(system prompt + messages), assistant text, tool calls, tool results, and
token counts. The file is opened lazily on first call and flushed after every
write. Errors are silently swallowed this must never break the agent.
"""
import json
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import IO, Any
logger = logging.getLogger(__name__)
_LLM_DEBUG_RAW = os.environ.get("HIVE_LLM_DEBUG", "").strip()
_LLM_DEBUG_ENABLED = _LLM_DEBUG_RAW.lower() in ("1", "true") or (
bool(_LLM_DEBUG_RAW) and _LLM_DEBUG_RAW.lower() not in ("0", "false", "")
)
_LLM_DEBUG_DIR = Path.home() / ".hive" / "llm_logs"
_log_file: IO[str] | None = None
_log_ready = False # lazy init guard
def _open_log() -> IO[str] | None:
"""Open a JSONL log file. Returns None if disabled."""
if not _LLM_DEBUG_ENABLED:
return None
raw = _LLM_DEBUG_RAW
if raw.lower() in ("1", "true"):
log_dir = Path.home() / ".hive" / "llm_logs"
else:
log_dir = Path(raw)
log_dir.mkdir(parents=True, exist_ok=True)
"""Open the JSONL log file for this process."""
_LLM_DEBUG_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = log_dir / f"{ts}.jsonl"
path = _LLM_DEBUG_DIR / f"{ts}.jsonl"
logger.info("LLM debug log → %s", path)
return open(path, "a", encoding="utf-8") # noqa: SIM115
@@ -50,6 +35,8 @@ def log_llm_turn(
stream_id: str,
execution_id: str,
iteration: int,
system_prompt: str,
messages: list[dict[str, Any]],
assistant_text: str,
tool_calls: list[dict[str, Any]],
tool_results: list[dict[str, Any]],
@@ -57,10 +44,8 @@ def log_llm_turn(
) -> None:
"""Write one JSONL line capturing a complete LLM turn.
No-op when HIVE_LLM_DEBUG is not set. Never raises.
Never raises.
"""
if not _LLM_DEBUG_ENABLED:
return
try:
global _log_file, _log_ready # noqa: PLW0603
if not _log_ready:
@@ -74,6 +59,8 @@ def log_llm_turn(
"stream_id": stream_id,
"execution_id": execution_id,
"iteration": iteration,
"system_prompt": system_prompt,
"messages": messages,
"assistant_text": assistant_text,
"tool_calls": tool_calls,
"tool_results": tool_results,
+828
View File
@@ -0,0 +1,828 @@
#!/usr/bin/env python3
"""Open a browser-based viewer for Hive LLM debug JSONL sessions.
Usage:
uv run --no-project scripts/llm_debug_log_visualizer.py
uv run --no-project scripts/llm_debug_log_visualizer.py --no-open
uv run --no-project scripts/llm_debug_log_visualizer.py --session <execution_id>
"""
from __future__ import annotations
import argparse
import json
import tempfile
import webbrowser
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
@dataclass
class SessionSummary:
execution_id: str
log_file: str
start_timestamp: str
end_timestamp: str
turn_count: int
streams: list[str]
nodes: list[str]
models: list[str]
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--logs-dir",
type=Path,
default=Path.home() / ".hive" / "llm_logs",
help="Directory containing Hive LLM debug JSONL files.",
)
parser.add_argument(
"--session",
help="Execution ID to select initially in the webpage.",
)
parser.add_argument(
"--output",
type=Path,
help="Optional HTML output path. Defaults to a temporary file.",
)
parser.add_argument(
"--limit-files",
type=int,
default=200,
help="Maximum number of newest log files to scan.",
)
parser.add_argument(
"--no-open",
action="store_true",
help="Generate the HTML but do not open a browser.",
)
return parser.parse_args()
def _safe_read_jsonl(path: Path) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
try:
with path.open(encoding="utf-8") as handle:
for line_number, raw_line in enumerate(handle, start=1):
line = raw_line.strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
payload = {
"timestamp": "",
"execution_id": "",
"assistant_text": "",
"_parse_error": f"{path.name}:{line_number}",
"_raw_line": line,
}
payload["_log_file"] = str(path)
records.append(payload)
except OSError as exc:
print(f"warning: failed to read {path}: {exc}")
return records
def _discover_records(logs_dir: Path, limit_files: int) -> list[dict[str, Any]]:
if not logs_dir.exists():
raise FileNotFoundError(f"log directory not found: {logs_dir}")
files = sorted(
[path for path in logs_dir.iterdir() if path.is_file() and path.suffix == ".jsonl"],
key=lambda path: path.stat().st_mtime,
reverse=True,
)[:limit_files]
records: list[dict[str, Any]] = []
for path in files:
records.extend(_safe_read_jsonl(path))
return records
def _format_timestamp(raw: str) -> str:
if not raw:
return "-"
try:
return datetime.fromisoformat(raw).strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
return raw
def _group_sessions(records: list[dict[str, Any]]) -> tuple[list[SessionSummary], dict[str, list[dict[str, Any]]]]:
by_session: dict[str, list[dict[str, Any]]] = defaultdict(list)
for record in records:
execution_id = str(record.get("execution_id") or "").strip()
if execution_id:
by_session[execution_id].append(record)
summaries: list[SessionSummary] = []
for execution_id, session_records in by_session.items():
session_records.sort(key=lambda record: (str(record.get("timestamp", "")), record.get("iteration", 0)))
first = session_records[0]
last = session_records[-1]
summaries.append(
SessionSummary(
execution_id=execution_id,
log_file=str(first.get("_log_file", "")),
start_timestamp=str(first.get("timestamp", "")),
end_timestamp=str(last.get("timestamp", "")),
turn_count=len(session_records),
streams=sorted({str(r.get("stream_id", "")) for r in session_records if r.get("stream_id")}),
nodes=sorted({str(r.get("node_id", "")) for r in session_records if r.get("node_id")}),
models=sorted(
{
str(r.get("token_counts", {}).get("model", ""))
for r in session_records
if isinstance(r.get("token_counts"), dict) and r.get("token_counts", {}).get("model")
}
),
)
)
summaries.sort(key=lambda summary: summary.start_timestamp, reverse=True)
return summaries, by_session
def _render_html(
summaries: list[SessionSummary],
sessions: dict[str, list[dict[str, Any]]],
initial_session_id: str,
) -> str:
summaries_data = [
{
"execution_id": summary.execution_id,
"log_file": summary.log_file,
"start_timestamp": summary.start_timestamp,
"end_timestamp": summary.end_timestamp,
"start_display": _format_timestamp(summary.start_timestamp),
"end_display": _format_timestamp(summary.end_timestamp),
"turn_count": summary.turn_count,
"streams": summary.streams,
"nodes": summary.nodes,
"models": summary.models,
}
for summary in summaries
]
sessions_data = {
execution_id: sorted(
records,
key=lambda record: (str(record.get("timestamp", "")), record.get("iteration", 0)),
)
for execution_id, records in sessions.items()
}
initial = initial_session_id or (summaries[0].execution_id if summaries else "")
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hive LLM Debug Viewer</title>
<style>
:root {{
--bg: #efe6d8;
--panel: rgba(255, 251, 245, 0.92);
--panel-strong: #fffdfa;
--ink: #1f1d19;
--muted: #6d6457;
--line: #ddceb6;
--accent: #b64a2b;
--accent-deep: #7a2813;
--sidebar: #2b211d;
--sidebar-soft: #3e302a;
--user: #0f766e;
--assistant: #7c3aed;
--tool: #9a3412;
--shadow: 0 18px 44px rgba(60, 39, 14, 0.12);
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
color: var(--ink);
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(182, 74, 43, 0.14), transparent 28rem),
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
}}
.app {{
min-height: 100vh;
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
}}
.sidebar {{
background:
linear-gradient(180deg, rgba(62, 48, 42, 0.96), rgba(29, 21, 18, 0.98));
color: white;
padding: 24px 18px;
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
}}
.brand {{
margin-bottom: 20px;
}}
.brand h1 {{
margin: 0 0 6px;
font-size: 28px;
line-height: 1;
}}
.brand p {{
margin: 0;
color: rgba(255, 255, 255, 0.72);
line-height: 1.45;
}}
.sidebar input, .sidebar select {{
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
color: white;
padding: 12px 14px;
margin: 10px 0;
}}
.sidebar input {{
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
color: white;
padding: 12px 14px;
margin: 10px 0;
}}
.sidebar input::placeholder {{
color: rgba(255, 255, 255, 0.5);
}}
.setup-note {{
margin-top: 14px;
padding: 14px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.12);
}}
.setup-note h3 {{
margin: 0 0 8px;
font-size: 14px;
}}
.setup-note p {{
margin: 0 0 10px;
color: rgba(255, 255, 255, 0.76);
line-height: 1.45;
font-size: 13px;
}}
.setup-note pre {{
margin: 0;
background: rgba(0, 0, 0, 0.24);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
}}
.session-list {{
display: grid;
gap: 10px;
margin-top: 16px;
}}
.session-card {{
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: white;
border-radius: 18px;
padding: 14px;
cursor: pointer;
text-align: left;
width: 100%;
}}
.session-card.active {{
background: linear-gradient(145deg, rgba(182, 74, 43, 0.96), rgba(122, 40, 19, 0.96));
border-color: rgba(255, 255, 255, 0.24);
}}
.session-card .sid {{
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 12px;
word-break: break-all;
opacity: 0.95;
}}
.session-card .meta {{
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.76);
}}
.session-card .meta span {{
border-radius: 999px;
background: rgba(255, 255, 255, 0.09);
padding: 4px 8px;
}}
.main {{
padding: 26px;
min-width: 0;
}}
.hero {{
background: linear-gradient(145deg, rgba(182, 74, 43, 0.96), rgba(122, 40, 19, 0.96));
color: white;
border-radius: 28px;
padding: 28px;
box-shadow: var(--shadow);
}}
.hero h2 {{
margin: 0 0 8px;
font-size: clamp(30px, 5vw, 46px);
line-height: 1.02;
}}
.hero code {{
display: inline-block;
margin-top: 4px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
font-size: 13px;
word-break: break-all;
}}
.meta-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
margin-top: 18px;
}}
.meta-card {{
border-radius: 16px;
padding: 14px;
background: rgba(255, 255, 255, 0.11);
border: 1px solid rgba(255, 255, 255, 0.14);
}}
.meta-card .label {{
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.68);
margin-bottom: 6px;
}}
.toolbar {{
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin: 22px 0 18px;
}}
.toolbar input {{
flex: 1 1 320px;
min-width: 220px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow);
}}
.toolbar button {{
border: 0;
border-radius: 999px;
padding: 12px 16px;
background: var(--accent);
color: white;
cursor: pointer;
}}
.turn {{
background: var(--panel);
border: 1px solid rgba(121, 93, 44, 0.14);
border-radius: 24px;
padding: 20px;
margin: 18px 0;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}}
.turn.hidden {{
display: none;
}}
.turn-head {{
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}}
.turn-title {{
font-size: 24px;
font-weight: 700;
}}
.turn-meta {{
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 13px;
}}
.turn-meta span {{
background: #efe4d1;
border-radius: 999px;
padding: 6px 10px;
}}
details.block {{
margin-top: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel-strong);
padding: 14px 16px;
}}
summary {{
cursor: pointer;
font-weight: 700;
}}
.message {{
margin-top: 12px;
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
background: #fffdfa;
}}
.message-header {{
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
font-size: 13px;
color: var(--muted);
}}
.badge {{
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
color: white;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}}
.badge-user {{ background: var(--user); }}
.badge-assistant {{ background: var(--assistant); }}
.badge-tool {{ background: var(--tool); }}
.badge-system {{ background: #334155; }}
pre {{
margin: 0;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
border-radius: 14px;
padding: 14px;
background: #faf5ec;
border: 1px solid #eee2cf;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 13px;
line-height: 1.55;
}}
.tool-block {{
margin-top: 12px;
}}
.tool-name {{
font-weight: 700;
}}
.status {{
margin-left: auto;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
text-transform: uppercase;
font-weight: 700;
}}
.status.ok {{
background: #dcfce7;
color: #166534;
}}
.status.error {{
background: #fee2e2;
color: #991b1b;
}}
.empty {{
padding: 32px;
color: var(--muted);
text-align: center;
border: 1px dashed var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.45);
}}
@media (max-width: 980px) {{
.app {{
grid-template-columns: 1fr;
}}
.sidebar {{
position: static;
height: auto;
}}
.main {{
padding-top: 14px;
}}
}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<h1>Hive Debug</h1>
<p>Pick a session in the browser and inspect prompts, inputs, outputs, and tool activity turn by turn.</p>
</div>
<input id="sessionSearch" type="search" placeholder="Filter sessions">
<div class="setup-note">
<h3>Logging status</h3>
<p>LLM turn logging is always on. If this list is empty, run Hive once and refresh after the session produces turns.</p>
<pre>~/.hive/llm_logs</pre>
</div>
<div class="session-list" id="sessionList"></div>
</aside>
<main class="main">
<section class="hero">
<h2 id="heroTitle">LLM Debug Session</h2>
<code id="heroId"></code>
<div class="meta-grid" id="metaGrid"></div>
</section>
<div class="toolbar">
<input id="turnFilter" type="search" placeholder="Filter selected session by text, tool name, role, model, or prompt content">
<button type="button" id="expandAll">Expand all</button>
<button type="button" id="collapseAll">Collapse all</button>
</div>
<div id="turns"></div>
</main>
</div>
<script id="session-summaries" type="application/json">{json.dumps(summaries_data, ensure_ascii=False)}</script>
<script id="session-records" type="application/json">{json.dumps(sessions_data, ensure_ascii=False)}</script>
<script>
const summaries = JSON.parse(document.getElementById("session-summaries").textContent);
const recordsBySession = JSON.parse(document.getElementById("session-records").textContent);
const initialSessionId = {json.dumps(initial, ensure_ascii=False)};
const sessionSearch = document.getElementById("sessionSearch");
const sessionList = document.getElementById("sessionList");
const heroTitle = document.getElementById("heroTitle");
const heroId = document.getElementById("heroId");
const metaGrid = document.getElementById("metaGrid");
const turnsEl = document.getElementById("turns");
const turnFilter = document.getElementById("turnFilter");
let activeSessionId = initialSessionId || (summaries[0] ? summaries[0].execution_id : "");
function text(value) {{
return value == null ? "" : String(value);
}}
function escapeHtml(value) {{
return text(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}}
function prettyJson(value) {{
return escapeHtml(JSON.stringify(value, null, 2));
}}
function sessionMatches(summary, query) {{
if (!query) return true;
const haystack = [
summary.execution_id,
summary.start_display,
summary.end_display,
summary.log_file,
...(summary.streams || []),
...(summary.nodes || []),
...(summary.models || []),
].join("\\n").toLowerCase();
return haystack.includes(query);
}}
function renderSessionChooser() {{
const query = sessionSearch.value.trim().toLowerCase();
const filtered = summaries.filter((summary) => sessionMatches(summary, query));
sessionList.innerHTML = filtered
.map((summary) => {{
const active = summary.execution_id === activeSessionId ? " active" : "";
const chips = [
summary.start_display,
`${{summary.turn_count}} turns`,
...(summary.models || []).slice(0, 2),
];
return `
<button type="button" class="session-card${{active}}" data-session-id="${{escapeHtml(summary.execution_id)}}">
<div class="sid">${{escapeHtml(summary.execution_id)}}</div>
<div class="meta">${{chips.map((chip) => `<span>${{escapeHtml(chip)}}</span>`).join("")}}</div>
</button>
`;
}})
.join("") || '<div class="empty">No matching sessions.</div>';
}}
function renderMetaCard(label, value) {{
return `<div class="meta-card"><span class="label">${{escapeHtml(label)}}</span>${{escapeHtml(value || "-")}}</div>`;
}}
function renderMessage(message, index) {{
const role = text(message.role || "unknown");
const content = text(message.content || "");
const toolCalls = message.tool_calls;
return `
<div class="message">
<div class="message-header">
<span class="badge badge-${{escapeHtml(role)}}">${{escapeHtml(role)}}</span>
<span>message ${{index}}</span>
</div>
${{
content
? `<pre>${{escapeHtml(content)}}</pre>`
: '<div class="empty">(empty message)</div>'
}}
${{
toolCalls
? `<details class="block"><summary>tool_calls</summary><pre>${{prettyJson(toolCalls)}}</pre></details>`
: ""
}}
</div>
`;
}}
function renderToolCall(toolCall, index) {{
const name = text(toolCall.tool_name || (toolCall.function || {{}}).name || "unknown");
const error = !!toolCall.is_error;
return `
<div class="tool-block">
<div class="message-header">
<span class="badge badge-tool">tool ${{index}}</span>
<span class="tool-name">${{escapeHtml(name)}}</span>
<span class="status ${{error ? "error" : "ok"}}">${{error ? "error" : "ok"}}</span>
</div>
<pre>${{prettyJson(toolCall)}}</pre>
</div>
`;
}}
function renderTurn(record) {{
const tokenCounts = record.token_counts || {{}};
const messages = Array.isArray(record.messages) ? record.messages : [];
const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : [];
const toolResults = Array.isArray(record.tool_results) ? record.tool_results : [];
const systemPrompt = text(record.system_prompt || "");
const assistantText = text(record.assistant_text || "");
const parseError = text(record._parse_error || "");
return `
<section class="turn">
<div class="turn-head">
<div class="turn-title">Iteration ${{escapeHtml(record.iteration ?? "?")}}</div>
<div class="turn-meta">
<span>${{escapeHtml(record.timestamp || "-")}}</span>
<span>node=${{escapeHtml(record.node_id || "-")}}</span>
<span>stream=${{escapeHtml(record.stream_id || "-")}}</span>
<span>model=${{escapeHtml(tokenCounts.model || "-")}}</span>
<span>stop=${{escapeHtml(tokenCounts.stop_reason || "-")}}</span>
<span>in=${{escapeHtml(tokenCounts.input ?? "-")}}</span>
<span>out=${{escapeHtml(tokenCounts.output ?? "-")}}</span>
</div>
</div>
${{
systemPrompt
? `<details class="block" open><summary>System prompt</summary><pre>${{escapeHtml(systemPrompt)}}</pre></details>`
: ""
}}
${{
messages.length
? `<details class="block" open><summary>Input messages (${{messages.length}})</summary>${{messages.map((message, index) => renderMessage(message, index + 1)).join("")}}</details>`
: ""
}}
<details class="block" open>
<summary>Assistant output</summary>
<pre>${{escapeHtml(assistantText)}}</pre>
</details>
${{
toolCalls.length
? `<details class="block" open><summary>Tool calls (${{toolCalls.length}})</summary>${{toolCalls.map((toolCall, index) => renderToolCall(toolCall, index + 1)).join("")}}</details>`
: ""
}}
${{
toolResults.length
? `<details class="block"><summary>Tool results (${{toolResults.length}})</summary><pre>${{prettyJson(toolResults)}}</pre></details>`
: ""
}}
${{
parseError
? `<details class="block"><summary>Parse error</summary><pre>${{prettyJson(record)}}</pre></details>`
: ""
}}
</section>
`;
}}
function renderSession(sessionId) {{
activeSessionId = sessionId;
const summary = summaries.find((entry) => entry.execution_id === sessionId);
const records = recordsBySession[sessionId] || [];
renderSessionChooser();
if (!summary) {{
heroTitle.textContent = "No session selected";
heroId.textContent = "";
metaGrid.innerHTML = "";
turnsEl.innerHTML = '<div class="empty">No session data available.</div>';
return;
}}
heroTitle.textContent = "LLM Debug Session";
heroId.textContent = summary.execution_id;
metaGrid.innerHTML = [
renderMetaCard("Started", summary.start_display),
renderMetaCard("Ended", summary.end_display),
renderMetaCard("Turns", String(summary.turn_count)),
renderMetaCard("Streams", (summary.streams || []).join(", ")),
renderMetaCard("Nodes", (summary.nodes || []).join(", ")),
renderMetaCard("Models", (summary.models || []).join(", ")),
renderMetaCard("Source file", summary.log_file),
].join("");
turnsEl.innerHTML = records.length
? records.map((record) => renderTurn(record)).join("")
: '<div class="empty">This session has no turn records.</div>';
applyTurnFilter();
history.replaceState(null, "", `#${{encodeURIComponent(sessionId)}}`);
}}
function applyTurnFilter() {{
const query = turnFilter.value.trim().toLowerCase();
for (const turn of document.querySelectorAll(".turn")) {{
const visible = !query || turn.textContent.toLowerCase().includes(query);
turn.classList.toggle("hidden", !visible);
}}
}}
sessionSearch.addEventListener("input", renderSessionChooser);
sessionList.addEventListener("click", (event) => {{
const card = event.target.closest(".session-card");
if (!card) return;
renderSession(card.dataset.sessionId);
}});
turnFilter.addEventListener("input", applyTurnFilter);
document.getElementById("expandAll").addEventListener("click", () => {{
for (const details of document.querySelectorAll("details")) details.open = true;
}});
document.getElementById("collapseAll").addEventListener("click", () => {{
for (const details of document.querySelectorAll("details")) details.open = false;
}});
const hashSession = decodeURIComponent(window.location.hash.replace(/^#/, ""));
const bootSession = recordsBySession[hashSession] ? hashSession : activeSessionId;
renderSessionChooser();
renderSession(bootSession);
</script>
</body>
</html>
"""
def _write_report(html_report: str, output: Path | None) -> Path:
if output is not None:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(html_report, encoding="utf-8")
return output
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
prefix="hive_llm_debug_",
suffix=".html",
delete=False,
dir="/tmp",
) as handle:
handle.write(html_report)
return Path(handle.name)
def main() -> int:
args = _parse_args()
records = _discover_records(args.logs_dir.expanduser(), args.limit_files)
summaries, sessions = _group_sessions(records)
initial_session_id = args.session or (summaries[0].execution_id if summaries else "")
if initial_session_id and initial_session_id not in sessions:
print(f"session not found: {initial_session_id}")
return 1
html_report = _render_html(summaries, sessions, initial_session_id)
output_path = _write_report(html_report, args.output)
print(output_path)
if not args.no_open:
webbrowser.open(output_path.resolve().as_uri())
return 0
if __name__ == "__main__":
raise SystemExit(main())