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.
- annoscript/__init__.py +1 -0
- annoscript/_backend_profiles.py +17 -0
- annoscript/_client_common.py +287 -0
- annoscript/_data.py +37 -0
- annoscript/_dispatch_pool.py +47 -0
- annoscript/_env.py +19 -0
- annoscript/_examples.py +34 -0
- annoscript/_expressions.py +93 -0
- annoscript/_plan_viewer.html +953 -0
- annoscript/_remote_jobs.py +211 -0
- annoscript/_request_adapters.py +71 -0
- annoscript/_speculation.py +321 -0
- annoscript/_test_artifacts.py +33 -0
- annoscript/_test_evaluation_http.py +117 -0
- annoscript/_test_ir_runtime.py +138 -0
- annoscript/_test_job_process.py +166 -0
- annoscript/_test_plan_runtime.py +331 -0
- annoscript/_test_remote_client.py +46 -0
- annoscript/_test_remote_server.py +75 -0
- annoscript/_test_synthetic.py +67 -0
- annoscript/anno.py +270 -0
- annoscript/backends/__init__.py +13 -0
- annoscript/backends/_batch_wire.py +26 -0
- annoscript/backends/_bounded_grammar.py +97 -0
- annoscript/backends/_example_layout.py +23 -0
- annoscript/backends/_generation_policy.py +116 -0
- annoscript/backends/_grammar.py +83 -0
- annoscript/backends/_preview.py +13 -0
- annoscript/backends/_prompt.py +115 -0
- annoscript/backends/_shared_rules.py +38 -0
- annoscript/backends/adapters/__init__.py +17 -0
- annoscript/backends/adapters/chat.py +39 -0
- annoscript/backends/adapters/decision.py +119 -0
- annoscript/backends/base.py +427 -0
- annoscript/backends/config.py +67 -0
- annoscript/backends/decision.py +150 -0
- annoscript/backends/llm.py +338 -0
- annoscript/client.py +247 -0
- annoscript/client_tests.py +17 -0
- annoscript/compiler.py +708 -0
- annoscript/cross_version_tests.py +68 -0
- annoscript/fixtures/request-adapters-v1.json +4741 -0
- annoscript/formatter.py +171 -0
- annoscript/grammar_tests.py +176 -0
- annoscript/inspect.py +77 -0
- annoscript/ir.py +458 -0
- annoscript/job_cross_version_tests.py +118 -0
- annoscript/job_protocol.py +180 -0
- annoscript/jobs.py +255 -0
- annoscript/metrics.py +153 -0
- annoscript/p0_grammar_tests.py +147 -0
- annoscript/passes/__init__.py +40 -0
- annoscript/passes/_analysis.py +178 -0
- annoscript/passes/control_flow.py +68 -0
- annoscript/passes/dead_assignments.py +44 -0
- annoscript/passes/folding.py +104 -0
- annoscript/passes/propagation.py +53 -0
- annoscript/passes/temporaries.py +45 -0
- annoscript/plan.py +542 -0
- annoscript/plan_formatter.py +452 -0
- annoscript/plan_lowering.py +571 -0
- annoscript/preview.py +115 -0
- annoscript/remote.py +304 -0
- annoscript/runtime.py +861 -0
- annoscript/server/__init__.py +1 -0
- annoscript/server/__main__.py +49 -0
- annoscript/server/_plan_cache.py +79 -0
- annoscript/server/api.py +146 -0
- annoscript/server/metrics.py +140 -0
- annoscript/server/migrate.py +61 -0
- annoscript/server/releases.py +143 -0
- annoscript/server/store.py +429 -0
- annoscript/server/worker.py +290 -0
- annoscript/substring_tests.py +79 -0
- annoscript/test_advisory_lengths.py +115 -0
- annoscript/test_backends.py +181 -0
- annoscript/test_client.py +349 -0
- annoscript/test_compact_batch.py +95 -0
- annoscript/test_compiler.py +444 -0
- annoscript/test_decision_adapters.py +410 -0
- annoscript/test_decision_wire.py +190 -0
- annoscript/test_diagnostics.py +97 -0
- annoscript/test_engineering_p0.py +191 -0
- annoscript/test_environment.py +65 -0
- annoscript/test_example_backends.py +251 -0
- annoscript/test_examples.py +149 -0
- annoscript/test_field_names.py +221 -0
- annoscript/test_formatter.py +153 -0
- annoscript/test_fusion_scheduling.py +109 -0
- annoscript/test_generation_policy.py +180 -0
- annoscript/test_ir.py +148 -0
- annoscript/test_jobs.py +371 -0
- annoscript/test_jobs_server.py +511 -0
- annoscript/test_llm_batch.py +428 -0
- annoscript/test_metrics.py +59 -0
- annoscript/test_metrics_server.py +261 -0
- annoscript/test_minimal_fusion.py +59 -0
- annoscript/test_nullable_generate.py +169 -0
- annoscript/test_passes.py +408 -0
- annoscript/test_plan.py +522 -0
- annoscript/test_plan_constraints.py +305 -0
- annoscript/test_plan_formatter.py +272 -0
- annoscript/test_prompt_layout.py +140 -0
- annoscript/test_qwen_backend.py +181 -0
- annoscript/test_qwen_pilot_regression.py +180 -0
- annoscript/test_release_client.py +20 -0
- annoscript/test_remote.py +308 -0
- annoscript/test_request_adapters.py +295 -0
- annoscript/test_requirements.py +228 -0
- annoscript/test_rule_dedup.py +67 -0
- annoscript/test_runtime.py +314 -0
- annoscript/test_runtime_client.py +133 -0
- annoscript/test_source_fields.py +111 -0
- annoscript/test_speculation.py +458 -0
- annoscript/test_substring_backend.py +81 -0
- annoscript/test_synthetic_example.py +61 -0
- annoscript-0.1.0.dist-info/METADATA +145 -0
- annoscript-0.1.0.dist-info/RECORD +128 -0
- annoscript-0.1.0.dist-info/WHEEL +4 -0
- annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
- annoscript_examples/__init__.py +1 -0
- annoscript_examples/fixtures.py +3 -0
- annoscript_examples/request.execution_plan.json +3017 -0
- annoscript_examples/request.json +858 -0
- annoscript_examples/request.py +37 -0
- annoscript_examples/request_preview.decision.json +23 -0
- annoscript_examples/request_preview.generation.json +20 -0
- 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()
|