polymath-agent 0.4.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.
- polymath/__init__.py +2 -0
- polymath/adapters/__init__.py +7 -0
- polymath/adapters/base.py +175 -0
- polymath/adapters/claude.py +280 -0
- polymath/adapters/gemini.py +186 -0
- polymath/adapters/ollama.py +117 -0
- polymath/adapters/openai_adapter.py +168 -0
- polymath/bootstrap.py +159 -0
- polymath/command_registry.py +41 -0
- polymath/command_service.py +572 -0
- polymath/compressor.py +90 -0
- polymath/config.py +293 -0
- polymath/context_manager.py +76 -0
- polymath/context_store.py +336 -0
- polymath/detector.py +442 -0
- polymath/domain.py +78 -0
- polymath/execution_service.py +325 -0
- polymath/main.py +1293 -0
- polymath/memory/__init__.py +15 -0
- polymath/memory/chunker.py +6 -0
- polymath/memory/embedder.py +179 -0
- polymath/memory/migrate.py +2 -0
- polymath/memory/retriever.py +2 -0
- polymath/memory/store.py +9 -0
- polymath/memory/sync.py +2 -0
- polymath/memory/writer.py +9 -0
- polymath/model_policy.py +172 -0
- polymath/orchestrator/__init__.py +68 -0
- polymath/orchestrator/attempt_ledger.py +34 -0
- polymath/orchestrator/ensemble.py +229 -0
- polymath/orchestrator/fanout.py +322 -0
- polymath/orchestrator/output_policy.py +61 -0
- polymath/orchestrator/race.py +311 -0
- polymath/orchestrator/run_controller.py +91 -0
- polymath/orchestrator/speculative_review.py +120 -0
- polymath/orchestrator/state_responder.py +184 -0
- polymath/orchestrator/worker_pool.py +37 -0
- polymath/permissions.py +82 -0
- polymath/pipeline.py +700 -0
- polymath/project_config.py +229 -0
- polymath/project_runtime.py +109 -0
- polymath/router.py +127 -0
- polymath/setup_wizard.py +106 -0
- polymath/slash_commands.py +566 -0
- polymath/subagents.py +486 -0
- polymath/tools.py +333 -0
- polymath/ui_state.py +84 -0
- polymath/workspace.py +66 -0
- polymath_agent-0.4.0.dist-info/METADATA +693 -0
- polymath_agent-0.4.0.dist-info/RECORD +54 -0
- polymath_agent-0.4.0.dist-info/WHEEL +5 -0
- polymath_agent-0.4.0.dist-info/entry_points.txt +2 -0
- polymath_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
- polymath_agent-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Concurrent execution patterns: failover (primary + delayed backup, first wins)
|
|
3
|
+
and first-success race (N candidates in parallel, first wins).
|
|
4
|
+
|
|
5
|
+
Both cancel losers cleanly. Both return (winner_id, result) so the caller can
|
|
6
|
+
log to the AttemptLedger which adapter actually produced the output.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Awaitable, Callable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RaceCandidate:
|
|
17
|
+
"""One contender in a race. The factory is invoked at race start; the id
|
|
18
|
+
is purely for telemetry / ledger logging."""
|
|
19
|
+
id: str
|
|
20
|
+
factory: Callable[[], Awaitable[Any]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RaceOutcome:
|
|
25
|
+
winner_id: str
|
|
26
|
+
result: Any
|
|
27
|
+
cancelled: list[str]
|
|
28
|
+
failed: dict[str, Exception]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AllCandidatesFailed(RuntimeError):
|
|
32
|
+
def __init__(self, failures: dict[str, Exception]) -> None:
|
|
33
|
+
super().__init__(
|
|
34
|
+
"All candidates failed: " +
|
|
35
|
+
"; ".join(f"{cid}: {exc}" for cid, exc in failures.items())
|
|
36
|
+
)
|
|
37
|
+
self.failures = failures
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def with_failover(
|
|
41
|
+
primary: RaceCandidate,
|
|
42
|
+
fallback: RaceCandidate | None,
|
|
43
|
+
delay_seconds: float = 5.0,
|
|
44
|
+
) -> RaceOutcome:
|
|
45
|
+
"""Start primary; after delay (or on primary failure), also start fallback.
|
|
46
|
+
First success wins. If both fail, raises AllCandidatesFailed."""
|
|
47
|
+
if fallback is None:
|
|
48
|
+
result = await primary.factory()
|
|
49
|
+
return RaceOutcome(winner_id=primary.id, result=result, cancelled=[], failed={})
|
|
50
|
+
|
|
51
|
+
primary_task = asyncio.create_task(primary.factory())
|
|
52
|
+
failed: dict[str, Exception] = {}
|
|
53
|
+
|
|
54
|
+
if delay_seconds > 0:
|
|
55
|
+
try:
|
|
56
|
+
await asyncio.wait_for(asyncio.shield(primary_task), timeout=delay_seconds)
|
|
57
|
+
return RaceOutcome(
|
|
58
|
+
winner_id=primary.id,
|
|
59
|
+
result=primary_task.result(),
|
|
60
|
+
cancelled=[],
|
|
61
|
+
failed={},
|
|
62
|
+
)
|
|
63
|
+
except asyncio.TimeoutError:
|
|
64
|
+
pass
|
|
65
|
+
except Exception as e:
|
|
66
|
+
failed[primary.id] = e
|
|
67
|
+
|
|
68
|
+
fallback_task: asyncio.Task | None = None
|
|
69
|
+
if not primary_task.done() or primary_task.cancelled() or primary_task.exception() is not None:
|
|
70
|
+
fallback_task = asyncio.create_task(fallback.factory())
|
|
71
|
+
|
|
72
|
+
pending: set[asyncio.Task] = set()
|
|
73
|
+
if not primary_task.done():
|
|
74
|
+
pending.add(primary_task)
|
|
75
|
+
if fallback_task is not None:
|
|
76
|
+
pending.add(fallback_task)
|
|
77
|
+
|
|
78
|
+
while pending:
|
|
79
|
+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
|
80
|
+
for task in done:
|
|
81
|
+
cid = primary.id if task is primary_task else fallback.id
|
|
82
|
+
if task.cancelled():
|
|
83
|
+
continue
|
|
84
|
+
exc = task.exception()
|
|
85
|
+
if exc is not None:
|
|
86
|
+
failed[cid] = exc
|
|
87
|
+
continue
|
|
88
|
+
cancelled = []
|
|
89
|
+
for other in pending:
|
|
90
|
+
other.cancel()
|
|
91
|
+
cancelled.append(primary.id if other is primary_task else fallback.id)
|
|
92
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
93
|
+
return RaceOutcome(
|
|
94
|
+
winner_id=cid,
|
|
95
|
+
result=task.result(),
|
|
96
|
+
cancelled=cancelled,
|
|
97
|
+
failed=failed,
|
|
98
|
+
)
|
|
99
|
+
raise AllCandidatesFailed(failed)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def race_first_success(
|
|
103
|
+
candidates: list[RaceCandidate],
|
|
104
|
+
timeout_seconds: float | None = None,
|
|
105
|
+
) -> RaceOutcome:
|
|
106
|
+
"""Fire all candidates in parallel; first non-error result wins; cancel rest."""
|
|
107
|
+
if not candidates:
|
|
108
|
+
raise AllCandidatesFailed({})
|
|
109
|
+
if len(candidates) == 1:
|
|
110
|
+
result = await candidates[0].factory()
|
|
111
|
+
return RaceOutcome(winner_id=candidates[0].id, result=result, cancelled=[], failed={})
|
|
112
|
+
|
|
113
|
+
tasks: dict[asyncio.Task, str] = {}
|
|
114
|
+
for c in candidates:
|
|
115
|
+
tasks[asyncio.create_task(c.factory())] = c.id
|
|
116
|
+
|
|
117
|
+
failed: dict[str, Exception] = {}
|
|
118
|
+
pending = set(tasks.keys())
|
|
119
|
+
deadline = (asyncio.get_event_loop().time() + timeout_seconds) if timeout_seconds else None
|
|
120
|
+
|
|
121
|
+
while pending:
|
|
122
|
+
timeout = max(0.0, deadline - asyncio.get_event_loop().time()) if deadline else None
|
|
123
|
+
done, pending = await asyncio.wait(
|
|
124
|
+
pending,
|
|
125
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
126
|
+
timeout=timeout,
|
|
127
|
+
)
|
|
128
|
+
if not done: # timeout
|
|
129
|
+
for t in pending:
|
|
130
|
+
t.cancel()
|
|
131
|
+
raise AllCandidatesFailed({**failed, "*": TimeoutError("race timed out")})
|
|
132
|
+
for task in done:
|
|
133
|
+
cid = tasks[task]
|
|
134
|
+
if task.cancelled():
|
|
135
|
+
continue
|
|
136
|
+
exc = task.exception()
|
|
137
|
+
if exc is not None:
|
|
138
|
+
failed[cid] = exc
|
|
139
|
+
continue
|
|
140
|
+
cancelled: list[str] = []
|
|
141
|
+
for other in pending:
|
|
142
|
+
other.cancel()
|
|
143
|
+
cancelled.append(tasks[other])
|
|
144
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
145
|
+
return RaceOutcome(
|
|
146
|
+
winner_id=cid,
|
|
147
|
+
result=task.result(),
|
|
148
|
+
cancelled=cancelled,
|
|
149
|
+
failed=failed,
|
|
150
|
+
)
|
|
151
|
+
raise AllCandidatesFailed(failed)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class StreamCandidate:
|
|
156
|
+
"""A streaming contender. The factory must return an async iterator of strings."""
|
|
157
|
+
id: str
|
|
158
|
+
factory: Callable[[], Any] # returns AsyncIterator[str] (sync return, async iterate)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class StreamOutcome:
|
|
163
|
+
winner_id: str
|
|
164
|
+
output: str
|
|
165
|
+
cancelled: list[str]
|
|
166
|
+
failed: dict[str, Exception]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def race_streams(
|
|
170
|
+
candidates: list[StreamCandidate],
|
|
171
|
+
on_token: Callable[[str], None] | None = None,
|
|
172
|
+
timeout_seconds: float | None = None,
|
|
173
|
+
) -> StreamOutcome:
|
|
174
|
+
"""Race multiple async streams. First to emit a token claims the terminal;
|
|
175
|
+
the rest are silently cancelled. Loser tokens never reach on_token.
|
|
176
|
+
Raises AllCandidatesFailed if every candidate errors before any token."""
|
|
177
|
+
if not candidates:
|
|
178
|
+
raise AllCandidatesFailed({})
|
|
179
|
+
on_token = on_token or (lambda _t: None)
|
|
180
|
+
if len(candidates) == 1:
|
|
181
|
+
output = ""
|
|
182
|
+
async for token in candidates[0].factory():
|
|
183
|
+
if isinstance(token, str):
|
|
184
|
+
output += token
|
|
185
|
+
on_token(token)
|
|
186
|
+
return StreamOutcome(winner_id=candidates[0].id, output=output, cancelled=[], failed={})
|
|
187
|
+
|
|
188
|
+
queues: dict[str, asyncio.Queue] = {c.id: asyncio.Queue() for c in candidates}
|
|
189
|
+
|
|
190
|
+
async def runner(c: StreamCandidate) -> None:
|
|
191
|
+
try:
|
|
192
|
+
async for token in c.factory():
|
|
193
|
+
if isinstance(token, str):
|
|
194
|
+
await queues[c.id].put(("tok", token))
|
|
195
|
+
await queues[c.id].put(("done", ""))
|
|
196
|
+
except asyncio.CancelledError:
|
|
197
|
+
raise
|
|
198
|
+
except Exception as e:
|
|
199
|
+
await queues[c.id].put(("err", e))
|
|
200
|
+
|
|
201
|
+
runner_tasks: dict[str, asyncio.Task] = {
|
|
202
|
+
c.id: asyncio.create_task(runner(c)) for c in candidates
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
winner_id: str | None = None
|
|
206
|
+
output = ""
|
|
207
|
+
failures: dict[str, Exception] = {}
|
|
208
|
+
cancelled: list[str] = []
|
|
209
|
+
finished_ids: set[str] = set()
|
|
210
|
+
|
|
211
|
+
waiters: dict[asyncio.Task, str] = {}
|
|
212
|
+
for cid, q in queues.items():
|
|
213
|
+
waiters[asyncio.create_task(q.get())] = cid
|
|
214
|
+
|
|
215
|
+
deadline = (asyncio.get_event_loop().time() + timeout_seconds) if timeout_seconds else None
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
while waiters:
|
|
219
|
+
timeout = max(0.0, deadline - asyncio.get_event_loop().time()) if deadline else None
|
|
220
|
+
done, _ = await asyncio.wait(
|
|
221
|
+
waiters.keys(),
|
|
222
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
223
|
+
timeout=timeout,
|
|
224
|
+
)
|
|
225
|
+
if not done:
|
|
226
|
+
raise AllCandidatesFailed({**failures, "*": TimeoutError("stream race timed out")})
|
|
227
|
+
|
|
228
|
+
for finished_waiter in done:
|
|
229
|
+
cid = waiters.pop(finished_waiter)
|
|
230
|
+
kind, payload = finished_waiter.result()
|
|
231
|
+
|
|
232
|
+
if kind == "err":
|
|
233
|
+
failures[cid] = payload
|
|
234
|
+
finished_ids.add(cid)
|
|
235
|
+
if winner_id == cid:
|
|
236
|
+
raise payload
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
if kind == "done":
|
|
240
|
+
finished_ids.add(cid)
|
|
241
|
+
if winner_id == cid:
|
|
242
|
+
return StreamOutcome(
|
|
243
|
+
winner_id=winner_id, output=output,
|
|
244
|
+
cancelled=cancelled, failed=failures,
|
|
245
|
+
)
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# kind == "tok"
|
|
249
|
+
if winner_id is None:
|
|
250
|
+
winner_id = cid
|
|
251
|
+
output += payload
|
|
252
|
+
on_token(payload)
|
|
253
|
+
waiters[asyncio.create_task(queues[cid].get())] = cid
|
|
254
|
+
for other_id, task in runner_tasks.items():
|
|
255
|
+
if other_id != cid and not task.done():
|
|
256
|
+
task.cancel()
|
|
257
|
+
cancelled.append(other_id)
|
|
258
|
+
elif cid == winner_id:
|
|
259
|
+
output += payload
|
|
260
|
+
on_token(payload)
|
|
261
|
+
waiters[asyncio.create_task(queues[cid].get())] = cid
|
|
262
|
+
# else: loser token, drop silently
|
|
263
|
+
finally:
|
|
264
|
+
for task in runner_tasks.values():
|
|
265
|
+
if not task.done():
|
|
266
|
+
task.cancel()
|
|
267
|
+
await asyncio.gather(*runner_tasks.values(), return_exceptions=True)
|
|
268
|
+
for waiter in waiters:
|
|
269
|
+
waiter.cancel()
|
|
270
|
+
await asyncio.gather(*waiters.keys(), return_exceptions=True)
|
|
271
|
+
|
|
272
|
+
if winner_id is None:
|
|
273
|
+
raise AllCandidatesFailed(failures)
|
|
274
|
+
return StreamOutcome(
|
|
275
|
+
winner_id=winner_id, output=output,
|
|
276
|
+
cancelled=cancelled, failed=failures,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def gather_all(
|
|
281
|
+
candidates: list[RaceCandidate],
|
|
282
|
+
timeout_seconds: float | None = None,
|
|
283
|
+
) -> dict[str, Any | Exception]:
|
|
284
|
+
"""Run all in parallel, wait for all (or timeout). Used by ensemble.
|
|
285
|
+
Returns {candidate_id: result_or_exception}."""
|
|
286
|
+
if not candidates:
|
|
287
|
+
return {}
|
|
288
|
+
tasks: dict[str, asyncio.Task] = {
|
|
289
|
+
c.id: asyncio.create_task(c.factory()) for c in candidates
|
|
290
|
+
}
|
|
291
|
+
try:
|
|
292
|
+
if timeout_seconds is not None:
|
|
293
|
+
await asyncio.wait_for(
|
|
294
|
+
asyncio.gather(*tasks.values(), return_exceptions=True),
|
|
295
|
+
timeout=timeout_seconds,
|
|
296
|
+
)
|
|
297
|
+
except asyncio.TimeoutError:
|
|
298
|
+
for task in tasks.values():
|
|
299
|
+
if not task.done():
|
|
300
|
+
task.cancel()
|
|
301
|
+
await asyncio.gather(*tasks.values(), return_exceptions=True)
|
|
302
|
+
|
|
303
|
+
results: dict[str, Any | Exception] = {}
|
|
304
|
+
for cid, task in tasks.items():
|
|
305
|
+
if task.cancelled():
|
|
306
|
+
results[cid] = TimeoutError("cancelled by ensemble timeout")
|
|
307
|
+
elif task.exception() is not None:
|
|
308
|
+
results[cid] = task.exception()
|
|
309
|
+
else:
|
|
310
|
+
results[cid] = task.result()
|
|
311
|
+
return results
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from polymath.config import TaskComplexity, TaskType
|
|
7
|
+
from polymath.orchestrator.attempt_ledger import AttemptLedger
|
|
8
|
+
from polymath.orchestrator.output_policy import OutputPolicy
|
|
9
|
+
from polymath.orchestrator.state_responder import StateResponder
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExecutionStrategy(Enum):
|
|
13
|
+
STATE = "state"
|
|
14
|
+
CHAT = "chat"
|
|
15
|
+
PIPELINE = "pipeline"
|
|
16
|
+
ENSEMBLE = "ensemble"
|
|
17
|
+
FANOUT = "fanout"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RunRequest:
|
|
22
|
+
text: str
|
|
23
|
+
explicit_ask: bool
|
|
24
|
+
task_type: TaskType
|
|
25
|
+
complexity: TaskComplexity
|
|
26
|
+
verify: bool
|
|
27
|
+
simplify: bool
|
|
28
|
+
profile: str
|
|
29
|
+
registry: list
|
|
30
|
+
ensemble: bool = False
|
|
31
|
+
fanout: bool = False
|
|
32
|
+
force_provider: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RunDecision:
|
|
37
|
+
strategy: ExecutionStrategy
|
|
38
|
+
local_response: str | None = None
|
|
39
|
+
attempt_ledger: AttemptLedger = field(default_factory=AttemptLedger)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RunController:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
state_responder: StateResponder | None = None,
|
|
46
|
+
output_policy: OutputPolicy | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.state_responder = state_responder or StateResponder()
|
|
49
|
+
self.output_policy = output_policy or OutputPolicy()
|
|
50
|
+
|
|
51
|
+
def decide(self, request: RunRequest) -> RunDecision:
|
|
52
|
+
local_response = self.state_responder.answer(
|
|
53
|
+
text=request.text,
|
|
54
|
+
registry=request.registry,
|
|
55
|
+
profile=request.profile,
|
|
56
|
+
)
|
|
57
|
+
if local_response is not None:
|
|
58
|
+
return RunDecision(
|
|
59
|
+
strategy=ExecutionStrategy.STATE,
|
|
60
|
+
local_response=local_response
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# User overrides
|
|
64
|
+
if request.ensemble:
|
|
65
|
+
return RunDecision(strategy=ExecutionStrategy.ENSEMBLE)
|
|
66
|
+
if request.fanout:
|
|
67
|
+
return RunDecision(strategy=ExecutionStrategy.FANOUT)
|
|
68
|
+
|
|
69
|
+
# 3. CHAT for simple general tasks or forced provider
|
|
70
|
+
use_direct_chat = request.explicit_ask or request.force_provider != "" or (
|
|
71
|
+
request.task_type == TaskType.GENERAL and
|
|
72
|
+
request.complexity == TaskComplexity.SIMPLE and
|
|
73
|
+
not request.verify and
|
|
74
|
+
not request.simplify
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if use_direct_chat:
|
|
78
|
+
return RunDecision(strategy=ExecutionStrategy.CHAT)
|
|
79
|
+
|
|
80
|
+
# Ensemble and fanout are never chosen for the user. Both multiply
|
|
81
|
+
# what a question costs — ensemble runs N models plus a merger — and
|
|
82
|
+
# the trigger would have been a heuristic classification of the
|
|
83
|
+
# prompt text, so the same question could cost one model or four
|
|
84
|
+
# with nothing to distinguish them beforehand. They stay on the
|
|
85
|
+
# explicit flags above: `@all` / --ensemble, and --fanout. That is
|
|
86
|
+
# also why the ensemble flow carries a cost warning; the warning is
|
|
87
|
+
# written for someone who asked for it.
|
|
88
|
+
return RunDecision(
|
|
89
|
+
strategy=ExecutionStrategy.PIPELINE,
|
|
90
|
+
attempt_ledger=AttemptLedger(),
|
|
91
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Speculative concurrent reviewer.
|
|
3
|
+
|
|
4
|
+
While primary's agent loop runs, the reviewer is fed partial output snapshots
|
|
5
|
+
and emits interim concerns in the background. When primary finishes, the
|
|
6
|
+
final review call uses those accumulated notes as priming — typically faster
|
|
7
|
+
than a cold review of the whole output.
|
|
8
|
+
|
|
9
|
+
Opt-in via cfg["memory"]["speculative_review"] (default off until proven on
|
|
10
|
+
real workloads). Failures are silent — they degrade to sequential review.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
from polymath.adapters.base import BaseAdapter, Message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SPECULATIVE_SYSTEM = (
|
|
20
|
+
"You are a critical reviewer reading partial output from another model that is "
|
|
21
|
+
"still working. Note ONLY concrete issues you can already see (factual errors, "
|
|
22
|
+
"missing requirements, broken logic). Skip style/wording. Limit to 3 bullet "
|
|
23
|
+
"points. If you see no issues yet, output exactly: NONE_YET"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
FINAL_REVIEW_SYSTEM = (
|
|
27
|
+
"You are a critical reviewer. Format strictly as:\n"
|
|
28
|
+
"VERDICT: APPROVED | NEEDS_WORK\n"
|
|
29
|
+
"ISSUES:\n- ...\n"
|
|
30
|
+
"SUGGESTIONS:\n- ...\n"
|
|
31
|
+
"If APPROVED with no issues, just say: VERDICT: APPROVED"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SpeculativeReviewer:
|
|
36
|
+
"""Background reviewer that observes partial output during primary execution."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
adapter: BaseAdapter,
|
|
41
|
+
model_id: str,
|
|
42
|
+
task: str,
|
|
43
|
+
max_inflight: int = 1,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.adapter = adapter
|
|
46
|
+
self.model_id = model_id
|
|
47
|
+
self.task = task
|
|
48
|
+
self._notes: list[str] = []
|
|
49
|
+
self._inflight: asyncio.Task | None = None
|
|
50
|
+
self._max_inflight = max_inflight
|
|
51
|
+
self.last_partial_seen = ""
|
|
52
|
+
|
|
53
|
+
def kick_off(self, partial_output: str) -> None:
|
|
54
|
+
"""Schedule a speculative review on the current partial output. Non-blocking.
|
|
55
|
+
Skips if a previous speculation is still in flight (only 1 at a time)."""
|
|
56
|
+
if not partial_output or partial_output == self.last_partial_seen:
|
|
57
|
+
return
|
|
58
|
+
if self._inflight is not None and not self._inflight.done():
|
|
59
|
+
return
|
|
60
|
+
self.last_partial_seen = partial_output
|
|
61
|
+
self._inflight = asyncio.create_task(self._speculate(partial_output))
|
|
62
|
+
|
|
63
|
+
async def _speculate(self, partial_output: str) -> None:
|
|
64
|
+
try:
|
|
65
|
+
resp = await self.adapter.complete(
|
|
66
|
+
messages=[Message(role="user", content=(
|
|
67
|
+
f"ORIGINAL TASK:\n{self.task}\n\n"
|
|
68
|
+
f"PARTIAL OUTPUT (in progress):\n{partial_output}\n\n"
|
|
69
|
+
f"Concerns so far?"
|
|
70
|
+
))],
|
|
71
|
+
model_id=self.model_id,
|
|
72
|
+
system=SPECULATIVE_SYSTEM,
|
|
73
|
+
temperature=0.2,
|
|
74
|
+
)
|
|
75
|
+
text = resp if isinstance(resp, str) else getattr(resp, "content", "")
|
|
76
|
+
if text and "NONE_YET" not in text.upper()[:30]:
|
|
77
|
+
self._notes.append(text.strip())
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
async def finalize(self, final_output: str, timeout_seconds: float = 2.0) -> str:
|
|
82
|
+
"""Wait briefly for any in-flight speculation, then make the final verdict call."""
|
|
83
|
+
if self._inflight is not None and not self._inflight.done():
|
|
84
|
+
try:
|
|
85
|
+
await asyncio.wait_for(asyncio.shield(self._inflight), timeout=timeout_seconds)
|
|
86
|
+
except (asyncio.TimeoutError, Exception):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
notes_block = ""
|
|
90
|
+
if self._notes:
|
|
91
|
+
joined_notes = "\n\n".join(self._notes[-3:])
|
|
92
|
+
notes_block = (
|
|
93
|
+
f"\nINTERIM CONCERNS YOU NOTED EARLIER (use these as priming, "
|
|
94
|
+
f"verify against the final output):\n{joined_notes}\n"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
prompt = (
|
|
98
|
+
f"ORIGINAL TASK:\n{self.task}\n\n"
|
|
99
|
+
f"FINAL RESPONSE TO REVIEW:\n{final_output}\n"
|
|
100
|
+
f"{notes_block}"
|
|
101
|
+
)
|
|
102
|
+
resp = await self.adapter.complete(
|
|
103
|
+
messages=[Message(role="user", content=prompt)],
|
|
104
|
+
model_id=self.model_id,
|
|
105
|
+
system=FINAL_REVIEW_SYSTEM,
|
|
106
|
+
temperature=0.3,
|
|
107
|
+
)
|
|
108
|
+
return resp if isinstance(resp, str) else getattr(resp, "content", "")
|
|
109
|
+
|
|
110
|
+
async def cancel(self) -> None:
|
|
111
|
+
if self._inflight is not None and not self._inflight.done():
|
|
112
|
+
self._inflight.cancel()
|
|
113
|
+
try:
|
|
114
|
+
await self._inflight
|
|
115
|
+
except (asyncio.CancelledError, Exception):
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def notes(self) -> list[str]:
|
|
120
|
+
return list(self._notes)
|