@agenit/cli 2.1.0 → 2.1.2

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.
@@ -0,0 +1,681 @@
1
+ // Debug Mode panel — LangSmith-style trace tree.
2
+ //
3
+ // Three columns: trace list (left), tree of selected trace (middle),
4
+ // run detail (right). All reads come from /api/debug/state on mount
5
+ // + per-trace `/api/debug/traces/<id>` on selection. Pending gates
6
+ // surface in a banner at the top with Release/Skip/Abort buttons.
7
+ //
8
+ // When debug mode is OFF the panel shows a single "Turn on" call-to-
9
+ // action that POSTs `/api/debug/config` `{enabled: true}`.
10
+ import { h } from "preact";
11
+ import { useEffect, useMemo, useState } from "preact/hooks";
12
+ import htm from "htm";
13
+
14
+ const html = htm.bind(h);
15
+
16
+ const POLL_MS = 1500;
17
+
18
+ function tokenFromUrl() {
19
+ try {
20
+ const u = new URL(window.location.href);
21
+ return u.searchParams.get("token");
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function authHeaders() {
28
+ const t = tokenFromUrl();
29
+ return t ? { "x-control-token": t } : {};
30
+ }
31
+
32
+ async function getJson(path) {
33
+ const res = await fetch(path, {
34
+ headers: { Accept: "application/json", ...authHeaders() },
35
+ });
36
+ if (!res.ok) {
37
+ const body = await res.text().catch(() => "");
38
+ throw new Error(`${res.status} ${res.statusText} ${body}`);
39
+ }
40
+ return await res.json();
41
+ }
42
+
43
+ async function postJson(path, body) {
44
+ const res = await fetch(path, {
45
+ method: "POST",
46
+ headers: {
47
+ "Content-Type": "application/json",
48
+ Accept: "application/json",
49
+ ...authHeaders(),
50
+ },
51
+ body: JSON.stringify(body ?? {}),
52
+ });
53
+ if (!res.ok) {
54
+ const text = await res.text().catch(() => "");
55
+ throw new Error(`${res.status} ${res.statusText} ${text}`);
56
+ }
57
+ return await res.json();
58
+ }
59
+
60
+ function statusGlyph(status) {
61
+ switch (status) {
62
+ case "ok":
63
+ return "✓";
64
+ case "error":
65
+ return "⨯";
66
+ case "running":
67
+ return "●";
68
+ case "aborted":
69
+ return "⏹";
70
+ case "skipped":
71
+ return "⊝";
72
+ default:
73
+ return "○";
74
+ }
75
+ }
76
+
77
+ function statusClass(status) {
78
+ return `debug-status debug-status-${status || "unknown"}`;
79
+ }
80
+
81
+ function formatDuration(ms) {
82
+ if (ms === null || ms === undefined) return "—";
83
+ if (ms < 1000) return `${ms}ms`;
84
+ return `${(ms / 1000).toFixed(2)}s`;
85
+ }
86
+
87
+ function flattenTree(runs) {
88
+ const byParent = new Map();
89
+ for (const r of runs) {
90
+ const arr = byParent.get(r.parentRunId);
91
+ if (arr) arr.push(r);
92
+ else byParent.set(r.parentRunId, [r]);
93
+ }
94
+ const out = [];
95
+ const walk = (parentId, depth) => {
96
+ const kids = byParent.get(parentId) ?? [];
97
+ for (const r of kids) {
98
+ out.push({ run: r, depth });
99
+ walk(r.id, depth + 1);
100
+ }
101
+ };
102
+ walk(null, 0);
103
+ return out;
104
+ }
105
+
106
+ function OffState({ onEnable, busy }) {
107
+ return html`
108
+ <div class="panel">
109
+ <h2 class="panel-title">Debug</h2>
110
+ <p class="placeholder">
111
+ Debug mode is <strong>OFF</strong>. Turn it on to start capturing
112
+ a tree of every chat turn, LLM call, tool dispatch, and agent
113
+ run. You can also flip it on from the REPL with
114
+ <code>/debug on</code> or <kbd>Ctrl+D</kbd>.
115
+ </p>
116
+ <button
117
+ type="button"
118
+ class="primary-button"
119
+ onClick=${onEnable}
120
+ disabled=${busy}
121
+ >
122
+ ${busy ? "enabling…" : "Turn on debug mode"}
123
+ </button>
124
+ </div>
125
+ `;
126
+ }
127
+
128
+ function PendingGatesBanner({ gates, onRelease, onSkip, onAbort }) {
129
+ if (gates.length === 0) return null;
130
+ return html`
131
+ <div class="debug-gates-banner" role="alert">
132
+ <div class="debug-gates-title">
133
+ ${gates.length} pending gate${gates.length === 1 ? "" : "s"} —
134
+ awaiting your decision
135
+ </div>
136
+ <ul class="debug-gates-list">
137
+ ${gates.map(
138
+ (g) => html`
139
+ <li key=${g.runId}>
140
+ <div class="debug-gate-row">
141
+ <span class="debug-gate-kind">${g.kind}</span>
142
+ <span class="debug-gate-desc">${g.description}</span>
143
+ <div class="debug-gate-actions">
144
+ <button
145
+ type="button"
146
+ onClick=${() => onRelease(g.runId)}
147
+ >
148
+ release
149
+ </button>
150
+ <button
151
+ type="button"
152
+ onClick=${() => onSkip(g.runId)}
153
+ >
154
+ skip
155
+ </button>
156
+ <button
157
+ type="button"
158
+ class="danger"
159
+ onClick=${() => onAbort(g.runId)}
160
+ >
161
+ abort
162
+ </button>
163
+ </div>
164
+ </div>
165
+ </li>
166
+ `,
167
+ )}
168
+ </ul>
169
+ </div>
170
+ `;
171
+ }
172
+
173
+ function TraceList({ traces, selectedTraceId, onSelect }) {
174
+ if (traces.length === 0) {
175
+ return html`<div class="debug-empty">(no traces yet)</div>`;
176
+ }
177
+ return html`
178
+ <ul class="debug-trace-list">
179
+ ${traces.map((t) => {
180
+ const active = t.traceId === selectedTraceId;
181
+ return html`
182
+ <li key=${t.traceId}>
183
+ <button
184
+ type="button"
185
+ class=${"debug-trace-row " + (active ? "active" : "")}
186
+ onClick=${() => onSelect(t.traceId)}
187
+ >
188
+ <span class=${statusClass(t.status)}>${statusGlyph(t.status)}</span>
189
+ <span class="debug-trace-name">${t.name}</span>
190
+ <span class="debug-trace-meta">
191
+ ${formatDuration(t.durationMs)}
192
+ ${t.totalTokens ? html` · ${t.totalTokens} tok` : null}
193
+ ${t.totalCostUsd
194
+ ? html` · $${t.totalCostUsd.toFixed(4)}`
195
+ : null}
196
+ </span>
197
+ </button>
198
+ </li>
199
+ `;
200
+ })}
201
+ </ul>
202
+ `;
203
+ }
204
+
205
+ function TraceTree({ runs, selectedRunId, onSelect }) {
206
+ const flat = useMemo(() => flattenTree(runs), [runs]);
207
+ if (flat.length === 0) {
208
+ return html`<div class="debug-empty">(empty trace)</div>`;
209
+ }
210
+ return html`
211
+ <ul class="debug-tree">
212
+ ${flat.map(({ run, depth }) => {
213
+ const active = run.id === selectedRunId;
214
+ return html`
215
+ <li key=${run.id} style=${`padding-left: ${depth * 16}px`}>
216
+ <button
217
+ type="button"
218
+ class=${"debug-tree-row " + (active ? "active" : "")}
219
+ onClick=${() => onSelect(run.id)}
220
+ >
221
+ <span class=${statusClass(run.status)}>${statusGlyph(run.status)}</span>
222
+ <span class="debug-kind">[${run.kind}]</span>
223
+ <span class="debug-name">${run.name}</span>
224
+ <span class="debug-meta">
225
+ ${formatDuration(run.durationMs)}
226
+ ${run.tokens?.total ? html` · ${run.tokens.total}tk` : null}
227
+ ${run.costUsd ? html` · $${run.costUsd.toFixed(4)}` : null}
228
+ </span>
229
+ </button>
230
+ </li>
231
+ `;
232
+ })}
233
+ </ul>
234
+ `;
235
+ }
236
+
237
+ function RunDetail({ run, onAddTag, onReplay }) {
238
+ const [tagInput, setTagInput] = useState("");
239
+ const [replayArgs, setReplayArgs] = useState("");
240
+ const [replayBusy, setReplayBusy] = useState(false);
241
+ const [replayResult, setReplayResult] = useState(null);
242
+ if (!run) {
243
+ return html`<div class="debug-empty">select a run to see its inputs / outputs</div>`;
244
+ }
245
+ const submitTag = (ev) => {
246
+ ev.preventDefault();
247
+ const v = tagInput.trim();
248
+ if (!v) return;
249
+ onAddTag(run.id, v);
250
+ setTagInput("");
251
+ };
252
+ const isTool = run.kind === "tool";
253
+ const originalArgs = (run.inputs && run.inputs.args) || {};
254
+ const initialReplayBody = useMemo(
255
+ () => (isTool ? JSON.stringify(originalArgs, null, 2) : ""),
256
+ [isTool, JSON.stringify(originalArgs)],
257
+ );
258
+ // Initialise replayArgs from initialReplayBody when the selected run
259
+ // changes — preact useMemo above gives us a stable string per run.
260
+ useEffect(() => {
261
+ setReplayArgs(initialReplayBody);
262
+ setReplayResult(null);
263
+ }, [run.id]);
264
+
265
+ const doReplay = async () => {
266
+ let parsed = null;
267
+ try {
268
+ parsed = JSON.parse(replayArgs || "{}");
269
+ } catch (e) {
270
+ setReplayResult({ ok: false, output: `invalid JSON: ${e.message}` });
271
+ return;
272
+ }
273
+ setReplayBusy(true);
274
+ try {
275
+ const res = await onReplay(run.id, parsed);
276
+ setReplayResult({
277
+ ok: !res.isError,
278
+ output: res.output,
279
+ runId: res.runId,
280
+ });
281
+ } catch (e) {
282
+ setReplayResult({ ok: false, output: e.message });
283
+ } finally {
284
+ setReplayBusy(false);
285
+ }
286
+ };
287
+ return html`
288
+ <div class="debug-detail">
289
+ <div class="debug-detail-header">
290
+ <span class=${statusClass(run.status)}>${statusGlyph(run.status)}</span>
291
+ <span class="debug-kind">[${run.kind}]</span>
292
+ <span class="debug-name">${run.name}</span>
293
+ </div>
294
+ <div class="debug-detail-meta">
295
+ <span>id: ${run.id.slice(0, 8)}</span>
296
+ <span>${formatDuration(run.durationMs)}</span>
297
+ ${run.tokens?.total
298
+ ? html`<span>${run.tokens.total} tok</span>`
299
+ : null}
300
+ ${run.costUsd
301
+ ? html`<span>$${run.costUsd.toFixed(4)}</span>`
302
+ : null}
303
+ ${run.model ? html`<span>model: ${run.model}</span>` : null}
304
+ ${run.tier ? html`<span>tier: ${run.tier}</span>` : null}
305
+ </div>
306
+ ${run.error
307
+ ? html`<div class="debug-error">error: ${run.error}</div>`
308
+ : null}
309
+
310
+ <details open class="debug-section">
311
+ <summary>INPUTS</summary>
312
+ <pre class="debug-json">${JSON.stringify(run.inputs, null, 2)}</pre>
313
+ </details>
314
+
315
+ <details open class="debug-section">
316
+ <summary>OUTPUTS</summary>
317
+ <pre class="debug-json">${JSON.stringify(run.outputs, null, 2)}</pre>
318
+ </details>
319
+
320
+ ${run.events && run.events.length > 0
321
+ ? html`
322
+ <details class="debug-section">
323
+ <summary>EVENTS (${run.events.length})</summary>
324
+ <pre class="debug-json">
325
+ ${run.events.map((e) => JSON.stringify(e)).join("\n")}</pre>
326
+ </details>
327
+ `
328
+ : null}
329
+
330
+ <div class="debug-tags-row">
331
+ <span class="debug-tags-label">tags:</span>
332
+ ${run.tags && run.tags.length > 0
333
+ ? run.tags.map(
334
+ (t) => html`<span key=${t} class="debug-tag">${t}</span>`,
335
+ )
336
+ : html`<span class="debug-empty-inline">(none)</span>`}
337
+ <form onSubmit=${submitTag} class="debug-tag-form">
338
+ <input
339
+ type="text"
340
+ value=${tagInput}
341
+ placeholder="add tag…"
342
+ onInput=${(e) => setTagInput(e.currentTarget.value)}
343
+ />
344
+ <button type="submit">add</button>
345
+ </form>
346
+ </div>
347
+
348
+ ${isTool
349
+ ? html`
350
+ <details class="debug-section debug-replay">
351
+ <summary>REPLAY — re-fire this tool with the same or edited args</summary>
352
+ <div class="debug-replay-body">
353
+ <textarea
354
+ class="debug-replay-input"
355
+ rows="6"
356
+ value=${replayArgs}
357
+ onInput=${(e) => setReplayArgs(e.currentTarget.value)}
358
+ spellcheck="false"
359
+ />
360
+ <div class="debug-replay-actions">
361
+ <button
362
+ type="button"
363
+ class="primary-button"
364
+ onClick=${doReplay}
365
+ disabled=${replayBusy}
366
+ >
367
+ ${replayBusy ? "running…" : "Try call again"}
368
+ </button>
369
+ <button
370
+ type="button"
371
+ onClick=${() => setReplayArgs(initialReplayBody)}
372
+ disabled=${replayBusy}
373
+ >
374
+ reset
375
+ </button>
376
+ </div>
377
+ ${replayResult
378
+ ? html`
379
+ <div class=${"debug-replay-result " + (replayResult.ok ? "ok" : "error")}>
380
+ ${replayResult.runId
381
+ ? html`<div>new run: <code>${replayResult.runId.slice(0, 8)}</code></div>`
382
+ : null}
383
+ <pre>${replayResult.output}</pre>
384
+ </div>
385
+ `
386
+ : null}
387
+ </div>
388
+ </details>
389
+ `
390
+ : null}
391
+ </div>
392
+ `;
393
+ }
394
+
395
+ function ConfigDrawer({ mode, onToggle, onSetKind, onSetFlag }) {
396
+ if (!mode) return null;
397
+ const pause = new Set(mode.pauseBeforeKinds);
398
+ return html`
399
+ <details class="debug-config">
400
+ <summary>Config</summary>
401
+ <div class="debug-config-body">
402
+ <label>
403
+ <input
404
+ type="checkbox"
405
+ checked=${mode.enabled}
406
+ onChange=${(e) => onToggle(e.currentTarget.checked)}
407
+ />
408
+ enabled
409
+ </label>
410
+ <label>
411
+ <input
412
+ type="checkbox"
413
+ checked=${mode.persistDeltas}
414
+ onChange=${(e) =>
415
+ onSetFlag("persistDeltas", e.currentTarget.checked)}
416
+ />
417
+ persistDeltas
418
+ </label>
419
+ <label>
420
+ <input
421
+ type="checkbox"
422
+ checked=${mode.persistThinking}
423
+ onChange=${(e) =>
424
+ onSetFlag("persistThinking", e.currentTarget.checked)}
425
+ />
426
+ persistThinking
427
+ </label>
428
+ <label>
429
+ <input
430
+ type="checkbox"
431
+ checked=${mode.noRedaction}
432
+ onChange=${(e) =>
433
+ onSetFlag("noRedaction", e.currentTarget.checked)}
434
+ />
435
+ noRedaction (⚠ secrets persisted verbatim)
436
+ </label>
437
+ <div class="debug-config-kinds">
438
+ <span>pause-before:</span>
439
+ ${["llm", "tool", "agent"].map(
440
+ (k) => html`
441
+ <label key=${k}>
442
+ <input
443
+ type="checkbox"
444
+ checked=${pause.has(k)}
445
+ onChange=${(e) => onSetKind(k, e.currentTarget.checked)}
446
+ />
447
+ ${k}
448
+ </label>
449
+ `,
450
+ )}
451
+ </div>
452
+ </div>
453
+ </details>
454
+ `;
455
+ }
456
+
457
+ export function Debug() {
458
+ const [mode, setMode] = useState(null);
459
+ const [traces, setTraces] = useState([]);
460
+ const [gates, setGates] = useState([]);
461
+ const [selectedTraceId, setSelectedTraceId] = useState(null);
462
+ const [traceRuns, setTraceRuns] = useState([]);
463
+ const [selectedRunId, setSelectedRunId] = useState(null);
464
+ const [selectedRun, setSelectedRun] = useState(null);
465
+ const [busy, setBusy] = useState(false);
466
+ const [error, setError] = useState(null);
467
+
468
+ const refreshState = async () => {
469
+ try {
470
+ const data = await getJson("/api/debug/state");
471
+ setMode(data.mode);
472
+ setTraces(data.recentTraces || []);
473
+ setGates(data.pendingGates || []);
474
+ setError(null);
475
+ if (
476
+ data.recentTraces.length > 0 &&
477
+ !data.recentTraces.find((t) => t.traceId === selectedTraceId)
478
+ ) {
479
+ setSelectedTraceId(data.recentTraces[0].traceId);
480
+ }
481
+ } catch (e) {
482
+ setError(e.message);
483
+ }
484
+ };
485
+
486
+ useEffect(() => {
487
+ refreshState();
488
+ const id = setInterval(refreshState, POLL_MS);
489
+ return () => clearInterval(id);
490
+ }, []);
491
+
492
+ // Load the selected trace's tree.
493
+ useEffect(() => {
494
+ if (!selectedTraceId) {
495
+ setTraceRuns([]);
496
+ setSelectedRunId(null);
497
+ return;
498
+ }
499
+ getJson(`/api/debug/traces/${encodeURIComponent(selectedTraceId)}`)
500
+ .then((d) => {
501
+ setTraceRuns(d.runs || []);
502
+ if (d.runs.length > 0) setSelectedRunId(d.runs[0].id);
503
+ else setSelectedRunId(null);
504
+ })
505
+ .catch((e) => setError(e.message));
506
+ }, [selectedTraceId]);
507
+
508
+ // Load full run detail when selection changes.
509
+ useEffect(() => {
510
+ if (!selectedRunId) {
511
+ setSelectedRun(null);
512
+ return;
513
+ }
514
+ getJson(`/api/debug/runs/${encodeURIComponent(selectedRunId)}`)
515
+ .then((d) => setSelectedRun(d.run))
516
+ .catch((e) => setError(e.message));
517
+ }, [selectedRunId]);
518
+
519
+ const turnOn = async () => {
520
+ setBusy(true);
521
+ try {
522
+ await postJson("/api/debug/config", { enabled: true });
523
+ await refreshState();
524
+ } catch (e) {
525
+ setError(e.message);
526
+ } finally {
527
+ setBusy(false);
528
+ }
529
+ };
530
+
531
+ const onToggleEnabled = async (on) => {
532
+ try {
533
+ await postJson("/api/debug/config", { enabled: on });
534
+ await refreshState();
535
+ } catch (e) {
536
+ setError(e.message);
537
+ }
538
+ };
539
+
540
+ const onSetFlag = async (field, value) => {
541
+ try {
542
+ await postJson("/api/debug/config", { [field]: value });
543
+ await refreshState();
544
+ } catch (e) {
545
+ setError(e.message);
546
+ }
547
+ };
548
+
549
+ const onSetKind = async (kind, on) => {
550
+ const cur = new Set(mode?.pauseBeforeKinds ?? []);
551
+ if (on) cur.add(kind);
552
+ else cur.delete(kind);
553
+ try {
554
+ await postJson("/api/debug/config", {
555
+ pauseBeforeKinds: Array.from(cur),
556
+ });
557
+ await refreshState();
558
+ } catch (e) {
559
+ setError(e.message);
560
+ }
561
+ };
562
+
563
+ const onGateAction = async (action, runId) => {
564
+ try {
565
+ await postJson(`/api/debug/gate/${encodeURIComponent(runId)}`, {
566
+ action,
567
+ });
568
+ await refreshState();
569
+ } catch (e) {
570
+ setError(e.message);
571
+ }
572
+ };
573
+
574
+ const onReplay = async (runId, argsOverride) => {
575
+ const res = await postJson(`/api/debug/replay/${encodeURIComponent(runId)}`, {
576
+ args: argsOverride,
577
+ });
578
+ // Refresh trace tree so the new sibling run appears.
579
+ if (selectedTraceId) {
580
+ const d = await getJson(
581
+ `/api/debug/traces/${encodeURIComponent(selectedTraceId)}`,
582
+ );
583
+ setTraceRuns(d.runs || []);
584
+ }
585
+ return res;
586
+ };
587
+
588
+ const onAddTag = async (runId, tag) => {
589
+ const merged = Array.from(new Set([...(selectedRun?.tags ?? []), tag]));
590
+ try {
591
+ await postJson(
592
+ `/api/debug/runs/${encodeURIComponent(runId)}/tags`,
593
+ { tags: merged },
594
+ );
595
+ const data = await getJson(
596
+ `/api/debug/runs/${encodeURIComponent(runId)}`,
597
+ );
598
+ setSelectedRun(data.run);
599
+ } catch (e) {
600
+ setError(e.message);
601
+ }
602
+ };
603
+
604
+ if (!mode) {
605
+ return html`
606
+ <div class="panel">
607
+ <h2 class="panel-title">Debug</h2>
608
+ <p class="placeholder">
609
+ ${error ? `error: ${error}` : "loading…"}
610
+ </p>
611
+ </div>
612
+ `;
613
+ }
614
+
615
+ if (!mode.enabled) {
616
+ return html`<${OffState} onEnable=${turnOn} busy=${busy} />`;
617
+ }
618
+
619
+ return html`
620
+ <div class="panel debug-panel">
621
+ <header class="debug-header">
622
+ <h2 class="panel-title">Debug</h2>
623
+ <div class="debug-header-meta">
624
+ <span>${traces.length} trace${traces.length === 1 ? "" : "s"}</span>
625
+ ${gates.length > 0
626
+ ? html`<span class="debug-header-pending">
627
+ · ${gates.length} pending gate${gates.length === 1 ? "" : "s"}
628
+ </span>`
629
+ : null}
630
+ ${mode.pauseBeforeKinds.length > 0
631
+ ? html`<span>· pause-before: ${mode.pauseBeforeKinds.join(", ")}</span>`
632
+ : null}
633
+ </div>
634
+ <${ConfigDrawer}
635
+ mode=${mode}
636
+ onToggle=${onToggleEnabled}
637
+ onSetKind=${onSetKind}
638
+ onSetFlag=${onSetFlag}
639
+ />
640
+ </header>
641
+
642
+ ${error ? html`<div class="debug-error">error: ${error}</div>` : null}
643
+
644
+ <${PendingGatesBanner}
645
+ gates=${gates}
646
+ onRelease=${(id) => onGateAction("release", id)}
647
+ onSkip=${(id) => onGateAction("skip", id)}
648
+ onAbort=${(id) => onGateAction("abort", id)}
649
+ />
650
+
651
+ <div class="debug-3pane">
652
+ <aside class="debug-traces">
653
+ <div class="debug-pane-title">Traces</div>
654
+ <${TraceList}
655
+ traces=${traces}
656
+ selectedTraceId=${selectedTraceId}
657
+ onSelect=${setSelectedTraceId}
658
+ />
659
+ </aside>
660
+
661
+ <section class="debug-tree-pane">
662
+ <div class="debug-pane-title">Tree</div>
663
+ <${TraceTree}
664
+ runs=${traceRuns}
665
+ selectedRunId=${selectedRunId}
666
+ onSelect=${setSelectedRunId}
667
+ />
668
+ </section>
669
+
670
+ <section class="debug-detail-pane">
671
+ <div class="debug-pane-title">Detail</div>
672
+ <${RunDetail}
673
+ run=${selectedRun}
674
+ onAddTag=${onAddTag}
675
+ onReplay=${onReplay}
676
+ />
677
+ </section>
678
+ </div>
679
+ </div>
680
+ `;
681
+ }
@@ -13,6 +13,7 @@ export const PANELS = [
13
13
  { id: "design", label: "Design" },
14
14
  { id: "memory", label: "Memory" },
15
15
  { id: "jobs", label: "Jobs" },
16
+ { id: "debug", label: "Debug" },
16
17
  ];
17
18
 
18
19
  export function LeftNav({ active, onSelect }) {