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,411 @@
1
+ """Day 2 session business rules and transaction orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import replace
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from uuid import UUID, uuid4
10
+
11
+ from nexus.domain.model import TokenUsageAggregate
12
+ from nexus.domain.persistence import (
13
+ NexusSession,
14
+ Repository,
15
+ Run,
16
+ RunStatus,
17
+ SessionSummary,
18
+ SessionTurn,
19
+ )
20
+ from nexus.domain.planning import TerminalStatus
21
+ from nexus.domain.ports.session_unit_of_work import SessionUnitOfWork, SessionUnitOfWorkFactory
22
+ from nexus.domain.runtime_events import FinalResult
23
+ from nexus.errors import SessionError
24
+
25
+
26
+ class SessionService:
27
+ """Own Day 2 session, run, history, and repository-safety decisions."""
28
+
29
+ def __init__(
30
+ self,
31
+ unit_of_work_factory: SessionUnitOfWorkFactory,
32
+ workspace_path: Path,
33
+ ) -> None:
34
+ self._unit_of_work_factory = unit_of_work_factory
35
+ self._canonical_repository_path = canonicalize_repository_path(workspace_path)
36
+
37
+ async def start_run(
38
+ self,
39
+ *,
40
+ run_id: str,
41
+ task: str,
42
+ session_id: str | None,
43
+ model_metadata: dict[str, object] | None,
44
+ ) -> Run:
45
+ """Atomically establish repository, session, run, and initiating turn."""
46
+
47
+ canonical_run_id = _canonical_uuid(run_id, "run")
48
+ requested_session_id = (
49
+ None if session_id is None else _canonical_uuid(session_id, "session")
50
+ )
51
+ now = datetime.now(UTC)
52
+ try:
53
+ async with self._unit_of_work_factory() as unit_of_work:
54
+ repository = await self._get_or_create_repository(unit_of_work, now)
55
+ session = await self._get_or_create_owned_session(
56
+ unit_of_work,
57
+ repository,
58
+ requested_session_id,
59
+ now,
60
+ )
61
+ run = Run(
62
+ run_id=canonical_run_id,
63
+ session_id=session.session_id,
64
+ task=task,
65
+ status=RunStatus.RUNNING,
66
+ model_metadata=None if model_metadata is None else dict(model_metadata),
67
+ started_at=now,
68
+ finished_at=None,
69
+ token_count=0,
70
+ tool_call_count=0,
71
+ changed_file_refs=None,
72
+ final_outcome=None,
73
+ graph_thread_id=f"nexus-run:{canonical_run_id}",
74
+ )
75
+ await unit_of_work.runs.add(run)
76
+ sequence = await unit_of_work.turns.next_sequence(session.session_id)
77
+ await unit_of_work.turns.add(
78
+ SessionTurn(
79
+ id=str(uuid4()),
80
+ session_id=session.session_id,
81
+ run_id=run.run_id,
82
+ sequence=sequence,
83
+ role="user",
84
+ content=task,
85
+ metadata=None,
86
+ created_at=now,
87
+ )
88
+ )
89
+ await unit_of_work.sessions.update(replace(session, last_active_at=now))
90
+ return run
91
+ except SessionError:
92
+ raise
93
+ except Exception as exc:
94
+ raise SessionError(
95
+ "Nexus could not persist the new session run.",
96
+ code="SESSION_PERSISTENCE_ERROR",
97
+ retryable=True,
98
+ ) from exc
99
+
100
+ async def list_sessions(self) -> list[SessionSummary]:
101
+ """List current-repository sessions in deterministic recency order."""
102
+
103
+ try:
104
+ async with self._unit_of_work_factory() as unit_of_work:
105
+ repository = await unit_of_work.repositories.get_by_canonical_path(
106
+ self._canonical_repository_path
107
+ )
108
+ if repository is None:
109
+ return []
110
+ sessions = await unit_of_work.sessions.list_by_repository(
111
+ repository.repository_id
112
+ )
113
+ return [
114
+ SessionSummary(
115
+ session_id=session.session_id,
116
+ last_active_at=session.last_active_at,
117
+ resumable=await unit_of_work.runs.has_interrupted(session.session_id),
118
+ )
119
+ for session in sessions
120
+ ]
121
+ except Exception as exc:
122
+ raise SessionError(
123
+ "Nexus could not list persisted sessions.",
124
+ code="SESSION_PERSISTENCE_ERROR",
125
+ retryable=True,
126
+ ) from exc
127
+
128
+ async def resolve_resumable_run(self, session_id: str) -> Run:
129
+ """Validate ownership before resolving the latest interrupted run."""
130
+
131
+ canonical_session_id = _canonical_uuid(session_id, "session")
132
+ try:
133
+ async with self._unit_of_work_factory() as unit_of_work:
134
+ session = await self._get_owned_session(unit_of_work, canonical_session_id)
135
+ run = await unit_of_work.runs.get_latest_interrupted(session.session_id)
136
+ if run is None:
137
+ raise SessionError(
138
+ "The session has no interrupted run to resume.",
139
+ code="SESSION_NOT_RESUMABLE",
140
+ )
141
+ return run
142
+ except SessionError:
143
+ raise
144
+ except Exception as exc:
145
+ raise SessionError(
146
+ "Nexus could not resolve the resumable session run.",
147
+ code="SESSION_PERSISTENCE_ERROR",
148
+ retryable=True,
149
+ ) from exc
150
+
151
+ async def context_repository_id(self) -> str:
152
+ """Resolve the business repository identity without creating a coding Run."""
153
+ async with self._unit_of_work_factory() as unit_of_work:
154
+ repository = await self._get_or_create_repository(unit_of_work, datetime.now(UTC))
155
+ return repository.repository_id
156
+
157
+ async def context_turns(self, session_id: str) -> list[SessionTurn]:
158
+ """Return durable user-visible turns for bounded context selection."""
159
+ async with self._unit_of_work_factory() as unit_of_work:
160
+ session = await self._get_owned_session(unit_of_work, session_id)
161
+ return await unit_of_work.turns.list_by_session(session.session_id)
162
+
163
+ async def mark_interrupted(self, run_id: str) -> Run:
164
+ """Record interruption only after the graph checkpoint is durable."""
165
+
166
+ return await self._transition_run(
167
+ run_id,
168
+ status=RunStatus.INTERRUPTED,
169
+ final_outcome=None,
170
+ finished_at=None,
171
+ assistant_content=None,
172
+ )
173
+
174
+ async def complete_run(self, run_id: str, content: str) -> Run:
175
+ """Atomically persist a terminal outcome and assistant history turn."""
176
+
177
+ return await self._transition_run(
178
+ run_id,
179
+ status=RunStatus.COMPLETED,
180
+ final_outcome={"content": content},
181
+ finished_at=datetime.now(UTC),
182
+ assistant_content=content,
183
+ )
184
+
185
+ async def fail_run(self, run_id: str, *, code: str, message: str) -> Run:
186
+ """Persist a safe terminal failure without raw infrastructure details."""
187
+
188
+ return await self._transition_run(
189
+ run_id,
190
+ status=RunStatus.FAILED,
191
+ final_outcome={"error": {"code": code, "message": message}},
192
+ finished_at=datetime.now(UTC),
193
+ assistant_content=None,
194
+ )
195
+
196
+ async def finalize_run(
197
+ self,
198
+ run_id: str,
199
+ event: FinalResult,
200
+ *,
201
+ tool_call_count: int,
202
+ step_count: int,
203
+ llm_call_count: int,
204
+ replan_count: int,
205
+ repair_count: int,
206
+ token_usage: TokenUsageAggregate | None = None,
207
+ ) -> Run:
208
+ """Persist one truthful Day 4 terminal result without a schema change."""
209
+
210
+ outcome = event.to_dict()["payload"]
211
+ aggregate = token_usage or TokenUsageAggregate()
212
+ outcome["metrics"] = {
213
+ "step_count": step_count,
214
+ "llm_call_count": llm_call_count,
215
+ "replan_count": replan_count,
216
+ "repair_count": repair_count,
217
+ "token_usage": aggregate.to_dict(),
218
+ }
219
+ return await self._transition_run(
220
+ run_id,
221
+ status=(
222
+ RunStatus.COMPLETED
223
+ if event.terminal_status is TerminalStatus.SUCCEEDED
224
+ else RunStatus.FAILED
225
+ ),
226
+ final_outcome=outcome,
227
+ finished_at=datetime.now(UTC),
228
+ assistant_content=event.content,
229
+ tool_call_count=tool_call_count,
230
+ token_count=aggregate.total_tokens_reported,
231
+ changed_file_refs=[item.path for item in event.changed_files],
232
+ )
233
+
234
+ async def _transition_run(
235
+ self,
236
+ run_id: str,
237
+ *,
238
+ status: RunStatus,
239
+ final_outcome: dict[str, object] | None,
240
+ finished_at: datetime | None,
241
+ assistant_content: str | None,
242
+ tool_call_count: int | None = None,
243
+ token_count: int | None = None,
244
+ changed_file_refs: list[str] | None = None,
245
+ ) -> Run:
246
+ canonical_run_id = _canonical_uuid(run_id, "run")
247
+ now = datetime.now(UTC)
248
+ try:
249
+ async with self._unit_of_work_factory() as unit_of_work:
250
+ run = await unit_of_work.runs.get(canonical_run_id)
251
+ if run is None:
252
+ raise SessionError("The run does not exist.", code="RUN_NOT_FOUND")
253
+ if status is RunStatus.INTERRUPTED and run.status is not RunStatus.RUNNING:
254
+ raise SessionError(
255
+ "Only a running run may become interrupted.",
256
+ code="INVALID_RUN_TRANSITION",
257
+ )
258
+ if status is RunStatus.COMPLETED and run.status not in {
259
+ RunStatus.RUNNING,
260
+ RunStatus.INTERRUPTED,
261
+ }:
262
+ raise SessionError(
263
+ "Only a running or interrupted run may complete.",
264
+ code="INVALID_RUN_TRANSITION",
265
+ )
266
+ if status is RunStatus.FAILED and run.status not in {
267
+ RunStatus.RUNNING,
268
+ RunStatus.INTERRUPTED,
269
+ }:
270
+ raise SessionError(
271
+ "Only a running or interrupted run may fail.",
272
+ code="INVALID_RUN_TRANSITION",
273
+ )
274
+ updated = replace(
275
+ run,
276
+ status=status,
277
+ final_outcome=final_outcome,
278
+ finished_at=finished_at,
279
+ tool_call_count=(
280
+ run.tool_call_count
281
+ if tool_call_count is None
282
+ else tool_call_count
283
+ ),
284
+ token_count=(run.token_count if token_count is None else token_count),
285
+ changed_file_refs=(
286
+ run.changed_file_refs
287
+ if changed_file_refs is None
288
+ else list(changed_file_refs)
289
+ ),
290
+ )
291
+ await unit_of_work.runs.update(updated)
292
+ session = await unit_of_work.sessions.get(run.session_id)
293
+ if session is None:
294
+ raise SessionError("The run session does not exist.", code="SESSION_NOT_FOUND")
295
+ await unit_of_work.sessions.update(replace(session, last_active_at=now))
296
+ if assistant_content is not None:
297
+ sequence = await unit_of_work.turns.next_sequence(session.session_id)
298
+ await unit_of_work.turns.add(
299
+ SessionTurn(
300
+ id=str(uuid4()),
301
+ session_id=session.session_id,
302
+ run_id=run.run_id,
303
+ sequence=sequence,
304
+ role="assistant",
305
+ content=assistant_content,
306
+ metadata=None,
307
+ created_at=now,
308
+ )
309
+ )
310
+ return updated
311
+ except SessionError:
312
+ raise
313
+ except Exception as exc:
314
+ raise SessionError(
315
+ "Nexus could not persist the run state.",
316
+ code="SESSION_PERSISTENCE_ERROR",
317
+ retryable=True,
318
+ ) from exc
319
+
320
+ async def _get_or_create_repository(
321
+ self,
322
+ unit_of_work: SessionUnitOfWork,
323
+ now: datetime,
324
+ ) -> Repository:
325
+ repository = await unit_of_work.repositories.get_by_canonical_path(
326
+ self._canonical_repository_path
327
+ )
328
+ if repository is not None:
329
+ return repository
330
+ repository = Repository(
331
+ repository_id=str(uuid4()),
332
+ canonical_path=self._canonical_repository_path,
333
+ metadata={},
334
+ configuration_reference=None,
335
+ created_at=now,
336
+ )
337
+ await unit_of_work.repositories.add(repository)
338
+ return repository
339
+
340
+ async def _get_or_create_owned_session(
341
+ self,
342
+ unit_of_work: SessionUnitOfWork,
343
+ repository: Repository,
344
+ session_id: str | None,
345
+ now: datetime,
346
+ ) -> NexusSession:
347
+ if session_id is not None:
348
+ session = await self._get_owned_session(unit_of_work, session_id)
349
+ if session.repository_id != repository.repository_id:
350
+ raise SessionError(
351
+ "The session belongs to a different repository.",
352
+ code="SESSION_REPOSITORY_MISMATCH",
353
+ )
354
+ return session
355
+ session = NexusSession(
356
+ session_id=str(uuid4()),
357
+ repository_id=repository.repository_id,
358
+ created_at=now,
359
+ last_active_at=now,
360
+ configuration_reference=None,
361
+ )
362
+ await unit_of_work.sessions.add(session)
363
+ return session
364
+
365
+ async def _get_owned_session(
366
+ self,
367
+ unit_of_work: SessionUnitOfWork,
368
+ session_id: str,
369
+ ) -> NexusSession:
370
+ session = await unit_of_work.sessions.get(session_id)
371
+ if session is None:
372
+ raise SessionError("The session does not exist.", code="SESSION_NOT_FOUND")
373
+ repository = await unit_of_work.repositories.get(session.repository_id)
374
+ if repository is None:
375
+ raise SessionError(
376
+ "The session repository no longer exists.",
377
+ code="SESSION_REPOSITORY_MISSING",
378
+ )
379
+ if repository.canonical_path != self._canonical_repository_path:
380
+ raise SessionError(
381
+ "The session belongs to a different repository.",
382
+ code="SESSION_REPOSITORY_MISMATCH",
383
+ )
384
+ return session
385
+
386
+
387
+ def canonicalize_repository_path(path: Path) -> str:
388
+ """Resolve an existing workspace using platform-native case normalization."""
389
+
390
+ try:
391
+ resolved = path.resolve(strict=True)
392
+ except OSError as exc:
393
+ raise SessionError(
394
+ "The active repository path does not exist.",
395
+ code="REPOSITORY_PATH_INVALID",
396
+ ) from exc
397
+ if not resolved.is_dir():
398
+ raise SessionError(
399
+ "The active repository path is not a directory.",
400
+ code="REPOSITORY_PATH_INVALID",
401
+ )
402
+ return os.path.normcase(str(resolved))
403
+
404
+
405
+ def _canonical_uuid(value: str, label: str) -> str:
406
+ try:
407
+ return str(UUID(value))
408
+ except ValueError as exc:
409
+ raise SessionError(
410
+ f"The {label} identifier is invalid.", code="INVALID_SESSION_ID"
411
+ ) from exc
@@ -0,0 +1,96 @@
1
+ """Bounded, provider-neutral recovery for Nexus structured model output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+
7
+ from nexus.application.model_call_context import bind_model_call_phase
8
+ from nexus.domain.model import ModelCallPhase, ModelMessage
9
+ from nexus.domain.ports.model_gateway import ModelGateway
10
+
11
+ _MessagesForAttempt = Callable[[ModelMessage | None], Sequence[ModelMessage]]
12
+
13
+ _CATEGORY_RULES = {
14
+ "ACTION_RELATION": "TOOL_ACTION requires an action; CONTINUE and TASK_READY require null.",
15
+ "ACTION_SCHEMA": "The action envelope must match the frozen AgentDecision schema.",
16
+ "COMMAND_CWD_RELATION": "command_cwd is null unless command_argv is present.",
17
+ "COMMAND_TOOL_RELATION": "A command step uses tool_name shell, a cwd, and no target paths.",
18
+ "EDIT_TARGET_COUNT": "An editing step targets exactly one repository-relative path.",
19
+ "INVALID_REPOSITORY_PATH": "Repository paths must use the allowed relative form.",
20
+ "JSON_DECODE": "Return exactly one raw JSON object without prose or a markdown fence.",
21
+ "KIND_ENUM": "AgentDecision kind must be TOOL_ACTION, CONTINUE, or TASK_READY.",
22
+ "NARRATIVE_AUTHORITY": "A narrative step cannot carry Tool authority.",
23
+ "NON_FROZEN_TOOL_AUTHORITY": "Only frozen Day 4 tools may carry Plan authority.",
24
+ "PLAN_SCHEMA": "The complete Plan must satisfy the frozen Nexus Plan contract.",
25
+ "REQUIRED_FIELD": "Every required text field must be a non-empty string.",
26
+ "SKILL_DUPLICATE_IDS": "selected_skill_ids must not contain duplicate identities.",
27
+ "SKILL_IDS_SCHEMA": "selected_skill_ids must be an array of valid Skill identities.",
28
+ "SKILL_MAX_SELECTED": "selected_skill_ids must not exceed the supplied maximum.",
29
+ "SKILL_SUMMARY_SCHEMA": "selection_reason_summary must be a non-empty bounded string.",
30
+ "SKILL_UNKNOWN_ID": "Every selected identity must exist in the supplied metadata.",
31
+ "STEP_KEYS": "Every PlanStep must contain exactly the frozen PlanStep fields.",
32
+ "STEP_SCHEMA": "Every PlanStep field must satisfy its frozen type contract.",
33
+ "STEPS_SCHEMA": "steps must be a non-empty array of PlanStep objects.",
34
+ "TOP_LEVEL_KEYS": "The top-level object must contain exactly the frozen fields.",
35
+ "TOP_LEVEL_TYPE": "The structured response must be a top-level JSON object.",
36
+ }
37
+
38
+ _AGENT_RETRY_REMINDER = (
39
+ " The approved Plan and observations remain authoritative. "
40
+ "Do not repeat actions recorded as successful."
41
+ )
42
+
43
+
44
+ class StructuredOutputViolation(ValueError):
45
+ """Allowlisted safe category without retaining model-authored content."""
46
+
47
+ def __init__(self, category: str) -> None:
48
+ if category not in _CATEGORY_RULES:
49
+ raise ValueError("Structured-output violation category is not allowlisted.")
50
+ self.category = category
51
+ super().__init__(category)
52
+
53
+
54
+ async def complete_structured[ResultT](
55
+ gateway: ModelGateway,
56
+ *,
57
+ phase: ModelCallPhase,
58
+ messages_for_attempt: _MessagesForAttempt,
59
+ parse: Callable[[str], ResultT],
60
+ ) -> ResultT:
61
+ """Generate and strictly parse, with one bounded sanitized retry."""
62
+
63
+ feedback: ModelMessage | None = None
64
+ for attempt in range(2):
65
+ messages = messages_for_attempt(feedback)
66
+ with bind_model_call_phase(phase):
67
+ response = await gateway.complete(messages)
68
+ try:
69
+ return parse(response.content)
70
+ except StructuredOutputViolation as exc:
71
+ if attempt == 1:
72
+ raise
73
+ feedback = _retry_feedback(phase, exc.category)
74
+ raise AssertionError("The bounded structured-output loop did not terminate.")
75
+
76
+
77
+ def append_retry_feedback(
78
+ messages: Sequence[ModelMessage],
79
+ feedback: ModelMessage | None,
80
+ ) -> tuple[ModelMessage, ...]:
81
+ """Append only the fixed sanitized feedback generated by this module."""
82
+
83
+ return tuple(messages) if feedback is None else (*messages, feedback)
84
+
85
+
86
+ def _retry_feedback(phase: ModelCallPhase, category: str) -> ModelMessage:
87
+ rule = _CATEGORY_RULES[category]
88
+ agent_reminder = _AGENT_RETRY_REMINDER if phase is ModelCallPhase.AGENT_STEP else ""
89
+ return ModelMessage(
90
+ "user",
91
+ "Previous structured output violated the frozen Nexus contract. "
92
+ f"Category: {category}. Frozen rule: {rule} "
93
+ "Regenerate one complete replacement object. Return raw JSON only. "
94
+ "Do not quote or refer to the previous output."
95
+ f"{agent_reminder}",
96
+ )