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,117 @@
1
+ from __future__ import annotations
2
+
3
+ import json as _json_module
4
+ from typing import Any, AsyncIterator
5
+
6
+ import httpx
7
+
8
+ from polymath.adapters.base import (
9
+ BaseAdapter, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_MAX_TOKENS,
10
+ Message, ToolCall,
11
+ )
12
+
13
+
14
+ class OllamaAdapter(BaseAdapter):
15
+ provider = "ollama"
16
+
17
+ def __init__(self, base_url: str = "http://localhost:11434") -> None:
18
+ self._base = base_url.rstrip("/")
19
+
20
+ def _to_messages(self, messages: list[Message], system: str) -> list[dict]:
21
+ result = []
22
+ if system:
23
+ result.append({"role": "system", "content": system})
24
+ for m in messages:
25
+ if m.role == "system":
26
+ continue
27
+ result.append({"role": m.role, "content": m.content})
28
+ return result
29
+
30
+ def _convert_tools(self, tools: list[Any] | None) -> list[dict] | None:
31
+ if not tools:
32
+ return None
33
+ return [
34
+ {
35
+ "type": "function",
36
+ "function": {
37
+ "name": t.name,
38
+ "description": t.description,
39
+ "parameters": t.parameters,
40
+ },
41
+ }
42
+ for t in tools
43
+ ]
44
+
45
+ async def complete(
46
+ self,
47
+ messages: list[Message],
48
+ model_id: str,
49
+ system: str = "",
50
+ tools: list[Any] | None = None,
51
+ temperature: float = 0.7,
52
+ max_tokens: int = DEFAULT_MAX_TOKENS,
53
+ ) -> str | Message:
54
+ body: dict[str, Any] = {
55
+ "model": model_id,
56
+ "messages": self._to_messages(messages, system),
57
+ "stream": False,
58
+ "options": {"temperature": temperature, "num_predict": max_tokens},
59
+ }
60
+ ollama_tools = self._convert_tools(tools)
61
+ if ollama_tools:
62
+ body["tools"] = ollama_tools
63
+
64
+ async with httpx.AsyncClient(timeout=120) as client:
65
+ resp = await client.post(f"{self._base}/api/chat", json=body)
66
+ resp.raise_for_status()
67
+ data = resp.json()
68
+
69
+ msg = data.get("message", {})
70
+ raw_tool_calls = msg.get("tool_calls", [])
71
+ if raw_tool_calls:
72
+ tc_list = []
73
+ for tc in raw_tool_calls:
74
+ fn = tc.get("function", {})
75
+ tc_id = tc.get("id", fn.get("name", ""))
76
+ args = fn.get("arguments", {})
77
+ if isinstance(args, str):
78
+ try:
79
+ args = _json_module.loads(args)
80
+ except Exception:
81
+ args = {}
82
+ tc_list.append(ToolCall(id=tc_id, name=fn.get("name", ""), arguments=args))
83
+ return Message(
84
+ role="assistant",
85
+ content=msg.get("content", ""),
86
+ tool_calls=tc_list,
87
+ )
88
+
89
+ return msg.get("content", "")
90
+
91
+ async def stream(
92
+ self,
93
+ messages: list[Message],
94
+ model_id: str,
95
+ system: str = "",
96
+ tools: list[Any] | None = None,
97
+ temperature: float = 0.7,
98
+ max_tokens: int = DEFAULT_STREAM_MAX_TOKENS,
99
+ ) -> AsyncIterator[str]:
100
+ body: dict[str, Any] = {
101
+ "model": model_id,
102
+ "messages": self._to_messages(messages, system),
103
+ "stream": True,
104
+ "options": {"temperature": temperature, "num_predict": max_tokens},
105
+ }
106
+ ollama_tools = self._convert_tools(tools)
107
+ if ollama_tools:
108
+ body["tools"] = ollama_tools
109
+
110
+ async with httpx.AsyncClient(timeout=120) as client:
111
+ async with client.stream("POST", f"{self._base}/api/chat", json=body) as resp:
112
+ async for line in resp.aiter_lines():
113
+ if line.strip():
114
+ chunk = _json_module.loads(line)
115
+ content = chunk.get("message", {}).get("content", "")
116
+ if content:
117
+ yield content
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, AsyncIterator
5
+
6
+ from polymath.adapters.base import (
7
+ AuthExpiredError, BaseAdapter, DEFAULT_MAX_TOKENS,
8
+ DEFAULT_STREAM_MAX_TOKENS, Message, ToolCall,
9
+ is_auth_failure, resolve_exception_types, truncation_notice,
10
+ )
11
+
12
+
13
+ def _auth_types() -> tuple[type, ...]:
14
+ return resolve_exception_types(
15
+ "openai", "AuthenticationError", "PermissionDeniedError",
16
+ )
17
+
18
+
19
+ class OpenAIAdapter(BaseAdapter):
20
+ provider = "openai"
21
+
22
+ def __init__(self, api_key: str, base_url: str = "") -> None:
23
+ from openai import AsyncOpenAI
24
+ client_kwargs: dict[str, Any] = {}
25
+ if base_url:
26
+ client_kwargs["base_url"] = base_url
27
+ if api_key.startswith("chatgpt_oauth:"):
28
+ # Codex CLI ChatGPT OAuth — use as bearer token
29
+ token = api_key[len("chatgpt_oauth:"):]
30
+ self._client = AsyncOpenAI(
31
+ api_key="unused",
32
+ default_headers={"Authorization": f"Bearer {token}"},
33
+ **client_kwargs,
34
+ )
35
+ else:
36
+ self._client = AsyncOpenAI(api_key=api_key or "lm-studio", **client_kwargs)
37
+ self._last_input_tokens: int = 0
38
+ self._last_output_tokens: int = 0
39
+
40
+ def _convert_tools(self, tools: list[Any] | None) -> list[dict] | None:
41
+ if not tools:
42
+ return None
43
+ oai_tools = []
44
+ for t in tools:
45
+ oai_tools.append({
46
+ "type": "function",
47
+ "function": t.to_json_schema()
48
+ })
49
+ return oai_tools
50
+
51
+ def _to_oai(self, messages: list[Message], system: str) -> list[dict]:
52
+ result = []
53
+ if system:
54
+ result.append({"role": "system", "content": system})
55
+ for m in messages:
56
+ if m.role == "system":
57
+ continue
58
+
59
+ msg = {"role": m.role, "content": m.content or ""}
60
+
61
+ if m.tool_calls:
62
+ msg["tool_calls"] = [
63
+ {
64
+ "id": tc.id,
65
+ "type": "function",
66
+ "function": {
67
+ "name": tc.name,
68
+ "arguments": json.dumps(tc.arguments)
69
+ }
70
+ } for tc in m.tool_calls
71
+ ]
72
+
73
+ if m.role == "tool":
74
+ msg["tool_call_id"] = m.tool_call_id
75
+
76
+ result.append(msg)
77
+ return result
78
+
79
+ async def complete(
80
+ self,
81
+ messages: list[Message],
82
+ model_id: str,
83
+ system: str = "",
84
+ tools: list[Any] | None = None,
85
+ temperature: float = 0.7,
86
+ max_tokens: int = DEFAULT_MAX_TOKENS,
87
+ ) -> str | Message:
88
+ oai_tools = self._convert_tools(tools)
89
+ kwargs = dict(
90
+ model=model_id,
91
+ messages=self._to_oai(messages, system),
92
+ temperature=temperature,
93
+ max_tokens=max_tokens,
94
+ )
95
+ if oai_tools:
96
+ kwargs["tools"] = oai_tools
97
+
98
+ try:
99
+ resp = await self._client.chat.completions.create(**kwargs)
100
+ except Exception as e:
101
+ if is_auth_failure(e, _auth_types()):
102
+ raise AuthExpiredError("openai", "codex login") from e
103
+ raise
104
+
105
+ if hasattr(resp, "usage") and resp.usage:
106
+ self._last_input_tokens = getattr(resp.usage, "prompt_tokens", 0)
107
+ self._last_output_tokens = getattr(resp.usage, "completion_tokens", 0)
108
+
109
+ choice = resp.choices[0]
110
+ oai_msg = choice.message
111
+ truncated = getattr(choice, "finish_reason", None) == "length"
112
+
113
+ if oai_msg.tool_calls:
114
+ tool_calls = [
115
+ ToolCall(
116
+ id=tc.id,
117
+ name=tc.function.name,
118
+ arguments=json.loads(tc.function.arguments)
119
+ ) for tc in oai_msg.tool_calls
120
+ ]
121
+ content = oai_msg.content or ""
122
+ if truncated:
123
+ content += truncation_notice(max_tokens)
124
+ return Message(
125
+ role="assistant",
126
+ content=content,
127
+ tool_calls=tool_calls
128
+ )
129
+
130
+ if truncated:
131
+ return (oai_msg.content or "") + truncation_notice(max_tokens)
132
+ return oai_msg.content
133
+
134
+ async def stream(
135
+ self,
136
+ messages: list[Message],
137
+ model_id: str,
138
+ system: str = "",
139
+ tools: list[Any] | None = None,
140
+ temperature: float = 0.7,
141
+ max_tokens: int = DEFAULT_STREAM_MAX_TOKENS,
142
+ ) -> AsyncIterator[str | ToolCall]:
143
+ # Simplified stream for text only for now
144
+ stream = await self._client.chat.completions.create(
145
+ model=model_id,
146
+ messages=self._to_oai(messages, system),
147
+ temperature=temperature,
148
+ max_tokens=max_tokens,
149
+ stream=True,
150
+ )
151
+ try:
152
+ async for chunk in stream:
153
+ if chunk.choices and chunk.choices[0].delta.content:
154
+ yield chunk.choices[0].delta.content
155
+ except Exception as e:
156
+ if is_auth_failure(e, _auth_types()):
157
+ raise AuthExpiredError("openai", "codex login") from e
158
+ raise
159
+
160
+ def count_tokens(self, text: str) -> int:
161
+ """tiktoken is OpenAI's own tokenizer, so it belongs here and only
162
+ here. cl100k_base covers the GPT-4 family; a model it does not know
163
+ falls back to the character estimate."""
164
+ try:
165
+ import tiktoken
166
+ return len(tiktoken.get_encoding("cl100k_base").encode(text))
167
+ except Exception:
168
+ return len(text) // 4
polymath/bootstrap.py ADDED
@@ -0,0 +1,159 @@
1
+ """
2
+ Dependency bootstrap — runs before any other import in main.py.
3
+
4
+ Checks the dependencies polymath needs and reports what is missing. It does
5
+ NOT install anything unless the user opts in with POLYMATH_AUTO_INSTALL=1,
6
+ because a published CLI must never mutate the environment it is run from:
7
+ `polymath --help` would otherwise pip-install into whatever interpreter is
8
+ active, which may be a shared or read-only one.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import os
14
+ import subprocess
15
+ import sys
16
+
17
+ #: Distribution name to suggest in the "run this to fix it" hint.
18
+ DIST_EXTRA = "polymath-agent[orchestrator]"
19
+
20
+
21
+ def auto_install_enabled() -> bool:
22
+ """Auto-install is opt-in, via POLYMATH_AUTO_INSTALL=1."""
23
+ return os.environ.get("POLYMATH_AUTO_INSTALL", "").strip().lower() in {
24
+ "1", "true", "yes", "on",
25
+ }
26
+
27
+
28
+ # (import_name, pip_package, min_version_check)
29
+ REQUIRED: list[tuple[str, str]] = [
30
+ ("anthropic", "anthropic>=0.40.0"),
31
+ ("openai", "openai>=1.50.0"),
32
+ ("rich", "rich>=13.0.0"),
33
+ ("prompt_toolkit", "prompt_toolkit>=3.0.0"),
34
+ ("tiktoken", "tiktoken>=0.7.0"),
35
+ ("httpx", "httpx>=0.27.0"),
36
+ ("sqlite_vec", "sqlite-vec>=0.1.6"),
37
+ ]
38
+
39
+ OPTIONAL: list[tuple[str, str, str]] = [
40
+ # (import_name, pip_package, human_label)
41
+ ("google.genai", "google-genai>=1.0.0", "Gemini"),
42
+ ]
43
+
44
+ # Old package → new package (auto-upgrade)
45
+ DEPRECATED: list[tuple[str, str, str]] = [
46
+ # (old_import, old_pip_pkg, new_pip_pkg)
47
+ ("google.generativeai", "google-generativeai", "google-genai>=1.0.0"),
48
+ ]
49
+
50
+
51
+ def _pip(*args: str) -> bool:
52
+ result = subprocess.run(
53
+ [sys.executable, "-m", "pip", "install", "-q", *args],
54
+ capture_output=True, text=True,
55
+ )
56
+ return result.returncode == 0
57
+
58
+
59
+ def _importable(module: str) -> bool:
60
+ import warnings
61
+ try:
62
+ with warnings.catch_warnings():
63
+ warnings.simplefilter("ignore")
64
+ importlib.import_module(module)
65
+ return True
66
+ except ImportError:
67
+ return False
68
+
69
+
70
+ def run() -> None:
71
+ """
72
+ Check deps and report. Only prints if something is missing.
73
+ Safe to call every startup — returns immediately if everything is fine.
74
+
75
+ With POLYMATH_AUTO_INSTALL=1 it also repairs what it finds, which is the
76
+ old self-healing behaviour and is meant for local development only.
77
+ """
78
+ auto = auto_install_enabled()
79
+ missing: list[str] = []
80
+ fixes: list[str] = []
81
+ failures: list[str] = []
82
+
83
+ # 1. Detect deprecated packages
84
+ for old_import, old_pkg, new_pkg in DEPRECATED:
85
+ if _importable(old_import):
86
+ if not auto:
87
+ missing.append(new_pkg)
88
+ continue
89
+ ok = _pip("--upgrade", new_pkg)
90
+ if ok:
91
+ fixes.append(f"upgraded {old_pkg} → {new_pkg.split('>=')[0]}")
92
+ else:
93
+ failures.append(f"could not upgrade {old_pkg} → {new_pkg}")
94
+
95
+ # 2. Check required packages
96
+ for module, package in REQUIRED:
97
+ if _importable(module):
98
+ continue
99
+ if not auto:
100
+ missing.append(package)
101
+ continue
102
+ ok = _pip(package)
103
+ if ok:
104
+ fixes.append(f"installed {package.split('>=')[0]}")
105
+ else:
106
+ failures.append(f"could not install {package}")
107
+
108
+ # 3. Check optional packages — only warn, don't force-install
109
+ missing_optional: list[tuple[str, str]] = []
110
+ for module, package, label in OPTIONAL:
111
+ if not _importable(module):
112
+ missing_optional.append((label, package))
113
+
114
+ # 4. Print summary (only if anything happened)
115
+ if not missing and not missing_optional and not fixes and not failures:
116
+ return
117
+
118
+ try:
119
+ from rich.console import Console
120
+ console = Console()
121
+
122
+ for fix in fixes:
123
+ console.print(f"[green]✓ {fix}[/green]")
124
+ for fail in failures:
125
+ console.print(f"[red]✗ {fail}[/red]")
126
+ if missing:
127
+ # escape() matters: DIST_EXTRA contains "[orchestrator]", which
128
+ # rich would otherwise parse as a style tag and drop.
129
+ from rich.markup import escape
130
+ console.print(
131
+ f"[yellow]missing: {escape(', '.join(sorted(missing)))}[/yellow]\n"
132
+ f"[dim]run: pip install \"{escape(DIST_EXTRA)}\"[/dim]"
133
+ )
134
+ if missing_optional:
135
+ names = ", ".join(label for label, _ in missing_optional)
136
+ pkgs = " ".join(pkg for _, pkg in missing_optional)
137
+ console.print(
138
+ f"[dim]optional: {names} not installed "
139
+ f"(run: pip install {pkgs})[/dim]"
140
+ )
141
+ if fixes:
142
+ console.print()
143
+ except Exception:
144
+ # rich itself might not be installed yet
145
+ for fix in fixes:
146
+ print(f"✓ {fix}")
147
+ for fail in failures:
148
+ print(f"✗ {fail}")
149
+ if missing:
150
+ print(f"missing: {', '.join(sorted(missing))}")
151
+ print(f'run: pip install "{DIST_EXTRA}"')
152
+
153
+
154
+ def check_models_available() -> tuple[bool, str]:
155
+ """
156
+ Returns (ok, warning_message).
157
+ Called after detect() — if zero models available, returns actionable message.
158
+ """
159
+ return True, ""
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from prompt_toolkit.completion import WordCompleter
4
+
5
+
6
+ BUILTIN_COMMANDS = [
7
+ "/help",
8
+ "/exit",
9
+ "/models",
10
+ "/sessions",
11
+ "/session",
12
+ "/new",
13
+ "/project",
14
+ "/project list",
15
+ "/project new",
16
+ "/project use",
17
+ "/project init",
18
+ "/context",
19
+ "/context show",
20
+ "/context add",
21
+ "/context edit",
22
+ "/commands",
23
+ "/subagents",
24
+ "/parallel",
25
+ "/accounts",
26
+ "/state",
27
+ "/queue",
28
+ "/memory",
29
+ "/memory show",
30
+ "/memory search",
31
+ "/memory rebuild",
32
+ "/memory confirm",
33
+ "/memory diff",
34
+ "/memory test",
35
+ ]
36
+
37
+
38
+ def build_repl_completer(custom_commands: list[str]) -> WordCompleter:
39
+ words = BUILTIN_COMMANDS + [f"/{name}" for name in sorted(custom_commands)]
40
+ words += ["@claude", "@gemini", "@openai", "@ollama", "@all", "--ask", "--cheap", "--fast", "--verify", "--simplify", "--ensemble", "--fanout"]
41
+ return WordCompleter(words, ignore_case=True, sentence=True)