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.
- nexus/__init__.py +4 -0
- nexus/application/__init__.py +2 -0
- nexus/application/approval_service.py +130 -0
- nexus/application/diff_service.py +177 -0
- nexus/application/event_publisher.py +180 -0
- nexus/application/execution_context.py +29 -0
- nexus/application/execution_ledger.py +126 -0
- nexus/application/model_call_context.py +29 -0
- nexus/application/observed_model_gateway.py +194 -0
- nexus/application/plan_approval_service.py +250 -0
- nexus/application/planning.py +668 -0
- nexus/application/runtime.py +624 -0
- nexus/application/session_service.py +411 -0
- nexus/application/structured_output.py +96 -0
- nexus/application/telemetry.py +396 -0
- nexus/application/telemetry_subscriber.py +216 -0
- nexus/application/tool_runtime.py +496 -0
- nexus/application/tracing_dispatcher.py +283 -0
- nexus/application/validation.py +293 -0
- nexus/config/__init__.py +17 -0
- nexus/config/loader.py +253 -0
- nexus/config/models.py +310 -0
- nexus/context/__init__.py +6 -0
- nexus/context/budget.py +24 -0
- nexus/context/builder.py +92 -0
- nexus/context/chunking.py +65 -0
- nexus/context/explorer.py +246 -0
- nexus/context/integration.py +63 -0
- nexus/context/manager.py +328 -0
- nexus/context/retrieval.py +261 -0
- nexus/domain/__init__.py +2 -0
- nexus/domain/agent_decision.py +113 -0
- nexus/domain/agent_state.py +49 -0
- nexus/domain/approvals.py +64 -0
- nexus/domain/context.py +108 -0
- nexus/domain/exploration.py +86 -0
- nexus/domain/mcp.py +51 -0
- nexus/domain/model.py +173 -0
- nexus/domain/observability.py +326 -0
- nexus/domain/persistence.py +122 -0
- nexus/domain/planning.py +329 -0
- nexus/domain/ports/__init__.py +61 -0
- nexus/domain/ports/agent_decision.py +9 -0
- nexus/domain/ports/approval_unit_of_work.py +27 -0
- nexus/domain/ports/checkpoint_provider.py +11 -0
- nexus/domain/ports/context.py +71 -0
- nexus/domain/ports/events.py +21 -0
- nexus/domain/ports/graph_runtime.py +30 -0
- nexus/domain/ports/mcp.py +23 -0
- nexus/domain/ports/model_gateway.py +21 -0
- nexus/domain/ports/model_input_budget.py +15 -0
- nexus/domain/ports/planning.py +62 -0
- nexus/domain/ports/repositories.py +54 -0
- nexus/domain/ports/repository_context.py +18 -0
- nexus/domain/ports/session_unit_of_work.py +33 -0
- nexus/domain/ports/skills.py +41 -0
- nexus/domain/ports/tooling.py +39 -0
- nexus/domain/ports/tracer.py +13 -0
- nexus/domain/ports/validation.py +32 -0
- nexus/domain/runtime_events.py +445 -0
- nexus/domain/skills.py +129 -0
- nexus/domain/tooling.py +163 -0
- nexus/domain/validation.py +161 -0
- nexus/errors/__init__.py +82 -0
- nexus/evaluation/__init__.py +7 -0
- nexus/evaluation/assertions.py +4 -0
- nexus/evaluation/evaluator.py +263 -0
- nexus/evaluation/fixtures.py +36 -0
- nexus/evaluation/harness.py +606 -0
- nexus/evaluation/loader.py +415 -0
- nexus/evaluation/metrics.py +27 -0
- nexus/evaluation/models.py +362 -0
- nexus/evaluation/report.py +43 -0
- nexus/evaluation/runner.py +480 -0
- nexus/infrastructure/__init__.py +2 -0
- nexus/infrastructure/asyncio_compat.py +13 -0
- nexus/infrastructure/bootstrap/__init__.py +17 -0
- nexus/infrastructure/bootstrap/composition.py +663 -0
- nexus/infrastructure/checkpoint/__init__.py +5 -0
- nexus/infrastructure/checkpoint/postgres.py +62 -0
- nexus/infrastructure/database.py +39 -0
- nexus/infrastructure/embedding.py +61 -0
- nexus/infrastructure/graph/__init__.py +6 -0
- nexus/infrastructure/graph/day4_runtime.py +966 -0
- nexus/infrastructure/graph/langgraph_runtime.py +172 -0
- nexus/infrastructure/mcp/__init__.py +5 -0
- nexus/infrastructure/mcp/manager.py +380 -0
- nexus/infrastructure/model_gateway/__init__.py +6 -0
- nexus/infrastructure/model_gateway/openai_compatible.py +345 -0
- nexus/infrastructure/observability/__init__.py +7 -0
- nexus/infrastructure/observability/console.py +31 -0
- nexus/infrastructure/observability/langsmith.py +236 -0
- nexus/infrastructure/observability/structured_logger.py +70 -0
- nexus/infrastructure/persistence/__init__.py +13 -0
- nexus/infrastructure/persistence/approval_unit_of_work.py +74 -0
- nexus/infrastructure/persistence/models.py +146 -0
- nexus/infrastructure/persistence/repositories.py +326 -0
- nexus/infrastructure/persistence/unit_of_work.py +82 -0
- nexus/infrastructure/repository_files.py +117 -0
- nexus/infrastructure/sandbox/__init__.py +5 -0
- nexus/infrastructure/sandbox/local_process.py +353 -0
- nexus/infrastructure/semantic.py +276 -0
- nexus/infrastructure/semantic_models.py +56 -0
- nexus/interfaces/__init__.py +2 -0
- nexus/interfaces/cli/__init__.py +2 -0
- nexus/interfaces/cli/__main__.py +6 -0
- nexus/interfaces/cli/app.py +266 -0
- nexus/interfaces/cli/renderer.py +118 -0
- nexus/security/__init__.py +14 -0
- nexus/security/approval_policies.py +73 -0
- nexus/security/command_policy.py +117 -0
- nexus/security/executables.py +107 -0
- nexus/security/git_commands.py +65 -0
- nexus/security/workspace.py +93 -0
- nexus/skills/__init__.py +7 -0
- nexus/skills/builtin/__init__.py +1 -0
- nexus/skills/builtin/debug-python/SKILL.md +26 -0
- nexus/skills/builtin/review-repository/SKILL.md +26 -0
- nexus/skills/builtin/write-tests/SKILL.md +26 -0
- nexus/skills/loader.py +439 -0
- nexus/skills/registry.py +146 -0
- nexus/skills/selector.py +152 -0
- nexus/tools/__init__.py +6 -0
- nexus/tools/editing.py +312 -0
- nexus/tools/mcp.py +160 -0
- nexus/tools/native.py +654 -0
- nexus/tools/registry.py +27 -0
- nexus_coding_agent-0.1.0.dist-info/METADATA +142 -0
- nexus_coding_agent-0.1.0.dist-info/RECORD +131 -0
- nexus_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|