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,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ensemble execution: run the same prompt across N models in parallel,
|
|
3
|
+
then optionally merge with a designated merger model.
|
|
4
|
+
|
|
5
|
+
Bypasses the full pipeline (no clarify / plan / review per worker — that
|
|
6
|
+
would N× the round-trips). Designed for the @all REPL trigger when the
|
|
7
|
+
user wants multiple perspectives or a consensus answer.
|
|
8
|
+
|
|
9
|
+
Memory retrieval happens once; all workers see the same chunks.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Awaitable, Callable
|
|
16
|
+
|
|
17
|
+
from polymath.adapters.base import BaseAdapter, Message
|
|
18
|
+
from polymath.config import ModelInfo, TaskType
|
|
19
|
+
from polymath.memory.retriever import retrieve_or_fallback
|
|
20
|
+
from polymath.orchestrator.race import RaceCandidate, gather_all
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DEFAULT_TIMEOUT_SECONDS = 60.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class EnsembleWorker:
|
|
28
|
+
adapter: BaseAdapter
|
|
29
|
+
model: ModelInfo
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class WorkerResult:
|
|
34
|
+
model_id: str
|
|
35
|
+
display_name: str
|
|
36
|
+
output: str = ""
|
|
37
|
+
error: str = ""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def ok(self) -> bool:
|
|
41
|
+
return not self.error and bool(self.output)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class EnsembleResult:
|
|
46
|
+
workers: list[WorkerResult] = field(default_factory=list)
|
|
47
|
+
merged: str = ""
|
|
48
|
+
merger_model: str = ""
|
|
49
|
+
disagreement_notes: str = ""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def successful(self) -> list[WorkerResult]:
|
|
53
|
+
return [w for w in self.workers if w.ok]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
MERGER_SYSTEM = (
|
|
57
|
+
"You are a merger / consensus arbiter. You will be shown the same task "
|
|
58
|
+
"answered independently by multiple models. Produce:\n"
|
|
59
|
+
"1) CONSENSUS — the best single answer, integrating points the models agreed on, "
|
|
60
|
+
"preferring better-supported claims. Do not invent new content.\n"
|
|
61
|
+
"2) DISAGREEMENTS — bullet points where the models gave materially different answers, "
|
|
62
|
+
"with which model said what. Skip purely cosmetic differences.\n"
|
|
63
|
+
"Format strictly as:\n"
|
|
64
|
+
"## CONSENSUS\n<answer>\n\n## DISAGREEMENTS\n- <model A> said X; <model B> said Y\n"
|
|
65
|
+
"If models substantially agreed, write `(none significant)` under DISAGREEMENTS."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def run_ensemble(
|
|
70
|
+
*,
|
|
71
|
+
workers: list[EnsembleWorker],
|
|
72
|
+
user_input: str,
|
|
73
|
+
task_type: TaskType,
|
|
74
|
+
active_project: str,
|
|
75
|
+
cfg: dict,
|
|
76
|
+
merger: EnsembleWorker | None = None,
|
|
77
|
+
extra_system: str = "",
|
|
78
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
79
|
+
on_worker_started: Callable[[str], None] | None = None,
|
|
80
|
+
on_worker_done: Callable[[WorkerResult], None] | None = None,
|
|
81
|
+
) -> EnsembleResult:
|
|
82
|
+
if not workers:
|
|
83
|
+
return EnsembleResult()
|
|
84
|
+
|
|
85
|
+
on_worker_started = on_worker_started or (lambda _name: None)
|
|
86
|
+
on_worker_done = on_worker_done or (lambda _result: None)
|
|
87
|
+
|
|
88
|
+
project_ctx = (
|
|
89
|
+
await retrieve_or_fallback(active_project, user_input, task_type, cfg)
|
|
90
|
+
if active_project else ""
|
|
91
|
+
)
|
|
92
|
+
base_system = "Answer concisely and directly."
|
|
93
|
+
if project_ctx:
|
|
94
|
+
base_system = f"{project_ctx}\n\n{base_system}"
|
|
95
|
+
if extra_system:
|
|
96
|
+
base_system = f"{base_system}\n\n{extra_system}"
|
|
97
|
+
|
|
98
|
+
candidates: list[RaceCandidate] = []
|
|
99
|
+
for w in workers:
|
|
100
|
+
on_worker_started(w.model.display_name)
|
|
101
|
+
candidates.append(RaceCandidate(
|
|
102
|
+
id=w.model.id,
|
|
103
|
+
factory=_make_call(w, user_input, base_system),
|
|
104
|
+
))
|
|
105
|
+
|
|
106
|
+
raw = await gather_all(candidates, timeout_seconds=timeout_seconds)
|
|
107
|
+
|
|
108
|
+
worker_results: list[WorkerResult] = []
|
|
109
|
+
for w in workers:
|
|
110
|
+
outcome = raw.get(w.model.id)
|
|
111
|
+
if isinstance(outcome, Exception):
|
|
112
|
+
wr = WorkerResult(model_id=w.model.id, display_name=w.model.display_name,
|
|
113
|
+
error=str(outcome))
|
|
114
|
+
else:
|
|
115
|
+
text = outcome if isinstance(outcome, str) else getattr(outcome, "content", "")
|
|
116
|
+
wr = WorkerResult(model_id=w.model.id, display_name=w.model.display_name,
|
|
117
|
+
output=text or "")
|
|
118
|
+
worker_results.append(wr)
|
|
119
|
+
on_worker_done(wr)
|
|
120
|
+
|
|
121
|
+
result = EnsembleResult(workers=worker_results)
|
|
122
|
+
|
|
123
|
+
successful = result.successful
|
|
124
|
+
if len(successful) <= 1 or merger is None:
|
|
125
|
+
if successful:
|
|
126
|
+
result.merged = successful[0].output
|
|
127
|
+
result.merger_model = "(no merge: only one successful response)" if len(successful) == 1 else ""
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
merge_prompt = _build_merge_prompt(user_input, successful)
|
|
131
|
+
try:
|
|
132
|
+
resp = await merger.adapter.complete(
|
|
133
|
+
messages=[Message(role="user", content=merge_prompt)],
|
|
134
|
+
model_id=merger.model.id,
|
|
135
|
+
system=MERGER_SYSTEM,
|
|
136
|
+
temperature=0.3,
|
|
137
|
+
)
|
|
138
|
+
merged_text = resp if isinstance(resp, str) else resp.content
|
|
139
|
+
result.merged, result.disagreement_notes = _split_consensus(merged_text)
|
|
140
|
+
result.merger_model = merger.model.display_name
|
|
141
|
+
except Exception as e:
|
|
142
|
+
# Fall back to the first successful raw output
|
|
143
|
+
result.merged = successful[0].output
|
|
144
|
+
result.merger_model = f"(merge failed: {e})"
|
|
145
|
+
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _make_call(worker: EnsembleWorker, user_input: str, system: str) -> Callable[[], Awaitable]:
|
|
150
|
+
async def call():
|
|
151
|
+
return await worker.adapter.complete(
|
|
152
|
+
messages=[Message(role="user", content=user_input)],
|
|
153
|
+
model_id=worker.model.id,
|
|
154
|
+
system=system,
|
|
155
|
+
temperature=0.5,
|
|
156
|
+
)
|
|
157
|
+
return call
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _build_merge_prompt(user_input: str, successful: list[WorkerResult]) -> str:
|
|
161
|
+
parts = [f"TASK:\n{user_input}\n", "INDEPENDENT ANSWERS:"]
|
|
162
|
+
for w in successful:
|
|
163
|
+
parts.append(f"\n--- {w.display_name} ---\n{w.output.strip()}")
|
|
164
|
+
return "\n".join(parts)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _split_consensus(text: str) -> tuple[str, str]:
|
|
168
|
+
"""Return (consensus, disagreements). Tolerant of header variations."""
|
|
169
|
+
upper = text.upper()
|
|
170
|
+
consensus_idx = upper.find("CONSENSUS")
|
|
171
|
+
disagree_idx = upper.find("DISAGREEMENT")
|
|
172
|
+
if consensus_idx == -1:
|
|
173
|
+
return text.strip(), ""
|
|
174
|
+
consensus_start = text.find("\n", consensus_idx) + 1
|
|
175
|
+
if disagree_idx == -1 or disagree_idx < consensus_idx:
|
|
176
|
+
return text[consensus_start:].strip(), ""
|
|
177
|
+
consensus = text[consensus_start:disagree_idx].strip().rstrip("#").strip()
|
|
178
|
+
disagree_start = text.find("\n", disagree_idx) + 1
|
|
179
|
+
disagreements = text[disagree_start:].strip()
|
|
180
|
+
return consensus, disagreements
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── Worker selection ─────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
def select_ensemble_workers(
|
|
186
|
+
registry: list,
|
|
187
|
+
cfg: dict,
|
|
188
|
+
max_workers: int = 3,
|
|
189
|
+
exclude_providers: set[str] | None = None,
|
|
190
|
+
) -> tuple[list[EnsembleWorker], EnsembleWorker | None]:
|
|
191
|
+
"""Pick top-N available models, one per provider where possible.
|
|
192
|
+
Returns (workers, merger). The merger is the highest-quality model."""
|
|
193
|
+
from polymath.model_policy import adapter_for
|
|
194
|
+
|
|
195
|
+
exclude = exclude_providers or set()
|
|
196
|
+
available = [m for m in registry if m.available and m.provider not in exclude]
|
|
197
|
+
if not available:
|
|
198
|
+
return [], None
|
|
199
|
+
|
|
200
|
+
by_provider: dict[str, list] = {}
|
|
201
|
+
for m in available:
|
|
202
|
+
by_provider.setdefault(m.provider, []).append(m)
|
|
203
|
+
for provider, ms in by_provider.items():
|
|
204
|
+
ms.sort(key=lambda x: (-x.quality, x.cost_tier.value))
|
|
205
|
+
|
|
206
|
+
chosen: list = []
|
|
207
|
+
while len(chosen) < max_workers:
|
|
208
|
+
added = False
|
|
209
|
+
for provider in sorted(by_provider.keys()):
|
|
210
|
+
if not by_provider[provider]:
|
|
211
|
+
continue
|
|
212
|
+
chosen.append(by_provider[provider].pop(0))
|
|
213
|
+
added = True
|
|
214
|
+
if len(chosen) >= max_workers:
|
|
215
|
+
break
|
|
216
|
+
if not added:
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
workers: list[EnsembleWorker] = []
|
|
220
|
+
for m in chosen:
|
|
221
|
+
ad = adapter_for(m.provider, cfg)
|
|
222
|
+
if ad:
|
|
223
|
+
workers.append(EnsembleWorker(adapter=ad, model=m))
|
|
224
|
+
|
|
225
|
+
merger: EnsembleWorker | None = None
|
|
226
|
+
if workers:
|
|
227
|
+
best = max(workers, key=lambda w: w.model.quality)
|
|
228
|
+
merger = best
|
|
229
|
+
return workers, merger
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Specialist fan-out: split a complex task into independent parallel subtasks,
|
|
3
|
+
route each to the best-fit model, run in parallel, integrate results.
|
|
4
|
+
|
|
5
|
+
Risky: a bad split contaminates everything downstream. Gated behind --fanout
|
|
6
|
+
flag, COMPLEX tasks only. The splitter is conservative — if the task is
|
|
7
|
+
inherently sequential, it returns a single subtask and the executor degrades
|
|
8
|
+
gracefully.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Awaitable, Callable
|
|
17
|
+
|
|
18
|
+
from polymath.adapters.base import BaseAdapter, Message
|
|
19
|
+
from polymath.config import ModelInfo, TaskType
|
|
20
|
+
from polymath.orchestrator.race import RaceCandidate, gather_all
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SPLITTER_SYSTEM = (
|
|
24
|
+
"You split complex tasks into independent parallel subtasks. Each subtask "
|
|
25
|
+
"must be executable on its own without seeing the others' outputs. If the "
|
|
26
|
+
"task is inherently sequential or single-purpose, return one subtask.\n\n"
|
|
27
|
+
"Output JSON only — an array of objects:\n"
|
|
28
|
+
'[{"id": "s1", "description": "<what to do>", "type": "code|analysis|creative|math|research|general"}]\n\n'
|
|
29
|
+
"Rules: 1-5 subtasks max. Each description must be concrete enough to act on alone. "
|
|
30
|
+
"No prose outside the JSON."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
JOINER_SYSTEM = (
|
|
34
|
+
"You integrate independent subtask outputs into a single coherent answer to the "
|
|
35
|
+
"original task. Resolve any conflicts between subtask outputs explicitly. Do not "
|
|
36
|
+
"introduce information that isn't present in the subtask outputs. Match the format "
|
|
37
|
+
"the original task asked for."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Subtask:
|
|
43
|
+
id: str
|
|
44
|
+
description: str
|
|
45
|
+
task_type: TaskType
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class SubtaskResult:
|
|
50
|
+
subtask: Subtask
|
|
51
|
+
model_id: str
|
|
52
|
+
model_display: str
|
|
53
|
+
output: str = ""
|
|
54
|
+
error: str = ""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def ok(self) -> bool:
|
|
58
|
+
return not self.error and bool(self.output)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class FanoutResult:
|
|
63
|
+
subtasks: list[Subtask] = field(default_factory=list)
|
|
64
|
+
results: list[SubtaskResult] = field(default_factory=list)
|
|
65
|
+
joined: str = ""
|
|
66
|
+
join_model: str = ""
|
|
67
|
+
skipped_reason: str = ""
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def successful(self) -> list[SubtaskResult]:
|
|
71
|
+
return [r for r in self.results if r.ok]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Public API ──────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
async def run_fanout(
|
|
77
|
+
*,
|
|
78
|
+
task: str,
|
|
79
|
+
plan: str,
|
|
80
|
+
registry: list,
|
|
81
|
+
cfg: dict,
|
|
82
|
+
splitter_adapter: BaseAdapter,
|
|
83
|
+
splitter_model: ModelInfo,
|
|
84
|
+
joiner_adapter: BaseAdapter,
|
|
85
|
+
joiner_model: ModelInfo,
|
|
86
|
+
profile: str = "quality-first",
|
|
87
|
+
on_split: Callable[[list[Subtask]], None] | None = None,
|
|
88
|
+
on_subtask_started: Callable[[Subtask, ModelInfo], None] | None = None,
|
|
89
|
+
on_subtask_done: Callable[[SubtaskResult], None] | None = None,
|
|
90
|
+
timeout_seconds: float = 120.0,
|
|
91
|
+
) -> FanoutResult:
|
|
92
|
+
on_split = on_split or (lambda _ts: None)
|
|
93
|
+
on_subtask_started = on_subtask_started or (lambda _s, _m: None)
|
|
94
|
+
on_subtask_done = on_subtask_done or (lambda _r: None)
|
|
95
|
+
|
|
96
|
+
subtasks = await split_task(task, plan, splitter_adapter, splitter_model.id)
|
|
97
|
+
on_split(subtasks)
|
|
98
|
+
|
|
99
|
+
if not subtasks:
|
|
100
|
+
return FanoutResult(skipped_reason="splitter returned no subtasks")
|
|
101
|
+
if len(subtasks) == 1:
|
|
102
|
+
return FanoutResult(
|
|
103
|
+
subtasks=subtasks,
|
|
104
|
+
skipped_reason="single subtask — fanout would be a no-op",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
routed = _route_subtasks(subtasks, registry, cfg, profile)
|
|
108
|
+
for subtask, route in zip(subtasks, routed):
|
|
109
|
+
if route:
|
|
110
|
+
on_subtask_started(subtask, route[0])
|
|
111
|
+
|
|
112
|
+
candidates: list[RaceCandidate] = []
|
|
113
|
+
valid: list[tuple[Subtask, ModelInfo, BaseAdapter]] = []
|
|
114
|
+
for subtask, route in zip(subtasks, routed):
|
|
115
|
+
if not route:
|
|
116
|
+
continue
|
|
117
|
+
model_info, adapter = route
|
|
118
|
+
valid.append((subtask, model_info, adapter))
|
|
119
|
+
candidates.append(RaceCandidate(
|
|
120
|
+
id=subtask.id,
|
|
121
|
+
factory=_make_subtask_call(adapter, model_info, subtask, task),
|
|
122
|
+
))
|
|
123
|
+
|
|
124
|
+
raw = await gather_all(candidates, timeout_seconds=timeout_seconds)
|
|
125
|
+
|
|
126
|
+
results: list[SubtaskResult] = []
|
|
127
|
+
for subtask, model_info, _adapter in valid:
|
|
128
|
+
outcome = raw.get(subtask.id)
|
|
129
|
+
if isinstance(outcome, Exception):
|
|
130
|
+
r = SubtaskResult(
|
|
131
|
+
subtask=subtask, model_id=model_info.id,
|
|
132
|
+
model_display=model_info.display_name, error=str(outcome),
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
text = outcome if isinstance(outcome, str) else getattr(outcome, "content", "")
|
|
136
|
+
r = SubtaskResult(
|
|
137
|
+
subtask=subtask, model_id=model_info.id,
|
|
138
|
+
model_display=model_info.display_name, output=text or "",
|
|
139
|
+
)
|
|
140
|
+
results.append(r)
|
|
141
|
+
on_subtask_done(r)
|
|
142
|
+
|
|
143
|
+
successful = [r for r in results if r.ok]
|
|
144
|
+
if not successful:
|
|
145
|
+
return FanoutResult(
|
|
146
|
+
subtasks=subtasks, results=results,
|
|
147
|
+
skipped_reason="all subtasks failed",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
joined = await join_results(task, successful, joiner_adapter, joiner_model.id)
|
|
151
|
+
return FanoutResult(
|
|
152
|
+
subtasks=subtasks,
|
|
153
|
+
results=results,
|
|
154
|
+
joined=joined,
|
|
155
|
+
join_model=joiner_model.display_name,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── Splitter ─────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
async def split_task(
|
|
162
|
+
task: str,
|
|
163
|
+
plan: str,
|
|
164
|
+
splitter_adapter: BaseAdapter,
|
|
165
|
+
splitter_model_id: str,
|
|
166
|
+
) -> list[Subtask]:
|
|
167
|
+
prompt = (
|
|
168
|
+
f"ORIGINAL TASK:\n{task}\n\n"
|
|
169
|
+
f"PLAN (already approved):\n{plan or '(no plan provided)'}\n\n"
|
|
170
|
+
"Now produce the JSON array of independent subtasks."
|
|
171
|
+
)
|
|
172
|
+
try:
|
|
173
|
+
resp = await splitter_adapter.complete(
|
|
174
|
+
messages=[Message(role="user", content=prompt)],
|
|
175
|
+
model_id=splitter_model_id,
|
|
176
|
+
system=SPLITTER_SYSTEM,
|
|
177
|
+
temperature=0.2,
|
|
178
|
+
)
|
|
179
|
+
except Exception:
|
|
180
|
+
return []
|
|
181
|
+
text = resp if isinstance(resp, str) else getattr(resp, "content", "")
|
|
182
|
+
return _parse_subtasks(text)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _parse_subtasks(text: str) -> list[Subtask]:
|
|
186
|
+
cleaned = text.strip()
|
|
187
|
+
fence = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", cleaned)
|
|
188
|
+
if fence:
|
|
189
|
+
cleaned = fence.group(1).strip()
|
|
190
|
+
bracket_start = cleaned.find("[")
|
|
191
|
+
bracket_end = cleaned.rfind("]")
|
|
192
|
+
if bracket_start == -1 or bracket_end == -1:
|
|
193
|
+
return []
|
|
194
|
+
try:
|
|
195
|
+
data = json.loads(cleaned[bracket_start : bracket_end + 1])
|
|
196
|
+
except Exception:
|
|
197
|
+
return []
|
|
198
|
+
if not isinstance(data, list):
|
|
199
|
+
return []
|
|
200
|
+
out: list[Subtask] = []
|
|
201
|
+
for i, item in enumerate(data[:5]):
|
|
202
|
+
if not isinstance(item, dict):
|
|
203
|
+
continue
|
|
204
|
+
sid = str(item.get("id") or f"s{i + 1}")
|
|
205
|
+
desc = str(item.get("description", "")).strip()
|
|
206
|
+
if not desc:
|
|
207
|
+
continue
|
|
208
|
+
type_str = str(item.get("type", "general")).strip().lower()
|
|
209
|
+
try:
|
|
210
|
+
task_type = TaskType(type_str)
|
|
211
|
+
except ValueError:
|
|
212
|
+
task_type = TaskType.GENERAL
|
|
213
|
+
out.append(Subtask(id=sid, description=desc, task_type=task_type))
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── Routing ──────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
def _route_subtasks(
|
|
220
|
+
subtasks: list[Subtask],
|
|
221
|
+
registry: list,
|
|
222
|
+
cfg: dict,
|
|
223
|
+
profile: str,
|
|
224
|
+
) -> list[tuple[ModelInfo, BaseAdapter] | None]:
|
|
225
|
+
from polymath.model_policy import adapter_for
|
|
226
|
+
from polymath.router import select_primary
|
|
227
|
+
|
|
228
|
+
available = [m for m in registry if m.available]
|
|
229
|
+
routes: list[tuple[ModelInfo, BaseAdapter] | None] = []
|
|
230
|
+
for subtask in subtasks:
|
|
231
|
+
pick = select_primary(available, subtask.task_type, profile)
|
|
232
|
+
if not pick:
|
|
233
|
+
routes.append(None)
|
|
234
|
+
continue
|
|
235
|
+
adapter = adapter_for(pick.provider, cfg)
|
|
236
|
+
if not adapter:
|
|
237
|
+
routes.append(None)
|
|
238
|
+
continue
|
|
239
|
+
routes.append((pick, adapter))
|
|
240
|
+
return routes
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ── Subtask execution ────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
def _make_subtask_call(
|
|
246
|
+
adapter: BaseAdapter,
|
|
247
|
+
model_info: ModelInfo,
|
|
248
|
+
subtask: Subtask,
|
|
249
|
+
parent_task: str,
|
|
250
|
+
) -> Callable[[], Awaitable]:
|
|
251
|
+
prompt = (
|
|
252
|
+
f"You are working on ONE PIECE of a larger task. Your subtask is below. "
|
|
253
|
+
f"Be self-contained — assume your output will be combined with sibling subtasks "
|
|
254
|
+
f"by an integrator. Do not reference other subtasks.\n\n"
|
|
255
|
+
f"PARENT TASK (for context only):\n{parent_task}\n\n"
|
|
256
|
+
f"YOUR SUBTASK:\n{subtask.description}"
|
|
257
|
+
)
|
|
258
|
+
async def call():
|
|
259
|
+
return await adapter.complete(
|
|
260
|
+
messages=[Message(role="user", content=prompt)],
|
|
261
|
+
model_id=model_info.id,
|
|
262
|
+
system="Answer the subtask only. Be concrete and concise.",
|
|
263
|
+
temperature=0.4,
|
|
264
|
+
)
|
|
265
|
+
return call
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ── Joiner ───────────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
async def join_results(
|
|
271
|
+
task: str,
|
|
272
|
+
successful: list[SubtaskResult],
|
|
273
|
+
joiner_adapter: BaseAdapter,
|
|
274
|
+
joiner_model_id: str,
|
|
275
|
+
) -> str:
|
|
276
|
+
parts = [f"ORIGINAL TASK:\n{task}\n", "SUBTASK OUTPUTS:"]
|
|
277
|
+
for r in successful:
|
|
278
|
+
parts.append(
|
|
279
|
+
f"\n--- {r.subtask.id}: {r.subtask.description} (by {r.model_display}) ---\n"
|
|
280
|
+
f"{r.output.strip()}"
|
|
281
|
+
)
|
|
282
|
+
prompt = "\n".join(parts) + "\n\nNow integrate these into a single coherent answer to the original task."
|
|
283
|
+
try:
|
|
284
|
+
resp = await joiner_adapter.complete(
|
|
285
|
+
messages=[Message(role="user", content=prompt)],
|
|
286
|
+
model_id=joiner_model_id,
|
|
287
|
+
system=JOINER_SYSTEM,
|
|
288
|
+
temperature=0.3,
|
|
289
|
+
)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
# On joiner failure, concatenate raw outputs as a graceful degradation
|
|
292
|
+
return "\n\n".join(
|
|
293
|
+
f"### {r.subtask.description}\n{r.output}" for r in successful
|
|
294
|
+
) + f"\n\n[joiner failed: {e}]"
|
|
295
|
+
return resp if isinstance(resp, str) else getattr(resp, "content", "")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ── Selection helpers ────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
def select_splitter_and_joiner(
|
|
301
|
+
registry: list,
|
|
302
|
+
cfg: dict,
|
|
303
|
+
profile: str,
|
|
304
|
+
) -> tuple[tuple[ModelInfo, BaseAdapter] | None, tuple[ModelInfo, BaseAdapter] | None]:
|
|
305
|
+
"""Pick high-quality models for split + join (these benefit from intelligence)."""
|
|
306
|
+
from polymath.model_policy import adapter_for
|
|
307
|
+
from polymath.router import select_primary
|
|
308
|
+
|
|
309
|
+
available = [m for m in registry if m.available]
|
|
310
|
+
splitter = select_primary(available, TaskType.ANALYSIS, profile)
|
|
311
|
+
joiner = select_primary(available, TaskType.ANALYSIS, profile)
|
|
312
|
+
splitter_pair = None
|
|
313
|
+
joiner_pair = None
|
|
314
|
+
if splitter:
|
|
315
|
+
ad = adapter_for(splitter.provider, cfg)
|
|
316
|
+
if ad:
|
|
317
|
+
splitter_pair = (splitter, ad)
|
|
318
|
+
if joiner:
|
|
319
|
+
ad = adapter_for(joiner.provider, cfg)
|
|
320
|
+
if ad:
|
|
321
|
+
joiner_pair = (joiner, ad)
|
|
322
|
+
return splitter_pair, joiner_pair
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from polymath.adapters.base import AuthExpiredError, is_rate_limit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class FailureDecision:
|
|
10
|
+
kind: str
|
|
11
|
+
retryable: bool
|
|
12
|
+
user_message: str
|
|
13
|
+
detail_message: str = ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OutputPolicy:
|
|
17
|
+
def classify_failure(self, exc: Exception) -> FailureDecision:
|
|
18
|
+
if isinstance(exc, AuthExpiredError):
|
|
19
|
+
return FailureDecision(
|
|
20
|
+
kind="auth",
|
|
21
|
+
retryable=True,
|
|
22
|
+
user_message=f"[yellow]⚠ Auth expired for [bold]{exc.provider}[/bold] — trying next model...[/yellow]",
|
|
23
|
+
detail_message=f"[dim]Re-login with:[/dim] [bold cyan]{exc.relogin_cmd}[/bold cyan]",
|
|
24
|
+
)
|
|
25
|
+
if is_rate_limit(exc):
|
|
26
|
+
return FailureDecision(
|
|
27
|
+
kind="quota",
|
|
28
|
+
retryable=True,
|
|
29
|
+
user_message="[yellow]⚠ Primary model failed due to quota/rate limit — trying next model...[/yellow]",
|
|
30
|
+
)
|
|
31
|
+
text = str(exc).lower()
|
|
32
|
+
# No status code distinguishes this one, so phrases are all there is.
|
|
33
|
+
# Kept narrow: "context" plus "limit" alone matched any error whose
|
|
34
|
+
# message happened to mention both words.
|
|
35
|
+
if any(marker in text for marker in (
|
|
36
|
+
"context window", "context length", "maximum context",
|
|
37
|
+
"context_length_exceeded", "too many tokens",
|
|
38
|
+
"prompt is too long",
|
|
39
|
+
)):
|
|
40
|
+
return FailureDecision(
|
|
41
|
+
kind="context",
|
|
42
|
+
retryable=True,
|
|
43
|
+
user_message="[yellow]⚠ Context window exhausted — trying next model...[/yellow]",
|
|
44
|
+
)
|
|
45
|
+
return FailureDecision(
|
|
46
|
+
kind="error",
|
|
47
|
+
retryable=False,
|
|
48
|
+
user_message=f"[red]Error: {exc}[/red]",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def final_failure_lines(self, decision: FailureDecision, exc: Exception) -> list[str]:
|
|
52
|
+
if decision.kind == "auth":
|
|
53
|
+
return [
|
|
54
|
+
f"[red bold]Auth expired:[/red bold] [yellow]{getattr(exc, 'provider', 'provider')}[/yellow] token is no longer valid.",
|
|
55
|
+
decision.detail_message,
|
|
56
|
+
"[dim]No other models available to fall back to.[/dim]",
|
|
57
|
+
]
|
|
58
|
+
return [
|
|
59
|
+
f"[red bold]Primary model failed:[/red bold] {exc}",
|
|
60
|
+
"[dim]No other models available to fall back to.[/dim]",
|
|
61
|
+
]
|