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