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.
Files changed (54) hide show
  1. polymath/__init__.py +2 -0
  2. polymath/adapters/__init__.py +7 -0
  3. polymath/adapters/base.py +175 -0
  4. polymath/adapters/claude.py +280 -0
  5. polymath/adapters/gemini.py +186 -0
  6. polymath/adapters/ollama.py +117 -0
  7. polymath/adapters/openai_adapter.py +168 -0
  8. polymath/bootstrap.py +159 -0
  9. polymath/command_registry.py +41 -0
  10. polymath/command_service.py +572 -0
  11. polymath/compressor.py +90 -0
  12. polymath/config.py +293 -0
  13. polymath/context_manager.py +76 -0
  14. polymath/context_store.py +336 -0
  15. polymath/detector.py +442 -0
  16. polymath/domain.py +78 -0
  17. polymath/execution_service.py +325 -0
  18. polymath/main.py +1293 -0
  19. polymath/memory/__init__.py +15 -0
  20. polymath/memory/chunker.py +6 -0
  21. polymath/memory/embedder.py +179 -0
  22. polymath/memory/migrate.py +2 -0
  23. polymath/memory/retriever.py +2 -0
  24. polymath/memory/store.py +9 -0
  25. polymath/memory/sync.py +2 -0
  26. polymath/memory/writer.py +9 -0
  27. polymath/model_policy.py +172 -0
  28. polymath/orchestrator/__init__.py +68 -0
  29. polymath/orchestrator/attempt_ledger.py +34 -0
  30. polymath/orchestrator/ensemble.py +229 -0
  31. polymath/orchestrator/fanout.py +322 -0
  32. polymath/orchestrator/output_policy.py +61 -0
  33. polymath/orchestrator/race.py +311 -0
  34. polymath/orchestrator/run_controller.py +91 -0
  35. polymath/orchestrator/speculative_review.py +120 -0
  36. polymath/orchestrator/state_responder.py +184 -0
  37. polymath/orchestrator/worker_pool.py +37 -0
  38. polymath/permissions.py +82 -0
  39. polymath/pipeline.py +700 -0
  40. polymath/project_config.py +229 -0
  41. polymath/project_runtime.py +109 -0
  42. polymath/router.py +127 -0
  43. polymath/setup_wizard.py +106 -0
  44. polymath/slash_commands.py +566 -0
  45. polymath/subagents.py +486 -0
  46. polymath/tools.py +333 -0
  47. polymath/ui_state.py +84 -0
  48. polymath/workspace.py +66 -0
  49. polymath_agent-0.4.0.dist-info/METADATA +693 -0
  50. polymath_agent-0.4.0.dist-info/RECORD +54 -0
  51. polymath_agent-0.4.0.dist-info/WHEEL +5 -0
  52. polymath_agent-0.4.0.dist-info/entry_points.txt +2 -0
  53. polymath_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
  54. 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
+ ]