alpha-engine-lib 0.32.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. alpha_engine_lib/__init__.py +3 -0
  2. alpha_engine_lib/agent_schemas.py +663 -0
  3. alpha_engine_lib/alerts.py +576 -0
  4. alpha_engine_lib/arcticdb.py +340 -0
  5. alpha_engine_lib/collector_results.py +69 -0
  6. alpha_engine_lib/cost.py +665 -0
  7. alpha_engine_lib/dates.py +273 -0
  8. alpha_engine_lib/decision_capture.py +462 -0
  9. alpha_engine_lib/ec2_spot.py +363 -0
  10. alpha_engine_lib/email_sender.py +206 -0
  11. alpha_engine_lib/eval_artifacts.py +361 -0
  12. alpha_engine_lib/logging.py +303 -0
  13. alpha_engine_lib/model_pricing.yaml +73 -0
  14. alpha_engine_lib/pillars.py +756 -0
  15. alpha_engine_lib/pipeline_status/__init__.py +70 -0
  16. alpha_engine_lib/pipeline_status/read.py +541 -0
  17. alpha_engine_lib/pipeline_status/registry.py +368 -0
  18. alpha_engine_lib/pipeline_status/templates.py +120 -0
  19. alpha_engine_lib/preflight.py +444 -0
  20. alpha_engine_lib/rag/__init__.py +39 -0
  21. alpha_engine_lib/rag/db.py +96 -0
  22. alpha_engine_lib/rag/embeddings.py +63 -0
  23. alpha_engine_lib/rag/migrations/0001_content_tsv.sql +39 -0
  24. alpha_engine_lib/rag/rerank.py +377 -0
  25. alpha_engine_lib/rag/retrieval.py +465 -0
  26. alpha_engine_lib/rag/schema.sql +65 -0
  27. alpha_engine_lib/reconcile.py +203 -0
  28. alpha_engine_lib/secrets.py +186 -0
  29. alpha_engine_lib/sources/__init__.py +35 -0
  30. alpha_engine_lib/sources/protocols.py +227 -0
  31. alpha_engine_lib/ssm_log_capture.py +274 -0
  32. alpha_engine_lib/telegram.py +165 -0
  33. alpha_engine_lib/trading_calendar.py +236 -0
  34. alpha_engine_lib/transparency.py +746 -0
  35. alpha_engine_lib/transparency_inventory.yaml +260 -0
  36. alpha_engine_lib/universe.py +83 -0
  37. alpha_engine_lib-0.32.0.dist-info/METADATA +217 -0
  38. alpha_engine_lib-0.32.0.dist-info/RECORD +40 -0
  39. alpha_engine_lib-0.32.0.dist-info/WHEEL +5 -0
  40. alpha_engine_lib-0.32.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,462 @@
1
+ """
2
+ Decision-artifact persistence schema for Alpha Engine agent capture.
3
+
4
+ Every LLM agent invocation across the Alpha Engine stack persists a
5
+ ``DecisionArtifact`` to S3 at ``s3://alpha-engine-research/decision_artifacts/
6
+ {YYYY}/{MM}/{DD}/{agent_id}/{run_id}.json``. The artifact captures the full
7
+ prompt + input data snapshot + agent output + cost so each decision can be:
8
+
9
+ - replayed against a different model version (capability-delta measurement
10
+ vs Claude 5 / Sonnet 5 / etc. as frontier models ship);
11
+ - analyzed for rationale clustering (does this agent do varied work or
12
+ collapse to deterministic patterns?);
13
+ - judged for output quality (LLM-as-judge eval against held-out decisions);
14
+ - audited for cost regressions (token spend per agent over time).
15
+
16
+ **Public surface:**
17
+
18
+ - :class:`DecisionArtifact` — the schema (top-level capture record).
19
+ - :class:`ModelMetadata` / :class:`FullPromptContext` — nested metadata.
20
+ - :func:`truncate_snapshot` — 1MB cap enforcer for input snapshots.
21
+ - :func:`capture_decision` — construct a ``DecisionArtifact`` and write
22
+ it to S3. Hard-fails on S3 errors per ``feedback_no_silent_fails``.
23
+ - :exc:`DecisionCaptureWriteError` — raised by ``capture_decision`` on
24
+ S3 failure (do not swallow).
25
+
26
+ **Schema versioning rule:** ``schema_version`` accepts ``1`` (legacy
27
+ LLM-only artifacts, pre-2026-05-11) or ``2`` (current — adds support for
28
+ deterministic decisions with ``model_metadata = None`` and
29
+ ``full_prompt_context = None``). New writes go out as v2. Reads accept
30
+ either. Fields are additive-only within a version; any rename or removal
31
+ would trigger ``schema_version=3``. Use :func:`is_llm_decision` to
32
+ discriminate LLM vs deterministic artifacts at read time.
33
+
34
+ **Compatibility posture:** the top-level ``DecisionArtifact`` model is
35
+ ``extra="forbid"`` (the contract is locked); the per-agent ``input_data_snapshot``
36
+ and ``agent_output`` dicts use ``extra="allow"`` since their shapes vary by
37
+ agent. ``ModelMetadata`` and ``FullPromptContext`` lock down the cross-cutting
38
+ metadata fields.
39
+
40
+ Workstream design: ``alpha-engine-docs/private/alpha-engine-research-typed-
41
+ state-capture-260429.md``.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import json
47
+ import logging
48
+ from datetime import datetime, timezone
49
+ from typing import Any, Literal
50
+
51
+ import boto3
52
+ from botocore.exceptions import BotoCoreError, ClientError
53
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ # ── Cross-cutting metadata ────────────────────────────────────────────────
59
+
60
+
61
+ class ModelMetadata(BaseModel):
62
+ """Per-invocation model identifier + token cost + run/agent context.
63
+
64
+ Token counts are zero-defaulted because some agent paths don't track
65
+ cache reads/creates. ``cost_usd`` is a derived convenience: the load-
66
+ bearing facts are token counts (immutable) and the active price card
67
+ at the time of the call. Use :func:`alpha_engine_lib.cost.recompute_cost`
68
+ to recompute from token counts whenever the rate card changes — never
69
+ treat ``cost_usd`` as canonical for analytics.
70
+
71
+ The remaining fields propagate run + agent context through the cost
72
+ telemetry stream so that cost rows can be drilled down by agent,
73
+ sector team, run type, and prompt version. All optional — populated
74
+ by callers as the matching upstream features ship (prompt versioning
75
+ populates ``prompt_id`` + ``prompt_version``; the LangGraph node
76
+ wrapper populates ``node_name``; the run-orchestrator populates
77
+ ``run_type`` + ``sector_team_id``).
78
+ """
79
+
80
+ model_config = ConfigDict(extra="forbid")
81
+
82
+ model_name: str
83
+ model_version: str | None = None
84
+ input_tokens: int = Field(default=0, ge=0)
85
+ output_tokens: int = Field(default=0, ge=0)
86
+ cache_read_tokens: int = Field(default=0, ge=0)
87
+ cache_create_tokens: int = Field(default=0, ge=0)
88
+ # Server-tool request counts (Anthropic ``Message.usage.server_tool_use``).
89
+ # Distinct from token classes — these are flat per-request fees billed
90
+ # via :class:`alpha_engine_lib.cost.ToolFee`, not the per-1M-token rate
91
+ # on :class:`PriceCard`. Zero-defaulted so consumers that don't use
92
+ # server tools omit the field harmlessly. Additive within schema v2.
93
+ web_search_requests: int = Field(default=0, ge=0)
94
+ web_fetch_requests: int = Field(default=0, ge=0)
95
+ cost_usd: float = Field(default=0.0, ge=0.0)
96
+ run_type: Literal["weekly_research", "morning", "EOD"] | None = None
97
+ node_name: str | None = None
98
+ sector_team_id: str | None = None
99
+ prompt_id: str | None = None
100
+ prompt_version: str | None = None
101
+
102
+
103
+ class FullPromptContext(BaseModel):
104
+ """Captures the full prompt the agent saw — system prompt, user prompt,
105
+ and any tool definitions registered for the call.
106
+
107
+ ``prompt_version_hash`` ties the capture to a specific prompt revision
108
+ via the prompt-versioning pipeline (P2.3 in the workstream design).
109
+ None until prompt versioning ships, at which point the wrapper begins
110
+ populating it.
111
+ """
112
+
113
+ model_config = ConfigDict(extra="forbid")
114
+
115
+ system_prompt: str
116
+ user_prompt: str
117
+ tool_definitions: list[dict] = Field(default_factory=list)
118
+ prompt_version_hash: str | None = None
119
+
120
+
121
+ # ── Top-level artifact ────────────────────────────────────────────────────
122
+
123
+
124
+ _INPUT_SNAPSHOT_DEFAULT_CAP_BYTES = 1_000_000 # 1 MB — see truncate_snapshot()
125
+
126
+
127
+ class DecisionArtifact(BaseModel):
128
+ """One captured agent decision — the substrate for replay, rationale
129
+ clustering, agent-justification, and LLM-as-judge eval.
130
+
131
+ Fields:
132
+
133
+ - ``schema_version``: ``1`` (legacy LLM-only) or ``2`` (current —
134
+ supports deterministic decisions with ``model_metadata=None`` +
135
+ ``full_prompt_context=None``). New writes go out as v2.
136
+ - ``run_id``: unique per pipeline invocation; ties multiple agents'
137
+ artifacts together for one Saturday SF run or weekday morning run.
138
+ - ``timestamp``: ISO-8601 capture time (wall clock at the moment the
139
+ wrapper writes to S3).
140
+ - ``agent_id``: identifies which agent produced this — e.g.
141
+ ``"sector_quant"``, ``"sector_qual"``, ``"macro_economist"``,
142
+ ``"ic_cio"``, ``"executor:entry_triggers"``, ``"executor:risk_guard"``.
143
+ - ``model_metadata``: model + version + cost. ``None`` for
144
+ deterministic decisions (e.g. ``executor:*`` algorithmic agents).
145
+ Required-paired with ``full_prompt_context``: both present or both
146
+ ``None``, never half-populated.
147
+ - ``full_prompt_context``: prompt + tool definitions. ``None`` for
148
+ deterministic decisions. Required-paired with ``model_metadata``.
149
+ - ``input_data_snapshot``: full input payload the agent saw at
150
+ decision time (market state, portfolio state, retrieved RAG chunks,
151
+ etc.). Truncated to fit ``_INPUT_SNAPSHOT_DEFAULT_CAP_BYTES`` if
152
+ pathologically large; ``input_data_truncated_at`` records the
153
+ pre-truncation byte size when truncation fires.
154
+ - ``input_data_summary``: deterministic human-readable view of the
155
+ snapshot (no LLM call). Populated by the wrapper from a typed
156
+ input model's ``__str__`` or equivalent. Not load-bearing for
157
+ replay — the full snapshot is.
158
+ - ``input_data_truncated_at``: byte size of the original snapshot
159
+ if truncation fired; None otherwise.
160
+ - ``agent_output``: serialized agent output dict (typed Pydantic
161
+ model dumped via ``model_dump()``). Includes reasoning, tool
162
+ calls, final structured decision. For deterministic decisions,
163
+ the decision verdict (chosen trigger / sizing / veto verdict).
164
+
165
+ Use :func:`is_llm_decision` to discriminate LLM vs deterministic at
166
+ read time — consumers (cost telemetry, replay harness, LLM-as-judge,
167
+ rationale clustering) gate on it to skip deterministic rows.
168
+ """
169
+
170
+ model_config = ConfigDict(extra="forbid")
171
+
172
+ schema_version: Literal[1, 2] = 2
173
+ run_id: str
174
+ timestamp: str # ISO-8601, e.g. "2026-04-29T22:30:00.000Z"
175
+ agent_id: str
176
+ model_metadata: ModelMetadata | None = None
177
+ full_prompt_context: FullPromptContext | None = None
178
+ input_data_snapshot: dict[str, Any]
179
+ input_data_summary: str | None = None
180
+ input_data_truncated_at: int | None = Field(default=None, ge=0)
181
+ agent_output: dict[str, Any]
182
+
183
+ @model_validator(mode="after")
184
+ def _llm_fields_paired(self) -> "DecisionArtifact":
185
+ """``model_metadata`` and ``full_prompt_context`` must both be
186
+ present (LLM agent) or both be ``None`` (deterministic decision).
187
+
188
+ Catches the silent-bug case where an LLM-agent producer accidentally
189
+ omits one of the two fields. Deterministic-producer callers pass
190
+ ``None`` for both, explicitly, by convention.
191
+ """
192
+ if (self.model_metadata is None) != (self.full_prompt_context is None):
193
+ raise ValueError(
194
+ "model_metadata and full_prompt_context must both be "
195
+ "present (LLM agent) or both be None (deterministic "
196
+ "decision); got "
197
+ f"model_metadata={'set' if self.model_metadata is not None else 'None'}, "
198
+ f"full_prompt_context={'set' if self.full_prompt_context is not None else 'None'}."
199
+ )
200
+ return self
201
+
202
+
203
+ def is_llm_decision(artifact: DecisionArtifact) -> bool:
204
+ """True iff ``artifact`` was produced by an LLM agent (vs a deterministic
205
+ decision).
206
+
207
+ Consumers gate on this to skip rows they can't or shouldn't process:
208
+
209
+ - **Cost telemetry** — skips deterministic rows ($0 cost, 0 tokens).
210
+ - **Replay harness** — deterministic decisions don't replay through an
211
+ LLM model; dispatch to a different replay path (or skip).
212
+ - **LLM-as-judge** — judge has nothing to evaluate without a prompt.
213
+ - **Rationale clustering** — clustering needs the reasoning text inside
214
+ ``agent_output``, which deterministic rows don't carry.
215
+
216
+ Discrimination rule: ``model_metadata is not None``. The
217
+ :meth:`DecisionArtifact._llm_fields_paired` validator guarantees that
218
+ ``model_metadata`` and ``full_prompt_context`` are both present or both
219
+ ``None``, so either field is sufficient to decide.
220
+ """
221
+ return artifact.model_metadata is not None
222
+
223
+
224
+ # ── Truncation helper for the 1MB cap ─────────────────────────────────────
225
+
226
+
227
+ def _serialized_size(payload: dict) -> int:
228
+ """JSON-serialize and return the byte length. Used as the size metric
229
+ for the cap check (matches what S3 will actually store)."""
230
+ return len(json.dumps(payload, default=str).encode("utf-8"))
231
+
232
+
233
+ def truncate_snapshot(
234
+ payload: dict,
235
+ cap_bytes: int = _INPUT_SNAPSHOT_DEFAULT_CAP_BYTES,
236
+ ) -> tuple[dict, int | None]:
237
+ """Truncate ``payload`` to fit under ``cap_bytes`` when serialized to JSON.
238
+
239
+ Strategy:
240
+
241
+ 1. If serialized size ≤ ``cap_bytes`` → return ``(payload, None)``.
242
+ No truncation marker.
243
+ 2. Otherwise, repeatedly drop the largest top-level field by size and
244
+ replace it with a ``{"_truncated": True, "original_field": <name>,
245
+ "original_size_bytes": <N>}`` marker until under cap.
246
+ 3. Return ``(truncated_payload, original_size)`` where ``original_size``
247
+ is the pre-truncation byte size — the wrapper stores this on
248
+ ``DecisionArtifact.input_data_truncated_at`` so consumers can detect
249
+ truncation.
250
+
251
+ Replay correctness note: a truncated artifact loses the dropped fields,
252
+ so replay against a different model would feed it different inputs and
253
+ produce non-comparable output. The truncation marker is a flag for
254
+ consumers to skip replay on truncated artifacts (or fall back to the
255
+ summary view). Steady-state agent inputs (sector_quant ~10-50KB,
256
+ macro_economist ~20-50KB, sector_qual ~300-400KB) are well under the
257
+ 1 MB default cap; truncation is a safety net for pathological cases,
258
+ not the steady-state path.
259
+ """
260
+ original_size = _serialized_size(payload)
261
+ if original_size <= cap_bytes:
262
+ return payload, None
263
+
264
+ # Truncation: drop largest top-level field, replace with marker, repeat.
265
+ truncated = dict(payload)
266
+ while _serialized_size(truncated) > cap_bytes:
267
+ # Find largest top-level field by serialized size.
268
+ sized_fields = [
269
+ (k, _serialized_size({k: v}))
270
+ for k, v in truncated.items()
271
+ if not (isinstance(v, dict) and v.get("_truncated") is True)
272
+ ]
273
+ if not sized_fields:
274
+ # Every field is already a truncation marker; pathological case.
275
+ # Replace whole payload with a single marker.
276
+ truncated = {
277
+ "_truncated": True,
278
+ "reason": "exceeded_cap_after_full_field_drop",
279
+ "original_size_bytes": original_size,
280
+ "cap_bytes": cap_bytes,
281
+ }
282
+ break
283
+ sized_fields.sort(key=lambda x: -x[1]) # largest first
284
+ largest_field, largest_size = sized_fields[0]
285
+ truncated[largest_field] = {
286
+ "_truncated": True,
287
+ "original_field": largest_field,
288
+ "original_size_bytes": largest_size,
289
+ }
290
+
291
+ return truncated, original_size
292
+
293
+
294
+ # ── Capture wrapper (constructs DecisionArtifact + writes to S3) ──────────
295
+
296
+
297
+ _DEFAULT_S3_BUCKET = "alpha-engine-research"
298
+ _DEFAULT_S3_PREFIX = "decision_artifacts"
299
+
300
+
301
+ class DecisionCaptureWriteError(RuntimeError):
302
+ """Raised when the S3 write of a captured ``DecisionArtifact`` fails.
303
+
304
+ Per ``feedback_no_silent_fails``, the capture path does not swallow
305
+ S3 errors — every captured artifact must land or the run hard-fails.
306
+ Callers that wish to make capture best-effort should catch this
307
+ exception explicitly at the call site (and add a CloudWatch metric
308
+ so silent capture loss is observable).
309
+ """
310
+
311
+
312
+ def _build_s3_key(
313
+ *,
314
+ s3_prefix: str,
315
+ capture_dt: datetime,
316
+ agent_id: str,
317
+ run_id: str,
318
+ ) -> str:
319
+ """Compute the canonical S3 key for a captured artifact.
320
+
321
+ Format: ``{s3_prefix}/{YYYY}/{MM}/{DD}/{agent_id}/{run_id}.json``
322
+
323
+ Date-partitioned by capture date (UTC) so consumers (replay harness,
324
+ rationale clustering, LLM-as-judge) can window by day cheaply.
325
+ Per-agent partitioning lets a single agent's corpus be queried
326
+ independently. ``run_id`` is the leaf so all artifacts from one
327
+ pipeline run can be discovered by listing the directory.
328
+ """
329
+ yyyy = capture_dt.strftime("%Y")
330
+ mm = capture_dt.strftime("%m")
331
+ dd = capture_dt.strftime("%d")
332
+ return f"{s3_prefix}/{yyyy}/{mm}/{dd}/{agent_id}/{run_id}.json"
333
+
334
+
335
+ def capture_decision(
336
+ *,
337
+ run_id: str,
338
+ agent_id: str,
339
+ model_metadata: ModelMetadata | None,
340
+ full_prompt_context: FullPromptContext | None,
341
+ input_data_snapshot: dict[str, Any],
342
+ agent_output: dict[str, Any],
343
+ input_data_summary: str | None = None,
344
+ s3_bucket: str = _DEFAULT_S3_BUCKET,
345
+ s3_prefix: str = _DEFAULT_S3_PREFIX,
346
+ s3_client: Any | None = None,
347
+ timestamp: datetime | None = None,
348
+ snapshot_cap_bytes: int = _INPUT_SNAPSHOT_DEFAULT_CAP_BYTES,
349
+ ) -> str:
350
+ """Construct a :class:`DecisionArtifact` and write it to S3.
351
+
352
+ Returns the S3 key the artifact was written to. Raises
353
+ :exc:`DecisionCaptureWriteError` on any S3 failure — the caller MUST
354
+ NOT swallow this silently per ``feedback_no_silent_fails``.
355
+
356
+ Parameters
357
+ ----------
358
+ run_id
359
+ Unique-per-pipeline-invocation identifier. Ties artifacts from
360
+ multiple agents in one run together.
361
+ agent_id
362
+ Identifies which agent produced this — e.g. ``"sector_quant"``,
363
+ ``"sector_qual"``, ``"macro_economist"``, ``"ic_cio"``,
364
+ ``"executor:entry_triggers"``, ``"executor:risk_guard"``.
365
+ model_metadata
366
+ Model identifier + token counts + cost. Pass ``None`` for
367
+ deterministic decisions (e.g. ``executor:*`` algorithmic agents).
368
+ Must be paired with ``full_prompt_context`` — both ``None`` or
369
+ both populated; half-populated raises.
370
+ full_prompt_context
371
+ System prompt + user prompt + tool definitions seen by the agent.
372
+ Pass ``None`` for deterministic decisions; paired with
373
+ ``model_metadata``.
374
+ input_data_snapshot
375
+ Full input payload — load-bearing for replay correctness. Will
376
+ be truncated to ``snapshot_cap_bytes`` if oversized; truncation
377
+ is recorded in ``DecisionArtifact.input_data_truncated_at``.
378
+ agent_output
379
+ Serialized agent output dict (typed Pydantic model dumped via
380
+ ``model_dump()``).
381
+ input_data_summary
382
+ Optional human-readable summary derived deterministically from
383
+ the typed input model. Not load-bearing for replay; useful for
384
+ dashboards + click-through views.
385
+ s3_bucket, s3_prefix
386
+ S3 location overrides. Defaults to
387
+ ``s3://alpha-engine-research/decision_artifacts/`` per the
388
+ workstream design.
389
+ s3_client
390
+ For testing — pass a moto-mocked or otherwise stubbed boto3 S3
391
+ client. Defaults to ``boto3.client("s3")`` if not provided.
392
+ timestamp
393
+ For testing — pass a fixed UTC datetime. Defaults to
394
+ ``datetime.now(timezone.utc)`` if not provided.
395
+ snapshot_cap_bytes
396
+ Override the default 1MB truncation cap.
397
+
398
+ Returns
399
+ -------
400
+ str
401
+ The S3 key the artifact was written to (e.g.
402
+ ``"decision_artifacts/2026/04/29/sector_quant/run-abc123.json"``).
403
+
404
+ Raises
405
+ ------
406
+ DecisionCaptureWriteError
407
+ If the S3 ``PutObject`` call fails for any reason
408
+ (BotoCoreError, ClientError including AccessDenied, etc.).
409
+ """
410
+ capture_dt = timestamp if timestamp is not None else datetime.now(timezone.utc)
411
+
412
+ # 1. Apply truncation cap on the snapshot.
413
+ snapshot_for_artifact, truncated_at = truncate_snapshot(
414
+ input_data_snapshot, cap_bytes=snapshot_cap_bytes,
415
+ )
416
+
417
+ # 2. Construct the artifact (Pydantic validates).
418
+ artifact = DecisionArtifact(
419
+ run_id=run_id,
420
+ timestamp=capture_dt.isoformat(),
421
+ agent_id=agent_id,
422
+ model_metadata=model_metadata,
423
+ full_prompt_context=full_prompt_context,
424
+ input_data_snapshot=snapshot_for_artifact,
425
+ input_data_summary=input_data_summary,
426
+ input_data_truncated_at=truncated_at,
427
+ agent_output=agent_output,
428
+ )
429
+
430
+ # 3. Compute S3 key + serialize.
431
+ s3_key = _build_s3_key(
432
+ s3_prefix=s3_prefix,
433
+ capture_dt=capture_dt,
434
+ agent_id=agent_id,
435
+ run_id=run_id,
436
+ )
437
+ body = artifact.model_dump_json().encode("utf-8")
438
+
439
+ # 4. Write to S3 — hard-fail on any error.
440
+ client = s3_client if s3_client is not None else boto3.client("s3")
441
+ try:
442
+ client.put_object(
443
+ Bucket=s3_bucket,
444
+ Key=s3_key,
445
+ Body=body,
446
+ ContentType="application/json",
447
+ )
448
+ except (BotoCoreError, ClientError) as exc:
449
+ raise DecisionCaptureWriteError(
450
+ f"Failed to write decision artifact to "
451
+ f"s3://{s3_bucket}/{s3_key}: {exc}"
452
+ ) from exc
453
+
454
+ if truncated_at is not None:
455
+ logger.warning(
456
+ "[decision_capture:%s] artifact for run_id=%s truncated "
457
+ "(original size %d bytes > cap %d bytes); replay correctness "
458
+ "may be reduced for this artifact",
459
+ agent_id, run_id, truncated_at, snapshot_cap_bytes,
460
+ )
461
+
462
+ return s3_key