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.
Files changed (43) hide show
  1. package/README.md +174 -305
  2. package/docs/CHANGELOG.md +307 -0
  3. package/docs/architecture.md +121 -0
  4. package/docs/mcp-tools.md +116 -0
  5. package/docs/privacy.md +74 -0
  6. package/docs/public-deploy.md +137 -0
  7. package/docs/security-model.md +121 -0
  8. package/knowledge_graph.py +123 -15
  9. package/llm_router.py +100 -28
  10. package/ltcai_cli.py +138 -5
  11. package/package.json +14 -2
  12. package/server.py +1756 -329
  13. package/skills/SKILL_TEMPLATE.md +61 -29
  14. package/skills/code_review/SKILL.md +28 -0
  15. package/skills/code_review/examples.md +59 -0
  16. package/skills/code_review/risk.json +9 -0
  17. package/skills/code_review/schema.json +65 -0
  18. package/skills/data_analysis/SKILL.md +28 -0
  19. package/skills/data_analysis/examples.md +62 -0
  20. package/skills/data_analysis/risk.json +9 -0
  21. package/skills/data_analysis/schema.json +61 -0
  22. package/skills/file_edit/SKILL.md +33 -0
  23. package/skills/file_edit/examples.md +45 -0
  24. package/skills/file_edit/risk.json +9 -0
  25. package/skills/file_edit/schema.json +60 -0
  26. package/skills/summarize_document/SKILL.md +68 -0
  27. package/skills/summarize_document/examples.md +65 -0
  28. package/skills/summarize_document/risk.json +9 -0
  29. package/skills/summarize_document/schema.json +71 -0
  30. package/skills/web_search/SKILL.md +28 -0
  31. package/skills/web_search/examples.md +61 -0
  32. package/skills/web_search/risk.json +9 -0
  33. package/skills/web_search/schema.json +62 -0
  34. package/static/account.html +53 -51
  35. package/static/admin.html +50 -46
  36. package/static/chat.html +124 -96
  37. package/static/graph.html +1231 -337
  38. package/static/manifest.json +2 -2
  39. package/tests/integration/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/tests/integration/__pycache__/test_api.cpython-314-pytest-9.0.3.pyc +0 -0
  41. package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
  42. package/tests/unit/test_tools.py +194 -1
  43. 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 typing import AsyncIterator, Optional, List, Dict
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), 'zh' (Chinese), or 'en' (English)."""
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": "user",
1606
+ "role": role,
1564
1607
  "disabled": False,
1565
1608
  }
1566
1609
  save_users(users)
1567
- return {"status": "ok", "message": "회원가입 성공!"}
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 = 6
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(mx is not None)
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 shutil.which("lms") is not None or Path("/Applications/LM Studio.app").exists()
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
- hf_models_root = Path.home() / ".latticeai" / "hf-models"
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
- marker = hf_models_root / repo_id.replace("/", "__")
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
- marker = hf_models_root / repo_id.replace("/", "__")
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
- for m in ENGINE_MODEL_CATALOG.get("lmstudio", []):
2290
- repo_id = m["id"].removeprefix("lmstudio:")
2291
- marker = hf_models_root / repo_id.replace("/", "__")
2292
- lmstudio_models.append({**m, "pulled": marker.exists()})
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
- marker = hf_models_root / repo_id.replace("/", "__")
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
- "installable": spec["id"] in ENGINE_INSTALLERS,
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(installer["command"]),
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": engine_status(),
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(), "current": router.current_model_id}
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
- if not shutil.which("ollama"):
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 in {"vllm", "lmstudio", "llamacpp", "local_mlx", "mlx"}:
2600
- if importlib.util.find_spec("huggingface_hub") is None:
2601
- raise HTTPException(status_code=400, detail="huggingface_hub가 없습니다. 먼저 엔진 설치를 진행해 주세요.")
2602
- target_dir = Path.home() / ".latticeai" / "hf-models" / model_name.replace("/", "__")
2603
- target_dir.mkdir(parents=True, exist_ok=True)
2604
- try:
2605
- completed = subprocess.run(
2606
- [sys.executable, "-m", "huggingface_hub", "download", model_name, "--local-dir", str(target_dir)],
2607
- capture_output=True, text=True, timeout=3600, check=False,
2608
- )
2609
- except subprocess.TimeoutExpired:
2610
- raise HTTPException(status_code=408, detail=f"{provider} 모델 다운로드 시간이 초과되었습니다.")
2611
- if completed.returncode != 0:
2612
- raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{provider} 모델 다운로드 실패")
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
- if req.engine and req.engine not in {"local_mlx", "mlx"} and ":" not in model_id:
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
- req.adapter_path,
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
- AGENT_SYSTEM_PROMPT = """You are Lattice AI Agent, a local computer-use coding assistant.
3082
- You have full access to the local filesystem via local_list / local_read / local_write tools.
3083
- Use read_file / write_file for paths inside the agent workspace (relative paths).
3084
- Use local_read / local_write for any absolute path on the system (e.g. ~/Downloads, ~/Desktop).
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
- Available actions:
3087
- - list_dir: {"action":"list_dir","args":{"path":"."}}
3088
- - workspace_tree: {"action":"workspace_tree","args":{"path":".","max_depth":3}}
3089
- - read_file: {"action":"read_file","args":{"path":"relative/path.txt"}}
3090
- - write_file: {"action":"write_file","args":{"path":"relative/path.txt","content":"complete file content"}}
3091
- - search_files: {"action":"search_files","args":{"query":"text","path":".","max_results":20}}
3092
- - clear_history: {"action":"clear_history","args":{"keep_last":0}}
3093
- - inspect_html: {"action":"inspect_html","args":{"path":"index.html"}}
3094
- - preview_url: {"action":"preview_url","args":{"path":"index.html"}}
3095
- - create_docx: {"action":"create_docx","args":{"title":"title","body":"paragraphs","filename":"document.docx"}}
3096
- - create_xlsx: {"action":"create_xlsx","args":{"rows":[["A","B"],[1,2]],"filename":"spreadsheet.xlsx","sheet_name":"Sheet1"}}
3097
- - create_pptx: {"action":"create_pptx","args":{"title":"title","slides":[{"title":"Slide","bullets":["point"]}],"filename":"presentation.pptx"}}
3098
- - create_pdf: {"action":"create_pdf","args":{"title":"title","body":"paragraphs","filename":"document.pdf"}}
3099
- - create_web_project: {"action":"create_web_project","args":{"path":"my_app","framework":"react","template":"vite"}} — scaffold a runnable web app project
3100
- - local_list: {"action":"local_list","args":{"path":"/Users/username/Downloads"}} — lists any local folder (UI will request user permission first)
3101
- - local_read: {"action":"local_read","args":{"path":"/Users/username/Documents/note.txt"}} — reads any local file (UI will request user permission first)
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
- - Respond with exactly one JSON object. No markdown, no code fences, no extra text.
3134
- - Use relative paths only.
3135
- - Create complete files, not fragments.
3136
- - Prefer simple, verifiable steps.
3137
- - Use inspect_html and preview_url for generated web UI.
3138
- - Use build_project when the user asks to build, compile, typecheck, or run a package build script.
3139
- - Use deploy_project when the user asks to deploy, preview, release, or package installers (pkg/exe) and package.json defines that script (e.g. package, dist, make, build:pkg, build:exe).
3140
- - If the user asks for app/service/web creation, prefer create_web_project first, then edit files with write_file/read_file and verify with build_project or run_command.
3141
- - If the user asks for installer outputs (.pkg/.exe), set up packaging config (for example Electron/electron-builder or equivalent), create package scripts in package.json, then run deploy_project for installer scripts.
3142
- - If .exe cannot be built on current OS/toolchain, still generate the full packaging config and scripts for Windows and report the exact missing prerequisite.
3143
- - Do not claim you cannot build or deploy. If a script, token, or platform config is missing, inspect the workspace and explain the exact missing piece.
3144
- - Use knowledge tools when the user asks to remember, search memory, or organize project context.
3145
- - Use run_command for local inspection, tests, and short development commands after files are written.
3146
- - For data analysis tasks, read the provided files first (read_document/local_read), compute with run_command when needed, and return concrete findings plus output artifact paths when created.
3147
- - Use clear_history when the user asks to forget, clear, delete, reset, or speed up chat history.
3148
- - Git is read-only: status, diff, log, and show only. Never commit, push, pull, fetch, clone, reset, or checkout.
3149
- - If the user asks for something unsafe or outside the workspace, explain the limitation with final.
3150
- - IMPORTANT: When user asks to create any document (docx, pdf, xlsx, pptx, word, excel, powerpoint, 문서, 파일, 엑셀, 파워포인트, PPT, 피피티), ALWAYS use the appropriate create_* action immediately with full, rich content. Never say you cannot create files.
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
- _TOOL_RISK: Dict[str, str] = {
3161
- # read-only workspace tools
3162
- "list_dir": "low", "workspace_tree": "low", "read_file": "low",
3163
- "search_files": "low", "inspect_html": "low",
3164
- # read-only local FS
3165
- "local_list": "low", "local_read": "low",
3166
- # read-only git
3167
- "git_status": "low", "git_log": "low", "git_diff": "low", "git_show": "low",
3168
- # read-only knowledge / computer
3169
- "knowledge_search": "low", "knowledge_tree": "low",
3170
- "obsidian_search": "low", "obsidian_tree": "low",
3171
- "computer_screenshot": "low", "computer_status": "low",
3172
- # write workspace
3173
- "write_file": "medium", "create_web_project": "medium",
3174
- "create_docx": "medium", "create_xlsx": "medium",
3175
- "create_pptx": "medium", "create_pdf": "medium",
3176
- # write knowledge
3177
- "knowledge_save": "medium", "obsidian_save": "medium",
3178
- # write local FS (arbitrary path — treated as medium; blocked from system roots below)
3179
- "local_write": "medium",
3180
- # preview
3181
- "preview_url": "medium",
3182
- # execute commands
3183
- "run_command": "high",
3184
- # computer control
3185
- "computer_click": "high", "computer_type": "high", "computer_key": "high",
3186
- "computer_scroll": "high", "computer_drag": "high", "computer_move": "high",
3187
- "computer_open_app": "high", "computer_open_url": "high",
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
- # Paths that local_write must never target (system-level protection)
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
- def _agent_risk(action_name: str, args: dict) -> str:
3198
- """Return risk level for an action, upgrading local_write to 'high' for system paths."""
3199
- risk = _TOOL_RISK.get(action_name, "medium")
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
- risk = "high"
3204
- return risk
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
- @app.post("/agent")
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
- ensure_agent_root()
3262
- transcript = []
3263
- max_steps = max(1, min(req.max_steps, 10))
3264
- lang = detect_language(req.message)
3265
- lang_hint = _LANG_HINT[lang]
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"{AGENT_SYSTEM_PROMPT}\n\n"
3271
- f"[LANGUAGE: {lang_hint}]\n\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"Recent conversation:\n{recent_context or '(none)'}\n\n"
3274
- f"User request:\n{req.message}\n\n"
3275
- f"Previous tool results:\n{json.dumps(transcript, ensure_ascii=False, indent=2)}"
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="Choose the next agent action.",
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({"step": step + 1, "action": "parse_error", "raw": str(raw), "error": str(exc)})
3288
- message = "작업 계획을 안정적으로 해석하지 못해 자동 실행을 중단했습니다. 요청을 더 짧고 구체적으로 다시 시도해 주세요."
3289
- save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
3290
- save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
3291
- created_files = _collect_created_files(transcript)
3292
- return {
3293
- "status": "ok",
3294
- "response": message,
3295
- "workspace": str(AGENT_ROOT),
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
- message = action.get("message", "작업을 완료했습니다.")
3303
- save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
3304
- save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
3305
- created_files = _collect_created_files(transcript)
3306
- return {"status": "ok", "response": message, "workspace": str(AGENT_ROOT), "steps": transcript, "created_files": created_files}
3307
-
3308
- # Prevent repeated file/project creation loops with identical action+args.
3309
- last_step = transcript[-1] if transcript else None
3310
- current_args = action.get("args") or {}
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 last_step
3314
- and last_step.get("action") == name
3315
- and (last_step.get("args") or {}) == current_args
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
- message = "요청한 파일 생성을 이미 완료해서 반복 실행을 중단했습니다."
3319
- save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
3320
- save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
3321
- created_files = _collect_created_files(transcript)
3322
- return {"status": "ok", "response": message, "workspace": str(AGENT_ROOT), "steps": transcript, "created_files": created_files}
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(current_args.get("keep_last", 0))
3326
- append_audit_event(
3327
- "history_delete",
3328
- user_email=current_user,
3329
- source=req.source or "agent",
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
- risk = _agent_risk(name, current_args)
4449
+ policy = _agent_policy(name, args)
4450
+ risk = _RISK_LEVEL_MAP.get(policy["risk"], "medium")
3338
4451
 
3339
- # Block system-path local_write even if the LLM tries it
3340
- if name == "local_write":
3341
- path = str(current_args.get("path", ""))
3342
- if any(path.startswith(p) for p in _LOCAL_WRITE_BLOCKED_PREFIXES):
3343
- transcript.append({
3344
- "step": step + 1, "action": name, "args": current_args,
3345
- "risk": "high", "error": f"BLOCKED: writing to system path is not allowed: {path}",
3346
- })
3347
- append_audit_event(
3348
- "agent_blocked", user_email=current_user, source=req.source or "agent",
3349
- action=name, path=path, reason="system_path",
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
- # Audit medium/high actions before execution
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
- step=step + 1, action=name, risk=risk,
3358
- args={k: v for k, v in (current_args or {}).items() if k != "content"},
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
- result = execute_tool(name, current_args)
3363
- transcript.append({"step": step + 1, "action": name, "args": current_args, "risk": risk, "result": result})
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({"step": step + 1, "action": name, "args": current_args, "risk": risk, "error": str(exc)})
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
- summary_context = (
3368
- f"{AGENT_SYSTEM_PROMPT}\n\n"
3369
- f"Recent conversation:\n{build_recent_chat_context(conversation_id=req.conversation_id) or '(none)'}\n\n"
3370
- f"User request:\n{req.message}\n\n"
3371
- f"Tool transcript:\n{json.dumps(transcript, ensure_ascii=False, indent=2)}"
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
- summary = await router.generate(
3374
- message='Return only {"action":"final","message":"..."} summarizing the current result in Korean.',
3375
- context=summary_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
- final_action = _extract_agent_action(str(summary))
3381
- message = final_action.get("message", str(summary))
4510
+ verdict = _extract_agent_action(str(raw))
3382
4511
  except ValueError:
3383
- message = str(summary)
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 {"status": "ok", "response": message, "workspace": str(AGENT_ROOT), "steps": transcript, "created_files": created_files}
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: ToolPathRequest, request: Request):
4761
+ async def tools_read_file(req: ToolReadFileRequest, request: Request):
3414
4762
  require_user(request)
3415
- return _tool_response(read_file, req.path)
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
- require_user(request)
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
- require_user(request)
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
- require_user(request)
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
- require_user(request)
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