ata-coder 2.4.2__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.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- ata_coder-2.4.2.dist-info/top_level.txt +1 -0
ata_coder/server.py
ADDED
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ATA Coder — HTTP API Server (stdlib-only, no FastAPI dependency).
|
|
3
|
+
|
|
4
|
+
Uses Python's built-in http.server + httpx for maximum compatibility.
|
|
5
|
+
Provides REST API and SSE streaming for the agent.
|
|
6
|
+
|
|
7
|
+
Endpoints:
|
|
8
|
+
POST /chat — Non-streaming chat
|
|
9
|
+
POST /chat/stream — SSE streaming chat
|
|
10
|
+
GET /health — Health check
|
|
11
|
+
GET /sessions — List active sessions
|
|
12
|
+
GET /sessions/{id} — Get session info
|
|
13
|
+
DELETE /sessions/{id} — Delete a session
|
|
14
|
+
GET /tools — List available tools
|
|
15
|
+
GET /skills — List available skills
|
|
16
|
+
GET /models — List available models
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python server.py # Start on port 8000
|
|
20
|
+
python server.py --port 3000 # Custom port
|
|
21
|
+
python server.py --host 0.0.0.0 # Public access
|
|
22
|
+
python main.py --server # From main launcher
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import queue
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
from urllib.parse import urlparse
|
|
36
|
+
|
|
37
|
+
# Allow running directly (python server.py) without pip install -e .
|
|
38
|
+
_pkg = str(Path(__file__).parent.resolve())
|
|
39
|
+
if _pkg not in sys.path:
|
|
40
|
+
sys.path.insert(0, _pkg)
|
|
41
|
+
|
|
42
|
+
from .config import AppConfig, get_config
|
|
43
|
+
from .tools import TOOL_DEFINITIONS
|
|
44
|
+
from .server_session import SessionStore
|
|
45
|
+
from .server_shell import shell_open, shell_ensure, shell_close, shell_close_all, get_shell_sessions
|
|
46
|
+
from .skills import get_skill_manager
|
|
47
|
+
from .utils import brief_args
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Session store → server_session.py (SessionStore)
|
|
53
|
+
|
|
54
|
+
# ══════════════════════════════════════════════════════════════════════# HTTP Request Handler
|
|
55
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
56
|
+
|
|
57
|
+
class AgentAPIHandler(BaseHTTPRequestHandler):
|
|
58
|
+
"""HTTP handler for the ATA Coder API.
|
|
59
|
+
|
|
60
|
+
*config* and *store* are set as class attributes by :func:`create_server`
|
|
61
|
+
before the server starts accepting requests. Each handler instance
|
|
62
|
+
accesses them via ``self`` (Python's normal attribute resolution finds
|
|
63
|
+
them on the class). This is the standard ``http.server`` pattern and
|
|
64
|
+
is safe because ``HTTPServer`` handles requests sequentially — if you
|
|
65
|
+
switch to ``ThreadingHTTPServer``, promote these to instance attributes
|
|
66
|
+
in ``__init__``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
# Class-level references (set by server factory)
|
|
70
|
+
config: AppConfig = None
|
|
71
|
+
store: SessionStore = None
|
|
72
|
+
_ws_lock: threading.Lock = threading.Lock() # protects workspace dir reads/writes
|
|
73
|
+
|
|
74
|
+
def __init__(self, *args, **kwargs):
|
|
75
|
+
# Per-instance copies for thread-safe access under ThreadingHTTPServer
|
|
76
|
+
self.config = self.__class__.config
|
|
77
|
+
self.store = self.__class__.store
|
|
78
|
+
super().__init__(*args, **kwargs)
|
|
79
|
+
|
|
80
|
+
def log_message(self, format, *args):
|
|
81
|
+
"""Suppress default logging; use our logger."""
|
|
82
|
+
logger.debug("%s - %s", self.client_address[0], format % args)
|
|
83
|
+
|
|
84
|
+
# ── Auth ────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def _check_auth(self) -> bool:
|
|
87
|
+
"""Verify API token if ATA_CODER_API_TOKEN is configured.
|
|
88
|
+
|
|
89
|
+
When no token is configured, authentication is disabled and all
|
|
90
|
+
requests are allowed (convenience for local/dev use). Set
|
|
91
|
+
ATA_CODER_API_TOKEN to require Bearer token authentication.
|
|
92
|
+
"""
|
|
93
|
+
expected = os.environ.get("ATA_CODER_API_TOKEN", "")
|
|
94
|
+
if not expected:
|
|
95
|
+
return True # no token configured = allow all
|
|
96
|
+
token = (self.headers.get("Authorization", "")
|
|
97
|
+
.removeprefix("Bearer ").strip())
|
|
98
|
+
if token != expected:
|
|
99
|
+
logger.warning("Invalid or missing API token from %s", self.client_address[0])
|
|
100
|
+
return False
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
def _require_auth(self, method_name: str) -> bool:
|
|
104
|
+
"""Check auth and send 403 if invalid. Returns True if ok."""
|
|
105
|
+
if self._check_auth():
|
|
106
|
+
return True
|
|
107
|
+
self._error(403, "Invalid or missing API token. "
|
|
108
|
+
"Set ATA_CODER_API_TOKEN env var on the server, "
|
|
109
|
+
"then send Authorization: Bearer <token> header.")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
# ── CORS ────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def _cors(self):
|
|
115
|
+
"""Set CORS headers, reflecting localhost origins.
|
|
116
|
+
|
|
117
|
+
Allows any localhost origin (multiple ports) for development,
|
|
118
|
+
plus the Origin header itself if it is a localhost address.
|
|
119
|
+
"""
|
|
120
|
+
origin = self.headers.get("Origin", "")
|
|
121
|
+
if origin and (
|
|
122
|
+
origin.startswith("http://localhost")
|
|
123
|
+
or origin.startswith("http://127.0.0.1")
|
|
124
|
+
or origin.startswith("http://[::1]")
|
|
125
|
+
):
|
|
126
|
+
self.send_header("Access-Control-Allow-Origin", origin)
|
|
127
|
+
else:
|
|
128
|
+
self.send_header("Access-Control-Allow-Origin", "http://localhost:3000")
|
|
129
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
|
130
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
131
|
+
|
|
132
|
+
def do_OPTIONS(self):
|
|
133
|
+
self.send_response(204)
|
|
134
|
+
self._cors()
|
|
135
|
+
self.end_headers()
|
|
136
|
+
|
|
137
|
+
# ── JSON helpers ────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def _json_response(self, data: Any, status: int = 200):
|
|
140
|
+
self.send_response(status)
|
|
141
|
+
self._cors()
|
|
142
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
143
|
+
self.end_headers()
|
|
144
|
+
try:
|
|
145
|
+
self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
|
|
146
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
|
|
147
|
+
pass # client disconnected before response could be sent
|
|
148
|
+
|
|
149
|
+
def _error(self, status: int, message: str):
|
|
150
|
+
self._json_response({"error": message}, status)
|
|
151
|
+
|
|
152
|
+
def _read_body(self) -> dict | None:
|
|
153
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
154
|
+
if length == 0:
|
|
155
|
+
return {}
|
|
156
|
+
if length > 10_000_000: # 10MB max
|
|
157
|
+
self._error(413, f"Request body too large ({length:,} bytes). Max 10MB.")
|
|
158
|
+
return None
|
|
159
|
+
body = self.rfile.read(length)
|
|
160
|
+
try:
|
|
161
|
+
return json.loads(body)
|
|
162
|
+
except json.JSONDecodeError as e:
|
|
163
|
+
self._error(400, f"Invalid JSON: {e}")
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def _path_parts(self) -> list[str]:
|
|
167
|
+
parsed = urlparse(self.path)
|
|
168
|
+
return [p for p in parsed.path.split("/") if p]
|
|
169
|
+
|
|
170
|
+
# ── Routing ─────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def do_GET(self):
|
|
173
|
+
if self.path != "/favicon.ico":
|
|
174
|
+
logger.debug("GET %s", self.path)
|
|
175
|
+
parts = self._path_parts()
|
|
176
|
+
|
|
177
|
+
if self.path == "/" or self.path == "/index.html":
|
|
178
|
+
self._serve_spa()
|
|
179
|
+
elif self.path == "/health":
|
|
180
|
+
self._handle_health()
|
|
181
|
+
elif self.path == "/tools":
|
|
182
|
+
self._handle_tools()
|
|
183
|
+
elif self.path == "/skills":
|
|
184
|
+
self._handle_skills()
|
|
185
|
+
elif self.path == "/models":
|
|
186
|
+
self._handle_models()
|
|
187
|
+
elif self.path == "/sessions":
|
|
188
|
+
self._handle_list_sessions()
|
|
189
|
+
elif self.path == "/api/workspace":
|
|
190
|
+
with self._ws_lock:
|
|
191
|
+
ws = self.config.agent.workspace_dir
|
|
192
|
+
self._json_response({"workspace": ws})
|
|
193
|
+
elif self.path.startswith("/css/") or self.path.startswith("/js/"):
|
|
194
|
+
self._serve_static(self.path.lstrip("/"))
|
|
195
|
+
elif len(parts) == 2 and parts[0] == "sessions":
|
|
196
|
+
self._handle_get_session(parts[1])
|
|
197
|
+
else:
|
|
198
|
+
self._error(404, f"Not found: {self.path}")
|
|
199
|
+
|
|
200
|
+
def do_POST(self):
|
|
201
|
+
logger.debug("POST %s", self.path)
|
|
202
|
+
if self.path == "/chat":
|
|
203
|
+
self._handle_chat()
|
|
204
|
+
elif self.path == "/chat/stream":
|
|
205
|
+
self._handle_chat_stream()
|
|
206
|
+
elif self.path == "/api/workspace":
|
|
207
|
+
self._handle_set_workspace()
|
|
208
|
+
elif self.path == "/api/shell":
|
|
209
|
+
self._handle_shell()
|
|
210
|
+
else:
|
|
211
|
+
self._error(404, f"Not found: {self.path}")
|
|
212
|
+
|
|
213
|
+
def do_DELETE(self):
|
|
214
|
+
if not self._require_auth("DELETE"):
|
|
215
|
+
return
|
|
216
|
+
parts = self._path_parts()
|
|
217
|
+
if len(parts) == 2 and parts[0] == "sessions":
|
|
218
|
+
self._handle_delete_session(parts[1])
|
|
219
|
+
else:
|
|
220
|
+
self._error(404, f"Not found: {self.path}")
|
|
221
|
+
|
|
222
|
+
# ── Chat helpers ─────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
def _parse_chat_request(self) -> dict | None:
|
|
225
|
+
"""Read and validate the JSON body for /chat and /chat/stream.
|
|
226
|
+
|
|
227
|
+
Returns a dict with keys: message, session_id, skill, model_override,
|
|
228
|
+
thinking_override. Returns None if the body is invalid (error response
|
|
229
|
+
already sent).
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
body = self._read_body()
|
|
233
|
+
except Exception:
|
|
234
|
+
self._error(400, "Invalid JSON body")
|
|
235
|
+
return None
|
|
236
|
+
if body is None:
|
|
237
|
+
return None # error response already sent by _read_body
|
|
238
|
+
message = body.get("message", "")
|
|
239
|
+
if not message:
|
|
240
|
+
self._error(400, "Missing 'message' field")
|
|
241
|
+
return None
|
|
242
|
+
return {
|
|
243
|
+
"message": message,
|
|
244
|
+
"session_id": body.get("session_id"),
|
|
245
|
+
"skill": body.get("skill", ""),
|
|
246
|
+
"model_override": body.get("model", ""),
|
|
247
|
+
"thinking_override": body.get("thinking", ""),
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
# ── Handlers ────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def _handle_health(self):
|
|
253
|
+
skill_mgr = get_skill_manager()
|
|
254
|
+
with self._ws_lock:
|
|
255
|
+
ws = self.config.agent.workspace_dir
|
|
256
|
+
self._json_response({
|
|
257
|
+
"status": "ok",
|
|
258
|
+
"model": self.config.llm.model,
|
|
259
|
+
"workspace": ws,
|
|
260
|
+
"tools": len(TOOL_DEFINITIONS),
|
|
261
|
+
"skills": [s.name for s in skill_mgr.list_skills()],
|
|
262
|
+
"mcp_servers": [],
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
def _handle_tools(self):
|
|
266
|
+
if not self._require_auth("tools"):
|
|
267
|
+
return
|
|
268
|
+
self._json_response({
|
|
269
|
+
"tools": [
|
|
270
|
+
{"name": t["function"]["name"], "description": t["function"]["description"]}
|
|
271
|
+
for t in TOOL_DEFINITIONS
|
|
272
|
+
]
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
def _handle_skills(self):
|
|
276
|
+
if not self._require_auth("skills"):
|
|
277
|
+
return
|
|
278
|
+
skill_mgr = get_skill_manager()
|
|
279
|
+
self._json_response({
|
|
280
|
+
"skills": [
|
|
281
|
+
{"name": s.name, "description": s.description, "triggers": s.triggers}
|
|
282
|
+
for s in skill_mgr.list_skills()
|
|
283
|
+
]
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
def _handle_models(self):
|
|
287
|
+
"""Fetch available models from API if possible, else return cached."""
|
|
288
|
+
try:
|
|
289
|
+
from .model_registry import fetch_available_models
|
|
290
|
+
models = fetch_available_models(self.config.llm.base_url, self.config.llm.api_key)
|
|
291
|
+
if models:
|
|
292
|
+
models_data = [{"id": m, "owned_by": "api"} for m in sorted(models)]
|
|
293
|
+
self._json_response({"models": models_data, "current": self.config.llm.model})
|
|
294
|
+
return
|
|
295
|
+
except Exception:
|
|
296
|
+
logger.debug("Failed to fetch models from API, using cache", exc_info=True)
|
|
297
|
+
# Fallback: cached model list
|
|
298
|
+
import os
|
|
299
|
+
cached = os.environ.get("ATA_CODER_MODELS_CACHE", self.config.llm.model)
|
|
300
|
+
models = [{"id": m.strip(), "owned_by": ""} for m in cached.split(",") if m.strip()]
|
|
301
|
+
self._json_response({"models": models, "current": self.config.llm.model})
|
|
302
|
+
|
|
303
|
+
def _handle_set_workspace(self):
|
|
304
|
+
"""Change the agent workspace directory."""
|
|
305
|
+
if not self._require_auth("workspace"):
|
|
306
|
+
return
|
|
307
|
+
body = self._read_body()
|
|
308
|
+
if body is None:
|
|
309
|
+
return # error response already sent by _read_body
|
|
310
|
+
new_ws = body.get("workspace", "")
|
|
311
|
+
if not new_ws:
|
|
312
|
+
self._error(400, "Missing 'workspace' field")
|
|
313
|
+
return
|
|
314
|
+
p = Path(new_ws).expanduser().resolve()
|
|
315
|
+
if not p.exists() or not p.is_dir():
|
|
316
|
+
self._error(400, f"Directory not found or not a directory: {p}")
|
|
317
|
+
return
|
|
318
|
+
with self._ws_lock:
|
|
319
|
+
self.config.agent.workspace_dir = str(p)
|
|
320
|
+
self._json_response({"workspace": str(p), "ok": True})
|
|
321
|
+
|
|
322
|
+
def _handle_shell(self):
|
|
323
|
+
"""Interactive PowerShell — persistent session with SSE streaming output."""
|
|
324
|
+
if not self._require_auth("shell"):
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
body = self._read_body()
|
|
328
|
+
if body is None:
|
|
329
|
+
return # error response already sent by _read_body
|
|
330
|
+
sid = body.get("session", "")
|
|
331
|
+
command = body.get("command", "")
|
|
332
|
+
action = body.get("action", "send")
|
|
333
|
+
|
|
334
|
+
# Open new session
|
|
335
|
+
if action == "open":
|
|
336
|
+
with self._ws_lock:
|
|
337
|
+
ws = self.config.agent.workspace_dir
|
|
338
|
+
sid = shell_open(ws)
|
|
339
|
+
default_prompt = "PS> " if os.name == "nt" else "$ "
|
|
340
|
+
_, _, _, prompt = get_shell_sessions().get(sid, (None, None, None, default_prompt))
|
|
341
|
+
self._json_response({"session": sid, "prompt": prompt})
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
# Close session
|
|
345
|
+
if action == "close":
|
|
346
|
+
shell_close(sid)
|
|
347
|
+
self._json_response({"ok": True})
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
# Send command to persistent session
|
|
351
|
+
if not command:
|
|
352
|
+
self._error(400, "Missing 'command'")
|
|
353
|
+
return
|
|
354
|
+
if not sid:
|
|
355
|
+
self._error(400, "Missing 'session'. Open a session first.")
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
# Safety check: run command through the same guard as the agent
|
|
359
|
+
from .safety_guard import SafetyGuard
|
|
360
|
+
with self._ws_lock:
|
|
361
|
+
ws = self.config.agent.workspace_dir
|
|
362
|
+
guard = SafetyGuard(ws)
|
|
363
|
+
safety = guard.check_shell(command)
|
|
364
|
+
if not safety.allowed:
|
|
365
|
+
logger.warning("⛔ Blocked shell command [%s]: %s", sid[:6], command[:120])
|
|
366
|
+
self._error(403, f"Command blocked: {safety.reason}")
|
|
367
|
+
return
|
|
368
|
+
if safety.warnings:
|
|
369
|
+
for w in safety.warnings:
|
|
370
|
+
logger.warning("⚠️ Shell warning [%s]: %s", sid[:6], w)
|
|
371
|
+
|
|
372
|
+
logger.info("💻 [%s] %s", sid[:6], command[:120])
|
|
373
|
+
|
|
374
|
+
proc, outq, lock, prompt = shell_ensure(sid, ws)
|
|
375
|
+
if not proc:
|
|
376
|
+
self._error(500, "Shell process not running")
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
# Drain any stale output, send command
|
|
380
|
+
with lock:
|
|
381
|
+
while not outq.empty():
|
|
382
|
+
try: outq.get_nowait()
|
|
383
|
+
except queue.Empty: break
|
|
384
|
+
proc.stdin.write((command + "\n").encode("utf-8"))
|
|
385
|
+
proc.stdin.flush()
|
|
386
|
+
|
|
387
|
+
# Stream output via SSE
|
|
388
|
+
self.send_response(200)
|
|
389
|
+
self._cors()
|
|
390
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
391
|
+
self.send_header("Cache-Control", "no-cache")
|
|
392
|
+
self.end_headers()
|
|
393
|
+
|
|
394
|
+
# Adaptive silence timeout: short initial wait, longer between lines
|
|
395
|
+
FAST_TIMEOUT = 0.6 # initial wait for first output (per cycle)
|
|
396
|
+
SLOW_TIMEOUT = 4.0 # silence between output lines
|
|
397
|
+
FIRST_OUTPUT_DEADLINE = time.time() + 10.0 # max wait for any output at all
|
|
398
|
+
deadline = time.time() + 120
|
|
399
|
+
silence_dl = time.time() + FAST_TIMEOUT
|
|
400
|
+
has_output = False
|
|
401
|
+
while time.time() < deadline:
|
|
402
|
+
try:
|
|
403
|
+
line = outq.get(timeout=0.2)
|
|
404
|
+
try:
|
|
405
|
+
self.wfile.write(
|
|
406
|
+
f"data: {json.dumps({'text': line})}\n\n".encode("utf-8")
|
|
407
|
+
)
|
|
408
|
+
self.wfile.flush()
|
|
409
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
|
|
410
|
+
return # client disconnected
|
|
411
|
+
has_output = True
|
|
412
|
+
silence_dl = time.time() + SLOW_TIMEOUT
|
|
413
|
+
except queue.Empty:
|
|
414
|
+
pass
|
|
415
|
+
if time.time() > silence_dl:
|
|
416
|
+
if not has_output and time.time() < FIRST_OUTPUT_DEADLINE:
|
|
417
|
+
silence_dl = time.time() + FAST_TIMEOUT # keep waiting for first output
|
|
418
|
+
continue
|
|
419
|
+
break # silence deadline passed: either got output or gave up on first
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
self.wfile.write(f"event: done\ndata: {json.dumps({'done': True})}\n\n".encode("utf-8"))
|
|
423
|
+
self.wfile.flush()
|
|
424
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
|
|
425
|
+
pass # client disconnected
|
|
426
|
+
|
|
427
|
+
def _serve_static(self, rel_path: str):
|
|
428
|
+
"""Serve a static file from the web/ directory."""
|
|
429
|
+
web_root = Path(__file__).parent / "web"
|
|
430
|
+
file_path = web_root / rel_path
|
|
431
|
+
if not file_path.exists() or not file_path.is_file():
|
|
432
|
+
self._error(404, f"Not found: {self.path}")
|
|
433
|
+
return
|
|
434
|
+
# Safety: prevent path traversal
|
|
435
|
+
try:
|
|
436
|
+
file_path.resolve().relative_to(web_root.resolve())
|
|
437
|
+
except ValueError:
|
|
438
|
+
self._error(403, "Forbidden")
|
|
439
|
+
return
|
|
440
|
+
content = file_path.read_bytes()
|
|
441
|
+
ct = "text/css" if rel_path.endswith(".css") else "application/javascript"
|
|
442
|
+
self.send_response(200)
|
|
443
|
+
self._cors()
|
|
444
|
+
self.send_header("Content-Type", ct + "; charset=utf-8")
|
|
445
|
+
self.send_header("Content-Length", str(len(content)))
|
|
446
|
+
self.send_header("Cache-Control", "public, max-age=3600")
|
|
447
|
+
self.end_headers()
|
|
448
|
+
try:
|
|
449
|
+
self.wfile.write(content)
|
|
450
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
|
|
451
|
+
pass # client disconnected
|
|
452
|
+
|
|
453
|
+
def _serve_spa(self):
|
|
454
|
+
"""Serve the single-page web UI from web/ directory."""
|
|
455
|
+
for fname in ("web/index.html", "web_ui.html"):
|
|
456
|
+
spa_html = Path(__file__).parent / fname
|
|
457
|
+
if spa_html.exists():
|
|
458
|
+
content = spa_html.read_text(encoding="utf-8")
|
|
459
|
+
break
|
|
460
|
+
else:
|
|
461
|
+
content = "<h1>ATA Coder Web UI</h1><p>web/index.html not found.</p>"
|
|
462
|
+
self.send_response(200)
|
|
463
|
+
self._cors()
|
|
464
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
465
|
+
self.end_headers()
|
|
466
|
+
try:
|
|
467
|
+
self.wfile.write(content.encode("utf-8"))
|
|
468
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
|
|
469
|
+
pass # client disconnected
|
|
470
|
+
|
|
471
|
+
def _handle_list_sessions(self):
|
|
472
|
+
if not self._require_auth("sessions"):
|
|
473
|
+
return
|
|
474
|
+
self._json_response({"sessions": self.store.list_sessions()})
|
|
475
|
+
|
|
476
|
+
def _handle_get_session(self, sid: str):
|
|
477
|
+
meta = self.store.get_meta(sid)
|
|
478
|
+
if not meta:
|
|
479
|
+
self._error(404, "Session not found")
|
|
480
|
+
return
|
|
481
|
+
info = dict(meta)
|
|
482
|
+
agent = self.store.get(sid)
|
|
483
|
+
if agent:
|
|
484
|
+
info["conversation"] = agent.get_conversation_summary()
|
|
485
|
+
self._json_response(info)
|
|
486
|
+
|
|
487
|
+
def _handle_delete_session(self, sid: str):
|
|
488
|
+
if self.store.delete(sid):
|
|
489
|
+
self._json_response({"status": "deleted", "session_id": sid})
|
|
490
|
+
else:
|
|
491
|
+
self._error(404, "Session not found")
|
|
492
|
+
|
|
493
|
+
# ── Chat (non-streaming) ────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
def _handle_chat(self):
|
|
496
|
+
if not self._require_auth("chat"):
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
req = self._parse_chat_request()
|
|
500
|
+
if req is None:
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
logger.info("📩 [%s] %.200s", time.strftime('%H:%M:%S'), self._sanitize_log(req["message"]))
|
|
504
|
+
|
|
505
|
+
is_new_session = req["session_id"] is None or self.store.get(req["session_id"]) is None
|
|
506
|
+
sid, agent = self.store.get_or_create(req["session_id"], self.config, req["skill"])
|
|
507
|
+
|
|
508
|
+
if req["model_override"]:
|
|
509
|
+
agent.llm.set_model(req["model_override"])
|
|
510
|
+
agent.llm.register_tools(agent._all_tools)
|
|
511
|
+
if req["thinking_override"]:
|
|
512
|
+
agent.llm.config.thinking_strength = req["thinking_override"]
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
import asyncio as _asyncio
|
|
516
|
+
response = _asyncio.run(agent.run(req["message"], stream=False, skill_name=req["skill"] or None,
|
|
517
|
+
reset_context=is_new_session))
|
|
518
|
+
except Exception as e:
|
|
519
|
+
logger.exception("Agent run failed: %s", e)
|
|
520
|
+
self._error(500, "Internal server error")
|
|
521
|
+
return
|
|
522
|
+
|
|
523
|
+
self.store.update_meta(
|
|
524
|
+
sid,
|
|
525
|
+
messages=len(agent._state.messages),
|
|
526
|
+
tool_calls=agent._state.tool_call_count,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
self._json_response({
|
|
530
|
+
"session_id": sid,
|
|
531
|
+
"response": response,
|
|
532
|
+
"tool_calls": agent._state.tool_call_count,
|
|
533
|
+
"tokens": {
|
|
534
|
+
"prompt": agent.llm.total_prompt_tokens,
|
|
535
|
+
"completion": agent.llm.total_completion_tokens,
|
|
536
|
+
"total": agent.llm.total_tokens,
|
|
537
|
+
},
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
# ── Chat (SSE streaming) ────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
def _handle_chat_stream(self):
|
|
543
|
+
if not self._require_auth("chat/stream"):
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
req = self._parse_chat_request()
|
|
547
|
+
if req is None:
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
logger.info("📩 [%s] %.200s skill=%s model=%s thinking=%s",
|
|
551
|
+
time.strftime('%H:%M:%S'), req["message"],
|
|
552
|
+
req["skill"] or "-", req["model_override"] or "-", req["thinking_override"] or "-")
|
|
553
|
+
|
|
554
|
+
is_new_session = req["session_id"] is None or self.store.get(req["session_id"]) is None
|
|
555
|
+
sid, agent = self.store.get_or_create(req["session_id"], self.config, req["skill"])
|
|
556
|
+
|
|
557
|
+
if req["model_override"]:
|
|
558
|
+
agent.llm.set_model(req["model_override"])
|
|
559
|
+
agent.llm.register_tools(agent._all_tools)
|
|
560
|
+
if req["thinking_override"]:
|
|
561
|
+
agent.llm.config.thinking_strength = req["thinking_override"]
|
|
562
|
+
|
|
563
|
+
self.send_response(200)
|
|
564
|
+
self._cors()
|
|
565
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
566
|
+
self.send_header("Cache-Control", "no-cache")
|
|
567
|
+
self.send_header("Connection", "close")
|
|
568
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
569
|
+
self.end_headers()
|
|
570
|
+
|
|
571
|
+
# ── Streaming loop (async, runs inside asyncio.run()) ──
|
|
572
|
+
import asyncio as _asyncio
|
|
573
|
+
|
|
574
|
+
async def _stream_agent():
|
|
575
|
+
events: _asyncio.Queue = _asyncio.Queue()
|
|
576
|
+
|
|
577
|
+
def _push_event(evt):
|
|
578
|
+
"""Filter events through SSE converter; skip non-streamable ones."""
|
|
579
|
+
tup = self._sse_event_tuple(evt)
|
|
580
|
+
if tup is not None:
|
|
581
|
+
events.put_nowait(tup)
|
|
582
|
+
|
|
583
|
+
agent.on_event(_push_event)
|
|
584
|
+
|
|
585
|
+
result_holder: dict[str, Any] = {"response": "", "error": None}
|
|
586
|
+
|
|
587
|
+
async def _run_agent():
|
|
588
|
+
try:
|
|
589
|
+
logger.info("▶ Agent started")
|
|
590
|
+
result_holder["response"] = await agent.run(
|
|
591
|
+
req["message"], stream=True, skill_name=req["skill"] or None,
|
|
592
|
+
reset_context=is_new_session
|
|
593
|
+
)
|
|
594
|
+
logger.info("✓ Agent completed")
|
|
595
|
+
except Exception as exc:
|
|
596
|
+
logger.info("✗ Agent error: %s", exc)
|
|
597
|
+
result_holder["error"] = str(exc)
|
|
598
|
+
|
|
599
|
+
agent_task = _asyncio.create_task(_run_agent())
|
|
600
|
+
|
|
601
|
+
# Stream events until agent task completes
|
|
602
|
+
while not agent_task.done():
|
|
603
|
+
try:
|
|
604
|
+
evt_type, payload = await _asyncio.wait_for(events.get(), timeout=0.1)
|
|
605
|
+
except _asyncio.TimeoutError:
|
|
606
|
+
continue
|
|
607
|
+
|
|
608
|
+
sse_data = _format_sse_data(evt_type, payload)
|
|
609
|
+
if sse_data is None:
|
|
610
|
+
continue
|
|
611
|
+
line = f"event: {evt_type}\ndata: {sse_data}\n\n"
|
|
612
|
+
try:
|
|
613
|
+
self.wfile.write(line.encode("utf-8"))
|
|
614
|
+
self.wfile.flush()
|
|
615
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
|
|
616
|
+
agent_task.cancel()
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
# Drain remaining events
|
|
620
|
+
while not events.empty():
|
|
621
|
+
try:
|
|
622
|
+
evt_type, payload = events.get_nowait()
|
|
623
|
+
sse_data = _format_sse_data(evt_type, payload)
|
|
624
|
+
if sse_data:
|
|
625
|
+
line = f"event: {evt_type}\ndata: {sse_data}\n\n"
|
|
626
|
+
self.wfile.write(line.encode("utf-8"))
|
|
627
|
+
self.wfile.flush()
|
|
628
|
+
except _asyncio.QueueEmpty:
|
|
629
|
+
break
|
|
630
|
+
|
|
631
|
+
return result_holder
|
|
632
|
+
|
|
633
|
+
result_holder = _asyncio.run(_stream_agent())
|
|
634
|
+
|
|
635
|
+
# Send final event with session_id so frontend can reuse it
|
|
636
|
+
final = json.dumps({
|
|
637
|
+
"session_id": sid,
|
|
638
|
+
"response": result_holder["response"] or "",
|
|
639
|
+
"error": result_holder["error"],
|
|
640
|
+
})
|
|
641
|
+
try:
|
|
642
|
+
self.wfile.write(f"event: done\ndata: {final}\n\n".encode("utf-8"))
|
|
643
|
+
self.wfile.flush()
|
|
644
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError, OSError):
|
|
645
|
+
pass
|
|
646
|
+
|
|
647
|
+
self.store.update_meta(
|
|
648
|
+
sid,
|
|
649
|
+
messages=len(agent._state.messages),
|
|
650
|
+
tool_calls=agent._state.tool_call_count,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
# ── SSE event builder (shared across stream handlers) ─────────────────
|
|
654
|
+
|
|
655
|
+
@staticmethod
|
|
656
|
+
def _sanitize_log(text: str) -> str:
|
|
657
|
+
"""Strip common secret patterns from text before logging."""
|
|
658
|
+
import re as _re
|
|
659
|
+
# API key patterns (sk-..., rk-, etc.)
|
|
660
|
+
text = _re.sub(r'sk-[a-zA-Z0-9_-]{20,}', 'sk-***', text)
|
|
661
|
+
text = _re.sub(r'rk-[a-zA-Z0-9_-]{20,}', 'rk-***', text)
|
|
662
|
+
# Bearer tokens
|
|
663
|
+
text = _re.sub(r'Bearer\s+[a-zA-Z0-9._\-]+', 'Bearer ***', text)
|
|
664
|
+
# AWS-style keys
|
|
665
|
+
text = _re.sub(r'AKIA[0-9A-Z]{16}', 'AKIA***', text)
|
|
666
|
+
text = _re.sub(r'AIza[0-9A-Za-z\-_]{35}', 'AIza***', text)
|
|
667
|
+
return text
|
|
668
|
+
|
|
669
|
+
@staticmethod
|
|
670
|
+
def _sse_event_tuple(event: Any):
|
|
671
|
+
"""Convert an AgentEvent into an (event_type, payload) tuple, or None if skipped."""
|
|
672
|
+
from .core import (TextDeltaEvent, ToolCallEvent, ToolResultEvent,
|
|
673
|
+
ToolStreamEvent, CompleteEvent, ErrorEvent, ReasoningEvent, ThinkingEvent)
|
|
674
|
+
|
|
675
|
+
if isinstance(event, TextDeltaEvent):
|
|
676
|
+
logger.debug("📤 text: %s", AgentAPIHandler._sanitize_log(event.text[:120]))
|
|
677
|
+
return ("text", event.text)
|
|
678
|
+
elif isinstance(event, ReasoningEvent):
|
|
679
|
+
logger.debug("🧠 thinking: %.100s", AgentAPIHandler._sanitize_log(event.text))
|
|
680
|
+
return ("thinking", event.text[:200])
|
|
681
|
+
elif isinstance(event, ThinkingEvent):
|
|
682
|
+
return None
|
|
683
|
+
elif isinstance(event, ToolStreamEvent):
|
|
684
|
+
# Real-time shell output — stream immediately to frontend
|
|
685
|
+
return ("tool_stream", {"tool": event.tool_name, "chunk": event.chunk})
|
|
686
|
+
elif isinstance(event, ToolCallEvent):
|
|
687
|
+
logger.debug("🔧 %s %s", event.tool_name, brief_args(event.arguments))
|
|
688
|
+
return ("tool_call", {"name": event.tool_name, "arguments": event.arguments, "source": event.source})
|
|
689
|
+
elif isinstance(event, ToolResultEvent):
|
|
690
|
+
if event.result.success:
|
|
691
|
+
logger.debug(" ✅ %s", AgentAPIHandler._sanitize_log((event.result.output or "")[:100].replace("\n"," ")))
|
|
692
|
+
else:
|
|
693
|
+
logger.debug(" ❌ %s", AgentAPIHandler._sanitize_log((event.result.error or "?")[:100]))
|
|
694
|
+
return ("tool_result", {"name": event.tool_name, "success": event.result.success,
|
|
695
|
+
"output": (event.result.output or "")[:4000], "error": event.result.error})
|
|
696
|
+
elif isinstance(event, ErrorEvent):
|
|
697
|
+
logger.info("💥 ERROR: %s", AgentAPIHandler._sanitize_log(event.error))
|
|
698
|
+
return ("error", {"error": event.error})
|
|
699
|
+
elif isinstance(event, CompleteEvent):
|
|
700
|
+
logger.info("🏁 Complete — %d tools, %.1fs", event.total_tool_calls, event.total_time)
|
|
701
|
+
return ("complete", {"tool_calls": event.total_tool_calls, "time": event.total_time})
|
|
702
|
+
return None
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _format_sse_data(evt_type: str, payload: Any) -> str | None:
|
|
706
|
+
"""Format an (event_type, payload) tuple into a JSON string for SSE output."""
|
|
707
|
+
if evt_type == "text":
|
|
708
|
+
return json.dumps({"type": "text", "text": payload}, ensure_ascii=False)
|
|
709
|
+
elif evt_type == "tool_stream":
|
|
710
|
+
return json.dumps({
|
|
711
|
+
"type": "tool_stream",
|
|
712
|
+
"tool": payload.get("tool", ""),
|
|
713
|
+
"chunk": payload.get("chunk", ""),
|
|
714
|
+
}, ensure_ascii=False)
|
|
715
|
+
elif evt_type == "thinking":
|
|
716
|
+
return json.dumps({"type": "thinking", "text": payload}, ensure_ascii=False)
|
|
717
|
+
elif evt_type == "tool_call":
|
|
718
|
+
# Send full arguments to the frontend — never truncate.
|
|
719
|
+
# brief_args() is only for server-side logging (line 677).
|
|
720
|
+
args = payload.get("arguments", {})
|
|
721
|
+
# Sanitize surrogates in argument values (defense against UTF-8 crashes)
|
|
722
|
+
from .utils import sanitize_surrogates
|
|
723
|
+
args = sanitize_surrogates(args)
|
|
724
|
+
return json.dumps({
|
|
725
|
+
"type": "tool_call",
|
|
726
|
+
"tool": payload["name"],
|
|
727
|
+
"args_summary": brief_args(args),
|
|
728
|
+
"args": args,
|
|
729
|
+
}, ensure_ascii=False)
|
|
730
|
+
elif evt_type == "tool_result":
|
|
731
|
+
return json.dumps({
|
|
732
|
+
"type": "tool_result",
|
|
733
|
+
"tool": payload["name"],
|
|
734
|
+
"ok": payload["success"],
|
|
735
|
+
"output": payload.get("output", ""),
|
|
736
|
+
}, ensure_ascii=False)
|
|
737
|
+
elif evt_type == "error":
|
|
738
|
+
return json.dumps({"type": "error", "error": payload.get("error", "")}, ensure_ascii=False)
|
|
739
|
+
elif evt_type == "complete":
|
|
740
|
+
return json.dumps({
|
|
741
|
+
"type": "complete",
|
|
742
|
+
"tools": payload["tool_calls"],
|
|
743
|
+
"time": payload["time"],
|
|
744
|
+
}, ensure_ascii=False)
|
|
745
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
749
|
+
# Server factory
|
|
750
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
751
|
+
|
|
752
|
+
# Shell management → server_shell.py
|
|
753
|
+
|
|
754
|
+
def create_server(
|
|
755
|
+
config: AppConfig | None = None,
|
|
756
|
+
host: str = "127.0.0.1",
|
|
757
|
+
port: int = 8000,
|
|
758
|
+
) -> HTTPServer:
|
|
759
|
+
"""Create and configure the HTTP API server."""
|
|
760
|
+
|
|
761
|
+
# Common mistakes: URLs, port numbers
|
|
762
|
+
if host.startswith("http://") or host.startswith("https://"):
|
|
763
|
+
host = host.split("://", 1)[1].rstrip("/")
|
|
764
|
+
if host.isdigit():
|
|
765
|
+
port = int(host)
|
|
766
|
+
host = "127.0.0.1"
|
|
767
|
+
|
|
768
|
+
config = config or get_config()
|
|
769
|
+
|
|
770
|
+
AgentAPIHandler.config = config
|
|
771
|
+
AgentAPIHandler.store = SessionStore()
|
|
772
|
+
|
|
773
|
+
try:
|
|
774
|
+
server = HTTPServer((host, port), AgentAPIHandler)
|
|
775
|
+
except OSError as e:
|
|
776
|
+
if "10048" in str(e) or "98" in str(e) or "Address already in use" in str(e):
|
|
777
|
+
logger.error("Port %d is already in use. Use --port to pick another, "
|
|
778
|
+
"or check: netstat -ano | findstr :%d", port, port)
|
|
779
|
+
raise SystemExit(1)
|
|
780
|
+
raise
|
|
781
|
+
|
|
782
|
+
# Close idle connections after 30s to prevent file descriptor exhaustion
|
|
783
|
+
server.socket.settimeout(30.0)
|
|
784
|
+
server.timeout = 30
|
|
785
|
+
|
|
786
|
+
logger.info("Server created: %s:%d", host, port)
|
|
787
|
+
return server
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
791
|
+
# Entry point
|
|
792
|
+
def _detect_lan_ip() -> str | None:
|
|
793
|
+
"""Detect the LAN IP address for mobile/tablet access."""
|
|
794
|
+
try:
|
|
795
|
+
import socket
|
|
796
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
797
|
+
s.settimeout(0.1)
|
|
798
|
+
s.connect(("8.8.8.8", 80))
|
|
799
|
+
ip = s.getsockname()[0]
|
|
800
|
+
s.close()
|
|
801
|
+
return ip
|
|
802
|
+
except Exception:
|
|
803
|
+
pass
|
|
804
|
+
# Fallback: iterate network interfaces
|
|
805
|
+
try:
|
|
806
|
+
import socket
|
|
807
|
+
hostname = socket.gethostname()
|
|
808
|
+
ip = socket.gethostbyname(hostname)
|
|
809
|
+
if ip and not ip.startswith("127."):
|
|
810
|
+
return ip
|
|
811
|
+
except Exception:
|
|
812
|
+
pass
|
|
813
|
+
return None
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
817
|
+
|
|
818
|
+
def main():
|
|
819
|
+
import argparse
|
|
820
|
+
parser = argparse.ArgumentParser(description="ATA Coder API Server")
|
|
821
|
+
parser.add_argument("--host", default="127.0.0.1", help="Bind host (127.0.0.1 = local only, 0.0.0.0 = LAN accessible)")
|
|
822
|
+
parser.add_argument("--port", "-p", type=int, default=8000, help="Bind port")
|
|
823
|
+
parser.add_argument("--local-only", action="store_true", help="Bind to 127.0.0.1 only (no LAN)")
|
|
824
|
+
parser.add_argument("--allow-all", "-A", action="store_true", help="Skip all permission prompts")
|
|
825
|
+
parser.add_argument("--model", "-m", help="Model name")
|
|
826
|
+
parser.add_argument("--verbose", "-v", action="store_true")
|
|
827
|
+
args = parser.parse_args()
|
|
828
|
+
|
|
829
|
+
# Server mode: verbose by default so you can see what the agent is doing
|
|
830
|
+
log_level = logging.DEBUG if args.verbose else logging.INFO
|
|
831
|
+
logging.basicConfig(
|
|
832
|
+
level=log_level,
|
|
833
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
if args.allow_all:
|
|
837
|
+
os.environ["ATA_CODER_ALLOW_ALL"] = "1"
|
|
838
|
+
|
|
839
|
+
config = get_config()
|
|
840
|
+
if args.model:
|
|
841
|
+
config.llm.model = args.model
|
|
842
|
+
|
|
843
|
+
if args.local_only:
|
|
844
|
+
args.host = "127.0.0.1"
|
|
845
|
+
|
|
846
|
+
# Security: warn if binding to all interfaces without authentication
|
|
847
|
+
if args.host == "0.0.0.0" and not os.environ.get("ATA_CODER_API_TOKEN"):
|
|
848
|
+
logger.warning(
|
|
849
|
+
"⚠️ Binding to 0.0.0.0 WITHOUT an API token — "
|
|
850
|
+
"anyone on the network can access the agent. "
|
|
851
|
+
"Set ATA_CODER_API_TOKEN env var or use --local-only."
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
server = create_server(config, args.host, args.port)
|
|
855
|
+
|
|
856
|
+
# Detect LAN IP for mobile access
|
|
857
|
+
lan_ip = _detect_lan_ip() if args.host == "0.0.0.0" else None
|
|
858
|
+
|
|
859
|
+
print("""
|
|
860
|
+
╔══════════════════════════════════════════════════╗
|
|
861
|
+
║ ATA Coder — Web UI ║
|
|
862
|
+
╠══════════════════════════════════════════════════╣""")
|
|
863
|
+
print(f"║ Local: http://127.0.0.1:{args.port:<29}║")
|
|
864
|
+
if lan_ip:
|
|
865
|
+
print(f"║ LAN: http://{lan_ip}:{args.port:<29}║")
|
|
866
|
+
else:
|
|
867
|
+
print("║ LAN: (use --host 0.0.0.0 for LAN access) ║")
|
|
868
|
+
print(f"""║ Model: {config.llm.model:<34}║
|
|
869
|
+
║ Tools: {len(TOOL_DEFINITIONS):<34}║
|
|
870
|
+
╚══════════════════════════════════════════════════╝
|
|
871
|
+
""")
|
|
872
|
+
|
|
873
|
+
try:
|
|
874
|
+
server.serve_forever()
|
|
875
|
+
except KeyboardInterrupt:
|
|
876
|
+
print("\nShutting down...")
|
|
877
|
+
shell_close_all()
|
|
878
|
+
server.shutdown()
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
if __name__ == "__main__":
|
|
882
|
+
main()
|