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.
- millforge/__init__.py +1174 -0
- millforge/_forge/LICENSE +21 -0
- millforge/_forge/PROVENANCE.json +295 -0
- millforge/_forge/UPDATE_POLICY.md +24 -0
- millforge/_forge/__init__.py +14 -0
- millforge/_forge/adapter.py +2232 -0
- millforge/_forge/base_runner.py +121 -0
- millforge/_forge/clients/__init__.py +10 -0
- millforge/_forge/clients/base.py +200 -0
- millforge/_forge/context/__init__.py +23 -0
- millforge/_forge/context/manager.py +178 -0
- millforge/_forge/context/strategies.py +335 -0
- millforge/_forge/core/__init__.py +16 -0
- millforge/_forge/core/inference.py +433 -0
- millforge/_forge/core/messages.py +119 -0
- millforge/_forge/core/runner.py +479 -0
- millforge/_forge/core/steps.py +108 -0
- millforge/_forge/core/workflow.py +400 -0
- millforge/_forge/errors.py +222 -0
- millforge/_forge/guardrails/__init__.py +21 -0
- millforge/_forge/guardrails/error_tracker.py +71 -0
- millforge/_forge/guardrails/guardrails.py +194 -0
- millforge/_forge/guardrails/nudge.py +47 -0
- millforge/_forge/guardrails/response_validator.py +119 -0
- millforge/_forge/guardrails/step_enforcer.py +183 -0
- millforge/_forge/prompts/__init__.py +16 -0
- millforge/_forge/prompts/nudges.py +95 -0
- millforge/_forge/prompts/templates.py +285 -0
- millforge/_version.py +3 -0
- millforge/artifacts.py +570 -0
- millforge/base/__init__.py +97 -0
- millforge/base/composition.py +402 -0
- millforge/base/context.py +285 -0
- millforge/base/harness.py +138 -0
- millforge/base/identity.py +465 -0
- millforge/base/options.py +34 -0
- millforge/base/platform.py +17 -0
- millforge/base/prompt.py +317 -0
- millforge/base/runner.py +546 -0
- millforge/compiled_plan.py +970 -0
- millforge/compiler/__init__.py +231 -0
- millforge/compiler/artifact_validation.py +257 -0
- millforge/compiler/canonicalization.py +169 -0
- millforge/compiler/capabilities.py +66 -0
- millforge/compiler/catalogs.py +500 -0
- millforge/compiler/diagnostics.py +491 -0
- millforge/compiler/graph.py +678 -0
- millforge/compiler/lowering.py +198 -0
- millforge/compiler/output.py +692 -0
- millforge/compiler/parsing.py +1424 -0
- millforge/compiler/requests.py +1180 -0
- millforge/compiler/schema_validation.py +272 -0
- millforge/compiler/semantic.py +490 -0
- millforge/compiler/service.py +448 -0
- millforge/compiler/source.py +375 -0
- millforge/compiler/validators.py +184 -0
- millforge/connectors/__init__.py +95 -0
- millforge/connectors/admission.py +801 -0
- millforge/connectors/broker.py +202 -0
- millforge/connectors/contracts.py +1159 -0
- millforge/connectors/diagnostics.py +189 -0
- millforge/connectors/fake.py +66 -0
- millforge/connectors/runtime.py +236 -0
- millforge/contracts.py +2860 -0
- millforge/custom_tools/__init__.py +67 -0
- millforge/custom_tools/compiler.py +724 -0
- millforge/custom_tools/contracts.py +1093 -0
- millforge/custom_tools/diagnostics.py +205 -0
- millforge/eval_artifacts.py +952 -0
- millforge/eval_boundary.py +2435 -0
- millforge/eval_fixtures/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
- millforge/eval_fixtures/default_pack/manifest.json +12 -0
- millforge/eval_modes.py +1282 -0
- millforge/eval_presets.py +1398 -0
- millforge/eval_reports.py +2517 -0
- millforge/eval_suite.py +2429 -0
- millforge/eval_trials.py +2632 -0
- millforge/eval_workflow.py +794 -0
- millforge/exceptions.py +122 -0
- millforge/model_backend.py +2098 -0
- millforge/protocols.py +340 -0
- millforge/py.typed +0 -0
- millforge/runtime.py +1791 -0
- millforge/testing/__init__.py +1089 -0
- millforge/tools/__init__.py +83 -0
- millforge/tools/builtin_runtime.py +1339 -0
- millforge/tools/builtins.py +773 -0
- millforge/tools/execution.py +1545 -0
- millforge/tools/path_policy.py +155 -0
- millforge/tools/pi_compat/PI_LICENSE +21 -0
- millforge/tools/pi_compat/PROVENANCE.json +55 -0
- millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
- millforge/tools/pi_compat/__init__.py +34 -0
- millforge/tools/pi_compat/contracts.py +49 -0
- millforge/tools/pi_compat/editing.py +390 -0
- millforge/tools/pi_compat/mutations.py +57 -0
- millforge/tools/pi_compat/operations.py +401 -0
- millforge/tools/pi_compat/paths.py +155 -0
- millforge/tools/pi_compat/process.py +1375 -0
- millforge/tools/pi_compat/search.py +738 -0
- millforge/tools/pi_compat/truncation.py +267 -0
- millforge/tools/pi_compat_catalog.py +396 -0
- millforge/tools/pi_compat_runtime.py +460 -0
- millforge/tools/registry.py +553 -0
- millforge/tools/results.py +533 -0
- millforge-0.1.0.dist-info/METADATA +844 -0
- millforge-0.1.0.dist-info/RECORD +116 -0
- millforge-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|