nexus-coding-agent 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.
Files changed (131) hide show
  1. nexus/__init__.py +4 -0
  2. nexus/application/__init__.py +2 -0
  3. nexus/application/approval_service.py +130 -0
  4. nexus/application/diff_service.py +177 -0
  5. nexus/application/event_publisher.py +180 -0
  6. nexus/application/execution_context.py +29 -0
  7. nexus/application/execution_ledger.py +126 -0
  8. nexus/application/model_call_context.py +29 -0
  9. nexus/application/observed_model_gateway.py +194 -0
  10. nexus/application/plan_approval_service.py +250 -0
  11. nexus/application/planning.py +668 -0
  12. nexus/application/runtime.py +624 -0
  13. nexus/application/session_service.py +411 -0
  14. nexus/application/structured_output.py +96 -0
  15. nexus/application/telemetry.py +396 -0
  16. nexus/application/telemetry_subscriber.py +216 -0
  17. nexus/application/tool_runtime.py +496 -0
  18. nexus/application/tracing_dispatcher.py +283 -0
  19. nexus/application/validation.py +293 -0
  20. nexus/config/__init__.py +17 -0
  21. nexus/config/loader.py +253 -0
  22. nexus/config/models.py +310 -0
  23. nexus/context/__init__.py +6 -0
  24. nexus/context/budget.py +24 -0
  25. nexus/context/builder.py +92 -0
  26. nexus/context/chunking.py +65 -0
  27. nexus/context/explorer.py +246 -0
  28. nexus/context/integration.py +63 -0
  29. nexus/context/manager.py +328 -0
  30. nexus/context/retrieval.py +261 -0
  31. nexus/domain/__init__.py +2 -0
  32. nexus/domain/agent_decision.py +113 -0
  33. nexus/domain/agent_state.py +49 -0
  34. nexus/domain/approvals.py +64 -0
  35. nexus/domain/context.py +108 -0
  36. nexus/domain/exploration.py +86 -0
  37. nexus/domain/mcp.py +51 -0
  38. nexus/domain/model.py +173 -0
  39. nexus/domain/observability.py +326 -0
  40. nexus/domain/persistence.py +122 -0
  41. nexus/domain/planning.py +329 -0
  42. nexus/domain/ports/__init__.py +61 -0
  43. nexus/domain/ports/agent_decision.py +9 -0
  44. nexus/domain/ports/approval_unit_of_work.py +27 -0
  45. nexus/domain/ports/checkpoint_provider.py +11 -0
  46. nexus/domain/ports/context.py +71 -0
  47. nexus/domain/ports/events.py +21 -0
  48. nexus/domain/ports/graph_runtime.py +30 -0
  49. nexus/domain/ports/mcp.py +23 -0
  50. nexus/domain/ports/model_gateway.py +21 -0
  51. nexus/domain/ports/model_input_budget.py +15 -0
  52. nexus/domain/ports/planning.py +62 -0
  53. nexus/domain/ports/repositories.py +54 -0
  54. nexus/domain/ports/repository_context.py +18 -0
  55. nexus/domain/ports/session_unit_of_work.py +33 -0
  56. nexus/domain/ports/skills.py +41 -0
  57. nexus/domain/ports/tooling.py +39 -0
  58. nexus/domain/ports/tracer.py +13 -0
  59. nexus/domain/ports/validation.py +32 -0
  60. nexus/domain/runtime_events.py +445 -0
  61. nexus/domain/skills.py +129 -0
  62. nexus/domain/tooling.py +163 -0
  63. nexus/domain/validation.py +161 -0
  64. nexus/errors/__init__.py +82 -0
  65. nexus/evaluation/__init__.py +7 -0
  66. nexus/evaluation/assertions.py +4 -0
  67. nexus/evaluation/evaluator.py +263 -0
  68. nexus/evaluation/fixtures.py +36 -0
  69. nexus/evaluation/harness.py +606 -0
  70. nexus/evaluation/loader.py +415 -0
  71. nexus/evaluation/metrics.py +27 -0
  72. nexus/evaluation/models.py +362 -0
  73. nexus/evaluation/report.py +43 -0
  74. nexus/evaluation/runner.py +480 -0
  75. nexus/infrastructure/__init__.py +2 -0
  76. nexus/infrastructure/asyncio_compat.py +13 -0
  77. nexus/infrastructure/bootstrap/__init__.py +17 -0
  78. nexus/infrastructure/bootstrap/composition.py +663 -0
  79. nexus/infrastructure/checkpoint/__init__.py +5 -0
  80. nexus/infrastructure/checkpoint/postgres.py +62 -0
  81. nexus/infrastructure/database.py +39 -0
  82. nexus/infrastructure/embedding.py +61 -0
  83. nexus/infrastructure/graph/__init__.py +6 -0
  84. nexus/infrastructure/graph/day4_runtime.py +966 -0
  85. nexus/infrastructure/graph/langgraph_runtime.py +172 -0
  86. nexus/infrastructure/mcp/__init__.py +5 -0
  87. nexus/infrastructure/mcp/manager.py +380 -0
  88. nexus/infrastructure/model_gateway/__init__.py +6 -0
  89. nexus/infrastructure/model_gateway/openai_compatible.py +345 -0
  90. nexus/infrastructure/observability/__init__.py +7 -0
  91. nexus/infrastructure/observability/console.py +31 -0
  92. nexus/infrastructure/observability/langsmith.py +236 -0
  93. nexus/infrastructure/observability/structured_logger.py +70 -0
  94. nexus/infrastructure/persistence/__init__.py +13 -0
  95. nexus/infrastructure/persistence/approval_unit_of_work.py +74 -0
  96. nexus/infrastructure/persistence/models.py +146 -0
  97. nexus/infrastructure/persistence/repositories.py +326 -0
  98. nexus/infrastructure/persistence/unit_of_work.py +82 -0
  99. nexus/infrastructure/repository_files.py +117 -0
  100. nexus/infrastructure/sandbox/__init__.py +5 -0
  101. nexus/infrastructure/sandbox/local_process.py +353 -0
  102. nexus/infrastructure/semantic.py +276 -0
  103. nexus/infrastructure/semantic_models.py +56 -0
  104. nexus/interfaces/__init__.py +2 -0
  105. nexus/interfaces/cli/__init__.py +2 -0
  106. nexus/interfaces/cli/__main__.py +6 -0
  107. nexus/interfaces/cli/app.py +266 -0
  108. nexus/interfaces/cli/renderer.py +118 -0
  109. nexus/security/__init__.py +14 -0
  110. nexus/security/approval_policies.py +73 -0
  111. nexus/security/command_policy.py +117 -0
  112. nexus/security/executables.py +107 -0
  113. nexus/security/git_commands.py +65 -0
  114. nexus/security/workspace.py +93 -0
  115. nexus/skills/__init__.py +7 -0
  116. nexus/skills/builtin/__init__.py +1 -0
  117. nexus/skills/builtin/debug-python/SKILL.md +26 -0
  118. nexus/skills/builtin/review-repository/SKILL.md +26 -0
  119. nexus/skills/builtin/write-tests/SKILL.md +26 -0
  120. nexus/skills/loader.py +439 -0
  121. nexus/skills/registry.py +146 -0
  122. nexus/skills/selector.py +152 -0
  123. nexus/tools/__init__.py +6 -0
  124. nexus/tools/editing.py +312 -0
  125. nexus/tools/mcp.py +160 -0
  126. nexus/tools/native.py +654 -0
  127. nexus/tools/registry.py +27 -0
  128. nexus_coding_agent-0.1.0.dist-info/METADATA +142 -0
  129. nexus_coding_agent-0.1.0.dist-info/RECORD +131 -0
  130. nexus_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  131. nexus_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,396 @@
1
+ """Strict allowlist conversion from business RuntimeEvents to TelemetryEvent v1.0."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from uuid import uuid4
9
+
10
+ from nexus.application.event_publisher import PublishedObservation
11
+ from nexus.domain.model import TokenUsage
12
+ from nexus.domain.observability import (
13
+ EVENT_PHASES,
14
+ RedactionMetadata,
15
+ TelemetryEvent,
16
+ TelemetryEventType,
17
+ telemetry_payload_dict,
18
+ telemetry_severity,
19
+ )
20
+ from nexus.domain.runtime_events import (
21
+ AgentStepCompleted,
22
+ ApprovalRequested,
23
+ ApprovalResolved,
24
+ ChangedFileRecorded,
25
+ ContextBuilt,
26
+ ErrorOccurred,
27
+ FinalResult,
28
+ ModelCallFinished,
29
+ ModelCallStarted,
30
+ ObservabilityWarning,
31
+ PlanCreated,
32
+ RepairStarted,
33
+ ReplanOccurred,
34
+ RepositoryExplored,
35
+ RunInterrupted,
36
+ RuntimeEvent,
37
+ TaskStarted,
38
+ ToolFinished,
39
+ ToolStarted,
40
+ ValidationFinished,
41
+ ValidationStarted,
42
+ )
43
+
44
+
45
+ class EventEnricher:
46
+ """Build only approved safe fields; arbitrary RuntimeEvent serialization is forbidden."""
47
+
48
+ def __init__(
49
+ self,
50
+ workspace: Path,
51
+ tool_sources: dict[str, str] | None = None,
52
+ ) -> None:
53
+ self._workspace = workspace.resolve()
54
+ self._tool_sources = dict(tool_sources or {})
55
+ self._validation_spans: dict[str, str] = {}
56
+
57
+ def enrich(
58
+ self,
59
+ event: RuntimeEvent,
60
+ observation: PublishedObservation,
61
+ *,
62
+ tool_call_count: int | None = None,
63
+ ) -> TelemetryEvent:
64
+ event_type, payload, omitted = self._payload(
65
+ event, tool_call_count=tool_call_count
66
+ )
67
+ span_id, parent_span_id = self._spans(event, event_type, observation)
68
+ success = event.success if isinstance(event, ModelCallFinished) else None
69
+ return TelemetryEvent(
70
+ event_id=str(uuid4()),
71
+ event_type=event_type,
72
+ timestamp=event.timestamp,
73
+ sequence=observation.sequence,
74
+ trace_id=observation.context.trace_id,
75
+ execution_id=observation.context.execution_id,
76
+ run_id=event.run_id,
77
+ session_id=event.session_id,
78
+ span_id=span_id,
79
+ parent_span_id=parent_span_id,
80
+ phase=EVENT_PHASES[event_type],
81
+ severity=telemetry_severity(event_type, model_call_success=success),
82
+ payload=payload,
83
+ redaction=RedactionMetadata(
84
+ redacted=bool(omitted),
85
+ omitted_fields=omitted,
86
+ ),
87
+ )
88
+
89
+ def _payload(
90
+ self, event: RuntimeEvent, *, tool_call_count: int | None
91
+ ) -> tuple[TelemetryEventType, dict[str, object], tuple[str, ...]]:
92
+ if isinstance(event, TaskStarted):
93
+ return (
94
+ TelemetryEventType.RUN_STARTED,
95
+ {"task_character_count": len(event.task)},
96
+ ("task",),
97
+ )
98
+ if isinstance(event, RepositoryExplored):
99
+ return (
100
+ TelemetryEventType.REPOSITORY_EXPLORED,
101
+ {
102
+ "instruction_path_count": len(event.instruction_paths),
103
+ "manifest_path_count": len(event.manifest_paths),
104
+ "relevant_path_count": len(event.relevant_paths),
105
+ "exploration_tool_calls": event.exploration_tool_calls,
106
+ "truncated": event.truncated,
107
+ },
108
+ ("repository_paths",),
109
+ )
110
+ if isinstance(event, ContextBuilt):
111
+ return (
112
+ TelemetryEventType.CONTEXT_BUILT,
113
+ {
114
+ "selected_path_count": len(event.selected_paths),
115
+ "retained_characters": event.retained_characters,
116
+ "truncated": event.truncated,
117
+ "selected_chunk_count": event.selected_chunk_count,
118
+ "semantic_retrieval_used": event.semantic_retrieval_used,
119
+ "semantic_retrieval_status": event.semantic_retrieval_status,
120
+ "selected_skill_ids": list(event.selected_skill_ids),
121
+ "selected_skill_count": len(event.selected_skill_ids),
122
+ "selection_reason_present": bool(event.skill_selection_reason_summary),
123
+ },
124
+ ("repository_paths", "skill_selection_reason"),
125
+ )
126
+ if isinstance(event, PlanCreated):
127
+ return (
128
+ TelemetryEventType.PLAN_CREATED,
129
+ {
130
+ "plan_id": event.plan_id,
131
+ "plan_version": event.plan_version,
132
+ "plan_kind": event.plan_kind.value,
133
+ "step_count": len(event.step_summaries),
134
+ "replan_reason_present": bool(event.replan_reason),
135
+ },
136
+ ("step_summaries", "replan_reason"),
137
+ )
138
+ if isinstance(event, ApprovalRequested):
139
+ return (
140
+ TelemetryEventType.APPROVAL_REQUESTED,
141
+ {
142
+ "approval_id": event.approval_id,
143
+ "subject": event.subject.value,
144
+ "invocation_id": event.invocation_id,
145
+ "plan_id": event.plan_id,
146
+ "plan_version": event.plan_version,
147
+ "operation": event.operation,
148
+ "risk_level": event.risk_level.value,
149
+ },
150
+ ("resource_or_command_summary",),
151
+ )
152
+ if isinstance(event, ApprovalResolved):
153
+ return (
154
+ TelemetryEventType.APPROVAL_RESOLVED,
155
+ {
156
+ "approval_id": event.approval_id,
157
+ "subject": event.subject.value,
158
+ "invocation_id": event.invocation_id,
159
+ "plan_id": event.plan_id,
160
+ "plan_version": event.plan_version,
161
+ "decision": event.decision.value,
162
+ "actor_category": event.actor_category.value,
163
+ },
164
+ (),
165
+ )
166
+ if isinstance(event, AgentStepCompleted):
167
+ return (
168
+ TelemetryEventType.AGENT_STEP_COMPLETED,
169
+ {
170
+ "step_count": event.step_count,
171
+ "decision_kind": event.decision_kind.value,
172
+ "model_call_id": event.model_call_id,
173
+ },
174
+ (),
175
+ )
176
+ if isinstance(event, ModelCallStarted):
177
+ return (
178
+ TelemetryEventType.MODEL_CALL_STARTED,
179
+ {
180
+ "model_call_id": event.model_call_id,
181
+ "model_call_phase": event.phase.value,
182
+ "provider": event.provider,
183
+ "model": event.model,
184
+ },
185
+ (),
186
+ )
187
+ if isinstance(event, ModelCallFinished):
188
+ return (
189
+ TelemetryEventType.MODEL_CALL_FINISHED,
190
+ {
191
+ "model_call_id": event.model_call_id,
192
+ "model_call_phase": event.phase.value,
193
+ "success": event.success,
194
+ "duration_ms": event.duration_ms,
195
+ "usage": _usage_payload(event.usage),
196
+ "error_code": event.error_code,
197
+ },
198
+ (),
199
+ )
200
+ if isinstance(event, ToolStarted):
201
+ return (
202
+ TelemetryEventType.TOOL_STARTED,
203
+ {
204
+ "invocation_id": event.invocation_id,
205
+ "tool_name": event.tool_name,
206
+ "source": self._tool_sources.get(event.tool_name, "UNKNOWN"),
207
+ "proposed_risk": event.risk_level.value,
208
+ "tool_call_count": tool_call_count,
209
+ },
210
+ (),
211
+ )
212
+ if isinstance(event, ToolFinished):
213
+ return (
214
+ TelemetryEventType.TOOL_FINISHED,
215
+ {
216
+ "invocation_id": event.invocation_id,
217
+ "tool_name": event.tool_name,
218
+ "source": self._tool_sources.get(event.tool_name, "UNKNOWN"),
219
+ "success": event.success,
220
+ "final_risk": event.risk_level.value,
221
+ "policy_decision": event.policy_decision.value,
222
+ "approval_decision": (
223
+ None
224
+ if event.approval_decision is None
225
+ else event.approval_decision.value
226
+ ),
227
+ "duration_ms": event.duration_ms,
228
+ "error_code": event.error_code,
229
+ },
230
+ (),
231
+ )
232
+ if isinstance(event, ReplanOccurred):
233
+ return (
234
+ TelemetryEventType.REPLAN_OCCURRED,
235
+ {
236
+ "plan_id": event.plan_id,
237
+ "previous_version": event.previous_version,
238
+ "new_version": event.new_version,
239
+ "replan_count": event.replan_count,
240
+ "reason_present": bool(event.reason),
241
+ },
242
+ ("reason",),
243
+ )
244
+ if isinstance(event, ValidationStarted):
245
+ return (
246
+ TelemetryEventType.VALIDATION_STARTED,
247
+ {
248
+ "check_ids": list(event.check_ids),
249
+ "check_kinds": [value.value for value in event.check_kinds],
250
+ },
251
+ (),
252
+ )
253
+ if isinstance(event, ValidationFinished):
254
+ return (
255
+ TelemetryEventType.VALIDATION_FINISHED,
256
+ {
257
+ "validation_status": event.validation_status.value,
258
+ "confidence": event.confidence.value,
259
+ "executed_check_count": event.executed_check_count,
260
+ "repair_count": event.repair_count,
261
+ "duration_ms": event.duration_ms,
262
+ },
263
+ (),
264
+ )
265
+ if isinstance(event, RepairStarted):
266
+ return (
267
+ TelemetryEventType.REPAIR_STARTED,
268
+ {
269
+ "plan_id": event.plan_id,
270
+ "plan_version": event.plan_version,
271
+ "repair_count": event.repair_count,
272
+ "max_repair_attempts": event.max_repair_attempts,
273
+ "failure_summary_present": bool(event.failure_summary),
274
+ },
275
+ ("failure_summary",),
276
+ )
277
+ if isinstance(event, ChangedFileRecorded):
278
+ self._validate_changed_path(event.relative_path)
279
+ return (
280
+ TelemetryEventType.CHANGED_FILE_RECORDED,
281
+ {
282
+ "invocation_id": event.invocation_id,
283
+ "relative_path": event.relative_path.replace("\\", "/"),
284
+ "change_kind": event.change_kind.value,
285
+ "changed_file_count": event.changed_file_count,
286
+ },
287
+ (),
288
+ )
289
+ if isinstance(event, RunInterrupted):
290
+ return TelemetryEventType.RUN_INTERRUPTED, {}, ()
291
+ if isinstance(event, FinalResult):
292
+ return (
293
+ TelemetryEventType.RUN_FINISHED,
294
+ {
295
+ "runtime_status": event.status.value,
296
+ "terminal_status": event.terminal_status.value,
297
+ "changed_file_count": len(event.changed_files),
298
+ "validation_status": (
299
+ None
300
+ if event.validation_result is None
301
+ else event.validation_result.status.value
302
+ ),
303
+ },
304
+ ("content", "diff", "validation_evidence"),
305
+ )
306
+ if isinstance(event, ErrorOccurred):
307
+ return (
308
+ TelemetryEventType.ERROR_OCCURRED,
309
+ {"error_code": event.code, "retryable": event.retryable},
310
+ ("error_message",),
311
+ )
312
+ if isinstance(event, ObservabilityWarning):
313
+ return (
314
+ TelemetryEventType.OBSERVABILITY_WARNING,
315
+ {
316
+ "code": event.code,
317
+ "sink": event.sink.value,
318
+ "operation": event.operation.value,
319
+ "fallback": event.fallback.value,
320
+ },
321
+ (),
322
+ )
323
+ raise ValueError("RuntimeEvent type is not registered for Day 8 telemetry.")
324
+
325
+ def _spans(
326
+ self,
327
+ event: RuntimeEvent,
328
+ event_type: TelemetryEventType,
329
+ observation: PublishedObservation,
330
+ ) -> tuple[str | None, str | None]:
331
+ root = observation.context.execution_id
332
+ if event_type in {
333
+ TelemetryEventType.RUN_STARTED,
334
+ TelemetryEventType.RUN_INTERRUPTED,
335
+ TelemetryEventType.RUN_FINISHED,
336
+ TelemetryEventType.ERROR_OCCURRED,
337
+ }:
338
+ return root, None
339
+ if isinstance(event, (ModelCallStarted, ModelCallFinished)):
340
+ return event.model_call_id, root
341
+ if isinstance(event, (ToolStarted, ToolFinished)):
342
+ parent = self._validation_spans.get(observation.context.execution_id, root)
343
+ return event.invocation_id, parent
344
+ if isinstance(event, ValidationStarted):
345
+ span_id = str(uuid4())
346
+ self._validation_spans[observation.context.execution_id] = span_id
347
+ return span_id, root
348
+ if isinstance(event, ValidationFinished):
349
+ return self._validation_spans.pop(observation.context.execution_id, root), root
350
+ return None, root
351
+
352
+ def _validate_changed_path(self, relative_path: str) -> None:
353
+ candidate = (self._workspace / relative_path).resolve()
354
+ try:
355
+ candidate.relative_to(self._workspace)
356
+ except ValueError as exc:
357
+ raise ValueError("Changed-file path escapes the workspace.") from exc
358
+
359
+
360
+ def _usage_payload(usage: TokenUsage) -> dict[str, object]:
361
+ return {
362
+ "input_tokens": usage.input_tokens,
363
+ "output_tokens": usage.output_tokens,
364
+ "total_tokens": usage.total_tokens,
365
+ "availability": usage.availability.value,
366
+ }
367
+
368
+
369
+ class TelemetryRedactor:
370
+ """Defense-in-depth rejection after the primary event allowlist."""
371
+
372
+ _FORBIDDEN = tuple(
373
+ re.compile(pattern, re.IGNORECASE)
374
+ for pattern in (
375
+ r"\b(?:authorization|cookie)\s*[:=]",
376
+ r"\b(?:postgresql|https?)://[^\s/@:]+:[^\s/@]+@",
377
+ r"\bsk-[a-z0-9_-]{12,}",
378
+ r"(?:^|[\s\"'])/[a-z0-9_.-]+/",
379
+ r"[a-z]:\\users\\",
380
+ r"-----BEGIN [A-Z ]+PRIVATE KEY-----",
381
+ )
382
+ )
383
+
384
+ def redact(self, event: TelemetryEvent) -> TelemetryEvent:
385
+ serialized = json.dumps(
386
+ {
387
+ "payload": telemetry_payload_dict(event.payload),
388
+ "omitted": event.redaction.omitted_fields,
389
+ "truncated": event.redaction.truncated_fields,
390
+ },
391
+ ensure_ascii=False,
392
+ separators=(",", ":"),
393
+ )
394
+ if any(pattern.search(serialized) for pattern in self._FORBIDDEN):
395
+ raise ValueError("Telemetry failed the defense-in-depth redaction boundary.")
396
+ return event
@@ -0,0 +1,216 @@
1
+ """Non-blocking telemetry ingress isolated from the required live event lane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+
9
+ from nexus.application.event_publisher import (
10
+ PublishedObservation,
11
+ current_published_observation,
12
+ )
13
+ from nexus.application.execution_context import bind_execution_context
14
+ from nexus.application.execution_ledger import ToolExecutionLedger
15
+ from nexus.application.telemetry import EventEnricher, TelemetryRedactor
16
+ from nexus.application.tracing_dispatcher import SafeTracerDispatcher, WarningEmitter
17
+ from nexus.domain.observability import (
18
+ RunExecutionContext,
19
+ TraceRunFinish,
20
+ TraceRunStart,
21
+ )
22
+ from nexus.domain.runtime_events import (
23
+ RuntimeEvent,
24
+ TraceFallback,
25
+ TraceOperation,
26
+ TraceSink,
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class _ObservedEvent:
32
+ event: RuntimeEvent
33
+ observation: PublishedObservation
34
+ tool_call_count: int | None
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class _FinishExecution:
39
+ finish: TraceRunFinish
40
+
41
+
42
+ _IngressItem = _ObservedEvent | _FinishExecution
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class _IngressExecution:
47
+ context: RunExecutionContext
48
+ queue: asyncio.Queue[_IngressItem]
49
+ worker: asyncio.Task[None]
50
+ finish: TraceRunFinish | None = None
51
+
52
+
53
+ class TelemetrySubscriber:
54
+ def __init__(
55
+ self,
56
+ enricher: EventEnricher,
57
+ dispatcher: SafeTracerDispatcher,
58
+ warning_emitter: WarningEmitter | None = None,
59
+ ledger: ToolExecutionLedger | None = None,
60
+ redactor: TelemetryRedactor | None = None,
61
+ flush_timeout_seconds: float = 2.0,
62
+ finish_observer: Callable[[TraceRunFinish], None] | None = None,
63
+ ) -> None:
64
+ if flush_timeout_seconds <= 0:
65
+ raise ValueError("Telemetry flush timeout must be positive.")
66
+ self._enricher = enricher
67
+ self._dispatcher = dispatcher
68
+ self._warning_emitter = warning_emitter
69
+ self._ledger = ledger
70
+ self._redactor = redactor or TelemetryRedactor()
71
+ self._flush_timeout_seconds = flush_timeout_seconds
72
+ self._finish_observer = finish_observer
73
+ self._executions: dict[str, _IngressExecution] = {}
74
+ self._warned_executions: set[str] = set()
75
+
76
+ def start_execution(self, start: TraceRunStart) -> None:
77
+ execution_id = start.context.execution_id
78
+ if execution_id in self._executions:
79
+ raise RuntimeError("Telemetry ingress execution already exists.")
80
+ self._dispatcher.start_execution(start)
81
+ queue: asyncio.Queue[_IngressItem] = asyncio.Queue()
82
+ worker = asyncio.create_task(self._work(execution_id, queue))
83
+ self._executions[execution_id] = _IngressExecution(
84
+ context=start.context,
85
+ queue=queue,
86
+ worker=worker,
87
+ )
88
+
89
+ async def on_event(self, event: RuntimeEvent) -> None:
90
+ observation = current_published_observation()
91
+ state = self._executions.get(observation.context.execution_id)
92
+ if state is None:
93
+ return
94
+ state.queue.put_nowait(
95
+ _ObservedEvent(
96
+ event,
97
+ observation,
98
+ None if self._ledger is None else self._ledger.count(event.run_id),
99
+ )
100
+ )
101
+
102
+ def finish_execution(self, finish: TraceRunFinish) -> None:
103
+ if self._finish_observer is not None:
104
+ try:
105
+ self._finish_observer(finish)
106
+ except Exception:
107
+ # Observability must remain fail-open. Evaluation detects a missing
108
+ # authoritative finish as its own infrastructure error.
109
+ pass
110
+ state = self._executions.get(finish.context.execution_id)
111
+ if state is None:
112
+ return
113
+ state.finish = finish
114
+ state.queue.put_nowait(_FinishExecution(finish))
115
+
116
+ async def drain_execution(self, execution_id: str) -> None:
117
+ state = self._executions.get(execution_id)
118
+ if state is None:
119
+ await self._dispatcher.drain_execution(execution_id)
120
+ return
121
+ try:
122
+ await asyncio.wait_for(
123
+ asyncio.shield(state.worker), timeout=self._flush_timeout_seconds
124
+ )
125
+ except TimeoutError:
126
+ state.worker.cancel()
127
+ await asyncio.gather(state.worker, return_exceptions=True)
128
+ await self._warn_once(
129
+ execution_id,
130
+ PublishedObservation(state.context, 1),
131
+ "TRACE_FLUSH_FAILED",
132
+ TraceOperation.FLUSH,
133
+ )
134
+ if state.finish is not None:
135
+ self._dispatcher.finish_execution(state.finish)
136
+ finally:
137
+ await self._dispatcher.drain_execution(execution_id)
138
+ if self._executions.get(execution_id) is state:
139
+ self._executions.pop(execution_id, None)
140
+ self._warned_executions.discard(execution_id)
141
+
142
+ async def close(self) -> None:
143
+ for execution_id in tuple(self._executions):
144
+ await self.drain_execution(execution_id)
145
+ await self._dispatcher.close()
146
+
147
+ async def _work(
148
+ self, execution_id: str, queue: asyncio.Queue[_IngressItem]
149
+ ) -> None:
150
+ lifecycle_completed = False
151
+ try:
152
+ while True:
153
+ item = await queue.get()
154
+ if isinstance(item, _FinishExecution):
155
+ self._dispatcher.finish_execution(item.finish)
156
+ await self._dispatcher.drain_execution(execution_id)
157
+ lifecycle_completed = True
158
+ return
159
+ try:
160
+ telemetry = self._enricher.enrich(
161
+ item.event,
162
+ item.observation,
163
+ tool_call_count=item.tool_call_count,
164
+ )
165
+ except Exception:
166
+ await self._warn_once(
167
+ execution_id,
168
+ item.observation,
169
+ "TELEMETRY_EVENT_INVALID",
170
+ TraceOperation.ENRICH,
171
+ )
172
+ continue
173
+ try:
174
+ telemetry = self._redactor.redact(telemetry)
175
+ except Exception:
176
+ await self._warn_once(
177
+ execution_id,
178
+ item.observation,
179
+ "TRACE_REDACTION_FAILED",
180
+ TraceOperation.REDACT,
181
+ )
182
+ continue
183
+ self._dispatcher.record(telemetry)
184
+ finally:
185
+ state = self._executions.get(execution_id)
186
+ if (
187
+ lifecycle_completed
188
+ and state is not None
189
+ and state.worker is asyncio.current_task()
190
+ ):
191
+ self._executions.pop(execution_id, None)
192
+ self._warned_executions.discard(execution_id)
193
+
194
+ async def _warn_once(
195
+ self,
196
+ execution_id: str,
197
+ observation: PublishedObservation,
198
+ code: str,
199
+ operation: TraceOperation,
200
+ ) -> None:
201
+ if (
202
+ self._warning_emitter is None
203
+ or execution_id in self._warned_executions
204
+ ):
205
+ return
206
+ self._warned_executions.add(execution_id)
207
+ try:
208
+ with bind_execution_context(observation.context):
209
+ await self._warning_emitter(
210
+ code,
211
+ TraceSink.TELEMETRY,
212
+ operation,
213
+ TraceFallback.NONE,
214
+ )
215
+ except Exception:
216
+ pass