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/config/loader.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Resolve Day 1 configuration with the frozen per-field precedence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from nexus.config.models import RuntimeConfig
|
|
14
|
+
from nexus.errors import ConfigurationError
|
|
15
|
+
|
|
16
|
+
_ENV_FIELDS = {
|
|
17
|
+
"NEXUS_MODEL_PROVIDER": "model_provider",
|
|
18
|
+
"NEXUS_MODEL_NAME": "model_name",
|
|
19
|
+
"NEXUS_MODEL_BASE_URL": "model_base_url",
|
|
20
|
+
"NEXUS_MODEL_API_KEY": "model_api_key",
|
|
21
|
+
"NEXUS_DATABASE_URL": "database_url",
|
|
22
|
+
"NEXUS_APPROVAL_MODE": "approval_mode",
|
|
23
|
+
"NEXUS_MAX_STEPS": "max_steps",
|
|
24
|
+
"NEXUS_MAX_REPAIR_ATTEMPTS": "max_repair_attempts",
|
|
25
|
+
"NEXUS_MAX_REPLANS": "max_replans",
|
|
26
|
+
"NEXUS_MAX_SELECTED_SKILLS": "max_selected_skills",
|
|
27
|
+
"NEXUS_CONSOLE_TRACING_ENABLED": "console_tracing_enabled",
|
|
28
|
+
"NEXUS_LANGSMITH_TRACING_ENABLED": "langsmith_tracing_enabled",
|
|
29
|
+
"NEXUS_LANGSMITH_PROJECT": "langsmith_project",
|
|
30
|
+
"NEXUS_LANGSMITH_ENDPOINT": "langsmith_endpoint",
|
|
31
|
+
"NEXUS_LANGSMITH_WORKSPACE_ID": "langsmith_workspace_id",
|
|
32
|
+
"NEXUS_LANGSMITH_API_KEY": "langsmith_api_key",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_CONTEXT_INTEGERS = (
|
|
36
|
+
"max_retrieved_chunks", "max_exploration_seed_chunks", "max_code_context_tokens",
|
|
37
|
+
"max_recent_observations", "max_model_input_tokens", "max_file_size_bytes",
|
|
38
|
+
)
|
|
39
|
+
_ENV_FIELDS.update({f"NEXUS_{name.upper()}": name for name in _CONTEXT_INTEGERS})
|
|
40
|
+
_ENV_FIELDS.update({
|
|
41
|
+
"NEXUS_SEMANTIC_ENABLED": "semantic_enabled",
|
|
42
|
+
"NEXUS_EMBEDDING_MODEL": "embedding_model",
|
|
43
|
+
"NEXUS_EMBEDDING_DIMENSION": "embedding_dimension",
|
|
44
|
+
"NEXUS_EMBEDDING_BASE_URL": "embedding_base_url",
|
|
45
|
+
"NEXUS_EMBEDDING_API_KEY": "embedding_api_key",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_runtime_config(
|
|
50
|
+
*,
|
|
51
|
+
cli_model: str | None = None,
|
|
52
|
+
cli_base_url: str | None = None,
|
|
53
|
+
repo_root: Path | None = None,
|
|
54
|
+
user_config_path: Path | None = None,
|
|
55
|
+
environ: Mapping[str, str] | None = None,
|
|
56
|
+
) -> RuntimeConfig:
|
|
57
|
+
"""Load configuration per field from lowest to highest priority."""
|
|
58
|
+
|
|
59
|
+
environment = os.environ if environ is None else environ
|
|
60
|
+
resolved: dict[str, Any] = {}
|
|
61
|
+
resolved.update(_environment_values(environment))
|
|
62
|
+
|
|
63
|
+
user_path = user_config_path or (Path.home() / ".nexus" / "config.toml")
|
|
64
|
+
resolved.update(_toml_values(user_path, allow_remote_observability=True))
|
|
65
|
+
|
|
66
|
+
repository = repo_root or Path.cwd()
|
|
67
|
+
resolved.update(
|
|
68
|
+
_toml_values(
|
|
69
|
+
repository / ".nexus" / "config.toml",
|
|
70
|
+
allow_mcp=False,
|
|
71
|
+
allow_remote_observability=False,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if cli_model is not None:
|
|
76
|
+
resolved["model_name"] = cli_model
|
|
77
|
+
if cli_base_url is not None:
|
|
78
|
+
resolved["model_base_url"] = cli_base_url
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
return RuntimeConfig.model_validate(resolved)
|
|
82
|
+
except ValidationError as exc:
|
|
83
|
+
code = (
|
|
84
|
+
"TRACE_CONFIGURATION_INVALID"
|
|
85
|
+
if any("langsmith" in str(error).lower() for error in exc.errors())
|
|
86
|
+
else None
|
|
87
|
+
)
|
|
88
|
+
raise ConfigurationError("Runtime configuration is invalid.", code=code) from exc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _environment_values(environ: Mapping[str, str]) -> dict[str, Any]:
|
|
92
|
+
return {
|
|
93
|
+
field: environ[variable]
|
|
94
|
+
for variable, field in _ENV_FIELDS.items()
|
|
95
|
+
if variable in environ
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _toml_values(
|
|
100
|
+
path: Path,
|
|
101
|
+
*,
|
|
102
|
+
allow_mcp: bool = True,
|
|
103
|
+
allow_remote_observability: bool = True,
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
if not path.is_file():
|
|
106
|
+
return {}
|
|
107
|
+
try:
|
|
108
|
+
with path.open("rb") as config_file:
|
|
109
|
+
document = tomllib.load(config_file)
|
|
110
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
111
|
+
raise ConfigurationError(f"Could not read Nexus configuration at {path}.") from exc
|
|
112
|
+
|
|
113
|
+
if not allow_mcp and "mcp" in document:
|
|
114
|
+
raise ConfigurationError(
|
|
115
|
+
"Repository configuration must not define [mcp]; MCP process and server "
|
|
116
|
+
"configuration is allowed only in the user-level ~/.nexus/config.toml."
|
|
117
|
+
)
|
|
118
|
+
observability = _table(document, "observability", path)
|
|
119
|
+
if not allow_remote_observability and _contains_key(document, "langsmith"):
|
|
120
|
+
raise ConfigurationError(
|
|
121
|
+
"Repository configuration must not define LangSmith observability.",
|
|
122
|
+
code="TRACE_CONFIGURATION_INVALID",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
model = _table(document, "model", path)
|
|
126
|
+
database = _table(document, "database", path)
|
|
127
|
+
runtime = _table(document, "runtime", path)
|
|
128
|
+
context = _table(document, "context", path)
|
|
129
|
+
embedding = _table(document, "embedding", path)
|
|
130
|
+
skills = _table(document, "skills", path)
|
|
131
|
+
mcp = _table(document, "mcp", path)
|
|
132
|
+
console = _table(observability, "console", path)
|
|
133
|
+
langsmith = _table(observability, "langsmith", path)
|
|
134
|
+
if "api_key" in embedding:
|
|
135
|
+
raise ConfigurationError("Embedding API keys are not supported in Nexus TOML.")
|
|
136
|
+
if "api_key" in model:
|
|
137
|
+
raise ConfigurationError(
|
|
138
|
+
f"API keys are not supported in Nexus TOML configuration at {path}."
|
|
139
|
+
)
|
|
140
|
+
if "api_key" in langsmith:
|
|
141
|
+
raise ConfigurationError(
|
|
142
|
+
"LangSmith API keys are accepted only from NEXUS_LANGSMITH_API_KEY.",
|
|
143
|
+
code="TRACE_CONFIGURATION_INVALID",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
values: dict[str, Any] = {}
|
|
147
|
+
for name in _CONTEXT_INTEGERS:
|
|
148
|
+
_copy_optional_integer(context, name, name, values, path)
|
|
149
|
+
if "semantic_enabled" in context:
|
|
150
|
+
if not isinstance(context["semantic_enabled"], bool):
|
|
151
|
+
raise ConfigurationError("semantic_enabled must be a TOML boolean.")
|
|
152
|
+
values["semantic_enabled"] = context["semantic_enabled"]
|
|
153
|
+
for name in ("provider", "model", "base_url"):
|
|
154
|
+
_copy_optional_string(embedding, name, f"embedding_{name}", values, path)
|
|
155
|
+
_copy_optional_integer(embedding, "dimension", "embedding_dimension", values, path)
|
|
156
|
+
_copy_optional_integer(
|
|
157
|
+
skills,
|
|
158
|
+
"max_selected_skills",
|
|
159
|
+
"max_selected_skills",
|
|
160
|
+
values,
|
|
161
|
+
path,
|
|
162
|
+
)
|
|
163
|
+
_copy_optional_string(model, "provider", "model_provider", values, path)
|
|
164
|
+
_copy_optional_string(model, "name", "model_name", values, path)
|
|
165
|
+
_copy_optional_string(model, "base_url", "model_base_url", values, path)
|
|
166
|
+
_copy_optional_string(database, "url", "database_url", values, path)
|
|
167
|
+
_copy_optional_string(runtime, "approval_mode", "approval_mode", values, path)
|
|
168
|
+
_copy_optional_integer(runtime, "max_steps", "max_steps", values, path)
|
|
169
|
+
_copy_optional_integer(
|
|
170
|
+
runtime,
|
|
171
|
+
"max_repair_attempts",
|
|
172
|
+
"max_repair_attempts",
|
|
173
|
+
values,
|
|
174
|
+
path,
|
|
175
|
+
)
|
|
176
|
+
_copy_optional_integer(runtime, "max_replans", "max_replans", values, path)
|
|
177
|
+
if "enabled" in mcp:
|
|
178
|
+
if not isinstance(mcp["enabled"], bool):
|
|
179
|
+
raise ConfigurationError("mcp.enabled must be a TOML boolean.")
|
|
180
|
+
values["mcp_enabled"] = mcp["enabled"]
|
|
181
|
+
if "servers" in mcp:
|
|
182
|
+
servers = mcp["servers"]
|
|
183
|
+
if not isinstance(servers, list) or any(
|
|
184
|
+
not isinstance(server, Mapping) for server in servers
|
|
185
|
+
):
|
|
186
|
+
raise ConfigurationError("mcp.servers must be an array of tables.")
|
|
187
|
+
values["mcp_servers"] = [dict(server) for server in servers]
|
|
188
|
+
if "enabled" in console:
|
|
189
|
+
if not isinstance(console["enabled"], bool):
|
|
190
|
+
raise ConfigurationError("observability.console.enabled must be a TOML boolean.")
|
|
191
|
+
values["console_tracing_enabled"] = console["enabled"]
|
|
192
|
+
if "enabled" in langsmith:
|
|
193
|
+
if not isinstance(langsmith["enabled"], bool):
|
|
194
|
+
raise ConfigurationError(
|
|
195
|
+
"observability.langsmith.enabled must be a TOML boolean.",
|
|
196
|
+
code="TRACE_CONFIGURATION_INVALID",
|
|
197
|
+
)
|
|
198
|
+
values["langsmith_tracing_enabled"] = langsmith["enabled"]
|
|
199
|
+
for name, target in (
|
|
200
|
+
("project", "langsmith_project"),
|
|
201
|
+
("endpoint", "langsmith_endpoint"),
|
|
202
|
+
("workspace_id", "langsmith_workspace_id"),
|
|
203
|
+
):
|
|
204
|
+
_copy_optional_string(langsmith, name, target, values, path)
|
|
205
|
+
return values
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _table(document: Mapping[str, Any], name: str, path: Path) -> Mapping[str, Any]:
|
|
209
|
+
value = document.get(name, {})
|
|
210
|
+
if not isinstance(value, Mapping):
|
|
211
|
+
raise ConfigurationError(f"[{name}] must be a TOML table in {path}.")
|
|
212
|
+
return value
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _copy_optional_string(
|
|
216
|
+
source: Mapping[str, Any],
|
|
217
|
+
source_key: str,
|
|
218
|
+
target_key: str,
|
|
219
|
+
target: dict[str, Any],
|
|
220
|
+
path: Path,
|
|
221
|
+
) -> None:
|
|
222
|
+
if source_key not in source:
|
|
223
|
+
return
|
|
224
|
+
value = source[source_key]
|
|
225
|
+
if not isinstance(value, str):
|
|
226
|
+
raise ConfigurationError(f"{source_key} must be a string in {path}.")
|
|
227
|
+
target[target_key] = value
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _copy_optional_integer(
|
|
231
|
+
source: Mapping[str, Any],
|
|
232
|
+
source_key: str,
|
|
233
|
+
target_key: str,
|
|
234
|
+
target: dict[str, Any],
|
|
235
|
+
path: Path,
|
|
236
|
+
) -> None:
|
|
237
|
+
if source_key not in source:
|
|
238
|
+
return
|
|
239
|
+
value = source[source_key]
|
|
240
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
241
|
+
raise ConfigurationError(f"{source_key} must be an integer in {path}.")
|
|
242
|
+
target[target_key] = value
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _contains_key(value: object, prohibited: str) -> bool:
|
|
246
|
+
if isinstance(value, Mapping):
|
|
247
|
+
return any(
|
|
248
|
+
prohibited in str(key).lower() or _contains_key(item, prohibited)
|
|
249
|
+
for key, item in value.items()
|
|
250
|
+
)
|
|
251
|
+
if isinstance(value, list):
|
|
252
|
+
return any(_contains_key(item, prohibited) for item in value)
|
|
253
|
+
return False
|
nexus/config/models.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Typed Day 1 runtime configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
from nexus.domain.tooling import RiskLevel
|
|
11
|
+
from nexus.errors import ConfigurationError
|
|
12
|
+
|
|
13
|
+
DEFAULT_DATABASE_URL = "postgresql+asyncpg://nexus:nexus@localhost:5432/nexus"
|
|
14
|
+
SUPPORTED_MODEL_PROVIDER = "openai_compatible"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApprovalMode(StrEnum):
|
|
18
|
+
APPROVAL = "approval"
|
|
19
|
+
AUTO = "auto"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MCPTransport(StrEnum):
|
|
23
|
+
STDIO = "stdio"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MCPToolRiskConfig(BaseModel):
|
|
27
|
+
model_config = ConfigDict(frozen=True)
|
|
28
|
+
|
|
29
|
+
tool_name: str
|
|
30
|
+
risk_level: RiskLevel
|
|
31
|
+
|
|
32
|
+
@field_validator("tool_name")
|
|
33
|
+
@classmethod
|
|
34
|
+
def validate_tool_name(cls, value: str) -> str:
|
|
35
|
+
if not value.strip():
|
|
36
|
+
raise ValueError("MCP tool_name must not be empty.")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MCPServerConfig(BaseModel):
|
|
41
|
+
model_config = ConfigDict(frozen=True)
|
|
42
|
+
|
|
43
|
+
server_id: str = Field(pattern=r"^[a-z][a-z0-9_-]{0,63}$")
|
|
44
|
+
enabled: bool = True
|
|
45
|
+
transport: MCPTransport = MCPTransport.STDIO
|
|
46
|
+
command: str
|
|
47
|
+
args: tuple[str, ...] = ()
|
|
48
|
+
inherit_environment: bool = True
|
|
49
|
+
connect_timeout_seconds: float = Field(default=10.0, gt=0, le=60)
|
|
50
|
+
tool_timeout_seconds: float = Field(default=30.0, gt=0, le=300)
|
|
51
|
+
max_connect_attempts: int = Field(default=2, ge=1, le=3)
|
|
52
|
+
tool_risks: tuple[MCPToolRiskConfig, ...] = ()
|
|
53
|
+
|
|
54
|
+
@field_validator("command")
|
|
55
|
+
@classmethod
|
|
56
|
+
def validate_command(cls, value: str) -> str:
|
|
57
|
+
if not value.strip():
|
|
58
|
+
raise ValueError("MCP command must not be empty.")
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
@field_validator("args")
|
|
62
|
+
@classmethod
|
|
63
|
+
def validate_args(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
64
|
+
if any(not item.strip() for item in value):
|
|
65
|
+
raise ValueError("MCP args must contain only non-empty strings.")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
@field_validator("max_connect_attempts", mode="before")
|
|
69
|
+
@classmethod
|
|
70
|
+
def reject_boolean_attempts(cls, value: object) -> object:
|
|
71
|
+
if isinstance(value, bool):
|
|
72
|
+
raise ValueError("MCP max_connect_attempts must be an integer.")
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
@model_validator(mode="after")
|
|
76
|
+
def validate_unique_risks(self) -> MCPServerConfig:
|
|
77
|
+
names = [item.tool_name for item in self.tool_risks]
|
|
78
|
+
if len(names) != len(set(names)):
|
|
79
|
+
raise ValueError("MCP tool_risks contains duplicate tool_name entries.")
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RuntimeConfig(BaseModel):
|
|
84
|
+
"""The exact approved Day 1 configuration surface."""
|
|
85
|
+
|
|
86
|
+
model_config = ConfigDict(frozen=True)
|
|
87
|
+
|
|
88
|
+
model_provider: str = SUPPORTED_MODEL_PROVIDER
|
|
89
|
+
model_name: str | None = None
|
|
90
|
+
model_base_url: str | None = None
|
|
91
|
+
model_api_key: SecretStr | None = None
|
|
92
|
+
database_url: str = DEFAULT_DATABASE_URL
|
|
93
|
+
approval_mode: ApprovalMode = ApprovalMode.APPROVAL
|
|
94
|
+
max_steps: int = 30
|
|
95
|
+
max_repair_attempts: int = 3
|
|
96
|
+
max_replans: int = 2
|
|
97
|
+
semantic_enabled: bool = False
|
|
98
|
+
lexical_top_k: int = Field(default=20, ge=20, le=20)
|
|
99
|
+
semantic_top_k: int = Field(default=20, ge=20, le=20)
|
|
100
|
+
rrf_k: int = Field(default=60, ge=60, le=60)
|
|
101
|
+
final_candidate_count: int = Field(default=12, ge=12, le=12)
|
|
102
|
+
max_retrieved_chunks: int = Field(default=12, ge=0, le=12)
|
|
103
|
+
max_exploration_seed_chunks: int = Field(default=6, ge=0, le=6)
|
|
104
|
+
max_code_context_tokens: int = Field(default=12000, ge=0, le=12000)
|
|
105
|
+
max_recent_observations: int = Field(default=8, ge=0, le=8)
|
|
106
|
+
max_model_input_tokens: int = Field(default=24000, ge=1, le=24000)
|
|
107
|
+
max_file_size_bytes: int = Field(default=1048576, ge=1)
|
|
108
|
+
embedding_provider: str = "openai_compatible"
|
|
109
|
+
embedding_model: str = ""
|
|
110
|
+
embedding_dimension: int = Field(default=0, ge=0)
|
|
111
|
+
embedding_base_url: str = ""
|
|
112
|
+
embedding_api_key: SecretStr | None = None
|
|
113
|
+
max_selected_skills: int = Field(default=2, ge=0, le=2)
|
|
114
|
+
mcp_enabled: bool = False
|
|
115
|
+
mcp_servers: tuple[MCPServerConfig, ...] = ()
|
|
116
|
+
console_tracing_enabled: bool = False
|
|
117
|
+
langsmith_tracing_enabled: bool = False
|
|
118
|
+
langsmith_project: str = "nexus"
|
|
119
|
+
langsmith_endpoint: str | None = None
|
|
120
|
+
langsmith_workspace_id: str | None = None
|
|
121
|
+
langsmith_api_key: SecretStr | None = None
|
|
122
|
+
|
|
123
|
+
def require_embedding(self) -> None:
|
|
124
|
+
"""Validate only when a semantic/index capability is actually requested."""
|
|
125
|
+
if (
|
|
126
|
+
self.embedding_provider != "openai_compatible"
|
|
127
|
+
or not self.embedding_model.strip()
|
|
128
|
+
or self.embedding_dimension <= 0
|
|
129
|
+
or not self.embedding_base_url.startswith(("https://", "http://"))
|
|
130
|
+
or self.embedding_api_key is None
|
|
131
|
+
or not self.embedding_api_key.get_secret_value().strip()
|
|
132
|
+
):
|
|
133
|
+
raise ConfigurationError(
|
|
134
|
+
"Configure embedding provider/model/dimension/base_url and "
|
|
135
|
+
"NEXUS_EMBEDDING_API_KEY, or disable semantic retrieval."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@model_validator(mode="after")
|
|
139
|
+
def validate_context_budget(self) -> RuntimeConfig:
|
|
140
|
+
if self.max_code_context_tokens > self.max_model_input_tokens:
|
|
141
|
+
raise ValueError("Code context budget must not exceed total model input budget.")
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
@field_validator("model_provider")
|
|
145
|
+
@classmethod
|
|
146
|
+
def validate_provider(cls, value: str) -> str:
|
|
147
|
+
if value != SUPPORTED_MODEL_PROVIDER:
|
|
148
|
+
raise ValueError(f"unsupported Day 1 model provider: {value!r}")
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
@field_validator(
|
|
152
|
+
"model_name",
|
|
153
|
+
"model_base_url",
|
|
154
|
+
"langsmith_endpoint",
|
|
155
|
+
"langsmith_workspace_id",
|
|
156
|
+
mode="before",
|
|
157
|
+
)
|
|
158
|
+
@classmethod
|
|
159
|
+
def normalize_optional_text(cls, value: object) -> object:
|
|
160
|
+
if isinstance(value, str):
|
|
161
|
+
normalized = value.strip()
|
|
162
|
+
return normalized or None
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
@field_validator("model_name")
|
|
166
|
+
@classmethod
|
|
167
|
+
def validate_model_name_length(cls, value: str | None) -> str | None:
|
|
168
|
+
if value is not None and len(value) > 256:
|
|
169
|
+
raise ValueError("Model name is too long for safe observability.")
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
@field_validator("database_url")
|
|
173
|
+
@classmethod
|
|
174
|
+
def validate_database_url(cls, value: str) -> str:
|
|
175
|
+
if not value.strip():
|
|
176
|
+
raise ValueError("database URL must not be empty")
|
|
177
|
+
if not value.startswith("postgresql+asyncpg://"):
|
|
178
|
+
raise ValueError("Day 1 database URL must use postgresql+asyncpg")
|
|
179
|
+
return value
|
|
180
|
+
|
|
181
|
+
@field_validator(
|
|
182
|
+
"max_steps",
|
|
183
|
+
"max_repair_attempts",
|
|
184
|
+
"max_replans",
|
|
185
|
+
"lexical_top_k",
|
|
186
|
+
"semantic_top_k",
|
|
187
|
+
"rrf_k",
|
|
188
|
+
"final_candidate_count",
|
|
189
|
+
"max_retrieved_chunks",
|
|
190
|
+
"max_exploration_seed_chunks",
|
|
191
|
+
"max_code_context_tokens",
|
|
192
|
+
"max_recent_observations",
|
|
193
|
+
"max_model_input_tokens",
|
|
194
|
+
"max_file_size_bytes",
|
|
195
|
+
"embedding_dimension",
|
|
196
|
+
"max_selected_skills",
|
|
197
|
+
mode="before",
|
|
198
|
+
)
|
|
199
|
+
@classmethod
|
|
200
|
+
def reject_boolean_limits(cls, value: object) -> object:
|
|
201
|
+
if isinstance(value, bool):
|
|
202
|
+
raise ValueError("Runtime limits must be integers, not booleans.")
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
@model_validator(mode="after")
|
|
206
|
+
def validate_day4_limits(self) -> RuntimeConfig:
|
|
207
|
+
if self.max_steps <= 0:
|
|
208
|
+
raise ValueError("max_steps must be greater than zero.")
|
|
209
|
+
if self.max_repair_attempts < 0 or self.max_replans < 0:
|
|
210
|
+
raise ValueError("Repair and Replan limits must not be negative.")
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
@model_validator(mode="after")
|
|
214
|
+
def validate_unique_mcp_servers(self) -> RuntimeConfig:
|
|
215
|
+
server_ids = [server.server_id for server in self.mcp_servers]
|
|
216
|
+
if len(server_ids) != len(set(server_ids)):
|
|
217
|
+
raise ValueError("MCP server_id values must be unique.")
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
@field_validator("langsmith_project")
|
|
221
|
+
@classmethod
|
|
222
|
+
def validate_langsmith_project(cls, value: str) -> str:
|
|
223
|
+
normalized = value.strip()
|
|
224
|
+
if not normalized or len(normalized) > 256:
|
|
225
|
+
raise ValueError("LangSmith project must be a bounded non-empty identifier.")
|
|
226
|
+
return normalized
|
|
227
|
+
|
|
228
|
+
@field_validator("langsmith_workspace_id")
|
|
229
|
+
@classmethod
|
|
230
|
+
def validate_langsmith_workspace_id(cls, value: str | None) -> str | None:
|
|
231
|
+
if value is not None and len(value) > 256:
|
|
232
|
+
raise ValueError("LangSmith workspace ID is too long.")
|
|
233
|
+
return value
|
|
234
|
+
|
|
235
|
+
@field_validator("langsmith_endpoint")
|
|
236
|
+
@classmethod
|
|
237
|
+
def validate_langsmith_endpoint(cls, value: str | None) -> str | None:
|
|
238
|
+
if value is None:
|
|
239
|
+
return None
|
|
240
|
+
parts = urlsplit(value)
|
|
241
|
+
if (
|
|
242
|
+
parts.scheme not in {"http", "https"}
|
|
243
|
+
or not parts.hostname
|
|
244
|
+
or parts.username is not None
|
|
245
|
+
or parts.password is not None
|
|
246
|
+
or parts.query
|
|
247
|
+
or parts.fragment
|
|
248
|
+
):
|
|
249
|
+
raise ValueError("LangSmith endpoint must be a credential-free HTTP(S) URL.")
|
|
250
|
+
return value
|
|
251
|
+
|
|
252
|
+
@model_validator(mode="after")
|
|
253
|
+
def validate_langsmith_enablement(self) -> RuntimeConfig:
|
|
254
|
+
if self.langsmith_tracing_enabled and (
|
|
255
|
+
self.langsmith_api_key is None
|
|
256
|
+
or not self.langsmith_api_key.get_secret_value().strip()
|
|
257
|
+
):
|
|
258
|
+
raise ValueError("Enabled LangSmith tracing requires NEXUS_LANGSMITH_API_KEY.")
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
def __repr__(self) -> str:
|
|
262
|
+
api_key = "**********" if self.model_api_key is not None else "None"
|
|
263
|
+
return (
|
|
264
|
+
"RuntimeConfig("
|
|
265
|
+
f"model_provider={self.model_provider!r}, "
|
|
266
|
+
f"model_name={self.model_name!r}, "
|
|
267
|
+
f"model_base_url={self.model_base_url!r}, "
|
|
268
|
+
f"model_api_key={api_key}, "
|
|
269
|
+
f"database_url={redact_database_url(self.database_url)!r}, "
|
|
270
|
+
f"approval_mode={self.approval_mode.value!r}, "
|
|
271
|
+
f"max_steps={self.max_steps!r}, "
|
|
272
|
+
f"max_repair_attempts={self.max_repair_attempts!r}, "
|
|
273
|
+
f"max_replans={self.max_replans!r}, "
|
|
274
|
+
f"mcp_enabled={self.mcp_enabled!r}, "
|
|
275
|
+
f"mcp_server_count={len(self.mcp_servers)!r}, "
|
|
276
|
+
f"console_tracing_enabled={self.console_tracing_enabled!r}, "
|
|
277
|
+
f"langsmith_tracing_enabled={self.langsmith_tracing_enabled!r}, "
|
|
278
|
+
f"langsmith_project={self.langsmith_project!r}, "
|
|
279
|
+
f"langsmith_endpoint={redact_endpoint(self.langsmith_endpoint)!r}, "
|
|
280
|
+
f"langsmith_workspace_id={self.langsmith_workspace_id!r}, "
|
|
281
|
+
f"langsmith_api_key={'**********' if self.langsmith_api_key else 'None'}"
|
|
282
|
+
")"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
__str__ = __repr__
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def redact_database_url(value: str) -> str:
|
|
289
|
+
"""Hide embedded database credentials while preserving useful location data."""
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
parts = urlsplit(value)
|
|
293
|
+
except ValueError:
|
|
294
|
+
return "<redacted-database-url>"
|
|
295
|
+
if parts.username is None and parts.password is None:
|
|
296
|
+
return value
|
|
297
|
+
|
|
298
|
+
hostname = parts.hostname or ""
|
|
299
|
+
port = f":{parts.port}" if parts.port is not None else ""
|
|
300
|
+
redacted_netloc = f"***:***@{hostname}{port}"
|
|
301
|
+
return urlunsplit((parts.scheme, redacted_netloc, parts.path, parts.query, parts.fragment))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def redact_endpoint(value: str | None) -> str | None:
|
|
305
|
+
if value is None:
|
|
306
|
+
return None
|
|
307
|
+
parts = urlsplit(value)
|
|
308
|
+
hostname = parts.hostname or ""
|
|
309
|
+
port = f":{parts.port}" if parts.port is not None else ""
|
|
310
|
+
return urlunsplit((parts.scheme, f"{hostname}{port}", "", "", ""))
|
nexus/context/budget.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Shared Day 5 model-input accounting exposed through the Day 7 guard port."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from nexus.context.manager import model_input_tokens
|
|
6
|
+
from nexus.domain.model import ModelMessage
|
|
7
|
+
from nexus.errors import ContextError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Day5ModelInputBudgetGuard:
|
|
11
|
+
def __init__(self, maximum: int) -> None:
|
|
12
|
+
self._maximum = maximum
|
|
13
|
+
|
|
14
|
+
def ensure_fits(
|
|
15
|
+
self,
|
|
16
|
+
messages: Sequence[ModelMessage],
|
|
17
|
+
*,
|
|
18
|
+
error_code: str,
|
|
19
|
+
) -> None:
|
|
20
|
+
if model_input_tokens(messages) > self._maximum:
|
|
21
|
+
raise ContextError(
|
|
22
|
+
"The complete model request exceeds the configured input budget.",
|
|
23
|
+
code=error_code,
|
|
24
|
+
)
|
nexus/context/builder.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Bounded Day 4 context seed construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from nexus.application.tool_runtime import ToolRuntime
|
|
9
|
+
from nexus.domain.exploration import (
|
|
10
|
+
ContextBuildRequest,
|
|
11
|
+
RepositoryInstruction,
|
|
12
|
+
SelectedFileContext,
|
|
13
|
+
WorkingContext,
|
|
14
|
+
)
|
|
15
|
+
from nexus.domain.tooling import ToolInvocation
|
|
16
|
+
from nexus.errors import ContextError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BoundedContextBuilder:
|
|
20
|
+
def __init__(self, tool_runtime: ToolRuntime) -> None:
|
|
21
|
+
self._tool_runtime = tool_runtime
|
|
22
|
+
|
|
23
|
+
async def build(self, request: ContextBuildRequest) -> WorkingContext:
|
|
24
|
+
selected: list[SelectedFileContext] = []
|
|
25
|
+
retained = 0
|
|
26
|
+
truncated = request.exploration.truncated
|
|
27
|
+
for evidence in request.exploration.relevant_files[:8]:
|
|
28
|
+
result = await self._tool_runtime.execute(
|
|
29
|
+
ToolInvocation(
|
|
30
|
+
str(uuid4()),
|
|
31
|
+
"read_file",
|
|
32
|
+
{"path": evidence.path, "start_line": 1, "max_lines": 400},
|
|
33
|
+
request.run_id,
|
|
34
|
+
request.session_id,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
if not result.success or result.output is None:
|
|
38
|
+
truncated = True
|
|
39
|
+
continue
|
|
40
|
+
content = result.output.get("content")
|
|
41
|
+
if not isinstance(content, str):
|
|
42
|
+
truncated = True
|
|
43
|
+
continue
|
|
44
|
+
remaining = 48_000 - retained
|
|
45
|
+
if remaining <= 0:
|
|
46
|
+
truncated = True
|
|
47
|
+
break
|
|
48
|
+
bounded = content[:remaining]
|
|
49
|
+
retained += len(bounded)
|
|
50
|
+
item_truncated = result.output.get("truncated") is True or len(bounded) < len(content)
|
|
51
|
+
truncated = truncated or item_truncated
|
|
52
|
+
selected.append(
|
|
53
|
+
SelectedFileContext(
|
|
54
|
+
evidence.path,
|
|
55
|
+
bounded,
|
|
56
|
+
_applicable_instructions(
|
|
57
|
+
evidence.path,
|
|
58
|
+
request.exploration.instructions,
|
|
59
|
+
),
|
|
60
|
+
evidence.discovery_reason,
|
|
61
|
+
item_truncated,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
if request.exploration.relevant_files and not selected:
|
|
65
|
+
raise ContextError(
|
|
66
|
+
"Nexus could not build context from selected repository evidence.",
|
|
67
|
+
code="CONTEXT_BUILD_FAILED",
|
|
68
|
+
retryable=True,
|
|
69
|
+
)
|
|
70
|
+
return WorkingContext(
|
|
71
|
+
request.task,
|
|
72
|
+
request.exploration.instructions,
|
|
73
|
+
request.exploration.manifests,
|
|
74
|
+
request.exploration.top_level_paths,
|
|
75
|
+
tuple(selected),
|
|
76
|
+
truncated,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _applicable_instructions(
|
|
81
|
+
path: str,
|
|
82
|
+
instructions: tuple[RepositoryInstruction, ...],
|
|
83
|
+
) -> tuple[str, ...]:
|
|
84
|
+
target = PurePosixPath(path)
|
|
85
|
+
applicable = [
|
|
86
|
+
instruction
|
|
87
|
+
for instruction in instructions
|
|
88
|
+
if instruction.scope_path == "."
|
|
89
|
+
or PurePosixPath(instruction.scope_path) == target.parent
|
|
90
|
+
or PurePosixPath(instruction.scope_path) in target.parents
|
|
91
|
+
]
|
|
92
|
+
return tuple(item.path for item in sorted(applicable, key=lambda item: (item.depth, item.path)))
|