millforge 0.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 (116) hide show
  1. millforge/__init__.py +1174 -0
  2. millforge/_forge/LICENSE +21 -0
  3. millforge/_forge/PROVENANCE.json +295 -0
  4. millforge/_forge/UPDATE_POLICY.md +24 -0
  5. millforge/_forge/__init__.py +14 -0
  6. millforge/_forge/adapter.py +2232 -0
  7. millforge/_forge/base_runner.py +121 -0
  8. millforge/_forge/clients/__init__.py +10 -0
  9. millforge/_forge/clients/base.py +200 -0
  10. millforge/_forge/context/__init__.py +23 -0
  11. millforge/_forge/context/manager.py +178 -0
  12. millforge/_forge/context/strategies.py +335 -0
  13. millforge/_forge/core/__init__.py +16 -0
  14. millforge/_forge/core/inference.py +433 -0
  15. millforge/_forge/core/messages.py +119 -0
  16. millforge/_forge/core/runner.py +479 -0
  17. millforge/_forge/core/steps.py +108 -0
  18. millforge/_forge/core/workflow.py +400 -0
  19. millforge/_forge/errors.py +222 -0
  20. millforge/_forge/guardrails/__init__.py +21 -0
  21. millforge/_forge/guardrails/error_tracker.py +71 -0
  22. millforge/_forge/guardrails/guardrails.py +194 -0
  23. millforge/_forge/guardrails/nudge.py +47 -0
  24. millforge/_forge/guardrails/response_validator.py +119 -0
  25. millforge/_forge/guardrails/step_enforcer.py +183 -0
  26. millforge/_forge/prompts/__init__.py +16 -0
  27. millforge/_forge/prompts/nudges.py +95 -0
  28. millforge/_forge/prompts/templates.py +285 -0
  29. millforge/_version.py +3 -0
  30. millforge/artifacts.py +570 -0
  31. millforge/base/__init__.py +97 -0
  32. millforge/base/composition.py +402 -0
  33. millforge/base/context.py +285 -0
  34. millforge/base/harness.py +138 -0
  35. millforge/base/identity.py +465 -0
  36. millforge/base/options.py +34 -0
  37. millforge/base/platform.py +17 -0
  38. millforge/base/prompt.py +317 -0
  39. millforge/base/runner.py +546 -0
  40. millforge/compiled_plan.py +970 -0
  41. millforge/compiler/__init__.py +231 -0
  42. millforge/compiler/artifact_validation.py +257 -0
  43. millforge/compiler/canonicalization.py +169 -0
  44. millforge/compiler/capabilities.py +66 -0
  45. millforge/compiler/catalogs.py +500 -0
  46. millforge/compiler/diagnostics.py +491 -0
  47. millforge/compiler/graph.py +678 -0
  48. millforge/compiler/lowering.py +198 -0
  49. millforge/compiler/output.py +692 -0
  50. millforge/compiler/parsing.py +1424 -0
  51. millforge/compiler/requests.py +1180 -0
  52. millforge/compiler/schema_validation.py +272 -0
  53. millforge/compiler/semantic.py +490 -0
  54. millforge/compiler/service.py +448 -0
  55. millforge/compiler/source.py +375 -0
  56. millforge/compiler/validators.py +184 -0
  57. millforge/connectors/__init__.py +95 -0
  58. millforge/connectors/admission.py +801 -0
  59. millforge/connectors/broker.py +202 -0
  60. millforge/connectors/contracts.py +1159 -0
  61. millforge/connectors/diagnostics.py +189 -0
  62. millforge/connectors/fake.py +66 -0
  63. millforge/connectors/runtime.py +236 -0
  64. millforge/contracts.py +2860 -0
  65. millforge/custom_tools/__init__.py +67 -0
  66. millforge/custom_tools/compiler.py +724 -0
  67. millforge/custom_tools/contracts.py +1093 -0
  68. millforge/custom_tools/diagnostics.py +205 -0
  69. millforge/eval_artifacts.py +952 -0
  70. millforge/eval_boundary.py +2435 -0
  71. millforge/eval_fixtures/__init__.py +1 -0
  72. millforge/eval_fixtures/default_pack/__init__.py +1 -0
  73. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
  74. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
  75. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
  76. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
  77. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
  78. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
  79. millforge/eval_fixtures/default_pack/manifest.json +12 -0
  80. millforge/eval_modes.py +1282 -0
  81. millforge/eval_presets.py +1398 -0
  82. millforge/eval_reports.py +2517 -0
  83. millforge/eval_suite.py +2429 -0
  84. millforge/eval_trials.py +2632 -0
  85. millforge/eval_workflow.py +794 -0
  86. millforge/exceptions.py +122 -0
  87. millforge/model_backend.py +2098 -0
  88. millforge/protocols.py +340 -0
  89. millforge/py.typed +0 -0
  90. millforge/runtime.py +1791 -0
  91. millforge/testing/__init__.py +1089 -0
  92. millforge/tools/__init__.py +83 -0
  93. millforge/tools/builtin_runtime.py +1339 -0
  94. millforge/tools/builtins.py +773 -0
  95. millforge/tools/execution.py +1545 -0
  96. millforge/tools/path_policy.py +155 -0
  97. millforge/tools/pi_compat/PI_LICENSE +21 -0
  98. millforge/tools/pi_compat/PROVENANCE.json +55 -0
  99. millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
  100. millforge/tools/pi_compat/__init__.py +34 -0
  101. millforge/tools/pi_compat/contracts.py +49 -0
  102. millforge/tools/pi_compat/editing.py +390 -0
  103. millforge/tools/pi_compat/mutations.py +57 -0
  104. millforge/tools/pi_compat/operations.py +401 -0
  105. millforge/tools/pi_compat/paths.py +155 -0
  106. millforge/tools/pi_compat/process.py +1375 -0
  107. millforge/tools/pi_compat/search.py +738 -0
  108. millforge/tools/pi_compat/truncation.py +267 -0
  109. millforge/tools/pi_compat_catalog.py +396 -0
  110. millforge/tools/pi_compat_runtime.py +460 -0
  111. millforge/tools/registry.py +553 -0
  112. millforge/tools/results.py +533 -0
  113. millforge-0.1.0.dist-info/METADATA +844 -0
  114. millforge-0.1.0.dist-info/RECORD +116 -0
  115. millforge-0.1.0.dist-info/WHEEL +4 -0
  116. millforge-0.1.0.dist-info/licenses/LICENSE +201 -0
millforge/artifacts.py ADDED
@@ -0,0 +1,570 @@
1
+ """RuntimeArtifactWriter — produces the 7 standard runtime artifacts
2
+ with path safety, atomic writes, JSONL validation, and byte-determinism.
3
+
4
+ Path safety
5
+ -----------
6
+ Rejects absolute child paths, ``..`` components, symlink/reparse-point
7
+ escape via ``Path.resolve()`` TOCTOU check, and writes outside
8
+ ``run_directory/millforge/``.
9
+
10
+ Atomic writes
11
+ -------------
12
+ Each artifact is written to a temporary file in the target directory,
13
+ flushed, fsynced, then atomically renamed to the final path via
14
+ ``os.rename()``.
15
+
16
+ JSONL
17
+ -----
18
+ Every line is independently valid JSON followed by a single newline
19
+ (``\\n``). A partial final line is impossible because the file is
20
+ produced entirely before being atomically renamed — readers either see
21
+ the complete file or nothing.
22
+
23
+ Byte determinism
24
+ ----------------
25
+ All JSON output uses sorted keys, ``ensure_ascii=True``,
26
+ ``allow_nan=False``, compact separators, and a trailing newline,
27
+ producing byte-identical output under identical inputs.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import json
34
+ import os
35
+ import tempfile
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ from pydantic import BaseModel
40
+
41
+ from millforge.compiled_plan import SessionEvent, ToolTraceRecord
42
+ from millforge.contracts import (
43
+ ArtifactManifestArtifact,
44
+ ArtifactRef,
45
+ DiagnosticArtifact,
46
+ DiagnosticMetadata,
47
+ ExecutionSummaryArtifact,
48
+ MetricsArtifact,
49
+ TerminalResultArtifact,
50
+ )
51
+ from millforge.exceptions import ArtifactWriteError
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Standard artifact identifiers
55
+ # ---------------------------------------------------------------------------
56
+
57
+ STANDARD_ARTIFACT_IDS: tuple[str, ...] = (
58
+ "terminal_result",
59
+ "execution_summary",
60
+ "events",
61
+ "tool_trace",
62
+ "metrics",
63
+ "artifact_manifest",
64
+ "diagnostic",
65
+ )
66
+
67
+ STANDARD_ARTIFACT_FILENAMES: dict[str, str] = {
68
+ "terminal_result": "terminal_result.json",
69
+ "execution_summary": "execution_summary.json",
70
+ "events": "events.jsonl",
71
+ "tool_trace": "tool_trace.jsonl",
72
+ "metrics": "metrics.json",
73
+ "artifact_manifest": "artifact_manifest.json",
74
+ "diagnostic": "diagnostic.json",
75
+ }
76
+
77
+ JSON_MEDIA_TYPE = "application/json"
78
+ NDJSON_MEDIA_TYPE = "application/x-ndjson"
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # RuntimeArtifactWriter
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ class RuntimeArtifactWriter:
86
+ """Writes the 7 standard runtime artifacts.
87
+
88
+ Enforces path safety (no absolute child paths, no ``..`` traversal,
89
+ no symlink escape, no writes outside ``run_directory/millforge/``).
90
+ Uses atomic file replacement (write-temp, flush, fsync, rename).
91
+ JSONL: each line independently valid JSON + newline.
92
+ Tracks all writes for manifest enrichment.
93
+
94
+ Parameters
95
+ ----------
96
+ run_directory : Path
97
+ The run directory root. All artifact writes must be within
98
+ ``run_directory / "millforge"``.
99
+ producer : str
100
+ Producer identifier for the artifact manifest.
101
+ Default ``"RuntimeArtifactWriter/v1"``.
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ run_directory: Path,
107
+ producer: str = "RuntimeArtifactWriter/v1",
108
+ ) -> None:
109
+ self._run_directory: Path = run_directory.resolve()
110
+ self._millforge_dir: Path = self._run_directory / "millforge"
111
+ self._millforge_dir.mkdir(parents=True, exist_ok=True)
112
+ self._producer: str = producer
113
+ # artifact_id -> manifest metadata for written complete artifacts.
114
+ self._artifacts: dict[str, dict[str, Any]] = {}
115
+
116
+ # ------------------------------------------------------------------
117
+ # Path safety
118
+ # ------------------------------------------------------------------
119
+
120
+ def _resolve_target(self, ref_path: Path) -> Path:
121
+ """Validate and resolve *ref_path* to an absolute path under ``millforge/``.
122
+
123
+ *ref_path* is interpreted as relative to the run directory.
124
+ The resolved path must fall within ``run_directory/millforge/``.
125
+
126
+ Raises
127
+ ------
128
+ ArtifactWriteError
129
+ On any path safety violation.
130
+ """
131
+ # 1. Reject absolute paths — all artifact paths must be relative
132
+ if ref_path.is_absolute():
133
+ raise ArtifactWriteError(f"Absolute artifact path rejected: {ref_path!r}")
134
+
135
+ # 2. Check for '..' components (path traversal)
136
+ parts = ref_path.parts
137
+ if ".." in parts:
138
+ raise ArtifactWriteError(
139
+ f"Path traversal rejected: {ref_path!r} contains '..'"
140
+ )
141
+
142
+ # 3. Build candidate by resolving the ref path against the run directory.
143
+ # This handles both bare filenames (e.g. "terminal_result.json") and
144
+ # prefixed paths (e.g. "millforge/terminal_result.json").
145
+ resolved_base = self._millforge_dir.resolve()
146
+ candidate = (self._run_directory / ref_path).resolve()
147
+
148
+ # 4. Verify candidate is under the millforge directory
149
+ try:
150
+ candidate.relative_to(resolved_base)
151
+ except ValueError:
152
+ raise ArtifactWriteError(
153
+ f"Path escape rejected: {ref_path!r} resolves outside "
154
+ f"millforge/ (resolved to {candidate!r})"
155
+ )
156
+
157
+ return candidate
158
+
159
+ # ------------------------------------------------------------------
160
+ # Atomic write
161
+ # ------------------------------------------------------------------
162
+
163
+ @staticmethod
164
+ def _atomic_write(target: Path, content: bytes) -> dict[str, Any]:
165
+ """Write *content* to *target* using atomic file replacement.
166
+
167
+ Creates a temporary file in the same directory as *target*,
168
+ writes *content*, flushes, fsyncs, then renames to *target*.
169
+
170
+ Returns a dict with ``byte_size`` and ``sha256_hex``.
171
+ """
172
+ sha256_hex = hashlib.sha256(content).hexdigest()
173
+ byte_size = len(content)
174
+
175
+ fd: int | None = None
176
+ tmp_path: str | None = None
177
+ try:
178
+ fd, tmp_path = tempfile.mkstemp(
179
+ dir=str(target.parent),
180
+ prefix=f".{target.name}.tmp.",
181
+ )
182
+ with os.fdopen(fd, "wb") as f:
183
+ f.write(content)
184
+ f.flush()
185
+ os.fsync(f.fileno())
186
+
187
+ os.replace(tmp_path, str(target))
188
+ tmp_path = None # successfully renamed — don't clean up
189
+ except OSError as e:
190
+ raise ArtifactWriteError(
191
+ f"Failed to write artifact to {target!r}: {e}",
192
+ cause=e,
193
+ )
194
+ finally:
195
+ if tmp_path is not None:
196
+ try:
197
+ if fd is not None:
198
+ os.close(fd)
199
+ except OSError:
200
+ pass
201
+ try:
202
+ os.unlink(tmp_path)
203
+ except OSError:
204
+ pass
205
+
206
+ return {
207
+ "byte_size": byte_size,
208
+ "sha256_hex": sha256_hex,
209
+ }
210
+
211
+ # ------------------------------------------------------------------
212
+ # Canonical JSON / JSONL serialization
213
+ # ------------------------------------------------------------------
214
+
215
+ @staticmethod
216
+ def _canonical_json(obj: Any) -> str:
217
+ """Serialize *obj* to canonical JSON (sorted keys, no whitespace).
218
+
219
+ Uses the same conventions as ``canonical_json_serialize()`` in
220
+ ``compiled_plan.py`` for consistency.
221
+ """
222
+ try:
223
+ return (
224
+ json.dumps(
225
+ obj,
226
+ sort_keys=True,
227
+ ensure_ascii=True,
228
+ allow_nan=False,
229
+ separators=(",", ":"),
230
+ )
231
+ + "\n"
232
+ )
233
+ except (ValueError, TypeError, OverflowError) as e:
234
+ raise ArtifactWriteError(
235
+ f"JSON serialization failed: {e}",
236
+ cause=e,
237
+ )
238
+
239
+ @staticmethod
240
+ def _serialize_json(data: Any) -> bytes:
241
+ """Serialize *data* to canonical JSON bytes (UTF-8)."""
242
+ return RuntimeArtifactWriter._canonical_json(data).encode("utf-8")
243
+
244
+ @staticmethod
245
+ def _serialize_jsonl(records: list[Any]) -> bytes:
246
+ """Serialize *records* as newline-delimited JSON bytes (UTF-8).
247
+
248
+ Each record is independently serialized as canonical JSON
249
+ followed by a single ``\\n``. Empty input returns empty bytes.
250
+ Partial final lines are impossible because serialization is
251
+ all-or-nothing.
252
+ """
253
+ if not records:
254
+ return b""
255
+
256
+ lines: list[bytes] = []
257
+ for i, record in enumerate(records):
258
+ try:
259
+ text = (
260
+ json.dumps(
261
+ record,
262
+ sort_keys=True,
263
+ ensure_ascii=True,
264
+ allow_nan=False,
265
+ separators=(",", ":"),
266
+ )
267
+ + "\n"
268
+ )
269
+ lines.append(text.encode("utf-8"))
270
+ except (ValueError, TypeError, OverflowError) as e:
271
+ raise ArtifactWriteError(
272
+ f"JSONL serialization failed at record {i}: {e}",
273
+ cause=e,
274
+ )
275
+
276
+ return b"".join(lines)
277
+
278
+ # ------------------------------------------------------------------
279
+ # Internal write helper
280
+ # ------------------------------------------------------------------
281
+
282
+ def _write_artifact(
283
+ self,
284
+ ref: ArtifactRef,
285
+ content: bytes,
286
+ media_type: str,
287
+ ) -> None:
288
+ """Validate path, write atomically, and track the artifact."""
289
+ target = self._resolve_target(ref.path)
290
+ target.parent.mkdir(parents=True, exist_ok=True)
291
+ meta = self._atomic_write(target, content)
292
+
293
+ self._artifacts[ref.artifact_id] = {
294
+ "path": str(ref.path),
295
+ "media_type": ref.content_type or media_type,
296
+ "byte_size": meta["byte_size"],
297
+ "sha256_hex": meta["sha256_hex"],
298
+ "complete": True,
299
+ "producer": self._producer,
300
+ "failure_code": None,
301
+ }
302
+
303
+ @staticmethod
304
+ def _validate_model(model: type[BaseModel], data: Any) -> dict[str, Any]:
305
+ try:
306
+ if isinstance(data, model):
307
+ instance = data
308
+ else:
309
+ instance = model.model_validate(data)
310
+ return instance.model_dump(mode="json")
311
+ except Exception as e:
312
+ raise ArtifactWriteError(
313
+ f"{model.__name__} validation failed: {e}",
314
+ cause=e,
315
+ )
316
+
317
+ @staticmethod
318
+ def _json_object_no_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
319
+ seen: set[str] = set()
320
+ result: dict[str, Any] = {}
321
+ for key, value in pairs:
322
+ if key in seen:
323
+ raise json.JSONDecodeError(f"Duplicate key {key!r}", "", 0)
324
+ seen.add(key)
325
+ result[key] = value
326
+ return result
327
+
328
+ # ------------------------------------------------------------------
329
+ # Public protocol methods
330
+ # ------------------------------------------------------------------
331
+
332
+ async def write_terminal_result(self, ref: ArtifactRef, data: Any) -> None:
333
+ """Write ``terminal_result.json``.
334
+
335
+ Written only for legal domain terminal or rejected results;
336
+ not for infrastructure, cancellation, or timeout failures.
337
+ """
338
+ payload = self._validate_model(TerminalResultArtifact, data)
339
+ content = self._serialize_json(payload)
340
+ self._write_artifact(ref, content, JSON_MEDIA_TYPE)
341
+
342
+ async def write_execution_summary(self, ref: ArtifactRef, data: Any) -> None:
343
+ """Write ``execution_summary.json``.
344
+
345
+ Written for every return path.
346
+ """
347
+ payload = self._validate_model(ExecutionSummaryArtifact, data)
348
+ content = self._serialize_json(payload)
349
+ self._write_artifact(ref, content, JSON_MEDIA_TYPE)
350
+
351
+ async def write_events(self, ref: ArtifactRef, data: Any) -> None:
352
+ """Write ``events.jsonl`` (JSONL format).
353
+
354
+ *data* should be a list of JSON-serializable objects, each
355
+ representing a ``SessionEvent`` record. Each record becomes
356
+ one line of valid JSON followed by ``\\n``.
357
+ """
358
+ if not isinstance(data, list | tuple):
359
+ raise ArtifactWriteError(
360
+ f"Expected a list for JSONL artifact {ref.artifact_id!r}, "
361
+ f"got {type(data).__name__}"
362
+ )
363
+ records = [self._validate_model(SessionEvent, item) for item in data]
364
+ content = self._serialize_jsonl(records)
365
+ self._write_artifact(ref, content, NDJSON_MEDIA_TYPE)
366
+
367
+ async def write_tool_trace(self, ref: ArtifactRef, data: Any) -> None:
368
+ """Write ``tool_trace.jsonl`` (JSONL format).
369
+
370
+ *data* should be a list of JSON-serializable objects, each
371
+ representing a ``ToolTraceRecord`` record. Each record becomes
372
+ one line of valid JSON followed by ``\\n``.
373
+ """
374
+ if not isinstance(data, list | tuple):
375
+ raise ArtifactWriteError(
376
+ f"Expected a list for JSONL artifact {ref.artifact_id!r}, "
377
+ f"got {type(data).__name__}"
378
+ )
379
+ records = [self._validate_model(ToolTraceRecord, item) for item in data]
380
+ content = self._serialize_jsonl(records)
381
+ self._write_artifact(ref, content, NDJSON_MEDIA_TYPE)
382
+
383
+ async def write_metrics(self, ref: ArtifactRef, data: Any) -> None:
384
+ """Write ``metrics.json``.
385
+
386
+ Written for every return path.
387
+ """
388
+ payload = self._validate_model(MetricsArtifact, data)
389
+ content = self._serialize_json(payload)
390
+ self._write_artifact(ref, content, JSON_MEDIA_TYPE)
391
+
392
+ async def write_artifact_manifest(self, ref: ArtifactRef, data: Any) -> None:
393
+ """Write ``artifact_manifest.json``.
394
+
395
+ Enriches the provided *data* with computed ``sha256_hex``,
396
+ ``byte_size``, ``complete``, and ``producer`` values for each
397
+ artifact tracked by this writer. The manifest does **not**
398
+ include an entry for itself.
399
+
400
+ *data* should be a dict with at least an ``"artifacts"`` key
401
+ whose value is a list of dicts, each containing at minimum
402
+ ``"artifact_id"``. The writer injects the computed metadata
403
+ into each dict.
404
+ """
405
+ target = self._resolve_target(ref.path)
406
+
407
+ # Enrich the caller-provided data with computed values
408
+ enriched = self._validate_model(
409
+ ArtifactManifestArtifact, self._enrich_manifest_data(data)
410
+ )
411
+
412
+ content = self._serialize_json(enriched)
413
+ meta = self._atomic_write(target, content)
414
+
415
+ # The manifest is tracked as written but is NOT added to
416
+ # _artifacts for manifest listing (it must not include itself).
417
+ # Store only path-level info for later queries.
418
+ self._manifest_path = str(ref.path)
419
+ self._manifest_media_type = ref.content_type or JSON_MEDIA_TYPE
420
+ self._manifest_byte_size = meta["byte_size"]
421
+ self._manifest_sha256 = meta["sha256_hex"]
422
+
423
+ async def write_diagnostic(self, ref: ArtifactRef, data: Any) -> None:
424
+ """Write ``diagnostic.json``.
425
+
426
+ Written only when a sanitized diagnostic is present.
427
+ """
428
+ if isinstance(data, DiagnosticMetadata):
429
+ data = {"schema_version": "1.0", "diagnostic": data.model_dump(mode="json")}
430
+ payload = self._validate_model(DiagnosticArtifact, data)
431
+ content = self._serialize_json(payload)
432
+ self._write_artifact(ref, content, JSON_MEDIA_TYPE)
433
+
434
+ # ------------------------------------------------------------------
435
+ # Manifest enrichment
436
+ # ------------------------------------------------------------------
437
+
438
+ def _enrich_manifest_data(self, data: Any) -> Any:
439
+ """Inject computed artifact metadata into manifest data.
440
+
441
+ If *data* is a dict containing an ``"artifacts"`` list of dicts,
442
+ the list is replaced with the writer's tracked artifacts in
443
+ deterministic artifact-id order. This guarantees manifest
444
+ completeness for the files written by this writer and prevents
445
+ caller-supplied stale metadata.
446
+
447
+ Any artifact entries not tracked by this writer are left
448
+ unchanged (they will lack computed fields).
449
+ """
450
+ if not isinstance(data, dict) or "artifacts" not in data:
451
+ return data
452
+
453
+ requested_ids = {
454
+ item["artifact_id"]
455
+ for item in data["artifacts"]
456
+ if isinstance(item, dict) and isinstance(item.get("artifact_id"), str)
457
+ }
458
+ artifact_ids = sorted(set(self._artifacts) | requested_ids)
459
+ missing = [
460
+ artifact_id
461
+ for artifact_id in artifact_ids
462
+ if artifact_id not in self._artifacts
463
+ ]
464
+ if missing:
465
+ raise ArtifactWriteError(
466
+ "Manifest references unwritten artifact(s): " + ", ".join(missing)
467
+ )
468
+
469
+ enriched_artifacts: list[dict[str, Any]] = []
470
+ for artifact_id in artifact_ids:
471
+ tracked = self._artifacts[artifact_id]
472
+ enriched_artifacts.append(
473
+ {
474
+ "artifact_id": artifact_id,
475
+ "path": tracked["path"],
476
+ "media_type": tracked["media_type"],
477
+ "byte_size": tracked["byte_size"],
478
+ "sha256_hex": tracked["sha256_hex"],
479
+ "complete": tracked["complete"],
480
+ "producer": tracked["producer"],
481
+ "failure_code": tracked["failure_code"],
482
+ }
483
+ )
484
+
485
+ result = dict(data)
486
+ result.setdefault("schema_version", "1.0")
487
+ result["artifacts"] = enriched_artifacts
488
+ return result
489
+
490
+ # ------------------------------------------------------------------
491
+ # JSONL readers
492
+ # ------------------------------------------------------------------
493
+
494
+ def _read_jsonl_models(
495
+ self, ref: ArtifactRef, model: type[BaseModel]
496
+ ) -> tuple[BaseModel, ...]:
497
+ target = self._resolve_target(ref.path)
498
+ try:
499
+ content = target.read_bytes()
500
+ except OSError as e:
501
+ raise ArtifactWriteError(
502
+ f"Failed to read artifact from {target!r}: {e}",
503
+ cause=e,
504
+ )
505
+
506
+ if content and not content.endswith(b"\n"):
507
+ raise ArtifactWriteError(
508
+ f"JSONL artifact {ref.artifact_id!r} has a partial final line"
509
+ )
510
+
511
+ records: list[BaseModel] = []
512
+ for line_number, line in enumerate(content.splitlines(), start=1):
513
+ if not line:
514
+ raise ArtifactWriteError(
515
+ f"JSONL artifact {ref.artifact_id!r} has an empty line "
516
+ f"at {line_number}"
517
+ )
518
+ try:
519
+ parsed = json.loads(
520
+ line.decode("utf-8"),
521
+ object_pairs_hook=self._json_object_no_duplicate_keys,
522
+ )
523
+ records.append(model.model_validate(parsed))
524
+ except Exception as e:
525
+ raise ArtifactWriteError(
526
+ f"JSONL artifact {ref.artifact_id!r} invalid at line "
527
+ f"{line_number}: {e}",
528
+ cause=e,
529
+ )
530
+ return tuple(records)
531
+
532
+ def read_events(self, ref: ArtifactRef) -> tuple[SessionEvent, ...]:
533
+ """Read and validate an ``events.jsonl`` artifact."""
534
+ records = self._read_jsonl_models(ref, SessionEvent)
535
+ return tuple(record for record in records if isinstance(record, SessionEvent))
536
+
537
+ def read_tool_trace(self, ref: ArtifactRef) -> tuple[ToolTraceRecord, ...]:
538
+ """Read and validate a ``tool_trace.jsonl`` artifact."""
539
+ records = self._read_jsonl_models(ref, ToolTraceRecord)
540
+ return tuple(
541
+ record for record in records if isinstance(record, ToolTraceRecord)
542
+ )
543
+
544
+ # ------------------------------------------------------------------
545
+ # Public accessors
546
+ # ------------------------------------------------------------------
547
+
548
+ @property
549
+ def tracked_artifacts(self) -> dict[str, dict[str, Any]]:
550
+ """Return a shallow copy of artifacts tracked by this writer.
551
+
552
+ Each value is a dict with keys ``path``, ``media_type``,
553
+ ``byte_size``, ``sha256_hex``, ``complete``, ``producer``,
554
+ ``failure_code``.
555
+ """
556
+ return dict(self._artifacts)
557
+
558
+ @property
559
+ def millforge_dir(self) -> Path:
560
+ """Return the resolved millforge output directory."""
561
+ return self._millforge_dir
562
+
563
+
564
+ __all__: list[str] = [
565
+ "NDJSON_MEDIA_TYPE",
566
+ "JSON_MEDIA_TYPE",
567
+ "STANDARD_ARTIFACT_FILENAMES",
568
+ "STANDARD_ARTIFACT_IDS",
569
+ "RuntimeArtifactWriter",
570
+ ]
@@ -0,0 +1,97 @@
1
+ from millforge.base.composition import (
2
+ MillforgeBaseComponents,
3
+ MillforgeBaseMetadata,
4
+ create_millforge_base_components,
5
+ )
6
+ from millforge.base.context import (
7
+ MillforgeBaseContextFile,
8
+ MillforgeBaseContextSnapshot,
9
+ load_millforge_base_context,
10
+ )
11
+ from millforge.base.harness import millforge_base_harness_source
12
+ from millforge.base.identity import (
13
+ MillforgeBaseRunnerDescriptor,
14
+ MillforgeInvocationEvidence,
15
+ describe_millforge_base,
16
+ )
17
+ from millforge.base.options import MillforgeBaseOptions
18
+ from millforge.base.prompt import (
19
+ MillforgeBasePromptBudgetError,
20
+ MillforgeBasePromptSnapshot,
21
+ build_millforge_base_system_prompt,
22
+ )
23
+ from millforge.base.runner import (
24
+ MillforgeBaseBindingError,
25
+ MillforgeBaseLiveRunner,
26
+ MillforgeBaseRunner,
27
+ MillforgeBaseRuntimeServices,
28
+ RuntimeArtifactWriterFactory,
29
+ create_millforge_base_live_runner,
30
+ create_millforge_base_runner,
31
+ default_runtime_artifact_writer_factory,
32
+ )
33
+ from millforge.model_backend import (
34
+ AuthenticationPolicy,
35
+ AuthenticationScheme,
36
+ CapabilityDeclarations,
37
+ CapabilitySupport,
38
+ EndpointConfig,
39
+ HeaderValuePolicy,
40
+ ModelBackendConfigError,
41
+ OpenAICompatibleTimeouts,
42
+ ReasoningEffort,
43
+ ReasoningMode,
44
+ ReasoningPolicy,
45
+ RequestOptionAllowlist,
46
+ ResolvedModelProfile,
47
+ ResolvedSecret,
48
+ SamplingPolicy,
49
+ SecretResolutionError,
50
+ SecretResolver,
51
+ TransportConfig,
52
+ UnsupportedModelCapabilityError,
53
+ )
54
+
55
+ __all__ = [
56
+ "MillforgeBaseOptions",
57
+ "MillforgeBaseContextFile",
58
+ "MillforgeBaseContextSnapshot",
59
+ "MillforgeBasePromptSnapshot",
60
+ "MillforgeBasePromptBudgetError",
61
+ "MillforgeBaseMetadata",
62
+ "MillforgeBaseComponents",
63
+ "MillforgeBaseRunnerDescriptor",
64
+ "MillforgeInvocationEvidence",
65
+ "load_millforge_base_context",
66
+ "build_millforge_base_system_prompt",
67
+ "millforge_base_harness_source",
68
+ "create_millforge_base_components",
69
+ "describe_millforge_base",
70
+ "RuntimeArtifactWriterFactory",
71
+ "default_runtime_artifact_writer_factory",
72
+ "MillforgeBaseRuntimeServices",
73
+ "MillforgeBaseBindingError",
74
+ "MillforgeBaseRunner",
75
+ "create_millforge_base_runner",
76
+ "MillforgeBaseLiveRunner",
77
+ "create_millforge_base_live_runner",
78
+ "AuthenticationPolicy",
79
+ "AuthenticationScheme",
80
+ "CapabilityDeclarations",
81
+ "CapabilitySupport",
82
+ "EndpointConfig",
83
+ "HeaderValuePolicy",
84
+ "ModelBackendConfigError",
85
+ "OpenAICompatibleTimeouts",
86
+ "ReasoningEffort",
87
+ "ReasoningMode",
88
+ "ReasoningPolicy",
89
+ "RequestOptionAllowlist",
90
+ "ResolvedModelProfile",
91
+ "ResolvedSecret",
92
+ "SamplingPolicy",
93
+ "SecretResolutionError",
94
+ "SecretResolver",
95
+ "TransportConfig",
96
+ "UnsupportedModelCapabilityError",
97
+ ]