loopview 0.1.0__py3-none-any.whl

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 (51) hide show
  1. loopview/__init__.py +3 -0
  2. loopview/app.py +252 -0
  3. loopview/cli.py +128 -0
  4. loopview/cost/__init__.py +1 -0
  5. loopview/cost/pricing.json +62 -0
  6. loopview/cost/pricing.py +74 -0
  7. loopview/cost/split.py +228 -0
  8. loopview/demo_data/flagship.otlp.jsonl +13 -0
  9. loopview/devtools/__init__.py +1 -0
  10. loopview/devtools/dump_normalized.py +211 -0
  11. loopview/ingest/__init__.py +1 -0
  12. loopview/ingest/otlp.py +305 -0
  13. loopview/ingest/raw.py +47 -0
  14. loopview/live.py +154 -0
  15. loopview/normalize/__init__.py +1 -0
  16. loopview/normalize/adapters/__init__.py +14 -0
  17. loopview/normalize/adapters/base.py +87 -0
  18. loopview/normalize/adapters/gen_ai.py +252 -0
  19. loopview/normalize/adapters/generic.py +22 -0
  20. loopview/normalize/adapters/openinference.py +367 -0
  21. loopview/normalize/derived_tools.py +90 -0
  22. loopview/normalize/loop_nodes.py +193 -0
  23. loopview/normalize/normalizer.py +326 -0
  24. loopview/normalize/schema.py +161 -0
  25. loopview/normalize/transitions.py +106 -0
  26. loopview/py.typed +0 -0
  27. loopview/static/assets/elk-worker.min-DfmSo98M.js +22 -0
  28. loopview/static/assets/index-CEGppBgk.css +1 -0
  29. loopview/static/assets/index-DzC96LiQ.js +21 -0
  30. loopview/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  31. loopview/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  32. loopview/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  33. loopview/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  34. loopview/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  35. loopview/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  36. loopview/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  37. loopview/static/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  38. loopview/static/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  39. loopview/static/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  40. loopview/static/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  41. loopview/static/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  42. loopview/static/index.html +13 -0
  43. loopview/store/__init__.py +1 -0
  44. loopview/store/capture.py +112 -0
  45. loopview/store/memory.py +165 -0
  46. loopview/tools/__init__.py +1 -0
  47. loopview/tools/report.py +347 -0
  48. loopview-0.1.0.dist-info/METADATA +72 -0
  49. loopview-0.1.0.dist-info/RECORD +51 -0
  50. loopview-0.1.0.dist-info/WHEEL +4 -0
  51. loopview-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,326 @@
1
+ """Turn one stored run (raw spans) into a NormalizedRun (steps + transitions).
2
+
3
+ The normalizer is pure: same spans and same `now` give the same output. It runs
4
+ again over the whole run whenever new spans arrive, which is simple and fast
5
+ enough for runs of a few hundred spans.
6
+
7
+ Passes:
8
+ 1. classify every span with its convention's adapter;
9
+ 2. infer running steps: a span whose parent hasn't arrived means the parent is
10
+ still running (exporters send a span only when it ends), so we add an
11
+ "inferred" step for it, named from what the child tells us;
12
+ 3. decide what is hidden (plumbing, duplicate subgraph spans, HTTP spans inside
13
+ model or tool calls);
14
+ 4. link each step to its nearest visible parent and to its scope (the flow step
15
+ it belongs to), promote nodes that contain other nodes to groups, build keys;
16
+ 5. rebuild tool calls from the conversation when no span records them
17
+ (derived_tools.py), and give flat agent loops `model` and `tools` nodes, as
18
+ LangGraph records them (loop_nodes.py);
19
+ 6. derive transitions.
20
+ """
21
+
22
+ import time
23
+ from dataclasses import dataclass
24
+
25
+ from loopview.cost.pricing import Pricing, default_pricing
26
+ from loopview.cost.split import call_cost
27
+ from loopview.ingest.raw import RawSpan
28
+ from loopview.normalize.adapters import adapter_for
29
+ from loopview.normalize.adapters.base import Classification, ParentHint, error_message
30
+ from loopview.normalize.derived_tools import derive_tool_calls
31
+ from loopview.normalize.loop_nodes import add_loop_nodes
32
+ from loopview.normalize.schema import (
33
+ FLOW_KINDS,
34
+ NormalizedRun,
35
+ RunInfo,
36
+ Step,
37
+ StepStatus,
38
+ )
39
+ from loopview.normalize.transitions import derive_transitions
40
+ from loopview.store.memory import Run
41
+
42
+ # A run that has received nothing for this long is treated as finished even if
43
+ # its root span never arrived (for example, the process was killed).
44
+ STALE_AFTER_NS = 30 * 1_000_000_000
45
+
46
+
47
+ @dataclass
48
+ class _Draft:
49
+ id: str
50
+ raw_parent: str | None
51
+ c: Classification
52
+ span: RawSpan | None # None for inferred (still running) steps
53
+ convention: str
54
+ start_ns: int
55
+ end_ns: int | None
56
+ hidden: bool = False
57
+ started_only: bool = False # reported started by loopview-sdk, not ended yet
58
+
59
+
60
+ def normalize_run(
61
+ run: Run, now_ns: int | None = None, pricing: Pricing | None = None
62
+ ) -> NormalizedRun:
63
+ now_ns = time.time_ns() if now_ns is None else now_ns
64
+ stale = now_ns - run.last_received_ns > STALE_AFTER_NS
65
+ drafts = _classify(run)
66
+ _add_costs(drafts, pricing or default_pricing())
67
+ _add_inferred_parents(run, drafts, stale)
68
+ _place_unclassified_starts(drafts)
69
+ _decide_hidden(drafts)
70
+ steps = derive_tool_calls(_link(run.trace_id, drafts, stale))
71
+ steps = add_loop_nodes(steps, root_name=_service_name(run))
72
+ transitions = derive_transitions(steps)
73
+ return NormalizedRun(run=_run_info(run, steps, stale), steps=steps, transitions=transitions)
74
+
75
+
76
+ # --- pass 1 --------------------------------------------------------------------------
77
+
78
+
79
+ def _classify(run: Run) -> dict[str, _Draft]:
80
+ drafts = {}
81
+ for span in run.spans.values():
82
+ adapter = adapter_for(span)
83
+ drafts[span.span_id] = _Draft(
84
+ id=span.span_id,
85
+ raw_parent=span.parent_span_id,
86
+ c=adapter.classify(span),
87
+ span=span,
88
+ convention=adapter.name,
89
+ start_ns=span.start_time_unix_nano,
90
+ end_ns=span.end_time_unix_nano,
91
+ )
92
+ # Start reports (loopview-sdk): real steps, with real names, still running.
93
+ for span in run.started.values():
94
+ adapter = adapter_for(span)
95
+ drafts[span.span_id] = _Draft(
96
+ id=span.span_id,
97
+ raw_parent=span.parent_span_id,
98
+ c=adapter.classify(span),
99
+ span=span,
100
+ convention=adapter.name,
101
+ start_ns=span.start_time_unix_nano,
102
+ end_ns=None,
103
+ started_only=True,
104
+ )
105
+ return drafts
106
+
107
+
108
+ def _add_costs(drafts: dict[str, _Draft], pricing: Pricing) -> None:
109
+ """Each finished model call gets its cost split. A call still running has no
110
+ usage yet, so it gets none."""
111
+ for d in drafts.values():
112
+ if d.c.model is not None and not d.started_only:
113
+ d.c.model.cost = call_cost(d.c.model, pricing)
114
+
115
+
116
+ # --- pass 2 --------------------------------------------------------------------------
117
+
118
+
119
+ def _add_inferred_parents(run: Run, drafts: dict[str, _Draft], stale: bool) -> None:
120
+ known = run.spans.keys() | run.started.keys()
121
+ missing: dict[str, list[RawSpan]] = {}
122
+ for span in [*run.spans.values(), *run.started.values()]:
123
+ if span.parent_span_id and span.parent_span_id not in known:
124
+ missing.setdefault(span.parent_span_id, []).append(span)
125
+
126
+ for parent_id, children in missing.items():
127
+ hint: ParentHint | None = None
128
+ convention = "generic"
129
+ for child in children:
130
+ adapter = adapter_for(child)
131
+ hint = adapter.infer_parent(child)
132
+ if hint:
133
+ convention = adapter.name
134
+ break
135
+ if hint:
136
+ c = Classification(hint.kind, hint.name, hint.type_label)
137
+ else:
138
+ # Nothing names it yet: use the service name, which is usually the
139
+ # application the whole run belongs to.
140
+ service = children[0].resource_attributes.get("service.name")
141
+ c = Classification("node", str(service or "running"), "running")
142
+ drafts[parent_id] = _Draft(
143
+ id=parent_id,
144
+ raw_parent=None, # unknown until its span arrives
145
+ c=c,
146
+ span=None,
147
+ convention=convention,
148
+ start_ns=min(child.start_time_unix_nano for child in children),
149
+ # A stale run won't send this span any more: close it at its last child.
150
+ end_ns=max(c.end_time_unix_nano or c.start_time_unix_nano for c in children)
151
+ if stale
152
+ else None,
153
+ )
154
+
155
+ # A trace has exactly one root. Until it arrives, several inferred steps can
156
+ # have no known parent; they all descend from the root, so hang them under
157
+ # the one that started first, which is the closest guess for the root.
158
+ if run.root is None:
159
+ orphans = sorted(
160
+ (d for d in drafts.values() if d.raw_parent is None),
161
+ key=lambda d: (d.start_ns, d.id),
162
+ )
163
+ for orphan in orphans[1:]:
164
+ orphan.raw_parent = orphans[0].id
165
+
166
+
167
+ def _place_unclassified_starts(drafts: dict[str, _Draft]) -> None:
168
+ """Start reports (loopview-sdk) of spans no adapter recognises yet.
169
+
170
+ Some instrumentations (OpenInference for LangChain) set every attribute when
171
+ the span ends, so at start a span is just a name. Position is the only clue:
172
+ steps of a flow are direct children of a container (the run's root, or an
173
+ agent), while plumbing (model wrappers, parsers) sits inside a step. So an
174
+ unclassified start is shown as a running node only under a container, and is
175
+ kept hidden otherwise until it ends and its adapter can classify it.
176
+ """
177
+ for d in drafts.values():
178
+ if not d.started_only or d.c.kind != "unknown":
179
+ continue
180
+ parent = drafts.get(d.raw_parent) if d.raw_parent else None
181
+ is_root = d.raw_parent is None
182
+ under_container = parent is not None and (
183
+ parent.raw_parent is None or parent.c.kind == "agent"
184
+ )
185
+ if is_root or under_container:
186
+ d.c.kind = "node"
187
+ d.c.type_label = "running"
188
+ d.c.attributes = {}
189
+ else:
190
+ d.c.hidden = True
191
+
192
+
193
+ # --- pass 3 --------------------------------------------------------------------------
194
+
195
+
196
+ def _decide_hidden(drafts: dict[str, _Draft]) -> None:
197
+ for d in drafts.values():
198
+ parent = drafts.get(d.raw_parent) if d.raw_parent else None
199
+ if d.c.hidden:
200
+ d.hidden = True
201
+ elif d.c.collapse_into_parent and parent is not None and parent.c.name == d.c.name:
202
+ d.hidden = True # a subgraph span directly inside the node that runs it
203
+ elif d.c.kind == "unknown" and _inside_call(d, drafts):
204
+ d.hidden = True # e.g. the HTTP request made by a model call
205
+
206
+
207
+ def _inside_call(d: _Draft, drafts: dict[str, _Draft]) -> bool:
208
+ parent_id = d.raw_parent
209
+ while parent_id and parent_id in drafts:
210
+ parent = drafts[parent_id]
211
+ if parent.c.kind in ("model_call", "tool_call"):
212
+ return True
213
+ if parent.c.kind != "unknown":
214
+ return False
215
+ parent_id = parent.raw_parent
216
+ return False
217
+
218
+
219
+ # --- pass 4 --------------------------------------------------------------------------
220
+
221
+
222
+ def _link(run_id: str, drafts: dict[str, _Draft], stale: bool) -> list[Step]:
223
+ def visible_ancestor(d: _Draft, flow_only: bool) -> str | None:
224
+ parent_id = d.raw_parent
225
+ while parent_id and parent_id in drafts:
226
+ parent = drafts[parent_id]
227
+ if not parent.hidden and (not flow_only or parent.c.kind in FLOW_KINDS):
228
+ return parent_id
229
+ parent_id = parent.raw_parent
230
+ return None
231
+
232
+ parent_of = {d.id: visible_ancestor(d, flow_only=False) for d in drafts.values()}
233
+ scope_of = {d.id: visible_ancestor(d, flow_only=True) for d in drafts.values()}
234
+
235
+ # A node that contains other flow steps is a group: a subgraph or nested agent.
236
+ owners = {
237
+ scope_of[d.id]
238
+ for d in drafts.values()
239
+ if not d.hidden and d.c.kind in FLOW_KINDS and scope_of[d.id]
240
+ }
241
+ for owner_id in owners:
242
+ owner = drafts[owner_id]
243
+ if owner.c.kind == "node":
244
+ owner.c.kind = "agent"
245
+ owner.c.type_label = "agent"
246
+
247
+ keys: dict[str, str] = {}
248
+
249
+ def key_of(step_id: str) -> str:
250
+ if step_id not in keys:
251
+ d = drafts[step_id]
252
+ name = d.c.name if d.c.kind in FLOW_KINDS else f"{d.c.kind}:{d.c.name}"
253
+ scope = scope_of[step_id]
254
+ keys[step_id] = f"{key_of(scope)}/{name}" if scope else name
255
+ return keys[step_id]
256
+
257
+ steps = []
258
+ for d in drafts.values():
259
+ status: StepStatus
260
+ if d.span is None or d.started_only:
261
+ status = "ok" if stale else "running"
262
+ else:
263
+ status = "error" if d.span.status_code == "error" else "ok"
264
+ steps.append(
265
+ Step(
266
+ id=d.id,
267
+ run_id=run_id,
268
+ parent_id=parent_of[d.id],
269
+ scope_id=scope_of[d.id],
270
+ kind=d.c.kind,
271
+ type_label=d.c.type_label,
272
+ name=d.c.name,
273
+ key=key_of(d.id),
274
+ status=status,
275
+ inferred=d.span is None,
276
+ hidden=d.hidden,
277
+ start_ns=d.start_ns,
278
+ end_ns=d.end_ns,
279
+ error=error_message(d.span) if d.span is not None and not d.started_only else None,
280
+ model=d.c.model,
281
+ tool=d.c.tool,
282
+ input=d.c.input,
283
+ output=d.c.output,
284
+ convention=d.convention,
285
+ attributes=d.c.attributes,
286
+ )
287
+ )
288
+ # Deterministic order: by start time, then span id (timestamps can tie).
289
+ steps.sort(key=lambda s: (s.start_ns, s.id))
290
+ return steps
291
+
292
+
293
+ def _service_name(run: Run) -> str:
294
+ """What to call a run's synthetic agent: the service that sent it."""
295
+ for span in run.spans.values():
296
+ name = span.resource_attributes.get("service.name")
297
+ if name and not str(name).startswith("unknown_service"):
298
+ return str(name)
299
+ return "agent"
300
+
301
+
302
+ # --- run info ------------------------------------------------------------------------
303
+
304
+
305
+ def _run_info(run: Run, steps: list[Step], stale: bool) -> RunInfo:
306
+ root = run.root
307
+ running = root is None and not stale
308
+ if root is not None and root.status_code == "error":
309
+ status: StepStatus = "error"
310
+ else:
311
+ status = "running" if running else "ok"
312
+ known = [*run.spans.values(), *run.started.values()]
313
+ named_after = root or min(known, key=lambda s: s.start_time_unix_nano)
314
+ # The top step: the real root, or while it's missing the provisional one.
315
+ top_step = next((s for s in steps if s.parent_id is None), None)
316
+ return RunInfo(
317
+ id=run.trace_id,
318
+ name=top_step.name if top_step else named_after.name,
319
+ service_name=named_after.resource_attributes.get("service.name"),
320
+ session_id=run.session_id,
321
+ status=status,
322
+ start_ns=min(s.start_ns for s in steps),
323
+ end_ns=None if running else max(s.end_ns or s.start_ns for s in steps),
324
+ step_count=len(steps),
325
+ last_received_ns=run.last_received_ns,
326
+ )
@@ -0,0 +1,161 @@
1
+ """The normalized schema: the only shape the UI ever sees.
2
+
3
+ Every span, whatever convention it follows, becomes one Step. Transitions
4
+ between steps are derived afterwards (transitions.py). A NormalizedRun is
5
+ everything the UI needs to draw one run: its steps and its transitions.
6
+
7
+ Kinds of steps:
8
+ - agent: a named actor that contains other steps (an agent, a workflow, a
9
+ graph or subgraph). Drawn as a group.
10
+ - node: a named step in a flow (a graph node, a chain step). Drawn as a card.
11
+ - model_call: one call to a model. Drawn as a detail of its node, not as a node.
12
+ - tool_call: one tool execution. Drawn as a small satellite of its node.
13
+ - unknown: a span no adapter recognised. Drawn generically, never dropped.
14
+ """
15
+
16
+ from typing import Any, Literal
17
+
18
+ from pydantic import BaseModel
19
+
20
+ StepKind = Literal["agent", "node", "model_call", "tool_call", "unknown"]
21
+ StepStatus = Literal["running", "ok", "error"]
22
+ TransitionKind = Literal["sequence", "loop", "fan_out", "fan_in", "handoff", "delegate", "return"]
23
+
24
+ # Steps that take part in control flow (graph nodes). Model and tool calls hang off them.
25
+ FLOW_KINDS: frozenset[str] = frozenset({"agent", "node", "unknown"})
26
+
27
+
28
+ class MessagePart(BaseModel):
29
+ type: Literal["text", "reasoning", "tool_call", "tool_result", "other"]
30
+ text: str | None = None # text and reasoning (thinking) parts, a readable form of "other"
31
+ id: str | None = None # tool call id, for tool_call and tool_result
32
+ name: str | None = None # tool name, for tool_call
33
+ arguments: Any = None # tool_call
34
+ result: Any = None # tool_result
35
+ # tool_result: the result was flagged as an error by the caller (Anthropic's
36
+ # is_error). None when the trace doesn't say, which is not the same as False.
37
+ is_error: bool | None = None
38
+
39
+
40
+ class Message(BaseModel):
41
+ role: str # system | user | assistant | tool (kept as given if something else)
42
+ parts: list[MessagePart]
43
+
44
+
45
+ class Usage(BaseModel):
46
+ """Token counts as the provider reported them. None means "not reported",
47
+ which is different from 0. Both specs define the input count as including
48
+ cache reads and writes, and the output count as including thinking."""
49
+
50
+ input_tokens: int | None = None
51
+ output_tokens: int | None = None
52
+ cache_read_tokens: int | None = None # part of input_tokens
53
+ cache_write_tokens: int | None = None # part of input_tokens
54
+ reasoning_tokens: int | None = None # part of output_tokens
55
+
56
+
57
+ CostSegmentName = Literal[
58
+ "tool_prompt",
59
+ "tool_definitions",
60
+ "system",
61
+ "history",
62
+ "tool_results",
63
+ "new_input",
64
+ "cache_read",
65
+ "cache_write",
66
+ "thinking",
67
+ "reply",
68
+ "unattributed",
69
+ ]
70
+
71
+
72
+ class CostSegment(BaseModel):
73
+ name: CostSegmentName
74
+ side: Literal["input", "output"]
75
+ tokens: int
76
+ dollars: float | None # None when the model has no price
77
+
78
+
79
+ class CallCost(BaseModel):
80
+ """Where a model call's tokens came from (see loopview/cost/split.py)."""
81
+
82
+ # estimated: split from recorded content, scaled to the reported counts
83
+ # content_not_recorded: reported counts, but nothing to split them with
84
+ # no_usage: the instrumentation reported no token counts at all
85
+ status: Literal["estimated", "content_not_recorded", "no_usage"]
86
+ segments: list[CostSegment] = []
87
+ tokens: int | None = None # reported input + output
88
+ dollars: float | None = None # None when the model has no price
89
+ price_key: str | None = None # the pricing entry used
90
+ price_source: str | None = None
91
+
92
+
93
+ class ModelCall(BaseModel):
94
+ provider: str | None = None
95
+ model: str | None = None
96
+ input: list[Message] = []
97
+ output: list[Message] = []
98
+ tool_definitions: list[Any] = [] # the tools the model was given, as recorded
99
+ usage: Usage | None = None # None when the instrumentation reported no counts
100
+ cost: CallCost | None = None # set by the normalizer
101
+
102
+
103
+ class ToolCall(BaseModel):
104
+ name: str
105
+ call_id: str | None = None
106
+ arguments: Any = None
107
+ result: Any = None
108
+
109
+
110
+ class Step(BaseModel):
111
+ id: str # the span id
112
+ run_id: str # the trace id
113
+ parent_id: str | None # nearest ancestor step that is not hidden
114
+ scope_id: str | None # nearest ancestor flow step (agent/node) that is not hidden
115
+ kind: StepKind
116
+ type_label: str # small label for the UI: "agent", "graph", "node", "llm", "tool"...
117
+ name: str
118
+ key: str # graph identity: scope path + name. Repeated runs of a node share a key.
119
+ status: StepStatus
120
+ # True when we only know this step exists because its children arrived:
121
+ # exporters send a span when it ends, so a running step has no span yet.
122
+ inferred: bool = False
123
+ # True for steps no span stands for: the `model` and `tools` nodes added to a
124
+ # flat agent loop (normalize/loop_nodes.py).
125
+ synthetic: bool = False
126
+ hidden: bool = False # internal plumbing (routing functions, parsers); kept, not drawn
127
+ start_ns: int
128
+ end_ns: int | None # None while running
129
+ error: str | None = None
130
+ model: ModelCall | None = None
131
+ tool: ToolCall | None = None
132
+ input: Any = None # generic input/output when the convention provides one
133
+ output: Any = None
134
+ convention: str # which adapter produced this step: gen_ai | openinference | generic
135
+ attributes: dict[str, Any] = {} # raw attributes, only for unknown steps
136
+
137
+
138
+ class Transition(BaseModel):
139
+ id: str
140
+ source: str # step id
141
+ target: str # step id
142
+ kind: TransitionKind
143
+ inferred: bool = True # derived from timing and nesting, not stated by the framework
144
+
145
+
146
+ class RunInfo(BaseModel):
147
+ id: str
148
+ name: str
149
+ service_name: str | None
150
+ session_id: str | None
151
+ status: StepStatus
152
+ start_ns: int
153
+ end_ns: int | None
154
+ step_count: int
155
+ last_received_ns: int
156
+
157
+
158
+ class NormalizedRun(BaseModel):
159
+ run: RunInfo
160
+ steps: list[Step]
161
+ transitions: list[Transition]
@@ -0,0 +1,106 @@
1
+ """Derive transitions (control moving between steps) from timing and nesting.
2
+
3
+ No convention we support states "control went from A to B", so we derive it.
4
+ Only flow steps (agents, nodes, unknown) take part; model and tool calls are
5
+ details of the step that made them.
6
+
7
+ Within one scope (the steps directly inside the same agent, graph or node):
8
+
9
+ A precedes B when A ended before B started.
10
+ A -> B when A precedes B and no step C sits between them
11
+ (A precedes C and C precedes B).
12
+
13
+ That one rule covers every architecture:
14
+ - sequence: A ends, B starts -> A -> B
15
+ - parallel: B1, B2, B3 overlap in time -> none precedes another, so
16
+ A -> B1, A -> B2, A -> B3 (fan out) and B1, B2, B3 -> C (fan in)
17
+ - loops: A runs again later -> an edge back to A's graph node
18
+ - handoffs: agent X ends, agent Y starts -> X -> Y
19
+
20
+ Across scopes: the first steps in a scope get a `delegate` edge from the scope's
21
+ owner (a supervisor calling a worker, a graph entering its first node), and the
22
+ last steps a `return` edge back once the owner has finished.
23
+
24
+ A running step (no end yet) precedes nothing, so edges out of it appear once it ends.
25
+ """
26
+
27
+ from loopview.normalize.schema import FLOW_KINDS, Step, Transition, TransitionKind
28
+
29
+
30
+ def derive_transitions(steps: list[Step]) -> list[Transition]:
31
+ by_id = {s.id: s for s in steps}
32
+ scopes: dict[str | None, list[Step]] = {}
33
+ for step in steps:
34
+ if not step.hidden and step.kind in FLOW_KINDS:
35
+ scopes.setdefault(step.scope_id, []).append(step)
36
+
37
+ transitions: list[Transition] = []
38
+ for scope_id, members in scopes.items():
39
+ members.sort(key=lambda s: (s.start_ns, s.id))
40
+ edges = _immediate_predecessors(members)
41
+ transitions += _label(edges, members)
42
+
43
+ owner = by_id.get(scope_id) if scope_id else None
44
+ if owner is None:
45
+ continue
46
+ has_incoming = {target for _, target in edges}
47
+ has_outgoing = {source for source, _ in edges}
48
+ for step in members:
49
+ if step.id not in has_incoming:
50
+ transitions.append(_transition(owner, step, "delegate"))
51
+ if owner.end_ns is not None and step.end_ns is not None and step.id not in has_outgoing:
52
+ transitions.append(_transition(step, owner, "return"))
53
+ return transitions
54
+
55
+
56
+ def _precedes(a: Step, b: Step) -> bool:
57
+ return a.end_ns is not None and a.end_ns <= b.start_ns and a.id != b.id
58
+
59
+
60
+ def _immediate_predecessors(members: list[Step]) -> list[tuple[str, str]]:
61
+ """Edges of the "happens before" order with the implied (transitive) ones removed.
62
+
63
+ O(n^2) per step in the worst case; scopes hold at most a few hundred steps.
64
+ """
65
+ edges = []
66
+ for b in members:
67
+ before = [a for a in members if _precedes(a, b)]
68
+ for a in before:
69
+ if not any(_precedes(a, c) for c in before):
70
+ edges.append((a.id, b.id))
71
+ return edges
72
+
73
+
74
+ def _label(edges: list[tuple[str, str]], members: list[Step]) -> list[Transition]:
75
+ by_id = {s.id: s for s in members}
76
+ # A graph node's position: when its key first appeared in this scope. An edge
77
+ # to a node that appeared no later than the source is a loop (a back edge).
78
+ first_seen: dict[str, int] = {}
79
+ for index, step in enumerate(members):
80
+ first_seen.setdefault(step.key, index)
81
+ out_degree: dict[str, int] = {}
82
+ in_degree: dict[str, int] = {}
83
+ for source, target in edges:
84
+ out_degree[source] = out_degree.get(source, 0) + 1
85
+ in_degree[target] = in_degree.get(target, 0) + 1
86
+
87
+ result = []
88
+ for source_id, target_id in edges:
89
+ source, target = by_id[source_id], by_id[target_id]
90
+ kind: TransitionKind
91
+ if first_seen[target.key] <= first_seen[source.key]:
92
+ kind = "loop"
93
+ elif out_degree[source_id] > 1:
94
+ kind = "fan_out"
95
+ elif in_degree[target_id] > 1:
96
+ kind = "fan_in"
97
+ elif target.kind == "agent" and not target.synthetic:
98
+ kind = "handoff" # a synthetic tools group is a step of the loop, not an agent
99
+ else:
100
+ kind = "sequence"
101
+ result.append(_transition(source, target, kind))
102
+ return result
103
+
104
+
105
+ def _transition(source: Step, target: Step, kind: TransitionKind) -> Transition:
106
+ return Transition(id=f"{source.id}>{target.id}", source=source.id, target=target.id, kind=kind)
loopview/py.typed ADDED
File without changes