spanforge 2.0.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 (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1006 @@
1
+ """spanforge.namespaces.trace — Span and agent payload types (RFC-0001 §8).
2
+
3
+ This module provides Python dataclasses for the ``llm.trace.*`` namespace.
4
+ All types map directly to the JSON Schema defined in
5
+ ``docs/schema/payloads/span.schema.json``,
6
+ ``docs/schema/payloads/agent-step.schema.json``, and
7
+ ``docs/schema/payloads/agent-run.schema.json``.
8
+
9
+ Shared value objects (``TokenUsage``, ``ModelInfo``, ``CostBreakdown``,
10
+ ``PricingTier``, ``ToolCall``, ``ReasoningStep``, ``DecisionPoint``) are
11
+ defined here because the trace namespace is where they are first introduced
12
+ and other namespaces reference them.
13
+
14
+ Enumerations
15
+ ------------
16
+ GenAISystem
17
+ RFC §10.1 — normalised provider identifier (OTel ``gen_ai.system``).
18
+ GenAIOperationName
19
+ RFC §10.2 — type of LLM operation (OTel ``gen_ai.operation.name``).
20
+ SpanKind
21
+ RFC §10.3 — OTel SpanKind values relevant to LLM operations.
22
+
23
+ Value objects
24
+ -------------
25
+ TokenUsage
26
+ RFC §9.1 — token counts with OTel-aligned field names.
27
+ ModelInfo
28
+ RFC §9.2 — model identity and provider.
29
+ CostBreakdown
30
+ RFC §9.3 — typed cost attribution record.
31
+ PricingTier
32
+ RFC §9.4 — pricing rates snapshot for cost reproduction.
33
+ ToolCall
34
+ RFC §8.1 — single tool invocation within a span.
35
+ ReasoningStep
36
+ RFC §8.2 — chain-of-thought unit; raw content MUST NOT be stored.
37
+ DecisionPoint
38
+ RFC §8.3 — explicit branching decision made by an agent.
39
+
40
+ Payload dataclasses
41
+ -------------------
42
+ SpanPayload
43
+ RFC §8.1 — single unit of LLM work (model call, tool, agent invocation).
44
+ AgentStepPayload
45
+ RFC §8.4 — one iteration of a multi-step agent loop.
46
+ AgentRunPayload
47
+ RFC §8.5 — root summary for a complete agent run.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import re
53
+ import time
54
+ from dataclasses import dataclass, field
55
+ from enum import Enum
56
+ from typing import Any
57
+
58
+ __all__ = [
59
+ "AgentRunPayload",
60
+ "AgentStepPayload",
61
+ "CostBreakdown",
62
+ "DecisionPoint",
63
+ "GenAIOperationName",
64
+ # Enumerations
65
+ "GenAISystem",
66
+ "ModelInfo",
67
+ "PricingTier",
68
+ "ReasoningStep",
69
+ "SpanEvent",
70
+ "SpanKind",
71
+ # Payloads
72
+ "SpanPayload",
73
+ # Value objects
74
+ "TokenUsage",
75
+ "ToolCall",
76
+ ]
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Compiled validation patterns (module-level, reused across instances)
80
+ # ---------------------------------------------------------------------------
81
+ _SPAN_ID_RE = re.compile(r"^[0-9a-f]{16}$")
82
+ _TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
83
+ _SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
84
+ _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
85
+ _CURRENCY_RE = re.compile(r"^[A-Z]{3}$")
86
+ # RFC-0001 §8.1 — session_id / user_id: 1–256 non-whitespace printable chars
87
+ _SAFE_ID_RE = re.compile(r"^\S{1,256}$")
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Enumerations (RFC §10)
92
+ # ---------------------------------------------------------------------------
93
+
94
+ class GenAISystem(str, Enum):
95
+ """RFC-0001 §10.1 — LLM provider identifier.
96
+
97
+ Maps directly to OTel ``gen_ai.system`` semantic convention values.
98
+ Use ``CUSTOM`` (``"_custom"``) for private or enterprise deployments;
99
+ MUST set ``ModelInfo.custom_system_name`` when ``CUSTOM`` is used.
100
+ """
101
+
102
+ OPENAI = "openai"
103
+ ANTHROPIC = "anthropic"
104
+ GOOGLE = "google"
105
+ COHERE = "cohere"
106
+ VERTEX_AI = "vertex_ai"
107
+ AWS_BEDROCK = "aws_bedrock"
108
+ AZ_AI_INFERENCE = "az.ai.inference"
109
+ GROQ = "groq"
110
+ OLLAMA = "ollama"
111
+ MISTRAL_AI = "mistral_ai"
112
+ TOGETHER_AI = "together_ai"
113
+ HUGGING_FACE = "hugging_face"
114
+ CUSTOM = "_custom"
115
+
116
+
117
+ class GenAIOperationName(str, Enum):
118
+ """RFC-0001 §10.2 — Type of LLM operation performed.
119
+
120
+ Maps to OTel ``gen_ai.operation.name``.
121
+ """
122
+
123
+ CHAT = "chat"
124
+ TEXT_COMPLETION = "text_completion"
125
+ EMBEDDINGS = "embeddings"
126
+ IMAGE_GENERATION = "image_generation"
127
+ EXECUTE_TOOL = "execute_tool"
128
+ INVOKE_AGENT = "invoke_agent"
129
+ CREATE_AGENT = "create_agent"
130
+ REASONING = "reasoning"
131
+
132
+
133
+ class SpanKind(str, Enum):
134
+ """RFC-0001 §10.3 — OTel SpanKind for LLM operations."""
135
+
136
+ CLIENT = "CLIENT" # Outbound LLM API call — most common
137
+ SERVER = "SERVER" # Incoming agent request
138
+ INTERNAL = "INTERNAL" # Internal reasoning or routing step
139
+ CONSUMER = "CONSUMER" # Tool execution triggered by LLM output
140
+ PRODUCER = "PRODUCER" # Event emitted by an agent for downstream consumption
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Value objects (RFC §9, §8.1-§8.3)
145
+ # ---------------------------------------------------------------------------
146
+
147
+ @dataclass(frozen=True)
148
+ class TokenUsage:
149
+ """RFC-0001 §9.1 — Token consumption record for a model call.
150
+
151
+ Uses OTel-aligned names: ``input_tokens`` / ``output_tokens`` (not
152
+ ``prompt_tokens`` / ``completion_tokens``).
153
+ """
154
+
155
+ input_tokens: int
156
+ output_tokens: int
157
+ total_tokens: int
158
+ cached_tokens: int | None = None
159
+ cache_creation_tokens: int | None = None
160
+ reasoning_tokens: int | None = None
161
+ image_tokens: int | None = None
162
+
163
+ def __post_init__(self) -> None:
164
+ for name in ("input_tokens", "output_tokens", "total_tokens"):
165
+ v = getattr(self, name)
166
+ if not isinstance(v, int) or v < 0:
167
+ raise ValueError(f"TokenUsage.{name} must be a non-negative int")
168
+ for opt in ("cached_tokens", "cache_creation_tokens", "reasoning_tokens", "image_tokens"):
169
+ v = getattr(self, opt)
170
+ if v is not None and (not isinstance(v, int) or v < 0):
171
+ raise ValueError(f"TokenUsage.{opt} must be a non-negative int or None")
172
+
173
+ def to_dict(self) -> dict[str, Any]:
174
+ """Serialise the payload to a plain ``dict``."""
175
+ d: dict[str, Any] = {
176
+ "input_tokens": self.input_tokens,
177
+ "output_tokens": self.output_tokens,
178
+ "total_tokens": self.total_tokens,
179
+ }
180
+ for name in ("cached_tokens", "cache_creation_tokens", "reasoning_tokens", "image_tokens"):
181
+ v = getattr(self, name)
182
+ if v is not None:
183
+ d[name] = v
184
+ return d
185
+
186
+ @classmethod
187
+ def from_dict(cls, data: dict[str, Any]) -> TokenUsage:
188
+ """Deserialise from a plain ``dict``."""
189
+ return cls(
190
+ input_tokens=int(data["input_tokens"]),
191
+ output_tokens=int(data["output_tokens"]),
192
+ total_tokens=int(data["total_tokens"]),
193
+ cached_tokens=int(data["cached_tokens"]) if "cached_tokens" in data else None,
194
+ cache_creation_tokens=int(data["cache_creation_tokens"]) if "cache_creation_tokens" in data else None, # noqa: E501
195
+ reasoning_tokens=int(data["reasoning_tokens"]) if "reasoning_tokens" in data else None,
196
+ image_tokens=int(data["image_tokens"]) if "image_tokens" in data else None,
197
+ )
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class ModelInfo:
202
+ """RFC-0001 §9.2 — Model identity and provider information.
203
+
204
+ ``custom_system_name`` is REQUIRED when ``system`` is
205
+ :attr:`GenAISystem.CUSTOM` (``"_custom"``).
206
+ """
207
+
208
+ system: GenAISystem | str
209
+ name: str
210
+ response_model: str | None = None
211
+ version: str | None = None
212
+ custom_system_name: str | None = None
213
+
214
+ def __post_init__(self) -> None:
215
+ sys_val = self.system.value if isinstance(self.system, GenAISystem) else self.system
216
+ if sys_val == "_custom" and not self.custom_system_name:
217
+ raise ValueError(
218
+ "ModelInfo.custom_system_name is REQUIRED when system is '_custom' (RFC §4 P6)"
219
+ )
220
+ if not isinstance(self.name, str) or not self.name:
221
+ raise ValueError("ModelInfo.name must be a non-empty string")
222
+
223
+ def to_dict(self) -> dict[str, Any]:
224
+ """Serialise the payload to a plain ``dict``."""
225
+ sys_val = self.system.value if isinstance(self.system, GenAISystem) else self.system
226
+ d: dict[str, Any] = {"system": sys_val, "name": self.name}
227
+ if self.response_model is not None:
228
+ d["response_model"] = self.response_model
229
+ if self.version is not None:
230
+ d["version"] = self.version
231
+ if self.custom_system_name is not None:
232
+ d["custom_system_name"] = self.custom_system_name
233
+ return d
234
+
235
+ @classmethod
236
+ def from_dict(cls, data: dict[str, Any]) -> ModelInfo:
237
+ """Deserialise from a plain ``dict``."""
238
+ sys_raw = data["system"]
239
+ try:
240
+ system: GenAISystem | str = GenAISystem(sys_raw)
241
+ except ValueError:
242
+ system = sys_raw
243
+ return cls(
244
+ system=system,
245
+ name=data["name"],
246
+ response_model=data.get("response_model"),
247
+ version=data.get("version"),
248
+ custom_system_name=data.get("custom_system_name"),
249
+ )
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class CostBreakdown:
254
+ """RFC-0001 §9.3 — Typed cost attribution record.
255
+
256
+ ``total_cost_usd`` MUST equal
257
+ ``input_cost_usd + output_cost_usd + reasoning_cost_usd - cached_discount_usd``
258
+ within ±1e-6 absolute tolerance.
259
+
260
+ Use :meth:`zero` to create a zero-filled instance when pricing data
261
+ is unavailable (§9.3 zero-fill allowance).
262
+ """
263
+
264
+ input_cost_usd: float
265
+ output_cost_usd: float
266
+ total_cost_usd: float
267
+ cached_discount_usd: float = 0.0
268
+ reasoning_cost_usd: float = 0.0
269
+ currency: str = "USD"
270
+ pricing_date: str | None = None
271
+
272
+ _TOLERANCE: float = 1e-6
273
+
274
+ def __post_init__(self) -> None:
275
+ for name in ("input_cost_usd", "output_cost_usd", "total_cost_usd",
276
+ "cached_discount_usd", "reasoning_cost_usd"):
277
+ v = getattr(self, name)
278
+ if not isinstance(v, (int, float)) or v < 0:
279
+ raise ValueError(f"CostBreakdown.{name} must be a non-negative number")
280
+ if not _CURRENCY_RE.match(self.currency):
281
+ raise ValueError("CostBreakdown.currency must be a 3-letter ISO 4217 code")
282
+ if self.pricing_date is not None and not _ISO_DATE_RE.match(self.pricing_date):
283
+ raise ValueError("CostBreakdown.pricing_date must be YYYY-MM-DD")
284
+ expected = (
285
+ self.input_cost_usd
286
+ + self.output_cost_usd
287
+ + self.reasoning_cost_usd
288
+ - self.cached_discount_usd
289
+ )
290
+ if abs(self.total_cost_usd - expected) > self._TOLERANCE:
291
+ raise ValueError(
292
+ f"CostBreakdown.total_cost_usd {self.total_cost_usd} != "
293
+ f"input + output + reasoning - cached_discount = {expected:.8f} (±{self._TOLERANCE})" # noqa: E501
294
+ )
295
+
296
+ def to_dict(self) -> dict[str, Any]:
297
+ """Serialise the payload to a plain ``dict``."""
298
+ d: dict[str, Any] = {
299
+ "input_cost_usd": self.input_cost_usd,
300
+ "output_cost_usd": self.output_cost_usd,
301
+ "total_cost_usd": self.total_cost_usd,
302
+ }
303
+ if self.cached_discount_usd:
304
+ d["cached_discount_usd"] = self.cached_discount_usd
305
+ if self.reasoning_cost_usd:
306
+ d["reasoning_cost_usd"] = self.reasoning_cost_usd
307
+ if self.currency != "USD":
308
+ d["currency"] = self.currency
309
+ if self.pricing_date is not None:
310
+ d["pricing_date"] = self.pricing_date
311
+ return d
312
+
313
+ @classmethod
314
+ def from_dict(cls, data: dict[str, Any]) -> CostBreakdown:
315
+ """Deserialise from a plain ``dict``."""
316
+ return cls(
317
+ input_cost_usd=float(data["input_cost_usd"]),
318
+ output_cost_usd=float(data["output_cost_usd"]),
319
+ total_cost_usd=float(data["total_cost_usd"]),
320
+ cached_discount_usd=float(data.get("cached_discount_usd", 0.0)),
321
+ reasoning_cost_usd=float(data.get("reasoning_cost_usd", 0.0)),
322
+ currency=data.get("currency", "USD"),
323
+ pricing_date=data.get("pricing_date"),
324
+ )
325
+
326
+ @classmethod
327
+ def zero(cls) -> CostBreakdown:
328
+ """Return a zero-filled CostBreakdown (§9.3 zero-fill allowance)."""
329
+ return cls(input_cost_usd=0.0, output_cost_usd=0.0, total_cost_usd=0.0)
330
+
331
+
332
+ @dataclass(frozen=True)
333
+ class PricingTier:
334
+ """RFC-0001 §9.4 — Pricing rates snapshot for cost reproduction.
335
+
336
+ Stores the exact pricing rates used to compute a ``CostBreakdown`` so
337
+ cost calculations remain reproducible indefinitely.
338
+ """
339
+
340
+ system: GenAISystem | str
341
+ model: str
342
+ input_per_million_usd: float
343
+ output_per_million_usd: float
344
+ effective_date: str
345
+ cached_input_per_million_usd: float | None = None
346
+ reasoning_per_million_usd: float | None = None
347
+
348
+ def __post_init__(self) -> None:
349
+ if not isinstance(self.model, str) or not self.model:
350
+ raise ValueError("PricingTier.model must be a non-empty string")
351
+ if not _ISO_DATE_RE.match(self.effective_date):
352
+ raise ValueError("PricingTier.effective_date must be YYYY-MM-DD")
353
+ for name in ("input_per_million_usd", "output_per_million_usd"):
354
+ v = getattr(self, name)
355
+ if not isinstance(v, (int, float)) or v < 0:
356
+ raise ValueError(f"PricingTier.{name} must be a non-negative number")
357
+
358
+ def to_dict(self) -> dict[str, Any]:
359
+ """Serialise the payload to a plain ``dict``."""
360
+ sys_val = self.system.value if isinstance(self.system, GenAISystem) else self.system
361
+ d: dict[str, Any] = {
362
+ "system": sys_val,
363
+ "model": self.model,
364
+ "input_per_million_usd": self.input_per_million_usd,
365
+ "output_per_million_usd": self.output_per_million_usd,
366
+ "effective_date": self.effective_date,
367
+ }
368
+ if self.cached_input_per_million_usd is not None:
369
+ d["cached_input_per_million_usd"] = self.cached_input_per_million_usd
370
+ if self.reasoning_per_million_usd is not None:
371
+ d["reasoning_per_million_usd"] = self.reasoning_per_million_usd
372
+ return d
373
+
374
+ @classmethod
375
+ def from_dict(cls, data: dict[str, Any]) -> PricingTier:
376
+ """Deserialise from a plain ``dict``."""
377
+ sys_raw = data["system"]
378
+ try:
379
+ system: GenAISystem | str = GenAISystem(sys_raw)
380
+ except ValueError:
381
+ system = sys_raw
382
+ return cls(
383
+ system=system,
384
+ model=data["model"],
385
+ input_per_million_usd=float(data["input_per_million_usd"]),
386
+ output_per_million_usd=float(data["output_per_million_usd"]),
387
+ effective_date=data["effective_date"],
388
+ cached_input_per_million_usd=float(data["cached_input_per_million_usd"])
389
+ if "cached_input_per_million_usd" in data else None,
390
+ reasoning_per_million_usd=float(data["reasoning_per_million_usd"])
391
+ if "reasoning_per_million_usd" in data else None,
392
+ )
393
+
394
+
395
+ @dataclass
396
+ class SpanEvent:
397
+ """A named, timestamped event within a span.
398
+
399
+ Unlike spans (which represent durations), events are instantaneous
400
+ points in time recorded inside an enclosing span.
401
+
402
+ Attributes:
403
+ name: Event name (e.g. ``"cache.hit"``, ``"retry.attempt.2"``).
404
+ timestamp_ns: Nanosecond-precision Unix timestamp (auto-filled on creation).
405
+ metadata: Arbitrary key-value metadata attached to this event.
406
+ """
407
+
408
+ name: str
409
+ timestamp_ns: int = field(default_factory=time.time_ns)
410
+ metadata: dict[str, Any] = field(default_factory=dict)
411
+
412
+ def __post_init__(self) -> None:
413
+ if not isinstance(self.name, str) or not self.name:
414
+ raise ValueError("SpanEvent.name must be a non-empty string")
415
+ if self.timestamp_ns < 0:
416
+ raise ValueError("SpanEvent.timestamp_ns must be non-negative")
417
+
418
+ def to_dict(self) -> dict[str, Any]:
419
+ """Serialise to a plain dict."""
420
+ return {
421
+ "name": self.name,
422
+ "timestamp_ns": self.timestamp_ns,
423
+ "metadata": self.metadata,
424
+ }
425
+
426
+ @classmethod
427
+ def from_dict(cls, data: dict[str, Any]) -> SpanEvent:
428
+ """Deserialise from a plain dict."""
429
+ return cls(
430
+ name=data["name"],
431
+ timestamp_ns=int(data["timestamp_ns"]),
432
+ metadata=dict(data.get("metadata", {})),
433
+ )
434
+
435
+
436
+ @dataclass(frozen=True)
437
+ class ToolCall:
438
+ """RFC-0001 §8.1 — A single tool invocation within a span.
439
+
440
+ ``arguments_hash`` stores a SHA-256 hash of the canonical JSON of
441
+ arguments. Raw argument values SHOULD NOT be stored by default (§20.4);
442
+ set ``SpanForgeConfig.include_raw_tool_io = True`` to opt in.
443
+ """
444
+
445
+ tool_call_id: str
446
+ function_name: str
447
+ status: str # "success" | "error" | "timeout" | "cancelled"
448
+ arguments_hash: str | None = None # 64 lowercase hex chars, no prefix
449
+ error_type: str | None = None
450
+ duration_ms: float | None = None
451
+ arguments_raw: str | None = None # populated only when include_raw_tool_io=True
452
+ result_raw: str | None = None # populated only when include_raw_tool_io=True
453
+ retry_count: int | None = None
454
+ external_api: str | None = None
455
+
456
+ _VALID_STATUSES = frozenset({"success", "error", "timeout", "cancelled"})
457
+
458
+ def __post_init__(self) -> None:
459
+ if not isinstance(self.tool_call_id, str) or not self.tool_call_id:
460
+ raise ValueError("ToolCall.tool_call_id must be a non-empty string")
461
+ if not isinstance(self.function_name, str) or not self.function_name:
462
+ raise ValueError("ToolCall.function_name must be a non-empty string")
463
+ if self.status not in self._VALID_STATUSES:
464
+ raise ValueError(f"ToolCall.status must be one of {sorted(self._VALID_STATUSES)}")
465
+ if self.arguments_hash is not None and not _SHA256_RE.match(self.arguments_hash):
466
+ raise ValueError("ToolCall.arguments_hash must be 64 lowercase hex chars (SHA-256)")
467
+ if self.duration_ms is not None and self.duration_ms < 0:
468
+ raise ValueError("ToolCall.duration_ms must be non-negative")
469
+ if self.retry_count is not None and self.retry_count < 0:
470
+ raise ValueError("ToolCall.retry_count must be a non-negative integer")
471
+
472
+ def to_dict(self) -> dict[str, Any]:
473
+ """Serialise the payload to a plain ``dict``."""
474
+ d: dict[str, Any] = {
475
+ "tool_call_id": self.tool_call_id,
476
+ "function_name": self.function_name,
477
+ "status": self.status,
478
+ }
479
+ if self.arguments_hash is not None:
480
+ d["arguments_hash"] = self.arguments_hash
481
+ if self.error_type is not None:
482
+ d["error_type"] = self.error_type
483
+ if self.duration_ms is not None:
484
+ d["duration_ms"] = self.duration_ms
485
+ # arguments_raw / result_raw contain raw PII; only emit when the
486
+ # operator has explicitly enabled raw tool I/O capture.
487
+ from spanforge.config import get_config # noqa: PLC0415
488
+ if self.arguments_raw is not None and get_config().include_raw_tool_io:
489
+ d["arguments_raw"] = self.arguments_raw
490
+ if self.result_raw is not None and get_config().include_raw_tool_io:
491
+ d["result_raw"] = self.result_raw
492
+ if self.retry_count is not None:
493
+ d["retry_count"] = self.retry_count
494
+ if self.external_api is not None:
495
+ d["external_api"] = self.external_api
496
+ return d
497
+
498
+ @classmethod
499
+ def from_dict(cls, data: dict[str, Any]) -> ToolCall:
500
+ """Deserialise from a plain ``dict``."""
501
+ return cls(
502
+ tool_call_id=data["tool_call_id"],
503
+ function_name=data["function_name"],
504
+ status=data["status"],
505
+ arguments_hash=data.get("arguments_hash"),
506
+ error_type=data.get("error_type"),
507
+ duration_ms=float(data["duration_ms"]) if "duration_ms" in data else None,
508
+ arguments_raw=data.get("arguments_raw"),
509
+ result_raw=data.get("result_raw"),
510
+ retry_count=int(data["retry_count"]) if "retry_count" in data else None,
511
+ external_api=data.get("external_api"),
512
+ )
513
+
514
+
515
+ @dataclass(frozen=True)
516
+ class ReasoningStep:
517
+ """RFC-0001 §8.2 — A discrete chain-of-thought unit.
518
+
519
+ **Critical:** Raw reasoning content MUST NOT be stored — only the
520
+ SHA-256 ``content_hash`` (64 lowercase hex chars, no prefix) MAY be
521
+ stored.
522
+ """
523
+
524
+ step_index: int
525
+ reasoning_tokens: int
526
+ duration_ms: float | None = None
527
+ content_hash: str | None = None # 64 lowercase hex chars, no prefix
528
+
529
+ def __post_init__(self) -> None:
530
+ if not isinstance(self.step_index, int) or self.step_index < 0:
531
+ raise ValueError("ReasoningStep.step_index must be a non-negative int")
532
+ if not isinstance(self.reasoning_tokens, int) or self.reasoning_tokens < 0:
533
+ raise ValueError("ReasoningStep.reasoning_tokens must be a non-negative int")
534
+ if self.duration_ms is not None and self.duration_ms < 0:
535
+ raise ValueError("ReasoningStep.duration_ms must be non-negative")
536
+ if self.content_hash is not None and not _SHA256_RE.match(self.content_hash):
537
+ raise ValueError(
538
+ "ReasoningStep.content_hash must be 64 lowercase hex chars (SHA-256, no prefix)"
539
+ )
540
+
541
+ def to_dict(self) -> dict[str, Any]:
542
+ """Serialise the payload to a plain ``dict``."""
543
+ d: dict[str, Any] = {
544
+ "step_index": self.step_index,
545
+ "reasoning_tokens": self.reasoning_tokens,
546
+ }
547
+ if self.duration_ms is not None:
548
+ d["duration_ms"] = self.duration_ms
549
+ if self.content_hash is not None:
550
+ d["content_hash"] = self.content_hash
551
+ return d
552
+
553
+ @classmethod
554
+ def from_dict(cls, data: dict[str, Any]) -> ReasoningStep:
555
+ """Deserialise from a plain ``dict``."""
556
+ return cls(
557
+ step_index=int(data["step_index"]),
558
+ reasoning_tokens=int(data["reasoning_tokens"]),
559
+ duration_ms=float(data["duration_ms"]) if "duration_ms" in data else None,
560
+ content_hash=data.get("content_hash"),
561
+ )
562
+
563
+
564
+ @dataclass(frozen=True)
565
+ class DecisionPoint:
566
+ """RFC-0001 §8.3 — An explicit branching decision recorded during an agent step.
567
+
568
+ ``rationale`` is OPTIONAL for black-box models that do not expose reasoning.
569
+ """
570
+
571
+ decision_id: str
572
+ decision_type: str # "tool_selection"|"route_choice"|"loop_termination"|"escalation"
573
+ options_considered: list[str]
574
+ chosen_option: str
575
+ rationale: str | None = None
576
+
577
+ _VALID_TYPES = frozenset({"tool_selection", "route_choice", "loop_termination", "escalation"})
578
+
579
+ def __post_init__(self) -> None:
580
+ if not isinstance(self.decision_id, str) or not self.decision_id:
581
+ raise ValueError("DecisionPoint.decision_id must be a non-empty string")
582
+ if self.decision_type not in self._VALID_TYPES:
583
+ raise ValueError(f"DecisionPoint.decision_type must be one of {sorted(self._VALID_TYPES)}") # noqa: E501
584
+ if not self.options_considered:
585
+ raise ValueError("DecisionPoint.options_considered must be a non-empty list")
586
+ if not isinstance(self.chosen_option, str) or not self.chosen_option:
587
+ raise ValueError("DecisionPoint.chosen_option must be a non-empty string")
588
+
589
+ def to_dict(self) -> dict[str, Any]:
590
+ """Serialise the payload to a plain ``dict``."""
591
+ d: dict[str, Any] = {
592
+ "decision_id": self.decision_id,
593
+ "decision_type": self.decision_type,
594
+ "options_considered": list(self.options_considered),
595
+ "chosen_option": self.chosen_option,
596
+ }
597
+ if self.rationale is not None:
598
+ d["rationale"] = self.rationale
599
+ return d
600
+
601
+ @classmethod
602
+ def from_dict(cls, data: dict[str, Any]) -> DecisionPoint:
603
+ """Deserialise from a plain ``dict``."""
604
+ return cls(
605
+ decision_id=data["decision_id"],
606
+ decision_type=data["decision_type"],
607
+ options_considered=list(data["options_considered"]),
608
+ chosen_option=data["chosen_option"],
609
+ rationale=data.get("rationale"),
610
+ )
611
+
612
+
613
+ # ---------------------------------------------------------------------------
614
+ # Payload dataclasses
615
+ # ---------------------------------------------------------------------------
616
+
617
+ @dataclass(frozen=True)
618
+ class SpanPayload:
619
+ """RFC-0001 §8.1 — A single unit of LLM work.
620
+
621
+ Used with event types: ``llm.trace.span.started``,
622
+ ``llm.trace.span.completed``, ``llm.trace.span.failed``,
623
+ ``llm.trace.reasoning.step``.
624
+ """
625
+
626
+ span_id: str # 16 lowercase hex chars
627
+ trace_id: str # 32 lowercase hex chars
628
+ span_name: str
629
+ operation: GenAIOperationName | str
630
+ span_kind: SpanKind | str
631
+ status: str # "ok" | "error" | "timeout"
632
+ start_time_unix_nano: int
633
+ end_time_unix_nano: int
634
+ duration_ms: float
635
+ parent_span_id: str | None = None
636
+ agent_run_id: str | None = None
637
+ model: ModelInfo | None = None
638
+ token_usage: TokenUsage | None = None
639
+ cost: CostBreakdown | None = None
640
+ tool_calls: list[ToolCall] = field(default_factory=list)
641
+ reasoning_steps: list[ReasoningStep] = field(default_factory=list)
642
+ finish_reason: str | None = None
643
+ error: str | None = None
644
+ error_type: str | None = None
645
+ attributes: dict[str, Any] | None = None
646
+ temperature: float | None = None
647
+ top_p: float | None = None
648
+ max_tokens: int | None = None
649
+ error_category: str | None = None
650
+ events: list[SpanEvent] = field(default_factory=list)
651
+ session_id: str | None = None
652
+ user_id: str | None = None
653
+ incoming_traceparent: str | None = None # W3C traceparent header from the caller
654
+
655
+ _VALID_STATUSES = frozenset({"ok", "error", "timeout"})
656
+
657
+ def __post_init__(self) -> None:
658
+ if not _SPAN_ID_RE.match(self.span_id):
659
+ raise ValueError(f"SpanPayload.span_id must be 16 lowercase hex chars, got {self.span_id!r}") # noqa: E501
660
+ if not _TRACE_ID_RE.match(self.trace_id):
661
+ raise ValueError(f"SpanPayload.trace_id must be 32 lowercase hex chars, got {self.trace_id!r}") # noqa: E501
662
+ if not isinstance(self.span_name, str) or not self.span_name:
663
+ raise ValueError("SpanPayload.span_name must be a non-empty string")
664
+ if self.parent_span_id is not None and not _SPAN_ID_RE.match(self.parent_span_id):
665
+ raise ValueError("SpanPayload.parent_span_id must be 16 lowercase hex chars")
666
+ status_val = self.status.value if isinstance(self.status, Enum) else self.status
667
+ if status_val not in self._VALID_STATUSES:
668
+ raise ValueError(f"SpanPayload.status must be one of {sorted(self._VALID_STATUSES)}")
669
+ if self.start_time_unix_nano < 0:
670
+ raise ValueError("SpanPayload.start_time_unix_nano must be non-negative")
671
+ if self.end_time_unix_nano < self.start_time_unix_nano:
672
+ raise ValueError("SpanPayload.end_time_unix_nano must be >= start_time_unix_nano")
673
+ if self.duration_ms < 0:
674
+ raise ValueError("SpanPayload.duration_ms must be non-negative")
675
+ # RFC-0001 §8.1 — duration_ms MUST equal (end - start) / 1_000_000 ± 1 ms
676
+ _computed_ms = (self.end_time_unix_nano - self.start_time_unix_nano) / 1_000_000
677
+ if abs(self.duration_ms - _computed_ms) > 1.0:
678
+ raise ValueError(
679
+ f"SpanPayload.duration_ms {self.duration_ms} must equal "
680
+ f"(end_time_unix_nano - start_time_unix_nano) / 1_000_000 "
681
+ f"= {_computed_ms:.3f} \u00b11 ms (RFC-0001 \u00a78.1)"
682
+ )
683
+ # Validate session_id / user_id: prevent PII/injection leakage via
684
+ # embedding whitespace, control chars, or excessively long values.
685
+ if self.session_id is not None and not _SAFE_ID_RE.match(self.session_id):
686
+ raise ValueError(
687
+ "SpanPayload.session_id must be 1–256 non-whitespace characters"
688
+ )
689
+ if self.user_id is not None and not _SAFE_ID_RE.match(self.user_id):
690
+ raise ValueError(
691
+ "SpanPayload.user_id must be 1–256 non-whitespace characters"
692
+ )
693
+
694
+ def _add_optional_fields(self, d: dict[str, Any]) -> None:
695
+ """Add optional fields to *d* when they are not None/empty."""
696
+ if self.parent_span_id is not None:
697
+ d["parent_span_id"] = self.parent_span_id
698
+ if self.agent_run_id is not None:
699
+ d["agent_run_id"] = self.agent_run_id
700
+ if self.model is not None:
701
+ d["model"] = self.model.to_dict()
702
+ if self.token_usage is not None:
703
+ d["token_usage"] = self.token_usage.to_dict()
704
+ if self.cost is not None:
705
+ d["cost"] = self.cost.to_dict()
706
+ if self.finish_reason is not None:
707
+ d["finish_reason"] = self.finish_reason
708
+ if self.error is not None:
709
+ d["error"] = self.error
710
+ if self.error_type is not None:
711
+ d["error_type"] = self.error_type
712
+ if self.attributes is not None:
713
+ d["attributes"] = self.attributes
714
+ if self.temperature is not None:
715
+ d["temperature"] = self.temperature
716
+ if self.top_p is not None:
717
+ d["top_p"] = self.top_p
718
+ if self.max_tokens is not None:
719
+ d["max_tokens"] = self.max_tokens
720
+ if self.error_category is not None:
721
+ d["error_category"] = self.error_category
722
+ if self.events:
723
+ d["events"] = [e.to_dict() for e in self.events]
724
+ if self.session_id is not None:
725
+ d["session_id"] = self.session_id
726
+ if self.user_id is not None:
727
+ d["user_id"] = self.user_id
728
+ if self.incoming_traceparent is not None:
729
+ d["incoming_traceparent"] = self.incoming_traceparent
730
+
731
+ def to_dict(self) -> dict[str, Any]:
732
+ """Serialise the payload to a plain ``dict``."""
733
+ op = self.operation.value if isinstance(self.operation, Enum) else self.operation
734
+ sk = self.span_kind.value if isinstance(self.span_kind, Enum) else self.span_kind
735
+ st = self.status.value if isinstance(self.status, Enum) else self.status
736
+ d: dict[str, Any] = {
737
+ "span_id": self.span_id,
738
+ "trace_id": self.trace_id,
739
+ "span_name": self.span_name,
740
+ "operation": op,
741
+ "span_kind": sk,
742
+ "status": st,
743
+ "start_time_unix_nano": self.start_time_unix_nano,
744
+ "end_time_unix_nano": self.end_time_unix_nano,
745
+ "duration_ms": self.duration_ms,
746
+ "tool_calls": [tc.to_dict() for tc in self.tool_calls],
747
+ "reasoning_steps": [rs.to_dict() for rs in self.reasoning_steps],
748
+ }
749
+ self._add_optional_fields(d)
750
+ return d
751
+
752
+ @classmethod
753
+ def from_dict(cls, data: dict[str, Any]) -> SpanPayload:
754
+ """Deserialise from a plain ``dict``."""
755
+ op_raw = data["operation"]
756
+ try:
757
+ operation: GenAIOperationName | str = GenAIOperationName(op_raw)
758
+ except ValueError:
759
+ operation = op_raw
760
+ sk_raw = data["span_kind"]
761
+ try:
762
+ span_kind: SpanKind | str = SpanKind(sk_raw)
763
+ except ValueError:
764
+ span_kind = sk_raw
765
+ return cls(
766
+ span_id=data["span_id"],
767
+ trace_id=data["trace_id"],
768
+ span_name=data["span_name"],
769
+ operation=operation,
770
+ span_kind=span_kind,
771
+ status=data["status"],
772
+ start_time_unix_nano=int(data["start_time_unix_nano"]),
773
+ end_time_unix_nano=int(data["end_time_unix_nano"]),
774
+ duration_ms=float(data["duration_ms"]),
775
+ parent_span_id=data.get("parent_span_id"),
776
+ agent_run_id=data.get("agent_run_id"),
777
+ model=ModelInfo.from_dict(data["model"]) if "model" in data else None,
778
+ token_usage=TokenUsage.from_dict(data["token_usage"]) if "token_usage" in data else None, # noqa: E501
779
+ cost=CostBreakdown.from_dict(data["cost"]) if "cost" in data else None,
780
+ tool_calls=[ToolCall.from_dict(tc) for tc in data.get("tool_calls", [])],
781
+ reasoning_steps=[ReasoningStep.from_dict(rs) for rs in data.get("reasoning_steps", [])],
782
+ finish_reason=data.get("finish_reason"),
783
+ error=data.get("error"),
784
+ error_type=data.get("error_type"),
785
+ attributes=data.get("attributes"),
786
+ temperature=float(data["temperature"]) if "temperature" in data else None,
787
+ top_p=float(data["top_p"]) if "top_p" in data else None,
788
+ max_tokens=int(data["max_tokens"]) if "max_tokens" in data else None,
789
+ error_category=data.get("error_category"),
790
+ events=[SpanEvent.from_dict(e) for e in data.get("events", [])],
791
+ session_id=data.get("session_id"),
792
+ user_id=data.get("user_id"),
793
+ incoming_traceparent=data.get("incoming_traceparent"),
794
+ )
795
+
796
+
797
+ @dataclass(frozen=True)
798
+ class AgentStepPayload:
799
+ """RFC-0001 §8.4 — One iteration of a multi-step agent loop.
800
+
801
+ Used with event type: ``llm.trace.agent.step``.
802
+ ``step_index`` is zero-based.
803
+ """
804
+
805
+ agent_run_id: str
806
+ step_index: int
807
+ span_id: str # 16 lowercase hex chars
808
+ trace_id: str # 32 lowercase hex chars
809
+ operation: GenAIOperationName | str
810
+ tool_calls: list[ToolCall]
811
+ reasoning_steps: list[ReasoningStep]
812
+ decision_points: list[DecisionPoint]
813
+ status: str # "ok" | "error" | "timeout"
814
+ start_time_unix_nano: int
815
+ end_time_unix_nano: int
816
+ duration_ms: float
817
+ parent_span_id: str | None = None
818
+ model: ModelInfo | None = None
819
+ token_usage: TokenUsage | None = None
820
+ cost: CostBreakdown | None = None
821
+ error: str | None = None
822
+ error_type: str | None = None
823
+ step_name: str | None = None
824
+
825
+ _VALID_STATUSES = frozenset({"ok", "error", "timeout"})
826
+
827
+ def __post_init__(self) -> None:
828
+ if not isinstance(self.agent_run_id, str) or not self.agent_run_id:
829
+ raise ValueError("AgentStepPayload.agent_run_id must be a non-empty string")
830
+ if not isinstance(self.step_index, int) or self.step_index < 0:
831
+ raise ValueError("AgentStepPayload.step_index must be a non-negative int")
832
+ if not _SPAN_ID_RE.match(self.span_id):
833
+ raise ValueError("AgentStepPayload.span_id must be 16 lowercase hex chars")
834
+ if not _TRACE_ID_RE.match(self.trace_id):
835
+ raise ValueError("AgentStepPayload.trace_id must be 32 lowercase hex chars")
836
+ if self.parent_span_id is not None and not _SPAN_ID_RE.match(self.parent_span_id):
837
+ raise ValueError("AgentStepPayload.parent_span_id must be 16 lowercase hex chars")
838
+ status_val = self.status.value if isinstance(self.status, Enum) else self.status
839
+ if status_val not in self._VALID_STATUSES:
840
+ raise ValueError(f"AgentStepPayload.status must be one of {sorted(self._VALID_STATUSES)}") # noqa: E501
841
+ if self.start_time_unix_nano < 0:
842
+ raise ValueError("AgentStepPayload.start_time_unix_nano must be non-negative")
843
+ if self.end_time_unix_nano < self.start_time_unix_nano:
844
+ raise ValueError("AgentStepPayload.end_time_unix_nano must be >= start_time_unix_nano")
845
+ # RFC-0001 §8.1 — duration_ms MUST equal (end - start) / 1_000_000 ± 1 ms
846
+ _computed_ms = (self.end_time_unix_nano - self.start_time_unix_nano) / 1_000_000
847
+ if abs(self.duration_ms - _computed_ms) > 1.0:
848
+ raise ValueError(
849
+ f"AgentStepPayload.duration_ms {self.duration_ms} must equal "
850
+ f"(end_time_unix_nano - start_time_unix_nano) / 1_000_000 "
851
+ f"= {_computed_ms:.3f} \u00b11 ms (RFC-0001 \u00a78.1)"
852
+ )
853
+
854
+ def to_dict(self) -> dict[str, Any]:
855
+ """Serialise the payload to a plain ``dict``."""
856
+ op = self.operation.value if isinstance(self.operation, Enum) else self.operation
857
+ st = self.status.value if isinstance(self.status, Enum) else self.status
858
+ d: dict[str, Any] = {
859
+ "agent_run_id": self.agent_run_id,
860
+ "step_index": self.step_index,
861
+ "span_id": self.span_id,
862
+ "trace_id": self.trace_id,
863
+ "operation": op,
864
+ "tool_calls": [tc.to_dict() for tc in self.tool_calls],
865
+ "reasoning_steps": [rs.to_dict() for rs in self.reasoning_steps],
866
+ "decision_points": [dp.to_dict() for dp in self.decision_points],
867
+ "status": st,
868
+ "start_time_unix_nano": self.start_time_unix_nano,
869
+ "end_time_unix_nano": self.end_time_unix_nano,
870
+ "duration_ms": self.duration_ms,
871
+ }
872
+ if self.parent_span_id is not None:
873
+ d["parent_span_id"] = self.parent_span_id
874
+ if self.model is not None:
875
+ d["model"] = self.model.to_dict()
876
+ if self.token_usage is not None:
877
+ d["token_usage"] = self.token_usage.to_dict()
878
+ if self.cost is not None:
879
+ d["cost"] = self.cost.to_dict()
880
+ if self.error is not None:
881
+ d["error"] = self.error
882
+ if self.error_type is not None:
883
+ d["error_type"] = self.error_type
884
+ if self.step_name is not None:
885
+ d["step_name"] = self.step_name
886
+ return d
887
+
888
+ @classmethod
889
+ def from_dict(cls, data: dict[str, Any]) -> AgentStepPayload:
890
+ """Deserialise from a plain ``dict``."""
891
+ op_raw = data["operation"]
892
+ try:
893
+ operation: GenAIOperationName | str = GenAIOperationName(op_raw)
894
+ except ValueError:
895
+ operation = op_raw
896
+ return cls(
897
+ agent_run_id=data["agent_run_id"],
898
+ step_index=int(data["step_index"]),
899
+ span_id=data["span_id"],
900
+ trace_id=data["trace_id"],
901
+ operation=operation,
902
+ tool_calls=[ToolCall.from_dict(tc) for tc in data.get("tool_calls", [])],
903
+ reasoning_steps=[ReasoningStep.from_dict(rs) for rs in data.get("reasoning_steps", [])],
904
+ decision_points=[DecisionPoint.from_dict(dp) for dp in data.get("decision_points", [])],
905
+ status=data["status"],
906
+ start_time_unix_nano=int(data["start_time_unix_nano"]),
907
+ end_time_unix_nano=int(data["end_time_unix_nano"]),
908
+ duration_ms=float(data["duration_ms"]),
909
+ parent_span_id=data.get("parent_span_id"),
910
+ model=ModelInfo.from_dict(data["model"]) if "model" in data else None,
911
+ token_usage=TokenUsage.from_dict(data["token_usage"]) if "token_usage" in data else None, # noqa: E501
912
+ cost=CostBreakdown.from_dict(data["cost"]) if "cost" in data else None,
913
+ error=data.get("error"),
914
+ error_type=data.get("error_type"),
915
+ step_name=data.get("step_name"),
916
+ )
917
+
918
+
919
+ @dataclass(frozen=True)
920
+ class AgentRunPayload:
921
+ """RFC-0001 §8.5 — Root-level summary for a complete agent run.
922
+
923
+ Used with event type: ``llm.trace.agent.completed``.
924
+ ``agent_run_id`` MUST match across all :class:`AgentStepPayload`
925
+ events for this run.
926
+ """
927
+
928
+ agent_run_id: str
929
+ agent_name: str
930
+ trace_id: str # 32 lowercase hex chars
931
+ root_span_id: str # 16 lowercase hex chars
932
+ total_steps: int
933
+ total_model_calls: int
934
+ total_tool_calls: int
935
+ total_token_usage: TokenUsage
936
+ total_cost: CostBreakdown
937
+ status: str # "ok"|"error"|"timeout"|"max_steps_exceeded"
938
+ start_time_unix_nano: int
939
+ end_time_unix_nano: int
940
+ duration_ms: float
941
+ termination_reason: str | None = None
942
+
943
+ _VALID_STATUSES = frozenset({"ok", "error", "timeout", "max_steps_exceeded"})
944
+
945
+ def __post_init__(self) -> None:
946
+ if not isinstance(self.agent_run_id, str) or not self.agent_run_id:
947
+ raise ValueError("AgentRunPayload.agent_run_id must be a non-empty string")
948
+ if not isinstance(self.agent_name, str) or not self.agent_name:
949
+ raise ValueError("AgentRunPayload.agent_name must be a non-empty string")
950
+ if not _TRACE_ID_RE.match(self.trace_id):
951
+ raise ValueError("AgentRunPayload.trace_id must be 32 lowercase hex chars")
952
+ if not _SPAN_ID_RE.match(self.root_span_id):
953
+ raise ValueError("AgentRunPayload.root_span_id must be 16 lowercase hex chars")
954
+ for name in ("total_steps", "total_model_calls", "total_tool_calls"):
955
+ v = getattr(self, name)
956
+ if not isinstance(v, int) or v < 0:
957
+ raise ValueError(f"AgentRunPayload.{name} must be a non-negative int")
958
+ status_val = self.status.value if isinstance(self.status, Enum) else self.status
959
+ if status_val not in self._VALID_STATUSES:
960
+ raise ValueError(f"AgentRunPayload.status must be one of {sorted(self._VALID_STATUSES)}") # noqa: E501
961
+ if self.start_time_unix_nano < 0:
962
+ raise ValueError("AgentRunPayload.start_time_unix_nano must be non-negative")
963
+ if self.end_time_unix_nano < self.start_time_unix_nano:
964
+ raise ValueError("AgentRunPayload.end_time_unix_nano must be >= start_time_unix_nano")
965
+
966
+ def to_dict(self) -> dict[str, Any]:
967
+ """Serialise the payload to a plain ``dict``."""
968
+ st = self.status.value if isinstance(self.status, Enum) else self.status
969
+ d: dict[str, Any] = {
970
+ "agent_run_id": self.agent_run_id,
971
+ "agent_name": self.agent_name,
972
+ "trace_id": self.trace_id,
973
+ "root_span_id": self.root_span_id,
974
+ "total_steps": self.total_steps,
975
+ "total_model_calls": self.total_model_calls,
976
+ "total_tool_calls": self.total_tool_calls,
977
+ "total_token_usage": self.total_token_usage.to_dict(),
978
+ "total_cost": self.total_cost.to_dict(),
979
+ "status": st,
980
+ "start_time_unix_nano": self.start_time_unix_nano,
981
+ "end_time_unix_nano": self.end_time_unix_nano,
982
+ "duration_ms": self.duration_ms,
983
+ }
984
+ if self.termination_reason is not None:
985
+ d["termination_reason"] = self.termination_reason
986
+ return d
987
+
988
+ @classmethod
989
+ def from_dict(cls, data: dict[str, Any]) -> AgentRunPayload:
990
+ """Deserialise from a plain ``dict``."""
991
+ return cls(
992
+ agent_run_id=data["agent_run_id"],
993
+ agent_name=data["agent_name"],
994
+ trace_id=data["trace_id"],
995
+ root_span_id=data["root_span_id"],
996
+ total_steps=int(data["total_steps"]),
997
+ total_model_calls=int(data["total_model_calls"]),
998
+ total_tool_calls=int(data["total_tool_calls"]),
999
+ total_token_usage=TokenUsage.from_dict(data["total_token_usage"]),
1000
+ total_cost=CostBreakdown.from_dict(data["total_cost"]),
1001
+ status=data["status"],
1002
+ start_time_unix_nano=int(data["start_time_unix_nano"]),
1003
+ end_time_unix_nano=int(data["end_time_unix_nano"]),
1004
+ duration_ms=float(data["duration_ms"]),
1005
+ termination_reason=data.get("termination_reason"),
1006
+ )