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.
Files changed (85) hide show
  1. package/README.md +114 -320
  2. package/docs/BENCHMARKS.md +107 -0
  3. package/docs/CHANGELOG.md +72 -0
  4. package/docs/CI_AND_RELEASE_GATES.md +106 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  6. package/docs/DEVELOPMENT.md +11 -8
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +15 -6
  9. package/docs/PERFORMANCE.md +9 -1
  10. package/docs/SECURITY_AUDIT.md +91 -0
  11. package/docs/TRUST_MODEL.md +1 -1
  12. package/docs/USABILITY_AUDIT.md +164 -0
  13. package/docs/WHY_LATTICE.md +1 -1
  14. package/docs/architecture.md +4 -2
  15. package/docs/kg-schema.md +1 -1
  16. package/docs/spec-vs-impl.md +8 -1
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/graph/retrieval.py +93 -4
  19. package/lattice_brain/graph/retrieval_vector.py +43 -0
  20. package/lattice_brain/ingestion.py +399 -14
  21. package/lattice_brain/runtime/multi_agent.py +1 -1
  22. package/latticeai/__init__.py +1 -1
  23. package/latticeai/api/brain_intelligence.py +12 -0
  24. package/latticeai/api/chat.py +17 -0
  25. package/latticeai/api/chat_helpers.py +48 -0
  26. package/latticeai/api/chat_stream.py +9 -1
  27. package/latticeai/api/local_files.py +120 -2
  28. package/latticeai/api/review_queue.py +7 -0
  29. package/latticeai/core/agent.py +105 -8
  30. package/latticeai/core/agent_eval.py +222 -1
  31. package/latticeai/core/legacy_compatibility.py +1 -1
  32. package/latticeai/core/marketplace.py +1 -1
  33. package/latticeai/core/tool_governor.py +92 -1
  34. package/latticeai/core/workspace_os.py +1 -1
  35. package/latticeai/services/architecture_readiness.py +1 -1
  36. package/latticeai/services/automation_intelligence.py +151 -14
  37. package/latticeai/services/brain_intelligence.py +72 -0
  38. package/latticeai/services/change_proposals.py +165 -23
  39. package/latticeai/services/product_readiness.py +1 -1
  40. package/latticeai/services/tool_dispatch.py +33 -0
  41. package/package.json +5 -3
  42. package/scripts/bench_models.py +312 -0
  43. package/scripts/check_bundle_budget.mjs +103 -0
  44. package/scripts/check_current_release_docs.mjs +1 -1
  45. package/scripts/check_doc_status.mjs +149 -0
  46. package/scripts/generate_sbom.py +70 -0
  47. package/src-tauri/Cargo.lock +1 -1
  48. package/src-tauri/Cargo.toml +1 -1
  49. package/src-tauri/tauri.conf.json +1 -1
  50. package/static/app/asset-manifest.json +27 -11
  51. package/static/app/assets/Act-CfOPUKPs.js +2 -0
  52. package/static/app/assets/Brain-Cgkh0Hpn.js +321 -0
  53. package/static/app/assets/BrainHome-D8e3wQiW.js +3 -0
  54. package/static/app/assets/BrainSignals-BjRBA18L.js +1 -0
  55. package/static/app/assets/Capture-CQQYQ1Ga.js +1 -0
  56. package/static/app/assets/CommandPalette-pss56Mg4.js +1 -0
  57. package/static/app/assets/Library-CoI3xYJk.js +1 -0
  58. package/static/app/assets/LivingBrain-CaU_hCxQ.js +1 -0
  59. package/static/app/assets/ProductFlow-Db-1O71-.js +1 -0
  60. package/static/app/assets/System-Dfocn0zN.js +1 -0
  61. package/static/app/assets/bot-Bp2x1i6F.js +1 -0
  62. package/static/app/assets/circle-check-CyGal42W.js +1 -0
  63. package/static/app/assets/cpu-DQFk82hG.js +1 -0
  64. package/static/app/assets/download-C70gKQVi.js +1 -0
  65. package/static/app/assets/folder-open-DNGytsVw.js +1 -0
  66. package/static/app/assets/hard-drive-CYo_bEl4.js +1 -0
  67. package/static/app/assets/i18n-BBMJshCW.js +7 -0
  68. package/static/app/assets/index-BjaXCeOZ.js +10 -0
  69. package/static/app/assets/{index-85wQvEie.css → index-BqRcLZR3.css} +1 -1
  70. package/static/app/assets/input-ChWD-Fsh.js +1 -0
  71. package/static/app/assets/navigation-D9D9_FPC.js +1 -0
  72. package/static/app/assets/network-DzLDGmtV.js +1 -0
  73. package/static/app/assets/primitives-CmaSAyGG.js +1 -0
  74. package/static/app/assets/sparkles-DXiaM1NS.js +1 -0
  75. package/static/app/assets/textarea-CV43-o79.js +1 -0
  76. package/static/app/index.html +4 -2
  77. package/static/sw.js +1 -1
  78. package/static/app/assets/Act-B6c39ays.js +0 -2
  79. package/static/app/assets/Brain-D7Qg4k6M.js +0 -321
  80. package/static/app/assets/Capture-VF_di68r.js +0 -1
  81. package/static/app/assets/Library-D_Gis2PA.js +0 -1
  82. package/static/app/assets/System-C5s5H2ov.js +0 -1
  83. package/static/app/assets/index-DJC_2oub.js +0 -18
  84. package/static/app/assets/primitives-DL4Nip8C.js +0 -1
  85. 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._read_before(path)
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._read_before(path)
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
- item = self._review_queue.get(item_id, workspace_id=workspace_id)
278
- if item.get("source") != "change_proposal":
279
- raise KeyError(f"not a change proposal: {item_id}")
280
- payload = item.get("payload") or {}
281
- kind = item.get("kind")
282
- path = str(payload.get("path") or "")
283
- target = self._resolve_path(path)
284
- if kind == "file_update":
285
- target.parent.mkdir(parents=True, exist_ok=True)
286
- target.write_text(str(payload.get("new_content") or ""), encoding="utf-8")
287
- elif kind == "file_delete":
288
- if target.is_file():
289
- target.unlink()
290
- else:
291
- raise ValueError(f"unknown change proposal kind: {kind}")
292
- approved = self._review_queue.approve(item_id, workspace_id=workspace_id)
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"]
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "9.7.0"
21
+ PRODUCT_VERSION_TARGET = "9.9.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -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.7.0",
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
- "docs:check-links": "node scripts/check_markdown_links.mjs",
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())