mklang 0.5.4__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.
- mklang/__init__.py +17 -0
- mklang/checkpoint.py +112 -0
- mklang/cli.py +406 -0
- mklang/config.py +50 -0
- mklang/data/mklang.schema.json +269 -0
- mklang/engine.py +570 -0
- mklang/errors.py +36 -0
- mklang/hooks.py +86 -0
- mklang/interpolate.py +44 -0
- mklang/lint.py +140 -0
- mklang/llm/__init__.py +5 -0
- mklang/llm/anthropic.py +129 -0
- mklang/llm/base.py +110 -0
- mklang/llm/mock.py +47 -0
- mklang/llm/openai_compat.py +137 -0
- mklang/loader.py +174 -0
- mklang/model.py +103 -0
- mklang/providers.py +75 -0
- mklang/registry.py +22 -0
- mklang/scripttest.py +219 -0
- mklang/tools.py +135 -0
- mklang-0.5.4.dist-info/METADATA +265 -0
- mklang-0.5.4.dist-info/RECORD +26 -0
- mklang-0.5.4.dist-info/WHEEL +4 -0
- mklang-0.5.4.dist-info/entry_points.txt +18 -0
- mklang-0.5.4.dist-info/licenses/LICENSE +201 -0
mklang/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""mklang — reference interpreter for the mklang language (core v0.2)."""
|
|
2
|
+
|
|
3
|
+
from .checkpoint import load_checkpoint, save_checkpoint
|
|
4
|
+
from .model import Gate, Machine, State, parse_machine
|
|
5
|
+
from .engine import RunResult, run
|
|
6
|
+
|
|
7
|
+
__version__ = "0.5.4"
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Gate",
|
|
10
|
+
"Machine",
|
|
11
|
+
"State",
|
|
12
|
+
"parse_machine",
|
|
13
|
+
"run",
|
|
14
|
+
"RunResult",
|
|
15
|
+
"load_checkpoint",
|
|
16
|
+
"save_checkpoint",
|
|
17
|
+
]
|
mklang/checkpoint.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Checkpoint frames and envelope I/O for resumable runs (ADR 0007)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
FORMAT = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _write_private(path: str | Path, text: str) -> None:
|
|
15
|
+
"""Write text with owner-only (0600) permissions.
|
|
16
|
+
|
|
17
|
+
A checkpoint serializes the FULL blackboard — customer text, PII, internal
|
|
18
|
+
policy — as plaintext JSON, and HITL suspends precisely on the most sensitive
|
|
19
|
+
cases (escalations), so these files linger longest exactly when they matter
|
|
20
|
+
most (SPEC §11). Encryption at rest is a host concern and an explicit v0.2
|
|
21
|
+
non-goal; owner-only permissions are the cheap, real baseline. Create the file
|
|
22
|
+
restricted from the start (no world-readable window) and chmod to cover a
|
|
23
|
+
pre-existing file whose mode `os.open` would not tighten. POSIX-only: on
|
|
24
|
+
Windows the mode is advisory and chmod may be a no-op."""
|
|
25
|
+
p = Path(path)
|
|
26
|
+
fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
27
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
28
|
+
f.write(text)
|
|
29
|
+
try:
|
|
30
|
+
os.chmod(p, 0o600)
|
|
31
|
+
except (OSError, NotImplementedError): # non-POSIX / unsupported filesystem
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def encode_repair(repair_left: dict[tuple[str, int], int]) -> list[list]:
|
|
36
|
+
"""Tuple-keyed repair budgets → JSON-safe [state_id, gate_idx, remaining] triples."""
|
|
37
|
+
return [[sid, gi, n] for (sid, gi), n in repair_left.items()]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def decode_repair(triples: list) -> dict[tuple[str, int], int]:
|
|
41
|
+
return {(sid, gi): n for sid, gi, n in triples}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_frame(
|
|
45
|
+
machine_name: str,
|
|
46
|
+
state_id: str,
|
|
47
|
+
ctx: dict,
|
|
48
|
+
steps: int,
|
|
49
|
+
total_in: int,
|
|
50
|
+
total_out: int,
|
|
51
|
+
feedback: str,
|
|
52
|
+
repair_left: dict[tuple[str, int], int],
|
|
53
|
+
trace: list[dict],
|
|
54
|
+
) -> dict:
|
|
55
|
+
"""Snapshot one run() loop-top: everything needed to re-enter the loop."""
|
|
56
|
+
return {
|
|
57
|
+
"machine": machine_name,
|
|
58
|
+
"state": state_id,
|
|
59
|
+
"ctx": dict(ctx),
|
|
60
|
+
"steps": steps,
|
|
61
|
+
"total_in": total_in,
|
|
62
|
+
"total_out": total_out,
|
|
63
|
+
"feedback": feedback,
|
|
64
|
+
"repair_left": encode_repair(repair_left),
|
|
65
|
+
"trace": list(trace),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def file_sha256(path: str | Path) -> str:
|
|
70
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def save_checkpoint(
|
|
74
|
+
path: str | Path,
|
|
75
|
+
machine_name: str,
|
|
76
|
+
machine_path: str | Path,
|
|
77
|
+
reason: str,
|
|
78
|
+
frames: list[dict],
|
|
79
|
+
cost_budget: int | None,
|
|
80
|
+
hitl: bool = False,
|
|
81
|
+
) -> None:
|
|
82
|
+
from . import __version__ # runtime import: __init__ imports engine imports this module
|
|
83
|
+
|
|
84
|
+
envelope = {
|
|
85
|
+
"format": FORMAT,
|
|
86
|
+
"mklang_version": __version__,
|
|
87
|
+
"created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
88
|
+
"machine": machine_name,
|
|
89
|
+
"machine_path": str(machine_path),
|
|
90
|
+
"machine_sha256": file_sha256(machine_path),
|
|
91
|
+
"reason": reason,
|
|
92
|
+
"cost_budget": cost_budget,
|
|
93
|
+
"hitl": hitl,
|
|
94
|
+
"frames": frames,
|
|
95
|
+
}
|
|
96
|
+
_write_private(path, json.dumps(envelope, ensure_ascii=False, indent=2))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_checkpoint(path: str | Path) -> dict:
|
|
100
|
+
ck = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
101
|
+
if not isinstance(ck, dict) or ck.get("format") != FORMAT:
|
|
102
|
+
raise ValueError(f"not an mklang checkpoint (expected format {FORMAT})")
|
|
103
|
+
for key in ("machine", "machine_path", "machine_sha256", "frames"):
|
|
104
|
+
if key not in ck:
|
|
105
|
+
raise ValueError(f"checkpoint missing key {key!r}")
|
|
106
|
+
if not ck["frames"]:
|
|
107
|
+
raise ValueError("checkpoint has no frames")
|
|
108
|
+
return ck
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def verify_hash(ck: dict, machine_path: str | Path) -> bool:
|
|
112
|
+
return file_sha256(machine_path) == ck["machine_sha256"]
|
mklang/cli.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""`mklang` command-line interface: run and check machines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .checkpoint import load_checkpoint, save_checkpoint, verify_hash
|
|
11
|
+
from .config import load_provider
|
|
12
|
+
from .engine import run
|
|
13
|
+
from .loader import check_tiers, load_machine, semantic_check
|
|
14
|
+
from .registry import load_registry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_llm(prov):
|
|
18
|
+
from .providers import build_llm
|
|
19
|
+
|
|
20
|
+
return build_llm(prov)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _coerce(value: str):
|
|
24
|
+
"""JSON-parse a --set value (so lists/objects/numbers work); fall back to str."""
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(value)
|
|
27
|
+
except (ValueError, TypeError):
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _apply_sets(ctx: dict, sets: list[str]) -> dict:
|
|
32
|
+
for kv in sets or []:
|
|
33
|
+
key, value = kv.split("=", 1)
|
|
34
|
+
cur = ctx
|
|
35
|
+
parts = key.split(".")
|
|
36
|
+
for p in parts[:-1]:
|
|
37
|
+
nxt = cur.get(p)
|
|
38
|
+
if not isinstance(nxt, dict):
|
|
39
|
+
nxt = {}
|
|
40
|
+
cur[p] = nxt
|
|
41
|
+
cur = nxt
|
|
42
|
+
cur[parts[-1]] = _coerce(value)
|
|
43
|
+
return ctx
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _prepare(args, machine_path: str):
|
|
47
|
+
"""Shared run/resume setup. Returns (prov, llm, registry, machine, tools, hooks) or exit code."""
|
|
48
|
+
prov = load_provider(args.config, args.provider)
|
|
49
|
+
if not prov.api_key and prov.name != "local":
|
|
50
|
+
print(f"# warning: no API key for provider '{prov.name}' — set it in .env", file=sys.stderr)
|
|
51
|
+
llm = _build_llm(prov)
|
|
52
|
+
registry = load_registry(Path(machine_path).parent, validate=False)
|
|
53
|
+
try:
|
|
54
|
+
machine = load_machine(machine_path)
|
|
55
|
+
except Exception as e: # noqa: BLE001 — surface load/validation failure cleanly
|
|
56
|
+
print(f"{machine_path}: ERROR: {getattr(e, 'message', str(e))}", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
registry[machine.name] = machine
|
|
59
|
+
errors, warnings = semantic_check(machine, registry, strict=getattr(args, "strict", False))
|
|
60
|
+
errors.extend(check_tiers(machine, prov.tiers))
|
|
61
|
+
for w in warnings:
|
|
62
|
+
print(f"# warning: {w}", file=sys.stderr)
|
|
63
|
+
if errors:
|
|
64
|
+
for e in errors:
|
|
65
|
+
print(f"{machine_path}: error: {e}", file=sys.stderr)
|
|
66
|
+
return 2
|
|
67
|
+
from .hooks import load_hook_registry
|
|
68
|
+
from .tools import load_tool_registry
|
|
69
|
+
|
|
70
|
+
tools = load_tool_registry()
|
|
71
|
+
hooks = load_hook_registry()
|
|
72
|
+
for sid, s in machine.states.items():
|
|
73
|
+
if s.kind == "tool" and s.tool not in tools:
|
|
74
|
+
print(
|
|
75
|
+
f"# warning: state '{sid}' uses tool '{s.tool}' not in the registry "
|
|
76
|
+
f"{sorted(tools)} — the run halts if it is reached",
|
|
77
|
+
file=sys.stderr,
|
|
78
|
+
)
|
|
79
|
+
for g in s.gates:
|
|
80
|
+
if g.hook and g.hook not in hooks:
|
|
81
|
+
print(
|
|
82
|
+
f"# warning: state '{sid}' uses hook '{g.hook}' not in the registry "
|
|
83
|
+
f"{sorted(hooks)} — the run halts if it is reached",
|
|
84
|
+
file=sys.stderr,
|
|
85
|
+
)
|
|
86
|
+
return prov, llm, registry, machine, tools, hooks
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _emit(res, checkpoint_path, machine, machine_path, cost_budget, hitl=False) -> int:
|
|
90
|
+
"""Print the result JSON; write a checkpoint on suspension. Exit: 0 done, 3 suspended, 1 halt."""
|
|
91
|
+
out = {
|
|
92
|
+
"status": res.status,
|
|
93
|
+
"error": res.error,
|
|
94
|
+
"result": res.result,
|
|
95
|
+
"usage": res.usage,
|
|
96
|
+
"trace": res.trace,
|
|
97
|
+
}
|
|
98
|
+
if res.at is not None:
|
|
99
|
+
out["at"] = res.at
|
|
100
|
+
if res.status == "suspended":
|
|
101
|
+
save_checkpoint(
|
|
102
|
+
checkpoint_path, machine.name, machine_path, res.error, res.frames, cost_budget, hitl
|
|
103
|
+
)
|
|
104
|
+
out["checkpoint"] = str(checkpoint_path)
|
|
105
|
+
print(
|
|
106
|
+
f"# suspended ({res.error}) — checkpoint written to {checkpoint_path}", file=sys.stderr
|
|
107
|
+
)
|
|
108
|
+
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
109
|
+
if res.status == "done":
|
|
110
|
+
return 0
|
|
111
|
+
return 3 if res.status == "suspended" else 1
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_run(args) -> int:
|
|
115
|
+
if args.hitl and not args.checkpoint:
|
|
116
|
+
print("--hitl requires --checkpoint (the suspension must land somewhere)", file=sys.stderr)
|
|
117
|
+
return 2
|
|
118
|
+
prep = _prepare(args, args.machine)
|
|
119
|
+
if isinstance(prep, int):
|
|
120
|
+
return prep
|
|
121
|
+
prov, llm, registry, machine, tools, hooks = prep
|
|
122
|
+
ctx = _apply_sets(dict(machine.context), args.set)
|
|
123
|
+
print(f"# {machine.name} · provider={prov.name} · tiers={prov.tiers}", file=sys.stderr)
|
|
124
|
+
res = run(
|
|
125
|
+
machine,
|
|
126
|
+
ctx,
|
|
127
|
+
registry,
|
|
128
|
+
llm,
|
|
129
|
+
prov.tiers,
|
|
130
|
+
prov.judge_override(),
|
|
131
|
+
tier_params=prov.params,
|
|
132
|
+
cost_budget=args.max_tokens,
|
|
133
|
+
tools=tools,
|
|
134
|
+
hooks=hooks,
|
|
135
|
+
suspendable=args.checkpoint is not None,
|
|
136
|
+
escalate_suspend=args.hitl,
|
|
137
|
+
)
|
|
138
|
+
return _emit(res, args.checkpoint, machine, args.machine, args.max_tokens, hitl=args.hitl)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_resume(args) -> int:
|
|
142
|
+
try:
|
|
143
|
+
ck = load_checkpoint(args.checkpoint)
|
|
144
|
+
except (OSError, ValueError) as e:
|
|
145
|
+
print(f"{args.checkpoint}: ERROR: {e}", file=sys.stderr)
|
|
146
|
+
return 2
|
|
147
|
+
machine_path = args.machine or ck["machine_path"]
|
|
148
|
+
try:
|
|
149
|
+
hash_ok = verify_hash(ck, machine_path)
|
|
150
|
+
except OSError as e:
|
|
151
|
+
print(f"{machine_path}: ERROR: {e}", file=sys.stderr)
|
|
152
|
+
return 2
|
|
153
|
+
if not hash_ok:
|
|
154
|
+
if not args.force:
|
|
155
|
+
print(
|
|
156
|
+
f"{machine_path}: ERROR: machine changed since checkpoint "
|
|
157
|
+
f"(sha256 mismatch); use --force to resume anyway",
|
|
158
|
+
file=sys.stderr,
|
|
159
|
+
)
|
|
160
|
+
return 2
|
|
161
|
+
print(
|
|
162
|
+
f"# warning: {machine_path} changed since checkpoint — resuming anyway", file=sys.stderr
|
|
163
|
+
)
|
|
164
|
+
prep = _prepare(args, machine_path)
|
|
165
|
+
if isinstance(prep, int):
|
|
166
|
+
return prep
|
|
167
|
+
prov, llm, registry, machine, tools, hooks = prep
|
|
168
|
+
cost_budget = args.max_tokens if args.max_tokens is not None else ck.get("cost_budget")
|
|
169
|
+
if ck.get("reason") == "cost-exhausted" and cost_budget is not None:
|
|
170
|
+
old = ck.get("cost_budget")
|
|
171
|
+
if old is not None and cost_budget <= old:
|
|
172
|
+
print(
|
|
173
|
+
f"# warning: cost budget {cost_budget} is not above the exhausted "
|
|
174
|
+
f"{old} — the run will suspend again immediately",
|
|
175
|
+
file=sys.stderr,
|
|
176
|
+
)
|
|
177
|
+
out_path = args.checkpoint_out or args.checkpoint
|
|
178
|
+
hitl = ck.get("hitl", False) or args.hitl
|
|
179
|
+
# A human reply lands in the innermost frame's context (the suspended run).
|
|
180
|
+
_apply_sets(ck["frames"][-1]["ctx"], args.set)
|
|
181
|
+
print(f"# {machine.name} · resume · provider={prov.name} · tiers={prov.tiers}", file=sys.stderr)
|
|
182
|
+
res = run(
|
|
183
|
+
machine,
|
|
184
|
+
dict(machine.context),
|
|
185
|
+
registry,
|
|
186
|
+
llm,
|
|
187
|
+
prov.tiers,
|
|
188
|
+
prov.judge_override(),
|
|
189
|
+
tier_params=prov.params,
|
|
190
|
+
cost_budget=cost_budget,
|
|
191
|
+
tools=tools,
|
|
192
|
+
hooks=hooks,
|
|
193
|
+
suspendable=True,
|
|
194
|
+
escalate_suspend=hitl,
|
|
195
|
+
resume=ck["frames"],
|
|
196
|
+
)
|
|
197
|
+
return _emit(res, out_path, machine, machine_path, cost_budget, hitl=hitl)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_lint(args) -> int:
|
|
201
|
+
from .lint import lint_machine
|
|
202
|
+
|
|
203
|
+
ok = True
|
|
204
|
+
findings_total = 0
|
|
205
|
+
for path in args.machines:
|
|
206
|
+
registry = load_registry(Path(path).parent, validate=False)
|
|
207
|
+
try:
|
|
208
|
+
machine = load_machine(path)
|
|
209
|
+
except Exception as e: # noqa: BLE001 — surface any load/validation failure
|
|
210
|
+
print(f"{path}: SCHEMA ERROR: {getattr(e, 'message', str(e))}")
|
|
211
|
+
ok = False
|
|
212
|
+
continue
|
|
213
|
+
errors, warnings = semantic_check(machine, registry, strict=args.strict)
|
|
214
|
+
findings = lint_machine(machine)
|
|
215
|
+
findings_total += len(findings)
|
|
216
|
+
for w in warnings:
|
|
217
|
+
print(f"{path}: warning: {w}")
|
|
218
|
+
for e in errors:
|
|
219
|
+
print(f"{path}: error: {e}")
|
|
220
|
+
for f in findings:
|
|
221
|
+
print(f"{path}: lint: {f}")
|
|
222
|
+
if errors:
|
|
223
|
+
ok = False
|
|
224
|
+
elif not findings:
|
|
225
|
+
print(f"{path}: ok")
|
|
226
|
+
if not ok:
|
|
227
|
+
return 1
|
|
228
|
+
return 1 if (args.strict and findings_total) else 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_test(args) -> int:
|
|
232
|
+
"""Run scenario tests against a machine with a scripted LLM (no API keys)."""
|
|
233
|
+
import yaml
|
|
234
|
+
|
|
235
|
+
from .scripttest import match_expectation, run_scenario
|
|
236
|
+
|
|
237
|
+
registry = load_registry(Path(args.machine).parent, validate=False)
|
|
238
|
+
try:
|
|
239
|
+
machine = load_machine(args.machine)
|
|
240
|
+
except Exception as e: # noqa: BLE001 — surface any load/validation failure
|
|
241
|
+
print(f"{args.machine}: SCHEMA ERROR: {getattr(e, 'message', str(e))}", file=sys.stderr)
|
|
242
|
+
return 2
|
|
243
|
+
registry[machine.name] = machine
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
doc = yaml.safe_load(Path(args.script).read_text(encoding="utf-8"))
|
|
247
|
+
except (OSError, yaml.YAMLError) as e:
|
|
248
|
+
print(f"{args.script}: ERROR: {e}", file=sys.stderr)
|
|
249
|
+
return 2
|
|
250
|
+
scenarios = (doc or {}).get("scenarios")
|
|
251
|
+
if not scenarios:
|
|
252
|
+
print(f"{args.script}: ERROR: no `scenarios:` list", file=sys.stderr)
|
|
253
|
+
return 2
|
|
254
|
+
|
|
255
|
+
all_pass = True
|
|
256
|
+
for i, sc in enumerate(scenarios):
|
|
257
|
+
name = sc.get("name", f"scenario[{i}]")
|
|
258
|
+
expect = sc.get("expect")
|
|
259
|
+
if expect is None:
|
|
260
|
+
print(f"FAIL {name}: scenario has no `expect:` block")
|
|
261
|
+
all_pass = False
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
result = run_scenario(machine, registry, sc)
|
|
265
|
+
except Exception as e: # noqa: BLE001 — a scenario error is a failure, not a crash
|
|
266
|
+
print(f"FAIL {name}: scenario raised {type(e).__name__}: {e}")
|
|
267
|
+
all_pass = False
|
|
268
|
+
continue
|
|
269
|
+
mismatches = match_expectation(result, expect)
|
|
270
|
+
if not mismatches:
|
|
271
|
+
print(f"PASS {name}")
|
|
272
|
+
continue
|
|
273
|
+
all_pass = False
|
|
274
|
+
first = mismatches[0]
|
|
275
|
+
print(f"FAIL {name}")
|
|
276
|
+
print(f" {first.key}: expected {first.expected!r}, got {first.actual!r}")
|
|
277
|
+
if len(mismatches) > 1:
|
|
278
|
+
print(f" (+{len(mismatches) - 1} more mismatch(es))")
|
|
279
|
+
return 0 if all_pass else 1
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cmd_check(args) -> int:
|
|
283
|
+
ok = True
|
|
284
|
+
for path in args.machines:
|
|
285
|
+
registry = load_registry(Path(path).parent, validate=False)
|
|
286
|
+
try:
|
|
287
|
+
machine = load_machine(path)
|
|
288
|
+
except Exception as e: # noqa: BLE001 — surface any load/validation failure
|
|
289
|
+
msg = getattr(e, "message", str(e))
|
|
290
|
+
print(f"{path}: SCHEMA ERROR: {msg}")
|
|
291
|
+
ok = False
|
|
292
|
+
continue
|
|
293
|
+
errors, warnings = semantic_check(machine, registry, strict=args.strict)
|
|
294
|
+
for w in warnings:
|
|
295
|
+
print(f"{path}: warning: {w}")
|
|
296
|
+
for e in errors:
|
|
297
|
+
print(f"{path}: error: {e}")
|
|
298
|
+
if errors:
|
|
299
|
+
ok = False
|
|
300
|
+
else:
|
|
301
|
+
print(f"{path}: ok")
|
|
302
|
+
return 0 if ok else 1
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def main(argv: list[str] | None = None) -> int:
|
|
306
|
+
ap = argparse.ArgumentParser(prog="mklang", description="Run and check mklang machines.")
|
|
307
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
308
|
+
|
|
309
|
+
r = sub.add_parser("run", help="execute a machine against a provider")
|
|
310
|
+
r.add_argument("machine")
|
|
311
|
+
r.add_argument("--config", default="config/runtime.example.yaml")
|
|
312
|
+
r.add_argument("--provider", default=None, help="override the config's `active` provider")
|
|
313
|
+
r.add_argument("--set", action="append", default=[], metavar="k.path=value")
|
|
314
|
+
r.add_argument(
|
|
315
|
+
"--max-tokens",
|
|
316
|
+
type=int,
|
|
317
|
+
default=None,
|
|
318
|
+
help="cost budget: halt once total tokens reach this",
|
|
319
|
+
)
|
|
320
|
+
r.add_argument(
|
|
321
|
+
"--checkpoint",
|
|
322
|
+
default=None,
|
|
323
|
+
metavar="PATH",
|
|
324
|
+
help="on budget exhaustion suspend and write a resumable checkpoint here "
|
|
325
|
+
"(contains the full context in plaintext; written 0600, see SPEC §11)",
|
|
326
|
+
)
|
|
327
|
+
r.add_argument(
|
|
328
|
+
"--hitl",
|
|
329
|
+
action="store_true",
|
|
330
|
+
help="a fired escalate gate suspends for human review (requires --checkpoint); "
|
|
331
|
+
"reply via `mklang resume --set`",
|
|
332
|
+
)
|
|
333
|
+
r.add_argument(
|
|
334
|
+
"--strict",
|
|
335
|
+
action="store_true",
|
|
336
|
+
help="refuse to run a document whose mklang: version is unsupported "
|
|
337
|
+
"(version-unsupported); default is a warning",
|
|
338
|
+
)
|
|
339
|
+
r.set_defaults(fn=cmd_run)
|
|
340
|
+
|
|
341
|
+
s = sub.add_parser("resume", help="resume a suspended run from a checkpoint")
|
|
342
|
+
s.add_argument("checkpoint")
|
|
343
|
+
s.add_argument("--config", default="config/runtime.example.yaml")
|
|
344
|
+
s.add_argument("--provider", default=None, help="override the config's `active` provider")
|
|
345
|
+
s.add_argument(
|
|
346
|
+
"--set",
|
|
347
|
+
action="append",
|
|
348
|
+
default=[],
|
|
349
|
+
metavar="k.path=value",
|
|
350
|
+
help="inject values (e.g. the human reply) into the suspended run's context",
|
|
351
|
+
)
|
|
352
|
+
s.add_argument(
|
|
353
|
+
"--hitl",
|
|
354
|
+
action="store_true",
|
|
355
|
+
help="keep suspending on escalate gates even if the checkpoint didn't record it",
|
|
356
|
+
)
|
|
357
|
+
s.add_argument(
|
|
358
|
+
"--max-tokens",
|
|
359
|
+
type=int,
|
|
360
|
+
default=None,
|
|
361
|
+
help="new cost budget (total, including tokens spent before the suspend)",
|
|
362
|
+
)
|
|
363
|
+
s.add_argument("--machine", default=None, help="machine path override (if the .mk moved)")
|
|
364
|
+
s.add_argument(
|
|
365
|
+
"--checkpoint",
|
|
366
|
+
dest="checkpoint_out",
|
|
367
|
+
default=None,
|
|
368
|
+
metavar="PATH",
|
|
369
|
+
help="where to write the checkpoint on re-suspension (default: overwrite the input)",
|
|
370
|
+
)
|
|
371
|
+
s.add_argument("--force", action="store_true", help="resume even if the machine file changed")
|
|
372
|
+
s.set_defaults(fn=cmd_resume)
|
|
373
|
+
|
|
374
|
+
c = sub.add_parser("check", help="validate machines (schema + semantics)")
|
|
375
|
+
c.add_argument("machines", nargs="+")
|
|
376
|
+
c.add_argument(
|
|
377
|
+
"--strict",
|
|
378
|
+
action="store_true",
|
|
379
|
+
help="treat an unsupported mklang: version as an error (version-unsupported)",
|
|
380
|
+
)
|
|
381
|
+
c.set_defaults(fn=cmd_check)
|
|
382
|
+
|
|
383
|
+
li = sub.add_parser("lint", help="check + static analysis (dead gates, unread outputs, typos)")
|
|
384
|
+
li.add_argument("machines", nargs="+")
|
|
385
|
+
li.add_argument("--strict", action="store_true", help="exit 1 when lint findings exist")
|
|
386
|
+
li.set_defaults(fn=cmd_lint)
|
|
387
|
+
|
|
388
|
+
t = sub.add_parser(
|
|
389
|
+
"test",
|
|
390
|
+
help="run scenario tests against a machine with a scripted LLM (no API keys)",
|
|
391
|
+
)
|
|
392
|
+
t.add_argument("machine")
|
|
393
|
+
t.add_argument(
|
|
394
|
+
"--script",
|
|
395
|
+
required=True,
|
|
396
|
+
metavar="FILE",
|
|
397
|
+
help="a .test.yaml of named scenarios (scripted llm/tools/hooks + expect)",
|
|
398
|
+
)
|
|
399
|
+
t.set_defaults(fn=cmd_test)
|
|
400
|
+
|
|
401
|
+
args = ap.parse_args(argv)
|
|
402
|
+
return args.fn(args)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ == "__main__":
|
|
406
|
+
sys.exit(main())
|
mklang/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Runtime configuration: load the tier->model map for a provider, keys from .env."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from dotenv import find_dotenv, load_dotenv
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ProviderConfig:
|
|
15
|
+
name: str
|
|
16
|
+
tiers: dict[str, str] # fast/balanced/reasoning -> model id
|
|
17
|
+
api_key: str = ""
|
|
18
|
+
base_url: str | None = None
|
|
19
|
+
judge: str | None = None
|
|
20
|
+
params: dict = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
def judge_override(self) -> str | None:
|
|
23
|
+
"""The optional global judge-model override (config `judge:`).
|
|
24
|
+
|
|
25
|
+
``None`` means gate judging follows each state's own capability tier
|
|
26
|
+
(SPEC §2.1) — a `reasoning` state's gates are judged by the reasoning
|
|
27
|
+
model, not silently downgraded. Set `judge:` only to force one cheaper
|
|
28
|
+
model for *all* gates as a cost optimization."""
|
|
29
|
+
return self.judge
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_provider(config_path: str, provider: str | None = None) -> ProviderConfig:
|
|
33
|
+
"""Load a provider block from the runtime YAML; resolve its key from the env.
|
|
34
|
+
|
|
35
|
+
`.env` is loaded first (python-dotenv), so keys never live in the config file."""
|
|
36
|
+
load_dotenv(find_dotenv(usecwd=True)) # search from cwd upward, not the package dir
|
|
37
|
+
cfg = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
|
|
38
|
+
name = provider or cfg["active"]
|
|
39
|
+
if name not in cfg.get("providers", {}):
|
|
40
|
+
raise KeyError(f"provider {name!r} not in {config_path}")
|
|
41
|
+
p = cfg["providers"][name]
|
|
42
|
+
api_key = os.environ.get(p.get("api_key_env", ""), "")
|
|
43
|
+
return ProviderConfig(
|
|
44
|
+
name=name,
|
|
45
|
+
tiers=p["tiers"],
|
|
46
|
+
api_key=api_key,
|
|
47
|
+
base_url=p.get("base_url"),
|
|
48
|
+
judge=p.get("judge"),
|
|
49
|
+
params=p.get("params", {}) or {},
|
|
50
|
+
)
|