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
|
@@ -36,6 +36,8 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
|
|
36
36
|
from fastapi.responses import Response, StreamingResponse
|
|
37
37
|
from pydantic import BaseModel
|
|
38
38
|
|
|
39
|
+
from ..core import timezones
|
|
40
|
+
|
|
39
41
|
logger = logging.getLogger(__name__)
|
|
40
42
|
|
|
41
43
|
|
|
@@ -304,11 +306,13 @@ def create_security_router(
|
|
|
304
306
|
report = build_sensitivity_report(history) or {}
|
|
305
307
|
summary = report.get("summary", {})
|
|
306
308
|
sev = summary.get("severity_counts", {}) or {}
|
|
307
|
-
|
|
309
|
+
# item 7: audit timestamp(로컬/설정 시간대)와 동일한 기준으로 "오늘"을 계산한다.
|
|
310
|
+
today = timezones.today_str()
|
|
308
311
|
today_events = [e for e in events if str(e.get("timestamp", ""))[:10] == today]
|
|
309
312
|
|
|
310
313
|
return {
|
|
311
|
-
"generated_at":
|
|
314
|
+
"generated_at": timezones.now_iso(),
|
|
315
|
+
"timezone": timezones.tz_name(),
|
|
312
316
|
"cards": {
|
|
313
317
|
"events_today": len(today_events),
|
|
314
318
|
"high_risk_events": int(sev.get("high", 0)),
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Agent Runtime — the Discover→Plan→Implement→Verify state machine.
|
|
2
|
+
|
|
3
|
+
This module is the deep one: a small interface (``AgentDeps`` ports +
|
|
4
|
+
``AgentRuntime.run_to_completion``) over the whole multi-role agent loop
|
|
5
|
+
(planner → executor → critic → rollback → memory). It carries no FastAPI,
|
|
6
|
+
no globals, and no I/O of its own — every collaborator is injected through
|
|
7
|
+
``AgentDeps``.
|
|
8
|
+
|
|
9
|
+
Two adapters justify the seam:
|
|
10
|
+
|
|
11
|
+
* production wires ``AgentDeps`` from server.py's ``LLMRouter``, governance
|
|
12
|
+
map, audit log, and prompts;
|
|
13
|
+
* tests pass fake ports (an LLM that returns canned JSON, a recording tool
|
|
14
|
+
executor) and drive a full PLAN→EXECUTE→VERIFY→DONE cycle without a server.
|
|
15
|
+
|
|
16
|
+
HTTP concerns — request parsing, chat-history persistence, response shaping,
|
|
17
|
+
scheduling the background memory update — stay in server.py. This module
|
|
18
|
+
only owns the state machine.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional
|
|
30
|
+
|
|
31
|
+
from tools import ToolError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentState(str, Enum):
|
|
35
|
+
IDLE = "IDLE"
|
|
36
|
+
PLANNING = "PLANNING"
|
|
37
|
+
WAITING_APPROVAL = "WAITING_APPROVAL"
|
|
38
|
+
EXECUTING = "EXECUTING"
|
|
39
|
+
VERIFYING = "VERIFYING"
|
|
40
|
+
FAILED = "FAILED"
|
|
41
|
+
ROLLBACK = "ROLLBACK"
|
|
42
|
+
DONE = "DONE"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Terminal states — the agent loop exits when reaching one of these
|
|
46
|
+
AGENT_TERMINAL_STATES: FrozenSet[AgentState] = frozenset({AgentState.DONE, AgentState.FAILED})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AgentRunContext:
|
|
50
|
+
"""Mutable state carrier passed through all agent phases."""
|
|
51
|
+
__slots__ = ("state", "plan", "transcript", "retry_count",
|
|
52
|
+
"state_history", "corrections", "final_message", "rollback_log",
|
|
53
|
+
"executing_model", "reviewing_model")
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self.state: AgentState = AgentState.IDLE
|
|
57
|
+
self.plan: dict = {}
|
|
58
|
+
self.transcript: list = []
|
|
59
|
+
self.retry_count: int = 0
|
|
60
|
+
self.state_history: list = []
|
|
61
|
+
self.corrections: list = []
|
|
62
|
+
self.final_message: str = ""
|
|
63
|
+
self.rollback_log: list = []
|
|
64
|
+
self.executing_model: Optional[str] = None
|
|
65
|
+
self.reviewing_model: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def extract_action(raw: str) -> Dict:
|
|
69
|
+
"""Parse one JSON action object out of an LLM response (tolerant of fences/prose)."""
|
|
70
|
+
text = raw.strip()
|
|
71
|
+
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL)
|
|
72
|
+
if fenced:
|
|
73
|
+
text = fenced.group(1).strip()
|
|
74
|
+
elif not text.startswith("{"):
|
|
75
|
+
start = text.find("{")
|
|
76
|
+
end = text.rfind("}")
|
|
77
|
+
if start >= 0 and end > start:
|
|
78
|
+
text = text[start : end + 1]
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
action = json.loads(text)
|
|
82
|
+
except json.JSONDecodeError as exc:
|
|
83
|
+
raise ValueError(f"Agent did not return valid JSON: {exc}") from exc
|
|
84
|
+
|
|
85
|
+
if not isinstance(action, dict) or "action" not in action:
|
|
86
|
+
raise ValueError("Agent JSON must include an action field.")
|
|
87
|
+
return action
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class AgentDeps:
|
|
92
|
+
"""The ports an :class:`AgentRuntime` needs from the outside world.
|
|
93
|
+
|
|
94
|
+
Everything the state machine touches is here, so the loop can be exercised
|
|
95
|
+
against fakes. See module docstring for the two-adapter rationale.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
# ── LLM port ─────────────────────────────────────────────────────
|
|
99
|
+
# generate_as(model_id, message, context, max_tokens, temperature) -> str
|
|
100
|
+
generate_as: Callable[..., Awaitable[Any]]
|
|
101
|
+
# generate(message, context, max_tokens, temperature) -> str
|
|
102
|
+
generate: Callable[..., Awaitable[Any]]
|
|
103
|
+
|
|
104
|
+
# ── tool port ────────────────────────────────────────────────────
|
|
105
|
+
execute_tool: Callable[[str, dict], dict]
|
|
106
|
+
policy_for: Callable[[str, dict], dict] # name, args -> governance policy
|
|
107
|
+
risk_level: Callable[[dict], str] # policy -> "low"|"medium"|"high"
|
|
108
|
+
check_role: Callable[[str, str], None] # tool_name, user -> raises if not allowed
|
|
109
|
+
tool_governance: Dict[str, dict] # name -> policy (for auto_approve set)
|
|
110
|
+
file_create_actions: FrozenSet[str]
|
|
111
|
+
|
|
112
|
+
# ── context / memory / audit ports ───────────────────────────────
|
|
113
|
+
recent_chat_context: Callable[..., str] # (conversation_id=...) -> str
|
|
114
|
+
clear_history: Callable[[int], dict]
|
|
115
|
+
knowledge_save: Callable[..., Any]
|
|
116
|
+
audit: Callable[..., None] # (event, **kw) -> None
|
|
117
|
+
|
|
118
|
+
# ── prompts + config ─────────────────────────────────────────────
|
|
119
|
+
planner_prompt: str
|
|
120
|
+
executor_prompt: str
|
|
121
|
+
critic_prompt: str
|
|
122
|
+
memory_updater_prompt: str
|
|
123
|
+
agent_root: Path
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AgentRuntime:
|
|
127
|
+
"""Drives the agent state machine over injected :class:`AgentDeps`."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, deps: AgentDeps) -> None:
|
|
130
|
+
self.deps = deps
|
|
131
|
+
|
|
132
|
+
# ── PLAN ─────────────────────────────────────────────────────────
|
|
133
|
+
async def plan(
|
|
134
|
+
self, ctx: AgentRunContext, req: Any, lang_hint: str, current_user: str,
|
|
135
|
+
model_id: Optional[str] = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""PLAN: Planner role produces a structured plan JSON."""
|
|
138
|
+
d = self.deps
|
|
139
|
+
context = (
|
|
140
|
+
f"{d.planner_prompt}\n\n"
|
|
141
|
+
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
142
|
+
f"Workspace root: {d.agent_root}\n\n"
|
|
143
|
+
f"User request: {req.message}"
|
|
144
|
+
)
|
|
145
|
+
raw = await d.generate_as(
|
|
146
|
+
model_id,
|
|
147
|
+
message="Produce a JSON execution plan for this request.",
|
|
148
|
+
context=context, max_tokens=1024, temperature=0.1,
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
plan = extract_action(str(raw))
|
|
152
|
+
except ValueError:
|
|
153
|
+
plan = {
|
|
154
|
+
"action": "plan", "state": "PLAN",
|
|
155
|
+
"goal": req.message, "steps": [],
|
|
156
|
+
"requires_approval": False, "rollback_strategy": "none", "estimated_steps": 1,
|
|
157
|
+
}
|
|
158
|
+
ctx.plan = plan
|
|
159
|
+
ctx.transcript.append({
|
|
160
|
+
"state": AgentState.PLANNING.value,
|
|
161
|
+
"goal": plan.get("goal", req.message),
|
|
162
|
+
"steps": plan.get("steps", []),
|
|
163
|
+
"requires_approval": plan.get("requires_approval", False),
|
|
164
|
+
"rollback_strategy": plan.get("rollback_strategy", "none"),
|
|
165
|
+
"estimated_steps": plan.get("estimated_steps", 1),
|
|
166
|
+
})
|
|
167
|
+
ctx.state = AgentState.WAITING_APPROVAL
|
|
168
|
+
|
|
169
|
+
# ── APPROVAL ─────────────────────────────────────────────────────
|
|
170
|
+
def approve(self, ctx: AgentRunContext, current_user: str) -> None:
|
|
171
|
+
"""APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
|
|
172
|
+
d = self.deps
|
|
173
|
+
auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
|
|
174
|
+
steps = ctx.plan.get("steps", [])
|
|
175
|
+
non_auto = [s.get("action") for s in steps if s.get("action") not in auto_approve_tools]
|
|
176
|
+
requires = ctx.plan.get("requires_approval", False) or bool(non_auto)
|
|
177
|
+
|
|
178
|
+
ctx.transcript.append({
|
|
179
|
+
"state": AgentState.WAITING_APPROVAL.value,
|
|
180
|
+
"requires_approval": requires,
|
|
181
|
+
"non_auto_approve_steps": non_auto,
|
|
182
|
+
"decision": "auto_approved",
|
|
183
|
+
})
|
|
184
|
+
d.audit(
|
|
185
|
+
"agent_approval", user_email=current_user,
|
|
186
|
+
requires_approval=requires, non_auto_steps=non_auto, decision="auto_approved",
|
|
187
|
+
)
|
|
188
|
+
ctx.state = AgentState.EXECUTING
|
|
189
|
+
|
|
190
|
+
# ── EXECUTE ──────────────────────────────────────────────────────
|
|
191
|
+
async def execute(
|
|
192
|
+
self, ctx: AgentRunContext, req: Any, lang_hint: str,
|
|
193
|
+
current_user: str, max_steps: int, model_id: Optional[str] = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
|
|
196
|
+
d = self.deps
|
|
197
|
+
exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
|
|
198
|
+
budget = max(1, max_steps - exec_count)
|
|
199
|
+
|
|
200
|
+
for _ in range(budget):
|
|
201
|
+
corrections_hint = (
|
|
202
|
+
"\n\nCritic corrections from previous attempt:\n"
|
|
203
|
+
+ "\n".join(f"- {c}" for c in ctx.corrections)
|
|
204
|
+
) if ctx.corrections else ""
|
|
205
|
+
|
|
206
|
+
context = (
|
|
207
|
+
f"{d.executor_prompt}\n\n"
|
|
208
|
+
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
209
|
+
f"Workspace root: {d.agent_root}\n\n"
|
|
210
|
+
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
|
|
211
|
+
f"Recent conversation:\n{d.recent_chat_context(conversation_id=req.conversation_id) or '(none)'}\n\n"
|
|
212
|
+
f"User request: {req.message}{corrections_hint}\n\n"
|
|
213
|
+
f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
214
|
+
)
|
|
215
|
+
raw = await d.generate_as(
|
|
216
|
+
model_id,
|
|
217
|
+
message="Execute the next step.",
|
|
218
|
+
context=context, max_tokens=4096, temperature=req.temperature,
|
|
219
|
+
)
|
|
220
|
+
try:
|
|
221
|
+
action = extract_action(str(raw))
|
|
222
|
+
except ValueError as exc:
|
|
223
|
+
ctx.transcript.append({
|
|
224
|
+
"state": AgentState.EXECUTING.value, "action": "parse_error",
|
|
225
|
+
"raw": str(raw)[:400], "error": str(exc),
|
|
226
|
+
})
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
name = action.get("action")
|
|
230
|
+
thoughts = str(action.get("thoughts") or "")[:600]
|
|
231
|
+
args = action.get("args") or {}
|
|
232
|
+
|
|
233
|
+
if name == "final":
|
|
234
|
+
ctx.final_message = action.get("message", "작업을 완료했습니다.")
|
|
235
|
+
ctx.transcript.append({
|
|
236
|
+
"state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
|
|
237
|
+
})
|
|
238
|
+
ctx.state = AgentState.VERIFYING
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
# Loop guard
|
|
242
|
+
exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
|
|
243
|
+
last = exec_steps[-1] if exec_steps else None
|
|
244
|
+
if (
|
|
245
|
+
name in d.file_create_actions and last
|
|
246
|
+
and last.get("action") == name
|
|
247
|
+
and (last.get("args") or {}) == args
|
|
248
|
+
and "result" in last
|
|
249
|
+
):
|
|
250
|
+
ctx.transcript.append({
|
|
251
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
252
|
+
"error": "LOOP_DETECTED: identical action+args repeated — halted.",
|
|
253
|
+
})
|
|
254
|
+
break
|
|
255
|
+
|
|
256
|
+
if name == "clear_history":
|
|
257
|
+
result = d.clear_history(args.get("keep_last", 0))
|
|
258
|
+
ctx.transcript.append({
|
|
259
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
260
|
+
"thoughts": thoughts, "args": args, "result": result,
|
|
261
|
+
})
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
policy = d.policy_for(name, args)
|
|
265
|
+
risk = d.risk_level(policy)
|
|
266
|
+
|
|
267
|
+
if policy["risk"] == "destructive":
|
|
268
|
+
ctx.transcript.append({
|
|
269
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
270
|
+
"thoughts": thoughts, "args": args, "risk": risk,
|
|
271
|
+
"governance": dict(policy),
|
|
272
|
+
"error": f"BLOCKED: destructive action '{name}' not permitted in agent mode.",
|
|
273
|
+
})
|
|
274
|
+
d.audit(
|
|
275
|
+
"agent_blocked", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
276
|
+
action=name, reason="destructive", governance=dict(policy),
|
|
277
|
+
)
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
if not policy["auto_approve"]:
|
|
281
|
+
d.audit(
|
|
282
|
+
"agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
283
|
+
state=AgentState.EXECUTING.value, action=name, risk=risk,
|
|
284
|
+
shell=policy["shell"], network=policy["network"],
|
|
285
|
+
destructive=policy["destructive"], sandbox=policy["sandbox"],
|
|
286
|
+
rollback=policy["rollback"],
|
|
287
|
+
args={k: v for k, v in args.items() if k != "content"},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
d.check_role(name, current_user)
|
|
292
|
+
result = d.execute_tool(name, args)
|
|
293
|
+
ctx.transcript.append({
|
|
294
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
295
|
+
"thoughts": thoughts, "args": args,
|
|
296
|
+
"risk": risk, "governance": dict(policy), "result": result,
|
|
297
|
+
})
|
|
298
|
+
except (ToolError, KeyError, TypeError) as exc:
|
|
299
|
+
ctx.transcript.append({
|
|
300
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
301
|
+
"thoughts": thoughts, "args": args,
|
|
302
|
+
"risk": risk, "governance": dict(policy), "error": str(exc),
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
ctx.state = AgentState.VERIFYING
|
|
306
|
+
|
|
307
|
+
# ── VERIFY ───────────────────────────────────────────────────────
|
|
308
|
+
async def verify(
|
|
309
|
+
self, ctx: AgentRunContext, req: Any, lang_hint: str, current_user: str,
|
|
310
|
+
max_retry: int = 3, model_id: Optional[str] = None,
|
|
311
|
+
) -> None:
|
|
312
|
+
"""VERIFYING: Critic role evaluates transcript → DONE / EXECUTING (retry) / ROLLBACK / FAILED."""
|
|
313
|
+
d = self.deps
|
|
314
|
+
context = (
|
|
315
|
+
f"{d.critic_prompt}\n\n"
|
|
316
|
+
f"[LANGUAGE HINT: {lang_hint}]\n\n"
|
|
317
|
+
f"Original request: {req.message}\n"
|
|
318
|
+
f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
|
|
319
|
+
f"Full transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
320
|
+
)
|
|
321
|
+
raw = await d.generate_as(
|
|
322
|
+
model_id,
|
|
323
|
+
message="Review the execution transcript and return your verdict JSON.",
|
|
324
|
+
context=context, max_tokens=512, temperature=0.1,
|
|
325
|
+
)
|
|
326
|
+
try:
|
|
327
|
+
verdict = extract_action(str(raw))
|
|
328
|
+
except ValueError:
|
|
329
|
+
verdict = {"action": "verdict", "verdict": "PASS", "next_state": "DONE",
|
|
330
|
+
"reason": "Critic parse failed — assuming pass.", "corrections": [], "confidence": 0.7}
|
|
331
|
+
|
|
332
|
+
ctx.corrections = verdict.get("corrections", [])
|
|
333
|
+
# Normalize legacy verdict next_state strings to current AgentState names
|
|
334
|
+
raw_next = verdict.get("next_state", "DONE")
|
|
335
|
+
next_s = {"COMPLETE": "DONE", "RETRY": "EXECUTING"}.get(raw_next, raw_next)
|
|
336
|
+
|
|
337
|
+
ctx.transcript.append({
|
|
338
|
+
"state": AgentState.VERIFYING.value,
|
|
339
|
+
"verdict": verdict.get("verdict", "PASS"),
|
|
340
|
+
"reason": verdict.get("reason", ""),
|
|
341
|
+
"corrections": ctx.corrections,
|
|
342
|
+
"confidence": verdict.get("confidence", 0.9),
|
|
343
|
+
"next_state": next_s,
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
if verdict.get("verdict") == "PASS" or next_s == "DONE":
|
|
347
|
+
if not ctx.final_message:
|
|
348
|
+
ctx.final_message = verdict.get("reason", "작업이 완료되었습니다.")
|
|
349
|
+
ctx.state = AgentState.DONE
|
|
350
|
+
elif next_s == "ROLLBACK":
|
|
351
|
+
ctx.state = AgentState.ROLLBACK
|
|
352
|
+
elif next_s == "EXECUTING":
|
|
353
|
+
if ctx.retry_count >= max_retry:
|
|
354
|
+
ctx.final_message = "처리 중 문제가 발생했습니다. 다시 시도해 주세요."
|
|
355
|
+
ctx.state = AgentState.FAILED
|
|
356
|
+
else:
|
|
357
|
+
ctx.retry_count += 1
|
|
358
|
+
ctx.transcript.append({
|
|
359
|
+
"state": AgentState.EXECUTING.value,
|
|
360
|
+
"retry_attempt": ctx.retry_count,
|
|
361
|
+
"corrections": ctx.corrections,
|
|
362
|
+
})
|
|
363
|
+
ctx.state = AgentState.EXECUTING
|
|
364
|
+
else:
|
|
365
|
+
ctx.final_message = verdict.get("reason", "검증자가 인식되지 않은 다음 상태를 반환했습니다.")
|
|
366
|
+
ctx.state = AgentState.FAILED
|
|
367
|
+
|
|
368
|
+
# ── ROLLBACK ─────────────────────────────────────────────────────
|
|
369
|
+
def rollback(self, ctx: AgentRunContext, current_user: str) -> None:
|
|
370
|
+
"""ROLLBACK: attempt git checkout for each edited file, then FAILED."""
|
|
371
|
+
d = self.deps
|
|
372
|
+
rolled: List[dict] = []
|
|
373
|
+
for step in ctx.transcript:
|
|
374
|
+
if step.get("state") != AgentState.EXECUTING.value:
|
|
375
|
+
continue
|
|
376
|
+
gov = step.get("governance", {})
|
|
377
|
+
if gov.get("rollback") != "git":
|
|
378
|
+
continue
|
|
379
|
+
result = step.get("result", {})
|
|
380
|
+
if not (isinstance(result, dict) and result.get("success")):
|
|
381
|
+
continue
|
|
382
|
+
path = result.get("path") or (step.get("args") or {}).get("path", "")
|
|
383
|
+
if not path:
|
|
384
|
+
continue
|
|
385
|
+
try:
|
|
386
|
+
r = subprocess.run(
|
|
387
|
+
["git", "checkout", "--", path], cwd=str(d.agent_root),
|
|
388
|
+
capture_output=True, text=True, timeout=10,
|
|
389
|
+
)
|
|
390
|
+
rolled.append({"path": path, "ok": r.returncode == 0, "stderr": r.stderr[:200]})
|
|
391
|
+
except Exception as exc:
|
|
392
|
+
rolled.append({"path": path, "ok": False, "error": str(exc)})
|
|
393
|
+
|
|
394
|
+
ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
|
|
395
|
+
recovered = [r["path"] for r in rolled if r.get("ok")]
|
|
396
|
+
ctx.final_message = (
|
|
397
|
+
f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
|
|
398
|
+
if recovered
|
|
399
|
+
else "롤백을 시도했으나 복구할 파일이 없거나 git이 초기화되지 않았습니다."
|
|
400
|
+
)
|
|
401
|
+
d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
|
|
402
|
+
# Rollback is a recovery from a failed verification — terminal state is FAILED
|
|
403
|
+
ctx.state = AgentState.FAILED
|
|
404
|
+
|
|
405
|
+
# ── MEMORY ───────────────────────────────────────────────────────
|
|
406
|
+
async def memory_update(self, ctx: AgentRunContext, req: Any, current_user: str) -> None:
|
|
407
|
+
"""Background: Memory Updater role extracts learnings after DONE."""
|
|
408
|
+
d = self.deps
|
|
409
|
+
context = (
|
|
410
|
+
f"{d.memory_updater_prompt}\n\n"
|
|
411
|
+
f"Completed task: {req.message}\n\n"
|
|
412
|
+
f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
|
|
413
|
+
)
|
|
414
|
+
try:
|
|
415
|
+
raw = await d.generate(
|
|
416
|
+
message="Extract learnings from this completed task.",
|
|
417
|
+
context=context, max_tokens=256, temperature=0.1,
|
|
418
|
+
)
|
|
419
|
+
mem = extract_action(str(raw))
|
|
420
|
+
if mem.get("save_to_knowledge") and mem.get("learnings"):
|
|
421
|
+
d.knowledge_save(
|
|
422
|
+
"\n".join(mem["learnings"]),
|
|
423
|
+
folder="30_Projects",
|
|
424
|
+
title=f"Agent: {req.message[:60]}",
|
|
425
|
+
)
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
|
|
429
|
+
# ── DRIVE LOOP ───────────────────────────────────────────────────
|
|
430
|
+
async def run_to_completion(
|
|
431
|
+
self, ctx: AgentRunContext, req: Any, lang_hint: str,
|
|
432
|
+
current_user: str, max_steps: int, max_retry: int,
|
|
433
|
+
) -> None:
|
|
434
|
+
"""Run EXECUTING → VERIFYING → ROLLBACK loop until a terminal state."""
|
|
435
|
+
while ctx.state not in AGENT_TERMINAL_STATES:
|
|
436
|
+
ctx.state_history.append(ctx.state.value)
|
|
437
|
+
if len(ctx.state_history) > 200:
|
|
438
|
+
ctx.final_message = "에이전트 상태 머신이 최대 반복(200)에 도달해 중단했습니다."
|
|
439
|
+
ctx.state = AgentState.FAILED
|
|
440
|
+
break
|
|
441
|
+
|
|
442
|
+
if ctx.state == AgentState.EXECUTING:
|
|
443
|
+
await self.execute(ctx, req, lang_hint, current_user, max_steps,
|
|
444
|
+
model_id=ctx.executing_model)
|
|
445
|
+
elif ctx.state == AgentState.VERIFYING:
|
|
446
|
+
await self.verify(ctx, req, lang_hint, current_user, max_retry,
|
|
447
|
+
model_id=ctx.reviewing_model)
|
|
448
|
+
elif ctx.state == AgentState.ROLLBACK:
|
|
449
|
+
self.rollback(ctx, current_user)
|
|
450
|
+
else:
|
|
451
|
+
ctx.state = AgentState.FAILED
|
|
452
|
+
|
|
453
|
+
ctx.state_history.append(ctx.state.value)
|
package/latticeai/core/audit.py
CHANGED
|
@@ -9,6 +9,8 @@ from datetime import datetime
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any, Callable, Dict, List, Optional
|
|
11
11
|
|
|
12
|
+
from . import timezones
|
|
13
|
+
|
|
12
14
|
_history_lock = threading.Lock()
|
|
13
15
|
|
|
14
16
|
SENSITIVE_PATTERNS = [
|
|
@@ -41,7 +43,8 @@ def append_audit_event(audit_file: Path, event_type: str, **payload) -> None:
|
|
|
41
43
|
try:
|
|
42
44
|
event = {
|
|
43
45
|
"event_type": event_type,
|
|
44
|
-
"
|
|
46
|
+
# item 7: 대시보드 "오늘" 계산과 동일한 시간대 기준으로 기록한다.
|
|
47
|
+
"timestamp": timezones.now_iso(),
|
|
45
48
|
**payload,
|
|
46
49
|
}
|
|
47
50
|
with _history_lock:
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""App-level configuration as a single deep module.
|
|
2
|
+
|
|
3
|
+
All environment parsing for Lattice AI's *application* settings (mode, host,
|
|
4
|
+
port, feature flags, SSO, auth gating, integrations) lives here behind one
|
|
5
|
+
interface: ``Config.from_env``. Callers read typed attributes off a frozen
|
|
6
|
+
``Config`` instance instead of reaching for ``os.getenv`` in 40 places.
|
|
7
|
+
|
|
8
|
+
The ``env`` mapping passed to ``from_env`` is the seam:
|
|
9
|
+
|
|
10
|
+
* production passes ``os.environ`` (the default);
|
|
11
|
+
* tests pass a plain ``dict`` and get a fully-formed ``Config`` with no
|
|
12
|
+
monkeypatching of the process environment.
|
|
13
|
+
|
|
14
|
+
Per-request provider credentials (``OPENAI_API_KEY``, ``LMSTUDIO_API_KEY`` …)
|
|
15
|
+
are intentionally *not* here — those belong to the LLM Router's provider
|
|
16
|
+
concept and are read dynamically at call time.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import List, Mapping, Optional
|
|
25
|
+
|
|
26
|
+
from latticeai.core.security import host_is_loopback
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _value(env: Mapping[str, str], key: str, default: str = "") -> str:
|
|
30
|
+
"""Mirror the legacy ``env_value``: ``getenv(key) or default or ""`` (no strip)."""
|
|
31
|
+
return env.get(key) or default or ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _str(env: Mapping[str, str], key: str, default: str = "") -> str:
|
|
35
|
+
"""Mirror ``os.getenv(key, default)``: default only when the key is absent."""
|
|
36
|
+
raw = env.get(key)
|
|
37
|
+
return raw if raw is not None else default
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _bool(env: Mapping[str, str], key: str, default: bool = False) -> bool:
|
|
41
|
+
raw = env.get(key)
|
|
42
|
+
if raw is None:
|
|
43
|
+
return default
|
|
44
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _int(env: Mapping[str, str], key: str, default: int) -> int:
|
|
48
|
+
raw = env.get(key)
|
|
49
|
+
if raw is None or not str(raw).strip():
|
|
50
|
+
return default
|
|
51
|
+
try:
|
|
52
|
+
return int(str(raw).strip())
|
|
53
|
+
except ValueError:
|
|
54
|
+
return default
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Config:
|
|
59
|
+
"""Everything a caller must know about app-level settings.
|
|
60
|
+
|
|
61
|
+
Construct once at startup via :meth:`from_env`; pass the values onward
|
|
62
|
+
rather than re-reading the environment.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
# ── mode / network ──────────────────────────────────────────────
|
|
66
|
+
app_mode: str
|
|
67
|
+
is_public: bool
|
|
68
|
+
host: str
|
|
69
|
+
port: int
|
|
70
|
+
network_exposed: bool
|
|
71
|
+
|
|
72
|
+
# ── feature flags ───────────────────────────────────────────────
|
|
73
|
+
enable_telegram: bool
|
|
74
|
+
enable_graph: bool
|
|
75
|
+
autoload_models: bool
|
|
76
|
+
model_idle_unload_seconds: int
|
|
77
|
+
allow_local_models: bool
|
|
78
|
+
|
|
79
|
+
# ── auth / security ─────────────────────────────────────────────
|
|
80
|
+
require_auth: bool
|
|
81
|
+
allow_plaintext_api_keys: bool
|
|
82
|
+
cors_allow_network: bool
|
|
83
|
+
cors_extra_origins: List[str]
|
|
84
|
+
rate_limit_enabled: bool
|
|
85
|
+
open_registration: bool
|
|
86
|
+
invite_code: str
|
|
87
|
+
invite_gate_enabled: bool
|
|
88
|
+
admin_emails: List[str]
|
|
89
|
+
|
|
90
|
+
# ── models ──────────────────────────────────────────────────────
|
|
91
|
+
public_model: str
|
|
92
|
+
local_model: str
|
|
93
|
+
local_draft_model: str
|
|
94
|
+
auto_read_chat_paths: bool
|
|
95
|
+
|
|
96
|
+
# ── SSO / OIDC ──────────────────────────────────────────────────
|
|
97
|
+
sso_discovery_url: str
|
|
98
|
+
sso_client_id: str
|
|
99
|
+
sso_client_secret: str
|
|
100
|
+
sso_redirect_uri: str
|
|
101
|
+
sso_provider_name: str
|
|
102
|
+
|
|
103
|
+
# ── integrations ────────────────────────────────────────────────
|
|
104
|
+
discord_permission_webhook: str
|
|
105
|
+
discord_bot_token: str
|
|
106
|
+
discord_permission_channel: str
|
|
107
|
+
permission_monitor_secret: str
|
|
108
|
+
|
|
109
|
+
# ── paths ───────────────────────────────────────────────────────
|
|
110
|
+
data_dir: Path
|
|
111
|
+
static_dir: Path
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_env(cls, env: Optional[Mapping[str, str]] = None, *, base_dir: Optional[Path] = None) -> "Config":
|
|
115
|
+
if env is None:
|
|
116
|
+
import os
|
|
117
|
+
env = os.environ
|
|
118
|
+
if base_dir is None:
|
|
119
|
+
base_dir = Path(__file__).resolve().parent.parent.parent
|
|
120
|
+
|
|
121
|
+
app_mode = _value(env, "LATTICEAI_MODE", "local").lower()
|
|
122
|
+
if app_mode not in {"local", "public"}:
|
|
123
|
+
app_mode = "local"
|
|
124
|
+
is_public = app_mode == "public"
|
|
125
|
+
|
|
126
|
+
host = _value(env, "LATTICEAI_HOST", "127.0.0.1")
|
|
127
|
+
port = _int(env, "LATTICEAI_PORT", 4825)
|
|
128
|
+
network_exposed = not host_is_loopback(host)
|
|
129
|
+
|
|
130
|
+
cors_extra = [item.strip() for item in _value(env, "LATTICEAI_CORS_ALLOWED_ORIGINS", "").split(",") if item.strip()]
|
|
131
|
+
admin_emails = [item.strip().lower() for item in _value(env, "LATTICEAI_ADMIN_EMAILS", "").split(",") if item.strip()]
|
|
132
|
+
|
|
133
|
+
public_model = _value(env, "LATTICEAI_PUBLIC_MODEL", _value(env, "LATTICEAI_DEFAULT_MODEL", "openai:gpt-4o-mini"))
|
|
134
|
+
local_model = _value(env, "LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-26b-a4b-it-4bit")
|
|
135
|
+
|
|
136
|
+
data_dir = Path(_value(env, "LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
|
|
137
|
+
static_dir = Path(_value(env, "LATTICEAI_STATIC_DIR", str(base_dir / "static")))
|
|
138
|
+
if not static_dir.exists():
|
|
139
|
+
packaged_static = Path(sys.prefix) / "static"
|
|
140
|
+
if packaged_static.exists():
|
|
141
|
+
static_dir = packaged_static
|
|
142
|
+
|
|
143
|
+
return cls(
|
|
144
|
+
app_mode=app_mode,
|
|
145
|
+
is_public=is_public,
|
|
146
|
+
host=host,
|
|
147
|
+
port=port,
|
|
148
|
+
network_exposed=network_exposed,
|
|
149
|
+
enable_telegram=_bool(env, "LATTICEAI_ENABLE_TELEGRAM", default=not is_public),
|
|
150
|
+
enable_graph=_bool(env, "LATTICEAI_ENABLE_GRAPH", default=True),
|
|
151
|
+
autoload_models=_bool(env, "LATTICEAI_AUTOLOAD_MODELS", default=is_public),
|
|
152
|
+
model_idle_unload_seconds=_int(env, "LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", 0),
|
|
153
|
+
allow_local_models=_bool(env, "LATTICEAI_ALLOW_LOCAL_MODELS", default=not is_public),
|
|
154
|
+
require_auth=_bool(env, "LATTICEAI_REQUIRE_AUTH", default=is_public or network_exposed),
|
|
155
|
+
allow_plaintext_api_keys=_bool(env, "LATTICEAI_ALLOW_PLAINTEXT_API_KEYS", default=False),
|
|
156
|
+
cors_allow_network=_bool(env, "LATTICEAI_CORS_ALLOW_NETWORK", default=False),
|
|
157
|
+
cors_extra_origins=cors_extra,
|
|
158
|
+
rate_limit_enabled=_str(env, "LATTICEAI_RATE_LIMIT", "1") != "0",
|
|
159
|
+
open_registration=_bool(env, "LATTICEAI_OPEN_REGISTRATION", default=not network_exposed and not is_public),
|
|
160
|
+
invite_code=_value(env, "LATTICEAI_INVITE_CODE", "gemma-lattice-ai"),
|
|
161
|
+
invite_gate_enabled=_bool(env, "LATTICEAI_INVITE_GATE_ENABLED", default=False),
|
|
162
|
+
admin_emails=admin_emails,
|
|
163
|
+
public_model=public_model,
|
|
164
|
+
local_model=local_model,
|
|
165
|
+
local_draft_model=_value(env, "LATTICEAI_LOCAL_DRAFT_MODEL", ""),
|
|
166
|
+
auto_read_chat_paths=_bool(env, "LATTICEAI_AUTO_READ_CHAT_PATHS", default=False),
|
|
167
|
+
sso_discovery_url=_value(env, "OIDC_DISCOVERY_URL", ""),
|
|
168
|
+
sso_client_id=_value(env, "OIDC_CLIENT_ID", ""),
|
|
169
|
+
sso_client_secret=_value(env, "OIDC_CLIENT_SECRET", ""),
|
|
170
|
+
sso_redirect_uri=_value(env, "OIDC_REDIRECT_URI", "http://localhost:4825/auth/sso/callback"),
|
|
171
|
+
sso_provider_name=_value(env, "OIDC_PROVIDER_NAME", "SSO"),
|
|
172
|
+
discord_permission_webhook=_value(env, "LATTICEAI_DISCORD_PERMISSION_WEBHOOK", ""),
|
|
173
|
+
discord_bot_token=_value(env, "LATTICEAI_DISCORD_BOT_TOKEN", ""),
|
|
174
|
+
discord_permission_channel=_value(env, "LATTICEAI_DISCORD_PERMISSION_CHANNEL", ""),
|
|
175
|
+
permission_monitor_secret=_value(env, "LATTICEAI_PERMISSION_SECRET", ""),
|
|
176
|
+
data_dir=data_dir,
|
|
177
|
+
static_dir=static_dir,
|
|
178
|
+
)
|