ltcai 3.6.0 → 4.0.1

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 (238) hide show
  1. package/README.md +39 -31
  2. package/docs/CHANGELOG.md +64 -0
  3. package/docs/REALTIME_COLLABORATION.md +3 -3
  4. package/docs/V3_FRONTEND.md +9 -8
  5. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  6. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
  7. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  8. package/docs/kg-schema.md +51 -53
  9. package/docs/spec-vs-impl.md +10 -10
  10. package/kg_schema.py +2 -520
  11. package/knowledge_graph.py +37 -4629
  12. package/knowledge_graph_api.py +11 -127
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +16 -17
  15. package/latticeai/api/agents.py +20 -7
  16. package/latticeai/api/auth.py +46 -15
  17. package/latticeai/api/chat.py +112 -76
  18. package/latticeai/api/health.py +1 -1
  19. package/latticeai/api/hooks.py +1 -1
  20. package/latticeai/api/invitations.py +100 -0
  21. package/latticeai/api/knowledge_graph.py +139 -0
  22. package/latticeai/api/local_files.py +1 -1
  23. package/latticeai/api/mcp.py +23 -11
  24. package/latticeai/api/memory.py +1 -1
  25. package/latticeai/api/models.py +1 -1
  26. package/latticeai/api/network.py +81 -0
  27. package/latticeai/api/plugins.py +3 -6
  28. package/latticeai/api/realtime.py +5 -8
  29. package/latticeai/api/search.py +26 -2
  30. package/latticeai/api/security_dashboard.py +2 -3
  31. package/latticeai/api/setup.py +2 -2
  32. package/latticeai/api/static_routes.py +11 -16
  33. package/latticeai/api/tools.py +3 -0
  34. package/latticeai/api/ui_redirects.py +26 -0
  35. package/latticeai/api/workflow_designer.py +85 -6
  36. package/latticeai/api/workspace.py +93 -57
  37. package/latticeai/app_factory.py +1781 -0
  38. package/latticeai/brain/__init__.py +18 -0
  39. package/latticeai/brain/_kg_common.py +1123 -0
  40. package/latticeai/brain/context.py +213 -0
  41. package/latticeai/brain/conversations.py +236 -0
  42. package/latticeai/brain/discovery.py +1455 -0
  43. package/latticeai/brain/documents.py +218 -0
  44. package/latticeai/brain/identity.py +175 -0
  45. package/latticeai/brain/ingest.py +644 -0
  46. package/latticeai/brain/memory.py +102 -0
  47. package/latticeai/brain/network.py +205 -0
  48. package/latticeai/brain/projection.py +561 -0
  49. package/latticeai/brain/provenance.py +401 -0
  50. package/latticeai/brain/retrieval.py +1316 -0
  51. package/latticeai/brain/schema.py +640 -0
  52. package/latticeai/brain/store.py +216 -0
  53. package/latticeai/brain/write_master.py +225 -0
  54. package/latticeai/core/agent.py +31 -7
  55. package/latticeai/core/audit.py +0 -7
  56. package/latticeai/core/config.py +1 -1
  57. package/latticeai/core/context_builder.py +1 -2
  58. package/latticeai/core/enterprise.py +1 -1
  59. package/latticeai/core/graph_curator.py +2 -2
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/mcp_registry.py +791 -0
  63. package/latticeai/core/model_compat.py +1 -1
  64. package/latticeai/core/model_resolution.py +0 -1
  65. package/latticeai/core/multi_agent.py +238 -4
  66. package/latticeai/core/policy.py +54 -0
  67. package/latticeai/core/realtime.py +65 -44
  68. package/latticeai/core/security.py +1 -1
  69. package/latticeai/core/sessions.py +66 -10
  70. package/latticeai/core/users.py +147 -0
  71. package/latticeai/core/workflow_engine.py +114 -2
  72. package/latticeai/core/workspace_os.py +477 -29
  73. package/latticeai/models/__init__.py +7 -0
  74. package/latticeai/models/router.py +779 -0
  75. package/latticeai/server_app.py +29 -1536
  76. package/latticeai/services/agent_runtime.py +243 -4
  77. package/latticeai/services/app_context.py +75 -14
  78. package/latticeai/services/ingestion.py +47 -0
  79. package/latticeai/services/kg_portability.py +33 -3
  80. package/latticeai/services/memory_service.py +39 -11
  81. package/latticeai/services/model_runtime.py +2 -5
  82. package/latticeai/services/platform_runtime.py +100 -23
  83. package/latticeai/services/run_executor.py +328 -0
  84. package/latticeai/services/search_service.py +17 -8
  85. package/latticeai/services/tool_dispatch.py +12 -2
  86. package/latticeai/services/triggers.py +241 -0
  87. package/latticeai/services/upload_service.py +37 -12
  88. package/latticeai/services/workspace_service.py +55 -16
  89. package/llm_router.py +29 -772
  90. package/ltcai_cli.py +1 -2
  91. package/mcp_registry.py +25 -788
  92. package/p_reinforce.py +124 -14
  93. package/package.json +10 -20
  94. package/scripts/bump_version.py +99 -0
  95. package/scripts/generate_diagrams.py +0 -1
  96. package/scripts/lint_v3.mjs +105 -18
  97. package/scripts/validate_release_artifacts.py +0 -1
  98. package/scripts/wheel_smoke.py +142 -0
  99. package/server.py +11 -7
  100. package/setup_wizard.py +1142 -0
  101. package/static/sw.js +81 -52
  102. package/static/v3/asset-manifest.json +33 -25
  103. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  104. package/static/v3/css/lattice.base.css +1 -1
  105. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  106. package/static/v3/css/lattice.components.css +1 -1
  107. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  108. package/static/v3/css/lattice.shell.css +1 -1
  109. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  110. package/static/v3/css/lattice.tokens.css +3 -0
  111. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  112. package/static/v3/css/lattice.views.css +2 -2
  113. package/static/v3/index.html +3 -4
  114. package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
  115. package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
  116. package/static/v3/js/core/api.js +57 -0
  117. package/static/v3/js/core/i18n.880e1fec.js +575 -0
  118. package/static/v3/js/core/i18n.js +575 -0
  119. package/static/v3/js/core/routes.37522821.js +101 -0
  120. package/static/v3/js/core/routes.js +71 -63
  121. package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
  122. package/static/v3/js/core/shell.js +66 -37
  123. package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
  124. package/static/v3/js/core/store.js +11 -1
  125. package/static/v3/js/views/account.eff40715.js +143 -0
  126. package/static/v3/js/views/account.js +143 -0
  127. package/static/v3/js/views/activity.0d271ef9.js +67 -0
  128. package/static/v3/js/views/activity.js +67 -0
  129. package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
  130. package/static/v3/js/views/admin-users.js +4 -6
  131. package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
  132. package/static/v3/js/views/agents.js +35 -12
  133. package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
  134. package/static/v3/js/views/chat.js +23 -0
  135. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  136. package/static/v3/js/views/graph-canvas.js +509 -0
  137. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  138. package/static/v3/js/views/hybrid-search.js +1 -2
  139. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
  140. package/static/v3/js/views/knowledge-graph.js +60 -44
  141. package/static/v3/js/views/network.52a4f181.js +97 -0
  142. package/static/v3/js/views/network.js +97 -0
  143. package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
  144. package/static/v3/js/views/planning.js +26 -5
  145. package/static/v3/js/views/runs.b63b2afa.js +144 -0
  146. package/static/v3/js/views/runs.js +144 -0
  147. package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
  148. package/static/v3/js/views/settings.js +7 -8
  149. package/static/v3/js/views/snapshots.6f5db095.js +135 -0
  150. package/static/v3/js/views/snapshots.js +135 -0
  151. package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
  152. package/static/v3/js/views/workflows.js +87 -2
  153. package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
  154. package/static/v3/js/views/workspace-admin.js +156 -0
  155. package/static/vendor/chart.umd.min.js +20 -0
  156. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  157. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  158. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  159. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  160. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  161. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  162. package/static/vendor/fonts/inter.css +44 -0
  163. package/static/vendor/icons/tabler-icons.min.css +4 -0
  164. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  165. package/static/vendor/marked.min.js +69 -0
  166. package/telegram_bot.py +1 -2
  167. package/tools/commands.py +4 -2
  168. package/tools/computer.py +1 -1
  169. package/tools/documents.py +1 -3
  170. package/tools/filesystem.py +0 -4
  171. package/tools/knowledge.py +1 -3
  172. package/tools/network.py +1 -3
  173. package/codex_telegram_bot.py +0 -195
  174. package/docs/assets/v3.4.0/agent-run.png +0 -0
  175. package/docs/assets/v3.4.0/agents.png +0 -0
  176. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  177. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  178. package/docs/assets/v3.4.0/chat.png +0 -0
  179. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  180. package/docs/assets/v3.4.0/files.png +0 -0
  181. package/docs/assets/v3.4.0/home.png +0 -0
  182. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  183. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  184. package/docs/assets/v3.4.0/local-agent.png +0 -0
  185. package/docs/assets/v3.4.0/memory.png +0 -0
  186. package/docs/assets/v3.4.0/settings.png +0 -0
  187. package/docs/assets/v3.4.0/vision-input.png +0 -0
  188. package/docs/assets/v3.4.0/workflows.png +0 -0
  189. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  190. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  191. package/docs/assets/v3.4.1/local-agent.png +0 -0
  192. package/docs/images/admin-dashboard.png +0 -0
  193. package/docs/images/architecture.png +0 -0
  194. package/docs/images/enterprise.png +0 -0
  195. package/docs/images/graph.png +0 -0
  196. package/docs/images/hero.gif +0 -0
  197. package/docs/images/knowledge-graph.png +0 -0
  198. package/docs/images/lattice-ai-demo.gif +0 -0
  199. package/docs/images/lattice-ai-hero.png +0 -0
  200. package/docs/images/logo.svg +0 -33
  201. package/docs/images/mobile-responsive.png +0 -0
  202. package/docs/images/model-recommendation.png +0 -0
  203. package/docs/images/onboarding.png +0 -0
  204. package/docs/images/organization.png +0 -0
  205. package/docs/images/pipeline.png +0 -0
  206. package/docs/images/screenshot-admin.png +0 -0
  207. package/docs/images/screenshot-chat.png +0 -0
  208. package/docs/images/screenshot-graph.png +0 -0
  209. package/docs/images/skills.png +0 -0
  210. package/docs/images/workspace-dark.png +0 -0
  211. package/docs/images/workspace-light.png +0 -0
  212. package/docs/images/workspace.png +0 -0
  213. package/requirements.txt +0 -16
  214. package/static/account.html +0 -115
  215. package/static/activity.html +0 -73
  216. package/static/admin.html +0 -488
  217. package/static/agents.html +0 -139
  218. package/static/chat.html +0 -844
  219. package/static/css/reference/account.css +0 -439
  220. package/static/css/reference/admin.css +0 -610
  221. package/static/css/reference/base.css +0 -1661
  222. package/static/css/reference/chat.css +0 -4623
  223. package/static/css/reference/graph.css +0 -1016
  224. package/static/css/responsive.css +0 -861
  225. package/static/graph.html +0 -124
  226. package/static/platform.css +0 -104
  227. package/static/plugins.html +0 -136
  228. package/static/scripts/account.js +0 -238
  229. package/static/scripts/admin.js +0 -1614
  230. package/static/scripts/chat.js +0 -5081
  231. package/static/scripts/graph.js +0 -1804
  232. package/static/scripts/platform.js +0 -64
  233. package/static/scripts/ux.js +0 -167
  234. package/static/scripts/workspace.js +0 -948
  235. package/static/v3/js/core/routes.2ce3815a.js +0 -93
  236. package/static/workflows.html +0 -146
  237. package/static/workspace.css +0 -1121
  238. package/static/workspace.html +0 -357
@@ -21,6 +21,7 @@ it, the frontend) now depends on this boundary instead of internal paths.
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ from datetime import datetime
24
25
  from typing import Any, Callable, Dict, List, Optional
25
26
 
26
27
  from latticeai.core.multi_agent import (
@@ -41,8 +42,12 @@ ROLE_DESCRIPTIONS = {
41
42
  # Run statuses the orchestrator can emit that mean "still working". The default
42
43
  # orchestrator runs synchronously, so persisted runs are always terminal; this
43
44
  # set lets the runtime report live work if a future async runner lands.
44
- _ACTIVE_STATUSES = {"running", "in_progress", "queued", "retrying"}
45
- _TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled"}
45
+ _ACTIVE_STATUSES = {"running", "in_progress", "queued", "retrying", "cancelling"}
46
+ _TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
47
+
48
+
49
+ def _now() -> str:
50
+ return datetime.now().isoformat(timespec="seconds")
46
51
 
47
52
 
48
53
  class AgentRuntime:
@@ -64,6 +69,13 @@ class AgentRuntime:
64
69
  # Lifecycle hooks registry (optional). When present, ``start`` fires the
65
70
  # ``pre_run`` / ``post_run`` hooks; a blocking ``pre_run`` aborts the run.
66
71
  self._hooks = hooks
72
+ self._run_executor: Any = None
73
+
74
+ def attach_executor(self, executor: Any) -> None:
75
+ self._run_executor = executor
76
+
77
+ def _execution_mode(self) -> str:
78
+ return "async" if self._run_executor is not None else "synchronous"
67
79
 
68
80
  # ── configuration ─────────────────────────────────────────────────────
69
81
  def config(self) -> Dict[str, Any]:
@@ -72,7 +84,12 @@ class AgentRuntime:
72
84
  "roles": list(AGENT_ROLES),
73
85
  "default_pipeline": list(CORE_PIPELINE),
74
86
  "max_retries_cap": self._max_retries_cap,
75
- "execution_mode": "synchronous",
87
+ "execution_mode": self._execution_mode(),
88
+ "cancellation": (
89
+ "cooperative; running synchronous model/tool calls finish their current step before a cancelled status is persisted"
90
+ if self._run_executor is not None else
91
+ "not supported for the synchronous runtime"
92
+ ),
76
93
  }
77
94
 
78
95
  def roles(self) -> List[Dict[str, Any]]:
@@ -150,7 +167,7 @@ class AgentRuntime:
150
167
  "runtime": {
151
168
  "ready": True,
152
169
  "version": MULTI_AGENT_VERSION,
153
- "execution_mode": "synchronous",
170
+ "execution_mode": self._execution_mode(),
154
171
  "default_pipeline": list(CORE_PIPELINE),
155
172
  "total_runs": len(runs),
156
173
  "active_runs": active,
@@ -184,6 +201,225 @@ class AgentRuntime:
184
201
  }
185
202
 
186
203
  # ── execution ─────────────────────────────────────────────────────────
204
+ def _fire_pre_run(
205
+ self,
206
+ *,
207
+ goal: str,
208
+ roles: Optional[List[str]],
209
+ max_retries: int,
210
+ user_email: Optional[str],
211
+ scope: Optional[str],
212
+ ) -> Optional[Dict[str, Any]]:
213
+ pre_dispatch: Optional[Dict[str, Any]] = None
214
+ if self._hooks is not None:
215
+ pre_dispatch = self._hooks.fire_hook(
216
+ "pre_run", "agent.run",
217
+ payload={"goal": goal, "roles": roles or None, "max_retries": max_retries},
218
+ user_email=user_email, workspace_id=scope,
219
+ )
220
+ if pre_dispatch.get("blocked"):
221
+ self._append_audit_event(
222
+ "multi_agent_run_blocked",
223
+ user_email=user_email,
224
+ reason=pre_dispatch.get("block_reason"),
225
+ )
226
+ raise PermissionError(pre_dispatch.get("block_reason") or "Agent run blocked by a pre_run hook.")
227
+ return pre_dispatch
228
+
229
+ @staticmethod
230
+ def _result_patch(result: Any, goal: str) -> Dict[str, Any]:
231
+ return {
232
+ "agent_id": result.agent_id,
233
+ "status": result.status,
234
+ "input": goal,
235
+ "output_text": result.output,
236
+ "timeline": result.timeline,
237
+ "relationships": [ROLE_AGENT_IDS.get(r, f"agent:{r}") for r in result.roles_run],
238
+ "handoffs": result.handoffs,
239
+ "context_packets": result.context_packets,
240
+ "plan": result.plan,
241
+ "plan_review": result.plan_review,
242
+ "review_history": result.review_history,
243
+ "retry_history": result.retry_history,
244
+ "memory_snapshots": result.memory_snapshots,
245
+ "mode": getattr(result, "mode", "simulation"),
246
+ "current_role": None,
247
+ }
248
+
249
+ def _post_run_hooks(
250
+ self,
251
+ *,
252
+ run_id: Optional[str],
253
+ result: Any,
254
+ user_email: Optional[str],
255
+ scope: Optional[str],
256
+ status: Optional[str] = None,
257
+ ) -> Optional[Dict[str, Any]]:
258
+ if self._hooks is None:
259
+ return None
260
+ return self._hooks.fire_hook(
261
+ "post_run", "agent.run",
262
+ payload={
263
+ "run_id": run_id,
264
+ "agent_id": result.agent_id,
265
+ "status": status or result.status,
266
+ "retries": result.retries,
267
+ },
268
+ user_email=user_email, workspace_id=scope,
269
+ )
270
+
271
+ def reserve_run(
272
+ self,
273
+ goal: str,
274
+ *,
275
+ user_email: Optional[str],
276
+ scope: Optional[str],
277
+ roles: Optional[List[str]] = None,
278
+ inputs: Optional[Dict[str, Any]] = None,
279
+ max_retries: int = 2,
280
+ ) -> Dict[str, Any]:
281
+ """Create the durable queued row used by the async executor."""
282
+ if not str(goal or "").strip():
283
+ raise ValueError("goal is required")
284
+ pre_dispatch = self._fire_pre_run(
285
+ goal=goal,
286
+ roles=roles,
287
+ max_retries=max_retries,
288
+ user_email=user_email,
289
+ scope=scope,
290
+ )
291
+ try:
292
+ orchestrator = self._orchestrator_factory(user_email or None, scope)
293
+ mode = getattr(orchestrator, "mode", "simulation")
294
+ except Exception:
295
+ mode = "simulation"
296
+ run = self._store.record_agent_run(
297
+ agent_id=ROLE_AGENT_IDS.get("executor", "agent:executor"),
298
+ status="queued",
299
+ input_text=goal,
300
+ output_text="",
301
+ timeline=[{"event": "agent_started", "status": "queued", "timestamp": _now()}],
302
+ relationships=[],
303
+ handoffs=[],
304
+ context_packets=[],
305
+ plan=[],
306
+ plan_review={},
307
+ review_history=[],
308
+ retry_history=[],
309
+ memory_snapshots=[],
310
+ user_email=user_email or None,
311
+ graph=None,
312
+ workspace_id=scope,
313
+ mode=mode,
314
+ )
315
+ run = self._store.update_agent_run(
316
+ run.get("id"),
317
+ workspace_id=scope,
318
+ execution_mode="async",
319
+ requested_roles=roles or None,
320
+ inputs=inputs or {},
321
+ max_retries=max_retries,
322
+ )
323
+ payload: Dict[str, Any] = {"run": run}
324
+ if pre_dispatch is not None:
325
+ payload["pre_run_hooks"] = pre_dispatch
326
+ return payload
327
+
328
+ def complete_reserved_run(
329
+ self,
330
+ run_id: str,
331
+ goal: str,
332
+ *,
333
+ user_email: Optional[str],
334
+ scope: Optional[str],
335
+ roles: Optional[List[str]] = None,
336
+ inputs: Optional[Dict[str, Any]] = None,
337
+ max_retries: int = 2,
338
+ pre_dispatch: Optional[Dict[str, Any]] = None,
339
+ cancel_requested: Optional[Callable[[], bool]] = None,
340
+ ) -> Dict[str, Any]:
341
+ """Execute orchestration and update an existing durable async row."""
342
+ run = self._store.get_agent_run(run_id, workspace_id=scope)
343
+ base_timeline = list(run.get("timeline") or [])
344
+ self._store.update_agent_run(
345
+ run_id,
346
+ workspace_id=scope,
347
+ status="running",
348
+ current_role=(roles or list(CORE_PIPELINE))[0] if (roles or CORE_PIPELINE) else None,
349
+ started_at=run.get("started_at") or _now(),
350
+ )
351
+ try:
352
+ orchestrator = self._orchestrator_factory(user_email or None, scope)
353
+ result = orchestrator.run(
354
+ goal,
355
+ user_email=user_email or None,
356
+ workspace_id=scope,
357
+ inputs=inputs or {},
358
+ roles=roles or None,
359
+ max_retries=max(0, min(int(max_retries or 0), self._max_retries_cap)),
360
+ )
361
+ except Exception as exc:
362
+ failed = self._store.update_agent_run(
363
+ run_id,
364
+ workspace_id=scope,
365
+ graph=self._workspace_graph(),
366
+ status="failed",
367
+ current_role=None,
368
+ error=str(exc),
369
+ output_text=str(exc),
370
+ timeline=base_timeline + [{
371
+ "event": "execution_failed",
372
+ "status": "failed",
373
+ "detail": str(exc),
374
+ "timestamp": _now(),
375
+ }],
376
+ )
377
+ self._append_audit_event("multi_agent_run", user_email=user_email, agent_id=failed.get("agent_id"), status="failed", retries=0)
378
+ return {"run": failed, "result": {"status": "failed", "error": str(exc)}}
379
+
380
+ patch = self._result_patch(result, goal)
381
+ patch["timeline"] = base_timeline + list(result.timeline or [])
382
+ if cancel_requested is not None and cancel_requested():
383
+ patch["status"] = "cancelled"
384
+ patch["current_role"] = None
385
+ patch["cancel_reason"] = "cancelled after the current synchronous step completed"
386
+ patch["cancelled_at"] = _now()
387
+ patch["timeline"] = patch["timeline"] + [{
388
+ "event": "execution_cancelled",
389
+ "status": "cancelled",
390
+ "reason": patch["cancel_reason"],
391
+ "timestamp": _now(),
392
+ }]
393
+ updated = self._store.update_agent_run(
394
+ run_id,
395
+ workspace_id=scope,
396
+ graph=self._workspace_graph(),
397
+ patch=patch,
398
+ )
399
+ self._append_audit_event(
400
+ "multi_agent_run",
401
+ user_email=user_email,
402
+ agent_id=result.agent_id,
403
+ status=updated.get("status") or result.status,
404
+ retries=result.retries,
405
+ )
406
+ post_dispatch = self._post_run_hooks(
407
+ run_id=run_id,
408
+ result=result,
409
+ user_email=user_email,
410
+ scope=scope,
411
+ status=updated.get("status") or result.status,
412
+ )
413
+ result_payload = result.as_dict()
414
+ if updated.get("status") == "cancelled":
415
+ result_payload = {"status": "cancelled", "reason": updated.get("cancel_reason"), "completed_result": result_payload}
416
+ payload = {"run": updated, "result": result_payload}
417
+ if pre_dispatch is not None:
418
+ payload["pre_run_hooks"] = pre_dispatch
419
+ if post_dispatch is not None:
420
+ payload["post_run_hooks"] = post_dispatch
421
+ return payload
422
+
187
423
  def start(
188
424
  self,
189
425
  goal: str,
@@ -242,6 +478,7 @@ class AgentRuntime:
242
478
  user_email=user_email or None,
243
479
  graph=self._workspace_graph(),
244
480
  workspace_id=scope,
481
+ mode=getattr(result, "mode", "simulation"),
245
482
  )
246
483
  self._append_audit_event(
247
484
  "multi_agent_run",
@@ -280,6 +517,8 @@ class AgentRuntime:
280
517
  exists the run has already completed. Report that honestly rather than
281
518
  pretending a cancellation occurred.
282
519
  """
520
+ if self._run_executor is not None:
521
+ return self._run_executor.cancel(run_id, kind="agent", scope=scope)
283
522
  try:
284
523
  run = self._store.get_agent_run(run_id, workspace_id=scope)
285
524
  except FileNotFoundError:
@@ -1,27 +1,88 @@
1
1
  """Application dependency context for router assembly.
2
2
 
3
- The concrete FastAPI app is still assembled in ``server_app``. This dataclass
4
- documents the shared dependency boundary for routers and services so future
5
- extractions can receive a typed context instead of importing the app module.
3
+ ``latticeai.app_factory.create_app`` builds one ``AppContext`` per app and
4
+ hands it to router factories, replacing the historical 25-30-kwarg closure
5
+ wiring. Every field defaults to ``None``-ish so tests can construct a context
6
+ carrying only the dependencies a router actually touches.
7
+
8
+ Fields are grouped by the consumer that motivated them; routers must treat the
9
+ context as read-only.
6
10
  """
7
11
 
8
12
  from __future__ import annotations
9
13
 
10
14
  from dataclasses import dataclass
11
15
  from pathlib import Path
12
- from typing import Any, Callable
16
+ from typing import Any, Callable, Optional
13
17
 
14
18
 
15
19
  @dataclass(frozen=True)
16
20
  class AppContext:
17
- config: Any
18
- data_dir: Path
19
- static_dir: Path
20
- model_router: Any
21
- workspace_store: Any
22
- workspace_service: Any
23
- knowledge_graph: Any
24
- local_kg_watcher: Any
25
- require_user: Callable[..., str]
26
- require_admin: Callable[..., tuple]
21
+ # ── core configuration / paths ────────────────────────────────────────
22
+ config: Any = None
23
+ data_dir: Optional[Path] = None
24
+ static_dir: Optional[Path] = None
25
+ base_dir: Optional[Path] = None
26
+ skills_dir: Optional[Path] = None
27
+
28
+ # ── singletons ────────────────────────────────────────────────────────
29
+ model_router: Any = None
30
+ workspace_store: Any = None
31
+ workspace_service: Any = None
32
+ knowledge_graph: Any = None
33
+ local_kg_watcher: Any = None
34
+ chat_service: Any = None
35
+ context_assembler: Any = None
36
+ brain_memory: Any = None
37
+ gardener: Any = None
38
+ hooks: Any = None
39
+ realtime_bus: Any = None
40
+ capability_registry: Any = None
41
+
42
+ # ── auth / session callables ──────────────────────────────────────────
43
+ require_user: Optional[Callable[..., str]] = None
44
+ require_admin: Optional[Callable[..., tuple]] = None
45
+ get_current_user: Optional[Callable[..., Optional[str]]] = None
46
+ load_users: Optional[Callable[[], dict]] = None
47
+ get_user_role: Optional[Callable[..., str]] = None
48
+ enforce_rate_limit: Optional[Callable[..., None]] = None
49
+
50
+ # ── audit / history callables ─────────────────────────────────────────
51
+ append_audit_event: Optional[Callable[..., None]] = None
52
+ get_audit_log: Optional[Callable[[], list]] = None
53
+ get_history: Optional[Callable[[], list]] = None
54
+ get_history_user: Optional[Callable[..., dict]] = None
55
+ save_to_history: Optional[Callable[..., None]] = None
56
+ clear_history: Optional[Callable[..., dict]] = None
57
+ clear_conversation: Optional[Callable[..., dict]] = None
58
+ group_history_conversations: Optional[Callable[..., list]] = None
59
+ get_conversation_messages: Optional[Callable[..., list]] = None
60
+ conversation_title: Optional[Callable[..., str]] = None
61
+
62
+ # ── knowledge graph access ────────────────────────────────────────────
63
+ enable_graph: bool = False
64
+ require_graph: Optional[Callable[[], None]] = None
65
+ workspace_graph: Optional[Callable[[], Any]] = None
66
+ graph_stats: Optional[Callable[[], dict]] = None
67
+
68
+ # ── workspace payload providers / skills ──────────────────────────────
69
+ workspace_models: Optional[Callable[[], dict]] = None
70
+ workspace_settings: Optional[Callable[[], dict]] = None
71
+ scan_environment: Optional[Callable[[], Any]] = None
72
+ local_sysinfo: Optional[Callable[..., Any]] = None
73
+ get_recommendations: Optional[Callable[..., Any]] = None
74
+ fetch_skills_marketplace: Optional[Callable[..., Any]] = None
75
+ install_skill: Optional[Callable[..., Any]] = None
76
+ remove_skill_directory: Optional[Callable[..., dict]] = None
77
+ redact_secret_text: Optional[Callable[[str], str]] = None
78
+ ui_file_response: Optional[Callable[..., Any]] = None
79
+
80
+ # ── models ────────────────────────────────────────────────────────────
81
+ public_model: str = ""
82
+ local_model: str = ""
27
83
 
84
+ # ── integrations ──────────────────────────────────────────────────────
85
+ # Fired as on_chat_message(role, text, source) after a chat exchange is
86
+ # persisted; ``None`` means no external chat mirror is registered. The
87
+ # telegram bridge subscribes here only when ENABLE_TELEGRAM is truthy.
88
+ on_chat_message: Optional[Callable[..., None]] = None
@@ -33,6 +33,15 @@ FILE_SOURCE_TYPES = frozenset({"file", "local_file", "upload", "pdf"})
33
33
  TEXT_SOURCE_TYPES = frozenset(
34
34
  {"web_url", "browser_tab", "text", "markdown", "note", "code", "clipboard"}
35
35
  )
36
+ # Conversational exchanges (read via ingest_message — role/content semantics,
37
+ # conversation chaining). v4: chat and MCP messages stop bypassing the
38
+ # pipeline, so they carry provenance and fire the hook lifecycle like every
39
+ # other source.
40
+ CHAT_SOURCE_TYPES = frozenset({"chat_message", "mcp_message"})
41
+ # Typed memory records (read via ingest_event → Decision/Experience/Event
42
+ # nodes). The Memory System writes through the same door as everything else.
43
+ MEMORY_SOURCE_TYPES = frozenset({"decision", "experience", "workspace_event"})
44
+ _MEMORY_NODE_TYPES = {"decision": "Decision", "experience": "Experience", "workspace_event": "Event"}
36
45
 
37
46
  DEFAULT_MAX_TEXT_BYTES = 5 * 1024 * 1024 # 5 MB of extracted text per item
38
47
 
@@ -143,6 +152,10 @@ class IngestionPipeline:
143
152
  }
144
153
 
145
154
  def _run() -> Dict[str, Any]:
155
+ if source_type in CHAT_SOURCE_TYPES:
156
+ return self._ingest_chat(item, source_type=source_type, owner=owner)
157
+ if source_type in MEMORY_SOURCE_TYPES:
158
+ return self._ingest_memory_record(item, source_type=source_type, owner=owner)
146
159
  if source_type in FILE_SOURCE_TYPES or (item.path and not item.text):
147
160
  return self._ingest_file(item, source_type=source_type, owner=owner, captured_at=captured_at)
148
161
  return self._ingest_text(item, source_type=source_type, owner=owner, captured_at=captured_at)
@@ -243,6 +256,40 @@ class IngestionPipeline:
243
256
  metadata={"mime_type": item.mime_type, **(item.metadata or {})},
244
257
  )
245
258
 
259
+ def _ingest_chat(self, item, *, source_type, owner) -> Dict[str, Any]:
260
+ text = item.text or ""
261
+ meta = item.metadata or {}
262
+ role = str(meta.get("role") or "user")
263
+ result = self._kg.ingest_message(
264
+ role,
265
+ text,
266
+ user_email=owner,
267
+ user_nickname=meta.get("user_nickname"),
268
+ source=meta.get("source") or source_type,
269
+ conversation_id=item.conversation_id,
270
+ raw=meta.get("raw"),
271
+ )
272
+ # ingest_message reports message/response node ids; normalize the keys
273
+ # the provenance step expects.
274
+ result.setdefault("node_id", result.get("node_id") or result.get("message_node_id") or result.get("id"))
275
+ result.setdefault("title", item.title or text[:80])
276
+ return result
277
+
278
+ def _ingest_memory_record(self, item, *, source_type, owner) -> Dict[str, Any]:
279
+ node_type = _MEMORY_NODE_TYPES[source_type]
280
+ meta = item.metadata or {}
281
+ result = self._kg.ingest_event(
282
+ node_type,
283
+ item.title or (item.text or node_type)[:120],
284
+ user_email=owner,
285
+ source=meta.get("source") or source_type,
286
+ conversation_id=item.conversation_id,
287
+ metadata={**meta, "detail": (item.text or "")[:2000]},
288
+ )
289
+ result.setdefault("node_id", result.get("node_id") or result.get("id"))
290
+ result.setdefault("title", item.title)
291
+ return result
292
+
246
293
  def _ingest_file(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
247
294
  if not item.path:
248
295
  raise ValueError("File ingestion requires a path.")
@@ -44,11 +44,16 @@ def _sha256_file(path: Path) -> str:
44
44
 
45
45
 
46
46
  class KGPortabilityService:
47
- def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True) -> None:
47
+ def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
48
48
  self._kg = knowledge_graph
49
49
  self._data_dir = Path(data_dir)
50
50
  self._enable = bool(enable_graph)
51
51
  self._exports_dir = self._data_dir / "workspace_exports"
52
+ # v4 sovereignty: when a DeviceIdentity is wired, exports are signed
53
+ # and imports record origin provenance. Pre-v4 unsigned bundles stay
54
+ # importable locally (origin='unsigned-legacy') — signatures are
55
+ # mandatory only on the Brain Network peer path.
56
+ self._identity = device_identity
52
57
 
53
58
  def available(self) -> bool:
54
59
  return self._enable and self._kg is not None
@@ -60,7 +65,7 @@ class KGPortabilityService:
60
65
  # ── logical export / import ──────────────────────────────────────────────
61
66
  def export(self, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
62
67
  self._require()
63
- data = self._kg.export_graph_data()
68
+ data = self._kg.export_graph_data(workspace_id=workspace_id)
64
69
  header = {
65
70
  "format": FORMAT,
66
71
  "format_version": FORMAT_VERSION,
@@ -69,7 +74,10 @@ class KGPortabilityService:
69
74
  "workspace_id": workspace_id,
70
75
  "counts": data.get("counts"),
71
76
  }
72
- return {"header": header, **data}
77
+ artifact = {"header": header, **data}
78
+ if self._identity is not None:
79
+ artifact["signature"] = self._identity.sign_manifest(header)
80
+ return artifact
73
81
 
74
82
  def export_to_file(self, path=None, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
75
83
  artifact = self.export(workspace_id=workspace_id)
@@ -84,8 +92,30 @@ class KGPortabilityService:
84
92
  raise ValueError("Invalid Knowledge Graph export artifact.")
85
93
  if mode not in ("merge", "replace"):
86
94
  raise ValueError("mode must be 'merge' or 'replace'.")
95
+ origin = "unsigned-legacy"
96
+ signature = artifact.get("signature")
97
+ if signature:
98
+ from latticeai.brain.identity import verify_manifest
99
+
100
+ if not verify_manifest(artifact.get("header") or {}, signature):
101
+ raise ValueError("Bundle signature verification failed — refusing to import.")
102
+ origin = f"device:{signature.get('fingerprint') or 'unknown'}"
87
103
  result = self._kg.import_graph_data(artifact, mode=mode, dry_run=dry_run)
88
104
  result["header"] = artifact.get("header")
105
+ result["origin"] = origin
106
+ result["signed"] = bool(signature)
107
+ if not dry_run:
108
+ try:
109
+ self._kg.record_provenance(
110
+ node_id="import:" + str((artifact.get("header") or {}).get("exported_at") or _now_iso()),
111
+ source_type="bundle_import",
112
+ pipeline="kg-portability",
113
+ owner=None,
114
+ metadata={"origin": origin, "mode": mode,
115
+ "counts": (artifact.get("header") or {}).get("counts")},
116
+ )
117
+ except Exception:
118
+ pass
89
119
  return result
90
120
 
91
121
  def import_from_file(self, path, *, mode: str = "merge", dry_run: bool = False) -> Dict[str, Any]:
@@ -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)