codecortex-context-engine 0.1.0a1__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.
- codecortex/__init__.py +3 -0
- codecortex/architecture/__init__.py +23 -0
- codecortex/architecture/drift.py +182 -0
- codecortex/architecture/inference.py +160 -0
- codecortex/backends/__init__.py +35 -0
- codecortex/backends/base.py +38 -0
- codecortex/backends/context.py +118 -0
- codecortex/backends/contracts.py +65 -0
- codecortex/backends/factory.py +60 -0
- codecortex/backends/graph.py +107 -0
- codecortex/backends/manager.py +303 -0
- codecortex/backends/mcp_client.py +196 -0
- codecortex/backends/pool.py +128 -0
- codecortex/backends/spec.py +83 -0
- codecortex/backends/symbols.py +189 -0
- codecortex/benchmark.py +211 -0
- codecortex/cli.py +409 -0
- codecortex/config.py +27 -0
- codecortex/context/__init__.py +13 -0
- codecortex/context/budget.py +62 -0
- codecortex/context/integrated.py +60 -0
- codecortex/context/pipeline.py +223 -0
- codecortex/core/__init__.py +1 -0
- codecortex/core/contracts.py +45 -0
- codecortex/core/errors.py +17 -0
- codecortex/core/models.py +69 -0
- codecortex/dashboard.py +259 -0
- codecortex/editing.py +41 -0
- codecortex/engines/__init__.py +5 -0
- codecortex/engines/builtin/__init__.py +5 -0
- codecortex/engines/builtin/factory.py +26 -0
- codecortex/engines/builtin/memory.py +35 -0
- codecortex/engines/builtin/repository.py +73 -0
- codecortex/engines/builtin/symbols.py +89 -0
- codecortex/engines/builtin/validation.py +54 -0
- codecortex/engines/registry.py +26 -0
- codecortex/entrypoint.py +266 -0
- codecortex/evaluation/__init__.py +55 -0
- codecortex/evaluation/external.py +265 -0
- codecortex/evaluation/production.py +670 -0
- codecortex/evaluation/regression.py +188 -0
- codecortex/gateway.py +38 -0
- codecortex/git_intelligence.py +252 -0
- codecortex/indexing/__init__.py +6 -0
- codecortex/indexing/graph.py +78 -0
- codecortex/indexing/impact.py +127 -0
- codecortex/indexing/incremental.py +163 -0
- codecortex/indexing/incremental_graph.py +189 -0
- codecortex/indexing/indexer.py +172 -0
- codecortex/indexing/relationships.py +179 -0
- codecortex/indexing/resolution.py +88 -0
- codecortex/integrations/__init__.py +5 -0
- codecortex/integrations/agents.py +235 -0
- codecortex/interfaces/__init__.py +1 -0
- codecortex/interfaces/mcp_bridge.py +66 -0
- codecortex/languages/__init__.py +5 -0
- codecortex/languages/native.py +166 -0
- codecortex/languages/registry.py +232 -0
- codecortex/mcp/__init__.py +5 -0
- codecortex/mcp/extended.py +114 -0
- codecortex/mcp/server.py +473 -0
- codecortex/memory/__init__.py +11 -0
- codecortex/memory/json_store.py +52 -0
- codecortex/memory/knowledge.py +193 -0
- codecortex/memory/team_store.py +193 -0
- codecortex/orchestrator.py +153 -0
- codecortex/pr_intelligence.py +214 -0
- codecortex/retrieval/__init__.py +16 -0
- codecortex/retrieval/hybrid.py +67 -0
- codecortex/retrieval/index.py +135 -0
- codecortex/retrieval/providers.py +67 -0
- codecortex/retrieval/repository.py +94 -0
- codecortex/router/__init__.py +5 -0
- codecortex/router/router.py +79 -0
- codecortex/runtime.py +69 -0
- codecortex/setup.py +100 -0
- codecortex/symbols/__init__.py +5 -0
- codecortex/symbols/providers.py +192 -0
- codecortex/telemetry/__init__.py +5 -0
- codecortex/telemetry/collector.py +43 -0
- codecortex/tracing/__init__.py +9 -0
- codecortex/tracing/task_trace.py +235 -0
- codecortex/workspace/__init__.py +9 -0
- codecortex/workspace/federation.py +173 -0
- codecortex_context_engine-0.1.0a1.dist-info/METADATA +381 -0
- codecortex_context_engine-0.1.0a1.dist-info/RECORD +90 -0
- codecortex_context_engine-0.1.0a1.dist-info/WHEEL +4 -0
- codecortex_context_engine-0.1.0a1.dist-info/entry_points.txt +3 -0
- codecortex_context_engine-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- codecortex_context_engine-0.1.0a1.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Configurable repository graph backend adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from codecortex.backends.base import ManagedAdapterMixin
|
|
12
|
+
from codecortex.backends.manager import BackendManager
|
|
13
|
+
from codecortex.backends.spec import BACKENDS
|
|
14
|
+
from codecortex.core.contracts import Engine
|
|
15
|
+
from codecortex.core.models import AgentRequest, Capability, ContextChunk, EngineResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GraphBackendAdapter(ManagedAdapterMixin, Engine):
|
|
19
|
+
capability = Capability.REPOSITORY
|
|
20
|
+
|
|
21
|
+
def __init__(self, project_root: Path, manager: BackendManager | None = None) -> None:
|
|
22
|
+
self.project_root = project_root.resolve()
|
|
23
|
+
self.manager = manager or BackendManager()
|
|
24
|
+
self.spec = BACKENDS["graph"]
|
|
25
|
+
|
|
26
|
+
async def health(self) -> bool:
|
|
27
|
+
return await asyncio.to_thread(self.manager.probe, self.spec, False)
|
|
28
|
+
|
|
29
|
+
def _graph_path(self) -> Path:
|
|
30
|
+
configured = os.getenv(
|
|
31
|
+
"CODECORTEX_GRAPH_BACKEND_OUTPUT",
|
|
32
|
+
".codecortex/backends/graph.json",
|
|
33
|
+
)
|
|
34
|
+
candidate = (self.project_root / configured).resolve()
|
|
35
|
+
try:
|
|
36
|
+
candidate.relative_to(self.project_root)
|
|
37
|
+
except ValueError:
|
|
38
|
+
raise ValueError("graph backend output must stay inside the project root") from None
|
|
39
|
+
return candidate
|
|
40
|
+
|
|
41
|
+
def build(self) -> dict[str, Any]:
|
|
42
|
+
self.manager.run(self.spec, (".",), cwd=self.project_root)
|
|
43
|
+
graph_path = self._graph_path()
|
|
44
|
+
if not graph_path.exists():
|
|
45
|
+
raise RuntimeError("graph backend completed without its configured graph payload")
|
|
46
|
+
payload = json.loads(graph_path.read_text(encoding="utf-8"))
|
|
47
|
+
if not isinstance(payload, dict):
|
|
48
|
+
raise RuntimeError("graph backend emitted an invalid graph payload")
|
|
49
|
+
return payload
|
|
50
|
+
|
|
51
|
+
def query(self, query: str) -> str:
|
|
52
|
+
return self.manager.run(
|
|
53
|
+
self.spec,
|
|
54
|
+
("query", query),
|
|
55
|
+
cwd=self.project_root,
|
|
56
|
+
timeout_seconds=90,
|
|
57
|
+
).stdout.strip()
|
|
58
|
+
|
|
59
|
+
def explain(self, node: str) -> str:
|
|
60
|
+
return self.manager.run(
|
|
61
|
+
self.spec,
|
|
62
|
+
("explain", node),
|
|
63
|
+
cwd=self.project_root,
|
|
64
|
+
timeout_seconds=60,
|
|
65
|
+
).stdout.strip()
|
|
66
|
+
|
|
67
|
+
def path(self, source: str, target: str) -> str:
|
|
68
|
+
return self.manager.run(
|
|
69
|
+
self.spec,
|
|
70
|
+
("path", source, target),
|
|
71
|
+
cwd=self.project_root,
|
|
72
|
+
timeout_seconds=60,
|
|
73
|
+
).stdout.strip()
|
|
74
|
+
|
|
75
|
+
async def execute(self, request: AgentRequest) -> EngineResult:
|
|
76
|
+
return await asyncio.to_thread(self._execute_sync, request)
|
|
77
|
+
|
|
78
|
+
def _execute_sync(self, request: AgentRequest) -> EngineResult:
|
|
79
|
+
mode = str(request.metadata.get("graph_mode", "query"))
|
|
80
|
+
if mode == "build":
|
|
81
|
+
content = json.dumps(self.build(), ensure_ascii=False)
|
|
82
|
+
elif mode == "explain":
|
|
83
|
+
content = self.explain(request.query)
|
|
84
|
+
elif mode == "path":
|
|
85
|
+
target = str(request.metadata.get("target", "")).strip()
|
|
86
|
+
if not target:
|
|
87
|
+
raise ValueError("graph path mode requires metadata.target")
|
|
88
|
+
content = self.path(request.query, target)
|
|
89
|
+
else:
|
|
90
|
+
content = self.query(request.query)
|
|
91
|
+
tokens = max(1, len(content) // 4) if content else 0
|
|
92
|
+
return EngineResult(
|
|
93
|
+
capability=self.capability,
|
|
94
|
+
content=content,
|
|
95
|
+
chunks=[
|
|
96
|
+
ContextChunk(
|
|
97
|
+
source="repository-graph",
|
|
98
|
+
content=content,
|
|
99
|
+
tokens=tokens,
|
|
100
|
+
relevance=0.95,
|
|
101
|
+
metadata={"backend": self.spec.key, "revision": self.spec.revision},
|
|
102
|
+
)
|
|
103
|
+
]
|
|
104
|
+
if content
|
|
105
|
+
else [],
|
|
106
|
+
metadata={"backend": self.spec.key, "revision": self.spec.revision},
|
|
107
|
+
)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Isolated lifecycle management for configurable backend engines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import venv
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from codecortex.backends.spec import BackendSpec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class ProcessResult:
|
|
21
|
+
argv: tuple[str, ...]
|
|
22
|
+
returncode: int
|
|
23
|
+
stdout: str
|
|
24
|
+
stderr: str
|
|
25
|
+
duration_ms: float
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BackendProcessError(RuntimeError):
|
|
29
|
+
def __init__(self, result: ProcessResult) -> None:
|
|
30
|
+
self.result = result
|
|
31
|
+
message = result.stderr.strip() or result.stdout.strip() or "backend process failed"
|
|
32
|
+
super().__init__(f"{result.argv[0]} exited with {result.returncode}: {message[:500]}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _default_cache_root() -> Path:
|
|
36
|
+
configured = os.getenv("CODECORTEX_BACKEND_HOME")
|
|
37
|
+
if configured:
|
|
38
|
+
return Path(configured).expanduser().resolve()
|
|
39
|
+
if sys.platform == "darwin":
|
|
40
|
+
return Path.home() / "Library" / "Caches" / "codecortex" / "backends"
|
|
41
|
+
if os.name == "nt":
|
|
42
|
+
base = Path(os.getenv("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
43
|
+
return base / "CodeCortex" / "backends"
|
|
44
|
+
return Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "codecortex" / "backends"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _discover_source_root() -> Path | None:
|
|
48
|
+
configured = os.getenv("CODECORTEX_SOURCE_ROOT")
|
|
49
|
+
if configured:
|
|
50
|
+
candidate = Path(configured).expanduser().resolve()
|
|
51
|
+
return candidate if candidate.is_dir() else None
|
|
52
|
+
for candidate in Path(__file__).resolve().parents:
|
|
53
|
+
if (candidate / "pyproject.toml").is_file():
|
|
54
|
+
return candidate
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BackendManager:
|
|
59
|
+
"""Provision explicitly configured engines in isolated environments."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
cache_root: Path | None = None,
|
|
64
|
+
timeout_seconds: float = 300.0,
|
|
65
|
+
health_ttl_seconds: float = 30.0,
|
|
66
|
+
source_root: Path | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.cache_root = (cache_root or _default_cache_root()).expanduser().resolve()
|
|
69
|
+
self.timeout_seconds = timeout_seconds
|
|
70
|
+
self.health_ttl_seconds = health_ttl_seconds
|
|
71
|
+
self.source_root = source_root.expanduser().resolve() if source_root else _discover_source_root()
|
|
72
|
+
self._probe_cache: dict[tuple[str, str], tuple[float, bool]] = {}
|
|
73
|
+
|
|
74
|
+
def environment_dir(self, spec: BackendSpec) -> Path:
|
|
75
|
+
revision = spec.revision[:12] if spec.revision else "unconfigured"
|
|
76
|
+
return self.cache_root / spec.key / revision
|
|
77
|
+
|
|
78
|
+
def metadata_path(self, spec: BackendSpec) -> Path:
|
|
79
|
+
return self.environment_dir(spec) / ".codecortex-backend.json"
|
|
80
|
+
|
|
81
|
+
def python_path(self, spec: BackendSpec) -> Path:
|
|
82
|
+
env = self.environment_dir(spec)
|
|
83
|
+
return env / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
84
|
+
|
|
85
|
+
def command_path(self, spec: BackendSpec) -> Path:
|
|
86
|
+
env = self.environment_dir(spec)
|
|
87
|
+
suffix = ".exe" if os.name == "nt" else ""
|
|
88
|
+
command = spec.command or "__unconfigured__"
|
|
89
|
+
return env / ("Scripts" if os.name == "nt" else "bin") / f"{command}{suffix}"
|
|
90
|
+
|
|
91
|
+
def local_source_path(self, spec: BackendSpec) -> Path | None:
|
|
92
|
+
if not spec.configured or self.source_root is None or not spec.vendor_path:
|
|
93
|
+
return None
|
|
94
|
+
candidate = (self.source_root / spec.vendor_path).resolve()
|
|
95
|
+
try:
|
|
96
|
+
candidate.relative_to(self.source_root)
|
|
97
|
+
except ValueError:
|
|
98
|
+
raise RuntimeError(f"backend local path escapes source root: {spec.vendor_path}") from None
|
|
99
|
+
if not candidate.is_dir() or not (candidate / "pyproject.toml").is_file():
|
|
100
|
+
return None
|
|
101
|
+
revision = self._git_revision(candidate)
|
|
102
|
+
if revision is not None and revision != spec.revision:
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
f"backend revision mismatch for {spec.key}: expected {spec.revision}, found {revision}"
|
|
105
|
+
)
|
|
106
|
+
return candidate
|
|
107
|
+
|
|
108
|
+
def install_requirement(self, spec: BackendSpec) -> str:
|
|
109
|
+
if not spec.configured:
|
|
110
|
+
raise RuntimeError(f"backend {spec.key!r} is not configured")
|
|
111
|
+
local = self.local_source_path(spec)
|
|
112
|
+
if local is None:
|
|
113
|
+
return spec.source_requirement
|
|
114
|
+
if spec.extras:
|
|
115
|
+
return f"{spec.package}[{','.join(spec.extras)}] @ {local.as_uri()}"
|
|
116
|
+
return str(local)
|
|
117
|
+
|
|
118
|
+
def installation_metadata(self, spec: BackendSpec) -> dict[str, object] | None:
|
|
119
|
+
return self._load_metadata(spec)
|
|
120
|
+
|
|
121
|
+
def is_installed(self, spec: BackendSpec) -> bool:
|
|
122
|
+
if not spec.configured:
|
|
123
|
+
return False
|
|
124
|
+
metadata = self._load_metadata(spec)
|
|
125
|
+
if not metadata or metadata.get("revision") != spec.revision or not self.command_path(spec).exists():
|
|
126
|
+
return False
|
|
127
|
+
local = self.local_source_path(spec)
|
|
128
|
+
return local is None or metadata.get("source_kind") == "local"
|
|
129
|
+
|
|
130
|
+
def ensure(self, spec: BackendSpec) -> Path:
|
|
131
|
+
if not spec.configured:
|
|
132
|
+
raise RuntimeError(f"backend {spec.key!r} is not configured")
|
|
133
|
+
if self.is_installed(spec):
|
|
134
|
+
return self.command_path(spec)
|
|
135
|
+
env_dir = self.environment_dir(spec)
|
|
136
|
+
env_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
lock = env_dir.with_suffix(".lock")
|
|
138
|
+
self._acquire_lock(lock)
|
|
139
|
+
try:
|
|
140
|
+
if self.is_installed(spec):
|
|
141
|
+
return self.command_path(spec)
|
|
142
|
+
if env_dir.exists():
|
|
143
|
+
shutil.rmtree(env_dir)
|
|
144
|
+
self._create_environment(env_dir, spec)
|
|
145
|
+
local = self.local_source_path(spec)
|
|
146
|
+
self._install(spec)
|
|
147
|
+
payload = asdict(spec)
|
|
148
|
+
payload["installed_at"] = time.time()
|
|
149
|
+
payload["source_kind"] = "local" if local is not None else "remote"
|
|
150
|
+
payload["source_path"] = str(local) if local is not None else None
|
|
151
|
+
self.metadata_path(spec).write_text(
|
|
152
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
153
|
+
encoding="utf-8",
|
|
154
|
+
)
|
|
155
|
+
command = self.command_path(spec)
|
|
156
|
+
if not command.exists():
|
|
157
|
+
raise RuntimeError(f"backend installed without expected command: {command}")
|
|
158
|
+
self._probe_cache.pop((spec.key, spec.revision), None)
|
|
159
|
+
return command
|
|
160
|
+
except Exception:
|
|
161
|
+
if env_dir.exists() and not self.is_installed(spec):
|
|
162
|
+
shutil.rmtree(env_dir, ignore_errors=True)
|
|
163
|
+
raise
|
|
164
|
+
finally:
|
|
165
|
+
self._release_lock(lock)
|
|
166
|
+
|
|
167
|
+
def run(
|
|
168
|
+
self,
|
|
169
|
+
spec: BackendSpec,
|
|
170
|
+
args: Sequence[str],
|
|
171
|
+
*,
|
|
172
|
+
cwd: Path | None = None,
|
|
173
|
+
env: Mapping[str, str] | None = None,
|
|
174
|
+
timeout_seconds: float | None = None,
|
|
175
|
+
check: bool = True,
|
|
176
|
+
provision: bool = True,
|
|
177
|
+
) -> ProcessResult:
|
|
178
|
+
command = self.ensure(spec) if provision else self.command_path(spec)
|
|
179
|
+
if not command.exists():
|
|
180
|
+
raise FileNotFoundError(command)
|
|
181
|
+
argv = (str(command), *(str(item) for item in args))
|
|
182
|
+
started = time.perf_counter()
|
|
183
|
+
process = subprocess.run(
|
|
184
|
+
argv,
|
|
185
|
+
cwd=str(cwd) if cwd else None,
|
|
186
|
+
env={**os.environ, **dict(env or {})},
|
|
187
|
+
text=True,
|
|
188
|
+
capture_output=True,
|
|
189
|
+
timeout=timeout_seconds or self.timeout_seconds,
|
|
190
|
+
check=False,
|
|
191
|
+
)
|
|
192
|
+
result = ProcessResult(
|
|
193
|
+
argv,
|
|
194
|
+
process.returncode,
|
|
195
|
+
process.stdout,
|
|
196
|
+
process.stderr,
|
|
197
|
+
(time.perf_counter() - started) * 1000,
|
|
198
|
+
)
|
|
199
|
+
if check and result.returncode != 0:
|
|
200
|
+
raise BackendProcessError(result)
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
def probe(self, spec: BackendSpec, provision: bool = False, *, force: bool = False) -> bool:
|
|
204
|
+
if not spec.configured:
|
|
205
|
+
return False
|
|
206
|
+
key = (spec.key, spec.revision)
|
|
207
|
+
now = time.monotonic()
|
|
208
|
+
cached = self._probe_cache.get(key)
|
|
209
|
+
if not force and cached is not None and now - cached[0] <= self.health_ttl_seconds:
|
|
210
|
+
return cached[1]
|
|
211
|
+
try:
|
|
212
|
+
if not provision and not self.is_installed(spec):
|
|
213
|
+
healthy = False
|
|
214
|
+
else:
|
|
215
|
+
result = self.run(
|
|
216
|
+
spec,
|
|
217
|
+
("--help",),
|
|
218
|
+
timeout_seconds=30,
|
|
219
|
+
provision=provision,
|
|
220
|
+
check=False,
|
|
221
|
+
)
|
|
222
|
+
healthy = result.returncode == 0
|
|
223
|
+
except (OSError, RuntimeError, subprocess.SubprocessError):
|
|
224
|
+
healthy = False
|
|
225
|
+
self._probe_cache[key] = (now, healthy)
|
|
226
|
+
return healthy
|
|
227
|
+
|
|
228
|
+
def remove(self, spec: BackendSpec) -> None:
|
|
229
|
+
shutil.rmtree(self.environment_dir(spec), ignore_errors=True)
|
|
230
|
+
self._probe_cache.pop((spec.key, spec.revision), None)
|
|
231
|
+
|
|
232
|
+
def _create_environment(self, env_dir: Path, spec: BackendSpec) -> None:
|
|
233
|
+
uv = shutil.which("uv")
|
|
234
|
+
if uv:
|
|
235
|
+
result = subprocess.run(
|
|
236
|
+
[uv, "venv", "--python", spec.python, str(env_dir)],
|
|
237
|
+
text=True,
|
|
238
|
+
capture_output=True,
|
|
239
|
+
timeout=self.timeout_seconds,
|
|
240
|
+
check=False,
|
|
241
|
+
)
|
|
242
|
+
if result.returncode == 0:
|
|
243
|
+
return
|
|
244
|
+
venv.EnvBuilder(with_pip=True, clear=True).create(env_dir)
|
|
245
|
+
|
|
246
|
+
def _install(self, spec: BackendSpec) -> None:
|
|
247
|
+
python = self.python_path(spec)
|
|
248
|
+
requirement = self.install_requirement(spec)
|
|
249
|
+
uv = shutil.which("uv")
|
|
250
|
+
argv = (
|
|
251
|
+
[uv, "pip", "install", "--python", str(python), requirement]
|
|
252
|
+
if uv
|
|
253
|
+
else [str(python), "-m", "pip", "install", requirement]
|
|
254
|
+
)
|
|
255
|
+
process = subprocess.run(
|
|
256
|
+
argv,
|
|
257
|
+
text=True,
|
|
258
|
+
capture_output=True,
|
|
259
|
+
timeout=self.timeout_seconds,
|
|
260
|
+
check=False,
|
|
261
|
+
)
|
|
262
|
+
if process.returncode != 0:
|
|
263
|
+
raise RuntimeError(process.stderr.strip() or process.stdout.strip())
|
|
264
|
+
|
|
265
|
+
def _load_metadata(self, spec: BackendSpec) -> dict[str, object] | None:
|
|
266
|
+
try:
|
|
267
|
+
value = json.loads(self.metadata_path(spec).read_text(encoding="utf-8"))
|
|
268
|
+
except (OSError, json.JSONDecodeError):
|
|
269
|
+
return None
|
|
270
|
+
return value if isinstance(value, dict) else None
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _git_revision(path: Path) -> str | None:
|
|
274
|
+
try:
|
|
275
|
+
process = subprocess.run(
|
|
276
|
+
["git", "-C", str(path), "rev-parse", "HEAD"],
|
|
277
|
+
text=True,
|
|
278
|
+
capture_output=True,
|
|
279
|
+
timeout=10,
|
|
280
|
+
check=False,
|
|
281
|
+
)
|
|
282
|
+
except (OSError, subprocess.SubprocessError):
|
|
283
|
+
return None
|
|
284
|
+
revision = process.stdout.strip()
|
|
285
|
+
return revision if process.returncode == 0 and len(revision) == 40 else None
|
|
286
|
+
|
|
287
|
+
def _acquire_lock(self, lock: Path) -> None:
|
|
288
|
+
deadline = time.monotonic() + min(self.timeout_seconds, 120.0)
|
|
289
|
+
while True:
|
|
290
|
+
try:
|
|
291
|
+
lock.mkdir(parents=False)
|
|
292
|
+
return
|
|
293
|
+
except FileExistsError:
|
|
294
|
+
if time.monotonic() >= deadline:
|
|
295
|
+
raise TimeoutError(f"timed out waiting for backend lock: {lock}") from None
|
|
296
|
+
time.sleep(0.1)
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _release_lock(lock: Path) -> None:
|
|
300
|
+
try:
|
|
301
|
+
lock.rmdir()
|
|
302
|
+
except OSError:
|
|
303
|
+
pass
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Small synchronous MCP stdio client used by isolated backend adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from codecortex.backends.manager import BackendManager
|
|
16
|
+
from codecortex.backends.spec import BackendSpec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MCPError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MCPStdioClient:
|
|
24
|
+
"""Persistent JSON-RPC client for an MCP server using stdio transport."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
manager: BackendManager,
|
|
29
|
+
spec: BackendSpec,
|
|
30
|
+
server_args: Sequence[str],
|
|
31
|
+
*,
|
|
32
|
+
cwd: Path | None = None,
|
|
33
|
+
env: Mapping[str, str] | None = None,
|
|
34
|
+
timeout_seconds: float = 60.0,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.manager = manager
|
|
37
|
+
self.spec = spec
|
|
38
|
+
self.server_args = tuple(server_args)
|
|
39
|
+
self.cwd = cwd
|
|
40
|
+
self.env = dict(env or {})
|
|
41
|
+
self.timeout_seconds = timeout_seconds
|
|
42
|
+
self._process: subprocess.Popen[str] | None = None
|
|
43
|
+
self._messages: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
44
|
+
self._stderr: queue.Queue[str] = queue.Queue()
|
|
45
|
+
self._request_id = 0
|
|
46
|
+
self._reader: threading.Thread | None = None
|
|
47
|
+
self._stderr_reader: threading.Thread | None = None
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> MCPStdioClient:
|
|
50
|
+
self.start()
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
|
54
|
+
self.close()
|
|
55
|
+
|
|
56
|
+
def start(self) -> None:
|
|
57
|
+
if self._process is not None:
|
|
58
|
+
return
|
|
59
|
+
command = self.manager.ensure(self.spec)
|
|
60
|
+
self._process = subprocess.Popen(
|
|
61
|
+
[str(command), *self.server_args],
|
|
62
|
+
cwd=str(self.cwd) if self.cwd else None,
|
|
63
|
+
env={**os.environ, **self.env},
|
|
64
|
+
stdin=subprocess.PIPE,
|
|
65
|
+
stdout=subprocess.PIPE,
|
|
66
|
+
stderr=subprocess.PIPE,
|
|
67
|
+
text=True,
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
bufsize=1,
|
|
70
|
+
)
|
|
71
|
+
self._reader = threading.Thread(target=self._read_stdout, daemon=True)
|
|
72
|
+
self._stderr_reader = threading.Thread(target=self._read_stderr, daemon=True)
|
|
73
|
+
self._reader.start()
|
|
74
|
+
self._stderr_reader.start()
|
|
75
|
+
self._initialize()
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
process = self._process
|
|
79
|
+
self._process = None
|
|
80
|
+
if process is None:
|
|
81
|
+
return
|
|
82
|
+
if process.poll() is None:
|
|
83
|
+
process.terminate()
|
|
84
|
+
try:
|
|
85
|
+
process.wait(timeout=3)
|
|
86
|
+
except subprocess.TimeoutExpired:
|
|
87
|
+
process.kill()
|
|
88
|
+
process.wait(timeout=3)
|
|
89
|
+
|
|
90
|
+
def tools(self) -> list[dict[str, Any]]:
|
|
91
|
+
result = self.request("tools/list", {})
|
|
92
|
+
tools = result.get("tools", []) if isinstance(result, dict) else []
|
|
93
|
+
return [item for item in tools if isinstance(item, dict)]
|
|
94
|
+
|
|
95
|
+
def call_tool(self, name: str, arguments: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
96
|
+
result = self.request("tools/call", {"name": name, "arguments": dict(arguments or {})})
|
|
97
|
+
if not isinstance(result, dict):
|
|
98
|
+
raise MCPError(f"tool {name!r} returned a non-object result")
|
|
99
|
+
if result.get("isError"):
|
|
100
|
+
raise MCPError(self._content_text(result) or f"tool {name!r} failed")
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
def request(self, method: str, params: Mapping[str, Any]) -> Any:
|
|
104
|
+
self._request_id += 1
|
|
105
|
+
request_id = self._request_id
|
|
106
|
+
self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": dict(params)})
|
|
107
|
+
deadline = time.monotonic() + self.timeout_seconds
|
|
108
|
+
deferred: list[dict[str, Any]] = []
|
|
109
|
+
try:
|
|
110
|
+
while time.monotonic() < deadline:
|
|
111
|
+
self._raise_if_exited()
|
|
112
|
+
try:
|
|
113
|
+
message = self._messages.get(timeout=min(0.25, max(0.01, deadline - time.monotonic())))
|
|
114
|
+
except queue.Empty:
|
|
115
|
+
continue
|
|
116
|
+
if message.get("id") != request_id:
|
|
117
|
+
deferred.append(message)
|
|
118
|
+
continue
|
|
119
|
+
if "error" in message:
|
|
120
|
+
raise MCPError(str(message["error"]))
|
|
121
|
+
return message.get("result")
|
|
122
|
+
raise TimeoutError(f"MCP request timed out: {method}")
|
|
123
|
+
finally:
|
|
124
|
+
for message in deferred:
|
|
125
|
+
self._messages.put(message)
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def content_text(result: Mapping[str, Any]) -> str:
|
|
129
|
+
return MCPStdioClient._content_text(result)
|
|
130
|
+
|
|
131
|
+
def _initialize(self) -> None:
|
|
132
|
+
protocol = os.getenv("CODECORTEX_MCP_PROTOCOL", "2025-06-18")
|
|
133
|
+
self.request(
|
|
134
|
+
"initialize",
|
|
135
|
+
{
|
|
136
|
+
"protocolVersion": protocol,
|
|
137
|
+
"capabilities": {},
|
|
138
|
+
"clientInfo": {"name": "CodeCortex", "version": "0.1.0"},
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
self._send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
|
|
142
|
+
|
|
143
|
+
def _send(self, payload: Mapping[str, Any]) -> None:
|
|
144
|
+
process = self._process
|
|
145
|
+
if process is None or process.stdin is None:
|
|
146
|
+
raise MCPError("MCP process is not running")
|
|
147
|
+
process.stdin.write(json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + "\n")
|
|
148
|
+
process.stdin.flush()
|
|
149
|
+
|
|
150
|
+
def _read_stdout(self) -> None:
|
|
151
|
+
process = self._process
|
|
152
|
+
if process is None or process.stdout is None:
|
|
153
|
+
return
|
|
154
|
+
for raw in process.stdout:
|
|
155
|
+
line = raw.strip()
|
|
156
|
+
if not line:
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
message = json.loads(line)
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
continue
|
|
162
|
+
if isinstance(message, dict):
|
|
163
|
+
self._messages.put(message)
|
|
164
|
+
|
|
165
|
+
def _read_stderr(self) -> None:
|
|
166
|
+
process = self._process
|
|
167
|
+
if process is None or process.stderr is None:
|
|
168
|
+
return
|
|
169
|
+
for raw in process.stderr:
|
|
170
|
+
if raw.strip():
|
|
171
|
+
self._stderr.put(raw.rstrip())
|
|
172
|
+
|
|
173
|
+
def _raise_if_exited(self) -> None:
|
|
174
|
+
process = self._process
|
|
175
|
+
if process is None:
|
|
176
|
+
raise MCPError("MCP process is not running")
|
|
177
|
+
code = process.poll()
|
|
178
|
+
if code is None:
|
|
179
|
+
return
|
|
180
|
+
lines: list[str] = []
|
|
181
|
+
while not self._stderr.empty() and len(lines) < 20:
|
|
182
|
+
lines.append(self._stderr.get_nowait())
|
|
183
|
+
detail = "\n".join(lines)
|
|
184
|
+
raise MCPError(f"MCP server exited with {code}: {detail}".strip())
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _content_text(result: Mapping[str, Any]) -> str:
|
|
188
|
+
chunks = result.get("content", [])
|
|
189
|
+
texts: list[str] = []
|
|
190
|
+
if isinstance(chunks, list):
|
|
191
|
+
for chunk in chunks:
|
|
192
|
+
if isinstance(chunk, dict) and chunk.get("type") == "text":
|
|
193
|
+
text = chunk.get("text")
|
|
194
|
+
if isinstance(text, str):
|
|
195
|
+
texts.append(text)
|
|
196
|
+
return "\n".join(texts)
|