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
nexus/context/manager.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Day 5 context policy, including v0.1.2's sole eviction order."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from dataclasses import asdict, replace
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
|
|
8
|
+
from nexus.context.builder import _applicable_instructions
|
|
9
|
+
from nexus.context.chunking import estimated_tokens, identity, language_for, text_hash
|
|
10
|
+
from nexus.context.explorer import _weakens_nexus_safety
|
|
11
|
+
from nexus.context.retrieval import ToolRepositoryAccess
|
|
12
|
+
from nexus.domain.agent_decision import Observation
|
|
13
|
+
from nexus.domain.context import ContextBudget, ContextCandidate, ContextRequest
|
|
14
|
+
from nexus.domain.exploration import RepositoryInstruction, SelectedFileContext, WorkingContext
|
|
15
|
+
from nexus.domain.model import ModelMessage
|
|
16
|
+
from nexus.domain.persistence import SessionTurn
|
|
17
|
+
from nexus.domain.planning import Plan
|
|
18
|
+
from nexus.domain.ports.context import Chunker, ContextProvider
|
|
19
|
+
from nexus.errors import ContextError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def context_payload(context: WorkingContext) -> dict[str, object]:
|
|
23
|
+
"""Only the selected view is model-visible; ranking evidence is not a second code copy."""
|
|
24
|
+
return {
|
|
25
|
+
"task": context.task,
|
|
26
|
+
"repository_instructions": [asdict(item) for item in context.repository_instructions],
|
|
27
|
+
"selected_files": [asdict(item) for item in context.selected_files],
|
|
28
|
+
"manifests": [asdict(item) for item in context.manifest_summaries],
|
|
29
|
+
"top_level_paths": context.top_level_paths,
|
|
30
|
+
"observations": [asdict(item) for item in context.recent_observations],
|
|
31
|
+
"conversation": [
|
|
32
|
+
{"role": item.role, "content": item.content}
|
|
33
|
+
for item in context.recent_conversation_turns
|
|
34
|
+
],
|
|
35
|
+
"compacted_observations": context.compacted_observations,
|
|
36
|
+
"compacted_conversation": context.compacted_conversation,
|
|
37
|
+
"selected_skills": [
|
|
38
|
+
{
|
|
39
|
+
"skill_id": item.metadata.skill_id,
|
|
40
|
+
"name": item.metadata.name,
|
|
41
|
+
"version": item.metadata.version,
|
|
42
|
+
"source": item.metadata.source.value,
|
|
43
|
+
"selected_reason": item.selected_reason,
|
|
44
|
+
"body": item.body,
|
|
45
|
+
}
|
|
46
|
+
for item in context.selected_skills
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def model_input_tokens(messages: Sequence[ModelMessage]) -> int:
|
|
52
|
+
# Count complete serialized messages, including roles/JSON escaping, plus a small
|
|
53
|
+
# conservative envelope estimate. This remains the approved character heuristic.
|
|
54
|
+
serialized = json.dumps([asdict(message) for message in messages], ensure_ascii=False)
|
|
55
|
+
return estimated_tokens(serialized) + 16 * len(messages) + 16
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _observation_summary(observations: Sequence[Observation]) -> str | None:
|
|
59
|
+
if not observations:
|
|
60
|
+
return None
|
|
61
|
+
return "\n".join(
|
|
62
|
+
f"{o.tool_name}: success={o.success}; error={o.error_code}; "
|
|
63
|
+
f"evidence={o.evidence_summary[:256]}; replan={o.replan_reason}"
|
|
64
|
+
for o in observations
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _conversation_summary(turns: Sequence[SessionTurn]) -> str | None:
|
|
69
|
+
return "\n".join(f"{t.role}: {t.content[:256]}" for t in turns) or None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BoundedContextManager:
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
provider: ContextProvider,
|
|
76
|
+
access: ToolRepositoryAccess,
|
|
77
|
+
chunker: Chunker,
|
|
78
|
+
budget: ContextBudget,
|
|
79
|
+
max_model_input_tokens: int,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._provider = provider
|
|
82
|
+
self._access = access
|
|
83
|
+
self._chunker = chunker
|
|
84
|
+
self._budget = budget
|
|
85
|
+
self._maximum = max_model_input_tokens
|
|
86
|
+
|
|
87
|
+
async def build(self, request: ContextRequest) -> WorkingContext:
|
|
88
|
+
with self._access.scope(request.run_id, request.session_id):
|
|
89
|
+
retrieval = await self._provider.retrieve(request)
|
|
90
|
+
seeds: list[ContextCandidate] = []
|
|
91
|
+
for evidence in request.exploration.relevant_files[:8]:
|
|
92
|
+
if not await self._access.allowed(evidence.path):
|
|
93
|
+
continue
|
|
94
|
+
content = await self._access.read(evidence.path)
|
|
95
|
+
if not content:
|
|
96
|
+
continue
|
|
97
|
+
chunks = self._chunker.chunk(
|
|
98
|
+
file_path=evidence.path,
|
|
99
|
+
language=language_for(evidence.path),
|
|
100
|
+
content=content,
|
|
101
|
+
file_hash=text_hash(content),
|
|
102
|
+
)
|
|
103
|
+
seeds.extend(
|
|
104
|
+
ContextCandidate(c, None, None, None, 0.0, ())
|
|
105
|
+
for c in chunks
|
|
106
|
+
if c.start_line <= 400
|
|
107
|
+
)
|
|
108
|
+
unique: dict[tuple[str, int, int, str], tuple[ContextCandidate, str]] = {}
|
|
109
|
+
seed_limit_reached = False
|
|
110
|
+
for candidate in seeds:
|
|
111
|
+
key = identity(candidate)
|
|
112
|
+
if key in unique:
|
|
113
|
+
continue
|
|
114
|
+
if len(unique) >= self._budget.max_exploration_seed_chunks:
|
|
115
|
+
seed_limit_reached = True
|
|
116
|
+
continue
|
|
117
|
+
unique[key] = (candidate, "exploration seed")
|
|
118
|
+
for candidate in retrieval.candidates:
|
|
119
|
+
unique.setdefault(identity(candidate), (candidate, "hybrid retrieval"))
|
|
120
|
+
selected: list[SelectedFileContext] = []
|
|
121
|
+
instructions = list(request.exploration.instructions)
|
|
122
|
+
for instruction in tuple(instructions):
|
|
123
|
+
if instruction.truncated:
|
|
124
|
+
content = await self._access.read(instruction.path)
|
|
125
|
+
if content is None:
|
|
126
|
+
raise ContextError(
|
|
127
|
+
"Required repository instructions could not be read.",
|
|
128
|
+
code="CONTEXT_BUILD_FAILED",
|
|
129
|
+
)
|
|
130
|
+
instructions[instructions.index(instruction)] = replace(
|
|
131
|
+
instruction,
|
|
132
|
+
content=content,
|
|
133
|
+
truncated=False,
|
|
134
|
+
)
|
|
135
|
+
tokens = 0
|
|
136
|
+
truncated = request.exploration.truncated or seed_limit_reached
|
|
137
|
+
for candidate, reason in unique.values():
|
|
138
|
+
chunk = candidate.chunk
|
|
139
|
+
cost = estimated_tokens(chunk.content)
|
|
140
|
+
if (
|
|
141
|
+
len(selected) >= self._budget.max_retrieved_chunks
|
|
142
|
+
or tokens + cost > self._budget.max_code_context_tokens
|
|
143
|
+
):
|
|
144
|
+
truncated = True
|
|
145
|
+
continue
|
|
146
|
+
if not await self._access.allowed(chunk.file_path):
|
|
147
|
+
truncated = True
|
|
148
|
+
continue
|
|
149
|
+
# Never present deleted/changed/now-ignored indexed text as current code.
|
|
150
|
+
current = await self._access.read(chunk.file_path)
|
|
151
|
+
if current is None or text_hash(current) != chunk.file_hash:
|
|
152
|
+
truncated = True
|
|
153
|
+
continue
|
|
154
|
+
await self._instructions(chunk.file_path, instructions)
|
|
155
|
+
selected.append(
|
|
156
|
+
SelectedFileContext(
|
|
157
|
+
chunk.file_path,
|
|
158
|
+
chunk.content,
|
|
159
|
+
_applicable_instructions(chunk.file_path, tuple(instructions)),
|
|
160
|
+
f"{reason}; lines {chunk.start_line}-{chunk.end_line}",
|
|
161
|
+
False,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
tokens += cost
|
|
165
|
+
context = WorkingContext(
|
|
166
|
+
request.task,
|
|
167
|
+
tuple(instructions),
|
|
168
|
+
request.exploration.manifests,
|
|
169
|
+
request.exploration.top_level_paths,
|
|
170
|
+
tuple(selected),
|
|
171
|
+
truncated,
|
|
172
|
+
retrieval.candidates,
|
|
173
|
+
None,
|
|
174
|
+
retrieval.semantic_used,
|
|
175
|
+
retrieval.semantic_status,
|
|
176
|
+
)
|
|
177
|
+
return await self.prepare_agent_context(
|
|
178
|
+
working_context=context,
|
|
179
|
+
plan=None,
|
|
180
|
+
observations=(),
|
|
181
|
+
conversation_turns=(),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def _instructions(self, path: str, instructions: list[RepositoryInstruction]) -> None:
|
|
185
|
+
known = {item.path for item in instructions if not item.truncated}
|
|
186
|
+
for parent in reversed(PurePosixPath(path).parents):
|
|
187
|
+
name = str(parent / "AGENTS.md")
|
|
188
|
+
if name in known:
|
|
189
|
+
continue
|
|
190
|
+
content = await self._access.read(name)
|
|
191
|
+
if content is None:
|
|
192
|
+
if any(item.path == name for item in instructions):
|
|
193
|
+
raise ContextError(
|
|
194
|
+
"Required repository instructions could not be read.",
|
|
195
|
+
code="CONTEXT_BUILD_FAILED",
|
|
196
|
+
)
|
|
197
|
+
continue
|
|
198
|
+
if _weakens_nexus_safety(content):
|
|
199
|
+
raise ContextError(
|
|
200
|
+
"Repository instructions conflict with Nexus safety.",
|
|
201
|
+
code="REPOSITORY_INSTRUCTION_CONFLICT",
|
|
202
|
+
)
|
|
203
|
+
instructions[:] = [item for item in instructions if item.path != name]
|
|
204
|
+
instructions.append(
|
|
205
|
+
RepositoryInstruction(
|
|
206
|
+
name,
|
|
207
|
+
str(parent),
|
|
208
|
+
len(parent.parts),
|
|
209
|
+
content,
|
|
210
|
+
False,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
instructions.sort(key=lambda item: (item.depth, item.path))
|
|
214
|
+
|
|
215
|
+
async def prepare_agent_context(
|
|
216
|
+
self,
|
|
217
|
+
*,
|
|
218
|
+
working_context: WorkingContext,
|
|
219
|
+
plan: Plan | None,
|
|
220
|
+
observations: Sequence[Observation],
|
|
221
|
+
conversation_turns: Sequence[SessionTurn],
|
|
222
|
+
) -> WorkingContext:
|
|
223
|
+
count = self._budget.max_recent_observations
|
|
224
|
+
turns = tuple(sorted(conversation_turns, key=lambda turn: turn.sequence))
|
|
225
|
+
recent_observations = tuple(observations[-count:]) if count else ()
|
|
226
|
+
older_observations = observations[:-count] if count else observations
|
|
227
|
+
context = replace(
|
|
228
|
+
working_context,
|
|
229
|
+
recent_observations=recent_observations,
|
|
230
|
+
compacted_observations=_observation_summary(older_observations),
|
|
231
|
+
recent_conversation_turns=turns[-6:],
|
|
232
|
+
compacted_conversation=_conversation_summary(turns[:-6]),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def render(view: WorkingContext) -> Sequence[ModelMessage]:
|
|
236
|
+
payload = context_payload(view)
|
|
237
|
+
payload["plan"] = None if plan is None else asdict(plan)
|
|
238
|
+
return [ModelMessage("user", json.dumps(payload, default=str, ensure_ascii=False))]
|
|
239
|
+
|
|
240
|
+
return self.fit_model_input(context, render)
|
|
241
|
+
|
|
242
|
+
def fit_model_input(
|
|
243
|
+
self,
|
|
244
|
+
context: WorkingContext,
|
|
245
|
+
render: Callable[[WorkingContext], Sequence[ModelMessage]],
|
|
246
|
+
) -> WorkingContext:
|
|
247
|
+
"""Fit the actual final prompt, including system and adapter-specific fields.
|
|
248
|
+
|
|
249
|
+
Rendering is pure; all reductions and their order remain owned here. The callback
|
|
250
|
+
also covers Plan/repair prompts without changing their public provider contracts.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def fits() -> bool:
|
|
254
|
+
return model_input_tokens(render(context)) <= self._maximum
|
|
255
|
+
|
|
256
|
+
if fits():
|
|
257
|
+
return context
|
|
258
|
+
# 1. Reverse selection priority: hybrid last, seed first in the original view.
|
|
259
|
+
while context.selected_files and not fits():
|
|
260
|
+
context = replace(context, selected_files=context.selected_files[:-1], truncated=True)
|
|
261
|
+
# 2. Older observations become bounded factual summaries before recent ones.
|
|
262
|
+
while context.recent_observations and not fits():
|
|
263
|
+
summary = _observation_summary(context.recent_observations[:1])
|
|
264
|
+
context = replace(
|
|
265
|
+
context,
|
|
266
|
+
recent_observations=context.recent_observations[1:],
|
|
267
|
+
truncated=True,
|
|
268
|
+
compacted_observations="\n".join(
|
|
269
|
+
filter(
|
|
270
|
+
None,
|
|
271
|
+
(
|
|
272
|
+
context.compacted_observations,
|
|
273
|
+
summary,
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
# 3. Conversation reduction cannot run before the observation stage.
|
|
279
|
+
while context.recent_conversation_turns and not fits():
|
|
280
|
+
summary = _conversation_summary(context.recent_conversation_turns[:1])
|
|
281
|
+
context = replace(
|
|
282
|
+
context,
|
|
283
|
+
recent_conversation_turns=context.recent_conversation_turns[1:],
|
|
284
|
+
compacted_conversation="\n".join(
|
|
285
|
+
filter(
|
|
286
|
+
None,
|
|
287
|
+
(
|
|
288
|
+
context.compacted_conversation,
|
|
289
|
+
summary,
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
),
|
|
293
|
+
truncated=True,
|
|
294
|
+
)
|
|
295
|
+
# 4. Non-authoritative repository evidence.
|
|
296
|
+
while context.manifest_summaries and not fits():
|
|
297
|
+
context = replace(
|
|
298
|
+
context, manifest_summaries=context.manifest_summaries[:-1], truncated=True
|
|
299
|
+
)
|
|
300
|
+
while context.top_level_paths and not fits():
|
|
301
|
+
context = replace(context, top_level_paths=context.top_level_paths[:-1], truncated=True)
|
|
302
|
+
# 5. Remaining optional compacted context, oldest entries first.
|
|
303
|
+
for field in ("compacted_conversation", "compacted_observations"):
|
|
304
|
+
while getattr(context, field) and not fits():
|
|
305
|
+
value = str(getattr(context, field))
|
|
306
|
+
reduced = "\n".join(value.splitlines()[1:]) or None
|
|
307
|
+
context = (
|
|
308
|
+
replace(context, compacted_conversation=reduced, truncated=True)
|
|
309
|
+
if field == "compacted_conversation"
|
|
310
|
+
else replace(context, compacted_observations=reduced, truncated=True)
|
|
311
|
+
)
|
|
312
|
+
while len(context.selected_skills) > 1 and not fits():
|
|
313
|
+
context = replace(
|
|
314
|
+
context,
|
|
315
|
+
selected_skills=context.selected_skills[:-1],
|
|
316
|
+
truncated=True,
|
|
317
|
+
)
|
|
318
|
+
if context.selected_skills and not fits():
|
|
319
|
+
raise ContextError(
|
|
320
|
+
"The first selected Skill cannot fit with required authoritative context.",
|
|
321
|
+
code="SKILL_CONTEXT_BUDGET_EXCEEDED",
|
|
322
|
+
)
|
|
323
|
+
if not fits():
|
|
324
|
+
raise ContextError(
|
|
325
|
+
"Required authoritative context exceeds the configured model input budget.",
|
|
326
|
+
code="CONTEXT_BUILD_FAILED",
|
|
327
|
+
)
|
|
328
|
+
return context
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Policy-safe lexical search and exact Reciprocal Rank Fusion."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from pathlib import PurePosixPath
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from pathspec import GitIgnoreSpec
|
|
12
|
+
|
|
13
|
+
from nexus.application.tool_runtime import ToolRuntime
|
|
14
|
+
from nexus.context.chunking import identity, language_for, normalize_text, text_hash
|
|
15
|
+
from nexus.domain.context import (
|
|
16
|
+
ContextCandidate,
|
|
17
|
+
ContextRequest,
|
|
18
|
+
IndexCompatibilityStatus,
|
|
19
|
+
RetrievalQuery,
|
|
20
|
+
RetrievalResult,
|
|
21
|
+
RetrievalSource,
|
|
22
|
+
)
|
|
23
|
+
from nexus.domain.ports.context import Chunker, LexicalSearchProvider, SemanticSearchProvider
|
|
24
|
+
from nexus.domain.tooling import ToolInvocation
|
|
25
|
+
from nexus.errors import ContextError, PermissionDeniedError
|
|
26
|
+
|
|
27
|
+
_EXCLUDED = frozenset(
|
|
28
|
+
{
|
|
29
|
+
".git",
|
|
30
|
+
"node_modules",
|
|
31
|
+
".venv",
|
|
32
|
+
"venv",
|
|
33
|
+
"__pycache__",
|
|
34
|
+
"dist",
|
|
35
|
+
"build",
|
|
36
|
+
"target",
|
|
37
|
+
"coverage",
|
|
38
|
+
".pytest_cache",
|
|
39
|
+
".mypy_cache",
|
|
40
|
+
".ruff_cache",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ToolRepositoryAccess:
|
|
46
|
+
def __init__(self, runtime: ToolRuntime) -> None:
|
|
47
|
+
self._runtime = runtime
|
|
48
|
+
self._scope: ContextVar[tuple[str, str]] = ContextVar("retrieval_tool_scope")
|
|
49
|
+
self._cache: ContextVar[dict[str, str | None]] = ContextVar("retrieval_file_cache")
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def scope(self, run_id: str, session_id: str) -> Iterator[None]:
|
|
53
|
+
token = self._scope.set((run_id, session_id))
|
|
54
|
+
cache = self._cache.set({})
|
|
55
|
+
try:
|
|
56
|
+
yield
|
|
57
|
+
finally:
|
|
58
|
+
self._cache.reset(cache)
|
|
59
|
+
self._scope.reset(token)
|
|
60
|
+
|
|
61
|
+
async def invoke(self, name: str, arguments: dict[str, object]) -> dict[str, object] | None:
|
|
62
|
+
run_id, session_id = self._scope.get()
|
|
63
|
+
result = await self._runtime.execute(
|
|
64
|
+
ToolInvocation(str(uuid4()), name, arguments, run_id, session_id),
|
|
65
|
+
)
|
|
66
|
+
if result.error is not None and result.error.code == "WORKSPACE_PATH_DENIED":
|
|
67
|
+
raise PermissionDeniedError(
|
|
68
|
+
"Repository read was denied by workspace policy.", code="WORKSPACE_PATH_DENIED"
|
|
69
|
+
)
|
|
70
|
+
if (
|
|
71
|
+
name == "read_file"
|
|
72
|
+
and str(arguments.get("path", "")).endswith("AGENTS.md")
|
|
73
|
+
and result.error is not None
|
|
74
|
+
and result.error.code == "UNSUPPORTED_FILE"
|
|
75
|
+
):
|
|
76
|
+
raise ContextError(
|
|
77
|
+
"Applicable repository instructions cannot be read safely.",
|
|
78
|
+
code="CONTEXT_BUILD_FAILED",
|
|
79
|
+
)
|
|
80
|
+
return result.output if result.success else None
|
|
81
|
+
|
|
82
|
+
async def read(self, path: str) -> str | None:
|
|
83
|
+
cache = self._cache.get()
|
|
84
|
+
if path in cache:
|
|
85
|
+
return cache[path]
|
|
86
|
+
parts: list[str] = []
|
|
87
|
+
start = 1
|
|
88
|
+
while True:
|
|
89
|
+
output = await self.invoke(
|
|
90
|
+
"read_file", {"path": path, "start_line": start, "max_lines": 400}
|
|
91
|
+
)
|
|
92
|
+
if output is None or not isinstance(output.get("content"), str):
|
|
93
|
+
cache[path] = None
|
|
94
|
+
return None
|
|
95
|
+
content = str(output["content"])
|
|
96
|
+
parts.append(content)
|
|
97
|
+
if not output.get("truncated"):
|
|
98
|
+
break
|
|
99
|
+
end = output.get("end_line")
|
|
100
|
+
if not isinstance(end, int) or end < start or not content:
|
|
101
|
+
cache[path] = None
|
|
102
|
+
return None
|
|
103
|
+
start = end + 1
|
|
104
|
+
cache[path] = normalize_text("".join(parts))
|
|
105
|
+
return cache[path]
|
|
106
|
+
|
|
107
|
+
async def allowed(self, path: str) -> bool:
|
|
108
|
+
target = PurePosixPath(path.replace("\\", "/"))
|
|
109
|
+
if (
|
|
110
|
+
target.is_absolute()
|
|
111
|
+
or ".." in target.parts
|
|
112
|
+
or any(p in _EXCLUDED for p in target.parts)
|
|
113
|
+
or target.name == "AGENTS.md"
|
|
114
|
+
):
|
|
115
|
+
return False
|
|
116
|
+
ignored = False
|
|
117
|
+
parents = list(reversed(target.parents))
|
|
118
|
+
for parent in parents:
|
|
119
|
+
ignore = await self.read(str(parent / ".gitignore"))
|
|
120
|
+
if ignore is None:
|
|
121
|
+
continue
|
|
122
|
+
spec = GitIgnoreSpec.from_lines(ignore.splitlines())
|
|
123
|
+
relative = target.relative_to(parent).as_posix()
|
|
124
|
+
parts = relative.split("/")
|
|
125
|
+
if any(spec.match_file("/".join(parts[:i]) + "/") for i in range(1, len(parts))):
|
|
126
|
+
return False
|
|
127
|
+
result = spec.check_file(relative)
|
|
128
|
+
if result.include is not None:
|
|
129
|
+
ignored = result.include
|
|
130
|
+
return not ignored
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def lexical_terms(task: str) -> tuple[str, ...]:
|
|
134
|
+
quoted = re.findall(r'`([^`]+)`|"([^"\n]+)"|\x27([^\x27\n]+)\x27', task)
|
|
135
|
+
tokens = re.findall(r"[A-Za-z_][A-Za-z0-9_.:/\\-]*", task)
|
|
136
|
+
paths = [token for token in tokens if "/" in token or "\\" in token]
|
|
137
|
+
words = re.findall(r"[^\W_]{3,}", task, flags=re.UNICODE)
|
|
138
|
+
ignored = {"the", "and", "for", "with", "from", "into", "this", "that", "please"}
|
|
139
|
+
ordered = [next(x for x in match if x) for match in quoted] + paths + tokens + words
|
|
140
|
+
return tuple(dict.fromkeys(term for term in ordered if term.casefold() not in ignored))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ToolLexicalSearchProvider:
|
|
144
|
+
def __init__(self, access: ToolRepositoryAccess, chunker: Chunker) -> None:
|
|
145
|
+
self._access = access
|
|
146
|
+
self._chunker = chunker
|
|
147
|
+
|
|
148
|
+
async def search(self, query: RetrievalQuery) -> tuple[ContextCandidate, ...]:
|
|
149
|
+
candidates: dict[tuple[str, int, int, str], ContextCandidate] = {}
|
|
150
|
+
for term in lexical_terms(query.text):
|
|
151
|
+
output = await self._access.invoke(
|
|
152
|
+
"lexical_search",
|
|
153
|
+
{
|
|
154
|
+
"pattern": term,
|
|
155
|
+
"path": ".",
|
|
156
|
+
"case_sensitive": True,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
if output is None:
|
|
160
|
+
continue
|
|
161
|
+
matches = output.get("matches", [])
|
|
162
|
+
if not isinstance(matches, list):
|
|
163
|
+
continue
|
|
164
|
+
for match in matches:
|
|
165
|
+
if not isinstance(match, dict):
|
|
166
|
+
continue
|
|
167
|
+
path, line = match.get("path"), match.get("line")
|
|
168
|
+
if not isinstance(path, str) or not isinstance(line, int):
|
|
169
|
+
continue
|
|
170
|
+
if not await self._access.allowed(path):
|
|
171
|
+
continue
|
|
172
|
+
content = await self._access.read(path)
|
|
173
|
+
if not content:
|
|
174
|
+
continue
|
|
175
|
+
windows = self._chunker.chunk(
|
|
176
|
+
file_path=path,
|
|
177
|
+
language=language_for(path),
|
|
178
|
+
content=content,
|
|
179
|
+
file_hash=text_hash(content),
|
|
180
|
+
)
|
|
181
|
+
start = 1 if len(content.splitlines()) <= 120 else 1 + ((line - 1) // 100) * 100
|
|
182
|
+
chunk = next((c for c in windows if c.start_line == start), None)
|
|
183
|
+
if chunk is None:
|
|
184
|
+
continue
|
|
185
|
+
candidate = ContextCandidate(
|
|
186
|
+
chunk, len(candidates) + 1, None, None, 0.0, (RetrievalSource.LEXICAL,)
|
|
187
|
+
)
|
|
188
|
+
candidates.setdefault(identity(candidate), candidate)
|
|
189
|
+
if len(candidates) >= min(query.limit, 20):
|
|
190
|
+
return tuple(candidates.values())
|
|
191
|
+
return tuple(candidates.values())
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def rrf_merge(
|
|
195
|
+
lexical: tuple[ContextCandidate, ...],
|
|
196
|
+
semantic: tuple[ContextCandidate, ...],
|
|
197
|
+
) -> tuple[ContextCandidate, ...]:
|
|
198
|
+
merged: dict[tuple[str, int, int, str], ContextCandidate] = {}
|
|
199
|
+
for candidate in (*lexical, *semantic):
|
|
200
|
+
key = identity(candidate)
|
|
201
|
+
previous = merged.get(key)
|
|
202
|
+
if previous is not None:
|
|
203
|
+
candidate = replace(
|
|
204
|
+
previous,
|
|
205
|
+
lexical_rank=previous.lexical_rank or candidate.lexical_rank,
|
|
206
|
+
semantic_rank=previous.semantic_rank or candidate.semantic_rank,
|
|
207
|
+
semantic_score=(
|
|
208
|
+
previous.semantic_score
|
|
209
|
+
if previous.semantic_rank is not None
|
|
210
|
+
else candidate.semantic_score
|
|
211
|
+
),
|
|
212
|
+
sources=tuple(
|
|
213
|
+
source
|
|
214
|
+
for source in RetrievalSource
|
|
215
|
+
if source in previous.sources or source in candidate.sources
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
ranks = [r for r in (candidate.lexical_rank, candidate.semantic_rank) if r is not None]
|
|
219
|
+
merged[key] = replace(candidate, rrf_score=sum(1 / (60 + rank) for rank in ranks))
|
|
220
|
+
return tuple(
|
|
221
|
+
sorted(
|
|
222
|
+
merged.values(),
|
|
223
|
+
key=lambda c: (
|
|
224
|
+
-c.rrf_score,
|
|
225
|
+
min(r for r in (c.lexical_rank, c.semantic_rank) if r is not None),
|
|
226
|
+
c.chunk.file_path,
|
|
227
|
+
c.chunk.start_line,
|
|
228
|
+
c.chunk.content_hash,
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class HybridContextProvider:
|
|
235
|
+
def __init__(
|
|
236
|
+
self,
|
|
237
|
+
lexical: LexicalSearchProvider,
|
|
238
|
+
semantic: SemanticSearchProvider | None,
|
|
239
|
+
) -> None:
|
|
240
|
+
self._lexical = lexical
|
|
241
|
+
self._semantic = semantic
|
|
242
|
+
|
|
243
|
+
async def retrieve(self, request: ContextRequest) -> RetrievalResult:
|
|
244
|
+
query = RetrievalQuery(request.repository_id, request.workspace_path, request.task, 20)
|
|
245
|
+
lexical = await self._lexical.search(query)
|
|
246
|
+
semantic: tuple[ContextCandidate, ...] = ()
|
|
247
|
+
status: str | None = None
|
|
248
|
+
used = False
|
|
249
|
+
if self._semantic is not None:
|
|
250
|
+
try:
|
|
251
|
+
compatible = await self._semantic.validate_index(request.repository_id)
|
|
252
|
+
if compatible.status is IndexCompatibilityStatus.COMPATIBLE:
|
|
253
|
+
semantic = await self._semantic.search(query)
|
|
254
|
+
used = True
|
|
255
|
+
else:
|
|
256
|
+
status = compatible.status.value
|
|
257
|
+
except ContextError as exc:
|
|
258
|
+
status = exc.code
|
|
259
|
+
except Exception:
|
|
260
|
+
status = "SEMANTIC_SEARCH_FAILED"
|
|
261
|
+
return RetrievalResult(rrf_merge(lexical, semantic)[:12], used, status)
|
nexus/domain/__init__.py
ADDED