ltcai 8.7.0 → 8.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 (103) hide show
  1. package/README.md +38 -26
  2. package/auto_setup.py +73 -8
  3. package/docs/CHANGELOG.md +63 -0
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +4 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +33 -14
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/TRUST_MODEL.md +5 -1
  11. package/docs/WHY_LATTICE.md +4 -3
  12. package/docs/architecture.md +4 -0
  13. package/docs/kg-schema.md +1 -1
  14. package/kg_schema.py +1 -1
  15. package/knowledge_graph.py +2 -2
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/conversations.py +156 -21
  18. package/lattice_brain/core.py +1 -1
  19. package/lattice_brain/graph/_kg_common.py +12 -34
  20. package/lattice_brain/graph/json_utils.py +25 -0
  21. package/lattice_brain/graph/retrieval.py +66 -27
  22. package/lattice_brain/graph/runtime.py +16 -0
  23. package/lattice_brain/ingestion.py +35 -20
  24. package/lattice_brain/runtime/agent_runtime.py +37 -3
  25. package/lattice_brain/runtime/multi_agent.py +1 -1
  26. package/latticeai/__init__.py +1 -1
  27. package/latticeai/api/chat.py +31 -14
  28. package/latticeai/api/mcp.py +3 -2
  29. package/latticeai/api/models.py +4 -1
  30. package/latticeai/api/permissions.py +69 -30
  31. package/latticeai/api/setup.py +17 -2
  32. package/latticeai/api/tools.py +104 -62
  33. package/latticeai/app_factory.py +93 -10
  34. package/latticeai/core/agent.py +25 -7
  35. package/latticeai/core/legacy_compatibility.py +36 -25
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/sessions.py +11 -3
  38. package/latticeai/core/tool_registry.py +15 -4
  39. package/latticeai/core/workspace_os.py +1 -1
  40. package/latticeai/runtime/bootstrap.py +1 -1
  41. package/latticeai/runtime/persistence_runtime.py +1 -1
  42. package/latticeai/runtime/platform_services_runtime.py +1 -1
  43. package/latticeai/services/app_context.py +1 -0
  44. package/latticeai/services/architecture_readiness.py +2 -2
  45. package/latticeai/services/memory_service.py +38 -7
  46. package/latticeai/services/model_engines.py +79 -12
  47. package/latticeai/services/model_runtime.py +24 -4
  48. package/latticeai/services/process_audit.py +208 -0
  49. package/latticeai/services/product_readiness.py +11 -11
  50. package/latticeai/services/search_service.py +106 -30
  51. package/latticeai/services/tool_dispatch.py +66 -0
  52. package/latticeai/services/workspace_service.py +15 -0
  53. package/package.json +1 -1
  54. package/scripts/check_i18n_literals.mjs +20 -8
  55. package/scripts/i18n_literal_allowlist.json +34 -0
  56. package/scripts/lint_frontend.mjs +6 -2
  57. package/setup_wizard.py +185 -19
  58. package/src-tauri/Cargo.lock +1 -1
  59. package/src-tauri/Cargo.toml +1 -1
  60. package/src-tauri/tauri.conf.json +1 -1
  61. package/static/app/asset-manifest.json +11 -11
  62. package/static/app/assets/{Act-_U7mhXir.js → Act-fZokUnC0.js} +1 -1
  63. package/static/app/assets/{Brain-BxyTHZ21.js → Brain-DtyuWubr.js} +2 -2
  64. package/static/app/assets/{Capture-DyDKWNh9.js → Capture-D5KV3Cu7.js} +1 -1
  65. package/static/app/assets/{Library-DJ8KioFM.js → Library-C9kyFkSt.js} +1 -1
  66. package/static/app/assets/{System-C0FIb3OO.js → System-VbChmX7r.js} +1 -1
  67. package/static/app/assets/index-DCh5AoXt.css +2 -0
  68. package/static/app/assets/index-DPdcPoF0.js +17 -0
  69. package/static/app/assets/{primitives-BywkNS3f.js → primitives-DFeanEV6.js} +1 -1
  70. package/static/app/assets/{textarea-CQ61Rycp.js → textarea-CD8UNKIy.js} +1 -1
  71. package/static/app/index.html +2 -2
  72. package/static/sw.js +1 -1
  73. package/lattice_brain/_kg_common.py +0 -11
  74. package/lattice_brain/discovery.py +0 -11
  75. package/lattice_brain/documents.py +0 -11
  76. package/lattice_brain/identity.py +0 -11
  77. package/lattice_brain/ingest.py +0 -11
  78. package/lattice_brain/network.py +0 -11
  79. package/lattice_brain/projection.py +0 -11
  80. package/lattice_brain/provenance.py +0 -11
  81. package/lattice_brain/retrieval.py +0 -11
  82. package/lattice_brain/schema.py +0 -11
  83. package/lattice_brain/store.py +0 -11
  84. package/lattice_brain/write_master.py +0 -11
  85. package/latticeai/brain/__init__.py +0 -36
  86. package/latticeai/brain/_kg_common.py +0 -17
  87. package/latticeai/brain/context.py +0 -17
  88. package/latticeai/brain/conversations.py +0 -17
  89. package/latticeai/brain/discovery.py +0 -17
  90. package/latticeai/brain/documents.py +0 -17
  91. package/latticeai/brain/identity.py +0 -17
  92. package/latticeai/brain/ingest.py +0 -17
  93. package/latticeai/brain/memory.py +0 -17
  94. package/latticeai/brain/network.py +0 -17
  95. package/latticeai/brain/projection.py +0 -17
  96. package/latticeai/brain/provenance.py +0 -17
  97. package/latticeai/brain/retrieval.py +0 -17
  98. package/latticeai/brain/schema.py +0 -17
  99. package/latticeai/brain/store.py +0 -17
  100. package/latticeai/brain/write_master.py +0 -17
  101. package/latticeai/services/agent_runtime.py +0 -11
  102. package/static/app/assets/index-Bh7IIlyY.js +0 -16
  103. package/static/app/assets/index-_M5aCv21.css +0 -2
@@ -0,0 +1,25 @@
1
+ """JSON helpers shared by graph storage, projection, and retrieval modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any, Dict, Optional
8
+
9
+
10
+ def _json(data: Optional[Dict[str, Any]]) -> str:
11
+ return json.dumps(data or {}, ensure_ascii=False, sort_keys=True)
12
+
13
+
14
+ def _safe_loads(raw: Optional[str]) -> Dict[str, Any]:
15
+ """Tolerantly parse a metadata_json column — returns {} on corrupt rows."""
16
+ if not raw:
17
+ return {}
18
+ try:
19
+ value = json.loads(raw)
20
+ return value if isinstance(value, dict) else {}
21
+ except (json.JSONDecodeError, TypeError) as e:
22
+ logging.warning(
23
+ "knowledge_graph: corrupt metadata_json (%s) — using empty dict", e
24
+ )
25
+ return {}
@@ -55,9 +55,18 @@ class KnowledgeGraphRetrievalMixin:
55
55
  extracted = meta.get("extracted") or {}
56
56
  node_id = row["id"]
57
57
  chunk_count = conn.execute(
58
- f"SELECT COUNT(*) AS c FROM {nt} WHERE type='Chunk' AND metadata_json LIKE ?",
59
- (f"%{node_id}%",),
58
+ "SELECT COUNT(*) AS c FROM chunks WHERE source_node=?",
59
+ (node_id,),
60
60
  ).fetchone()["c"]
61
+ if not chunk_count:
62
+ # Legacy projections represented chunks as graph nodes and
63
+ # linked them only through metadata_json. Keep read
64
+ # compatibility without making the fragile LIKE path the
65
+ # primary query.
66
+ chunk_count = conn.execute(
67
+ f"SELECT COUNT(*) AS c FROM {nt} WHERE type='Chunk' AND metadata_json LIKE ?",
68
+ (f"%{node_id}%",),
69
+ ).fetchone()["c"]
61
70
  documents.append(
62
71
  {
63
72
  "id": node_id,
@@ -255,7 +264,7 @@ class KnowledgeGraphRetrievalMixin:
255
264
  node.pop("_raw_importance", None)
256
265
  return {"nodes": nodes, "edges": edges}
257
266
 
258
- def search(self, query: str, limit: int = 30) -> Dict[str, Any]:
267
+ def search(self, query: str, limit: int = 30, *, allowed_workspaces=None) -> Dict[str, Any]:
259
268
  query = str(query or "").strip()
260
269
  q = f"%{query}%"
261
270
  limit = max(1, min(int(limit or 30), 100))
@@ -347,9 +356,7 @@ class KnowledgeGraphRetrievalMixin:
347
356
  # the legacy LIKE path regardless of FTS bm25 tie ordering.
348
357
  rows = sorted(rows, key=lambda r: r["id"])
349
358
  rows = sorted(rows, key=score, reverse=True)[:limit]
350
- return {
351
- "query": query,
352
- "matches": [
359
+ matches = [
353
360
  {
354
361
  "id": row["id"],
355
362
  "type": row["type"],
@@ -359,15 +366,17 @@ class KnowledgeGraphRetrievalMixin:
359
366
  "updated_at": row["updated_at"],
360
367
  }
361
368
  for row in rows
362
- ],
363
- }
369
+ ]
370
+ if allowed_workspaces is not None:
371
+ matches = self.filter_scoped_nodes(matches, allowed_workspaces)
372
+ return {"query": query, "matches": matches}
364
373
 
365
- def context_for_query(self, query: str, limit: int = 6) -> str:
374
+ def context_for_query(self, query: str, limit: int = 6, *, allowed_workspaces=None) -> str:
366
375
  """Return compact graph-backed RAG context for chat generation."""
367
376
  query = str(query or "").strip()
368
377
  if not query:
369
378
  return ""
370
- matches = self.search(query, limit).get("matches", [])
379
+ matches = self.search(query, limit, allowed_workspaces=allowed_workspaces).get("matches", [])
371
380
  if not matches:
372
381
  topics = _topic_candidates(query, limit=4)
373
382
  if topics:
@@ -404,6 +413,8 @@ class KnowledgeGraphRetrievalMixin:
404
413
  )
405
414
  if len(matches) >= limit:
406
415
  break
416
+ if allowed_workspaces is not None:
417
+ matches = self.filter_scoped_nodes(matches, allowed_workspaces)
407
418
  lines = []
408
419
  for match in matches[:limit]:
409
420
  meta = match.get("metadata") or {}
@@ -420,8 +431,10 @@ class KnowledgeGraphRetrievalMixin:
420
431
  )
421
432
  return "\n".join(lines)
422
433
 
423
- def neighbors(self, node_id: str) -> Dict[str, Any]:
434
+ def neighbors(self, node_id: str, *, allowed_workspaces=None) -> Dict[str, Any]:
424
435
  """Return direct neighbors (1-hop) of a node."""
436
+ if allowed_workspaces is not None and not self.filter_scoped_nodes([{"id": node_id}], allowed_workspaces):
437
+ raise ValueError(f"graph node not found: {node_id}")
425
438
  nt, et = self._read_tables()
426
439
  with self._connect() as conn:
427
440
  edge_rows = conn.execute(
@@ -458,9 +471,17 @@ class KnowledgeGraphRetrievalMixin:
458
471
  list(neighbor_ids),
459
472
  )
460
473
  ]
474
+ if allowed_workspaces is not None:
475
+ nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
476
+ kept = {node.get("id") for node in nodes}
477
+ edges = [
478
+ edge for edge in edges
479
+ if (edge.get("from") == node_id or edge.get("from") in kept)
480
+ and (edge.get("to") == node_id or edge.get("to") in kept)
481
+ ]
461
482
  return {"node_id": node_id, "neighbors": nodes, "edges": edges}
462
483
 
463
- def get_node(self, node_id: str) -> Dict[str, Any]:
484
+ def get_node(self, node_id: str, *, allowed_workspaces=None) -> Dict[str, Any]:
464
485
  node_id = str(node_id or "").strip()
465
486
  if not node_id:
466
487
  raise ValueError("node_id required")
@@ -480,7 +501,7 @@ class KnowledgeGraphRetrievalMixin:
480
501
  f"SELECT COUNT(*) AS c FROM {et} WHERE from_node=? OR to_node=?",
481
502
  (node_id, node_id),
482
503
  ).fetchone()["c"]
483
- return {
504
+ node = {
484
505
  "id": row["id"],
485
506
  "type": row["type"],
486
507
  "title": row["title"],
@@ -489,6 +510,9 @@ class KnowledgeGraphRetrievalMixin:
489
510
  "updated_at": row["updated_at"],
490
511
  "degree": degree,
491
512
  }
513
+ if allowed_workspaces is not None and not self.filter_scoped_nodes([node], allowed_workspaces):
514
+ raise ValueError(f"graph node not found: {node_id}")
515
+ return node
492
516
 
493
517
  def relationship_search(
494
518
  self,
@@ -497,6 +521,7 @@ class KnowledgeGraphRetrievalMixin:
497
521
  node_id: str = "",
498
522
  relationship_type: str = "",
499
523
  limit: int = 30,
524
+ allowed_workspaces=None,
500
525
  ) -> Dict[str, Any]:
501
526
  query = str(query or "").strip()
502
527
  node_id = str(node_id or "").strip()
@@ -535,11 +560,7 @@ class KnowledgeGraphRetrievalMixin:
535
560
  """,
536
561
  (*params, limit),
537
562
  ).fetchall()
538
- return {
539
- "query": query,
540
- "node_id": node_id,
541
- "relationship_type": relationship_type,
542
- "relationships": [
563
+ relationships = [
543
564
  {
544
565
  "id": row["id"],
545
566
  "type": row["type"],
@@ -562,15 +583,32 @@ class KnowledgeGraphRetrievalMixin:
562
583
  },
563
584
  }
564
585
  for row in rows
565
- ],
586
+ ]
587
+ if allowed_workspaces is not None:
588
+ kept = []
589
+ for rel in relationships:
590
+ endpoints = [
591
+ {"id": (rel.get("source") or {}).get("id")},
592
+ {"id": (rel.get("target") or {}).get("id")},
593
+ ]
594
+ if len(self.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
595
+ kept.append(rel)
596
+ relationships = kept
597
+ return {
598
+ "query": query,
599
+ "node_id": node_id,
600
+ "relationship_type": relationship_type,
601
+ "relationships": relationships,
566
602
  }
567
603
 
568
604
  def traverse(
569
- self, node_id: str, *, depth: int = 1, limit: int = 100
605
+ self, node_id: str, *, depth: int = 1, limit: int = 100, allowed_workspaces=None
570
606
  ) -> Dict[str, Any]:
571
607
  node_id = str(node_id or "").strip()
572
608
  if not node_id:
573
609
  raise ValueError("node_id required")
610
+ if allowed_workspaces is not None and not self.filter_scoped_nodes([{"id": node_id}], allowed_workspaces):
611
+ raise ValueError(f"graph node not found: {node_id}")
574
612
  depth = max(0, min(int(depth or 1), 4))
575
613
  limit = max(1, min(int(limit or 100), 500))
576
614
  nt, et = self._read_tables()
@@ -617,10 +655,7 @@ class KnowledgeGraphRetrievalMixin:
617
655
  """,
618
656
  list(visited),
619
657
  ).fetchall()
620
- return {
621
- "root": node_id,
622
- "depth": depth,
623
- "nodes": [
658
+ nodes = [
624
659
  {
625
660
  "id": row["id"],
626
661
  "type": row["type"],
@@ -630,9 +665,13 @@ class KnowledgeGraphRetrievalMixin:
630
665
  "updated_at": row["updated_at"],
631
666
  }
632
667
  for row in node_rows
633
- ],
634
- "edges": list(edges_by_id.values()),
635
- }
668
+ ]
669
+ edges = list(edges_by_id.values())
670
+ if allowed_workspaces is not None:
671
+ nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
672
+ kept = {node.get("id") for node in nodes}
673
+ edges = [edge for edge in edges if edge.get("from") in kept and edge.get("to") in kept]
674
+ return {"root": node_id, "depth": depth, "nodes": nodes, "edges": edges}
636
675
 
637
676
  def _iter_vector_source_items(
638
677
  self,
@@ -0,0 +1,16 @@
1
+ """Runtime hooks for the knowledge graph package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ _llm_router_ref: Any = None
8
+
9
+
10
+ def set_llm_router(router_instance: Any) -> None:
11
+ global _llm_router_ref
12
+ _llm_router_ref = router_instance
13
+
14
+
15
+ def get_llm_router() -> Any:
16
+ return _llm_router_ref
@@ -132,7 +132,7 @@ class IngestionPipeline:
132
132
  # ── public API ───────────────────────────────────────────────────────────
133
133
  def ingest(self, item: IngestionItem, *, user_email: Optional[str] = None) -> IngestionResult:
134
134
  """Normalize, hash, route through dispatch_tool, and record provenance."""
135
- source_type = str(item.source_type or "text").strip()
135
+ source_type = str(item.source_type or "text").strip().lower()
136
136
  if not self.available():
137
137
  return IngestionResult(
138
138
  status="unavailable", source_type=source_type,
@@ -187,25 +187,33 @@ class IngestionPipeline:
187
187
  embedded = bool(self._kg.node_is_embedded(node_id)) if node_id else False
188
188
  title = raw.get("title") or item.title
189
189
 
190
- prov = self._kg.record_provenance(
191
- node_id=node_id,
192
- source_type=source_type,
193
- pipeline=self._pipeline_name,
194
- source_uri=item.source_uri,
195
- content_hash=content_hash,
196
- title=title,
197
- owner=owner,
198
- workspace_id=item.workspace_id,
199
- captured_at=captured_at,
200
- modified_at=item.modified_at,
201
- embedded=embedded,
202
- linked=bool(raw.get("source_node_id")),
203
- duplicate=bool(raw.get("duplicate")),
204
- agent_used=item.agent_used,
205
- chunk_count=len(chunk_ids),
206
- permissions=item.permissions,
207
- metadata=item.metadata,
208
- )
190
+ # Provenance capture must never turn an already-persisted ingest into a
191
+ # caller-visible failure: the graph write above succeeded, so a broken
192
+ # provenance table degrades the result instead of raising.
193
+ provenance_detail: Optional[str] = None
194
+ try:
195
+ prov = self._kg.record_provenance(
196
+ node_id=node_id,
197
+ source_type=source_type,
198
+ pipeline=self._pipeline_name,
199
+ source_uri=item.source_uri,
200
+ content_hash=content_hash,
201
+ title=title,
202
+ owner=owner,
203
+ workspace_id=item.workspace_id,
204
+ captured_at=captured_at,
205
+ modified_at=item.modified_at,
206
+ embedded=embedded,
207
+ linked=bool(raw.get("source_node_id")),
208
+ duplicate=bool(raw.get("duplicate")),
209
+ agent_used=item.agent_used,
210
+ chunk_count=len(chunk_ids),
211
+ permissions=item.permissions,
212
+ metadata=item.metadata,
213
+ )
214
+ except Exception as exc: # noqa: BLE001 — the ingest itself already landed
215
+ prov = {}
216
+ provenance_detail = f"provenance capture failed: {exc}"
209
217
  if self._audit is not None:
210
218
  try:
211
219
  self._audit(
@@ -232,11 +240,16 @@ class IngestionPipeline:
232
240
  embedded=embedded,
233
241
  indexing_status="indexed",
234
242
  provenance_id=prov.get("id"),
243
+ detail=provenance_detail,
235
244
  )
236
245
 
237
246
  # ── routing helpers ──────────────────────────────────────────────────────
238
247
  def _ingest_text(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
239
248
  text = item.text or ""
249
+ if not text.strip():
250
+ raise ValueError(
251
+ f"Empty content: {source_type} ingestion requires non-empty text."
252
+ )
240
253
  if len(text.encode("utf-8", "ignore")) > self._max_text_bytes:
241
254
  raise ValueError(
242
255
  f"Text payload exceeds the {self._max_text_bytes // (1024 * 1024)}MB ingestion limit."
@@ -297,6 +310,8 @@ class IngestionPipeline:
297
310
  path = Path(item.path)
298
311
  if not path.exists():
299
312
  raise FileNotFoundError(f"File not found: {path}")
313
+ if path.is_dir():
314
+ raise ValueError(f"File ingestion requires a file, got a directory: {path}")
300
315
  return self._kg.ingest_document(
301
316
  path,
302
317
  original_filename=item.title or path.name,
@@ -42,6 +42,7 @@ from .contracts import (
42
42
  contract_views,
43
43
  extract_contract,
44
44
  multi_agent_contract,
45
+ run_record_contract,
45
46
  runtime_boundary_contract,
46
47
  )
47
48
 
@@ -308,7 +309,7 @@ class AgentRuntime:
308
309
  def get_run(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
309
310
  run = self._store.get_agent_run(run_id, workspace_id=scope)
310
311
  payload = {"run": run}
311
- contract = extract_contract(run)
312
+ contract = self._ensure_contract(run)
312
313
  if contract is not None:
313
314
  payload["contract"] = contract_view(contract)
314
315
  return payload
@@ -324,7 +325,7 @@ class AgentRuntime:
324
325
  def events(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
325
326
  run = self._store.get_agent_run(run_id, workspace_id=scope)
326
327
  status = str(run.get("status") or "")
327
- contract = extract_contract(run)
328
+ contract = self._ensure_contract(run)
328
329
  return {
329
330
  "run_id": run_id,
330
331
  "status": status,
@@ -364,6 +365,35 @@ class AgentRuntime:
364
365
  def _clamp_retries(self, max_retries: int) -> int:
365
366
  return max(0, min(int(max_retries or 0), self._max_retries_cap))
366
367
 
368
+ def _validate_roles(self, roles: Optional[List[str]]) -> Optional[List[str]]:
369
+ """Reject unknown roles at the boundary instead of deep in orchestration.
370
+
371
+ ``preview`` reports unknown roles as a blocking reason; execution paths
372
+ must enforce the same contract so a run can never be recorded with a
373
+ role the runtime does not own.
374
+ """
375
+ if not roles:
376
+ return None
377
+ unknown = [role for role in roles if role not in AGENT_ROLES]
378
+ if unknown:
379
+ raise ValueError(f"unknown roles: {', '.join(unknown)}")
380
+ return list(roles)
381
+
382
+ @staticmethod
383
+ def _ensure_contract(record: Any) -> Optional[Dict[str, Any]]:
384
+ """Return the record's family contract, synthesizing one for legacy rows.
385
+
386
+ Every read surface (get_run/events/replay) must expose the
387
+ ``agent-run-contract/v1`` envelope even for runs persisted before the
388
+ contract family existed, so consumers never need a legacy branch.
389
+ """
390
+ contract = extract_contract(record)
391
+ if contract is not None:
392
+ return contract
393
+ if isinstance(record, dict) and record.get("id"):
394
+ return run_record_contract(record)
395
+ return None
396
+
367
397
  @staticmethod
368
398
  def _result_patch(result: Any, goal: str) -> Dict[str, Any]:
369
399
  return {
@@ -420,6 +450,7 @@ class AgentRuntime:
420
450
  """Create the durable queued row used by the async executor."""
421
451
  if not str(goal or "").strip():
422
452
  raise ValueError("goal is required")
453
+ roles = self._validate_roles(roles)
423
454
  pre_dispatch = self._fire_pre_run(
424
455
  goal=goal,
425
456
  roles=roles,
@@ -454,7 +485,9 @@ class AgentRuntime:
454
485
  execution_mode="async",
455
486
  requested_roles=roles or None,
456
487
  inputs=inputs or {},
457
- max_retries=max_retries,
488
+ # Persist the clamped budget so the durable row reflects what the
489
+ # executor will actually honor, not the raw client request.
490
+ max_retries=self._clamp_retries(max_retries),
458
491
  )
459
492
  payload: Dict[str, Any] = {"run": run}
460
493
  if pre_dispatch is not None:
@@ -574,6 +607,7 @@ class AgentRuntime:
574
607
  ) -> Dict[str, Any]:
575
608
  if not str(goal or "").strip():
576
609
  raise ValueError("goal is required")
610
+ roles = self._validate_roles(roles)
577
611
 
578
612
  pre_dispatch = self._fire_pre_run(
579
613
  goal=goal,
@@ -21,7 +21,7 @@ from typing import Any, Callable, Dict, List, Optional
21
21
  from .contracts import multi_agent_contract
22
22
 
23
23
 
24
- MULTI_AGENT_VERSION = "8.7.0"
24
+ MULTI_AGENT_VERSION = "8.9.0"
25
25
 
26
26
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
27
27
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "8.7.0"
3
+ __version__ = "8.9.0"
@@ -286,6 +286,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
286
286
  group_history_conversations = context.group_history_conversations
287
287
  get_conversation_messages = context.get_conversation_messages
288
288
  conversation_title = context.conversation_title
289
+ allowed_workspaces_for = context.allowed_workspaces_for
289
290
 
290
291
  CONFIG = context.config
291
292
  CHAT_SERVICE = context.chat_service
@@ -314,6 +315,18 @@ def create_chat_router(context: AppContext) -> APIRouter:
314
315
  except Exception as exc:
315
316
  logging.warning("chat message bridge failed: %s", exc)
316
317
 
318
+ def history_scope_for_user(user_email: Optional[str]) -> Dict:
319
+ require_auth = bool(getattr(CONFIG, "require_auth", False))
320
+ scoped_user = user_email if require_auth else None
321
+ allowed = None
322
+ if require_auth and scoped_user and allowed_workspaces_for is not None:
323
+ allowed = allowed_workspaces_for(scoped_user)
324
+ return {
325
+ "user_email": scoped_user,
326
+ "allowed_workspaces": allowed,
327
+ "include_legacy_global": not require_auth,
328
+ }
329
+
317
330
  def recent_chat_context(
318
331
  limit: int = 10,
319
332
  include_image_missing_replies: bool = True,
@@ -449,14 +462,14 @@ def create_chat_router(context: AppContext) -> APIRouter:
449
462
  except Exception as e:
450
463
  logging.warning("knowledge graph clear event ingest failed: %s", e)
451
464
  if command == "/clear_all":
452
- result = clear_history(0)
465
+ result = clear_history(0, **history_scope_for_user(effective_email))
453
466
  answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
454
467
  else:
455
468
  if req.conversation_id:
456
- result = clear_conversation(req.conversation_id)
469
+ result = clear_conversation(req.conversation_id, **history_scope_for_user(effective_email))
457
470
  answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
458
471
  else:
459
- result = clear_history(0)
472
+ result = clear_history(0, **history_scope_for_user(effective_email))
460
473
  answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
461
474
  append_audit_event(
462
475
  "clear_command",
@@ -791,20 +804,20 @@ def create_chat_router(context: AppContext) -> APIRouter:
791
804
  @api_router.get("/history")
792
805
  async def fetch_history(request: Request):
793
806
  """웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
794
- require_user(request)
795
- return get_history()
807
+ current_user = require_user(request)
808
+ return get_history(**history_scope_for_user(current_user))
796
809
 
797
810
  @api_router.get("/history/conversations")
798
811
  async def fetch_history_conversations(request: Request):
799
812
  """저장된 히스토리를 대화 단위로 묶어 반환합니다."""
800
- require_user(request)
801
- return group_history_conversations()
813
+ current_user = require_user(request)
814
+ return group_history_conversations(get_history(**history_scope_for_user(current_user)))
802
815
 
803
816
  @api_router.get("/history/conversations/{conversation_id:path}")
804
817
  async def fetch_history_conversation(conversation_id: str, request: Request):
805
818
  """선택한 대화의 메시지를 반환합니다."""
806
- require_user(request)
807
- messages = get_conversation_messages(conversation_id)
819
+ current_user = require_user(request)
820
+ messages = get_conversation_messages(conversation_id, **history_scope_for_user(current_user))
808
821
  if not messages:
809
822
  raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
810
823
  return {"id": conversation_id, "messages": messages}
@@ -814,7 +827,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
814
827
  async def delete_history_conversation(conversation_id: str, request: Request):
815
828
  """선택한 대화방의 메시지만 삭제합니다."""
816
829
  email = require_user(request)
817
- result = clear_conversation(conversation_id, request.query_params.get("started_at"))
830
+ result = clear_conversation(
831
+ conversation_id,
832
+ request.query_params.get("started_at"),
833
+ **history_scope_for_user(email),
834
+ )
818
835
  append_audit_event(
819
836
  "conversation_delete",
820
837
  user_email=email,
@@ -829,7 +846,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
829
846
  @api_router.delete("/history")
830
847
  async def delete_history(request: Request, keep_last: int = 0):
831
848
  email = require_user(request)
832
- result = clear_history(keep_last)
849
+ result = clear_history(keep_last, **history_scope_for_user(email))
833
850
  append_audit_event(
834
851
  "history_delete",
835
852
  user_email=email,
@@ -842,11 +859,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
842
859
  @api_router.get("/history/search")
843
860
  async def search_history(q: str, request: Request):
844
861
  """키워드로 채팅 히스토리를 검색합니다."""
845
- require_user(request)
862
+ current_user = require_user(request)
846
863
  if not q or not q.strip():
847
864
  return {"results": [], "query": q}
848
865
  q_lower = q.strip().lower()
849
- history = get_history()
866
+ history = get_history(**history_scope_for_user(current_user))
850
867
  matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
851
868
  grouped: Dict[str, Dict] = {}
852
869
  for item in matches:
@@ -991,7 +1008,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
991
1008
  }
992
1009
 
993
1010
  # Auto-approve and run to completion (default behaviour)
994
- _AGENT_RUNTIME.approve(ctx, current_user)
1011
+ _AGENT_RUNTIME.approve(ctx, current_user, approved_by_human=True)
995
1012
  return await _agent_finish(ctx, req, lang_hint, current_user, max_steps, max_retry)
996
1013
 
997
1014
 
@@ -30,6 +30,7 @@ from latticeai.core.mcp_registry import (
30
30
  SKILLS_DIR,
31
31
  )
32
32
  from latticeai.core.tool_registry import MCP_TOOL_DESCRIPTIONS
33
+ from latticeai.services.tool_dispatch import enforce_tool_policy
33
34
  from tools import AGENT_ROOT, execute_tool
34
35
 
35
36
 
@@ -392,7 +393,7 @@ def create_mcp_router(
392
393
  args.get("limit", 6),
393
394
  )
394
395
  }
395
- _check_tool_role(req.action, current_user)
396
- return _tool_response(execute_tool, req.action, req.args or {})
396
+ enforce_tool_policy(req.action, req.args or {}, current_user=current_user, source="mcp")
397
+ return _tool_response(execute_tool, req.action, req.args or {}, source="mcp")
397
398
 
398
399
  return router
@@ -67,6 +67,7 @@ class LoadModelRequest(BaseModel):
67
67
 
68
68
  class InstallEngineRequest(BaseModel):
69
69
  engine: str
70
+ confirmation_token: Optional[str] = None
70
71
 
71
72
 
72
73
  class SetApiKeyRequest(BaseModel):
@@ -99,7 +100,7 @@ def create_models_router(
99
100
  get_current_user: Callable[[Request], Optional[str]],
100
101
  load_users: Callable[[], Dict],
101
102
  get_user_role: Callable[..., str],
102
- install_engine: Callable[[str], Dict],
103
+ install_engine: Callable[..., Dict],
103
104
  verify_cloud_models: Callable[..., Any],
104
105
  normalize_local_model_request: Callable[..., str],
105
106
  download_hf_model: Callable[..., Dict],
@@ -281,6 +282,8 @@ def create_models_router(
281
282
  @router.post("/engines/install")
282
283
  async def engines_install(req: InstallEngineRequest, request: Request):
283
284
  require_user(request)
285
+ if req.confirmation_token:
286
+ return install_engine(req.engine, confirmation_token=req.confirmation_token)
284
287
  return install_engine(req.engine)
285
288
 
286
289
  @router.post("/engines/verify-cloud")