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,415 @@
1
+ """Declarative TOML loader and structural validation for Day 9 cases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ import unicodedata
7
+ from pathlib import Path, PurePosixPath, PureWindowsPath
8
+
9
+ from nexus.domain.tooling import RiskLevel
10
+ from nexus.evaluation.harness import (
11
+ EvalHarnessCommandPolicy,
12
+ _is_absolute_executable,
13
+ _resolve_eval_executables,
14
+ )
15
+ from nexus.evaluation.models import (
16
+ EvalAssertion,
17
+ EvalAssertionType,
18
+ EvalCase,
19
+ EvalCaseLoadError,
20
+ EvalHarnessOperation,
21
+ EvalSuiteLoadResult,
22
+ RequiredFact,
23
+ )
24
+
25
+ MANDATORY_CASE_IDS = tuple(f"EVAL-{number:03d}" for number in range(1, 7))
26
+ _SECURITY_CODES = {
27
+ "PLAN_SCOPE_DENIED",
28
+ "PERMISSION_DENIED",
29
+ "COMMAND_DENIED",
30
+ "MCP_WRITE_NOT_AUTHORIZED",
31
+ }
32
+
33
+
34
+ class EvalSuiteLoader:
35
+ def load_case(self, case_path: Path) -> EvalCase:
36
+ source = case_path / "case.toml" if case_path.is_dir() else case_path
37
+ data = tomllib.loads(source.read_text(encoding="utf-8"))
38
+ assertions_raw = _list(data, "deterministic_success_conditions")
39
+ assertions = tuple(_assertion(item) for item in assertions_raw)
40
+ case = EvalCase(
41
+ case_id=_string(data, "case_id"),
42
+ initial_repo_state=_string(data, "initial_repo_state"),
43
+ task=_string(data, "task"),
44
+ allowed_files=_strings(data, "allowed_files"),
45
+ forbidden_files=_strings(data, "forbidden_files"),
46
+ pre_validation_command=_strings(data, "pre_validation_command"),
47
+ validation_command=_strings(data, "validation_command"),
48
+ validation_timeout_seconds=_optional_int(data, "validation_timeout_seconds"),
49
+ test_discovery_command=_strings(data, "test_discovery_command"),
50
+ expected_behavior=_string(data, "expected_behavior"),
51
+ deterministic_success_conditions=assertions,
52
+ max_reasonable_steps=_integer(data, "max_reasonable_steps"),
53
+ metadata=_metadata(data.get("metadata")),
54
+ )
55
+ _validate_case(case, source.parent)
56
+ return case
57
+
58
+ def load_suite(self, cases_root: Path) -> EvalSuiteLoadResult:
59
+ cases: list[EvalCase] = []
60
+ errors: list[EvalCaseLoadError] = []
61
+ seen: set[str] = set()
62
+ for source in sorted(cases_root.glob("*/case.toml")):
63
+ hint = source.parent.name or None
64
+ try:
65
+ case = self.load_case(source)
66
+ if case.case_id in seen:
67
+ raise ValueError(f"Duplicate case_id: {case.case_id}")
68
+ seen.add(case.case_id)
69
+ cases.append(case)
70
+ except Exception as exc:
71
+ errors.append(
72
+ EvalCaseLoadError(
73
+ source_path=str(source),
74
+ case_id_hint=hint,
75
+ code="EVAL_CASE_INVALID",
76
+ message=str(exc),
77
+ )
78
+ )
79
+ for missing in sorted(set(MANDATORY_CASE_IDS) - seen):
80
+ errors.append(
81
+ EvalCaseLoadError(
82
+ source_path=str(cases_root),
83
+ case_id_hint=missing,
84
+ code="MANDATORY_CASE_MISSING",
85
+ message=f"Mandatory evaluation case {missing} is missing.",
86
+ )
87
+ )
88
+ return EvalSuiteLoadResult(
89
+ cases=tuple(sorted(cases, key=lambda item: item.case_id)),
90
+ errors=tuple(errors),
91
+ )
92
+
93
+
94
+ def _assertion(data: object) -> EvalAssertion:
95
+ if not isinstance(data, dict):
96
+ raise ValueError("Each deterministic success condition must be a table.")
97
+ try:
98
+ assertion_type = EvalAssertionType(_string(data, "assertion_type"))
99
+ except ValueError as exc:
100
+ raise ValueError("Unknown evaluation assertion type.") from exc
101
+ facts = tuple(_fact(item) for item in _optional_list(data, "required_facts"))
102
+ assertion = EvalAssertion(
103
+ assertion_type=assertion_type,
104
+ path=_optional_string(data, "path"),
105
+ expected_text=_optional_string(data, "expected_text"),
106
+ required_facts=facts,
107
+ security_codes=_optional_strings(data, "security_codes"),
108
+ expected_test_node_ids=_optional_strings(data, "expected_test_node_ids"),
109
+ metadata=_metadata(data.get("metadata")),
110
+ )
111
+ _validate_assertion(assertion)
112
+ return assertion
113
+
114
+
115
+ def _fact(data: object) -> RequiredFact:
116
+ if not isinstance(data, dict):
117
+ raise ValueError("Required facts must be tables.")
118
+ return RequiredFact(
119
+ fact_id=unicodedata.normalize("NFC", _string(data, "fact_id")),
120
+ description=unicodedata.normalize("NFC", _string(data, "description")),
121
+ required_terms=tuple(
122
+ unicodedata.normalize("NFC", item) for item in _strings(data, "required_terms")
123
+ ),
124
+ )
125
+
126
+
127
+ def _validate_case(case: EvalCase, case_directory: Path) -> None:
128
+ if not case.case_id or not case.task.strip() or not case.expected_behavior.strip():
129
+ raise ValueError("Case identifiers, task, and expected behavior must be non-empty.")
130
+ if case.max_reasonable_steps < 1:
131
+ raise ValueError("max_reasonable_steps must be positive.")
132
+ timeout = case.validation_timeout_seconds
133
+ if timeout is not None and not 1 <= timeout <= 300:
134
+ raise ValueError("validation_timeout_seconds must be in 1..300.")
135
+ _repository_path(case.initial_repo_state, allow_dot=True)
136
+ fixture = case_directory / PurePosixPath(case.initial_repo_state)
137
+ if not fixture.exists() or not fixture.is_dir():
138
+ raise ValueError("initial_repo_state must name an existing fixture directory.")
139
+ try:
140
+ fixture.resolve(strict=True).relative_to(case_directory.resolve(strict=True))
141
+ except (OSError, ValueError) as exc:
142
+ raise ValueError("initial_repo_state escapes the case directory.") from exc
143
+ for path in (*case.allowed_files, *case.forbidden_files):
144
+ _repository_path(path)
145
+ if len(set(case.allowed_files)) != len(case.allowed_files):
146
+ raise ValueError("allowed_files must be unique.")
147
+ if len(set(case.forbidden_files)) != len(case.forbidden_files):
148
+ raise ValueError("forbidden_files must be unique.")
149
+ if set(case.allowed_files) & set(case.forbidden_files):
150
+ raise ValueError("A path cannot be both allowed and forbidden.")
151
+ if not case.deterministic_success_conditions:
152
+ raise ValueError("At least one deterministic assertion is required.")
153
+ fact_ids = [
154
+ fact.fact_id
155
+ for assertion in case.deterministic_success_conditions
156
+ for fact in assertion.required_facts
157
+ ]
158
+ if len(fact_ids) != len(set(fact_ids)):
159
+ raise ValueError("Required fact ids must be unique within a case.")
160
+ _validate_commands(case, case_directory)
161
+ _validate_mandatory_case(case)
162
+
163
+
164
+ def _validate_assertion(assertion: EvalAssertion) -> None:
165
+ required = {
166
+ EvalAssertionType.REQUIRED_PATH_CHANGED: {"path"},
167
+ EvalAssertionType.REQUIRED_PATH_CREATED: {"path"},
168
+ EvalAssertionType.REQUIRED_TEXT_IN_FILE: {"path", "expected_text"},
169
+ EvalAssertionType.REQUIRED_FINAL_FACTS: {"required_facts"},
170
+ EvalAssertionType.REQUIRED_SECURITY_EVIDENCE: {"security_codes"},
171
+ EvalAssertionType.TARGETED_TEST_ADDED: {"path", "expected_test_node_ids"},
172
+ }.get(assertion.assertion_type, set())
173
+ present = set()
174
+ if assertion.path is not None:
175
+ present.add("path")
176
+ _repository_path(assertion.path)
177
+ if assertion.expected_text is not None:
178
+ present.add("expected_text")
179
+ if assertion.required_facts:
180
+ present.add("required_facts")
181
+ if assertion.security_codes:
182
+ present.add("security_codes")
183
+ if assertion.expected_test_node_ids:
184
+ present.add("expected_test_node_ids")
185
+ if present != required:
186
+ raise ValueError(f"Assertion {assertion.assertion_type.value} has incompatible parameters.")
187
+ for fact in assertion.required_facts:
188
+ if not fact.fact_id or not fact.description.strip() or not fact.required_terms:
189
+ raise ValueError("Required facts and their terms must be non-empty.")
190
+ if any(not term for term in fact.required_terms):
191
+ raise ValueError("Required fact terms must be non-empty.")
192
+ if any(code not in _SECURITY_CODES for code in assertion.security_codes):
193
+ raise ValueError("Security evidence uses an unapproved error code.")
194
+ if len(set(assertion.expected_test_node_ids)) != len(assertion.expected_test_node_ids):
195
+ raise ValueError("Expected pytest node ids must be unique.")
196
+ for node in assertion.expected_test_node_ids:
197
+ if "::" not in node:
198
+ raise ValueError("Expected pytest node ids must include a node suffix.")
199
+ _repository_path(node.split("::", 1)[0])
200
+
201
+
202
+ def _validate_commands(case: EvalCase, case_directory: Path) -> None:
203
+ for command, discovery in (
204
+ (case.pre_validation_command, False),
205
+ (case.validation_command, False),
206
+ (case.test_discovery_command, True),
207
+ ):
208
+ if not command:
209
+ continue
210
+ operation = (
211
+ EvalHarnessOperation.TEST_DISCOVERY if discovery else EvalHarnessOperation.VALIDATION
212
+ )
213
+ if _is_absolute_executable(command[0]):
214
+ try:
215
+ policy = EvalHarnessCommandPolicy(_resolve_eval_executables(case_directory))
216
+ valid = (
217
+ policy.classify(
218
+ operation=operation.value,
219
+ arguments={"argv": command},
220
+ )
221
+ is RiskLevel.SAFE
222
+ )
223
+ except Exception:
224
+ valid = False
225
+ if not valid:
226
+ raise ValueError(f"Command is outside the Day 9 Harness grammar: {command!r}")
227
+ continue
228
+ if "/" in command[0] or "\\" in command[0]:
229
+ raise ValueError(f"Command is outside the Day 9 Harness grammar: {command!r}")
230
+ executable = Path(command[0]).name.casefold().removesuffix(".exe")
231
+ if discovery:
232
+ valid = executable == "pytest" and EvalHarnessCommandPolicy._valid_discovery(
233
+ command[1:]
234
+ )
235
+ else:
236
+ valid = EvalHarnessCommandPolicy._valid_validation(executable, command[1:])
237
+ if not valid:
238
+ raise ValueError(f"Command is outside the Day 9 Harness grammar: {command!r}")
239
+
240
+
241
+ def _validate_mandatory_case(case: EvalCase) -> None:
242
+ assertions = case.deterministic_success_conditions
243
+ types = [item.assertion_type for item in assertions]
244
+ if case.case_id in {"EVAL-001", "EVAL-002", "EVAL-003"}:
245
+ if (
246
+ not case.pre_validation_command
247
+ or case.pre_validation_command != case.validation_command
248
+ ):
249
+ raise ValueError(
250
+ f"{case.case_id} requires equal non-empty pre/post validation commands."
251
+ )
252
+ if case.test_discovery_command:
253
+ raise ValueError(f"{case.case_id} test discovery command must be empty.")
254
+ if case.case_id in {"EVAL-001", "EVAL-002"} and (
255
+ Path(case.pre_validation_command[0]).name.casefold().removesuffix(".exe") != "pytest"
256
+ ):
257
+ raise ValueError(f"{case.case_id} requires pytest pre/post validation.")
258
+ common = {
259
+ EvalAssertionType.VALIDATION_PASSES,
260
+ EvalAssertionType.NO_UNAUTHORIZED_CHANGES,
261
+ EvalAssertionType.NO_FORBIDDEN_CHANGES,
262
+ }
263
+ if not common.issubset(types):
264
+ raise ValueError(f"{case.case_id} is missing mandatory postconditions.")
265
+ required_type = (
266
+ EvalAssertionType.REQUIRED_PATH_CHANGED if case.case_id != "EVAL-002" else None
267
+ )
268
+ if required_type and required_type not in types:
269
+ raise ValueError(f"{case.case_id} requires a changed-path assertion.")
270
+ if case.case_id == "EVAL-002" and not any(
271
+ item in types
272
+ for item in (
273
+ EvalAssertionType.REQUIRED_PATH_CHANGED,
274
+ EvalAssertionType.REQUIRED_PATH_CREATED,
275
+ )
276
+ ):
277
+ raise ValueError("EVAL-002 requires a path change/create assertion.")
278
+ elif case.case_id == "EVAL-004":
279
+ targeted = [
280
+ item
281
+ for item in assertions
282
+ if item.assertion_type is EvalAssertionType.TARGETED_TEST_ADDED
283
+ ]
284
+ if len(targeted) != 1:
285
+ raise ValueError("EVAL-004 requires exactly one TARGETED_TEST_ADDED assertion.")
286
+ common = {
287
+ EvalAssertionType.VALIDATION_PASSES,
288
+ EvalAssertionType.NO_UNAUTHORIZED_CHANGES,
289
+ EvalAssertionType.NO_FORBIDDEN_CHANGES,
290
+ }
291
+ if not common.issubset(types):
292
+ raise ValueError("EVAL-004 is missing mandatory postconditions.")
293
+ assertion = targeted[0]
294
+ assert assertion.path is not None
295
+ if (
296
+ not case.pre_validation_command
297
+ or not case.validation_command
298
+ or not case.test_discovery_command
299
+ or case.pre_validation_command == case.validation_command
300
+ ):
301
+ raise ValueError("EVAL-004 requires distinct explicit pre/post/discovery commands.")
302
+ post_targets = _pytest_targets(case.validation_command)
303
+ pre_targets = set(_pytest_targets(case.pre_validation_command))
304
+ expected = set(assertion.expected_test_node_ids)
305
+ if "-vv" not in case.validation_command or "-q" in case.validation_command:
306
+ raise ValueError("EVAL-004 post-validation requires -vv and forbids -q.")
307
+ if not expected.issubset(post_targets) or expected & pre_targets:
308
+ raise ValueError("EVAL-004 expected nodes must be post-targeted and not pre-targeted.")
309
+ if any(node.split("::", 1)[0] != assertion.path for node in expected):
310
+ raise ValueError("EVAL-004 node ids must belong to TARGETED_TEST_ADDED.path.")
311
+ if assertion.path not in case.test_discovery_command[3:]:
312
+ raise ValueError("EVAL-004 discovery must target the asserted test file.")
313
+ elif case.case_id == "EVAL-005":
314
+ if case.pre_validation_command or case.validation_command or case.test_discovery_command:
315
+ raise ValueError("EVAL-005 commands must all be empty.")
316
+ if (
317
+ EvalAssertionType.NO_REPOSITORY_CHANGES not in types
318
+ or EvalAssertionType.REQUIRED_FINAL_FACTS not in types
319
+ ):
320
+ raise ValueError("EVAL-005 requires no-change and final-facts assertions.")
321
+ elif case.case_id == "EVAL-006":
322
+ if case.pre_validation_command or case.validation_command or case.test_discovery_command:
323
+ raise ValueError("Mandatory EVAL-006 commands must all be empty.")
324
+ required = {
325
+ EvalAssertionType.NO_UNAUTHORIZED_CHANGES,
326
+ EvalAssertionType.NO_FORBIDDEN_CHANGES,
327
+ EvalAssertionType.REQUIRED_SECURITY_EVIDENCE,
328
+ }
329
+ if not required.issubset(types):
330
+ raise ValueError("EVAL-006 requires repository and formal security assertions.")
331
+
332
+
333
+ def _pytest_targets(command: tuple[str, ...]) -> set[str]:
334
+ return {item for item in command[1:] if not item.startswith("-") and not item.isdigit()}
335
+
336
+
337
+ def _repository_path(value: str, *, allow_dot: bool = False) -> None:
338
+ path = PurePosixPath(value)
339
+ if (
340
+ not value
341
+ or path.is_absolute()
342
+ or PureWindowsPath(value).is_absolute()
343
+ or ".." in path.parts
344
+ or "\\" in value
345
+ or (value == "." and not allow_dot)
346
+ or value != path.as_posix()
347
+ ):
348
+ raise ValueError(f"Invalid repository-relative POSIX path: {value!r}")
349
+
350
+
351
+ def _string(data: dict[str, object], name: str) -> str:
352
+ value = data.get(name)
353
+ if not isinstance(value, str) or not value:
354
+ raise ValueError(f"{name} must be a non-empty string.")
355
+ return value
356
+
357
+
358
+ def _optional_string(data: dict[str, object], name: str) -> str | None:
359
+ value = data.get(name)
360
+ if value is None:
361
+ return None
362
+ if not isinstance(value, str):
363
+ raise ValueError(f"{name} must be a string.")
364
+ return value
365
+
366
+
367
+ def _strings(data: dict[str, object], name: str) -> tuple[str, ...]:
368
+ value = data.get(name)
369
+ if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
370
+ raise ValueError(f"{name} must be an array of non-empty strings.")
371
+ return tuple(value)
372
+
373
+
374
+ def _optional_strings(data: dict[str, object], name: str) -> tuple[str, ...]:
375
+ return () if name not in data else _strings(data, name)
376
+
377
+
378
+ def _integer(data: dict[str, object], name: str) -> int:
379
+ value = data.get(name)
380
+ if not isinstance(value, int) or isinstance(value, bool):
381
+ raise ValueError(f"{name} must be an integer.")
382
+ return value
383
+
384
+
385
+ def _optional_int(data: dict[str, object], name: str) -> int | None:
386
+ return None if data.get(name) is None else _integer(data, name)
387
+
388
+
389
+ def _list(data: dict[str, object], name: str) -> list[object]:
390
+ value = data.get(name)
391
+ if not isinstance(value, list):
392
+ raise ValueError(f"{name} must be an array of tables.")
393
+ return value
394
+
395
+
396
+ def _optional_list(data: dict[str, object], name: str) -> list[object]:
397
+ return [] if name not in data else _list(data, name)
398
+
399
+
400
+ def _metadata(value: object) -> dict[str, object] | None:
401
+ if value is None:
402
+ return None
403
+ if not isinstance(value, dict) or not _is_json_value(value):
404
+ raise ValueError("metadata must be a JSON object.")
405
+ return dict(value)
406
+
407
+
408
+ def _is_json_value(value: object) -> bool:
409
+ if value is None or isinstance(value, (str, int, float, bool)):
410
+ return True
411
+ if isinstance(value, list):
412
+ return all(_is_json_value(item) for item in value)
413
+ if isinstance(value, dict):
414
+ return all(isinstance(key, str) and _is_json_value(item) for key, item in value.items())
415
+ return False
@@ -0,0 +1,27 @@
1
+ """Authoritative Day 8 trace-finish collection for evaluations."""
2
+
3
+ from nexus.domain.observability import TraceRunFinish
4
+ from nexus.evaluation.models import EvalMetrics
5
+
6
+
7
+ class InMemoryEvalTraceCollector:
8
+ def __init__(self) -> None:
9
+ self._finishes: dict[str, TraceRunFinish] = {}
10
+
11
+ def record_finish(self, finish: TraceRunFinish) -> None:
12
+ self._finishes[finish.context.run_id] = finish
13
+
14
+ def get_finish(self, run_id: str) -> TraceRunFinish | None:
15
+ return self._finishes.get(run_id)
16
+
17
+
18
+ def metrics_from_finish(finish: TraceRunFinish) -> EvalMetrics:
19
+ return EvalMetrics(
20
+ agent_steps=finish.step_count,
21
+ llm_calls=finish.llm_call_count,
22
+ tool_calls=finish.tool_call_count,
23
+ replans=finish.replan_count,
24
+ repairs=finish.repair_count,
25
+ latency_ms=finish.duration_ms,
26
+ token_usage=finish.token_usage,
27
+ )