ltcai 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.py CHANGED
@@ -52,8 +52,8 @@ from latticeai.core.context_builder import retrieve_context_for_generation, form
52
52
  from latticeai.core.document_generator import detect_document_intent, DocumentGenerationSession
53
53
  from local_knowledge_api import LocalKnowledgeWatcher, create_local_knowledge_router
54
54
  from latticeai.core.security import (
55
- hash_password as _hash_password,
56
- verify_password as _verify_password,
55
+ hash_password,
56
+ verify_password,
57
57
  host_is_loopback as _host_is_loopback_impl,
58
58
  client_ip as _client_ip_impl,
59
59
  bytes_match_extension as _bytes_match_extension_impl,
@@ -78,6 +78,7 @@ from latticeai.core.model_compat import (
78
78
  record_smoke_result as _record_smoke_result,
79
79
  fast_postprocess as _compat_fast_postprocess,
80
80
  validate_smoke_response as _validate_smoke_response,
81
+ classify_smoke_response as _classify_smoke_response,
81
82
  list_cached_profiles as _list_compat_profiles,
82
83
  SMOKE_PROMPT as _SMOKE_PROMPT,
83
84
  )
@@ -90,6 +91,15 @@ from latticeai.core.graph_curator import (
90
91
  auto_build_graph_overlay as _auto_build_graph_overlay,
91
92
  mask_secrets as _curator_mask_secrets,
92
93
  )
94
+ from latticeai.core.config import Config
95
+ from latticeai.core.agent import (
96
+ AgentState,
97
+ AgentRunContext,
98
+ AGENT_TERMINAL_STATES,
99
+ AgentDeps,
100
+ AgentRuntime,
101
+ extract_action as _extract_agent_action,
102
+ )
93
103
  import mcp_registry
94
104
  from mcp_registry import (
95
105
  MCP_REGISTRY, _THIRD_PARTY_SKILL_SOURCES, _KNOWN_REPO_LICENSES,
@@ -225,39 +235,38 @@ def env_bool(name: str, default: bool = False) -> bool:
225
235
  return default
226
236
  return raw.strip().lower() in {"1", "true", "yes", "on"}
227
237
 
228
- APP_MODE = env_value("LATTICEAI_MODE", "local").strip().lower()
229
- if APP_MODE not in {"local", "public"}:
230
- APP_MODE = "local"
231
- IS_PUBLIC_MODE = APP_MODE == "public"
232
- DEFAULT_HOST = env_value("LATTICEAI_HOST", "127.0.0.1")
233
- DEFAULT_PORT = int(env_value("LATTICEAI_PORT", "4825"))
238
+ # ── App-level config — parsed once, in one place (latticeai.core.config) ──────
239
+ # The module-level names below are kept as a compatibility surface for the rest
240
+ # of server.py; all of them are now derived from a single CONFIG instance.
241
+ CONFIG = Config.from_env()
242
+
243
+ APP_MODE = CONFIG.app_mode
244
+ IS_PUBLIC_MODE = CONFIG.is_public
245
+ DEFAULT_HOST = CONFIG.host
246
+ DEFAULT_PORT = CONFIG.port
234
247
  def _host_is_loopback(host: str) -> bool:
235
248
  return _host_is_loopback_impl(host)
236
249
 
237
- NETWORK_EXPOSED = not _host_is_loopback(DEFAULT_HOST)
238
- ENABLE_TELEGRAM = env_bool("LATTICEAI_ENABLE_TELEGRAM", default=not IS_PUBLIC_MODE)
239
- ENABLE_GRAPH = env_bool("LATTICEAI_ENABLE_GRAPH", default=True)
240
- AUTOLOAD_MODELS = env_bool("LATTICEAI_AUTOLOAD_MODELS", default=IS_PUBLIC_MODE)
241
- MODEL_IDLE_UNLOAD_SECONDS = int(env_value("LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", "0"))
242
- ALLOW_LOCAL_MODELS = env_bool("LATTICEAI_ALLOW_LOCAL_MODELS", default=not IS_PUBLIC_MODE)
243
- REQUIRE_AUTH = env_bool("LATTICEAI_REQUIRE_AUTH", default=IS_PUBLIC_MODE or NETWORK_EXPOSED)
244
- ALLOW_PLAINTEXT_API_KEYS = env_bool("LATTICEAI_ALLOW_PLAINTEXT_API_KEYS", default=False)
245
- CORS_ALLOW_NETWORK = env_bool("LATTICEAI_CORS_ALLOW_NETWORK", default=False)
246
- CORS_EXTRA_ORIGINS = [
247
- item.strip()
248
- for item in env_value("LATTICEAI_CORS_ALLOWED_ORIGINS", "").split(",")
249
- if item.strip()
250
- ]
251
- PUBLIC_MODEL = env_value("LATTICEAI_PUBLIC_MODEL", env_value("LATTICEAI_DEFAULT_MODEL", "openai:gpt-4o-mini"))
252
- LOCAL_MODEL = env_value("LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-26b-a4b-it-4bit")
253
- LOCAL_DRAFT_MODEL = env_value("LATTICEAI_LOCAL_DRAFT_MODEL", "")
250
+ NETWORK_EXPOSED = CONFIG.network_exposed
251
+ ENABLE_TELEGRAM = CONFIG.enable_telegram
252
+ ENABLE_GRAPH = CONFIG.enable_graph
253
+ AUTOLOAD_MODELS = CONFIG.autoload_models
254
+ MODEL_IDLE_UNLOAD_SECONDS = CONFIG.model_idle_unload_seconds
255
+ ALLOW_LOCAL_MODELS = CONFIG.allow_local_models
256
+ REQUIRE_AUTH = CONFIG.require_auth
257
+ ALLOW_PLAINTEXT_API_KEYS = CONFIG.allow_plaintext_api_keys
258
+ CORS_ALLOW_NETWORK = CONFIG.cors_allow_network
259
+ CORS_EXTRA_ORIGINS = CONFIG.cors_extra_origins
260
+ PUBLIC_MODEL = CONFIG.public_model
261
+ LOCAL_MODEL = CONFIG.local_model
262
+ LOCAL_DRAFT_MODEL = CONFIG.local_draft_model
254
263
 
255
264
  # ── SSO / OIDC config ─────────────────────────────────────────────────────────
256
- SSO_DISCOVERY_URL = env_value("OIDC_DISCOVERY_URL", "")
257
- SSO_CLIENT_ID = env_value("OIDC_CLIENT_ID", "")
258
- SSO_CLIENT_SECRET = env_value("OIDC_CLIENT_SECRET", "")
259
- SSO_REDIRECT_URI = env_value("OIDC_REDIRECT_URI", "http://localhost:4825/auth/sso/callback")
260
- SSO_PROVIDER_NAME = env_value("OIDC_PROVIDER_NAME", "SSO")
265
+ SSO_DISCOVERY_URL = CONFIG.sso_discovery_url
266
+ SSO_CLIENT_ID = CONFIG.sso_client_id
267
+ SSO_CLIENT_SECRET = CONFIG.sso_client_secret
268
+ SSO_REDIRECT_URI = CONFIG.sso_redirect_uri
269
+ SSO_PROVIDER_NAME = CONFIG.sso_provider_name
261
270
  _sso_discovery_cache: Optional[Dict] = None
262
271
  _sso_discovery_cache_url: str = ""
263
272
  _sso_states: Dict[str, float] = {} # state → timestamp (CSRF protection)
@@ -282,13 +291,8 @@ async def _get_sso_discovery() -> Optional[Dict]:
282
291
  return None
283
292
  return _sso_discovery_cache
284
293
 
285
- # ── Password hashing — delegated to latticeai.core.security ────────────────────
286
- def hash_password(password: str) -> str:
287
- return _hash_password(password)
288
-
289
- def verify_password(password: str, hashed: str) -> bool:
290
- return _verify_password(password, hashed)
291
-
294
+ # ── Password hashing — used directly from latticeai.core.security ──────────────
295
+ # (hash_password / verify_password are imported above; no local wrapper needed)
292
296
  def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict) -> bool:
293
297
  """평문 비밀번호를 투명하게 해시로 마이그레이션. 마이그레이션 발생 시 audit log 남김."""
294
298
  if ":" in stored and len(stored) > 64:
@@ -325,13 +329,9 @@ def invalidate_session(token: str) -> None:
325
329
 
326
330
  # ── User Management Logic ──────────────────────────────────────────────────
327
331
  BASE_DIR = Path(__file__).resolve().parent
328
- DATA_DIR = Path(env_value("LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
332
+ DATA_DIR = CONFIG.data_dir
329
333
  DATA_DIR.mkdir(parents=True, exist_ok=True)
330
- STATIC_DIR = Path(env_value("LATTICEAI_STATIC_DIR", str(BASE_DIR / "static")))
331
- if not STATIC_DIR.exists():
332
- packaged_static = Path(sys.prefix) / "static"
333
- if packaged_static.exists():
334
- STATIC_DIR = packaged_static
334
+ STATIC_DIR = CONFIG.static_dir
335
335
 
336
336
  USERS_FILE = DATA_DIR / "users.json"
337
337
  HISTORY_FILE = DATA_DIR / "chat_history.json"
@@ -869,11 +869,7 @@ def get_user_role(email: str, users: Optional[Dict] = None) -> str:
869
869
  user = users.get(email) or {}
870
870
  if user.get("role") in {"admin", "user"}:
871
871
  return user["role"]
872
- admin_emails = {
873
- item.strip().lower()
874
- for item in env_value("LATTICEAI_ADMIN_EMAILS", "").split(",")
875
- if item.strip()
876
- }
872
+ admin_emails = set(CONFIG.admin_emails)
877
873
  if email.lower() in admin_emails:
878
874
  return "admin"
879
875
  first_email = next(iter(users), None)
@@ -899,7 +895,7 @@ def require_user(request: Request) -> str:
899
895
 
900
896
 
901
897
  # ── Rate limiting & file validation — delegated to latticeai.core.security ────
902
- _RATE_LIMIT_ENABLED = os.getenv("LATTICEAI_RATE_LIMIT", "1") != "0"
898
+ _RATE_LIMIT_ENABLED = CONFIG.rate_limit_enabled
903
899
 
904
900
  def enforce_rate_limit(email: str, bucket_key: str) -> None:
905
901
  _enforce_rate_limit(email, bucket_key, enabled=_RATE_LIMIT_ENABLED)
@@ -1125,7 +1121,7 @@ async def lifespan(app: FastAPI):
1125
1121
  except Exception:
1126
1122
  pass
1127
1123
 
1128
- app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.3.0", lifespan=lifespan)
1124
+ app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.4.0", lifespan=lifespan)
1129
1125
 
1130
1126
  CORS_ALLOWED_ORIGINS = [
1131
1127
  f"http://localhost:{DEFAULT_PORT}",
@@ -1155,9 +1151,9 @@ if _ICONS_DIR.exists():
1155
1151
  app.mount("/icons", StaticFiles(directory=str(_ICONS_DIR)), name="icons")
1156
1152
  ensure_agent_root()
1157
1153
 
1158
- OPEN_REGISTRATION = env_bool("LATTICEAI_OPEN_REGISTRATION", default=not NETWORK_EXPOSED and not IS_PUBLIC_MODE)
1159
- INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
1160
- INVITE_GATE_ENABLED = env_bool("LATTICEAI_INVITE_GATE_ENABLED", default=False)
1154
+ OPEN_REGISTRATION = CONFIG.open_registration
1155
+ INVITE_CODE = CONFIG.invite_code
1156
+ INVITE_GATE_ENABLED = CONFIG.invite_gate_enabled
1161
1157
 
1162
1158
  # ── Auth & Admin routers (latticeai.api) ─────────────────────────────────────
1163
1159
  app.include_router(create_auth_router(
@@ -1442,39 +1438,8 @@ class AgentEvalRequest(BaseModel):
1442
1438
  case_id: Optional[str] = None
1443
1439
 
1444
1440
 
1445
- class AgentState(str, Enum):
1446
- IDLE = "IDLE"
1447
- PLANNING = "PLANNING"
1448
- WAITING_APPROVAL = "WAITING_APPROVAL"
1449
- EXECUTING = "EXECUTING"
1450
- VERIFYING = "VERIFYING"
1451
- FAILED = "FAILED"
1452
- ROLLBACK = "ROLLBACK"
1453
- DONE = "DONE"
1454
-
1455
-
1456
- # Terminal states — the agent loop exits when reaching one of these
1457
- AGENT_TERMINAL_STATES = frozenset({AgentState.DONE, AgentState.FAILED})
1458
-
1459
-
1460
- class AgentRunContext:
1461
- """Mutable state carrier passed through all agent phases."""
1462
- __slots__ = ("state", "plan", "transcript", "retry_count",
1463
- "state_history", "corrections", "final_message", "rollback_log",
1464
- "executing_model", "reviewing_model")
1465
-
1466
- def __init__(self) -> None:
1467
- self.state: AgentState = AgentState.IDLE
1468
- self.plan: dict = {}
1469
- self.transcript: list = []
1470
- self.retry_count: int = 0
1471
- self.state_history: list = []
1472
- self.corrections: list = []
1473
- self.final_message: str = ""
1474
- self.rollback_log: list = []
1475
- self.executing_model: str | None = None
1476
- self.reviewing_model: str | None = None
1477
-
1441
+ # AgentState / AgentRunContext / AGENT_TERMINAL_STATES are defined in
1442
+ # latticeai.core.agent and imported at the top of this module.
1478
1443
 
1479
1444
  # Pending agent contexts waiting for human approval: context_id → (ctx, req, lang_hint, current_user)
1480
1445
  _pending_agents: dict[str, tuple] = {}
@@ -3054,9 +3019,12 @@ async def _smoke_test_loaded_model(
3054
3019
  )
3055
3020
  except Exception as exc: # pragma: no cover - generator may not exist on all engines
3056
3021
  reason = str(exc)[:200] or "generation_failed"
3057
- profile = _record_smoke_result(resolution.load_id, resolution.engine, False, reason)
3022
+ profile = _record_smoke_result(
3023
+ resolution.load_id, resolution.engine, False, reason, status="failed"
3024
+ )
3058
3025
  return {
3059
3026
  "ok": False,
3027
+ "status": "failed",
3060
3028
  "reason": reason,
3061
3029
  "answer": None,
3062
3030
  "profile": profile.to_dict(),
@@ -3064,10 +3032,15 @@ async def _smoke_test_loaded_model(
3064
3032
 
3065
3033
  profile = _ensure_compat_profile(resolution.load_id, resolution.engine)
3066
3034
  cleaned = _compat_fast_postprocess(str(text or ""), profile.to_dict())
3067
- ok, reason = _validate_smoke_response(cleaned)
3068
- profile = _record_smoke_result(resolution.load_id, resolution.engine, ok, reason)
3035
+ # item 3-3: ok / degraded / failed 3분류. degraded는 채팅은 가능하다.
3036
+ status, reason = _classify_smoke_response(cleaned)
3037
+ ok = status != "failed"
3038
+ profile = _record_smoke_result(
3039
+ resolution.load_id, resolution.engine, ok, reason, status=status
3040
+ )
3069
3041
  return {
3070
3042
  "ok": ok,
3043
+ "status": status,
3071
3044
  "reason": reason,
3072
3045
  "answer": cleaned,
3073
3046
  "profile": profile.to_dict(),
@@ -3159,7 +3132,8 @@ async def prepare_and_load_model(
3159
3132
  try:
3160
3133
  smoke_result = await _smoke_test_loaded_model(resolution, api_key_override=user_api_key)
3161
3134
  ready_to_chat = bool(smoke_result.get("ok"))
3162
- compat_status = "ok" if ready_to_chat else "degraded"
3135
+ # item 3-3: smoke 결과의 3분류(ok/degraded/failed)를 그대로 노출한다.
3136
+ compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
3163
3137
  except Exception as exc: # never break load on smoke test failures
3164
3138
  logging.warning("smoke test failed for %s: %s", resolution.load_id, exc)
3165
3139
  compat_status = "unknown"
@@ -3402,7 +3376,8 @@ async def prepare_and_load_model_stream(
3402
3376
  try:
3403
3377
  smoke_result = await _smoke_test_loaded_model(resolution_stream, api_key_override=user_api_key)
3404
3378
  ready_to_chat = bool(smoke_result.get("ok"))
3405
- compat_status = "ok" if ready_to_chat else "degraded"
3379
+ # item 3-3: smoke 결과의 3분류(ok/degraded/failed)를 그대로 노출한다.
3380
+ compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
3406
3381
  except Exception as exc:
3407
3382
  logging.warning("smoke test (stream) failed for %s: %s", resolution_stream.load_id, exc)
3408
3383
  compat_status = "unknown"
@@ -3491,7 +3466,7 @@ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str
3491
3466
 
3492
3467
  @app.get("/health")
3493
3468
  async def health(request: Request):
3494
- base = {"status": "ok", "version": "0.3.0", "mode": APP_MODE}
3469
+ base = {"status": "ok", "version": "0.4.0", "mode": APP_MODE}
3495
3470
  if not get_current_user(request) and REQUIRE_AUTH:
3496
3471
  return base
3497
3472
  engines = await asyncio.to_thread(engine_status)
@@ -3904,7 +3879,7 @@ async def chat(req: ChatRequest, request: Request):
3904
3879
  if screenshot_context:
3905
3880
  context += f"\n\n{screenshot_context}"
3906
3881
 
3907
- if env_bool("LATTICEAI_AUTO_READ_CHAT_PATHS", default=False):
3882
+ if CONFIG.auto_read_chat_paths:
3908
3883
  _file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
3909
3884
  for _m in _file_path_re.finditer(req.message or ""):
3910
3885
  _fpath = _m.group(1).strip()
@@ -4577,322 +4552,35 @@ def _collect_created_files(transcript: list) -> list:
4577
4552
  return files
4578
4553
 
4579
4554
 
4580
- def _extract_agent_action(raw: str) -> Dict:
4581
- text = raw.strip()
4582
- fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL)
4583
- if fenced:
4584
- text = fenced.group(1).strip()
4585
- elif not text.startswith("{"):
4586
- start = text.find("{")
4587
- end = text.rfind("}")
4588
- if start >= 0 and end > start:
4589
- text = text[start : end + 1]
4590
-
4591
- try:
4592
- action = json.loads(text)
4593
- except json.JSONDecodeError as exc:
4594
- raise ValueError(f"Agent did not return valid JSON: {exc}") from exc
4595
-
4596
- if not isinstance(action, dict) or "action" not in action:
4597
- raise ValueError("Agent JSON must include an action field.")
4598
- return action
4599
-
4600
-
4601
- # ── Agent State Machine — Phase Functions ─────────────────────────────────────
4602
-
4603
- async def _phase_plan(
4604
- ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str, current_user: str,
4605
- model_id: str | None = None,
4606
- ) -> None:
4607
- """PLAN: Planner role produces a structured plan JSON."""
4608
- context = (
4609
- f"{PLANNER_PROMPT}\n\n"
4610
- f"[LANGUAGE HINT: {lang_hint}]\n"
4611
- f"Workspace root: {AGENT_ROOT}\n\n"
4612
- f"User request: {req.message}"
4613
- )
4614
- raw = await router.generate_as(
4615
- model_id,
4616
- message="Produce a JSON execution plan for this request.",
4617
- context=context, max_tokens=1024, temperature=0.1,
4618
- )
4619
- try:
4620
- plan = _extract_agent_action(str(raw))
4621
- except ValueError:
4622
- plan = {
4623
- "action": "plan", "state": "PLAN",
4624
- "goal": req.message, "steps": [],
4625
- "requires_approval": False, "rollback_strategy": "none", "estimated_steps": 1,
4626
- }
4627
- ctx.plan = plan
4628
- ctx.transcript.append({
4629
- "state": AgentState.PLANNING.value,
4630
- "goal": plan.get("goal", req.message),
4631
- "steps": plan.get("steps", []),
4632
- "requires_approval": plan.get("requires_approval", False),
4633
- "rollback_strategy": plan.get("rollback_strategy", "none"),
4634
- "estimated_steps": plan.get("estimated_steps", 1),
4635
- })
4636
- ctx.state = AgentState.WAITING_APPROVAL
4637
-
4638
-
4639
- def _phase_approval(ctx: AgentRunContext, current_user: str) -> None:
4640
- """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
4641
- auto_approve_tools = {name for name, p in TOOL_GOVERNANCE.items() if p["auto_approve"]}
4642
- steps = ctx.plan.get("steps", [])
4643
- non_auto = [s.get("action") for s in steps if s.get("action") not in auto_approve_tools]
4644
- requires = ctx.plan.get("requires_approval", False) or bool(non_auto)
4645
-
4646
- ctx.transcript.append({
4647
- "state": AgentState.WAITING_APPROVAL.value,
4648
- "requires_approval": requires,
4649
- "non_auto_approve_steps": non_auto,
4650
- "decision": "auto_approved",
4651
- })
4652
- append_audit_event(
4653
- "agent_approval", user_email=current_user,
4654
- requires_approval=requires, non_auto_steps=non_auto, decision="auto_approved",
4655
- )
4656
- ctx.state = AgentState.EXECUTING
4657
-
4658
-
4659
- async def _phase_execute(
4660
- ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str,
4661
- current_user: str, max_steps: int, model_id: str | None = None,
4662
- ) -> None:
4663
- """EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
4664
- exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
4665
- budget = max(1, max_steps - exec_count)
4666
-
4667
- for _ in range(budget):
4668
- corrections_hint = (
4669
- "\n\nCritic corrections from previous attempt:\n"
4670
- + "\n".join(f"- {c}" for c in ctx.corrections)
4671
- ) if ctx.corrections else ""
4672
-
4673
- context = (
4674
- f"{EXECUTOR_PROMPT}\n\n"
4675
- f"[LANGUAGE HINT: {lang_hint}]\n"
4676
- f"Workspace root: {AGENT_ROOT}\n\n"
4677
- f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
4678
- f"Recent conversation:\n{build_recent_chat_context(conversation_id=req.conversation_id) or '(none)'}\n\n"
4679
- f"User request: {req.message}{corrections_hint}\n\n"
4680
- f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
4681
- )
4682
- raw = await router.generate_as(
4683
- model_id,
4684
- message="Execute the next step.",
4685
- context=context, max_tokens=4096, temperature=req.temperature,
4686
- )
4687
- try:
4688
- action = _extract_agent_action(str(raw))
4689
- except ValueError as exc:
4690
- ctx.transcript.append({
4691
- "state": AgentState.EXECUTING.value, "action": "parse_error",
4692
- "raw": str(raw)[:400], "error": str(exc),
4693
- })
4694
- break
4695
-
4696
- name = action.get("action")
4697
- thoughts = str(action.get("thoughts") or "")[:600]
4698
- args = action.get("args") or {}
4699
-
4700
- if name == "final":
4701
- ctx.final_message = action.get("message", "작업을 완료했습니다.")
4702
- ctx.transcript.append({
4703
- "state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
4704
- })
4705
- ctx.state = AgentState.VERIFYING
4706
- return
4707
-
4708
- # Loop guard
4709
- exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
4710
- last = exec_steps[-1] if exec_steps else None
4711
- if (
4712
- name in _FILE_CREATE_ACTIONS and last
4713
- and last.get("action") == name
4714
- and (last.get("args") or {}) == args
4715
- and "result" in last
4716
- ):
4717
- ctx.transcript.append({
4718
- "state": AgentState.EXECUTING.value, "action": name,
4719
- "error": "LOOP_DETECTED: identical action+args repeated — halted.",
4720
- })
4721
- break
4722
-
4723
- if name == "clear_history":
4724
- result = clear_history(args.get("keep_last", 0))
4725
- ctx.transcript.append({
4726
- "state": AgentState.EXECUTING.value, "action": name,
4727
- "thoughts": thoughts, "args": args, "result": result,
4728
- })
4729
- continue
4730
-
4731
- policy = _agent_policy(name, args)
4732
- risk = _RISK_LEVEL_MAP.get(policy["risk"], "medium")
4733
-
4734
- if policy["risk"] == "destructive":
4735
- ctx.transcript.append({
4736
- "state": AgentState.EXECUTING.value, "action": name,
4737
- "thoughts": thoughts, "args": args, "risk": risk,
4738
- "governance": dict(policy),
4739
- "error": f"BLOCKED: destructive action '{name}' not permitted in agent mode.",
4740
- })
4741
- append_audit_event(
4742
- "agent_blocked", user_email=current_user, source=req.source or "agent",
4743
- action=name, reason="destructive", governance=dict(policy),
4744
- )
4745
- continue
4746
-
4747
- if not policy["auto_approve"]:
4748
- append_audit_event(
4749
- "agent_exec", user_email=current_user, source=req.source or "agent",
4750
- state=AgentState.EXECUTING.value, action=name, risk=risk,
4751
- shell=policy["shell"], network=policy["network"],
4752
- destructive=policy["destructive"], sandbox=policy["sandbox"],
4753
- rollback=policy["rollback"],
4754
- args={k: v for k, v in args.items() if k != "content"},
4755
- )
4756
-
4757
- try:
4758
- _check_tool_role(name, current_user)
4759
- result = execute_tool(name, args)
4760
- ctx.transcript.append({
4761
- "state": AgentState.EXECUTING.value, "action": name,
4762
- "thoughts": thoughts, "args": args,
4763
- "risk": risk, "governance": dict(policy), "result": result,
4764
- })
4765
- except (ToolError, KeyError, TypeError) as exc:
4766
- ctx.transcript.append({
4767
- "state": AgentState.EXECUTING.value, "action": name,
4768
- "thoughts": thoughts, "args": args,
4769
- "risk": risk, "governance": dict(policy), "error": str(exc),
4770
- })
4771
-
4772
- ctx.state = AgentState.VERIFYING
4773
-
4774
-
4775
- async def _phase_verify(
4776
- ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str, current_user: str,
4777
- max_retry: int = 3, model_id: str | None = None,
4778
- ) -> None:
4779
- """VERIFYING: Critic role evaluates transcript → DONE / EXECUTING (retry) / ROLLBACK / FAILED."""
4780
- context = (
4781
- f"{CRITIC_PROMPT}\n\n"
4782
- f"[LANGUAGE HINT: {lang_hint}]\n\n"
4783
- f"Original request: {req.message}\n"
4784
- f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
4785
- f"Full transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
4786
- )
4787
- raw = await router.generate_as(
4788
- model_id,
4789
- message="Review the execution transcript and return your verdict JSON.",
4790
- context=context, max_tokens=512, temperature=0.1,
4555
+ # ── Agent Runtime wiring ──────────────────────────────────────────────────────
4556
+ # The Discover→Plan→Implement→Verify state machine lives in
4557
+ # latticeai.core.agent. server.py wires the ports (LLM, tools, governance,
4558
+ # audit, prompts) into one AgentRuntime and keeps only the HTTP glue below.
4559
+
4560
+ def _build_agent_runtime() -> AgentRuntime:
4561
+ deps = AgentDeps(
4562
+ generate_as=router.generate_as,
4563
+ generate=router.generate,
4564
+ execute_tool=execute_tool,
4565
+ policy_for=_agent_policy,
4566
+ risk_level=lambda policy: _RISK_LEVEL_MAP.get(policy["risk"], "medium"),
4567
+ check_role=_check_tool_role,
4568
+ tool_governance=TOOL_GOVERNANCE,
4569
+ file_create_actions=frozenset(_FILE_CREATE_ACTIONS),
4570
+ recent_chat_context=build_recent_chat_context,
4571
+ clear_history=clear_history,
4572
+ knowledge_save=knowledge_save,
4573
+ audit=append_audit_event,
4574
+ planner_prompt=PLANNER_PROMPT,
4575
+ executor_prompt=EXECUTOR_PROMPT,
4576
+ critic_prompt=CRITIC_PROMPT,
4577
+ memory_updater_prompt=MEMORY_UPDATER_PROMPT,
4578
+ agent_root=AGENT_ROOT,
4791
4579
  )
4792
- try:
4793
- verdict = _extract_agent_action(str(raw))
4794
- except ValueError:
4795
- verdict = {"action": "verdict", "verdict": "PASS", "next_state": "DONE",
4796
- "reason": "Critic parse failed — assuming pass.", "corrections": [], "confidence": 0.7}
4797
-
4798
- ctx.corrections = verdict.get("corrections", [])
4799
- # Normalize legacy verdict next_state strings to current AgentState names
4800
- raw_next = verdict.get("next_state", "DONE")
4801
- next_s = {"COMPLETE": "DONE", "RETRY": "EXECUTING"}.get(raw_next, raw_next)
4802
-
4803
- ctx.transcript.append({
4804
- "state": AgentState.VERIFYING.value,
4805
- "verdict": verdict.get("verdict", "PASS"),
4806
- "reason": verdict.get("reason", ""),
4807
- "corrections": ctx.corrections,
4808
- "confidence": verdict.get("confidence", 0.9),
4809
- "next_state": next_s,
4810
- })
4811
-
4812
- if verdict.get("verdict") == "PASS" or next_s == "DONE":
4813
- if not ctx.final_message:
4814
- ctx.final_message = verdict.get("reason", "작업이 완료되었습니다.")
4815
- ctx.state = AgentState.DONE
4816
- elif next_s == "ROLLBACK":
4817
- ctx.state = AgentState.ROLLBACK
4818
- elif next_s == "EXECUTING":
4819
- if ctx.retry_count >= max_retry:
4820
- ctx.final_message = "처리 중 문제가 발생했습니다. 다시 시도해 주세요."
4821
- ctx.state = AgentState.FAILED
4822
- else:
4823
- ctx.retry_count += 1
4824
- ctx.transcript.append({
4825
- "state": AgentState.EXECUTING.value,
4826
- "retry_attempt": ctx.retry_count,
4827
- "corrections": ctx.corrections,
4828
- })
4829
- ctx.state = AgentState.EXECUTING
4830
- else:
4831
- ctx.final_message = verdict.get("reason", "검증자가 인식되지 않은 다음 상태를 반환했습니다.")
4832
- ctx.state = AgentState.FAILED
4580
+ return AgentRuntime(deps)
4833
4581
 
4834
4582
 
4835
- def _phase_rollback(ctx: AgentRunContext, current_user: str) -> None:
4836
- """ROLLBACK: attempt git checkout for each edited file, then COMPLETE."""
4837
- import subprocess as _sp
4838
- rolled: list = []
4839
- for step in ctx.transcript:
4840
- if step.get("state") != AgentState.EXECUTING.value:
4841
- continue
4842
- gov = step.get("governance", {})
4843
- if gov.get("rollback") != "git":
4844
- continue
4845
- result = step.get("result", {})
4846
- if not (isinstance(result, dict) and result.get("success")):
4847
- continue
4848
- path = result.get("path") or (step.get("args") or {}).get("path", "")
4849
- if not path:
4850
- continue
4851
- try:
4852
- r = _sp.run(
4853
- ["git", "checkout", "--", path], cwd=str(AGENT_ROOT),
4854
- capture_output=True, text=True, timeout=10,
4855
- )
4856
- rolled.append({"path": path, "ok": r.returncode == 0, "stderr": r.stderr[:200]})
4857
- except Exception as exc:
4858
- rolled.append({"path": path, "ok": False, "error": str(exc)})
4859
-
4860
- ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
4861
- recovered = [r["path"] for r in rolled if r.get("ok")]
4862
- ctx.final_message = (
4863
- f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
4864
- if recovered
4865
- else "롤백을 시도했으나 복구할 파일이 없거나 git이 초기화되지 않았습니다."
4866
- )
4867
- append_audit_event("agent_rollback", user_email=current_user, rolled_back=rolled)
4868
- # Rollback is a recovery from a failed verification — terminal state is FAILED
4869
- ctx.state = AgentState.FAILED
4870
-
4871
-
4872
- async def _phase_memory_update(
4873
- ctx: AgentRunContext, req: AgentRequest, router, current_user: str,
4874
- ) -> None:
4875
- """Background: Memory Updater role extracts learnings after COMPLETE."""
4876
- context = (
4877
- f"{MEMORY_UPDATER_PROMPT}\n\n"
4878
- f"Completed task: {req.message}\n\n"
4879
- f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
4880
- )
4881
- try:
4882
- raw = await router.generate(
4883
- message="Extract learnings from this completed task.",
4884
- context=context, max_tokens=256, temperature=0.1,
4885
- )
4886
- mem = _extract_agent_action(str(raw))
4887
- if mem.get("save_to_knowledge") and mem.get("learnings"):
4888
- from tools import knowledge_save
4889
- knowledge_save(
4890
- "\n".join(mem["learnings"]),
4891
- folder="30_Projects",
4892
- title=f"Agent: {req.message[:60]}",
4893
- )
4894
- except Exception:
4895
- pass
4583
+ _AGENT_RUNTIME = _build_agent_runtime()
4896
4584
 
4897
4585
 
4898
4586
  # ── Eval harness ──────────────────────────────────────────────────────────────
@@ -4971,7 +4659,7 @@ async def agent(req: AgentRequest, request: Request):
4971
4659
  # PLANNING phase
4972
4660
  ctx.state = AgentState.PLANNING
4973
4661
  ctx.state_history.append(ctx.state.value)
4974
- await _phase_plan(ctx, req, router, lang_hint, current_user, model_id=req.planning_model)
4662
+ await _AGENT_RUNTIME.plan(ctx, req, lang_hint, current_user, model_id=req.planning_model)
4975
4663
 
4976
4664
  # Human-in-the-loop: pause after planning, return plan to UI
4977
4665
  if req.human_in_loop:
@@ -4990,38 +4678,17 @@ async def agent(req: AgentRequest, request: Request):
4990
4678
  }
4991
4679
 
4992
4680
  # Auto-approve and run to completion (default behaviour)
4993
- _phase_approval(ctx, current_user)
4994
- return await _agent_run_to_completion(ctx, req, router, lang_hint, current_user, max_steps, max_retry)
4681
+ _AGENT_RUNTIME.approve(ctx, current_user)
4682
+ return await _agent_finish(ctx, req, lang_hint, current_user, max_steps, max_retry)
4995
4683
 
4996
4684
 
4997
- async def _agent_run_to_completion(
4998
- ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str,
4685
+ async def _agent_finish(
4686
+ ctx: AgentRunContext, req: AgentRequest, lang_hint: str,
4999
4687
  current_user: str, max_steps: int, max_retry: int,
5000
4688
  ) -> dict:
5001
- """Run EXECUTING VERIFYING loop until terminal state."""
5002
- while ctx.state not in AGENT_TERMINAL_STATES:
5003
- ctx.state_history.append(ctx.state.value)
5004
- if len(ctx.state_history) > 200:
5005
- ctx.final_message = "에이전트 상태 머신이 최대 반복(200)에 도달해 중단했습니다."
5006
- ctx.state = AgentState.FAILED
5007
- break
5008
-
5009
- if ctx.state == AgentState.EXECUTING:
5010
- await _phase_execute(ctx, req, router, lang_hint, current_user, max_steps,
5011
- model_id=ctx.executing_model)
5012
-
5013
- elif ctx.state == AgentState.VERIFYING:
5014
- await _phase_verify(ctx, req, router, lang_hint, current_user, max_retry,
5015
- model_id=ctx.reviewing_model)
5016
-
5017
- elif ctx.state == AgentState.ROLLBACK:
5018
- _phase_rollback(ctx, current_user)
5019
-
5020
- else:
5021
- ctx.state = AgentState.FAILED
5022
-
5023
- ctx.state_history.append(ctx.state.value)
5024
- asyncio.create_task(_phase_memory_update(ctx, req, router, current_user))
4689
+ """HTTP glue: drive the runtime to a terminal state, persist, shape the response."""
4690
+ await _AGENT_RUNTIME.run_to_completion(ctx, req, lang_hint, current_user, max_steps, max_retry)
4691
+ asyncio.create_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
5025
4692
 
5026
4693
  message = ctx.final_message or "작업을 완료했습니다."
5027
4694
  save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
@@ -5061,11 +4728,11 @@ async def agent_resume(req: AgentResumeRequest, request: Request):
5061
4728
  ctx.executing_model = req.executing_model or ctx.executing_model
5062
4729
  ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
5063
4730
 
5064
- _phase_approval(ctx, current_user)
4731
+ _AGENT_RUNTIME.approve(ctx, current_user)
5065
4732
 
5066
4733
  max_steps = max(1, min(orig_req.max_steps, 50))
5067
4734
  max_retry = 3
5068
- return await _agent_run_to_completion(ctx, orig_req, router, lang_hint, current_user, max_steps, max_retry)
4735
+ return await _agent_finish(ctx, orig_req, lang_hint, current_user, max_steps, max_retry)
5069
4736
 
5070
4737
 
5071
4738
  # ── Direct Tool API ───────────────────────────────────────────────────────────
@@ -5344,13 +5011,13 @@ _local_approval_lock = threading.Lock()
5344
5011
  _local_approvals: Dict[str, Dict[str, object]] = {}
5345
5012
 
5346
5013
  # Discord bot / webhook settings for permission notifications (optional)
5347
- DISCORD_PERMISSION_WEBHOOK_URL = env_value("LATTICEAI_DISCORD_PERMISSION_WEBHOOK", "")
5348
- DISCORD_BOT_TOKEN = env_value("LATTICEAI_DISCORD_BOT_TOKEN", "")
5349
- DISCORD_PERMISSION_CHANNEL = env_value("LATTICEAI_DISCORD_PERMISSION_CHANNEL", "")
5014
+ DISCORD_PERMISSION_WEBHOOK_URL = CONFIG.discord_permission_webhook
5015
+ DISCORD_BOT_TOKEN = CONFIG.discord_bot_token
5016
+ DISCORD_PERMISSION_CHANNEL = CONFIG.discord_permission_channel
5350
5017
 
5351
5018
  # Secret token that allows permission monitor script to call approve/deny endpoints
5352
5019
  # without an admin user session (used by perm_monitor.py).
5353
- PERMISSION_MONITOR_SECRET = env_value("LATTICEAI_PERMISSION_SECRET", "")
5020
+ PERMISSION_MONITOR_SECRET = CONFIG.permission_monitor_secret
5354
5021
 
5355
5022
  # Local queue file — written by server, read by perm_monitor.py
5356
5023
  _PERM_QUEUE_FILE = DATA_DIR / "permission_queue.json"