ltcai 0.3.2 → 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 -224
- package/docs/CHANGELOG.md +38 -0
- package/kg_schema.py +42 -0
- package/knowledge_graph.py +232 -36
- package/latticeai/core/agent.py +453 -0
- package/latticeai/core/config.py +178 -0
- package/package.json +1 -1
- package/server.py +92 -436
- 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,
|
|
@@ -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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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 =
|
|
239
|
-
ENABLE_TELEGRAM =
|
|
240
|
-
ENABLE_GRAPH =
|
|
241
|
-
AUTOLOAD_MODELS =
|
|
242
|
-
MODEL_IDLE_UNLOAD_SECONDS =
|
|
243
|
-
ALLOW_LOCAL_MODELS =
|
|
244
|
-
REQUIRE_AUTH =
|
|
245
|
-
ALLOW_PLAINTEXT_API_KEYS =
|
|
246
|
-
CORS_ALLOW_NETWORK =
|
|
247
|
-
CORS_EXTRA_ORIGINS =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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 =
|
|
258
|
-
SSO_CLIENT_ID =
|
|
259
|
-
SSO_CLIENT_SECRET =
|
|
260
|
-
SSO_REDIRECT_URI =
|
|
261
|
-
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
|
|
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 —
|
|
287
|
-
|
|
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 =
|
|
332
|
+
DATA_DIR = CONFIG.data_dir
|
|
330
333
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
331
|
-
STATIC_DIR =
|
|
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 =
|
|
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.
|
|
1124
|
+
app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.4.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 =
|
|
1160
|
-
INVITE_CODE =
|
|
1161
|
-
INVITE_GATE_ENABLED =
|
|
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
|
-
|
|
1447
|
-
|
|
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.
|
|
3469
|
+
base = {"status": "ok", "version": "0.4.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
|
|
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
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5005
|
-
return await
|
|
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
|
|
5009
|
-
ctx: AgentRunContext, req: AgentRequest,
|
|
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
|
-
"""
|
|
5013
|
-
|
|
5014
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
5359
|
-
DISCORD_BOT_TOKEN =
|
|
5360
|
-
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 =
|
|
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"
|