ltcai 3.5.0 → 4.0.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 (181) hide show
  1. package/README.md +73 -35
  2. package/docs/CARRYOVER_AUDIT_v3.6.0.md +61 -0
  3. package/docs/CHANGELOG.md +32 -0
  4. package/docs/HANDOVER_v3.6.0.md +46 -0
  5. package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +49 -0
  6. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
  8. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  9. package/docs/architecture.md +13 -12
  10. package/docs/kg-schema.md +102 -53
  11. package/docs/privacy.md +18 -2
  12. package/docs/security-model.md +17 -0
  13. package/kg_schema.py +139 -10
  14. package/knowledge_graph.py +874 -26
  15. package/knowledge_graph_api.py +11 -127
  16. package/latticeai/__init__.py +1 -1
  17. package/latticeai/api/admin.py +1 -1
  18. package/latticeai/api/agents.py +7 -1
  19. package/latticeai/api/auth.py +27 -4
  20. package/latticeai/api/browser.py +217 -0
  21. package/latticeai/api/chat.py +112 -76
  22. package/latticeai/api/health.py +1 -1
  23. package/latticeai/api/hooks.py +1 -1
  24. package/latticeai/api/knowledge_graph.py +146 -0
  25. package/latticeai/api/local_files.py +1 -1
  26. package/latticeai/api/mcp.py +23 -11
  27. package/latticeai/api/memory.py +1 -1
  28. package/latticeai/api/models.py +1 -1
  29. package/latticeai/api/network.py +81 -0
  30. package/latticeai/api/portability.py +93 -0
  31. package/latticeai/api/realtime.py +1 -1
  32. package/latticeai/api/search.py +26 -2
  33. package/latticeai/api/security_dashboard.py +2 -3
  34. package/latticeai/api/setup.py +2 -2
  35. package/latticeai/api/static_routes.py +2 -4
  36. package/latticeai/api/tools.py +3 -0
  37. package/latticeai/api/workflow_designer.py +46 -0
  38. package/latticeai/api/workspace.py +71 -49
  39. package/latticeai/app_factory.py +1710 -0
  40. package/latticeai/brain/__init__.py +18 -0
  41. package/latticeai/brain/context.py +213 -0
  42. package/latticeai/brain/conversations.py +236 -0
  43. package/latticeai/brain/identity.py +175 -0
  44. package/latticeai/brain/memory.py +102 -0
  45. package/latticeai/brain/network.py +205 -0
  46. package/latticeai/core/agent.py +31 -7
  47. package/latticeai/core/audit.py +0 -7
  48. package/latticeai/core/config.py +1 -1
  49. package/latticeai/core/context_builder.py +1 -2
  50. package/latticeai/core/enterprise.py +1 -1
  51. package/latticeai/core/graph_curator.py +2 -2
  52. package/latticeai/core/marketplace.py +1 -1
  53. package/latticeai/core/mcp_registry.py +791 -0
  54. package/latticeai/core/model_compat.py +1 -1
  55. package/latticeai/core/model_resolution.py +0 -1
  56. package/latticeai/core/multi_agent.py +238 -4
  57. package/latticeai/core/security.py +1 -1
  58. package/latticeai/core/sessions.py +37 -7
  59. package/latticeai/core/workflow_engine.py +114 -2
  60. package/latticeai/core/workspace_os.py +58 -10
  61. package/latticeai/models/__init__.py +7 -0
  62. package/latticeai/models/router.py +779 -0
  63. package/latticeai/server_app.py +29 -1504
  64. package/latticeai/services/agent_runtime.py +1 -0
  65. package/latticeai/services/app_context.py +75 -14
  66. package/latticeai/services/ingestion.py +318 -0
  67. package/latticeai/services/kg_portability.py +207 -0
  68. package/latticeai/services/memory_service.py +39 -11
  69. package/latticeai/services/model_runtime.py +2 -5
  70. package/latticeai/services/platform_runtime.py +100 -23
  71. package/latticeai/services/search_service.py +17 -8
  72. package/latticeai/services/tool_dispatch.py +12 -2
  73. package/latticeai/services/triggers.py +241 -0
  74. package/latticeai/services/upload_service.py +37 -12
  75. package/latticeai/services/workspace_service.py +31 -0
  76. package/llm_router.py +29 -772
  77. package/ltcai_cli.py +1 -2
  78. package/mcp_registry.py +25 -788
  79. package/p_reinforce.py +124 -14
  80. package/package.json +11 -8
  81. package/scripts/build_vsix.mjs +72 -0
  82. package/scripts/bump_version.py +99 -0
  83. package/scripts/generate_diagrams.py +0 -1
  84. package/scripts/lint_v3.mjs +82 -18
  85. package/scripts/validate_release_artifacts.py +0 -1
  86. package/scripts/wheel_smoke.py +142 -0
  87. package/server.py +11 -7
  88. package/setup_wizard.py +1142 -0
  89. package/static/account.html +2 -4
  90. package/static/admin.html +3 -5
  91. package/static/chat.html +3 -6
  92. package/static/graph.html +2 -4
  93. package/static/sw.js +81 -52
  94. package/static/v3/asset-manifest.json +20 -19
  95. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  96. package/static/v3/css/lattice.base.css +1 -1
  97. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  98. package/static/v3/css/lattice.components.css +1 -1
  99. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  100. package/static/v3/css/lattice.shell.css +1 -1
  101. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  102. package/static/v3/css/lattice.tokens.css +3 -0
  103. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  104. package/static/v3/css/lattice.views.css +2 -2
  105. package/static/v3/index.html +3 -4
  106. package/static/v3/js/{app.d086489d.js → app.356e6452.js} +1 -1
  107. package/static/v3/js/core/{api.12b568ad.js → api.7a308b89.js} +39 -1
  108. package/static/v3/js/core/api.js +38 -0
  109. package/static/v3/js/core/{routes.d214b399.js → routes.7222343d.js} +22 -22
  110. package/static/v3/js/core/routes.js +22 -22
  111. package/static/v3/js/core/{shell.d05266f5.js → shell.a1657f20.js} +4 -4
  112. package/static/v3/js/core/shell.js +1 -1
  113. package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
  114. package/static/v3/js/core/store.js +1 -1
  115. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  116. package/static/v3/js/views/graph-canvas.js +509 -0
  117. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  118. package/static/v3/js/views/hybrid-search.js +1 -2
  119. package/static/v3/js/views/knowledge-graph.5e40cbeb.js +509 -0
  120. package/static/v3/js/views/knowledge-graph.js +326 -54
  121. package/static/vendor/chart.umd.min.js +20 -0
  122. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  123. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  124. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  125. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  126. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  127. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  128. package/static/vendor/fonts/inter.css +44 -0
  129. package/static/vendor/icons/tabler-icons.min.css +4 -0
  130. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  131. package/static/vendor/marked.min.js +69 -0
  132. package/static/workspace.html +2 -2
  133. package/telegram_bot.py +1 -2
  134. package/tools/commands.py +4 -2
  135. package/tools/computer.py +1 -1
  136. package/tools/documents.py +1 -3
  137. package/tools/filesystem.py +0 -4
  138. package/tools/knowledge.py +1 -3
  139. package/tools/network.py +1 -3
  140. package/codex_telegram_bot.py +0 -195
  141. package/docs/assets/v3.4.0/agent-run.png +0 -0
  142. package/docs/assets/v3.4.0/agents.png +0 -0
  143. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  144. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  145. package/docs/assets/v3.4.0/chat.png +0 -0
  146. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  147. package/docs/assets/v3.4.0/files.png +0 -0
  148. package/docs/assets/v3.4.0/home.png +0 -0
  149. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  150. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  151. package/docs/assets/v3.4.0/local-agent.png +0 -0
  152. package/docs/assets/v3.4.0/memory.png +0 -0
  153. package/docs/assets/v3.4.0/settings.png +0 -0
  154. package/docs/assets/v3.4.0/vision-input.png +0 -0
  155. package/docs/assets/v3.4.0/workflows.png +0 -0
  156. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  157. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  158. package/docs/assets/v3.4.1/local-agent.png +0 -0
  159. package/docs/images/admin-dashboard.png +0 -0
  160. package/docs/images/architecture.png +0 -0
  161. package/docs/images/enterprise.png +0 -0
  162. package/docs/images/graph.png +0 -0
  163. package/docs/images/hero.gif +0 -0
  164. package/docs/images/knowledge-graph.png +0 -0
  165. package/docs/images/lattice-ai-demo.gif +0 -0
  166. package/docs/images/lattice-ai-hero.png +0 -0
  167. package/docs/images/logo.svg +0 -33
  168. package/docs/images/mobile-responsive.png +0 -0
  169. package/docs/images/model-recommendation.png +0 -0
  170. package/docs/images/onboarding.png +0 -0
  171. package/docs/images/organization.png +0 -0
  172. package/docs/images/pipeline.png +0 -0
  173. package/docs/images/screenshot-admin.png +0 -0
  174. package/docs/images/screenshot-chat.png +0 -0
  175. package/docs/images/screenshot-graph.png +0 -0
  176. package/docs/images/skills.png +0 -0
  177. package/docs/images/workspace-dark.png +0 -0
  178. package/docs/images/workspace-light.png +0 -0
  179. package/docs/images/workspace.png +0 -0
  180. package/requirements.txt +0 -16
  181. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +0 -237
@@ -24,7 +24,7 @@ from __future__ import annotations
24
24
  import json
25
25
  from datetime import datetime
26
26
  from pathlib import Path
27
- from typing import Any, Callable, Dict, List, Optional
27
+ from typing import Any, Dict, List, Optional
28
28
 
29
29
  # Personal workspace memory kinds (from WorkspaceOS.MEMORY_KINDS).
30
30
  WORKSPACE_KINDS = (
@@ -60,12 +60,16 @@ class MemoryService:
60
60
  knowledge_graph: Any = None,
61
61
  enable_graph: bool = True,
62
62
  history_file: Optional[Path] = None,
63
+ conversation_store: Any = None,
63
64
  ):
64
65
  self._store = store
65
66
  self._kg = knowledge_graph
66
67
  self._enable_graph = bool(enable_graph and knowledge_graph is not None)
67
68
  self._data_dir = Path(data_dir)
68
69
  self._history_file = Path(history_file) if history_file else (self._data_dir / "chat_history.json")
70
+ # v4: the durable SQLite conversation store supersedes the JSON file
71
+ # as the conversation tier's backing store when provided.
72
+ self._conversation_store = conversation_store
69
73
 
70
74
  # ── helpers over the underlying stores ────────────────────────────────
71
75
  def _workspace_memories(self, *, user_email: Optional[str], workspace_id: Optional[str]) -> List[Dict[str, Any]]:
@@ -87,6 +91,14 @@ class MemoryService:
87
91
  return []
88
92
 
89
93
  def _conversations(self) -> List[Dict[str, Any]]:
94
+ if self._conversation_store is not None:
95
+ try:
96
+ grouped: Dict[str, List[Dict[str, Any]]] = {}
97
+ for item in self._conversation_store.history():
98
+ grouped.setdefault(item.get("conversation_id") or "legacy-previous-history", []).append(item)
99
+ return [{"id": conv_id, "messages": msgs} for conv_id, msgs in grouped.items()]
100
+ except Exception:
101
+ return []
90
102
  if not self._history_file.exists():
91
103
  return []
92
104
  try:
@@ -130,7 +142,10 @@ class MemoryService:
130
142
 
131
143
  ws_bytes = _file_size(self._data_dir / "workspace_os.json")
132
144
  kg_bytes = _file_size(self._data_dir / "knowledge_graph.sqlite")
133
- conv_bytes = _file_size(self._history_file)
145
+ if self._conversation_store is not None:
146
+ conv_bytes = int(getattr(self._conversation_store, "size_bytes", lambda: 0)())
147
+ else:
148
+ conv_bytes = _file_size(self._history_file)
134
149
 
135
150
  node_total = sum((kg_stats or {}).get("nodes", {}).values()) if kg_stats else None
136
151
  edge_total = sum((kg_stats or {}).get("edges", {}).values()) if kg_stats else None
@@ -159,7 +174,7 @@ class MemoryService:
159
174
  {
160
175
  "id": "conversation", "type": "conversation", "label": "Conversation Memory",
161
176
  "count": len(convs), "size_bytes": conv_bytes,
162
- "health": "ok" if self._history_file.exists() else "empty",
177
+ "health": "ok" if (self._conversation_store is not None or self._history_file.exists()) else "empty",
163
178
  "detail": "Historical interaction memory from chat.",
164
179
  },
165
180
  {
@@ -202,6 +217,18 @@ class MemoryService:
202
217
  limit: int = 20,
203
218
  ) -> Dict[str, Any]:
204
219
  q = str(query or "").strip()
220
+ query_tokens = [tok for tok in q.lower().split() if tok]
221
+
222
+ def _lexical_score(*texts: Any) -> float:
223
+ # Honest, comparable relevance: fraction of query tokens present.
224
+ # Both tiers share this scorer so the cross-tier ranking is real,
225
+ # not an artifact of per-tier constants.
226
+ if not query_tokens:
227
+ return 0.0
228
+ haystack = " ".join(str(t or "") for t in texts).lower()
229
+ hits = sum(1 for tok in query_tokens if tok in haystack)
230
+ return round(hits / len(query_tokens), 4)
231
+
205
232
  results: List[Dict[str, Any]] = []
206
233
 
207
234
  try:
@@ -215,23 +242,24 @@ class MemoryService:
215
242
  "title": (m.get("kind") or "memory"),
216
243
  "snippet": str(m.get("content") or "")[:240],
217
244
  "kind": m.get("kind"),
218
- "score": 0.6,
245
+ "score": _lexical_score(m.get("content"), " ".join(m.get("tags") or []), m.get("kind")),
219
246
  "tags": m.get("tags") or [],
220
247
  })
221
248
 
222
249
  if self._enable_graph and q:
223
250
  try:
224
- hits = self._kg.search(q, limit).get("results", [])
251
+ # KnowledgeGraph.search returns {"query": ..., "matches": [...]}.
252
+ hits = self._kg.search(q, limit).get("matches", [])
225
253
  except Exception:
226
254
  hits = []
227
- for hsit in hits[:limit]:
255
+ for hit in hits[:limit]:
228
256
  results.append({
229
257
  "source": "graph",
230
- "id": hsit.get("id") or hsit.get("node_id"),
231
- "title": hsit.get("title") or hsit.get("name") or "node",
232
- "snippet": str(hsit.get("summary") or hsit.get("content") or "")[:240],
233
- "kind": hsit.get("type") or "node",
234
- "score": float(hsit.get("score") or 0.5),
258
+ "id": hit.get("id") or hit.get("node_id"),
259
+ "title": hit.get("title") or hit.get("name") or "node",
260
+ "snippet": str(hit.get("summary") or hit.get("content") or "")[:240],
261
+ "kind": hit.get("type") or "node",
262
+ "score": _lexical_score(hit.get("title"), hit.get("name"), hit.get("summary"), hit.get("content")),
235
263
  })
236
264
 
237
265
  results.sort(key=lambda r: r.get("score", 0), reverse=True)
@@ -18,7 +18,6 @@ import re
18
18
  import shutil
19
19
  import subprocess
20
20
  import sys
21
- import tempfile
22
21
  import threading
23
22
  import time
24
23
  import urllib.error
@@ -26,16 +25,14 @@ import urllib.request
26
25
  from pathlib import Path
27
26
  from typing import AsyncIterator, Dict, List, Optional
28
27
 
29
- import httpx
30
28
  from fastapi import HTTPException, Request
31
29
 
32
- from llm_router import (
30
+ from latticeai.models.router import (
33
31
  AsyncOpenAI,
34
32
  HF_MODELS_ROOT,
35
33
  OPENAI_COMPATIBLE_PROVIDERS,
36
34
  ensure_mlx_runtime,
37
35
  hf_model_dir,
38
- normalize_branding,
39
36
  parse_model_ref,
40
37
  )
41
38
  from latticeai.core.model_compat import (
@@ -89,7 +86,7 @@ def configure_model_runtime(**deps) -> None:
89
86
  # Catalog data + version-dedup helpers live in ``model_catalog``; re-exported
90
87
  # here so existing ``from ...model_runtime import ENGINE_MODEL_CATALOG`` imports
91
88
  # keep working.
92
- from latticeai.services.model_catalog import ( # noqa: F401 (re-export)
89
+ from latticeai.services.model_catalog import ( # noqa: E402, F401 (re-export after the module globals it documents)
93
90
  ENGINE_INSTALLERS,
94
91
  ENGINE_MODEL_CATALOG,
95
92
  MODEL_ENGINE_ALIASES,
@@ -18,8 +18,9 @@ from typing import Any, Callable, Dict, Optional, Set
18
18
  from fastapi import HTTPException, Request
19
19
 
20
20
  from latticeai.core.hooks import dispatch_tool
21
- from latticeai.core.multi_agent import MultiAgentOrchestrator, default_role_runner
22
- from latticeai.core.workflow_engine import WorkflowEngine
21
+ from latticeai.core.multi_agent import MultiAgentOrchestrator, default_role_runner, llm_role_runner
22
+ from latticeai.core.workflow_engine import ApprovalRequired, WorkflowEngine
23
+ from tools import execute_tool
23
24
 
24
25
 
25
26
  class PlatformRuntime:
@@ -34,6 +35,9 @@ class PlatformRuntime:
34
35
  workspace_scope_from_request: Callable[[Request], Optional[str]],
35
36
  get_tool_permission: Callable[..., Dict[str, Any]],
36
37
  hooks: Any = None,
38
+ llm_generate: Optional[Callable[..., str]] = None,
39
+ llm_available: Optional[Callable[[], bool]] = None,
40
+ agent_registry: Any = None,
37
41
  ):
38
42
  self.store = store
39
43
  self.svc = workspace_service
@@ -45,6 +49,12 @@ class PlatformRuntime:
45
49
  # Lifecycle hooks registry — wires the workflow runtime + workflow tool
46
50
  # nodes into the same pre_*/post_* lifecycle as the HTTP + agent paths.
47
51
  self.hooks = hooks
52
+ # v4 (T7b): a synchronous model bridge. When a model is loaded,
53
+ # build_orchestrator returns the REAL (mode='llm') runner; otherwise
54
+ # the deterministic runner, honestly labeled mode='simulation'.
55
+ self.llm_generate = llm_generate
56
+ self.llm_available = llm_available or (lambda: False)
57
+ self.agent_registry = agent_registry
48
58
 
49
59
  # ── request gating ────────────────────────────────────────────────────
50
60
 
@@ -77,31 +87,57 @@ class PlatformRuntime:
77
87
  # ── shared node runners ───────────────────────────────────────────────
78
88
 
79
89
  def _tool_node_runner(self):
80
- """Workflow tool node: records the invocation + governance decision but
81
- never silently executes exec/destructive tools (those need approval)."""
90
+ """Workflow tool node: EXECUTES the tool under governance (v4).
91
+
92
+ Auto-approve tools run immediately through the shared dispatch_tool
93
+ lifecycle. Tools whose policy requires approval raise
94
+ :class:`ApprovalRequired` so the engine pauses the run into
95
+ ``awaiting_approval`` — never a silent ``{recorded: true}`` success,
96
+ never an unapproved execution. A resumed run carries the approved
97
+ node id in ``context['__approved_nodes__']``.
98
+ """
82
99
  def runner(*, node, context):
83
100
  cfg = node.get("config") or {}
84
101
  name = cfg.get("tool") or ""
85
102
  args = cfg.get("args") or {}
86
-
87
- def _record():
88
- try:
89
- permission = dict(self.get_tool_permission(name))
90
- except Exception:
91
- permission = {"tool": name, "risk": "unknown"}
92
- return {"tool": name, "args": args, "recorded": True, "permission": permission}
103
+ if not name:
104
+ raise ValueError("tool node has no tool configured")
105
+ try:
106
+ permission = dict(self.get_tool_permission(name, args))
107
+ except TypeError:
108
+ permission = dict(self.get_tool_permission(name))
109
+ approved_nodes = set(context.get("__approved_nodes__") or [])
110
+ if permission.get("requires_approval") and node.get("id") not in approved_nodes:
111
+ raise ApprovalRequired(
112
+ f"tool '{name}' requires explicit approval before a workflow may run it",
113
+ tool=name, args=args, permission=permission,
114
+ )
115
+
116
+ def _execute():
117
+ return execute_tool(name, args)
93
118
 
94
119
  # Same tool lifecycle as the HTTP + agent paths (a pre_tool block
95
120
  # raises PermissionError, surfaced as the node error by the engine).
96
- return dispatch_tool(self.hooks, name or "tool", args, _record, source="workflow")
121
+ result = dispatch_tool(self.hooks, name, args, _execute, source="workflow")
122
+ return {"tool": name, "args": args, "executed": True,
123
+ "permission": permission, "result": result}
97
124
  return runner
98
125
 
99
126
  def _skill_node_runner(self):
127
+ """Skill nodes refuse honestly: a skill is an instruction package for
128
+ an LLM; without a model-driven executor there is nothing to run, and
129
+ pretending otherwise (the pre-v4 existence check that reported 'ok')
130
+ is exactly the fake functionality v4 bans."""
100
131
  def runner(*, node, context):
101
132
  cfg = node.get("config") or {}
102
133
  name = cfg.get("skill") or ""
103
134
  entry = self.store.load_state().get("skill_registry", {}).get(name) or {}
104
- return {"skill": name, "found": bool(entry), "enabled": bool(entry.get("enabled"))}
135
+ if not entry:
136
+ raise ValueError(f"skill '{name}' is not installed")
137
+ raise RuntimeError(
138
+ f"skill '{name}' requires LLM-driven execution, which workflow "
139
+ "skill nodes do not provide in this build — refusing to fake a result"
140
+ )
105
141
  return runner
106
142
 
107
143
  def _context_provider(self, user, scope):
@@ -116,15 +152,25 @@ class PlatformRuntime:
116
152
  def plugin_capability_runners(self, user, scope) -> Dict[str, Callable[..., Any]]:
117
153
  """Runners the Plugin SDK boundary dispatches to (one per capability)."""
118
154
  def run_skill(*, plugin_id, action, args, manifest):
119
- return {"plugin": plugin_id, "ran_skills": manifest.provides.get("skills", [])}
155
+ raise RuntimeError(
156
+ f"plugin '{plugin_id}' skill execution requires an LLM-driven "
157
+ "runner, which this build does not provide — refusing to fake a result"
158
+ )
120
159
 
121
160
  def run_tool(*, plugin_id, action, args, manifest):
122
161
  tool = args.get("tool") or (manifest.provides.get("tools") or [None])[0]
123
- try:
124
- permission = dict(self.get_tool_permission(tool)) if tool else {}
125
- except Exception:
126
- permission = {}
127
- return {"plugin": plugin_id, "tool": tool, "permission": permission, "recorded": True}
162
+ if not tool:
163
+ raise ValueError(f"plugin '{plugin_id}' run_tool needs a tool name")
164
+ permission = dict(self.get_tool_permission(tool))
165
+ if permission.get("requires_approval"):
166
+ raise ApprovalRequired(
167
+ f"plugin tool '{tool}' requires explicit approval",
168
+ tool=tool, args=args, permission=permission,
169
+ )
170
+ result = dispatch_tool(self.hooks, tool, args, lambda: execute_tool(tool, args),
171
+ source=f"plugin:{plugin_id}")
172
+ return {"plugin": plugin_id, "tool": tool, "permission": permission,
173
+ "executed": True, "result": result}
128
174
 
129
175
  def run_workflow(*, plugin_id, action, args, manifest):
130
176
  wf_id = args.get("workflow_id")
@@ -175,6 +221,9 @@ class PlatformRuntime:
175
221
  workflow_id=workflow_id, name=workflow.get("name") or "workflow",
176
222
  status=result.status, timeline=result.timeline, outputs=result.outputs,
177
223
  user_email=user, graph=self.workspace_graph(), workspace_id=scope,
224
+ mode="live",
225
+ pause={"node": result.paused_node, "pending": result.pending_approval,
226
+ "context": result.paused_context} if result.status == "awaiting_approval" else None,
178
227
  )
179
228
  return {"workflow_run_id": run["id"], "status": result.status}
180
229
 
@@ -209,8 +258,36 @@ class PlatformRuntime:
209
258
  }
210
259
 
211
260
  def build_orchestrator(self, user, scope) -> MultiAgentOrchestrator:
261
+ workflow_runner = lambda wf_ref, ctx: self.run_workflow_by_id(wf_ref, user, scope, with_agent=False, inputs=ctx.inputs) # noqa: E731
262
+ plugin_runner = lambda pid, ctx: self.registry.execute_action(pid, "run_skill", {}, runners=self.plugin_capability_runners(user, scope), workspace_id=scope).as_dict() # noqa: E731
263
+ context_provider = self._context_provider(user, scope)
264
+ custom_agents = {}
265
+ if self.agent_registry is not None:
266
+ try:
267
+ custom_agents = {
268
+ a["id"]: a for a in self.agent_registry.all()
269
+ if str(a.get("id", "")).startswith("agent:custom:") and a.get("enabled", True)
270
+ }
271
+ except Exception:
272
+ custom_agents = {}
273
+ if self.llm_generate is not None and self.llm_available():
274
+ from latticeai.core.agent_prompts import CRITIC_PROMPT, PLANNER_PROMPT
275
+
276
+ return MultiAgentOrchestrator(
277
+ role_runner=llm_role_runner(
278
+ generate=self.llm_generate,
279
+ planner_prompt=PLANNER_PROMPT,
280
+ critic_prompt=CRITIC_PROMPT,
281
+ context_provider=context_provider,
282
+ workflow_runner=workflow_runner,
283
+ plugin_runner=plugin_runner,
284
+ custom_agents=custom_agents,
285
+ ),
286
+ mode="llm",
287
+ custom_agents=custom_agents,
288
+ )
212
289
  return MultiAgentOrchestrator(role_runner=default_role_runner(
213
- workflow_runner=lambda wf_ref, ctx: self.run_workflow_by_id(wf_ref, user, scope, with_agent=False, inputs=ctx.inputs),
214
- plugin_runner=lambda pid, ctx: self.registry.execute_action(pid, "run_skill", {}, runners=self.plugin_capability_runners(user, scope), workspace_id=scope).as_dict(),
215
- context_provider=self._context_provider(user, scope),
216
- ))
290
+ workflow_runner=workflow_runner,
291
+ plugin_runner=plugin_runner,
292
+ context_provider=context_provider,
293
+ ), mode="simulation", custom_agents=custom_agents)
@@ -7,7 +7,7 @@ keyword search into UI-ready contracts without tying routers to store internals.
7
7
  from __future__ import annotations
8
8
 
9
9
  from dataclasses import dataclass
10
- from typing import Any, Dict, List, Mapping, Optional
10
+ from typing import Any, Dict, Mapping, Optional
11
11
 
12
12
 
13
13
  DEFAULT_HYBRID_WEIGHTS = {
@@ -34,7 +34,15 @@ class SearchService:
34
34
  raise ValueError("knowledge graph is disabled")
35
35
  return self.graph_store
36
36
 
37
- def keyword_search(self, query: str, *, limit: int = 30) -> Dict[str, Any]:
37
+ def _scope(self, matches, allowed_workspaces):
38
+ """Drop matches scoped to workspaces the caller is not a member of
39
+ (None = no scoping; legacy-global rows stay visible — documented)."""
40
+ if allowed_workspaces is None:
41
+ return matches
42
+ graph = self._require_graph()
43
+ return graph.filter_scoped_nodes(matches, allowed_workspaces)
44
+
45
+ def keyword_search(self, query: str, *, limit: int = 30, allowed_workspaces=None) -> Dict[str, Any]:
38
46
  graph = self._require_graph()
39
47
  payload = graph.search(query, limit)
40
48
  matches = []
@@ -53,9 +61,9 @@ class SearchService:
53
61
  "metadata": match.get("metadata") or {},
54
62
  "updated_at": match.get("updated_at"),
55
63
  })
56
- return {"query": query, "mode": "keyword", "matches": matches}
64
+ return {"query": query, "mode": "keyword", "matches": self._scope(matches, allowed_workspaces)}
57
65
 
58
- def vector_search(self, query: str, *, limit: int = 30, min_score: float = 0.0) -> Dict[str, Any]:
66
+ def vector_search(self, query: str, *, limit: int = 30, min_score: float = 0.0, allowed_workspaces=None) -> Dict[str, Any]:
59
67
  graph = self._require_graph()
60
68
  payload = graph.vector_search(query, limit=limit, min_score=min_score)
61
69
  matches = []
@@ -80,10 +88,10 @@ class SearchService:
80
88
  "mode": "vector",
81
89
  "embedding_model": payload.get("embedding_model"),
82
90
  "embedding_dim": payload.get("embedding_dim"),
83
- "matches": matches,
91
+ "matches": self._scope(matches, allowed_workspaces),
84
92
  }
85
93
 
86
- def graph_search(self, query: str, *, limit: int = 30, expand_depth: int = 1) -> Dict[str, Any]:
94
+ def graph_search(self, query: str, *, limit: int = 30, expand_depth: int = 1, allowed_workspaces=None) -> Dict[str, Any]:
87
95
  graph = self._require_graph()
88
96
  limit = max(1, min(int(limit or 30), 100))
89
97
  expand_depth = max(0, min(int(expand_depth or 1), 3))
@@ -157,7 +165,7 @@ class SearchService:
157
165
  match["rank"] = rank
158
166
  match["score"] = round(float(match["score"]), 6)
159
167
  match["source_scores"]["graph"] = round(float(match["source_scores"]["graph"]), 6)
160
- return {"query": query, "mode": "graph", "expand_depth": expand_depth, "matches": matches}
168
+ return {"query": query, "mode": "graph", "expand_depth": expand_depth, "matches": self._scope(matches, allowed_workspaces)}
161
169
 
162
170
  def hybrid_search(
163
171
  self,
@@ -168,6 +176,7 @@ class SearchService:
168
176
  vector_limit: int = 30,
169
177
  graph_limit: int = 30,
170
178
  weights: Optional[Mapping[str, float]] = None,
179
+ allowed_workspaces=None,
171
180
  ) -> Dict[str, Any]:
172
181
  weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights or {})}
173
182
  channels = {
@@ -202,7 +211,7 @@ class SearchService:
202
211
  current.setdefault("graph_context", [])
203
212
  current["graph_context"].extend(result.get("graph_context") or [])
204
213
 
205
- matches = sorted(fused.values(), key=lambda item: item["score"], reverse=True)[: max(1, min(limit, 100))]
214
+ matches = self._scope(sorted(fused.values(), key=lambda item: item["score"], reverse=True), allowed_workspaces)[: max(1, min(limit, 100))]
206
215
  for rank, match in enumerate(matches, start=1):
207
216
  match["rank"] = rank
208
217
  match["score"] = round(float(match["score"]), 6)
@@ -22,8 +22,16 @@ from latticeai.core.tool_registry import ToolPermission, ToolPolicy
22
22
  from tools import AGENT_ROOT, DEFAULT_TOOL_REGISTRY, ToolError, ensure_agent_root
23
23
 
24
24
 
25
- _load_users: Callable[[], Dict[str, Any]] = lambda: {}
26
- _get_user_role: Callable[..., str] = lambda _email, _users=None: "user"
25
+ def _default_load_users() -> Dict[str, Any]:
26
+ return {}
27
+
28
+
29
+ def _default_get_user_role(_email, _users=None) -> str:
30
+ return "user"
31
+
32
+
33
+ _load_users: Callable[[], Dict[str, Any]] = _default_load_users
34
+ _get_user_role: Callable[..., str] = _default_get_user_role
27
35
 
28
36
  FILE_CREATE_ACTIONS = set(DEFAULT_TOOL_REGISTRY.file_create_actions)
29
37
  TOOL_GOVERNANCE: Dict[str, ToolPolicy] = dict(DEFAULT_TOOL_REGISTRY.governance)
@@ -104,6 +112,7 @@ def build_agent_runtime(
104
112
  knowledge_save: Callable[..., Dict[str, Any]],
105
113
  audit: Callable[..., None],
106
114
  hooks: Any = None,
115
+ brain_memory: Any = None,
107
116
  ) -> AgentRuntime:
108
117
  ensure_agent_root()
109
118
  deps = AgentDeps(
@@ -125,6 +134,7 @@ def build_agent_runtime(
125
134
  memory_updater_prompt=MEMORY_UPDATER_PROMPT,
126
135
  agent_root=AGENT_ROOT,
127
136
  hooks=hooks,
137
+ brain_memory=brain_memory,
128
138
  )
129
139
  return AgentRuntime(deps)
130
140