nudge-runtime 1.0.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.
@@ -0,0 +1,1822 @@
1
+ """nudge_runtime — the Nudge Python runtime (roadmap day 4–10).
2
+
3
+ Ships today:
4
+ - ``Schema`` / ``schema`` / ``extend`` — JSON-Schema-ish dicts; record values
5
+ are plain dicts validated at runtime (dataclasses land post-MVP)
6
+ - ``validate`` — dependency-free schema validator (objects, arrays, scalars,
7
+ ``minimum``/``maximum``, ``format: uri``)
8
+ - ``llm_call`` — typed LLM call with the design §4.2 repair loop:
9
+ schema violation → validation errors are fed back, up to ``retry`` rounds,
10
+ then ``SchemaFailure``. Every attempt is its own trace record.
11
+ - trace store — JSONL, ``v: 1`` records (design §6.1). ``llm.call`` records
12
+ carry inline ``input``/``output`` at MVP (the content-addressed payload
13
+ store lands post-MVP); ``@effectful`` fns emit ``fn.return`` records.
14
+ - ``replay(path)`` → ``Trace`` (design §6.3): ``.cost_usd`` is Σ llm.call
15
+ cost, ``.output`` is the last ``fn.return`` value as an ``AttrDict``.
16
+ Wrong record versions raise ``ReplayMismatch``.
17
+ - replay mode — set ``NUDGE_REPLAY=<trace.jsonl>`` and ``llm_call`` reads
18
+ outputs from the trace in order instead of calling any provider: full
19
+ replay burns zero tokens (design §6.2). Repair rounds are replayed
20
+ faithfully (each attempt consumes its record). Running out of records
21
+ raises ``ReplayMismatch``. Default mode ``all`` also mocks tool calls
22
+ from the trace; ``NUDGE_REPLAY_MODE=llm`` is the hybrid mode — LLM from
23
+ the trace, tools executed live (and traced, so drift is visible).
24
+ - tool calls — ``tool_stub`` executes the stub and emits ``tool.call``
25
+ trace records in live/hybrid runs (design §6.1/§8); real MCP wiring
26
+ lands post-MVP.
27
+ - streaming (design §4.5) — ``llm_stream`` feeds provider chunks through an
28
+ incremental schema validator; a prefix that can no longer satisfy the
29
+ schema aborts the stream early (tokens saved) and counts as a schema
30
+ violation, so the §4.2 repair loop applies. Trace records gain additive
31
+ ``streamed`` / ``chunks`` / ``early_abort`` fields.
32
+ - agent state + checkpoints (design §7) — ``AgentState`` persists every
33
+ state write to ``.nudge/runs/<run_id>/checkpoint.json`` and registers
34
+ ``program``/``trace`` for the run. ``nudge resume <run_id>`` re-executes
35
+ the program replaying the recorded prefix (``NUDGE_RESUME=1``): replayed
36
+ state writes are suppressed (the checkpoint already reflects them), and
37
+ once the recorded llm/tool records run out, calls go live and append to
38
+ the same trace. Reducer writes use ``merge``: dicts union (right wins),
39
+ lists append-dedup.
40
+ - multi-server MCP routing (design §8) — tool stubs carry their
41
+ ``impl: mcp("server").…`` server; ``NUDGE_MCP_SERVERS`` (JSON registry)
42
+ validates it and ``tool.call`` records gain a ``server`` field.
43
+ - OTel span export (design §6) — with ``NUDGE_OTEL=<path>`` every trace
44
+ record is also written as an OTel-shaped JSON-lines span (file export;
45
+ OTLP transport post-MVP).
46
+ - model routing (design §4.4) — ``route((label, model, cond), ...)`` picks
47
+ the first arm whose condition holds (``otherwise`` is the ``None``
48
+ fallback); the chosen arm lands as an additive ``route`` field on the
49
+ next llm call's trace record.
50
+ - budget enforcement (design §4.3) — fake pricing is a flat $0.001/call
51
+ (deterministic, not a model price); per-call walls via ``budget=`` and the
52
+ run-level counter via ``NUDGE_BUDGET`` (shared by all ``par`` branches);
53
+ overruns raise ``BudgetExceeded`` and the trace stays complete
54
+ - fake provider — deterministic, schema-driven (synthesizes conforming
55
+ values), zero tokens. ``NUDGE_FAKE_FAIL_FIRST=k`` forces k initial schema
56
+ violations so repair paths are testable in CI
57
+ - ``render``, ``USD``, ``effectful``, ``tool_stub``, ``AttrDict``,
58
+ thread-pooled ``par_map`` / ``par_all`` / ``par_race`` (order-preserving,
59
+ shared budget counter)
60
+
61
+ Env: ``NUDGE_PROVIDER=fake`` (default) or a real provider — the model
62
+ string prefix (``gemini:gemini-2.5-flash``) or the env itself selects one
63
+ of ``openai | gemini | groq | mimo | mistral | anthropic | ollama`` (design
64
+ §4.6); ``NUDGE_BASE_URL``/``NUDGE_API_KEY`` (+ provider-specific key envs)
65
+ configure it,
66
+ ``NUDGE_TRACE`` (trace path, default ``trace.jsonl``), ``NUDGE_REPLAY``
67
+ (trace to replay from instead of calling a provider), ``NUDGE_BUDGET``
68
+ (run-level USD budget, §4.3), ``NUDGE_REPAIR_BUDGET`` (cumulative ceiling on
69
+ repair-round spend across the run — repair is valuable, but not unbounded),
70
+ ``NUDGE_RUN_ID`` (checkpoint store key,
71
+ §7), ``NUDGE_RESUME`` (with ``NUDGE_REPLAY``: continue past the recorded
72
+ prefix instead of raising ``ReplayMismatch``).
73
+ """
74
+
75
+ from __future__ import annotations
76
+
77
+ import functools
78
+ import json
79
+ import os
80
+ import re
81
+ import sys
82
+ import threading
83
+ import time
84
+ import uuid
85
+ from concurrent.futures import ThreadPoolExecutor, as_completed
86
+ from pathlib import Path
87
+
88
+ __version__ = "0.1.0"
89
+
90
+
91
+ # ── schemas ──────────────────────────────────────────────────────────
92
+
93
+ class Schema(dict):
94
+ """A JSON-Schema-ish dict. Being a dict lets aliases nest freely."""
95
+
96
+ def __init__(self, d=(), name=None):
97
+ super().__init__(d)
98
+ self.name = name
99
+
100
+
101
+ def schema(d, name=None):
102
+ return d if isinstance(d, Schema) else Schema(d, name)
103
+
104
+
105
+ def extend(base, extra):
106
+ """Merge refinement keys onto an existing (alias) schema."""
107
+ merged = Schema(dict(base))
108
+ merged.update(extra)
109
+ return merged
110
+
111
+
112
+ def validate(sch, value, path="$"):
113
+ """Return a list of validation errors ([] means the value conforms)."""
114
+ errs = []
115
+ if not isinstance(sch, dict) or not sch:
116
+ return errs
117
+ t = sch.get("type")
118
+ if t == "object":
119
+ if not isinstance(value, dict):
120
+ return [f"{path}: expected object, got {_kind(value)}"]
121
+ for req in sch.get("required", []):
122
+ if req not in value:
123
+ errs.append(f"{path}.{req}: missing required field")
124
+ for key, sub in sch.get("properties", {}).items():
125
+ if key in value:
126
+ errs += validate(sub, value[key], f"{path}.{key}")
127
+ elif t == "array":
128
+ if not isinstance(value, list):
129
+ return [f"{path}: expected array, got {_kind(value)}"]
130
+ for i, item in enumerate(value):
131
+ errs += validate(sch.get("items", {}), item, f"{path}[{i}]")
132
+ elif t == "string":
133
+ if not isinstance(value, str):
134
+ return [f"{path}: expected string, got {_kind(value)}"]
135
+ if sch.get("format") == "uri":
136
+ from urllib.parse import urlparse
137
+ parsed = urlparse(value)
138
+ if not (parsed.scheme and parsed.netloc):
139
+ errs.append(f"{path}: not a valid uri: {value!r}")
140
+ elif t == "number":
141
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
142
+ return [f"{path}: expected number, got {_kind(value)}"]
143
+ if "minimum" in sch and value < sch["minimum"]:
144
+ errs.append(f"{path}: {value} < minimum {sch['minimum']}")
145
+ if "maximum" in sch and value > sch["maximum"]:
146
+ errs.append(f"{path}: {value} > maximum {sch['maximum']}")
147
+ elif t == "integer":
148
+ if not isinstance(value, int) or isinstance(value, bool):
149
+ return [f"{path}: expected integer, got {_kind(value)}"]
150
+ elif t == "boolean":
151
+ if not isinstance(value, bool):
152
+ return [f"{path}: expected boolean, got {_kind(value)}"]
153
+ elif t == "null":
154
+ if value is not None:
155
+ errs.append(f"{path}: expected null, got {_kind(value)}")
156
+ return errs
157
+
158
+
159
+ def _kind(value):
160
+ return type(value).__name__
161
+
162
+
163
+ def _synth(sch):
164
+ """Synthesize a schema-conforming value (the fake provider's answer)."""
165
+ if not isinstance(sch, dict):
166
+ return None
167
+ t = sch.get("type")
168
+ if t == "object":
169
+ return {k: _synth(s) for k, s in sch.get("properties", {}).items()}
170
+ if t == "array":
171
+ # 3 items: enough for fan-out shapes (par map over model-planned
172
+ # subtasks) to actually exercise their cardinality in tests
173
+ return [_synth(sch.get("items", {})) for _ in range(3)]
174
+ if t == "string":
175
+ if sch.get("format") == "uri":
176
+ return "https://example.com/fake"
177
+ return "fake"
178
+ if t == "number":
179
+ lo, hi = sch.get("minimum"), sch.get("maximum")
180
+ if lo is not None and hi is not None:
181
+ return (lo + hi) / 2
182
+ if lo is not None:
183
+ return float(lo)
184
+ if hi is not None:
185
+ return float(hi)
186
+ return 0.5
187
+ if t == "integer":
188
+ lo = sch.get("minimum")
189
+ return int(lo) if lo is not None else 1
190
+ if t == "boolean":
191
+ return True
192
+ if t == "null":
193
+ return None
194
+ return {}
195
+
196
+
197
+ # ── errors ───────────────────────────────────────────────────────────
198
+
199
+ class SchemaFailure(Exception):
200
+ """Retries exhausted (design §4.2). Carries all validation errors and
201
+ the last raw output; the trace keeps every attempt."""
202
+
203
+ def __init__(self, errors, raw):
204
+ self.errors = errors
205
+ self.raw = raw
206
+ first = errors[0] if errors else "validation failed"
207
+ more = f" (+{len(errors) - 1} more)" if len(errors) > 1 else ""
208
+ super().__init__(f"SchemaFailure: {first}{more}")
209
+
210
+
211
+ class BudgetExceeded(Exception):
212
+ """The budget wall was hit (design §4.3): either a single call cost more
213
+ than its own ``budget``, or the run-level counter (``NUDGE_BUDGET``,
214
+ shared by all ``par`` branches) ran out. The trace is complete up to the
215
+ crash point."""
216
+
217
+
218
+ class ReplayMismatch(Exception):
219
+ """Trace ↔ program disagreement (design §11): unsupported record
220
+ version, missing trace, or the program made more LLM calls than the
221
+ replayed trace holds."""
222
+
223
+
224
+ # ── small helpers ────────────────────────────────────────────────────
225
+
226
+ def USD(x) -> float:
227
+ """Budget literal. Real budget enforcement lands on roadmap day 11–12."""
228
+ return float(x)
229
+
230
+
231
+ def render(template: str, mapping: dict) -> str:
232
+ """Fill ``{name}`` / ``{dotted.path}`` holes in a prompt template."""
233
+ out = template
234
+ for key, value in mapping.items():
235
+ out = out.replace("{" + key + "}", str(value))
236
+ return out
237
+
238
+
239
+ def effectful(effects):
240
+ """Attach the declared effect set as metadata (verification is the
241
+ compiler's job) and record every return as a ``fn.return`` trace
242
+ record — that is what ``Trace.output`` replays in tests (§6.3)."""
243
+ def deco(fn):
244
+ @functools.wraps(fn)
245
+ def wrapper(*args, **kwargs):
246
+ out = fn(*args, **kwargs)
247
+ rec = {"kind": "fn.return", "fn": fn.__name__, "output": _jsonable(out)}
248
+ branch = _current_branch()
249
+ if branch:
250
+ rec["branch"] = branch
251
+ _emit_trace(rec)
252
+ return out
253
+ wrapper.__nudge_effects__ = frozenset(effects)
254
+ return wrapper
255
+ return deco
256
+
257
+
258
+ def _replay_mode():
259
+ """None (live), ``"all"`` (full replay) or ``"llm"`` (hybrid: LLM from
260
+ the trace, tools live) — design §6.2 run modes."""
261
+ if not os.environ.get("NUDGE_REPLAY"):
262
+ return None
263
+ return os.environ.get("NUDGE_REPLAY_MODE", "all")
264
+
265
+
266
+ _REPLAY_TOOL_STATE = {"outputs": None, "idx": {}}
267
+
268
+
269
+ def _replay_tool_outputs():
270
+ if _REPLAY_TOOL_STATE["outputs"] is None:
271
+ trace = Trace(os.environ["NUDGE_REPLAY"])
272
+ by_tool = {}
273
+ for r in trace.tool_calls():
274
+ by_tool.setdefault(r.get("tool"), []).append(r.get("output"))
275
+ _REPLAY_TOOL_STATE["outputs"] = by_tool
276
+ return _REPLAY_TOOL_STATE["outputs"]
277
+
278
+
279
+ def _replay_tool_output(name):
280
+ """Full-replay tool mock: the recorded output for this tool's next call,
281
+ or ``[]`` when the trace holds none (design §6.2 mock default)."""
282
+ outputs = _replay_tool_outputs()
283
+ idx = _REPLAY_TOOL_STATE["idx"].get(name, 0)
284
+ recorded = outputs.get(name, [])
285
+ if idx < len(recorded):
286
+ _REPLAY_TOOL_STATE["idx"][name] = idx + 1
287
+ return recorded[idx]
288
+ return []
289
+
290
+
291
+ def _replay_tool_available(name):
292
+ """True while the trace still holds an unconsumed output for this tool."""
293
+ recorded = _replay_tool_outputs().get(name, [])
294
+ return _REPLAY_TOOL_STATE["idx"].get(name, 0) < len(recorded)
295
+
296
+
297
+ def _mcp_registry():
298
+ """Multi-server MCP registry (design §8, v0.3b): ``NUDGE_MCP_SERVERS``
299
+ holds a JSON object mapping server names to their config, e.g.
300
+ ``{"search": {"command": "python3 server.py", "tools": ["web_search"]}}``.
301
+ v1.1d: entries with ``command`` get a real stdio JSON-RPC transport;
302
+ entries without one keep the stub (``[]``) behavior."""
303
+ raw = os.environ.get("NUDGE_MCP_SERVERS")
304
+ if not raw:
305
+ return None
306
+ return json.loads(raw)
307
+
308
+
309
+ _MCP_SESSIONS = {}
310
+
311
+
312
+ def _mcp_call(server, name, args, cfg):
313
+ """Real MCP transport (design §8, v1.1d): spawn the server over stdio and
314
+ speak newline-delimited JSON-RPC (MCP stdio framing) — one persistent
315
+ session per server: ``initialize`` → ``notifications/initialized`` →
316
+ ``tools/call``. Registry entry needs ``"command"`` (string or argv list).
317
+ Text content that parses as JSON is returned decoded; otherwise raw.
318
+ Any transport or server error raises — never a silent fake result."""
319
+ import shlex
320
+ import subprocess
321
+
322
+ sess = _MCP_SESSIONS.get(server)
323
+ if sess is None:
324
+ cmd = cfg.get("command")
325
+ if not cmd:
326
+ raise RuntimeError(
327
+ f"MCP server '{server}' has no 'command' in NUDGE_MCP_SERVERS"
328
+ )
329
+ argv = shlex.split(cmd) if isinstance(cmd, str) else list(cmd)
330
+ try:
331
+ proc = subprocess.Popen(
332
+ argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1
333
+ )
334
+ except OSError as e:
335
+ raise RuntimeError(f"MCP server '{server}' failed to start ({argv[0]}): {e}")
336
+ rid = [0]
337
+
338
+ def request(method, params):
339
+ rid[0] += 1
340
+ proc.stdin.write(
341
+ json.dumps({"jsonrpc": "2.0", "id": rid[0], "method": method, "params": params}) + "\n"
342
+ )
343
+ proc.stdin.flush()
344
+ line = proc.stdout.readline()
345
+ if not line:
346
+ raise RuntimeError(f"MCP server '{server}' closed the pipe during '{method}'")
347
+ msg = json.loads(line)
348
+ if "error" in msg:
349
+ raise RuntimeError(f"MCP '{method}' on '{server}': {msg['error']}")
350
+ return msg.get("result") or {}
351
+
352
+ def notify(method, params):
353
+ proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": method, "params": params}) + "\n")
354
+ proc.stdin.flush()
355
+
356
+ request(
357
+ "initialize",
358
+ {
359
+ "protocolVersion": "2024-11-05",
360
+ "capabilities": {},
361
+ "clientInfo": {"name": "nudge", "version": "1.1"},
362
+ },
363
+ )
364
+ notify("notifications/initialized", {})
365
+ sess = (proc, request)
366
+ _MCP_SESSIONS[server] = sess
367
+
368
+ _, request = sess
369
+ result = request(
370
+ "tools/call",
371
+ {"name": name, "arguments": args if isinstance(args, dict) else {"args": list(args or [])}},
372
+ )
373
+ if result.get("isError"):
374
+ raise RuntimeError(f"MCP tool '{name}' on '{server}' reported an error: {result.get('content')}")
375
+ content = result.get("content", [])
376
+ if len(content) == 1 and content[0].get("type") == "text":
377
+ text = content[0].get("text", "")
378
+ try:
379
+ return json.loads(text)
380
+ except ValueError:
381
+ return text
382
+ return content
383
+
384
+
385
+ def tool_stub(name, args=None, server=None):
386
+ """Tool call (design §8).
387
+
388
+ Live + hybrid replay: executes and records a ``tool.call`` trace record
389
+ (with ``server`` when the tool declared ``impl: mcp("server").…``).
390
+ Real transport (v1.1d): when the registry entry for ``server`` carries a
391
+ ``command``, the call goes to the actual MCP server over stdio and the
392
+ real output lands in the trace. Entries without ``command`` keep the
393
+ stub result ``[]``. Full replay: mocked from the trace — no record
394
+ written. Resume (design §7): consumes the recorded prefix, then runs
395
+ live and records. An unknown server name fails fast.
396
+ """
397
+ registry = _mcp_registry() if server is not None else None
398
+ if server is not None:
399
+ if registry is not None and server not in registry:
400
+ raise RuntimeError(
401
+ f"unknown MCP server '{server}' for tool '{name}' "
402
+ f"(registry has: {', '.join(sorted(registry))})"
403
+ )
404
+ if _replay_mode() == "all":
405
+ if not os.environ.get("NUDGE_RESUME"):
406
+ # design §6.2/v1.9: exhausting the recorded prefix WITHOUT resume
407
+ # raises — a program that changed its tool-call pattern must fail
408
+ # the replay, not silently mock [] (same strictness as llm calls)
409
+ if not _replay_tool_available(name):
410
+ raise ReplayMismatch(
411
+ f"program called tool '{name}' more times than the trace "
412
+ "holds (tool replay exhaustion raises like llm replay)"
413
+ )
414
+ return _replay_tool_output(name)
415
+ if _replay_tool_available(name):
416
+ return _replay_tool_output(name)
417
+ # resume past the recorded prefix: fall through to a live call
418
+ if registry is not None and registry[server].get("command"):
419
+ result = _mcp_call(server, name, args, registry[server])
420
+ else:
421
+ result = []
422
+ record = {
423
+ "kind": "tool.call",
424
+ "tool": name,
425
+ "input": _jsonable(list(args) if args is not None else []),
426
+ "output": _jsonable(result),
427
+ }
428
+ if server is not None:
429
+ record["server"] = server
430
+ branch = _current_branch()
431
+ if branch:
432
+ record["branch"] = branch
433
+ _emit_trace(record)
434
+ return result
435
+
436
+
437
+ def python(module):
438
+ """`import python(...)` escape hatch — lands post-MVP (v0.2)."""
439
+ raise NotImplementedError("python() interop lands post-MVP (v0.2)")
440
+
441
+
442
+ def mcp(server):
443
+ """`mcp("server")` tool implementations — land with the MCP client."""
444
+ raise NotImplementedError("mcp() lands with the MCP client (post-MVP)")
445
+
446
+
447
+ # ── dynamic record values ──────────────────────────────────────────
448
+
449
+ class AttrDict(dict):
450
+ """dict with attribute access, so generated Python can use Nudge's
451
+ ``record.field`` syntax verbatim (``t.output.findings``)."""
452
+
453
+ def __getattr__(self, name):
454
+ try:
455
+ return self[name]
456
+ except KeyError:
457
+ raise AttributeError(name) from None
458
+
459
+
460
+ def _attr(value):
461
+ """Recursively wrap dicts in AttrDict (lists keep their shape)."""
462
+ if isinstance(value, dict) and not isinstance(value, AttrDict):
463
+ return AttrDict({k: _attr(v) for k, v in value.items()})
464
+ if isinstance(value, list):
465
+ return [_attr(v) for v in value]
466
+ return value
467
+
468
+
469
+ def _jsonable(value):
470
+ """Best-effort JSON serialization for trace payloads."""
471
+ if isinstance(value, dict):
472
+ return {k: _jsonable(v) for k, v in value.items()}
473
+ if isinstance(value, (list, tuple)):
474
+ return [_jsonable(v) for v in value]
475
+ if value is None or isinstance(value, (str, int, float, bool)):
476
+ return value
477
+ return str(value)
478
+
479
+
480
+ # ── trace ────────────────────────────────────────────────────────────
481
+
482
+ def _trace_path() -> Path:
483
+ return Path(os.environ.get("NUDGE_TRACE", "trace.jsonl"))
484
+
485
+
486
+ _TRACE_LOCK = threading.Lock()
487
+
488
+ # in-memory seq counter (v1.4 fix): the old code re-counted every line of
489
+ # the trace file on EVERY record — O(n²) for a run with n records, and a
490
+ # lock-held full scan bottleneck under par branches. Seeded once per path,
491
+ # then incremented in memory; empty lines no longer skew the sequence.
492
+ _SEQ = {"n": None, "path": None}
493
+
494
+
495
+ def _emit_trace(record: dict) -> None:
496
+ path = _trace_path()
497
+ # serialized: par branches emit concurrently and seq must stay unique
498
+ with _TRACE_LOCK:
499
+ if _SEQ["n"] is None or _SEQ["path"] != str(path):
500
+ n = 0
501
+ if path.exists():
502
+ with path.open("r", encoding="utf-8") as f:
503
+ n = sum(1 for line in f if line.strip())
504
+ _SEQ["n"], _SEQ["path"] = n, str(path)
505
+ _SEQ["n"] += 1
506
+ line = {"v": 1, "seq": _SEQ["n"], **record}
507
+ with path.open("a", encoding="utf-8") as f:
508
+ f.write(json.dumps(line, ensure_ascii=False) + "\n")
509
+ _otel_export(line)
510
+
511
+
512
+ _OTEL_TRACE_ID = None
513
+
514
+
515
+ def _otel_export(record: dict) -> None:
516
+ """OTel-compatible span export (design §6, v0.3d): when ``NUDGE_OTEL``
517
+ names a path, every trace record also lands there as a JSON-lines span
518
+ (trace_id per process, span_id per record, record fields as
519
+ attributes). File export only — OTLP transport lands post-MVP."""
520
+ path = os.environ.get("NUDGE_OTEL")
521
+ if not path:
522
+ return
523
+ global _OTEL_TRACE_ID
524
+ if _OTEL_TRACE_ID is None:
525
+ _OTEL_TRACE_ID = uuid.uuid4().hex
526
+ now_ns = time.time_ns()
527
+ attributes = {k: v for k, v in record.items() if k not in ("v", "seq", "kind")}
528
+ ok = record.get("outcome", "ok") == "ok"
529
+ span = {
530
+ "traceId": _OTEL_TRACE_ID,
531
+ "spanId": uuid.uuid4().hex[:16],
532
+ "name": record.get("kind", "span"),
533
+ "kind": 3, # SPAN_KIND_CLIENT
534
+ "startTimeUnixNano": now_ns,
535
+ "endTimeUnixNano": now_ns,
536
+ "attributes": _jsonable(attributes),
537
+ "status": {"code": 1 if ok else 2},
538
+ }
539
+ with _TRACE_LOCK:
540
+ with open(path, "a", encoding="utf-8") as f:
541
+ f.write(json.dumps(span, ensure_ascii=False) + "\n")
542
+
543
+
544
+ def _trace_call(model, prompt, out, repair_round, outcome, extra=None,
545
+ provider="fake", tokens=None, cost=None):
546
+ # MVP: input/output are inline (design §6.1 content-addressed payload
547
+ # store lands post-MVP — v1-compatible additive fields)
548
+ record = {
549
+ "kind": "llm.call",
550
+ "model": model or "default",
551
+ "params": {"temperature": 0},
552
+ "input": str(prompt),
553
+ "output": _jsonable(out),
554
+ "tokens": tokens or {"in": len(str(prompt).split()), "out": len(str(out).split())},
555
+ "cost_usd": FAKE_CALL_COST if cost is None else cost,
556
+ "repair_round": repair_round,
557
+ "outcome": outcome,
558
+ "provider": provider,
559
+ }
560
+ if _pricing_unknown(provider, model):
561
+ # additive NTF field: cost_usd is a $0 placeholder, not a measurement
562
+ record["pricing"] = "unknown"
563
+ if extra:
564
+ # additive v1 fields (design §6.1): streamed / chunks / early_abort
565
+ record.update(extra)
566
+ branch = _current_branch()
567
+ if branch:
568
+ record["branch"] = branch
569
+ _emit_trace(record)
570
+
571
+
572
+ # ── replay (design §6.2, §6.3) ──────────────────────────────────────
573
+
574
+ class Trace:
575
+ """A recorded run, loaded from JSONL. Property-test input (§6.3)."""
576
+
577
+ def __init__(self, path):
578
+ self.path = Path(path)
579
+ if not self.path.exists():
580
+ raise ReplayMismatch(f"trace not found: {path}")
581
+ self.records = []
582
+ for line in self.path.read_text(encoding="utf-8").splitlines():
583
+ if line.strip():
584
+ self.records.append(json.loads(line))
585
+ for r in self.records:
586
+ if r.get("v") != 1:
587
+ raise ReplayMismatch(
588
+ f"unsupported trace record version {r.get('v')!r} "
589
+ f"(this runtime speaks v1; run `nudge trace migrate`)"
590
+ )
591
+
592
+ @property
593
+ def cost_usd(self):
594
+ return sum(r.get("cost_usd", 0.0) for r in self.llm_calls())
595
+
596
+ @property
597
+ def output(self):
598
+ """The last ``fn.return`` value (dot-accessible via AttrDict)."""
599
+ for r in reversed(self.records):
600
+ if r.get("kind") == "fn.return":
601
+ return _attr(r.get("output"))
602
+ return None
603
+
604
+ def llm_calls(self):
605
+ return [r for r in self.records if r.get("kind") == "llm.call"]
606
+
607
+ def tool_calls(self):
608
+ return [r for r in self.records if r.get("kind") == "tool.call"]
609
+
610
+
611
+ def replay(path):
612
+ """Load a recorded trace (design §6.3). IO effect at the call site."""
613
+ return Trace(path)
614
+
615
+
616
+ _REPLAY_STATE = {"outputs": None, "idx": 0}
617
+
618
+
619
+ def _replay_outputs():
620
+ if _REPLAY_STATE["outputs"] is None:
621
+ trace = Trace(os.environ["NUDGE_REPLAY"])
622
+ _REPLAY_STATE["outputs"] = [r.get("output") for r in trace.llm_calls()]
623
+ return _REPLAY_STATE["outputs"]
624
+
625
+
626
+ # ── budget (design §4.3) ─────────────────────────────────────────────
627
+
628
+ # Fake-provider pricing: flat $0.001 per call. Deterministic, NOT a model
629
+ # price — it exists so budget walls are testable at zero token cost.
630
+ FAKE_CALL_COST = 0.001
631
+
632
+
633
+ # ── real providers (design §4.6, v1.1a) ─────────────────────────────
634
+ # One OpenAI-compatible HTTP adapter, stdlib-only (urllib). The provider is
635
+ # chosen by the model string prefix (`gemini:gemini-2.5-flash`) or by
636
+ # NUDGE_PROVIDER; NUDGE_BASE_URL overrides the endpoint, and the key comes
637
+ # from NUDGE_API_KEY or the provider-specific env. Local/free-tier models
638
+ # price at $0 — budget walls keep working with real token counts.
639
+
640
+ _PROVIDER_BASE_URLS = {
641
+ "openai": "https://api.openai.com/v1",
642
+ "gemini": "https://generativelanguage.googleapis.com/v1beta/openai",
643
+ "groq": "https://api.groq.com/openai/v1",
644
+ "mimo": "https://token-plan-sgp.xiaomimimo.com/v1",
645
+ "ollama": "http://localhost:11434/v1",
646
+ "mistral": "https://api.mistral.ai/v1",
647
+ # Anthropic speaks its own Messages API, not the OpenAI shape —
648
+ # _complete dispatches it to _anthropic_chat below.
649
+ "anthropic": "https://api.anthropic.com",
650
+ }
651
+
652
+ _PROVIDER_KEY_ENVS = {
653
+ "openai": "OPENAI_API_KEY",
654
+ "gemini": "GEMINI_API_KEY",
655
+ "groq": "GROQ_API_KEY",
656
+ "mimo": "MIMO_API_KEY",
657
+ "mistral": "MISTRAL_API_KEY",
658
+ "anthropic": "ANTHROPIC_API_KEY",
659
+ }
660
+
661
+ # USD per 1M tokens: (input, output). Models absent from the table price at
662
+ # $0 — but a $0 on a metered provider is usually a missing table entry, not a
663
+ # free call, so the runtime flags it (W9001 + trace `pricing: "unknown"`).
664
+ _MODEL_PRICING = {
665
+ "gemini-2.5-flash": (0.30, 2.50),
666
+ "gemini-2.0-flash": (0.10, 0.40),
667
+ "gpt-4o-mini": (0.15, 0.60),
668
+ "llama-3.3-70b-versatile": (0.59, 0.79),
669
+ "mistral-small-latest": (0.10, 0.30),
670
+ "mistral-large-latest": (2.00, 6.00),
671
+ "claude-haiku-4-5": (1.00, 5.00),
672
+ "claude-sonnet-4-5": (3.00, 15.00),
673
+ }
674
+
675
+
676
+ def _split_model(model):
677
+ """`gemini:gemini-2.5-flash` -> ("gemini", "gemini-2.5-flash");
678
+ a bare name -> (None, model)."""
679
+ if model and ":" in model:
680
+ prefix, bare = model.split(":", 1)
681
+ if prefix in _PROVIDER_BASE_URLS:
682
+ return prefix, bare
683
+ return None, model
684
+
685
+
686
+ def _real_provider_for(model):
687
+ """(provider, bare_model) when a real provider should handle this call,
688
+ else None (the fake provider handles it)."""
689
+ env = os.environ.get("NUDGE_PROVIDER")
690
+ if env == "fake":
691
+ # EXPLICIT fake wins over any model prefix — that is how tests and
692
+ # $0 example runs force the fake even for `anthropic:...` models
693
+ return None
694
+ prefix, bare = _split_model(model)
695
+ if prefix:
696
+ return prefix, bare
697
+ if env is None or env == "":
698
+ return None
699
+ if env not in _PROVIDER_BASE_URLS:
700
+ raise RuntimeError(
701
+ f"unknown NUDGE_PROVIDER '{env}' "
702
+ "(openai | gemini | groq | mimo | mistral | anthropic | ollama | fake)"
703
+ )
704
+ return env, bare
705
+
706
+
707
+ def _openai_chat(provider, model, prompt):
708
+ """One non-streaming chat completion against an OpenAI-compatible API.
709
+ Returns (text, prompt_tokens, completion_tokens)."""
710
+ import urllib.error
711
+ import urllib.request
712
+ base = os.environ.get("NUDGE_BASE_URL", _PROVIDER_BASE_URLS[provider])
713
+ key_env = _PROVIDER_KEY_ENVS.get(provider)
714
+ key = os.environ.get("NUDGE_API_KEY") or (os.environ.get(key_env, "") if key_env else "")
715
+ body = json.dumps({
716
+ "model": model,
717
+ "messages": [{"role": "user", "content": str(prompt)}],
718
+ }).encode()
719
+ req = urllib.request.Request(
720
+ base.rstrip("/") + "/chat/completions", data=body,
721
+ headers={
722
+ "Content-Type": "application/json",
723
+ "Authorization": f"Bearer {key}",
724
+ # Cloudflare (error 1010) bans urllib's default UA on some providers
725
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
726
+ },
727
+ )
728
+ data = None
729
+ last_err = None
730
+ # 429s are routine on free tiers — back off and retry (5s, 25s, 125s)
731
+ for attempt in range(4):
732
+ try:
733
+ with urllib.request.urlopen(req, timeout=120) as resp:
734
+ data = json.loads(resp.read())
735
+ break
736
+ except urllib.error.HTTPError as e:
737
+ detail = e.read().decode("utf-8", "replace")[:500]
738
+ last_err = RuntimeError(f"{provider} provider HTTP {e.code}: {detail}")
739
+ if e.code == 429 and attempt < 3:
740
+ time.sleep(5 * (5 ** attempt))
741
+ continue
742
+ raise last_err
743
+ except urllib.error.URLError as e:
744
+ raise RuntimeError(f"{provider} provider unreachable: {e.reason}")
745
+ try:
746
+ text = data["choices"][0]["message"]["content"]
747
+ except (KeyError, IndexError, TypeError):
748
+ raise RuntimeError(
749
+ f"{provider} provider returned an unexpected payload: {str(data)[:500]}"
750
+ )
751
+ usage = data.get("usage") or {}
752
+ return text, int(usage.get("prompt_tokens") or 0), int(usage.get("completion_tokens") or 0)
753
+
754
+
755
+ def _anthropic_chat(provider, model, prompt):
756
+ """One non-streaming call against Anthropic's Messages API (not the
757
+ OpenAI shape). Returns (text, input_tokens, output_tokens)."""
758
+ import urllib.error
759
+ import urllib.request
760
+ base = os.environ.get("NUDGE_BASE_URL", _PROVIDER_BASE_URLS[provider])
761
+ key = os.environ.get("NUDGE_API_KEY") or os.environ.get(
762
+ _PROVIDER_KEY_ENVS[provider], "")
763
+ body = json.dumps({
764
+ "model": model,
765
+ "max_tokens": 4096,
766
+ "messages": [{"role": "user", "content": str(prompt)}],
767
+ }).encode()
768
+ req = urllib.request.Request(
769
+ base.rstrip("/") + "/v1/messages", data=body,
770
+ headers={
771
+ "Content-Type": "application/json",
772
+ "x-api-key": key,
773
+ "anthropic-version": "2023-06-01",
774
+ },
775
+ )
776
+ data = None
777
+ last_err = None
778
+ for attempt in range(4):
779
+ try:
780
+ with urllib.request.urlopen(req, timeout=120) as resp:
781
+ data = json.loads(resp.read())
782
+ break
783
+ except urllib.error.HTTPError as e:
784
+ detail = e.read().decode("utf-8", "replace")[:500]
785
+ last_err = RuntimeError(f"{provider} provider HTTP {e.code}: {detail}")
786
+ if e.code == 429 and attempt < 3:
787
+ time.sleep(5 * (5 ** attempt))
788
+ continue
789
+ raise last_err
790
+ except urllib.error.URLError as e:
791
+ raise RuntimeError(f"{provider} provider unreachable: {e.reason}")
792
+ try:
793
+ blocks = [b.get("text", "") for b in data["content"] if b.get("type") == "text"]
794
+ text = "".join(blocks)
795
+ except (KeyError, TypeError, AttributeError):
796
+ raise RuntimeError(
797
+ f"{provider} provider returned an unexpected payload: {str(data)[:500]}"
798
+ )
799
+ usage = data.get("usage") or {}
800
+ return text, int(usage.get("input_tokens") or 0), int(usage.get("output_tokens") or 0)
801
+
802
+
803
+ def _sse_events(req, provider):
804
+ """Open an SSE request (429 backoff like the non-streaming path) and
805
+ yield parsed ``data: {...}`` payloads until ``[DONE]`` or EOF."""
806
+ import urllib.error
807
+ import urllib.request
808
+ resp = None
809
+ last_err = None
810
+ for attempt in range(4):
811
+ try:
812
+ resp = urllib.request.urlopen(req, timeout=120)
813
+ break
814
+ except urllib.error.HTTPError as e:
815
+ detail = e.read().decode("utf-8", "replace")[:500]
816
+ last_err = RuntimeError(f"{provider} provider HTTP {e.code}: {detail}")
817
+ if e.code == 429 and attempt < 3:
818
+ time.sleep(5 * (5 ** attempt))
819
+ continue
820
+ raise last_err
821
+ except urllib.error.URLError as e:
822
+ raise RuntimeError(f"{provider} provider unreachable: {e.reason}")
823
+ with resp:
824
+ for raw in resp:
825
+ line = raw.decode("utf-8", "replace").strip()
826
+ if not line.startswith("data:"):
827
+ continue
828
+ payload = line[5:].strip()
829
+ if payload == "[DONE]":
830
+ return
831
+ try:
832
+ ev = json.loads(payload)
833
+ except json.JSONDecodeError:
834
+ continue
835
+ if isinstance(ev, dict):
836
+ yield ev
837
+
838
+
839
+ def _openai_chat_stream(provider, model, prompt, usage):
840
+ """Stream one OpenAI-compatible chat completion: yields text deltas and
841
+ fills ``usage`` ("in"/"out") when the server reports token counts."""
842
+ import urllib.request
843
+ base = os.environ.get("NUDGE_BASE_URL", _PROVIDER_BASE_URLS[provider])
844
+ key_env = _PROVIDER_KEY_ENVS.get(provider)
845
+ key = os.environ.get("NUDGE_API_KEY") or (os.environ.get(key_env, "") if key_env else "")
846
+ body = json.dumps({
847
+ "model": model,
848
+ "messages": [{"role": "user", "content": str(prompt)}],
849
+ "stream": True,
850
+ "stream_options": {"include_usage": True},
851
+ }).encode()
852
+ req = urllib.request.Request(
853
+ base.rstrip("/") + "/chat/completions", data=body,
854
+ headers={
855
+ "Content-Type": "application/json",
856
+ "Authorization": f"Bearer {key}",
857
+ "Accept": "text/event-stream",
858
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
859
+ },
860
+ )
861
+ for ev in _sse_events(req, provider):
862
+ u = ev.get("usage")
863
+ if u:
864
+ usage["in"] = int(u.get("prompt_tokens") or usage["in"])
865
+ usage["out"] = int(u.get("completion_tokens") or usage["out"])
866
+ choices = ev.get("choices") or []
867
+ if choices:
868
+ delta = (choices[0].get("delta") or {}).get("content")
869
+ if delta:
870
+ yield delta
871
+
872
+
873
+ def _anthropic_chat_stream(provider, model, prompt, usage):
874
+ """Stream one Anthropic Messages call: yields text deltas and fills
875
+ ``usage`` from message_start / message_delta events."""
876
+ import urllib.request
877
+ base = os.environ.get("NUDGE_BASE_URL", _PROVIDER_BASE_URLS[provider])
878
+ key = os.environ.get("NUDGE_API_KEY") or os.environ.get(
879
+ _PROVIDER_KEY_ENVS[provider], "")
880
+ body = json.dumps({
881
+ "model": model,
882
+ "max_tokens": 4096,
883
+ "stream": True,
884
+ "messages": [{"role": "user", "content": str(prompt)}],
885
+ }).encode()
886
+ req = urllib.request.Request(
887
+ base.rstrip("/") + "/v1/messages", data=body,
888
+ headers={
889
+ "Content-Type": "application/json",
890
+ "x-api-key": key,
891
+ "anthropic-version": "2023-06-01",
892
+ "Accept": "text/event-stream",
893
+ },
894
+ )
895
+ for ev in _sse_events(req, provider):
896
+ t = ev.get("type")
897
+ if t == "message_start":
898
+ u = (ev.get("message") or {}).get("usage") or {}
899
+ usage["in"] = int(u.get("input_tokens") or usage["in"])
900
+ elif t == "content_block_delta":
901
+ delta = (ev.get("delta") or {}).get("text")
902
+ if delta:
903
+ yield delta
904
+ elif t == "message_delta":
905
+ u = ev.get("usage") or {}
906
+ usage["out"] = int(u.get("output_tokens") or usage["out"])
907
+
908
+
909
+ def _extract_json(text):
910
+ """Best-effort JSON extraction from a real model's answer: ```json
911
+ fences first, then the first balanced-looking {...} / [...] span. A
912
+ failure returns the raw text — schema validation reports it, and the
913
+ §4.2 repair loop gets its chance."""
914
+ s = text.strip()
915
+ if s.startswith("```"):
916
+ s = re.sub(r"^```(?:json)?\s*", "", s)
917
+ s = re.sub(r"\s*```$", "", s)
918
+ try:
919
+ return json.loads(s)
920
+ except json.JSONDecodeError:
921
+ pass
922
+ for i, ch in enumerate(s):
923
+ if ch in "{[":
924
+ closer = "}" if ch == "{" else "]"
925
+ end = s.rfind(closer)
926
+ if end > i:
927
+ try:
928
+ return json.loads(s[i:end + 1])
929
+ except json.JSONDecodeError:
930
+ pass
931
+ break
932
+ return text
933
+
934
+
935
+ def _complete(provider, model, prompt, schema):
936
+ """(output, in_tokens, out_tokens) — one completion on the given
937
+ provider. Real-provider answers are JSON-extracted when a schema is
938
+ set; the fake provider synthesizes as before."""
939
+ if provider == "fake":
940
+ return _fake_answer(prompt, model, schema), 0, 0
941
+ bare = _split_model(model)[1]
942
+ if provider == "anthropic":
943
+ text, in_t, out_t = _anthropic_chat(provider, bare, prompt)
944
+ else:
945
+ text, in_t, out_t = _openai_chat(provider, bare, prompt)
946
+ if schema is not None:
947
+ return _extract_json(text), in_t, out_t
948
+ return text, in_t, out_t
949
+
950
+
951
+ # Providers whose calls are legitimately $0 without a table entry: local
952
+ # Ollama, plan-priced MiMo, and the fake test double.
953
+ _PRICING_UNKNOWN_OK = {"fake", "ollama", "mimo"}
954
+ _PRICING_WARNED = set()
955
+
956
+
957
+ def _pricing_unknown(provider, model):
958
+ """True when the model has no pricing entry on a provider where that is
959
+ suspicious — i.e. cost_usd will be a $0 placeholder, not a real free call."""
960
+ if provider in _PRICING_UNKNOWN_OK:
961
+ return False
962
+ return _MODEL_PRICING.get(_split_model(model)[1]) is None
963
+
964
+
965
+ def _call_cost(provider, model, in_t, out_t):
966
+ """USD cost of one call: flat fake pricing, or the pricing table for
967
+ real providers (unknown/free/local models → $0, with a one-time warning
968
+ when the $0 is a missing table entry rather than a genuinely free call)."""
969
+ if provider == "fake":
970
+ return FAKE_CALL_COST
971
+ prices = _MODEL_PRICING.get(_split_model(model)[1])
972
+ if prices is None:
973
+ if _pricing_unknown(provider, model):
974
+ bare = _split_model(model)[1]
975
+ if bare not in _PRICING_WARNED and os.environ.get("NUDGE_PRICING_WARN", "1") != "0":
976
+ _PRICING_WARNED.add(bare)
977
+ print(
978
+ f"warning[W9001]: no pricing entry for '{bare}' ({provider}) — "
979
+ "recording $0 cost; add the model to _MODEL_PRICING "
980
+ "(NUDGE_PRICING_WARN=0 silences)",
981
+ file=sys.stderr,
982
+ )
983
+ return 0.0
984
+ return (in_t * prices[0] + out_t * prices[1]) / 1_000_000
985
+
986
+
987
+ _BUDGET_STATE = {"spent": 0.0, "lock": threading.Lock()}
988
+
989
+ _REPAIR_BUDGET_STATE = {"spent": 0.0, "lock": threading.Lock()}
990
+
991
+
992
+ def _repair_budget_limit():
993
+ raw = os.environ.get("NUDGE_REPAIR_BUDGET")
994
+ return float(raw) if raw else None
995
+
996
+
997
+ def _repair_budget_precheck():
998
+ """Repair rounds share a cumulative, run-level ceiling. Reasoning models
999
+ can make a single repair round cost more than the original call — the
1000
+ wall keeps 'fix it' from silently outspending the work itself."""
1001
+ limit = _repair_budget_limit()
1002
+ if limit is not None:
1003
+ with _REPAIR_BUDGET_STATE["lock"]:
1004
+ spent = _REPAIR_BUDGET_STATE["spent"]
1005
+ if spent >= limit:
1006
+ raise BudgetExceeded(
1007
+ f"repair budget exhausted: ${spent:.4f} spent of ${limit:.4f} "
1008
+ "(NUDGE_REPAIR_BUDGET caps cumulative repair-round spend)"
1009
+ )
1010
+
1011
+
1012
+ def _repair_budget_charge(cost):
1013
+ if _repair_budget_limit() is not None:
1014
+ with _REPAIR_BUDGET_STATE["lock"]:
1015
+ _REPAIR_BUDGET_STATE["spent"] += cost
1016
+
1017
+
1018
+
1019
+ def _budget_limit():
1020
+ raw = os.environ.get("NUDGE_BUDGET")
1021
+ return float(raw) if raw else None
1022
+
1023
+
1024
+ def _budget_precheck():
1025
+ """A call whose inherited budget is already gone never starts."""
1026
+ limit = _budget_limit()
1027
+ if limit is not None:
1028
+ with _BUDGET_STATE["lock"]:
1029
+ spent = _BUDGET_STATE["spent"]
1030
+ if spent >= limit:
1031
+ raise BudgetExceeded(
1032
+ f"run budget exhausted: ${spent:.4f} spent of ${limit:.4f}"
1033
+ )
1034
+
1035
+
1036
+ def _budget_charge(cost, call_budget):
1037
+ """Charge one call: per-call wall first, then the shared run counter."""
1038
+ if call_budget is not None and cost > float(call_budget):
1039
+ raise BudgetExceeded(
1040
+ f"call cost ${cost:.4f} exceeds its declared budget ${float(call_budget):.4f}"
1041
+ )
1042
+ limit = _budget_limit()
1043
+ if limit is not None:
1044
+ with _BUDGET_STATE["lock"]:
1045
+ _BUDGET_STATE["spent"] += cost
1046
+ spent = _BUDGET_STATE["spent"]
1047
+ if spent > limit:
1048
+ raise BudgetExceeded(
1049
+ f"run budget exceeded: ${spent:.4f} spent of ${limit:.4f}"
1050
+ )
1051
+
1052
+
1053
+ # ── agent state + checkpoints (design §7) ──────────────────────────
1054
+
1055
+ class AgentState:
1056
+ """Checkpointed agent state (design §7, v0.2c MVP).
1057
+
1058
+ Every attribute write persists the full state to
1059
+ ``.nudge/runs/<run_id>/checkpoint.json`` (SQLite/Postgres stores are
1060
+ post-MVP). The run directory also registers ``program`` (the emitted
1061
+ entry file) and ``trace`` so ``nudge resume <run_id>`` can re-execute.
1062
+
1063
+ Resume semantics: with ``NUDGE_RESUME`` set, the checkpoint is loaded
1064
+ and the first ``writes`` state writes of the re-execution are
1065
+ suppressed — deterministic replay of the recorded prefix reproduces
1066
+ exactly those writes, and the checkpoint already reflects them. Writes
1067
+ past the crash point go live and checkpoint as usual.
1068
+ """
1069
+
1070
+ def __init__(self, agent, defaults):
1071
+ object.__setattr__(self, "_agent", agent)
1072
+ run = os.environ.get("NUDGE_RUN_ID") or f"run-{os.getpid()}"
1073
+ run_dir = Path(".nudge") / "runs" / run
1074
+ run_dir.mkdir(parents=True, exist_ok=True)
1075
+ object.__setattr__(self, "_dir", run_dir)
1076
+ values, writes = dict(defaults), 0
1077
+ ckpt = run_dir / "checkpoint.json"
1078
+ saved_values = None
1079
+ resuming = bool(os.environ.get("NUDGE_RESUME")) and ckpt.exists()
1080
+ if resuming:
1081
+ saved = json.loads(ckpt.read_text(encoding="utf-8"))
1082
+ writes = saved.get("writes", 0)
1083
+ saved_values = _jsonable(saved.get("values", {}))
1084
+ # Replay starts from the DEFAULTS, not the checkpoint: suppressed
1085
+ # writes are re-applied so augmented writes (+=) accumulate
1086
+ # correctly, and the prefix end is verified against the recorded
1087
+ # checkpoint (v1.6 divergence guard). Loading the checkpoint
1088
+ # would double-apply every += of the prefix.
1089
+ object.__setattr__(self, "_values", values)
1090
+ object.__setattr__(self, "_writes", writes)
1091
+ object.__setattr__(self, "_suppress", writes if os.environ.get("NUDGE_RESUME") else 0)
1092
+ # divergence guard reference: the recorded final values the
1093
+ # replayed prefix must reproduce (v1.6 — used to be unchecked)
1094
+ object.__setattr__(self, "_saved_values", saved_values)
1095
+ # NUDGE_PROGRAM overrides the registered entry file — `nudgec test`
1096
+ # runs the module through a driver script, so sys.argv[0] would
1097
+ # otherwise point `nudgec resume` at the wrong file
1098
+ program = os.environ.get("NUDGE_PROGRAM") or os.path.abspath(sys.argv[0])
1099
+ (run_dir / "program").write_text(program, encoding="utf-8")
1100
+ trace = os.environ.get("NUDGE_TRACE")
1101
+ if trace:
1102
+ (run_dir / "trace").write_text(os.path.abspath(trace), encoding="utf-8")
1103
+ if not resuming:
1104
+ self._checkpoint()
1105
+ # on resume the recorded checkpoint must survive until the replayed
1106
+ # prefix is verified — writing defaults over it here would destroy
1107
+ # both the resume point and the divergence-guard reference
1108
+
1109
+ def __getattr__(self, name):
1110
+ try:
1111
+ return object.__getattribute__(self, "_values")[name]
1112
+ except KeyError:
1113
+ raise AttributeError(name) from None
1114
+
1115
+ def __setattr__(self, name, value):
1116
+ if self._suppress > 0:
1117
+ # replayed-prefix write: APPLY it (so a diverged replay is
1118
+ # visible in the values) but don't checkpoint — the recorded
1119
+ # checkpoint already reflects a faithful prefix
1120
+ self._values[name] = value
1121
+ object.__setattr__(self, "_suppress", self._suppress - 1)
1122
+ if self._suppress == 0:
1123
+ self._guard_replay_faithful()
1124
+ return
1125
+ self._values[name] = value
1126
+ object.__setattr__(self, "_writes", self._writes + 1)
1127
+ self._checkpoint()
1128
+
1129
+ def _guard_replay_faithful(self):
1130
+ """Resume divergence guard (v1.6): once the recorded prefix has been
1131
+ replayed, the reproduced state must equal the recorded checkpoint —
1132
+ otherwise the program changed since the crash and continuing would
1133
+ silently fork history. (A replay with FEWER writes than the prefix
1134
+ never reaches this point; that case is caught by llm/tool replay
1135
+ divergence instead.)"""
1136
+ saved = self._saved_values
1137
+ if saved is None:
1138
+ return
1139
+ now = _jsonable(self._values)
1140
+ if now != saved:
1141
+ raise ReplayMismatch(
1142
+ f"resume divergence in agent '{self._agent}': the replayed "
1143
+ f"state {now!r} does not match the recorded checkpoint "
1144
+ f"{saved!r} — the program changed since the crash; start a "
1145
+ f"new run"
1146
+ )
1147
+
1148
+ def __repr__(self):
1149
+ return f"AgentState({self._agent!r}, {self._values!r})"
1150
+
1151
+ def _checkpoint(self):
1152
+ payload = {
1153
+ "agent": self._agent,
1154
+ "values": _jsonable(self._values),
1155
+ "writes": self._writes,
1156
+ }
1157
+ (self._dir / "checkpoint.json").write_text(
1158
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
1159
+ )
1160
+
1161
+
1162
+ # ── model routing (design §4.4) ─────────────────────────────────────
1163
+
1164
+ _LAST_ROUTE = threading.local()
1165
+
1166
+ # NTF v1.1 (additive): records emitted inside a `par` branch carry a `branch`
1167
+ # label — "par[0]", "par[1]", ... — so trace-view/trace-diff can separate
1168
+ # parallel lanes. Frozen-v1 compatible (additive field, design §6.1).
1169
+ _BRANCH = threading.local()
1170
+
1171
+
1172
+ def _current_branch():
1173
+ return getattr(_BRANCH, "id", None)
1174
+
1175
+
1176
+ def _run_with_branch(label, fn, x):
1177
+ prev = getattr(_BRANCH, "id", None)
1178
+ _BRANCH.id = label
1179
+ try:
1180
+ return _call_unpacked(fn, x)
1181
+ finally:
1182
+ _BRANCH.id = prev
1183
+
1184
+
1185
+
1186
+ def route(*arms):
1187
+ """User-defined model routing (design §4.4, v0.4): arms are
1188
+ ``(label, model, cond_fn_or_None)`` triples evaluated in order; the
1189
+ first arm whose condition is truthy wins, the arm with ``None`` is the
1190
+ ``otherwise`` fallback. The chosen label is picked up by the next
1191
+ ``llm_call``/``llm_stream`` as an additive ``route`` trace field."""
1192
+ chosen = None
1193
+ for label, model, cond in arms:
1194
+ if cond is None:
1195
+ if chosen is None:
1196
+ chosen = (label, model)
1197
+ break
1198
+ if cond():
1199
+ chosen = (label, model)
1200
+ break
1201
+ if chosen is None:
1202
+ raise RuntimeError("route block matched no arm and has no otherwise fallback")
1203
+ _LAST_ROUTE.choice = chosen
1204
+ return chosen[1]
1205
+
1206
+
1207
+ def _take_route_label():
1208
+ choice = getattr(_LAST_ROUTE, "choice", None)
1209
+ _LAST_ROUTE.choice = None
1210
+ return choice[0] if choice else None
1211
+
1212
+
1213
+ # ── merge reducer (design §7) ────────────────────────────────────────
1214
+
1215
+ def merge(l, r):
1216
+ """CRDT-style join behind `l | merge r` (design §7): dicts union
1217
+ (right side wins on key conflicts), lists append items the left side
1218
+ does not already hold (grow-only set), and anything else is
1219
+ overwritten by the right side."""
1220
+ if isinstance(l, dict) and isinstance(r, dict):
1221
+ return {**l, **r}
1222
+ if isinstance(l, list) and isinstance(r, list):
1223
+ out = list(l)
1224
+ for x in r:
1225
+ if x not in out:
1226
+ out.append(x)
1227
+ return out
1228
+ return r
1229
+
1230
+
1231
+ # ── streaming (design §4.5) ──────────────────────────────────────────
1232
+
1233
+ class _PrefixImpossible(Exception):
1234
+ """The streamed prefix can no longer satisfy the schema (design §4.5)."""
1235
+
1236
+
1237
+ class _PrefixValidator:
1238
+ """Incremental JSON-stream viability checker (design §4.5).
1239
+
1240
+ Chunks are fed as they arrive; :meth:`feed` raises
1241
+ :class:`_PrefixImpossible` the moment *no* completion of the prefix can
1242
+ satisfy the schema, so the runtime can abort the stream early (tokens
1243
+ not yet spent are saved). Abort conditions: a literal of the wrong type
1244
+ starts, a number completes outside ``minimum``/``maximum`` or non-
1245
+ integral under ``integer``, a ``format: uri`` string completes invalid,
1246
+ an object closes with a ``required`` key missing, or the JSON itself
1247
+ malforms. Unknown object keys are allowed (JSON-Schema default) and a
1248
+ ``{}`` schema accepts anything.
1249
+ """
1250
+
1251
+ def __init__(self, sch):
1252
+ self.sch = sch if isinstance(sch, dict) else {}
1253
+ self.stack = [] # open object/array frames
1254
+ self.scalar = None # in-progress string/key/number/literal
1255
+ self.done = False # root value completed
1256
+
1257
+ def feed(self, text):
1258
+ for ch in text:
1259
+ self._feed_char(ch)
1260
+
1261
+ # ── schema helpers ────────────────────────────────────────────
1262
+ @staticmethod
1263
+ def _type_of(sch):
1264
+ return sch.get("type") if isinstance(sch, dict) else None
1265
+
1266
+ def _check_start(self, ch, sch):
1267
+ t = self._type_of(sch)
1268
+ if t is None:
1269
+ return
1270
+ ok = {
1271
+ "object": ch == "{",
1272
+ "array": ch == "[",
1273
+ "string": ch == '"',
1274
+ "number": ch == "-" or ch.isdigit(),
1275
+ "integer": ch == "-" or ch.isdigit(),
1276
+ "boolean": ch in "tf",
1277
+ "null": ch == "n",
1278
+ }.get(t)
1279
+ if ok is False:
1280
+ raise _PrefixImpossible(f"expected {t}, value starts with {ch!r}")
1281
+
1282
+ # ── char machine ──────────────────────────────────────────────
1283
+ def _feed_char(self, ch):
1284
+ if self.done:
1285
+ if not ch.isspace():
1286
+ raise _PrefixImpossible("trailing data after complete document")
1287
+ return
1288
+ if self.scalar is not None:
1289
+ self._feed_scalar(ch)
1290
+ return
1291
+ if ch.isspace():
1292
+ return
1293
+ if not self.stack:
1294
+ self._start_value(ch, self.sch)
1295
+ return
1296
+ f = self.stack[-1]
1297
+ if f["kind"] == "obj":
1298
+ st = f["state"]
1299
+ if st == "key":
1300
+ if ch == '"':
1301
+ self.scalar = {"kind": "key", "buf": "", "esc": False}
1302
+ elif ch == "}":
1303
+ self._close_obj(f)
1304
+ else:
1305
+ raise _PrefixImpossible(f"object expects a key or '}}', got {ch!r}")
1306
+ elif st == "colon":
1307
+ if ch == ":":
1308
+ f["state"] = "value"
1309
+ else:
1310
+ raise _PrefixImpossible(f"expected ':', got {ch!r}")
1311
+ elif st == "value":
1312
+ f["state"] = "comma"
1313
+ subsch = {}
1314
+ if isinstance(f["sch"], dict):
1315
+ subsch = f["sch"].get("properties", {}).get(f["key"], {})
1316
+ self._start_value(ch, subsch)
1317
+ else: # comma
1318
+ if ch == ",":
1319
+ f["state"] = "key"
1320
+ elif ch == "}":
1321
+ self._close_obj(f)
1322
+ else:
1323
+ raise _PrefixImpossible(f"object expects ',' or '}}', got {ch!r}")
1324
+ else: # arr
1325
+ if f["state"] == "value":
1326
+ if ch == "]":
1327
+ self.stack.pop()
1328
+ self._after_value()
1329
+ else:
1330
+ f["state"] = "comma"
1331
+ subsch = f["sch"].get("items", {}) if isinstance(f["sch"], dict) else {}
1332
+ self._start_value(ch, subsch)
1333
+ else: # comma
1334
+ if ch == ",":
1335
+ f["state"] = "value"
1336
+ elif ch == "]":
1337
+ self.stack.pop()
1338
+ self._after_value()
1339
+ else:
1340
+ raise _PrefixImpossible(f"array expects ',' or ']', got {ch!r}")
1341
+
1342
+ def _start_value(self, ch, sch):
1343
+ self._check_start(ch, sch)
1344
+ if ch == "{":
1345
+ self.stack.append({"kind": "obj", "sch": sch, "state": "key",
1346
+ "key": None, "seen": set()})
1347
+ elif ch == "[":
1348
+ self.stack.append({"kind": "arr", "sch": sch, "state": "value"})
1349
+ elif ch == '"':
1350
+ self.scalar = {"kind": "string", "sch": sch, "buf": "", "esc": False}
1351
+ elif ch == "-" or ch.isdigit():
1352
+ self.scalar = {"kind": "number", "sch": sch, "buf": ch}
1353
+ else:
1354
+ self.scalar = {"kind": "literal", "sch": sch, "buf": ch}
1355
+
1356
+ def _feed_scalar(self, ch):
1357
+ s = self.scalar
1358
+ if s["kind"] in ("string", "key"):
1359
+ if s["esc"]:
1360
+ s["esc"] = False
1361
+ s["buf"] += ch
1362
+ elif ch == "\\":
1363
+ s["esc"] = True
1364
+ elif ch == '"':
1365
+ self.scalar = None
1366
+ if s["kind"] == "key":
1367
+ f = self.stack[-1]
1368
+ f["key"] = s["buf"]
1369
+ f["seen"].add(s["buf"])
1370
+ f["state"] = "colon"
1371
+ else:
1372
+ sch = s["sch"]
1373
+ if isinstance(sch, dict) and sch.get("format") == "uri":
1374
+ from urllib.parse import urlparse
1375
+ parsed = urlparse(s["buf"])
1376
+ if not (parsed.scheme and parsed.netloc):
1377
+ raise _PrefixImpossible(f"not a valid uri: {s['buf']!r}")
1378
+ self._after_value()
1379
+ else:
1380
+ s["buf"] += ch
1381
+ elif s["kind"] == "number":
1382
+ if ch in "0123456789+-.eE":
1383
+ s["buf"] += ch
1384
+ else:
1385
+ self.scalar = None
1386
+ try:
1387
+ num = float(s["buf"])
1388
+ except ValueError:
1389
+ raise _PrefixImpossible(f"malformed number {s['buf']!r}")
1390
+ sch = s["sch"]
1391
+ if isinstance(sch, dict):
1392
+ if sch.get("type") == "integer" and num != int(num):
1393
+ raise _PrefixImpossible(f"{s['buf']} is not an integer")
1394
+ if "minimum" in sch and num < sch["minimum"]:
1395
+ raise _PrefixImpossible(f"{num} < minimum {sch['minimum']}")
1396
+ if "maximum" in sch and num > sch["maximum"]:
1397
+ raise _PrefixImpossible(f"{num} > maximum {sch['maximum']}")
1398
+ self._after_value()
1399
+ self._feed_char(ch) # the delimiter belongs to the parent
1400
+ else: # literal: true / false / null
1401
+ s["buf"] += ch
1402
+ buf = s["buf"]
1403
+ if not any(w.startswith(buf) for w in ("true", "false", "null")):
1404
+ raise _PrefixImpossible(f"malformed literal {buf!r}")
1405
+ if buf in ("true", "false", "null"):
1406
+ self.scalar = None
1407
+ sch = s["sch"]
1408
+ t = self._type_of(sch)
1409
+ if t == "boolean" and buf == "null":
1410
+ raise _PrefixImpossible("expected boolean, got null")
1411
+ if t == "null" and buf != "null":
1412
+ raise _PrefixImpossible(f"expected null, got {buf}")
1413
+ self._after_value()
1414
+
1415
+ def _close_obj(self, f):
1416
+ if isinstance(f["sch"], dict):
1417
+ missing = [k for k in f["sch"].get("required", []) if k not in f["seen"]]
1418
+ if missing:
1419
+ raise _PrefixImpossible(f"object closed missing required {missing[0]!r}")
1420
+ self.stack.pop()
1421
+ self._after_value()
1422
+
1423
+ def _after_value(self):
1424
+ if not self.stack:
1425
+ self.done = True
1426
+
1427
+
1428
+ class _FenceFilter:
1429
+ """```json fence tolerance for streamed schema validation.
1430
+
1431
+ Non-streaming :func:`_extract_json` strips markdown fences; streamed
1432
+ chunks must get the same tolerance or fence-habit models (reasoning
1433
+ models love ```json) would early-abort every stream. Buffers until the
1434
+ fence question is decided, then forwards only the inner JSON to the
1435
+ prefix validator; a closing fence at line start ends forwarding.
1436
+ """
1437
+
1438
+ def __init__(self, validator):
1439
+ self.v = validator
1440
+ self.buf = ""
1441
+ self.mode = None # None = deciding, "plain", "fence"
1442
+ self.line_start = True
1443
+ self.tail = "" # 1-2 backticks at line start, maybe a fence
1444
+ self.closed = False
1445
+
1446
+ def feed(self, chunk):
1447
+ if self.closed:
1448
+ return
1449
+ if self.mode is None:
1450
+ self.buf += chunk
1451
+ s = self.buf.lstrip()
1452
+ if s in ("", "`", "``"):
1453
+ return
1454
+ if s.startswith("```"):
1455
+ if "\n" not in s:
1456
+ return
1457
+ self.mode = "fence"
1458
+ inner = s.split("\n", 1)[1]
1459
+ self.buf = ""
1460
+ if inner:
1461
+ self._feed_inner(inner)
1462
+ return
1463
+ self.mode = "plain"
1464
+ self.v.feed(self.buf)
1465
+ self.buf = ""
1466
+ return
1467
+ if self.mode == "plain":
1468
+ self.v.feed(chunk)
1469
+ return
1470
+ self._feed_inner(chunk)
1471
+
1472
+ def _feed_inner(self, text):
1473
+ for ch in text:
1474
+ if self.closed:
1475
+ return
1476
+ if self.line_start and (ch == "`" or self.tail):
1477
+ if ch == "`":
1478
+ self.tail += ch
1479
+ if self.tail == "```":
1480
+ self.closed = True
1481
+ continue
1482
+ # 1-2 stray backticks turned out to be content
1483
+ self.v.feed(self.tail)
1484
+ self.tail = ""
1485
+ self.line_start = ch == "\n"
1486
+ self.v.feed(ch)
1487
+
1488
+
1489
+ def llm_stream(prompt, model=None, schema=None, retry=0, repair=False,
1490
+ budget=None, cache=None, tags=None, chunk_size=14):
1491
+ """One streaming typed LLM call (design §4.5).
1492
+
1493
+ The answer arrives in chunks; with ``schema`` set, every prefix is
1494
+ validated incrementally (:class:`_PrefixValidator`) and a prefix that
1495
+ can no longer satisfy the schema aborts the stream early — the abort
1496
+ counts as a schema violation, so the §4.2 repair loop applies. Trace
1497
+ records carry additive ``streamed``/``chunks``/``early_abort`` fields
1498
+ (§6.1). The fake provider chunks deterministically (``chunk_size``
1499
+ characters); real providers stream over SSE (OpenAI-compatible and
1500
+ Anthropic Messages). Replay consumes the recorded final value like
1501
+ :func:`llm_call` (§6.2 — stream flags stay in the old trace).
1502
+ """
1503
+ if os.environ.get("NUDGE_REPLAY"):
1504
+ return llm_call(prompt, model=model, schema=schema, retry=retry,
1505
+ repair=repair, budget=budget, cache=cache, tags=tags)
1506
+ real = _real_provider_for(model)
1507
+ provider = real[0] if real else "fake"
1508
+
1509
+ attempts = 1 + (retry if repair and schema is not None else 0)
1510
+ last_errors, last_raw = [], None
1511
+ _budget_precheck()
1512
+ # design §4.4: a model chosen via rt.route carries its arm label
1513
+ route_label = _take_route_label()
1514
+ # design §4.3 (v1.20 fix): the declared budget caps the WHOLE call site,
1515
+ # repair rounds included — same wall as llm_call
1516
+ site_spent = [0.0]
1517
+ def charge_site(cost):
1518
+ remaining = None if budget is None else float(budget) - site_spent[0]
1519
+ if remaining is not None and cost > remaining:
1520
+ raise BudgetExceeded(
1521
+ f"call site budget exhausted: round cost ${cost:.4f} with "
1522
+ f"${remaining:.4f} left of the declared ${float(budget):.4f} "
1523
+ f"(repair rounds share the site budget)"
1524
+ )
1525
+ site_spent[0] += cost
1526
+ _budget_charge(cost, None)
1527
+ if round_no >= 1:
1528
+ _repair_budget_charge(cost)
1529
+
1530
+ def _x(d):
1531
+ return {**d, "route": route_label} if route_label else d
1532
+
1533
+ for round_no in range(attempts):
1534
+ if round_no >= 1:
1535
+ _repair_budget_precheck()
1536
+ if provider != "fake":
1537
+ # real SSE streaming (v1.2): provider deltas feed the same
1538
+ # prefix validator the fake path uses — early abort and repair
1539
+ # behave identically, tokens/cost come from the usage events
1540
+ usage = {"in": 0, "out": 0}
1541
+ bare = _split_model(model)[1]
1542
+ stream = (_anthropic_chat_stream if provider == "anthropic"
1543
+ else _openai_chat_stream)(provider, bare, prompt, usage)
1544
+ acc, consumed, aborted = [], 0, None
1545
+ validator = _PrefixValidator(schema) if schema is not None else None
1546
+ vfilter = _FenceFilter(validator) if validator is not None else None
1547
+ for chunk in stream:
1548
+ acc.append(chunk)
1549
+ consumed += 1
1550
+ if vfilter is not None:
1551
+ try:
1552
+ vfilter.feed(chunk)
1553
+ except _PrefixImpossible as e:
1554
+ aborted = str(e)
1555
+ break
1556
+ text = "".join(acc)
1557
+ in_t = usage["in"] or len(str(prompt).split())
1558
+ out_t = usage["out"] or len(text.split())
1559
+ cost = _call_cost(provider, model, in_t, out_t)
1560
+ tok = {"in": in_t, "out": out_t}
1561
+ if aborted is not None:
1562
+ last_errors, last_raw = [f"stream aborted: {aborted}"], text
1563
+ _trace_call(model, prompt, text, round_no, "schema_violation",
1564
+ extra=_x({"streamed": True, "chunks": consumed, "early_abort": True}),
1565
+ provider=provider, tokens=tok, cost=cost)
1566
+ charge_site(cost)
1567
+ prompt = _REPAIR_HINT.format(errors="stream aborted: " + aborted) + "\n" + str(prompt)
1568
+ continue
1569
+ out = _extract_json(text) if schema is not None else text
1570
+ if schema is None:
1571
+ _trace_call(model, prompt, out, round_no, "ok",
1572
+ extra=_x({"streamed": True, "chunks": consumed}),
1573
+ provider=provider, tokens=tok, cost=cost)
1574
+ charge_site(cost)
1575
+ return out
1576
+ errors = validate(schema, out)
1577
+ if not errors:
1578
+ _trace_call(model, prompt, out, round_no, "ok",
1579
+ extra=_x({"streamed": True, "chunks": consumed}),
1580
+ provider=provider, tokens=tok, cost=cost)
1581
+ charge_site(cost)
1582
+ return _attr(out)
1583
+ last_errors, last_raw = errors, out
1584
+ _trace_call(model, prompt, out, round_no, "schema_violation",
1585
+ extra=_x({"streamed": True, "chunks": consumed}),
1586
+ provider=provider, tokens=tok, cost=cost)
1587
+ charge_site(cost)
1588
+ prompt = _REPAIR_HINT.format(errors="; ".join(errors)) + "\n" + str(prompt)
1589
+ continue
1590
+ out = _fake_answer(prompt, model, schema)
1591
+ if schema is not None:
1592
+ text = json.dumps(_jsonable(out), ensure_ascii=False)
1593
+ else:
1594
+ text = str(out)
1595
+ chunks = [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)] or [""]
1596
+ validator = _PrefixValidator(schema) if schema is not None else None
1597
+ aborted, consumed = None, 0
1598
+ for chunk in chunks:
1599
+ consumed += 1
1600
+ if validator is not None:
1601
+ try:
1602
+ validator.feed(chunk)
1603
+ except _PrefixImpossible as e:
1604
+ aborted = str(e)
1605
+ break
1606
+ if aborted is not None:
1607
+ last_errors, last_raw = [f"stream aborted: {aborted}"], out
1608
+ _trace_call(model, prompt, out, round_no, "schema_violation",
1609
+ extra=_x({"streamed": True, "chunks": consumed, "early_abort": True}))
1610
+ charge_site(FAKE_CALL_COST)
1611
+ # design §4.5: an unsatisfiable prefix aborts early and triggers repair
1612
+ prompt = _REPAIR_HINT.format(errors="stream aborted: " + aborted) + "\n" + str(prompt)
1613
+ continue
1614
+ if schema is None:
1615
+ _trace_call(model, prompt, out, 0, "ok",
1616
+ extra=_x({"streamed": True, "chunks": consumed}))
1617
+ charge_site(FAKE_CALL_COST)
1618
+ return out
1619
+ errors = validate(schema, out)
1620
+ if not errors:
1621
+ _trace_call(model, prompt, out, round_no, "ok",
1622
+ extra=_x({"streamed": True, "chunks": consumed}))
1623
+ charge_site(FAKE_CALL_COST)
1624
+ return out
1625
+ last_errors, last_raw = errors, out
1626
+ _trace_call(model, prompt, out, round_no, "schema_violation",
1627
+ extra=_x({"streamed": True, "chunks": consumed}))
1628
+ charge_site(FAKE_CALL_COST)
1629
+ # design §4.2 step 1: feed raw output errors back to the model
1630
+ prompt = _REPAIR_HINT.format(errors="; ".join(errors)) + "\n" + str(prompt)
1631
+ raise SchemaFailure(last_errors, last_raw)
1632
+
1633
+
1634
+ # ── the LLM call ─────────────────────────────────────────────────────
1635
+
1636
+ _FAKE_STATE = {"fail_left": int(os.environ.get("NUDGE_FAKE_FAIL_FIRST", "0"))}
1637
+
1638
+ _REPAIR_HINT = (
1639
+ "Your previous output failed validation. Errors: {errors}. "
1640
+ "Emit corrected output only."
1641
+ )
1642
+
1643
+
1644
+ def _fake_answer(prompt, model, sch):
1645
+ if _FAKE_STATE["fail_left"] > 0:
1646
+ _FAKE_STATE["fail_left"] -= 1
1647
+ return {"__invalid__": True} if sch is not None else "fake failure"
1648
+ if sch is not None:
1649
+ return _synth(sch)
1650
+ return f"[fake:{model or 'default'}] {str(prompt)[:80]}"
1651
+
1652
+
1653
+ def llm_call(prompt, model=None, schema=None, retry=0, repair=False,
1654
+ budget=None, cache=None, tags=None):
1655
+ """One typed LLM call (design §4).
1656
+
1657
+ MVP: fake provider only. With ``schema`` set, output is validated; a
1658
+ violation triggers the §4.2 repair loop for up to ``retry`` rounds when
1659
+ ``repair`` is set, then raises :class:`SchemaFailure`.
1660
+ """
1661
+ replaying = os.environ.get("NUDGE_REPLAY")
1662
+ if replaying:
1663
+ provider, real = "replay", None
1664
+ else:
1665
+ real = _real_provider_for(model)
1666
+ provider = real[0] if real else "fake"
1667
+
1668
+ attempts = 1 + (retry if repair and schema is not None else 0)
1669
+ last_errors, last_raw = [], None
1670
+ if provider != "replay":
1671
+ _budget_precheck()
1672
+ # design §4.4: a model chosen via rt.route carries its arm label
1673
+ route_label = _take_route_label()
1674
+ route_extra = {"route": route_label} if route_label else None
1675
+ # design §4.3: the declared `budget` caps the WHOLE call site, repair
1676
+ # rounds included — each round is charged against what remains
1677
+ site_spent = [0.0]
1678
+ def charge_site(cost):
1679
+ remaining = None if budget is None else float(budget) - site_spent[0]
1680
+ if remaining is not None and cost > remaining:
1681
+ raise BudgetExceeded(
1682
+ f"call site budget exhausted: round cost ${cost:.4f} with "
1683
+ f"${remaining:.4f} left of the declared ${float(budget):.4f} "
1684
+ f"(repair rounds share the site budget)"
1685
+ )
1686
+ site_spent[0] += cost
1687
+ _budget_charge(cost, None)
1688
+ if round_no >= 1:
1689
+ _repair_budget_charge(cost)
1690
+ for round_no in range(attempts):
1691
+ if round_no >= 1 and provider != "replay":
1692
+ _repair_budget_precheck()
1693
+ if provider == "replay":
1694
+ outputs = _replay_outputs()
1695
+ if _REPLAY_STATE["idx"] >= len(outputs):
1696
+ if not os.environ.get("NUDGE_RESUME"):
1697
+ raise ReplayMismatch(
1698
+ "program made more llm calls than the trace holds "
1699
+ f"({len(outputs)} records)"
1700
+ )
1701
+ # resume (design §7): the recorded prefix is exhausted —
1702
+ # continue live against the fake provider and trace it
1703
+ provider = real[0] if real else "fake"
1704
+ out, in_t, out_t = _complete(provider, model, prompt, schema)
1705
+ else:
1706
+ out = outputs[_REPLAY_STATE["idx"]]
1707
+ _REPLAY_STATE["idx"] += 1
1708
+ else:
1709
+ out, in_t, out_t = _complete(provider, model, prompt, schema)
1710
+ if schema is None:
1711
+ if provider != "replay":
1712
+ _trace_call(model, prompt, out, 0, "ok", extra=route_extra,
1713
+ provider=provider, tokens={"in": in_t, "out": out_t},
1714
+ cost=_call_cost(provider, model, in_t, out_t))
1715
+ charge_site(_call_cost(provider, model, in_t, out_t))
1716
+ return out
1717
+ errors = validate(schema, out)
1718
+ if not errors:
1719
+ if provider != "replay":
1720
+ _trace_call(model, prompt, out, round_no, "ok", extra=route_extra,
1721
+ provider=provider, tokens={"in": in_t, "out": out_t},
1722
+ cost=_call_cost(provider, model, in_t, out_t))
1723
+ charge_site(_call_cost(provider, model, in_t, out_t))
1724
+ # validated records support Nudge's `.field` syntax (AttrDict)
1725
+ return _attr(out)
1726
+ last_errors, last_raw = errors, out
1727
+ if provider != "replay":
1728
+ _trace_call(model, prompt, out, round_no, "schema_violation", extra=route_extra,
1729
+ provider=provider, tokens={"in": in_t, "out": out_t},
1730
+ cost=_call_cost(provider, model, in_t, out_t))
1731
+ charge_site(_call_cost(provider, model, in_t, out_t))
1732
+ # design §4.2 step 1: feed raw output errors back to the model
1733
+ prompt = _REPAIR_HINT.format(errors="; ".join(errors)) + "\n" + str(prompt)
1734
+ raise SchemaFailure(last_errors, last_raw)
1735
+
1736
+
1737
+ # ── parallelism (design §5) ──────────────────────────────────────────
1738
+
1739
+
1740
+ def _call_unpacked(fn, x):
1741
+ """Nudge's pair-unpacking: when the lambda takes more than one parameter
1742
+ and the element is a pair (a tuple, or the ``{first, second}`` record
1743
+ produced by :func:`zip`), it is spread across the parameters —
1744
+ ``|(a, h)| -> f(a, h)``."""
1745
+ try:
1746
+ argc = fn.__code__.co_argcount
1747
+ except AttributeError:
1748
+ argc = 1
1749
+ if argc > 1:
1750
+ if isinstance(x, tuple) and len(x) == argc:
1751
+ return fn(*x)
1752
+ if isinstance(x, dict) and argc == 2 and "first" in x and "second" in x:
1753
+ return fn(x["first"], x["second"])
1754
+ return fn(x)
1755
+
1756
+
1757
+ _py_zip = zip
1758
+
1759
+
1760
+ def zip(a, b):
1761
+ """Nudge `a zip b` — pairwise zip as a REUSABLE list of ``{first,
1762
+ second}`` records, matching the checker's type model (so `.first` /
1763
+ `.second` field access works) while :func:`_call_unpacked` still spreads
1764
+ pairs across multi-param ``par map`` lambdas. (Python's builtin zip
1765
+ yields one-shot tuples — field access on them was an AttributeError.)"""
1766
+ return [AttrDict({"first": x, "second": y}) for x, y in _py_zip(a, b)]
1767
+
1768
+
1769
+ def par_map(coll, fn, concurrency=None):
1770
+ """Thread-pool fan-out. Results keep input order (map semantics); the
1771
+ budget counter is shared across branches, so a wall hit surfaces as
1772
+ ``BudgetExceeded`` from an in-flight branch (design §4.3/§5)."""
1773
+ items = list(coll)
1774
+ if not items:
1775
+ return []
1776
+ workers = concurrency or min(32, len(items))
1777
+ with ThreadPoolExecutor(max_workers=workers) as pool:
1778
+ return list(pool.map(
1779
+ lambda ix: _run_with_branch(f"par[{ix[0]}]", fn, ix[1]),
1780
+ enumerate(items),
1781
+ ))
1782
+
1783
+
1784
+ def par_all(items):
1785
+ """Barrier: run all branches concurrently, return results in order."""
1786
+ items = list(items)
1787
+ if not items:
1788
+ return []
1789
+ with ThreadPoolExecutor(max_workers=len(items)) as pool:
1790
+ return list(pool.map(
1791
+ lambda ix: _run_with_branch(f"par[{ix[0]}]", (lambda f: f() if callable(f) else f), ix[1]),
1792
+ enumerate(items),
1793
+ ))
1794
+
1795
+
1796
+ def par_race(items):
1797
+ """First completed branch wins; losers are cancelled best-effort
1798
+ (a call already in flight keeps its spend — design §5 budget refund
1799
+ is post-MVP).
1800
+
1801
+ v1.3 fix: the pool no longer joins on exit — the old
1802
+ ``with ThreadPoolExecutor(...)`` block made ``shutdown(wait=True)``
1803
+ wait for every losing branch before returning, so a "race" took as
1804
+ long as the SLOWEST candidate. Losers now keep running in the
1805
+ background while the winner's result returns immediately."""
1806
+ items = list(items)
1807
+ if not items:
1808
+ raise ValueError("par race needs at least one candidate")
1809
+ pool = ThreadPoolExecutor(max_workers=len(items))
1810
+ futures = [
1811
+ pool.submit(_run_with_branch, f"par[{i}]", (lambda f: f() if callable(f) else f), it)
1812
+ for i, it in enumerate(items)
1813
+ ]
1814
+ try:
1815
+ for done in as_completed(futures):
1816
+ for other in futures:
1817
+ if other is not done:
1818
+ other.cancel()
1819
+ return done.result()
1820
+ finally:
1821
+ pool.shutdown(wait=False, cancel_futures=True)
1822
+ raise ValueError("par race found no result")