generic-ml-cache-core 0.2.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.
- generic_ml_cache_core/__init__.py +64 -0
- generic_ml_cache_core/adapter/__init__.py +1 -0
- generic_ml_cache_core/adapter/inbound/__init__.py +1 -0
- generic_ml_cache_core/adapter/inbound/composition.py +96 -0
- generic_ml_cache_core/adapter/out/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/api/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/api/stub_api_client_adapter.py +30 -0
- generic_ml_cache_core/adapter/out/client/__init__.py +28 -0
- generic_ml_cache_core/adapter/out/client/claude.py +214 -0
- generic_ml_cache_core/adapter/out/client/codex.py +171 -0
- generic_ml_cache_core/adapter/out/client/cursor.py +208 -0
- generic_ml_cache_core/adapter/out/client/discover.py +121 -0
- generic_ml_cache_core/adapter/out/client/isolation.py +396 -0
- generic_ml_cache_core/adapter/out/client/local_client_runner.py +54 -0
- generic_ml_cache_core/adapter/out/client/passthrough_client_runner.py +47 -0
- generic_ml_cache_core/adapter/out/client/prime_directive.py +53 -0
- generic_ml_cache_core/adapter/out/client/registry.py +34 -0
- generic_ml_cache_core/adapter/out/clock/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/clock/system_clock.py +16 -0
- generic_ml_cache_core/adapter/out/fingerprint/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/fingerprint/filesystem_file_fingerprint.py +30 -0
- generic_ml_cache_core/adapter/out/metrics/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/metrics/access_registry.py +147 -0
- generic_ml_cache_core/adapter/out/metrics/journal_metrics.py +45 -0
- generic_ml_cache_core/adapter/out/persistence/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/persistence/call_identity_serialization.py +100 -0
- generic_ml_cache_core/adapter/out/persistence/in_memory_execution_repository.py +69 -0
- generic_ml_cache_core/adapter/out/persistence/sqlite_execution_repository.py +398 -0
- generic_ml_cache_core/adapter/out/storage/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/storage/filesystem_blob_store.py +47 -0
- generic_ml_cache_core/application/__init__.py +1 -0
- generic_ml_cache_core/application/domain/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/client_status.py +17 -0
- generic_ml_cache_core/application/domain/model/execution/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/execution/artifact.py +78 -0
- generic_ml_cache_core/application/domain/model/execution/execution_failure.py +32 -0
- generic_ml_cache_core/application/domain/model/execution/execution_kind.py +26 -0
- generic_ml_cache_core/application/domain/model/execution/execution_state.py +21 -0
- generic_ml_cache_core/application/domain/model/execution/ml_execution.py +41 -0
- generic_ml_cache_core/application/domain/model/identity/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/identity/api_call_identity.py +36 -0
- generic_ml_cache_core/application/domain/model/identity/call_identity.py +25 -0
- generic_ml_cache_core/application/domain/model/identity/managed_call_identity.py +54 -0
- generic_ml_cache_core/application/domain/model/identity/passthrough_call_identity.py +35 -0
- generic_ml_cache_core/application/domain/model/model_info.py +20 -0
- generic_ml_cache_core/application/domain/model/model_listing.py +29 -0
- generic_ml_cache_core/application/domain/model/parsed_output.py +23 -0
- generic_ml_cache_core/application/domain/model/probe/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/probe/probe_report.py +26 -0
- generic_ml_cache_core/application/domain/model/probe/probe_status.py +13 -0
- generic_ml_cache_core/application/domain/model/run/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/run/cache_mode.py +21 -0
- generic_ml_cache_core/application/domain/model/run/client_run_request.py +35 -0
- generic_ml_cache_core/application/domain/model/run/client_run_result.py +65 -0
- generic_ml_cache_core/application/domain/model/run/message.py +20 -0
- generic_ml_cache_core/application/domain/model/usage/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/usage/token_usage.py +53 -0
- generic_ml_cache_core/application/domain/model/usage/usage.py +108 -0
- generic_ml_cache_core/application/domain/service/__init__.py +1 -0
- generic_ml_cache_core/application/domain/service/cacheability.py +19 -0
- generic_ml_cache_core/application/domain/service/message_fingerprinting.py +25 -0
- generic_ml_cache_core/application/port/__init__.py +1 -0
- generic_ml_cache_core/application/port/inbound/__init__.py +1 -0
- generic_ml_cache_core/application/port/inbound/probe_command.py +35 -0
- generic_ml_cache_core/application/port/inbound/probe_use_case.py +19 -0
- generic_ml_cache_core/application/port/inbound/run_api_execution_command.py +40 -0
- generic_ml_cache_core/application/port/inbound/run_api_execution_use_case.py +20 -0
- generic_ml_cache_core/application/port/inbound/run_managed_local_execution_command.py +48 -0
- generic_ml_cache_core/application/port/inbound/run_managed_local_execution_use_case.py +25 -0
- generic_ml_cache_core/application/port/inbound/run_passthrough_execution_command.py +35 -0
- generic_ml_cache_core/application/port/inbound/run_passthrough_execution_use_case.py +20 -0
- generic_ml_cache_core/application/port/out/__init__.py +1 -0
- generic_ml_cache_core/application/port/out/api_client_port.py +26 -0
- generic_ml_cache_core/application/port/out/base.py +272 -0
- generic_ml_cache_core/application/port/out/blob_store_port.py +37 -0
- generic_ml_cache_core/application/port/out/client_runner_port.py +26 -0
- generic_ml_cache_core/application/port/out/clock_port.py +22 -0
- generic_ml_cache_core/application/port/out/execution_repository_port.py +40 -0
- generic_ml_cache_core/application/port/out/file_fingerprint_port.py +25 -0
- generic_ml_cache_core/application/port/out/metrics_port.py +54 -0
- generic_ml_cache_core/application/port/out/passthrough_runner_port.py +25 -0
- generic_ml_cache_core/application/usecase/__init__.py +1 -0
- generic_ml_cache_core/application/usecase/cached_ml_execution_service.py +198 -0
- generic_ml_cache_core/application/usecase/call_identity_building.py +60 -0
- generic_ml_cache_core/application/usecase/journal_events.py +19 -0
- generic_ml_cache_core/application/usecase/probe_service.py +44 -0
- generic_ml_cache_core/application/usecase/run_api_execution_service.py +69 -0
- generic_ml_cache_core/application/usecase/run_managed_local_execution_service.py +84 -0
- generic_ml_cache_core/application/usecase/run_passthrough_execution_service.py +67 -0
- generic_ml_cache_core/common/__init__.py +1 -0
- generic_ml_cache_core/common/checksum.py +82 -0
- generic_ml_cache_core/common/errors.py +76 -0
- generic_ml_cache_core/stream.py +65 -0
- generic_ml_cache_core-0.2.0.dist-info/METADATA +104 -0
- generic_ml_cache_core-0.2.0.dist-info/RECORD +99 -0
- generic_ml_cache_core-0.2.0.dist-info/WHEEL +4 -0
- generic_ml_cache_core-0.2.0.dist-info/licenses/LICENSE +201 -0
- generic_ml_cache_core-0.2.0.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Run a client in an isolated folder and capture exactly what it produced.
|
|
4
|
+
|
|
5
|
+
Isolation is correctness, not just hygiene: only by running the client in a
|
|
6
|
+
folder of our own can we attribute created/modified files to *the run* rather
|
|
7
|
+
than to whatever the user already had lying around. Before/after diffing in a
|
|
8
|
+
shared folder would be unsound.
|
|
9
|
+
|
|
10
|
+
Flow:
|
|
11
|
+
1. make a fresh temp folder
|
|
12
|
+
2. adapter.prepare(...) writes the client's input files there
|
|
13
|
+
3. snapshot the folder <-- baseline includes step-2 files, so they are
|
|
14
|
+
NOT mistaken for client output
|
|
15
|
+
4. launch the client (cwd = the folder)
|
|
16
|
+
5. snapshot again; diff against the baseline -> captured files
|
|
17
|
+
6. delete the folder
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import os
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import threading
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Callable, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
from generic_ml_cache_core.application.domain.model.run.client_run_result import (
|
|
33
|
+
ClientRunResult,
|
|
34
|
+
GeneratedFile,
|
|
35
|
+
)
|
|
36
|
+
from generic_ml_cache_core.application.domain.model.usage.token_usage import TokenUsage
|
|
37
|
+
from generic_ml_cache_core.application.port.out.base import ClientAdapter
|
|
38
|
+
from generic_ml_cache_core.common.errors import CommandLineTooLong, RunInterrupted
|
|
39
|
+
from generic_ml_cache_core.adapter.out.client.prime_directive import build_system_prompt
|
|
40
|
+
from generic_ml_cache_core.stream import StreamWriter
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _snapshot(root: Path) -> Dict[str, str]:
|
|
44
|
+
"""Map each file's POSIX-relative path -> sha256 of its bytes."""
|
|
45
|
+
snap: Dict[str, str] = {}
|
|
46
|
+
for path in root.rglob("*"):
|
|
47
|
+
if path.is_file() and not path.is_symlink():
|
|
48
|
+
rel = path.relative_to(root).as_posix()
|
|
49
|
+
snap[rel] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
50
|
+
return snap
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _capture_changes(root: Path, baseline: Dict[str, str]) -> List[GeneratedFile]:
|
|
54
|
+
"""Return files that were created or modified, sorted by path.
|
|
55
|
+
|
|
56
|
+
Deletions are intentionally ignored (the client started in an effectively
|
|
57
|
+
empty folder, so there is nothing meaningful to delete).
|
|
58
|
+
"""
|
|
59
|
+
after = _snapshot(root)
|
|
60
|
+
captured: List[GeneratedFile] = []
|
|
61
|
+
for rel in sorted(after):
|
|
62
|
+
if baseline.get(rel) != after[rel]:
|
|
63
|
+
captured.append(GeneratedFile(name=rel, content=(root / rel).read_bytes()))
|
|
64
|
+
return captured
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _terminate_group(proc: subprocess.Popen) -> None:
|
|
68
|
+
"""Tear down the client and everything it spawned. The client runs in its own
|
|
69
|
+
process group / session, so one signal to the group reaches grandchildren too --
|
|
70
|
+
no orphans left behind. Best-effort: a race where the child already exited is
|
|
71
|
+
fine (that is exactly what we wanted)."""
|
|
72
|
+
if proc.poll() is not None:
|
|
73
|
+
return
|
|
74
|
+
try:
|
|
75
|
+
if os.name == "posix":
|
|
76
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
77
|
+
else: # Windows has no killpg; terminate the child process directly
|
|
78
|
+
proc.terminate()
|
|
79
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _command_line_limit() -> tuple[str, int, str]:
|
|
84
|
+
"""The binding command-line size limit for this OS, as (scope, bytes, label).
|
|
85
|
+
|
|
86
|
+
``scope`` is ``"arg"`` when the limit is on a single argument (Linux's
|
|
87
|
+
``MAX_ARG_STRLEN``) or ``"total"`` when it is on the whole argument area
|
|
88
|
+
(Windows' ``CreateProcess`` cap; other POSIX ``ARG_MAX``). These are the real
|
|
89
|
+
OS limits, so a call measured at or above one would fail at launch anyway --
|
|
90
|
+
catching it here just makes the failure legible.
|
|
91
|
+
"""
|
|
92
|
+
if os.name == "nt":
|
|
93
|
+
return ("total", 32_767, "the Windows command-line limit")
|
|
94
|
+
if sys.platform.startswith("linux"):
|
|
95
|
+
return ("arg", 128 * 1024, "the Linux per-argument limit (MAX_ARG_STRLEN)")
|
|
96
|
+
try:
|
|
97
|
+
arg_max = int(os.sysconf("SC_ARG_MAX"))
|
|
98
|
+
except (ValueError, OSError):
|
|
99
|
+
arg_max = 1024 * 1024
|
|
100
|
+
return ("total", arg_max, "this OS's total argument limit (ARG_MAX)")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_command_line_size(argv: List[str]) -> None:
|
|
104
|
+
"""Fail legibly if the assembled command line would exceed the OS limit.
|
|
105
|
+
|
|
106
|
+
Only a client that carries the prompt in argv (cursor-agent) can approach this;
|
|
107
|
+
claude and codex put the prompt on stdin, so their command line stays small and
|
|
108
|
+
this never fires for them.
|
|
109
|
+
"""
|
|
110
|
+
scope, limit, label = _command_line_limit()
|
|
111
|
+
sizes = [len(arg.encode("utf-8")) for arg in argv]
|
|
112
|
+
measured = max(sizes) if scope == "arg" else sum(sizes) + len(argv)
|
|
113
|
+
# Headroom for the executable path, separators and OS bookkeeping.
|
|
114
|
+
if measured >= limit - 4096:
|
|
115
|
+
raise CommandLineTooLong(
|
|
116
|
+
f"the launched command is ~{measured // 1024} KiB, over {label} "
|
|
117
|
+
f"(~{limit // 1024} KiB). This client takes the prompt as a command-line "
|
|
118
|
+
"argument (it has no stdin path), so a prompt this large cannot be launched "
|
|
119
|
+
"here. Declare large content as input files (--input-file) and reference "
|
|
120
|
+
"them in a short prompt, or use a tier backed by a client that reads the "
|
|
121
|
+
"prompt on stdin (claude/codex)."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _communicate_streaming(
|
|
126
|
+
proc: subprocess.Popen,
|
|
127
|
+
stdin_text: Optional[str],
|
|
128
|
+
timeout: float | None,
|
|
129
|
+
on_line: Callable[[str], None],
|
|
130
|
+
) -> tuple[str, str]:
|
|
131
|
+
"""Like ``proc.communicate()``, but hand each stdout line to ``on_line`` as it
|
|
132
|
+
arrives so a live consumer sees progress in real time.
|
|
133
|
+
|
|
134
|
+
Stdin is fed and stderr drained on worker threads to avoid the classic pipe
|
|
135
|
+
deadlock, and stdout is read line by line on a third thread. This is the
|
|
136
|
+
portable approach -- it uses threads, not ``select``/``poll`` on file
|
|
137
|
+
descriptors (POSIX-only) -- so it behaves the same on Linux, macOS and Windows.
|
|
138
|
+
Raises ``subprocess.TimeoutExpired`` on timeout, matching ``communicate()`` so
|
|
139
|
+
the caller's teardown path is unchanged.
|
|
140
|
+
"""
|
|
141
|
+
out_lines: List[str] = []
|
|
142
|
+
err_chunks: List[str] = []
|
|
143
|
+
|
|
144
|
+
def _feed_stdin() -> None:
|
|
145
|
+
if stdin_text is not None and proc.stdin is not None:
|
|
146
|
+
try:
|
|
147
|
+
proc.stdin.write(stdin_text)
|
|
148
|
+
except (OSError, ValueError):
|
|
149
|
+
pass
|
|
150
|
+
if proc.stdin is not None:
|
|
151
|
+
try:
|
|
152
|
+
proc.stdin.close()
|
|
153
|
+
except (OSError, ValueError):
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
def _read_stderr() -> None:
|
|
157
|
+
if proc.stderr is None:
|
|
158
|
+
return
|
|
159
|
+
try:
|
|
160
|
+
for chunk in proc.stderr:
|
|
161
|
+
err_chunks.append(chunk)
|
|
162
|
+
except (OSError, ValueError):
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
def _read_stdout() -> None:
|
|
166
|
+
if proc.stdout is None:
|
|
167
|
+
return
|
|
168
|
+
try:
|
|
169
|
+
for line in proc.stdout:
|
|
170
|
+
out_lines.append(line)
|
|
171
|
+
try:
|
|
172
|
+
on_line(line)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass # the live view must never break the run
|
|
175
|
+
except (OSError, ValueError):
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
workers = [
|
|
179
|
+
threading.Thread(target=_feed_stdin, daemon=True),
|
|
180
|
+
threading.Thread(target=_read_stderr, daemon=True),
|
|
181
|
+
threading.Thread(target=_read_stdout, daemon=True),
|
|
182
|
+
]
|
|
183
|
+
for w in workers:
|
|
184
|
+
w.start()
|
|
185
|
+
proc.wait(timeout=timeout) # raises TimeoutExpired -> caller tears down
|
|
186
|
+
for w in workers:
|
|
187
|
+
w.join(timeout=5)
|
|
188
|
+
return "".join(out_lines), "".join(err_chunks)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _run_client(
|
|
192
|
+
argv: List[str],
|
|
193
|
+
cwd: Path,
|
|
194
|
+
stdin_payload: Optional[str],
|
|
195
|
+
timeout: float | None,
|
|
196
|
+
env: Optional[dict] = None,
|
|
197
|
+
on_line: Optional[Callable[[str], None]] = None,
|
|
198
|
+
) -> tuple[str, str, int]:
|
|
199
|
+
"""Launch the client in its own process group and wait, while honoring a stop
|
|
200
|
+
signal from the caller (the workflow engine, DESIGN cross-app clean stop).
|
|
201
|
+
|
|
202
|
+
On SIGTERM/SIGINT the whole group is torn down and ``RunInterrupted`` is raised,
|
|
203
|
+
so the caller records no execution (an interrupted call is not a result). A
|
|
204
|
+
timeout keeps the prior contract: kill the group and re-raise ``TimeoutExpired``.
|
|
205
|
+
|
|
206
|
+
Signal handlers can only be installed on the main thread; off it (a host that
|
|
207
|
+
embeds the cache on a worker thread) we still run and still tear down on timeout
|
|
208
|
+
-- we simply cannot catch a process signal there, which is the host's to manage.
|
|
209
|
+
"""
|
|
210
|
+
group_kwargs: dict = (
|
|
211
|
+
{"start_new_session": True}
|
|
212
|
+
if os.name == "posix"
|
|
213
|
+
else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
214
|
+
)
|
|
215
|
+
use_stdin = stdin_payload is not None
|
|
216
|
+
proc = subprocess.Popen(
|
|
217
|
+
argv,
|
|
218
|
+
cwd=str(cwd),
|
|
219
|
+
stdin=subprocess.PIPE if use_stdin else None,
|
|
220
|
+
stdout=subprocess.PIPE,
|
|
221
|
+
stderr=subprocess.PIPE,
|
|
222
|
+
text=True,
|
|
223
|
+
env=env,
|
|
224
|
+
**group_kwargs,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
stopped: dict[str, int | None] = {"signum": None}
|
|
228
|
+
previous: dict[int, object] = {}
|
|
229
|
+
installed: List[int] = []
|
|
230
|
+
|
|
231
|
+
def _on_stop(signum, _frame):
|
|
232
|
+
# Killing the child makes communicate() return on its own; we record the
|
|
233
|
+
# signal and raise *after* it returns, never from inside the handler.
|
|
234
|
+
stopped["signum"] = signum
|
|
235
|
+
_terminate_group(proc)
|
|
236
|
+
|
|
237
|
+
if threading.current_thread() is threading.main_thread():
|
|
238
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
239
|
+
try:
|
|
240
|
+
previous[sig] = signal.signal(sig, _on_stop)
|
|
241
|
+
installed.append(sig)
|
|
242
|
+
except (ValueError, OSError, RuntimeError):
|
|
243
|
+
pass # not settable on this platform/context; carry on without it
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
if on_line is None:
|
|
247
|
+
out, err = proc.communicate(input=stdin_payload if use_stdin else None, timeout=timeout)
|
|
248
|
+
else:
|
|
249
|
+
out, err = _communicate_streaming(
|
|
250
|
+
proc, stdin_payload if use_stdin else None, timeout, on_line
|
|
251
|
+
)
|
|
252
|
+
except subprocess.TimeoutExpired:
|
|
253
|
+
_terminate_group(proc)
|
|
254
|
+
if on_line is None:
|
|
255
|
+
proc.communicate() # reap the killed child so no zombie lingers
|
|
256
|
+
else:
|
|
257
|
+
# the reader threads hit EOF on the kill; reap without re-reading pipes
|
|
258
|
+
try:
|
|
259
|
+
proc.wait(timeout=5)
|
|
260
|
+
except subprocess.TimeoutExpired:
|
|
261
|
+
pass
|
|
262
|
+
raise
|
|
263
|
+
finally:
|
|
264
|
+
for sig in installed:
|
|
265
|
+
signal.signal(sig, previous[sig])
|
|
266
|
+
|
|
267
|
+
if stopped["signum"] is not None:
|
|
268
|
+
raise RunInterrupted(
|
|
269
|
+
f"client run was stopped (signal {stopped['signum']}) before it completed"
|
|
270
|
+
)
|
|
271
|
+
return out or "", err or "", proc.returncode
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def record_real_call(
|
|
275
|
+
adapter: ClientAdapter,
|
|
276
|
+
executable: str,
|
|
277
|
+
model: str,
|
|
278
|
+
effort: str,
|
|
279
|
+
context: str,
|
|
280
|
+
prompt: str,
|
|
281
|
+
user_system_prompt: str | None = None,
|
|
282
|
+
timeout: float | None = None,
|
|
283
|
+
allowed_read_paths: Optional[List[str]] = None,
|
|
284
|
+
add_dir_paths: Optional[List[str]] = None,
|
|
285
|
+
client_args: Optional[List[str]] = None,
|
|
286
|
+
grants: Optional[List[str]] = None,
|
|
287
|
+
stream_path: Optional[str] = None,
|
|
288
|
+
) -> ClientRunResult:
|
|
289
|
+
"""Execute the client once in isolation and capture its full result.
|
|
290
|
+
|
|
291
|
+
The prime directive is injected here (record time) and is deliberately NOT
|
|
292
|
+
returned as part of the cached input -- it is operational scaffolding. When
|
|
293
|
+
``allowed_read_paths`` is given (declared input files and/or allow-path
|
|
294
|
+
folders), the directive is widened to let the client read those paths. When
|
|
295
|
+
``add_dir_paths`` is given, the adapter may *additionally* open a hard
|
|
296
|
+
per-client read door for those folders (e.g. Claude's ``--add-dir``).
|
|
297
|
+
"""
|
|
298
|
+
system_prompt = build_system_prompt(user_system_prompt, allowed_read_paths)
|
|
299
|
+
|
|
300
|
+
# Opt-in live progress: an NDJSON event file the cache writes as the call runs.
|
|
301
|
+
# Display-only -- it never changes what is recorded (see stream.py).
|
|
302
|
+
writer = StreamWriter(Path(stream_path)) if stream_path else None
|
|
303
|
+
on_line: Optional[Callable[[str], None]] = None
|
|
304
|
+
if writer is not None:
|
|
305
|
+
writer.event(
|
|
306
|
+
"run.start",
|
|
307
|
+
client=adapter.name,
|
|
308
|
+
model=model,
|
|
309
|
+
effort=effort or None,
|
|
310
|
+
grants=",".join(sorted(set(grants or []))) or None,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def _emit(line: str) -> None:
|
|
314
|
+
event = adapter.stream_event(line)
|
|
315
|
+
if event:
|
|
316
|
+
writer.event(event.pop("kind"), **event)
|
|
317
|
+
|
|
318
|
+
on_line = _emit
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
with (
|
|
322
|
+
tempfile.TemporaryDirectory(prefix="gmlc-run-") as tmp,
|
|
323
|
+
tempfile.TemporaryDirectory(prefix="gmlc-home-") as home_tmp,
|
|
324
|
+
):
|
|
325
|
+
run_dir = Path(tmp)
|
|
326
|
+
# The config home is a SEPARATE folder from run_dir, so the settings
|
|
327
|
+
# file and any seeded credentials are never snapshotted into the
|
|
328
|
+
# stored record and are deleted with the run. Capabilities are enabled by the
|
|
329
|
+
# file written here, not by argv flags (v0.0.16; see docs/reference/grants.md).
|
|
330
|
+
config_home = Path(home_tmp)
|
|
331
|
+
adapter.prepare(run_dir, context, prompt, system_prompt)
|
|
332
|
+
baseline = _snapshot(run_dir)
|
|
333
|
+
|
|
334
|
+
argv = adapter.build_argv(
|
|
335
|
+
executable,
|
|
336
|
+
run_dir,
|
|
337
|
+
model,
|
|
338
|
+
effort,
|
|
339
|
+
context,
|
|
340
|
+
prompt,
|
|
341
|
+
system_prompt,
|
|
342
|
+
client_args or [],
|
|
343
|
+
grants or [],
|
|
344
|
+
)
|
|
345
|
+
argv += adapter.read_access_argv(add_dir_paths or [])
|
|
346
|
+
# Forced operational flags a client requires for a grant its file cannot
|
|
347
|
+
# express (Cursor's --force for external network egress).
|
|
348
|
+
argv += adapter.grant_argv(grants or [])
|
|
349
|
+
# Render the client's config file into the redirected home and collect
|
|
350
|
+
# the env (CODEX_HOME/CLAUDE_CONFIG_DIR/CURSOR_CONFIG_DIR) the run needs.
|
|
351
|
+
grant_env = adapter.grant_setup(run_dir, config_home, grants or [])
|
|
352
|
+
run_env = {**os.environ, **grant_env} if grant_env else None
|
|
353
|
+
stdin_payload = adapter.stdin_payload(context, prompt, system_prompt)
|
|
354
|
+
|
|
355
|
+
# Fail legibly before the OS rejects an oversize command line (only a
|
|
356
|
+
# client that carries the prompt in argv -- cursor -- can hit this).
|
|
357
|
+
_check_command_line_size(argv)
|
|
358
|
+
|
|
359
|
+
# A stop signal here raises RunInterrupted, unwinding before any capture
|
|
360
|
+
# or record write -- an interrupted call leaves no half-written record.
|
|
361
|
+
stdout, stderr, returncode = _run_client(
|
|
362
|
+
argv, run_dir, stdin_payload, timeout, run_env, on_line
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
files = _capture_changes(run_dir, baseline)
|
|
366
|
+
|
|
367
|
+
# The client ran in its structured (JSON) output mode, so raw stdout carries
|
|
368
|
+
# the answer *and* the usage. The adapter lifts the clean answer back out
|
|
369
|
+
# (what the caller sees on stdout) and reads the normalized usage from the
|
|
370
|
+
# same output. parse_output degrades on its own if the output is unexpected.
|
|
371
|
+
parsed = adapter.parse_output(stdout)
|
|
372
|
+
|
|
373
|
+
if writer is not None:
|
|
374
|
+
writer.event(
|
|
375
|
+
"run.end",
|
|
376
|
+
exit=returncode,
|
|
377
|
+
files=len(files),
|
|
378
|
+
input_tokens=parsed.usage.input_tokens if parsed.usage else None,
|
|
379
|
+
output_tokens=parsed.usage.output_tokens if parsed.usage else None,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Translate the adapter's parsed usage (its own type) into the core
|
|
383
|
+
# TokenUsage at this boundary; the dict shape is identical.
|
|
384
|
+
token_usage = (
|
|
385
|
+
TokenUsage.from_dict(parsed.usage.to_dict()) if parsed.usage is not None else None
|
|
386
|
+
)
|
|
387
|
+
return ClientRunResult(
|
|
388
|
+
exit_code=returncode,
|
|
389
|
+
stdout=parsed.text,
|
|
390
|
+
stderr=stderr,
|
|
391
|
+
files=files,
|
|
392
|
+
token_usage=token_usage,
|
|
393
|
+
)
|
|
394
|
+
finally:
|
|
395
|
+
if writer is not None:
|
|
396
|
+
writer.close()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""LocalClientRunner: the ClientRunnerPort over the isolated client machinery."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
|
|
9
|
+
from generic_ml_cache_core.adapter.out.client.isolation import record_real_call
|
|
10
|
+
from generic_ml_cache_core.adapter.out.client.registry import get_adapter
|
|
11
|
+
from generic_ml_cache_core.application.domain.model.run.client_run_request import ClientRunRequest
|
|
12
|
+
from generic_ml_cache_core.application.domain.model.run.client_run_result import ClientRunResult
|
|
13
|
+
from generic_ml_cache_core.application.port.out.client_runner_port import ClientRunnerPort
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LocalClientRunner(ClientRunnerPort):
|
|
17
|
+
"""Runs a managed local client in isolation and returns its raw result.
|
|
18
|
+
|
|
19
|
+
Wraps the isolation machinery (``record_real_call``): it resolves the client
|
|
20
|
+
adapter and executable, opens the read-door for the declared input files and
|
|
21
|
+
allow-path folders, launches the client, and returns the captured
|
|
22
|
+
``ClientRunResult``. The executable override (per client) and the timeout are
|
|
23
|
+
injected from the composition root's config.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
executable_override: Optional[Callable[[str], Optional[str]]] = None,
|
|
29
|
+
timeout: Optional[float] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._executable_override = executable_override or (lambda _client: None)
|
|
32
|
+
self._timeout = timeout
|
|
33
|
+
|
|
34
|
+
def run(self, client_run_request: ClientRunRequest) -> ClientRunResult:
|
|
35
|
+
adapter = get_adapter(client_run_request.client)
|
|
36
|
+
executable = adapter.resolve_executable(
|
|
37
|
+
self._executable_override(client_run_request.client)
|
|
38
|
+
)
|
|
39
|
+
return record_real_call(
|
|
40
|
+
adapter=adapter,
|
|
41
|
+
executable=executable,
|
|
42
|
+
model=client_run_request.model,
|
|
43
|
+
effort=client_run_request.effort,
|
|
44
|
+
context=client_run_request.context,
|
|
45
|
+
prompt=client_run_request.prompt,
|
|
46
|
+
user_system_prompt=client_run_request.user_system_prompt,
|
|
47
|
+
timeout=self._timeout,
|
|
48
|
+
allowed_read_paths=sorted(
|
|
49
|
+
[*client_run_request.input_file_paths, *client_run_request.allow_paths]
|
|
50
|
+
),
|
|
51
|
+
add_dir_paths=sorted(client_run_request.allow_paths),
|
|
52
|
+
client_args=list(client_run_request.client_args),
|
|
53
|
+
grants=list(client_run_request.grants),
|
|
54
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""PassthroughClientRunner: the PassthroughRunnerPort over a direct launch."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Callable, List, Optional
|
|
9
|
+
|
|
10
|
+
from generic_ml_cache_core.adapter.out.client.registry import get_adapter
|
|
11
|
+
from generic_ml_cache_core.application.domain.model.run.client_run_result import ClientRunResult
|
|
12
|
+
from generic_ml_cache_core.application.port.out.passthrough_runner_port import PassthroughRunnerPort
|
|
13
|
+
|
|
14
|
+
_TEXT_ENCODING = "utf-8"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PassthroughClientRunner(PassthroughRunnerPort):
|
|
18
|
+
"""Runs a client with its opaque native args, in the caller's own folder.
|
|
19
|
+
|
|
20
|
+
No isolation, no file capture: it resolves the client executable, forwards the
|
|
21
|
+
native arguments verbatim, and captures stdout/stderr/exit. Output bytes are
|
|
22
|
+
decoded leniently so unusual client output never crashes the run. The
|
|
23
|
+
executable override and timeout are injected from the composition root.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
executable_override: Optional[Callable[[str], Optional[str]]] = None,
|
|
29
|
+
timeout: Optional[float] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._executable_override = executable_override or (lambda _client: None)
|
|
32
|
+
self._timeout = timeout
|
|
33
|
+
|
|
34
|
+
def run(self, client: str, native_args: List[str]) -> ClientRunResult:
|
|
35
|
+
adapter = get_adapter(client)
|
|
36
|
+
executable = adapter.resolve_executable(self._executable_override(client))
|
|
37
|
+
completed = subprocess.run(
|
|
38
|
+
[executable, *native_args],
|
|
39
|
+
capture_output=True,
|
|
40
|
+
timeout=self._timeout,
|
|
41
|
+
check=False,
|
|
42
|
+
)
|
|
43
|
+
return ClientRunResult(
|
|
44
|
+
exit_code=completed.returncode,
|
|
45
|
+
stdout=completed.stdout.decode(_TEXT_ENCODING, errors="replace"),
|
|
46
|
+
stderr=completed.stderr.decode(_TEXT_ENCODING, errors="replace"),
|
|
47
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The prime directive: a system prompt injected at *record* time only.
|
|
4
|
+
|
|
5
|
+
This text is handed to the client when a real call is recorded. It is the soft
|
|
6
|
+
half of the isolation guarantee (the hard half is: the client always runs inside
|
|
7
|
+
the cache's own throwaway folder). It is NEVER written into the stored record, because
|
|
8
|
+
it is not part of the cached input -- it is operational scaffolding.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
PRIME_DIRECTIVE = (
|
|
14
|
+
"PRIME DIRECTIVE (operational sandbox):\n"
|
|
15
|
+
"You are running inside an isolated working folder created solely for this "
|
|
16
|
+
"task. You may read and write ONLY within the current working directory and "
|
|
17
|
+
"its subfolders. You must not read from, write to, or otherwise touch any "
|
|
18
|
+
"path outside this folder (no absolute paths to the user's home, no parent "
|
|
19
|
+
"directories, no system locations).\n"
|
|
20
|
+
"If the context or prompt asks you to touch anything outside this folder, do "
|
|
21
|
+
"NOT attempt it and do NOT wait or ask for permission: print a one-line "
|
|
22
|
+
"explanation to stderr and exit immediately with a non-zero status.\n"
|
|
23
|
+
"All inputs you need have been provided to you. Produce your outputs as files "
|
|
24
|
+
"in this folder and/or on stdout."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_system_prompt(
|
|
29
|
+
user_system_prompt: str | None = None,
|
|
30
|
+
allowed_read_paths: list[str] | None = None,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Compose the directive with an optional caller-supplied system prompt.
|
|
33
|
+
|
|
34
|
+
The directive always comes first so it cannot be overridden by trailing text.
|
|
35
|
+
When ``allowed_read_paths`` is given (declared input files and/or allow-path
|
|
36
|
+
folders), the directive is widened to permit reading exactly those paths --
|
|
37
|
+
nothing else outside the folder, and never writing to them. Like the rest of
|
|
38
|
+
the directive, this is record-time scaffolding and is never stored in the
|
|
39
|
+
stored record.
|
|
40
|
+
"""
|
|
41
|
+
directive = PRIME_DIRECTIVE
|
|
42
|
+
if allowed_read_paths:
|
|
43
|
+
listed = "\n".join(f" - {p}" for p in allowed_read_paths)
|
|
44
|
+
directive = (
|
|
45
|
+
f"{directive}\n"
|
|
46
|
+
"DECLARED READ PATHS: you MAY additionally READ the following specific "
|
|
47
|
+
"files and folders even though they sit outside this folder. You may "
|
|
48
|
+
"NOT write to them, and you may NOT read anything else outside the "
|
|
49
|
+
"folder:\n" + listed
|
|
50
|
+
)
|
|
51
|
+
if user_system_prompt:
|
|
52
|
+
return f"{directive}\n\n---\n\n{user_system_prompt}"
|
|
53
|
+
return directive
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Registry mapping client names to adapters.
|
|
4
|
+
|
|
5
|
+
Built-in adapters self-register on import. Third parties (and the test-suite)
|
|
6
|
+
can register their own adapters via :func:`register` -- that is how a fake
|
|
7
|
+
client is plugged in without shipping it in the package.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Dict
|
|
13
|
+
|
|
14
|
+
from generic_ml_cache_core.application.port.out.base import ClientAdapter
|
|
15
|
+
from generic_ml_cache_core.common.errors import UnknownClient
|
|
16
|
+
|
|
17
|
+
_REGISTRY: Dict[str, ClientAdapter] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(adapter: ClientAdapter) -> ClientAdapter:
|
|
21
|
+
_REGISTRY[adapter.name] = adapter
|
|
22
|
+
return adapter
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_adapter(name: str) -> ClientAdapter:
|
|
26
|
+
try:
|
|
27
|
+
return _REGISTRY[name]
|
|
28
|
+
except KeyError:
|
|
29
|
+
known = ", ".join(sorted(_REGISTRY)) or "(none)"
|
|
30
|
+
raise UnknownClient(f"unknown client {name!r}; registered: {known}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def registered_names() -> list[str]:
|
|
34
|
+
return sorted(_REGISTRY)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Hexagonal layer package."""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""SystemClock: the real wall-clock implementation of ClockPort."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from generic_ml_cache_core.application.port.out.clock_port import ClockPort
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SystemClock(ClockPort):
|
|
13
|
+
"""Reads the operating system clock, in UTC."""
|
|
14
|
+
|
|
15
|
+
def now(self) -> datetime:
|
|
16
|
+
return datetime.now(timezone.utc)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Hexagonal layer package."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""FilesystemFileFingerprint: read a file and fingerprint it at the edge."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from generic_ml_cache_core.application.port.out.file_fingerprint_port import FileFingerprintPort
|
|
10
|
+
from generic_ml_cache_core.common.checksum import file_content_fingerprint
|
|
11
|
+
from generic_ml_cache_core.common.errors import InputFileError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FilesystemFileFingerprint(FileFingerprintPort):
|
|
15
|
+
"""Fingerprint a declared input file off the local filesystem.
|
|
16
|
+
|
|
17
|
+
Reads the bytes and applies the imported core rule, returning only the
|
|
18
|
+
checksum. The content stays inside this adapter — it never crosses back to
|
|
19
|
+
the use case. The rule is imported from the core, never reimplemented here.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def fingerprint(self, path: str) -> str:
|
|
23
|
+
file_path = Path(path)
|
|
24
|
+
if not file_path.is_file():
|
|
25
|
+
raise InputFileError(f"input file not found: {path}")
|
|
26
|
+
try:
|
|
27
|
+
content = file_path.read_bytes()
|
|
28
|
+
except OSError as error:
|
|
29
|
+
raise InputFileError(f"cannot read input file {path}: {error}") from error
|
|
30
|
+
return file_content_fingerprint(content)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Hexagonal layer package."""
|