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/README.md +285 -208
- package/docs/CHANGELOG.md +73 -0
- package/kg_schema.py +42 -0
- package/knowledge_graph.py +232 -36
- package/latticeai/api/security_dashboard.py +6 -2
- package/latticeai/core/agent.py +453 -0
- package/latticeai/core/audit.py +4 -1
- package/latticeai/core/config.py +178 -0
- package/latticeai/core/graph_curator.py +60 -4
- package/latticeai/core/model_compat.py +67 -24
- package/latticeai/core/timezones.py +80 -0
- package/package.json +2 -2
- package/server.py +108 -441
- package/static/scripts/chat.js +105 -16
- package/tools.py +87 -115
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
|
|
56
|
-
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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 =
|
|
238
|
-
ENABLE_TELEGRAM =
|
|
239
|
-
ENABLE_GRAPH =
|
|
240
|
-
AUTOLOAD_MODELS =
|
|
241
|
-
MODEL_IDLE_UNLOAD_SECONDS =
|
|
242
|
-
ALLOW_LOCAL_MODELS =
|
|
243
|
-
REQUIRE_AUTH =
|
|
244
|
-
ALLOW_PLAINTEXT_API_KEYS =
|
|
245
|
-
CORS_ALLOW_NETWORK =
|
|
246
|
-
CORS_EXTRA_ORIGINS =
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 =
|
|
257
|
-
SSO_CLIENT_ID =
|
|
258
|
-
SSO_CLIENT_SECRET =
|
|
259
|
-
SSO_REDIRECT_URI =
|
|
260
|
-
SSO_PROVIDER_NAME =
|
|
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 —
|
|
286
|
-
|
|
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 =
|
|
332
|
+
DATA_DIR = CONFIG.data_dir
|
|
329
333
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
330
|
-
STATIC_DIR =
|
|
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 =
|
|
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.
|
|
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 =
|
|
1159
|
-
INVITE_CODE =
|
|
1160
|
-
INVITE_GATE_ENABLED =
|
|
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
|
-
|
|
1446
|
-
|
|
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(
|
|
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
|
|
3068
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4994
|
-
return await
|
|
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
|
|
4998
|
-
ctx: AgentRunContext, req: AgentRequest,
|
|
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
|
-
"""
|
|
5002
|
-
|
|
5003
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
5348
|
-
DISCORD_BOT_TOKEN =
|
|
5349
|
-
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 =
|
|
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"
|