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,431 @@
1
+ """Structural candidates built on the existing declared scheduler and experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from threading import RLock
7
+ from types import MappingProxyType
8
+
9
+ from agentloop.budget_types import ResourceUsage
10
+ from agentloop.context_experiments import _close_invalid, _deadline, _sum_usage
11
+ from agentloop.context_types import synchronous
12
+ from agentloop.experiment_observations import observation, record_experiment_observations
13
+ from agentloop.experiment_types import (
14
+ ExperimentResult,
15
+ ExperimentRunner,
16
+ fingerprint,
17
+ owned,
18
+ reference,
19
+ )
20
+ from agentloop.harness import Harness, HarnessConfig
21
+ from agentloop.scheduling_plan import plan_tools
22
+ from agentloop.scheduling_types import ScheduleConfig, SchedulingError, ToolCall
23
+ from agentloop.structural_types import (
24
+ STRUCTURAL_KEY,
25
+ StageReference,
26
+ publish_structural_observations,
27
+ )
28
+ from agentloop.tool_scheduling import ToolScheduler, scheduling_policy
29
+ from agentloop.tracer import current_trace
30
+ from agentloop.workflows import StageInfo, trace_operation
31
+
32
+
33
+ def _stages(values):
34
+ if (
35
+ not isinstance(values, (tuple, list))
36
+ or not 1 <= len(values) <= 64
37
+ or any(type(item) is not StageReference for item in values)
38
+ or len({item.stage_id for item in values}) != len(values)
39
+ ):
40
+ raise ValueError("structural candidates require unique bounded stage references")
41
+ return tuple(values)
42
+
43
+
44
+ def _graph_runner(
45
+ candidate_id,
46
+ factory,
47
+ assemble,
48
+ *,
49
+ stages,
50
+ factory_ref,
51
+ assembler_ref,
52
+ why_ref,
53
+ removed,
54
+ max_concurrency,
55
+ strict,
56
+ reservation,
57
+ ):
58
+ stages = _stages(stages)
59
+ if not synchronous(factory) or not synchronous(assemble):
60
+ raise ValueError("stage factory and assembler must be trusted synchronous callbacks")
61
+ for name, value in (
62
+ ("factory_ref", factory_ref),
63
+ ("assembler_ref", assembler_ref),
64
+ ("why_ref", why_ref),
65
+ ):
66
+ reference(value, name)
67
+ removed = owned(removed)
68
+ ids = {stage.stage_id for stage in stages}
69
+ if not isinstance(removed, dict) or not set(removed) <= ids:
70
+ raise ValueError("removed stages must belong to the explicit stage set")
71
+ settings = ScheduleConfig(
72
+ "structural-schedule",
73
+ "1.0",
74
+ "stages",
75
+ max_concurrency=max_concurrency,
76
+ on_unknown="error" if strict else "sequential",
77
+ )
78
+ config = {
79
+ "schema_version": "1.0",
80
+ "family": "parallelization" if strict else "stage_removal",
81
+ "stages": [stage.declaration() for stage in stages],
82
+ "factory_ref": factory_ref,
83
+ "assembler_ref": assembler_ref,
84
+ "why_ref": why_ref,
85
+ "removed": {key: fingerprint(value) for key, value in removed.items()},
86
+ "scheduler": settings.to_dict(),
87
+ }
88
+ config_hash = fingerprint(config)
89
+ source = "structural-config:" + config_hash
90
+
91
+ def invoke(request):
92
+ trace = current_trace()
93
+ if trace is None:
94
+ raise ValueError("structural candidate requires an active experiment")
95
+ receipt = {
96
+ "schema_version": "1.0",
97
+ "config_hash": config_hash,
98
+ "family": config["family"],
99
+ "why_ref": why_ref,
100
+ "changed_stages": sorted(ids if strict else removed),
101
+ "stages": [stage.declaration() for stage in stages],
102
+ "removed_value_hashes": config["removed"],
103
+ "state": "preparing",
104
+ "invoked_stages": [],
105
+ "skipped_stages": [],
106
+ "output_hashes": {},
107
+ "usage": {},
108
+ "scheduler": None,
109
+ }
110
+ trace.metadata[STRUCTURAL_KEY] = receipt
111
+ lock, event_ids = RLock(), {}
112
+ scheduler = ToolScheduler(
113
+ Harness(HarnessConfig("enforce", (scheduling_policy(settings),))).start_run(),
114
+ config=settings,
115
+ )
116
+ result = None
117
+ try:
118
+ _deadline(request)
119
+ calls = factory(request)
120
+ if (
121
+ not isinstance(calls, (tuple, list))
122
+ or any(type(call) is not ToolCall for call in calls)
123
+ or {call.call_id for call in calls} != ids
124
+ or len(calls) != len(ids)
125
+ ):
126
+ raise ValueError("stage factory differs from declared stage identities")
127
+ # Validate the original dependency/resource contract before any
128
+ # removal can hide an unsafe or cyclic source plan.
129
+ receipt["original_plan"] = plan_tools(calls, settings)
130
+ references = {stage.stage_id: stage for stage in stages}
131
+ wrapped = []
132
+ for call in calls:
133
+ if call.call_id in removed:
134
+
135
+ def skipped(context, call=call):
136
+ with lock:
137
+ receipt["skipped_stages"].append(call.call_id)
138
+ return ExperimentResult(
139
+ removed[call.call_id],
140
+ # Fixed accounting provenance, not a credential.
141
+ usage=ResourceUsage(
142
+ tokens=0,
143
+ token_provenance="user_supplied",
144
+ complete=True, # nosec B106
145
+ ),
146
+ )
147
+
148
+ wrapped.append(
149
+ replace(
150
+ call,
151
+ invoke=skipped,
152
+ effect="read_only",
153
+ reads=(),
154
+ writes=(),
155
+ concurrent=True,
156
+ usage_reader=lambda value: value.usage,
157
+ error_usage_reader=None,
158
+ )
159
+ )
160
+ continue
161
+
162
+ def measured(context, call=call):
163
+ _deadline(request)
164
+ stage = references[call.call_id]
165
+ with lock:
166
+ dependencies = [
167
+ event_ids[key] for key in (call.depends_on or ()) if key in event_ids
168
+ ]
169
+ receipt["invoked_stages"].append(call.call_id)
170
+ copied = replace(
171
+ context,
172
+ prerequisites=MappingProxyType(
173
+ {key: value.output for key, value in context.prerequisites.items()}
174
+ ),
175
+ )
176
+ with trace_operation(
177
+ call.call_id,
178
+ kind=stage.kind,
179
+ stage=StageInfo(
180
+ call.call_id,
181
+ stage.version,
182
+ kind=stage.kind,
183
+ input_ref=request.input_ref,
184
+ ),
185
+ depends_on=dependencies if call.depends_on is not None else None,
186
+ metadata={
187
+ "structural_source_ref": stage.source_ref,
188
+ "parallel_safe": call.concurrent is True and call.effect == "read_only",
189
+ },
190
+ ) as span:
191
+ with lock:
192
+ event_ids[call.call_id] = span.event_id
193
+ value = call.invoke(copied)
194
+ if type(value) is not ExperimentResult:
195
+ _close_invalid(value)
196
+ raise ValueError("stage callback must return ExperimentResult")
197
+ with lock:
198
+ receipt["output_hashes"][call.call_id] = fingerprint(value.output)
199
+ receipt["usage"][call.call_id] = value.usage.to_dict()
200
+ return value
201
+
202
+ wrapped.append(
203
+ replace(
204
+ call,
205
+ invoke=measured,
206
+ usage_reader=lambda value: value.usage,
207
+ error_usage_reader=None,
208
+ )
209
+ )
210
+ result = scheduler.execute(wrapped, timeout_s=request.remaining_s)
211
+ if not result.completed:
212
+ raise ValueError("structural stage execution is incomplete")
213
+ _deadline(request)
214
+ outputs = MappingProxyType(
215
+ {item.call_id: item.value.output for item in result.outcomes}
216
+ )
217
+ value = assemble(request, outputs)
218
+ if type(value) is not ExperimentResult:
219
+ _close_invalid(value)
220
+ raise ValueError("assembler must return ExperimentResult with usage")
221
+ receipt["assembler_usage"] = value.usage.to_dict()
222
+ usages = [item.value.usage for item in result.outcomes] + [value.usage]
223
+ receipt["state"] = "completed"
224
+ return ExperimentResult(value.output, usage=_sum_usage(usages))
225
+ except SchedulingError as exc:
226
+ receipt.update(state="abstained", reason=exc.reason_code)
227
+ raise
228
+ except BaseException:
229
+ receipt["state"] = "failed"
230
+ raise
231
+ finally:
232
+ receipt["scheduler"] = scheduler.export_evidence()
233
+ receipt["invoked_stages"].sort()
234
+ receipt["skipped_stages"].sort()
235
+ evidence = list(receipt["scheduler"]["records"].values())
236
+ peak = evidence[0]["peak_running"] if evidence else 0
237
+ observed = result or scheduler.last_result
238
+ failures = (
239
+ sum(item.status != "completed" for item in observed.outcomes)
240
+ if observed is not None
241
+ else None
242
+ )
243
+ publish_structural_observations(
244
+ receipt,
245
+ {
246
+ "structural_state": observation(
247
+ receipt["state"], unit="state", source_ref=source
248
+ ),
249
+ "stages_invoked": observation(
250
+ len(receipt["invoked_stages"]), unit="operations", source_ref=source
251
+ ),
252
+ "stages_skipped": observation(
253
+ len(receipt["skipped_stages"]), unit="operations", source_ref=source
254
+ ),
255
+ "stage_failures": observation(failures, unit="operations", source_ref=source),
256
+ "peak_parallel_stages": observation(peak, unit="operations", source_ref=source),
257
+ "parallel_execution_observed": observation(
258
+ peak > 1, unit="boolean", source_ref=source
259
+ ),
260
+ },
261
+ )
262
+
263
+ return ExperimentRunner(
264
+ candidate_id,
265
+ "1.0",
266
+ "agentloop.structural-" + config["family"] + ":1.0",
267
+ "sha256:" + config_hash,
268
+ invoke,
269
+ reservation=reservation,
270
+ )
271
+
272
+
273
+ def stage_removal_runner(
274
+ candidate_id,
275
+ factory,
276
+ assemble,
277
+ *,
278
+ stages,
279
+ removed,
280
+ factory_ref,
281
+ assembler_ref,
282
+ why_ref,
283
+ reservation,
284
+ ):
285
+ if not removed:
286
+ raise ValueError(
287
+ "stage removal requires explicit replacement values for omitted operations"
288
+ )
289
+ return _graph_runner(
290
+ candidate_id,
291
+ factory,
292
+ assemble,
293
+ stages=stages,
294
+ factory_ref=factory_ref,
295
+ assembler_ref=assembler_ref,
296
+ why_ref=why_ref,
297
+ removed=removed,
298
+ max_concurrency=1,
299
+ strict=False,
300
+ reservation=reservation,
301
+ )
302
+
303
+
304
+ def parallel_experiment_runner(
305
+ candidate_id,
306
+ factory,
307
+ assemble,
308
+ *,
309
+ stages,
310
+ factory_ref,
311
+ assembler_ref,
312
+ why_ref,
313
+ reservation,
314
+ max_concurrency=4,
315
+ ):
316
+ return _graph_runner(
317
+ candidate_id,
318
+ factory,
319
+ assemble,
320
+ stages=stages,
321
+ factory_ref=factory_ref,
322
+ assembler_ref=assembler_ref,
323
+ why_ref=why_ref,
324
+ removed={},
325
+ max_concurrency=max_concurrency,
326
+ strict=True,
327
+ reservation=reservation,
328
+ )
329
+
330
+
331
+ def conditional_experiment_runner(
332
+ candidate_id,
333
+ expensive,
334
+ fallback,
335
+ predicate,
336
+ *,
337
+ stages,
338
+ predicate_ref,
339
+ why_ref,
340
+ reservation,
341
+ on_unknown="expensive",
342
+ ):
343
+ stages = _stages(stages)
344
+ if (
345
+ type(expensive) is not ExperimentRunner
346
+ or type(fallback) is not ExperimentRunner
347
+ or not synchronous(predicate)
348
+ or on_unknown not in {"expensive", "fallback", "error"}
349
+ ):
350
+ raise ValueError(
351
+ "conditional candidates require explicit implementations and unknown handling"
352
+ )
353
+ reference(predicate_ref, "predicate_ref")
354
+ reference(why_ref, "why_ref")
355
+ config = {
356
+ "schema_version": "1.0",
357
+ "stages": [stage.declaration() for stage in stages],
358
+ "expensive": expensive.declaration(),
359
+ "fallback": fallback.declaration(),
360
+ "predicate_ref": predicate_ref,
361
+ "why_ref": why_ref,
362
+ "on_unknown": on_unknown,
363
+ }
364
+ config_hash = fingerprint(config)
365
+ source = "conditional-config:" + config_hash
366
+
367
+ def invoke(request):
368
+ if (
369
+ expensive.declaration() != config["expensive"]
370
+ or fallback.declaration() != config["fallback"]
371
+ ):
372
+ raise ValueError("conditional implementation binding changed")
373
+ trace = current_trace()
374
+ receipt = {
375
+ "schema_version": "1.0",
376
+ "family": "conditional",
377
+ "config_hash": config_hash,
378
+ "why_ref": why_ref,
379
+ "changed_stages": [stage.declaration() for stage in stages],
380
+ "predicate": None,
381
+ "route": None,
382
+ "unknown_reason": None,
383
+ }
384
+ trace.metadata[STRUCTURAL_KEY] = receipt
385
+ _deadline(request)
386
+ usage = ResourceUsage(tokens=0, token_provenance="user_supplied", complete=True) # nosec B106
387
+ try:
388
+ value = predicate(request)
389
+ reported_usage = type(value) is ExperimentResult
390
+ if type(value) is ExperimentResult:
391
+ usage, value = value.usage, value.output
392
+ if value is not None and type(value) is not bool:
393
+ _close_invalid(value)
394
+ if not reported_usage:
395
+ usage = ResourceUsage()
396
+ value, receipt["unknown_reason"] = None, "invalid_predicate"
397
+ except Exception:
398
+ value, usage, receipt["unknown_reason"] = None, ResourceUsage(), "predicate_error"
399
+ known = type(value) is bool
400
+ route = ("expensive" if value else "fallback") if known else on_unknown
401
+ receipt.update(predicate=value, route=route, predicate_usage=usage.to_dict())
402
+ record_experiment_observations(
403
+ {
404
+ "conditional_predicate_known": observation(
405
+ known, unit="boolean", source_ref=source
406
+ ),
407
+ "conditional_predicate_value": observation(
408
+ value, unit="boolean", source_ref=source
409
+ ),
410
+ "conditional_route": observation(route, unit="state", source_ref=source),
411
+ "conditional_unknown": observation(not known, unit="boolean", source_ref=source),
412
+ }
413
+ )
414
+ if route == "error":
415
+ raise ValueError("conditional predicate is unknown")
416
+ _deadline(request)
417
+ result = (expensive if route == "expensive" else fallback).invoke(request)
418
+ if type(result) is not ExperimentResult:
419
+ _close_invalid(result)
420
+ raise ValueError("conditional implementation must return ExperimentResult")
421
+ receipt["selected_usage"] = result.usage.to_dict()
422
+ return ExperimentResult(result.output, usage=_sum_usage([usage, result.usage]))
423
+
424
+ return ExperimentRunner(
425
+ candidate_id,
426
+ "1.0",
427
+ "agentloop.conditional-execution:1.0",
428
+ "sha256:" + config_hash,
429
+ invoke,
430
+ reservation=reservation,
431
+ )
@@ -0,0 +1,113 @@
1
+ """Explicit stage identities and bounded batch requests for offline experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, field
7
+
8
+ from agentloop.budget_types import ResourceUsage
9
+ from agentloop.context_types import identifier
10
+ from agentloop.experiment_observations import record_experiment_observations
11
+ from agentloop.experiment_types import canonical, reference
12
+
13
+ STRUCTURAL_KEY = "agentloop.structural_experiment"
14
+
15
+
16
+ def publish_structural_observations(receipt, values):
17
+ try:
18
+ record_experiment_observations(values)
19
+ except BaseException:
20
+ receipt["capture_error"] = "observation_capture_failed"
21
+ if receipt["state"] not in {"failed", "abstained"}:
22
+ raise
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class StageReference:
27
+ stage_id: str
28
+ version: str
29
+ source_ref: str
30
+ kind: str = "transform"
31
+
32
+ def __post_init__(self):
33
+ identifier(self.stage_id, "stage_id")
34
+ identifier(self.version, "stage version")
35
+ reference(self.source_ref, "stage source")
36
+ if self.kind not in {
37
+ "tool",
38
+ "transform",
39
+ "classifier",
40
+ "rule",
41
+ "external_service",
42
+ "retriever",
43
+ "evaluator",
44
+ }:
45
+ raise ValueError("stage wrappers require a declared non-model operation kind")
46
+
47
+ def declaration(self):
48
+ return asdict(self)
49
+
50
+
51
+ @dataclass(frozen=True, init=False)
52
+ class BatchItem:
53
+ item_id: str
54
+ _json: str = field(repr=False)
55
+
56
+ def __init__(self, item_id, inputs):
57
+ identifier(item_id, "item_id")
58
+ object.__setattr__(self, "item_id", item_id)
59
+ object.__setattr__(self, "_json", canonical(inputs))
60
+
61
+ @property
62
+ def inputs(self):
63
+ return json.loads(self._json)
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class BatchRequest:
68
+ batch_id: str
69
+ items: tuple[BatchItem, ...]
70
+ request: object = field(repr=False)
71
+
72
+ @property
73
+ def remaining_s(self):
74
+ return self.request.remaining_s
75
+
76
+
77
+ @dataclass(frozen=True, init=False)
78
+ class BatchItemOutcome:
79
+ item_id: str
80
+ status: str
81
+ error_code: str | None
82
+ _json: str | None = field(repr=False)
83
+
84
+ def __init__(self, item_id, output=None, *, status="completed", error_code=None):
85
+ identifier(item_id, "item_id")
86
+ if status not in {"completed", "failed", "unknown"}:
87
+ raise ValueError("unsupported batch item status")
88
+ if error_code is not None:
89
+ identifier(error_code, "error_code")
90
+ object.__setattr__(self, "item_id", item_id)
91
+ object.__setattr__(self, "status", status)
92
+ object.__setattr__(self, "error_code", error_code)
93
+ object.__setattr__(self, "_json", canonical(output) if status == "completed" else None)
94
+
95
+ @property
96
+ def output(self):
97
+ return None if self._json is None else json.loads(self._json)
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class BatchResult:
102
+ items: tuple[BatchItemOutcome, ...]
103
+ usage: ResourceUsage = field(default_factory=ResourceUsage)
104
+
105
+ def __post_init__(self):
106
+ if (
107
+ not isinstance(self.items, (list, tuple))
108
+ or len(self.items) > 1024
109
+ or any(type(item) is not BatchItemOutcome for item in self.items)
110
+ or type(self.usage) is not ResourceUsage
111
+ ):
112
+ raise ValueError("batch result requires bounded item outcomes and exclusive usage")
113
+ object.__setattr__(self, "items", tuple(self.items))