agent-tracegraph 0.3.1__tar.gz → 0.4.0__tar.gz

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.
Files changed (35) hide show
  1. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/CHANGELOG.md +25 -0
  2. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/PKG-INFO +16 -7
  3. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/README.md +15 -6
  4. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/pyproject.toml +1 -1
  5. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/langgraph_checkpoint.py +107 -0
  6. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/artifact.py +9 -3
  7. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/model.py +7 -1
  8. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/.gitignore +0 -0
  9. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/LICENSE +0 -0
  10. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/SECURITY.md +0 -0
  11. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/contracts/analysis-report.schema.json +0 -0
  12. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/contracts/phoenix-runtime.json +0 -0
  13. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/contracts/review-candidates.schema.json +0 -0
  14. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/__init__.py +0 -0
  15. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/_atomic_write.py +0 -0
  16. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/__init__.py +0 -0
  17. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/base.py +0 -0
  18. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/otlp_spans.py +0 -0
  19. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/adapters/phoenix_export.py +0 -0
  20. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/__init__.py +0 -0
  21. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/ahu.py +0 -0
  22. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/diagnose.py +0 -0
  23. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/explain.py +0 -0
  24. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/memo.py +0 -0
  25. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/analysis/patterns.py +0 -0
  26. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/cli.py +0 -0
  27. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/input_validation.py +0 -0
  28. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/normalize.py +0 -0
  29. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/py.typed +0 -0
  30. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/review_candidates.py +0 -0
  31. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/sqlite_snapshot.py +0 -0
  32. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/store/__init__.py +0 -0
  33. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/store/base.py +0 -0
  34. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/store/in_memory.py +0 -0
  35. {agent_tracegraph-0.3.1 → agent_tracegraph-0.4.0}/src/tracegraph/store/ladybug.py +0 -0
@@ -6,6 +6,31 @@ The **artifact** schema version and the **report**/**review-candidate** JSON sch
6
6
  separate contracts from this version number; changes to any of them are called out
7
7
  explicitly.
8
8
 
9
+ ## 0.4.0 - 2026-10-14
10
+
11
+ ### Added
12
+
13
+ - **LangGraph tasks paused by `interrupt()` are reported.** A task whose only write on the
14
+ thread's newest root checkpoint is `__interrupt__` — it paused and the history holds no
15
+ resume and no result — becomes an `unset` step with the new source value `interrupt`,
16
+ linked by `CAUSED_BY` to that checkpoint and named by the same task-id reconstruction as
17
+ failed tasks. The step describes the recorded history, not whether LangGraph would resume
18
+ the task now. An interrupt recorded beside a resume or an error and no result is ambiguous
19
+ in the checkpoint (under LangGraph 1.2.2 a resumed task that wrote nothing looks exactly
20
+ like one paused twice; 1.2.15 marks it finished), so it is disclosed at ingest instead. Trace `status` is unchanged: a pause is not an error.
21
+ - **Artifact schema version 5**, stamped by content: only an artifact carrying an
22
+ `interrupt` step declares it, so every other artifact serializes byte for byte as before.
23
+ 0.3.1 and earlier refuse a version 5 artifact by name; this build reads versions 1 to 5,
24
+ and still rejects an envelope that declares less than its content needs.
25
+
26
+ ### Known limitations
27
+
28
+ - Tasks scheduled but never started — the recursion limit, a killed process, an
29
+ `interrupt_before`/`interrupt_after` breakpoint — leave no write and are still not
30
+ reported. Telling them apart from finished work needs the compiled graph.
31
+ - An interrupt inside a subgraph is reported as the subgraph's node at the root, not as the
32
+ node inside the subgraph that paused.
33
+
9
34
  ## 0.3.1 - 2026-10-13
10
35
 
11
36
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-tracegraph
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Time-travel debugging and causal RCA for LangGraph agent traces, on a causal graph.
5
5
  Project-URL: Homepage, https://github.com/memtomem/tracegraph
6
6
  Project-URL: Repository, https://github.com/memtomem/tracegraph
@@ -289,9 +289,11 @@ and any change to them is called out in the [changelog](CHANGELOG.md).
289
289
 
290
290
  Known limits:
291
291
 
292
- - A LangGraph task that was scheduled but left no result and no recorded error (a run
293
- paused by `interrupt()`, stopped by the recursion limit, or killed) is not reported, so
294
- such a run reads `ok` unless some other step recorded an error.
292
+ - A LangGraph task that was scheduled but never started (a run stopped by the recursion
293
+ limit, killed, or held at an `interrupt_before`/`interrupt_after` breakpoint) leaves no
294
+ write and is not reported, so such a run reads `ok` unless some other step recorded an
295
+ error. A task paused by `interrupt()` *is* reported (below), but only at the root graph:
296
+ the subgraph node is named, not the node inside it that paused.
295
297
  - Phoenix exports carry no span links, so their artifacts are parent-only; additional fan-in
296
298
  causes may be missing.
297
299
  - Report privacy (`safe-v1`) applies to reports; OTLP and LangGraph artifacts keep the
@@ -376,9 +378,16 @@ cancels its siblings and records that through the same channel; a cancelled task
376
378
  as an `unset` step with no message, because it was torn down rather than at fault. Only the
377
379
  latest persisted error survives per task, so repeated attempts are not reconstructed, and
378
380
  `Send` packets are nameable only where `langgraph.types` is importable — an ordinary install
379
- discloses them instead. An artifact whose derived tree relies on EdgeOrigin precedence declares
380
- artifact `schema_version` 4; otherwise, an artifact carrying derived task steps declares
381
- `schema_version` 3, and artifacts without them still serialize as version 2, byte for byte.
381
+ discloses them instead. A task that called `interrupt()` and whose only write on the thread's
382
+ newest root checkpoint is that interrupt becomes an `unset` step with source `interrupt` under
383
+ that checkpoint: the recorded history holds no resume and no result for it. That describes the
384
+ history, not whether LangGraph would resume the task today. An interrupt recorded together with
385
+ a resume or an error and no result is ambiguous in the checkpoint itself — under LangGraph 1.2.2
386
+ a task resumed with nothing to write looks exactly like one paused a second time — so it is
387
+ disclosed at ingest, not reported. An artifact carrying interrupt steps declares artifact `schema_version` 5; one
388
+ whose derived tree relies on EdgeOrigin precedence declares 4; otherwise, an artifact carrying
389
+ derived task steps declares `schema_version` 3, and artifacts without them still serialize as
390
+ version 2, byte for byte.
382
391
  Missing observed errors still do not
383
392
  prove success: an artifact ingested by an older build, or one using an unrecognized checkpoint
384
393
  layout, carries no derived step and looks exactly like a clean run, which is why the capture
@@ -258,9 +258,11 @@ and any change to them is called out in the [changelog](CHANGELOG.md).
258
258
 
259
259
  Known limits:
260
260
 
261
- - A LangGraph task that was scheduled but left no result and no recorded error (a run
262
- paused by `interrupt()`, stopped by the recursion limit, or killed) is not reported, so
263
- such a run reads `ok` unless some other step recorded an error.
261
+ - A LangGraph task that was scheduled but never started (a run stopped by the recursion
262
+ limit, killed, or held at an `interrupt_before`/`interrupt_after` breakpoint) leaves no
263
+ write and is not reported, so such a run reads `ok` unless some other step recorded an
264
+ error. A task paused by `interrupt()` *is* reported (below), but only at the root graph:
265
+ the subgraph node is named, not the node inside it that paused.
264
266
  - Phoenix exports carry no span links, so their artifacts are parent-only; additional fan-in
265
267
  causes may be missing.
266
268
  - Report privacy (`safe-v1`) applies to reports; OTLP and LangGraph artifacts keep the
@@ -345,9 +347,16 @@ cancels its siblings and records that through the same channel; a cancelled task
345
347
  as an `unset` step with no message, because it was torn down rather than at fault. Only the
346
348
  latest persisted error survives per task, so repeated attempts are not reconstructed, and
347
349
  `Send` packets are nameable only where `langgraph.types` is importable — an ordinary install
348
- discloses them instead. An artifact whose derived tree relies on EdgeOrigin precedence declares
349
- artifact `schema_version` 4; otherwise, an artifact carrying derived task steps declares
350
- `schema_version` 3, and artifacts without them still serialize as version 2, byte for byte.
350
+ discloses them instead. A task that called `interrupt()` and whose only write on the thread's
351
+ newest root checkpoint is that interrupt becomes an `unset` step with source `interrupt` under
352
+ that checkpoint: the recorded history holds no resume and no result for it. That describes the
353
+ history, not whether LangGraph would resume the task today. An interrupt recorded together with
354
+ a resume or an error and no result is ambiguous in the checkpoint itself — under LangGraph 1.2.2
355
+ a task resumed with nothing to write looks exactly like one paused a second time — so it is
356
+ disclosed at ingest, not reported. An artifact carrying interrupt steps declares artifact `schema_version` 5; one
357
+ whose derived tree relies on EdgeOrigin precedence declares 4; otherwise, an artifact carrying
358
+ derived task steps declares `schema_version` 3, and artifacts without them still serialize as
359
+ version 2, byte for byte.
351
360
  Missing observed errors still do not
352
361
  prove success: an artifact ingested by an older build, or one using an unrecognized checkpoint
353
362
  layout, carries no derived step and looks exactly like a clean run, which is why the capture
@@ -4,7 +4,7 @@
4
4
  # the schema `kind` constants and the TRACEGRAPH_* environment variables — those are package
5
5
  # identity, not distribution identity, and renaming them would break published contracts.
6
6
  name = "agent-tracegraph"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "Time-travel debugging and causal RCA for LangGraph agent traces, on a causal graph."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -54,6 +54,17 @@ steps carrying no message, and only the task that actually raised is an ``ERROR`
54
54
  Only the *latest* persisted error survives per task id — a fail/fail/succeed sequence leaves
55
55
  one record — so attempts are never reconstructed. Errors are recorded wherever they appear;
56
56
  this module does not infer anything from their *absence*.
57
+
58
+ Interrupted tasks
59
+ -----------------
60
+ A node that calls ``interrupt()`` leaves an ``__interrupt__`` pending write on the checkpoint
61
+ that scheduled it, and LangGraph resumes from the thread's newest root checkpoint. A task whose
62
+ *only* write on that checkpoint is ``__interrupt__`` therefore never received a resume or
63
+ produced a result, and becomes an ``UNSET`` step with ``source=INTERRUPT``. That is a statement
64
+ about the recorded history, not a prediction that LangGraph would resume it now. Tasks that were
65
+ scheduled but never started (the recursion limit, a killed process, a static breakpoint) leave no
66
+ write at all and are not reported: telling them apart from finished work needs the compiled
67
+ graph, which this adapter does not have.
57
68
  """
58
69
 
59
70
  from __future__ import annotations
@@ -108,6 +119,11 @@ _CONFIG_FIELDS = {name: TypeAdapter(str) for name in ("thread_id", "checkpoint_n
108
119
  # them — so copying them is safer than importing a private module that may move.
109
120
  _ERROR = "__error__"
110
121
  _ERROR_SOURCE_NODE = "__error_source_node__"
122
+ _INTERRUPT = "__interrupt__"
123
+ _RESUME = "__resume__"
124
+ #: Writes that can sit beside an ``__interrupt__`` without recording whether the task is still
125
+ #: paused. Any other write is a result, and the task is no longer waiting.
126
+ _UNDECIDED_WITH_INTERRUPT = frozenset({_INTERRUPT, _RESUME, _ERROR, _ERROR_SOURCE_NODE})
111
127
  _START = "__start__"
112
128
  _TASKS = "__pregel_tasks"
113
129
  _PULL = "__pregel_pull"
@@ -320,6 +336,11 @@ class LangGraphCheckpointAdapter:
320
336
  ingest_warnings = self._derive_task_steps(
321
337
  trace_id, steps, checkpoints, key_by_step_id, original_seq, writes_by_step, edges
322
338
  )
339
+ ingest_warnings.extend(
340
+ self._derive_interrupt_steps(
341
+ trace_id, steps, checkpoints, key_by_step_id, original_seq, writes_by_step, edges
342
+ )
343
+ )
323
344
  if malformed_writes:
324
345
  ingest_warnings.append(
325
346
  f"{malformed_writes} pending write(s) are not a (task id, channel, value) "
@@ -439,6 +460,92 @@ class LangGraphCheckpointAdapter:
439
460
  )
440
461
  return warnings
441
462
 
463
+ @classmethod
464
+ def _derive_interrupt_steps(
465
+ cls,
466
+ trace_id: str,
467
+ steps: dict[str, Step],
468
+ checkpoints: dict[str, dict],
469
+ key_by_step_id: dict[str, _CheckpointKey],
470
+ original_seq: dict[str, int],
471
+ writes_by_step: dict[str, dict[str, list[tuple[str, Any]]]],
472
+ edges: list[Edge],
473
+ ) -> list[str]:
474
+ """Add one UNSET step per task the head checkpoint records as interrupted, never resumed.
475
+
476
+ Only the thread's head — the newest root checkpoint, which is what LangGraph itself
477
+ resumes from — is read. An ``__interrupt__`` write on any earlier checkpoint was resumed,
478
+ forked past, or abandoned, and is history rather than an open pause.
479
+
480
+ A task is reported only when ``__interrupt__`` is its *only* write. Anything else that
481
+ accompanies it leaves the task's state unrecorded, and it is disclosed rather than
482
+ guessed: under LangGraph 1.2.2 a resumed task that returned nothing leaves
483
+ ``{__interrupt__, __resume__}``, exactly like one that reached a second ``interrupt()``
484
+ (LangGraph overwrites the interrupt in place; 1.2.15 adds ``__no_writes__`` to the
485
+ first, which then reads as finished); a failure followed by a retry that interrupts leaves the same
486
+ ``{__error__, __interrupt__}`` as the reverse order, because savers keep no order.
487
+ """
488
+ root_ids = [sid for sid, (ns, _) in key_by_step_id.items() if ns == _ROOT_NS]
489
+ if not root_ids:
490
+ return []
491
+ head_id = max(root_ids, key=lambda sid: checkpoints[sid]["id"])
492
+ head = steps[head_id]
493
+ cp = checkpoints[head_id]
494
+ _, cid = key_by_step_id[head_id]
495
+ warnings: list[str] = []
496
+ undecided = 0
497
+ for task_id, task_writes in (writes_by_step.get(head_id) or {}).items():
498
+ if task_id == _NULL_TASK_ID:
499
+ continue
500
+ channels = {channel for channel, _ in task_writes}
501
+ if _INTERRUPT not in channels:
502
+ continue
503
+ if channels != {_INTERRUPT}:
504
+ if channels <= _UNDECIDED_WITH_INTERRUPT:
505
+ undecided += 1
506
+ continue
507
+ name = cls._resolve_task_name(cp, _ROOT_NS, original_seq[head_id], task_id, task_writes)
508
+ if name is None:
509
+ warnings.append(
510
+ f"checkpoint {cid}: interrupted task {task_id} could not be attributed to a "
511
+ "node; no candidate reproduced its task id, which is also what a change in "
512
+ "LangGraph's task-id algorithm would look like."
513
+ )
514
+ derived_id = f"{head_id}#interrupt:{task_id}"
515
+ if derived_id in steps:
516
+ raise ValueError(
517
+ f"derived interrupt step id {derived_id!r} collides with a checkpoint id "
518
+ "or another derived step"
519
+ )
520
+ steps[derived_id] = Step(
521
+ step_id=derived_id,
522
+ trace_id=trace_id,
523
+ seq=head.seq + 1,
524
+ ts=head.ts,
525
+ kind=StepKind.TOOL if name and _looks_like_tool_node(name) else StepKind.CHAIN,
526
+ source=StepSource.INTERRUPT,
527
+ name=name,
528
+ # The task has no outcome yet. The interrupt's value is whatever the node asked
529
+ # its caller, which is user content, so it is not carried as a message.
530
+ status=StepStatus.UNSET,
531
+ )
532
+ edges.append(
533
+ Edge(
534
+ type=EdgeType.CAUSED_BY,
535
+ src=derived_id,
536
+ dst=head_id,
537
+ # LangGraph keys the write to the head checkpoint itself.
538
+ origin=EdgeOrigin.CHECKPOINT_PARENT,
539
+ )
540
+ )
541
+ if undecided:
542
+ warnings.append(
543
+ f"checkpoint {cid}: {undecided} task(s) recorded an interrupt together with a "
544
+ "resume or an error and no result; the checkpoint does not record whether each "
545
+ "is still paused, so none is reported as interrupted."
546
+ )
547
+ return warnings
548
+
442
549
  @staticmethod
443
550
  def _writes_by_task(pending_writes: Any) -> tuple[dict[str, list[tuple[str, Any]]], int]:
444
551
  """Group a checkpoint's pending writes by task id, in first-seen order.
@@ -19,13 +19,13 @@ from tracegraph.input_validation import loads as json_loads
19
19
  from tracegraph.model import NormalizedTrace, StepSource
20
20
 
21
21
  #: Bump when the on-disk shape changes incompatibly.
22
- ARTIFACT_SCHEMA_VERSION = 4
22
+ ARTIFACT_SCHEMA_VERSION = 5
23
23
 
24
24
  #: Versions this build can read. 1 needs a migration (see ``_migrate_v1``); 2 -> 3 is the
25
25
  #: identity, because 3 adds only a new ``Step.source`` value and changes nothing existing.
26
26
  #: 4 signals that TREE_PARENT was derived under EdgeOrigin precedence rather than
27
- #: legacy temporal-only selection.
28
- _READABLE_VERSIONS = (1, 2, 3, ARTIFACT_SCHEMA_VERSION)
27
+ #: legacy temporal-only selection. 5 adds only the ``interrupt`` ``Step.source`` value.
28
+ _READABLE_VERSIONS = (1, 2, 3, 4, ARTIFACT_SCHEMA_VERSION)
29
29
 
30
30
 
31
31
  def _required_version(nt: NormalizedTrace) -> int:
@@ -38,6 +38,9 @@ def _required_version(nt: NormalizedTrace) -> int:
38
38
  bump would invalidate digests already exported in review-candidate reports for artifacts
39
39
  whose content did not change at all.
40
40
 
41
+ * An artifact that carries an interrupt step is stamped 5, whatever its projection: a
42
+ v5 reader also reads the v4 projection, and an older reader would otherwise fail on
43
+ the unknown enum value instead of refusing the version by name.
41
44
  * An artifact whose derived TREE_PARENT relies on EdgeOrigin priority is stamped 4,
42
45
  so older readers (which used temporal-only parent selection) refuse it with an
43
46
  explicit "unsupported artifact schema_version 4" rather than failing canonical
@@ -47,6 +50,9 @@ def _required_version(nt: NormalizedTrace) -> int:
47
50
  """
48
51
  from tracegraph.normalize import requires_origin_priority_projection
49
52
 
53
+ interrupt = StepSource.INTERRUPT
54
+ if any(step.source is interrupt for step in nt.steps):
55
+ return 5
50
56
  if requires_origin_priority_projection(nt):
51
57
  return 4
52
58
  task = StepSource.TASK
@@ -54,10 +54,15 @@ class StepKind(str, Enum):
54
54
  class StepSource(str, Enum):
55
55
  """How the step entered the trace (mirrors LangGraph ``metadata.source``).
56
56
 
57
- ``TASK`` is the one value that does *not* come from ``metadata.source``: it marks a step
57
+ ``TASK`` and ``INTERRUPT`` do *not* come from ``metadata.source``. ``TASK`` marks a step
58
58
  derived from a checkpoint's **pending writes** (one LangGraph task) rather than from a
59
59
  checkpoint itself. It is explicit provenance, not a naming convention — ``step_id`` is an
60
60
  unrestricted string, so nothing about an id may be read as evidence of how a step was made.
61
+
62
+ ``INTERRUPT`` is likewise derived from pending writes: a LangGraph task that called ``interrupt()`` and
63
+ for which the recorded history holds no resume and no result. It states what the
64
+ checkpoints record, not that LangGraph would resume the task today — a thread re-run under
65
+ a revised graph can finish without writing a checkpoint, leaving the record unchanged.
61
66
  """
62
67
 
63
68
  INPUT = "input"
@@ -65,6 +70,7 @@ class StepSource(str, Enum):
65
70
  UPDATE = "update"
66
71
  FORK = "fork"
67
72
  TASK = "task"
73
+ INTERRUPT = "interrupt"
68
74
 
69
75
 
70
76
  class StepStatus(str, Enum):