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
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import os
5
+ import warnings
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ # Framework integrations AgentLoop ships helpers for, mapped to the import name
10
+ # used to detect whether that framework's SDK is installed.
11
+ _DETECTABLE_INTEGRATIONS: dict[str, str] = {
12
+ "openai": "openai",
13
+ "langgraph": "langgraph",
14
+ "crewai": "crewai",
15
+ }
16
+
17
+ # Integrations that are not detectable as an installed Python package because
18
+ # instrumentation happens outside this process, mapped to guidance.
19
+ _NON_PYTHON_INTEGRATIONS: dict[str, str] = {
20
+ "vercel_ai_sdk": "JavaScript-side; call trace_from_vercel_ai_events(...) on exported telemetry",
21
+ }
22
+
23
+
24
+ @dataclass
25
+ class DetectionResult:
26
+ """Which framework integrations are available in the current environment.
27
+
28
+ This is a *detection* result. It reports whether each integration's SDK is
29
+ importable in this process; it does not wrap, patch, or register anything.
30
+ Instrument an application by applying the helpers in
31
+ :mod:`agentloop.integrations` (or the generic decorator SDK) from that
32
+ application's own startup.
33
+ """
34
+
35
+ available: list[str] = field(default_factory=list)
36
+ unavailable: dict[str, str] = field(default_factory=dict)
37
+
38
+ @property
39
+ def ok(self) -> bool:
40
+ return bool(self.available)
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ return {"ok": self.ok, "available": self.available, "unavailable": self.unavailable}
44
+
45
+
46
+ def _module_available(module_name: str) -> bool:
47
+ """Return True if ``module_name`` is importable, without importing it."""
48
+ try:
49
+ return importlib.util.find_spec(module_name) is not None
50
+ except (ImportError, ValueError):
51
+ return False
52
+
53
+
54
+ def detect_integrations() -> DetectionResult:
55
+ """Detect which AgentLoop framework integrations are available here.
56
+
57
+ Detection only checks package availability with
58
+ :func:`importlib.util.find_spec` and never imports the framework, so it is
59
+ cheap and side-effect free and safe to run at application startup. The result
60
+ is deterministic for a given environment, so repeated calls return equal
61
+ results.
62
+
63
+ This function does not enable instrumentation. To record traces, apply the
64
+ helpers in :mod:`agentloop.integrations` (for example
65
+ ``instrument_openai_client``) or the generic decorator SDK from the
66
+ application process you want to trace.
67
+ """
68
+
69
+ result = DetectionResult()
70
+
71
+ for name, module_name in _DETECTABLE_INTEGRATIONS.items():
72
+ if _module_available(module_name):
73
+ result.available.append(name)
74
+ else:
75
+ result.unavailable[name] = "package not installed"
76
+
77
+ result.unavailable.update(_NON_PYTHON_INTEGRATIONS)
78
+ return result
79
+
80
+
81
+ # Backwards-compatible aliases. The previous names implied that calling this
82
+ # enabled instrumentation, which it never did.
83
+ InstrumentationResult = DetectionResult
84
+
85
+
86
+ def auto_instrument(**kwargs: Any) -> DetectionResult:
87
+ """Deprecated alias for :func:`detect_integrations`.
88
+
89
+ The old name and its ``enabled`` field wrongly implied that calling it
90
+ activated instrumentation; it only ever detected installed frameworks. Use
91
+ :func:`detect_integrations` instead and apply
92
+ :mod:`agentloop.integrations` helpers from your application startup. Any
93
+ keyword arguments are ignored and were never used.
94
+ """
95
+
96
+ warnings.warn(
97
+ "auto_instrument() never enabled instrumentation; it only detects "
98
+ "installed frameworks. It is deprecated: use detect_integrations() and "
99
+ "apply agentloop.integrations helpers from your application startup.",
100
+ DeprecationWarning,
101
+ stacklevel=2,
102
+ )
103
+ return detect_integrations()
104
+
105
+
106
+ def instrument(target: Any, *, integration: str, enabled: bool | None = None) -> Any:
107
+ """Enable a supported adapter on an explicitly supplied application object.
108
+
109
+ This startup call is required even when environment configuration is used.
110
+ False disables installation on this call; it does not unwrap an object that
111
+ was previously instrumented. Execution boundaries remain host-owned.
112
+ """
113
+ if integration not in {"openai", "langgraph_builder", "crewai_task"}:
114
+ raise ValueError("integration must be openai, langgraph_builder or crewai_task")
115
+ if enabled is None:
116
+ setting = os.getenv("AGENTLOOP_INSTRUMENTATION_ENABLED", "true").strip().lower()
117
+ if setting not in {"true", "false", "1", "0"}:
118
+ raise ValueError("AGENTLOOP_INSTRUMENTATION_ENABLED must be true/false/1/0")
119
+ enabled = setting in {"true", "1"}
120
+ if type(enabled) is not bool:
121
+ raise TypeError("enabled must be boolean or None")
122
+ if not enabled:
123
+ return target
124
+ if integration == "openai":
125
+ from agentloop.integrations.openai import instrument_openai_client
126
+
127
+ responses = getattr(target, "responses", None)
128
+ completions = getattr(getattr(target, "chat", None), "completions", None)
129
+ if not any(
130
+ callable(getattr(resource, "create", None)) for resource in (responses, completions)
131
+ ):
132
+ raise TypeError("OpenAI-like client exposes no supported create method")
133
+ return instrument_openai_client(target)
134
+ if integration == "langgraph_builder":
135
+ from agentloop.integrations.langgraph import instrument_state_graph
136
+
137
+ return instrument_state_graph(target)
138
+ from agentloop.integrations.crewai import instrument_task
139
+
140
+ if not any(
141
+ callable(getattr(target, name, None))
142
+ for name in ("execute", "execute_sync", "execute_async", "run")
143
+ ):
144
+ raise TypeError("CrewAI-like task exposes no supported execution method")
145
+ return instrument_task(target)
@@ -0,0 +1,295 @@
1
+ """Bounded caller-provided batch candidates with retained per-item outcomes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from time import perf_counter
7
+
8
+ from agentloop.budget_types import Reservation
9
+ from agentloop.context_experiments import _close_invalid, _deadline, _path, _sum_usage, _value
10
+ from agentloop.context_types import EXACT_COUNTS, ContextTokenCount, identifier, synchronous
11
+ from agentloop.experiment_observations import observation
12
+ from agentloop.experiment_types import (
13
+ ExperimentResult,
14
+ ExperimentRunner,
15
+ canonical,
16
+ fingerprint,
17
+ reference,
18
+ )
19
+ from agentloop.structural_types import (
20
+ STRUCTURAL_KEY,
21
+ BatchItem,
22
+ BatchRequest,
23
+ BatchResult,
24
+ StageReference,
25
+ publish_structural_observations,
26
+ )
27
+ from agentloop.studies import summarize_values
28
+ from agentloop.tracer import current_trace
29
+ from agentloop.workflows import StageInfo, trace_operation
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class BatchConstraints:
34
+ batch_size: int
35
+ provider_max_items: int
36
+ provider_max_payload_bytes: int
37
+ provider_ref: str
38
+ max_items: int = 10000
39
+ max_batches: int = 128
40
+ provider_max_input_tokens: int | None = None
41
+
42
+ def __post_init__(self):
43
+ for name in (
44
+ "batch_size",
45
+ "provider_max_items",
46
+ "provider_max_payload_bytes",
47
+ "max_items",
48
+ "max_batches",
49
+ ):
50
+ if type(getattr(self, name)) is not int or getattr(self, name) < 1:
51
+ raise ValueError("batch bounds must be positive integers")
52
+ if (
53
+ self.batch_size > self.provider_max_items
54
+ or self.provider_max_items > 1024
55
+ or self.max_items > 10000
56
+ or self.max_batches > 1000
57
+ ):
58
+ raise ValueError("batch configuration exceeds declared bounds")
59
+ if self.provider_max_input_tokens is not None and (
60
+ type(self.provider_max_input_tokens) is not int or self.provider_max_input_tokens < 1
61
+ ):
62
+ raise ValueError("input token limit must be a positive integer")
63
+ reference(self.provider_ref, "provider constraint reference")
64
+
65
+
66
+ def batching_experiment_runner(
67
+ candidate_id,
68
+ invoke_batch,
69
+ *,
70
+ stage,
71
+ items_path,
72
+ constraints,
73
+ implementation_ref,
74
+ configuration_ref,
75
+ why_ref,
76
+ reservation=None,
77
+ token_counter=None,
78
+ counter_ref=None,
79
+ on_item_failure="fail_task",
80
+ ):
81
+ if (
82
+ type(stage) is not StageReference
83
+ or type(constraints) is not BatchConstraints
84
+ or not synchronous(invoke_batch)
85
+ ):
86
+ raise ValueError(
87
+ "batch experiment requires a stage, constraints and trusted batch implementation"
88
+ )
89
+ path = _path(items_path)
90
+ for name, value in (
91
+ ("implementation_ref", implementation_ref),
92
+ ("configuration_ref", configuration_ref),
93
+ ("why_ref", why_ref),
94
+ ):
95
+ reference(value, name)
96
+ if constraints.provider_max_input_tokens is not None and (
97
+ not synchronous(token_counter) or counter_ref is None
98
+ ):
99
+ raise ValueError("token-constrained batches require a declared exact counter")
100
+ if counter_ref is not None:
101
+ identifier(counter_ref, "counter_ref")
102
+ if on_item_failure not in {"fail_task", "return_partial"}:
103
+ raise ValueError("unknown batch failure semantics")
104
+ config = {
105
+ "schema_version": "1.0",
106
+ "stage": stage.declaration(),
107
+ "items_path": path,
108
+ "constraints": asdict(constraints),
109
+ "implementation_ref": implementation_ref,
110
+ "configuration_ref": configuration_ref,
111
+ "why_ref": why_ref,
112
+ "counter_ref": counter_ref,
113
+ "on_item_failure": on_item_failure,
114
+ }
115
+ config_hash = fingerprint(config)
116
+ source = "batch-config:" + config_hash
117
+
118
+ def invoke(request):
119
+ trace = current_trace()
120
+ if trace is None:
121
+ raise ValueError("batch candidate requires an active experiment")
122
+ receipt = {
123
+ "schema_version": "1.0",
124
+ "family": "batching",
125
+ "config_hash": config_hash,
126
+ "stage": stage.declaration(),
127
+ "why_ref": why_ref,
128
+ "constraints": asdict(constraints),
129
+ "state": "preparing",
130
+ "batches": [],
131
+ "items_planned": None,
132
+ }
133
+ trace.metadata[STRUCTURAL_KEY] = receipt
134
+ completed, failed, unknown, invoked, latencies, usages, outputs = 0, 0, 0, 0, [], [], []
135
+ items = None
136
+ try:
137
+ items = _value(request.inputs, path)
138
+ if not isinstance(items, list) or len(items) > constraints.max_items:
139
+ raise ValueError("batch input must be a bounded item list")
140
+ receipt["items_planned"] = len(items)
141
+ planned_batches = (len(items) + constraints.batch_size - 1) // constraints.batch_size
142
+ if planned_batches > constraints.max_batches:
143
+ raise ValueError("planned batches exceed the execution bound")
144
+ for offset in range(0, len(items), constraints.batch_size):
145
+ batch = tuple(
146
+ BatchItem(f"item_{index:06d}", items[index])
147
+ for index in range(offset, min(offset + constraints.batch_size, len(items)))
148
+ )
149
+ batch_id = f"batch_{offset // constraints.batch_size:06d}"
150
+ record = {
151
+ "batch_id": batch_id,
152
+ "item_ids": [item.item_id for item in batch],
153
+ "input_hashes": [fingerprint(item.inputs) for item in batch],
154
+ "status": "pending",
155
+ "latency_ms": None,
156
+ "outcomes": [],
157
+ "usage": None,
158
+ }
159
+ receipt["batches"].append(record)
160
+ encoded = canonical(
161
+ [{"item_id": item.item_id, "inputs": item.inputs} for item in batch]
162
+ )
163
+ if len(encoded.encode("utf-8")) > constraints.provider_max_payload_bytes:
164
+ record["status"] = "provider_constraint_rejected"
165
+ raise ValueError("batch exceeds declared provider payload bound")
166
+ _deadline(request)
167
+ if constraints.provider_max_input_tokens is not None:
168
+ count = token_counter(batch)
169
+ if (
170
+ type(count) is not ContextTokenCount
171
+ or count.provenance not in EXACT_COUNTS
172
+ or count.reference != counter_ref
173
+ or count.value is None
174
+ or count.value > constraints.provider_max_input_tokens
175
+ ):
176
+ record["status"] = "provider_constraint_rejected"
177
+ _close_invalid(count)
178
+ raise ValueError("batch input token bound is unavailable or exceeded")
179
+ record["input_token_count"] = count.to_dict()
180
+ _deadline(request)
181
+ began = perf_counter()
182
+ invoked += 1
183
+ try:
184
+ with trace_operation(
185
+ stage.stage_id,
186
+ kind=stage.kind,
187
+ stage=StageInfo(
188
+ stage.stage_id,
189
+ stage.version,
190
+ kind=stage.kind,
191
+ input_ref=request.input_ref,
192
+ ),
193
+ metadata={
194
+ "batch_id": batch_id,
195
+ "batch_size": len(batch),
196
+ "constraint_ref": constraints.provider_ref,
197
+ },
198
+ ):
199
+ result = invoke_batch(BatchRequest(batch_id, batch, request))
200
+ if type(result) is not BatchResult:
201
+ _close_invalid(result)
202
+ raise ValueError("batch implementation must return BatchResult")
203
+ record["usage"] = result.usage.to_dict()
204
+ usages.append(result.usage)
205
+ expected = {item.item_id for item in batch}
206
+ if (
207
+ len(result.items) != len(expected)
208
+ or {item.item_id for item in result.items} != expected
209
+ ):
210
+ raise ValueError(
211
+ "batch outcomes must match every planned item exactly once"
212
+ )
213
+ by_id = {item.item_id: item for item in result.items}
214
+ for item in batch:
215
+ outcome = by_id[item.item_id]
216
+ record["outcomes"].append(
217
+ {
218
+ "item_id": item.item_id,
219
+ "status": outcome.status,
220
+ "error_code": outcome.error_code,
221
+ "output_hash": fingerprint(outcome.output)
222
+ if outcome.status == "completed"
223
+ else None,
224
+ }
225
+ )
226
+ outputs.append(outcome.output if outcome.status == "completed" else None)
227
+ completed += outcome.status == "completed"
228
+ failed += outcome.status == "failed"
229
+ unknown += outcome.status == "unknown"
230
+ record["status"] = (
231
+ "completed"
232
+ if all(item.status == "completed" for item in result.items)
233
+ else "partial"
234
+ )
235
+ if record["status"] == "partial" and on_item_failure == "fail_task":
236
+ raise ValueError("partial batch failure requires task failure")
237
+ except BaseException:
238
+ if record["status"] == "pending":
239
+ record["status"] = "failed"
240
+ record["outcomes"] = [
241
+ {
242
+ "item_id": item.item_id,
243
+ "status": "unknown",
244
+ "error_code": "batch_error",
245
+ "output_hash": None,
246
+ }
247
+ for item in batch
248
+ ]
249
+ unknown += len(batch)
250
+ raise
251
+ finally:
252
+ record["latency_ms"] = (perf_counter() - began) * 1000
253
+ latencies.append(record["latency_ms"])
254
+ receipt["state"] = "completed"
255
+ if not usages:
256
+ return ExperimentResult([])
257
+ return ExperimentResult(outputs, usage=_sum_usage(usages))
258
+ except BaseException:
259
+ receipt["state"] = "failed"
260
+ raise
261
+ finally:
262
+ receipt["latency_distribution_ms"] = summarize_values(latencies)
263
+ planned = len(items) if isinstance(items, list) else None
264
+ remaining = None if planned is None else planned - completed - failed - unknown
265
+ publish_structural_observations(
266
+ receipt,
267
+ {
268
+ "batch_state": observation(receipt["state"], unit="state", source_ref=source),
269
+ "batch_size": observation(
270
+ constraints.batch_size, kind="declared", unit="items", source_ref=source
271
+ ),
272
+ "batch_invocations": observation(invoked, unit="calls", source_ref=source),
273
+ "batch_items_planned": observation(planned, unit="items", source_ref=source),
274
+ "batch_items_completed": observation(
275
+ completed, unit="completed_items", source_ref=source
276
+ ),
277
+ "batch_items_failed": observation(failed, unit="items", source_ref=source),
278
+ "batch_items_unknown": observation(unknown, unit="items", source_ref=source),
279
+ "batch_items_not_started": observation(
280
+ remaining, unit="items", source_ref=source
281
+ ),
282
+ "batch_latency_p95_ms": observation(
283
+ receipt["latency_distribution_ms"]["p95"], unit="ms", source_ref=source
284
+ ),
285
+ },
286
+ )
287
+
288
+ return ExperimentRunner(
289
+ candidate_id,
290
+ "1.0",
291
+ "agentloop.batching:1.0",
292
+ "sha256:" + config_hash,
293
+ invoke,
294
+ reservation=reservation or Reservation(),
295
+ )
agentloop/batching.py ADDED
@@ -0,0 +1,95 @@
1
+ """Model-call groups that do not contradict recorded causal dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from collections.abc import Mapping
7
+
8
+ from agentloop.workflow_types import stage_summary
9
+
10
+
11
+ def model_batch_groups(graph):
12
+ """Return hypotheses; absent dependencies never prove output independence.
13
+
14
+ Index dependencies once, reject unresolved/cyclic ancestry, then inspect each
15
+ candidate group's ancestor closure without recursion or all-pairs searches.
16
+ Inferred sequence edges describe ordering, not causal dependencies.
17
+ """
18
+ by_id = {node.node_id: node for node in graph.nodes}
19
+ if len(by_id) != len(graph.nodes):
20
+ return []
21
+ dependencies = {identity: set() for identity in by_id}
22
+ invalid = set()
23
+ groups = {}
24
+ for node in graph.nodes:
25
+ metadata = node.metadata
26
+ if not isinstance(metadata, Mapping):
27
+ invalid.add(node.node_id)
28
+ metadata = {}
29
+ required = metadata.get("depends_on", [])
30
+ if not isinstance(required, list) or any(
31
+ not isinstance(identity, str) or not identity for identity in required
32
+ ):
33
+ invalid.add(node.node_id)
34
+ else:
35
+ dependencies[node.node_id].update(required)
36
+ stage = stage_summary(node)
37
+ if stage is not None:
38
+ if (
39
+ stage.get("schema_status") != "supported"
40
+ or stage.get("dependency_status") == "invalid"
41
+ ):
42
+ invalid.add(node.node_id)
43
+ elif stage.get("dependencies_declared"):
44
+ dependencies[node.node_id].update(stage["depends_on"])
45
+ if node.parent_id is not None:
46
+ dependencies[node.node_id].add(node.parent_id)
47
+ if node.operation_kind == "model":
48
+ groups.setdefault((node.name, node.model, node.parent_id), []).append(node.node_id)
49
+ if not any(len(identities) >= 3 for identities in groups.values()):
50
+ return []
51
+ for edge in graph.edges:
52
+ if edge.kind in {"dependency", "depends_on", "parent"} and edge.target in by_id:
53
+ dependencies[edge.target].add(edge.source)
54
+
55
+ # Removing roots leaves cycles and their descendants unresolved. Invalid or
56
+ # missing ancestors propagate to descendants as the valid portion is visited.
57
+ successors = {identity: [] for identity in by_id}
58
+ indegrees = {}
59
+ for identity, required in dependencies.items():
60
+ indegrees[identity] = len(required)
61
+ for ancestor in required:
62
+ if ancestor in successors:
63
+ successors[ancestor].append(identity)
64
+ else:
65
+ invalid.add(identity)
66
+ ready = deque(identity for identity, count in indegrees.items() if not count)
67
+ visited = set()
68
+ while ready:
69
+ identity = ready.popleft()
70
+ visited.add(identity)
71
+ for successor in successors[identity]:
72
+ if identity in invalid:
73
+ invalid.add(successor)
74
+ indegrees[successor] -= 1
75
+ if indegrees[successor] == 0:
76
+ ready.append(successor)
77
+ invalid.update(by_id.keys() - visited)
78
+
79
+ eligible = []
80
+ for (name, _, _), identities in groups.items():
81
+ members = set(identities)
82
+ if len(members) < 3 or members & invalid:
83
+ continue
84
+ pending = [ancestor for identity in identities for ancestor in dependencies[identity]]
85
+ ancestors = set()
86
+ while pending:
87
+ ancestor = pending.pop()
88
+ if ancestor in members:
89
+ break
90
+ if ancestor not in ancestors:
91
+ ancestors.add(ancestor)
92
+ pending.extend(dependencies[ancestor])
93
+ else:
94
+ eligible.append((name, [by_id[identity] for identity in identities]))
95
+ return eligible