local-coding-agent 1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""MCP server exposing proposal-only ``delegate_code`` via the official SDK.
|
|
2
|
+
|
|
3
|
+
Built on ``mcp==2.0.0``, which speaks the 2026-07-28 stateless protocol
|
|
4
|
+
(per-request ``_meta``, ``server/discover``, ``resultType``) and auto-falls
|
|
5
|
+
back to the legacy ``initialize`` handshake for older clients through
|
|
6
|
+
``serve_dual_era_loop``. Policy, validation, idempotency and result ownership
|
|
7
|
+
stay in :class:`DelegationService`; this server is only a wire adapter.
|
|
8
|
+
|
|
9
|
+
With ``enable_tasks``, the server also mounts the ``io.modelcontextprotocol/tasks``
|
|
10
|
+
extension over a bounded worker pool (async lifecycle) and an ``apply_proposal``
|
|
11
|
+
tool whose confirmation is a Multi Round-Trip Request elicitation.
|
|
12
|
+
|
|
13
|
+
Single-tenant: the stdio server serves one direct client process, so every
|
|
14
|
+
request shares the fixed caller id ``"mcp-stdio"`` (idempotency + task pool
|
|
15
|
+
namespace). Multi-tenant caller scoping belongs to a remote HTTP gate.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import asyncio
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
from typing import Annotated, Any
|
|
25
|
+
|
|
26
|
+
from .service import DelegationService
|
|
27
|
+
from .worker_pool import ExecutionOverload, SharedExecutionGate
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from pydantic import BaseModel
|
|
31
|
+
except ImportError: # pragma: no cover - pydantic comes with mcp
|
|
32
|
+
BaseModel = None # type: ignore[assignment]
|
|
33
|
+
|
|
34
|
+
_CALLER_ID = "mcp-stdio"
|
|
35
|
+
_SERVER_NAME = "local-coding-agent"
|
|
36
|
+
_SERVER_VERSION = "1.1.0"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _AsyncExecutionGate:
|
|
42
|
+
"""Async adapter over the shared thread-safe runtime gate."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, runtime_gate: SharedExecutionGate) -> None:
|
|
45
|
+
self._runtime_gate = runtime_gate
|
|
46
|
+
|
|
47
|
+
async def run(self, function, *args):
|
|
48
|
+
return await asyncio.to_thread(self._runtime_gate.run, function, *args)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
from mcp.server.mcpserver import MCPServer
|
|
52
|
+
from mcp.types import CallToolResult, TextContent
|
|
53
|
+
|
|
54
|
+
_MCP_AVAILABLE = True
|
|
55
|
+
except ImportError: # pragma: no cover - dependency is optional
|
|
56
|
+
MCPServer = None # type: ignore[assignment]
|
|
57
|
+
CallToolResult = None # type: ignore[assignment]
|
|
58
|
+
TextContent = None # type: ignore[assignment]
|
|
59
|
+
_MCP_AVAILABLE = False
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
from mcp.server.elicitation import (
|
|
63
|
+
AcceptedElicitation,
|
|
64
|
+
CancelledElicitation,
|
|
65
|
+
DeclinedElicitation,
|
|
66
|
+
ElicitationResult,
|
|
67
|
+
)
|
|
68
|
+
from mcp.server.mcpserver import Context, Elicit, Resolve
|
|
69
|
+
except ImportError: # pragma: no cover - mcp is an optional dependency
|
|
70
|
+
AcceptedElicitation = None # type: ignore[assignment]
|
|
71
|
+
CancelledElicitation = None # type: ignore[assignment]
|
|
72
|
+
DeclinedElicitation = None # type: ignore[assignment]
|
|
73
|
+
ElicitationResult = None # type: ignore[assignment]
|
|
74
|
+
Context = None # type: ignore[assignment]
|
|
75
|
+
Elicit = None # type: ignore[assignment]
|
|
76
|
+
Resolve = None # type: ignore[assignment]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _require_mcp() -> None:
|
|
80
|
+
if not _MCP_AVAILABLE:
|
|
81
|
+
raise ImportError(
|
|
82
|
+
"the MCP server requires the 'mcp' package; install it with `pip install mcp==2.0.0`"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _delegate_request(request_id: Any, workspace_ref: Any, model_profile: Any, task: Any):
|
|
87
|
+
from .service import DelegationRequest
|
|
88
|
+
from .task import TaskEnvelope
|
|
89
|
+
|
|
90
|
+
return DelegationRequest(
|
|
91
|
+
request_id=request_id,
|
|
92
|
+
workspace_ref=workspace_ref,
|
|
93
|
+
model_profile=model_profile,
|
|
94
|
+
task=TaskEnvelope.from_mapping(task),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Module-level apply confirmation model and resolver so the official SDK can
|
|
99
|
+
# evaluate the ``apply_proposal`` tool's annotations via ``inspect.signature``.
|
|
100
|
+
# Defined only when pydantic/mcp are present; they are referenced solely at
|
|
101
|
+
# tool-registration time, after ``_require_mcp`` has already succeeded.
|
|
102
|
+
if BaseModel is not None:
|
|
103
|
+
class ApplyConfirmation(BaseModel):
|
|
104
|
+
confirm: bool
|
|
105
|
+
proposal_id: str
|
|
106
|
+
workspace_ref: str
|
|
107
|
+
proposal_digest: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _proposal_digest(preview: dict[str, Any]) -> str:
|
|
111
|
+
canonical = {
|
|
112
|
+
"request_id": preview.get("request_id"),
|
|
113
|
+
"workspace_ref": preview.get("workspace_ref"),
|
|
114
|
+
"status": preview.get("status"),
|
|
115
|
+
"summary": preview.get("summary", ""),
|
|
116
|
+
"files": preview.get("files", []),
|
|
117
|
+
"patch": preview.get("patch", ""),
|
|
118
|
+
}
|
|
119
|
+
encoded = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
120
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _require_apply_preview(preview: Any, request_id: str, workspace_ref: str) -> dict[str, Any]:
|
|
124
|
+
if not isinstance(preview, dict):
|
|
125
|
+
raise ValueError("proposal preview is unavailable")
|
|
126
|
+
if preview.get("status") != "accepted":
|
|
127
|
+
raise ValueError("proposal preview is not an accepted proposal")
|
|
128
|
+
if preview.get("request_id") != request_id or preview.get("workspace_ref") != workspace_ref:
|
|
129
|
+
raise ValueError("proposal preview identity does not match the apply request")
|
|
130
|
+
if not isinstance(preview.get("files"), list):
|
|
131
|
+
raise ValueError("proposal preview files are unavailable")
|
|
132
|
+
if not isinstance(preview.get("patch"), str) or not preview["patch"].strip():
|
|
133
|
+
raise ValueError("proposal preview does not contain a patch")
|
|
134
|
+
return preview
|
|
135
|
+
|
|
136
|
+
def _resolve_apply(ctx: Context) -> Elicit[ApplyConfirmation]:
|
|
137
|
+
arguments: dict[str, Any] = {}
|
|
138
|
+
input_params = getattr(ctx, "_input_params", None)
|
|
139
|
+
raw_arguments = getattr(input_params, "arguments", None)
|
|
140
|
+
if isinstance(raw_arguments, dict):
|
|
141
|
+
arguments = raw_arguments
|
|
142
|
+
if not arguments:
|
|
143
|
+
request_context = getattr(ctx, "request_context", None)
|
|
144
|
+
request = getattr(request_context, "request", None)
|
|
145
|
+
raw_arguments = getattr(request, "arguments", None)
|
|
146
|
+
if isinstance(raw_arguments, dict):
|
|
147
|
+
arguments = raw_arguments
|
|
148
|
+
|
|
149
|
+
request_id = arguments.get("request_id", "unknown")
|
|
150
|
+
workspace_ref = arguments.get("workspace_ref", "unknown")
|
|
151
|
+
service = getattr(ctx.mcp_server, "_delegation_service", None)
|
|
152
|
+
if service is None:
|
|
153
|
+
service = getattr(ctx.mcp_server, "_codex_service", None)
|
|
154
|
+
preview = _require_apply_preview(
|
|
155
|
+
service.proposal_preview(_CALLER_ID, workspace_ref, request_id), request_id, workspace_ref
|
|
156
|
+
)
|
|
157
|
+
files = ", ".join(str(path) for path in preview.get("files", [])) or "(не указаны)"
|
|
158
|
+
patch = preview["patch"]
|
|
159
|
+
message = (
|
|
160
|
+
"Подтвердите применение именно этого предложения к рабочей области.\n"
|
|
161
|
+
f"proposal_id: {preview['request_id']}\n"
|
|
162
|
+
f"workspace: {preview['workspace_ref']}\n"
|
|
163
|
+
f"proposal_digest: {_proposal_digest(preview)}\n"
|
|
164
|
+
f"status: {preview['status']}\n"
|
|
165
|
+
f"summary: {preview.get('summary', '')}\n"
|
|
166
|
+
f"files: {files}\n"
|
|
167
|
+
f"diff:\n{patch}"
|
|
168
|
+
)
|
|
169
|
+
return Elicit(message, ApplyConfirmation)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_server(
|
|
173
|
+
service: DelegationService,
|
|
174
|
+
*,
|
|
175
|
+
enable_tasks: bool = False,
|
|
176
|
+
max_workers: int = 1,
|
|
177
|
+
max_queue: int = 16,
|
|
178
|
+
task_store: Any | None = None,
|
|
179
|
+
):
|
|
180
|
+
"""Build an official-SDK MCP server exposing proposal-only ``delegate_code``.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
service: The transport-neutral delegation service.
|
|
184
|
+
enable_tasks: When True, mount the Tasks extension (async lifecycle) and
|
|
185
|
+
``apply_proposal``. When False (default), keep the pure synchronous
|
|
186
|
+
proposal-only path.
|
|
187
|
+
max_workers: Active sync/task worker slots.
|
|
188
|
+
max_queue: Admission queue bound for sync/task work.
|
|
189
|
+
task_store: Optional durable TaskStore for task state persistence across restarts.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
_require_mcp()
|
|
193
|
+
runtime_gate = SharedExecutionGate(max_workers, max_queue)
|
|
194
|
+
execution_gate = _AsyncExecutionGate(runtime_gate)
|
|
195
|
+
|
|
196
|
+
extensions: list[Any] = []
|
|
197
|
+
if enable_tasks:
|
|
198
|
+
from .tasks import TasksExtension
|
|
199
|
+
from .worker_pool import BoundedWorkerPool
|
|
200
|
+
|
|
201
|
+
pool = BoundedWorkerPool(
|
|
202
|
+
service,
|
|
203
|
+
max_workers=max_workers,
|
|
204
|
+
max_queue=max_queue,
|
|
205
|
+
execution_gate=runtime_gate,
|
|
206
|
+
task_store=task_store,
|
|
207
|
+
)
|
|
208
|
+
extensions.append(TasksExtension(pool, caller_id=_CALLER_ID))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
server = MCPServer(
|
|
212
|
+
name=_SERVER_NAME,
|
|
213
|
+
version=_SERVER_VERSION,
|
|
214
|
+
instructions=(
|
|
215
|
+
"Delegates one atomic, proposal-only coding task to a local Ollama "
|
|
216
|
+
"model. Returns a controller-owned result (status, patch, checks, "
|
|
217
|
+
"risks, validation, audit); never applies changes to the workspace."
|
|
218
|
+
),
|
|
219
|
+
extensions=extensions or None,
|
|
220
|
+
)
|
|
221
|
+
setattr(server, "_delegation_service", service)
|
|
222
|
+
setattr(server, "_codex_service", service)
|
|
223
|
+
|
|
224
|
+
@server.resource(
|
|
225
|
+
"model://profile",
|
|
226
|
+
name="Model Profiles & Capabilities",
|
|
227
|
+
description="Catalog of model profiles, context limits, and verified intelligence ladder tiers",
|
|
228
|
+
mime_type="application/json",
|
|
229
|
+
)
|
|
230
|
+
async def get_model_profiles() -> str:
|
|
231
|
+
from pathlib import Path
|
|
232
|
+
from .profiles import list_profiles, get_profile
|
|
233
|
+
items = []
|
|
234
|
+
for name in list_profiles():
|
|
235
|
+
p = get_profile(name)
|
|
236
|
+
items.append({
|
|
237
|
+
"name": p.name,
|
|
238
|
+
"model": p.model,
|
|
239
|
+
"provider": getattr(p, "provider", "ollama"),
|
|
240
|
+
"num_ctx": p.num_ctx,
|
|
241
|
+
"num_predict": p.num_predict,
|
|
242
|
+
"max_context_length": p.max_context_length,
|
|
243
|
+
"policy_context_cap": p.max_context_length,
|
|
244
|
+
"model_context_limit": p.model_context_limit,
|
|
245
|
+
"think": p.think,
|
|
246
|
+
})
|
|
247
|
+
latest_bench: dict[str, Any] | None = None
|
|
248
|
+
bench_path = Path(".local-run/benchmarks/latest.json")
|
|
249
|
+
if bench_path.is_file():
|
|
250
|
+
try:
|
|
251
|
+
latest_bench = json.loads(bench_path.read_text(encoding="utf-8"))
|
|
252
|
+
except Exception:
|
|
253
|
+
pass
|
|
254
|
+
return _json({
|
|
255
|
+
"profiles": items,
|
|
256
|
+
"default_profile": "qwen3-8b-q6k",
|
|
257
|
+
"latest_benchmark": latest_bench,
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@server.tool(
|
|
262
|
+
name="delegate_code",
|
|
263
|
+
description=(
|
|
264
|
+
"Delegate one atomic, proposal-only coding task to a local Ollama "
|
|
265
|
+
"model. The result is controller-owned and never applies changes."
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
async def delegate_code(
|
|
269
|
+
request_id: str,
|
|
270
|
+
workspace_ref: str,
|
|
271
|
+
model_profile: str,
|
|
272
|
+
task: dict[str, Any],
|
|
273
|
+
) -> CallToolResult:
|
|
274
|
+
request = _delegate_request(request_id, workspace_ref, model_profile, task)
|
|
275
|
+
try:
|
|
276
|
+
result = await execution_gate.run(service.delegate, _CALLER_ID, request)
|
|
277
|
+
except ExecutionOverload:
|
|
278
|
+
result = {
|
|
279
|
+
"status": "failed",
|
|
280
|
+
"error": {"kind": "queue_overload", "message": "bounded execution queue is full"},
|
|
281
|
+
"applied": False,
|
|
282
|
+
}
|
|
283
|
+
text = _json(result)
|
|
284
|
+
return CallToolResult(
|
|
285
|
+
content=[TextContent(type="text", text=text)],
|
|
286
|
+
structured_content=result,
|
|
287
|
+
is_error=result.get("status") == "failed",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if enable_tasks:
|
|
291
|
+
_register_apply_proposal(server, service, execution_gate)
|
|
292
|
+
|
|
293
|
+
return server
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _register_apply_proposal(server, service: DelegationService, execution_gate: _AsyncExecutionGate) -> None:
|
|
297
|
+
@server.tool(
|
|
298
|
+
name="apply_proposal",
|
|
299
|
+
description=(
|
|
300
|
+
"Apply a previously accepted proposal to its workspace after explicit "
|
|
301
|
+
"confirmation. Revalidates the patch, applies it, runs allowlisted "
|
|
302
|
+
"checks and rolls back on failure."
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
async def apply_proposal(
|
|
306
|
+
request_id: str,
|
|
307
|
+
workspace_ref: str,
|
|
308
|
+
confirmation: Annotated[ElicitationResult[ApplyConfirmation], Resolve(_resolve_apply)],
|
|
309
|
+
) -> CallToolResult:
|
|
310
|
+
if isinstance(confirmation, DeclinedElicitation):
|
|
311
|
+
result = {"status": "rejected", "error": {"kind": "apply_declined", "message": "apply was declined"}}
|
|
312
|
+
elif isinstance(confirmation, CancelledElicitation):
|
|
313
|
+
result = {"status": "rejected", "error": {"kind": "apply_cancelled", "message": "apply was cancelled"}}
|
|
314
|
+
elif isinstance(confirmation, AcceptedElicitation) and confirmation.data.confirm is True:
|
|
315
|
+
try:
|
|
316
|
+
preview = _require_apply_preview(
|
|
317
|
+
service.proposal_preview(_CALLER_ID, workspace_ref, request_id), request_id, workspace_ref
|
|
318
|
+
)
|
|
319
|
+
expected_digest = _proposal_digest(preview)
|
|
320
|
+
except (AttributeError, TypeError, ValueError) as error:
|
|
321
|
+
result = {
|
|
322
|
+
"status": "failed",
|
|
323
|
+
"error": {"kind": "apply_confirmation_mismatch", "message": str(error)},
|
|
324
|
+
"applied": False,
|
|
325
|
+
}
|
|
326
|
+
else:
|
|
327
|
+
if (
|
|
328
|
+
confirmation.data.proposal_id != request_id
|
|
329
|
+
or confirmation.data.workspace_ref != workspace_ref
|
|
330
|
+
or confirmation.data.proposal_digest != expected_digest
|
|
331
|
+
):
|
|
332
|
+
result = {
|
|
333
|
+
"status": "failed",
|
|
334
|
+
"error": {
|
|
335
|
+
"kind": "apply_confirmation_mismatch",
|
|
336
|
+
"message": "confirmation does not match the current proposal preview",
|
|
337
|
+
},
|
|
338
|
+
"applied": False,
|
|
339
|
+
}
|
|
340
|
+
else:
|
|
341
|
+
try:
|
|
342
|
+
result = await execution_gate.run(service.apply, _CALLER_ID, workspace_ref, request_id)
|
|
343
|
+
except ExecutionOverload:
|
|
344
|
+
result = {
|
|
345
|
+
"status": "failed",
|
|
346
|
+
"error": {"kind": "queue_overload", "message": "bounded execution queue is full"},
|
|
347
|
+
"applied": False,
|
|
348
|
+
}
|
|
349
|
+
else:
|
|
350
|
+
result = {"status": "rejected", "error": {"kind": "apply_declined", "message": "apply was not confirmed"}}
|
|
351
|
+
text = _json(result)
|
|
352
|
+
return CallToolResult(
|
|
353
|
+
content=[TextContent(type="text", text=text)],
|
|
354
|
+
structured_content=result,
|
|
355
|
+
is_error=result.get("status") == "failed",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _json(value: Any) -> str:
|
|
360
|
+
import json
|
|
361
|
+
|
|
362
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def main(argv: list[str] | None = None) -> int:
|
|
366
|
+
parser = argparse.ArgumentParser(description="Run the proposal-only MCP stdio server")
|
|
367
|
+
parser.add_argument("--workspace-ref", default="workspace")
|
|
368
|
+
parser.add_argument("--workspace", required=True)
|
|
369
|
+
parser.add_argument("--enable-tasks", action="store_true", help="Mount the Tasks extension and apply_proposal")
|
|
370
|
+
args = parser.parse_args(argv)
|
|
371
|
+
service = DelegationService({args.workspace_ref: args.workspace})
|
|
372
|
+
build_server(service, enable_tasks=args.enable_tasks).run(transport="stdio")
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
if __name__ == "__main__":
|
|
377
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Explicit Ollama loaded-model and VRAM management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MemoryClient(Protocol):
|
|
10
|
+
def loaded_models(self) -> dict[str, Any]: ...
|
|
11
|
+
|
|
12
|
+
def unload_model(self, model: str | None = None) -> dict[str, Any]: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MemoryBudgetError(RuntimeError):
|
|
16
|
+
"""The loaded models cannot fit inside the requested VRAM budget."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MemoryOperationError(RuntimeError):
|
|
20
|
+
"""A memory operation could not be verified against a supported backend."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class LoadedModel:
|
|
25
|
+
name: str
|
|
26
|
+
size_vram: int = 0
|
|
27
|
+
size: int = 0
|
|
28
|
+
expires_at: str | None = None
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_mapping(cls, value: dict[str, Any]) -> "LoadedModel":
|
|
32
|
+
name = value.get("name") or value.get("model")
|
|
33
|
+
if not isinstance(name, str) or not name:
|
|
34
|
+
raise ValueError("loaded model has no name")
|
|
35
|
+
size_vram = value.get("size_vram", 0)
|
|
36
|
+
size = value.get("size", 0)
|
|
37
|
+
if not isinstance(size_vram, int) or size_vram < 0:
|
|
38
|
+
raise ValueError(f"invalid size_vram for model {name}")
|
|
39
|
+
if not isinstance(size, int) or size < 0:
|
|
40
|
+
raise ValueError(f"invalid size for model {name}")
|
|
41
|
+
expires_at = value.get("expires_at")
|
|
42
|
+
if expires_at is not None and not isinstance(expires_at, str):
|
|
43
|
+
raise ValueError(f"invalid expires_at for model {name}")
|
|
44
|
+
return cls(name=name, size_vram=size_vram, size=size, expires_at=expires_at)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class MemorySnapshot:
|
|
49
|
+
models: tuple[LoadedModel, ...]
|
|
50
|
+
is_supported: bool = True
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def total_vram_bytes(self) -> int:
|
|
54
|
+
return sum(model.size_vram for model in self.models)
|
|
55
|
+
|
|
56
|
+
def as_dict(self) -> dict[str, Any]:
|
|
57
|
+
return {
|
|
58
|
+
"supported": self.is_supported,
|
|
59
|
+
"total_vram_bytes": self.total_vram_bytes,
|
|
60
|
+
"models": [
|
|
61
|
+
{
|
|
62
|
+
"name": model.name,
|
|
63
|
+
"size_vram": model.size_vram,
|
|
64
|
+
"size": model.size,
|
|
65
|
+
"expires_at": model.expires_at,
|
|
66
|
+
}
|
|
67
|
+
for model in self.models
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ModelMemoryManager:
|
|
73
|
+
def __init__(self, client: MemoryClient) -> None:
|
|
74
|
+
self.client = client
|
|
75
|
+
|
|
76
|
+
def snapshot(self) -> MemorySnapshot:
|
|
77
|
+
try:
|
|
78
|
+
payload = self.client.loaded_models()
|
|
79
|
+
except Exception:
|
|
80
|
+
return MemorySnapshot(models=(), is_supported=False)
|
|
81
|
+
if not isinstance(payload, dict) or "models" not in payload:
|
|
82
|
+
return MemorySnapshot(models=(), is_supported=False)
|
|
83
|
+
raw_models = payload["models"]
|
|
84
|
+
if not isinstance(raw_models, list):
|
|
85
|
+
return MemorySnapshot(models=(), is_supported=False)
|
|
86
|
+
try:
|
|
87
|
+
models = tuple(LoadedModel.from_mapping(model) for model in raw_models)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
return MemorySnapshot(models=(), is_supported=False)
|
|
90
|
+
return MemorySnapshot(models=models)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _require_supported(snapshot: MemorySnapshot) -> MemorySnapshot:
|
|
94
|
+
if not snapshot.is_supported:
|
|
95
|
+
raise MemoryOperationError(
|
|
96
|
+
"Ollama loaded-model memory state is unavailable; operation was not verified"
|
|
97
|
+
)
|
|
98
|
+
return snapshot
|
|
99
|
+
|
|
100
|
+
def unload_model(self, model: str) -> MemorySnapshot:
|
|
101
|
+
if not isinstance(model, str) or not model.strip():
|
|
102
|
+
raise ValueError("model must be a non-empty string")
|
|
103
|
+
before = self._require_supported(self.snapshot())
|
|
104
|
+
if not any(loaded.name == model for loaded in before.models):
|
|
105
|
+
raise MemoryOperationError(f"loaded model is not present: {model}")
|
|
106
|
+
self.client.unload_model(model)
|
|
107
|
+
return self._require_supported(self.snapshot())
|
|
108
|
+
|
|
109
|
+
def unload_all(self) -> MemorySnapshot:
|
|
110
|
+
before = self._require_supported(self.snapshot())
|
|
111
|
+
for model in before.models:
|
|
112
|
+
self.client.unload_model(model.name)
|
|
113
|
+
return self._require_supported(self.snapshot())
|
|
114
|
+
|
|
115
|
+
def enforce_limit(self, max_vram_bytes: int, *, keep: tuple[str, ...] = ()) -> MemorySnapshot:
|
|
116
|
+
if max_vram_bytes < 0:
|
|
117
|
+
raise ValueError("max_vram_bytes must not be negative")
|
|
118
|
+
protected = set(keep)
|
|
119
|
+
snapshot = self._require_supported(self.snapshot())
|
|
120
|
+
if snapshot.total_vram_bytes <= max_vram_bytes:
|
|
121
|
+
return snapshot
|
|
122
|
+
protected_vram = sum(model.size_vram for model in snapshot.models if model.name in protected)
|
|
123
|
+
if protected_vram > max_vram_bytes:
|
|
124
|
+
raise MemoryBudgetError(
|
|
125
|
+
f"protected models use {protected_vram} bytes, above budget {max_vram_bytes}"
|
|
126
|
+
)
|
|
127
|
+
candidates = sorted(
|
|
128
|
+
(model for model in snapshot.models if model.name not in protected),
|
|
129
|
+
key=lambda model: (-model.size_vram, model.name),
|
|
130
|
+
)
|
|
131
|
+
for model in candidates:
|
|
132
|
+
if snapshot.total_vram_bytes <= max_vram_bytes:
|
|
133
|
+
break
|
|
134
|
+
self.client.unload_model(model.name)
|
|
135
|
+
snapshot = self._require_supported(self.snapshot())
|
|
136
|
+
if snapshot.total_vram_bytes > max_vram_bytes:
|
|
137
|
+
raise MemoryBudgetError(
|
|
138
|
+
f"loaded models still use {snapshot.total_vram_bytes} bytes, above budget {max_vram_bytes}"
|
|
139
|
+
)
|
|
140
|
+
return snapshot
|