ltcai 0.3.2 → 0.5.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,
@@ -91,6 +91,15 @@ from latticeai.core.graph_curator import (
91
91
  auto_build_graph_overlay as _auto_build_graph_overlay,
92
92
  mask_secrets as _curator_mask_secrets,
93
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
+ )
94
103
  import mcp_registry
95
104
  from mcp_registry import (
96
105
  MCP_REGISTRY, _THIRD_PARTY_SKILL_SOURCES, _KNOWN_REPO_LICENSES,
@@ -226,39 +235,38 @@ def env_bool(name: str, default: bool = False) -> bool:
226
235
  return default
227
236
  return raw.strip().lower() in {"1", "true", "yes", "on"}
228
237
 
229
- APP_MODE = env_value("LATTICEAI_MODE", "local").strip().lower()
230
- if APP_MODE not in {"local", "public"}:
231
- APP_MODE = "local"
232
- IS_PUBLIC_MODE = APP_MODE == "public"
233
- DEFAULT_HOST = env_value("LATTICEAI_HOST", "127.0.0.1")
234
- 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
235
247
  def _host_is_loopback(host: str) -> bool:
236
248
  return _host_is_loopback_impl(host)
237
249
 
238
- NETWORK_EXPOSED = not _host_is_loopback(DEFAULT_HOST)
239
- ENABLE_TELEGRAM = env_bool("LATTICEAI_ENABLE_TELEGRAM", default=not IS_PUBLIC_MODE)
240
- ENABLE_GRAPH = env_bool("LATTICEAI_ENABLE_GRAPH", default=True)
241
- AUTOLOAD_MODELS = env_bool("LATTICEAI_AUTOLOAD_MODELS", default=IS_PUBLIC_MODE)
242
- MODEL_IDLE_UNLOAD_SECONDS = int(env_value("LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", "0"))
243
- ALLOW_LOCAL_MODELS = env_bool("LATTICEAI_ALLOW_LOCAL_MODELS", default=not IS_PUBLIC_MODE)
244
- REQUIRE_AUTH = env_bool("LATTICEAI_REQUIRE_AUTH", default=IS_PUBLIC_MODE or NETWORK_EXPOSED)
245
- ALLOW_PLAINTEXT_API_KEYS = env_bool("LATTICEAI_ALLOW_PLAINTEXT_API_KEYS", default=False)
246
- CORS_ALLOW_NETWORK = env_bool("LATTICEAI_CORS_ALLOW_NETWORK", default=False)
247
- CORS_EXTRA_ORIGINS = [
248
- item.strip()
249
- for item in env_value("LATTICEAI_CORS_ALLOWED_ORIGINS", "").split(",")
250
- if item.strip()
251
- ]
252
- PUBLIC_MODEL = env_value("LATTICEAI_PUBLIC_MODEL", env_value("LATTICEAI_DEFAULT_MODEL", "openai:gpt-4o-mini"))
253
- LOCAL_MODEL = env_value("LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-26b-a4b-it-4bit")
254
- 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
255
263
 
256
264
  # ── SSO / OIDC config ─────────────────────────────────────────────────────────
257
- SSO_DISCOVERY_URL = env_value("OIDC_DISCOVERY_URL", "")
258
- SSO_CLIENT_ID = env_value("OIDC_CLIENT_ID", "")
259
- SSO_CLIENT_SECRET = env_value("OIDC_CLIENT_SECRET", "")
260
- SSO_REDIRECT_URI = env_value("OIDC_REDIRECT_URI", "http://localhost:4825/auth/sso/callback")
261
- 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
262
270
  _sso_discovery_cache: Optional[Dict] = None
263
271
  _sso_discovery_cache_url: str = ""
264
272
  _sso_states: Dict[str, float] = {} # state → timestamp (CSRF protection)
@@ -283,13 +291,8 @@ async def _get_sso_discovery() -> Optional[Dict]:
283
291
  return None
284
292
  return _sso_discovery_cache
285
293
 
286
- # ── Password hashing — delegated to latticeai.core.security ────────────────────
287
- def hash_password(password: str) -> str:
288
- return _hash_password(password)
289
-
290
- def verify_password(password: str, hashed: str) -> bool:
291
- return _verify_password(password, hashed)
292
-
294
+ # ── Password hashing — used directly from latticeai.core.security ──────────────
295
+ # (hash_password / verify_password are imported above; no local wrapper needed)
293
296
  def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict) -> bool:
294
297
  """평문 비밀번호를 투명하게 해시로 마이그레이션. 마이그레이션 발생 시 audit log 남김."""
295
298
  if ":" in stored and len(stored) > 64:
@@ -326,13 +329,9 @@ def invalidate_session(token: str) -> None:
326
329
 
327
330
  # ── User Management Logic ──────────────────────────────────────────────────
328
331
  BASE_DIR = Path(__file__).resolve().parent
329
- DATA_DIR = Path(env_value("LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
332
+ DATA_DIR = CONFIG.data_dir
330
333
  DATA_DIR.mkdir(parents=True, exist_ok=True)
331
- STATIC_DIR = Path(env_value("LATTICEAI_STATIC_DIR", str(BASE_DIR / "static")))
332
- if not STATIC_DIR.exists():
333
- packaged_static = Path(sys.prefix) / "static"
334
- if packaged_static.exists():
335
- STATIC_DIR = packaged_static
334
+ STATIC_DIR = CONFIG.static_dir
336
335
 
337
336
  USERS_FILE = DATA_DIR / "users.json"
338
337
  HISTORY_FILE = DATA_DIR / "chat_history.json"
@@ -870,11 +869,7 @@ def get_user_role(email: str, users: Optional[Dict] = None) -> str:
870
869
  user = users.get(email) or {}
871
870
  if user.get("role") in {"admin", "user"}:
872
871
  return user["role"]
873
- admin_emails = {
874
- item.strip().lower()
875
- for item in env_value("LATTICEAI_ADMIN_EMAILS", "").split(",")
876
- if item.strip()
877
- }
872
+ admin_emails = set(CONFIG.admin_emails)
878
873
  if email.lower() in admin_emails:
879
874
  return "admin"
880
875
  first_email = next(iter(users), None)
@@ -900,7 +895,7 @@ def require_user(request: Request) -> str:
900
895
 
901
896
 
902
897
  # ── Rate limiting & file validation — delegated to latticeai.core.security ────
903
- _RATE_LIMIT_ENABLED = os.getenv("LATTICEAI_RATE_LIMIT", "1") != "0"
898
+ _RATE_LIMIT_ENABLED = CONFIG.rate_limit_enabled
904
899
 
905
900
  def enforce_rate_limit(email: str, bucket_key: str) -> None:
906
901
  _enforce_rate_limit(email, bucket_key, enabled=_RATE_LIMIT_ENABLED)
@@ -1126,7 +1121,7 @@ async def lifespan(app: FastAPI):
1126
1121
  except Exception:
1127
1122
  pass
1128
1123
 
1129
- app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.3.2", lifespan=lifespan)
1124
+ app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.5.0", lifespan=lifespan)
1130
1125
 
1131
1126
  CORS_ALLOWED_ORIGINS = [
1132
1127
  f"http://localhost:{DEFAULT_PORT}",
@@ -1156,9 +1151,9 @@ if _ICONS_DIR.exists():
1156
1151
  app.mount("/icons", StaticFiles(directory=str(_ICONS_DIR)), name="icons")
1157
1152
  ensure_agent_root()
1158
1153
 
1159
- OPEN_REGISTRATION = env_bool("LATTICEAI_OPEN_REGISTRATION", default=not NETWORK_EXPOSED and not IS_PUBLIC_MODE)
1160
- INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
1161
- 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
1162
1157
 
1163
1158
  # ── Auth & Admin routers (latticeai.api) ─────────────────────────────────────
1164
1159
  app.include_router(create_auth_router(
@@ -1443,39 +1438,8 @@ class AgentEvalRequest(BaseModel):
1443
1438
  case_id: Optional[str] = None
1444
1439
 
1445
1440
 
1446
- class AgentState(str, Enum):
1447
- IDLE = "IDLE"
1448
- PLANNING = "PLANNING"
1449
- WAITING_APPROVAL = "WAITING_APPROVAL"
1450
- EXECUTING = "EXECUTING"
1451
- VERIFYING = "VERIFYING"
1452
- FAILED = "FAILED"
1453
- ROLLBACK = "ROLLBACK"
1454
- DONE = "DONE"
1455
-
1456
-
1457
- # Terminal states — the agent loop exits when reaching one of these
1458
- AGENT_TERMINAL_STATES = frozenset({AgentState.DONE, AgentState.FAILED})
1459
-
1460
-
1461
- class AgentRunContext:
1462
- """Mutable state carrier passed through all agent phases."""
1463
- __slots__ = ("state", "plan", "transcript", "retry_count",
1464
- "state_history", "corrections", "final_message", "rollback_log",
1465
- "executing_model", "reviewing_model")
1466
-
1467
- def __init__(self) -> None:
1468
- self.state: AgentState = AgentState.IDLE
1469
- self.plan: dict = {}
1470
- self.transcript: list = []
1471
- self.retry_count: int = 0
1472
- self.state_history: list = []
1473
- self.corrections: list = []
1474
- self.final_message: str = ""
1475
- self.rollback_log: list = []
1476
- self.executing_model: str | None = None
1477
- self.reviewing_model: str | None = None
1478
-
1441
+ # AgentState / AgentRunContext / AGENT_TERMINAL_STATES are defined in
1442
+ # latticeai.core.agent and imported at the top of this module.
1479
1443
 
1480
1444
  # Pending agent contexts waiting for human approval: context_id → (ctx, req, lang_hint, current_user)
1481
1445
  _pending_agents: dict[str, tuple] = {}
@@ -3502,7 +3466,7 @@ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str
3502
3466
 
3503
3467
  @app.get("/health")
3504
3468
  async def health(request: Request):
3505
- base = {"status": "ok", "version": "0.3.2", "mode": APP_MODE}
3469
+ base = {"status": "ok", "version": "0.5.0", "mode": APP_MODE}
3506
3470
  if not get_current_user(request) and REQUIRE_AUTH:
3507
3471
  return base
3508
3472
  engines = await asyncio.to_thread(engine_status)
@@ -3915,7 +3879,7 @@ async def chat(req: ChatRequest, request: Request):
3915
3879
  if screenshot_context:
3916
3880
  context += f"\n\n{screenshot_context}"
3917
3881
 
3918
- if env_bool("LATTICEAI_AUTO_READ_CHAT_PATHS", default=False):
3882
+ if CONFIG.auto_read_chat_paths:
3919
3883
  _file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
3920
3884
  for _m in _file_path_re.finditer(req.message or ""):
3921
3885
  _fpath = _m.group(1).strip()
@@ -4588,322 +4552,35 @@ def _collect_created_files(transcript: list) -> list:
4588
4552
  return files
4589
4553
 
4590
4554
 
4591
- def _extract_agent_action(raw: str) -> Dict:
4592
- text = raw.strip()
4593
- fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL)
4594
- if fenced:
4595
- text = fenced.group(1).strip()
4596
- elif not text.startswith("{"):
4597
- start = text.find("{")
4598
- end = text.rfind("}")
4599
- if start >= 0 and end > start:
4600
- text = text[start : end + 1]
4601
-
4602
- try:
4603
- action = json.loads(text)
4604
- except json.JSONDecodeError as exc:
4605
- raise ValueError(f"Agent did not return valid JSON: {exc}") from exc
4606
-
4607
- if not isinstance(action, dict) or "action" not in action:
4608
- raise ValueError("Agent JSON must include an action field.")
4609
- return action
4610
-
4611
-
4612
- # ── Agent State Machine — Phase Functions ─────────────────────────────────────
4613
-
4614
- async def _phase_plan(
4615
- ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str, current_user: str,
4616
- model_id: str | None = None,
4617
- ) -> None:
4618
- """PLAN: Planner role produces a structured plan JSON."""
4619
- context = (
4620
- f"{PLANNER_PROMPT}\n\n"
4621
- f"[LANGUAGE HINT: {lang_hint}]\n"
4622
- f"Workspace root: {AGENT_ROOT}\n\n"
4623
- f"User request: {req.message}"
4624
- )
4625
- raw = await router.generate_as(
4626
- model_id,
4627
- message="Produce a JSON execution plan for this request.",
4628
- context=context, max_tokens=1024, temperature=0.1,
4629
- )
4630
- try:
4631
- plan = _extract_agent_action(str(raw))
4632
- except ValueError:
4633
- plan = {
4634
- "action": "plan", "state": "PLAN",
4635
- "goal": req.message, "steps": [],
4636
- "requires_approval": False, "rollback_strategy": "none", "estimated_steps": 1,
4637
- }
4638
- ctx.plan = plan
4639
- ctx.transcript.append({
4640
- "state": AgentState.PLANNING.value,
4641
- "goal": plan.get("goal", req.message),
4642
- "steps": plan.get("steps", []),
4643
- "requires_approval": plan.get("requires_approval", False),
4644
- "rollback_strategy": plan.get("rollback_strategy", "none"),
4645
- "estimated_steps": plan.get("estimated_steps", 1),
4646
- })
4647
- ctx.state = AgentState.WAITING_APPROVAL
4648
-
4649
-
4650
- def _phase_approval(ctx: AgentRunContext, current_user: str) -> None:
4651
- """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
4652
- auto_approve_tools = {name for name, p in TOOL_GOVERNANCE.items() if p["auto_approve"]}
4653
- steps = ctx.plan.get("steps", [])
4654
- non_auto = [s.get("action") for s in steps if s.get("action") not in auto_approve_tools]
4655
- requires = ctx.plan.get("requires_approval", False) or bool(non_auto)
4656
-
4657
- ctx.transcript.append({
4658
- "state": AgentState.WAITING_APPROVAL.value,
4659
- "requires_approval": requires,
4660
- "non_auto_approve_steps": non_auto,
4661
- "decision": "auto_approved",
4662
- })
4663
- append_audit_event(
4664
- "agent_approval", user_email=current_user,
4665
- requires_approval=requires, non_auto_steps=non_auto, decision="auto_approved",
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,
4666
4579
  )
4667
- ctx.state = AgentState.EXECUTING
4668
-
4580
+ return AgentRuntime(deps)
4669
4581
 
4670
- async def _phase_execute(
4671
- ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str,
4672
- current_user: str, max_steps: int, model_id: str | None = None,
4673
- ) -> None:
4674
- """EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
4675
- exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
4676
- budget = max(1, max_steps - exec_count)
4677
-
4678
- for _ in range(budget):
4679
- corrections_hint = (
4680
- "\n\nCritic corrections from previous attempt:\n"
4681
- + "\n".join(f"- {c}" for c in ctx.corrections)
4682
- ) if ctx.corrections else ""
4683
-
4684
- context = (
4685
- f"{EXECUTOR_PROMPT}\n\n"
4686
- f"[LANGUAGE HINT: {lang_hint}]\n"
4687
- f"Workspace root: {AGENT_ROOT}\n\n"
4688
- f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
4689
- f"Recent conversation:\n{build_recent_chat_context(conversation_id=req.conversation_id) or '(none)'}\n\n"
4690
- f"User request: {req.message}{corrections_hint}\n\n"
4691
- f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
4692
- )
4693
- raw = await router.generate_as(
4694
- model_id,
4695
- message="Execute the next step.",
4696
- context=context, max_tokens=4096, temperature=req.temperature,
4697
- )
4698
- try:
4699
- action = _extract_agent_action(str(raw))
4700
- except ValueError as exc:
4701
- ctx.transcript.append({
4702
- "state": AgentState.EXECUTING.value, "action": "parse_error",
4703
- "raw": str(raw)[:400], "error": str(exc),
4704
- })
4705
- break
4706
4582
 
4707
- name = action.get("action")
4708
- thoughts = str(action.get("thoughts") or "")[:600]
4709
- args = action.get("args") or {}
4710
-
4711
- if name == "final":
4712
- ctx.final_message = action.get("message", "작업을 완료했습니다.")
4713
- ctx.transcript.append({
4714
- "state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
4715
- })
4716
- ctx.state = AgentState.VERIFYING
4717
- return
4718
-
4719
- # Loop guard
4720
- exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
4721
- last = exec_steps[-1] if exec_steps else None
4722
- if (
4723
- name in _FILE_CREATE_ACTIONS and last
4724
- and last.get("action") == name
4725
- and (last.get("args") or {}) == args
4726
- and "result" in last
4727
- ):
4728
- ctx.transcript.append({
4729
- "state": AgentState.EXECUTING.value, "action": name,
4730
- "error": "LOOP_DETECTED: identical action+args repeated — halted.",
4731
- })
4732
- break
4733
-
4734
- if name == "clear_history":
4735
- result = clear_history(args.get("keep_last", 0))
4736
- ctx.transcript.append({
4737
- "state": AgentState.EXECUTING.value, "action": name,
4738
- "thoughts": thoughts, "args": args, "result": result,
4739
- })
4740
- continue
4741
-
4742
- policy = _agent_policy(name, args)
4743
- risk = _RISK_LEVEL_MAP.get(policy["risk"], "medium")
4744
-
4745
- if policy["risk"] == "destructive":
4746
- ctx.transcript.append({
4747
- "state": AgentState.EXECUTING.value, "action": name,
4748
- "thoughts": thoughts, "args": args, "risk": risk,
4749
- "governance": dict(policy),
4750
- "error": f"BLOCKED: destructive action '{name}' not permitted in agent mode.",
4751
- })
4752
- append_audit_event(
4753
- "agent_blocked", user_email=current_user, source=req.source or "agent",
4754
- action=name, reason="destructive", governance=dict(policy),
4755
- )
4756
- continue
4757
-
4758
- if not policy["auto_approve"]:
4759
- append_audit_event(
4760
- "agent_exec", user_email=current_user, source=req.source or "agent",
4761
- state=AgentState.EXECUTING.value, action=name, risk=risk,
4762
- shell=policy["shell"], network=policy["network"],
4763
- destructive=policy["destructive"], sandbox=policy["sandbox"],
4764
- rollback=policy["rollback"],
4765
- args={k: v for k, v in args.items() if k != "content"},
4766
- )
4767
-
4768
- try:
4769
- _check_tool_role(name, current_user)
4770
- result = execute_tool(name, args)
4771
- ctx.transcript.append({
4772
- "state": AgentState.EXECUTING.value, "action": name,
4773
- "thoughts": thoughts, "args": args,
4774
- "risk": risk, "governance": dict(policy), "result": result,
4775
- })
4776
- except (ToolError, KeyError, TypeError) as exc:
4777
- ctx.transcript.append({
4778
- "state": AgentState.EXECUTING.value, "action": name,
4779
- "thoughts": thoughts, "args": args,
4780
- "risk": risk, "governance": dict(policy), "error": str(exc),
4781
- })
4782
-
4783
- ctx.state = AgentState.VERIFYING
4784
-
4785
-
4786
- async def _phase_verify(
4787
- ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str, current_user: str,
4788
- max_retry: int = 3, model_id: str | None = None,
4789
- ) -> None:
4790
- """VERIFYING: Critic role evaluates transcript → DONE / EXECUTING (retry) / ROLLBACK / FAILED."""
4791
- context = (
4792
- f"{CRITIC_PROMPT}\n\n"
4793
- f"[LANGUAGE HINT: {lang_hint}]\n\n"
4794
- f"Original request: {req.message}\n"
4795
- f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
4796
- f"Full transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
4797
- )
4798
- raw = await router.generate_as(
4799
- model_id,
4800
- message="Review the execution transcript and return your verdict JSON.",
4801
- context=context, max_tokens=512, temperature=0.1,
4802
- )
4803
- try:
4804
- verdict = _extract_agent_action(str(raw))
4805
- except ValueError:
4806
- verdict = {"action": "verdict", "verdict": "PASS", "next_state": "DONE",
4807
- "reason": "Critic parse failed — assuming pass.", "corrections": [], "confidence": 0.7}
4808
-
4809
- ctx.corrections = verdict.get("corrections", [])
4810
- # Normalize legacy verdict next_state strings to current AgentState names
4811
- raw_next = verdict.get("next_state", "DONE")
4812
- next_s = {"COMPLETE": "DONE", "RETRY": "EXECUTING"}.get(raw_next, raw_next)
4813
-
4814
- ctx.transcript.append({
4815
- "state": AgentState.VERIFYING.value,
4816
- "verdict": verdict.get("verdict", "PASS"),
4817
- "reason": verdict.get("reason", ""),
4818
- "corrections": ctx.corrections,
4819
- "confidence": verdict.get("confidence", 0.9),
4820
- "next_state": next_s,
4821
- })
4822
-
4823
- if verdict.get("verdict") == "PASS" or next_s == "DONE":
4824
- if not ctx.final_message:
4825
- ctx.final_message = verdict.get("reason", "작업이 완료되었습니다.")
4826
- ctx.state = AgentState.DONE
4827
- elif next_s == "ROLLBACK":
4828
- ctx.state = AgentState.ROLLBACK
4829
- elif next_s == "EXECUTING":
4830
- if ctx.retry_count >= max_retry:
4831
- ctx.final_message = "처리 중 문제가 발생했습니다. 다시 시도해 주세요."
4832
- ctx.state = AgentState.FAILED
4833
- else:
4834
- ctx.retry_count += 1
4835
- ctx.transcript.append({
4836
- "state": AgentState.EXECUTING.value,
4837
- "retry_attempt": ctx.retry_count,
4838
- "corrections": ctx.corrections,
4839
- })
4840
- ctx.state = AgentState.EXECUTING
4841
- else:
4842
- ctx.final_message = verdict.get("reason", "검증자가 인식되지 않은 다음 상태를 반환했습니다.")
4843
- ctx.state = AgentState.FAILED
4844
-
4845
-
4846
- def _phase_rollback(ctx: AgentRunContext, current_user: str) -> None:
4847
- """ROLLBACK: attempt git checkout for each edited file, then COMPLETE."""
4848
- import subprocess as _sp
4849
- rolled: list = []
4850
- for step in ctx.transcript:
4851
- if step.get("state") != AgentState.EXECUTING.value:
4852
- continue
4853
- gov = step.get("governance", {})
4854
- if gov.get("rollback") != "git":
4855
- continue
4856
- result = step.get("result", {})
4857
- if not (isinstance(result, dict) and result.get("success")):
4858
- continue
4859
- path = result.get("path") or (step.get("args") or {}).get("path", "")
4860
- if not path:
4861
- continue
4862
- try:
4863
- r = _sp.run(
4864
- ["git", "checkout", "--", path], cwd=str(AGENT_ROOT),
4865
- capture_output=True, text=True, timeout=10,
4866
- )
4867
- rolled.append({"path": path, "ok": r.returncode == 0, "stderr": r.stderr[:200]})
4868
- except Exception as exc:
4869
- rolled.append({"path": path, "ok": False, "error": str(exc)})
4870
-
4871
- ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
4872
- recovered = [r["path"] for r in rolled if r.get("ok")]
4873
- ctx.final_message = (
4874
- f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
4875
- if recovered
4876
- else "롤백을 시도했으나 복구할 파일이 없거나 git이 초기화되지 않았습니다."
4877
- )
4878
- append_audit_event("agent_rollback", user_email=current_user, rolled_back=rolled)
4879
- # Rollback is a recovery from a failed verification — terminal state is FAILED
4880
- ctx.state = AgentState.FAILED
4881
-
4882
-
4883
- async def _phase_memory_update(
4884
- ctx: AgentRunContext, req: AgentRequest, router, current_user: str,
4885
- ) -> None:
4886
- """Background: Memory Updater role extracts learnings after COMPLETE."""
4887
- context = (
4888
- f"{MEMORY_UPDATER_PROMPT}\n\n"
4889
- f"Completed task: {req.message}\n\n"
4890
- f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
4891
- )
4892
- try:
4893
- raw = await router.generate(
4894
- message="Extract learnings from this completed task.",
4895
- context=context, max_tokens=256, temperature=0.1,
4896
- )
4897
- mem = _extract_agent_action(str(raw))
4898
- if mem.get("save_to_knowledge") and mem.get("learnings"):
4899
- from tools import knowledge_save
4900
- knowledge_save(
4901
- "\n".join(mem["learnings"]),
4902
- folder="30_Projects",
4903
- title=f"Agent: {req.message[:60]}",
4904
- )
4905
- except Exception:
4906
- pass
4583
+ _AGENT_RUNTIME = _build_agent_runtime()
4907
4584
 
4908
4585
 
4909
4586
  # ── Eval harness ──────────────────────────────────────────────────────────────
@@ -4982,7 +4659,7 @@ async def agent(req: AgentRequest, request: Request):
4982
4659
  # PLANNING phase
4983
4660
  ctx.state = AgentState.PLANNING
4984
4661
  ctx.state_history.append(ctx.state.value)
4985
- 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)
4986
4663
 
4987
4664
  # Human-in-the-loop: pause after planning, return plan to UI
4988
4665
  if req.human_in_loop:
@@ -5001,38 +4678,17 @@ async def agent(req: AgentRequest, request: Request):
5001
4678
  }
5002
4679
 
5003
4680
  # Auto-approve and run to completion (default behaviour)
5004
- _phase_approval(ctx, current_user)
5005
- 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)
5006
4683
 
5007
4684
 
5008
- async def _agent_run_to_completion(
5009
- ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str,
4685
+ async def _agent_finish(
4686
+ ctx: AgentRunContext, req: AgentRequest, lang_hint: str,
5010
4687
  current_user: str, max_steps: int, max_retry: int,
5011
4688
  ) -> dict:
5012
- """Run EXECUTING VERIFYING loop until terminal state."""
5013
- while ctx.state not in AGENT_TERMINAL_STATES:
5014
- ctx.state_history.append(ctx.state.value)
5015
- if len(ctx.state_history) > 200:
5016
- ctx.final_message = "에이전트 상태 머신이 최대 반복(200)에 도달해 중단했습니다."
5017
- ctx.state = AgentState.FAILED
5018
- break
5019
-
5020
- if ctx.state == AgentState.EXECUTING:
5021
- await _phase_execute(ctx, req, router, lang_hint, current_user, max_steps,
5022
- model_id=ctx.executing_model)
5023
-
5024
- elif ctx.state == AgentState.VERIFYING:
5025
- await _phase_verify(ctx, req, router, lang_hint, current_user, max_retry,
5026
- model_id=ctx.reviewing_model)
5027
-
5028
- elif ctx.state == AgentState.ROLLBACK:
5029
- _phase_rollback(ctx, current_user)
5030
-
5031
- else:
5032
- ctx.state = AgentState.FAILED
5033
-
5034
- ctx.state_history.append(ctx.state.value)
5035
- 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))
5036
4692
 
5037
4693
  message = ctx.final_message or "작업을 완료했습니다."
5038
4694
  save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
@@ -5072,11 +4728,11 @@ async def agent_resume(req: AgentResumeRequest, request: Request):
5072
4728
  ctx.executing_model = req.executing_model or ctx.executing_model
5073
4729
  ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
5074
4730
 
5075
- _phase_approval(ctx, current_user)
4731
+ _AGENT_RUNTIME.approve(ctx, current_user)
5076
4732
 
5077
4733
  max_steps = max(1, min(orig_req.max_steps, 50))
5078
4734
  max_retry = 3
5079
- 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)
5080
4736
 
5081
4737
 
5082
4738
  # ── Direct Tool API ───────────────────────────────────────────────────────────
@@ -5355,13 +5011,13 @@ _local_approval_lock = threading.Lock()
5355
5011
  _local_approvals: Dict[str, Dict[str, object]] = {}
5356
5012
 
5357
5013
  # Discord bot / webhook settings for permission notifications (optional)
5358
- DISCORD_PERMISSION_WEBHOOK_URL = env_value("LATTICEAI_DISCORD_PERMISSION_WEBHOOK", "")
5359
- DISCORD_BOT_TOKEN = env_value("LATTICEAI_DISCORD_BOT_TOKEN", "")
5360
- 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
5361
5017
 
5362
5018
  # Secret token that allows permission monitor script to call approve/deny endpoints
5363
5019
  # without an admin user session (used by perm_monitor.py).
5364
- PERMISSION_MONITOR_SECRET = env_value("LATTICEAI_PERMISSION_SECRET", "")
5020
+ PERMISSION_MONITOR_SECRET = CONFIG.permission_monitor_secret
5365
5021
 
5366
5022
  # Local queue file — written by server, read by perm_monitor.py
5367
5023
  _PERM_QUEUE_FILE = DATA_DIR / "permission_queue.json"