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/tools/native.py
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""Day 3 native repository inspection, shell, and read-only Git tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Iterator
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from nexus.domain.ports.tooling import SandboxExecutor, Tool
|
|
13
|
+
from nexus.domain.tooling import (
|
|
14
|
+
JsonObject,
|
|
15
|
+
PolicyDecision,
|
|
16
|
+
RiskLevel,
|
|
17
|
+
SandboxRequest,
|
|
18
|
+
SandboxResult,
|
|
19
|
+
ToolError,
|
|
20
|
+
ToolInvocation,
|
|
21
|
+
ToolResult,
|
|
22
|
+
)
|
|
23
|
+
from nexus.errors import NexusError, PermissionDeniedError, ToolExecutionError
|
|
24
|
+
from nexus.security.executables import TrustedExecutables
|
|
25
|
+
from nexus.security.git_commands import diff_argv, log_argv, status_argv
|
|
26
|
+
from nexus.security.workspace import WorkspaceGuard
|
|
27
|
+
from nexus.tools.editing import PatchTool, WriteFileTool
|
|
28
|
+
|
|
29
|
+
_MAX_FILE_BYTES = 1_048_576
|
|
30
|
+
_MAX_TEXT_OUTPUT_BYTES = 1_048_576
|
|
31
|
+
_MAX_PATH_RESULTS = 200
|
|
32
|
+
_MAX_LEXICAL_RESULTS = 200
|
|
33
|
+
_MAX_READ_LINES = 400
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ListFilesTool:
|
|
37
|
+
name = "list_files"
|
|
38
|
+
|
|
39
|
+
def __init__(self, guard: WorkspaceGuard) -> None:
|
|
40
|
+
self._guard = guard
|
|
41
|
+
|
|
42
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
43
|
+
started = time.perf_counter()
|
|
44
|
+
try:
|
|
45
|
+
path, recursive = _list_arguments(invocation.arguments)
|
|
46
|
+
root = self._guard.resolve_existing(path, require_directory=True)
|
|
47
|
+
paths, truncated = _list_paths(self._guard, root, recursive=recursive)
|
|
48
|
+
return _success(invocation, {"paths": paths, "truncated": truncated}, started)
|
|
49
|
+
except NexusError as exc:
|
|
50
|
+
return _nexus_failure(invocation, exc, started)
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
return _invalid_arguments(invocation, started)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SearchFilesTool:
|
|
56
|
+
name = "search_files"
|
|
57
|
+
|
|
58
|
+
def __init__(self, guard: WorkspaceGuard) -> None:
|
|
59
|
+
self._guard = guard
|
|
60
|
+
|
|
61
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
62
|
+
started = time.perf_counter()
|
|
63
|
+
try:
|
|
64
|
+
_require_keys(invocation.arguments, required={"pattern"}, optional={"path"})
|
|
65
|
+
pattern = _required_string(invocation.arguments, "pattern")
|
|
66
|
+
path = _optional_string(invocation.arguments, "path", ".")
|
|
67
|
+
root = self._guard.resolve_existing(path, require_directory=True)
|
|
68
|
+
matches, truncated = _search_paths(self._guard, root, pattern)
|
|
69
|
+
return _success(
|
|
70
|
+
invocation,
|
|
71
|
+
{"paths": matches, "truncated": truncated},
|
|
72
|
+
started,
|
|
73
|
+
)
|
|
74
|
+
except NexusError as exc:
|
|
75
|
+
return _nexus_failure(invocation, exc, started)
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
return _invalid_arguments(invocation, started)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ReadFileTool:
|
|
81
|
+
name = "read_file"
|
|
82
|
+
|
|
83
|
+
def __init__(self, guard: WorkspaceGuard) -> None:
|
|
84
|
+
self._guard = guard
|
|
85
|
+
|
|
86
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
87
|
+
started = time.perf_counter()
|
|
88
|
+
try:
|
|
89
|
+
_require_keys(
|
|
90
|
+
invocation.arguments,
|
|
91
|
+
required={"path"},
|
|
92
|
+
optional={"start_line", "max_lines"},
|
|
93
|
+
)
|
|
94
|
+
path = _required_string(invocation.arguments, "path")
|
|
95
|
+
start_line = _bounded_int(invocation.arguments, "start_line", 1, 1, 2**31 - 1)
|
|
96
|
+
max_lines = _bounded_int(
|
|
97
|
+
invocation.arguments,
|
|
98
|
+
"max_lines",
|
|
99
|
+
_MAX_READ_LINES,
|
|
100
|
+
1,
|
|
101
|
+
_MAX_READ_LINES,
|
|
102
|
+
)
|
|
103
|
+
resolved = self._guard.resolve_existing(path)
|
|
104
|
+
if not resolved.is_file():
|
|
105
|
+
raise ToolExecutionError(
|
|
106
|
+
"The requested path is not supported text.",
|
|
107
|
+
code="UNSUPPORTED_FILE",
|
|
108
|
+
)
|
|
109
|
+
content = _read_text_file(resolved)
|
|
110
|
+
lines = content.splitlines(keepends=True)
|
|
111
|
+
selected = lines[start_line - 1 : start_line - 1 + max_lines]
|
|
112
|
+
rendered = "".join(selected)
|
|
113
|
+
rendered_bytes = rendered.encode("utf-8")
|
|
114
|
+
if len(rendered_bytes) > _MAX_TEXT_OUTPUT_BYTES:
|
|
115
|
+
rendered = rendered_bytes[:_MAX_TEXT_OUTPUT_BYTES].decode(
|
|
116
|
+
"utf-8", errors="ignore"
|
|
117
|
+
)
|
|
118
|
+
end_line = start_line + len(selected) - 1
|
|
119
|
+
truncated = start_line - 1 + len(selected) < len(lines)
|
|
120
|
+
return _success(
|
|
121
|
+
invocation,
|
|
122
|
+
{
|
|
123
|
+
"path": self._guard.relative_display(resolved),
|
|
124
|
+
"start_line": start_line,
|
|
125
|
+
"end_line": end_line,
|
|
126
|
+
"content": rendered,
|
|
127
|
+
"truncated": truncated,
|
|
128
|
+
},
|
|
129
|
+
started,
|
|
130
|
+
)
|
|
131
|
+
except NexusError as exc:
|
|
132
|
+
return _nexus_failure(invocation, exc, started)
|
|
133
|
+
except (TypeError, ValueError):
|
|
134
|
+
return _invalid_arguments(invocation, started)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class LexicalSearchTool:
|
|
138
|
+
name = "lexical_search"
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
guard: WorkspaceGuard,
|
|
143
|
+
allow_path: Callable[[str], bool] | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
self._guard = guard
|
|
146
|
+
self._allow_path = allow_path
|
|
147
|
+
|
|
148
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
149
|
+
started = time.perf_counter()
|
|
150
|
+
try:
|
|
151
|
+
_require_keys(
|
|
152
|
+
invocation.arguments,
|
|
153
|
+
required={"pattern"},
|
|
154
|
+
optional={"path", "case_sensitive"},
|
|
155
|
+
)
|
|
156
|
+
pattern = _required_string(invocation.arguments, "pattern")
|
|
157
|
+
path = _optional_string(invocation.arguments, "path", ".")
|
|
158
|
+
case_sensitive = _optional_bool(invocation.arguments, "case_sensitive", True)
|
|
159
|
+
root = self._guard.resolve_existing(path)
|
|
160
|
+
matches, truncated = _lexical_matches(
|
|
161
|
+
self._guard,
|
|
162
|
+
root,
|
|
163
|
+
pattern,
|
|
164
|
+
case_sensitive=case_sensitive,
|
|
165
|
+
allow_path=self._allow_path,
|
|
166
|
+
)
|
|
167
|
+
return _success(
|
|
168
|
+
invocation,
|
|
169
|
+
{"matches": matches, "truncated": truncated},
|
|
170
|
+
started,
|
|
171
|
+
)
|
|
172
|
+
except NexusError as exc:
|
|
173
|
+
return _nexus_failure(invocation, exc, started)
|
|
174
|
+
except (TypeError, ValueError):
|
|
175
|
+
return _invalid_arguments(invocation, started)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ShellTool:
|
|
179
|
+
name = "shell"
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
guard: WorkspaceGuard,
|
|
184
|
+
sandbox: SandboxExecutor,
|
|
185
|
+
executables: TrustedExecutables,
|
|
186
|
+
) -> None:
|
|
187
|
+
self._guard = guard
|
|
188
|
+
self._sandbox = sandbox
|
|
189
|
+
self._executables = executables
|
|
190
|
+
|
|
191
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
192
|
+
started = time.perf_counter()
|
|
193
|
+
try:
|
|
194
|
+
_require_keys(
|
|
195
|
+
invocation.arguments,
|
|
196
|
+
required={"argv"},
|
|
197
|
+
optional={"cwd", "timeout_seconds"},
|
|
198
|
+
)
|
|
199
|
+
argv = _string_list(invocation.arguments.get("argv"))
|
|
200
|
+
cwd = _optional_string(invocation.arguments, "cwd", ".")
|
|
201
|
+
timeout = _bounded_float(
|
|
202
|
+
invocation.arguments,
|
|
203
|
+
"timeout_seconds",
|
|
204
|
+
30.0,
|
|
205
|
+
0.0,
|
|
206
|
+
300.0,
|
|
207
|
+
)
|
|
208
|
+
self._guard.resolve_existing(cwd, require_directory=True)
|
|
209
|
+
request = SandboxRequest(
|
|
210
|
+
operation=self.name,
|
|
211
|
+
argv=self._executables.normalize_argv(argv),
|
|
212
|
+
cwd=cwd,
|
|
213
|
+
timeout_seconds=timeout,
|
|
214
|
+
)
|
|
215
|
+
return _from_sandbox(
|
|
216
|
+
invocation,
|
|
217
|
+
await self._sandbox.execute(request),
|
|
218
|
+
started,
|
|
219
|
+
)
|
|
220
|
+
except NexusError as exc:
|
|
221
|
+
return _nexus_failure(invocation, exc, started)
|
|
222
|
+
except (TypeError, ValueError):
|
|
223
|
+
return _invalid_arguments(invocation, started)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class GitStatusTool:
|
|
227
|
+
name = "git_status"
|
|
228
|
+
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
sandbox: SandboxExecutor,
|
|
232
|
+
executables: TrustedExecutables,
|
|
233
|
+
) -> None:
|
|
234
|
+
self._sandbox = sandbox
|
|
235
|
+
self._executables = executables
|
|
236
|
+
|
|
237
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
238
|
+
started = time.perf_counter()
|
|
239
|
+
try:
|
|
240
|
+
_require_keys(invocation.arguments, required=set(), optional=set())
|
|
241
|
+
git = _require_git(self._executables)
|
|
242
|
+
request = SandboxRequest(self.name, status_argv(git), ".", 30.0)
|
|
243
|
+
return _from_git_sandbox(
|
|
244
|
+
invocation,
|
|
245
|
+
await self._sandbox.execute(request),
|
|
246
|
+
started,
|
|
247
|
+
)
|
|
248
|
+
except NexusError as exc:
|
|
249
|
+
return _nexus_failure(invocation, exc, started)
|
|
250
|
+
except (TypeError, ValueError):
|
|
251
|
+
return _invalid_arguments(invocation, started)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class GitDiffTool:
|
|
255
|
+
name = "git_diff"
|
|
256
|
+
|
|
257
|
+
def __init__(
|
|
258
|
+
self,
|
|
259
|
+
sandbox: SandboxExecutor,
|
|
260
|
+
executables: TrustedExecutables,
|
|
261
|
+
) -> None:
|
|
262
|
+
self._sandbox = sandbox
|
|
263
|
+
self._executables = executables
|
|
264
|
+
|
|
265
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
266
|
+
started = time.perf_counter()
|
|
267
|
+
try:
|
|
268
|
+
_require_keys(invocation.arguments, required=set(), optional={"staged"})
|
|
269
|
+
staged = _optional_bool(invocation.arguments, "staged", False)
|
|
270
|
+
git = _require_git(self._executables)
|
|
271
|
+
request = SandboxRequest(self.name, diff_argv(git, staged=staged), ".", 30.0)
|
|
272
|
+
return _from_git_sandbox(
|
|
273
|
+
invocation,
|
|
274
|
+
await self._sandbox.execute(request),
|
|
275
|
+
started,
|
|
276
|
+
)
|
|
277
|
+
except NexusError as exc:
|
|
278
|
+
return _nexus_failure(invocation, exc, started)
|
|
279
|
+
except (TypeError, ValueError):
|
|
280
|
+
return _invalid_arguments(invocation, started)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class GitLogTool:
|
|
284
|
+
name = "git_log"
|
|
285
|
+
|
|
286
|
+
def __init__(
|
|
287
|
+
self,
|
|
288
|
+
sandbox: SandboxExecutor,
|
|
289
|
+
executables: TrustedExecutables,
|
|
290
|
+
) -> None:
|
|
291
|
+
self._sandbox = sandbox
|
|
292
|
+
self._executables = executables
|
|
293
|
+
|
|
294
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
295
|
+
started = time.perf_counter()
|
|
296
|
+
try:
|
|
297
|
+
_require_keys(invocation.arguments, required=set(), optional={"max_entries"})
|
|
298
|
+
max_entries = _bounded_int(
|
|
299
|
+
invocation.arguments,
|
|
300
|
+
"max_entries",
|
|
301
|
+
20,
|
|
302
|
+
1,
|
|
303
|
+
100,
|
|
304
|
+
)
|
|
305
|
+
git = _require_git(self._executables)
|
|
306
|
+
request = SandboxRequest(
|
|
307
|
+
self.name,
|
|
308
|
+
log_argv(git, max_entries=max_entries),
|
|
309
|
+
".",
|
|
310
|
+
30.0,
|
|
311
|
+
)
|
|
312
|
+
return _from_git_sandbox(
|
|
313
|
+
invocation,
|
|
314
|
+
await self._sandbox.execute(request),
|
|
315
|
+
started,
|
|
316
|
+
)
|
|
317
|
+
except NexusError as exc:
|
|
318
|
+
return _nexus_failure(invocation, exc, started)
|
|
319
|
+
except (TypeError, ValueError):
|
|
320
|
+
return _invalid_arguments(invocation, started)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def build_native_tools(
|
|
324
|
+
guard: WorkspaceGuard,
|
|
325
|
+
sandbox: SandboxExecutor,
|
|
326
|
+
executables: TrustedExecutables,
|
|
327
|
+
*,
|
|
328
|
+
allow_lexical_path: Callable[[str], bool] | None = None,
|
|
329
|
+
) -> list[Tool]:
|
|
330
|
+
return [
|
|
331
|
+
ListFilesTool(guard),
|
|
332
|
+
SearchFilesTool(guard),
|
|
333
|
+
ReadFileTool(guard),
|
|
334
|
+
LexicalSearchTool(guard, allow_lexical_path),
|
|
335
|
+
PatchTool(guard),
|
|
336
|
+
WriteFileTool(guard),
|
|
337
|
+
ShellTool(guard, sandbox, executables),
|
|
338
|
+
GitStatusTool(sandbox, executables),
|
|
339
|
+
GitDiffTool(sandbox, executables),
|
|
340
|
+
GitLogTool(sandbox, executables),
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _list_arguments(arguments: JsonObject) -> tuple[str, bool]:
|
|
345
|
+
_require_keys(arguments, required=set(), optional={"path", "recursive"})
|
|
346
|
+
return (
|
|
347
|
+
_optional_string(arguments, "path", "."),
|
|
348
|
+
_optional_bool(arguments, "recursive", True),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _list_paths(
|
|
353
|
+
guard: WorkspaceGuard,
|
|
354
|
+
root: Path,
|
|
355
|
+
*,
|
|
356
|
+
recursive: bool,
|
|
357
|
+
) -> tuple[list[str], bool]:
|
|
358
|
+
if _is_git_metadata_path(guard, root):
|
|
359
|
+
return [], False
|
|
360
|
+
results = sorted(
|
|
361
|
+
guard.relative_display(file_path)
|
|
362
|
+
for file_path in _iter_files(root, recursive=recursive)
|
|
363
|
+
)
|
|
364
|
+
return results[:_MAX_PATH_RESULTS], len(results) > _MAX_PATH_RESULTS
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _search_paths(
|
|
368
|
+
guard: WorkspaceGuard,
|
|
369
|
+
root: Path,
|
|
370
|
+
pattern: str,
|
|
371
|
+
) -> tuple[list[str], bool]:
|
|
372
|
+
if _is_git_metadata_path(guard, root):
|
|
373
|
+
return [], False
|
|
374
|
+
matches = sorted(
|
|
375
|
+
relative
|
|
376
|
+
for file_path in _iter_files(root, recursive=True)
|
|
377
|
+
if fnmatch.fnmatchcase(
|
|
378
|
+
relative := guard.relative_display(file_path),
|
|
379
|
+
pattern,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
return matches[:_MAX_PATH_RESULTS], len(matches) > _MAX_PATH_RESULTS
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _iter_files(root: Path, *, recursive: bool) -> Iterator[Path]:
|
|
386
|
+
if root.is_file():
|
|
387
|
+
yield root
|
|
388
|
+
return
|
|
389
|
+
for current, directories, files in os.walk(root, followlinks=False):
|
|
390
|
+
current_path = Path(current)
|
|
391
|
+
directories[:] = sorted(
|
|
392
|
+
name
|
|
393
|
+
for name in directories
|
|
394
|
+
if name != ".git" and not _is_link_or_junction(current_path / name)
|
|
395
|
+
)
|
|
396
|
+
for name in sorted(files):
|
|
397
|
+
candidate = current_path / name
|
|
398
|
+
if not _is_link_or_junction(candidate):
|
|
399
|
+
yield candidate
|
|
400
|
+
if not recursive:
|
|
401
|
+
break
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _lexical_matches(
|
|
405
|
+
guard: WorkspaceGuard,
|
|
406
|
+
root: Path,
|
|
407
|
+
pattern: str,
|
|
408
|
+
*,
|
|
409
|
+
case_sensitive: bool,
|
|
410
|
+
allow_path: Callable[[str], bool] | None = None,
|
|
411
|
+
) -> tuple[list[JsonObject], bool]:
|
|
412
|
+
if _is_git_metadata_path(guard, root):
|
|
413
|
+
return [], False
|
|
414
|
+
needle = pattern if case_sensitive else pattern.casefold()
|
|
415
|
+
matches: list[JsonObject] = []
|
|
416
|
+
output_bytes = 0
|
|
417
|
+
candidates = sorted(
|
|
418
|
+
_iter_files(root, recursive=True),
|
|
419
|
+
key=guard.relative_display,
|
|
420
|
+
)
|
|
421
|
+
for candidate in candidates:
|
|
422
|
+
if allow_path is not None and not allow_path(guard.relative_display(candidate)):
|
|
423
|
+
continue
|
|
424
|
+
try:
|
|
425
|
+
resolved = guard.resolve_existing(
|
|
426
|
+
guard.relative_display(candidate),
|
|
427
|
+
require_file=True,
|
|
428
|
+
)
|
|
429
|
+
content = _read_text_file(resolved)
|
|
430
|
+
except NexusError:
|
|
431
|
+
continue
|
|
432
|
+
for line_number, line in enumerate(content.splitlines(), start=1):
|
|
433
|
+
haystack = line if case_sensitive else line.casefold()
|
|
434
|
+
column = haystack.find(needle)
|
|
435
|
+
if column < 0:
|
|
436
|
+
continue
|
|
437
|
+
match: JsonObject = {
|
|
438
|
+
"path": guard.relative_display(resolved),
|
|
439
|
+
"line": line_number,
|
|
440
|
+
"column": column + 1,
|
|
441
|
+
"text": line,
|
|
442
|
+
}
|
|
443
|
+
encoded = str(match).encode("utf-8")
|
|
444
|
+
if len(matches) >= _MAX_LEXICAL_RESULTS or (
|
|
445
|
+
output_bytes + len(encoded) > _MAX_TEXT_OUTPUT_BYTES
|
|
446
|
+
):
|
|
447
|
+
return matches, True
|
|
448
|
+
matches.append(match)
|
|
449
|
+
output_bytes += len(encoded)
|
|
450
|
+
return matches, False
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _read_text_file(path: Path) -> str:
|
|
454
|
+
try:
|
|
455
|
+
size = path.stat().st_size
|
|
456
|
+
if size > _MAX_FILE_BYTES:
|
|
457
|
+
raise ToolExecutionError(
|
|
458
|
+
"The requested file exceeds the Day 3 size limit.",
|
|
459
|
+
code="UNSUPPORTED_FILE",
|
|
460
|
+
)
|
|
461
|
+
content = path.read_bytes()
|
|
462
|
+
if b"\x00" in content:
|
|
463
|
+
raise ToolExecutionError(
|
|
464
|
+
"The requested file is not supported text.",
|
|
465
|
+
code="UNSUPPORTED_FILE",
|
|
466
|
+
)
|
|
467
|
+
return content.decode("utf-8")
|
|
468
|
+
except UnicodeDecodeError as exc:
|
|
469
|
+
raise ToolExecutionError(
|
|
470
|
+
"The requested file is not valid UTF-8 text.",
|
|
471
|
+
code="UNSUPPORTED_FILE",
|
|
472
|
+
) from exc
|
|
473
|
+
except OSError as exc:
|
|
474
|
+
raise ToolExecutionError("The requested file could not be read.") from exc
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _from_sandbox(
|
|
478
|
+
invocation: ToolInvocation,
|
|
479
|
+
result: SandboxResult,
|
|
480
|
+
started: float,
|
|
481
|
+
) -> ToolResult:
|
|
482
|
+
output = asdict(result)
|
|
483
|
+
output["risk_level"] = result.risk_level.value
|
|
484
|
+
output["policy_decision"] = result.policy_decision.value
|
|
485
|
+
if result.error is not None:
|
|
486
|
+
output["error"] = asdict(result.error)
|
|
487
|
+
return ToolResult(
|
|
488
|
+
invocation.invocation_id,
|
|
489
|
+
invocation.tool_name,
|
|
490
|
+
result.error is None and result.exit_code == 0,
|
|
491
|
+
output,
|
|
492
|
+
result.error,
|
|
493
|
+
result.risk_level,
|
|
494
|
+
result.policy_decision,
|
|
495
|
+
None,
|
|
496
|
+
_duration_ms(started),
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _from_git_sandbox(
|
|
501
|
+
invocation: ToolInvocation,
|
|
502
|
+
result: SandboxResult,
|
|
503
|
+
started: float,
|
|
504
|
+
) -> ToolResult:
|
|
505
|
+
output: JsonObject = {
|
|
506
|
+
"exit_code": result.exit_code,
|
|
507
|
+
"stdout": result.stdout,
|
|
508
|
+
"stderr": result.stderr,
|
|
509
|
+
"truncated": result.output_truncated,
|
|
510
|
+
}
|
|
511
|
+
return ToolResult(
|
|
512
|
+
invocation.invocation_id,
|
|
513
|
+
invocation.tool_name,
|
|
514
|
+
result.error is None and result.exit_code == 0,
|
|
515
|
+
output,
|
|
516
|
+
result.error,
|
|
517
|
+
result.risk_level,
|
|
518
|
+
result.policy_decision,
|
|
519
|
+
None,
|
|
520
|
+
_duration_ms(started),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _success(
|
|
525
|
+
invocation: ToolInvocation,
|
|
526
|
+
output: JsonObject,
|
|
527
|
+
started: float,
|
|
528
|
+
) -> ToolResult:
|
|
529
|
+
return ToolResult(
|
|
530
|
+
invocation.invocation_id,
|
|
531
|
+
invocation.tool_name,
|
|
532
|
+
True,
|
|
533
|
+
output,
|
|
534
|
+
None,
|
|
535
|
+
RiskLevel.SAFE,
|
|
536
|
+
PolicyDecision.ALLOWED,
|
|
537
|
+
None,
|
|
538
|
+
_duration_ms(started),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _nexus_failure(
|
|
543
|
+
invocation: ToolInvocation,
|
|
544
|
+
error: NexusError,
|
|
545
|
+
started: float,
|
|
546
|
+
) -> ToolResult:
|
|
547
|
+
dangerous = isinstance(error, PermissionDeniedError) or error.code == "WORKSPACE_PATH_DENIED"
|
|
548
|
+
return ToolResult(
|
|
549
|
+
invocation.invocation_id,
|
|
550
|
+
invocation.tool_name,
|
|
551
|
+
False,
|
|
552
|
+
None,
|
|
553
|
+
ToolError(error.code, str(error), error.retryable),
|
|
554
|
+
RiskLevel.DANGEROUS if dangerous else RiskLevel.SAFE,
|
|
555
|
+
PolicyDecision.DENIED if dangerous else PolicyDecision.ALLOWED,
|
|
556
|
+
None,
|
|
557
|
+
_duration_ms(started),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _invalid_arguments(invocation: ToolInvocation, started: float) -> ToolResult:
|
|
562
|
+
return ToolResult(
|
|
563
|
+
invocation.invocation_id,
|
|
564
|
+
invocation.tool_name,
|
|
565
|
+
False,
|
|
566
|
+
None,
|
|
567
|
+
ToolError("INVALID_TOOL_ARGUMENTS", "The Tool arguments are invalid.", False),
|
|
568
|
+
RiskLevel.SAFE,
|
|
569
|
+
PolicyDecision.ALLOWED,
|
|
570
|
+
None,
|
|
571
|
+
_duration_ms(started),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _require_keys(arguments: JsonObject, *, required: set[str], optional: set[str]) -> None:
|
|
576
|
+
keys = set(arguments)
|
|
577
|
+
if not required <= keys or not keys <= required | optional:
|
|
578
|
+
raise ValueError("Invalid Tool argument keys.")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _required_string(arguments: JsonObject, key: str) -> str:
|
|
582
|
+
value = arguments.get(key)
|
|
583
|
+
if not isinstance(value, str) or not value:
|
|
584
|
+
raise ValueError(f"{key} must be a non-empty string.")
|
|
585
|
+
return value
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _optional_string(arguments: JsonObject, key: str, default: str) -> str:
|
|
589
|
+
value = arguments.get(key, default)
|
|
590
|
+
if not isinstance(value, str) or not value:
|
|
591
|
+
raise ValueError(f"{key} must be a non-empty string.")
|
|
592
|
+
return value
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _optional_bool(arguments: JsonObject, key: str, default: bool) -> bool:
|
|
596
|
+
value = arguments.get(key, default)
|
|
597
|
+
if not isinstance(value, bool):
|
|
598
|
+
raise ValueError(f"{key} must be a bool.")
|
|
599
|
+
return value
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _bounded_int(
|
|
603
|
+
arguments: JsonObject,
|
|
604
|
+
key: str,
|
|
605
|
+
default: int,
|
|
606
|
+
minimum: int,
|
|
607
|
+
maximum: int,
|
|
608
|
+
) -> int:
|
|
609
|
+
value = arguments.get(key, default)
|
|
610
|
+
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
|
611
|
+
raise ValueError(f"{key} is outside its allowed range.")
|
|
612
|
+
return value
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _bounded_float(
|
|
616
|
+
arguments: JsonObject,
|
|
617
|
+
key: str,
|
|
618
|
+
default: float,
|
|
619
|
+
minimum: float,
|
|
620
|
+
maximum: float,
|
|
621
|
+
) -> float:
|
|
622
|
+
value = arguments.get(key, default)
|
|
623
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
624
|
+
raise ValueError(f"{key} must be numeric.")
|
|
625
|
+
normalized = float(value)
|
|
626
|
+
if not minimum < normalized <= maximum:
|
|
627
|
+
raise ValueError(f"{key} is outside its allowed range.")
|
|
628
|
+
return normalized
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _string_list(value: object) -> list[str]:
|
|
632
|
+
if not isinstance(value, list) or not value or not all(
|
|
633
|
+
isinstance(item, str) and item for item in value
|
|
634
|
+
):
|
|
635
|
+
raise ValueError("argv must be a non-empty list of strings.")
|
|
636
|
+
return list(value)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _require_git(executables: TrustedExecutables) -> str:
|
|
640
|
+
if executables.git is None:
|
|
641
|
+
raise ToolExecutionError("Git is unavailable to the native Git Tool.")
|
|
642
|
+
return executables.git
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _is_link_or_junction(path: Path) -> bool:
|
|
646
|
+
return path.is_symlink() or (hasattr(path, "is_junction") and path.is_junction())
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _is_git_metadata_path(guard: WorkspaceGuard, path: Path) -> bool:
|
|
650
|
+
return ".git" in guard.relative_display(path).split("/")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _duration_ms(started: float) -> int:
|
|
654
|
+
return max(0, round((time.perf_counter() - started) * 1000))
|
nexus/tools/registry.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Immutable exact-name Tool registry."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from nexus.domain.ports.tooling import Tool
|
|
6
|
+
from nexus.errors import ToolExecutionError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ToolRegistry:
|
|
10
|
+
def __init__(self, tools: Sequence[Tool]) -> None:
|
|
11
|
+
registry: dict[str, Tool] = {}
|
|
12
|
+
for tool in tools:
|
|
13
|
+
if not tool.name:
|
|
14
|
+
raise ValueError("Tool names must not be empty.")
|
|
15
|
+
if tool.name in registry:
|
|
16
|
+
raise ValueError(f"Duplicate Tool name: {tool.name}")
|
|
17
|
+
registry[tool.name] = tool
|
|
18
|
+
self._tools = registry
|
|
19
|
+
|
|
20
|
+
def resolve(self, name: str) -> Tool:
|
|
21
|
+
try:
|
|
22
|
+
return self._tools[name]
|
|
23
|
+
except KeyError as exc:
|
|
24
|
+
raise ToolExecutionError(
|
|
25
|
+
"The requested Tool is not registered.",
|
|
26
|
+
code="TOOL_NOT_FOUND",
|
|
27
|
+
) from exc
|