ltcai 9.7.0 → 9.9.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 +114 -320
- package/docs/BENCHMARKS.md +107 -0
- package/docs/CHANGELOG.md +72 -0
- package/docs/CI_AND_RELEASE_GATES.md +106 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +11 -8
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +15 -6
- package/docs/PERFORMANCE.md +9 -1
- package/docs/SECURITY_AUDIT.md +91 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/USABILITY_AUDIT.md +164 -0
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/architecture.md +4 -2
- package/docs/kg-schema.md +1 -1
- package/docs/spec-vs-impl.md +8 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/retrieval.py +93 -4
- package/lattice_brain/graph/retrieval_vector.py +43 -0
- package/lattice_brain/ingestion.py +399 -14
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +12 -0
- package/latticeai/api/chat.py +17 -0
- package/latticeai/api/chat_helpers.py +48 -0
- package/latticeai/api/chat_stream.py +9 -1
- package/latticeai/api/local_files.py +120 -2
- package/latticeai/api/review_queue.py +7 -0
- package/latticeai/core/agent.py +105 -8
- package/latticeai/core/agent_eval.py +222 -1
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/tool_governor.py +92 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +151 -14
- package/latticeai/services/brain_intelligence.py +72 -0
- package/latticeai/services/change_proposals.py +165 -23
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +33 -0
- package/package.json +5 -3
- package/scripts/bench_models.py +312 -0
- package/scripts/check_bundle_budget.mjs +103 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_doc_status.mjs +149 -0
- package/scripts/generate_sbom.py +70 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +27 -11
- package/static/app/assets/Act-CfOPUKPs.js +2 -0
- package/static/app/assets/Brain-Cgkh0Hpn.js +321 -0
- package/static/app/assets/BrainHome-D8e3wQiW.js +3 -0
- package/static/app/assets/BrainSignals-BjRBA18L.js +1 -0
- package/static/app/assets/Capture-CQQYQ1Ga.js +1 -0
- package/static/app/assets/CommandPalette-pss56Mg4.js +1 -0
- package/static/app/assets/Library-CoI3xYJk.js +1 -0
- package/static/app/assets/LivingBrain-CaU_hCxQ.js +1 -0
- package/static/app/assets/ProductFlow-Db-1O71-.js +1 -0
- package/static/app/assets/System-Dfocn0zN.js +1 -0
- package/static/app/assets/bot-Bp2x1i6F.js +1 -0
- package/static/app/assets/circle-check-CyGal42W.js +1 -0
- package/static/app/assets/cpu-DQFk82hG.js +1 -0
- package/static/app/assets/download-C70gKQVi.js +1 -0
- package/static/app/assets/folder-open-DNGytsVw.js +1 -0
- package/static/app/assets/hard-drive-CYo_bEl4.js +1 -0
- package/static/app/assets/i18n-BBMJshCW.js +7 -0
- package/static/app/assets/index-BjaXCeOZ.js +10 -0
- package/static/app/assets/{index-85wQvEie.css → index-BqRcLZR3.css} +1 -1
- package/static/app/assets/input-ChWD-Fsh.js +1 -0
- package/static/app/assets/navigation-D9D9_FPC.js +1 -0
- package/static/app/assets/network-DzLDGmtV.js +1 -0
- package/static/app/assets/primitives-CmaSAyGG.js +1 -0
- package/static/app/assets/sparkles-DXiaM1NS.js +1 -0
- package/static/app/assets/textarea-CV43-o79.js +1 -0
- package/static/app/index.html +4 -2
- package/static/sw.js +1 -1
- package/static/app/assets/Act-B6c39ays.js +0 -2
- package/static/app/assets/Brain-D7Qg4k6M.js +0 -321
- package/static/app/assets/Capture-VF_di68r.js +0 -1
- package/static/app/assets/Library-D_Gis2PA.js +0 -1
- package/static/app/assets/System-C5s5H2ov.js +0 -1
- package/static/app/assets/index-DJC_2oub.js +0 -18
- package/static/app/assets/primitives-DL4Nip8C.js +0 -1
- package/static/app/assets/textarea-woZfCXHy.js +0 -1
|
@@ -21,9 +21,13 @@ change audit log. The classification itself lives in
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
23
|
import difflib
|
|
24
|
+
import hashlib
|
|
24
25
|
import logging
|
|
26
|
+
import os
|
|
27
|
+
import tempfile
|
|
28
|
+
import threading
|
|
25
29
|
from pathlib import Path
|
|
26
|
-
from typing import Any, Callable, Dict, List, Optional
|
|
30
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
27
31
|
|
|
28
32
|
from latticeai.core.tool_governor import classify_tool_call
|
|
29
33
|
|
|
@@ -34,6 +38,55 @@ _MAX_DIFF_LINES = 400
|
|
|
34
38
|
_SMALL_TIER_DIFF_LINES = 40
|
|
35
39
|
|
|
36
40
|
|
|
41
|
+
class ProposalConflictError(ValueError):
|
|
42
|
+
"""The target file changed (or the proposal was already resolved) between
|
|
43
|
+
staging and approval — applying now would silently destroy user edits.
|
|
44
|
+
|
|
45
|
+
Subclasses :class:`ValueError` deliberately: API surfaces that only know
|
|
46
|
+
``except ValueError`` degrade to a 4xx instead of applying or crashing,
|
|
47
|
+
while conflict-aware surfaces catch this class first and answer **409**
|
|
48
|
+
with a rebase hint.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
reason: str,
|
|
55
|
+
path: str,
|
|
56
|
+
kind: str,
|
|
57
|
+
base_sha256: str = "",
|
|
58
|
+
current_sha256: str = "",
|
|
59
|
+
rebase_hint: str = "",
|
|
60
|
+
) -> None:
|
|
61
|
+
self.reason = reason
|
|
62
|
+
self.path = path
|
|
63
|
+
self.kind = kind
|
|
64
|
+
self.base_sha256 = base_sha256
|
|
65
|
+
self.current_sha256 = current_sha256
|
|
66
|
+
self.rebase_hint = rebase_hint or (
|
|
67
|
+
"제안 생성 이후 파일 상태가 바뀌었습니다. 이 제안을 거부하고 "
|
|
68
|
+
"현재 파일 내용을 기준으로 제안을 다시 생성하세요."
|
|
69
|
+
)
|
|
70
|
+
super().__init__(f"change proposal conflict ({reason}): {path}")
|
|
71
|
+
|
|
72
|
+
def to_detail(self) -> Dict[str, Any]:
|
|
73
|
+
"""409 response body — no staged content, just what the UI needs."""
|
|
74
|
+
return {
|
|
75
|
+
"error": "change_proposal_conflict",
|
|
76
|
+
"conflict": True,
|
|
77
|
+
"reason": self.reason,
|
|
78
|
+
"path": self.path,
|
|
79
|
+
"kind": self.kind,
|
|
80
|
+
"base_sha256": self.base_sha256,
|
|
81
|
+
"current_sha256": self.current_sha256,
|
|
82
|
+
"rebase_hint": self.rebase_hint,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _sha256_text(content: str) -> str:
|
|
87
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
88
|
+
|
|
89
|
+
|
|
37
90
|
def _unified_diff(before: str, after: str, path: str) -> List[str]:
|
|
38
91
|
lines = list(
|
|
39
92
|
difflib.unified_diff(
|
|
@@ -60,6 +113,11 @@ class ChangeProposalService:
|
|
|
60
113
|
self._review_queue = review_queue
|
|
61
114
|
self._resolve_path = resolve_path
|
|
62
115
|
self._audit = audit or (lambda *a, **kw: None)
|
|
116
|
+
# One protected section for status-check → conflict-check → apply →
|
|
117
|
+
# transition. In-process serialization is the right weight here: the
|
|
118
|
+
# review queue lives in this process and the write itself is atomic
|
|
119
|
+
# (temp file + os.replace), so a cross-process file lock adds nothing.
|
|
120
|
+
self._apply_lock = threading.Lock()
|
|
63
121
|
|
|
64
122
|
# ── classification / staging (agent-loop governor port) ─────────────
|
|
65
123
|
|
|
@@ -125,15 +183,24 @@ class ChangeProposalService:
|
|
|
125
183
|
return False
|
|
126
184
|
|
|
127
185
|
def _read_before(self, path: str) -> str:
|
|
186
|
+
return self._snapshot(path)[1]
|
|
187
|
+
|
|
188
|
+
def _snapshot(self, path: str) -> Tuple[bool, str]:
|
|
189
|
+
"""(exists, content) of the target *as the proposal pipeline sees it*.
|
|
190
|
+
|
|
191
|
+
Both staging and the approve-time conflict check go through this one
|
|
192
|
+
normalization (truncate → utf-8 with replacement), so an unchanged
|
|
193
|
+
file always hashes identically at both points in time.
|
|
194
|
+
"""
|
|
128
195
|
try:
|
|
129
196
|
target = self._resolve_path(path)
|
|
130
197
|
if not target.is_file():
|
|
131
|
-
return ""
|
|
198
|
+
return False, ""
|
|
132
199
|
raw = target.read_bytes()[:_MAX_STAGED_BYTES]
|
|
133
|
-
return raw.decode("utf-8", errors="replace")
|
|
200
|
+
return True, raw.decode("utf-8", errors="replace")
|
|
134
201
|
except Exception:
|
|
135
202
|
LOGGER.exception("change proposal read failed")
|
|
136
|
-
return ""
|
|
203
|
+
return False, ""
|
|
137
204
|
|
|
138
205
|
def _staged_content(self, name: str, args: Dict[str, Any]) -> Optional[str]:
|
|
139
206
|
if name == "write_file":
|
|
@@ -164,7 +231,7 @@ class ChangeProposalService:
|
|
|
164
231
|
workspace_id: Optional[str] = None,
|
|
165
232
|
context: Optional[Dict[str, Any]] = None,
|
|
166
233
|
) -> Dict[str, Any]:
|
|
167
|
-
before = self.
|
|
234
|
+
base_exists, before = self._snapshot(path)
|
|
168
235
|
diff = _unified_diff(before, new_content, path)
|
|
169
236
|
tier = "small" if len(diff) <= _SMALL_TIER_DIFF_LINES else "large"
|
|
170
237
|
provenance: Dict[str, Any] = {"proposed_by": proposed_by, "reason": reason}
|
|
@@ -183,6 +250,11 @@ class ChangeProposalService:
|
|
|
183
250
|
"tier": tier,
|
|
184
251
|
"before_bytes": len(before.encode("utf-8")),
|
|
185
252
|
"after_bytes": len(new_content.encode("utf-8")),
|
|
253
|
+
# Base snapshot for the approve-time conflict check: an empty
|
|
254
|
+
# base_sha256 with base_exists=False means "proposed against a
|
|
255
|
+
# missing file", never "hash of the empty string".
|
|
256
|
+
"base_exists": base_exists,
|
|
257
|
+
"base_sha256": _sha256_text(before) if base_exists else "",
|
|
186
258
|
},
|
|
187
259
|
provenance=provenance,
|
|
188
260
|
user_email=user_email,
|
|
@@ -203,7 +275,7 @@ class ChangeProposalService:
|
|
|
203
275
|
user_email: Optional[str] = None,
|
|
204
276
|
workspace_id: Optional[str] = None,
|
|
205
277
|
) -> Dict[str, Any]:
|
|
206
|
-
before = self.
|
|
278
|
+
base_exists, before = self._snapshot(path)
|
|
207
279
|
item = self._review_queue.create(
|
|
208
280
|
title=f"파일 삭제 제안: {path}",
|
|
209
281
|
summary=reason or "기존 파일을 삭제하는 작업이라 검토 후 적용됩니다.",
|
|
@@ -215,6 +287,8 @@ class ChangeProposalService:
|
|
|
215
287
|
"tier": "large",
|
|
216
288
|
"before_bytes": len(before.encode("utf-8")),
|
|
217
289
|
"after_bytes": 0,
|
|
290
|
+
"base_exists": base_exists,
|
|
291
|
+
"base_sha256": _sha256_text(before) if base_exists else "",
|
|
218
292
|
},
|
|
219
293
|
provenance={"proposed_by": proposed_by, "reason": reason},
|
|
220
294
|
user_email=user_email,
|
|
@@ -274,28 +348,96 @@ class ChangeProposalService:
|
|
|
274
348
|
self, item_id: str, *, user_email: Optional[str] = None,
|
|
275
349
|
workspace_id: Optional[str] = None,
|
|
276
350
|
) -> Dict[str, Any]:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
351
|
+
"""Apply the staged change exactly as reviewed — but only if the file
|
|
352
|
+
on disk still matches the base snapshot the reviewer looked at.
|
|
353
|
+
|
|
354
|
+
Raises :class:`ProposalConflictError` (→ HTTP 409) when the target
|
|
355
|
+
drifted since staging or the proposal was already resolved; nothing
|
|
356
|
+
touches disk in that case, so out-of-band user edits are preserved.
|
|
357
|
+
"""
|
|
358
|
+
with self._apply_lock:
|
|
359
|
+
item = self._review_queue.get(item_id, workspace_id=workspace_id)
|
|
360
|
+
if item.get("source") != "change_proposal":
|
|
361
|
+
raise KeyError(f"not a change proposal: {item_id}")
|
|
362
|
+
payload = item.get("payload") or {}
|
|
363
|
+
kind = str(item.get("kind") or "")
|
|
364
|
+
path = str(payload.get("path") or "")
|
|
365
|
+
if kind not in ("file_update", "file_delete"):
|
|
366
|
+
raise ValueError(f"unknown change proposal kind: {kind}")
|
|
367
|
+
|
|
368
|
+
# Duplicate/concurrent approval guard: a resolved proposal must
|
|
369
|
+
# never re-apply (the disk may have moved on since it was applied).
|
|
370
|
+
status = str(item.get("status") or "pending")
|
|
371
|
+
if status not in ("pending", "snoozed"):
|
|
372
|
+
raise ProposalConflictError(
|
|
373
|
+
reason=f"already_{status}", path=path, kind=kind,
|
|
374
|
+
rebase_hint="이미 처리된 제안입니다. 다시 적용할 수 없습니다.",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
self._check_base_unchanged(payload, path=path, kind=kind)
|
|
378
|
+
|
|
379
|
+
target = self._resolve_path(path)
|
|
380
|
+
if kind == "file_update":
|
|
381
|
+
self._atomic_write(target, str(payload.get("new_content") or ""))
|
|
382
|
+
else: # file_delete — existence re-verified inside the lock
|
|
383
|
+
if target.is_file():
|
|
384
|
+
target.unlink()
|
|
385
|
+
approved = self._review_queue.approve(item_id, workspace_id=workspace_id)
|
|
293
386
|
self._audit(
|
|
294
387
|
"change_proposal_applied", user_email=user_email,
|
|
295
388
|
proposal_id=item_id, path=path, kind=kind,
|
|
296
389
|
)
|
|
297
390
|
return {"item": approved, "applied": True, "path": path, "kind": kind}
|
|
298
391
|
|
|
392
|
+
def _check_base_unchanged(
|
|
393
|
+
self, payload: Dict[str, Any], *, path: str, kind: str
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Compare the disk state *now* against the staged base snapshot."""
|
|
396
|
+
if "base_sha256" not in payload or "base_exists" not in payload:
|
|
397
|
+
# Legacy proposal staged before base snapshots existed — keep the
|
|
398
|
+
# historical apply-as-reviewed behavior rather than rejecting it.
|
|
399
|
+
return
|
|
400
|
+
base_exists = bool(payload.get("base_exists"))
|
|
401
|
+
base_sha256 = str(payload.get("base_sha256") or "")
|
|
402
|
+
current_exists, current_content = self._snapshot(path)
|
|
403
|
+
current_sha256 = _sha256_text(current_content) if current_exists else ""
|
|
404
|
+
if not base_exists:
|
|
405
|
+
if current_exists:
|
|
406
|
+
raise ProposalConflictError(
|
|
407
|
+
reason="file_created_since_proposal", path=path, kind=kind,
|
|
408
|
+
current_sha256=current_sha256,
|
|
409
|
+
)
|
|
410
|
+
return
|
|
411
|
+
if not current_exists:
|
|
412
|
+
raise ProposalConflictError(
|
|
413
|
+
reason="file_deleted_since_proposal", path=path, kind=kind,
|
|
414
|
+
base_sha256=base_sha256,
|
|
415
|
+
)
|
|
416
|
+
if current_sha256 != base_sha256:
|
|
417
|
+
raise ProposalConflictError(
|
|
418
|
+
reason="file_modified_since_proposal", path=path, kind=kind,
|
|
419
|
+
base_sha256=base_sha256, current_sha256=current_sha256,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def _atomic_write(target: Path, content: str) -> None:
|
|
424
|
+
"""Write via a same-directory temp file + ``os.replace`` so readers
|
|
425
|
+
never observe a partially written proposal."""
|
|
426
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
428
|
+
dir=str(target.parent), prefix=f".{target.name}.", suffix=".staged"
|
|
429
|
+
)
|
|
430
|
+
try:
|
|
431
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
432
|
+
handle.write(content)
|
|
433
|
+
os.replace(tmp_name, target)
|
|
434
|
+
except BaseException:
|
|
435
|
+
try:
|
|
436
|
+
os.unlink(tmp_name)
|
|
437
|
+
except OSError:
|
|
438
|
+
pass
|
|
439
|
+
raise
|
|
440
|
+
|
|
299
441
|
def reject(
|
|
300
442
|
self, item_id: str, *, user_email: Optional[str] = None,
|
|
301
443
|
workspace_id: Optional[str] = None, reason: str = "",
|
|
@@ -319,4 +461,4 @@ class ChangeProposalService:
|
|
|
319
461
|
return {"item": dismissed, "applied": False, "reason": reason}
|
|
320
462
|
|
|
321
463
|
|
|
322
|
-
__all__ = ["ChangeProposalService"]
|
|
464
|
+
__all__ = ["ChangeProposalService", "ProposalConflictError"]
|
|
@@ -21,6 +21,7 @@ from latticeai.core.agent_prompts import (
|
|
|
21
21
|
PLANNER_PROMPT,
|
|
22
22
|
)
|
|
23
23
|
from latticeai.core.policy import role_has_capability
|
|
24
|
+
from latticeai.core.tool_governor import classify_tool_call
|
|
24
25
|
from latticeai.core.tool_registry import ToolPermission, ToolPolicy
|
|
25
26
|
from latticeai.tools import AGENT_ROOT, DEFAULT_TOOL_REGISTRY, ToolError, ensure_agent_root
|
|
26
27
|
|
|
@@ -122,6 +123,22 @@ class ToolDispatchService:
|
|
|
122
123
|
detail=f"'{tool_name}' 툴에는 '{capability}' capability가 필요합니다.",
|
|
123
124
|
)
|
|
124
125
|
|
|
126
|
+
def _governed_path_exists(self, path: str) -> bool:
|
|
127
|
+
"""Best-effort existence check for governance classification.
|
|
128
|
+
|
|
129
|
+
Resolves workspace-relative paths under ``AGENT_ROOT`` and honors
|
|
130
|
+
absolute paths (home-sandbox writes). Never raises — an unresolvable
|
|
131
|
+
path is treated as non-existent (additive), which the fail-closed
|
|
132
|
+
guard only escalates when the target actually exists.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
candidate = Path(path)
|
|
136
|
+
if not candidate.is_absolute():
|
|
137
|
+
candidate = Path(AGENT_ROOT) / candidate
|
|
138
|
+
return candidate.exists()
|
|
139
|
+
except Exception:
|
|
140
|
+
return False
|
|
141
|
+
|
|
125
142
|
def user_role(self, current_user: str) -> str:
|
|
126
143
|
users = self.load_users()
|
|
127
144
|
try:
|
|
@@ -162,6 +179,22 @@ class ToolDispatchService:
|
|
|
162
179
|
status_code=403,
|
|
163
180
|
detail=f"'{tool_name}' 툴은 파괴적 작업으로 차단되었습니다.",
|
|
164
181
|
)
|
|
182
|
+
# Fail-closed governance: a call that would rewrite existing content but
|
|
183
|
+
# cannot be staged as a reviewable proposal is blocked, never applied
|
|
184
|
+
# silently. New-file (additive) calls are unaffected.
|
|
185
|
+
verdict = classify_tool_call(
|
|
186
|
+
tool_name, args or {},
|
|
187
|
+
policy=dict(policy), path_exists=self._governed_path_exists,
|
|
188
|
+
)
|
|
189
|
+
if verdict.get("fail_closed"):
|
|
190
|
+
raise HTTPException(
|
|
191
|
+
status_code=409,
|
|
192
|
+
detail=(
|
|
193
|
+
f"'{tool_name}' 툴은 기존 콘텐츠를 변경하지만 검토 가능한 제안으로 "
|
|
194
|
+
"적용할 수 없어 차단되었습니다. 새 파일 이름으로 생성하거나 "
|
|
195
|
+
"지원되는 편집 도구(write_file/edit_file)를 사용하세요."
|
|
196
|
+
),
|
|
197
|
+
)
|
|
165
198
|
if (
|
|
166
199
|
require_auto_approval
|
|
167
200
|
and not trusted_admin
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.9.0",
|
|
4
4
|
"description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
"build:assets": "vite build && node scripts/build_frontend_assets.mjs",
|
|
25
25
|
"build:python": "node scripts/run_python.mjs -m build",
|
|
26
26
|
"check:python": "node scripts/run_python.mjs scripts/check_python.py",
|
|
27
|
-
"lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run test:browser-extension",
|
|
27
|
+
"lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run check:bundle && npm run test:browser-extension",
|
|
28
28
|
"lint:python": "node scripts/run_python.mjs -m ruff check .",
|
|
29
29
|
"lint:frontend": "node scripts/lint_frontend.mjs",
|
|
30
|
-
"
|
|
30
|
+
"check:bundle": "node scripts/check_bundle_budget.mjs",
|
|
31
|
+
"docs:check-links": "node scripts/check_markdown_links.mjs && node scripts/check_doc_status.mjs",
|
|
32
|
+
"docs:check-status": "node scripts/check_doc_status.mjs",
|
|
31
33
|
"docs:check-current": "node scripts/check_current_release_docs.mjs",
|
|
32
34
|
"typecheck": "npm run typecheck:frontend && cd vscode-extension && npm run build",
|
|
33
35
|
"typecheck:frontend": "npx tsc -p tsconfig.json --noEmit",
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Model robustness benchmark harness for the Lattice agent loop.
|
|
3
|
+
|
|
4
|
+
What it measures
|
|
5
|
+
================
|
|
6
|
+
The product claim is that the Brain stays durable "across any AI model" — a
|
|
7
|
+
weaker model may emit sloppier output, but the agent loop repairs it and still
|
|
8
|
+
completes the task. This harness turns that claim into a **matrix** of three
|
|
9
|
+
numbers per model tier:
|
|
10
|
+
|
|
11
|
+
* ``success_rate`` — fraction of model outputs the loop could turn into a valid
|
|
12
|
+
action (i.e. the task can proceed).
|
|
13
|
+
* ``repair_rate`` — of the successful parses, the fraction that ONLY succeeded
|
|
14
|
+
because the loop's tolerant parser had to repair the output (fences, prose,
|
|
15
|
+
trailing commas, ``<think>`` blocks, Python-dict literals). High repair_rate
|
|
16
|
+
= "this tier leans hard on the loop's robustness".
|
|
17
|
+
* ``latency_ms`` — see the honesty note below.
|
|
18
|
+
|
|
19
|
+
It measures **real code**: every output is fed through
|
|
20
|
+
``latticeai.core.agent.extract_action_details`` — the exact parser/repair
|
|
21
|
+
function the production loop uses (``agent.py`` calls it in plan/execute/verify).
|
|
22
|
+
An ``agent-loop`` reference row additionally runs the real
|
|
23
|
+
``latticeai.core.agent_eval.run_agent_eval`` state machine over its 20
|
|
24
|
+
scripted scenarios.
|
|
25
|
+
|
|
26
|
+
Two modes
|
|
27
|
+
=========
|
|
28
|
+
1. **scripted** (default, always runnable, no model/network): a curated corpus
|
|
29
|
+
of model-realistic outputs per quality tier (frontier / mid-local /
|
|
30
|
+
weak-local). Proves the harness works and exposes the loop's repair boundary
|
|
31
|
+
deterministically.
|
|
32
|
+
2. **live** (opt-in, ``--live-endpoint``): sends a fixed set of agent prompts to
|
|
33
|
+
an OpenAI-compatible local endpoint (e.g. LM Studio / llama.cpp / vLLM) and
|
|
34
|
+
runs the *real* completions through the same parser, measuring true
|
|
35
|
+
end-to-end generation latency. Falls back to scripted with an honest message
|
|
36
|
+
if the endpoint is unreachable.
|
|
37
|
+
|
|
38
|
+
Honesty note on latency
|
|
39
|
+
========================
|
|
40
|
+
In **scripted** mode ``latency_ms`` is the *parse+repair* cost only
|
|
41
|
+
(microseconds); it is NOT model inference time and must not be read as such.
|
|
42
|
+
Real generation latency is only meaningful in **live** mode.
|
|
43
|
+
|
|
44
|
+
Usage
|
|
45
|
+
=====
|
|
46
|
+
.venv/bin/python scripts/bench_models.py # scripted matrix
|
|
47
|
+
.venv/bin/python scripts/bench_models.py --json out.json # + machine output
|
|
48
|
+
.venv/bin/python scripts/bench_models.py \
|
|
49
|
+
--live-endpoint http://127.0.0.1:1234/v1 --model my-local-model
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import argparse
|
|
55
|
+
import json
|
|
56
|
+
import statistics
|
|
57
|
+
import sys
|
|
58
|
+
import time
|
|
59
|
+
import urllib.error
|
|
60
|
+
import urllib.request
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
from typing import Any, Dict, List, Tuple
|
|
63
|
+
|
|
64
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
65
|
+
|
|
66
|
+
from latticeai.core.agent import extract_action_details # noqa: E402
|
|
67
|
+
from latticeai.core.agent_eval import run_agent_eval # noqa: E402
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Scripted corpora ─────────────────────────────────────────────────────
|
|
71
|
+
# Each entry is one model output for a canonical agent turn. The tiers encode
|
|
72
|
+
# how the SAME intent degrades as model quality drops. All "should_parse=True"
|
|
73
|
+
# entries are recoverable by the real loop; "should_parse=False" entries are
|
|
74
|
+
# genuinely broken (past the repair boundary) and SHOULD fail — that is what
|
|
75
|
+
# makes success_rate < 1.0 meaningful rather than a rigged demo.
|
|
76
|
+
|
|
77
|
+
_CLEAN = [
|
|
78
|
+
('{"action": "plan", "goal": "ingest", "steps": [{"action": "write_file"}]}', True),
|
|
79
|
+
('{"action": "write_file", "args": {"path": "note.txt", "content": "hi"}}', True),
|
|
80
|
+
('{"action": "final", "message": "done"}', True),
|
|
81
|
+
('{"action": "verdict", "verdict": "PASS", "next_state": "DONE", "reason": "ok"}', True),
|
|
82
|
+
('{"action": "knowledge_graph_search", "args": {"query": "roadmap"}}', True),
|
|
83
|
+
('{"action": "read_file", "args": {"path": "README.md"}}', True),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
_MID = [
|
|
87
|
+
# markdown-fenced JSON (very common)
|
|
88
|
+
('```json\n{"action": "plan", "goal": "ingest", "steps": []}\n```', True),
|
|
89
|
+
('```\n{"action": "write_file", "args": {"path": "a.txt", "content": "x"}}\n```', True),
|
|
90
|
+
# prose preamble then the object
|
|
91
|
+
('Sure! Here is the next step:\n{"action": "final", "message": "done"}', True),
|
|
92
|
+
# trailing comma before closing brace
|
|
93
|
+
('{"action": "read_file", "args": {"path": "README.md",}}', True),
|
|
94
|
+
# trailing comma in array
|
|
95
|
+
('{"action": "plan", "goal": "g", "steps": [{"action": "read_file"},]}', True),
|
|
96
|
+
('```json\n{"action": "verdict", "verdict": "PASS", "next_state": "DONE", "reason": "ok"}\n```', True),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
_WEAK = [
|
|
100
|
+
# <think> reasoning block that itself contains braces, then the action
|
|
101
|
+
('<think>I should write the file {maybe}</think>\n{"action": "write_file", "args": {"path": "n.txt", "content": "c"}}', True),
|
|
102
|
+
('<reasoning>ok</reasoning> {"action": "final", "message": "done"}', True),
|
|
103
|
+
# Python dict literal (single quotes, True) — ast.literal_eval path
|
|
104
|
+
("{'action': 'read_file', 'args': {'path': 'x.txt'}}", True),
|
|
105
|
+
("{'action': 'verdict', 'verdict': 'PASS', 'next_state': 'DONE', 'reason': 'ok'}", True),
|
|
106
|
+
# fenced + trailing comma + prose all at once
|
|
107
|
+
('Here you go:\n```json\n{"action": "plan", "goal": "g", "steps": [],}\n```', True),
|
|
108
|
+
# genuinely broken: pure prose, no JSON object at all -> must fail
|
|
109
|
+
("I think we are done here, nothing else to do.", False),
|
|
110
|
+
# genuinely broken: object but missing the required "action" field
|
|
111
|
+
('{"message": "done", "status": "ok"}', False),
|
|
112
|
+
# genuinely broken: truncated / unbalanced braces past repair
|
|
113
|
+
('{"action": "write_file", "args": {"path": "n.txt"', False),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
_PROFILES: Dict[str, List[Tuple[str, bool]]] = {
|
|
117
|
+
"frontier (clean JSON)": _CLEAN,
|
|
118
|
+
"mid-local (fenced/prose/commas)": _MID,
|
|
119
|
+
"weak-local (think/py-literal/broken)": _WEAK,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _bench_corpus(corpus: List[Tuple[str, bool]]) -> Dict[str, Any]:
|
|
124
|
+
total = len(corpus)
|
|
125
|
+
parsed = 0
|
|
126
|
+
repaired = 0
|
|
127
|
+
latencies: List[float] = []
|
|
128
|
+
mismatches: List[str] = []
|
|
129
|
+
repair_kinds: Dict[str, int] = {}
|
|
130
|
+
for raw, should_parse in corpus:
|
|
131
|
+
t0 = time.perf_counter()
|
|
132
|
+
try:
|
|
133
|
+
_action, repairs = extract_action_details(raw)
|
|
134
|
+
ok = True
|
|
135
|
+
except (ValueError, Exception): # noqa: BLE001 - parser raises ValueError
|
|
136
|
+
ok = False
|
|
137
|
+
repairs = []
|
|
138
|
+
latencies.append((time.perf_counter() - t0) * 1000.0)
|
|
139
|
+
if ok:
|
|
140
|
+
parsed += 1
|
|
141
|
+
if repairs:
|
|
142
|
+
repaired += 1
|
|
143
|
+
for kind in repairs:
|
|
144
|
+
repair_kinds[kind] = repair_kinds.get(kind, 0) + 1
|
|
145
|
+
if ok != should_parse:
|
|
146
|
+
mismatches.append(raw[:60])
|
|
147
|
+
return {
|
|
148
|
+
"total": total,
|
|
149
|
+
"parsed": parsed,
|
|
150
|
+
"repaired": repaired,
|
|
151
|
+
"success_rate": round(parsed / total, 4) if total else 0.0,
|
|
152
|
+
"repair_rate": round(repaired / parsed, 4) if parsed else 0.0,
|
|
153
|
+
"latency_ms_mean": round(statistics.fmean(latencies), 4) if latencies else 0.0,
|
|
154
|
+
"repair_kinds": repair_kinds,
|
|
155
|
+
# A healthy harness has zero mismatches: every "should_parse" call
|
|
156
|
+
# matched reality. Non-empty means the corpus/parser disagree.
|
|
157
|
+
"harness_mismatches": mismatches,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── Live (OpenAI-compatible) path ────────────────────────────────────────
|
|
162
|
+
_LIVE_PROMPTS = [
|
|
163
|
+
"Return ONLY one JSON object for the next agent step: read the file README.md.",
|
|
164
|
+
"Return ONLY one JSON object: plan to ingest a document, then finish.",
|
|
165
|
+
"Return ONLY one JSON object: write 'hello' to notes.txt.",
|
|
166
|
+
"Return ONLY one JSON object: a PASS verdict moving the loop to DONE.",
|
|
167
|
+
"Return ONLY one JSON object: search the knowledge graph for 'roadmap'.",
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
_SYSTEM = (
|
|
171
|
+
"You are the executor of an agent loop. Every reply MUST be exactly one "
|
|
172
|
+
'JSON object with an "action" field and nothing else.'
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _openai_chat(base_url: str, model: str, prompt: str, *, timeout: float) -> Tuple[str, float]:
|
|
177
|
+
url = base_url.rstrip("/") + "/chat/completions"
|
|
178
|
+
body = json.dumps(
|
|
179
|
+
{
|
|
180
|
+
"model": model,
|
|
181
|
+
"messages": [
|
|
182
|
+
{"role": "system", "content": _SYSTEM},
|
|
183
|
+
{"role": "user", "content": prompt},
|
|
184
|
+
],
|
|
185
|
+
"temperature": 0.2,
|
|
186
|
+
"max_tokens": 256,
|
|
187
|
+
}
|
|
188
|
+
).encode("utf-8")
|
|
189
|
+
req = urllib.request.Request( # noqa: S310 - operator-supplied local endpoint
|
|
190
|
+
url, data=body, headers={"Content-Type": "application/json"}, method="POST"
|
|
191
|
+
)
|
|
192
|
+
t0 = time.perf_counter()
|
|
193
|
+
with urllib.request.urlopen(req, timeout=timeout) as res: # noqa: S310
|
|
194
|
+
payload = json.loads(res.read().decode("utf-8", errors="replace"))
|
|
195
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
196
|
+
text = payload["choices"][0]["message"]["content"]
|
|
197
|
+
return text, latency
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _bench_live(base_url: str, model: str, *, timeout: float) -> Dict[str, Any]:
|
|
201
|
+
total = 0
|
|
202
|
+
parsed = 0
|
|
203
|
+
repaired = 0
|
|
204
|
+
latencies: List[float] = []
|
|
205
|
+
for prompt in _LIVE_PROMPTS:
|
|
206
|
+
total += 1
|
|
207
|
+
try:
|
|
208
|
+
text, latency = _openai_chat(base_url, model, prompt, timeout=timeout)
|
|
209
|
+
except (urllib.error.URLError, OSError, KeyError, ValueError) as exc:
|
|
210
|
+
return {"error": f"live endpoint unreachable/invalid: {exc}"}
|
|
211
|
+
latencies.append(latency)
|
|
212
|
+
try:
|
|
213
|
+
_action, repairs = extract_action_details(text)
|
|
214
|
+
parsed += 1
|
|
215
|
+
if repairs:
|
|
216
|
+
repaired += 1
|
|
217
|
+
except Exception: # noqa: BLE001
|
|
218
|
+
pass
|
|
219
|
+
return {
|
|
220
|
+
"total": total,
|
|
221
|
+
"parsed": parsed,
|
|
222
|
+
"repaired": repaired,
|
|
223
|
+
"success_rate": round(parsed / total, 4) if total else 0.0,
|
|
224
|
+
"repair_rate": round(repaired / parsed, 4) if parsed else 0.0,
|
|
225
|
+
"latency_ms_mean": round(statistics.fmean(latencies), 2) if latencies else 0.0,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Reporting ────────────────────────────────────────────────────────────
|
|
230
|
+
def _fmt_row(name: str, r: Dict[str, Any]) -> str:
|
|
231
|
+
return (
|
|
232
|
+
f" {name:<40} "
|
|
233
|
+
f"success={r['success_rate']:<7} "
|
|
234
|
+
f"repair={r['repair_rate']:<7} "
|
|
235
|
+
f"n={r.get('total', '-'):<4} "
|
|
236
|
+
f"latency_ms={r['latency_ms_mean']}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main(argv: List[str] | None = None) -> int:
|
|
241
|
+
parser = argparse.ArgumentParser(description="Model robustness benchmark harness")
|
|
242
|
+
parser.add_argument("--json", dest="json_out", help="write full report JSON to this path")
|
|
243
|
+
parser.add_argument("--live-endpoint", help="OpenAI-compatible base URL, e.g. http://127.0.0.1:1234/v1")
|
|
244
|
+
parser.add_argument("--model", help="model id for the live endpoint")
|
|
245
|
+
parser.add_argument("--timeout", type=float, default=30.0, help="live request timeout seconds")
|
|
246
|
+
args = parser.parse_args(argv)
|
|
247
|
+
|
|
248
|
+
report: Dict[str, Any] = {"mode": "scripted", "profiles": {}}
|
|
249
|
+
|
|
250
|
+
# Scripted matrix (always).
|
|
251
|
+
for name, corpus in _PROFILES.items():
|
|
252
|
+
report["profiles"][name] = _bench_corpus(corpus)
|
|
253
|
+
|
|
254
|
+
# Real agent-loop reference row.
|
|
255
|
+
loop = run_agent_eval()
|
|
256
|
+
report["agent_loop_reference"] = {
|
|
257
|
+
"scenarios": loop["scenarios"],
|
|
258
|
+
"passed": loop["passed"],
|
|
259
|
+
"success_rate": loop["success_rate"],
|
|
260
|
+
"recovery_rate": loop["recovery_rate"],
|
|
261
|
+
"parse_errors": loop["parse_errors"],
|
|
262
|
+
"parse_recovered": loop["parse_recovered"],
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
# Optional live row.
|
|
266
|
+
if args.live_endpoint:
|
|
267
|
+
if not args.model:
|
|
268
|
+
print("--live-endpoint requires --model", file=sys.stderr)
|
|
269
|
+
return 2
|
|
270
|
+
live = _bench_live(args.live_endpoint, args.model, timeout=args.timeout)
|
|
271
|
+
report["mode"] = "scripted+live"
|
|
272
|
+
report["live"] = {"endpoint": args.live_endpoint, "model": args.model, **live}
|
|
273
|
+
|
|
274
|
+
# Human-readable matrix.
|
|
275
|
+
print("Model robustness benchmark (real parser: extract_action_details)")
|
|
276
|
+
print("=" * 72)
|
|
277
|
+
print("Scripted tiers (latency_ms = parse+repair only, NOT model inference):")
|
|
278
|
+
mismatch_total = 0
|
|
279
|
+
for name, r in report["profiles"].items():
|
|
280
|
+
print(_fmt_row(name, r))
|
|
281
|
+
if r["repair_kinds"]:
|
|
282
|
+
print(f" repairs used: {r['repair_kinds']}")
|
|
283
|
+
mismatch_total += len(r["harness_mismatches"])
|
|
284
|
+
ref = report["agent_loop_reference"]
|
|
285
|
+
print("-" * 72)
|
|
286
|
+
print(
|
|
287
|
+
f" agent-loop (real state machine) "
|
|
288
|
+
f"success={ref['success_rate']:<7} "
|
|
289
|
+
f"recovery={ref['recovery_rate']:<7} "
|
|
290
|
+
f"scenarios={ref['scenarios']}"
|
|
291
|
+
)
|
|
292
|
+
if "live" in report:
|
|
293
|
+
lv = report["live"]
|
|
294
|
+
print("-" * 72)
|
|
295
|
+
if "error" in lv:
|
|
296
|
+
print(f" live [{lv['model']}] SKIPPED: {lv['error']}")
|
|
297
|
+
else:
|
|
298
|
+
print(_fmt_row(f"live [{lv['model']}] (real inference)", lv))
|
|
299
|
+
print("=" * 72)
|
|
300
|
+
print(f"harness self-check: {mismatch_total} corpus/parser mismatches (0 = healthy)")
|
|
301
|
+
|
|
302
|
+
if args.json_out:
|
|
303
|
+
Path(args.json_out).write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
304
|
+
print(f"wrote {args.json_out}")
|
|
305
|
+
|
|
306
|
+
# Non-zero exit only if the harness itself is inconsistent (a real bug),
|
|
307
|
+
# never because a weak tier scored low — low scores are the finding.
|
|
308
|
+
return 1 if mismatch_total else 0
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
raise SystemExit(main())
|