ltcai 0.1.9 → 0.1.16
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.
- package/README.md +174 -305
- package/docs/CHANGELOG.md +307 -0
- package/docs/architecture.md +121 -0
- package/docs/mcp-tools.md +116 -0
- package/docs/privacy.md +74 -0
- package/docs/public-deploy.md +137 -0
- package/docs/security-model.md +121 -0
- package/knowledge_graph.py +123 -15
- package/llm_router.py +100 -28
- package/ltcai_cli.py +138 -5
- package/package.json +14 -2
- package/server.py +1756 -329
- package/skills/SKILL_TEMPLATE.md +61 -29
- package/skills/code_review/SKILL.md +28 -0
- package/skills/code_review/examples.md +59 -0
- package/skills/code_review/risk.json +9 -0
- package/skills/code_review/schema.json +65 -0
- package/skills/data_analysis/SKILL.md +28 -0
- package/skills/data_analysis/examples.md +62 -0
- package/skills/data_analysis/risk.json +9 -0
- package/skills/data_analysis/schema.json +61 -0
- package/skills/file_edit/SKILL.md +33 -0
- package/skills/file_edit/examples.md +45 -0
- package/skills/file_edit/risk.json +9 -0
- package/skills/file_edit/schema.json +60 -0
- package/skills/summarize_document/SKILL.md +68 -0
- package/skills/summarize_document/examples.md +65 -0
- package/skills/summarize_document/risk.json +9 -0
- package/skills/summarize_document/schema.json +71 -0
- package/skills/web_search/SKILL.md +28 -0
- package/skills/web_search/examples.md +61 -0
- package/skills/web_search/risk.json +9 -0
- package/skills/web_search/schema.json +62 -0
- package/static/account.html +53 -51
- package/static/admin.html +50 -46
- package/static/chat.html +124 -96
- package/static/graph.html +1231 -337
- package/static/manifest.json +2 -2
- package/tests/integration/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__pycache__/test_api.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/test_tools.py +194 -1
- package/tools.py +264 -4
package/server.py
CHANGED
|
@@ -11,6 +11,7 @@ import io
|
|
|
11
11
|
import json
|
|
12
12
|
import logging
|
|
13
13
|
import os
|
|
14
|
+
import platform
|
|
14
15
|
import re
|
|
15
16
|
import secrets
|
|
16
17
|
import threading
|
|
@@ -19,6 +20,8 @@ import subprocess
|
|
|
19
20
|
import sys
|
|
20
21
|
import tempfile
|
|
21
22
|
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
22
25
|
from contextlib import asynccontextmanager
|
|
23
26
|
from pathlib import Path
|
|
24
27
|
|
|
@@ -29,7 +32,8 @@ try:
|
|
|
29
32
|
except Exception as e:
|
|
30
33
|
print(f"⚠️ MLX Metal context unavailable: {e}")
|
|
31
34
|
mx = None
|
|
32
|
-
from
|
|
35
|
+
from enum import Enum
|
|
36
|
+
from typing import AsyncIterator, Optional, List, Dict, TypedDict
|
|
33
37
|
|
|
34
38
|
import uvicorn
|
|
35
39
|
from fastapi import FastAPI, File, HTTPException, Request, Cookie, UploadFile
|
|
@@ -39,7 +43,7 @@ from fastapi.staticfiles import StaticFiles
|
|
|
39
43
|
from pydantic import BaseModel
|
|
40
44
|
from PIL import Image
|
|
41
45
|
|
|
42
|
-
from llm_router import AsyncOpenAI, LLMRouter, OPENAI_COMPATIBLE_PROVIDERS, parse_model_ref, mx, normalize_branding
|
|
46
|
+
from llm_router import AsyncOpenAI, LLMRouter, OPENAI_COMPATIBLE_PROVIDERS, HF_MODELS_ROOT, ensure_mlx_runtime, hf_model_dir, parse_model_ref, mx, normalize_branding
|
|
43
47
|
from knowledge_graph import KnowledgeGraphStore
|
|
44
48
|
from p_reinforce import BRAIN_DIR, PReinforceGardener
|
|
45
49
|
from setup import get_recommendations, install_stream, open_url, scan_environment
|
|
@@ -65,12 +69,14 @@ from tools import (
|
|
|
65
69
|
read_document,
|
|
66
70
|
deploy_project,
|
|
67
71
|
desktop_bridge_status,
|
|
72
|
+
edit_file,
|
|
68
73
|
ensure_agent_root,
|
|
69
74
|
execute_tool,
|
|
70
75
|
git_diff,
|
|
71
76
|
git_log,
|
|
72
77
|
git_show,
|
|
73
78
|
git_status,
|
|
79
|
+
grep,
|
|
74
80
|
inspect_html,
|
|
75
81
|
knowledge_save,
|
|
76
82
|
knowledge_search,
|
|
@@ -87,6 +93,8 @@ from tools import (
|
|
|
87
93
|
read_file,
|
|
88
94
|
run_command,
|
|
89
95
|
search_files,
|
|
96
|
+
todo_read,
|
|
97
|
+
todo_write,
|
|
90
98
|
workspace_tree,
|
|
91
99
|
write_file,
|
|
92
100
|
)
|
|
@@ -99,19 +107,15 @@ except Exception:
|
|
|
99
107
|
from datetime import datetime
|
|
100
108
|
|
|
101
109
|
def detect_language(text: str) -> str:
|
|
102
|
-
"""Detect language: 'ko' (Korean)
|
|
110
|
+
"""Detect language: 'ko' (Korean) or 'en' (English)."""
|
|
103
111
|
total = max(len(text), 1)
|
|
104
112
|
ko = sum(1 for c in text if '가' <= c <= '힣')
|
|
105
|
-
zh = sum(1 for c in text if '一' <= c <= '鿿')
|
|
106
113
|
if ko / total > 0.05:
|
|
107
114
|
return "ko"
|
|
108
|
-
if zh / total > 0.05:
|
|
109
|
-
return "zh"
|
|
110
115
|
return "en"
|
|
111
116
|
|
|
112
117
|
_LANG_HINT = {
|
|
113
118
|
"ko": "Respond in Korean (한국어로 답변하세요).",
|
|
114
|
-
"zh": "Respond in Chinese (用中文回答).",
|
|
115
119
|
"en": "Respond in English.",
|
|
116
120
|
}
|
|
117
121
|
|
|
@@ -259,6 +263,30 @@ def _persist_sessions(sessions: Dict[str, tuple]) -> None:
|
|
|
259
263
|
|
|
260
264
|
_sessions: Dict[str, tuple] = _load_sessions()
|
|
261
265
|
|
|
266
|
+
# ── Rate limiting ─────────────────────────────────────────────────────────────
|
|
267
|
+
_rate_windows: dict[tuple[str, str], list[float]] = {}
|
|
268
|
+
_rate_lock = threading.Lock()
|
|
269
|
+
|
|
270
|
+
def _check_rate_limit(ip: str, action: str, max_calls: int, window_secs: float) -> None:
|
|
271
|
+
key = (ip, action)
|
|
272
|
+
now = time.time()
|
|
273
|
+
cutoff = now - window_secs
|
|
274
|
+
with _rate_lock:
|
|
275
|
+
calls = [t for t in _rate_windows.get(key, []) if t > cutoff]
|
|
276
|
+
if len(calls) >= max_calls:
|
|
277
|
+
raise HTTPException(status_code=429, detail="요청이 너무 많습니다. 잠시 후 다시 시도하세요.")
|
|
278
|
+
calls.append(now)
|
|
279
|
+
_rate_windows[key] = calls
|
|
280
|
+
|
|
281
|
+
def _client_ip(request: Request) -> str:
|
|
282
|
+
for header in ("CF-Connecting-IP", "X-Forwarded-For"):
|
|
283
|
+
val = request.headers.get(header)
|
|
284
|
+
if val:
|
|
285
|
+
return val.split(",")[0].strip()
|
|
286
|
+
return request.client.host if request.client else "unknown"
|
|
287
|
+
|
|
288
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
262
290
|
def create_session(email: str) -> str:
|
|
263
291
|
token = secrets.token_urlsafe(32)
|
|
264
292
|
with _sessions_lock:
|
|
@@ -1526,6 +1554,13 @@ async def lifespan(app: FastAPI):
|
|
|
1526
1554
|
yield
|
|
1527
1555
|
finally:
|
|
1528
1556
|
router.unload_all()
|
|
1557
|
+
for proc in LOCAL_SERVER_PROCESSES.values():
|
|
1558
|
+
try:
|
|
1559
|
+
if proc.poll() is None:
|
|
1560
|
+
proc.terminate()
|
|
1561
|
+
proc.wait(timeout=5)
|
|
1562
|
+
except Exception:
|
|
1563
|
+
pass
|
|
1529
1564
|
|
|
1530
1565
|
app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="2.1.0", lifespan=lifespan)
|
|
1531
1566
|
|
|
@@ -1551,23 +1586,34 @@ if _ICONS_DIR.exists():
|
|
|
1551
1586
|
ensure_agent_root()
|
|
1552
1587
|
app.mount("/agent-files", StaticFiles(directory=str(AGENT_ROOT)), name="agent-files")
|
|
1553
1588
|
|
|
1589
|
+
OPEN_REGISTRATION = env_bool("LATTICEAI_OPEN_REGISTRATION", default=True)
|
|
1590
|
+
|
|
1554
1591
|
@app.post("/register")
|
|
1555
|
-
async def register(req: UserRegister):
|
|
1592
|
+
async def register(req: UserRegister, request: Request):
|
|
1593
|
+
# 5 registration attempts per IP per hour
|
|
1594
|
+
_check_rate_limit(_client_ip(request), "register", max_calls=5, window_secs=3600)
|
|
1595
|
+
if not OPEN_REGISTRATION:
|
|
1596
|
+
raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
|
|
1556
1597
|
users = load_users()
|
|
1557
1598
|
if req.email in users:
|
|
1558
1599
|
raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
|
|
1600
|
+
# First user to register on a fresh server becomes admin automatically
|
|
1601
|
+
role = "admin" if not users else "user"
|
|
1559
1602
|
users[req.email] = {
|
|
1560
1603
|
"password": hash_password(req.password),
|
|
1561
1604
|
"name": req.name,
|
|
1562
1605
|
"nickname": req.nickname,
|
|
1563
|
-
"role":
|
|
1606
|
+
"role": role,
|
|
1564
1607
|
"disabled": False,
|
|
1565
1608
|
}
|
|
1566
1609
|
save_users(users)
|
|
1567
|
-
|
|
1610
|
+
msg = "회원가입 성공! 첫 번째 사용자로 관리자 권한이 부여되었습니다." if role == "admin" else "회원가입 성공!"
|
|
1611
|
+
return {"status": "ok", "message": msg, "role": role}
|
|
1568
1612
|
|
|
1569
1613
|
@app.post("/login")
|
|
1570
|
-
async def login(req: UserLogin):
|
|
1614
|
+
async def login(req: UserLogin, request: Request):
|
|
1615
|
+
# 10 login attempts per IP per 5 minutes
|
|
1616
|
+
_check_rate_limit(_client_ip(request), "login", max_calls=10, window_secs=300)
|
|
1571
1617
|
users = load_users()
|
|
1572
1618
|
user = users.get(req.email)
|
|
1573
1619
|
if not user or not verify_and_migrate_password(req.email, req.password, user.get("password", ""), users):
|
|
@@ -1964,6 +2010,11 @@ class SetApiKeyRequest(BaseModel):
|
|
|
1964
2010
|
class PullModelRequest(BaseModel):
|
|
1965
2011
|
model: str
|
|
1966
2012
|
|
|
2013
|
+
class PrepareModelRequest(BaseModel):
|
|
2014
|
+
model: str
|
|
2015
|
+
engine: Optional[str] = None
|
|
2016
|
+
user_email: Optional[str] = None
|
|
2017
|
+
|
|
1967
2018
|
class VerifyCloudRequest(BaseModel):
|
|
1968
2019
|
force: bool = False
|
|
1969
2020
|
provider: Optional[str] = None
|
|
@@ -1978,12 +2029,48 @@ class AgentRequest(BaseModel):
|
|
|
1978
2029
|
message: str
|
|
1979
2030
|
conversation_id: Optional[str] = None
|
|
1980
2031
|
source: Optional[str] = None
|
|
1981
|
-
max_steps: int =
|
|
2032
|
+
max_steps: int = 25
|
|
1982
2033
|
temperature: float = 0.1
|
|
1983
2034
|
user_email: Optional[str] = None
|
|
1984
2035
|
user_nickname: Optional[str] = None
|
|
1985
2036
|
|
|
1986
2037
|
|
|
2038
|
+
class AgentEvalRequest(BaseModel):
|
|
2039
|
+
skill: str
|
|
2040
|
+
case_id: Optional[str] = None
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
class AgentState(str, Enum):
|
|
2044
|
+
IDLE = "IDLE"
|
|
2045
|
+
PLANNING = "PLANNING"
|
|
2046
|
+
WAITING_APPROVAL = "WAITING_APPROVAL"
|
|
2047
|
+
EXECUTING = "EXECUTING"
|
|
2048
|
+
VERIFYING = "VERIFYING"
|
|
2049
|
+
FAILED = "FAILED"
|
|
2050
|
+
ROLLBACK = "ROLLBACK"
|
|
2051
|
+
DONE = "DONE"
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
# Terminal states — the agent loop exits when reaching one of these
|
|
2055
|
+
AGENT_TERMINAL_STATES = frozenset({AgentState.DONE, AgentState.FAILED})
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
class AgentRunContext:
|
|
2059
|
+
"""Mutable state carrier passed through all agent phases."""
|
|
2060
|
+
__slots__ = ("state", "plan", "transcript", "retry_count",
|
|
2061
|
+
"state_history", "corrections", "final_message", "rollback_log")
|
|
2062
|
+
|
|
2063
|
+
def __init__(self) -> None:
|
|
2064
|
+
self.state: AgentState = AgentState.IDLE
|
|
2065
|
+
self.plan: dict = {}
|
|
2066
|
+
self.transcript: list = []
|
|
2067
|
+
self.retry_count: int = 0
|
|
2068
|
+
self.state_history: list = []
|
|
2069
|
+
self.corrections: list = []
|
|
2070
|
+
self.final_message: str = ""
|
|
2071
|
+
self.rollback_log: list = []
|
|
2072
|
+
|
|
2073
|
+
|
|
1987
2074
|
class ToolPathRequest(BaseModel):
|
|
1988
2075
|
path: str = "."
|
|
1989
2076
|
|
|
@@ -2009,6 +2096,33 @@ class ToolSearchFilesRequest(BaseModel):
|
|
|
2009
2096
|
max_results: int = 20
|
|
2010
2097
|
|
|
2011
2098
|
|
|
2099
|
+
class ToolReadFileRequest(BaseModel):
|
|
2100
|
+
path: str
|
|
2101
|
+
offset: int = 0
|
|
2102
|
+
limit: int = 0
|
|
2103
|
+
line_numbers: bool = True
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
class ToolEditFileRequest(BaseModel):
|
|
2107
|
+
path: str
|
|
2108
|
+
old_string: str
|
|
2109
|
+
new_string: str
|
|
2110
|
+
replace_all: bool = False
|
|
2111
|
+
|
|
2112
|
+
|
|
2113
|
+
class ToolGrepRequest(BaseModel):
|
|
2114
|
+
pattern: str
|
|
2115
|
+
path: str = "."
|
|
2116
|
+
glob: Optional[str] = None
|
|
2117
|
+
max_results: int = 50
|
|
2118
|
+
case_insensitive: bool = False
|
|
2119
|
+
context_lines: int = 0
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
class ToolTodoWriteRequest(BaseModel):
|
|
2123
|
+
todos: List[Dict] = []
|
|
2124
|
+
|
|
2125
|
+
|
|
2012
2126
|
class ToolWorkspaceTreeRequest(BaseModel):
|
|
2013
2127
|
path: str = "."
|
|
2014
2128
|
max_depth: int = 3
|
|
@@ -2171,6 +2285,7 @@ ENGINE_MODEL_CATALOG = {
|
|
|
2171
2285
|
{"id": "ollama:llama3.1:70b", "name": "Llama 3.1 70B via Ollama", "family": "Llama 3.1", "tag": "local-server", "size": "pull required", "pullable": True},
|
|
2172
2286
|
],
|
|
2173
2287
|
"vllm": [
|
|
2288
|
+
{"id": "vllm:Qwen/Qwen2.5-0.5B-Instruct-AWQ", "name": "Qwen 2.5 0.5B AWQ via vLLM", "family": "Qwen 2.5", "tag": "local-light", "size": "0.5B", "pullable": True},
|
|
2174
2289
|
{"id": "vllm:google/gemma-2-2b", "name": "Gemma 2 2B Base via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
|
|
2175
2290
|
{"id": "vllm:google/gemma-2-2b-it", "name": "Gemma 2 2B via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
|
|
2176
2291
|
{"id": "vllm:google/gemma-2-9b", "name": "Gemma 2 9B Base via vLLM", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
|
|
@@ -2186,6 +2301,7 @@ ENGINE_MODEL_CATALOG = {
|
|
|
2186
2301
|
{"id": "vllm:meta-llama/Llama-3.1-70B-Instruct", "name": "Llama 3.1 70B via vLLM", "family": "Llama 3.1", "tag": "local-server", "size": "server model", "pullable": True},
|
|
2187
2302
|
],
|
|
2188
2303
|
"lmstudio": [
|
|
2304
|
+
{"id": "lmstudio:https://huggingface.co/lmstudio-community/Qwen2.5-0.5B-Instruct-GGUF", "name": "Qwen 2.5 0.5B GGUF via LM Studio", "family": "Qwen 2.5", "tag": "local-light", "size": "0.5B", "pullable": True},
|
|
2189
2305
|
{"id": "lmstudio:google/gemma-2-2b-it", "name": "Gemma 2 2B via LM Studio", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
|
|
2190
2306
|
{"id": "lmstudio:google/gemma-2-9b-it", "name": "Gemma 2 9B via LM Studio", "family": "Gemma", "tag": "local-server", "size": "server model", "pullable": True},
|
|
2191
2307
|
{"id": "lmstudio:Qwen/Qwen2.5-3B-Instruct", "name": "Qwen 2.5 3B via LM Studio", "family": "Qwen 2.5", "tag": "local-server", "size": "server model", "pullable": True},
|
|
@@ -2199,9 +2315,9 @@ ENGINE_MODEL_CATALOG = {
|
|
|
2199
2315
|
{"id": "lmstudio:meta-llama/Llama-3.1-70B-Instruct", "name": "Llama 3.1 70B via LM Studio", "family": "Llama 3.1", "tag": "local-server", "size": "server model", "pullable": True},
|
|
2200
2316
|
],
|
|
2201
2317
|
"llamacpp": [
|
|
2318
|
+
{"id": "llamacpp:lmstudio-community/Qwen2.5-0.5B-Instruct-GGUF", "name": "Qwen 2.5 0.5B GGUF via llama.cpp", "family": "Qwen 2.5", "tag": "gguf-q4", "size": "0.5B", "pullable": True},
|
|
2202
2319
|
{"id": "llamacpp:unsloth/gemma-2-2b-it-GGUF", "name": "Gemma 2 2B GGUF via llama.cpp", "family": "Gemma", "tag": "gguf-q4", "size": "gguf", "pullable": True},
|
|
2203
2320
|
{"id": "llamacpp:unsloth/gemma-2-9b-it-GGUF", "name": "Gemma 2 9B GGUF via llama.cpp", "family": "Gemma", "tag": "gguf-q4", "size": "gguf", "pullable": True},
|
|
2204
|
-
{"id": "llamacpp:Qwen/Qwen2.5-3B-Instruct-GGUF", "name": "Qwen 2.5 3B GGUF via llama.cpp", "family": "Qwen 2.5", "tag": "gguf-q4", "size": "gguf", "pullable": True},
|
|
2205
2321
|
{"id": "llamacpp:Qwen/Qwen2.5-7B-Instruct-GGUF", "name": "Qwen 2.5 7B GGUF via llama.cpp", "family": "Qwen 2.5", "tag": "local-server", "size": "gguf", "pullable": True},
|
|
2206
2322
|
{"id": "llamacpp:Qwen/Qwen2.5-14B-Instruct-GGUF", "name": "Qwen 2.5 14B GGUF via llama.cpp", "family": "Qwen 2.5", "tag": "local-server", "size": "gguf", "pullable": True},
|
|
2207
2323
|
{"id": "llamacpp:Qwen/Qwen2.5-32B-Instruct-GGUF", "name": "Qwen 2.5 32B GGUF via llama.cpp", "family": "Qwen 2.5", "tag": "gguf-q4", "size": "gguf", "pullable": True},
|
|
@@ -2228,6 +2344,299 @@ def _update_env_file(env_file: Path, key: str, value: str) -> None:
|
|
|
2228
2344
|
env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
2229
2345
|
|
|
2230
2346
|
|
|
2347
|
+
LOCAL_SERVER_PROCESSES: Dict[str, subprocess.Popen] = {}
|
|
2348
|
+
VLLM_METAL_ENV = Path.home() / ".venv-vllm-metal"
|
|
2349
|
+
VLLM_METAL_BIN = VLLM_METAL_ENV / "bin" / "vllm"
|
|
2350
|
+
VLLM_METAL_PYTHON = VLLM_METAL_ENV / "bin" / "python"
|
|
2351
|
+
LMSTUDIO_BUNDLED_CLI = Path("/Applications/LM Studio.app/Contents/Resources/app/.webpack/lms")
|
|
2352
|
+
|
|
2353
|
+
def find_lmstudio_cli() -> Optional[str]:
|
|
2354
|
+
cli = shutil.which("lms")
|
|
2355
|
+
if cli:
|
|
2356
|
+
return cli
|
|
2357
|
+
if LMSTUDIO_BUNDLED_CLI.exists():
|
|
2358
|
+
return str(LMSTUDIO_BUNDLED_CLI)
|
|
2359
|
+
return None
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
def vllm_executable() -> Optional[str]:
|
|
2363
|
+
found = shutil.which("vllm")
|
|
2364
|
+
if found:
|
|
2365
|
+
return found
|
|
2366
|
+
if VLLM_METAL_BIN.exists():
|
|
2367
|
+
return str(VLLM_METAL_BIN)
|
|
2368
|
+
return None
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
def vllm_metal_python() -> Optional[str]:
|
|
2372
|
+
if VLLM_METAL_PYTHON.exists():
|
|
2373
|
+
return str(VLLM_METAL_PYTHON)
|
|
2374
|
+
return None
|
|
2375
|
+
|
|
2376
|
+
|
|
2377
|
+
def _json_request(
|
|
2378
|
+
url: str,
|
|
2379
|
+
*,
|
|
2380
|
+
method: str = "GET",
|
|
2381
|
+
payload: Optional[Dict[str, object]] = None,
|
|
2382
|
+
headers: Optional[Dict[str, str]] = None,
|
|
2383
|
+
timeout: float = 10.0,
|
|
2384
|
+
) -> Dict[str, object]:
|
|
2385
|
+
data = None
|
|
2386
|
+
req_headers = dict(headers or {})
|
|
2387
|
+
if payload is not None:
|
|
2388
|
+
data = json.dumps(payload).encode("utf-8")
|
|
2389
|
+
req_headers.setdefault("Content-Type", "application/json")
|
|
2390
|
+
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
|
2391
|
+
with urllib.request.urlopen(req, timeout=timeout) as res:
|
|
2392
|
+
raw = res.read().decode("utf-8", errors="replace")
|
|
2393
|
+
if not raw.strip():
|
|
2394
|
+
return {}
|
|
2395
|
+
return json.loads(raw)
|
|
2396
|
+
|
|
2397
|
+
|
|
2398
|
+
def lmstudio_api_base() -> str:
|
|
2399
|
+
return (os.getenv("LMSTUDIO_BASE_URL") or OPENAI_COMPATIBLE_PROVIDERS["lmstudio"]["base_url"]).rstrip("/")
|
|
2400
|
+
|
|
2401
|
+
|
|
2402
|
+
def lmstudio_native_api_base() -> str:
|
|
2403
|
+
base = lmstudio_api_base()
|
|
2404
|
+
return base[:-3] if base.endswith("/v1") else base
|
|
2405
|
+
|
|
2406
|
+
|
|
2407
|
+
def ensure_lmstudio_server() -> None:
|
|
2408
|
+
base_url = lmstudio_native_api_base()
|
|
2409
|
+
try:
|
|
2410
|
+
_json_request(f"{base_url}/api/v1/models", headers={"Authorization": "Bearer lmstudio"}, timeout=2.5)
|
|
2411
|
+
return
|
|
2412
|
+
except Exception:
|
|
2413
|
+
pass
|
|
2414
|
+
|
|
2415
|
+
cli = find_lmstudio_cli()
|
|
2416
|
+
if not cli:
|
|
2417
|
+
raise HTTPException(status_code=400, detail="LM Studio CLI를 찾지 못했습니다. LM Studio를 설치한 뒤 다시 시도하세요.")
|
|
2418
|
+
|
|
2419
|
+
try:
|
|
2420
|
+
subprocess.Popen(
|
|
2421
|
+
[cli, "server", "start"],
|
|
2422
|
+
stdout=subprocess.DEVNULL,
|
|
2423
|
+
stderr=subprocess.DEVNULL,
|
|
2424
|
+
start_new_session=True,
|
|
2425
|
+
)
|
|
2426
|
+
except Exception as e:
|
|
2427
|
+
raise HTTPException(status_code=500, detail=f"LM Studio 서버 시작 실패: {e}")
|
|
2428
|
+
|
|
2429
|
+
deadline = time.time() + 45
|
|
2430
|
+
while time.time() < deadline:
|
|
2431
|
+
try:
|
|
2432
|
+
_json_request(f"{base_url}/api/v1/models", headers={"Authorization": "Bearer lmstudio"}, timeout=2.5)
|
|
2433
|
+
return
|
|
2434
|
+
except Exception:
|
|
2435
|
+
time.sleep(1)
|
|
2436
|
+
raise HTTPException(status_code=500, detail="LM Studio Local Server를 자동으로 시작하지 못했습니다.")
|
|
2437
|
+
|
|
2438
|
+
|
|
2439
|
+
_LMSTUDIO_MODELS_CACHE: List[Dict[str, object]] = []
|
|
2440
|
+
_LMSTUDIO_MODELS_CACHE_TS: float = 0.0
|
|
2441
|
+
_LMSTUDIO_MODELS_CACHE_TTL: float = 10.0
|
|
2442
|
+
|
|
2443
|
+
|
|
2444
|
+
def get_lmstudio_models(*, force: bool = False) -> List[Dict[str, object]]:
|
|
2445
|
+
global _LMSTUDIO_MODELS_CACHE, _LMSTUDIO_MODELS_CACHE_TS
|
|
2446
|
+
if not force and time.monotonic() - _LMSTUDIO_MODELS_CACHE_TS < _LMSTUDIO_MODELS_CACHE_TTL:
|
|
2447
|
+
return _LMSTUDIO_MODELS_CACHE
|
|
2448
|
+
try:
|
|
2449
|
+
ensure_lmstudio_server()
|
|
2450
|
+
except HTTPException:
|
|
2451
|
+
return _LMSTUDIO_MODELS_CACHE
|
|
2452
|
+
try:
|
|
2453
|
+
payload = _json_request(
|
|
2454
|
+
f"{lmstudio_native_api_base()}/api/v1/models",
|
|
2455
|
+
headers={"Authorization": f"Bearer {os.getenv('LMSTUDIO_API_KEY') or 'lmstudio'}"},
|
|
2456
|
+
timeout=5,
|
|
2457
|
+
)
|
|
2458
|
+
except Exception:
|
|
2459
|
+
return _LMSTUDIO_MODELS_CACHE
|
|
2460
|
+
models = payload.get("models")
|
|
2461
|
+
_LMSTUDIO_MODELS_CACHE = models if isinstance(models, list) else []
|
|
2462
|
+
_LMSTUDIO_MODELS_CACHE_TS = time.monotonic()
|
|
2463
|
+
return _LMSTUDIO_MODELS_CACHE
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def _lmstudio_candidate_keys(model_name: str) -> List[str]:
|
|
2467
|
+
raw = model_name.strip()
|
|
2468
|
+
if not raw:
|
|
2469
|
+
return []
|
|
2470
|
+
slug = raw.split("/")[-1].lower()
|
|
2471
|
+
slug = slug.replace("-gguf", "").replace("-awq", "")
|
|
2472
|
+
parts = [p for p in slug.split("-") if p]
|
|
2473
|
+
candidates = [raw.lower(), slug]
|
|
2474
|
+
if parts:
|
|
2475
|
+
candidates.append("-".join(parts[: min(4, len(parts))]))
|
|
2476
|
+
return list(dict.fromkeys(candidates))
|
|
2477
|
+
|
|
2478
|
+
|
|
2479
|
+
def _find_lmstudio_model_key(model_name: str, models: List[Dict[str, object]]) -> Optional[str]:
|
|
2480
|
+
if not models:
|
|
2481
|
+
return None
|
|
2482
|
+
candidate_keys = _lmstudio_candidate_keys(model_name)
|
|
2483
|
+
exact = []
|
|
2484
|
+
fuzzy = []
|
|
2485
|
+
for item in models:
|
|
2486
|
+
if not isinstance(item, dict):
|
|
2487
|
+
continue
|
|
2488
|
+
key = str(item.get("key") or "").strip()
|
|
2489
|
+
display_name = str(item.get("display_name") or "").strip()
|
|
2490
|
+
haystacks = [key.lower(), display_name.lower()]
|
|
2491
|
+
if any(raw == key.lower() for raw in candidate_keys):
|
|
2492
|
+
exact.append(key)
|
|
2493
|
+
continue
|
|
2494
|
+
if any(token and token in hay for token in candidate_keys for hay in haystacks):
|
|
2495
|
+
fuzzy.append(key)
|
|
2496
|
+
return (exact or fuzzy or [None])[0]
|
|
2497
|
+
|
|
2498
|
+
|
|
2499
|
+
def ensure_lmstudio_model(model_name: str) -> Dict[str, object]:
|
|
2500
|
+
ensure_lmstudio_server()
|
|
2501
|
+
auth_header = {"Authorization": f"Bearer {os.getenv('LMSTUDIO_API_KEY') or 'lmstudio'}"}
|
|
2502
|
+
models = get_lmstudio_models()
|
|
2503
|
+
found_key = _find_lmstudio_model_key(model_name, models)
|
|
2504
|
+
model_key = found_key or model_name
|
|
2505
|
+
|
|
2506
|
+
if not found_key:
|
|
2507
|
+
try:
|
|
2508
|
+
job = _json_request(
|
|
2509
|
+
f"{lmstudio_native_api_base()}/api/v1/models/download",
|
|
2510
|
+
method="POST",
|
|
2511
|
+
payload={"model": model_name},
|
|
2512
|
+
headers=auth_header,
|
|
2513
|
+
timeout=30,
|
|
2514
|
+
)
|
|
2515
|
+
except urllib.error.HTTPError as e:
|
|
2516
|
+
detail = e.read().decode("utf-8", errors="replace")[-2000:]
|
|
2517
|
+
raise HTTPException(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {detail or e.reason}")
|
|
2518
|
+
except Exception as e:
|
|
2519
|
+
raise HTTPException(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {e}")
|
|
2520
|
+
|
|
2521
|
+
status = str(job.get("status") or "")
|
|
2522
|
+
job_id = str(job.get("job_id") or "")
|
|
2523
|
+
if status not in {"completed", "already_downloaded"} and job_id:
|
|
2524
|
+
deadline = time.time() + 3600
|
|
2525
|
+
while time.time() < deadline:
|
|
2526
|
+
polled = _json_request(
|
|
2527
|
+
f"{lmstudio_native_api_base()}/api/v1/models/download/status/{job_id}",
|
|
2528
|
+
headers=auth_header,
|
|
2529
|
+
timeout=30,
|
|
2530
|
+
)
|
|
2531
|
+
polled_status = str(polled.get("status") or "")
|
|
2532
|
+
if polled_status == "completed":
|
|
2533
|
+
break
|
|
2534
|
+
if polled_status == "failed":
|
|
2535
|
+
raise HTTPException(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {polled}")
|
|
2536
|
+
time.sleep(2)
|
|
2537
|
+
else:
|
|
2538
|
+
raise HTTPException(status_code=408, detail="LM Studio 모델 다운로드 시간이 초과되었습니다.")
|
|
2539
|
+
|
|
2540
|
+
models = get_lmstudio_models(force=True)
|
|
2541
|
+
model_key = _find_lmstudio_model_key(model_name, models) or model_name
|
|
2542
|
+
|
|
2543
|
+
target = next((item for item in models if isinstance(item, dict) and item.get("key") == model_key), None)
|
|
2544
|
+
loaded_instances = target.get("loaded_instances") if isinstance(target, dict) else None
|
|
2545
|
+
if loaded_instances:
|
|
2546
|
+
return {"provider": "lmstudio", "model": model_name, "resolved_model": model_key, "server_ready": True, "cached": True}
|
|
2547
|
+
|
|
2548
|
+
try:
|
|
2549
|
+
loaded = _json_request(
|
|
2550
|
+
f"{lmstudio_native_api_base()}/api/v1/models/load",
|
|
2551
|
+
method="POST",
|
|
2552
|
+
payload={"model": model_key, "context_length": 4096},
|
|
2553
|
+
headers=auth_header,
|
|
2554
|
+
timeout=120,
|
|
2555
|
+
)
|
|
2556
|
+
except urllib.error.HTTPError as e:
|
|
2557
|
+
detail = e.read().decode("utf-8", errors="replace")[-2000:]
|
|
2558
|
+
raise HTTPException(status_code=500, detail=f"LM Studio 모델 로드 실패: {detail or e.reason}")
|
|
2559
|
+
except Exception as e:
|
|
2560
|
+
raise HTTPException(status_code=500, detail=f"LM Studio 모델 로드 실패: {e}")
|
|
2561
|
+
|
|
2562
|
+
if str(loaded.get("status") or "") != "loaded":
|
|
2563
|
+
raise HTTPException(status_code=500, detail=f"LM Studio 모델 로드 실패: {loaded}")
|
|
2564
|
+
|
|
2565
|
+
return {
|
|
2566
|
+
"provider": "lmstudio",
|
|
2567
|
+
"model": model_name,
|
|
2568
|
+
"resolved_model": model_key,
|
|
2569
|
+
"instance_id": loaded.get("instance_id"),
|
|
2570
|
+
"server_ready": True,
|
|
2571
|
+
"cached": False,
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
def engine_support_status(engine: str) -> Dict[str, object]:
|
|
2575
|
+
if engine != "vllm":
|
|
2576
|
+
return {"supported": True, "reason": None}
|
|
2577
|
+
is_apple_silicon = sys.platform == "darwin" and platform.machine() == "arm64"
|
|
2578
|
+
if sys.platform == "darwin" and not is_apple_silicon:
|
|
2579
|
+
return {"supported": False, "reason": "vLLM Metal 자동 설치는 Apple Silicon macOS에서만 지원됩니다."}
|
|
2580
|
+
if sys.version_info >= (3, 13) and is_apple_silicon:
|
|
2581
|
+
return {"supported": True, "reason": "현재 환경에서는 vLLM Metal 전용 런타임으로 설치합니다."}
|
|
2582
|
+
if sys.version_info >= (3, 13):
|
|
2583
|
+
return {"supported": False, "reason": "vLLM 설치는 현재 Python 3.13 이하 또는 별도 전용 런타임이 필요합니다."}
|
|
2584
|
+
return {"supported": True, "reason": None}
|
|
2585
|
+
|
|
2586
|
+
def hf_model_ready(repo_id: str, provider: str = "local_mlx") -> bool:
|
|
2587
|
+
model_dir = hf_model_dir(repo_id)
|
|
2588
|
+
if provider == "vllm" and (not model_dir.exists() or not model_dir.is_dir()):
|
|
2589
|
+
hf_cache_repo = Path.home() / ".cache" / "huggingface" / "hub" / f"models--{repo_id.replace('/', '--')}"
|
|
2590
|
+
if hf_cache_repo.exists() and any(hf_cache_repo.glob("snapshots/*")):
|
|
2591
|
+
return True
|
|
2592
|
+
return False
|
|
2593
|
+
if not model_dir.exists() or not model_dir.is_dir():
|
|
2594
|
+
return False
|
|
2595
|
+
if provider == "llamacpp":
|
|
2596
|
+
return any(model_dir.rglob("*.gguf"))
|
|
2597
|
+
has_config = (model_dir / "config.json").exists()
|
|
2598
|
+
has_weights = any(model_dir.glob("*.safetensors")) or any(model_dir.glob("*.bin"))
|
|
2599
|
+
has_tokenizer = (
|
|
2600
|
+
(model_dir / "tokenizer.json").exists()
|
|
2601
|
+
or (model_dir / "tokenizer.model").exists()
|
|
2602
|
+
or (model_dir / "tokenizer_config.json").exists()
|
|
2603
|
+
)
|
|
2604
|
+
return has_config and has_weights and has_tokenizer
|
|
2605
|
+
|
|
2606
|
+
def download_hf_model(repo_id: str, provider: str = "local_mlx") -> Dict[str, object]:
|
|
2607
|
+
if importlib.util.find_spec("huggingface_hub") is None:
|
|
2608
|
+
raise HTTPException(status_code=400, detail="huggingface_hub가 없습니다. 먼저 MLX runtime 설치를 진행해 주세요.")
|
|
2609
|
+
|
|
2610
|
+
target_dir = hf_model_dir(repo_id)
|
|
2611
|
+
if hf_model_ready(repo_id, provider):
|
|
2612
|
+
return {"model": repo_id, "path": str(target_dir), "cached": True}
|
|
2613
|
+
|
|
2614
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
2615
|
+
try:
|
|
2616
|
+
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
|
|
2617
|
+
|
|
2618
|
+
if provider == "llamacpp":
|
|
2619
|
+
files = HfApi().list_repo_files(repo_id)
|
|
2620
|
+
ggufs = sorted([name for name in files if name.lower().endswith(".gguf")])
|
|
2621
|
+
if not ggufs:
|
|
2622
|
+
raise RuntimeError("GGUF 파일을 찾지 못했습니다.")
|
|
2623
|
+
preference = ("q4_k_m", "q4_0", "q4_k_s", "q3_k_m", "q2_k")
|
|
2624
|
+
filename = next(
|
|
2625
|
+
(name for pref in preference for name in ggufs if pref in name.lower()),
|
|
2626
|
+
ggufs[0],
|
|
2627
|
+
)
|
|
2628
|
+
hf_hub_download(repo_id=repo_id, filename=filename, local_dir=str(target_dir))
|
|
2629
|
+
else:
|
|
2630
|
+
snapshot_download(repo_id=repo_id, local_dir=str(target_dir), resume_download=True)
|
|
2631
|
+
except Exception as e:
|
|
2632
|
+
raise HTTPException(status_code=500, detail=f"{repo_id} 다운로드 실패: {str(e)[-2000:]}")
|
|
2633
|
+
|
|
2634
|
+
if not hf_model_ready(repo_id, provider):
|
|
2635
|
+
raise HTTPException(status_code=500, detail=f"{repo_id} 다운로드가 완료되지 않았습니다. 모델 파일을 찾지 못했습니다.")
|
|
2636
|
+
|
|
2637
|
+
return {"model": repo_id, "path": str(target_dir), "cached": False}
|
|
2638
|
+
|
|
2639
|
+
|
|
2231
2640
|
def get_ollama_pulled_models() -> set:
|
|
2232
2641
|
if not shutil.which("ollama"):
|
|
2233
2642
|
return set()
|
|
@@ -2243,15 +2652,183 @@ def get_ollama_pulled_models() -> set:
|
|
|
2243
2652
|
return set()
|
|
2244
2653
|
|
|
2245
2654
|
|
|
2655
|
+
def get_openai_compatible_server_models(provider: str) -> List[str]:
|
|
2656
|
+
if provider == "lmstudio":
|
|
2657
|
+
models = []
|
|
2658
|
+
for item in get_lmstudio_models():
|
|
2659
|
+
if not isinstance(item, dict):
|
|
2660
|
+
continue
|
|
2661
|
+
key = str(item.get("key") or "").strip()
|
|
2662
|
+
loaded_instances = item.get("loaded_instances") or []
|
|
2663
|
+
if loaded_instances:
|
|
2664
|
+
instance_ids = [
|
|
2665
|
+
str(instance.get("id") or "").strip()
|
|
2666
|
+
for instance in loaded_instances
|
|
2667
|
+
if isinstance(instance, dict) and instance.get("id")
|
|
2668
|
+
]
|
|
2669
|
+
models.extend(instance_ids or ([key] if key else []))
|
|
2670
|
+
return list(dict.fromkeys([model for model in models if model]))
|
|
2671
|
+
|
|
2672
|
+
config = OPENAI_COMPATIBLE_PROVIDERS.get(provider) or {}
|
|
2673
|
+
base_url = os.getenv(config.get("base_url_env", "")) if config.get("base_url_env") else None
|
|
2674
|
+
base_url = (base_url or config.get("base_url") or "").rstrip("/")
|
|
2675
|
+
if not base_url:
|
|
2676
|
+
return []
|
|
2677
|
+
|
|
2678
|
+
api_key = os.getenv(config.get("env_key", "")) or config.get("api_key_fallback") or provider
|
|
2679
|
+
req = urllib.request.Request(
|
|
2680
|
+
f"{base_url}/models",
|
|
2681
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
2682
|
+
method="GET",
|
|
2683
|
+
)
|
|
2684
|
+
try:
|
|
2685
|
+
with urllib.request.urlopen(req, timeout=2.5) as res:
|
|
2686
|
+
payload = json.loads(res.read().decode("utf-8", errors="replace"))
|
|
2687
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
|
|
2688
|
+
return []
|
|
2689
|
+
|
|
2690
|
+
models = []
|
|
2691
|
+
for item in payload.get("data") or []:
|
|
2692
|
+
model_id = item.get("id") if isinstance(item, dict) else None
|
|
2693
|
+
if model_id:
|
|
2694
|
+
models.append(str(model_id))
|
|
2695
|
+
return models
|
|
2696
|
+
|
|
2697
|
+
|
|
2698
|
+
def ensure_ollama_server() -> None:
|
|
2699
|
+
if not shutil.which("ollama"):
|
|
2700
|
+
raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
|
|
2701
|
+
try:
|
|
2702
|
+
probe = subprocess.run(["ollama", "list"], capture_output=True, text=True, timeout=3, check=False)
|
|
2703
|
+
if probe.returncode == 0:
|
|
2704
|
+
return
|
|
2705
|
+
except Exception:
|
|
2706
|
+
pass
|
|
2707
|
+
subprocess.Popen(
|
|
2708
|
+
["ollama", "serve"],
|
|
2709
|
+
stdout=subprocess.DEVNULL,
|
|
2710
|
+
stderr=subprocess.DEVNULL,
|
|
2711
|
+
start_new_session=True,
|
|
2712
|
+
)
|
|
2713
|
+
deadline = time.time() + 20
|
|
2714
|
+
while time.time() < deadline:
|
|
2715
|
+
try:
|
|
2716
|
+
probe = subprocess.run(["ollama", "list"], capture_output=True, text=True, timeout=3, check=False)
|
|
2717
|
+
if probe.returncode == 0:
|
|
2718
|
+
return
|
|
2719
|
+
except Exception:
|
|
2720
|
+
pass
|
|
2721
|
+
time.sleep(0.5)
|
|
2722
|
+
raise HTTPException(status_code=500, detail="Ollama 서버를 자동으로 시작하지 못했습니다.")
|
|
2723
|
+
|
|
2724
|
+
|
|
2725
|
+
def wait_for_openai_compatible_server(provider: str, model_name: Optional[str] = None, timeout: int = 45) -> bool:
|
|
2726
|
+
deadline = time.time() + timeout
|
|
2727
|
+
while time.time() < deadline:
|
|
2728
|
+
models = get_openai_compatible_server_models(provider)
|
|
2729
|
+
if models and (not model_name or model_name in models):
|
|
2730
|
+
return True
|
|
2731
|
+
time.sleep(1)
|
|
2732
|
+
return False
|
|
2733
|
+
|
|
2734
|
+
|
|
2735
|
+
def ensure_vllm_server(model_name: str) -> None:
|
|
2736
|
+
served_models = get_openai_compatible_server_models("vllm")
|
|
2737
|
+
if model_name in served_models:
|
|
2738
|
+
return
|
|
2739
|
+
vllm_bin = vllm_executable()
|
|
2740
|
+
vllm_metal_py = vllm_metal_python()
|
|
2741
|
+
if not vllm_bin and not vllm_metal_py and importlib.util.find_spec("vllm") is None:
|
|
2742
|
+
raise HTTPException(status_code=400, detail="vLLM runtime이 설치되지 않았습니다.")
|
|
2743
|
+
|
|
2744
|
+
local_dir = hf_model_dir(model_name)
|
|
2745
|
+
if not vllm_metal_py and not hf_model_ready(model_name, "vllm"):
|
|
2746
|
+
download_hf_model(model_name, "vllm")
|
|
2747
|
+
|
|
2748
|
+
running = LOCAL_SERVER_PROCESSES.get("vllm")
|
|
2749
|
+
if running and running.poll() is None:
|
|
2750
|
+
running.terminate()
|
|
2751
|
+
try:
|
|
2752
|
+
running.wait(timeout=10)
|
|
2753
|
+
except subprocess.TimeoutExpired:
|
|
2754
|
+
running.kill()
|
|
2755
|
+
elif served_models:
|
|
2756
|
+
raise HTTPException(status_code=409, detail="다른 vLLM 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
|
|
2757
|
+
|
|
2758
|
+
running = LOCAL_SERVER_PROCESSES.get("vllm")
|
|
2759
|
+
if running and running.poll() is None:
|
|
2760
|
+
return
|
|
2761
|
+
|
|
2762
|
+
_host_args = ["--host", "127.0.0.1", "--port", "8000"]
|
|
2763
|
+
if vllm_metal_py:
|
|
2764
|
+
command = [vllm_metal_py, "-m", "vllm_metal.server", "--model", model_name, *_host_args]
|
|
2765
|
+
elif vllm_bin:
|
|
2766
|
+
command = [vllm_bin, "serve", str(local_dir), "--served-model-name", model_name, *_host_args]
|
|
2767
|
+
else:
|
|
2768
|
+
command = [sys.executable, "-m", "vllm.entrypoints.openai.api_server", "--model", str(local_dir), "--served-model-name", model_name, *_host_args]
|
|
2769
|
+
LOCAL_SERVER_PROCESSES["vllm"] = subprocess.Popen(
|
|
2770
|
+
command,
|
|
2771
|
+
stdout=subprocess.DEVNULL,
|
|
2772
|
+
stderr=subprocess.DEVNULL,
|
|
2773
|
+
start_new_session=True,
|
|
2774
|
+
)
|
|
2775
|
+
if not wait_for_openai_compatible_server("vllm", model_name, timeout=90):
|
|
2776
|
+
raise HTTPException(status_code=500, detail="vLLM 서버가 모델을 자동 로드하지 못했습니다.")
|
|
2777
|
+
|
|
2778
|
+
|
|
2779
|
+
def ensure_llamacpp_server(model_name: str) -> None:
|
|
2780
|
+
served_models = get_openai_compatible_server_models("llamacpp")
|
|
2781
|
+
if model_name in served_models:
|
|
2782
|
+
return
|
|
2783
|
+
running = LOCAL_SERVER_PROCESSES.get("llamacpp")
|
|
2784
|
+
if running and running.poll() is None:
|
|
2785
|
+
running.terminate()
|
|
2786
|
+
try:
|
|
2787
|
+
running.wait(timeout=10)
|
|
2788
|
+
except subprocess.TimeoutExpired:
|
|
2789
|
+
running.kill()
|
|
2790
|
+
elif served_models:
|
|
2791
|
+
raise HTTPException(status_code=409, detail="다른 llama.cpp 서버가 이미 실행 중입니다. 현재 서버를 종료한 뒤 다시 시도하세요.")
|
|
2792
|
+
if not shutil.which("llama-server"):
|
|
2793
|
+
raise HTTPException(status_code=400, detail="llama.cpp가 설치되지 않았습니다.")
|
|
2794
|
+
if not hf_model_ready(model_name, "llamacpp"):
|
|
2795
|
+
download_hf_model(model_name, "llamacpp")
|
|
2796
|
+
|
|
2797
|
+
gguf_files = sorted(hf_model_dir(model_name).rglob("*.gguf"))
|
|
2798
|
+
if not gguf_files:
|
|
2799
|
+
raise HTTPException(status_code=500, detail="다운로드된 GGUF 파일을 찾지 못했습니다.")
|
|
2800
|
+
|
|
2801
|
+
preferred = next((p for p in gguf_files if "q4_k_m" in p.name.lower()), None)
|
|
2802
|
+
model_file = preferred or gguf_files[0]
|
|
2803
|
+
LOCAL_SERVER_PROCESSES["llamacpp"] = subprocess.Popen(
|
|
2804
|
+
[
|
|
2805
|
+
"llama-server",
|
|
2806
|
+
"-m",
|
|
2807
|
+
str(model_file),
|
|
2808
|
+
"--alias",
|
|
2809
|
+
model_name,
|
|
2810
|
+
"--host",
|
|
2811
|
+
"127.0.0.1",
|
|
2812
|
+
"--port",
|
|
2813
|
+
"8080",
|
|
2814
|
+
],
|
|
2815
|
+
stdout=subprocess.DEVNULL,
|
|
2816
|
+
stderr=subprocess.DEVNULL,
|
|
2817
|
+
start_new_session=True,
|
|
2818
|
+
)
|
|
2819
|
+
if not wait_for_openai_compatible_server("llamacpp", model_name, timeout=45):
|
|
2820
|
+
raise HTTPException(status_code=500, detail="llama.cpp 서버가 모델을 자동 로드하지 못했습니다.")
|
|
2821
|
+
|
|
2822
|
+
|
|
2246
2823
|
def engine_installed(engine: str) -> bool:
|
|
2247
2824
|
if engine == "local_mlx":
|
|
2248
|
-
return bool(
|
|
2825
|
+
return bool(importlib.util.find_spec("mlx") and importlib.util.find_spec("mlx_lm"))
|
|
2249
2826
|
if engine == "ollama":
|
|
2250
2827
|
return shutil.which("ollama") is not None
|
|
2251
2828
|
if engine == "vllm":
|
|
2252
|
-
return importlib.util.find_spec("vllm") is not None
|
|
2829
|
+
return vllm_metal_python() is not None or vllm_executable() is not None or importlib.util.find_spec("vllm") is not None
|
|
2253
2830
|
if engine == "lmstudio":
|
|
2254
|
-
return
|
|
2831
|
+
return find_lmstudio_cli() is not None or Path("/Applications/LM Studio.app").exists()
|
|
2255
2832
|
if engine == "llamacpp":
|
|
2256
2833
|
return shutil.which("llama-server") is not None
|
|
2257
2834
|
if engine in {"openai", "openrouter", "groq", "together", "xai"}:
|
|
@@ -2271,31 +2848,52 @@ def engine_status() -> List[Dict]:
|
|
|
2271
2848
|
pull_name = m["id"].removeprefix("ollama:")
|
|
2272
2849
|
ollama_models.append({**m, "pulled": pull_name in pulled})
|
|
2273
2850
|
|
|
2274
|
-
|
|
2275
|
-
hf_models_root.mkdir(parents=True, exist_ok=True)
|
|
2851
|
+
HF_MODELS_ROOT.mkdir(parents=True, exist_ok=True)
|
|
2276
2852
|
mlx_models = []
|
|
2277
2853
|
for m in ENGINE_MODEL_CATALOG.get("local_mlx", []):
|
|
2278
2854
|
repo_id = m["id"]
|
|
2279
|
-
|
|
2280
|
-
mlx_models.append({**m, "pulled": marker.exists()})
|
|
2855
|
+
mlx_models.append({**m, "pulled": hf_model_ready(repo_id, "local_mlx")})
|
|
2281
2856
|
|
|
2282
2857
|
vllm_models = []
|
|
2283
2858
|
for m in ENGINE_MODEL_CATALOG.get("vllm", []):
|
|
2284
2859
|
repo_id = m["id"].removeprefix("vllm:")
|
|
2285
|
-
|
|
2286
|
-
vllm_models.append({**m, "pulled": marker.exists()})
|
|
2860
|
+
vllm_models.append({**m, "pulled": hf_model_ready(repo_id, "vllm")})
|
|
2287
2861
|
|
|
2288
2862
|
lmstudio_models = []
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2863
|
+
downloaded_lmstudio = get_lmstudio_models()
|
|
2864
|
+
downloaded_by_key = {}
|
|
2865
|
+
for item in downloaded_lmstudio:
|
|
2866
|
+
if not isinstance(item, dict):
|
|
2867
|
+
continue
|
|
2868
|
+
key = str(item.get("key") or "").strip()
|
|
2869
|
+
if not key:
|
|
2870
|
+
continue
|
|
2871
|
+
downloaded_by_key[key] = item
|
|
2872
|
+
loaded_instances = item.get("loaded_instances") or []
|
|
2873
|
+
lmstudio_models.append({
|
|
2874
|
+
"id": f"lmstudio:{key}",
|
|
2875
|
+
"name": item.get("display_name") or f"LM Studio · {key}",
|
|
2876
|
+
"family": item.get("architecture") or item.get("publisher") or "LM Studio",
|
|
2877
|
+
"tag": "loaded-server-model" if loaded_instances else "downloaded",
|
|
2878
|
+
"size": item.get("params_string") or item.get("format") or "LM Studio",
|
|
2879
|
+
"pullable": True,
|
|
2880
|
+
"pulled": True,
|
|
2881
|
+
})
|
|
2882
|
+
|
|
2883
|
+
if not lmstudio_models:
|
|
2884
|
+
for m in ENGINE_MODEL_CATALOG.get("lmstudio", []):
|
|
2885
|
+
lmstudio_models.append({**m, "pulled": False})
|
|
2886
|
+
else:
|
|
2887
|
+
known_ids = {item["id"] for item in lmstudio_models}
|
|
2888
|
+
for m in ENGINE_MODEL_CATALOG.get("lmstudio", []):
|
|
2889
|
+
repo_id = m["id"].removeprefix("lmstudio:")
|
|
2890
|
+
if f"lmstudio:{repo_id}" not in known_ids and repo_id not in downloaded_by_key:
|
|
2891
|
+
lmstudio_models.append({**m, "pulled": False})
|
|
2293
2892
|
|
|
2294
2893
|
llamacpp_models = []
|
|
2295
2894
|
for m in ENGINE_MODEL_CATALOG.get("llamacpp", []):
|
|
2296
2895
|
repo_id = m["id"].removeprefix("llamacpp:")
|
|
2297
|
-
|
|
2298
|
-
llamacpp_models.append({**m, "pulled": marker.exists()})
|
|
2896
|
+
llamacpp_models.append({**m, "pulled": hf_model_ready(repo_id, "llamacpp")})
|
|
2299
2897
|
|
|
2300
2898
|
local_server_specs = [
|
|
2301
2899
|
{
|
|
@@ -2303,12 +2901,19 @@ def engine_status() -> List[Dict]:
|
|
|
2303
2901
|
"name": "vLLM",
|
|
2304
2902
|
"description": "vLLM OpenAI 호환 서버(예: http://localhost:8000/v1)에 연결합니다.",
|
|
2305
2903
|
"requires": "VLLM_BASE_URL",
|
|
2904
|
+
"note": engine_support_status("vllm").get("reason"),
|
|
2306
2905
|
},
|
|
2307
2906
|
{
|
|
2308
2907
|
"id": "lmstudio",
|
|
2309
2908
|
"name": "LM Studio",
|
|
2310
2909
|
"description": "LM Studio 로컬 OpenAI 호환 서버에 연결합니다.",
|
|
2311
2910
|
"requires": "LMSTUDIO_BASE_URL",
|
|
2911
|
+
"note": (
|
|
2912
|
+
"다운로드된 모델은 자동 감지하고, 선택 시 필요하면 다운로드 후 바로 로드합니다."
|
|
2913
|
+
if downloaded_lmstudio else
|
|
2914
|
+
"LM Studio 설치 후 모델을 선택하면 Local Server 시작, 다운로드, 로드를 자동으로 진행합니다."
|
|
2915
|
+
),
|
|
2916
|
+
"server_ready": bool(downloaded_lmstudio),
|
|
2312
2917
|
},
|
|
2313
2918
|
{
|
|
2314
2919
|
"id": "llamacpp",
|
|
@@ -2341,13 +2946,16 @@ def engine_status() -> List[Dict]:
|
|
|
2341
2946
|
},
|
|
2342
2947
|
]
|
|
2343
2948
|
for spec in local_server_specs:
|
|
2949
|
+
support = engine_support_status(spec["id"])
|
|
2344
2950
|
engines.append({
|
|
2345
2951
|
"id": spec["id"],
|
|
2346
2952
|
"name": spec["name"],
|
|
2347
2953
|
"kind": "local-server",
|
|
2348
2954
|
"description": spec["description"],
|
|
2349
2955
|
"installed": engine_installed(spec["id"]),
|
|
2350
|
-
"
|
|
2956
|
+
"supported": support["supported"],
|
|
2957
|
+
"support_reason": support["reason"],
|
|
2958
|
+
"installable": support["supported"] and spec["id"] in ENGINE_INSTALLERS,
|
|
2351
2959
|
"install_label": ENGINE_INSTALLERS.get(spec["id"], {}).get("label"),
|
|
2352
2960
|
"requires": spec["requires"],
|
|
2353
2961
|
"models": (
|
|
@@ -2356,7 +2964,8 @@ def engine_status() -> List[Dict]:
|
|
|
2356
2964
|
else llamacpp_models if spec["id"] == "llamacpp"
|
|
2357
2965
|
else ENGINE_MODEL_CATALOG.get(spec["id"], [])
|
|
2358
2966
|
),
|
|
2359
|
-
"note": f"{spec['requires']} 설정 시 활성화됩니다.",
|
|
2967
|
+
"note": spec.get("note") or support["reason"] or f"{spec['requires']} 설정 시 활성화됩니다.",
|
|
2968
|
+
"server_ready": spec.get("server_ready"),
|
|
2360
2969
|
})
|
|
2361
2970
|
for provider in ["openai", "openrouter", "groq", "together", "xai"]:
|
|
2362
2971
|
env_key = next((item.get("requires") for item in cloud_by_provider.get(provider, []) if item.get("requires")), None)
|
|
@@ -2423,20 +3032,32 @@ def install_engine(engine: str) -> Dict:
|
|
|
2423
3032
|
required_binary = installer.get("requires_binary")
|
|
2424
3033
|
if required_binary and shutil.which(required_binary) is None:
|
|
2425
3034
|
raise HTTPException(status_code=400, detail=f"{required_binary}가 설치되어 있지 않아 자동 설치할 수 없습니다.")
|
|
3035
|
+
command = installer["command"]
|
|
3036
|
+
run_kwargs = {
|
|
3037
|
+
"cwd": str(Path(__file__).resolve().parent),
|
|
3038
|
+
"capture_output": True,
|
|
3039
|
+
"text": True,
|
|
3040
|
+
"timeout": 900,
|
|
3041
|
+
"check": False,
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
if engine == "vllm" and sys.platform == "darwin" and platform.machine() == "arm64":
|
|
3045
|
+
command = [
|
|
3046
|
+
"/bin/bash",
|
|
3047
|
+
"-lc",
|
|
3048
|
+
"set -euo pipefail; "
|
|
3049
|
+
"if [ ! -x /opt/homebrew/bin/python3.12 ]; then brew install python@3.12; fi; "
|
|
3050
|
+
"/opt/homebrew/bin/python3.12 -m venv ~/.venv-vllm-metal; "
|
|
3051
|
+
"~/.venv-vllm-metal/bin/pip install -U pip setuptools wheel; "
|
|
3052
|
+
"~/.venv-vllm-metal/bin/pip install vllm-metal",
|
|
3053
|
+
]
|
|
2426
3054
|
try:
|
|
2427
|
-
completed = subprocess.run(
|
|
2428
|
-
installer["command"],
|
|
2429
|
-
cwd=str(Path(__file__).resolve().parent),
|
|
2430
|
-
capture_output=True,
|
|
2431
|
-
text=True,
|
|
2432
|
-
timeout=900,
|
|
2433
|
-
check=False,
|
|
2434
|
-
)
|
|
3055
|
+
completed = subprocess.run(command, **run_kwargs)
|
|
2435
3056
|
except subprocess.TimeoutExpired:
|
|
2436
3057
|
raise HTTPException(status_code=408, detail="엔진 설치 시간이 초과되었습니다.")
|
|
2437
3058
|
result = {
|
|
2438
3059
|
"engine": engine,
|
|
2439
|
-
"command": " ".join(
|
|
3060
|
+
"command": " ".join(command),
|
|
2440
3061
|
"returncode": completed.returncode,
|
|
2441
3062
|
"stdout": completed.stdout[-12000:],
|
|
2442
3063
|
"stderr": completed.stderr[-12000:],
|
|
@@ -2467,6 +3088,119 @@ def install_engine(engine: str) -> Dict:
|
|
|
2467
3088
|
result["daemon_started"] = False
|
|
2468
3089
|
return result
|
|
2469
3090
|
|
|
3091
|
+
|
|
3092
|
+
def normalize_local_model_request(model_id: str, engine: Optional[str] = None) -> str:
|
|
3093
|
+
model_id = model_id.strip()
|
|
3094
|
+
engine = (engine or "").strip().lower()
|
|
3095
|
+
if engine in {"local_mlx", "mlx"} and model_id.startswith(("local_mlx:", "mlx:")):
|
|
3096
|
+
return model_id.split(":", 1)[1].strip()
|
|
3097
|
+
if engine and engine not in {"local_mlx", "mlx"} and ":" not in model_id:
|
|
3098
|
+
return f"{engine}:{model_id}"
|
|
3099
|
+
return model_id
|
|
3100
|
+
|
|
3101
|
+
|
|
3102
|
+
def ensure_engine_ready(engine: str) -> Dict[str, object]:
|
|
3103
|
+
engine = "local_mlx" if engine == "mlx" else engine
|
|
3104
|
+
if engine not in ENGINE_INSTALLERS and engine not in OPENAI_COMPATIBLE_PROVIDERS:
|
|
3105
|
+
raise HTTPException(status_code=400, detail=f"지원하지 않는 엔진입니다: {engine}")
|
|
3106
|
+
support = engine_support_status(engine)
|
|
3107
|
+
if not support["supported"]:
|
|
3108
|
+
raise HTTPException(status_code=400, detail=str(support["reason"]))
|
|
3109
|
+
|
|
3110
|
+
if engine_installed(engine):
|
|
3111
|
+
if engine == "local_mlx":
|
|
3112
|
+
ensure_mlx_runtime()
|
|
3113
|
+
return {"engine": engine, "installed": True, "installed_now": False}
|
|
3114
|
+
|
|
3115
|
+
if engine not in ENGINE_INSTALLERS:
|
|
3116
|
+
raise HTTPException(status_code=400, detail=f"{engine} 엔진 설치 방법이 등록되어 있지 않습니다.")
|
|
3117
|
+
|
|
3118
|
+
result = install_engine(engine)
|
|
3119
|
+
if result.get("returncode") not in (0, None) or not engine_installed(engine):
|
|
3120
|
+
detail = result.get("stderr") or result.get("stdout") or f"{engine} 설치에 실패했습니다."
|
|
3121
|
+
raise HTTPException(status_code=500, detail=str(detail)[-2000:])
|
|
3122
|
+
|
|
3123
|
+
if engine == "local_mlx":
|
|
3124
|
+
ensure_mlx_runtime()
|
|
3125
|
+
return {"engine": engine, "installed": True, "installed_now": True, "install": result}
|
|
3126
|
+
|
|
3127
|
+
|
|
3128
|
+
async def prepare_and_load_model(
|
|
3129
|
+
model_id: str,
|
|
3130
|
+
request: Request,
|
|
3131
|
+
engine: Optional[str] = None,
|
|
3132
|
+
user_email: Optional[str] = None,
|
|
3133
|
+
adapter_path: Optional[str] = None,
|
|
3134
|
+
draft_model_id: Optional[str] = None,
|
|
3135
|
+
) -> Dict[str, object]:
|
|
3136
|
+
model_id = normalize_local_model_request(model_id, engine)
|
|
3137
|
+
if not model_id:
|
|
3138
|
+
raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
|
|
3139
|
+
|
|
3140
|
+
parsed_provider, parsed_model = parse_model_ref(model_id)
|
|
3141
|
+
if parsed_provider == "mlx":
|
|
3142
|
+
parsed_provider = "local_mlx"
|
|
3143
|
+
|
|
3144
|
+
local_engines = {"local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"}
|
|
3145
|
+
install_result: Dict[str, object] = {}
|
|
3146
|
+
download_result: Optional[Dict[str, object]] = None
|
|
3147
|
+
|
|
3148
|
+
if parsed_provider in local_engines:
|
|
3149
|
+
install_result = ensure_engine_ready(parsed_provider)
|
|
3150
|
+
|
|
3151
|
+
if parsed_provider == "local_mlx":
|
|
3152
|
+
explicit_path = Path(parsed_model).expanduser()
|
|
3153
|
+
if not explicit_path.exists() and not hf_model_ready(parsed_model, "local_mlx"):
|
|
3154
|
+
download_result = download_hf_model(parsed_model, "local_mlx")
|
|
3155
|
+
elif parsed_provider == "ollama":
|
|
3156
|
+
ensure_ollama_server()
|
|
3157
|
+
if parsed_model not in get_ollama_pulled_models():
|
|
3158
|
+
completed = subprocess.run(
|
|
3159
|
+
["ollama", "pull", parsed_model],
|
|
3160
|
+
capture_output=True,
|
|
3161
|
+
text=True,
|
|
3162
|
+
timeout=900,
|
|
3163
|
+
check=False,
|
|
3164
|
+
)
|
|
3165
|
+
if completed.returncode != 0:
|
|
3166
|
+
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "Ollama 모델 다운로드 실패")
|
|
3167
|
+
download_result = {"provider": "ollama", "model": parsed_model, "returncode": completed.returncode}
|
|
3168
|
+
elif parsed_provider == "vllm":
|
|
3169
|
+
ensure_vllm_server(parsed_model)
|
|
3170
|
+
download_result = {"provider": "vllm", "model": parsed_model, "server_ready": True}
|
|
3171
|
+
elif parsed_provider == "llamacpp":
|
|
3172
|
+
ensure_llamacpp_server(parsed_model)
|
|
3173
|
+
download_result = {"provider": "llamacpp", "model": parsed_model, "server_ready": True}
|
|
3174
|
+
elif parsed_provider == "lmstudio":
|
|
3175
|
+
ensured = ensure_lmstudio_model(parsed_model)
|
|
3176
|
+
resolved_model = str(
|
|
3177
|
+
ensured.get("instance_id")
|
|
3178
|
+
or ensured.get("resolved_model")
|
|
3179
|
+
or parsed_model
|
|
3180
|
+
).strip()
|
|
3181
|
+
parsed_model = resolved_model
|
|
3182
|
+
model_id = f"lmstudio:{resolved_model}"
|
|
3183
|
+
download_result = ensured
|
|
3184
|
+
|
|
3185
|
+
effective_email = (user_email or get_current_user(request) or "").strip()
|
|
3186
|
+
user_api_key = get_user_api_key(effective_email, parsed_provider) if parsed_provider != "local_mlx" else None
|
|
3187
|
+
msg = await router.load_model(
|
|
3188
|
+
model_id,
|
|
3189
|
+
adapter_path,
|
|
3190
|
+
draft_model_id=draft_model_id,
|
|
3191
|
+
api_key_override=user_api_key,
|
|
3192
|
+
owner=effective_email or None,
|
|
3193
|
+
)
|
|
3194
|
+
return {
|
|
3195
|
+
"status": "ok",
|
|
3196
|
+
"message": msg,
|
|
3197
|
+
"model": model_id,
|
|
3198
|
+
"current": router.current_model_id,
|
|
3199
|
+
"engine": parsed_provider,
|
|
3200
|
+
"installed_now": bool(install_result.get("installed_now")),
|
|
3201
|
+
"download": download_result,
|
|
3202
|
+
}
|
|
3203
|
+
|
|
2470
3204
|
CLOUD_VERIFY_CACHE: Dict[str, Dict] = {}
|
|
2471
3205
|
CLOUD_VERIFY_TTL_SECONDS = 600
|
|
2472
3206
|
|
|
@@ -2531,6 +3265,7 @@ async def health(request: Request):
|
|
|
2531
3265
|
base = {"status": "ok", "version": "2.1.0", "mode": APP_MODE}
|
|
2532
3266
|
if not get_current_user(request) and REQUIRE_AUTH:
|
|
2533
3267
|
return base
|
|
3268
|
+
engines = await asyncio.to_thread(engine_status)
|
|
2534
3269
|
return {
|
|
2535
3270
|
**base,
|
|
2536
3271
|
"current_model": router.current_model_id,
|
|
@@ -2538,7 +3273,7 @@ async def health(request: Request):
|
|
|
2538
3273
|
"device": "Apple Silicon MLX" if not IS_PUBLIC_MODE else "Public cloud/API runtime",
|
|
2539
3274
|
"features": runtime_features(),
|
|
2540
3275
|
"providers": router.detected_cloud_models(),
|
|
2541
|
-
"engines":
|
|
3276
|
+
"engines": engines,
|
|
2542
3277
|
}
|
|
2543
3278
|
|
|
2544
3279
|
|
|
@@ -2550,7 +3285,7 @@ async def mode():
|
|
|
2550
3285
|
|
|
2551
3286
|
@app.get("/engines")
|
|
2552
3287
|
async def engines():
|
|
2553
|
-
return {"engines": engine_status
|
|
3288
|
+
return {"engines": await asyncio.to_thread(engine_status), "current": router.current_model_id}
|
|
2554
3289
|
|
|
2555
3290
|
|
|
2556
3291
|
@app.post("/engines/install")
|
|
@@ -2583,8 +3318,7 @@ async def pull_ollama_model(req: PullModelRequest, request: Request):
|
|
|
2583
3318
|
raise HTTPException(status_code=400, detail="모델 이름이 비어 있습니다.")
|
|
2584
3319
|
|
|
2585
3320
|
if provider == "ollama":
|
|
2586
|
-
|
|
2587
|
-
raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
|
|
3321
|
+
ensure_ollama_server()
|
|
2588
3322
|
try:
|
|
2589
3323
|
completed = subprocess.run(
|
|
2590
3324
|
["ollama", "pull", model_name],
|
|
@@ -2596,25 +3330,35 @@ async def pull_ollama_model(req: PullModelRequest, request: Request):
|
|
|
2596
3330
|
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "pull 실패")
|
|
2597
3331
|
return {"provider": provider, "model": model_name, "returncode": completed.returncode}
|
|
2598
3332
|
|
|
2599
|
-
if provider
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
return {"provider": provider, "model": model_name, "returncode": completed.returncode, "path": str(target_dir)}
|
|
3333
|
+
if provider == "lmstudio":
|
|
3334
|
+
raise HTTPException(
|
|
3335
|
+
status_code=400,
|
|
3336
|
+
detail=(
|
|
3337
|
+
"LM Studio 모델은 Lattice에서 Hugging Face로 pull하지 않습니다. "
|
|
3338
|
+
"LM Studio 앱에서 모델을 다운로드하고 Local Server를 켠 뒤 모델을 로드하세요. "
|
|
3339
|
+
"그러면 모델 선택창에 실제 /v1/models 항목이 표시됩니다."
|
|
3340
|
+
),
|
|
3341
|
+
)
|
|
3342
|
+
|
|
3343
|
+
if provider in {"vllm", "llamacpp", "local_mlx", "mlx"}:
|
|
3344
|
+
download_provider = "local_mlx" if provider == "mlx" else provider
|
|
3345
|
+
result = download_hf_model(model_name, download_provider)
|
|
3346
|
+
return {"provider": provider, "model": model_name, "returncode": 0, **result}
|
|
2614
3347
|
|
|
2615
3348
|
raise HTTPException(status_code=400, detail=f"{provider} 엔진 모델 다운로드는 아직 자동화되지 않았습니다.")
|
|
2616
3349
|
|
|
2617
3350
|
|
|
3351
|
+
@app.post("/engines/prepare-model")
|
|
3352
|
+
async def engines_prepare_model(req: PrepareModelRequest, request: Request):
|
|
3353
|
+
require_user(request)
|
|
3354
|
+
return await prepare_and_load_model(
|
|
3355
|
+
req.model,
|
|
3356
|
+
request,
|
|
3357
|
+
engine=req.engine,
|
|
3358
|
+
user_email=req.user_email,
|
|
3359
|
+
)
|
|
3360
|
+
|
|
3361
|
+
|
|
2618
3362
|
@app.post("/setup/set-api-key")
|
|
2619
3363
|
async def set_api_key(req: SetApiKeyRequest, request: Request):
|
|
2620
3364
|
from llm_router import OPENAI_COMPATIBLE_PROVIDERS
|
|
@@ -2661,7 +3405,7 @@ async def list_models():
|
|
|
2661
3405
|
return {
|
|
2662
3406
|
"recommended": recommended,
|
|
2663
3407
|
"cloud": router.detected_cloud_models(),
|
|
2664
|
-
"engines": engine_status
|
|
3408
|
+
"engines": await asyncio.to_thread(engine_status),
|
|
2665
3409
|
"loaded": router.loaded_model_ids,
|
|
2666
3410
|
"current": router.current_model_id,
|
|
2667
3411
|
}
|
|
@@ -2680,21 +3424,14 @@ async def load_model(req: LoadModelRequest, request: Request):
|
|
|
2680
3424
|
status_code=400,
|
|
2681
3425
|
detail="Public mode blocks local MLX model loading. Use openai:, openrouter:, groq:, together:, or set LATTICEAI_ALLOW_LOCAL_MODELS=true.",
|
|
2682
3426
|
)
|
|
2683
|
-
|
|
2684
|
-
model_id = f"{req.engine}:{model_id}"
|
|
2685
|
-
effective_email = (req.user_email or get_current_user(request) or "").strip()
|
|
2686
|
-
user_api_key = None
|
|
2687
|
-
if ":" in model_id:
|
|
2688
|
-
provider = model_id.split(":", 1)[0]
|
|
2689
|
-
user_api_key = get_user_api_key(effective_email, provider)
|
|
2690
|
-
msg = await router.load_model(
|
|
3427
|
+
return await prepare_and_load_model(
|
|
2691
3428
|
model_id,
|
|
2692
|
-
|
|
3429
|
+
request,
|
|
3430
|
+
engine=req.engine,
|
|
3431
|
+
user_email=req.user_email,
|
|
3432
|
+
adapter_path=req.adapter_path,
|
|
2693
3433
|
draft_model_id=req.draft_model_id,
|
|
2694
|
-
api_key_override=user_api_key,
|
|
2695
|
-
owner=effective_email or None,
|
|
2696
3434
|
)
|
|
2697
|
-
return {"status": "ok", "message": msg, "current": router.current_model_id}
|
|
2698
3435
|
except HTTPException:
|
|
2699
3436
|
raise
|
|
2700
3437
|
except Exception as e:
|
|
@@ -3078,130 +3815,462 @@ async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = No
|
|
|
3078
3815
|
|
|
3079
3816
|
# ── Local Computer Agent ──────────────────────────────────────────────────────
|
|
3080
3817
|
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3818
|
+
# ── Agent Role Prompts (Planner / Executor / Critic / Memory Updater) ─────────
|
|
3819
|
+
|
|
3820
|
+
_TOOL_CATALOG_BRIEF = """
|
|
3821
|
+
FILESYSTEM : list_dir workspace_tree read_file write_file edit_file grep search_files inspect_html preview_url
|
|
3822
|
+
PLANNING : todo_read todo_write
|
|
3823
|
+
PROJECT : run_command build_project deploy_project create_web_project
|
|
3824
|
+
GIT (read) : git_status git_diff git_log git_show
|
|
3825
|
+
LOCAL FS : local_list local_read local_write read_document
|
|
3826
|
+
DOCS : create_docx create_xlsx create_pptx create_pdf
|
|
3827
|
+
KNOWLEDGE : knowledge_save knowledge_search knowledge_tree
|
|
3828
|
+
COMPUTER : computer_screenshot computer_open_app computer_open_url computer_click computer_type computer_key
|
|
3829
|
+
MISC : network_status clear_history final
|
|
3830
|
+
"""
|
|
3085
3831
|
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
- local_write: {"action":"local_write","args":{"path":"/Users/username/Desktop/output.txt","content":"..."}} — writes any local file (UI will request user permission first)
|
|
3103
|
-
- read_document: {"action":"read_document","args":{"path":"/absolute/path/to/file.pdf"}} — extract text from PDF, DOCX, XLSX, PPTX, TXT, MD, CSV
|
|
3104
|
-
- computer_screenshot: {"action":"computer_screenshot","args":{}} — capture current screen as base64 PNG
|
|
3105
|
-
- computer_open_app: {"action":"computer_open_app","args":{"app":"Google Chrome"}} — open or focus a Mac app
|
|
3106
|
-
- computer_open_url: {"action":"computer_open_url","args":{"url":"https://example.com","app":"Google Chrome"}} — open URL in app
|
|
3107
|
-
- computer_click: {"action":"computer_click","args":{"x":500,"y":300,"button":"left","double":false}}
|
|
3108
|
-
- computer_type: {"action":"computer_type","args":{"text":"hello"}}
|
|
3109
|
-
- computer_key: {"action":"computer_key","args":{"key":"command+c"}} — e.g. return, escape, tab, command+v
|
|
3110
|
-
- computer_scroll: {"action":"computer_scroll","args":{"x":500,"y":300,"direction":"down","clicks":3}}
|
|
3111
|
-
- computer_move: {"action":"computer_move","args":{"x":500,"y":300}}
|
|
3112
|
-
- computer_drag: {"action":"computer_drag","args":{"x1":100,"y1":100,"x2":500,"y2":500}}
|
|
3113
|
-
- computer_status: {"action":"computer_status","args":{}} — check if Computer Use is available
|
|
3114
|
-
- chrome_status: {"action":"chrome_status","args":{}}
|
|
3115
|
-
- computer_use_status: {"action":"computer_use_status","args":{}}
|
|
3116
|
-
- knowledge_save: {"action":"knowledge_save","args":{"folder":"30_Projects","title":"short title","content":"note"}}
|
|
3117
|
-
- knowledge_search: {"action":"knowledge_search","args":{"query":"keyword","max_results":5}}
|
|
3118
|
-
- knowledge_tree: {"action":"knowledge_tree","args":{}}
|
|
3119
|
-
- obsidian_save: {"action":"obsidian_save","args":{"folder":"30_Projects","title":"short title","content":"note"}}
|
|
3120
|
-
- obsidian_search: {"action":"obsidian_search","args":{"query":"keyword","max_results":5}}
|
|
3121
|
-
- obsidian_tree: {"action":"obsidian_tree","args":{}}
|
|
3122
|
-
- git_status: {"action":"git_status","args":{}}
|
|
3123
|
-
- git_diff: {"action":"git_diff","args":{"path":"optional/relative/path"}}
|
|
3124
|
-
- git_log: {"action":"git_log","args":{"max_count":5}}
|
|
3125
|
-
- git_show: {"action":"git_show","args":{"revision":"HEAD"}}
|
|
3126
|
-
- network_status: {"action":"network_status","args":{}} — get current local/private IP, public IP, hostname, and Wi-Fi info
|
|
3127
|
-
- run_command: {"action":"run_command","args":{"command":"python3 app.py","cwd":"."}}
|
|
3128
|
-
- build_project: {"action":"build_project","args":{"cwd":".","script":"build"}}
|
|
3129
|
-
- deploy_project: {"action":"deploy_project","args":{"cwd":".","script":"deploy"}}
|
|
3130
|
-
- final: {"action":"final","message":"short Korean summary of what you did"}
|
|
3832
|
+
PLANNER_PROMPT = """You are the PLANNER role in Lattice AI's multi-role agent harness.
|
|
3833
|
+
Your ONLY job: analyze the request and produce a structured execution plan.
|
|
3834
|
+
You do NOT call tools or write code.
|
|
3835
|
+
|
|
3836
|
+
Respond with exactly ONE JSON object (no markdown, no fences):
|
|
3837
|
+
{
|
|
3838
|
+
"action": "plan",
|
|
3839
|
+
"state": "PLANNING",
|
|
3840
|
+
"goal": "one-sentence goal in the user's language",
|
|
3841
|
+
"steps": [
|
|
3842
|
+
{"id": 1, "description": "what this step does", "action": "expected_tool", "purpose": "why needed"}
|
|
3843
|
+
],
|
|
3844
|
+
"requires_approval": true,
|
|
3845
|
+
"rollback_strategy": "git",
|
|
3846
|
+
"estimated_steps": 3
|
|
3847
|
+
}
|
|
3131
3848
|
|
|
3132
3849
|
Rules:
|
|
3133
|
-
-
|
|
3134
|
-
-
|
|
3135
|
-
-
|
|
3136
|
-
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
-
|
|
3145
|
-
-
|
|
3146
|
-
-
|
|
3147
|
-
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3850
|
+
- requires_approval = true if ANY step uses write/exec tools (edit_file, write_file, run_command, etc.)
|
|
3851
|
+
- rollback_strategy = "git" if steps modify existing files; "none" otherwise
|
|
3852
|
+
- Keep steps realistic: 2-4 for simple tasks, up to 10 for complex ones
|
|
3853
|
+
- Do NOT specify full tool args — that is the Executor's job
|
|
3854
|
+
|
|
3855
|
+
Available tools:""" + _TOOL_CATALOG_BRIEF
|
|
3856
|
+
|
|
3857
|
+
EXECUTOR_PROMPT = """You are the EXECUTOR role in Lattice AI's multi-role agent harness.
|
|
3858
|
+
You have a plan from the Planner. Execute it step by step using exactly one tool per response.
|
|
3859
|
+
|
|
3860
|
+
You think and act like a senior software engineer:
|
|
3861
|
+
- Read (read_file, grep) BEFORE editing — never guess at file contents
|
|
3862
|
+
- Prefer edit_file over write_file for existing files
|
|
3863
|
+
- Keep changes small and precise
|
|
3864
|
+
- Verify after changes with build_project or run_command
|
|
3865
|
+
|
|
3866
|
+
Respond with exactly ONE JSON object per step:
|
|
3867
|
+
{"thoughts": "what you learned / why this next action", "action": "tool_name", "args": {...}}
|
|
3868
|
+
|
|
3869
|
+
When the task is fully done AND a tool result in this run confirms it:
|
|
3870
|
+
{"thoughts": "verified", "action": "final", "message": "한국어로 무엇을 했고 어디서 검증했는지 요약"}
|
|
3871
|
+
|
|
3872
|
+
ANTI-PATTERNS (will halt the loop):
|
|
3873
|
+
- Editing without reading first → read_file + grep BEFORE edit_file
|
|
3874
|
+
- Repeating the same action+args → check the transcript
|
|
3875
|
+
- Claiming done without a verification tool result in transcript
|
|
3876
|
+
- Hallucinating imports or file paths that were never confirmed by a tool result
|
|
3877
|
+
|
|
3878
|
+
Available tools:""" + _TOOL_CATALOG_BRIEF
|
|
3879
|
+
|
|
3880
|
+
CRITIC_PROMPT = """You are the CRITIC / REVIEWER role in Lattice AI's multi-role agent harness.
|
|
3881
|
+
Review the execution transcript and determine whether the goal was achieved.
|
|
3882
|
+
|
|
3883
|
+
Respond with exactly ONE JSON object:
|
|
3884
|
+
{
|
|
3885
|
+
"action": "verdict",
|
|
3886
|
+
"state": "VERIFYING",
|
|
3887
|
+
"verdict": "PASS",
|
|
3888
|
+
"reason": "why you think it passed or failed (cite specific tool results)",
|
|
3889
|
+
"corrections": [],
|
|
3890
|
+
"confidence": 0.95,
|
|
3891
|
+
"next_state": "DONE"
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
verdict: "PASS" | "FAIL"
|
|
3895
|
+
next_state:
|
|
3896
|
+
"DONE" — task succeeded; finish
|
|
3897
|
+
"EXECUTING" — task failed but corrections can fix it (use corrections field for retry)
|
|
3898
|
+
"ROLLBACK" — task failed AND file changes should be undone
|
|
3899
|
+
|
|
3900
|
+
Criteria for PASS: a tool result in the transcript explicitly confirms success.
|
|
3901
|
+
Be strict. Claiming done without evidence = FAIL."""
|
|
3902
|
+
|
|
3903
|
+
MEMORY_UPDATER_PROMPT = """You are the MEMORY UPDATER role in Lattice AI's multi-role agent harness.
|
|
3904
|
+
After a completed task, extract reusable learnings.
|
|
3905
|
+
|
|
3906
|
+
Respond with exactly ONE JSON object:
|
|
3907
|
+
{
|
|
3908
|
+
"action": "memory",
|
|
3909
|
+
"state": "DONE",
|
|
3910
|
+
"learnings": ["one concise fact about this codebase or task"],
|
|
3911
|
+
"artifacts": ["relative/path/to/created_or_modified_file"],
|
|
3912
|
+
"save_to_knowledge": false
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
Rules:
|
|
3916
|
+
- max 5 learnings, one sentence each
|
|
3917
|
+
- save_to_knowledge = true only if learnings are genuinely useful across future sessions
|
|
3918
|
+
- artifacts = files the Executor actually created or modified (from transcript)
|
|
3919
|
+
"""
|
|
3920
|
+
|
|
3921
|
+
# Keep backward-compat alias used by any existing callers
|
|
3922
|
+
AGENT_SYSTEM_PROMPT = EXECUTOR_PROMPT
|
|
3923
|
+
|
|
3924
|
+
# Marker: the old monolithic prompt was replaced by 4-role prompts above.
|
|
3925
|
+
# Legacy variable kept so Telegram bot / VS Code extension still work.
|
|
3926
|
+
|
|
3927
|
+
_ORIGINAL_MONOLITHIC_PROMPT_NOTE = """You are Lattice AI Agent — a local, professional-grade coding assistant.
|
|
3928
|
+
You have full access to a sandboxed workspace and (with user approval) the wider filesystem.
|
|
3929
|
+
You think and work like a senior software engineer, not like an autocompleter.
|
|
3930
|
+
|
|
3931
|
+
================================================================================
|
|
3932
|
+
HOW A PROFESSIONAL DEVELOPER THINKS — your operating loop
|
|
3933
|
+
================================================================================
|
|
3934
|
+
Every multi-step task follows four phases. Skipping phases is the #1 cause of bad
|
|
3935
|
+
output. Do not skip them.
|
|
3936
|
+
|
|
3937
|
+
1) DISCOVER (read first, then act)
|
|
3938
|
+
- Map the territory before changing it. Use workspace_tree, list_dir, grep,
|
|
3939
|
+
and read_file BEFORE writing or editing anything.
|
|
3940
|
+
- When the user names a file/feature/function, locate it (grep) and read the
|
|
3941
|
+
surrounding code BEFORE proposing a change.
|
|
3942
|
+
- Read package.json, pyproject.toml, requirements.txt, tsconfig.json, and
|
|
3943
|
+
other config files before assuming a library/version/tool is available.
|
|
3944
|
+
- Never guess at APIs, imports, file paths, function signatures, or types.
|
|
3945
|
+
If you don't know, look it up with grep/read_file. Hallucinated code is
|
|
3946
|
+
the worst possible output.
|
|
3947
|
+
|
|
3948
|
+
2) PLAN (write the plan down)
|
|
3949
|
+
- For any task with 3+ distinct steps, call todo_write FIRST with a concrete
|
|
3950
|
+
checklist (3–10 items). Keep exactly one item in_progress at a time.
|
|
3951
|
+
- The plan should describe WHAT will change and HOW you'll verify it works,
|
|
3952
|
+
not vague intentions ("look at code", "fix bugs"). Bad plans produce bad code.
|
|
3953
|
+
- Update the todo list (todo_write again) as items complete or new ones emerge.
|
|
3954
|
+
|
|
3955
|
+
3) IMPLEMENT (small, precise diffs)
|
|
3956
|
+
- Prefer edit_file over write_file when modifying existing files. edit_file
|
|
3957
|
+
requires exact byte-level old_string match — read the file first and copy
|
|
3958
|
+
the surrounding context verbatim. This forces correctness.
|
|
3959
|
+
- Use write_file only for brand-new files or when fully rewriting a file you
|
|
3960
|
+
understand end-to-end.
|
|
3961
|
+
- Keep diffs as small as the task requires. Don't refactor "while you're
|
|
3962
|
+
there." Don't add abstractions for hypothetical future needs.
|
|
3963
|
+
- Code quality:
|
|
3964
|
+
* No new comments unless the WHY is non-obvious (a subtle invariant, a
|
|
3965
|
+
workaround for a specific bug, behavior that would surprise a reader).
|
|
3966
|
+
Never write comments that just restate what the code does.
|
|
3967
|
+
* No backward-compat shims, no dead code, no unused imports/variables.
|
|
3968
|
+
* No defensive try/except around code that can't fail. Trust internal
|
|
3969
|
+
contracts; validate only at system boundaries (user input, network).
|
|
3970
|
+
* Match the surrounding code's style (indent, quotes, naming).
|
|
3971
|
+
|
|
3972
|
+
4) VERIFY (prove it works before claiming done)
|
|
3973
|
+
- After code changes, RUN something that confirms correctness:
|
|
3974
|
+
* build_project for build/typecheck/test scripts
|
|
3975
|
+
* run_command for python/node scripts and tests
|
|
3976
|
+
* inspect_html + preview_url for generated UI
|
|
3977
|
+
- If verification fails, treat the failure as the new task. Diagnose root
|
|
3978
|
+
cause; do not paper over it (no try/except shortcuts, no --no-verify, no
|
|
3979
|
+
disabling tests). Re-enter Discover phase if needed.
|
|
3980
|
+
- Never claim a task is "complete," "saved," "fixed," "working," or
|
|
3981
|
+
"deployed" unless a tool result in this same agent run confirms it.
|
|
3982
|
+
|
|
3983
|
+
================================================================================
|
|
3984
|
+
RESPONSE FORMAT (strict)
|
|
3985
|
+
================================================================================
|
|
3986
|
+
Respond with exactly ONE JSON object per step. No markdown, no code fences, no
|
|
3987
|
+
extra prose. Include a short `thoughts` field that records your current reasoning
|
|
3988
|
+
(what you just learned, what you'll do next, why). The user does not see it
|
|
3989
|
+
directly — it exists so you can plan across steps.
|
|
3990
|
+
|
|
3991
|
+
{"thoughts": "Need to read App.tsx before editing the import. Workspace tree
|
|
3992
|
+
confirms only one App.tsx exists.",
|
|
3993
|
+
"action": "read_file",
|
|
3994
|
+
"args": {"path": "src/App.tsx"}}
|
|
3995
|
+
|
|
3996
|
+
When the task is fully complete AND verified:
|
|
3997
|
+
{"thoughts": "Build passed, file written, ready to summarize.",
|
|
3998
|
+
"action": "final",
|
|
3999
|
+
"message": "한국어로 간결하게 무엇을 만들었고 어디서 검증했는지 요약."}
|
|
4000
|
+
|
|
4001
|
+
If you cannot proceed (missing tool, blocked path, ambiguous user intent), use
|
|
4002
|
+
`final` and clearly state the blocker and the smallest next step the user can
|
|
4003
|
+
take to unblock it. Do NOT loop on the same failing action.
|
|
4004
|
+
|
|
4005
|
+
================================================================================
|
|
4006
|
+
TOOL CATALOG
|
|
4007
|
+
================================================================================
|
|
4008
|
+
Filesystem (workspace, relative paths):
|
|
4009
|
+
list_dir {"path":"."}
|
|
4010
|
+
workspace_tree {"path":".", "max_depth":3}
|
|
4011
|
+
read_file {"path":"src/App.tsx", "offset":0, "limit":0, "line_numbers":true}
|
|
4012
|
+
— returns numbered view + total_lines. Use offset/limit for big files.
|
|
4013
|
+
write_file {"path":"new_file.py", "content":"..."} — new files / full rewrites
|
|
4014
|
+
edit_file {"path":"existing.py", "old_string":"exact text", "new_string":"new text",
|
|
4015
|
+
"replace_all":false}
|
|
4016
|
+
— preferred for existing files. old_string MUST appear once
|
|
4017
|
+
(unless replace_all=true). Include enough surrounding context
|
|
4018
|
+
to make it unique.
|
|
4019
|
+
grep {"pattern":"regex", "path":".", "glob":"*.py", "max_results":50,
|
|
4020
|
+
"case_insensitive":false, "context_lines":2}
|
|
4021
|
+
— regex search across the codebase. Use this before assuming a
|
|
4022
|
+
symbol exists.
|
|
4023
|
+
search_files {"query":"substring", "path":".", "max_results":20} — legacy substring search
|
|
4024
|
+
inspect_html {"path":"index.html"}
|
|
4025
|
+
preview_url {"path":"index.html"}
|
|
4026
|
+
|
|
4027
|
+
Planning:
|
|
4028
|
+
todo_read {}
|
|
4029
|
+
todo_write {"todos":[{"id":"1","content":"...","status":"pending"}]}
|
|
4030
|
+
— status ∈ pending|in_progress|completed.
|
|
4031
|
+
Use proactively for any task with 3+ steps.
|
|
4032
|
+
|
|
4033
|
+
Project ops:
|
|
4034
|
+
run_command {"command":"python3 app.py", "cwd":"."}
|
|
4035
|
+
— allowed binaries: pwd ls find cat sed head tail wc rg python python3 node npm npx
|
|
4036
|
+
— git is NOT allowed here; use the git_* tools below (read-only).
|
|
4037
|
+
build_project {"cwd":".", "script":"build"} — also: compile, typecheck, test
|
|
4038
|
+
deploy_project {"cwd":".", "script":"deploy"} — also: preview, release, package, dist, make, build:pkg, build:exe
|
|
4039
|
+
create_web_project {"path":"my_app", "framework":"react", "template":"vite"}
|
|
4040
|
+
|
|
4041
|
+
Git (read-only):
|
|
4042
|
+
git_status, git_diff, git_log, git_show
|
|
4043
|
+
— Never commit/push/pull/fetch/clone/reset/checkout. Lattice agent does not author git history.
|
|
4044
|
+
|
|
4045
|
+
Local filesystem (outside workspace; UI prompts user for approval):
|
|
4046
|
+
local_list {"path":"/Users/.../Downloads"}
|
|
4047
|
+
local_read {"path":"/abs/path/file.txt"}
|
|
4048
|
+
local_write {"path":"/abs/path/file.txt", "content":"..."}
|
|
4049
|
+
read_document {"path":"/abs/path/report.pdf"} — PDF, DOCX, XLSX, PPTX, TXT, MD, CSV
|
|
4050
|
+
|
|
4051
|
+
Document generation (written to workspace generated_* folders):
|
|
4052
|
+
create_docx {"title":"...", "body":"...", "filename":"doc.docx"}
|
|
4053
|
+
create_xlsx {"rows":[["A","B"],[1,2]], "filename":"sheet.xlsx", "sheet_name":"Sheet1"}
|
|
4054
|
+
create_pptx {"title":"...", "slides":[{"title":"...","bullets":["..."]}], "filename":"deck.pptx"}
|
|
4055
|
+
create_pdf {"title":"...", "body":"...", "filename":"doc.pdf"}
|
|
4056
|
+
|
|
4057
|
+
Knowledge / memory (Obsidian-compatible Markdown vault):
|
|
4058
|
+
knowledge_save {"folder":"30_Projects", "title":"...", "content":"..."}
|
|
4059
|
+
knowledge_search {"query":"...", "max_results":5}
|
|
4060
|
+
knowledge_tree {}
|
|
4061
|
+
obsidian_save / obsidian_search / obsidian_tree — same as knowledge_*, with vault URIs
|
|
4062
|
+
|
|
4063
|
+
Computer use (macOS desktop control, requires Accessibility permission):
|
|
4064
|
+
computer_screenshot, computer_open_app, computer_open_url, computer_click,
|
|
4065
|
+
computer_type, computer_key, computer_scroll, computer_move, computer_drag,
|
|
4066
|
+
computer_status, chrome_status, computer_use_status
|
|
4067
|
+
— Use screenshot to ground state; click/type to interact. Verify with another screenshot.
|
|
4068
|
+
|
|
4069
|
+
Misc:
|
|
4070
|
+
network_status {}
|
|
4071
|
+
clear_history {"keep_last":0}
|
|
4072
|
+
final {"message":"..."}
|
|
4073
|
+
|
|
4074
|
+
================================================================================
|
|
4075
|
+
DOMAIN RULES (keep in mind)
|
|
4076
|
+
================================================================================
|
|
4077
|
+
- Frontend: don't assume Tailwind/framer-motion/TypeScript exist. Read
|
|
4078
|
+
package.json first. If a dependency is missing, either add it explicitly to
|
|
4079
|
+
package.json (and create the config files it needs) or pick a simpler stack
|
|
4080
|
+
that already works.
|
|
4081
|
+
- Installers (.pkg/.exe): set up the packaging config (e.g. electron-builder)
|
|
4082
|
+
with full scripts in package.json, then run deploy_project. If the current
|
|
4083
|
+
OS/toolchain can't produce the artifact, still generate complete config and
|
|
4084
|
+
state the exact missing prerequisite — do not say "I can't."
|
|
4085
|
+
- Data analysis: read the data files (read_document/local_read), compute with
|
|
4086
|
+
run_command, report concrete findings plus output artifact paths.
|
|
4087
|
+
- Document requests (docx/xlsx/pptx/pdf, 문서/엑셀/PPT/피피티/파워포인트): call
|
|
4088
|
+
the matching create_* action immediately with rich, complete content. Never
|
|
4089
|
+
say you cannot create files.
|
|
4090
|
+
- Korean/English: answer in the language the user used; default to Korean
|
|
4091
|
+
if mixed or ambiguous.
|
|
4092
|
+
|
|
4093
|
+
================================================================================
|
|
4094
|
+
ANTI-PATTERNS (will be flagged by the orchestrator)
|
|
4095
|
+
================================================================================
|
|
4096
|
+
- Editing without reading first → use read_file + grep before edit_file.
|
|
4097
|
+
- Repeating the same action with the same args → the loop will halt you.
|
|
4098
|
+
- Claiming "done" without a verification tool result in the transcript.
|
|
4099
|
+
- Adding new dependencies without updating package.json / requirements.txt.
|
|
4100
|
+
- Producing fragments when the user asked for a complete file or runnable app.
|
|
4101
|
+
- Stuffing speculative features beyond the user's actual request.
|
|
4102
|
+
- Decorative placeholder URLs / fake data when real data is available.
|
|
3151
4103
|
"""
|
|
3152
4104
|
|
|
3153
4105
|
|
|
3154
|
-
_FILE_CREATE_ACTIONS = {"create_docx", "create_xlsx", "create_pptx", "create_pdf", "write_file", "create_web_project"}
|
|
4106
|
+
_FILE_CREATE_ACTIONS = {"create_docx", "create_xlsx", "create_pptx", "create_pdf", "write_file", "edit_file", "create_web_project"}
|
|
3155
4107
|
|
|
3156
4108
|
# Harness risk level per tool action.
|
|
3157
4109
|
# low — read-only, no side effects
|
|
3158
4110
|
# medium — write/create files or knowledge entries
|
|
3159
4111
|
# high — execute commands, control computer, write to arbitrary FS paths
|
|
3160
|
-
|
|
3161
|
-
# read
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
#
|
|
3165
|
-
|
|
3166
|
-
#
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
#
|
|
3181
|
-
"
|
|
3182
|
-
|
|
3183
|
-
"
|
|
3184
|
-
|
|
3185
|
-
"
|
|
3186
|
-
"
|
|
3187
|
-
"
|
|
4112
|
+
class ToolPolicy(TypedDict):
|
|
4113
|
+
risk: str # "read" | "write" | "exec" | "destructive"
|
|
4114
|
+
destructive: bool # True = data loss possible, no auto-undo
|
|
4115
|
+
shell: bool # True = spawns a subprocess
|
|
4116
|
+
network: bool # True = makes external network calls
|
|
4117
|
+
auto_approve: bool# True = agent may call without human confirmation
|
|
4118
|
+
sandbox: str # "workspace" | "home" | "system"
|
|
4119
|
+
rollback: str # "none" | "backup" | "git"
|
|
4120
|
+
|
|
4121
|
+
|
|
4122
|
+
_R = lambda s, sb="workspace", ro="none": ToolPolicy(risk="read", destructive=False, shell=False, network=False, auto_approve=True, sandbox=sb, rollback=ro)
|
|
4123
|
+
_RS = lambda s, sb="workspace", ro="none": ToolPolicy(risk="read", destructive=False, shell=True, network=False, auto_approve=True, sandbox=sb, rollback=ro)
|
|
4124
|
+
_RN = lambda s, sb="system", ro="none": ToolPolicy(risk="read", destructive=False, shell=True, network=True, auto_approve=True, sandbox=sb, rollback=ro)
|
|
4125
|
+
_W = lambda s, sb="workspace", ro="none": ToolPolicy(risk="write", destructive=False, shell=False, network=False, auto_approve=False, sandbox=sb, rollback=ro)
|
|
4126
|
+
_E = lambda s, sb="workspace", ro="none": ToolPolicy(risk="exec", destructive=False, shell=True, network=False, auto_approve=False, sandbox=sb, rollback=ro)
|
|
4127
|
+
_EN = lambda s, sb="workspace", ro="none": ToolPolicy(risk="exec", destructive=False, shell=True, network=True, auto_approve=False, sandbox=sb, rollback=ro)
|
|
4128
|
+
_EC = lambda s, sb="system", ro="none": ToolPolicy(risk="exec", destructive=False, shell=False, network=False, auto_approve=False, sandbox=sb, rollback=ro)
|
|
4129
|
+
_D = lambda s, sb="workspace", ro="none": ToolPolicy(risk="destructive", destructive=True, shell=True, network=False, auto_approve=False, sandbox=sb, rollback=ro)
|
|
4130
|
+
|
|
4131
|
+
TOOL_GOVERNANCE: Dict[str, ToolPolicy] = {
|
|
4132
|
+
# ── read-only / workspace ──────────────────────────────────────────────────
|
|
4133
|
+
"list_dir": _R("list_dir"),
|
|
4134
|
+
"workspace_tree": _R("workspace_tree"),
|
|
4135
|
+
"read_file": _R("read_file"),
|
|
4136
|
+
"search_files": _R("search_files"),
|
|
4137
|
+
"grep": _R("grep"),
|
|
4138
|
+
"inspect_html": _R("inspect_html"),
|
|
4139
|
+
"todo_read": _R("todo_read"),
|
|
4140
|
+
# ── read-only / home FS ───────────────────────────────────────────────────
|
|
4141
|
+
"local_list": _R("local_list", sb="home"),
|
|
4142
|
+
"local_read": _R("local_read", sb="home"),
|
|
4143
|
+
# ── read-only / git (spawns subprocess, read-only) ───────────────────────
|
|
4144
|
+
"git_status": _RS("git_status"),
|
|
4145
|
+
"git_diff": _RS("git_diff"),
|
|
4146
|
+
"git_log": _RS("git_log"),
|
|
4147
|
+
"git_show": _RS("git_show"),
|
|
4148
|
+
# ── read-only / knowledge ─────────────────────────────────────────────────
|
|
4149
|
+
"knowledge_search": _R("knowledge_search", sb="home"),
|
|
4150
|
+
"knowledge_tree": _R("knowledge_tree", sb="home"),
|
|
4151
|
+
"obsidian_search": _R("obsidian_search", sb="home"),
|
|
4152
|
+
"obsidian_tree": _R("obsidian_tree", sb="home"),
|
|
4153
|
+
# ── read-only / system ────────────────────────────────────────────────────
|
|
4154
|
+
"computer_screenshot":_R("computer_screenshot", sb="system"),
|
|
4155
|
+
"computer_status": _R("computer_status", sb="system"),
|
|
4156
|
+
"chrome_status": _R("chrome_status", sb="system"),
|
|
4157
|
+
"computer_use_status":_R("computer_use_status", sb="system"),
|
|
4158
|
+
"network_status": _RN("network_status"),
|
|
4159
|
+
# ── write / workspace ─────────────────────────────────────────────────────
|
|
4160
|
+
"write_file": _W("write_file", ro="git"),
|
|
4161
|
+
"edit_file": _W("edit_file", ro="git"),
|
|
4162
|
+
"create_web_project": _W("create_web_project"),
|
|
4163
|
+
"create_docx": _W("create_docx"),
|
|
4164
|
+
"create_xlsx": _W("create_xlsx"),
|
|
4165
|
+
"create_pptx": _W("create_pptx"),
|
|
4166
|
+
"create_pdf": _W("create_pdf"),
|
|
4167
|
+
"preview_url": _W("preview_url"),
|
|
4168
|
+
"todo_write": _W("todo_write"),
|
|
4169
|
+
# ── write / home FS ───────────────────────────────────────────────────────
|
|
4170
|
+
"knowledge_save": _W("knowledge_save", sb="home"),
|
|
4171
|
+
"obsidian_save": _W("obsidian_save", sb="home"),
|
|
4172
|
+
"local_write": _W("local_write", sb="home"),
|
|
4173
|
+
# ── exec / workspace ──────────────────────────────────────────────────────
|
|
4174
|
+
"run_command": _E("run_command"),
|
|
4175
|
+
"build_project": _E("build_project"),
|
|
4176
|
+
# ── exec / network ────────────────────────────────────────────────────────
|
|
4177
|
+
"deploy_project": _EN("deploy_project"),
|
|
4178
|
+
# ── exec / computer use (system-level input injection) ───────────────────
|
|
4179
|
+
"computer_click": _EC("computer_click"),
|
|
4180
|
+
"computer_type": _EC("computer_type"),
|
|
4181
|
+
"computer_key": _EC("computer_key"),
|
|
4182
|
+
"computer_scroll": _EC("computer_scroll"),
|
|
4183
|
+
"computer_drag": _EC("computer_drag"),
|
|
4184
|
+
"computer_move": _EC("computer_move"),
|
|
4185
|
+
"computer_open_app": _EC("computer_open_app"),
|
|
4186
|
+
"computer_open_url": ToolPolicy(risk="exec", destructive=False, shell=False, network=True, auto_approve=False, sandbox="system", rollback="none"),
|
|
3188
4187
|
}
|
|
3189
4188
|
|
|
3190
|
-
|
|
4189
|
+
_TOOL_GOVERNANCE_DEFAULT = ToolPolicy(
|
|
4190
|
+
risk="write", destructive=False, shell=False, network=False,
|
|
4191
|
+
auto_approve=False, sandbox="workspace", rollback="none",
|
|
4192
|
+
)
|
|
4193
|
+
|
|
4194
|
+
# Tools that require admin role — computer control + shell execution
|
|
4195
|
+
ADMIN_ONLY_TOOLS: frozenset[str] = frozenset(
|
|
4196
|
+
name for name, policy in TOOL_GOVERNANCE.items()
|
|
4197
|
+
if policy["sandbox"] == "system" or policy["risk"] in {"exec", "destructive"}
|
|
4198
|
+
)
|
|
4199
|
+
|
|
4200
|
+
def _check_tool_role(tool_name: str, current_user: str) -> None:
|
|
4201
|
+
if tool_name not in ADMIN_ONLY_TOOLS:
|
|
4202
|
+
return
|
|
4203
|
+
users = load_users()
|
|
4204
|
+
if get_user_role(current_user, users) != "admin":
|
|
4205
|
+
raise HTTPException(
|
|
4206
|
+
status_code=403,
|
|
4207
|
+
detail=f"'{tool_name}' 툴은 관리자 전용입니다.",
|
|
4208
|
+
)
|
|
4209
|
+
|
|
4210
|
+
# Paths that local_write / local_list must never target
|
|
3191
4211
|
_LOCAL_WRITE_BLOCKED_PREFIXES = (
|
|
3192
4212
|
"/etc/", "/usr/", "/bin/", "/sbin/", "/System/", "/private/etc/",
|
|
3193
4213
|
"/Library/LaunchDaemons/", "/Library/LaunchAgents/",
|
|
3194
4214
|
)
|
|
3195
4215
|
|
|
4216
|
+
# Backward-compat: map policy risk → legacy low/medium/high string
|
|
4217
|
+
_RISK_LEVEL_MAP = {"read": "low", "write": "medium", "exec": "high", "destructive": "high"}
|
|
3196
4218
|
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
4219
|
+
|
|
4220
|
+
def _agent_policy(action_name: str, args: dict) -> ToolPolicy:
|
|
4221
|
+
"""Return the full governance policy for an action.
|
|
4222
|
+
|
|
4223
|
+
Upgrades local_write to destructive risk when targeting system paths.
|
|
4224
|
+
"""
|
|
4225
|
+
policy = TOOL_GOVERNANCE.get(action_name, _TOOL_GOVERNANCE_DEFAULT)
|
|
3200
4226
|
if action_name == "local_write":
|
|
3201
4227
|
path = str(args.get("path", ""))
|
|
3202
4228
|
if any(path.startswith(p) for p in _LOCAL_WRITE_BLOCKED_PREFIXES):
|
|
3203
|
-
|
|
3204
|
-
|
|
4229
|
+
policy = ToolPolicy(
|
|
4230
|
+
risk="destructive", destructive=True, shell=False, network=False,
|
|
4231
|
+
auto_approve=False, sandbox="system", rollback="none",
|
|
4232
|
+
)
|
|
4233
|
+
return policy
|
|
4234
|
+
|
|
4235
|
+
|
|
4236
|
+
def _agent_risk(action_name: str, args: dict) -> str:
|
|
4237
|
+
"""Return legacy low/medium/high risk string (kept for transcript backward-compat)."""
|
|
4238
|
+
return _RISK_LEVEL_MAP.get(_agent_policy(action_name, args)["risk"], "medium")
|
|
4239
|
+
|
|
4240
|
+
|
|
4241
|
+
# ── Tool Permission Layer ─────────────────────────────────────────────────────
|
|
4242
|
+
# A compact, public-facing view of each tool's authorization profile, derived
|
|
4243
|
+
# from TOOL_GOVERNANCE. Designed for client UIs / approval dialogs that don't
|
|
4244
|
+
# need the full 7-dimensional governance object.
|
|
4245
|
+
#
|
|
4246
|
+
# Example:
|
|
4247
|
+
# { "tool": "shell", "risk": "high", "requires_approval": true, "network": false }
|
|
4248
|
+
|
|
4249
|
+
class ToolPermission(TypedDict):
|
|
4250
|
+
tool: str
|
|
4251
|
+
risk: str # "low" | "medium" | "high"
|
|
4252
|
+
requires_approval: bool # inverse of governance.auto_approve
|
|
4253
|
+
network: bool # tool makes external network calls
|
|
4254
|
+
|
|
4255
|
+
|
|
4256
|
+
def get_tool_permission(name: str, args: Optional[dict] = None) -> ToolPermission:
|
|
4257
|
+
"""Return the simplified permission view for a tool name.
|
|
4258
|
+
|
|
4259
|
+
`args` lets path-sensitive tools (e.g. local_write to /etc) escalate risk;
|
|
4260
|
+
omit it for static catalog views.
|
|
4261
|
+
"""
|
|
4262
|
+
policy = _agent_policy(name, args or {})
|
|
4263
|
+
return ToolPermission(
|
|
4264
|
+
tool=name,
|
|
4265
|
+
risk=_RISK_LEVEL_MAP.get(policy["risk"], "medium"),
|
|
4266
|
+
requires_approval=not policy["auto_approve"],
|
|
4267
|
+
network=policy["network"],
|
|
4268
|
+
)
|
|
4269
|
+
|
|
4270
|
+
|
|
4271
|
+
def list_tool_permissions() -> list:
|
|
4272
|
+
"""Return permission views for every governed tool, sorted by tool name."""
|
|
4273
|
+
return [get_tool_permission(name) for name in sorted(TOOL_GOVERNANCE.keys())]
|
|
3205
4274
|
|
|
3206
4275
|
|
|
3207
4276
|
def _collect_created_files(transcript: list) -> list:
|
|
@@ -3250,142 +4319,421 @@ def _extract_agent_action(raw: str) -> Dict:
|
|
|
3250
4319
|
return action
|
|
3251
4320
|
|
|
3252
4321
|
|
|
3253
|
-
|
|
3254
|
-
async def agent(req: AgentRequest, request: Request):
|
|
3255
|
-
"""Natural-language local agent loop for Telegram and future clients."""
|
|
3256
|
-
current_user = require_user(request)
|
|
3257
|
-
enforce_rate_limit(current_user, "agent")
|
|
3258
|
-
if not router.current_model_id:
|
|
3259
|
-
raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
|
|
4322
|
+
# ── Agent State Machine — Phase Functions ─────────────────────────────────────
|
|
3260
4323
|
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
4324
|
+
async def _phase_plan(
|
|
4325
|
+
ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str, current_user: str,
|
|
4326
|
+
) -> None:
|
|
4327
|
+
"""PLAN: Planner role produces a structured plan JSON."""
|
|
4328
|
+
context = (
|
|
4329
|
+
f"{PLANNER_PROMPT}\n\n"
|
|
4330
|
+
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
4331
|
+
f"Workspace root: {AGENT_ROOT}\n\n"
|
|
4332
|
+
f"User request: {req.message}"
|
|
4333
|
+
)
|
|
4334
|
+
raw = await router.generate(
|
|
4335
|
+
message="Produce a JSON execution plan for this request.",
|
|
4336
|
+
context=context, max_tokens=1024, temperature=0.1,
|
|
4337
|
+
)
|
|
4338
|
+
try:
|
|
4339
|
+
plan = _extract_agent_action(str(raw))
|
|
4340
|
+
except ValueError:
|
|
4341
|
+
plan = {
|
|
4342
|
+
"action": "plan", "state": "PLAN",
|
|
4343
|
+
"goal": req.message, "steps": [],
|
|
4344
|
+
"requires_approval": False, "rollback_strategy": "none", "estimated_steps": 1,
|
|
4345
|
+
}
|
|
4346
|
+
ctx.plan = plan
|
|
4347
|
+
ctx.transcript.append({
|
|
4348
|
+
"state": AgentState.PLANNING.value,
|
|
4349
|
+
"goal": plan.get("goal", req.message),
|
|
4350
|
+
"steps": plan.get("steps", []),
|
|
4351
|
+
"requires_approval": plan.get("requires_approval", False),
|
|
4352
|
+
"rollback_strategy": plan.get("rollback_strategy", "none"),
|
|
4353
|
+
"estimated_steps": plan.get("estimated_steps", 1),
|
|
4354
|
+
})
|
|
4355
|
+
ctx.state = AgentState.WAITING_APPROVAL
|
|
4356
|
+
|
|
4357
|
+
|
|
4358
|
+
def _phase_approval(ctx: AgentRunContext, current_user: str) -> None:
|
|
4359
|
+
"""APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
|
|
4360
|
+
auto_approve_tools = {name for name, p in TOOL_GOVERNANCE.items() if p["auto_approve"]}
|
|
4361
|
+
steps = ctx.plan.get("steps", [])
|
|
4362
|
+
non_auto = [s.get("action") for s in steps if s.get("action") not in auto_approve_tools]
|
|
4363
|
+
requires = ctx.plan.get("requires_approval", False) or bool(non_auto)
|
|
4364
|
+
|
|
4365
|
+
ctx.transcript.append({
|
|
4366
|
+
"state": AgentState.WAITING_APPROVAL.value,
|
|
4367
|
+
"requires_approval": requires,
|
|
4368
|
+
"non_auto_approve_steps": non_auto,
|
|
4369
|
+
"decision": "auto_approved",
|
|
4370
|
+
})
|
|
4371
|
+
append_audit_event(
|
|
4372
|
+
"agent_approval", user_email=current_user,
|
|
4373
|
+
requires_approval=requires, non_auto_steps=non_auto, decision="auto_approved",
|
|
4374
|
+
)
|
|
4375
|
+
ctx.state = AgentState.EXECUTING
|
|
4376
|
+
|
|
4377
|
+
|
|
4378
|
+
async def _phase_execute(
|
|
4379
|
+
ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str,
|
|
4380
|
+
current_user: str, max_steps: int,
|
|
4381
|
+
) -> None:
|
|
4382
|
+
"""EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
|
|
4383
|
+
exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
|
|
4384
|
+
budget = max(1, max_steps - exec_count)
|
|
4385
|
+
|
|
4386
|
+
for _ in range(budget):
|
|
4387
|
+
corrections_hint = (
|
|
4388
|
+
"\n\nCritic corrections from previous attempt:\n"
|
|
4389
|
+
+ "\n".join(f"- {c}" for c in ctx.corrections)
|
|
4390
|
+
) if ctx.corrections else ""
|
|
3266
4391
|
|
|
3267
|
-
for step in range(max_steps):
|
|
3268
|
-
recent_context = build_recent_chat_context(conversation_id=req.conversation_id)
|
|
3269
4392
|
context = (
|
|
3270
|
-
f"{
|
|
3271
|
-
f"[LANGUAGE: {lang_hint}]\n
|
|
4393
|
+
f"{EXECUTOR_PROMPT}\n\n"
|
|
4394
|
+
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
3272
4395
|
f"Workspace root: {AGENT_ROOT}\n\n"
|
|
3273
|
-
f"
|
|
3274
|
-
f"
|
|
3275
|
-
f"
|
|
4396
|
+
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
|
|
4397
|
+
f"Recent conversation:\n{build_recent_chat_context(conversation_id=req.conversation_id) or '(none)'}\n\n"
|
|
4398
|
+
f"User request: {req.message}{corrections_hint}\n\n"
|
|
4399
|
+
f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
3276
4400
|
)
|
|
3277
4401
|
raw = await router.generate(
|
|
3278
|
-
message="
|
|
3279
|
-
context=context,
|
|
3280
|
-
max_tokens=4096,
|
|
3281
|
-
temperature=req.temperature,
|
|
4402
|
+
message="Execute the next step.",
|
|
4403
|
+
context=context, max_tokens=4096, temperature=req.temperature,
|
|
3282
4404
|
)
|
|
3283
|
-
|
|
3284
4405
|
try:
|
|
3285
4406
|
action = _extract_agent_action(str(raw))
|
|
3286
4407
|
except ValueError as exc:
|
|
3287
|
-
transcript.append({
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
"steps": transcript,
|
|
3297
|
-
"created_files": created_files,
|
|
3298
|
-
}
|
|
4408
|
+
ctx.transcript.append({
|
|
4409
|
+
"state": AgentState.EXECUTING.value, "action": "parse_error",
|
|
4410
|
+
"raw": str(raw)[:400], "error": str(exc),
|
|
4411
|
+
})
|
|
4412
|
+
break
|
|
4413
|
+
|
|
4414
|
+
name = action.get("action")
|
|
4415
|
+
thoughts = str(action.get("thoughts") or "")[:600]
|
|
4416
|
+
args = action.get("args") or {}
|
|
3299
4417
|
|
|
3300
|
-
name = action.get("action")
|
|
3301
4418
|
if name == "final":
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
4419
|
+
ctx.final_message = action.get("message", "작업을 완료했습니다.")
|
|
4420
|
+
ctx.transcript.append({
|
|
4421
|
+
"state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
|
|
4422
|
+
})
|
|
4423
|
+
ctx.state = AgentState.VERIFYING
|
|
4424
|
+
return
|
|
4425
|
+
|
|
4426
|
+
# Loop guard
|
|
4427
|
+
exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
|
|
4428
|
+
last = exec_steps[-1] if exec_steps else None
|
|
3311
4429
|
if (
|
|
3312
|
-
name in _FILE_CREATE_ACTIONS
|
|
3313
|
-
and
|
|
3314
|
-
and
|
|
3315
|
-
and
|
|
3316
|
-
and "result" in last_step
|
|
4430
|
+
name in _FILE_CREATE_ACTIONS and last
|
|
4431
|
+
and last.get("action") == name
|
|
4432
|
+
and (last.get("args") or {}) == args
|
|
4433
|
+
and "result" in last
|
|
3317
4434
|
):
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
4435
|
+
ctx.transcript.append({
|
|
4436
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
4437
|
+
"error": "LOOP_DETECTED: identical action+args repeated — halted.",
|
|
4438
|
+
})
|
|
4439
|
+
break
|
|
3323
4440
|
|
|
3324
4441
|
if name == "clear_history":
|
|
3325
|
-
result = clear_history(
|
|
3326
|
-
|
|
3327
|
-
"
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
keep_last=current_args.get("keep_last", 0),
|
|
3331
|
-
removed=result.get("removed", 0),
|
|
3332
|
-
kept=result.get("kept", 0),
|
|
3333
|
-
)
|
|
3334
|
-
transcript.append({"step": step + 1, "action": name, "args": current_args, "result": result})
|
|
4442
|
+
result = clear_history(args.get("keep_last", 0))
|
|
4443
|
+
ctx.transcript.append({
|
|
4444
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
4445
|
+
"thoughts": thoughts, "args": args, "result": result,
|
|
4446
|
+
})
|
|
3335
4447
|
continue
|
|
3336
4448
|
|
|
3337
|
-
|
|
4449
|
+
policy = _agent_policy(name, args)
|
|
4450
|
+
risk = _RISK_LEVEL_MAP.get(policy["risk"], "medium")
|
|
3338
4451
|
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
continue
|
|
4452
|
+
if policy["risk"] == "destructive":
|
|
4453
|
+
ctx.transcript.append({
|
|
4454
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
4455
|
+
"thoughts": thoughts, "args": args, "risk": risk,
|
|
4456
|
+
"governance": dict(policy),
|
|
4457
|
+
"error": f"BLOCKED: destructive action '{name}' not permitted in agent mode.",
|
|
4458
|
+
})
|
|
4459
|
+
append_audit_event(
|
|
4460
|
+
"agent_blocked", user_email=current_user, source=req.source or "agent",
|
|
4461
|
+
action=name, reason="destructive", governance=dict(policy),
|
|
4462
|
+
)
|
|
4463
|
+
continue
|
|
3352
4464
|
|
|
3353
|
-
|
|
3354
|
-
if risk in ("medium", "high"):
|
|
4465
|
+
if not policy["auto_approve"]:
|
|
3355
4466
|
append_audit_event(
|
|
3356
4467
|
"agent_exec", user_email=current_user, source=req.source or "agent",
|
|
3357
|
-
|
|
3358
|
-
|
|
4468
|
+
state=AgentState.EXECUTING.value, action=name, risk=risk,
|
|
4469
|
+
shell=policy["shell"], network=policy["network"],
|
|
4470
|
+
destructive=policy["destructive"], sandbox=policy["sandbox"],
|
|
4471
|
+
rollback=policy["rollback"],
|
|
4472
|
+
args={k: v for k, v in args.items() if k != "content"},
|
|
3359
4473
|
)
|
|
3360
4474
|
|
|
3361
4475
|
try:
|
|
3362
|
-
|
|
3363
|
-
|
|
4476
|
+
_check_tool_role(name, current_user)
|
|
4477
|
+
result = execute_tool(name, args)
|
|
4478
|
+
ctx.transcript.append({
|
|
4479
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
4480
|
+
"thoughts": thoughts, "args": args,
|
|
4481
|
+
"risk": risk, "governance": dict(policy), "result": result,
|
|
4482
|
+
})
|
|
3364
4483
|
except (ToolError, KeyError, TypeError) as exc:
|
|
3365
|
-
transcript.append({
|
|
4484
|
+
ctx.transcript.append({
|
|
4485
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
4486
|
+
"thoughts": thoughts, "args": args,
|
|
4487
|
+
"risk": risk, "governance": dict(policy), "error": str(exc),
|
|
4488
|
+
})
|
|
3366
4489
|
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
4490
|
+
ctx.state = AgentState.VERIFYING
|
|
4491
|
+
|
|
4492
|
+
|
|
4493
|
+
async def _phase_verify(
|
|
4494
|
+
ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str, current_user: str,
|
|
4495
|
+
max_retry: int = 3,
|
|
4496
|
+
) -> None:
|
|
4497
|
+
"""VERIFYING: Critic role evaluates transcript → DONE / EXECUTING (retry) / ROLLBACK / FAILED."""
|
|
4498
|
+
context = (
|
|
4499
|
+
f"{CRITIC_PROMPT}\n\n"
|
|
4500
|
+
f"[LANGUAGE HINT: {lang_hint}]\n\n"
|
|
4501
|
+
f"Original request: {req.message}\n"
|
|
4502
|
+
f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
|
|
4503
|
+
f"Full transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
3372
4504
|
)
|
|
3373
|
-
|
|
3374
|
-
message=
|
|
3375
|
-
context=
|
|
3376
|
-
max_tokens=1024,
|
|
3377
|
-
temperature=0.1,
|
|
4505
|
+
raw = await router.generate(
|
|
4506
|
+
message="Review the execution transcript and return your verdict JSON.",
|
|
4507
|
+
context=context, max_tokens=512, temperature=0.1,
|
|
3378
4508
|
)
|
|
3379
4509
|
try:
|
|
3380
|
-
|
|
3381
|
-
message = final_action.get("message", str(summary))
|
|
4510
|
+
verdict = _extract_agent_action(str(raw))
|
|
3382
4511
|
except ValueError:
|
|
3383
|
-
|
|
4512
|
+
verdict = {"action": "verdict", "verdict": "PASS", "next_state": "DONE",
|
|
4513
|
+
"reason": "Critic parse failed — assuming pass.", "corrections": [], "confidence": 0.7}
|
|
4514
|
+
|
|
4515
|
+
ctx.corrections = verdict.get("corrections", [])
|
|
4516
|
+
# Normalize legacy verdict next_state strings to current AgentState names
|
|
4517
|
+
raw_next = verdict.get("next_state", "DONE")
|
|
4518
|
+
next_s = {"COMPLETE": "DONE", "RETRY": "EXECUTING"}.get(raw_next, raw_next)
|
|
4519
|
+
|
|
4520
|
+
ctx.transcript.append({
|
|
4521
|
+
"state": AgentState.VERIFYING.value,
|
|
4522
|
+
"verdict": verdict.get("verdict", "PASS"),
|
|
4523
|
+
"reason": verdict.get("reason", ""),
|
|
4524
|
+
"corrections": ctx.corrections,
|
|
4525
|
+
"confidence": verdict.get("confidence", 0.9),
|
|
4526
|
+
"next_state": next_s,
|
|
4527
|
+
})
|
|
3384
4528
|
|
|
4529
|
+
if verdict.get("verdict") == "PASS" or next_s == "DONE":
|
|
4530
|
+
if not ctx.final_message:
|
|
4531
|
+
ctx.final_message = verdict.get("reason", "작업이 완료되었습니다.")
|
|
4532
|
+
ctx.state = AgentState.DONE
|
|
4533
|
+
elif next_s == "ROLLBACK":
|
|
4534
|
+
ctx.state = AgentState.ROLLBACK
|
|
4535
|
+
elif next_s == "EXECUTING":
|
|
4536
|
+
if ctx.retry_count >= max_retry:
|
|
4537
|
+
ctx.final_message = (
|
|
4538
|
+
f"최대 재시도({max_retry}회) 초과로 작업을 종료했습니다. "
|
|
4539
|
+
f"마지막 비판: {verdict.get('reason', '(없음)')}"
|
|
4540
|
+
)
|
|
4541
|
+
ctx.state = AgentState.FAILED
|
|
4542
|
+
else:
|
|
4543
|
+
ctx.retry_count += 1
|
|
4544
|
+
ctx.transcript.append({
|
|
4545
|
+
"state": AgentState.EXECUTING.value,
|
|
4546
|
+
"retry_attempt": ctx.retry_count,
|
|
4547
|
+
"corrections": ctx.corrections,
|
|
4548
|
+
})
|
|
4549
|
+
ctx.state = AgentState.EXECUTING
|
|
4550
|
+
else:
|
|
4551
|
+
ctx.final_message = verdict.get("reason", "검증자가 인식되지 않은 다음 상태를 반환했습니다.")
|
|
4552
|
+
ctx.state = AgentState.FAILED
|
|
4553
|
+
|
|
4554
|
+
|
|
4555
|
+
def _phase_rollback(ctx: AgentRunContext, current_user: str) -> None:
|
|
4556
|
+
"""ROLLBACK: attempt git checkout for each edited file, then COMPLETE."""
|
|
4557
|
+
import subprocess as _sp
|
|
4558
|
+
rolled: list = []
|
|
4559
|
+
for step in ctx.transcript:
|
|
4560
|
+
if step.get("state") != AgentState.EXECUTING.value:
|
|
4561
|
+
continue
|
|
4562
|
+
gov = step.get("governance", {})
|
|
4563
|
+
if gov.get("rollback") != "git":
|
|
4564
|
+
continue
|
|
4565
|
+
result = step.get("result", {})
|
|
4566
|
+
if not (isinstance(result, dict) and result.get("success")):
|
|
4567
|
+
continue
|
|
4568
|
+
path = result.get("path") or (step.get("args") or {}).get("path", "")
|
|
4569
|
+
if not path:
|
|
4570
|
+
continue
|
|
4571
|
+
try:
|
|
4572
|
+
r = _sp.run(
|
|
4573
|
+
["git", "checkout", "--", path], cwd=str(AGENT_ROOT),
|
|
4574
|
+
capture_output=True, text=True, timeout=10,
|
|
4575
|
+
)
|
|
4576
|
+
rolled.append({"path": path, "ok": r.returncode == 0, "stderr": r.stderr[:200]})
|
|
4577
|
+
except Exception as exc:
|
|
4578
|
+
rolled.append({"path": path, "ok": False, "error": str(exc)})
|
|
4579
|
+
|
|
4580
|
+
ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
|
|
4581
|
+
recovered = [r["path"] for r in rolled if r.get("ok")]
|
|
4582
|
+
ctx.final_message = (
|
|
4583
|
+
f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
|
|
4584
|
+
if recovered
|
|
4585
|
+
else "롤백을 시도했으나 복구할 파일이 없거나 git이 초기화되지 않았습니다."
|
|
4586
|
+
)
|
|
4587
|
+
append_audit_event("agent_rollback", user_email=current_user, rolled_back=rolled)
|
|
4588
|
+
# Rollback is a recovery from a failed verification — terminal state is FAILED
|
|
4589
|
+
ctx.state = AgentState.FAILED
|
|
4590
|
+
|
|
4591
|
+
|
|
4592
|
+
async def _phase_memory_update(
|
|
4593
|
+
ctx: AgentRunContext, req: AgentRequest, router, current_user: str,
|
|
4594
|
+
) -> None:
|
|
4595
|
+
"""Background: Memory Updater role extracts learnings after COMPLETE."""
|
|
4596
|
+
context = (
|
|
4597
|
+
f"{MEMORY_UPDATER_PROMPT}\n\n"
|
|
4598
|
+
f"Completed task: {req.message}\n\n"
|
|
4599
|
+
f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
|
|
4600
|
+
)
|
|
4601
|
+
try:
|
|
4602
|
+
raw = await router.generate(
|
|
4603
|
+
message="Extract learnings from this completed task.",
|
|
4604
|
+
context=context, max_tokens=256, temperature=0.1,
|
|
4605
|
+
)
|
|
4606
|
+
mem = _extract_agent_action(str(raw))
|
|
4607
|
+
if mem.get("save_to_knowledge") and mem.get("learnings"):
|
|
4608
|
+
from tools import knowledge_save
|
|
4609
|
+
knowledge_save(
|
|
4610
|
+
"\n".join(mem["learnings"]),
|
|
4611
|
+
folder="30_Projects",
|
|
4612
|
+
title=f"Agent: {req.message[:60]}",
|
|
4613
|
+
)
|
|
4614
|
+
except Exception:
|
|
4615
|
+
pass
|
|
4616
|
+
|
|
4617
|
+
|
|
4618
|
+
# ── Eval harness ──────────────────────────────────────────────────────────────
|
|
4619
|
+
|
|
4620
|
+
@app.post("/agent/eval")
|
|
4621
|
+
async def agent_eval(req: AgentEvalRequest, request: Request):
|
|
4622
|
+
"""Run a skill's eval cases from schema.json and return pass/fail per case."""
|
|
4623
|
+
require_user(request)
|
|
4624
|
+
skill_dir = Path(__file__).resolve().parent / "skills" / req.skill
|
|
4625
|
+
schema_path = skill_dir / "schema.json"
|
|
4626
|
+
if not schema_path.exists():
|
|
4627
|
+
raise HTTPException(404, detail=f"Skill '{req.skill}' not found or missing schema.json")
|
|
4628
|
+
|
|
4629
|
+
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
4630
|
+
eval_cases = schema.get("evals", [])
|
|
4631
|
+
if req.case_id:
|
|
4632
|
+
eval_cases = [c for c in eval_cases if c.get("id") == req.case_id]
|
|
4633
|
+
if not eval_cases:
|
|
4634
|
+
return {"skill": req.skill, "total": 0, "passed": 0, "failed": 0, "results": [],
|
|
4635
|
+
"message": "No eval cases defined in schema.json"}
|
|
4636
|
+
|
|
4637
|
+
action_name = schema.get("action", req.skill)
|
|
4638
|
+
results = []
|
|
4639
|
+
for case in eval_cases:
|
|
4640
|
+
case_id = case.get("id", "?")
|
|
4641
|
+
try:
|
|
4642
|
+
result = execute_tool(action_name, case.get("input", {}))
|
|
4643
|
+
criteria = case.get("pass_criteria", "")
|
|
4644
|
+
if "success == true" in criteria:
|
|
4645
|
+
passed = result.get("success") is True
|
|
4646
|
+
elif "success == false" in criteria:
|
|
4647
|
+
passed = result.get("success") is False
|
|
4648
|
+
else:
|
|
4649
|
+
passed = True # manual review required
|
|
4650
|
+
results.append({"id": case_id, "description": case.get("description", ""),
|
|
4651
|
+
"passed": passed, "result": result, "pass_criteria": criteria})
|
|
4652
|
+
except Exception as exc:
|
|
4653
|
+
results.append({"id": case_id, "description": case.get("description", ""),
|
|
4654
|
+
"passed": False, "error": str(exc),
|
|
4655
|
+
"pass_criteria": case.get("pass_criteria", "")})
|
|
4656
|
+
|
|
4657
|
+
n_passed = sum(1 for r in results if r.get("passed") is True)
|
|
4658
|
+
return {
|
|
4659
|
+
"skill": req.skill, "action": action_name,
|
|
4660
|
+
"total": len(results), "passed": n_passed, "failed": len(results) - n_passed,
|
|
4661
|
+
"results": results,
|
|
4662
|
+
}
|
|
4663
|
+
|
|
4664
|
+
|
|
4665
|
+
@app.post("/agent")
|
|
4666
|
+
async def agent(req: AgentRequest, request: Request):
|
|
4667
|
+
"""Natural-language local agent.
|
|
4668
|
+
|
|
4669
|
+
State machine:
|
|
4670
|
+
IDLE → PLANNING → WAITING_APPROVAL → EXECUTING → VERIFYING
|
|
4671
|
+
↓ ↓
|
|
4672
|
+
FAILED DONE | EXECUTING(retry) | ROLLBACK
|
|
4673
|
+
↓
|
|
4674
|
+
FAILED
|
|
4675
|
+
"""
|
|
4676
|
+
current_user = require_user(request)
|
|
4677
|
+
enforce_rate_limit(current_user, "agent")
|
|
4678
|
+
if not router.current_model_id:
|
|
4679
|
+
raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
|
|
4680
|
+
|
|
4681
|
+
ensure_agent_root()
|
|
4682
|
+
lang = detect_language(req.message)
|
|
4683
|
+
lang_hint = _LANG_HINT[lang]
|
|
4684
|
+
max_steps = max(1, min(req.max_steps, 50))
|
|
4685
|
+
max_retry = 3
|
|
4686
|
+
|
|
4687
|
+
ctx = AgentRunContext()
|
|
4688
|
+
|
|
4689
|
+
while ctx.state not in AGENT_TERMINAL_STATES:
|
|
4690
|
+
ctx.state_history.append(ctx.state.value)
|
|
4691
|
+
# Hard guard against infinite state loops
|
|
4692
|
+
if len(ctx.state_history) > 200:
|
|
4693
|
+
ctx.final_message = "에이전트 상태 머신이 최대 반복(200)에 도달해 중단했습니다."
|
|
4694
|
+
ctx.state = AgentState.FAILED
|
|
4695
|
+
break
|
|
4696
|
+
|
|
4697
|
+
if ctx.state == AgentState.IDLE:
|
|
4698
|
+
ctx.state = AgentState.PLANNING
|
|
4699
|
+
|
|
4700
|
+
elif ctx.state == AgentState.PLANNING:
|
|
4701
|
+
await _phase_plan(ctx, req, router, lang_hint, current_user)
|
|
4702
|
+
|
|
4703
|
+
elif ctx.state == AgentState.WAITING_APPROVAL:
|
|
4704
|
+
_phase_approval(ctx, current_user)
|
|
4705
|
+
|
|
4706
|
+
elif ctx.state == AgentState.EXECUTING:
|
|
4707
|
+
await _phase_execute(ctx, req, router, lang_hint, current_user, max_steps)
|
|
4708
|
+
|
|
4709
|
+
elif ctx.state == AgentState.VERIFYING:
|
|
4710
|
+
await _phase_verify(ctx, req, router, lang_hint, current_user, max_retry)
|
|
4711
|
+
|
|
4712
|
+
elif ctx.state == AgentState.ROLLBACK:
|
|
4713
|
+
_phase_rollback(ctx, current_user)
|
|
4714
|
+
|
|
4715
|
+
else:
|
|
4716
|
+
ctx.state = AgentState.FAILED
|
|
4717
|
+
|
|
4718
|
+
# Record terminal state in history for clients
|
|
4719
|
+
ctx.state_history.append(ctx.state.value)
|
|
4720
|
+
|
|
4721
|
+
# Fire-and-forget memory update — does not block the response
|
|
4722
|
+
asyncio.create_task(_phase_memory_update(ctx, req, router, current_user))
|
|
4723
|
+
|
|
4724
|
+
message = ctx.final_message or "작업을 완료했습니다."
|
|
3385
4725
|
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
3386
4726
|
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
|
|
3387
|
-
created_files = _collect_created_files(transcript)
|
|
3388
|
-
return {
|
|
4727
|
+
created_files = _collect_created_files(ctx.transcript)
|
|
4728
|
+
return {
|
|
4729
|
+
"status": "ok" if ctx.state == AgentState.DONE else "failed",
|
|
4730
|
+
"response": message,
|
|
4731
|
+
"workspace": str(AGENT_ROOT),
|
|
4732
|
+
"steps": ctx.transcript,
|
|
4733
|
+
"state_history": ctx.state_history,
|
|
4734
|
+
"final_state": ctx.state.value,
|
|
4735
|
+
"created_files": created_files,
|
|
4736
|
+
}
|
|
3389
4737
|
|
|
3390
4738
|
|
|
3391
4739
|
# ── Direct Tool API ───────────────────────────────────────────────────────────
|
|
@@ -3410,9 +4758,13 @@ async def tools_workspace_tree(req: ToolWorkspaceTreeRequest, request: Request):
|
|
|
3410
4758
|
|
|
3411
4759
|
|
|
3412
4760
|
@app.post("/tools/read_file")
|
|
3413
|
-
async def tools_read_file(req:
|
|
4761
|
+
async def tools_read_file(req: ToolReadFileRequest, request: Request):
|
|
3414
4762
|
require_user(request)
|
|
3415
|
-
|
|
4763
|
+
try:
|
|
4764
|
+
return {"status": "ok", "workspace": str(AGENT_ROOT),
|
|
4765
|
+
"result": read_file(req.path, offset=req.offset, limit=req.limit, line_numbers=req.line_numbers)}
|
|
4766
|
+
except ToolError as exc:
|
|
4767
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
3416
4768
|
|
|
3417
4769
|
|
|
3418
4770
|
@app.post("/tools/write_file")
|
|
@@ -3421,12 +4773,51 @@ async def tools_write_file(req: ToolWriteFileRequest, request: Request):
|
|
|
3421
4773
|
return _tool_response(write_file, req.path, req.content)
|
|
3422
4774
|
|
|
3423
4775
|
|
|
4776
|
+
@app.post("/tools/edit_file")
|
|
4777
|
+
async def tools_edit_file(req: ToolEditFileRequest, request: Request):
|
|
4778
|
+
require_user(request)
|
|
4779
|
+
try:
|
|
4780
|
+
return {"status": "ok", "workspace": str(AGENT_ROOT),
|
|
4781
|
+
"result": edit_file(req.path, req.old_string, req.new_string, replace_all=req.replace_all)}
|
|
4782
|
+
except ToolError as exc:
|
|
4783
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
4784
|
+
|
|
4785
|
+
|
|
3424
4786
|
@app.post("/tools/search_files")
|
|
3425
4787
|
async def tools_search_files(req: ToolSearchFilesRequest, request: Request):
|
|
3426
4788
|
require_user(request)
|
|
3427
4789
|
return _tool_response(search_files, req.query, req.path, req.max_results)
|
|
3428
4790
|
|
|
3429
4791
|
|
|
4792
|
+
@app.post("/tools/grep")
|
|
4793
|
+
async def tools_grep(req: ToolGrepRequest, request: Request):
|
|
4794
|
+
require_user(request)
|
|
4795
|
+
try:
|
|
4796
|
+
return {"status": "ok", "workspace": str(AGENT_ROOT),
|
|
4797
|
+
"result": grep(
|
|
4798
|
+
req.pattern,
|
|
4799
|
+
path=req.path,
|
|
4800
|
+
glob=req.glob,
|
|
4801
|
+
max_results=req.max_results,
|
|
4802
|
+
case_insensitive=req.case_insensitive,
|
|
4803
|
+
context_lines=req.context_lines,
|
|
4804
|
+
)}
|
|
4805
|
+
except ToolError as exc:
|
|
4806
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
4807
|
+
|
|
4808
|
+
|
|
4809
|
+
@app.post("/tools/todo_read")
|
|
4810
|
+
async def tools_todo_read(request: Request):
|
|
4811
|
+
require_user(request)
|
|
4812
|
+
return _tool_response(todo_read)
|
|
4813
|
+
|
|
4814
|
+
|
|
4815
|
+
@app.post("/tools/todo_write")
|
|
4816
|
+
async def tools_todo_write(req: ToolTodoWriteRequest, request: Request):
|
|
4817
|
+
require_user(request)
|
|
4818
|
+
return _tool_response(todo_write, req.todos)
|
|
4819
|
+
|
|
4820
|
+
|
|
3430
4821
|
@app.post("/tools/clear_history")
|
|
3431
4822
|
async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
|
|
3432
4823
|
current_user = require_user(request)
|
|
@@ -3806,7 +5197,7 @@ async def cu_drag(req: CuDragRequest, request: Request):
|
|
|
3806
5197
|
@app.post("/cu/agent")
|
|
3807
5198
|
async def cu_agent(req: CuAgentRequest, request: Request):
|
|
3808
5199
|
"""SSE streaming Computer Use agent loop."""
|
|
3809
|
-
|
|
5200
|
+
require_admin(request)
|
|
3810
5201
|
async def _stream():
|
|
3811
5202
|
task_lower = (req.task or "").lower()
|
|
3812
5203
|
url_match = re.search(r"(https?://[^\s]+|localhost:\d+[^\s]*|127\.0\.0\.1:\d+[^\s]*)", req.task or "")
|
|
@@ -3981,7 +5372,7 @@ async def tools_git_show(req: ToolGitShowRequest, request: Request):
|
|
|
3981
5372
|
|
|
3982
5373
|
@app.post("/tools/run_command")
|
|
3983
5374
|
async def tools_run_command(req: ToolRunCommandRequest, request: Request):
|
|
3984
|
-
|
|
5375
|
+
require_admin(request)
|
|
3985
5376
|
return _tool_response(run_command, req.command, req.cwd)
|
|
3986
5377
|
|
|
3987
5378
|
|
|
@@ -3993,71 +5384,106 @@ async def tools_network_status(request: Request):
|
|
|
3993
5384
|
|
|
3994
5385
|
@app.post("/tools/build_project")
|
|
3995
5386
|
async def tools_build_project(req: ToolScriptRequest, request: Request):
|
|
3996
|
-
|
|
5387
|
+
require_admin(request)
|
|
3997
5388
|
return _tool_response(build_project, req.cwd, req.script)
|
|
3998
5389
|
|
|
3999
5390
|
|
|
4000
5391
|
@app.post("/tools/deploy_project")
|
|
4001
5392
|
async def tools_deploy_project(req: ToolScriptRequest, request: Request):
|
|
4002
|
-
|
|
5393
|
+
require_admin(request)
|
|
4003
5394
|
return _tool_response(deploy_project, req.cwd, req.script)
|
|
4004
5395
|
|
|
4005
5396
|
|
|
5397
|
+
_MCP_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
|
5398
|
+
"list_dir": "List files in the agent workspace.",
|
|
5399
|
+
"workspace_tree": "Return a recursive workspace tree.",
|
|
5400
|
+
"read_file": "Read a UTF-8 file from the workspace with optional line numbers and offset/limit slicing.",
|
|
5401
|
+
"write_file": "Write a UTF-8 file inside the workspace (new files / full rewrites).",
|
|
5402
|
+
"edit_file": "Precise diff-style edit: replace exact old_string with new_string. Requires unique match unless replace_all=true.",
|
|
5403
|
+
"search_files": "Substring search in text files (legacy).",
|
|
5404
|
+
"grep": "Regex search across the workspace with line numbers and optional context.",
|
|
5405
|
+
"todo_read": "Read the agent's persistent TODO list for the current workspace.",
|
|
5406
|
+
"todo_write": "Replace the agent's TODO list (id, content, status: pending/in_progress/completed).",
|
|
5407
|
+
"clear_history": "Clear chat history to reduce context and speed up responses.",
|
|
5408
|
+
"inspect_html": "Inspect local HTML structure and assets.",
|
|
5409
|
+
"preview_url": "Return a server URL for a workspace file.",
|
|
5410
|
+
"create_docx": "Create a Word DOCX document in the agent workspace.",
|
|
5411
|
+
"create_xlsx": "Create an XLSX spreadsheet in the agent workspace.",
|
|
5412
|
+
"create_pptx": "Create a PPTX presentation deck in the agent workspace.",
|
|
5413
|
+
"create_pdf": "Create a PDF document in the agent workspace.",
|
|
5414
|
+
"local_list": "List any local folder (requires user permission via UI).",
|
|
5415
|
+
"local_read": "Read any local file (requires user permission via UI).",
|
|
5416
|
+
"local_write": "Write any local file (requires user permission via UI).",
|
|
5417
|
+
"read_document": "Extract text from PDF, DOCX, XLSX, PPTX, TXT, MD, CSV files.",
|
|
5418
|
+
"computer_screenshot": "Capture the current Mac screen as base64 PNG.",
|
|
5419
|
+
"computer_open_app": "Open or focus a Mac app, e.g. Google Chrome.",
|
|
5420
|
+
"computer_open_url": "Open a URL in a Mac app, e.g. Google Chrome.",
|
|
5421
|
+
"computer_click": "Click at screen coordinates (x, y).",
|
|
5422
|
+
"computer_type": "Type text at the current focus position.",
|
|
5423
|
+
"computer_key": "Press a keyboard key or shortcut (e.g. 'command+c').",
|
|
5424
|
+
"computer_scroll": "Scroll at screen coordinates.",
|
|
5425
|
+
"computer_move": "Move the mouse to screen coordinates.",
|
|
5426
|
+
"computer_drag": "Drag from (x1,y1) to (x2,y2).",
|
|
5427
|
+
"computer_status": "Check if Mac Computer Use (pyautogui) is available.",
|
|
5428
|
+
"chrome_status": "Report Chrome desktop bridge availability.",
|
|
5429
|
+
"computer_use_status": "Report Mac Computer Use bridge availability.",
|
|
5430
|
+
"knowledge_save": "Save a note into the local knowledge garden.",
|
|
5431
|
+
"knowledge_search": "Search the local knowledge garden.",
|
|
5432
|
+
"knowledge_tree": "List local knowledge garden markdown files.",
|
|
5433
|
+
"knowledge_graph_ingest":"Ingest a message, AI answer, or connector event into the SQLite knowledge graph.",
|
|
5434
|
+
"knowledge_graph_search":"Search graph nodes, summaries, and JSON metadata.",
|
|
5435
|
+
"knowledge_graph_graph": "Return Obsidian-style graph nodes and edges.",
|
|
5436
|
+
"knowledge_graph_context":"Return compact graph-backed RAG context for a prompt.",
|
|
5437
|
+
"obsidian_save": "Save a note into the Obsidian-compatible memory vault.",
|
|
5438
|
+
"obsidian_search": "Search the Obsidian-compatible memory vault.",
|
|
5439
|
+
"obsidian_tree": "List Obsidian memory vault markdown files.",
|
|
5440
|
+
"git_status": "Read-only local git status inside the workspace.",
|
|
5441
|
+
"git_diff": "Read-only local git diff inside the workspace.",
|
|
5442
|
+
"git_log": "Read-only local git log inside the workspace.",
|
|
5443
|
+
"git_show": "Read-only local git show --stat inside the workspace.",
|
|
5444
|
+
"network_status": "Get current local/private IP, public IP, hostname, and Wi-Fi info.",
|
|
5445
|
+
"run_command": "Run an allowlisted local command inside the workspace.",
|
|
5446
|
+
"build_project": "Run an allowlisted package.json build/compile/typecheck/test script to verify changes actually work.",
|
|
5447
|
+
"deploy_project": "Run an allowlisted package.json deploy/preview/release/package installer script (pkg/exe).",
|
|
5448
|
+
}
|
|
5449
|
+
|
|
5450
|
+
|
|
5451
|
+
@app.get("/tools/permissions")
|
|
5452
|
+
async def tools_permissions(request: Request):
|
|
5453
|
+
"""Compact tool permission view (tool / risk / requires_approval / network).
|
|
5454
|
+
|
|
5455
|
+
A simpler authorization-layer summary derived from TOOL_GOVERNANCE.
|
|
5456
|
+
Use /mcp/tools for the full 7-dimensional governance object.
|
|
5457
|
+
"""
|
|
5458
|
+
require_user(request)
|
|
5459
|
+
return {"status": "ok", "permissions": list_tool_permissions()}
|
|
5460
|
+
|
|
5461
|
+
|
|
4006
5462
|
@app.get("/mcp/tools")
|
|
4007
5463
|
async def mcp_tools():
|
|
4008
5464
|
installed = load_mcp_installs().get("installed", {})
|
|
5465
|
+
tools = []
|
|
5466
|
+
for name, description in _MCP_TOOL_DESCRIPTIONS.items():
|
|
5467
|
+
policy = TOOL_GOVERNANCE.get(name, _TOOL_GOVERNANCE_DEFAULT)
|
|
5468
|
+
tools.append({
|
|
5469
|
+
"name": name,
|
|
5470
|
+
"description": description,
|
|
5471
|
+
"permission": get_tool_permission(name),
|
|
5472
|
+
"governance": {
|
|
5473
|
+
"risk": policy["risk"],
|
|
5474
|
+
"destructive": policy["destructive"],
|
|
5475
|
+
"shell": policy["shell"],
|
|
5476
|
+
"network": policy["network"],
|
|
5477
|
+
"auto_approve": policy["auto_approve"],
|
|
5478
|
+
"sandbox": policy["sandbox"],
|
|
5479
|
+
"rollback": policy["rollback"],
|
|
5480
|
+
},
|
|
5481
|
+
})
|
|
4009
5482
|
return {
|
|
4010
5483
|
"status": "ok",
|
|
4011
5484
|
"workspace": str(AGENT_ROOT),
|
|
4012
5485
|
"installed_mcps": [mcp_public_item(item, installed) for item in MCP_REGISTRY],
|
|
4013
|
-
"tools":
|
|
4014
|
-
{"name": "list_dir", "description": "List files in the agent workspace."},
|
|
4015
|
-
{"name": "workspace_tree", "description": "Return a recursive workspace tree."},
|
|
4016
|
-
{"name": "read_file", "description": "Read a UTF-8 file from the workspace."},
|
|
4017
|
-
{"name": "write_file", "description": "Write a UTF-8 file inside the workspace."},
|
|
4018
|
-
{"name": "search_files", "description": "Search text files inside the workspace."},
|
|
4019
|
-
{"name": "clear_history", "description": "Clear chat history to reduce context and speed up responses."},
|
|
4020
|
-
{"name": "inspect_html", "description": "Inspect local HTML structure and assets."},
|
|
4021
|
-
{"name": "preview_url", "description": "Return a server URL for a workspace file."},
|
|
4022
|
-
{"name": "create_docx", "description": "Create a Word DOCX document in the agent workspace."},
|
|
4023
|
-
{"name": "create_xlsx", "description": "Create an XLSX spreadsheet in the agent workspace."},
|
|
4024
|
-
{"name": "create_pptx", "description": "Create a PPTX presentation deck in the agent workspace."},
|
|
4025
|
-
{"name": "create_pdf", "description": "Create a PDF document in the agent workspace."},
|
|
4026
|
-
{"name": "local_list", "description": "List any local folder (requires user permission via UI)."},
|
|
4027
|
-
{"name": "local_read", "description": "Read any local file (requires user permission via UI)."},
|
|
4028
|
-
{"name": "local_write", "description": "Write any local file (requires user permission via UI)."},
|
|
4029
|
-
{"name": "read_document", "description": "Extract text from PDF, DOCX, XLSX, PPTX, TXT, MD, CSV files."},
|
|
4030
|
-
{"name": "computer_screenshot", "description": "Capture the current Mac screen as base64 PNG."},
|
|
4031
|
-
{"name": "computer_open_app", "description": "Open or focus a Mac app, e.g. Google Chrome."},
|
|
4032
|
-
{"name": "computer_open_url", "description": "Open a URL in a Mac app, e.g. Google Chrome."},
|
|
4033
|
-
{"name": "computer_click", "description": "Click at screen coordinates (x, y)."},
|
|
4034
|
-
{"name": "computer_type", "description": "Type text at the current focus position."},
|
|
4035
|
-
{"name": "computer_key", "description": "Press a keyboard key or shortcut (e.g. 'command+c')."},
|
|
4036
|
-
{"name": "computer_scroll", "description": "Scroll at screen coordinates."},
|
|
4037
|
-
{"name": "computer_move", "description": "Move the mouse to screen coordinates."},
|
|
4038
|
-
{"name": "computer_drag", "description": "Drag from (x1,y1) to (x2,y2)."},
|
|
4039
|
-
{"name": "computer_status", "description": "Check if Mac Computer Use (pyautogui) is available."},
|
|
4040
|
-
{"name": "chrome_status", "description": "Report Chrome desktop bridge availability."},
|
|
4041
|
-
{"name": "computer_use_status", "description": "Report Mac Computer Use bridge availability."},
|
|
4042
|
-
{"name": "knowledge_save", "description": "Save a note into the local knowledge garden."},
|
|
4043
|
-
{"name": "knowledge_search", "description": "Search the local knowledge garden."},
|
|
4044
|
-
{"name": "knowledge_tree", "description": "List local knowledge garden markdown files."},
|
|
4045
|
-
{"name": "knowledge_graph_ingest", "description": "Ingest a message, AI answer, or connector event into the SQLite knowledge graph."},
|
|
4046
|
-
{"name": "knowledge_graph_search", "description": "Search graph nodes, summaries, and JSON metadata."},
|
|
4047
|
-
{"name": "knowledge_graph_graph", "description": "Return Obsidian-style graph nodes and edges."},
|
|
4048
|
-
{"name": "knowledge_graph_context", "description": "Return compact graph-backed RAG context for a prompt."},
|
|
4049
|
-
{"name": "obsidian_save", "description": "Save a note into the Obsidian-compatible memory vault."},
|
|
4050
|
-
{"name": "obsidian_search", "description": "Search the Obsidian-compatible memory vault."},
|
|
4051
|
-
{"name": "obsidian_tree", "description": "List Obsidian memory vault markdown files."},
|
|
4052
|
-
{"name": "git_status", "description": "Read-only local git status inside the workspace."},
|
|
4053
|
-
{"name": "git_diff", "description": "Read-only local git diff inside the workspace."},
|
|
4054
|
-
{"name": "git_log", "description": "Read-only local git log inside the workspace."},
|
|
4055
|
-
{"name": "git_show", "description": "Read-only local git show --stat inside the workspace."},
|
|
4056
|
-
{"name": "network_status", "description": "Get current local/private IP, public IP, hostname, and Wi-Fi info."},
|
|
4057
|
-
{"name": "run_command", "description": "Run an allowlisted local command inside the workspace."},
|
|
4058
|
-
{"name": "build_project", "description": "Run an allowlisted package.json build/compile/typecheck/test script."},
|
|
4059
|
-
{"name": "deploy_project", "description": "Run an allowlisted package.json deploy/preview/release/package installer script (pkg/exe)."},
|
|
4060
|
-
],
|
|
5486
|
+
"tools": tools,
|
|
4061
5487
|
}
|
|
4062
5488
|
|
|
4063
5489
|
|
|
@@ -4115,6 +5541,7 @@ async def mcp_call(req: McpCallRequest, request: Request):
|
|
|
4115
5541
|
args.get("limit", 6),
|
|
4116
5542
|
)
|
|
4117
5543
|
}
|
|
5544
|
+
_check_tool_role(req.action, current_user)
|
|
4118
5545
|
return _tool_response(execute_tool, req.action, req.args or {})
|
|
4119
5546
|
|
|
4120
5547
|
|