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
polymath/tools.py ADDED
@@ -0,0 +1,333 @@
1
+ """
2
+ Tool (function) support for polymath models.
3
+ Provides a registry of tools that models can call to interact with the environment.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, List, Optional
14
+
15
+ from polymath.config import CONFIG_DIR
16
+
17
+
18
+ # ── Scope ─────────────────────────────────────────────────────────────────────
19
+ #
20
+ # Reading is not free. The model can put whatever it reads into its output,
21
+ # and that output goes to the next model, the transcript, and the brain. So a
22
+ # read outside the project is an escalation and has to be asked about, and the
23
+ # directory holding the API keys is refused however the call arrives.
24
+
25
+
26
+ #: Markers that identify a real project root, in preference order.
27
+ _PROJECT_MARKERS = (".polymath", ".git")
28
+
29
+ #: Paths that are refused wherever they sit, whatever the project root is.
30
+ #: Position cannot launder these: a project root that happens to contain
31
+ #: your ssh keys does not make reading them ordinary. Relative to home
32
+ #: unless absolute.
33
+ _DENIED_HOME_PATHS = (
34
+ ".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud",
35
+ ".kube", ".docker/config.json", ".netrc", ".npmrc", ".pypirc",
36
+ ".git-credentials", ".bash_history", ".zsh_history",
37
+ )
38
+
39
+ #: File names that carry secrets wherever they appear, including inside
40
+ #: the project root.
41
+ _DENIED_NAMES = (".env", ".netrc", ".git-credentials")
42
+ _DENIED_PREFIXES = ("id_rsa", "id_ed25519", "id_ecdsa", "id_dsa")
43
+
44
+
45
+ def workspace_root() -> Path | None:
46
+ """The project the user is working in, or None if there isn't one.
47
+
48
+ Anchored to a real project marker rather than to the current directory.
49
+ Polymath is a REPL you launch from anywhere, and launching it from home
50
+ used to make the whole home directory the project, so ssh keys and .env
51
+ files counted as ordinary in-project reads.
52
+
53
+ None means no free-read zone: every read is asked about. That is the
54
+ right default for "I started this in a directory that is not a project".
55
+ """
56
+ start = Path.cwd().resolve()
57
+ for directory in [start, *start.parents]:
58
+ if directory == Path.home():
59
+ # Home is never a project root, even with a dotfiles repo in it.
60
+ break
61
+ for marker in _PROJECT_MARKERS:
62
+ if (directory / marker).exists():
63
+ return directory
64
+ return None
65
+
66
+
67
+ def is_denied(path: str) -> bool:
68
+ """True for paths nothing the model does should reach, wherever they are."""
69
+ try:
70
+ target = resolve_path(path)
71
+ except (OSError, ValueError):
72
+ return True
73
+ if target.name in _DENIED_NAMES:
74
+ return True
75
+ if any(target.name.startswith(prefix) for prefix in _DENIED_PREFIXES):
76
+ return True
77
+ home = Path.home().resolve()
78
+ for relative in _DENIED_HOME_PATHS:
79
+ candidate = (home / relative)
80
+ try:
81
+ if target == candidate or target.is_relative_to(candidate):
82
+ return True
83
+ except (OSError, ValueError):
84
+ continue
85
+ return False
86
+
87
+
88
+ def resolve_path(path: str) -> Path:
89
+ """Absolute, symlink-resolved. Resolving matters: a link inside the
90
+ workspace can point anywhere, and a string prefix test would not notice."""
91
+ return Path(path).expanduser().resolve()
92
+
93
+
94
+ def is_inside_workspace(path: str) -> bool:
95
+ """True only when there is a project root and the path resolves inside it."""
96
+ root = workspace_root()
97
+ if root is None:
98
+ return False
99
+ try:
100
+ return resolve_path(path).is_relative_to(root)
101
+ except (OSError, ValueError):
102
+ return False
103
+
104
+
105
+ def is_protected(path: str) -> bool:
106
+ """True for paths nothing the model does has any business touching.
107
+
108
+ ~/.polymath holds the API keys for every provider. Reading it once puts
109
+ them in the transcript, which is then sent to the next model.
110
+ """
111
+ try:
112
+ if resolve_path(path).is_relative_to(CONFIG_DIR.resolve()):
113
+ return True
114
+ except (OSError, ValueError):
115
+ return False
116
+ return is_denied(path)
117
+
118
+
119
+ #: Arguments naming a filesystem path, per tool.
120
+ _PATH_ARGUMENT = {
121
+ "read_file": "file_path",
122
+ "write_file": "file_path",
123
+ "list_directory": "path",
124
+ }
125
+
126
+
127
+ def permission_scope(tool_name: str, arguments: Dict[str, Any] | None) -> str:
128
+ """Why this particular call needs asking about, or "" if it does not.
129
+
130
+ This is per-call, not per-tool: reading inside the project is ordinary,
131
+ reading your ssh key is not, and both are `read_file`.
132
+ """
133
+ argument = _PATH_ARGUMENT.get(tool_name)
134
+ if not argument:
135
+ return ""
136
+ path = (arguments or {}).get(argument)
137
+ if not isinstance(path, str) or not path:
138
+ return ""
139
+ if is_protected(path):
140
+ return "a credentials path, which the model is never given"
141
+ if not is_inside_workspace(path):
142
+ if workspace_root() is None:
143
+ return "outside any project — polymath was not started in one"
144
+ return "outside the current project"
145
+ return ""
146
+
147
+
148
+ @dataclass
149
+ class Tool:
150
+ name: str
151
+ description: str
152
+ parameters: Dict[str, Any] # JSON schema for arguments
153
+ callable: Callable[..., Any]
154
+ requires_permission: bool = False
155
+
156
+ def needs_permission(self, arguments: Dict[str, Any] | None = None) -> bool:
157
+ """Whether this call must be approved. Tools that always need consent
158
+ say so; the rest need it only when the call leaves the project."""
159
+ if self.requires_permission:
160
+ return True
161
+ return bool(permission_scope(self.name, arguments))
162
+
163
+ def to_json_schema(self) -> Dict[str, Any]:
164
+ """Convert to standard JSON schema format."""
165
+ return {
166
+ "name": self.name,
167
+ "description": self.description,
168
+ "parameters": self.parameters,
169
+ }
170
+
171
+
172
+ class ToolRegistry:
173
+ def __init__(self):
174
+ self._tools: Dict[str, Tool] = {}
175
+
176
+ def register(self, tool: Tool):
177
+ self._tools[tool.name] = tool
178
+
179
+ def get(self, name: str) -> Optional[Tool]:
180
+ return self._tools.get(name)
181
+
182
+ def list_tools(self) -> List[Tool]:
183
+ return list(self._tools.values())
184
+
185
+ async def execute(self, name: str, args: Dict[str, Any]) -> str:
186
+ """Execute tool and return result as string."""
187
+ tool = self.get(name)
188
+ if not tool:
189
+ return f"Error: Tool '{name}' not found."
190
+
191
+ try:
192
+ # Handle sync call for now; can be expanded for async if needed
193
+ result = tool.callable(**args)
194
+ if result is None:
195
+ return "Success"
196
+ if isinstance(result, (dict, list)):
197
+ return json.dumps(result, indent=2)
198
+ return str(result)
199
+ except Exception as e:
200
+ return f"Error executing '{name}': {str(e)}"
201
+
202
+
203
+ # ── Built-in Tools ────────────────────────────────────────────────────────────
204
+
205
+ def list_directory(path: str = ".") -> List[str]:
206
+ """Lists files and directories in a given path."""
207
+ if is_protected(path):
208
+ return [f"Error: refused — {path} is inside polymath's config directory."]
209
+ return os.listdir(path)
210
+
211
+
212
+ def read_file(file_path: str) -> str:
213
+ """Reads the content of a file."""
214
+ if is_protected(file_path):
215
+ return (
216
+ f"Error: refused — {file_path} is inside polymath's config "
217
+ "directory, which holds your API keys."
218
+ )
219
+ with open(file_path, "r") as f:
220
+ return f.read()
221
+
222
+
223
+ def write_file(file_path: str, content: str) -> str:
224
+ """Writes content to a file (restricted to current workspace).
225
+
226
+ The path is resolved before the test, so a symlink inside the project
227
+ that points elsewhere is refused. The previous version compared the
228
+ textual absolute path, which normalises ".." but follows no links, so
229
+ `project/link.txt -> /etc/passwd` passed the check.
230
+ """
231
+ if is_protected(file_path):
232
+ return (
233
+ f"Error: refused — {file_path} is inside polymath's config "
234
+ "directory, which holds your API keys."
235
+ )
236
+ root = workspace_root()
237
+ target = resolve_path(file_path)
238
+ if root is None:
239
+ return (
240
+ "Error: write_file needs a project. polymath was started outside "
241
+ "any .polymath folder or git repository, so there is nowhere it "
242
+ "is safe to write."
243
+ )
244
+ if target != root and not target.is_relative_to(root):
245
+ return (
246
+ f"Error: write_file is restricted to current workspace ({root}). "
247
+ f"Refused: {target}"
248
+ )
249
+ target.parent.mkdir(parents=True, exist_ok=True)
250
+ target.write_text(content)
251
+ return f"File written to {file_path}"
252
+
253
+
254
+ def run_shell_command(command: str) -> str:
255
+ """Runs a shell command and returns stdout + stderr."""
256
+ import shlex
257
+ try:
258
+ args = shlex.split(command)
259
+ except ValueError as e:
260
+ return f"Error: Invalid command syntax: {e}"
261
+ try:
262
+ result = subprocess.run(
263
+ args,
264
+ shell=False,
265
+ capture_output=True,
266
+ text=True,
267
+ timeout=30
268
+ )
269
+ output = result.stdout
270
+ if result.stderr:
271
+ output += f"\nErrors:\n{result.stderr}"
272
+ return output
273
+ except Exception as e:
274
+ return str(e)
275
+
276
+
277
+ # ── Global Registry ───────────────────────────────────────────────────────────
278
+
279
+ registry = ToolRegistry()
280
+
281
+ registry.register(Tool(
282
+ name="list_directory",
283
+ description="List files and directories in a given path.",
284
+ parameters={
285
+ "type": "object",
286
+ "properties": {
287
+ "path": {"type": "string", "description": "The directory path (default: '.')"}
288
+ },
289
+ },
290
+ callable=list_directory
291
+ ))
292
+
293
+ registry.register(Tool(
294
+ name="read_file",
295
+ description="Read the contents of a file.",
296
+ parameters={
297
+ "type": "object",
298
+ "properties": {
299
+ "file_path": {"type": "string", "description": "The path to the file to read."}
300
+ },
301
+ "required": ["file_path"]
302
+ },
303
+ callable=read_file
304
+ ))
305
+
306
+ registry.register(Tool(
307
+ name="write_file",
308
+ description="Write content to a file (creates parent directories if needed).",
309
+ parameters={
310
+ "type": "object",
311
+ "properties": {
312
+ "file_path": {"type": "string", "description": "The path to write the file to."},
313
+ "content": {"type": "string", "description": "The content to write."}
314
+ },
315
+ "required": ["file_path", "content"]
316
+ },
317
+ callable=write_file,
318
+ requires_permission=True
319
+ ))
320
+
321
+ registry.register(Tool(
322
+ name="run_shell_command",
323
+ description="Execute a shell command in the local environment.",
324
+ parameters={
325
+ "type": "object",
326
+ "properties": {
327
+ "command": {"type": "string", "description": "The command to run."}
328
+ },
329
+ "required": ["command"]
330
+ },
331
+ callable=run_shell_command,
332
+ requires_permission=True
333
+ ))
polymath/ui_state.py ADDED
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from dataclasses import dataclass
5
+
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+
9
+ from polymath.domain import Project
10
+
11
+
12
+ def prompt_toolbar(
13
+ project: Project,
14
+ queued_inputs: deque[str | None],
15
+ active: bool,
16
+ session_id: str,
17
+ ) -> str:
18
+ queue_preview = [q for q in list(queued_inputs)[-2:] if q]
19
+ queue_text = " | ".join(queue_preview)[:120] if queue_preview else "empty"
20
+ repo_name = project.repo_root.name if project.repo_root else "no-repo"
21
+ branch = project.repo_branch or "-"
22
+ dirty = "dirty" if project.repo_dirty else "clean"
23
+ mode = "busy" if active else "idle"
24
+ source = "shared" if project.shared else ("local" if project.name else "none")
25
+ return (
26
+ f" session {session_id} | project {project.name or '-'} ({source}) | "
27
+ f"git {repo_name}:{branch}:{dirty} | queue {len(queued_inputs)} [{queue_text}] | {mode} "
28
+ )
29
+
30
+
31
+ def render_queue_table(queued_inputs: list[str]) -> Table:
32
+ table = Table(title="Queued Commands", show_header=True, header_style="bold cyan")
33
+ table.add_column("#", style="dim", width=4)
34
+ table.add_column("Command", style="white")
35
+ for idx, item in enumerate(queued_inputs, 1):
36
+ table.add_row(str(idx), item)
37
+ return table
38
+
39
+
40
+ @dataclass
41
+ class RunStatus:
42
+ phase: str
43
+ model: str
44
+ note: str = ""
45
+ reviewer: str = ""
46
+ queue_count: int = 0
47
+ context_line: str = ""
48
+
49
+
50
+ def render_run_status(status: RunStatus) -> Panel:
51
+ lines = [
52
+ f"[bold cyan]≡ Running Agent[/bold cyan]",
53
+ f"[dim]phase:[/dim] {status.phase} [dim]model:[/dim] {status.model} [dim]queue:[/dim] {status.queue_count}",
54
+ ]
55
+ if status.reviewer:
56
+ lines.append(f"[dim]reviewer:[/dim] {status.reviewer}")
57
+ if status.context_line:
58
+ lines.append(f"[dim]{status.context_line}[/dim]")
59
+ if status.note:
60
+ lines.append(status.note)
61
+ return Panel("\n".join(lines), border_style="cyan")
62
+
63
+
64
+ @dataclass
65
+ class CompletionSummary:
66
+ session_id: str
67
+ primary_model: str
68
+ reviewer_model: str = ""
69
+ token_line: str = ""
70
+ queue_count: int = 0
71
+
72
+
73
+ def render_completion_summary(summary: CompletionSummary) -> Panel:
74
+ lines = [
75
+ "[bold green]✓ Agent Complete[/bold green]",
76
+ f"[dim]session:[/dim] {summary.session_id}",
77
+ f"[dim]primary:[/dim] {summary.primary_model}",
78
+ ]
79
+ if summary.reviewer_model:
80
+ lines.append(f"[dim]reviewer:[/dim] {summary.reviewer_model}")
81
+ if summary.token_line:
82
+ lines.append(f"[dim]{summary.token_line}[/dim]")
83
+ lines.append(f"[dim]queue remaining:[/dim] {summary.queue_count}")
84
+ return Panel("\n".join(lines), border_style="green")
polymath/workspace.py ADDED
@@ -0,0 +1,66 @@
1
+ """
2
+ Workspace awareness for polymath.
3
+ Scans the current directory to provide context to the models.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+
12
+ def scan_workspace(root_dir: str = ".") -> Dict[str, Any]:
13
+ """Scans the workspace for structure and key files."""
14
+ root = Path(root_dir).resolve()
15
+
16
+ # 1. Directory Structure (shallow)
17
+ structure = []
18
+ try:
19
+ for item in root.iterdir():
20
+ if item.name.startswith(".") or item.name == "__pycache__":
21
+ continue
22
+ if item.is_dir():
23
+ structure.append(f" [DIR] {item.name}/")
24
+ else:
25
+ structure.append(f" [FILE] {item.name}")
26
+ except Exception:
27
+ pass
28
+
29
+ # 2. Key Files
30
+ key_files = ["README.md", "pyproject.toml", "package.json", "requirements.txt", "setup.py"]
31
+ file_contents = {}
32
+ for kf in key_files:
33
+ p = root / kf
34
+ if p.exists() and p.is_file():
35
+ try:
36
+ # Read first 2KB of each key file
37
+ with open(p, "r") as f:
38
+ file_contents[kf] = f.read(2048)
39
+ except Exception:
40
+ pass
41
+
42
+ return {
43
+ "root": str(root),
44
+ "structure": structure,
45
+ "key_files": file_contents
46
+ }
47
+
48
+
49
+ def get_workspace_context(root_dir: str = ".") -> str:
50
+ """Formats workspace scan into a string for system prompts."""
51
+ data = scan_workspace(root_dir)
52
+
53
+ lines = [
54
+ f"CURRENT WORKSPACE: {data['root']}",
55
+ "DIRECTORY STRUCTURE:",
56
+ ]
57
+ lines.extend(data["structure"][:30]) # Limit to 30 items
58
+
59
+ if data["key_files"]:
60
+ lines.append("\nKEY FILE CONTENTS:")
61
+ for name, content in data["key_files"].items():
62
+ lines.append(f"--- {name} ---")
63
+ lines.append(content)
64
+ lines.append("-" * (len(name) + 8))
65
+
66
+ return "\n".join(lines)