annoscript 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. annoscript/__init__.py +1 -0
  2. annoscript/_backend_profiles.py +17 -0
  3. annoscript/_client_common.py +287 -0
  4. annoscript/_data.py +37 -0
  5. annoscript/_dispatch_pool.py +47 -0
  6. annoscript/_env.py +19 -0
  7. annoscript/_examples.py +34 -0
  8. annoscript/_expressions.py +93 -0
  9. annoscript/_plan_viewer.html +953 -0
  10. annoscript/_remote_jobs.py +211 -0
  11. annoscript/_request_adapters.py +71 -0
  12. annoscript/_speculation.py +321 -0
  13. annoscript/_test_artifacts.py +33 -0
  14. annoscript/_test_evaluation_http.py +117 -0
  15. annoscript/_test_ir_runtime.py +138 -0
  16. annoscript/_test_job_process.py +166 -0
  17. annoscript/_test_plan_runtime.py +331 -0
  18. annoscript/_test_remote_client.py +46 -0
  19. annoscript/_test_remote_server.py +75 -0
  20. annoscript/_test_synthetic.py +67 -0
  21. annoscript/anno.py +270 -0
  22. annoscript/backends/__init__.py +13 -0
  23. annoscript/backends/_batch_wire.py +26 -0
  24. annoscript/backends/_bounded_grammar.py +97 -0
  25. annoscript/backends/_example_layout.py +23 -0
  26. annoscript/backends/_generation_policy.py +116 -0
  27. annoscript/backends/_grammar.py +83 -0
  28. annoscript/backends/_preview.py +13 -0
  29. annoscript/backends/_prompt.py +115 -0
  30. annoscript/backends/_shared_rules.py +38 -0
  31. annoscript/backends/adapters/__init__.py +17 -0
  32. annoscript/backends/adapters/chat.py +39 -0
  33. annoscript/backends/adapters/decision.py +119 -0
  34. annoscript/backends/base.py +427 -0
  35. annoscript/backends/config.py +67 -0
  36. annoscript/backends/decision.py +150 -0
  37. annoscript/backends/llm.py +338 -0
  38. annoscript/client.py +247 -0
  39. annoscript/client_tests.py +17 -0
  40. annoscript/compiler.py +708 -0
  41. annoscript/cross_version_tests.py +68 -0
  42. annoscript/fixtures/request-adapters-v1.json +4741 -0
  43. annoscript/formatter.py +171 -0
  44. annoscript/grammar_tests.py +176 -0
  45. annoscript/inspect.py +77 -0
  46. annoscript/ir.py +458 -0
  47. annoscript/job_cross_version_tests.py +118 -0
  48. annoscript/job_protocol.py +180 -0
  49. annoscript/jobs.py +255 -0
  50. annoscript/metrics.py +153 -0
  51. annoscript/p0_grammar_tests.py +147 -0
  52. annoscript/passes/__init__.py +40 -0
  53. annoscript/passes/_analysis.py +178 -0
  54. annoscript/passes/control_flow.py +68 -0
  55. annoscript/passes/dead_assignments.py +44 -0
  56. annoscript/passes/folding.py +104 -0
  57. annoscript/passes/propagation.py +53 -0
  58. annoscript/passes/temporaries.py +45 -0
  59. annoscript/plan.py +542 -0
  60. annoscript/plan_formatter.py +452 -0
  61. annoscript/plan_lowering.py +571 -0
  62. annoscript/preview.py +115 -0
  63. annoscript/remote.py +304 -0
  64. annoscript/runtime.py +861 -0
  65. annoscript/server/__init__.py +1 -0
  66. annoscript/server/__main__.py +49 -0
  67. annoscript/server/_plan_cache.py +79 -0
  68. annoscript/server/api.py +146 -0
  69. annoscript/server/metrics.py +140 -0
  70. annoscript/server/migrate.py +61 -0
  71. annoscript/server/releases.py +143 -0
  72. annoscript/server/store.py +429 -0
  73. annoscript/server/worker.py +290 -0
  74. annoscript/substring_tests.py +79 -0
  75. annoscript/test_advisory_lengths.py +115 -0
  76. annoscript/test_backends.py +181 -0
  77. annoscript/test_client.py +349 -0
  78. annoscript/test_compact_batch.py +95 -0
  79. annoscript/test_compiler.py +444 -0
  80. annoscript/test_decision_adapters.py +410 -0
  81. annoscript/test_decision_wire.py +190 -0
  82. annoscript/test_diagnostics.py +97 -0
  83. annoscript/test_engineering_p0.py +191 -0
  84. annoscript/test_environment.py +65 -0
  85. annoscript/test_example_backends.py +251 -0
  86. annoscript/test_examples.py +149 -0
  87. annoscript/test_field_names.py +221 -0
  88. annoscript/test_formatter.py +153 -0
  89. annoscript/test_fusion_scheduling.py +109 -0
  90. annoscript/test_generation_policy.py +180 -0
  91. annoscript/test_ir.py +148 -0
  92. annoscript/test_jobs.py +371 -0
  93. annoscript/test_jobs_server.py +511 -0
  94. annoscript/test_llm_batch.py +428 -0
  95. annoscript/test_metrics.py +59 -0
  96. annoscript/test_metrics_server.py +261 -0
  97. annoscript/test_minimal_fusion.py +59 -0
  98. annoscript/test_nullable_generate.py +169 -0
  99. annoscript/test_passes.py +408 -0
  100. annoscript/test_plan.py +522 -0
  101. annoscript/test_plan_constraints.py +305 -0
  102. annoscript/test_plan_formatter.py +272 -0
  103. annoscript/test_prompt_layout.py +140 -0
  104. annoscript/test_qwen_backend.py +181 -0
  105. annoscript/test_qwen_pilot_regression.py +180 -0
  106. annoscript/test_release_client.py +20 -0
  107. annoscript/test_remote.py +308 -0
  108. annoscript/test_request_adapters.py +295 -0
  109. annoscript/test_requirements.py +228 -0
  110. annoscript/test_rule_dedup.py +67 -0
  111. annoscript/test_runtime.py +314 -0
  112. annoscript/test_runtime_client.py +133 -0
  113. annoscript/test_source_fields.py +111 -0
  114. annoscript/test_speculation.py +458 -0
  115. annoscript/test_substring_backend.py +81 -0
  116. annoscript/test_synthetic_example.py +61 -0
  117. annoscript-0.1.0.dist-info/METADATA +145 -0
  118. annoscript-0.1.0.dist-info/RECORD +128 -0
  119. annoscript-0.1.0.dist-info/WHEEL +4 -0
  120. annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
  121. annoscript_examples/__init__.py +1 -0
  122. annoscript_examples/fixtures.py +3 -0
  123. annoscript_examples/request.execution_plan.json +3017 -0
  124. annoscript_examples/request.json +858 -0
  125. annoscript_examples/request.py +37 -0
  126. annoscript_examples/request_preview.decision.json +23 -0
  127. annoscript_examples/request_preview.generation.json +20 -0
  128. annoscript_examples/run.py +24 -0
annoscript/runtime.py ADDED
@@ -0,0 +1,861 @@
1
+ """Dependency-driven execution with runtime-owned, per-state Decision batches.
2
+
3
+ Only network requests get asyncio Tasks. Local Plan tasks are advanced by one
4
+ event-loop owner; no source code, Python program or test executor is executed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from collections import deque
11
+ from dataclasses import dataclass, field
12
+ from time import monotonic
13
+
14
+ from annoscript import plan as p
15
+ from annoscript._data import snapshot
16
+ from annoscript._expressions import expression, unpack
17
+ from annoscript.backends.base import (InferenceContext, normalize_call, validate_response,
18
+ DECISION_OPERATIONS, BackendFatalError, InferenceError,
19
+ LLM_BATCH_DEFAULTS, validate_batch_options)
20
+ from annoscript.client import Result, ResultError
21
+ from annoscript._speculation import Speculation
22
+
23
+
24
+ _WAIT, _SKIP, _END = object(), object(), object()
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RunInput:
29
+ index: int
30
+ state: object
31
+
32
+
33
+ class ConstraintError(ValueError):
34
+ pass
35
+
36
+
37
+ @dataclass
38
+ class _Instance:
39
+ task: p.Task
40
+ ctx: tuple
41
+ status: str = "pending"
42
+ result: object = None
43
+ iterator: object = None
44
+ iterations: list = field(default_factory=list)
45
+ exhausted: bool = False
46
+ entered: bool = False
47
+
48
+
49
+ @dataclass
50
+ class _Request:
51
+ call: object
52
+ waiters: list
53
+ status: str = "queued"
54
+ response: object = None
55
+ repair_attempt: int = 0
56
+ repair_parent: str | None = None
57
+ repair_feedback: str | None = None
58
+ key: object = None
59
+ speculative: bool = False
60
+ discarded: bool = False
61
+ origins: list = field(default_factory=list)
62
+ error: object = None
63
+ error_batch: str | None = None
64
+ repairable: bool = False
65
+
66
+
67
+ class AsyncResultStream:
68
+ """A single-consumer submission stream; closing cancels only this submission."""
69
+ def __init__(self, runtime, submission_id, plan, inputs, mode):
70
+ self.runtime, self.id, self.plan, self.mode = runtime, submission_id, plan, mode
71
+ self.pending = deque(inputs)
72
+ self.runs = set()
73
+ self.queue = asyncio.Queue()
74
+ self.closed = False
75
+ self.finished = False
76
+
77
+ def __aiter__(self):
78
+ return self
79
+
80
+ async def __anext__(self):
81
+ self.runtime._bind()
82
+ if self.closed:
83
+ raise StopAsyncIteration
84
+ try:
85
+ item = await self.queue.get()
86
+ except BaseException:
87
+ await self.aclose()
88
+ raise
89
+ if item is _END:
90
+ await self.aclose()
91
+ raise StopAsyncIteration
92
+ if isinstance(item, BaseException):
93
+ await self.aclose()
94
+ raise item
95
+ return item
96
+
97
+ async def __aenter__(self):
98
+ if self.closed:
99
+ raise RuntimeError("result stream is closed")
100
+ return self
101
+
102
+ async def __aexit__(self, *args):
103
+ await self.aclose()
104
+
105
+ async def aclose(self):
106
+ self.runtime._bind()
107
+ if not self.closed:
108
+ self.closed = True
109
+ self.pending.clear()
110
+ for run in list(self.runs):
111
+ self.runtime._remove_run(run, cancel=True)
112
+ while not self.queue.empty():
113
+ self.queue.get_nowait()
114
+ self.queue.put_nowait(_END)
115
+ self.runtime._submissions.pop(self.id, None)
116
+ self.runtime._wake.set()
117
+
118
+
119
+ class _Run:
120
+ def __init__(self, runtime, stream, input, run_id):
121
+ self.runtime, self.stream, self.input, self.id = runtime, stream, input, run_id
122
+ self.plan, self.mode = stream.plan, stream.mode
123
+ self.context = InferenceContext(input.state, self.plan.config.system_prompt)
124
+ self.tasks = {t.id: t for t in self.plan.tasks}
125
+ self.regions = {r.id: r for r in self.plan.regions}
126
+ self.values = {v.id: v for v in self.plan.values}
127
+ self.requests = {r.id: r for r in self.plan.requests}
128
+ self.incoming = {t.id: [] for t in self.plan.tasks}
129
+ for dep in self.plan.dependencies_for(self.mode):
130
+ self.incoming[dep.target].append(dep)
131
+ self.paths = {}
132
+ for t in self.plan.tasks:
133
+ path, region = [], self.regions[t.region]
134
+ while region.parent is not None:
135
+ if region.kind == "loop":
136
+ path.append(region.owner)
137
+ region = self.regions[region.parent]
138
+ self.paths[t.id] = tuple(reversed(path))
139
+ self.instances, self.region_runs, self.outputs, self.items = {}, {}, {}, {}
140
+ self.pending = deque()
141
+ self.allocations = {}
142
+ self.cache = {}
143
+ self._call_sequence = 0
144
+ self.revision = 0
145
+ self.local_ready = False
146
+ self.speculation = Speculation(self) if self.mode == "speculative" else None
147
+ self.return_values = []
148
+ self.done = False
149
+ self.root = next(r.id for r in self.plan.regions if r.kind == "root")
150
+ self.launch(self.root, ())
151
+
152
+ def launch(self, region, ctx):
153
+ keys = []
154
+ for tid in self.regions[region].tasks:
155
+ key = tid, ctx
156
+ self.instances[key] = _Instance(self.tasks[tid], ctx)
157
+ self.pending.append(key)
158
+ keys.append(key)
159
+ self.region_runs[region, ctx] = keys
160
+
161
+ def instance(self, tid, ctx):
162
+ ctx = ctx[:len(self.paths[tid])]
163
+ found = self.instances.get((tid, ctx))
164
+ if found is not None:
165
+ return found
166
+ region = self.regions[self.tasks[tid].region]
167
+ if region.kind in ("then", "else"):
168
+ owner = self.instance(region.owner, ctx)
169
+ if owner is _SKIP:
170
+ return _SKIP
171
+ if owner is not _WAIT and owner.status == "done" and (owner.result is _SKIP or bool(owner.result) != (region.kind == "then")):
172
+ return _SKIP
173
+ return _WAIT
174
+
175
+ def port(self, tid, ctx, port="done"):
176
+ inst = self.instance(tid, ctx)
177
+ if inst is _SKIP:
178
+ return False
179
+ if inst is _WAIT:
180
+ return _WAIT
181
+ if port == "enter" and inst.entered:
182
+ return True
183
+ if inst.status != "done":
184
+ return _WAIT
185
+ if inst.result is _SKIP:
186
+ return False
187
+ if port == "false":
188
+ return not inst.result
189
+ if port in ("true", "continue"):
190
+ return bool(inst.result)
191
+ return True
192
+
193
+ def dependency(self, dep, ctx):
194
+ if dep.condition:
195
+ condition = self.port(dep.condition.task, ctx, dep.condition.port)
196
+ if condition is _WAIT:
197
+ return False
198
+ if not condition:
199
+ return True
200
+ m = dep.mapping
201
+ common = 0
202
+ for a, b in zip(m.source_loops, m.target_loops):
203
+ if a != b:
204
+ break
205
+ common += 1
206
+ if m.carried_loop is not None:
207
+ common = min(common, m.target_loops.index(m.carried_loop) + 1)
208
+ prefix = list(ctx[:common])
209
+ if m.carried_loop is not None:
210
+ i = m.target_loops.index(m.carried_loop)
211
+ prefix[i] -= m.distance
212
+ if prefix[i] < 0:
213
+ return True
214
+ contexts = [tuple(prefix)]
215
+ for loop in m.source_loops[common:]:
216
+ expanded = []
217
+ for parent in contexts:
218
+ inst = self.instance(loop, parent)
219
+ if inst is _WAIT or inst is not _SKIP and inst.status != "done":
220
+ return False
221
+ if inst is not _SKIP and inst.result is not _SKIP:
222
+ expanded.extend(parent + (i,) for i in inst.iterations)
223
+ contexts = expanded
224
+ return all(self.port(dep.source, c, dep.port) is not _WAIT for c in contexts)
225
+
226
+ def value(self, vid, ctx):
227
+ producer = self.values[vid].producer
228
+ return self.outputs[vid, ctx[:len(self.paths[producer])]]
229
+
230
+ def expr(self, bound, ctx):
231
+ env = {b.name: self.value(b.value, ctx) for b in bound.bindings}
232
+ return expression(bound.expression, env, self.input.state, self.allocations)
233
+
234
+ def region_done(self, region, ctx):
235
+ return all(self.instances[k].status == "done" for k in self.region_runs[region, ctx])
236
+
237
+ def region_returned(self, region, ctx):
238
+ return any((isinstance(i.task, p.Return) and i.result is not _SKIP) or
239
+ (isinstance(i.task, p.Join) and i.result is False)
240
+ for i in (self.instances[k] for k in self.region_runs[region, ctx]))
241
+
242
+ def finish(self, inst, values=(), result=None):
243
+ for vid, value in zip(inst.task.outputs, values):
244
+ self.outputs[vid, inst.ctx] = value
245
+ inst.status, inst.result = "done", result
246
+ self.revision += 1
247
+ self.local_ready = False
248
+ self.runtime._record("task_done", self, task=inst.task.id, path=inst.ctx, skipped=result is _SKIP)
249
+
250
+ def request_key(self, task, ctx):
251
+ request = self.requests[task.request]
252
+ key = request.id, ctx[:len(request.scope)]
253
+ if not request.reusable:
254
+ key += (task.id, ctx)
255
+ return key
256
+
257
+ def call_id(self):
258
+ return f"{self.id}-q{self._call_sequence}"
259
+
260
+ def new_request(self, call, waiters, **options):
261
+ self._call_sequence += 1
262
+ return _Request(call, waiters, **options)
263
+
264
+ def all_requests(self):
265
+ requests = list(self.cache.values())
266
+ if self.speculation is not None:
267
+ seen = {id(r) for r in requests}
268
+ requests.extend(r for r in self.speculation.requests if id(r) not in seen)
269
+ return requests
270
+
271
+ def step(self, inst):
272
+ t, ctx = inst.task, inst.ctx
273
+ if inst.status == "pending":
274
+ for gate in t.gates:
275
+ decision = self.port(gate.task, ctx, gate.port)
276
+ if decision is _WAIT:
277
+ return False
278
+ if not decision:
279
+ self.finish(inst, result=_SKIP)
280
+ return True
281
+ if not all(self.dependency(d, ctx) for d in self.incoming[t.id]):
282
+ return False
283
+ if isinstance(t, p.Loop):
284
+ if not inst.entered:
285
+ inst.iterator = iter(self.expr(t.iterable, ctx))
286
+ inst.entered, inst.status = True, "loop"
287
+ return True
288
+ active = sum(not self.region_done(t.body, ctx + (i,)) for i in inst.iterations)
289
+ execution_mode = "eager" if self.speculation is not None else self.mode
290
+ if not inst.exhausted and active < (1 if execution_mode in t.serial_modes else self.runtime.loop_window):
291
+ try:
292
+ item = next(inst.iterator)
293
+ except StopIteration:
294
+ inst.exhausted = True
295
+ else:
296
+ index = len(inst.iterations)
297
+ self.items[t.id, ctx + (index,)] = item
298
+ inst.iterations.append(index)
299
+ self.launch(t.body, ctx + (index,))
300
+ return True
301
+ if inst.exhausted and active == 0:
302
+ values = [self.value(e.update, ctx + (inst.iterations[-1],)) if inst.iterations else self.value(e.initial, ctx) for e in t.exports]
303
+ self.finish(inst, values)
304
+ return True
305
+ return False
306
+ if isinstance(t, p.Join):
307
+ branch = self.tasks[t.branch]
308
+ selected = self.instance(t.branch, ctx).result
309
+ region = branch.then_region if selected else branch.else_region
310
+ if not self.region_done(region, ctx):
311
+ return False
312
+ self.finish(inst, result=not self.region_returned(region, ctx))
313
+ elif isinstance(t, p.Eval):
314
+ self.finish(inst, [self.expr(t.expression, ctx)])
315
+ elif isinstance(t, p.Unpack):
316
+ self.finish(inst, unpack(t.target, self.expr(t.expression, ctx)))
317
+ elif isinstance(t, p.Store):
318
+ container = self.expr(t.container, ctx)
319
+ index = self.expr(t.index, ctx)
320
+ item = self.expr(t.item, ctx)
321
+ if self.allocations.get(id(container)) is not container:
322
+ raise ValueError("Store requires a locally constructed container")
323
+ container[index] = item
324
+ self.finish(inst)
325
+ elif isinstance(t, p.Infer):
326
+ request = self.requests[t.request]
327
+ key = self.request_key(t, ctx)
328
+ args, kwvalues = self.value(t.arguments, ctx)
329
+ # Every site's argument binding is checked even when its request is cached.
330
+ call = normalize_call(self.call_id(), request.operation, args, dict(zip(t.keywords, kwvalues)), self.context)
331
+ cached = self.cache.get(key)
332
+ if cached is None and self.speculation is not None:
333
+ self.speculation.retire_mismatches(key, call, t, ctx)
334
+ cached = self.speculation.lookup(key, call)
335
+ if cached is not None:
336
+ self.cache[key] = cached
337
+ cached.discarded = False
338
+ self.runtime._record("speculation_adopted", self, task=t.id, path=ctx, call=cached.call.id)
339
+ if cached is None:
340
+ cached = self.cache[key] = self.new_request(call, [inst], key=key)
341
+ self.runtime._record("infer_ready", self, task=t.id, path=ctx, call=call.id, operation=call.operation)
342
+ else:
343
+ cached.waiters.append(inst)
344
+ self.runtime._record("infer_reuse", self, task=t.id, path=ctx, call=cached.call.id)
345
+ inst.status = "infer"
346
+ if cached.status == "done":
347
+ self.finish(inst, [cached.response])
348
+ elif cached.status == "error":
349
+ if cached.repairable:
350
+ self.runtime._repair_batch(self, [cached], {cached.call.id: cached.error}, cached.error_batch)
351
+ else:
352
+ raise cached.error
353
+ elif isinstance(t, p.Branch):
354
+ selected = bool(self.expr(t.condition, ctx))
355
+ self.launch(t.then_region if selected else t.else_region, ctx)
356
+ self.finish(inst, result=selected)
357
+ elif isinstance(t, p.Merge):
358
+ selected = self.instance(t.branch, ctx).result
359
+ value = t.then_value if selected else t.else_value
360
+ self.finish(inst, [self.value(value, ctx)] if value else (), result=None if value else _SKIP)
361
+ elif isinstance(t, p.Carry):
362
+ value = self.value(t.initial, ctx[:-1]) if ctx[-1] == 0 else self.value(t.update, ctx[:-1] + (ctx[-1]-1,))
363
+ self.finish(inst, [value])
364
+ elif isinstance(t, p.Iteration):
365
+ self.finish(inst, unpack(t.target, self.items[t.loop, ctx]))
366
+ elif isinstance(t, p.Check):
367
+ condition = self.expr(t.condition, ctx)
368
+ message = self.expr(t.message, ctx) if t.message is not None else None
369
+ if not condition:
370
+ raise ConstraintError(message or "Constraint failed")
371
+ self.finish(inst)
372
+ elif isinstance(t, p.Return):
373
+ value = self.expr(t.expression, ctx)
374
+ self.return_values.append(value)
375
+ self.finish(inst, result=value)
376
+ else:
377
+ raise ValueError("unsupported plan task")
378
+ return True
379
+
380
+ def advance(self, budget):
381
+ count = 0
382
+ self.local_ready = False
383
+ while count < budget and not self.done:
384
+ changed = False
385
+ for _ in range(len(self.pending)):
386
+ inst = self.instances[self.pending.popleft()]
387
+ if inst.status not in ("pending", "loop"):
388
+ continue
389
+ try:
390
+ revision = self.revision
391
+ progress = self.step(inst)
392
+ if progress and self.revision == revision:
393
+ # Region/iterator progress and request adoption also
394
+ # invalidate a completed private candidate scan.
395
+ self.revision += 1
396
+ except Exception as exc:
397
+ self.runtime._fail_run(self, exc, inst)
398
+ return count + 1
399
+ if inst.status in ("pending", "loop"):
400
+ self.pending.append((inst.task.id, inst.ctx))
401
+ if progress:
402
+ changed = True
403
+ count += 1
404
+ if count >= budget:
405
+ break
406
+ if not changed:
407
+ self.local_ready = True
408
+ break
409
+ if not self.done and self.region_done(self.root, ()):
410
+ try:
411
+ if len(self.return_values) != 1:
412
+ raise ValueError("plan must produce exactly one return value")
413
+ value = snapshot(self.return_values[0])
414
+ except Exception as exc:
415
+ self.runtime._fail_run(self, exc)
416
+ else:
417
+ self.stream.queue.put_nowait(Result(self.input.index, "success", value))
418
+ self.runtime._record("run_done", self)
419
+ self.runtime._remove_run(self)
420
+ return count
421
+
422
+
423
+ class AnnoRuntime:
424
+ def __init__(self, decision_backend, llm_backend, *, max_active_runs=8,
425
+ max_decision_inflight=4, max_llm_inflight=4, max_batch_questions=32,
426
+ loop_window=32, local_task_budget=1024, trace=False,
427
+ max_llm_batch_calls=8, max_llm_batch_input_bytes=262144,
428
+ max_llm_batch_output_tokens=16384, llm_batch_default_tokens=1024,
429
+ llm_batch_repair_attempts=1, request_slots=None):
430
+ for name, value in (("max_active_runs", max_active_runs), ("max_decision_inflight", max_decision_inflight),
431
+ ("max_llm_inflight", max_llm_inflight), ("max_batch_questions", max_batch_questions),
432
+ ("loop_window", loop_window), ("local_task_budget", local_task_budget)):
433
+ if type(value) is not int or value < 1:
434
+ raise ValueError(f"{name} must be a positive integer")
435
+ setattr(self, name, value)
436
+ batch_options = {name: value for name, value in locals().copy().items() if name in LLM_BATCH_DEFAULTS}
437
+ validate_batch_options(batch_options)
438
+ for name, value in batch_options.items():
439
+ setattr(self, name, value)
440
+ self.decision_backend, self.llm_backend = decision_backend, llm_backend
441
+ self.trace = [] if trace else None
442
+ self._loop = self._driver = None
443
+ self._wake = None
444
+ self._submissions, self._runs, self._inflight = {}, {}, {}
445
+ self._events = deque()
446
+ self._sequence = 0
447
+ self.closed = False
448
+ self.request_slots = request_slots
449
+ self._slot_leases = {}
450
+ self._slot_wait_started = {}
451
+
452
+ def _bind(self):
453
+ loop = asyncio.get_running_loop()
454
+ if self._loop is None:
455
+ self._loop = loop
456
+ self._wake = asyncio.Event()
457
+ elif self._loop is not loop:
458
+ raise RuntimeError("AnnoRuntime belongs to another event loop")
459
+
460
+ def _id(self, prefix):
461
+ self._sequence += 1
462
+ return f"{prefix}{self._sequence}"
463
+
464
+ def _record(self, event, run=None, **fields):
465
+ if self.trace is not None:
466
+ self.trace.append({"event": event, "time": monotonic(),
467
+ **({"run": run.id, "submission": run.stream.id, "index": run.input.index} if run else {}), **fields})
468
+
469
+ async def __aenter__(self):
470
+ self._bind()
471
+ if self.closed:
472
+ raise RuntimeError("runtime is closed")
473
+ return self
474
+
475
+ async def __aexit__(self, *args):
476
+ await self.aclose()
477
+
478
+ async def submit(self, plan, inputs, *, mode="eager"):
479
+ self._bind()
480
+ if self.closed:
481
+ raise RuntimeError("runtime is closed")
482
+ if self._driver is not None and self._driver.done():
483
+ raise BackendFatalError("runtime scheduler has stopped", code="runtime_failed")
484
+ if not isinstance(plan, p.ExecutionPlan):
485
+ raise TypeError("submit requires ExecutionPlan")
486
+ plan.validate()
487
+ plan.dependencies_for(mode)
488
+ snapshots, indices = [], set()
489
+ for item in inputs:
490
+ if not isinstance(item, RunInput) or type(item.index) is not int or item.index < 0 or item.index in indices:
491
+ raise ValueError("inputs require unique nonnegative indices")
492
+ indices.add(item.index)
493
+ snapshots.append(RunInput(item.index, snapshot(item.state, state=True)))
494
+ stream = AsyncResultStream(self, self._id("submission"), plan, snapshots, mode)
495
+ self._submissions[stream.id] = stream
496
+ if self._driver is None:
497
+ self._driver = asyncio.create_task(self._drive())
498
+ self._wake.set()
499
+ return stream
500
+
501
+ def _remove_run(self, run, cancel=False):
502
+ if run.speculation is not None:
503
+ run.speculation.prune(final=True)
504
+ run.done = True
505
+ run.stream.runs.discard(run)
506
+ self._runs.pop(run.id, None)
507
+ if self.request_slots is not None:
508
+ self.request_slots.cancel((self, run.id))
509
+ for kind in ("decision", "llm"):
510
+ since = self._slot_wait_started.pop((run.id, kind), None)
511
+ if since is not None:
512
+ self._record("slot_wait_cancelled", run, backend=kind, wait_seconds=monotonic() - since)
513
+ if cancel and self.request_slots is None:
514
+ for future, (owner, _, _, _) in list(self._inflight.items()):
515
+ if owner is run:
516
+ future.cancel()
517
+
518
+ def _fail_run(self, run, error, inst=None):
519
+ if run.done:
520
+ return
521
+ if isinstance(error, BackendFatalError):
522
+ self._fail_submission(run.stream, error)
523
+ return
524
+ code = getattr(error, "code", "constraint_unsatisfied" if isinstance(error, ConstraintError) else "execution_failed")
525
+ message = str(error)
526
+ if inst is not None:
527
+ span = inst.task.source.span
528
+ message += f" [{span.module}:{span.line}:{span.column}, task={inst.task.id}, path={inst.ctx}]"
529
+ for call in inst.task.source.calls:
530
+ message += f" [inlined at {call.module}:{call.line}:{call.column}]"
531
+ run.stream.queue.put_nowait(Result(run.input.index, "error", error=ResultError(code, message)))
532
+ self._record("run_failed", run, code=code, task=inst.task.id if inst else None, path=inst.ctx if inst else ())
533
+ self._remove_run(run, cancel=True)
534
+
535
+ def _fail_submission(self, stream, error):
536
+ stream.pending.clear()
537
+ for run in list(stream.runs):
538
+ self._remove_run(run, cancel=True)
539
+ # Fatal transport errors must be delivered before buffered successes.
540
+ while not stream.queue.empty():
541
+ stream.queue.get_nowait()
542
+ stream.queue.put_nowait(error)
543
+ stream.finished = True
544
+
545
+ def _admit(self):
546
+ available = [s for s in self._submissions.values() if not s.closed and not s.finished and s.pending]
547
+ while available and len(self._runs) < self.max_active_runs:
548
+ for stream in available:
549
+ if stream.pending and len(self._runs) < self.max_active_runs:
550
+ run = _Run(self, stream, stream.pending.popleft(), self._id("run"))
551
+ stream.runs.add(run)
552
+ self._runs[run.id] = run
553
+ self._submissions.pop(stream.id)
554
+ self._submissions[stream.id] = stream
555
+ self._record("run_started", run, entry=stream.plan.entry)
556
+ available = [s for s in available if s.pending]
557
+
558
+ def _send(self, run, requests, kind, batch_plan=None, slot_release=None):
559
+ calls = [r.call for r in requests]
560
+ for request in requests:
561
+ request.status = "inflight"
562
+ batch_id = self._id("http")
563
+ backend = self.decision_backend if kind == "decision" else self.llm_backend
564
+ details = {}
565
+ if batch_plan is not None:
566
+ details = {"input_bytes": batch_plan.input_bytes}
567
+ if kind == "llm":
568
+ details["max_tokens"] = batch_plan.max_tokens
569
+ if requests[0].repair_attempt:
570
+ details.update(repair_attempt=requests[0].repair_attempt, repair_parent=requests[0].repair_parent)
571
+ self._record("http_submitted", run, batch=batch_id, backend=kind, model=getattr(backend, "model", None),
572
+ calls=[c.id for c in calls], questions=len(calls),
573
+ **({"speculative_calls": [r.call.id for r in requests if not r.waiters]}
574
+ if run.speculation is not None else {}), **details)
575
+ if kind == "decision":
576
+ options = {"allow_partial": True} if (run.speculation is not None and
577
+ getattr(backend, "supports_partial_answers", False) is True) else {}
578
+ if batch_plan is not None and getattr(backend, "supports_prepared_batch", False) is True:
579
+ options["prepared"] = batch_plan.prepared
580
+ coroutine = backend.infer_batch(run.context, calls, **options)
581
+ elif batch_plan is not None:
582
+ prepared = ({"prepared": batch_plan.prepared} if
583
+ getattr(backend, "supports_prepared_batch", False) is True else {})
584
+ coroutine = backend.infer_batch(run.context, calls, max_tokens=batch_plan.max_tokens, **prepared)
585
+ elif requests[0].repair_attempt:
586
+ resolve = getattr(backend, "resolve_token_budget", None)
587
+ repair_budget = (resolve(calls[0], self.llm_batch_default_tokens) if resolve else
588
+ calls[0].arguments.get("max_num_tokens") or self.llm_batch_default_tokens)
589
+ coroutine = backend.infer(run.context, calls[0], repair_feedback=requests[0].repair_feedback,
590
+ max_tokens_override=repair_budget)
591
+ else:
592
+ coroutine = backend.infer(run.context, calls[0])
593
+ future = asyncio.create_task(coroutine)
594
+ self._inflight[future] = run, requests, kind, batch_id
595
+ if slot_release is not None:
596
+ self._slot_leases[future] = slot_release
597
+ future.add_done_callback(self._completed)
598
+
599
+ def _completed(self, future):
600
+ release = self._slot_leases.pop(future, None)
601
+ if release is not None:
602
+ release()
603
+ run, _, kind, _ = self._inflight[future]
604
+ self._record("slot_released", run, backend=kind)
605
+ self._events.append(future)
606
+ self._wake.set()
607
+
608
+ def _repair_batch(self, run, requests, errors, batch):
609
+ """Only batch content failures get one separately scheduled single-call repair."""
610
+ for request in requests:
611
+ error = errors.get(request.call.id)
612
+ if error is not None and (isinstance(error, BackendFatalError)
613
+ or error.code not in ("invalid_response", "invalid_batch_output", "incomplete_batch_generation")
614
+ or request.repair_attempt >= self.llm_batch_repair_attempts):
615
+ self._fail_run(run, error, request.waiters[0])
616
+ return False
617
+ for request in requests:
618
+ if request.call.id not in errors:
619
+ continue
620
+ request.repair_attempt += 1
621
+ request.repair_parent = batch
622
+ request.repair_feedback = (
623
+ "回答缺失、被截断或不符合任务要求。请检查结构、数量、非空要求和原文来源;字符长度为建议"
624
+ )
625
+ request.status = "queued"
626
+ request.error = None
627
+ request.repairable = False
628
+ return True
629
+
630
+ def _speculative_error(self, run, request, error, batch, *, repairable):
631
+ if isinstance(error, BackendFatalError):
632
+ self._fail_run(run, error)
633
+ return
634
+ request.status, request.error = "error", error
635
+ request.error_batch, request.repairable = batch, repairable
636
+ run.revision += 1
637
+ if request.waiters:
638
+ if repairable:
639
+ self._repair_batch(run, [request], {request.call.id: error}, batch)
640
+ else:
641
+ self._fail_run(run, error, request.waiters[0])
642
+ else:
643
+ self._record("speculation_error_deferred", run, call=request.call.id,
644
+ code=getattr(error, "code", "execution_failed"))
645
+
646
+ def _receive(self):
647
+ while self._events:
648
+ future = self._events.popleft()
649
+ run, requests, kind, batch = self._inflight.pop(future)
650
+ metadata = {}
651
+ try:
652
+ output = future.result()
653
+ metadata = output.metadata
654
+ is_batch = kind == "decision" or len(requests) > 1
655
+ responses = output.responses if is_batch else {requests[0].call.id: output.response}
656
+ errors = dict(getattr(output, "errors", {})) if is_batch else {}
657
+ if (set(responses) & set(errors) or set(responses) | set(errors) != {r.call.id for r in requests}
658
+ or any(not isinstance(error, InferenceError) for error in errors.values())
659
+ or (kind == "decision" and errors and run.speculation is None)):
660
+ raise InferenceError("backend returned incorrect call IDs",
661
+ code="invalid_batch_output" if kind == "llm" and is_batch else "invalid_response")
662
+ for request in requests:
663
+ if request.call.id in responses:
664
+ try:
665
+ validate_response(request.call, responses[request.call.id])
666
+ except InferenceError as exc:
667
+ if run.speculation is None and (kind != "llm" or len(requests) < 2):
668
+ raise
669
+ errors[request.call.id] = exc
670
+ except asyncio.CancelledError:
671
+ self._record("http_cancelled", run, batch=batch, backend=kind)
672
+ except Exception as exc:
673
+ self._record("http_failed", run, batch=batch, backend=kind, **(getattr(exc, "metadata", {}) or metadata))
674
+ if run.speculation is not None:
675
+ if not run.done:
676
+ for request in requests:
677
+ self._speculative_error(run, request, exc, batch,
678
+ repairable=kind == "llm" and len(requests) > 1 and
679
+ getattr(exc, "code", None) in ("invalid_batch_output", "incomplete_batch_generation"))
680
+ if run.done:
681
+ break
682
+ continue
683
+ if (not run.done and kind == "llm" and len(requests) > 1
684
+ and getattr(exc, "code", None) in ("invalid_batch_output", "incomplete_batch_generation")):
685
+ self._repair_batch(run, requests, {r.call.id: exc for r in requests}, batch)
686
+ continue
687
+ failed = next((r for r in requests if r.call.id == getattr(exc, "call_id", None)), requests[0])
688
+ self._fail_run(run, exc, failed.waiters[0])
689
+ else:
690
+ self._record("http_completed", run, batch=batch, backend=kind,
691
+ **({"failed_calls": list(errors)} if errors else {}), **metadata)
692
+ if run.done:
693
+ continue
694
+ if errors and run.speculation is not None:
695
+ for request in requests:
696
+ if request.call.id in errors:
697
+ self._speculative_error(run, request, errors[request.call.id], batch,
698
+ repairable=kind == "llm" and len(requests) > 1)
699
+ if run.done:
700
+ break
701
+ if run.done:
702
+ continue
703
+ elif errors and not self._repair_batch(run, requests, errors, batch):
704
+ continue
705
+ for request in requests:
706
+ if request.call.id in errors:
707
+ continue
708
+ request.status = "done"
709
+ request.error = None
710
+ request.response = responses[request.call.id]
711
+ run.revision += 1
712
+ for inst in request.waiters:
713
+ run.finish(inst, [request.response])
714
+
715
+ def _dispatch(self):
716
+ counts = {kind: sum(info[2] == kind for info in self._inflight.values()) for kind in ("decision", "llm")}
717
+ # One batch/call per run per pass provides round-robin fairness.
718
+ more = True
719
+ while more:
720
+ more = False
721
+ for run in list(self._runs.values()):
722
+ if run.speculation is not None:
723
+ if not run.local_ready or not run.speculation.ready:
724
+ continue
725
+ run.speculation.prune()
726
+ queued = [r for r in run.all_requests() if r.status == "queued" and not r.discarded]
727
+ for kind, limit in (("decision", self.max_decision_inflight), ("llm", self.max_llm_inflight)):
728
+ if run.speculation is not None and kind != run.speculation.phase:
729
+ continue
730
+ eligible = [r for r in queued if (r.call.operation in DECISION_OPERATIONS) == (kind == "decision") and r.status == "queued"]
731
+ if kind == "llm" and run.speculation is not None:
732
+ eligible = [r for r in eligible if r.waiters or id(r) in run.speculation.selected_requests]
733
+ if eligible and counts[kind] < limit:
734
+ release = None
735
+ if self.request_slots is not None:
736
+ owner = (self, run.id)
737
+ release = self.request_slots.acquire(kind, owner, self._wake.set)
738
+ key = (run.id, kind)
739
+ if release is None:
740
+ if key not in self._slot_wait_started:
741
+ self._slot_wait_started[key] = monotonic()
742
+ self._record("slot_wait", run, backend=kind)
743
+ continue
744
+ since = self._slot_wait_started.pop(key, None)
745
+ self._record("slot_acquired", run, backend=kind,
746
+ wait_seconds=0 if since is None else monotonic() - since)
747
+ plan = None
748
+ if kind == "decision":
749
+ selected = eligible[:self.max_batch_questions]
750
+ if getattr(self.decision_backend, "supports_decision_batch_planning", False) is True:
751
+ try:
752
+ plan = self.decision_backend.plan_batch(run.context, [r.call for r in eligible],
753
+ max_calls=self.max_batch_questions)
754
+ if plan is None:
755
+ selected = eligible[:1]
756
+ else:
757
+ selected = eligible[:len(plan.calls)]
758
+ if (not selected or len(selected) > self.max_batch_questions
759
+ or tuple(r.call for r in selected) != plan.calls):
760
+ raise ValueError("backend selected an invalid Decision batch prefix")
761
+ except BaseException:
762
+ if release is not None:
763
+ release()
764
+ raise
765
+ else:
766
+ repairs = [r for r in eligible if r.repair_attempt]
767
+ if not repairs and any(r.status == "queued" and r.repair_attempt
768
+ for other in self._runs.values() for r in other.all_requests()):
769
+ if release is not None:
770
+ release()
771
+ continue
772
+ selected = (repairs or eligible)[:1]
773
+ if (not repairs and self.max_llm_batch_calls > 1
774
+ and getattr(self.llm_backend, "supports_llm_batch", False) is True):
775
+ try:
776
+ plan = self.llm_backend.plan_batch(run.context, [r.call for r in eligible],
777
+ max_calls=self.max_llm_batch_calls, max_input_bytes=self.max_llm_batch_input_bytes,
778
+ max_output_tokens=self.max_llm_batch_output_tokens, default_tokens=self.llm_batch_default_tokens)
779
+ except BaseException:
780
+ if release is not None:
781
+ release()
782
+ raise
783
+ if plan is not None:
784
+ selected = eligible[:len(plan.calls)]
785
+ if tuple(r.call for r in selected) != plan.calls or len(selected) < 2:
786
+ if release is not None:
787
+ release()
788
+ raise ValueError("backend selected an invalid batch prefix")
789
+ try:
790
+ self._send(run, selected, kind, plan, slot_release=release)
791
+ except BaseException:
792
+ if release is not None:
793
+ release()
794
+ raise
795
+ self._runs.pop(run.id)
796
+ self._runs[run.id] = run
797
+ counts[kind] += 1
798
+ more = True
799
+
800
+ async def _drive(self):
801
+ try:
802
+ while not self.closed:
803
+ self._wake.clear()
804
+ self._receive()
805
+ self._admit()
806
+ progress = 0
807
+ for run in list(self._runs.values()):
808
+ progress += run.advance(self.local_task_budget)
809
+ if not run.done and run.speculation is not None:
810
+ progress += run.speculation.advance(self.local_task_budget)
811
+ self._dispatch()
812
+ for stream in list(self._submissions.values()):
813
+ if not stream.closed and not stream.finished and not stream.pending and not stream.runs:
814
+ stream.finished = True
815
+ stream.queue.put_nowait(_END)
816
+ if progress:
817
+ await asyncio.sleep(0)
818
+ else:
819
+ waiting_for_slots = (self.request_slots is not None and any(
820
+ self.request_slots.waiting((self, run.id)) for run in self._runs.values()))
821
+ if self._runs and not self._inflight and not waiting_for_slots:
822
+ for run in list(self._runs.values()):
823
+ self._fail_run(run, ValueError("plan cannot make progress"))
824
+ continue
825
+ await self._wake.wait()
826
+ except asyncio.CancelledError:
827
+ pass
828
+ except Exception:
829
+ for stream in list(self._submissions.values()):
830
+ if not stream.closed:
831
+ self._fail_submission(stream, BackendFatalError("runtime scheduler failed", code="runtime_failed"))
832
+
833
+ async def aclose(self):
834
+ self._bind()
835
+ if self.closed:
836
+ return
837
+ self.closed = True
838
+ for stream in list(self._submissions.values()):
839
+ await stream.aclose()
840
+ if self._driver is not None:
841
+ self._driver.cancel()
842
+ pending = list(self._inflight)
843
+ if self.request_slots is None:
844
+ for task in pending:
845
+ task.cancel()
846
+ # Shared slots represent actual work, including orphaned responses.
847
+ # Preserve the transport's existing timeout and accounting on cancellation.
848
+ draining = asyncio.gather(*pending, *([self._driver] if self._driver else []), return_exceptions=True)
849
+ cancelled = False
850
+ try:
851
+ await asyncio.shield(draining)
852
+ except asyncio.CancelledError:
853
+ await draining
854
+ cancelled = True
855
+ self._receive()
856
+ backends = [self.decision_backend]
857
+ if self.llm_backend is not self.decision_backend:
858
+ backends.append(self.llm_backend)
859
+ await asyncio.gather(*(backend.aclose() for backend in backends))
860
+ if cancelled:
861
+ raise asyncio.CancelledError()