#!/usr/bin/env python3 """Build one HTML page that compares the builder runs in a run directory. build_report.py [--step judge|prices=queued|running|done ...] --step writes the state of a step that comes after the run to status.json, then builds the page. The page shows a queued or running step in the Result and Cost columns. When the live server serves the page, the page reloads when it is built again. Inputs in : prompt.txt the prompt that every builder received case.json {"title", "expected", "prices": {model: {"input", "cached", "cacheWrite", "output", "source"}}} Prices are USD per million tokens, with the URL of the price page. They are optional. A model with a price gets its cost calculated from its tokens. /run.json and the files that the runner scripts wrote for each builder verdicts.json optional: {"": {"verdict": "pass|partial|fail", "summary": "...", "checks": [{"id": 3, "ok": true|false|null, "text": "..."}]}} id is the number of the expected outcome item. It is optional. Output: /report/index.html with the canvas images copied next to it. """ import argparse import html import json import os import re import shutil import sys import time import urllib.request from collections import Counter from datetime import datetime from pathlib import Path ASSET_CACHE = Path.home() / '.config' / 'n8n-builder-compare' / 'assets' PLAYER_VERSION = '3.17.0' PLAYER_FILES = ('asciinema-player.min.js', 'asciinema-player.css') MAX_CHARS = 15000 TITLES = {'instance-ai': 'Instance AI', 'claude': 'Claude Code + n8n MCP', 'codex': 'Codex + n8n MCP'} def ts(value): value = value.replace('Z', '+00:00') if '+' not in value[10:] and '-' not in value[19:]: value += '+00:00' return datetime.fromisoformat(value).timestamp() def esc(value): return html.escape(value if isinstance(value, str) else json.dumps(value, indent=2, ensure_ascii=False)) def pretty(value): if isinstance(value, str): try: value = json.loads(value) except ValueError: pass text = value if isinstance(value, str) else json.dumps(value, indent=2, ensure_ascii=False) if len(text) > MAX_CHARS: text = text[:MAX_CHARS] + f'\n\n… truncated ({len(text):,} chars in total)' return text def content_text(content): if isinstance(content, dict) and 'content' in content: content = content['content'] if isinstance(content, list): return '\n'.join(c.get('text', '') if isinstance(c, dict) else str(c) for c in content) return content if isinstance(content, str) else json.dumps(content) def kilo(n): return f'{n / 1_000_000:.2f}M' if n >= 1_000_000 else f'{n / 1000:.1f}k' if n >= 1000 else str(n) def price_calls(calls, model, prices): """Calculate the cost of each call from the case.json price of the model.""" p = (prices or {}).get(model) if not p: return False for c in calls: fresh = max(c['prompt'] - c['cache_read'] - c['cache_write'], 0) c['cost'] = ( fresh * p.get('input', 0) + c['cache_read'] * p.get('cached', 0) + c['cache_write'] * p.get('cacheWrite', p.get('input', 0)) + c['out'] * p.get('output', 0) ) / 1_000_000 return True def png_size(path): """Width and height in pixels, read from the PNG header.""" header = Path(path).read_bytes()[16:24] return int.from_bytes(header[:4], 'big'), int.from_bytes(header[4:], 'big') def read_jsonl(path): lines = Path(path).read_text().splitlines() rows = [] for i, line in enumerate(lines): if not line.strip(): continue try: rows.append(json.loads(line)) except json.JSONDecodeError: # A running builder can be in the middle of writing the last line. if i == len(lines) - 1: break raise return rows def run_seconds(meta): # A CLI run also spends time on container start-up, the prompt paste wait and shutdown. if 'turnStartedAt' in meta or 'turnFinishedAt' in meta: if meta.get('turnStartedAt') and meta.get('turnFinishedAt'): return ts(meta['turnFinishedAt']) - ts(meta['turnStartedAt']) return None return ts(meta['finishedAt']) - ts(meta['startedAt']) if meta.get('finishedAt') and meta.get('startedAt') else None def load_claude(run_dir, meta, prices): rows = read_jsonl(run_dir / meta['sessionFile']) # Interactive runs have no event stream, so there is no result event with a cost estimate. stream = read_jsonl(run_dir / 'stream.jsonl') if (run_dir / 'stream.jsonl').exists() else [] final = next((d for d in stream if d.get('type') == 'result'), {}) start = ts(next(r['timestamp'] for r in rows if r.get('timestamp'))) results = {} for r in rows: content = r['message'].get('content') if r.get('type') == 'user' else None for c in content if isinstance(content, list) else []: if c.get('type') == 'tool_result': results[c['tool_use_id']] = (r['timestamp'], c) # One API response is split over several rows that repeat its usage. The last copy is complete. calls, events, model = {}, [], None for r in rows: if r.get('type') != 'assistant': continue msg = r['message'] model = msg.get('model') or model t = ts(r['timestamp']) - start usage = msg.get('usage') or {} read, write, inp = (usage.get('cache_read_input_tokens') or 0, usage.get('cache_creation_input_tokens') or 0, usage.get('input_tokens') or 0) # Anthropic reports uncached input only. prompt = inp + read + write calls.setdefault(msg['id'], {'t': t, 'id': msg['id']}).update( prompt=prompt, cache_read=read, cache_write=write, out=usage.get('output_tokens') or 0, cost=None) for c in msg.get('content', []): kind = c.get('type') if kind == 'thinking' and c.get('thinking', '').strip(): events.append({'kind': 'reasoning', 't': t, 'text': c['thinking'], 'call': msg['id']}) elif kind == 'text' and c.get('text', '').strip(): events.append({'kind': 'text', 't': t, 'text': c['text'], 'call': msg['id']}) elif kind == 'tool_use': end, res = results.get(c['id'], (None, {})) events.append({ 'kind': 'tool', 't': t, 'call': msg['id'], 'name': split_tool_name(c['name'])[1], 'fullName': c['name'], 'duration': (ts(end) - start - t) if end else None, 'args': c.get('input'), 'result': content_text(res.get('content')), 'error': bool(res.get('is_error')), }) calls = list(calls.values()) if price_calls(calls, model, prices): cost, source = f'${sum(c["cost"] for c in calls):.2f}', 'prices' elif final.get('total_cost_usd') is not None: cost, source = f'${final["total_cost_usd"]:.2f}', 'claude-code' else: cost, source = 'not reported', None duration = final['duration_ms'] / 1000 if final.get('duration_ms') else run_seconds(meta) return {'events': events, 'calls': calls, 'model': model, 'context': claude_context(rows), 'duration': f'{duration:.0f}s' if duration else '-', 'cost': cost, 'costSource': source} def claude_context(rows): """The system prompt and tools of the first API request, from the request shape in the session file.""" shape = next((r['shape'] for r in rows if r.get('type') == 'api-request-shape'), None) if not shape: return None system = shape.get('system') text = system if isinstance(system, str) else '\n\n'.join(b.get('text', '') for b in system or []) tools = [{'name': t.get('name'), 'description': t.get('description') or '', 'schema': t.get('input_schema'), 'type': t.get('type'), 'deferred': bool(t.get('defer_loading'))} for t in shape.get('tools') or []] # With tool search on, the model gets only the names of deferred tools and loads a schema with ToolSearch. deferred = {} for r in rows: delta = r.get('attachment') if isinstance(r.get('attachment'), dict) else {} if delta.get('type') == 'deferred_tools_delta': deferred.update(dict.fromkeys(delta.get('addedNames') or [])) for name in delta.get('removedNames') or []: deferred.pop(name, None) known = {t['name'] for t in tools} tools += [{'name': name, 'description': '', 'schema': None, 'type': None, 'deferred': True} for name in deferred if name not in known] return {'messages': [{'label': 'System prompt', 'text': text}] if text else [], 'tools': tools} def mcp_name(item): return f'mcp__{item["server"]}__{item.get("tool")}' if item.get('server') else None def codex_session_events(rows): """Trace events from the item_completed rows of a Codex session file.""" first = next((ts(r['timestamp']) for r in rows if r.get('timestamp')), 0) events = [] # Codex calls MCP tools from scripts that it runs with its exec tool. The scripts are not n8n tool calls. outputs = {(r.get('payload') or {}).get('call_id'): (r.get('payload') or {}).get('output') for r in rows if (r.get('payload') or {}).get('type') == 'custom_tool_call_output'} for r in rows: payload = r.get('payload') or {} if r.get('type') == 'response_item' and payload.get('type') == 'custom_tool_call': events.append({'kind': 'code', 't': ts(r['timestamp']) - first, 'name': payload.get('name'), 'code': payload.get('input'), 'result': content_text(outputs.get(payload.get('call_id')))}) continue if r.get('type') != 'event_msg' or payload.get('type') != 'item_completed': continue item = payload.get('item') or {} begin = (payload.get('started_at_ms') or 0) / 1000 - first if payload.get('started_at_ms') else ts(r['timestamp']) - first end = (payload.get('completed_at_ms') or 0) / 1000 - first if payload.get('completed_at_ms') else begin kind = item.get('type') if kind in ('AgentMessage', 'Reasoning'): text = '\n'.join(c.get('text', '') for c in item.get('content') or item.get('summary_text') or [] if isinstance(c, dict)).strip() if text: events.append({'kind': 'text' if kind == 'AgentMessage' else 'reasoning', 't': begin, 'text': text}) elif kind == 'McpToolCall': result = item.get('result') events.append({ 'kind': 'tool', 't': begin, 'name': item.get('tool'), 'fullName': mcp_name(item), 'duration': end - begin, 'args': item.get('arguments'), 'result': content_text(result) if result else item.get('error'), 'error': bool(item.get('error')) or item.get('status') == 'failed' or (isinstance(result, dict) and bool(result.get('isError') or result.get('is_error'))), }) return events def load_codex(run_dir, meta, prices): session = run_dir / meta['sessionFile'] if meta.get('sessionFile') else None rows = read_jsonl(session) if session and session.exists() else [] tools = codex_request_tools(run_dir / (meta.get('requests') or 'requests.jsonl'), meta.get('sessionId')) if not (run_dir / 'stream.jsonl').exists(): return codex_result(codex_session_events(rows), rows, meta, prices, run_seconds(meta), tools) stream = read_jsonl(run_dir / 'stream.jsonl') times_file = run_dir / 'stream.times' times = [float(x) for x in times_file.read_text().split()] if times_file.exists() else [0.0] * len(stream) start = times[0] if times else 0.0 started, events = {}, [] for event, at in zip(stream, times): item = event.get('item') or {} t = at - start if event.get('type') == 'item.started': started[item.get('id')] = t continue if event.get('type') != 'item.completed': continue kind = item.get('type') if kind == 'agent_message' and (item.get('text') or '').strip(): events.append({'kind': 'text', 't': t, 'text': item['text']}) elif kind == 'reasoning' and (item.get('text') or '').strip(): events.append({'kind': 'reasoning', 't': t, 'text': item['text']}) elif kind == 'mcp_tool_call': begin = started.get(item.get('id'), t) events.append({ 'kind': 'tool', 't': begin, 'name': item.get('tool'), 'fullName': mcp_name(item), 'duration': t - begin, 'args': item.get('arguments'), 'result': content_text(item.get('result')) if item.get('result') else item.get('error'), 'error': bool(item.get('error')) or item.get('status') == 'failed', }) elif kind == 'command_execution': begin = started.get(item.get('id'), t) events.append({ 'kind': 'tool', 't': begin, 'name': 'shell', 'duration': t - begin, 'args': item.get('command'), 'result': item.get('aggregated_output'), 'error': item.get('exit_code') not in (0, None), }) return codex_result(events, rows, meta, prices, (times[-1] - start) if times else None, tools) def codex_result(events, rows, meta, prices, duration, tools=None): calls, model = [], meta.get('model') first = next((ts(r['timestamp']) for r in rows if r.get('timestamp')), 0) for r in rows: payload = r.get('payload') or {} if r.get('type') == 'turn_context': model = payload.get('model') or model if r.get('type') == 'token_usage_record': u = payload.get('usage') or {} calls.append({'t': ts(r['timestamp']) - first, 'prompt': u.get('input_tokens') or 0, 'cache_read': u.get('cached_input_tokens') or 0, 'cache_write': u.get('cache_write_input_tokens') or 0, 'out': u.get('output_tokens') or 0, 'cost': None}) priced = price_calls(calls, model, prices) return {'events': events, 'calls': calls, 'model': model, 'context': codex_context(rows, tools), 'duration': f'{duration:.0f}s' if duration else '-', 'cost': f'${sum(c["cost"] for c in calls):.2f}' if priced else 'not reported', 'costSource': 'prices' if priced else None} NESTED_TOOL = re.compile(r'^### `([^`]+)`[ \t]*$', re.M) TS_BLOCK = re.compile(r'```ts\n(.*?)```', re.S) def split_nested_tools(text): """In code mode, the exec description lists the tools that its scripts can call, one ### section each with a TypeScript declaration. Returns the description before those sections and the tools.""" marks = list(NESTED_TOOL.finditer(text)) nested = [] for i, m in enumerate(marks): body = text[m.end():marks[i + 1].start() if i + 1 < len(marks) else len(text)] blocks = TS_BLOCK.findall(body) if blocks: nested.append((m.group(1), body.split('exec tool declaration:')[0].strip(), blocks[-1].strip())) if not nested: return text, [] return text[:marks[0].start()].rstrip(), nested def codex_tool(t, name): tool = {'name': name, 'description': t.get('description') or '', 'schema': t.get('parameters'), 'type': t.get('type')} if t.get('type') != 'custom': return [tool] fmt = t.get('format') or {} if fmt.get('definition'): tool['code'] = {'label': f'Input grammar ({fmt.get("syntax") or fmt.get("type")})', 'text': fmt['definition']} tool['description'], nested = split_nested_tools(tool['description']) return [tool] + [{'name': n, 'description': d, 'schema': None, 'type': 'function', 'group': f'System · in {name}', 'code': {'label': f'Declaration in {name}', 'text': decl}} for n, d, decl in nested] def codex_request_tools(path, session_id=None): """The tools of the first model request of the session thread that the request recorder saved, as one flat list. Side tasks, such as a title request, run on other threads with other tools.""" if not path.exists(): return None rows = [r for r in read_jsonl(path) if r.get('tools')] own = [r for r in rows if session_id and (r.get('client_metadata') or {}).get('thread_id') and session_id.endswith(r['client_metadata']['thread_id'])] raw = (own or rows or [{}])[0].get('tools') tools = [] for t in raw or []: if t.get('type') != 'namespace': tools += codex_tool(t, t.get('name') or t.get('type')) continue space = t.get('name') or '' for sub in t.get('tools') or []: name = sub.get('name') or '' tools += codex_tool(sub, f'{space.rstrip("_")}__{name}' if space.startswith('mcp__') else name) return tools or None def codex_context(rows, tools=None): """The base instructions and the messages that Codex adds before the prompt, from the session file, and the tools from the request recording.""" meta = next((r.get('payload') or {} for r in rows if r.get('type') == 'session_meta'), {}) text = (meta.get('base_instructions') or {}).get('text') messages = [{'label': 'System prompt', 'text': text}] if text else [] for r in rows: payload = r.get('payload') or {} if r.get('type') != 'response_item' or payload.get('type') != 'message': continue body = '\n'.join(c.get('text', '') for c in payload.get('content') or [] if isinstance(c, dict)).strip() # Codex wraps its own context in tags. The first untagged user message is the prompt. if payload.get('role') == 'user' and not body.startswith('<'): break if payload.get('role') in ('developer', 'user') and body: messages.append({'label': 'Developer message' if payload['role'] == 'developer' else 'Context', 'text': body}) if not messages and not tools: return None if not tools: return {'messages': messages, 'tools': None, 'toolsNote': 'Codex does not save its tool definitions, and this run has no request recording.'} notes = {} if any(t.get('group') for t in tools) and not any(split_tool_name(t['name'])[0] for t in tools): notes['MCP'] = 'Not in the prompt. Codex scripts look up MCP tools in ALL_TOOLS at run time.' return {'messages': messages, 'tools': tools, 'toolNotes': notes} def tree_events(tree, start, label=''): tool_calls = {c['toolCallId']: c for c in tree.get('toolCalls') or []} events, last_t = [], 0.0 for item in tree.get('timeline') or []: if item.get('type') == 'tool-call' and item.get('toolCallId') in tool_calls: c = tool_calls[item['toolCallId']] t = ts(c['startedAt']) - start if c.get('startedAt') else last_t last_t = t result = c.get('result') events.append({ 'kind': 'tool', 't': t, 'name': label + c['toolName'], 'duration': (ts(c['completedAt']) - ts(c['startedAt'])) if c.get('completedAt') and c.get('startedAt') else None, 'args': c.get('args'), 'result': result, 'error': isinstance(result, dict) and (result.get('success') is False or 'error' in result), 'note': 'Confirmation card answered by the auto-approve policy.' if c.get('confirmation') else None, }) elif (item.get('content') or '').strip(): events.append({'kind': 'reasoning' if item['type'] == 'reasoning' else 'text', 't': last_t, 'text': item['content'], 'approx': True}) for child in tree.get('children') or []: events += tree_events(child, start, f'{child.get("role") or child.get("agentId")} › ') return events def load_instance_ai(run_dir, meta, prices): trace = json.loads((run_dir / 'trace.json').read_text()) rich = trace['rich'].get('data', trace['rich']) messages = rich['messages'] user = next(m for m in messages if m.get('role') == 'user') start = ts(user['createdAt']) events = [] for m in messages: if m.get('role') == 'assistant' and m.get('agentTree'): events += tree_events(m['agentTree'], start) events.sort(key=lambda e: e['t']) calls, model, cost = [], None, 'not reported' usage_file = run_dir / 'usage.json' if usage_file.exists(): usage = json.loads(usage_file.read_text()) models = sorted({u['model'] for u in usage if u.get('model')}) model = ', '.join(models) or None calls = sorted(( {'t': ts(u['start']) - start, 'start': ts(u['start']) - start, 'end': ts(u['end']) - start if u.get('end') else None, 'name': (u.get('name') or '').removeprefix('llm: ') or None, 'prompt': u['in'], 'cache_read': u['cache_read'], 'cache_write': u['cache_write'], 'out': u['out'], 'cost': u['cost']} for u in usage), key=lambda c: c['t']) cost = f'${sum(c["cost"] for c in calls):.2f}' duration = ts(meta['finishedAt']) - ts(meta['startedAt']) contexts = instance_ai_contexts(run_dir / 'context.json') first = next(iter(contexts), None) return {'events': events, 'calls': calls, 'model': model, 'duration': f'{duration:.0f}s', 'cost': cost, 'costSource': 'langsmith' if usage_file.exists() else None, 'context': contexts.pop(first) if first else { 'messages': [], 'tools': None, 'toolsNote': 'This run has no context.json. Run langsmith_usage.py again to fetch it from LangSmith.'}, 'agentContexts': contexts} LEADING_TAG_BLOCK = re.compile(r'\s*<([\w-]+)>.*?', re.S) def split_tagged_context(text): """n8n puts tagged blocks, such as , before the user prompt. Returns (blocks, rest).""" blocks, pos = [], 0 while m := LEADING_TAG_BLOCK.match(text, pos): blocks.append(m.group(0).strip()) pos = m.end() return blocks, text[pos:].strip() def instance_ai_contexts(path): """{agent: context} from the context.json of langsmith_usage.py, in the order that the agents started. The first agent answers the prompt, so the report shows its prompt in the User card, not here.""" if not path.exists(): return {} contexts = {} for i, agent in enumerate(json.loads(path.read_text())): messages, system_seen = [], False for m in agent.get('messages') or []: if m['role'] == 'system': messages.append({'label': 'System message' if system_seen else 'System prompt', 'text': m['text']}) system_seen = True continue blocks, rest = split_tagged_context(m['text']) messages += [{'label': 'Context', 'text': b} for b in blocks] if rest and i > 0: messages.append({'label': 'Briefing', 'text': rest}) loaded = [{**t, 'group': 'System · loaded with load_tool'} for t in agent.get('loadedTools') or []] contexts[(agent['agent'] or '').removeprefix('llm: ') or None] = { 'messages': [m for m in messages if m['text'].strip()], 'tools': (agent.get('tools') or []) + loaded or None} return contexts LOADERS = {'instance-ai': load_instance_ai, 'claude': load_claude, 'codex': load_codex} def build_steps(calls, events): """Group the trace events by the LLM call that produced them. Returns [{"call", "events"}].""" if not calls: return [{'call': None, 'events': [e]} for e in events] steps = [{'call': c, 'events': []} for c in calls] by_id = {c['id']: s for c, s in zip(calls, steps) if c.get('id')} starts = [c.get('start') for c in calls] ends = [c.get('end') if c.get('end') is not None else c['t'] for c in calls] known_starts = all(s is not None for s in starts) last = len(calls) - 1 for e in events: if e.get('call') in by_id: by_id[e['call']]['events'].append(e) continue t = e['t'] # A tool call runs after the response that asked for it. Text is written during its response. if e['kind'] == 'tool': edges = starts if known_starts else ends i = max((k for k, edge in enumerate(edges) if edge <= t + 0.05), default=0) else: edges = starts if known_starts else ends i = next((k for k, edge in enumerate(edges) if edge >= t - 0.05), last) steps[i]['events'].append(e) return steps def token_bar(c, max_prompt, max_out): fresh = max(c['prompt'] - c['cache_read'] - c['cache_write'], 0) segs = ''.join( f'' for cls, n in (('read', c['cache_read']), ('write', c['cache_write']), ('fresh', fresh)) if n ) return (f'
{segs}
' f'
') def step_preview(events, last): tools = Counter(e['name'] for e in events if e['kind'] == 'tool') if tools: return f'{esc(", ".join(f"{n} ×{k}" if k > 1 else n for n, k in tools.items()))}' text = next((e['text'] for kind in ('text', 'reasoning') for e in events if e['kind'] == kind), None) if text and last: return 'Final reply' if text: return f'{esc(" ".join(text.split())[:160])}' code = next((e for e in events if e['kind'] == 'code'), None) if code: return f'{esc(code["name"])}: {esc(" ".join((code["code"] or "").split())[:160])}' return 'No text or tool calls' def size_label(text): n = len((text or '').encode()) return f'{n} B' if n < 1024 else f'{n / 1024:.1f} KB' def render_card(e, index): time = f'{e["t"]:.1f}s{"*" if e.get("approx") else ""}' if e['kind'] == 'text': return (f'
Agent' f'{time}
{esc(e["text"])}
') if e['kind'] == 'reasoning': return (f'
Reasoning' f'{time}
{esc(e["text"])}
') if e['kind'] == 'code': return (f'
Script' f'{esc(e["name"])}{time}
' f'
{esc(e["code"] or "")}
' f'
Output
{esc(pretty(e["result"]))}
') output = pretty(e['result']) duration = f' · {e["duration"]:.1f}s' if e.get('duration') is not None else '' status = 'Error' if e['error'] else '' note = f'
{esc(e["note"])}
' if e.get('note') else '' return ( f'
' f'Tool #{index}{esc(e["name"])}{status}' f'{time}{duration}
{note}' f'
Input
{esc(pretty(e["args"]))}
' f'Output · {size_label(output)}' f'
{esc(output)}
' ) def split_tool_name(name): """('n8n', 'search_nodes') for 'mcp__n8n__search_nodes'. Names without an MCP prefix have no server.""" parts = (name or '').split('__') return (parts[1], '__'.join(parts[2:])) if len(parts) >= 3 and parts[0] == 'mcp' else (None, name or '') def tool_group(tool): """System for the built-in tools of the CLI, one group for each MCP server, and Other for the rest, such as tools that the model provider runs.""" server = split_tool_name(tool['name'])[0] if server: return f'MCP · {server}' if tool.get('type') not in (None, 'custom', 'function'): return 'Other' return 'System' def schema_type(s): if not isinstance(s, dict): return 'any' if 'enum' in s: values = [json.dumps(v) for v in s['enum']] return ' | '.join(values[:6]) + (' | …' if len(values) > 6 else '') if 'const' in s: return json.dumps(s['const']) for key in ('anyOf', 'oneOf'): if key in s: return ' | '.join(dict.fromkeys(schema_type(x) for x in s[key])) t = s.get('type') if isinstance(t, list): return ' | '.join(t) if t == 'array': return f'{schema_type(s.get("items"))}[]' return t or 'any' def schema_limits(s): labels = (('minLength', 'min length'), ('maxLength', 'max length'), ('minimum', 'min'), ('maximum', 'max'), ('minItems', 'min items'), ('maxItems', 'max items'), ('format', 'format'), ('pattern', 'pattern'), ('default', 'default')) return ' · '.join(f'{label} {json.dumps(s[k]) if k == "default" else s[k]}' for k, label in labels if k in s) def schema_rows(schema, prefix='', depth=0): """One row for each parameter. Nested objects add rows with a dotted name, two levels deep.""" if not isinstance(schema, dict): return [] required = set(schema.get('required') or []) rows = [] for key, s in (schema.get('properties') or {}).items(): s = s if isinstance(s, dict) else {} rows.append({'name': prefix + key, 'type': schema_type(s), 'required': key in required, 'description': s.get('description') or '', 'limits': schema_limits(s), 'depth': depth}) if depth < 2: items = s.get('items') if isinstance(s.get('items'), dict) else {} if s.get('properties'): rows += schema_rows(s, f'{prefix}{key}.', depth + 1) elif items.get('properties'): rows += schema_rows(items, f'{prefix}{key}[].', depth + 1) return rows def render_params(schema): rows = schema_rows(schema) if not rows: return '

No parameters.

' body = ''.join( f'{esc(r["name"])}' + ('required' if r['required'] else '') + f'{esc(r["type"])}{esc(r["description"])}' + (f'
{esc(r["limits"])}
' if r['limits'] else '') + '' for r in rows ) return f'{body}
ParameterTypeDescription
' def render_tool_spec(e): if e.get('runtime'): return '

Not in the prompt. The model found this tool at run time, so the report has no schema for it.

' if e.get('deferred') and not e.get('schema'): return ('

Deferred. The model got only the name, and loads the schema with ToolSearch ' 'when it needs the tool.

') if e.get('code'): return f'
{esc(e["code"]["label"])}
{esc(e["code"]["text"])}
' return (f'{render_params(e["schema"])}
JSON schema' f'
{esc(json.dumps(e["schema"], indent=2))}
') def render_tools(tools, events, notes=None): """Tool chips grouped by area, with the calls of this run, and the parameters of the selected tool. notes replaces the text of an empty group.""" calls, failures = {}, {} for e in events: if e['kind'] in ('tool', 'code'): name = e.get('fullName') or e['name'] calls[name] = calls.get(name, 0) + 1 failures[name] = failures.get(name, 0) + bool(e.get('error')) entries = [] for t in tools: short = split_tool_name(t['name'])[1] count = calls.get(t['name'], 0) or calls.get(short, 0) failed = failures.get(t['name'], 0) or failures.get(short, 0) entries.append({**t, 'short': short, 'group': t.get('group') or tool_group(t), 'calls': count, 'failed': failed}) known = {t['name'] for t in tools} for name, count in calls.items(): server, short = split_tool_name(name) if server and name not in known: entries.append({'name': name, 'short': short, 'description': '', 'group': f'MCP · {server}', 'calls': count, 'failed': failures[name], 'runtime': True}) used = [e for e in entries if e['calls']] selected = max(used, key=lambda e: e['calls'])['name'] if used else entries[0]['name'] names = {e['group'] for e in entries} # System and MCP always show, so an empty group says that the model had none of these tools. order = (['System'] + sorted(n for n in names if n.startswith('System ·')) + sorted(n for n in names if n.startswith('MCP')) + (['Other'] if 'Other' in names else [])) if not any(n.startswith('MCP') for n in names): order.insert(order.index('Other') if 'Other' in order else len(order), 'MCP') groups = [] for group in order: members = sorted((e for e in entries if e['group'] == group), key=lambda e: (-e['calls'], e['short'])) note = (notes or {}).get(group.split(' · ')[0]) if not members: groups.append(f'
{esc(group)} · 0
' f'{esc(note or "None given to the model.")}
') continue if all(e.get('runtime') for e in members): note = note or 'Not in the prompt. The model found these tools at run time.' elif any(e.get('deferred') and not e.get('schema') for e in members): note = 'Dashed tools are deferred: the model got only their names, and loads a schema with ToolSearch.' else: note = None chips = ''.join( f'' for e in members ) groups.append(f'
{esc(group)} · {len(members)}
' + (f'{esc(note)}' if note else '') + f'
{chips}
') details = [] for e in entries: usage = '' if e['calls']: usage = f'Called ×{e["calls"]}' if e['failed']: usage += f'{e["failed"]} failed' details.append( f'
' f'
{esc(e["short"])}' f'{esc(e["group"])}{usage}
' + (f'
{esc(e["description"].strip())}
' if e['description'].strip() else '') + f'{render_tool_spec(e)}
' ) return ( f'
Tools' f'{len(tools)} tools · {len(used)} called · {size_label(json.dumps(tools))}' f'
' f'Highlighted tools were called in this run.
' f'
{"".join(groups)}
{"".join(details)}
' ) def render_context(context, events): """Collapsed cards for what the model gets before the prompt: system prompt, added messages and tools.""" if not context: return [] cards = [ f'
{esc(m["label"])}' f'{size_label(m["text"])}
{esc(m["text"])}
' for m in context.get('messages') or [] ] if context.get('tools'): cards.append(render_tools(context['tools'], events, context.get('toolNotes'))) elif context.get('toolsNote'): cards.append(f'
Tools' f'{esc(context["toolsNote"])}
') return cards def sub_agent_events(events): """Sub-agent tool events with the 'role › ' label removed, so that they match the tool names of the agent.""" return [{**e, 'name': e['name'].split(' › ', 1)[1], 'fullName': None} for e in events if e['kind'] == 'tool' and ' › ' in e['name']] def render_steps(calls, events, max_prompt, max_out, prompt, context=None, agent_contexts=None): steps = build_steps(calls, events) if not steps: return fold('LLM steps', '', '

No trace for this run.

', cls='steps') names = {s['call'].get('name') for s in steps if s['call']} pending = dict(agent_contexts or {}) items, panes, tool_index = [], [], 0 for i, step in enumerate(steps, 1): c, evs = step['call'], step['events'] failed = sum(1 for e in evs if e['kind'] == 'tool' and e['error']) # The first call answers the prompt, so the model context and the prompt open its detail. # A sub-agent gets its own context, which opens the detail of its first call. cards = render_context(context, events) if i == 1 else [] if c and c.get('name') in pending: cards += render_context(pending.pop(c['name']), sub_agent_events(events)) if i == 1 and prompt: cards.append(f'
User' f'0.0s
{esc(prompt)}
') for e in evs: tool_index += e['kind'] == 'tool' cards.append(render_card(e, tool_index)) t = c['t'] if c else evs[0]['t'] label = f'{esc(c["name"])}' if c and c.get('name') and len(names) > 1 else '' if failed: label += f'{failed} failed' items.append( f'' ) stats = '' if c: fresh = max(c['prompt'] - c['cache_read'] - c['cache_write'], 0) cells = [('Input', kilo(c['prompt'])), ('Cache read', kilo(c['cache_read'])), ('Cache write', kilo(c['cache_write'])), ('Uncached', kilo(fresh)), ('Output', kilo(c['out']))] if c.get('cost') is not None: cells.append(('Cost', f'${c["cost"]:.3f}')) stats = '
' + ''.join( f'
{k}
{v}
' for k, v in cells) + '
' title = f'Step {i}' + (f' · {esc(c["name"])}' if c and c.get('name') else '') panes.append( f'
' f'

{title}

{t:.1f}s from the prompt
' f'{stats}{"".join(cards) or "

This call wrote no text and called no tools.

"}
' ) legend = ('
Cache readCache write' 'Uncached inputOutput
') count = f'{len(steps)} LLM calls' if calls else f'{len(steps)} events, no per-call token data' return fold('LLM steps', f'{count}', f'{legend if calls else ""}
{"".join(items)}
' f'
{"".join(panes)}
', cls='steps') def code_spans(text): """Show `backticked` parts of HTML text as code. Text with an odd number of backticks stays plain.""" if text.count('`') % 2: return text return ''.join(f'{part}' if i % 2 else part for i, part in enumerate(text.split('`'))) def inline_code(text): """Escape text and show `backticked` parts as code.""" return code_spans(esc(text)) CHECK_GROUPS = ((False, 'bad', '✗', 'Not met'), (None, 'warn', '?', 'Not confirmed'), (True, 'ok', '✓', 'Met')) AUTO_CHECKS_FILE = 'auto-checks.json' # A failed check in these groups of auto-checks.json changes the verdict, when a group has no gating flag. GATING_GROUPS = ('correctness', 'execution', 'contract') def load_auto_checks(sub): path = sub / AUTO_CHECKS_FILE return json.loads(path.read_text()) if path.exists() else None def load_workflow(sub): path = sub / 'workflow.json' return json.loads(path.read_text()) if path.exists() else None def criteria_ids(expected): return [num for _, items in parse_criteria(expected or '') or [] for num, _ in items] def node_value(node, path): """The value at a path such as parameters.text or rules.values[0]. A path without the parameters prefix also reads from the parameters. Returns (found, value).""" keys = [int(k) if k.isdigit() else k for k in re.findall(r'[^.\[\]\'"]+', path or '')] for root in (node, node.get('parameters') or {}): current, found = root, True for key in keys: if isinstance(current, list) and isinstance(key, int) and key < len(current): current = current[key] elif isinstance(current, dict) and key in current: current = current[key] else: found = False break if found: return True, current return False, None def loose(text): """Text for a containment test: one quote style and single spaces.""" return re.sub(r'\s+', ' ', text.replace('\\"', '"').replace('\\n', ' ')).replace('"', "'").strip() def evidence_problems(evidence, workflow): """The evidence entries that the workflow does not contain. An entry is {node, path, value}. An entry with "source": "message" quotes the final message and is not checked here.""" nodes = {n['name']: n for n in (workflow or {}).get('nodes', [])} problems = [] for e in evidence: if e.get('source') == 'message': continue node = nodes.get(e.get('node')) if node is None: problems.append(f'no node is called "{e.get("node")}"') continue target = node if e.get('path'): found, target = node_value(node, e['path']) if not found: problems.append(f'{e["node"]} has no {e["path"]}') continue value = e.get('value') text = target if isinstance(target, str) else json.dumps(target, ensure_ascii=False) if value not in (None, '') and loose(str(value)) not in loose(text): where = f'{e["node"]} › {e["path"]}' if e.get('path') else e['node'] problems.append(f'{where} does not contain "{value}"') return problems def is_strict(v): """A panel verdict, or a verdict with cited evidence: a met check must then cite evidence.""" return v.get('version', 1) >= 2 or bool(v.get('judges')) or any(c.get('evidence') for c in v.get('checks') or []) def add_note(check, note): check['note'] = f'{check["note"]} {note}' if check.get('note') else note def verify_checks(v, workflow): """The checks of a verdict, with a met check that has no evidence, or evidence that the workflow does not contain, changed to not confirmed.""" strict = is_strict(v) out = [] for c in v.get('checks') or []: c = dict(c) if c.get('ok') is True and strict: if not c.get('evidence'): c['ok'] = None add_note(c, 'No evidence cited, so this is not confirmed.') elif problems := evidence_problems(c['evidence'], workflow): c['ok'] = None add_note(c, 'Evidence not found: ' + '; '.join(problems) + '.') out.append(c) return out def is_gating(group): """Older auto-checks.json files have no gating flag, so their group key decides.""" return group.get('gating', group['key'] in GATING_GROUPS) def gating_failures(auto): return [c for g in (auto or {}).get('groups', []) if is_gating(g) for c in g['checks'] if c.get('ok') is False and c.get('level') != 'info'] def brief_met(checks, ids): """(met, total) over the expected outcome criteria. Without criteria ids, or when no check has an id, over all checks.""" if not ids or all(c.get('id') is None for c in checks): return sum(1 for c in checks if c.get('ok') is True), len(checks) by_id = {} for c in checks: by_id.setdefault(str(c.get('id')), []).append(c.get('ok')) return sum(1 for i in ids if by_id.get(i) and all(ok is True for ok in by_id[i])), len(ids) def compute_verdict(v, checks, auto, has_workflow, ids): """pass, partial or fail from the criteria, the goal and the auto-checks. The judge verdict of an old verdict file stays, but a failed auto-check turns its pass into partial.""" if not v: return None gating = gating_failures(auto) if not is_strict(v): return 'partial' if v.get('verdict') == 'pass' and gating else v.get('verdict') if not has_workflow or v.get('goalMet') is False: return 'fail' met, total = brief_met(checks, ids) return 'pass' if total and met == total and not gating else 'partial' def verdict_view(v, sub, expected): """The verdict as the page shows it: verified checks and the computed verdict. Returns (view, auto).""" auto = load_auto_checks(sub) if not v: return v, auto workflow = load_workflow(sub) checks = verify_checks(v, workflow) view = dict(v, checks=checks, judgeVerdict=v.get('verdict')) view['verdict'] = compute_verdict(v, checks, auto, workflow is not None, criteria_ids(expected)) if quality := quality_group(v): auto = dict(auto or {'groups': []}, groups=[*(auto or {}).get('groups', []), quality]) return view, auto def quality_group(v): """The LLM binary checks of the eval suite, as the judge panel answered them. They do not change the verdict.""" items = v.get('quality') or [] if not items: return None checks = [{'id': f'Q{i + 1}', 'ok': q.get('ok'), 'text': f'{q["id"]}: {q.get("text", "")}', 'details': [q['note']] if q.get('note') else [], 'source': {'label': 'Instance AI eval binary checks (LLM), answered by the judges', 'path': 'packages/@n8n/instance-ai/evaluations/binaryChecks/checks'}} for i, q in enumerate(items)] scored = [c for c in checks if c['ok'] is not None] return {'key': 'quality', 'title': 'Eval quality checks', 'gating': False, 'note': 'The LLM binary checks of the Instance AI eval suite. The judge panel answers them. This score does not change the verdict.', 'score': {'passed': sum(1 for c in scored if c['ok']), 'total': len(scored)}, 'checks': checks} def standards_score(auto): group = next((g for g in (auto or {}).get('groups', []) if g['key'] == 'standards'), None) return (group or {}).get('score') def verdict_head(v, expected=''): checks = v.get('checks') or [] met, total = brief_met(checks, criteria_ids(expected)) count = f'{met} of {total} met' if total else '' panel = '' if v.get('judges'): split = len(v.get('disagreements') or []) panel = (f'{v["judges"]} judges' + (f' · {split} split' if split else '') + '') return f'{panel}{count}{badge(v.get("verdict"))}' CHECK_CLASS = {True: 'ok', False: 'bad', None: 'warn'} OTHER_FINDINGS = 'Other findings' def check_body(c): """The text of a judge check, with its note and the evidence that it cites.""" parts = [f'{inline_code(c.get("text", ""))}'] if c.get('note'): parts.append(f'{inline_code(c["note"])}') cited = [] for e in c.get('evidence') or []: where = 'Final message' if e.get('source') == 'message' else ' › '.join(filter(None, [e.get('node'), e.get('path')])) value = f' = {esc(str(e["value"]))}' if e.get('value') not in (None, '') else '' cited.append(f'
  • {esc(where)}{value}
  • ') if cited: parts.append(f'
      {"".join(cited)}
    ') return f'{"".join(parts)}' def render_goal(v): """A failed goal shows above the checks, because it fails the run on its own. A minority of judges that says the goal is not met shows as a warning.""" if v.get('goalMet') is not False: split = next((d for d in v.get('disagreements') or [] if d.get('id') == 'goal'), None) if not split: return '' no = split['votes'].count(False) reasons = '; '.join(t for t in split.get('texts') or [] if t) return (f'
    • ' f'{no} of {len(split["votes"])} judges {"says" if no == 1 else "say"} the workflow does not do what the prompt asks' f'{": " + inline_code(reasons) if reasons else ""}
    ') reason = f': {inline_code(v["goalReason"])}' if v.get('goalReason') else '' return (f'
    • ' f'The workflow does not do what the prompt asks{reason}
    ') def render_checks(v, expected=''): """The judge summary and the checks. expected is the plain expected outcome text. When it has numbered groups and the checks have an id, the checks show under the same groups as the brief. Otherwise the failed checks show first.""" checks = v.get('checks') or [] summary = f'

    {inline_code(v["summary"])}

    ' if v.get('summary') else '' summary += render_goal(v) criteria = parse_criteria(esc(expected)) if expected else None if criteria and any(c.get('id') is not None for c in checks): return summary + render_checks_by_criteria(checks, criteria) groups = [] for ok, cls, mark, label in CHECK_GROUPS: items = [c for c in checks if c.get('ok') is ok] if items: rows = ''.join(f'
  • ' f'{check_body(c)}
  • ' for c in items) groups.append(f'
    {label} · {len(items)}
    ' f'
      {rows}
    ') return summary + ''.join(groups) def render_checks_by_criteria(checks, criteria): """The checks under the groups of the expected outcome. A group with a failed or unconfirmed check shows open. A criterion without a check shows its own text as not judged. Checks with no known id show under Other findings.""" by_id = {} for c in checks: by_id.setdefault(str(c.get('id')), []).append(c) known = {num for _, items in criteria for num, _ in items} other = [c for c in checks if str(c.get('id')) not in known] groups = [(heading, [(num, by_id.get(num), text) for num, text in items]) for heading, items in criteria] if other: groups.append((OTHER_FINDINGS, [('·', [c], '') for c in other])) parts = [] for heading, rows in groups: html_rows = ''.join( ''.join(f'
  • ' f'{check_body(c)}
  • ' for c in cs) if cs else f'
  • ' f'Not judged: {code_spans(text)}
  • ' for num, cs, text in rows) body = f'
      {html_rows}
    ' if not heading: parts.append(f'
    {body}
    ') continue met = sum(1 for _, cs, _ in rows if cs and all(c.get('ok') is True for c in cs)) failed = met < len(rows) count = f'{len(rows)} found' if heading == OTHER_FINDINGS else f'{met} of {len(rows)} met' label = f'{count}' parts.append(f'
    ' f'{heading}{label}{body}
    ') return f'
    {"".join(parts)}
    ' JUDGE_WAIT_TEXT = {'queued': 'Waiting for the judge.', 'running': 'The judge is checking this run.'} def fold(title, meta, body, open_=False, cls=''): """A collapsible box: the title and meta HTML in the header row, the body below.""" return (f'
    ' f'

    {title}

    {meta}
    {body}
    ') def render_verdict(v, expected='', judge=None, pending=False): """The Expected outcome box. It shows open when the verdict is not pass or a check is not confirmed. pending is true on the live page, before the judges can run.""" if not v: if pending: return fold('Expected outcome', 'Not verified yet', '

    The judges check this run against the expected outcome after all builders finish.

    ') meta = pending_badge('judge', judge) or 'Not judged' return fold('Expected outcome', meta, f'

    {JUDGE_WAIT_TEXT.get(judge, "Not judged yet.")}

    ') attention = v.get('verdict') != 'pass' or any(c.get('ok') is not True for c in v.get('checks') or []) return fold('Expected outcome', verdict_head(v, expected), render_checks(v, expected), open_=attention) def auto_check_row(c): info = c.get('level') == 'info' cls = 'info' if info else CHECK_CLASS.get(c.get('ok'), 'warn') details = ''.join(f'
  • {inline_code(d)}
  • ' for d in c.get('details') or []) source = c.get('source') or {} links = [] for s in (source, source.get('also') or {}): if s.get('url'): links.append(f'{esc(s["label"])} ↗') elif s.get('label'): links.append(esc(s['label']) + (f' ({esc(s["path"])})' if s.get('path') else '')) return (f'
  • ' f'{inline_code(c.get("text", ""))}' + (f'
      {details}
    ' if details else '') + (f'Source: {" · ".join(links)}' if links else '') + '
  • ') def render_auto_checks(auto): """The groups of auto-checks.json. A gating group with a failed check shows open.""" if not auto: return '' parts = [] for g in auto.get('groups', []): judged = [c for c in g['checks'] if c.get('ok') is not None and c.get('level') != 'info'] failed = sum(1 for c in judged if c['ok'] is False) gating = is_gating(g) score = g.get('score') if score: label = f'Score {score["passed"]} of {score["total"]} · does not change the verdict' else: label = f'{len(judged) - failed} of {len(judged)} passed' if judged else 'Not checked' rows = ''.join(auto_check_row(c) for c in g['checks']) parts.append(fold( esc(g['title']), f'{esc(label)}', f'

    {esc(g.get("note", ""))}

      {rows}
    ', open_=gating and failed > 0, cls='auto-group')) return ''.join(parts) PENDING_CHECK_GROUPS = ( ('Correctness', 'Checks from the node definitions, the n8n editor and the workflow graph.'), ('Execution with pinned data', 'Runs the workflow once for each scenario, with pinned data.'), ('n8n standards', 'Rules from n8n-io/skills and the workflow-sdk best practices.'), ) def render_pending_checks(): """Placeholders for the auto-check groups that every run gets, before auto-checks.json exists.""" return ''.join(fold(title, 'Not run yet', f'

    {esc(note)} These checks run after all builders finish.

    ', cls='auto-group') for title, note in PENDING_CHECK_GROUPS) def render_result(verdict, judge, auto): """The Result cell: the verdict badge and the standards score.""" score = standards_score(auto) std = (f' ' f'Std {score["passed"]}/{score["total"]}' if score and score['total'] else '') return badge((verdict or {}).get('verdict'), judge) + std PLAYER_CACHE = ASSET_CACHE / f'asciinema-player-{PLAYER_VERSION}' PLAYER_CDN = f'https://cdn.jsdelivr.net/npm/asciinema-player@{PLAYER_VERSION}/dist/bundle/' def ensure_player_cache(): """Download the asciinema player once. Raises OSError when the download fails.""" PLAYER_CACHE.mkdir(parents=True, exist_ok=True) for name in PLAYER_FILES: if not (PLAYER_CACHE / name).exists(): partial = PLAYER_CACHE / f'{name}.part' urllib.request.urlretrieve(PLAYER_CDN + name, partial) partial.rename(PLAYER_CACHE / name) def player_assets(report): """Copy the asciinema player next to the page, so the page works offline. Returns its URLs.""" cache, cdn = PLAYER_CACHE, PLAYER_CDN try: ensure_player_cache() (report / 'vendor').mkdir(exist_ok=True) for name in PLAYER_FILES: shutil.copy(cache / name, report / 'vendor' / name) return [f'vendor/{name}' for name in PLAYER_FILES] except OSError: return [cdn + name for name in PLAYER_FILES] def cast_length(text): """Seconds from the start of an asciicast to its last frame.""" for line in reversed(text.splitlines()): if line.startswith('['): try: return float(json.loads(line)[0]) except (ValueError, IndexError, TypeError): continue return 0.0 def collect_screens(runs, run_dir, report, verdicts): """Copy the screen recordings next to the page. Returns one entry for each recording.""" screens_dir = report / 'screens' shutil.rmtree(screens_dir, ignore_errors=True) screens = [] for name, meta, data in runs: source = run_dir / name / meta['screen'] if meta.get('screen') else None if not source or not source.exists(): continue screens_dir.mkdir(parents=True, exist_ok=True) screen = {'name': name, 'title': TITLES.get(meta['kind'], meta['kind']), 'duration': data['duration'], 'verdict': (verdicts.get(name) or {}).get('verdict')} if source.suffix == '.cast': text = source.read_text() # Browsers block fetch() on file:// pages, so the recording is loaded as a script. (screens_dir / f'{name}.cast.js').write_text( f'(window.CASTS = window.CASTS || {{}})[{json.dumps(name)}] = {json.dumps(text)};\n') screen.update(type='cast', src=f'screens/{name}.cast.js', end=cast_length(text)) else: shutil.copy(source, screens_dir / f'{name}{source.suffix}') screen.update(type='video', src=f'screens/{name}{source.suffix}') poster = source.with_name(f'{source.stem}-poster.png') if poster.exists(): shutil.copy(poster, screens_dir / f'{name}-poster.png') screen['poster'] = f'screens/{name}-poster.png' screens.append(screen) return screens PLAY_ICON = '' REPLAY_FINEPRINT = ('Terminal screens are exact recordings of the interactive CLI. Instance AI is a video ' 'of the n8n UI. Skip pauses shortens idle time in the terminal recordings only. Press Esc to close.') def screen_kind(s): return esc(s.get('label') or ('Terminal' if s['type'] == 'cast' else 'n8n UI')) def render_replay(screens, fineprint=REPLAY_FINEPRINT): """A strip of final screens that play in place, and a full-screen player that Expand opens. A screen can set "label" to replace the "Terminal" or "n8n UI" text under its card.""" if not screens: return '' cards = [] for s in screens: if s['type'] == 'cast': thumb = f'
    ' elif s.get('poster'): thumb = f'' else: thumb = f'' kind = screen_kind(s) cards.append( f'
    ' f'
    ' f'
    {esc(s["title"])}{badge(s["verdict"]) if s["verdict"] else ""}
    ' f'{kind} · {esc(s["duration"])}
    ' ) side_by_side = '' tabs = ''.join(f'' for s in screens) if len(screens) > 1: tabs += '' figures = ''.join( f'
    ' f'{esc(s["title"])}{screen_kind(s)} · {esc(s["duration"])}' f'{badge(s["verdict"]) if s.get("verdict") else ""}
    ' + (f'' if s['type'] == 'video' else '
    ') + '
    ' for s in screens ) speeds = ''.join(f'' for n in (1, 2, 4, 8)) return ( f'

    Replay

    {side_by_side}
    ' f'
    {"".join(cards)}
    ' '' ) PENDING_BADGES = { 'judge': {'queued': ('st-waiting', 'Judge queued'), 'running': ('st-running', 'Judging')}, 'prices': {'queued': ('st-waiting', 'Price queued'), 'running': ('st-running', 'Finding price')}, } def pending_badge(step, state): """The badge of a step that comes after the run, or '' when the step is not queued or running.""" cls, label = PENDING_BADGES[step].get(state, (None, None)) return f'{label}' if cls else '' def badge(verdict, judge=None): """The verdict badge. Without a verdict, judge is the state of the judge step.""" if not verdict: return pending_badge('judge', judge) or 'Not judged' return f'{esc(verdict.upper())}' RUN_ORDER = {'instance-ai': 0, 'claude': 1, 'codex': 2} def load_case(run_dir): case = json.loads((run_dir / 'case.json').read_text()) if 'prompt' not in case: case['prompt'] = (run_dir / 'prompt.txt').read_text().strip() return case def load_verdicts(run_dir): verdicts_file = run_dir / 'verdicts.json' return json.loads(verdicts_file.read_text()) if verdicts_file.exists() else {} def load_run(sub, meta, prices): try: return LOADERS[meta['kind']](sub, meta, prices) except Exception as e: # keep the other runs when one run has broken files return {'events': [], 'calls': [], 'model': meta.get('model'), 'duration': '-', 'cost': '-', 'costSource': None, 'loadError': f'{type(e).__name__}: {e}'} def collect_runs(run_dir, case): runs = [] for meta_file in sorted(run_dir.glob('*/run.json')): sub = meta_file.parent meta = json.loads(meta_file.read_text()) if meta.get('kind') not in LOADERS or sub.name == 'report': continue runs.append((sub.name, meta, load_run(sub, meta, case.get('prices')))) runs.sort(key=lambda r: (RUN_ORDER.get(r[1]['kind'], 9), r[0])) return runs def token_scale(runs): all_calls = [c for _, _, d in runs for c in d['calls']] return max([c['prompt'] for c in all_calls] + [1]), max([c['out'] for c in all_calls] + [1]) def run_title(name, meta): title = TITLES.get(meta['kind'], meta['kind']) return title if name in ('iai', 'claude', 'codex') else f'{title} ({name})' def render_row_cells(name, meta, data, result): """The table cells of one run. result is the HTML of the Result cell. data can have costPending, the state of the price step, and costTitle, the source of the cost.""" calls = data['calls'] title = f' title="{esc(data["costTitle"])}"' if data.get('costTitle') else '' cost = pending_badge('prices', data.get('costPending')) or f'{esc(data["cost"])}' tools = [e for e in data['events'] if e['kind'] == 'tool'] errors = sum(e['error'] for e in tools) prompt = sum(c['prompt'] for c in calls) cached = sum(c['cache_read'] for c in calls) out = sum(c['out'] for c in calls) cells = [ f'{esc(run_title(name, meta))}', esc(data.get('model') or '-'), (f'{esc(meta["effort"])}' if meta.get('effort') and meta.get('effortSource') else esc(meta.get('effort') or '-')), result, data['duration'], str(len(calls)), f'{len(tools)}' + (f' ({errors} err)' if errors else ''), kilo(prompt), f'{100 * cached / prompt:.0f}%' if prompt else '-', kilo(out), cost, ] return ''.join(f'{c}' for c in cells) def render_canvas_box(name, meta, report, base_url, empty='No workflow was created.'): wf = meta.get('workflowId') image = report / 'img' / f'{name}.png' # capture_canvas.mjs takes the screenshot at twice the CSS size. size = ' '.join(f'{k}="{v // 2}"' for k, v in zip(('width', 'height'), png_size(image))) if image.exists() else '' n8n_url = f'{base_url}/workflow/{wf}' if wf else '' title = esc(run_title(name, meta)) canvas = ( f'{title} canvas' if image.exists() else f'

    {esc(empty)}

    ' ) workflow = load_workflow(report.parent / name) nodes = [n for n in (workflow or {}).get('nodes', []) if n.get('type') != 'n8n-nodes-base.stickyNote'] count = f'{len(nodes)} node{"" if len(nodes) == 1 else "s"}' if workflow else '' link = f'Open in n8n ↗' if wf else '' return fold('Canvas', f'{count}{link}', canvas, cls='canvas-box') def render_panel_body(data, verdict, canvas_box, max_prompt, max_out, prompt, expected='', judge=None, auto=None, pending=False): """The content of the section of one run: canvas, result, auto-checks and LLM steps. judge is the state of the judge step, and auto the content of auto-checks.json. pending is true on the live page: checks and verdicts that do not exist yet show as not run.""" error = f'
    Could not read this run: {esc(data["loadError"])}
    ' if data.get('loadError') else '' checks = render_pending_checks() if pending and not auto else render_auto_checks(auto) return (f'{error}
    {canvas_box}{render_verdict(verdict, expected, judge, pending)}' f'{checks}
    ' f'{render_steps(data["calls"], data["events"], max_prompt, max_out, prompt, data.get("context"), data.get("agentContexts"))}') POST_RUN_STEPS = ('judge', 'prices') POST_RUN_STATES = ('queued', 'running', 'done') def step_arg(value): step, _, state = value.partition('=') if step not in POST_RUN_STEPS or state not in POST_RUN_STATES: raise argparse.ArgumentTypeError(f'use {"|".join(POST_RUN_STEPS)}={"|".join(POST_RUN_STATES)}, not {value}') return step, state def read_status(run_dir, steps=()): """status.json. steps are (step, state) pairs to write to its postRun object first.""" status_file = run_dir / 'status.json' status = json.loads(status_file.read_text()) if status_file.exists() else {} if steps: status.setdefault('postRun', {}).update(steps) tmp = status_file.with_suffix('.tmp') tmp.write_text(json.dumps(status, indent=2)) os.replace(tmp, status_file) return status def cost_title(meta, data, prices): """Where the cost of a run comes from, for the tooltip of its Cost cell.""" source = data.get('costSource') if source == 'langsmith': return 'From the LangSmith call costs.' if source == 'claude-code': return 'The estimate of Claude Code.' p = prices.get(data.get('model')) if source == 'prices' else None if not p: return 'Instance AI cost comes from LangSmith.' if meta['kind'] == 'instance-ai' else 'No published price.' return (f'Tokens × the published price of {data["model"]}: ${p.get("input", 0)} input, ' f'${p.get("cached", 0)} cache read, ${p.get("cacheWrite", p.get("input", 0))} cache write, ' f'${p.get("output", 0)} output per million tokens.' + (f' Source: {p["source"]}' if p.get('source') else '')) RELOAD_JS = """ // Served by the live server while a step is pending: reload when the page is built again. window.addEventListener('DOMContentLoaded', ()=>{ const saved = JSON.parse(sessionStorage.getItem('report-view') || 'null'); if (saved) { sessionStorage.removeItem('report-view'); if (document.getElementById(saved.run)) show(saved.run); window.scrollTo(0, saved.y); } if (!PENDING || !location.protocol.startsWith('http')) return; setInterval(async ()=>{ try { const version = (await (await fetch('version.txt', {cache: 'no-store'})).text()).trim(); if (version && version !== REPORT_VERSION) { sessionStorage.setItem('report-view', JSON.stringify({run: document.querySelector('.run.active')?.id, y: window.scrollY})); location.reload(); } } catch {} }, 3000); }); """ def main(): ap = argparse.ArgumentParser(description='Build the comparison page of a run directory.') ap.add_argument('run_dir', nargs='?', default='.') ap.add_argument('--step', type=step_arg, action='append', default=[], help='Set the state of a step after the run, for example judge=running. Repeat for more steps.') args = ap.parse_args() run_dir = Path(args.run_dir).resolve() status = read_status(run_dir, args.step) case = load_case(run_dir) verdicts = load_verdicts(run_dir) report = run_dir / 'report' (report / 'img').mkdir(parents=True, exist_ok=True) if ASSET_CACHE.exists(): for sub in ('fonts', 'brand'): if (ASSET_CACHE / sub).exists(): shutil.copytree(ASSET_CACHE / sub, report / sub, dirs_exist_ok=True) runs = collect_runs(run_dir, case) if not runs: sys.exit('No runs found.') for name, _, _ in runs: if (run_dir / name / 'canvas.png').exists(): shutil.copy(run_dir / name / 'canvas.png', report / 'img' / f'{name}.png') max_prompt, max_out = token_scale(runs) base_url = case.get('baseUrl', 'http://localhost:5678') post_run = status.get('postRun') or {} # A run with a verdict is judged, so a step that was not marked done does not show as pending. judge = post_run.get('judge') if any(name not in verdicts for name, _, _ in runs) else None pending = False views = {name: verdict_view(verdicts.get(name), run_dir / name, case.get('expected') or '') for name, _, _ in runs} rows, panels = [], [] for i, (name, meta, data) in enumerate(runs): active = ' active' if i == 0 else '' verdict, auto = views[name] data['costTitle'] = cost_title(meta, data, case.get('prices') or {}) if meta['kind'] != 'instance-ai' and data.get('costSource') is None: data['costPending'] = post_run.get('prices') pending = pending or (not verdict and judge in ('queued', 'running')) \ or data.get('costPending') in ('queued', 'running') rows.append(f'' + render_row_cells(name, meta, data, render_result(verdict, judge, auto)) + '') canvas_box = render_canvas_box(name, meta, report, base_url) panels.append(f'
    ' + render_panel_body(data, verdict, canvas_box, max_prompt, max_out, case['prompt'], case.get('expected') or '', judge, auto=auto) + '
    ') # The agent reads this file to find the models that still need a published price. models = [{'run': name, 'kind': meta['kind'], 'model': data.get('model'), 'costSource': data.get('costSource')} for name, meta, data in runs] (run_dir / 'models.json').write_text(json.dumps(models, indent=2)) unpriced = sorted({m['model'] for m in models if m['model'] and m['kind'] != 'instance-ai' and m['costSource'] != 'prices'}) screens = collect_screens(runs, run_dir, report, {name: v for name, (v, _) in views.items() if v}) head, scripts = '', '' if any(s['type'] == 'cast' for s in screens): player_js, player_css = player_assets(report) head = f'' scripts = f'' + ''.join( f'' for s in screens if s['type'] == 'cast') version = str(time.time_ns()) scripts += (f'') meta = [f'{len(runs)} builder' + ('s' if len(runs) > 1 else '')] if status.get('startedAt'): meta.insert(0, datetime.fromtimestamp(status['startedAt']).strftime('%b %-d, %Y · %H:%M')) if status.get('mode') == 'parallel': meta.append('run in parallel') page = fill_template(esc(case.get('title') or 'Builder comparison'), ''.join(rows), '', ''.join(panels), replay=render_replay(screens), head=head, scripts=scripts, prompt=esc(case['prompt']), expected=esc(case.get('expected') or '-'), meta=esc(' · '.join(meta))) (report / 'index.html').write_text(page) (report / 'version.txt').write_text(version) print(report / 'index.html') if unpriced: print('models without a published price in case.json: ' + ', '.join(unpriced)) LIST_ITEM = re.compile(r'^\s*(?:(\d+)[.)]|[-*•])\s+') def parse_criteria(expected): """Split an expected outcome into [(heading, [(number, text)])]. A line that is not a list item starts a new group. Returns None when the text is not a list.""" groups = [] for line in expected.splitlines(): if not line.strip(): continue match = LIST_ITEM.match(line) if not match: groups.append((line.strip().rstrip(':'), [])) continue if not groups: groups.append(('', [])) items = groups[-1][1] items.append((match.group(1) or str(sum(len(g[1]) for g in groups)), line[match.end():].strip())) if sum(len(items) for _, items in groups) < 2 or any(not items for _, items in groups): return None return groups def render_criteria(groups): """The groups from parse_criteria as numbered lists. A group with a heading shows collapsed. The text is HTML.""" parts = [] for heading, items in groups: rows = ''.join(f'
  • {num}{code_spans(text)}
  • ' for num, text in items) if not heading: parts.append(f'
      {rows}
    ') continue span = items[0][0] if len(items) == 1 else f'{items[0][0]}–{items[-1][0]}' parts.append( f'
    {heading}' f'{span} · {len(items)} checks' f'
      {rows}
    ') return ''.join(parts) def render_brief(prompt, expected): """The prompt and expected outcome boxes. Both arguments are HTML. An expected outcome with one numbered or bulleted item on each line shows as numbered lists, grouped under the plain lines between them.""" groups = parse_criteria(expected) if groups: total = sum(len(items) for _, items in groups) named = sum(1 for heading, _ in groups if heading) label = f'{total} checks' + (f' · {named} groups' if named > 1 else '') toggle = '' if named else '' body = render_criteria(groups) count = f'{label}{toggle}' else: body, count = f'
    {code_spans(expected)}
    ', '' words = len(html.unescape(prompt).split()) prompt_meta = (f'Sent to every builder · {words} words' '') return ( '
    ' + fold('Prompt', prompt_meta, f'
    {prompt}
    ', open_=True, cls='brief-card prompt') + fold('Expected outcome', count, body, cls='brief-card expected') + '
    ' ) THEAD = ('RunModelEffortResultActive timeLLM callsTool calls' 'Input tokensCachedOutput tokensCost') def fill_template(title, rows, notes, panels, replay='', head='', scripts='', case=None, thead=THEAD, prompt='', expected='', after_table='', kicker='Builder comparison', meta=''): """The full page. Text arguments are HTML. case defaults to the prompt and expected outcome boxes. kicker is the label next to the logo, and meta the line under the title. replay comes before the table, and after_table between the table and the panels.""" return (TEMPLATE .replace('{{THEAD}}', thead) .replace('{{HEAD}}', head) .replace('{{REPLAY}}', replay) .replace('{{AFTER_TABLE}}', after_table) .replace('{{SCRIPTS}}', scripts) .replace('{{KICKER}}', kicker) .replace('{{META}}', f'

    {meta}

    ' if meta else '') .replace('{{TITLE}}', title) .replace('{{ROWS}}', rows) .replace('{{NOTES}}', f'

    {notes}

    ' if notes else '') .replace('{{CASE}}', render_brief(prompt, expected) if case is None else case) .replace('{{PANELS}}', panels)) TEMPLATE = """ {{TITLE}} {{HEAD}}
    n8n {{KICKER}}

    {{TITLE}}

    {{META}}
    {{CASE}} {{REPLAY}} {{THEAD}}{{ROWS}}
    {{NOTES}} {{AFTER_TABLE}} {{PANELS}} {{SCRIPTS}} """ if __name__ == '__main__': main()