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
agentloop/otel_ids.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Map arbitrary AgentLoop identifiers to valid OpenTelemetry trace/span ids.
|
|
2
|
+
|
|
3
|
+
OTLP requires trace ids to be 32 lowercase hex characters and span ids 16, with
|
|
4
|
+
a non-zero value. AgentLoop lets callers (and the API/local constructors) pick
|
|
5
|
+
custom run/event ids that may contain a prefix, non-hex characters, separators,
|
|
6
|
+
or all-zero values — the smoke script even uses a hyphenated run id. Exporting
|
|
7
|
+
those verbatim produced invalid ids that conforming OTLP consumers reject.
|
|
8
|
+
|
|
9
|
+
These helpers produce a valid, deterministic id for any native identifier while
|
|
10
|
+
leaving an already-valid id untouched, so:
|
|
11
|
+
|
|
12
|
+
- a valid OTLP id (correct width, non-zero lowercase hex) is preserved exactly,
|
|
13
|
+
including one imported into AgentLoop, so it round-trips back out unchanged;
|
|
14
|
+
- every other id — non-hex, wrong width (including the tracer's 16-hex run id
|
|
15
|
+
that is too short for a 32-char trace id), empty, or all-zero — is hashed with
|
|
16
|
+
SHA-256, which keeps distinct native ids distinct. In particular short hex ids
|
|
17
|
+
are NOT left-zero-padded: padding would map ``"a"`` and ``"0a"`` to the same
|
|
18
|
+
value and break within-trace uniqueness / parent linkage.
|
|
19
|
+
|
|
20
|
+
The exporters keep the original native id in span attributes so a remapped id
|
|
21
|
+
stays diagnosable.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
|
|
28
|
+
_HEX_DIGITS = frozenset("0123456789abcdef")
|
|
29
|
+
_TRACE_WIDTH = 32
|
|
30
|
+
_SPAN_WIDTH = 16
|
|
31
|
+
_TRACE_PREFIXES = ("run_", "trace_")
|
|
32
|
+
_SPAN_PREFIXES = ("span_", "evt_")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_valid_trace_id(value: str) -> bool:
|
|
36
|
+
"""True if ``value`` is a valid OTLP trace id (32 lowercase hex, non-zero)."""
|
|
37
|
+
return _is_valid(value, _TRACE_WIDTH)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_valid_span_id(value: str) -> bool:
|
|
41
|
+
"""True if ``value`` is a valid OTLP span id (16 lowercase hex, non-zero)."""
|
|
42
|
+
return _is_valid(value, _SPAN_WIDTH)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def to_trace_id(native: str) -> str:
|
|
46
|
+
"""Return a valid 32-char lowercase-hex OTLP trace id for ``native``."""
|
|
47
|
+
return _to_otel_id(native, _TRACE_WIDTH, _TRACE_PREFIXES)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def to_span_id(native: str) -> str:
|
|
51
|
+
"""Return a valid 16-char lowercase-hex OTLP span id for ``native``."""
|
|
52
|
+
return _to_otel_id(native, _SPAN_WIDTH, _SPAN_PREFIXES)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_valid(value: str, width: int) -> bool:
|
|
56
|
+
return len(value) == width and _HEX_DIGITS.issuperset(value) and set(value) != {"0"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _to_otel_id(native: str, width: int, prefixes: tuple[str, ...]) -> str:
|
|
60
|
+
stripped = _strip_prefix(str(native), prefixes).lower()
|
|
61
|
+
# An already-valid id (e.g. a real OTLP id, or one round-tripped through
|
|
62
|
+
# import) is preserved exactly. Everything else — non-hex, wrong width,
|
|
63
|
+
# empty, or all-zero — is hashed so distinct native ids stay distinct.
|
|
64
|
+
# Note: short hex ids are deliberately NOT zero-padded, because padding maps
|
|
65
|
+
# "a" and "0a" (and any leading-zero variants) to the same value.
|
|
66
|
+
if _is_valid(stripped, width):
|
|
67
|
+
return stripped
|
|
68
|
+
return _hash_id(native, width)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _strip_prefix(value: str, prefixes: tuple[str, ...]) -> str:
|
|
72
|
+
for prefix in prefixes:
|
|
73
|
+
if value.startswith(prefix):
|
|
74
|
+
return value[len(prefix) :]
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _hash_id(native: str, width: int) -> str:
|
|
79
|
+
digest = hashlib.sha256(str(native).encode("utf-8")).hexdigest()[:width]
|
|
80
|
+
# SHA-256 can't realistically be all-zero, but OTLP forbids it, so guard.
|
|
81
|
+
return ("1" + digest[1:]) if set(digest) == {"0"} else digest
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Normalized fields for pinned GenAI, OpenInference, and MCP conventions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agentloop.schema import TraceValidationError
|
|
9
|
+
|
|
10
|
+
INPUT_USAGE = ("gen_ai.usage.input_tokens", "llm.token_count.prompt", "llm.usage.prompt_tokens")
|
|
11
|
+
OUTPUT_USAGE = (
|
|
12
|
+
"gen_ai.usage.output_tokens",
|
|
13
|
+
"llm.token_count.completion",
|
|
14
|
+
"llm.usage.completion_tokens",
|
|
15
|
+
)
|
|
16
|
+
_OI_KINDS = {
|
|
17
|
+
"LLM": "model",
|
|
18
|
+
"EMBEDDING": "model",
|
|
19
|
+
"CHAIN": "workflow",
|
|
20
|
+
"AGENT": "agent",
|
|
21
|
+
"TOOL": "tool",
|
|
22
|
+
"RETRIEVER": "retriever",
|
|
23
|
+
"RERANKER": "reranker",
|
|
24
|
+
"GUARDRAIL": "guardrail",
|
|
25
|
+
"EVALUATOR": "evaluator",
|
|
26
|
+
}
|
|
27
|
+
_MEMORY_OPERATIONS = {
|
|
28
|
+
"create_memory",
|
|
29
|
+
"create_memory_store",
|
|
30
|
+
"delete_memory",
|
|
31
|
+
"delete_memory_store",
|
|
32
|
+
"search_memory",
|
|
33
|
+
"update_memory",
|
|
34
|
+
"upsert_memory",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extended_kind(attrs: dict[str, Any]) -> str | None:
|
|
39
|
+
operation = attrs.get("gen_ai.operation.name")
|
|
40
|
+
if isinstance(operation, str) and operation in _MEMORY_OPERATIONS:
|
|
41
|
+
return "memory"
|
|
42
|
+
if isinstance(operation, str) and operation in {"plan", "fetch_response"}:
|
|
43
|
+
return "unknown"
|
|
44
|
+
if operation:
|
|
45
|
+
return None
|
|
46
|
+
if "openinference.span.kind" in attrs:
|
|
47
|
+
return _OI_KINDS.get(str(attrs["openinference.span.kind"]).upper(), "unknown")
|
|
48
|
+
if "mcp.method.name" in attrs:
|
|
49
|
+
return {"tools/call": "tool", "resources/read": "retriever"}.get(
|
|
50
|
+
str(attrs["mcp.method.name"]), "unknown"
|
|
51
|
+
)
|
|
52
|
+
if any(key in attrs for key in ("http.request.method", "http.method", "rpc.system")):
|
|
53
|
+
return "unknown"
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def usage_count(attrs: dict[str, Any], keys: tuple[str, ...]) -> int:
|
|
58
|
+
for key in keys:
|
|
59
|
+
if key not in attrs:
|
|
60
|
+
continue
|
|
61
|
+
value = attrs[key]
|
|
62
|
+
if isinstance(value, bool) or not isinstance(value, int | str):
|
|
63
|
+
raise TraceValidationError(key, "usage must be a nonnegative integer")
|
|
64
|
+
try:
|
|
65
|
+
result = int(value)
|
|
66
|
+
except ValueError as exc:
|
|
67
|
+
raise TraceValidationError(key, "usage must be a nonnegative integer") from exc
|
|
68
|
+
if result < 0:
|
|
69
|
+
raise TraceValidationError(key, "usage must be a nonnegative integer")
|
|
70
|
+
return result
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def normalize_metadata(attrs: dict[str, Any], metadata: dict[str, Any]) -> None:
|
|
75
|
+
aliases = {
|
|
76
|
+
"provider": ("gen_ai.provider.name", "gen_ai.system", "llm.provider"),
|
|
77
|
+
"conversation_id": ("gen_ai.conversation.id",),
|
|
78
|
+
"session_id": ("session.id", "mcp.session.id"),
|
|
79
|
+
"tool_call_id": ("gen_ai.tool.call.id", "tool.id", "tool_call.id"),
|
|
80
|
+
"error_type": ("error.type", "exception.type"),
|
|
81
|
+
}
|
|
82
|
+
for target, keys in aliases.items():
|
|
83
|
+
for key in keys:
|
|
84
|
+
if isinstance(attrs.get(key), str):
|
|
85
|
+
metadata.setdefault(target, attrs[key])
|
|
86
|
+
break
|
|
87
|
+
for target, keys in {
|
|
88
|
+
"cached_input_tokens": (
|
|
89
|
+
"gen_ai.usage.cache_read.input_tokens",
|
|
90
|
+
"llm.token_count.prompt_details.cache_read",
|
|
91
|
+
),
|
|
92
|
+
"cache_write_input_tokens": (
|
|
93
|
+
"gen_ai.usage.cache_write.input_tokens",
|
|
94
|
+
"llm.token_count.prompt_details.cache_write",
|
|
95
|
+
),
|
|
96
|
+
"reasoning_output_tokens": (
|
|
97
|
+
"gen_ai.usage.reasoning.output_tokens",
|
|
98
|
+
"llm.token_count.completion_details.reasoning",
|
|
99
|
+
),
|
|
100
|
+
}.items():
|
|
101
|
+
if any(key in attrs for key in keys):
|
|
102
|
+
metadata.setdefault(target, usage_count(attrs, keys))
|
|
103
|
+
reported_cost = attrs.get("llm.cost.total")
|
|
104
|
+
if (
|
|
105
|
+
not isinstance(reported_cost, bool)
|
|
106
|
+
and isinstance(reported_cost, int | float)
|
|
107
|
+
and math.isfinite(reported_cost)
|
|
108
|
+
and reported_cost >= 0
|
|
109
|
+
):
|
|
110
|
+
metadata.setdefault("provider_reported_cost_usd", reported_cost)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def evaluation_result(attrs: dict[str, Any], *, source: str) -> dict[str, Any] | None:
|
|
114
|
+
families = (
|
|
115
|
+
(
|
|
116
|
+
"gen_ai.evaluation.name",
|
|
117
|
+
"gen_ai.evaluation.score.value",
|
|
118
|
+
"gen_ai.evaluation.score.label",
|
|
119
|
+
"gen_ai.evaluation.explanation",
|
|
120
|
+
),
|
|
121
|
+
("evaluation.name", "evaluation.score", "evaluation.label", "evaluation.explanation"),
|
|
122
|
+
("annotation.name", "annotation.score", "annotation.label", "annotation.explanation"),
|
|
123
|
+
)
|
|
124
|
+
for name, score, label, explanation in families:
|
|
125
|
+
if name in attrs:
|
|
126
|
+
return {
|
|
127
|
+
"source": source,
|
|
128
|
+
"name": attrs[name],
|
|
129
|
+
"score": attrs.get(score),
|
|
130
|
+
"label": attrs.get(label),
|
|
131
|
+
"explanation": attrs.get(explanation),
|
|
132
|
+
"interpretation": "external evaluation; scale and acceptance criteria are source-defined",
|
|
133
|
+
}
|
|
134
|
+
return None
|
agentloop/parallelism.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Conservative concurrency candidates shared by reports and execution graphs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agentloop.operations import operation_kind
|
|
9
|
+
from agentloop.timing import duration_ms, event_interval_ms
|
|
10
|
+
from agentloop.workflow_types import stage_summary
|
|
11
|
+
|
|
12
|
+
PARALLELISM_NOTICE = (
|
|
13
|
+
"Repeated tool calls may be candidates for concurrent execution. "
|
|
14
|
+
"Verify output independence and shared-state safety before changing them."
|
|
15
|
+
)
|
|
16
|
+
PARALLELISM_REWRITE = (
|
|
17
|
+
"After verifying output independence and shared-state safety, use asyncio.gather "
|
|
18
|
+
"or ThreadPoolExecutor and validate the change with replay."
|
|
19
|
+
)
|
|
20
|
+
PARALLELISM_FORMULA = (
|
|
21
|
+
"sum(tool durations) - max(tool duration), assuming the calls can run concurrently"
|
|
22
|
+
)
|
|
23
|
+
_ASSUMPTIONS = (
|
|
24
|
+
"No call consumes another call's output.",
|
|
25
|
+
"Calls do not conflict through shared mutable state.",
|
|
26
|
+
"Calls are on the runtime-limiting path and scheduling adds no overhead.",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parallelization_candidates(
|
|
31
|
+
events: Iterable[Any], *, dependency_edges: Iterable[tuple[str, str]] = ()
|
|
32
|
+
) -> list[dict[str, Any]]:
|
|
33
|
+
"""Return hypotheses, never proof of independence or verified savings.
|
|
34
|
+
|
|
35
|
+
Optional metadata: ``parallel_safe`` is a boolean declaration, ``depends_on``
|
|
36
|
+
is a list of span IDs. Explicit relationships take precedence over declarations.
|
|
37
|
+
Inferred sequence edges must not be passed as causal dependencies.
|
|
38
|
+
"""
|
|
39
|
+
by_id = {}
|
|
40
|
+
groups: dict[tuple[str, str | None], list[str]] = {}
|
|
41
|
+
dependencies: dict[str, set[str]] = {}
|
|
42
|
+
invalid_dependencies: set[str] = set()
|
|
43
|
+
unsafe: set[str] = set()
|
|
44
|
+
declarations: set[str] = set()
|
|
45
|
+
for index, event in enumerate(events):
|
|
46
|
+
span_id = (
|
|
47
|
+
getattr(event, "event_id", None) or getattr(event, "node_id", None) or f"node_{index}"
|
|
48
|
+
)
|
|
49
|
+
if span_id in by_id:
|
|
50
|
+
return [] # Ambiguous span identity cannot support dependency analysis.
|
|
51
|
+
by_id[span_id] = event
|
|
52
|
+
metadata = getattr(event, "metadata", None)
|
|
53
|
+
metadata = metadata if isinstance(metadata, Mapping) else {}
|
|
54
|
+
required = metadata.get("depends_on", [])
|
|
55
|
+
if not isinstance(required, list) or any(
|
|
56
|
+
not isinstance(value, str) or not value for value in required
|
|
57
|
+
):
|
|
58
|
+
invalid_dependencies.add(span_id)
|
|
59
|
+
required = []
|
|
60
|
+
dependencies[span_id] = set(required)
|
|
61
|
+
stage = stage_summary(event)
|
|
62
|
+
if stage is not None and (
|
|
63
|
+
stage.get("schema_status") != "supported" or stage.get("dependency_status") == "invalid"
|
|
64
|
+
):
|
|
65
|
+
invalid_dependencies.add(span_id)
|
|
66
|
+
if "parallel_safe" in metadata:
|
|
67
|
+
if metadata["parallel_safe"] is True:
|
|
68
|
+
declarations.add(span_id)
|
|
69
|
+
else:
|
|
70
|
+
unsafe.add(span_id)
|
|
71
|
+
if operation_kind(event) == "tool":
|
|
72
|
+
groups.setdefault((event.name, getattr(event, "parent_id", None)), []).append(span_id)
|
|
73
|
+
for span_id, event in by_id.items():
|
|
74
|
+
parent_id = getattr(event, "parent_id", None)
|
|
75
|
+
if parent_id in by_id:
|
|
76
|
+
dependencies[span_id].add(parent_id)
|
|
77
|
+
for source, target in dependency_edges:
|
|
78
|
+
if target in dependencies:
|
|
79
|
+
dependencies[target].add(source)
|
|
80
|
+
|
|
81
|
+
candidates = []
|
|
82
|
+
for (name, _), span_ids in groups.items():
|
|
83
|
+
members = set(span_ids)
|
|
84
|
+
if len(members) < 3 or members & unsafe or members & invalid_dependencies:
|
|
85
|
+
continue
|
|
86
|
+
pending = [ancestor for member in members for ancestor in dependencies[member]]
|
|
87
|
+
visited = set()
|
|
88
|
+
blocked = False
|
|
89
|
+
while pending:
|
|
90
|
+
ancestor = pending.pop()
|
|
91
|
+
if ancestor in members or ancestor not in by_id or ancestor in invalid_dependencies:
|
|
92
|
+
blocked = True
|
|
93
|
+
break
|
|
94
|
+
if ancestor not in visited:
|
|
95
|
+
visited.add(ancestor)
|
|
96
|
+
pending.extend(dependencies[ancestor])
|
|
97
|
+
if blocked:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
intervals = [event_interval_ms(by_id[span_id]) for span_id in span_ids]
|
|
101
|
+
known = sorted(interval for interval in intervals if interval is not None)
|
|
102
|
+
if any(previous[1] > current[0] for previous, current in zip(known, known[1:])):
|
|
103
|
+
continue # Observed overlap already invalidates the serial-savings model.
|
|
104
|
+
complete_timing = len(known) == len(span_ids)
|
|
105
|
+
durations = [duration_ms(by_id[span_id]) for span_id in span_ids]
|
|
106
|
+
sequential, parallel = sum(durations), max(durations)
|
|
107
|
+
if sequential <= parallel:
|
|
108
|
+
continue
|
|
109
|
+
declared = members <= declarations
|
|
110
|
+
assumptions = list(_ASSUMPTIONS)
|
|
111
|
+
if not complete_timing:
|
|
112
|
+
assumptions.append("Calls ran serially; timing is incomplete or inconsistent.")
|
|
113
|
+
candidates.append(
|
|
114
|
+
{
|
|
115
|
+
"name": name,
|
|
116
|
+
"event_type": "tool_call",
|
|
117
|
+
"count": len(span_ids),
|
|
118
|
+
"node_ids": sorted(span_ids),
|
|
119
|
+
"sequential_time_ms": round(sequential, 3),
|
|
120
|
+
"estimated_parallel_time_ms": round(parallel, 3),
|
|
121
|
+
"estimated_savings_ms": round(sequential - parallel, 3),
|
|
122
|
+
"evidence_level": "declared" if declared else "inferred",
|
|
123
|
+
"confidence": "medium" if declared and complete_timing else "low",
|
|
124
|
+
"assumptions": assumptions,
|
|
125
|
+
"observations": {
|
|
126
|
+
"evidence_level": "observed",
|
|
127
|
+
"tool_call_count": len(span_ids),
|
|
128
|
+
"cumulative_span_time_ms": round(sequential, 3),
|
|
129
|
+
"timing": "non_overlapping" if complete_timing else "unavailable",
|
|
130
|
+
},
|
|
131
|
+
"description": (
|
|
132
|
+
"The integration declares these calls safe to run concurrently; validate with replay."
|
|
133
|
+
if declared
|
|
134
|
+
else PARALLELISM_NOTICE
|
|
135
|
+
),
|
|
136
|
+
"estimate_formula": PARALLELISM_FORMULA,
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
return sorted(
|
|
140
|
+
candidates, key=lambda item: (-item["estimated_savings_ms"], item["name"], item["node_ids"])
|
|
141
|
+
)
|