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,405 @@
1
+ """Transport-neutral, proposal-only entry point for bounded delegations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import sys
8
+ import time
9
+ from collections import OrderedDict
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+ from threading import Event, RLock
13
+ from typing import Any, Callable, Mapping
14
+
15
+ from .atomizer import TaskBudget, preflight
16
+ from .controller import Controller, ModelClient, mediated_apply
17
+ from .controller._post_apply import apply_failure
18
+ from .ollama_adapter import ModelProfile, build_client
19
+ from .profiles import get_profile
20
+ from .stats import append_stats, default_stats_path
21
+ from .task import TaskEnvelope
22
+
23
+
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DelegationRequest:
28
+ """A host-approved request that is independent of any transport schema."""
29
+
30
+ request_id: str
31
+ workspace_ref: str
32
+ model_profile: str
33
+ task: TaskEnvelope
34
+
35
+ def __post_init__(self) -> None:
36
+ for name, value in (
37
+ ("request_id", self.request_id),
38
+ ("workspace_ref", self.workspace_ref),
39
+ ("model_profile", self.model_profile),
40
+ ):
41
+ if not isinstance(value, str) or not value.strip():
42
+ raise ValueError(f"delegation field '{name}' must be a non-empty string")
43
+ if not isinstance(self.task, TaskEnvelope):
44
+ raise ValueError("delegation field 'task' must be a TaskEnvelope")
45
+
46
+ @classmethod
47
+ def from_mapping(cls, value: Mapping[str, Any]) -> "DelegationRequest":
48
+ if not isinstance(value, Mapping):
49
+ raise ValueError("delegation request must be an object")
50
+ task = value.get("task")
51
+ if not isinstance(task, Mapping):
52
+ raise ValueError("delegation field 'task' must be an object")
53
+ return cls(
54
+ request_id=value.get("request_id"),
55
+ workspace_ref=value.get("workspace_ref"),
56
+ model_profile=value.get("model_profile"),
57
+ task=TaskEnvelope.from_mapping(task),
58
+ )
59
+
60
+
61
+ @dataclass
62
+ class _CachedResult:
63
+ fingerprint: str
64
+ completed: Event
65
+ result: dict[str, Any] | None = None
66
+ request: DelegationRequest | None = None
67
+
68
+
69
+ class DelegationService:
70
+ """Direct adapter that resolves only host-registered workspaces and profiles.
71
+
72
+ Direct ``delegate`` remains proposal-only and does not accept an apply flag
73
+ or arbitrary paths. The separate mediated ``apply`` method is host-facing,
74
+ confirmation-bound and still keeps policy/result ownership in the controller
75
+ and service boundary.
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ workspaces: Mapping[str, str | Path],
81
+ *,
82
+ model_factory: Callable[[ModelProfile], ModelClient] = build_client,
83
+ max_turns: int = 4,
84
+ max_cached_results: int = 256,
85
+ preflight_budget: TaskBudget = TaskBudget(),
86
+ ) -> None:
87
+ if max_turns <= 0:
88
+ raise ValueError("max_turns must be positive")
89
+ if max_cached_results <= 0:
90
+ raise ValueError("max_cached_results must be positive")
91
+ if not isinstance(preflight_budget, TaskBudget):
92
+ raise ValueError("preflight_budget must be a TaskBudget")
93
+ registered: dict[str, Path] = {}
94
+ for reference, raw_path in workspaces.items():
95
+ if not isinstance(reference, str) or not reference.strip():
96
+ raise ValueError("workspace references must be non-empty strings")
97
+ path = Path(raw_path).resolve()
98
+ if not path.is_dir():
99
+ raise ValueError(f"registered workspace {reference!r} is not a directory: {path}")
100
+ registered[reference] = path
101
+ self._workspaces = registered
102
+ self._apply_locks = {reference: RLock() for reference in registered}
103
+ self._model_factory = model_factory
104
+ self._max_turns = max_turns
105
+ self._max_cached_results = max_cached_results
106
+ self._preflight_budget = preflight_budget
107
+ self._cache: OrderedDict[tuple[str, str, str], _CachedResult] = OrderedDict()
108
+ self._cache_lock = RLock()
109
+
110
+ def delegate(
111
+ self,
112
+ caller_id: str,
113
+ request: DelegationRequest,
114
+ *,
115
+ cancel_event: Event | None = None,
116
+ completion_event: Event | None = None,
117
+ ) -> dict[str, Any]:
118
+ """Run one proposal-only delegation with caller-scoped idempotency."""
119
+
120
+ controller_started = Event()
121
+ try:
122
+ if not isinstance(caller_id, str) or not caller_id.strip():
123
+ return self._policy_failure("invalid_caller", "caller_id must be a non-empty string")
124
+ if not isinstance(request, DelegationRequest):
125
+ return self._policy_failure("invalid_request", "request must be a DelegationRequest")
126
+
127
+ cache_key = (caller_id, request.workspace_ref, request.request_id)
128
+ fingerprint = self._fingerprint(request)
129
+ with self._cache_lock:
130
+ cached = self._cache.get(cache_key)
131
+ if cached is None:
132
+ if len(self._cache) >= self._max_cached_results:
133
+ for stale_key, stale_record in self._cache.items():
134
+ if stale_record.completed.is_set():
135
+ self._cache.pop(stale_key)
136
+ break
137
+ else:
138
+ return self._policy_failure(
139
+ "idempotency_capacity",
140
+ "in-memory idempotency capacity is exhausted by active requests",
141
+ )
142
+ cached = _CachedResult(fingerprint=fingerprint, completed=Event(), request=request)
143
+ self._cache[cache_key] = cached
144
+ owner = True
145
+ elif cached.fingerprint != fingerprint:
146
+ return self._policy_failure(
147
+ "idempotency_conflict",
148
+ "request_id was already used with a different request payload",
149
+ )
150
+ else:
151
+ self._cache.move_to_end(cache_key)
152
+ owner = False
153
+
154
+ if not owner:
155
+ cached.completed.wait()
156
+ assert cached.result is not None
157
+ return copy.deepcopy(cached.result)
158
+
159
+ result: dict[str, Any] | None = None
160
+ started_ns = time.monotonic_ns()
161
+ try:
162
+ try:
163
+ result = self._execute(
164
+ request,
165
+ cancel_event=cancel_event,
166
+ completion_event=completion_event,
167
+ controller_started=controller_started,
168
+ )
169
+ except Exception as error:
170
+ import traceback
171
+ traceback.print_exc(file=sys.stderr)
172
+ result = self._policy_failure("controller_error", f"controller execution failed: {error}")
173
+ finally:
174
+ if result is None:
175
+ result = self._policy_failure("interrupted", "controller execution interrupted")
176
+ normalized = self._normalize_result(result)
177
+ append_stats(
178
+ default_stats_path(),
179
+ normalized,
180
+ model=request.model_profile,
181
+ latency_ns=time.monotonic_ns() - started_ns,
182
+ )
183
+ with self._cache_lock:
184
+ cached.result = copy.deepcopy(normalized)
185
+ cached.completed.set()
186
+ self._evict_completed_results()
187
+ self._mirror_desktop_task(request, normalized)
188
+ return copy.deepcopy(normalized)
189
+ finally:
190
+ if completion_event is not None and not controller_started.is_set():
191
+ completion_event.set()
192
+
193
+ def _mirror_desktop_task(self, request: DelegationRequest, result: Mapping[str, Any]) -> None:
194
+ """Surface a completed delegation in the desktop task panel.
195
+
196
+ The desktop harness polls ``<workspace>/.local_agent_tasks.json``; MCP /
197
+ stdio / CLI delegations run in a separate process and would otherwise be
198
+ invisible there. This upstreams the same shared-file journal pattern as
199
+ :func:`append_stats` so every delegation shows up in one place.
200
+
201
+ # ponytail: best-effort; never raises. Cross-process writers have no
202
+ lock, but a lost update on a dashboard record is not worth a lock file.
203
+ """
204
+ workspace = self._workspaces.get(request.workspace_ref)
205
+ if workspace is None:
206
+ return
207
+ path = Path(workspace) / ".local_agent_tasks.json"
208
+ try:
209
+ tasks = []
210
+ if path.exists():
211
+ try:
212
+ data = json.loads(path.read_text(encoding="utf-8"))
213
+ if isinstance(data, list):
214
+ tasks = data
215
+ except Exception:
216
+ tasks = []
217
+ status = "accepted" if result.get("status") in ("accepted", "candidate") else "failed"
218
+ error = result.get("error") or ""
219
+ if isinstance(error, dict):
220
+ error = error.get("message", "")
221
+ record = {
222
+ "id": request.request_id,
223
+ "goal": request.task.goal,
224
+ "files": list(request.task.files),
225
+ "checks": list(request.task.checks),
226
+ "profile": request.model_profile,
227
+ "status": status,
228
+ "created_at": time.time(),
229
+ "started_at": time.time(),
230
+ "finished_at": time.time(),
231
+ "summary": str(result.get("summary") or ""),
232
+ "patch": str(result.get("patch") or ""),
233
+ "checks_results": result.get("checks") if isinstance(result.get("checks"), list) else [],
234
+ "error": str(error),
235
+ }
236
+ tasks = [t for t in tasks if t.get("id") != record["id"]]
237
+ tasks.insert(0, record)
238
+ path.write_text(json.dumps(tasks[:100], indent=2, ensure_ascii=False), encoding="utf-8")
239
+ except Exception:
240
+ pass
241
+
242
+ def _execute(
243
+ self,
244
+ request: DelegationRequest,
245
+ *,
246
+ cancel_event: Event | None = None,
247
+ completion_event: Event | None = None,
248
+ controller_started: Event | None = None,
249
+ ) -> dict[str, Any]:
250
+ workspace = self._workspaces.get(request.workspace_ref)
251
+ if workspace is None:
252
+ return self._policy_failure(
253
+ "unknown_workspace",
254
+ f"workspace_ref is not registered: {request.workspace_ref!r}",
255
+ )
256
+ report = preflight(request.task, self._preflight_budget)
257
+ if not report.accepted:
258
+ return self._policy_failure("preflight_rejected", report.reason or "preflight_rejected")
259
+ try:
260
+ profile = get_profile(request.model_profile)
261
+ except ValueError:
262
+ return self._policy_failure(
263
+ "unknown_model_profile",
264
+ f"model_profile is not allowlisted: {request.model_profile!r}",
265
+ )
266
+ model = self._model_factory(profile)
267
+ # apply is intentionally absent: direct delegation always remains a proposal.
268
+ if controller_started is not None:
269
+ controller_started.set()
270
+ return Controller(
271
+ model,
272
+ workspace,
273
+ max_turns=self._max_turns,
274
+ max_files=self._preflight_budget.max_files,
275
+ preflight_budget=self._preflight_budget,
276
+ system_contract=profile.system_contract,
277
+ ).run(
278
+ request.task,
279
+ cancel_event=cancel_event,
280
+ completion_event=completion_event,
281
+ )
282
+
283
+ def apply(
284
+ self,
285
+ caller_id: str,
286
+ workspace_ref: str,
287
+ request_id: str,
288
+ *,
289
+ cancel_event: Event | None = None,
290
+ ) -> dict[str, Any]:
291
+ """Apply a previously stored terminal proposal to its workspace.
292
+
293
+ Mediated-apply: revalidates the stored patch against the current
294
+ workspace, applies it, runs the task's allowlisted checks, and rolls
295
+ back on any failure. Only a stored terminal proposal whose status is
296
+ ``accepted`` and that carries a resolved ``patch`` is applied; the local
297
+ model is never invoked again.
298
+ """
299
+
300
+ audit: list[dict[str, Any]] = [{"event": "apply_requested", "request_id": request_id}]
301
+ if not isinstance(caller_id, str) or not caller_id.strip():
302
+ return self._policy_failure("invalid_caller", "caller_id must be a non-empty string")
303
+ cache_key = (caller_id, workspace_ref, request_id)
304
+ with self._cache_lock:
305
+ record = self._cache.get(cache_key)
306
+ if record is None:
307
+ return apply_failure("unknown_proposal", "proposal is unknown for this caller/workspace", audit)
308
+ assert record.request is not None
309
+ request = record.request
310
+ record.completed.wait()
311
+ proposal = record.result
312
+ if proposal is None:
313
+ return apply_failure("unknown_proposal", "proposal has no stored result", audit)
314
+ if proposal.get("status") != "accepted":
315
+ return apply_failure("proposal_not_accepted", "only an accepted proposal may be applied", audit)
316
+ patch = proposal.get("patch")
317
+ if not isinstance(patch, str) or not patch.strip():
318
+ return apply_failure("no_patch", "proposal carries no resolved patch", audit)
319
+ if not request.task.checks:
320
+ return apply_failure(
321
+ "apply_requires_checks",
322
+ "applying a non-empty patch requires at least one targeted check",
323
+ audit,
324
+ )
325
+
326
+ workspace = self._workspaces.get(request.workspace_ref)
327
+ if workspace is None:
328
+ return apply_failure("unknown_workspace", "workspace_ref is not registered", audit)
329
+
330
+ workspace_lock = self._apply_locks.get(request.workspace_ref)
331
+ if workspace_lock is None:
332
+ return apply_failure("unknown_workspace", "workspace_ref is not registered", audit)
333
+ with workspace_lock:
334
+ return self._apply_to_workspace(request, proposal, workspace, patch, cancel_event, audit)
335
+
336
+ def _apply_to_workspace(
337
+ self,
338
+ request: DelegationRequest,
339
+ proposal: Mapping[str, Any],
340
+ workspace: Path,
341
+ patch: str,
342
+ cancel_event: Event | None,
343
+ audit: list[dict[str, Any]],
344
+ ) -> dict[str, Any]:
345
+ """Delegate the mutation pipeline to the shared mediated-apply seam."""
346
+
347
+ result = mediated_apply(workspace, patch, request.task, cancel_event=cancel_event, audit=audit)
348
+ if result.get("applied") is True:
349
+ result["summary"] = proposal.get("summary", "")
350
+ result["patch"] = patch
351
+ return result
352
+
353
+
354
+ def proposal_preview(
355
+ self,
356
+ caller_id: str,
357
+ workspace_ref: str,
358
+ request_id: str,
359
+ ) -> dict[str, Any]:
360
+ """Return bounded, read-only details for an informed apply confirmation."""
361
+
362
+ cache_key = (caller_id, workspace_ref, request_id)
363
+ with self._cache_lock:
364
+ record = self._cache.get(cache_key)
365
+ if record is None or record.request is None:
366
+ return {"status": "unknown_proposal", "request_id": request_id}
367
+ if not record.completed.is_set() or record.result is None:
368
+ return {"status": "working", "request_id": request_id}
369
+ proposal = record.result
370
+ return {
371
+ "status": proposal.get("status"),
372
+ "request_id": request_id,
373
+ "workspace_ref": workspace_ref,
374
+ "summary": proposal.get("summary", ""),
375
+ "files": list(record.request.task.files),
376
+ "patch": proposal.get("patch", ""),
377
+ }
378
+
379
+ def _evict_completed_results(self) -> None:
380
+ while len(self._cache) > self._max_cached_results:
381
+ for key, record in self._cache.items():
382
+ if record.completed.is_set():
383
+ self._cache.pop(key)
384
+ break
385
+ else:
386
+ # Active reservations must keep their idempotency boundary.
387
+ return
388
+
389
+ @staticmethod
390
+ def _normalize_result(result: Mapping[str, Any]) -> dict[str, Any]:
391
+ normalized = dict(result)
392
+ normalized["applied"] = False
393
+ return normalized
394
+
395
+ @staticmethod
396
+ def _fingerprint(request: DelegationRequest) -> str:
397
+ return json.dumps(asdict(request), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
398
+
399
+ @staticmethod
400
+ def _policy_failure(kind: str, message: str) -> dict[str, Any]:
401
+ return {
402
+ "status": "failed",
403
+ "error": {"kind": kind, "message": message},
404
+ "audit": [{"event": "policy_rejected", "kind": kind}],
405
+ }