agentloop-profiler 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 (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
agentloop/savings.py ADDED
@@ -0,0 +1,158 @@
1
+ """Compatible-selection model for combining per-recommendation savings.
2
+
3
+ Optimization cards (and the findings derived from them) that touch the same
4
+ spans compete for the same work: applying one changes or removes the spans the
5
+ other targeted, so their estimates are alternatives, not additive. A realizable
6
+ plan therefore applies a pairwise span-disjoint subset of the recommendations.
7
+
8
+ ``select_compatible`` picks that subset: latency savings is the primary
9
+ objective and cost savings breaks ties, so both reported totals always come
10
+ from the same subset. Small overlap-connected components are solved exactly.
11
+ Larger components use a deterministic greedy fallback and the returned
12
+ ``Selection`` says explicitly that the result is not proven optimal.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Iterator, Sequence
19
+
20
+ # Exact maximum-weight independent-set search is exponential in the size of one
21
+ # overlap-connected component. Twenty-four keeps the common path exact while
22
+ # retaining a deterministic bounded fallback for unusually dense plans.
23
+ _EXACT_SEARCH_LIMIT = 24
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SavingsItem:
28
+ spans: frozenset[str]
29
+ latency_ms: float = 0.0
30
+ cost_usd: float = 0.0
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Selection:
35
+ indices: tuple[int, ...]
36
+ latency_ms: float
37
+ cost_usd: float
38
+ optimal: bool = True
39
+ algorithm: str = "exact_branch_and_bound"
40
+ exact_component_limit: int = _EXACT_SEARCH_LIMIT
41
+
42
+
43
+ def select_compatible(items: Sequence[SavingsItem]) -> Selection:
44
+ """Return a deterministic compatible subset and its optimality metadata.
45
+
46
+ Latency savings is the primary objective and cost savings breaks ties.
47
+ Components up to ``_EXACT_SEARCH_LIMIT`` are solved exactly. Larger
48
+ components use a deterministic greedy fallback; in that case ``optimal`` is
49
+ false so callers cannot accidentally present an approximation as a maximum.
50
+ Span-free items are always selected.
51
+ """
52
+ chosen = [index for index, item in enumerate(items) if not item.spans]
53
+ spanned = [index for index, item in enumerate(items) if item.spans]
54
+ neighbor_sets: dict[int, set[int]] = {index: set() for index in spanned}
55
+ for position, left in enumerate(spanned):
56
+ for right in spanned[position + 1 :]:
57
+ if items[left].spans & items[right].spans:
58
+ neighbor_sets[left].add(right)
59
+ neighbor_sets[right].add(left)
60
+ adjacency = {index: frozenset(neighbors) for index, neighbors in neighbor_sets.items()}
61
+ used_approximation = False
62
+ for component in _components(spanned, adjacency):
63
+ if len(component) > _EXACT_SEARCH_LIMIT:
64
+ chosen.extend(_greedy_pick(items, component))
65
+ used_approximation = True
66
+ else:
67
+ chosen.extend(_exact_pick(items, component, adjacency))
68
+ chosen.sort()
69
+ return Selection(
70
+ indices=tuple(chosen),
71
+ latency_ms=sum(items[index].latency_ms for index in chosen),
72
+ cost_usd=sum(items[index].cost_usd for index in chosen),
73
+ optimal=not used_approximation,
74
+ algorithm=("hybrid_exact_greedy" if used_approximation else "exact_branch_and_bound"),
75
+ exact_component_limit=_EXACT_SEARCH_LIMIT,
76
+ )
77
+
78
+
79
+ def _components(nodes: list[int], adjacency: dict[int, frozenset[int]]) -> Iterator[list[int]]:
80
+ seen: set[int] = set()
81
+ for start in nodes:
82
+ if start in seen:
83
+ continue
84
+ seen.add(start)
85
+ stack = [start]
86
+ component: list[int] = []
87
+ while stack:
88
+ node = stack.pop()
89
+ component.append(node)
90
+ for neighbor in sorted(adjacency[node], reverse=True):
91
+ if neighbor not in seen:
92
+ seen.add(neighbor)
93
+ stack.append(neighbor)
94
+ yield component
95
+
96
+
97
+ def _savings_key(items: Sequence[SavingsItem], index: int) -> tuple[float, float]:
98
+ return (items[index].latency_ms, items[index].cost_usd)
99
+
100
+
101
+ def _exact_pick(
102
+ items: Sequence[SavingsItem], component: list[int], adjacency: dict[int, frozenset[int]]
103
+ ) -> list[int]:
104
+ order = sorted(
105
+ component,
106
+ key=lambda index: (-items[index].latency_ms, -items[index].cost_usd, index),
107
+ )
108
+ suffix_latency = [0.0] * (len(order) + 1)
109
+ for position in range(len(order) - 1, -1, -1):
110
+ suffix_latency[position] = suffix_latency[position + 1] + items[order[position]].latency_ms
111
+ best_key = (float("-inf"), float("-inf"))
112
+ best_tuple: tuple[int, ...] | None = None
113
+
114
+ def search(
115
+ position: int, blocked: frozenset[int], latency: float, cost: float, picked: list[int]
116
+ ) -> None:
117
+ nonlocal best_key, best_tuple
118
+ if latency + suffix_latency[position] < best_key[0]:
119
+ return
120
+ if position == len(order):
121
+ score = (latency, cost)
122
+ candidate = tuple(sorted(picked))
123
+ if score > best_key or (
124
+ score == best_key and (best_tuple is None or candidate < best_tuple)
125
+ ):
126
+ best_key = score
127
+ best_tuple = candidate
128
+ return
129
+ index = order[position]
130
+ if index not in blocked:
131
+ picked.append(index)
132
+ search(
133
+ position + 1,
134
+ blocked | adjacency[index],
135
+ latency + items[index].latency_ms,
136
+ cost + items[index].cost_usd,
137
+ picked,
138
+ )
139
+ picked.pop()
140
+ search(position + 1, blocked, latency, cost, picked)
141
+
142
+ search(0, frozenset(), 0.0, 0.0, [])
143
+ return list(best_tuple or ())
144
+
145
+
146
+ def _greedy_pick(items: Sequence[SavingsItem], component: list[int]) -> list[int]:
147
+ picked: list[int] = []
148
+ used_spans: set[str] = set()
149
+ order = sorted(
150
+ component,
151
+ key=lambda index: (-items[index].latency_ms, -items[index].cost_usd, index),
152
+ )
153
+ for index in order:
154
+ if items[index].spans & used_spans:
155
+ continue
156
+ picked.append(index)
157
+ used_spans |= items[index].spans
158
+ return picked
@@ -0,0 +1,99 @@
1
+ """Stable DAG planning using current declarations, never historical success."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+
7
+ from agentloop.context_types import fingerprint
8
+ from agentloop.scheduling_types import ScheduleConfig, SchedulingError, ToolCall
9
+
10
+
11
+ def plan_tools(calls, config, *, enforce=True):
12
+ if (
13
+ type(config) is not ScheduleConfig
14
+ or not isinstance(calls, (list, tuple))
15
+ or not 1 <= len(calls) <= 256
16
+ or any(type(call) is not ToolCall for call in calls)
17
+ ):
18
+ raise SchedulingError("invalid_plan")
19
+ ordered = tuple(call.call_id for call in calls)
20
+ by_id = {call.call_id: call for call in calls}
21
+ if len(by_id) != len(calls):
22
+ raise SchedulingError("duplicate_call")
23
+ rank = {identity: index for index, identity in enumerate(ordered)}
24
+ dependencies = {call.call_id: set(call.depends_on or ()) for call in calls}
25
+ if sum(map(len, dependencies.values())) > 4096:
26
+ raise SchedulingError("invalid_plan")
27
+ if any(not values <= by_id.keys() for values in dependencies.values()):
28
+ raise SchedulingError("missing_dependency")
29
+ successors, indegrees = {identity: set() for identity in ordered}, {}
30
+ for identity, values in dependencies.items():
31
+ indegrees[identity] = len(values)
32
+ for source in values:
33
+ successors[source].add(identity)
34
+ ready = [rank[identity] for identity in ordered if indegrees[identity] == 0]
35
+ heapq.heapify(ready)
36
+ topological = []
37
+ while ready:
38
+ identity = ordered[heapq.heappop(ready)]
39
+ topological.append(identity)
40
+ for target in successors[identity]:
41
+ indegrees[target] -= 1
42
+ if indegrees[target] == 0:
43
+ heapq.heappush(ready, rank[target])
44
+ if len(topological) != len(calls):
45
+ raise SchedulingError("cycle")
46
+ unknown = [
47
+ call.call_id
48
+ for call in calls
49
+ if call.depends_on is None
50
+ or call.reads is None
51
+ or call.writes is None
52
+ or call.concurrent is None
53
+ or call.effect == "unknown"
54
+ ]
55
+ unsupported = [call.call_id for call in calls if call.concurrent is False]
56
+ if unknown and config.on_unknown == "error" and enforce:
57
+ raise SchedulingError("unknown_safety")
58
+ if any(call.depends_on is None for call in calls):
59
+ # Unknown dependency semantics permit only the caller's original order.
60
+ if any(
61
+ rank[source] >= rank[target]
62
+ for target, sources in dependencies.items()
63
+ for source in sources
64
+ ):
65
+ raise SchedulingError("unknown_order")
66
+ topological = list(ordered)
67
+ effective = {identity: set(values) for identity, values in dependencies.items()}
68
+ conflicts = []
69
+ if unknown or unsupported:
70
+ for previous, following in zip(topological, topological[1:]):
71
+ effective[following].add(previous)
72
+ else:
73
+ access = {
74
+ identity: (set(by_id[identity].reads), set(by_id[identity].writes))
75
+ for identity in ordered
76
+ }
77
+ for index, before in enumerate(topological):
78
+ reads, writes = access[before]
79
+ for after in topological[index + 1 :]:
80
+ other_reads, other_writes = access[after]
81
+ shared = (writes & (other_reads | other_writes)) | (other_writes & reads)
82
+ if shared:
83
+ effective[after].add(before)
84
+ conflicts.append(
85
+ {"before": before, "after": after, "resources": sorted(shared)}
86
+ )
87
+ declaration = {
88
+ "calls": [call.declaration() for call in calls],
89
+ "configuration": config.to_dict(),
90
+ "serial_order": topological,
91
+ "effective_dependencies": {
92
+ identity: sorted(effective[identity], key=rank.get) for identity in ordered
93
+ },
94
+ "unknown_safety": unknown,
95
+ "unsupported_concurrency": unsupported,
96
+ "resource_conflicts": conflicts,
97
+ "proposed_concurrency": 1 if unknown or unsupported else config.max_concurrency,
98
+ }
99
+ return {**declaration, "plan_hash": fingerprint(declaration)}
@@ -0,0 +1,169 @@
1
+ """Explicit tool dependencies, resource contracts and ordered outcomes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from threading import Event
7
+ from time import monotonic
8
+ from types import MappingProxyType
9
+ from typing import Any, Callable
10
+
11
+ from agentloop.budget_types import DispatchOptions, ResourceUsage
12
+ from agentloop.context_types import fingerprint, identifier, synchronous
13
+
14
+ SCHEDULE_KEY = "agentloop.tool_schedule"
15
+ SCHEDULE_VERSION = "1.0"
16
+
17
+
18
+ class SchedulingError(ValueError):
19
+ def __init__(self, reason):
20
+ allowed = {
21
+ "invalid_plan",
22
+ "duplicate_call",
23
+ "missing_dependency",
24
+ "duplicate_dependency",
25
+ "cycle",
26
+ "unknown_safety",
27
+ "unknown_order",
28
+ "already_started",
29
+ "unsupported_lifecycle",
30
+ "evidence_limit",
31
+ "invalid_trace_evidence",
32
+ }
33
+ self.reason_code = (
34
+ reason if isinstance(reason, str) and reason in allowed else "invalid_plan"
35
+ )
36
+ super().__init__(f"Tool scheduling failed: {self.reason_code}")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ScheduleConfig:
41
+ policy_id: str
42
+ version: str
43
+ branch_id: str
44
+ max_concurrency: int = 4
45
+ on_unknown: str = "sequential"
46
+ on_error: str = "stop"
47
+
48
+ def __post_init__(self):
49
+ for name in ("policy_id", "version", "branch_id"):
50
+ identifier(getattr(self, name), name)
51
+ if type(self.max_concurrency) is not int or not 1 <= self.max_concurrency <= 32:
52
+ raise ValueError("max_concurrency must be 1..32")
53
+ if self.on_unknown not in {"sequential", "error"} or self.on_error not in {
54
+ "stop",
55
+ "continue_independent",
56
+ }:
57
+ raise ValueError("unsupported scheduling policy")
58
+
59
+ def to_dict(self):
60
+ return asdict(self)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ToolCall:
65
+ call_id: str
66
+ invoke: Callable[[ToolContext], Any] = field(repr=False, compare=False)
67
+ depends_on: tuple[str, ...] | None = None
68
+ reads: tuple[str, ...] | None = None
69
+ writes: tuple[str, ...] | None = None
70
+ concurrent: bool | None = None
71
+ effect: str = "unknown"
72
+ dispatch: DispatchOptions = field(default_factory=DispatchOptions)
73
+ usage_reader: Callable[[Any], ResourceUsage | None] | None = field(
74
+ default=None, repr=False, compare=False
75
+ )
76
+ error_usage_reader: Callable[[BaseException], ResourceUsage | None] | None = field(
77
+ default=None, repr=False, compare=False
78
+ )
79
+
80
+ def __post_init__(self):
81
+ identifier(self.call_id, "call_id")
82
+ if not synchronous(self.invoke):
83
+ raise ValueError("tool callback must be synchronous")
84
+ for name, maximum in (("depends_on", 256), ("reads", 64), ("writes", 64)):
85
+ values = getattr(self, name)
86
+ if values is None:
87
+ continue
88
+ if not isinstance(values, (list, tuple)) or len(values) > maximum:
89
+ raise ValueError("tool declarations must be bounded sequences")
90
+ for value in values:
91
+ identifier(value, name)
92
+ if len(values) != len(set(values)):
93
+ raise SchedulingError(
94
+ "duplicate_dependency" if name == "depends_on" else "invalid_plan"
95
+ )
96
+ object.__setattr__(self, name, tuple(values))
97
+ if self.concurrent is not None and type(self.concurrent) is not bool:
98
+ raise ValueError("concurrent capability must be boolean or unknown")
99
+ if self.effect not in {"read_only", "mutating", "unknown"}:
100
+ raise ValueError("unsupported effect declaration")
101
+ if self.effect == "read_only" and self.writes:
102
+ raise ValueError("read-only call cannot declare writes")
103
+ if self.effect == "mutating" and self.writes == ():
104
+ raise ValueError("mutating call requires written resource declarations")
105
+ if type(self.dispatch) is not DispatchOptions:
106
+ raise ValueError("dispatch must be DispatchOptions")
107
+ for reader in (self.usage_reader, self.error_usage_reader):
108
+ if reader is not None and not synchronous(reader):
109
+ raise ValueError("usage readers must be synchronous")
110
+
111
+ def declaration(self):
112
+ dispatch = asdict(self.dispatch)
113
+ if dispatch["step"] is not None:
114
+ dispatch["step"]["step_id"] = "sha256:" + fingerprint(dispatch["step"]["step_id"])
115
+ if dispatch["reservation"] is not None:
116
+ dispatch["reservation"]["evidence_refs"] = [
117
+ "sha256:" + fingerprint(value) for value in dispatch["reservation"]["evidence_refs"]
118
+ ]
119
+ return {
120
+ "call_id": self.call_id,
121
+ "depends_on": None if self.depends_on is None else list(self.depends_on),
122
+ "reads": None if self.reads is None else list(self.reads),
123
+ "writes": None if self.writes is None else list(self.writes),
124
+ "concurrent": self.concurrent,
125
+ "effect": self.effect,
126
+ "dispatch": dispatch,
127
+ }
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class ToolContext:
132
+ call_id: str
133
+ prerequisites: MappingProxyType = field(repr=False)
134
+ _stop: Event = field(repr=False)
135
+ _external: Event | None = field(repr=False)
136
+ deadline: float | None = None
137
+
138
+ @property
139
+ def cancellation_requested(self):
140
+ return (
141
+ self._stop.is_set()
142
+ or (self._external is not None and self._external.is_set())
143
+ or (self.deadline is not None and monotonic() >= self.deadline)
144
+ )
145
+
146
+ @property
147
+ def remaining_s(self):
148
+ return None if self.deadline is None else max(0.0, self.deadline - monotonic())
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class ToolOutcome:
153
+ call_id: str
154
+ status: str
155
+ value: Any = field(default=None, repr=False, compare=False)
156
+ error: BaseException | None = field(default=None, repr=False, compare=False)
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class ScheduledResult:
161
+ schedule_id: str
162
+ outcomes: tuple[ToolOutcome, ...]
163
+ stop_reason: str | None = None
164
+
165
+ @property
166
+ def completed(self):
167
+ return self.stop_reason is None and all(
168
+ item.status == "completed" for item in self.outcomes
169
+ )
agentloop/schema.py ADDED
@@ -0,0 +1,257 @@
1
+ """Native trace schema: versioning, validation, and compatibility policy.
2
+
3
+ This module is the single source of truth for the serialized AgentLoop trace
4
+ contract. It is imported by the core deserialization path (`AgentTrace.from_dict`
5
+ / `AgentEvent.from_dict`), which every consumer — CLI import, HTTP ingestion,
6
+ both stores, and the OTLP/Vercel adapters' round-trip tests — shares.
7
+
8
+ It has no framework dependency: validation failures raise
9
+ :class:`TraceValidationError`, and boundary code (for example the FastAPI server)
10
+ maps that to a structured 4xx response. See ``docs/TRACE_SCHEMA.md`` for the
11
+ published schema and compatibility policy.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from typing import Any
18
+
19
+ # Native trace schema version. Serialized traces carry this under
20
+ # ``schema_version``. Bump the MAJOR component only for a breaking change to the
21
+ # serialized shape; readers accept an equal-or-lower MAJOR and tolerate a missing
22
+ # value (0.4-era traces predate the field). See ``docs/TRACE_SCHEMA.md``.
23
+ #
24
+ # 1.1 added the optional event field ``token_provenance`` (a backward-compatible
25
+ # addition: 1.0 traces omit it and read back as provenance "unspecified").
26
+ SCHEMA_VERSION = "1.1"
27
+
28
+ # Statuses AgentLoop records on an event.
29
+ _VALID_STATUSES = frozenset({"ok", "error"})
30
+
31
+ # Event fields the schema knows about. Unknown structural fields are dropped on
32
+ # read (the documented forward-compatibility policy); use ``metadata`` to carry
33
+ # data that must survive a round trip.
34
+ _KNOWN_EVENT_FIELDS = frozenset(
35
+ {
36
+ "event_id",
37
+ "run_id",
38
+ "event_type",
39
+ "name",
40
+ "started_at",
41
+ "ended_at",
42
+ "duration_ms",
43
+ "parent_id",
44
+ "model",
45
+ "input_tokens",
46
+ "output_tokens",
47
+ "token_provenance",
48
+ "input_text",
49
+ "output_text",
50
+ "status",
51
+ "error",
52
+ "metadata",
53
+ }
54
+ )
55
+
56
+ # Event fields that must be present and non-null (they have no dataclass default).
57
+ _REQUIRED_EVENT_FIELDS = (
58
+ "event_id",
59
+ "run_id",
60
+ "event_type",
61
+ "name",
62
+ "started_at",
63
+ "ended_at",
64
+ "duration_ms",
65
+ )
66
+
67
+ _REQUIRED_STRING_EVENT_FIELDS = (
68
+ "event_id",
69
+ "run_id",
70
+ "event_type",
71
+ "name",
72
+ "started_at",
73
+ "ended_at",
74
+ )
75
+
76
+ _OPTIONAL_STRING_EVENT_FIELDS = (
77
+ "parent_id",
78
+ "model",
79
+ "input_text",
80
+ "output_text",
81
+ "error",
82
+ # Deliberately validated as a free string rather than a closed enum: a value
83
+ # this build does not recognize must not make a trace from a newer MINOR
84
+ # version unreadable. agentloop.tokens.provenance_grade() grades anything
85
+ # unrecognized as "unspecified", so an unknown value is never read as exact.
86
+ "token_provenance",
87
+ )
88
+
89
+ _NON_NEGATIVE_INT_EVENT_FIELDS = ("input_tokens", "output_tokens")
90
+
91
+
92
+ class TraceValidationError(ValueError):
93
+ """A serialized trace or event failed schema validation.
94
+
95
+ Carries the offending ``field`` path and a human ``reason`` so a boundary can
96
+ surface both in a 4xx response. It subclasses :class:`ValueError` so existing
97
+ callers that already catch ``ValueError`` (the CLI loaders, for example) keep
98
+ working.
99
+ """
100
+
101
+ def __init__(self, field: str, reason: str) -> None:
102
+ self.field = field
103
+ self.reason = reason
104
+ super().__init__(f"{field}: {reason}")
105
+
106
+
107
+ def schema_major(version: str) -> int:
108
+ """Return the MAJOR component of a ``schema_version`` string, or 0 if unset."""
109
+
110
+ if not version:
111
+ return 0
112
+ head = str(version).split(".", 1)[0]
113
+ try:
114
+ return int(head)
115
+ except ValueError:
116
+ raise TraceValidationError("schema_version", f"is not a valid version: {version!r}")
117
+
118
+
119
+ def check_schema_version(version: Any) -> None:
120
+ """Reject payloads written by a newer, incompatible major schema version."""
121
+
122
+ if version is None:
123
+ return # 0.4-era traces have no schema_version; tolerate their absence.
124
+ if not isinstance(version, str):
125
+ raise TraceValidationError("schema_version", "must be a string")
126
+ if schema_major(version) > schema_major(SCHEMA_VERSION):
127
+ raise TraceValidationError(
128
+ "schema_version",
129
+ f"trace was written with schema {version}, which is newer than the "
130
+ f"supported {SCHEMA_VERSION}; upgrade AgentLoop to read it",
131
+ )
132
+
133
+
134
+ def coerce_event_dict(data: Any, *, index: int) -> dict[str, Any]:
135
+ """Validate one serialized event and return kwargs for :class:`AgentEvent`.
136
+
137
+ Unknown fields are ignored (forward-compatibility policy). Raises
138
+ :class:`TraceValidationError` naming ``events[<index>].<field>`` on any
139
+ missing required field, wrong type, or out-of-range numeric value.
140
+ """
141
+
142
+ where = f"events[{index}]"
143
+ if not isinstance(data, dict):
144
+ raise TraceValidationError(where, "must be an object")
145
+
146
+ for name in _REQUIRED_EVENT_FIELDS:
147
+ if data.get(name) is None:
148
+ raise TraceValidationError(f"{where}.{name}", "is required")
149
+
150
+ clean: dict[str, Any] = {}
151
+ for name in _REQUIRED_STRING_EVENT_FIELDS:
152
+ value = data[name]
153
+ if not isinstance(value, str) or not value:
154
+ raise TraceValidationError(f"{where}.{name}", "must be a non-empty string")
155
+ clean[name] = value
156
+
157
+ for name in _OPTIONAL_STRING_EVENT_FIELDS:
158
+ value = data.get(name)
159
+ if value is not None and not isinstance(value, str):
160
+ raise TraceValidationError(f"{where}.{name}", "must be a string or null")
161
+ clean[name] = value
162
+
163
+ clean["duration_ms"] = _non_negative_number(data["duration_ms"], f"{where}.duration_ms")
164
+
165
+ for name in _NON_NEGATIVE_INT_EVENT_FIELDS:
166
+ if name in data and data[name] is not None:
167
+ clean[name] = _non_negative_int(data[name], f"{where}.{name}")
168
+
169
+ status = data.get("status", "ok")
170
+ if not isinstance(status, str) or status not in _VALID_STATUSES:
171
+ raise TraceValidationError(
172
+ f"{where}.status",
173
+ f"must be one of {', '.join(sorted(_VALID_STATUSES))}",
174
+ )
175
+ clean["status"] = status
176
+
177
+ metadata = data.get("metadata", {})
178
+ if not isinstance(metadata, dict):
179
+ raise TraceValidationError(f"{where}.metadata", "must be an object")
180
+ clean["metadata"] = metadata
181
+
182
+ return clean
183
+
184
+
185
+ def validate_trace_dict(data: Any) -> None:
186
+ """Validate a serialized trace envelope before its events are constructed.
187
+
188
+ Checks the schema version, required top-level fields, metadata shape, unique
189
+ event ids, and event-to-trace run-id consistency. Individual event fields are
190
+ validated by :func:`coerce_event_dict`.
191
+ """
192
+
193
+ if not isinstance(data, dict):
194
+ raise TraceValidationError("trace", "must be an object")
195
+
196
+ check_schema_version(data.get("schema_version"))
197
+
198
+ name = data.get("name")
199
+ if not isinstance(name, str) or not name:
200
+ raise TraceValidationError("name", "must be a non-empty string")
201
+
202
+ run_id = data.get("run_id")
203
+ if not isinstance(run_id, str) or not run_id:
204
+ raise TraceValidationError("run_id", "must be a non-empty string")
205
+
206
+ metadata = data.get("metadata", {})
207
+ if not isinstance(metadata, dict):
208
+ raise TraceValidationError("metadata", "must be an object")
209
+
210
+ validate_elapsed_ms(data.get("elapsed_ms"))
211
+
212
+ events = data.get("events", [])
213
+ if not isinstance(events, list):
214
+ raise TraceValidationError("events", "must be a list")
215
+
216
+ seen_ids: set[str] = set()
217
+ for index, event in enumerate(events):
218
+ if not isinstance(event, dict):
219
+ raise TraceValidationError(f"events[{index}]", "must be an object")
220
+ event_id = event.get("event_id")
221
+ if isinstance(event_id, str):
222
+ if event_id in seen_ids:
223
+ raise TraceValidationError(
224
+ f"events[{index}].event_id", f"is not unique within the trace: {event_id}"
225
+ )
226
+ seen_ids.add(event_id)
227
+ event_run_id = event.get("run_id")
228
+ if isinstance(event_run_id, str) and event_run_id != run_id:
229
+ raise TraceValidationError(
230
+ f"events[{index}].run_id",
231
+ f"does not match the trace run_id {run_id!r}",
232
+ )
233
+
234
+
235
+ def validate_elapsed_ms(value: Any) -> float | None:
236
+ """Validate optional trace runtime before constructors or HTTP models coerce it."""
237
+ return None if value is None else _non_negative_number(value, "elapsed_ms")
238
+
239
+
240
+ def _non_negative_number(value: Any, field: str) -> float:
241
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
242
+ raise TraceValidationError(field, "must be a number")
243
+ try:
244
+ number = float(value)
245
+ except OverflowError:
246
+ raise TraceValidationError(field, "must be a finite, non-negative number") from None
247
+ if not math.isfinite(number) or number < 0:
248
+ raise TraceValidationError(field, "must be a finite, non-negative number")
249
+ return number
250
+
251
+
252
+ def _non_negative_int(value: Any, field: str) -> int:
253
+ if isinstance(value, bool) or not isinstance(value, int):
254
+ raise TraceValidationError(field, "must be an integer")
255
+ if value < 0:
256
+ raise TraceValidationError(field, "must be non-negative")
257
+ return value