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