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 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
+ )