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/loop_guards.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Operational guardrails for declared retries and repeated caller state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from agentloop.budget_types import RETRY_SOURCES, count
|
|
11
|
+
from agentloop.harness import BOUNDARIES, Decision, Hook, HookContext, Policy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class LoopLimits:
|
|
16
|
+
"""Retry caps and optional bounded-history repetition/oscillation limits."""
|
|
17
|
+
|
|
18
|
+
max_retries_per_step: int | None = 3
|
|
19
|
+
max_identical_calls: int | None = None
|
|
20
|
+
oscillation_period: int | None = None
|
|
21
|
+
oscillation_repeats: int = 3
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
count(self.max_retries_per_step, "max_retries_per_step")
|
|
25
|
+
count(self.max_identical_calls, "max_identical_calls")
|
|
26
|
+
if self.max_identical_calls == 0:
|
|
27
|
+
raise ValueError("max_identical_calls must allow at least one call")
|
|
28
|
+
if self.max_identical_calls is not None and self.max_identical_calls > 256:
|
|
29
|
+
raise ValueError("identical-call history is bounded to 256 calls")
|
|
30
|
+
if self.oscillation_period is not None and (
|
|
31
|
+
type(self.oscillation_period) is not int or not 2 <= self.oscillation_period <= 32
|
|
32
|
+
):
|
|
33
|
+
raise ValueError("oscillation_period must be between 2 and 32")
|
|
34
|
+
if type(self.oscillation_repeats) is not int or not 2 <= self.oscillation_repeats <= 8:
|
|
35
|
+
raise ValueError("oscillation_repeats must be between 2 and 8")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _StepState:
|
|
40
|
+
retries: int = 0
|
|
41
|
+
retries_in_flight: int = 0
|
|
42
|
+
retry_sources: dict[str, int] = field(default_factory=lambda: dict.fromkeys(RETRY_SOURCES, 0))
|
|
43
|
+
history: deque = field(default_factory=deque)
|
|
44
|
+
pending: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
last_status: str | None = None
|
|
46
|
+
last_decision_id: str | None = None
|
|
47
|
+
last_mutating: bool | None = None
|
|
48
|
+
last_arguments: str | None = None
|
|
49
|
+
active_calls: int = 0
|
|
50
|
+
mutating_in_flight: int = 0
|
|
51
|
+
semantic_epoch: int = 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _LoopGuard:
|
|
55
|
+
def __init__(self, limits: LoopLimits) -> None:
|
|
56
|
+
self.limits = limits
|
|
57
|
+
self.window = max(
|
|
58
|
+
limits.max_identical_calls or 1,
|
|
59
|
+
(limits.oscillation_period or 1) * limits.oscillation_repeats,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _feedback(
|
|
63
|
+
self,
|
|
64
|
+
context: HookContext,
|
|
65
|
+
state: _StepState,
|
|
66
|
+
action: str,
|
|
67
|
+
reason: str,
|
|
68
|
+
message: str,
|
|
69
|
+
retry_of: str | None = None,
|
|
70
|
+
) -> Decision:
|
|
71
|
+
step = context.dispatch.step
|
|
72
|
+
refs = []
|
|
73
|
+
if step is not None:
|
|
74
|
+
refs.append(f"step:{step.step_id}")
|
|
75
|
+
if step.argument_fingerprint is not None:
|
|
76
|
+
refs.append(f"arguments:sha256:{step.argument_fingerprint}")
|
|
77
|
+
if step.progress_fingerprint is not None:
|
|
78
|
+
refs.append(f"progress:sha256:{step.progress_fingerprint}")
|
|
79
|
+
if context.dispatch.retry_source is not None:
|
|
80
|
+
refs.append(f"retry_source:{context.dispatch.retry_source}")
|
|
81
|
+
return Decision(
|
|
82
|
+
action, reason, evidence_refs=tuple(refs), retry_of=retry_of, feedback=message
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _oscillating(self, history: list[tuple[str, str]]) -> bool:
|
|
86
|
+
maximum = self.limits.oscillation_period
|
|
87
|
+
if maximum is None:
|
|
88
|
+
return False
|
|
89
|
+
for period in range(2, maximum + 1):
|
|
90
|
+
size = period * self.limits.oscillation_repeats
|
|
91
|
+
if len(history) < size:
|
|
92
|
+
continue
|
|
93
|
+
tail = history[-size:]
|
|
94
|
+
pattern = tail[:period]
|
|
95
|
+
if len(set(pattern)) > 1 and all(
|
|
96
|
+
tail[index] == pattern[index % period] for index in range(size)
|
|
97
|
+
):
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
def __call__(self, context: HookContext) -> Decision:
|
|
102
|
+
step = context.dispatch.step
|
|
103
|
+
if step is None:
|
|
104
|
+
if context.dispatch.retry_source is not None:
|
|
105
|
+
return Decision(
|
|
106
|
+
"escalate",
|
|
107
|
+
"retry_step_unknown",
|
|
108
|
+
feedback="Retry admission needs an explicit step identity and declared safe semantics.",
|
|
109
|
+
)
|
|
110
|
+
return Decision(
|
|
111
|
+
"continue",
|
|
112
|
+
"loop_evidence_unavailable",
|
|
113
|
+
feedback="No step identity was supplied; no semantic no-progress conclusion is available.",
|
|
114
|
+
)
|
|
115
|
+
states = context.state.setdefault("steps", {})
|
|
116
|
+
key = (context.branch_id, step.step_id)
|
|
117
|
+
state = states.get(key)
|
|
118
|
+
if state is None:
|
|
119
|
+
state = states[key] = _StepState(history=deque(maxlen=self.window))
|
|
120
|
+
retry = context.dispatch.retry_source is not None
|
|
121
|
+
if context.hook.phase == "after":
|
|
122
|
+
pending = state.pending.pop(context.call_id, None)
|
|
123
|
+
if pending is not None:
|
|
124
|
+
if pending["admitted"]:
|
|
125
|
+
state.active_calls -= 1
|
|
126
|
+
if pending["mutating"] is True:
|
|
127
|
+
state.mutating_in_flight -= 1
|
|
128
|
+
if pending["retry_reserved"]:
|
|
129
|
+
state.retries_in_flight -= 1
|
|
130
|
+
if context.dispatched:
|
|
131
|
+
if pending["overlapped"] or pending["reset_history"]:
|
|
132
|
+
state.history.clear()
|
|
133
|
+
state.semantic_epoch += 1
|
|
134
|
+
if pending["retry"]:
|
|
135
|
+
state.retries += 1
|
|
136
|
+
state.retry_sources[pending["retry_source"]] += 1
|
|
137
|
+
if pending["history"] is not None and pending["epoch"] == state.semantic_epoch:
|
|
138
|
+
state.history.append(pending["history"])
|
|
139
|
+
state.last_status = context.status
|
|
140
|
+
state.last_decision_id = pending["decision_id"]
|
|
141
|
+
state.last_mutating = step.mutating
|
|
142
|
+
state.last_arguments = step.argument_fingerprint
|
|
143
|
+
return self._feedback(
|
|
144
|
+
context,
|
|
145
|
+
state,
|
|
146
|
+
"continue",
|
|
147
|
+
"loop_attempt_recorded",
|
|
148
|
+
"Attempt outcome recorded; retries remain host-controlled.",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
prior = state.last_decision_id if retry else None
|
|
152
|
+
action, reason = "continue", "loop_continue"
|
|
153
|
+
message = "The declared step is within its configured operational limits."
|
|
154
|
+
evidence = None
|
|
155
|
+
reset_history = retry or step.polling
|
|
156
|
+
overlapped = state.active_calls > 0
|
|
157
|
+
retry_count = state.retries + state.retries_in_flight
|
|
158
|
+
ambiguous_repeat = (
|
|
159
|
+
not retry
|
|
160
|
+
and state.last_status in {"error", "cancelled", "closed"}
|
|
161
|
+
and step.argument_fingerprint is not None
|
|
162
|
+
and step.argument_fingerprint == state.last_arguments
|
|
163
|
+
and (step.mutating is not False or state.last_mutating is not False)
|
|
164
|
+
and not step.retry_safe
|
|
165
|
+
)
|
|
166
|
+
if ambiguous_repeat:
|
|
167
|
+
action, reason = "escalate", "ambiguous_side_effect_repeat"
|
|
168
|
+
message = "Identical input follows an ambiguous potentially mutating attempt; explicit safe retry semantics are required."
|
|
169
|
+
elif retry:
|
|
170
|
+
mutating_in_flight = state.mutating_in_flight > 0
|
|
171
|
+
safe = step.retry_safe or (
|
|
172
|
+
step.mutating is False
|
|
173
|
+
and state.last_mutating is not True
|
|
174
|
+
and not mutating_in_flight
|
|
175
|
+
)
|
|
176
|
+
if not safe:
|
|
177
|
+
action, reason = "escalate", "retry_safety_unknown"
|
|
178
|
+
message = "Retrying a potentially mutating step requires declared safe retry semantics; prior side effects may be ambiguous."
|
|
179
|
+
elif (
|
|
180
|
+
self.limits.max_retries_per_step is not None
|
|
181
|
+
and retry_count >= self.limits.max_retries_per_step
|
|
182
|
+
):
|
|
183
|
+
action, reason = "stop", "step_retry_limit"
|
|
184
|
+
message = "The step reached its configured retry limit; no additional attempt is admitted."
|
|
185
|
+
else:
|
|
186
|
+
message = f"Declared {context.dispatch.retry_source} retry admitted within its bound; no-progress heuristics are not applied to retries."
|
|
187
|
+
elif step.polling:
|
|
188
|
+
reason = "declared_polling"
|
|
189
|
+
message = (
|
|
190
|
+
"Declared polling bypasses semantic repetition guards; global budgets still apply."
|
|
191
|
+
)
|
|
192
|
+
elif step.argument_fingerprint is None or step.progress_fingerprint is None:
|
|
193
|
+
reset_history = True
|
|
194
|
+
reason = "loop_evidence_unavailable"
|
|
195
|
+
message = "Input/progress evidence is incomplete; repeated names alone do not establish a no-progress loop."
|
|
196
|
+
else:
|
|
197
|
+
evidence = (step.argument_fingerprint, step.progress_fingerprint)
|
|
198
|
+
if overlapped:
|
|
199
|
+
evidence = None
|
|
200
|
+
reset_history = True
|
|
201
|
+
reason = "loop_concurrent_evidence"
|
|
202
|
+
message = "Overlapping attempts do not establish a sequential no-progress cycle; global budgets still apply."
|
|
203
|
+
history = list(state.history)
|
|
204
|
+
repeated = 0
|
|
205
|
+
for previous in reversed(history):
|
|
206
|
+
if previous != evidence:
|
|
207
|
+
break
|
|
208
|
+
repeated += 1
|
|
209
|
+
if (
|
|
210
|
+
evidence is not None
|
|
211
|
+
and self.limits.max_identical_calls is not None
|
|
212
|
+
and repeated >= self.limits.max_identical_calls
|
|
213
|
+
):
|
|
214
|
+
action, reason = "stop", "identical_step_limit"
|
|
215
|
+
message = f"Unchanged input/progress fingerprints reached the configured bound ({self.limits.max_identical_calls}); this is not proof of semantic uselessness."
|
|
216
|
+
elif evidence is not None and self._oscillating(history + [evidence]):
|
|
217
|
+
action, reason = "stop", "oscillation_limit"
|
|
218
|
+
message = "Caller-declared input/progress fingerprints repeat a configured cycle; validate the task-specific meaning before changing the workflow."
|
|
219
|
+
admitted = action == "continue" or context.mode == "shadow"
|
|
220
|
+
retry_reserved = retry and admitted
|
|
221
|
+
if retry_reserved:
|
|
222
|
+
state.retries_in_flight += 1
|
|
223
|
+
if admitted:
|
|
224
|
+
state.active_calls += 1
|
|
225
|
+
if step.mutating is True:
|
|
226
|
+
state.mutating_in_flight += 1
|
|
227
|
+
state.pending[context.call_id] = {
|
|
228
|
+
"retry": retry,
|
|
229
|
+
"retry_source": context.dispatch.retry_source,
|
|
230
|
+
"retry_reserved": retry_reserved,
|
|
231
|
+
"history": evidence if admitted else None,
|
|
232
|
+
"decision_id": context.decision_id,
|
|
233
|
+
"admitted": admitted,
|
|
234
|
+
"reset_history": reset_history,
|
|
235
|
+
"overlapped": overlapped,
|
|
236
|
+
"mutating": step.mutating,
|
|
237
|
+
"epoch": state.semantic_epoch,
|
|
238
|
+
}
|
|
239
|
+
return self._feedback(context, state, action, reason, message, retry_of=prior)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def loop_guard_policy(
|
|
243
|
+
limits: LoopLimits = LoopLimits(),
|
|
244
|
+
*,
|
|
245
|
+
policy_id: str = "loop_guard",
|
|
246
|
+
priority: int = 200,
|
|
247
|
+
boundaries: Iterable[str] = BOUNDARIES,
|
|
248
|
+
) -> Policy:
|
|
249
|
+
"""Compose with budget_policy for total iteration/retry/resource admission."""
|
|
250
|
+
if type(limits) is not LoopLimits:
|
|
251
|
+
raise ValueError("limits must be LoopLimits")
|
|
252
|
+
try:
|
|
253
|
+
selected = frozenset(boundaries)
|
|
254
|
+
except TypeError:
|
|
255
|
+
raise ValueError("loop boundaries must be named hooks") from None
|
|
256
|
+
if not selected or not selected <= BOUNDARIES:
|
|
257
|
+
raise ValueError("unsupported loop boundary")
|
|
258
|
+
return Policy(
|
|
259
|
+
policy_id,
|
|
260
|
+
"1.0",
|
|
261
|
+
_LoopGuard(limits),
|
|
262
|
+
hooks=frozenset(
|
|
263
|
+
Hook(boundary, phase) for boundary in selected for phase in ("before", "after")
|
|
264
|
+
),
|
|
265
|
+
actions=frozenset({"continue", "stop", "escalate"}),
|
|
266
|
+
priority=priority,
|
|
267
|
+
configuration={
|
|
268
|
+
"limits": asdict(limits),
|
|
269
|
+
"automatic_retries": False,
|
|
270
|
+
"semantic_progress_source": "caller_declared",
|
|
271
|
+
"history_bounded": True,
|
|
272
|
+
"boundaries": sorted(selected),
|
|
273
|
+
},
|
|
274
|
+
)
|
agentloop/loop_types.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Explicit, payload-free evidence for operational retry and loop guards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from hashlib import sha256
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
_DIGEST = re.compile(r"[a-f0-9]{64}\Z")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fingerprint(value: Any) -> str:
|
|
16
|
+
"""Hash selected finite JSON values; never serialize arbitrary object reprs.
|
|
17
|
+
|
|
18
|
+
Mapping order is ignored. Represent meaningful order as a list. Inputs are
|
|
19
|
+
not retained, but hashes of predictable or sensitive values are not anonymous.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def validate(item: Any) -> None:
|
|
23
|
+
if item is None or type(item) in {str, bool, int}:
|
|
24
|
+
return
|
|
25
|
+
if type(item) is float and math.isfinite(item):
|
|
26
|
+
return
|
|
27
|
+
if type(item) is list:
|
|
28
|
+
for child in item:
|
|
29
|
+
validate(child)
|
|
30
|
+
return
|
|
31
|
+
if type(item) is dict and all(type(key) is str for key in item):
|
|
32
|
+
for child in item.values():
|
|
33
|
+
validate(child)
|
|
34
|
+
return
|
|
35
|
+
raise ValueError("fingerprint inputs must be finite JSON values with string keys")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
validate(value)
|
|
39
|
+
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
|
40
|
+
except RecursionError:
|
|
41
|
+
raise ValueError("fingerprint inputs must not contain cycles") from None
|
|
42
|
+
return sha256(encoded).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class StepInfo:
|
|
47
|
+
"""Caller-declared step identity, state evidence, polling, and retry safety."""
|
|
48
|
+
|
|
49
|
+
step_id: str
|
|
50
|
+
argument_fingerprint: str | None = None
|
|
51
|
+
progress_fingerprint: str | None = None
|
|
52
|
+
polling: bool = False
|
|
53
|
+
mutating: bool | None = None
|
|
54
|
+
retry_safe: bool = False
|
|
55
|
+
|
|
56
|
+
def __post_init__(self) -> None:
|
|
57
|
+
if not isinstance(self.step_id, str) or not self.step_id:
|
|
58
|
+
raise ValueError("step_id must be a nonempty opaque identifier")
|
|
59
|
+
for name in ("argument_fingerprint", "progress_fingerprint"):
|
|
60
|
+
value = getattr(self, name)
|
|
61
|
+
if value is not None and (not isinstance(value, str) or not _DIGEST.fullmatch(value)):
|
|
62
|
+
raise ValueError("step fingerprints must be SHA-256 hex digests")
|
|
63
|
+
if type(self.polling) is not bool or type(self.retry_safe) is not bool:
|
|
64
|
+
raise ValueError("polling and retry_safe must be booleans")
|
|
65
|
+
if self.mutating is not None and type(self.mutating) is not bool:
|
|
66
|
+
raise ValueError("mutating must be a boolean or unknown")
|
agentloop/markdown.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
_LINE_BREAKS = re.compile(r"\r\n?|\n|\x85|\u2028|\u2029")
|
|
7
|
+
_BACKTICK_RUNS = re.compile(r"`+")
|
|
8
|
+
_SAFE_INFO_STRING = re.compile(r"[A-Za-z0-9_.+-]*\Z")
|
|
9
|
+
|
|
10
|
+
# Markdown treats these characters as inline syntax, while HTML parsers give
|
|
11
|
+
# angle brackets and ampersands additional meaning. Character references keep
|
|
12
|
+
# the rendered text unchanged without relying on escape ordering.
|
|
13
|
+
_INLINE_ESCAPES = {
|
|
14
|
+
"&": "&",
|
|
15
|
+
"<": "<",
|
|
16
|
+
">": ">",
|
|
17
|
+
"\\": "\",
|
|
18
|
+
"`": "`",
|
|
19
|
+
"*": "*",
|
|
20
|
+
"_": "_",
|
|
21
|
+
"[": "[",
|
|
22
|
+
"]": "]",
|
|
23
|
+
"~": "~",
|
|
24
|
+
}
|
|
25
|
+
_TABLE_TRANSLATION = str.maketrans({"|": "|"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def markdown_text(value: Any) -> str:
|
|
29
|
+
"""Render an untrusted value as single-line Markdown text."""
|
|
30
|
+
|
|
31
|
+
content = _single_line(value)
|
|
32
|
+
return "".join(
|
|
33
|
+
character
|
|
34
|
+
if character == "_" and _underscore_is_inside_identifier(content, index)
|
|
35
|
+
else _INLINE_ESCAPES.get(character, character)
|
|
36
|
+
for index, character in enumerate(content)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def markdown_heading(value: Any) -> str:
|
|
41
|
+
"""Render an untrusted value inside a heading without allowing new blocks."""
|
|
42
|
+
|
|
43
|
+
return markdown_text(value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def markdown_table_cell(value: Any) -> str:
|
|
47
|
+
"""Render an untrusted value without adding Markdown table columns or rows."""
|
|
48
|
+
|
|
49
|
+
return markdown_text(value).translate(_TABLE_TRANSLATION)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def markdown_code_span(value: Any) -> str:
|
|
53
|
+
"""Render an untrusted value in an inline code span with a safe delimiter."""
|
|
54
|
+
|
|
55
|
+
content = _single_line(value)
|
|
56
|
+
if not content:
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
delimiter = "`" * (_longest_backtick_run(content) + 1)
|
|
60
|
+
needs_padding = content.startswith("`") or content.endswith("`")
|
|
61
|
+
if content.strip(" ") and (content.startswith(" ") or content.endswith(" ")):
|
|
62
|
+
needs_padding = True
|
|
63
|
+
padding = " " if needs_padding else ""
|
|
64
|
+
return f"{delimiter}{padding}{content}{padding}{delimiter}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def markdown_fenced_code(value: Any, *, language: str = "") -> str:
|
|
68
|
+
"""Render untrusted multiline code using a fence longer than its contents."""
|
|
69
|
+
|
|
70
|
+
if not _SAFE_INFO_STRING.fullmatch(language):
|
|
71
|
+
raise ValueError("Markdown fence language contains unsafe characters")
|
|
72
|
+
|
|
73
|
+
content = _normalize_line_breaks(_string(value))
|
|
74
|
+
fence = "`" * max(3, _longest_backtick_run(content) + 1)
|
|
75
|
+
opening = f"{fence}{language}"
|
|
76
|
+
separator = "" if content.endswith("\n") else "\n"
|
|
77
|
+
return f"{opening}\n{content}{separator}{fence}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _single_line(value: Any) -> str:
|
|
81
|
+
return _LINE_BREAKS.sub(" ", _string(value))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _normalize_line_breaks(value: str) -> str:
|
|
85
|
+
return _LINE_BREAKS.sub("\n", value)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _longest_backtick_run(value: str) -> int:
|
|
89
|
+
return max((len(match.group(0)) for match in _BACKTICK_RUNS.finditer(value)), default=0)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _underscore_is_inside_identifier(value: str, index: int) -> bool:
|
|
93
|
+
return (
|
|
94
|
+
index > 0
|
|
95
|
+
and index + 1 < len(value)
|
|
96
|
+
and value[index - 1].isalnum()
|
|
97
|
+
and value[index + 1].isalnum()
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _string(value: Any) -> str:
|
|
102
|
+
return "" if value is None else str(value)
|