openclaw-diag-cli 1.10.3 → 1.11.1
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/CHANGELOG.md +33 -0
- package/README.md +1 -0
- package/ocdiag/__init__.py +1 -1
- package/ocdiag/collectors/plugin_diag.py +15 -4
- package/ocdiag/inspectors/trace.py +394 -76
- package/ocdiag/main.py +19 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.10.4 — plugin_diag windowed scan filters to dated-in-window (2026-06-16)
|
|
4
|
+
|
|
5
|
+
Minimal correctness hardening of the v1.10.2 full-cache reuse. The 7d/30d
|
|
6
|
+
windowed trajectory scan now filters its `collect_runs` result to
|
|
7
|
+
`r.started_ts_ms and r.started_ts_ms >= since` BEFORE sorting / top-30 /
|
|
8
|
+
plugin_entries sampling.
|
|
9
|
+
|
|
10
|
+
### Why
|
|
11
|
+
`collect_runs` keeps undated runs (`started_ts_ms == 0`), and when served from
|
|
12
|
+
a full-scan cache (the `all` path, after `configuration` ran) it also carries
|
|
13
|
+
undated runs from old-mtime files that the standalone prefilter disk scan
|
|
14
|
+
drops. In the edge case of FEWER than 30 dated runs in the window plus an old
|
|
15
|
+
undated run that happens to carry plugin metadata, the full-cache path could
|
|
16
|
+
let that stale run into the top-30 and mislabel it as a `7d`/`30d` hit — while
|
|
17
|
+
standalone would not. Filtering to dated-in-window at the scan site:
|
|
18
|
+
|
|
19
|
+
- makes the windowed sample (and `trajectory_runs_scanned`) byte-identical
|
|
20
|
+
across standalone and `all`/full-cache modes — measured 108 == 108;
|
|
21
|
+
- prevents a stale undated plugin run from being mislabeled as a window hit;
|
|
22
|
+
- keeps the full performance win (plugin_diag in `all` ~128ms vs ~540ms).
|
|
23
|
+
|
|
24
|
+
The full fallback intentionally keeps the unfiltered view so sparse/old
|
|
25
|
+
history (including undated runs) is still inspectable.
|
|
26
|
+
|
|
27
|
+
### Tests
|
|
28
|
+
- `tests/test_cache_prefilter_reuse.py`:
|
|
29
|
+
`test_old_undated_plugin_run_not_labeled_window_hit` — stages <30 dated runs
|
|
30
|
+
(no plugin metadata) plus one old-mtime undated run WITH plugin metadata;
|
|
31
|
+
asserts neither standalone nor `all`/full-cache labels it `7d`/`30d` (both
|
|
32
|
+
resolve to `full_fallback`). Verified to FAIL against pre-fix code (full-cache
|
|
33
|
+
mode returned `7d`) and PASS after. Suite: 200 passed, 1 skipped.
|
|
34
|
+
|
|
35
|
+
|
|
3
36
|
## v1.10.3 — regression test: full-cache reuse conclusion-equivalence (2026-06-16)
|
|
4
37
|
|
|
5
38
|
Test-only release. No runtime change — the shipped package is byte-identical
|
package/README.md
CHANGED
|
@@ -125,6 +125,7 @@ openclaw-diag examples # Show usage examples
|
|
|
125
125
|
openclaw-diag trace <uuid> # Last user message
|
|
126
126
|
openclaw-diag trace <uuid> --msg-index 0 # First message
|
|
127
127
|
openclaw-diag trace <uuid> --msg-match "deploy" # Match by content
|
|
128
|
+
openclaw-diag trace <uuid> --all-messages # Every user turn, one block each
|
|
128
129
|
openclaw-diag trace <uuid> --no-trajectory # Skip trajectory enrichment
|
|
129
130
|
|
|
130
131
|
# Extract: dump session content
|
package/ocdiag/__init__.py
CHANGED
|
@@ -741,10 +741,21 @@ def _section_trajectory(
|
|
|
741
741
|
("7d", 7 * 86400 * 1000),
|
|
742
742
|
("30d", 30 * 86400 * 1000),
|
|
743
743
|
):
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
)
|
|
744
|
+
since = trajectory.ms_ago(window_ms)
|
|
745
|
+
runs = ctx.collect_runs(since_ms=since, mtime_prefilter=True)
|
|
746
|
+
# Restrict to DATED runs strictly inside the window BEFORE sampling.
|
|
747
|
+
# ``collect_runs`` keeps undated runs (started_ts_ms == 0), and when
|
|
748
|
+
# served from a full-scan cache (the ``all`` path) may additionally
|
|
749
|
+
# carry undated runs from old-mtime files that the standalone
|
|
750
|
+
# prefilter disk scan drops. Filtering here makes the windowed sample
|
|
751
|
+
# byte-identical across both modes and prevents an old undated run
|
|
752
|
+
# that happens to carry plugin metadata from being mislabeled as a
|
|
753
|
+
# "7d"/"30d" hit. (The full fallback below intentionally keeps the
|
|
754
|
+
# unfiltered view so sparse/old history is still inspectable.)
|
|
755
|
+
runs = [
|
|
756
|
+
r for r in runs
|
|
757
|
+
if r.started_ts_ms and r.started_ts_ms >= since
|
|
758
|
+
]
|
|
748
759
|
runs.sort(key=lambda r: r.started_ts_ms or 0, reverse=True)
|
|
749
760
|
candidate = [r for r in runs[:30] if r.plugin_entries]
|
|
750
761
|
if candidate:
|
|
@@ -26,6 +26,7 @@ from ..core.types import Report, Section, Verdict
|
|
|
26
26
|
from ..tracing import (
|
|
27
27
|
analyze_phases,
|
|
28
28
|
build_system_prompt_info,
|
|
29
|
+
extract_text,
|
|
29
30
|
extract_trace_records,
|
|
30
31
|
find_first_message,
|
|
31
32
|
find_gateway_logs,
|
|
@@ -370,6 +371,152 @@ def _section_gateway(s: Section, gw: Dict[str, Any]) -> None:
|
|
|
370
371
|
)
|
|
371
372
|
|
|
372
373
|
|
|
374
|
+
def _user_text_snippet(user_rec: Dict[str, Any], max_chars: int = 80) -> str:
|
|
375
|
+
"""One-line preview of a user message's text content.
|
|
376
|
+
|
|
377
|
+
Used in the per-turn meta header when --all-messages is on so the reader
|
|
378
|
+
can tell turns apart at a glance. Whitespace is collapsed to single
|
|
379
|
+
spaces; the result is truncated with an ellipsis.
|
|
380
|
+
"""
|
|
381
|
+
msg = user_rec.get("message") or {}
|
|
382
|
+
text = extract_text(msg.get("content", "")) or ""
|
|
383
|
+
text = " ".join(text.split())
|
|
384
|
+
if len(text) > max_chars:
|
|
385
|
+
text = text[:max_chars - 1] + "…"
|
|
386
|
+
return text
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _build_turn_sections(
|
|
390
|
+
report: Report,
|
|
391
|
+
*,
|
|
392
|
+
analysis: Dict[str, Any],
|
|
393
|
+
user_msg_ordinal: int,
|
|
394
|
+
user_msg_id: str,
|
|
395
|
+
total_user_msgs: int,
|
|
396
|
+
turn_label: str,
|
|
397
|
+
user_snippet: str,
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Append the per-turn timeline / summary / model / tool sections.
|
|
400
|
+
|
|
401
|
+
``turn_label`` is prepended to each section title so multi-turn output
|
|
402
|
+
keeps the per-turn blocks visually delimited (e.g. ``"Turn #2/5 · "``).
|
|
403
|
+
|
|
404
|
+
Per-turn vs session-level enrichment split (see TraceInspector.collect):
|
|
405
|
+
Per-turn here = timeline + summary + model breakdown + tool breakdown.
|
|
406
|
+
Trajectory + System Prompt + Gateway are session-scoped (one per file)
|
|
407
|
+
and are emitted ONCE by the caller, not per turn.
|
|
408
|
+
"""
|
|
409
|
+
s_meta = report.section(f"{turn_label}Trace · 元信息")
|
|
410
|
+
s_meta.ok(
|
|
411
|
+
"user_message",
|
|
412
|
+
f"user message #{user_msg_ordinal} of {total_user_msgs} "
|
|
413
|
+
f"(id: {user_msg_id}) {user_snippet}",
|
|
414
|
+
data={
|
|
415
|
+
"index": user_msg_ordinal,
|
|
416
|
+
"id": user_msg_id,
|
|
417
|
+
"total": total_user_msgs,
|
|
418
|
+
"snippet": user_snippet,
|
|
419
|
+
},
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
s_timeline = report.section(f"{turn_label}Trace · 时间轴")
|
|
423
|
+
_section_timeline(s_timeline, analysis)
|
|
424
|
+
|
|
425
|
+
s_summary = report.section(f"{turn_label}Trace · 汇总")
|
|
426
|
+
_section_summary(s_summary, analysis)
|
|
427
|
+
|
|
428
|
+
if analysis["model_calls"]:
|
|
429
|
+
s_models = report.section(f"{turn_label}Trace · Model 拆解")
|
|
430
|
+
_section_model_breakdown(s_models, analysis)
|
|
431
|
+
|
|
432
|
+
if analysis["tool_execs"]:
|
|
433
|
+
s_tools = report.section(f"{turn_label}Trace · 工具拆解")
|
|
434
|
+
_section_tool_breakdown(s_tools, analysis)
|
|
435
|
+
|
|
436
|
+
# Slow E2E → WARN. Attach to this turn's summary section so the verdict
|
|
437
|
+
# surfaces without inventing a new section.
|
|
438
|
+
total_ms = analysis["summary"]["total_ms"]
|
|
439
|
+
if total_ms > SLOW_THRESHOLD_MS:
|
|
440
|
+
s_summary.warn(
|
|
441
|
+
"trace.slow",
|
|
442
|
+
f"E2E elapsed {fmt_duration(total_ms)} > "
|
|
443
|
+
f"{SLOW_THRESHOLD_MS // 1000}s",
|
|
444
|
+
data={"total_ms": total_ms, "threshold_ms": SLOW_THRESHOLD_MS},
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _section_aggregate(
|
|
449
|
+
s: Section, per_turn: List[Dict[str, Any]],
|
|
450
|
+
) -> None:
|
|
451
|
+
"""Render the cross-turn summary at the end of an --all-messages run.
|
|
452
|
+
|
|
453
|
+
Aggregates totals across every turn (count, cumulative E2E / model /
|
|
454
|
+
tool time, cumulative tokens) and surfaces any turn whose E2E exceeded
|
|
455
|
+
SLOW_THRESHOLD_MS as a per-turn WARN line.
|
|
456
|
+
"""
|
|
457
|
+
n = len(per_turn)
|
|
458
|
+
total_e2e = sum(t["summary"]["total_ms"] for t in per_turn)
|
|
459
|
+
total_model = sum(t["summary"]["model_total_ms"] for t in per_turn)
|
|
460
|
+
total_tool = sum(t["summary"]["tool_total_ms"] for t in per_turn)
|
|
461
|
+
total_in = sum(t["summary"]["total_input_tokens"] for t in per_turn)
|
|
462
|
+
total_out = sum(t["summary"]["total_output_tokens"] for t in per_turn)
|
|
463
|
+
total_cr = sum(t["summary"]["total_cache_read"] for t in per_turn)
|
|
464
|
+
total_cw = sum(t["summary"]["total_cache_write"] for t in per_turn)
|
|
465
|
+
total_models = sum(t["summary"]["model_count"] for t in per_turn)
|
|
466
|
+
total_tools = sum(t["summary"]["tool_count"] for t in per_turn)
|
|
467
|
+
|
|
468
|
+
s.ok(
|
|
469
|
+
"agg.turns", f"Turns: {n}",
|
|
470
|
+
data={"count": n},
|
|
471
|
+
)
|
|
472
|
+
s.ok(
|
|
473
|
+
"agg.total", f"Cumulative E2E: {fmt_duration(total_e2e)}",
|
|
474
|
+
data={"total_ms": total_e2e},
|
|
475
|
+
)
|
|
476
|
+
s.ok(
|
|
477
|
+
"agg.model",
|
|
478
|
+
f"Cumulative model time: {fmt_duration(total_model)} "
|
|
479
|
+
f"across {total_models} call(s)",
|
|
480
|
+
data={"total_ms": total_model, "count": total_models},
|
|
481
|
+
)
|
|
482
|
+
s.ok(
|
|
483
|
+
"agg.tool",
|
|
484
|
+
f"Cumulative tool time: {fmt_duration(total_tool)} "
|
|
485
|
+
f"across {total_tools} call(s)",
|
|
486
|
+
data={"total_ms": total_tool, "count": total_tools},
|
|
487
|
+
)
|
|
488
|
+
tok_msg = f"Cumulative tokens: in={total_in} out={total_out}"
|
|
489
|
+
if total_cr:
|
|
490
|
+
tok_msg += f" cache_read={total_cr}"
|
|
491
|
+
if total_cw:
|
|
492
|
+
tok_msg += f" cache_write={total_cw}"
|
|
493
|
+
s.ok(
|
|
494
|
+
"agg.tokens", tok_msg,
|
|
495
|
+
data={
|
|
496
|
+
"input": total_in,
|
|
497
|
+
"output": total_out,
|
|
498
|
+
"cache_read": total_cr,
|
|
499
|
+
"cache_write": total_cw,
|
|
500
|
+
},
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
for t in per_turn:
|
|
504
|
+
if t["summary"]["total_ms"] > SLOW_THRESHOLD_MS:
|
|
505
|
+
s.warn(
|
|
506
|
+
f"agg.slow.{t['index']}",
|
|
507
|
+
f"Turn #{t['index'] + 1} slow: "
|
|
508
|
+
f"{fmt_duration(t['summary']['total_ms'])} > "
|
|
509
|
+
f"{SLOW_THRESHOLD_MS // 1000}s "
|
|
510
|
+
f"(id: {t['user_message_id']})",
|
|
511
|
+
data={
|
|
512
|
+
"index": t["index"],
|
|
513
|
+
"user_message_id": t["user_message_id"],
|
|
514
|
+
"total_ms": t["summary"]["total_ms"],
|
|
515
|
+
"threshold_ms": SLOW_THRESHOLD_MS,
|
|
516
|
+
},
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
373
520
|
@register
|
|
374
521
|
class TraceInspector:
|
|
375
522
|
id = "trace"
|
|
@@ -402,6 +549,28 @@ class TraceInspector:
|
|
|
402
549
|
report.elapsed_ms = (time.time() - t0) * 1000
|
|
403
550
|
return report
|
|
404
551
|
|
|
552
|
+
# --all-messages is mutually exclusive with the single-turn selectors.
|
|
553
|
+
# The CLI parser already rejects the combination, but inspectors can
|
|
554
|
+
# also be called programmatically (tests, future SDK callers); guard
|
|
555
|
+
# here so the contract holds end-to-end.
|
|
556
|
+
all_messages = bool(kwargs.get("all_messages"))
|
|
557
|
+
if all_messages and (
|
|
558
|
+
kwargs.get("msg_index") is not None
|
|
559
|
+
or kwargs.get("msg_id") is not None
|
|
560
|
+
or kwargs.get("msg_match") is not None
|
|
561
|
+
):
|
|
562
|
+
report.error = (
|
|
563
|
+
"--all-messages cannot be combined with "
|
|
564
|
+
"--msg-index/--msg-id/--msg-match"
|
|
565
|
+
)
|
|
566
|
+
report.diag_error = DiagError(
|
|
567
|
+
code="INVALID_ARGUMENT",
|
|
568
|
+
message=report.error,
|
|
569
|
+
hint="pick either --all-messages OR a single-turn selector",
|
|
570
|
+
)
|
|
571
|
+
report.elapsed_ms = (time.time() - t0) * 1000
|
|
572
|
+
return report
|
|
573
|
+
|
|
405
574
|
files, candidates = sessions.resolve(
|
|
406
575
|
session_id,
|
|
407
576
|
base_dir=str(ctx.sessions_base),
|
|
@@ -466,41 +635,69 @@ class TraceInspector:
|
|
|
466
635
|
report.elapsed_ms = (time.time() - t0) * 1000
|
|
467
636
|
return report
|
|
468
637
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
)
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
638
|
+
# ── Pick which user turn(s) to analyze ──
|
|
639
|
+
# Single-turn (default, or any --msg-* selector) → one analysis.
|
|
640
|
+
# All-messages → analyze every user turn from find_user_messages.
|
|
641
|
+
# We still call analyze_phases per turn so each block has its own
|
|
642
|
+
# base_epoch_ms / timeline / summary identical to a single-turn run.
|
|
643
|
+
total_user_msgs = len(user_msgs)
|
|
644
|
+
if all_messages:
|
|
645
|
+
selected: List[tuple] = list(user_msgs)
|
|
646
|
+
else:
|
|
647
|
+
rec_idx, user_rec = select_user_message(
|
|
648
|
+
records,
|
|
649
|
+
kwargs.get("msg_index"),
|
|
650
|
+
kwargs.get("msg_id"),
|
|
651
|
+
kwargs.get("msg_match"),
|
|
478
652
|
)
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
653
|
+
selected = [(rec_idx, user_rec)]
|
|
654
|
+
|
|
655
|
+
# Run analyze_phases for each selected turn. The first turn's
|
|
656
|
+
# analysis seeds the session-level enrichment (trajectory / system
|
|
657
|
+
# prompt / gateway) — those data sources are session-scoped, not
|
|
658
|
+
# per-turn, so they are computed ONCE using the first turn's
|
|
659
|
+
# base_epoch_ms (which is the earliest timestamp in selected turns).
|
|
660
|
+
per_turn_analyses: List[Dict[str, Any]] = []
|
|
661
|
+
for ordinal, (rec_idx, user_rec) in enumerate(selected):
|
|
662
|
+
try:
|
|
663
|
+
turn_index = next(
|
|
664
|
+
i for i, (ri, _) in enumerate(user_msgs) if ri == rec_idx
|
|
665
|
+
)
|
|
666
|
+
except StopIteration:
|
|
667
|
+
turn_index = ordinal
|
|
668
|
+
turn_records = extract_trace_records(records, rec_idx)
|
|
669
|
+
analysis = analyze_phases(turn_records)
|
|
670
|
+
per_turn_analyses.append({
|
|
671
|
+
"index": turn_index,
|
|
672
|
+
"user_message_id": user_rec.get("id", "?"),
|
|
673
|
+
"user_record": user_rec,
|
|
674
|
+
"analysis": analysis,
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
# ── Session-level enrichment (computed once) ──
|
|
678
|
+
# Trajectory / system prompt / gateway timing reflect properties of
|
|
679
|
+
# the whole run/file, not a single user turn. Computing them once
|
|
680
|
+
# keeps cost down on long sessions and avoids duplicating identical
|
|
681
|
+
# blocks per turn. The base epoch we hand to load_trajectory_info /
|
|
682
|
+
# load_gateway_timing is the FIRST selected turn's base_epoch_ms;
|
|
683
|
+
# for single-turn this matches the legacy behaviour exactly, and for
|
|
684
|
+
# all-messages it puts the trajectory offsets on the same axis as
|
|
685
|
+
# the first turn block (the most useful anchor point).
|
|
686
|
+
first_analysis = per_turn_analyses[0]["analysis"]
|
|
687
|
+
base_epoch_ms = first_analysis["base_epoch_ms"]
|
|
492
688
|
|
|
493
689
|
traj_info: Optional[Dict[str, Any]] = None
|
|
494
690
|
if not kwargs.get("no_trajectory"):
|
|
495
691
|
traj_path = find_trajectory_file(session_file)
|
|
496
692
|
if traj_path:
|
|
497
|
-
traj_info = load_trajectory_info(traj_path,
|
|
693
|
+
traj_info = load_trajectory_info(traj_path, base_epoch_ms)
|
|
498
694
|
if traj_info is not None:
|
|
499
695
|
_apply_traj_redaction(
|
|
500
696
|
traj_info,
|
|
501
697
|
mask=bool(kwargs.get("mask")),
|
|
502
698
|
show_tool_metas=bool(kwargs.get("show_tool_metas")),
|
|
503
|
-
show_plugin_snapshot=bool(
|
|
699
|
+
show_plugin_snapshot=bool(
|
|
700
|
+
kwargs.get("show_plugin_snapshot")),
|
|
504
701
|
)
|
|
505
702
|
|
|
506
703
|
gw_info: Optional[Dict[str, Any]] = None
|
|
@@ -508,15 +705,15 @@ class TraceInspector:
|
|
|
508
705
|
log_files = find_gateway_logs(str(ctx.log_dir))
|
|
509
706
|
if log_files:
|
|
510
707
|
gw_info = load_gateway_timing(
|
|
511
|
-
log_files, full_session_id,
|
|
708
|
+
log_files, full_session_id, base_epoch_ms,
|
|
512
709
|
)
|
|
513
710
|
|
|
514
711
|
store_report = sessions.lookup_system_prompt_report(
|
|
515
712
|
session_file, full_session_id,
|
|
516
713
|
)
|
|
517
714
|
system_prompt = build_system_prompt_info(store_report, traj_info)
|
|
518
|
-
if system_prompt is not None and
|
|
519
|
-
first_call =
|
|
715
|
+
if system_prompt is not None and first_analysis.get("model_calls"):
|
|
716
|
+
first_call = first_analysis["model_calls"][0]
|
|
520
717
|
actual_input = (
|
|
521
718
|
(first_call.get("tokens_in") or 0)
|
|
522
719
|
+ (first_call.get("cache_read") or 0)
|
|
@@ -532,58 +729,179 @@ class TraceInspector:
|
|
|
532
729
|
if system_prompt is not None:
|
|
533
730
|
report.data["systemPrompt"] = system_prompt
|
|
534
731
|
|
|
535
|
-
# ──
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
)
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
"
|
|
549
|
-
data
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
732
|
+
# ── report.data wiring ──
|
|
733
|
+
# Single-turn path: keep the legacy keys (timeline, model_calls,
|
|
734
|
+
# tool_execs, summary, base_epoch_ms, user_message_*) at the top
|
|
735
|
+
# level so existing JSON consumers see no schema change.
|
|
736
|
+
# All-messages path: add report.data["all_messages"] (per-turn list)
|
|
737
|
+
# and report.data["aggregate"] (cumulative). The legacy single-turn
|
|
738
|
+
# keys are NOT populated in this mode — consumers that asked for
|
|
739
|
+
# all-messages should read the new keys.
|
|
740
|
+
if not all_messages:
|
|
741
|
+
single = per_turn_analyses[0]
|
|
742
|
+
report.data["base_epoch_ms"] = single["analysis"]["base_epoch_ms"]
|
|
743
|
+
report.data["timeline"] = single["analysis"]["events"]
|
|
744
|
+
report.data["model_calls"] = single["analysis"]["model_calls"]
|
|
745
|
+
report.data["tool_execs"] = single["analysis"]["tool_execs"]
|
|
746
|
+
report.data["summary"] = single["analysis"]["summary"]
|
|
747
|
+
report.data["user_message_index"] = single["index"]
|
|
748
|
+
report.data["user_message_id"] = single["user_message_id"]
|
|
749
|
+
else:
|
|
750
|
+
all_msgs_payload: List[Dict[str, Any]] = []
|
|
751
|
+
for t in per_turn_analyses:
|
|
752
|
+
a = t["analysis"]
|
|
753
|
+
all_msgs_payload.append({
|
|
754
|
+
"index": t["index"],
|
|
755
|
+
"user_message_id": t["user_message_id"],
|
|
756
|
+
"user_message_snippet": _user_text_snippet(t["user_record"]),
|
|
757
|
+
"base_epoch_ms": a["base_epoch_ms"],
|
|
758
|
+
"timeline": a["events"],
|
|
759
|
+
"model_calls": a["model_calls"],
|
|
760
|
+
"tool_execs": a["tool_execs"],
|
|
761
|
+
"summary": a["summary"],
|
|
762
|
+
})
|
|
763
|
+
report.data["all_messages"] = all_msgs_payload
|
|
764
|
+
report.data["aggregate"] = {
|
|
765
|
+
"turns": len(per_turn_analyses),
|
|
766
|
+
"total_ms": sum(
|
|
767
|
+
t["analysis"]["summary"]["total_ms"]
|
|
768
|
+
for t in per_turn_analyses
|
|
769
|
+
),
|
|
770
|
+
"model_total_ms": sum(
|
|
771
|
+
t["analysis"]["summary"]["model_total_ms"]
|
|
772
|
+
for t in per_turn_analyses
|
|
773
|
+
),
|
|
774
|
+
"tool_total_ms": sum(
|
|
775
|
+
t["analysis"]["summary"]["tool_total_ms"]
|
|
776
|
+
for t in per_turn_analyses
|
|
777
|
+
),
|
|
778
|
+
"total_input_tokens": sum(
|
|
779
|
+
t["analysis"]["summary"]["total_input_tokens"]
|
|
780
|
+
for t in per_turn_analyses
|
|
781
|
+
),
|
|
782
|
+
"total_output_tokens": sum(
|
|
783
|
+
t["analysis"]["summary"]["total_output_tokens"]
|
|
784
|
+
for t in per_turn_analyses
|
|
785
|
+
),
|
|
786
|
+
"total_cache_read": sum(
|
|
787
|
+
t["analysis"]["summary"]["total_cache_read"]
|
|
788
|
+
for t in per_turn_analyses
|
|
789
|
+
),
|
|
790
|
+
"total_cache_write": sum(
|
|
791
|
+
t["analysis"]["summary"]["total_cache_write"]
|
|
792
|
+
for t in per_turn_analyses
|
|
793
|
+
),
|
|
794
|
+
}
|
|
569
795
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
796
|
+
# ── Sections ──
|
|
797
|
+
if not all_messages:
|
|
798
|
+
# Single-turn: emit one "session" line under 元信息 followed by
|
|
799
|
+
# the per-turn block, byte-identical to pre-1.11.0 output.
|
|
800
|
+
single = per_turn_analyses[0]
|
|
801
|
+
s_meta = report.section("Trace · 元信息")
|
|
802
|
+
s_meta.ok(
|
|
803
|
+
"session", f"session: {full_session_id}",
|
|
804
|
+
data={"session_id": full_session_id, "file": session_file},
|
|
805
|
+
)
|
|
806
|
+
msg_hint = (
|
|
807
|
+
f"user message #{single['index']} of {total_user_msgs} "
|
|
808
|
+
f"(id: {single['user_message_id']})"
|
|
809
|
+
)
|
|
810
|
+
if total_user_msgs > 1:
|
|
811
|
+
msg_hint += (
|
|
812
|
+
f" [use --msg-index 0~{total_user_msgs - 1} to select]"
|
|
813
|
+
)
|
|
814
|
+
s_meta.ok(
|
|
815
|
+
"user_message", msg_hint,
|
|
816
|
+
data={
|
|
817
|
+
"index": single["index"],
|
|
818
|
+
"id": single["user_message_id"],
|
|
819
|
+
"total": total_user_msgs,
|
|
820
|
+
},
|
|
821
|
+
)
|
|
822
|
+
s_timeline = report.section("Trace · 时间轴")
|
|
823
|
+
_section_timeline(s_timeline, single["analysis"])
|
|
824
|
+
s_summary = report.section("Trace · 汇总")
|
|
825
|
+
_section_summary(s_summary, single["analysis"])
|
|
826
|
+
if single["analysis"]["model_calls"]:
|
|
827
|
+
s_models = report.section("Trace · Model 拆解")
|
|
828
|
+
_section_model_breakdown(s_models, single["analysis"])
|
|
829
|
+
if single["analysis"]["tool_execs"]:
|
|
830
|
+
s_tools = report.section("Trace · 工具拆解")
|
|
831
|
+
_section_tool_breakdown(s_tools, single["analysis"])
|
|
832
|
+
|
|
833
|
+
if traj_info:
|
|
834
|
+
s_traj = report.section("Trace · Trajectory")
|
|
835
|
+
_section_trajectory(s_traj, traj_info)
|
|
836
|
+
if system_prompt:
|
|
837
|
+
s_sp = report.section("Trace · System Prompt")
|
|
838
|
+
_section_system_prompt(s_sp, system_prompt)
|
|
839
|
+
if gw_info:
|
|
840
|
+
s_gw = report.section("Trace · Gateway 计时")
|
|
841
|
+
_section_gateway(s_gw, gw_info)
|
|
842
|
+
|
|
843
|
+
total_ms = single["analysis"]["summary"]["total_ms"]
|
|
844
|
+
if total_ms > SLOW_THRESHOLD_MS:
|
|
845
|
+
s_summary.warn(
|
|
846
|
+
"trace.slow",
|
|
847
|
+
f"E2E elapsed {fmt_duration(total_ms)} > "
|
|
848
|
+
f"{SLOW_THRESHOLD_MS // 1000}s",
|
|
849
|
+
data={
|
|
850
|
+
"total_ms": total_ms,
|
|
851
|
+
"threshold_ms": SLOW_THRESHOLD_MS,
|
|
852
|
+
},
|
|
853
|
+
)
|
|
854
|
+
else:
|
|
855
|
+
# All-messages: a session-wide overview, then per-turn blocks
|
|
856
|
+
# delimited by a "Turn #i/N" prefix, then session-level
|
|
857
|
+
# enrichment ONCE, then a final aggregate section.
|
|
858
|
+
n = len(per_turn_analyses)
|
|
859
|
+
s_overview = report.section("Trace · 元信息 (全部消息)")
|
|
860
|
+
s_overview.ok(
|
|
861
|
+
"session", f"session: {full_session_id}",
|
|
862
|
+
data={"session_id": full_session_id, "file": session_file},
|
|
863
|
+
)
|
|
864
|
+
s_overview.ok(
|
|
865
|
+
"all_messages",
|
|
866
|
+
f"tracing all {n} user message(s) in this session",
|
|
867
|
+
data={"count": n},
|
|
868
|
+
)
|
|
573
869
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
870
|
+
for t in per_turn_analyses:
|
|
871
|
+
turn_label = f"Turn #{t['index'] + 1}/{total_user_msgs} · "
|
|
872
|
+
_build_turn_sections(
|
|
873
|
+
report,
|
|
874
|
+
analysis=t["analysis"],
|
|
875
|
+
user_msg_ordinal=t["index"],
|
|
876
|
+
user_msg_id=t["user_message_id"],
|
|
877
|
+
total_user_msgs=total_user_msgs,
|
|
878
|
+
turn_label=turn_label,
|
|
879
|
+
user_snippet=_user_text_snippet(t["user_record"]),
|
|
880
|
+
)
|
|
577
881
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
882
|
+
# Session-scoped enrichment is rendered ONCE — emitting it per
|
|
883
|
+
# turn would duplicate large, identical blocks (system prompt
|
|
884
|
+
# alone can be 10k+ chars). Title-prefixed with "Session · " so
|
|
885
|
+
# the reader sees it is run-wide, not turn-specific.
|
|
886
|
+
if traj_info:
|
|
887
|
+
s_traj = report.section("Session · Trajectory")
|
|
888
|
+
_section_trajectory(s_traj, traj_info)
|
|
889
|
+
if system_prompt:
|
|
890
|
+
s_sp = report.section("Session · System Prompt")
|
|
891
|
+
_section_system_prompt(s_sp, system_prompt)
|
|
892
|
+
if gw_info:
|
|
893
|
+
s_gw = report.section("Session · Gateway 计时")
|
|
894
|
+
_section_gateway(s_gw, gw_info)
|
|
895
|
+
|
|
896
|
+
s_agg = report.section("Trace · 跨消息汇总")
|
|
897
|
+
_section_aggregate(s_agg, [
|
|
898
|
+
{
|
|
899
|
+
"index": t["index"],
|
|
900
|
+
"user_message_id": t["user_message_id"],
|
|
901
|
+
"summary": t["analysis"]["summary"],
|
|
902
|
+
}
|
|
903
|
+
for t in per_turn_analyses
|
|
904
|
+
])
|
|
587
905
|
|
|
588
906
|
report.elapsed_ms = (time.time() - t0) * 1000
|
|
589
907
|
return report
|
package/ocdiag/main.py
CHANGED
|
@@ -323,7 +323,8 @@ _TRACE_EPILOG = """示例:
|
|
|
323
323
|
openclaw-diag trace 7e9f3b31 # 该 session 最后一条用户消息
|
|
324
324
|
openclaw-diag trace 7e9f3b31 --msg-index 0 # 第一条
|
|
325
325
|
openclaw-diag trace 7e9f3b31 --msg-match deploy # 按内容匹配
|
|
326
|
-
openclaw-diag trace 7e9f3b31 --
|
|
326
|
+
openclaw-diag trace 7e9f3b31 --all-messages # 一次跑完全部用户消息(每轮一段)
|
|
327
|
+
openclaw-diag trace 7e9f3b31 -A --format json # 全部用户消息,JSON 输出
|
|
327
328
|
"""
|
|
328
329
|
|
|
329
330
|
_EXTRACT_EPILOG = """示例:
|
|
@@ -355,6 +356,10 @@ def _build_trace_parser() -> argparse.ArgumentParser:
|
|
|
355
356
|
p.add_argument("--msg-id", default=None, help="Message by id field")
|
|
356
357
|
p.add_argument("--msg-match", default=None,
|
|
357
358
|
help="First user message containing TEXT")
|
|
359
|
+
p.add_argument("-A", "--all-messages", action="store_true",
|
|
360
|
+
dest="all_messages",
|
|
361
|
+
help="Trace every user message in the session "
|
|
362
|
+
"(mutually exclusive with --msg-index/--msg-id/--msg-match)")
|
|
358
363
|
p.add_argument("--no-trajectory", action="store_true")
|
|
359
364
|
p.add_argument("--no-log", action="store_true")
|
|
360
365
|
p.add_argument("--show-tool-metas", action="store_true")
|
|
@@ -416,11 +421,24 @@ def cmd_inspector(head: str, rest: List[str]) -> int:
|
|
|
416
421
|
if head == "trace":
|
|
417
422
|
parser = _build_trace_parser()
|
|
418
423
|
ns = parser.parse_args(rest)
|
|
424
|
+
# --all-messages traces every user turn; it is mutually exclusive with
|
|
425
|
+
# the single-turn selectors. Reject the combination at parse time so
|
|
426
|
+
# the inspector never sees an ambiguous request.
|
|
427
|
+
if ns.all_messages and (
|
|
428
|
+
ns.msg_index is not None
|
|
429
|
+
or ns.msg_id is not None
|
|
430
|
+
or ns.msg_match is not None
|
|
431
|
+
):
|
|
432
|
+
parser.error(
|
|
433
|
+
"--all-messages/-A cannot be combined with "
|
|
434
|
+
"--msg-index/--msg-id/--msg-match",
|
|
435
|
+
)
|
|
419
436
|
kwargs = {
|
|
420
437
|
"session_id": ns.session_id,
|
|
421
438
|
"msg_index": ns.msg_index,
|
|
422
439
|
"msg_id": ns.msg_id,
|
|
423
440
|
"msg_match": ns.msg_match,
|
|
441
|
+
"all_messages": ns.all_messages,
|
|
424
442
|
"no_trajectory": ns.no_trajectory,
|
|
425
443
|
"no_log": ns.no_log,
|
|
426
444
|
"show_tool_metas": ns.show_tool_metas,
|
package/package.json
CHANGED