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,966 @@
1
+ """Frozen Day 4 coding loop implemented behind Nexus-owned boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Awaitable, Callable, Sequence
7
+ from dataclasses import fields, replace
8
+ from typing import Any, cast
9
+ from uuid import uuid4
10
+
11
+ from langchain_core.runnables import RunnableConfig
12
+ from langgraph.checkpoint.base import BaseCheckpointSaver
13
+ from langgraph.graph import END, START, StateGraph
14
+ from langgraph.types import Command, interrupt
15
+
16
+ from nexus.application.diff_service import FinalDiffCollector, FinalDiffEvidence
17
+ from nexus.application.event_publisher import LiveRuntimeEventBridge
18
+ from nexus.application.execution_ledger import (
19
+ RuntimeEventBuffer,
20
+ ToolExecutionLedger,
21
+ )
22
+ from nexus.application.plan_approval_service import PlanApprovalService
23
+ from nexus.application.tool_runtime import ToolRuntime
24
+ from nexus.config.models import ApprovalMode
25
+ from nexus.domain.agent_decision import (
26
+ AgentDecisionKind,
27
+ AgentDecisionRequest,
28
+ Observation,
29
+ ToolAction,
30
+ )
31
+ from nexus.domain.agent_state import AgentState
32
+ from nexus.domain.approvals import ApprovalRequest
33
+ from nexus.domain.exploration import ContextBuildRequest, ExplorationRequest
34
+ from nexus.domain.model import ModelMessage
35
+ from nexus.domain.persistence import SessionTurn
36
+ from nexus.domain.planning import (
37
+ ChangedFile,
38
+ ChangeKind,
39
+ Plan,
40
+ PlanApprovalResumeInput,
41
+ PlanKind,
42
+ PlanStatus,
43
+ TerminalStatus,
44
+ )
45
+ from nexus.domain.ports.agent_decision import AgentDecisionAdapter
46
+ from nexus.domain.ports.context import ContextManager
47
+ from nexus.domain.ports.graph_runtime import GraphRuntime
48
+ from nexus.domain.ports.planning import Planner, PlanningRequest, RepairPlanningRequest
49
+ from nexus.domain.ports.repository_context import ContextBuilder, RepositoryExplorer
50
+ from nexus.domain.ports.validation import ValidationPlanner, ValidationRunner
51
+ from nexus.domain.runtime_events import (
52
+ AgentStepCompleted,
53
+ ApprovalActorCategory,
54
+ ApprovalRequested,
55
+ ApprovalResolved,
56
+ ApprovalSubject,
57
+ ChangedFileRecorded,
58
+ ContextBuilt,
59
+ FinalResult,
60
+ PlanCreated,
61
+ RepairStarted,
62
+ ReplanOccurred,
63
+ RepositoryExplored,
64
+ RuntimeStatus,
65
+ )
66
+ from nexus.domain.tooling import ApprovalDecision, RiskLevel, ToolInvocation, ToolResult
67
+ from nexus.domain.validation import ValidationStatus
68
+ from nexus.errors import NexusError
69
+
70
+ _SAFE_PATCH_FAILURE_DETAILS = {
71
+ ("INVALID_PATCH", "The patch exceeds the Day 4 size limit."): "patch exceeds size limit",
72
+ ("INVALID_PATCH", "The patched file exceeds the Day 4 size limit."): (
73
+ "patched file exceeds size limit"
74
+ ),
75
+ ("INVALID_PATCH", "The patch target is not supported UTF-8 text."): (
76
+ "target is not supported UTF-8 text"
77
+ ),
78
+ ("INVALID_PATCH", "The patch target is not valid UTF-8 text."): (
79
+ "target is not valid UTF-8 text"
80
+ ),
81
+ ("INVALID_PATCH", "The patch has no valid hunk."): "patch has no valid hunk",
82
+ ("INVALID_PATCH", "Patch headers do not match the target."): (
83
+ "patch headers do not match the target path"
84
+ ),
85
+ ("INVALID_PATCH", "Multi-file patches are not allowed."): (
86
+ "patch must contain only one file"
87
+ ),
88
+ ("INVALID_PATCH", "The patch contains invalid hunk syntax."): (
89
+ "unified-diff hunk syntax is invalid"
90
+ ),
91
+ ("INVALID_PATCH", "Invalid no-newline marker."): "no-newline marker is invalid",
92
+ ("INVALID_PATCH", "Patch hunk body is invalid."): "unified-diff hunk body is invalid",
93
+ ("INVALID_PATCH", "Patch hunk counts do not match."): (
94
+ "unified-diff hunk header counts do not match the hunk body"
95
+ ),
96
+ ("INVALID_PATCH", "The patch has no hunk."): "patch has no hunk",
97
+ ("INVALID_PATCH", "The patch is invalid."): "patch is invalid",
98
+ ("PATCH_CONFLICT", "Patch hunk position is invalid."): "hunk position is invalid",
99
+ ("PATCH_CONFLICT", "No-newline marker conflicts with source."): (
100
+ "no-newline marker conflicts with current source"
101
+ ),
102
+ ("PATCH_CONFLICT", "Patch context does not match."): (
103
+ "patch context does not match current source"
104
+ ),
105
+ ("PATCH_NO_CHANGES", "The patch does not change file content."): (
106
+ "patch does not change file content"
107
+ ),
108
+ }
109
+ _PATCH_HUNK_HEADER = re.compile(
110
+ r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$"
111
+ )
112
+
113
+
114
+ class Day4LangGraphRuntime:
115
+ """Execute the approved Day 4 graph with durable Plan approval interrupts."""
116
+
117
+ def __init__(
118
+ self,
119
+ *,
120
+ explorer: RepositoryExplorer,
121
+ context_builder: ContextBuilder,
122
+ planner: Planner,
123
+ agent: AgentDecisionAdapter,
124
+ tool_runtime: ToolRuntime,
125
+ validation_planner: ValidationPlanner,
126
+ validation_runner: ValidationRunner,
127
+ plan_approval_service: PlanApprovalService,
128
+ diff_collector: FinalDiffCollector,
129
+ event_buffer: RuntimeEventBuffer | LiveRuntimeEventBridge,
130
+ ledger: ToolExecutionLedger,
131
+ approval_mode: ApprovalMode,
132
+ max_steps: int,
133
+ max_repair_attempts: int,
134
+ max_replans: int,
135
+ checkpointer: BaseCheckpointSaver[Any] | None = None,
136
+ legacy_runtime: GraphRuntime | None = None,
137
+ context_manager: ContextManager | None = None,
138
+ conversation_turns: Callable[[str], Awaitable[Sequence[SessionTurn]]] | None = None,
139
+ model_call_id: Callable[[], str] | None = None,
140
+ ) -> None:
141
+ self._explorer = explorer
142
+ self._context_builder = context_builder
143
+ self._planner = planner
144
+ self._agent = agent
145
+ self._tool_runtime = tool_runtime
146
+ self._validation_planner = validation_planner
147
+ self._validation_runner = validation_runner
148
+ self._plan_approval_service = plan_approval_service
149
+ self._diff_collector = diff_collector
150
+ self._events = event_buffer
151
+ self._ledger = ledger
152
+ self._approval_mode = approval_mode
153
+ self._max_steps = max_steps
154
+ self._max_repair_attempts = max_repair_attempts
155
+ self._max_replans = max_replans
156
+ self._legacy_runtime = legacy_runtime
157
+ self._context_manager = context_manager
158
+ self._conversation_turns = conversation_turns
159
+ self._model_call_id = model_call_id
160
+
161
+ builder = StateGraph(AgentState)
162
+ builder.add_node("initialize_run", self._initialize_run)
163
+ builder.add_node("explore_repository", self._explore_repository)
164
+ builder.add_node("build_context", self._build_context)
165
+ builder.add_node("create_plan", self._create_plan)
166
+ builder.add_node(
167
+ "approval_gate",
168
+ self._approval_gate,
169
+ destinations=("agent_step", "finalize_failed"),
170
+ )
171
+ builder.add_node(
172
+ "agent_step",
173
+ self._agent_step,
174
+ destinations=("agent_step", "execute_tool", "validate", "finalize_failed"),
175
+ )
176
+ builder.add_node("execute_tool", self._execute_tool)
177
+ builder.add_node(
178
+ "observe",
179
+ self._observe,
180
+ destinations=("agent_step", "create_plan", "finalize_failed"),
181
+ )
182
+ builder.add_node(
183
+ "validate",
184
+ self._validate,
185
+ destinations=("finalize", "repair_plan", "finalize_failed"),
186
+ )
187
+ builder.add_node("repair_plan", self._repair_plan)
188
+ builder.add_node("finalize", self._finalize)
189
+ builder.add_node("finalize_failed", self._finalize_failed)
190
+ builder.add_edge(START, "initialize_run")
191
+ builder.add_edge("initialize_run", "explore_repository")
192
+ builder.add_edge("explore_repository", "build_context")
193
+ builder.add_edge("build_context", "create_plan")
194
+ builder.add_edge("create_plan", "approval_gate")
195
+ builder.add_edge("execute_tool", "observe")
196
+ builder.add_edge("repair_plan", "agent_step")
197
+ builder.add_edge("finalize", END)
198
+ builder.add_edge("finalize_failed", END)
199
+ self._graph = builder.compile(checkpointer=checkpointer)
200
+
201
+ async def run(
202
+ self,
203
+ state: AgentState,
204
+ *,
205
+ thread_id: str | None = None,
206
+ ) -> AgentState:
207
+ try:
208
+ result = await self._graph.ainvoke(
209
+ state,
210
+ config=_thread_config(
211
+ thread_id,
212
+ run_id=state.run_id,
213
+ session_id=state.session_id,
214
+ ),
215
+ )
216
+ converted = _to_agent_state(result)
217
+ if converted.pending_plan_approval is not None:
218
+ return replace(converted, status=RuntimeStatus.INTERRUPTED)
219
+ return converted
220
+ except NexusError:
221
+ await self._persist_failed_attempt_evidence(thread_id)
222
+ raise
223
+ except Exception as exc:
224
+ raise NexusError(
225
+ "The Day 4 graph execution failed.",
226
+ code="GRAPH_EXECUTION_ERROR",
227
+ retryable=True,
228
+ ) from exc
229
+
230
+ async def resume(
231
+ self,
232
+ *,
233
+ thread_id: str,
234
+ resume_input: PlanApprovalResumeInput | None = None,
235
+ ) -> AgentState:
236
+ config = cast(RunnableConfig, _thread_config(thread_id))
237
+ try:
238
+ snapshot = await self._graph.aget_state(config)
239
+ current = _to_agent_state(snapshot.values)
240
+ if (
241
+ self._legacy_runtime is not None
242
+ and current.plan is None
243
+ and current.exploration is None
244
+ and bool(current.messages)
245
+ ):
246
+ return await self._legacy_runtime.resume(thread_id=thread_id)
247
+ if (
248
+ "model_response" in snapshot.next
249
+ and self._legacy_runtime is not None
250
+ ):
251
+ return await self._legacy_runtime.resume(thread_id=thread_id)
252
+ if current.pending_plan_approval is not None and resume_input is None:
253
+ await self._emit_approval(current)
254
+ return replace(current, status=RuntimeStatus.INTERRUPTED)
255
+ graph_input: Command[Any] | None = None
256
+ if resume_input is not None:
257
+ graph_input = Command(
258
+ resume={
259
+ "decision": resume_input.decision.value,
260
+ "reason": resume_input.reason,
261
+ }
262
+ )
263
+ result = await self._graph.ainvoke(graph_input, config=config)
264
+ converted = _to_agent_state(result)
265
+ if converted.pending_plan_approval is not None:
266
+ return replace(converted, status=RuntimeStatus.INTERRUPTED)
267
+ return converted
268
+ except NexusError:
269
+ await self._persist_failed_attempt_evidence(thread_id)
270
+ raise
271
+ except Exception as exc:
272
+ raise NexusError(
273
+ "Nexus could not resume the Day 4 graph execution.",
274
+ code="GRAPH_RESUME_ERROR",
275
+ retryable=True,
276
+ ) from exc
277
+
278
+ async def _initialize_run(self, state: AgentState) -> dict[str, object]:
279
+ if state.session_id is None:
280
+ raise NexusError(
281
+ "Day 4 requires an established Session.",
282
+ code="SESSION_PERSISTENCE_REQUIRED",
283
+ )
284
+ return {"status": RuntimeStatus.STARTED}
285
+
286
+ async def _explore_repository(self, state: AgentState) -> dict[str, object]:
287
+ session_id = _session_id(state)
288
+ result = await self._explorer.explore(
289
+ ExplorationRequest(state.task, state.run_id, session_id)
290
+ )
291
+ await self._events.emit(
292
+ RepositoryExplored(
293
+ run_id=state.run_id,
294
+ session_id=session_id,
295
+ instruction_paths=tuple(item.path for item in result.instructions),
296
+ manifest_paths=tuple(item.path for item in result.manifests),
297
+ relevant_paths=tuple(item.path for item in result.relevant_files),
298
+ exploration_tool_calls=len(result.tool_results),
299
+ truncated=result.truncated,
300
+ )
301
+ )
302
+ return self._evidence_update(state, exploration=result)
303
+
304
+ async def _build_context(self, state: AgentState) -> dict[str, object]:
305
+ if state.exploration is None:
306
+ raise NexusError("Exploration evidence is missing.", code="GRAPH_INVALID_STATE")
307
+ session_id = _session_id(state)
308
+ context = await self._context_builder.build(
309
+ ContextBuildRequest(
310
+ state.task,
311
+ state.exploration,
312
+ state.run_id,
313
+ session_id,
314
+ )
315
+ )
316
+ await self._events.emit(
317
+ ContextBuilt(
318
+ run_id=state.run_id,
319
+ session_id=session_id,
320
+ selected_paths=tuple(item.path for item in context.selected_files),
321
+ retained_characters=sum(len(item.content) for item in context.selected_files),
322
+ truncated=context.truncated,
323
+ selected_chunk_count=len(context.selected_files),
324
+ semantic_retrieval_used=context.semantic_retrieval_used,
325
+ semantic_retrieval_status=context.semantic_retrieval_status,
326
+ selected_skill_ids=tuple(
327
+ skill.metadata.skill_id for skill in context.selected_skills
328
+ ),
329
+ skill_selection_reason_summary=(
330
+ None
331
+ if context.skill_selection_result is None
332
+ else context.skill_selection_result.selection_reason_summary
333
+ ),
334
+ )
335
+ )
336
+ return self._evidence_update(state, context=context)
337
+
338
+ async def _create_plan(self, state: AgentState) -> dict[str, object]:
339
+ if state.context is None:
340
+ raise NexusError("Working context is missing.", code="GRAPH_INVALID_STATE")
341
+ session_id = _session_id(state)
342
+ previous = state.plan
343
+ reason = None
344
+ kind = PlanKind.INITIAL
345
+ if previous is not None:
346
+ if not state.observations or state.observations[-1].replan_reason is None:
347
+ raise NexusError("Material Replan reason is missing.", code="GRAPH_INVALID_STATE")
348
+ kind = PlanKind.REPLAN
349
+ reason = state.observations[-1].replan_reason
350
+ plan = await self._planner.create_plan(
351
+ PlanningRequest(
352
+ state.task,
353
+ state.context,
354
+ kind,
355
+ previous,
356
+ reason,
357
+ state.run_id,
358
+ session_id,
359
+ )
360
+ )
361
+ history = state.plan_history
362
+ replan_count = state.replan_count
363
+ if previous is not None:
364
+ history = (*history, replace(previous, status=PlanStatus.SUPERSEDED))
365
+ replan_count += 1
366
+ await self._events.emit(
367
+ ReplanOccurred(
368
+ run_id=state.run_id,
369
+ session_id=session_id,
370
+ plan_id=plan.plan_id,
371
+ previous_version=previous.version,
372
+ new_version=plan.version,
373
+ reason=reason or "Material approved-scope change required.",
374
+ replan_count=replan_count,
375
+ )
376
+ )
377
+ await self._events.emit(
378
+ PlanCreated(
379
+ run_id=state.run_id,
380
+ session_id=session_id,
381
+ plan_id=plan.plan_id,
382
+ plan_version=plan.version,
383
+ plan_kind=plan.kind,
384
+ step_summaries=_step_summaries(plan),
385
+ replan_reason=plan.replan_reason,
386
+ )
387
+ )
388
+ pending = None
389
+ if self._approval_mode is ApprovalMode.APPROVAL:
390
+ pending = await self._plan_approval_service.create_pending(plan)
391
+ await self._events.emit(
392
+ _approval_event(plan, pending)
393
+ )
394
+ return self._evidence_update(
395
+ state,
396
+ plan=plan,
397
+ plan_history=history,
398
+ replan_count=replan_count,
399
+ pending_plan_approval=pending,
400
+ approved_plan=None,
401
+ repair_guidance=None,
402
+ )
403
+
404
+ async def _approval_gate(self, state: AgentState) -> Command[Any]:
405
+ plan = state.plan
406
+ if plan is None:
407
+ raise NexusError("Plan approval has no Plan.", code="GRAPH_INVALID_STATE")
408
+ if self._approval_mode is ApprovalMode.AUTO:
409
+ approval = await self._plan_approval_service.create_auto_approved(plan)
410
+ else:
411
+ if state.pending_plan_approval is None:
412
+ raise NexusError(
413
+ "Pending Plan approval is missing.",
414
+ code="GRAPH_INVALID_STATE",
415
+ )
416
+ supplied = interrupt(
417
+ {
418
+ "approval_id": state.pending_plan_approval.approval_id,
419
+ "plan_id": plan.plan_id,
420
+ "plan_version": plan.version,
421
+ }
422
+ )
423
+ resume_input = _resume_input(supplied)
424
+ approval = await self._plan_approval_service.persist_user_decision(
425
+ state.pending_plan_approval.approval_id,
426
+ resume_input,
427
+ )
428
+ await self._events.emit(
429
+ ApprovalResolved(
430
+ run_id=state.run_id,
431
+ session_id=_session_id(state),
432
+ approval_id=approval.approval_id,
433
+ subject=ApprovalSubject.PLAN,
434
+ invocation_id=None,
435
+ plan_id=plan.plan_id,
436
+ plan_version=plan.version,
437
+ decision=approval.decision,
438
+ actor_category=(
439
+ ApprovalActorCategory.USER
440
+ if approval.actor == "user"
441
+ else ApprovalActorCategory.POLICY
442
+ ),
443
+ )
444
+ )
445
+ activated = self._plan_approval_service.activate(plan, approval)
446
+ update: dict[str, object] = {
447
+ "plan": activated,
448
+ "pending_plan_approval": None,
449
+ }
450
+ if approval.decision is ApprovalDecision.DENIED:
451
+ update["terminal_status"] = TerminalStatus.FAILED_APPROVAL_DENIED
452
+ return Command(update=update, goto="finalize_failed")
453
+ update["approved_plan"] = self._plan_approval_service.evidence(plan, approval)
454
+ return Command(update=update, goto="agent_step")
455
+
456
+ async def _agent_step(self, state: AgentState) -> Command[Any]:
457
+ if state.step_count >= self._max_steps:
458
+ return Command(
459
+ update={"terminal_status": TerminalStatus.STOPPED_MAX_STEPS},
460
+ goto="finalize_failed",
461
+ )
462
+ if state.context is None or state.plan is None:
463
+ raise NexusError("Agent decision context is missing.", code="GRAPH_INVALID_STATE")
464
+ self._ledger.begin_step(state.run_id)
465
+ context = state.context
466
+ if self._context_manager is not None:
467
+ turns = (() if self._conversation_turns is None
468
+ else await self._conversation_turns(_session_id(state)))
469
+ context = await self._context_manager.prepare_agent_context(
470
+ working_context=context, plan=state.plan, observations=state.observations,
471
+ conversation_turns=turns,
472
+ )
473
+ decision = await self._agent.decide(
474
+ AgentDecisionRequest(
475
+ state.task,
476
+ context,
477
+ state.plan,
478
+ state.observations,
479
+ state.validation_result,
480
+ state.repair_guidance,
481
+ )
482
+ )
483
+ if self._model_call_id is not None:
484
+ await self._events.emit(
485
+ AgentStepCompleted(
486
+ run_id=state.run_id,
487
+ session_id=_session_id(state),
488
+ step_count=self._ledger.step_count(state.run_id),
489
+ decision_kind=decision.kind,
490
+ model_call_id=self._model_call_id(),
491
+ )
492
+ )
493
+ update: dict[str, object] = {
494
+ "pending_tool_action": decision.action,
495
+ }
496
+ if decision.kind is AgentDecisionKind.TASK_READY:
497
+ update["messages"] = [
498
+ *state.messages,
499
+ ModelMessage(role="assistant", content=decision.summary),
500
+ ]
501
+ update = self._evidence_update(state, **update)
502
+ if decision.kind is AgentDecisionKind.TOOL_ACTION:
503
+ return Command(update=update, goto="execute_tool")
504
+ if decision.kind is AgentDecisionKind.TASK_READY:
505
+ return Command(update=update, goto="validate")
506
+ return Command(update=update, goto="agent_step")
507
+
508
+ async def _execute_tool(self, state: AgentState) -> dict[str, object]:
509
+ action = state.pending_tool_action
510
+ authorization = state.approved_plan
511
+ if action is None or authorization is None:
512
+ raise NexusError("Agent Tool execution lacks authority.", code="GRAPH_INVALID_STATE")
513
+ result = await self._tool_runtime.execute(
514
+ ToolInvocation(
515
+ str(uuid4()),
516
+ action.tool_name,
517
+ action.arguments,
518
+ state.run_id,
519
+ _session_id(state),
520
+ ),
521
+ authorization=authorization,
522
+ )
523
+ changed = state.changed_files
524
+ if result.success and result.tool_name in {"apply_patch", "write_file"}:
525
+ changed = _record_change(changed, result)
526
+ recorded = next(
527
+ item for item in changed if item.latest_invocation_id == result.invocation_id
528
+ )
529
+ await self._events.emit(
530
+ ChangedFileRecorded(
531
+ run_id=state.run_id,
532
+ session_id=_session_id(state),
533
+ invocation_id=result.invocation_id,
534
+ relative_path=recorded.path,
535
+ change_kind=recorded.change_kind,
536
+ changed_file_count=len(changed),
537
+ )
538
+ )
539
+ return self._evidence_update(
540
+ state,
541
+ latest_tool_result=result,
542
+ changed_files=changed,
543
+ )
544
+
545
+ async def _observe(self, state: AgentState) -> Command[Any]:
546
+ result = state.latest_tool_result
547
+ action = state.pending_tool_action
548
+ if result is None or action is None:
549
+ raise NexusError("Observation evidence is missing.", code="GRAPH_INVALID_STATE")
550
+ code = None if result.error is None else result.error.code
551
+ reason = (
552
+ "The next required Agent action was outside the currently approved Plan scope."
553
+ if code == "PLAN_SCOPE_DENIED"
554
+ else None
555
+ )
556
+ observation = Observation(
557
+ result.invocation_id,
558
+ result.tool_name,
559
+ result.success,
560
+ _observation_summary(result, action),
561
+ code,
562
+ reason,
563
+ )
564
+ update = self._evidence_update(
565
+ state,
566
+ observations=(*state.observations, observation),
567
+ pending_tool_action=None,
568
+ )
569
+ if reason is None:
570
+ return Command(update=update, goto="agent_step")
571
+ if state.replan_count >= self._max_replans:
572
+ update["terminal_status"] = TerminalStatus.STOPPED_MAX_REPLANS
573
+ return Command(update=update, goto="finalize_failed")
574
+ return Command(update=update, goto="create_plan")
575
+
576
+ async def _validate(self, state: AgentState) -> Command[Any]:
577
+ if (
578
+ state.plan is None
579
+ or state.exploration is None
580
+ or state.context is None
581
+ or state.approved_plan is None
582
+ ):
583
+ raise NexusError("Validation state is incomplete.", code="GRAPH_INVALID_STATE")
584
+ validation_plan = await self._validation_planner.plan(
585
+ task=state.task,
586
+ plan=state.plan,
587
+ exploration=state.exploration,
588
+ context=state.context,
589
+ changed_files=state.changed_files,
590
+ )
591
+ result = await self._validation_runner.run(
592
+ validation_plan,
593
+ run_id=state.run_id,
594
+ session_id=_session_id(state),
595
+ authorization=state.approved_plan,
596
+ repair_count=state.repair_count,
597
+ changed=bool(state.changed_files),
598
+ )
599
+ update = self._evidence_update(state, validation_result=result)
600
+ if result.status is ValidationStatus.PASS:
601
+ return Command(update=update, goto="finalize")
602
+ if (
603
+ result.status is ValidationStatus.FAIL
604
+ and result.repairable
605
+ and state.repair_count < self._max_repair_attempts
606
+ ):
607
+ return Command(update=update, goto="repair_plan")
608
+ if result.status is ValidationStatus.UNKNOWN:
609
+ update["terminal_status"] = TerminalStatus.FAILED_VALIDATION_UNKNOWN
610
+ elif result.repairable and state.repair_count >= self._max_repair_attempts:
611
+ update["terminal_status"] = TerminalStatus.STOPPED_MAX_REPAIRS
612
+ else:
613
+ update["terminal_status"] = TerminalStatus.FAILED_VALIDATION
614
+ return Command(update=update, goto="finalize_failed")
615
+
616
+ async def _repair_plan(self, state: AgentState) -> dict[str, object]:
617
+ if state.plan is None or state.context is None or state.validation_result is None:
618
+ raise NexusError("Repair state is incomplete.", code="GRAPH_INVALID_STATE")
619
+ self._ledger.begin_repair(state.run_id)
620
+ attempt = state.repair_count + 1
621
+ await self._events.emit(
622
+ RepairStarted(
623
+ run_id=state.run_id,
624
+ session_id=_session_id(state),
625
+ plan_id=state.plan.plan_id,
626
+ plan_version=state.plan.version,
627
+ repair_count=attempt,
628
+ max_repair_attempts=self._max_repair_attempts,
629
+ failure_summary=state.validation_result.summary,
630
+ )
631
+ )
632
+ guidance = await self._planner.create_repair_guidance(
633
+ RepairPlanningRequest(
634
+ state.task,
635
+ state.context,
636
+ state.plan,
637
+ state.validation_result,
638
+ attempt,
639
+ )
640
+ )
641
+ return self._evidence_update(state, repair_guidance=guidance)
642
+
643
+ async def _finalize(self, state: AgentState) -> dict[str, object]:
644
+ evidence = await self._collect_diff(state)
645
+ terminal = (
646
+ TerminalStatus.SUCCEEDED
647
+ if evidence.error_code is None
648
+ else TerminalStatus.FAILED
649
+ )
650
+ if terminal is TerminalStatus.SUCCEEDED and _is_read_only_completion(state):
651
+ content = _latest_assistant_content(state)
652
+ elif terminal is TerminalStatus.SUCCEEDED:
653
+ content = "Task completed with approved changes and validation evidence."
654
+ else:
655
+ content = "Task failed because an exact final diff was unavailable (DIFF_UNAVAILABLE)."
656
+ return await self._emit_final(
657
+ state,
658
+ terminal,
659
+ content,
660
+ evidence.diff,
661
+ evidence.includes_preexisting_changes,
662
+ )
663
+
664
+ async def _finalize_failed(self, state: AgentState) -> dict[str, object]:
665
+ evidence = await self._collect_diff(state)
666
+ terminal = state.terminal_status or TerminalStatus.FAILED
667
+ content = f"Task ended with terminal status {terminal.value}."
668
+ if evidence.error_code is not None:
669
+ content += " Exact final diff is unavailable (DIFF_UNAVAILABLE)."
670
+ return await self._emit_final(
671
+ state,
672
+ terminal,
673
+ content,
674
+ evidence.diff,
675
+ evidence.includes_preexisting_changes,
676
+ )
677
+
678
+ async def _collect_diff(self, state: AgentState) -> FinalDiffEvidence:
679
+ initial = None if state.exploration is None else state.exploration.initial_git_status
680
+ return await self._diff_collector.collect(
681
+ run_id=state.run_id,
682
+ session_id=_session_id(state),
683
+ changed_files=state.changed_files,
684
+ initial_git_status=initial,
685
+ )
686
+
687
+ async def _emit_final(
688
+ self,
689
+ state: AgentState,
690
+ terminal: TerminalStatus,
691
+ content: str,
692
+ diff: str | None,
693
+ includes_preexisting_changes: bool,
694
+ ) -> dict[str, object]:
695
+ status = (
696
+ RuntimeStatus.COMPLETED
697
+ if terminal is TerminalStatus.SUCCEEDED
698
+ else RuntimeStatus.FAILED
699
+ )
700
+ event = FinalResult(
701
+ run_id=state.run_id,
702
+ session_id=_session_id(state),
703
+ content=content,
704
+ terminal_status=terminal,
705
+ changed_files=state.changed_files,
706
+ diff=diff,
707
+ validation_result=state.validation_result,
708
+ includes_preexisting_changes=includes_preexisting_changes,
709
+ )
710
+ await self._events.emit(event)
711
+ update = self._evidence_update(
712
+ state,
713
+ status=status,
714
+ terminal_status=terminal,
715
+ messages=[*state.messages, ModelMessage(role="assistant", content=content)],
716
+ )
717
+ if state.plan is not None:
718
+ update["plan"] = replace(
719
+ state.plan,
720
+ status=(
721
+ PlanStatus.COMPLETED
722
+ if terminal is TerminalStatus.SUCCEEDED
723
+ else PlanStatus.FAILED
724
+ ),
725
+ )
726
+ return update
727
+
728
+ async def _emit_approval(self, state: AgentState) -> None:
729
+ pending = state.pending_plan_approval
730
+ plan = state.plan
731
+ if pending is None or plan is None:
732
+ return
733
+ await self._events.emit(
734
+ PlanCreated(
735
+ run_id=plan.run_id,
736
+ session_id=plan.session_id,
737
+ plan_id=plan.plan_id,
738
+ plan_version=plan.version,
739
+ plan_kind=plan.kind,
740
+ step_summaries=_step_summaries(plan),
741
+ replan_reason=plan.replan_reason,
742
+ )
743
+ )
744
+ await self._events.emit(
745
+ _approval_event(plan, pending)
746
+ )
747
+
748
+ def _evidence_update(
749
+ self, state: AgentState, **values: object
750
+ ) -> dict[str, object]:
751
+ run_id = state.run_id
752
+ self._ledger.restore(
753
+ run_id,
754
+ tool_call_count=state.tool_call_count,
755
+ model_call_count=state.llm_call_count,
756
+ step_count=state.step_count,
757
+ repair_count=state.repair_count,
758
+ tool_results=state.tool_results,
759
+ token_usage=state.token_usage,
760
+ )
761
+ return {
762
+ **values,
763
+ "step_count": self._ledger.step_count(run_id),
764
+ "tool_call_count": self._ledger.count(run_id),
765
+ "llm_call_count": self._ledger.model_count(run_id),
766
+ "repair_count": self._ledger.repair_count(run_id),
767
+ "token_usage": self._ledger.token_usage(run_id),
768
+ "tool_results": self._ledger.results(run_id),
769
+ }
770
+
771
+ async def _persist_failed_attempt_evidence(self, thread_id: str | None) -> None:
772
+ if thread_id is None:
773
+ return
774
+ config = cast(RunnableConfig, _thread_config(thread_id))
775
+ snapshot = await self._graph.aget_state(config)
776
+ if not snapshot.values:
777
+ return
778
+ current = _to_agent_state(snapshot.values)
779
+ await self._graph.aupdate_state(config, self._evidence_update(current))
780
+
781
+
782
+ def _thread_config(
783
+ thread_id: str | None,
784
+ *,
785
+ run_id: str | None = None,
786
+ session_id: str | None = None,
787
+ ) -> RunnableConfig | None:
788
+ if thread_id is None:
789
+ return None
790
+ metadata = {
791
+ key: value
792
+ for key, value in {"run_id": run_id, "session_id": session_id}.items()
793
+ if value is not None
794
+ }
795
+ return {"configurable": {"thread_id": thread_id}, "metadata": metadata}
796
+
797
+
798
+ def _to_agent_state(value: object) -> AgentState:
799
+ if isinstance(value, AgentState):
800
+ return value
801
+ if not isinstance(value, dict):
802
+ raise NexusError("The graph returned invalid state.", code="GRAPH_INVALID_STATE")
803
+ names = {item.name for item in fields(AgentState)}
804
+ try:
805
+ return AgentState(**{name: value[name] for name in names if name in value})
806
+ except (KeyError, TypeError, ValueError) as exc:
807
+ raise NexusError("The graph returned invalid state.", code="GRAPH_INVALID_STATE") from exc
808
+
809
+
810
+ def _session_id(state: AgentState) -> str:
811
+ if state.session_id is None:
812
+ raise NexusError("Day 4 Session identity is missing.", code="GRAPH_INVALID_STATE")
813
+ return state.session_id
814
+
815
+
816
+ def _resume_input(value: object) -> PlanApprovalResumeInput:
817
+ if not isinstance(value, dict):
818
+ raise NexusError("Plan approval input is invalid.", code="INVALID_APPROVAL_TRANSITION")
819
+ try:
820
+ decision = ApprovalDecision(str(value.get("decision")))
821
+ reason = value.get("reason")
822
+ if reason is not None and not isinstance(reason, str):
823
+ raise ValueError
824
+ return PlanApprovalResumeInput(decision, reason)
825
+ except (TypeError, ValueError) as exc:
826
+ raise NexusError(
827
+ "Plan approval input is invalid.",
828
+ code="INVALID_APPROVAL_TRANSITION",
829
+ ) from exc
830
+
831
+
832
+ def _step_summaries(plan: Plan) -> tuple[str, ...]:
833
+ values: list[str] = []
834
+ for step in plan.steps:
835
+ summary = f"{step.sequence}. {step.description}"
836
+ if step.tool_name in {"apply_patch", "write_file"}:
837
+ summary += f" | WRITE {step.tool_name} {step.target_paths[0]}"
838
+ if step.command_argv is not None:
839
+ summary += f" | VALIDATE argv={list(step.command_argv)!r} cwd={step.command_cwd}"
840
+ values.append(summary)
841
+ return tuple(values)
842
+
843
+
844
+ def _approval_event(plan: Plan, pending: ApprovalRequest) -> ApprovalRequested:
845
+ return ApprovalRequested(
846
+ run_id=plan.run_id,
847
+ session_id=plan.session_id,
848
+ approval_id=pending.approval_id,
849
+ invocation_id=None,
850
+ operation="approve_plan",
851
+ risk_level=RiskLevel.WRITE,
852
+ resource_or_command_summary=pending.resource_or_command_summary,
853
+ subject=ApprovalSubject.PLAN,
854
+ plan_id=plan.plan_id,
855
+ plan_version=plan.version,
856
+ )
857
+
858
+
859
+ def _record_change(
860
+ current: tuple[ChangedFile, ...],
861
+ result: ToolResult,
862
+ ) -> tuple[ChangedFile, ...]:
863
+ output = result.output or {}
864
+ path = output.get("path")
865
+ kind_value = output.get("change_kind")
866
+ if not isinstance(path, str) or not isinstance(kind_value, str):
867
+ raise NexusError("Editing Tool omitted change evidence.", code="GRAPH_INVALID_STATE")
868
+ for index, item in enumerate(current):
869
+ if item.path == path:
870
+ updated = replace(item, latest_invocation_id=result.invocation_id)
871
+ return (*current[:index], updated, *current[index + 1 :])
872
+ return tuple(
873
+ sorted(
874
+ (
875
+ *current,
876
+ ChangedFile(
877
+ path,
878
+ ChangeKind(kind_value),
879
+ result.invocation_id,
880
+ result.invocation_id,
881
+ ),
882
+ ),
883
+ key=lambda item: item.path,
884
+ )
885
+ )
886
+
887
+
888
+ def _observation_summary(result: ToolResult, action: ToolAction | None = None) -> str:
889
+ if result.success:
890
+ if result.tool_name == "read_file" and result.output is not None:
891
+ path = result.output.get("path")
892
+ content = result.output.get("content")
893
+ if isinstance(path, str) and isinstance(content, str):
894
+ return f"read_file {path}:\n{content[:4000]}"
895
+ return f"{result.tool_name} completed successfully."[:512]
896
+ code = "UNKNOWN" if result.error is None else result.error.code
897
+ if result.tool_name == "apply_patch" and result.error is not None:
898
+ detail = _SAFE_PATCH_FAILURE_DETAILS.get((code, result.error.message))
899
+ if detail is not None:
900
+ if result.error.message == "Patch hunk counts do not match.":
901
+ count_detail = _patch_count_detail(action)
902
+ if count_detail is not None:
903
+ detail = f"{detail}; {count_detail}"
904
+ return f"apply_patch failed with {code}: {detail}."[:512]
905
+ return f"{result.tool_name} failed with {code}."[:512]
906
+
907
+
908
+ def _patch_count_detail(action: ToolAction | None) -> str | None:
909
+ if action is None or action.tool_name != "apply_patch":
910
+ return None
911
+ patch = action.arguments.get("patch")
912
+ if not isinstance(patch, str):
913
+ return None
914
+ lines = patch.replace("\r\n", "\n").replace("\r", "\n").splitlines()
915
+ for index, line in enumerate(lines):
916
+ match = _PATCH_HUNK_HEADER.match(line)
917
+ if match is None:
918
+ continue
919
+ declared_old = int(match.group(2) or "1")
920
+ declared_new = int(match.group(4) or "1")
921
+ body: list[str] = []
922
+ for candidate in lines[index + 1 :]:
923
+ if candidate.startswith("@@ "):
924
+ break
925
+ body.append(candidate)
926
+ actual_old = sum(
927
+ 1
928
+ for candidate in body
929
+ if candidate != "\"
930
+ and candidate.startswith((" ", "-"))
931
+ )
932
+ actual_new = sum(
933
+ 1
934
+ for candidate in body
935
+ if candidate != "\"
936
+ and candidate.startswith((" ", "+"))
937
+ )
938
+ if (declared_old, declared_new) != (actual_old, actual_new):
939
+ return (
940
+ f"the hunk declares old_count={declared_old} and "
941
+ f"new_count={declared_new}, but its body contains "
942
+ f"old_count={actual_old} and new_count={actual_new}; regenerate the full patch "
943
+ "with header counts equal to the body counts"
944
+ )
945
+ return None
946
+
947
+
948
+ def _is_read_only_completion(state: AgentState) -> bool:
949
+ plan = state.plan
950
+ return bool(
951
+ plan is not None
952
+ and not state.changed_files
953
+ and not plan.authorization_scope.allowed_write_actions
954
+ and not plan.authorization_scope.allowed_commands
955
+ )
956
+
957
+
958
+ def _latest_assistant_content(state: AgentState) -> str:
959
+ if state.messages and state.messages[-1].role == "assistant":
960
+ content = state.messages[-1].content.strip()
961
+ if content:
962
+ return content
963
+ raise NexusError(
964
+ "Read-only completion is missing the grounded Agent answer.",
965
+ code="GRAPH_INVALID_STATE",
966
+ )