glassbox-trace 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -20
- package/dist/glassbox.html +250 -45
- package/package.json +57 -57
- package/src/cli.mjs +202 -22
- package/src/tail.mjs +5 -5
- package/src/trace-core.js +247 -44
- package/src/viewer.html +3 -1
package/src/trace-core.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
10
10
|
'use strict';
|
|
11
11
|
|
|
12
|
-
const VERSION = '0.
|
|
12
|
+
const VERSION = '0.6.0';
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Rate card (USD per million tokens). Prefix-matched against model ids so dated
|
|
@@ -57,6 +57,13 @@
|
|
|
57
57
|
longTurnTools: 30,
|
|
58
58
|
failedToolRate: 0.5,
|
|
59
59
|
failedToolMinCalls: 3,
|
|
60
|
+
failedToolWarnMinErrors: 2,
|
|
61
|
+
failedToolWarnRate: 0.2,
|
|
62
|
+
cacheMissMinShare: 0.05,
|
|
63
|
+
longGenerationMaxTokPerSec: 15,
|
|
64
|
+
duplicateReadAgents: 3,
|
|
65
|
+
duplicateReadWarnChars: 100000,
|
|
66
|
+
groupRepeatsMin: 3, // slow-tool / oversized-result: this many from one tool become one finding
|
|
60
67
|
};
|
|
61
68
|
|
|
62
69
|
// ---------------------------------------------------------------------------
|
|
@@ -70,6 +77,12 @@
|
|
|
70
77
|
// Cowork: Artifact publishes a page (a write), Skill loads instructions (a read); Task* are bookkeeping (other).
|
|
71
78
|
const COWORK_READ = new Set(['Skill', 'ListSkills', 'SearchSkills', 'TaskList', 'TaskGet']);
|
|
72
79
|
const COWORK_WRITE = new Set(['Artifact']);
|
|
80
|
+
// Blocking reads of a background task's output: the duration is the task's run time, and re-issuing the
|
|
81
|
+
// same call after a "still running" timeout is polling, not a retry.
|
|
82
|
+
const WAIT_TOOLS = new Set(['TaskOutput', 'AgentOutputTool']);
|
|
83
|
+
const isWait = (c) => WAIT_TOOLS.has(c.name) && !(c.input && c.input.block === false);
|
|
84
|
+
const waitTarget = (c) => { const i = c.input || {}; return String(i.task_id || i.agentId || i.bash_id || ''); };
|
|
85
|
+
const hasRange = (c) => !!(c.input && (c.input.limit != null || c.input.offset != null || c.input.head_limit != null));
|
|
73
86
|
// MCP leaf names are verb_noun or noun_verb ("memory_read", "device_list_dir", "send_message"): match the verb as a word.
|
|
74
87
|
const MCP_READ = /(^|_)(get|list|search|read|find|fetch|query|describe|show|stage|screenshot|context|status|info|check)(_|$)/i;
|
|
75
88
|
const MCP_WRITE = /(^|_)(write|set|put|create|update|delete|remove|commit|send|reply|forward|upload|str_replace|append|label|move|rename|save)(_|$)/i;
|
|
@@ -150,6 +163,26 @@
|
|
|
150
163
|
return !u.input && !u.cacheRead && !u.cacheWrite && !u.output;
|
|
151
164
|
}
|
|
152
165
|
|
|
166
|
+
// A tool call that never ran because the human or a permission rule stopped it. Claude Code
|
|
167
|
+
// records toolDenialKind (user-rejected, permission-rule, automode-blocked, interrupted); older
|
|
168
|
+
// transcripts only have the result text. A shell's own "Permission denied" is a real failure.
|
|
169
|
+
function denialKind(rec, text) {
|
|
170
|
+
if (rec && rec.toolDenialKind) return String(rec.toolDenialKind);
|
|
171
|
+
const t = String(text || '').trim();
|
|
172
|
+
if (/^\[Request interrupted by user/.test(t)) return 'interrupted';
|
|
173
|
+
if (/^The user doesn't want to proceed with this tool use|^User rejected tool use|^Denied by voice/.test(t)) return 'user-rejected';
|
|
174
|
+
if (/^Permission (for this action was|to use \S+ has been) denied/.test(t)) return 'permission-rule';
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// One API response is written as several records (one per content block). Subagent transcripts
|
|
179
|
+
// rewrite output_tokens as the stream progresses, so the first record can be far below the final
|
|
180
|
+
// count; the other fields repeat. Keep the largest value seen for each field.
|
|
181
|
+
function maxUsage(a, b) {
|
|
182
|
+
for (const k of Object.keys(a)) if ((b[k] || 0) > a[k]) a[k] = b[k];
|
|
183
|
+
return a;
|
|
184
|
+
}
|
|
185
|
+
|
|
153
186
|
// ---------------------------------------------------------------------------
|
|
154
187
|
// Line parsing
|
|
155
188
|
// ---------------------------------------------------------------------------
|
|
@@ -250,6 +283,7 @@
|
|
|
250
283
|
agents.set(id, {
|
|
251
284
|
id, type: id === 'main' ? 'main' : 'subagent', description: null, parentToolUseId: null,
|
|
252
285
|
start: null, end: null, usage: emptyUsage(), requestCount: 0, toolCount: 0, model: null,
|
|
286
|
+
workflowRun: (String(rec._file || '').match(/workflows[\\/](wf_[^\\/]+)[\\/]/) || [])[1] || null,
|
|
253
287
|
});
|
|
254
288
|
}
|
|
255
289
|
const a = agents.get(id);
|
|
@@ -300,7 +334,9 @@
|
|
|
300
334
|
if (!meta.gitBranch && rec.gitBranch) meta.gitBranch = rec.gitBranch;
|
|
301
335
|
if (!meta.entrypoint && rec.entrypoint) meta.entrypoint = rec.entrypoint;
|
|
302
336
|
const t = ts(rec);
|
|
303
|
-
|
|
337
|
+
// Only conversation records bound the session: bookkeeping records (frame-link, file-history…)
|
|
338
|
+
// can be written days later and would stretch the wall clock.
|
|
339
|
+
if (t != null && (type === 'user' || type === 'assistant' || type === 'system')) { if (meta.start == null || t < meta.start) meta.start = t; if (meta.end == null || t > meta.end) meta.end = t; }
|
|
304
340
|
|
|
305
341
|
if (type === 'summary') { if (rec.summary && !meta.title) meta.title = rec.summary; events.push({ kind: 'summary', at: t, detail: rec.summary }); continue; }
|
|
306
342
|
if (type === 'attachment') { attachments.count++; attachments.chars += JSON.stringify(rec.attachment || '').length; continue; }
|
|
@@ -354,6 +390,7 @@
|
|
|
354
390
|
if (structured && typeof structured === 'object' && !text && !imgs.length) call.resultChars = JSON.stringify(structured).length;
|
|
355
391
|
// stream-json puts is_error only in block; Claude Code also stores toolUseResult as an Error string
|
|
356
392
|
if (!tr.is_error && typeof structured === 'string' && /^Error/i.test(structured)) { call.status = 'error'; call.isError = true; }
|
|
393
|
+
if (call.isError) call.denial = denialKind(rec, text);
|
|
357
394
|
} else {
|
|
358
395
|
toolCalls.push(mkOrphanResult(tr, rec, a.id, text, turns.length ? turns[turns.length - 1].index : null));
|
|
359
396
|
}
|
|
@@ -366,6 +403,7 @@
|
|
|
366
403
|
let kind = 'human';
|
|
367
404
|
if (rec.isCompactSummary) kind = 'compact';
|
|
368
405
|
else if (rec.isMeta) kind = 'meta';
|
|
406
|
+
else if (/^\[Request interrupted by user/.test(text.trim())) kind = 'interrupt';
|
|
369
407
|
else if (/^<(system-reminder|local-command|command-name|bash-input|bash-stdout)/.test(text.trim())) kind = 'meta';
|
|
370
408
|
ensureTurn(rec, kind, text);
|
|
371
409
|
continue;
|
|
@@ -388,16 +426,10 @@
|
|
|
388
426
|
turn.requestIds.push(req.id);
|
|
389
427
|
a.requestCount++;
|
|
390
428
|
if (msg.model) { pushModel(meta, msg.model); if (!a.model) a.model = msg.model; }
|
|
391
|
-
const u = usageFrom(msg.usage);
|
|
392
|
-
if (!isEmptyUsage(u)) { req.usage = u; req.contextTokens = u.input + u.cacheRead + u.cacheWrite; addUsage(turn.usage, u); addUsage(a.usage, u); }
|
|
393
429
|
} else {
|
|
394
430
|
if (t != null) { if (req.start == null || t < req.start) req.start = t; if (req.end == null || t > req.end) req.end = t; }
|
|
395
|
-
// usage: if the first record lacked it and a later one has it, take it once
|
|
396
|
-
if (isEmptyUsage(req.usage)) {
|
|
397
|
-
const u = usageFrom(msg.usage);
|
|
398
|
-
if (!isEmptyUsage(u)) { req.usage = u; req.contextTokens = u.input + u.cacheRead + u.cacheWrite; const turn = turns[req.turnIndex]; addUsage(turn.usage, u); addUsage(a.usage, u); }
|
|
399
|
-
}
|
|
400
431
|
}
|
|
432
|
+
maxUsage(req.usage, usageFrom(msg.usage)); // turn and agent totals are summed once, after the loop
|
|
401
433
|
req.records++;
|
|
402
434
|
if (msg.stop_reason) req.stopReason = msg.stop_reason;
|
|
403
435
|
if (rec.isApiErrorMessage) req.isApiError = true;
|
|
@@ -410,7 +442,7 @@
|
|
|
410
442
|
id: b.id || (key + ':' + blockIndex), agent: a.id, requestId: req.id, name: b.name || 'unknown', input: b.input || {},
|
|
411
443
|
start: t, end: null, durationMs: null, status: 'orphan', isError: false, resultText: '', resultChars: 0,
|
|
412
444
|
resultStructured: null, category: toolCategory(b.name), turnIndex: req.turnIndex, subagentId: null,
|
|
413
|
-
inputChars: JSON.stringify(b.input || {}).length, resultImages: 0, imageBytes: 0,
|
|
445
|
+
inputChars: JSON.stringify(b.input || {}).length, resultImages: 0, imageBytes: 0, denial: null,
|
|
414
446
|
};
|
|
415
447
|
toolCalls.push(call);
|
|
416
448
|
toolCallById.set(call.id, call);
|
|
@@ -432,7 +464,12 @@
|
|
|
432
464
|
}
|
|
433
465
|
|
|
434
466
|
// Finalise
|
|
435
|
-
for (const req of requests)
|
|
467
|
+
for (const req of requests) {
|
|
468
|
+
req.blocks.sort((x, y) => x.index - y.index);
|
|
469
|
+
req.contextTokens = req.usage.input + req.usage.cacheRead + req.usage.cacheWrite;
|
|
470
|
+
addUsage(turns[req.turnIndex].usage, req.usage);
|
|
471
|
+
addUsage(agents.get(req.agent).usage, req.usage);
|
|
472
|
+
}
|
|
436
473
|
for (const turn of turns) if (turn.end == null) turn.end = meta.end;
|
|
437
474
|
// Response timing. Assistant record timestamps are written when a block finishes streaming,
|
|
438
475
|
// so a request's "start" is really its first completed block. We measure from the moment the
|
|
@@ -464,9 +501,14 @@
|
|
|
464
501
|
const parent = toolCalls.find((c) => c.category === 'agent' && c.agent === 'main' && ((c.resultText && c.resultText.includes(ag.id)) || (c.resultStructured && JSON.stringify(c.resultStructured).includes(ag.id))));
|
|
465
502
|
if (parent) ag.parentToolUseId = parent.id;
|
|
466
503
|
}
|
|
504
|
+
// Workflow agents live in subagents/workflows/<runId>/; the Workflow call's result names that runId.
|
|
505
|
+
if (!ag.parentToolUseId && ag.workflowRun) {
|
|
506
|
+
const parent = toolCalls.find((c) => c.name === 'Workflow' && ((c.resultStructured && c.resultStructured.runId === ag.workflowRun) || (c.resultText && c.resultText.includes(ag.workflowRun))));
|
|
507
|
+
if (parent) ag.parentToolUseId = parent.id;
|
|
508
|
+
}
|
|
467
509
|
if (ag.parentToolUseId) {
|
|
468
510
|
const parent = toolCallById.get(ag.parentToolUseId);
|
|
469
|
-
if (parent) { parent.subagentId = ag.id; if (!ag.description && parent.input && parent.input.description) ag.description = parent.input.description; if (!ag.agentType && parent.input && parent.input.subagent_type) ag.agentType = parent.input.subagent_type; }
|
|
511
|
+
if (parent) { if (!parent.subagentId) parent.subagentId = ag.id; (parent.subagentIds || (parent.subagentIds = [])).push(ag.id); if (!ag.description && parent.input && parent.input.description) ag.description = parent.input.description; if (!ag.agentType && parent.input && parent.input.subagent_type) ag.agentType = parent.input.subagent_type; }
|
|
470
512
|
}
|
|
471
513
|
}
|
|
472
514
|
|
|
@@ -520,9 +562,20 @@
|
|
|
520
562
|
if (!byTurnSig.has(sig)) byTurnSig.set(sig, []);
|
|
521
563
|
byTurnSig.get(sig).push(c);
|
|
522
564
|
}
|
|
565
|
+
const callIndex = new Map(calls.map((c, i) => [c, i]));
|
|
523
566
|
for (const group of byTurnSig.values()) {
|
|
524
567
|
if (group.length >= o.retryLoopMin) {
|
|
525
568
|
const errs = group.filter((c) => c.isError).length;
|
|
569
|
+
if (isWait(group[0]) && errs < 2) continue; // polling a running task; failing polls still count
|
|
570
|
+
if (errs < 2) {
|
|
571
|
+
// Re-running a test or taking another screenshot after something changed is observing, not
|
|
572
|
+
// looping: only flag successful repeats that returned the same thing with nothing in between.
|
|
573
|
+
const inGroup = new Set(group);
|
|
574
|
+
const between = calls.slice(callIndex.get(group[0]) + 1, callIndex.get(group[group.length - 1]));
|
|
575
|
+
const changed = between.some((c) => c.agent === group[0].agent && !inGroup.has(c) && c.category !== 'read');
|
|
576
|
+
const same = group.every((c) => c.resultText === group[0].resultText && c.imageBytes === group[0].imageBytes);
|
|
577
|
+
if (changed || !same) continue;
|
|
578
|
+
}
|
|
526
579
|
push({
|
|
527
580
|
id: 'retry-loop', severity: errs >= 2 ? 'error' : 'warn',
|
|
528
581
|
title: `${group[0].name} called ${group.length}× with identical input`,
|
|
@@ -532,14 +585,16 @@
|
|
|
532
585
|
}
|
|
533
586
|
}
|
|
534
587
|
|
|
535
|
-
// failed-tool: per tool error rate
|
|
588
|
+
// failed-tool: per tool error rate. Calls the human or a permission rule stopped are not the
|
|
589
|
+
// tool's failure (see permission-denied); one stray error in a long session is not a pattern.
|
|
536
590
|
const byTool = new Map();
|
|
537
|
-
for (const c of calls) { if (c.unmatched) continue; if (!byTool.has(c.name)) byTool.set(c.name, []); byTool.get(c.name).push(c); }
|
|
591
|
+
for (const c of calls) { if (c.unmatched || c.denial) continue; if (!byTool.has(c.name)) byTool.set(c.name, []); byTool.get(c.name).push(c); }
|
|
538
592
|
for (const [name, list] of byTool) {
|
|
539
593
|
const errs = list.filter((c) => c.isError);
|
|
540
594
|
if (!errs.length) continue;
|
|
541
595
|
const rate = errs.length / list.length;
|
|
542
596
|
const bad = list.length >= o.failedToolMinCalls && rate >= o.failedToolRate;
|
|
597
|
+
if (!bad && (errs.length < o.failedToolWarnMinErrors || rate < o.failedToolWarnRate)) continue;
|
|
543
598
|
push({
|
|
544
599
|
id: 'failed-tool', severity: bad ? 'error' : 'warn',
|
|
545
600
|
title: `${name} failed ${errs.length} of ${list.length} calls (${Math.round(rate * 100)}%)`,
|
|
@@ -548,6 +603,18 @@
|
|
|
548
603
|
});
|
|
549
604
|
}
|
|
550
605
|
|
|
606
|
+
// permission-denied: calls that never ran because the human rejected them, a permission rule or
|
|
607
|
+
// the auto-mode classifier blocked them, or the human interrupted.
|
|
608
|
+
const denied = calls.filter((c) => c.denial);
|
|
609
|
+
if (denied.length) {
|
|
610
|
+
const kinds = {}; for (const c of denied) kinds[c.denial] = (kinds[c.denial] || 0) + 1;
|
|
611
|
+
push({
|
|
612
|
+
id: 'permission-denied', severity: 'info', title: `${denied.length} tool call${denied.length > 1 ? 's' : ''} denied or interrupted`,
|
|
613
|
+
detail: Object.entries(kinds).map(([k, n]) => `${k} ×${n}`).join(', ') + ' — ' + summariseNames(denied),
|
|
614
|
+
evidence: { toolCallIds: denied.map((c) => c.id) }, metric: denied.length,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
551
618
|
// orphan-tool
|
|
552
619
|
const orphans = calls.filter((c) => c.status === 'orphan');
|
|
553
620
|
if (orphans.length) push({
|
|
@@ -572,18 +639,52 @@
|
|
|
572
639
|
};
|
|
573
640
|
for (const c of calls) {
|
|
574
641
|
if (c.agent !== ag.id || c.unmatched) continue;
|
|
575
|
-
|
|
642
|
+
// MCP calls that aren't reads are actions (browser navigate/click, device commands) and end a run;
|
|
643
|
+
// agent and user calls don't.
|
|
644
|
+
if (c.category === 'read') run.push(c); else if (c.category === 'write' || c.category === 'exec' || c.category === 'mcp') flush();
|
|
576
645
|
}
|
|
577
646
|
flush();
|
|
578
647
|
}
|
|
579
648
|
|
|
580
|
-
//
|
|
649
|
+
// duplicate-subagent-read: the same file read by several agents, each paying for it in its own context
|
|
650
|
+
const sharedReads = new Set(); // reported here, so oversized-result below skips them
|
|
651
|
+
const readsByFile = new Map();
|
|
581
652
|
for (const c of calls) {
|
|
582
|
-
if (c.
|
|
653
|
+
if (c.name !== 'Read' || c.isError || !c.input || !c.input.file_path) continue;
|
|
654
|
+
const k = String(c.input.file_path).replace(/\\/g, '/').toLowerCase();
|
|
655
|
+
if (!readsByFile.has(k)) readsByFile.set(k, []);
|
|
656
|
+
readsByFile.get(k).push(c);
|
|
657
|
+
}
|
|
658
|
+
for (const list of readsByFile.values()) {
|
|
659
|
+
const agentIds = new Set(list.map((c) => c.agent));
|
|
660
|
+
if (agentIds.size < o.duplicateReadAgents) continue;
|
|
661
|
+
const chars = list.reduce((s, c) => s + c.resultChars, 0);
|
|
662
|
+
for (const c of list) sharedReads.add(c);
|
|
663
|
+
const largest = Math.max(...list.map((c) => c.resultChars));
|
|
664
|
+
push({
|
|
665
|
+
id: 'duplicate-subagent-read', severity: largest >= o.oversizedResultError ? 'error' : chars >= o.duplicateReadWarnChars ? 'warn' : 'info',
|
|
666
|
+
title: `Same file read by ${agentIds.size} agents (${fmtInt(chars)} chars)`, // path only in detail, which --redact blanks
|
|
667
|
+
detail: `${list.length} reads of ${list[0].input.file_path} across ${agentIds.size} agents; each copy is billed in that agent's context on every later request.`,
|
|
668
|
+
evidence: { toolCallIds: list.map((c) => c.id) }, metric: chars,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// oversized-result: one line per call, or one per tool when a tool does it repeatedly
|
|
673
|
+
const bigByTool = new Map();
|
|
674
|
+
for (const c of calls) if (c.resultChars >= o.oversizedResultWarn && !sharedReads.has(c)) { if (!bigByTool.has(c.name)) bigByTool.set(c.name, []); bigByTool.get(c.name).push(c); }
|
|
675
|
+
for (const [name, list] of bigByTool) {
|
|
676
|
+
const largest = Math.max(...list.map((c) => c.resultChars)), total = list.reduce((s, c) => s + c.resultChars, 0);
|
|
677
|
+
const ranged = list.every(hasRange);
|
|
678
|
+
const detail = 'Large tool results stay in context for the rest of the session. ' + (ranged ? 'The call already set a range; long lines or a wide window still cost, so narrow it or grep first.' : 'Consider head/limit, grep, or a subagent.');
|
|
679
|
+
if (list.length >= o.groupRepeatsMin) push({
|
|
680
|
+
id: 'oversized-result', severity: largest >= o.oversizedResultError ? 'error' : 'warn',
|
|
681
|
+
title: `${name} returned over ${fmtInt(o.oversizedResultWarn)} chars ${list.length} times (largest ${fmtInt(largest)}, ${fmtInt(total)} in all)`,
|
|
682
|
+
detail, evidence: { toolCallIds: list.map((c) => c.id) }, metric: largest,
|
|
683
|
+
});
|
|
684
|
+
else for (const c of list) push({
|
|
583
685
|
id: 'oversized-result', severity: c.resultChars >= o.oversizedResultError ? 'error' : 'warn',
|
|
584
686
|
title: `${c.name} returned ${fmtInt(c.resultChars)} chars`,
|
|
585
|
-
detail:
|
|
586
|
-
evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.resultChars,
|
|
687
|
+
detail, evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.resultChars,
|
|
587
688
|
});
|
|
588
689
|
}
|
|
589
690
|
|
|
@@ -603,21 +704,45 @@
|
|
|
603
704
|
if (r.contextTokens >= o.contextBloatWarn && !crossed) crossed = r;
|
|
604
705
|
if (!peak || r.contextTokens > peak.contextTokens) peak = r;
|
|
605
706
|
}
|
|
606
|
-
if (crossed)
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
707
|
+
if (crossed) {
|
|
708
|
+
// What the large context cost: every request (any agent) sent with more than the threshold.
|
|
709
|
+
let above = 0, all = 0, n = 0;
|
|
710
|
+
for (const r of reqs) { const c = requestCost(r.usage, rateFor(r.model, o.rates)); if (c == null) continue; all += c; if (r.contextTokens >= o.contextBloatWarn) { above += c; n++; } }
|
|
711
|
+
const spend = all > 0 ? ` · ${usd(above)} (${Math.round(above / all * 100)}% of cost) spent above ${fmtInt(o.contextBloatWarn)}` : '';
|
|
712
|
+
push({
|
|
713
|
+
id: 'context-bloat', severity: peak.contextTokens >= o.contextBloatError ? 'error' : 'warn',
|
|
714
|
+
title: `Context reached ${fmtInt(peak.contextTokens)} tokens${spend}`,
|
|
715
|
+
detail: `First crossed ${fmtInt(o.contextBloatWarn)} at request #${reqs.indexOf(crossed) + 1} (turn ${crossed.turnIndex + 1}); ${n} request${n === 1 ? '' : 's'} ran above it. Every one of them re-reads that prefix.`,
|
|
716
|
+
evidence: { requestIds: [crossed.id, peak.id], turnIndex: crossed.turnIndex }, metric: peak.contextTokens, cost: all > 0 ? above : null,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
612
719
|
|
|
613
|
-
// cache-churn
|
|
614
|
-
|
|
720
|
+
// cache-churn / idle-cache-expiry: a request that re-wrote prefix the previous request of the same
|
|
721
|
+
// agent had already cached. A big cache write on its own is usually just a new tool result being
|
|
722
|
+
// cached, which is expected. After a gap longer than the cache lifetime the miss is expiry, not
|
|
723
|
+
// invalidation; right after a compaction it is an expected rebuild.
|
|
724
|
+
const prevByAgent = new Map(); const ttlByAgent = new Map();
|
|
615
725
|
for (const r of reqs) {
|
|
616
|
-
|
|
617
|
-
if (r.usage.
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
726
|
+
const prev = prevByAgent.get(r.agent); prevByAgent.set(r.agent, r);
|
|
727
|
+
if (r.usage.cacheWrite1h > 0) ttlByAgent.set(r.agent, 3600e3); else if (r.usage.cacheWrite5m > 0 && !ttlByAgent.has(r.agent)) ttlByAgent.set(r.agent, 300e3);
|
|
728
|
+
if (!prev) continue;
|
|
729
|
+
const cached = prev.usage.cacheRead + prev.usage.cacheWrite;
|
|
730
|
+
const missed = cached - r.usage.cacheRead;
|
|
731
|
+
if (missed < o.cacheChurnMin || missed < cached * o.cacheMissMinShare) continue;
|
|
732
|
+
const sent = r.responseFrom != null ? r.responseFrom : r.start;
|
|
733
|
+
if (prev.end != null && sent != null && trace.events.some((e) => e.kind === 'compact' && e.at != null && e.at >= prev.end && e.at <= sent)) continue;
|
|
734
|
+
const idle = prev.end != null && sent != null ? sent - prev.end : null;
|
|
735
|
+
const ttl = ttlByAgent.get(r.agent) || 300e3;
|
|
736
|
+
const n = reqs.indexOf(r) + 1;
|
|
737
|
+
if (idle != null && idle > ttl) push({
|
|
738
|
+
id: 'idle-cache-expiry', severity: 'info', title: `${fmtInt(missed)} tokens re-cached after ${fmtDur(idle)} idle`,
|
|
739
|
+
detail: `Request #${n}${r.agent !== 'main' ? ' (subagent)' : ''}: the gap was longer than the ${ttl >= 3600e3 ? '1-hour' : '5-minute'} cache lifetime, so the whole prefix was written again.`,
|
|
740
|
+
evidence: { requestIds: [prev.id, r.id], turnIndex: r.turnIndex }, metric: missed,
|
|
741
|
+
});
|
|
742
|
+
else push({
|
|
743
|
+
id: 'cache-churn', severity: 'info', title: `${fmtInt(missed)} cached tokens re-written at request #${n}`,
|
|
744
|
+
detail: `The previous request had ${fmtInt(cached)} tokens cached; this one read only ${fmtInt(r.usage.cacheRead)} back${idle != null ? ` ${fmtDur(idle)} later` : ''}. Something early in the prompt changed (system prompt, tool list, early messages).`,
|
|
745
|
+
evidence: { requestIds: [prev.id, r.id], turnIndex: r.turnIndex }, metric: missed,
|
|
621
746
|
});
|
|
622
747
|
}
|
|
623
748
|
|
|
@@ -628,14 +753,40 @@
|
|
|
628
753
|
evidence: {}, metric: trace.totals.cacheHitRatio,
|
|
629
754
|
});
|
|
630
755
|
|
|
631
|
-
// slow-tool
|
|
756
|
+
// slow-tool: blocking waits roll up per background task, other slow calls per tool when repeated
|
|
757
|
+
const waits = new Map(), slowByTool = new Map();
|
|
632
758
|
for (const c of calls) {
|
|
633
|
-
if (c.
|
|
759
|
+
if (isWait(c)) { const k = c.agent + '|' + c.name + '|' + waitTarget(c); if (!waits.has(k)) waits.set(k, []); waits.get(k).push(c); continue; }
|
|
760
|
+
// Time spent on a question to the human, or on a permission prompt that ended in a denial, is the human's.
|
|
761
|
+
if (c.category === 'user' || c.denial) continue;
|
|
762
|
+
if (c.durationMs != null && c.durationMs >= o.slowToolInfoMs) { if (!slowByTool.has(c.name)) slowByTool.set(c.name, []); slowByTool.get(c.name).push(c); }
|
|
763
|
+
}
|
|
764
|
+
for (const [name, list] of slowByTool) {
|
|
765
|
+
const longest = Math.max(...list.map((c) => c.durationMs)), total = list.reduce((s, c) => s + c.durationMs, 0);
|
|
766
|
+
const detail = list[0].category === 'agent' ? 'Subagent run time.' : 'Long-running tool call.';
|
|
767
|
+
if (list.length >= o.groupRepeatsMin) push({
|
|
768
|
+
id: 'slow-tool', severity: longest >= o.slowToolWarnMs ? 'warn' : 'info',
|
|
769
|
+
title: `${name} took over ${fmtDur(o.slowToolInfoMs)} ${list.length} times (longest ${fmtDur(longest)}, ${fmtDur(total)} in all)`,
|
|
770
|
+
detail, evidence: { toolCallIds: list.map((c) => c.id) }, metric: total,
|
|
771
|
+
});
|
|
772
|
+
else for (const c of list) push({
|
|
634
773
|
id: 'slow-tool', severity: c.durationMs >= o.slowToolWarnMs ? 'warn' : 'info',
|
|
635
|
-
title: `${c.name} took ${fmtDur(c.durationMs)}`, detail
|
|
774
|
+
title: `${c.name} took ${fmtDur(c.durationMs)}`, detail,
|
|
636
775
|
evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.durationMs,
|
|
637
776
|
});
|
|
638
777
|
}
|
|
778
|
+
// One finding per waited-on task, always info: the time belongs to the background job.
|
|
779
|
+
for (const group of waits.values()) {
|
|
780
|
+
const ms = group.reduce((s, c) => s + (c.durationMs || 0), 0);
|
|
781
|
+
if (ms < o.slowToolInfoMs) continue;
|
|
782
|
+
const target = waitTarget(group[0]);
|
|
783
|
+
push({
|
|
784
|
+
id: 'slow-tool', severity: 'info',
|
|
785
|
+
title: `${group[0].name} waited ${fmtDur(ms)} on ${target ? 'task ' + target : 'a background task'}${group.length > 1 ? ` (${group.length} polls)` : ''}`,
|
|
786
|
+
detail: 'Blocking wait on background work: the time is the task\'s run time, not a slow tool.',
|
|
787
|
+
evidence: { toolCallIds: group.map((c) => c.id), turnIndex: group[0].turnIndex }, metric: ms,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
639
790
|
|
|
640
791
|
// slow-model: a long response that produced little — API latency, rate limiting or a stall,
|
|
641
792
|
// as opposed to a long response that was simply writing a lot (reported separately as info).
|
|
@@ -648,9 +799,10 @@
|
|
|
648
799
|
detail: `Request #${n} (${r.tokensPerSec != null ? r.tokensPerSec.toFixed(1) : '?'} tok/s): API latency, rate limiting or a stalled stream.`,
|
|
649
800
|
evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.responseMs,
|
|
650
801
|
});
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
802
|
+
// A long response that wrote a lot at a normal rate is just a big output. Only a slow rate is worth a line.
|
|
803
|
+
else if (r.tokensPerSec != null && r.tokensPerSec < o.longGenerationMaxTokPerSec) push({
|
|
804
|
+
id: 'long-generation', severity: 'info', title: `${fmtDur(r.responseMs)} writing ${fmtInt(r.usage.output)} tokens (${r.tokensPerSec.toFixed(1)} tok/s)`,
|
|
805
|
+
detail: `Request #${n} (${r.blocksSummary}) streamed well below the usual rate: a partial stall or a slow API period.`,
|
|
654
806
|
evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.responseMs,
|
|
655
807
|
});
|
|
656
808
|
}
|
|
@@ -682,10 +834,18 @@
|
|
|
682
834
|
detail: 'Most of the spend happened in delegated work.', evidence: {}, metric: subTokens / allTokens,
|
|
683
835
|
});
|
|
684
836
|
|
|
685
|
-
// long-turn
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
837
|
+
// long-turn: main conversation only (a subagent's whole run is one turn by design), numbered like
|
|
838
|
+
// the viewer's T1, T2… (human prompts).
|
|
839
|
+
// Meta turns (injected reminders, interrupts) between two prompts belong to the stretch before them.
|
|
840
|
+
const stretches = [];
|
|
841
|
+
for (const tn of trace.turns) {
|
|
842
|
+
if (tn.agent !== 'main') continue;
|
|
843
|
+
if (tn.promptKind === 'human' || !stretches.length) stretches.push({ first: tn, no: stretches.length + 1, ids: [] });
|
|
844
|
+
stretches[stretches.length - 1].ids.push(...tn.toolCallIds);
|
|
845
|
+
}
|
|
846
|
+
for (const st of stretches) if (st.ids.length >= o.longTurnTools) push({
|
|
847
|
+
id: 'long-turn', severity: 'info', title: `Turn ${st.no} made ${st.ids.length} tool calls`,
|
|
848
|
+
detail: 'A very long autonomous stretch. Worth a skim for wasted work.', evidence: { turnIndex: st.first.index, toolCallIds: st.ids }, metric: st.ids.length,
|
|
689
849
|
});
|
|
690
850
|
|
|
691
851
|
const order = { error: 0, warn: 1, info: 2 };
|
|
@@ -771,6 +931,9 @@
|
|
|
771
931
|
'thinking-heavy': 'Most output tokens were thinking; fine for hard problems, but for routine tool loops a lower effort setting is cheaper and just as good.',
|
|
772
932
|
'subagent-share': 'Most spend was in subagents; give them narrower briefs and ask for short structured returns so the parent pays for less.',
|
|
773
933
|
'long-turn': 'A very long autonomous turn; check in with a short progress summary at natural checkpoints so wasted work is caught earlier.',
|
|
934
|
+
'permission-denied': 'Before a call the user may not want (installs, network, deleting, publishing), say what it will do and ask; for calls they always allow, suggest a permissions allow rule so the prompt goes away.',
|
|
935
|
+
'duplicate-subagent-read': 'When several subagents need the same file, put the relevant excerpt in their briefs or have one agent summarise it once, instead of every agent reading it into its own context.',
|
|
936
|
+
'idle-cache-expiry': 'The cache expired while the session sat idle; resume sooner, use the 1-hour cache lifetime where available, or start a fresh session with a summary instead of re-sending the whole history.',
|
|
774
937
|
};
|
|
775
938
|
|
|
776
939
|
// Advice for a concrete finding: the rule's line, except where the evidence says otherwise.
|
|
@@ -780,6 +943,11 @@
|
|
|
780
943
|
const c = trace.toolCalls.find((x) => x.id === ids[0]);
|
|
781
944
|
if (c && c.category === 'user') return null; // waiting on the human is not the agent's time
|
|
782
945
|
if (c && c.category === 'agent') return 'Subagent run time: give it a narrower brief and ask for a short structured return so the parent is not blocked for as long.';
|
|
946
|
+
if (c && isWait(c)) return 'Waiting on a long background task: keep doing independent work while it runs, or tell the user it is still going, instead of blocking the turn on it.';
|
|
947
|
+
}
|
|
948
|
+
if (f.id === 'oversized-result') {
|
|
949
|
+
const c = trace.toolCalls.find((x) => x.id === ((f.evidence && f.evidence.toolCallIds) || [])[0]);
|
|
950
|
+
if (c && hasRange(c)) return 'The read already had a range and still returned a lot: grep for the part you need first, or read a narrower window.';
|
|
783
951
|
}
|
|
784
952
|
return ADVICE[f.id] || null;
|
|
785
953
|
}
|
|
@@ -814,9 +982,44 @@
|
|
|
814
982
|
}
|
|
815
983
|
|
|
816
984
|
// Rules whose detail quotes tool output or model text (everything else is names and numbers).
|
|
817
|
-
const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error']);
|
|
985
|
+
const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error', 'duplicate-subagent-read']);
|
|
818
986
|
function redactDetail(f) { return CONTENT_DETAIL.has(f.id) ? blank(f.detail) : f.detail; }
|
|
819
987
|
|
|
988
|
+
// ---------------------------------------------------------------------------
|
|
989
|
+
// Per-file use: how often each file was read or written, by how many agents, and how often
|
|
990
|
+
// those calls failed. Paths are normalised (backslashes, case) but otherwise kept; the CLI's
|
|
991
|
+
// legend replaces them with keys when sharing.
|
|
992
|
+
// ---------------------------------------------------------------------------
|
|
993
|
+
const FILE_READ = new Set(['Read']);
|
|
994
|
+
const FILE_WRITE = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
995
|
+
function filePathOf(c) { const i = c && c.input; if (!i) return null; const p = i.file_path || i.notebook_path; return typeof p === 'string' && p ? p : null; }
|
|
996
|
+
function normalisePath(p) { return String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); }
|
|
997
|
+
function fileStats(trace, opts) {
|
|
998
|
+
const minCalls = (opts && opts.minCalls) || 3, minAgents = (opts && opts.minAgents) || 2;
|
|
999
|
+
const rows = new Map();
|
|
1000
|
+
for (const c of trace.toolCalls) {
|
|
1001
|
+
if (c.unmatched) continue;
|
|
1002
|
+
const isRead = FILE_READ.has(c.name), isWrite = FILE_WRITE.has(c.name);
|
|
1003
|
+
if (!isRead && !isWrite) continue;
|
|
1004
|
+
const p = filePathOf(c); if (!p) continue;
|
|
1005
|
+
const k = normalisePath(p);
|
|
1006
|
+
if (!rows.has(k)) rows.set(k, { path: p, reads: 0, writes: 0, errors: 0, chars: 0, agentSet: new Set(), tools: {} });
|
|
1007
|
+
const r = rows.get(k);
|
|
1008
|
+
if (isRead) { r.reads++; r.chars += c.resultChars || 0; } else r.writes++;
|
|
1009
|
+
if (c.isError) r.errors++;
|
|
1010
|
+
r.agentSet.add(c.agent);
|
|
1011
|
+
r.tools[c.name] = (r.tools[c.name] || 0) + 1;
|
|
1012
|
+
}
|
|
1013
|
+
const out = [];
|
|
1014
|
+
for (const r of rows.values()) {
|
|
1015
|
+
const calls = r.reads + r.writes;
|
|
1016
|
+
if (calls < minCalls && !r.errors && r.agentSet.size < minAgents) continue;
|
|
1017
|
+
out.push({ path: r.path, reads: r.reads, writes: r.writes, errors: r.errors, agents: r.agentSet.size, chars: r.chars, tools: r.tools });
|
|
1018
|
+
}
|
|
1019
|
+
out.sort((a, b) => (b.reads + b.writes) - (a.reads + a.writes) || b.errors - a.errors || a.path.localeCompare(b.path));
|
|
1020
|
+
return out;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
820
1023
|
function reportMarkdown(trace, findings, cost, opts) {
|
|
821
1024
|
opts = opts || {};
|
|
822
1025
|
const redact = !!opts.redact, maxEv = opts.maxEvidence || 6;
|
|
@@ -972,5 +1175,5 @@
|
|
|
972
1175
|
}
|
|
973
1176
|
function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
|
|
974
1177
|
|
|
975
|
-
return { VERSION, RATES, DEFAULTS, ADVICE, METRICS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, fmtMetric, fmtRatio, fmtChange, stableStringify, inputSummary, adviceFor, redactDetail, reportMarkdown, compare, compareMarkdown, toolTable };
|
|
1178
|
+
return { VERSION, RATES, DEFAULTS, ADVICE, METRICS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, fmtMetric, fmtRatio, fmtChange, stableStringify, inputSummary, adviceFor, redactDetail, reportMarkdown, compare, compareMarkdown, toolTable, fileStats, filePathOf, normalisePath };
|
|
976
1179
|
});
|
package/src/viewer.html
CHANGED
|
@@ -881,7 +881,9 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
881
881
|
}
|
|
882
882
|
async function loadSession(s) {
|
|
883
883
|
const files = [{ name: s.id + '.jsonl', text: await (await s.handle.getFile()).text() }];
|
|
884
|
-
|
|
884
|
+
// subagents/*.jsonl plus Workflow agents in subagents/workflows/<runId>/ (journal.jsonl is the workflow's log)
|
|
885
|
+
const walk = async (dir, rel) => { for await (const [n, h] of dir.entries()) { if (h.kind === 'directory') await walk(h, rel + '/' + n); else if (/\.(jsonl|json)$/.test(n) && n !== 'journal.jsonl') files.push({ name: rel + '/' + n, text: await (await h.getFile()).text() }); } };
|
|
886
|
+
try { await walk(await (await s.pdir.getDirectoryHandle(s.id)).getDirectoryHandle('subagents'), s.id + '/subagents'); } catch (e) { }
|
|
885
887
|
$('#sessions-dlg').close(); if (S.pickOther) { S.pickOther = false; loadOther(files); } else load(files, false);
|
|
886
888
|
}
|
|
887
889
|
async function showSessions(dir) {
|