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.
Files changed (99) hide show
  1. generic_ml_cache_core/__init__.py +64 -0
  2. generic_ml_cache_core/adapter/__init__.py +1 -0
  3. generic_ml_cache_core/adapter/inbound/__init__.py +1 -0
  4. generic_ml_cache_core/adapter/inbound/composition.py +96 -0
  5. generic_ml_cache_core/adapter/out/__init__.py +1 -0
  6. generic_ml_cache_core/adapter/out/api/__init__.py +1 -0
  7. generic_ml_cache_core/adapter/out/api/stub_api_client_adapter.py +30 -0
  8. generic_ml_cache_core/adapter/out/client/__init__.py +28 -0
  9. generic_ml_cache_core/adapter/out/client/claude.py +214 -0
  10. generic_ml_cache_core/adapter/out/client/codex.py +171 -0
  11. generic_ml_cache_core/adapter/out/client/cursor.py +208 -0
  12. generic_ml_cache_core/adapter/out/client/discover.py +121 -0
  13. generic_ml_cache_core/adapter/out/client/isolation.py +396 -0
  14. generic_ml_cache_core/adapter/out/client/local_client_runner.py +54 -0
  15. generic_ml_cache_core/adapter/out/client/passthrough_client_runner.py +47 -0
  16. generic_ml_cache_core/adapter/out/client/prime_directive.py +53 -0
  17. generic_ml_cache_core/adapter/out/client/registry.py +34 -0
  18. generic_ml_cache_core/adapter/out/clock/__init__.py +1 -0
  19. generic_ml_cache_core/adapter/out/clock/system_clock.py +16 -0
  20. generic_ml_cache_core/adapter/out/fingerprint/__init__.py +1 -0
  21. generic_ml_cache_core/adapter/out/fingerprint/filesystem_file_fingerprint.py +30 -0
  22. generic_ml_cache_core/adapter/out/metrics/__init__.py +1 -0
  23. generic_ml_cache_core/adapter/out/metrics/access_registry.py +147 -0
  24. generic_ml_cache_core/adapter/out/metrics/journal_metrics.py +45 -0
  25. generic_ml_cache_core/adapter/out/persistence/__init__.py +1 -0
  26. generic_ml_cache_core/adapter/out/persistence/call_identity_serialization.py +100 -0
  27. generic_ml_cache_core/adapter/out/persistence/in_memory_execution_repository.py +69 -0
  28. generic_ml_cache_core/adapter/out/persistence/sqlite_execution_repository.py +398 -0
  29. generic_ml_cache_core/adapter/out/storage/__init__.py +1 -0
  30. generic_ml_cache_core/adapter/out/storage/filesystem_blob_store.py +47 -0
  31. generic_ml_cache_core/application/__init__.py +1 -0
  32. generic_ml_cache_core/application/domain/__init__.py +1 -0
  33. generic_ml_cache_core/application/domain/model/__init__.py +1 -0
  34. generic_ml_cache_core/application/domain/model/client_status.py +17 -0
  35. generic_ml_cache_core/application/domain/model/execution/__init__.py +1 -0
  36. generic_ml_cache_core/application/domain/model/execution/artifact.py +78 -0
  37. generic_ml_cache_core/application/domain/model/execution/execution_failure.py +32 -0
  38. generic_ml_cache_core/application/domain/model/execution/execution_kind.py +26 -0
  39. generic_ml_cache_core/application/domain/model/execution/execution_state.py +21 -0
  40. generic_ml_cache_core/application/domain/model/execution/ml_execution.py +41 -0
  41. generic_ml_cache_core/application/domain/model/identity/__init__.py +1 -0
  42. generic_ml_cache_core/application/domain/model/identity/api_call_identity.py +36 -0
  43. generic_ml_cache_core/application/domain/model/identity/call_identity.py +25 -0
  44. generic_ml_cache_core/application/domain/model/identity/managed_call_identity.py +54 -0
  45. generic_ml_cache_core/application/domain/model/identity/passthrough_call_identity.py +35 -0
  46. generic_ml_cache_core/application/domain/model/model_info.py +20 -0
  47. generic_ml_cache_core/application/domain/model/model_listing.py +29 -0
  48. generic_ml_cache_core/application/domain/model/parsed_output.py +23 -0
  49. generic_ml_cache_core/application/domain/model/probe/__init__.py +1 -0
  50. generic_ml_cache_core/application/domain/model/probe/probe_report.py +26 -0
  51. generic_ml_cache_core/application/domain/model/probe/probe_status.py +13 -0
  52. generic_ml_cache_core/application/domain/model/run/__init__.py +1 -0
  53. generic_ml_cache_core/application/domain/model/run/cache_mode.py +21 -0
  54. generic_ml_cache_core/application/domain/model/run/client_run_request.py +35 -0
  55. generic_ml_cache_core/application/domain/model/run/client_run_result.py +65 -0
  56. generic_ml_cache_core/application/domain/model/run/message.py +20 -0
  57. generic_ml_cache_core/application/domain/model/usage/__init__.py +1 -0
  58. generic_ml_cache_core/application/domain/model/usage/token_usage.py +53 -0
  59. generic_ml_cache_core/application/domain/model/usage/usage.py +108 -0
  60. generic_ml_cache_core/application/domain/service/__init__.py +1 -0
  61. generic_ml_cache_core/application/domain/service/cacheability.py +19 -0
  62. generic_ml_cache_core/application/domain/service/message_fingerprinting.py +25 -0
  63. generic_ml_cache_core/application/port/__init__.py +1 -0
  64. generic_ml_cache_core/application/port/inbound/__init__.py +1 -0
  65. generic_ml_cache_core/application/port/inbound/probe_command.py +35 -0
  66. generic_ml_cache_core/application/port/inbound/probe_use_case.py +19 -0
  67. generic_ml_cache_core/application/port/inbound/run_api_execution_command.py +40 -0
  68. generic_ml_cache_core/application/port/inbound/run_api_execution_use_case.py +20 -0
  69. generic_ml_cache_core/application/port/inbound/run_managed_local_execution_command.py +48 -0
  70. generic_ml_cache_core/application/port/inbound/run_managed_local_execution_use_case.py +25 -0
  71. generic_ml_cache_core/application/port/inbound/run_passthrough_execution_command.py +35 -0
  72. generic_ml_cache_core/application/port/inbound/run_passthrough_execution_use_case.py +20 -0
  73. generic_ml_cache_core/application/port/out/__init__.py +1 -0
  74. generic_ml_cache_core/application/port/out/api_client_port.py +26 -0
  75. generic_ml_cache_core/application/port/out/base.py +272 -0
  76. generic_ml_cache_core/application/port/out/blob_store_port.py +37 -0
  77. generic_ml_cache_core/application/port/out/client_runner_port.py +26 -0
  78. generic_ml_cache_core/application/port/out/clock_port.py +22 -0
  79. generic_ml_cache_core/application/port/out/execution_repository_port.py +40 -0
  80. generic_ml_cache_core/application/port/out/file_fingerprint_port.py +25 -0
  81. generic_ml_cache_core/application/port/out/metrics_port.py +54 -0
  82. generic_ml_cache_core/application/port/out/passthrough_runner_port.py +25 -0
  83. generic_ml_cache_core/application/usecase/__init__.py +1 -0
  84. generic_ml_cache_core/application/usecase/cached_ml_execution_service.py +198 -0
  85. generic_ml_cache_core/application/usecase/call_identity_building.py +60 -0
  86. generic_ml_cache_core/application/usecase/journal_events.py +19 -0
  87. generic_ml_cache_core/application/usecase/probe_service.py +44 -0
  88. generic_ml_cache_core/application/usecase/run_api_execution_service.py +69 -0
  89. generic_ml_cache_core/application/usecase/run_managed_local_execution_service.py +84 -0
  90. generic_ml_cache_core/application/usecase/run_passthrough_execution_service.py +67 -0
  91. generic_ml_cache_core/common/__init__.py +1 -0
  92. generic_ml_cache_core/common/checksum.py +82 -0
  93. generic_ml_cache_core/common/errors.py +76 -0
  94. generic_ml_cache_core/stream.py +65 -0
  95. generic_ml_cache_core-0.2.0.dist-info/METADATA +104 -0
  96. generic_ml_cache_core-0.2.0.dist-info/RECORD +99 -0
  97. generic_ml_cache_core-0.2.0.dist-info/WHEEL +4 -0
  98. generic_ml_cache_core-0.2.0.dist-info/licenses/LICENSE +201 -0
  99. 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."""