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.
Files changed (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. 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