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,184 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from polymath.config import PRIORITY_PROFILES, TaskType
6
+ from polymath.router import select_primary
7
+
8
+
9
+ class StateResponder:
10
+ _MODEL_PATTERNS = (
11
+ "what all models are you using",
12
+ "what all models do you use",
13
+ "which all models are you using",
14
+ "which all models do you use",
15
+ "what models are you using",
16
+ "which models are you using",
17
+ "what model are you using",
18
+ "which model are you using",
19
+ "show models you are using",
20
+ "what models do you have",
21
+ "which models do you have",
22
+ )
23
+ _CAPABILITY_PATTERNS = (
24
+ "what can you do",
25
+ "what all can you do",
26
+ "what do you do",
27
+ "what all do you do",
28
+ "what are your capabilities",
29
+ "what can polymath do",
30
+ "help me with",
31
+ )
32
+ _COMPARISON_PATTERNS = (
33
+ "better than",
34
+ "stronger than",
35
+ "why not",
36
+ "why are you using",
37
+ "shouldn't you use",
38
+ "isn't",
39
+ )
40
+ _PROVIDER_WORDS = ("claude", "gemini", "gpt", "openai", "ollama", "lm studio", "llama", "mistral", "opus", "sonnet", "haiku")
41
+
42
+ def can_answer(self, text: str) -> bool:
43
+ lowered = text.lower().strip()
44
+ return (
45
+ any(pattern in lowered for pattern in self._MODEL_PATTERNS)
46
+ or any(pattern in lowered for pattern in self._CAPABILITY_PATTERNS)
47
+ or self._is_model_policy_question(lowered)
48
+ )
49
+
50
+ def _is_model_policy_question(self, lowered: str) -> bool:
51
+ return (
52
+ any(pattern in lowered for pattern in self._COMPARISON_PATTERNS)
53
+ and any(word in lowered for word in self._PROVIDER_WORDS)
54
+ )
55
+
56
+ def _resolve_primary(self, available, profile: str):
57
+ primary = select_primary(list(available), TaskType.GENERAL, profile)
58
+ if primary:
59
+ return primary
60
+ ordered = sorted(
61
+ available,
62
+ key=PRIORITY_PROFILES.get(profile, PRIORITY_PROFILES["quality-first"]),
63
+ )
64
+ return ordered[0]
65
+
66
+ def _model_aliases(self, model) -> list[tuple[str, int]]:
67
+ display = model.display_name.lower()
68
+ aliases: list[tuple[str, int]] = [
69
+ (display, 120),
70
+ (re.sub(r"[^a-z0-9]+", "", display), 100),
71
+ (model.id.lower(), 90),
72
+ ]
73
+ tokens = [token for token in re.findall(r"[a-z0-9]+", display) if len(token) > 2]
74
+ for token in tokens:
75
+ aliases.append((token, 18))
76
+ aliases.append((model.provider.lower(), 4))
77
+ return aliases
78
+
79
+ def _score_model_match(self, text: str, model) -> int:
80
+ lowered = text.lower()
81
+ compact_text = re.sub(r"[^a-z0-9]+", "", lowered)
82
+ score = 0
83
+ for alias, weight in self._model_aliases(model):
84
+ if not alias:
85
+ continue
86
+ if alias == model.provider.lower():
87
+ if re.search(rf"\b{re.escape(alias)}\b", lowered):
88
+ score += weight
89
+ continue
90
+ if alias == re.sub(r"[^a-z0-9]+", "", alias):
91
+ if alias in compact_text:
92
+ score += weight
93
+ elif alias in lowered:
94
+ score += weight
95
+ elif re.search(rf"\b{re.escape(alias)}\b", lowered):
96
+ score += weight
97
+ return score
98
+
99
+ def _best_match(self, text: str, registry):
100
+ scored = []
101
+ for model in registry:
102
+ score = self._score_model_match(text, model)
103
+ if score > 0:
104
+ scored.append((score, model))
105
+ if not scored:
106
+ return None
107
+ scored.sort(key=lambda item: (-item[0], -item[1].quality, -item[1].context_window, -item[1].speed))
108
+ return scored[0][1]
109
+
110
+ def _split_comparison_sides(self, text: str) -> tuple[str, str] | None:
111
+ lowered = text.lower().strip()
112
+ for marker in ("better than", "stronger than", "over"):
113
+ if marker in lowered:
114
+ left, right = lowered.split(marker, 1)
115
+ return left.strip(), right.strip()
116
+ return None
117
+
118
+ def _find_mentions(self, text: str, registry) -> list:
119
+ parts = self._split_comparison_sides(text)
120
+ if parts:
121
+ left = self._best_match(parts[0], registry)
122
+ right = self._best_match(parts[1], registry)
123
+ resolved = []
124
+ for model in (left, right):
125
+ if model and all(existing.id != model.id for existing in resolved):
126
+ resolved.append(model)
127
+ if len(resolved) >= 2:
128
+ return resolved[:2]
129
+ scored_matches: list[tuple[int, object]] = []
130
+ for model in registry:
131
+ score = self._score_model_match(text, model)
132
+ if score > 0:
133
+ scored_matches.append((score, model))
134
+ scored_matches.sort(key=lambda item: (-item[0], -item[1].quality, -item[1].context_window, -item[1].speed))
135
+ seen = set()
136
+ unique = []
137
+ for _, model in scored_matches:
138
+ if model.id not in seen:
139
+ unique.append(model)
140
+ seen.add(model.id)
141
+ return unique[:2]
142
+
143
+ def answer(self, text: str, registry, profile: str) -> str | None:
144
+ if not self.can_answer(text):
145
+ return None
146
+ available = [m for m in registry if m.available]
147
+ if not available:
148
+ return "I don't currently have any available models."
149
+ lowered = text.lower().strip()
150
+ if any(pattern in lowered for pattern in self._CAPABILITY_PATTERNS):
151
+ return (
152
+ "I can route across available models, answer direct questions, run the multi-step planning/execution/review pipeline, "
153
+ "use repo or detached project context, manage queued prompts, run slash commands and subagents, and work with git-backed shared `.polymath/` project metadata."
154
+ )
155
+ ordered = sorted(
156
+ available,
157
+ key=PRIORITY_PROFILES.get(profile, PRIORITY_PROFILES["quality-first"]),
158
+ )
159
+ primary = self._resolve_primary(available, profile)
160
+ if self._is_model_policy_question(lowered):
161
+ mentioned = self._find_mentions(lowered, available)
162
+ if len(mentioned) >= 2:
163
+ left, right = mentioned[0], mentioned[1]
164
+ left_score = (left.quality, left.context_window, left.speed)
165
+ right_score = (right.quality, right.context_window, right.speed)
166
+ if left_score == right_score:
167
+ verdict = f"In the current registry, {left.display_name} and {right.display_name} are effectively tied on the stored policy metrics."
168
+ elif left_score > right_score:
169
+ verdict = f"Yes. In the current registry, {left.display_name} ranks above {right.display_name} on raw policy strength."
170
+ else:
171
+ verdict = f"No. In the current registry, {right.display_name} ranks above {left.display_name} on raw policy strength."
172
+ return (
173
+ f"{verdict} Under `{profile}`, the current primary choice for a general task is {primary.display_name}. "
174
+ f"`quality-first` now breaks ties by quality, then context window, then speed, then cost."
175
+ )
176
+ return (
177
+ f"Under `{profile}`, my current primary choice for a general task is {primary.display_name}. "
178
+ f"`quality-first` now breaks ties by quality, then context window, then speed, then cost."
179
+ )
180
+ names = ", ".join(m.display_name for m in ordered)
181
+ return (
182
+ f"I'm currently orchestrating across {len(ordered)} available models: {names}. "
183
+ f"My current primary choice under the `{profile}` profile is {primary.display_name}."
184
+ )
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Awaitable, Callable
6
+
7
+
8
+ @dataclass
9
+ class WorkerPool:
10
+ tasks: set[asyncio.Task[Any]] = field(default_factory=set)
11
+
12
+ async def run(self, factory: Callable[[], Awaitable[Any]]) -> Any:
13
+ task = asyncio.create_task(factory())
14
+ self.tasks.add(task)
15
+ try:
16
+ return await task
17
+ finally:
18
+ self.tasks.discard(task)
19
+
20
+ async def gather(self, factories: list[Callable[[], Awaitable[Any]]]) -> list[Any]:
21
+ if not factories:
22
+ return []
23
+ tasks = [asyncio.create_task(factory()) for factory in factories]
24
+ self.tasks.update(tasks)
25
+ try:
26
+ return await asyncio.gather(*tasks, return_exceptions=True)
27
+ finally:
28
+ for task in tasks:
29
+ self.tasks.discard(task)
30
+
31
+ async def cancel_all(self) -> None:
32
+ if not self.tasks:
33
+ return
34
+ for task in list(self.tasks):
35
+ task.cancel()
36
+ await asyncio.gather(*list(self.tasks), return_exceptions=True)
37
+ self.tasks.clear()
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable
4
+
5
+ from polymath.domain import PermissionRequest
6
+ from polymath.project_config import LocalProject, is_shell_command_allowed, is_tool_allowed
7
+ from polymath.tools import permission_scope
8
+
9
+
10
+ #: Tools whose first argument is a command line, so the shell allowlist
11
+ #: applies to them as well as the tool allowlist.
12
+ SHELL_TOOLS = ("run_shell_command",)
13
+
14
+
15
+ def build_permission_request(
16
+ tool_name: str,
17
+ arguments: dict[str, Any] | None = None,
18
+ ) -> PermissionRequest:
19
+ """Build a permission request straight from the tool call.
20
+
21
+ The decision is made on `tool_name` and `arguments`. `action` is derived
22
+ from them only so the prompt has something to show, which means what the
23
+ user is asked about and what is checked are the same values.
24
+
25
+ This replaces an earlier version that rendered the call to a sentence and
26
+ recovered the command from it with a regular expression. That recovery
27
+ stopped at the first quote, so a command containing one was approved on
28
+ the strength of a truncated fragment.
29
+ """
30
+ arguments = dict(arguments or {})
31
+ shell_command = ""
32
+ if tool_name in SHELL_TOOLS:
33
+ raw = arguments.get("command", "")
34
+ shell_command = raw if isinstance(raw, str) else ""
35
+ reason = permission_scope(tool_name, arguments)
36
+ return PermissionRequest(
37
+ action=render_action(tool_name, arguments, reason),
38
+ tool_name=tool_name,
39
+ shell_command=shell_command,
40
+ arguments=arguments,
41
+ escalation_reason=reason,
42
+ requires_prompt=True,
43
+ )
44
+
45
+
46
+ def render_action(
47
+ tool_name: str,
48
+ arguments: dict[str, Any],
49
+ escalation_reason: str = "",
50
+ ) -> str:
51
+ """Human-readable one-liner for the permission prompt. Display only."""
52
+ line = f"Execute {tool_name} with {arguments}"
53
+ if escalation_reason:
54
+ line += f" [{escalation_reason}]"
55
+ return line
56
+
57
+
58
+ def is_preapproved(request: PermissionRequest, local_project: LocalProject | None) -> bool:
59
+ # A project's allowed_tools list says "read_file is fine here". It does
60
+ # not say "and my ssh key too" — an escalated call is always asked about.
61
+ if request.escalation_reason:
62
+ return False
63
+ if not local_project or not request.tool_name:
64
+ return False
65
+ if not is_tool_allowed(request.tool_name, local_project.settings):
66
+ return False
67
+ if request.tool_name != "run_shell_command":
68
+ return True
69
+ return bool(request.shell_command) and is_shell_command_allowed(
70
+ request.shell_command,
71
+ local_project.settings,
72
+ )
73
+
74
+
75
+ def decide_permission(
76
+ request: PermissionRequest,
77
+ local_project: LocalProject | None,
78
+ prompt_user: Callable[[str], bool],
79
+ ) -> bool:
80
+ if is_preapproved(request, local_project):
81
+ return True
82
+ return prompt_user(request.action)