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,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)