andromity 0.1.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.
- andromity/__init__.py +2 -0
- andromity/assets/sounds/done.wav +0 -0
- andromity/cli.py +73 -0
- andromity/config.py +316 -0
- andromity/core/__init__.py +1 -0
- andromity/core/agent.py +211 -0
- andromity/core/audio.py +32 -0
- andromity/core/cron.py +324 -0
- andromity/core/debug_log.py +36 -0
- andromity/core/events.py +49 -0
- andromity/core/git_ops.py +186 -0
- andromity/core/mcp.py +636 -0
- andromity/core/models.py +428 -0
- andromity/core/oauth.py +454 -0
- andromity/core/planner.py +79 -0
- andromity/core/profiles.py +122 -0
- andromity/core/provider.py +188 -0
- andromity/core/search.py +345 -0
- andromity/core/security.py +64 -0
- andromity/core/session.py +169 -0
- andromity/core/todo.py +97 -0
- andromity/core/tools.py +1091 -0
- andromity/core/web.py +170 -0
- andromity/telemetry.py +102 -0
- andromity/tui/__init__.py +1 -0
- andromity/tui/app.py +1570 -0
- andromity/tui/footer.py +456 -0
- andromity/tui/markup_utils.py +26 -0
- andromity/tui/overlays/__init__.py +1 -0
- andromity/tui/overlays/batch_review.py +141 -0
- andromity/tui/overlays/cron.py +452 -0
- andromity/tui/overlays/model.py +305 -0
- andromity/tui/overlays/profile.py +109 -0
- andromity/tui/overlays/session.py +143 -0
- andromity/tui/overlays/settings.py +1061 -0
- andromity/tui/overlays/trust.py +60 -0
- andromity/tui/overlays/undo.py +77 -0
- andromity/tui/panels/__init__.py +1 -0
- andromity/tui/panels/chat.py +446 -0
- andromity/tui/panels/diff.py +613 -0
- andromity/tui/panels/filetree.py +306 -0
- andromity/tui/panels/plan.py +152 -0
- andromity-0.1.0.dist-info/METADATA +381 -0
- andromity-0.1.0.dist-info/RECORD +48 -0
- andromity-0.1.0.dist-info/WHEEL +5 -0
- andromity-0.1.0.dist-info/entry_points.txt +2 -0
- andromity-0.1.0.dist-info/licenses/LICENSE +21 -0
- andromity-0.1.0.dist-info/top_level.txt +1 -0
andromity/__init__.py
ADDED
|
Binary file
|
andromity/cli.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import click
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@click.group(invoke_without_command=True)
|
|
6
|
+
@click.pass_context
|
|
7
|
+
def main(ctx):
|
|
8
|
+
"""Andromity — The coding agent that never clocks out.
|
|
9
|
+
|
|
10
|
+
Run without arguments to launch the interactive TUI.
|
|
11
|
+
"""
|
|
12
|
+
if ctx.invoked_subcommand is None:
|
|
13
|
+
# No subcommand given — launch TUI directly
|
|
14
|
+
_launch_tui()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _launch_tui():
|
|
18
|
+
from andromity.telemetry import maybe_ping
|
|
19
|
+
maybe_ping()
|
|
20
|
+
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
console = Console()
|
|
23
|
+
with console.status("[bold cyan]✦ Starting Andromity...[/bold cyan]", spinner="dots12"):
|
|
24
|
+
from andromity.tui.app import AndromityApp
|
|
25
|
+
app = AndromityApp()
|
|
26
|
+
app.run()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@main.command()
|
|
30
|
+
@click.argument("prompt")
|
|
31
|
+
@click.option("--yes", is_flag=True, help="Auto-approve all actions")
|
|
32
|
+
@click.option("--dry-run", is_flag=True, help="Preview actions without executing")
|
|
33
|
+
@click.option("--profile", type=str, default="builder", help="Agent profile to use")
|
|
34
|
+
def run(prompt, yes, dry_run, profile):
|
|
35
|
+
"""Headless single-task mode."""
|
|
36
|
+
asyncio.run(_run_async(prompt, yes, dry_run, profile))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@main.command()
|
|
40
|
+
def tui():
|
|
41
|
+
"""Launch the interactive TUI (same as running `andromity` with no args)."""
|
|
42
|
+
_launch_tui()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _run_async(prompt, yes, dry_run, profile):
|
|
47
|
+
from andromity.config import config
|
|
48
|
+
from andromity.core.session import Session
|
|
49
|
+
from andromity.core.agent import Agent
|
|
50
|
+
from andromity.core.events import TextDelta, ToolCallStart, ToolCallEnd, Done
|
|
51
|
+
from andromity.core.models import get_context_limit_for_model, get_ollama_num_ctx
|
|
52
|
+
session = Session(name="headless-session")
|
|
53
|
+
provider = config.get("default", "provider", "")
|
|
54
|
+
model = config.get("default", "model", "")
|
|
55
|
+
if provider == "ollama":
|
|
56
|
+
ctx_limit = get_ollama_num_ctx(model)
|
|
57
|
+
else:
|
|
58
|
+
ctx_limit = get_context_limit_for_model(provider, model) if (provider and model) else 0
|
|
59
|
+
agent = Agent(session, profile=profile, dry_run=dry_run, auto_approve=yes, ctx_limit=ctx_limit)
|
|
60
|
+
print(f"\nUser: {prompt}\n")
|
|
61
|
+
print("Andromity:", end=" ", flush=True)
|
|
62
|
+
async for event in agent.run(prompt):
|
|
63
|
+
if isinstance(event, TextDelta):
|
|
64
|
+
print(event.text, end="", flush=True)
|
|
65
|
+
elif isinstance(event, ToolCallStart):
|
|
66
|
+
print(f"\n[Tool: {event.tool_name}]", end=" ", flush=True)
|
|
67
|
+
elif isinstance(event, ToolCallEnd):
|
|
68
|
+
print("[Done]", end=" ", flush=True)
|
|
69
|
+
print(f"\n\nTokens: {session.token_total} | Cost: ${session.cost_usd:.4f}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
main()
|
andromity/config.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
if sys.version_info >= (3, 11):
|
|
10
|
+
import tomllib
|
|
11
|
+
else:
|
|
12
|
+
import tomli as tomllib
|
|
13
|
+
|
|
14
|
+
import tomli_w
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_config_dir() -> Path:
|
|
18
|
+
if platform.system() == "Windows":
|
|
19
|
+
return Path(os.environ.get("APPDATA", "~")).expanduser() / "andromity"
|
|
20
|
+
return Path.home() / ".andromity"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_shell() -> str:
|
|
24
|
+
if platform.system() == "Windows":
|
|
25
|
+
return "powershell"
|
|
26
|
+
return os.environ.get("SHELL", "/bin/bash")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfigManager:
|
|
30
|
+
def __init__(self, config_dir: Optional[Path] = None):
|
|
31
|
+
self.config_dir = config_dir or get_config_dir()
|
|
32
|
+
self.config_path = self.config_dir / "config.toml"
|
|
33
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
self._config_cache: Dict[str, Any] = {}
|
|
35
|
+
self._load()
|
|
36
|
+
|
|
37
|
+
def _load(self):
|
|
38
|
+
if not self.config_path.exists():
|
|
39
|
+
self._create_default_config()
|
|
40
|
+
with open(self.config_path, "rb") as f:
|
|
41
|
+
try:
|
|
42
|
+
self._config_cache = tomllib.load(f)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"Warning: Failed to parse {self.config_path}: {e}")
|
|
45
|
+
self._config_cache = {}
|
|
46
|
+
|
|
47
|
+
def _create_default_config(self):
|
|
48
|
+
default_config = {
|
|
49
|
+
"default": {
|
|
50
|
+
"provider": "anthropic",
|
|
51
|
+
"model": "claude-sonnet-4-6",
|
|
52
|
+
"profile": "builder",
|
|
53
|
+
"permission_mode": "safe",
|
|
54
|
+
"allowed_commands": ["npm run", "npm test", "npm list", "npm run dev", "pytest", "python -m pytest", "git status", "git diff", "git log", "ls", "dir", "cat", "echo"]
|
|
55
|
+
},
|
|
56
|
+
"providers": [
|
|
57
|
+
{"name": "anthropic", "type": "anthropic"},
|
|
58
|
+
{"name": "ollama", "type": "ollama", "base_url": "http://localhost:11434"},
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
self.save(default_config)
|
|
62
|
+
|
|
63
|
+
def save(self, config_data: Optional[Dict[str, Any]] = None):
|
|
64
|
+
data_to_save = config_data if config_data is not None else self._config_cache
|
|
65
|
+
with open(self.config_path, "wb") as f:
|
|
66
|
+
tomli_w.dump(data_to_save, f)
|
|
67
|
+
if config_data is not None:
|
|
68
|
+
self._config_cache = config_data
|
|
69
|
+
|
|
70
|
+
def get(self, section: str, key: str, default: Any = None) -> Any:
|
|
71
|
+
return self._config_cache.get(section, {}).get(key, default)
|
|
72
|
+
|
|
73
|
+
def set(self, section: str, key: str, value: Any):
|
|
74
|
+
if section not in self._config_cache:
|
|
75
|
+
self._config_cache[section] = {}
|
|
76
|
+
self._config_cache[section][key] = value
|
|
77
|
+
self.save()
|
|
78
|
+
|
|
79
|
+
def get_provider_config(self, provider_name: str) -> Optional[Dict[str, Any]]:
|
|
80
|
+
providers = self._config_cache.get("providers", [])
|
|
81
|
+
for p in providers:
|
|
82
|
+
if p.get("name") == provider_name:
|
|
83
|
+
return p
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def get_api_key(self, provider_name: str) -> Optional[str]:
|
|
87
|
+
provider_cfg = self.get_provider_config(provider_name)
|
|
88
|
+
if provider_cfg:
|
|
89
|
+
key = provider_cfg.get("api_key")
|
|
90
|
+
if key:
|
|
91
|
+
if key.startswith("$"):
|
|
92
|
+
return os.environ.get(key[1:])
|
|
93
|
+
return key
|
|
94
|
+
|
|
95
|
+
# Fallback to standard env vars
|
|
96
|
+
env_map = {
|
|
97
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
98
|
+
"openai": "OPENAI_API_KEY",
|
|
99
|
+
"google": "GEMINI_API_KEY",
|
|
100
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
101
|
+
"groq": "GROQ_API_KEY",
|
|
102
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
103
|
+
"nvidia": "NVIDIA_API_KEY",
|
|
104
|
+
}
|
|
105
|
+
if provider_name == "google":
|
|
106
|
+
return os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
|
107
|
+
if provider_name == "nvidia":
|
|
108
|
+
return os.environ.get("NVIDIA_API_KEY") or os.environ.get("NVIDIA_NIM_API_KEY")
|
|
109
|
+
env_var = env_map.get(provider_name)
|
|
110
|
+
if env_var:
|
|
111
|
+
return os.environ.get(env_var)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def set_api_key(self, provider_name: str, api_key: str):
|
|
115
|
+
"""Set or update the API key for a given provider in config.toml and environment."""
|
|
116
|
+
providers = self._config_cache.get("providers", [])
|
|
117
|
+
found = False
|
|
118
|
+
for p in providers:
|
|
119
|
+
if p.get("name") == provider_name:
|
|
120
|
+
p["api_key"] = api_key
|
|
121
|
+
found = True
|
|
122
|
+
break
|
|
123
|
+
if not found:
|
|
124
|
+
providers.append({"name": provider_name, "type": provider_name, "api_key": api_key})
|
|
125
|
+
self._config_cache["providers"] = providers
|
|
126
|
+
self.save()
|
|
127
|
+
|
|
128
|
+
# Also set in os.environ for immediate use by litellm
|
|
129
|
+
env_map = {
|
|
130
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
131
|
+
"openai": "OPENAI_API_KEY",
|
|
132
|
+
"google": "GEMINI_API_KEY",
|
|
133
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
134
|
+
"groq": "GROQ_API_KEY",
|
|
135
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
136
|
+
}
|
|
137
|
+
env_var = env_map.get(provider_name)
|
|
138
|
+
if env_var:
|
|
139
|
+
os.environ[env_var] = api_key
|
|
140
|
+
|
|
141
|
+
def list_providers(self) -> list:
|
|
142
|
+
return self._config_cache.get("providers", [])
|
|
143
|
+
|
|
144
|
+
# ─── User Management ─────────────────────────────────────────────────
|
|
145
|
+
def get_user(self) -> Dict[str, str]:
|
|
146
|
+
return self._config_cache.get("user", {})
|
|
147
|
+
|
|
148
|
+
def set_user(self, name: str, email: str):
|
|
149
|
+
if "user" not in self._config_cache:
|
|
150
|
+
self._config_cache["user"] = {}
|
|
151
|
+
if name is not None:
|
|
152
|
+
self._config_cache["user"]["name"] = name
|
|
153
|
+
if email is not None:
|
|
154
|
+
self._config_cache["user"]["email"] = email
|
|
155
|
+
self.save()
|
|
156
|
+
|
|
157
|
+
# ─── MCP Config ──────────────────────────────────────────────────────
|
|
158
|
+
def get_mcp_config_path(self, project_path: str = "") -> Path:
|
|
159
|
+
"""Find the most specific mcp.json file available."""
|
|
160
|
+
candidates = []
|
|
161
|
+
if project_path:
|
|
162
|
+
candidates.extend([
|
|
163
|
+
Path(project_path) / ".andromity" / "mcp.json",
|
|
164
|
+
Path(project_path) / ".vscode" / "mcp.json",
|
|
165
|
+
])
|
|
166
|
+
candidates.append(Path.home() / ".andromity" / "mcp.json")
|
|
167
|
+
for p in candidates:
|
|
168
|
+
if p.is_file():
|
|
169
|
+
return p
|
|
170
|
+
# Default to home if none exist
|
|
171
|
+
default_path = Path.home() / ".andromity" / "mcp.json"
|
|
172
|
+
default_path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
if not default_path.exists():
|
|
174
|
+
import json
|
|
175
|
+
default_path.write_text(json.dumps({"mcpServers": {}}, indent=2), encoding="utf-8")
|
|
176
|
+
return default_path
|
|
177
|
+
|
|
178
|
+
def set_mcp_server_disabled(self, project_path: str, server_name: str, disabled: bool):
|
|
179
|
+
"""Toggle the 'disabled' flag for a specific MCP server in the config."""
|
|
180
|
+
path = self.get_mcp_config_path(project_path)
|
|
181
|
+
if not path.exists():
|
|
182
|
+
return
|
|
183
|
+
try:
|
|
184
|
+
import json
|
|
185
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
186
|
+
servers = data.get("mcpServers") or data.get("servers") or {}
|
|
187
|
+
if server_name in servers and isinstance(servers[server_name], dict):
|
|
188
|
+
servers[server_name]["disabled"] = disabled
|
|
189
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
def _find_mcp_file_for_server(self, project_path: str, server_name: str):
|
|
194
|
+
"""
|
|
195
|
+
Return (path, data, key) for the mcp.json file that contains server_name,
|
|
196
|
+
searching project then global Gemini config.
|
|
197
|
+
Returns (None, None, None) if not found.
|
|
198
|
+
"""
|
|
199
|
+
import json
|
|
200
|
+
|
|
201
|
+
# Candidate files in priority order
|
|
202
|
+
candidates = []
|
|
203
|
+
if project_path:
|
|
204
|
+
candidates.extend([
|
|
205
|
+
Path(project_path) / ".andromity" / "mcp.json",
|
|
206
|
+
Path(project_path) / ".vscode" / "mcp.json",
|
|
207
|
+
])
|
|
208
|
+
candidates.extend([
|
|
209
|
+
Path.home() / ".andromity" / "mcp.json",
|
|
210
|
+
Path.home() / ".gemini" / "config" / "mcp_config.json",
|
|
211
|
+
])
|
|
212
|
+
|
|
213
|
+
for p in candidates:
|
|
214
|
+
if not p.is_file():
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
218
|
+
except Exception:
|
|
219
|
+
continue
|
|
220
|
+
for key in ("mcpServers", "servers"):
|
|
221
|
+
if server_name in data.get(key, {}):
|
|
222
|
+
return p, data, key
|
|
223
|
+
return None, None, None
|
|
224
|
+
|
|
225
|
+
def set_mcp_server_env(self, project_path: str, server_name: str, key: str, value: str):
|
|
226
|
+
"""Set a single env-var key for an MCP server in the config file."""
|
|
227
|
+
path, data, srv_key = self._find_mcp_file_for_server(project_path, server_name)
|
|
228
|
+
if path is None:
|
|
229
|
+
return
|
|
230
|
+
try:
|
|
231
|
+
import json
|
|
232
|
+
servers = data.get(srv_key, {})
|
|
233
|
+
if "env" not in servers[server_name]:
|
|
234
|
+
servers[server_name]["env"] = {}
|
|
235
|
+
servers[server_name]["env"][key] = value
|
|
236
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
def remove_mcp_server(self, project_path: str, server_name: str) -> bool:
|
|
241
|
+
"""Remove an MCP server entry from the config file. Returns True on success."""
|
|
242
|
+
path, data, srv_key = self._find_mcp_file_for_server(project_path, server_name)
|
|
243
|
+
if path is None:
|
|
244
|
+
return False
|
|
245
|
+
try:
|
|
246
|
+
import json
|
|
247
|
+
del data[srv_key][server_name]
|
|
248
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
249
|
+
return True
|
|
250
|
+
except Exception:
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
def convert_remote_to_mcp_remote(
|
|
254
|
+
self, project_path: str, server_name: str, token: str
|
|
255
|
+
) -> bool:
|
|
256
|
+
"""
|
|
257
|
+
Convert a remote HTTP server (serverUrl only) to use npx mcp-remote
|
|
258
|
+
with an Authorization: Bearer header, so it can be launched as stdio.
|
|
259
|
+
Searches project and global Gemini config. Returns True on success.
|
|
260
|
+
"""
|
|
261
|
+
path, data, srv_key = self._find_mcp_file_for_server(project_path, server_name)
|
|
262
|
+
if path is None:
|
|
263
|
+
return False
|
|
264
|
+
try:
|
|
265
|
+
import json
|
|
266
|
+
conf = data[srv_key][server_name]
|
|
267
|
+
remote_url = conf.get("serverUrl") or conf.get("url") or ""
|
|
268
|
+
if not remote_url:
|
|
269
|
+
return False
|
|
270
|
+
# Preserve safe metadata fields only
|
|
271
|
+
meta = {k: v for k, v in conf.items()
|
|
272
|
+
if k not in ("command", "args", "env", "serverUrl", "url",
|
|
273
|
+
"type", "$typeName")}
|
|
274
|
+
data[srv_key][server_name] = {
|
|
275
|
+
"command": "npx",
|
|
276
|
+
"args": [
|
|
277
|
+
"-y", "mcp-remote", remote_url,
|
|
278
|
+
"--header", f"Authorization:Bearer {token}",
|
|
279
|
+
],
|
|
280
|
+
"env": {},
|
|
281
|
+
**meta,
|
|
282
|
+
}
|
|
283
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
284
|
+
return True
|
|
285
|
+
except Exception:
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
# ─── Trust Management ────────────────────────────────────────────────
|
|
289
|
+
def _trust_key(self, path: str) -> str:
|
|
290
|
+
resolved = str(Path(path).resolve())
|
|
291
|
+
return "p" + hashlib.sha256(resolved.encode()).hexdigest()[:15]
|
|
292
|
+
|
|
293
|
+
def is_trusted(self, path: str) -> bool:
|
|
294
|
+
key = self._trust_key(path)
|
|
295
|
+
return key in self._config_cache.get("trusted_projects", {})
|
|
296
|
+
|
|
297
|
+
def set_trusted(self, path: str):
|
|
298
|
+
resolved = str(Path(path).resolve())
|
|
299
|
+
key = self._trust_key(path)
|
|
300
|
+
if "trusted_projects" not in self._config_cache:
|
|
301
|
+
self._config_cache["trusted_projects"] = {}
|
|
302
|
+
self._config_cache["trusted_projects"][key] = {
|
|
303
|
+
"path": resolved,
|
|
304
|
+
"trusted_at": datetime.now(timezone.utc).isoformat(),
|
|
305
|
+
}
|
|
306
|
+
self.save()
|
|
307
|
+
|
|
308
|
+
def revoke_trust(self, path: str):
|
|
309
|
+
key = self._trust_key(path)
|
|
310
|
+
trusted = self._config_cache.get("trusted_projects", {})
|
|
311
|
+
if key in trusted:
|
|
312
|
+
del trusted[key]
|
|
313
|
+
self.save()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
config = ConfigManager()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core modules."""
|
andromity/core/agent.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import AsyncGenerator, Dict, Any, Optional, Callable
|
|
3
|
+
|
|
4
|
+
from andromity.core.provider import stream_completion
|
|
5
|
+
from andromity.core.session import Session
|
|
6
|
+
from andromity.core.profiles import get_system_prompt, filter_tools_for_profile
|
|
7
|
+
from andromity.core.tools import CORE_TOOLS, ToolRegistry, execute_tool, register_session
|
|
8
|
+
from andromity.core.events import (
|
|
9
|
+
StreamEvent, TextDelta, ToolCallStart, ToolCallDelta, ToolCallEnd, Done, ToolResult, PlanApprovalRequired
|
|
10
|
+
)
|
|
11
|
+
from andromity.config import config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Agent:
|
|
15
|
+
def __init__(self, session: Session, profile: str = None, dry_run: bool = False,
|
|
16
|
+
auto_approve: bool = False, on_tool_approval: Optional[Callable] = None,
|
|
17
|
+
ctx_limit: int = 0):
|
|
18
|
+
self.session = session
|
|
19
|
+
self.profile = profile or config.get("default", "profile", "builder")
|
|
20
|
+
self.dry_run = dry_run
|
|
21
|
+
self.auto_approve = auto_approve
|
|
22
|
+
self.on_tool_approval = on_tool_approval
|
|
23
|
+
self.ctx_limit = ctx_limit
|
|
24
|
+
self.allowed_tools = filter_tools_for_profile(CORE_TOOLS, self.profile)
|
|
25
|
+
self._empty_retried = False
|
|
26
|
+
# Register session so plan tools can store plan in it
|
|
27
|
+
register_session(session)
|
|
28
|
+
sys_prompt = get_system_prompt(self.profile)
|
|
29
|
+
deferred_catalog = ToolRegistry.get_instance().get_deferred_prompt_catalog()
|
|
30
|
+
if deferred_catalog:
|
|
31
|
+
sys_prompt += "\n\n" + deferred_catalog
|
|
32
|
+
|
|
33
|
+
if not self.session.messages:
|
|
34
|
+
self.session.add_message("system", sys_prompt)
|
|
35
|
+
elif self.session.messages[0]["role"] == "system":
|
|
36
|
+
self.session.messages[0]["content"] = sys_prompt
|
|
37
|
+
self.session.save()
|
|
38
|
+
|
|
39
|
+
async def _compact_context(self) -> AsyncGenerator[StreamEvent, None]:
|
|
40
|
+
limit = self.ctx_limit
|
|
41
|
+
if limit <= 0:
|
|
42
|
+
from andromity.core.models import get_context_limit_for_model
|
|
43
|
+
provider = config.get("default", "provider", "")
|
|
44
|
+
model = config.get("default", "model", "")
|
|
45
|
+
limit = get_context_limit_for_model(provider, model)
|
|
46
|
+
if limit <= 0:
|
|
47
|
+
limit = 131072 # 128K default for unknown models
|
|
48
|
+
|
|
49
|
+
# Use real API-reported token count as primary source.
|
|
50
|
+
# Fall back to a crude character estimate only if we haven't had any API reply yet.
|
|
51
|
+
if self.session.token_total > 0:
|
|
52
|
+
current_tokens = self.session.token_total
|
|
53
|
+
else:
|
|
54
|
+
current_tokens = sum(len(str(m.get("content", ""))) // 4 for m in self.session.messages)
|
|
55
|
+
threshold = int(limit * 0.8)
|
|
56
|
+
|
|
57
|
+
# Also compact if history has grown beyond 30 user/assistant turns
|
|
58
|
+
# regardless of token count — keeps the window fresh for long sessions.
|
|
59
|
+
non_system = [m for m in self.session.messages if m.get("role") != "system"]
|
|
60
|
+
turn_limit_hit = len(non_system) > 60 # 30 user + 30 assistant = 60 messages
|
|
61
|
+
|
|
62
|
+
if (current_tokens > threshold or turn_limit_hit) and len(self.session.messages) > 6:
|
|
63
|
+
reason = "turn limit (30 turns)" if turn_limit_hit else "token limit"
|
|
64
|
+
yield TextDelta(text=f"\n*Context window near {reason}. Compacting memory...*\n\n")
|
|
65
|
+
|
|
66
|
+
keep_last_n = 10
|
|
67
|
+
msgs_to_summarize = self.session.messages[1:-keep_last_n]
|
|
68
|
+
|
|
69
|
+
summary_prompt = (
|
|
70
|
+
"Summarize the following conversation history. Focus on decisions made, "
|
|
71
|
+
"files created, the overarching goal and important details. Combine this with the previous "
|
|
72
|
+
"summary if one exists:\n\n"
|
|
73
|
+
)
|
|
74
|
+
for m in msgs_to_summarize:
|
|
75
|
+
role = m.get("role", "unknown")
|
|
76
|
+
content = str(m.get("content", ""))
|
|
77
|
+
if len(content) > 500:
|
|
78
|
+
content = content[:500] + " ...[truncated]"
|
|
79
|
+
summary_prompt += f"{role.upper()}: {content}\n\n"
|
|
80
|
+
|
|
81
|
+
summary_msgs = [{"role": "user", "content": summary_prompt}]
|
|
82
|
+
|
|
83
|
+
new_summary = ""
|
|
84
|
+
async for event in stream_completion(summary_msgs, tools=[]):
|
|
85
|
+
if isinstance(event, TextDelta):
|
|
86
|
+
new_summary += event.text
|
|
87
|
+
|
|
88
|
+
removed = self.session.compact_messages(new_summary, keep_last_n=keep_last_n)
|
|
89
|
+
yield TextDelta(text=f"*Compacted {removed} messages into memory.*\n\n")
|
|
90
|
+
|
|
91
|
+
async def run(self, user_input: str) -> AsyncGenerator[StreamEvent, None]:
|
|
92
|
+
self.session.add_message("user", content=user_input)
|
|
93
|
+
|
|
94
|
+
async for event in self._compact_context():
|
|
95
|
+
yield event
|
|
96
|
+
|
|
97
|
+
while True:
|
|
98
|
+
current_tool_id = None
|
|
99
|
+
current_tool_name = None
|
|
100
|
+
current_tool_args_str = ""
|
|
101
|
+
tool_calls_to_execute = []
|
|
102
|
+
assistant_content = ""
|
|
103
|
+
last_usage = None
|
|
104
|
+
|
|
105
|
+
async for event in stream_completion(self.session.messages, tools=self.allowed_tools):
|
|
106
|
+
yield event
|
|
107
|
+
if isinstance(event, TextDelta):
|
|
108
|
+
assistant_content += event.text
|
|
109
|
+
elif isinstance(event, ToolCallStart):
|
|
110
|
+
current_tool_id = event.tool_id
|
|
111
|
+
current_tool_name = event.tool_name
|
|
112
|
+
current_tool_args_str = ""
|
|
113
|
+
elif isinstance(event, ToolCallDelta):
|
|
114
|
+
if event.tool_id == current_tool_id:
|
|
115
|
+
current_tool_args_str += event.args_json_chunk
|
|
116
|
+
elif isinstance(event, ToolCallEnd):
|
|
117
|
+
if current_tool_id == event.tool_id:
|
|
118
|
+
tool_calls_to_execute.append({
|
|
119
|
+
"id": current_tool_id, "type": "function",
|
|
120
|
+
"function": {"name": current_tool_name, "arguments": current_tool_args_str},
|
|
121
|
+
})
|
|
122
|
+
current_tool_id = None
|
|
123
|
+
elif isinstance(event, Done):
|
|
124
|
+
last_usage = event.usage
|
|
125
|
+
|
|
126
|
+
if last_usage:
|
|
127
|
+
from andromity.config import config
|
|
128
|
+
m = f"{config.get('default', 'provider', 'ollama')}/{config.get('default', 'model', '')}"
|
|
129
|
+
self.session.update_usage(last_usage, model=m)
|
|
130
|
+
else:
|
|
131
|
+
prompt_tokens = sum(len(str(m.get("content", ""))) // 4 for m in self.session.messages)
|
|
132
|
+
completion_tokens = len(assistant_content) // 4
|
|
133
|
+
self.session.update_usage({
|
|
134
|
+
"prompt_tokens": prompt_tokens,
|
|
135
|
+
"completion_tokens": completion_tokens,
|
|
136
|
+
"total_tokens": prompt_tokens + completion_tokens
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
self.session.add_message(
|
|
140
|
+
"assistant",
|
|
141
|
+
content=assistant_content if assistant_content else None,
|
|
142
|
+
tool_calls=tool_calls_to_execute if tool_calls_to_execute else None,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if not assistant_content and not tool_calls_to_execute:
|
|
146
|
+
# Model returned nothing — retry once before giving up
|
|
147
|
+
if not getattr(self, '_empty_retried', False):
|
|
148
|
+
self._empty_retried = True
|
|
149
|
+
yield TextDelta(text="\n*Model returned empty, retrying...*\n\n")
|
|
150
|
+
continue
|
|
151
|
+
self._empty_retried = False
|
|
152
|
+
from andromity.core.models import get_context_limit_for_model
|
|
153
|
+
provider = config.get("default", "provider", "")
|
|
154
|
+
model = config.get("default", "model", "")
|
|
155
|
+
limit = self.ctx_limit or get_context_limit_for_model(provider, model)
|
|
156
|
+
current_tokens = sum(len(str(m.get("content", ""))) // 4 for m in self.session.messages)
|
|
157
|
+
if limit > 0 and current_tokens > limit * 0.9:
|
|
158
|
+
warning = (
|
|
159
|
+
f"\n**[No response from model]** Context full ({current_tokens:,}/{limit:,} tokens). "
|
|
160
|
+
"Try `/new` for a fresh session, or switch model with **Ctrl+L**.\n"
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
warning = (
|
|
164
|
+
"\n**[No response from model]** The model returned an empty response. "
|
|
165
|
+
"Try rephrasing your message or switch model with **Ctrl+L**.\n"
|
|
166
|
+
)
|
|
167
|
+
yield TextDelta(text=warning)
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
self._empty_retried = False
|
|
171
|
+
|
|
172
|
+
if not tool_calls_to_execute:
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
for tool_call in tool_calls_to_execute:
|
|
176
|
+
tool_name = tool_call["function"]["name"]
|
|
177
|
+
try:
|
|
178
|
+
args = json.loads(tool_call["function"]["arguments"])
|
|
179
|
+
except json.JSONDecodeError:
|
|
180
|
+
args = {}
|
|
181
|
+
|
|
182
|
+
if not self.auto_approve and self.on_tool_approval:
|
|
183
|
+
import asyncio
|
|
184
|
+
is_approved = await self.on_tool_approval(tool_name, args) if asyncio.iscoroutinefunction(self.on_tool_approval) else self.on_tool_approval(tool_name, args)
|
|
185
|
+
if not is_approved:
|
|
186
|
+
result = (
|
|
187
|
+
f"TOOL REJECTED BY USER: '{tool_name}' was explicitly declined.\n"
|
|
188
|
+
f"Do NOT retry this tool call.\n"
|
|
189
|
+
f"Acknowledge the rejection, then ask the user how they would like to proceed."
|
|
190
|
+
)
|
|
191
|
+
self.session.add_message("tool", content=result, name=tool_name, tool_call_id=tool_call["id"])
|
|
192
|
+
yield ToolResult(tool_id=tool_call["id"], result="[Rejected by User]")
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
if self.dry_run:
|
|
196
|
+
result = f"[DRY RUN] Would execute {tool_name}({json.dumps(args, indent=2)})"
|
|
197
|
+
else:
|
|
198
|
+
try:
|
|
199
|
+
from andromity.core.tools import execute_tool_async
|
|
200
|
+
result = await execute_tool_async(tool_name, args)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
result = f"Error executing {tool_name}: {e}"
|
|
203
|
+
|
|
204
|
+
self.session.add_message("tool", content=str(result), name=tool_name, tool_call_id=tool_call["id"])
|
|
205
|
+
yield ToolResult(tool_id=tool_call["id"], result=str(result))
|
|
206
|
+
|
|
207
|
+
if tool_name == "write_plan":
|
|
208
|
+
plan = self.session.load_plan_obj()
|
|
209
|
+
if plan and plan.status == "pending":
|
|
210
|
+
yield PlanApprovalRequired(plan=plan)
|
|
211
|
+
|
andromity/core/audio.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
def play_sound(sound_name: str = "done.wav"):
|
|
8
|
+
"""
|
|
9
|
+
Play a sound file cross-platform without blocking.
|
|
10
|
+
Assumes sound file is in the project root.
|
|
11
|
+
"""
|
|
12
|
+
# Find sound file inside the package assets folder
|
|
13
|
+
sound_path = Path(__file__).resolve().parent.parent / "assets" / "sounds" / sound_name
|
|
14
|
+
|
|
15
|
+
if not sound_path.exists():
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
path_str = str(sound_path)
|
|
19
|
+
|
|
20
|
+
def _play():
|
|
21
|
+
try:
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
import winsound
|
|
24
|
+
winsound.PlaySound(path_str, winsound.SND_FILENAME)
|
|
25
|
+
elif sys.platform == "darwin":
|
|
26
|
+
subprocess.run(["afplay", path_str], check=False)
|
|
27
|
+
else:
|
|
28
|
+
subprocess.run(["aplay", "-q", path_str], check=False, stderr=subprocess.DEVNULL)
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
threading.Thread(target=_play, daemon=True).start()
|