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.
- agentloop/__init__.py +136 -0
- agentloop/ablation_protocol.py +229 -0
- agentloop/ablations.py +564 -0
- agentloop/aggregate_cli.py +88 -0
- agentloop/aggregate_stats.py +175 -0
- agentloop/aggregates.py +532 -0
- agentloop/audit.py +39 -0
- agentloop/autoinstrument.py +145 -0
- agentloop/batch_experiments.py +295 -0
- agentloop/batching.py +95 -0
- agentloop/budget_types.py +335 -0
- agentloop/budgets.py +539 -0
- agentloop/calibration.py +872 -0
- agentloop/checkpoint_state.py +306 -0
- agentloop/checkpoint_store.py +86 -0
- agentloop/ci.py +202 -0
- agentloop/cli.py +1314 -0
- agentloop/client.py +228 -0
- agentloop/completion.py +345 -0
- agentloop/completion_types.py +199 -0
- agentloop/config.py +50 -0
- agentloop/context_controls.py +443 -0
- agentloop/context_experiments.py +337 -0
- agentloop/context_types.py +216 -0
- agentloop/costs.py +667 -0
- agentloop/cursor_validation.py +48 -0
- agentloop/decorators.py +445 -0
- agentloop/demo.py +300 -0
- agentloop/doctor.py +341 -0
- agentloop/drift.py +361 -0
- agentloop/drift_cli.py +147 -0
- agentloop/drift_types.py +110 -0
- agentloop/drift_windows.py +340 -0
- agentloop/entrypoint.py +201 -0
- agentloop/estimates.py +133 -0
- agentloop/events.py +75 -0
- agentloop/experiment_artifacts.py +80 -0
- agentloop/experiment_observations.py +169 -0
- agentloop/experiment_reports.py +545 -0
- agentloop/experiment_types.py +355 -0
- agentloop/experiments.py +392 -0
- agentloop/exporters.py +117 -0
- agentloop/finding_benchmark_types.py +131 -0
- agentloop/finding_benchmarks.py +424 -0
- agentloop/findings.py +419 -0
- agentloop/graph.py +464 -0
- agentloop/harness.py +825 -0
- agentloop/harness_bench/__init__.py +1 -0
- agentloop/harness_bench/cli.py +77 -0
- agentloop/harness_bench/drivers.py +96 -0
- agentloop/harness_bench/offline.py +507 -0
- agentloop/harness_evidence.py +585 -0
- agentloop/harness_usage.py +91 -0
- agentloop/html_report.py +860 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/harbor/__init__.py +1 -0
- agentloop/integrations/harbor/atif.py +982 -0
- agentloop/integrations/harbor/atif_validation.py +329 -0
- agentloop/integrations/harbor/cli.py +207 -0
- agentloop/integrations/harbor/manifest.py +86 -0
- agentloop/integrations/harbor/trial_evidence.py +196 -0
- agentloop/integrations/harbor/trials.py +1109 -0
- agentloop/integrations/harbor/verifier.py +118 -0
- agentloop/integrations/langgraph.py +150 -0
- agentloop/integrations/langgraph_harness.py +466 -0
- agentloop/integrations/langgraph_recovery.py +366 -0
- agentloop/integrations/omnigent/__init__.py +1 -0
- agentloop/integrations/omnigent/cli.py +78 -0
- agentloop/integrations/omnigent/evidence.py +93 -0
- agentloop/integrations/omnigent/telemetry.py +422 -0
- agentloop/integrations/openai.py +415 -0
- agentloop/integrations/openai_agents.py +291 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/interoperability/__init__.py +5 -0
- agentloop/interoperability/artifacts.py +112 -0
- agentloop/interoperability/cli.py +59 -0
- agentloop/interoperability/cohort_evidence.py +175 -0
- agentloop/interoperability/cohorts.py +681 -0
- agentloop/interoperability/contracts.py +427 -0
- agentloop/interoperability/coordination.py +554 -0
- agentloop/interoperability/evidence.py +215 -0
- agentloop/interoperability/otlp_jsonl.py +775 -0
- agentloop/interoperability/otlp_validation.py +496 -0
- agentloop/interoperability/registry.py +561 -0
- agentloop/interoperability/validation.py +333 -0
- agentloop/intervention_service.py +110 -0
- agentloop/intervention_store.py +118 -0
- agentloop/interventions.py +190 -0
- agentloop/issues.py +90 -0
- agentloop/judgment_adapters.py +125 -0
- agentloop/judgment_benchmark_types.py +98 -0
- agentloop/judgment_benchmarks.py +606 -0
- agentloop/judgment_types.py +241 -0
- agentloop/judgment_views.py +38 -0
- agentloop/judgments.py +342 -0
- agentloop/loop_guards.py +274 -0
- agentloop/loop_types.py +66 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +342 -0
- agentloop/migrations.py +303 -0
- agentloop/model_routing.py +470 -0
- agentloop/onboarding.py +315 -0
- agentloop/operations.py +44 -0
- agentloop/optimizer.py +199 -0
- agentloop/otel.py +715 -0
- agentloop/otel_ids.py +81 -0
- agentloop/otel_semantics.py +134 -0
- agentloop/parallelism.py +141 -0
- agentloop/patches.py +603 -0
- agentloop/plan_export.py +126 -0
- agentloop/policy_promotion.py +325 -0
- agentloop/promotion_evidence.py +304 -0
- agentloop/promotion_types.py +209 -0
- agentloop/quality.py +446 -0
- agentloop/ranking.py +529 -0
- agentloop/recovery_evidence.py +96 -0
- agentloop/replay.py +753 -0
- agentloop/retention.py +375 -0
- agentloop/retention_types.py +113 -0
- agentloop/reuse_experiments.py +247 -0
- agentloop/routing_checks.py +116 -0
- agentloop/routing_types.py +241 -0
- agentloop/rules.py +396 -0
- agentloop/runtime.py +286 -0
- agentloop/savings.py +158 -0
- agentloop/scheduling_plan.py +99 -0
- agentloop/scheduling_types.py +169 -0
- agentloop/schema.py +257 -0
- agentloop/semantic_rules.py +200 -0
- agentloop/semantic_waste.py +345 -0
- agentloop/semantic_waste_types.py +88 -0
- agentloop/server.py +494 -0
- agentloop/store.py +1429 -0
- agentloop/structural_experiments.py +431 -0
- agentloop/structural_types.py +113 -0
- agentloop/structured_quality.py +1020 -0
- agentloop/studies.py +629 -0
- agentloop/study_statistics.py +58 -0
- agentloop/substitution_evidence.py +199 -0
- agentloop/substitution_export.py +190 -0
- agentloop/substitution_reports.py +407 -0
- agentloop/substitution_types.py +284 -0
- agentloop/substitutions.py +267 -0
- agentloop/timing.py +142 -0
- agentloop/tokens.py +188 -0
- agentloop/tool_scheduling.py +410 -0
- agentloop/trace_sources.py +31 -0
- agentloop/tracer.py +494 -0
- agentloop/value.py +306 -0
- agentloop/version.py +3 -0
- agentloop/workflow_types.py +264 -0
- agentloop/workflows.py +284 -0
- agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
- agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
- agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
- agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +5 -0
- dashboard/app.py +922 -0
- dashboard/streamlit_compat.py +63 -0
- dashboard/trace_upload.py +57 -0
- 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
|