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
@@ -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)
@@ -0,0 +1,328 @@
1
+ """Durable asyncio run executor for v4 Act runtimes.
2
+
3
+ The executor owns server-loop tasks for agent and workflow runs while the
4
+ workspace store remains the durable source of truth. Work is persisted before it
5
+ starts, updated as it moves through queued/running/cancelling/final states, and
6
+ reconciled on startup so orphaned active rows never masquerade as live work.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from typing import Any, Callable, Dict, Optional
15
+
16
+ from latticeai.core.workflow_engine import WorkflowEngine
17
+
18
+
19
+ ACTIVE_STATUSES = {"queued", "running", "in_progress", "retrying", "cancelling"}
20
+ TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
21
+
22
+
23
+ def _now() -> str:
24
+ return datetime.now().isoformat(timespec="seconds")
25
+
26
+
27
+ @dataclass
28
+ class _RunHandle:
29
+ run_id: str
30
+ kind: str
31
+ scope: Optional[str]
32
+ task: Optional[asyncio.Task] = None
33
+ cancel_requested: bool = False
34
+ started: bool = False
35
+
36
+
37
+ class RunExecutor:
38
+ """Async task manager for persisted agent/workflow executions."""
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ store: Any,
44
+ agent_runtime: Any,
45
+ build_workflow_runners: Callable[[Optional[str], Optional[str]], Dict[str, Callable[..., Any]]],
46
+ workspace_graph: Callable[[], Any],
47
+ append_audit_event: Callable[..., None],
48
+ hooks: Any = None,
49
+ ) -> None:
50
+ self.store = store
51
+ self.agent_runtime = agent_runtime
52
+ self.build_workflow_runners = build_workflow_runners
53
+ self.workspace_graph = workspace_graph
54
+ self.append_audit_event = append_audit_event
55
+ self.hooks = hooks
56
+ self._handles: Dict[str, _RunHandle] = {}
57
+ self._results: Dict[str, Dict[str, Any]] = {}
58
+
59
+ # ── startup reconciliation ───────────────────────────────────────────
60
+
61
+ def reconcile_startup(self) -> Dict[str, Any]:
62
+ return self.store.reconcile_interrupted_runs(reason="server_startup")
63
+
64
+ # ── agent runs ───────────────────────────────────────────────────────
65
+
66
+ async def start_agent(
67
+ self,
68
+ goal: str,
69
+ *,
70
+ user_email: Optional[str],
71
+ scope: Optional[str],
72
+ roles: Optional[list[str]] = None,
73
+ inputs: Optional[Dict[str, Any]] = None,
74
+ max_retries: int = 2,
75
+ ) -> Dict[str, Any]:
76
+ reserved = self.agent_runtime.reserve_run(
77
+ goal,
78
+ user_email=user_email,
79
+ scope=scope,
80
+ roles=roles,
81
+ inputs=inputs or {},
82
+ max_retries=max_retries,
83
+ )
84
+ run_id = reserved["run"]["id"]
85
+ handle = _RunHandle(run_id=run_id, kind="agent", scope=scope)
86
+ handle.task = asyncio.create_task(
87
+ self._run_agent(handle, goal, user_email=user_email, roles=roles, inputs=inputs or {}, max_retries=max_retries)
88
+ )
89
+ self._handles[run_id] = handle
90
+ return {
91
+ **reserved,
92
+ "execution_mode": "async",
93
+ "accepted": True,
94
+ "events_url": f"/agents/api/runs/{run_id}/events",
95
+ "stop_url": f"/agents/api/runs/{run_id}/stop",
96
+ }
97
+
98
+ async def _run_agent(
99
+ self,
100
+ handle: _RunHandle,
101
+ goal: str,
102
+ *,
103
+ user_email: Optional[str],
104
+ roles: Optional[list[str]],
105
+ inputs: Dict[str, Any],
106
+ max_retries: int,
107
+ ) -> None:
108
+ run_id = handle.run_id
109
+ try:
110
+ if handle.cancel_requested:
111
+ self._cancel_agent_record(run_id, handle.scope, "cancelled before execution started")
112
+ return
113
+ handle.started = True
114
+ payload = await asyncio.to_thread(
115
+ self.agent_runtime.complete_reserved_run,
116
+ run_id,
117
+ goal,
118
+ user_email=user_email,
119
+ scope=handle.scope,
120
+ roles=roles,
121
+ inputs=inputs,
122
+ max_retries=max_retries,
123
+ cancel_requested=lambda: handle.cancel_requested,
124
+ )
125
+ if handle.cancel_requested and (payload.get("run") or {}).get("status") != "cancelled":
126
+ self._cancel_agent_record(run_id, handle.scope, "cancelled after the final result was persisted")
127
+ else:
128
+ self._results[run_id] = payload
129
+ finally:
130
+ self._handles.pop(run_id, None)
131
+
132
+ def _cancel_agent_record(self, run_id: str, scope: Optional[str], reason: str) -> Dict[str, Any]:
133
+ run = self.store.get_agent_run(run_id, workspace_id=scope)
134
+ timeline = list(run.get("timeline") or [])
135
+ timeline.append({"event": "execution_cancelled", "status": "cancelled", "reason": reason, "timestamp": _now()})
136
+ cancelled = self.store.update_agent_run(
137
+ run_id,
138
+ workspace_id=scope,
139
+ status="cancelled",
140
+ current_role=None,
141
+ cancel_reason=reason,
142
+ cancelled_at=_now(),
143
+ output_text=run.get("output_preview") or reason,
144
+ timeline=timeline,
145
+ graph=self.workspace_graph(),
146
+ )
147
+ payload = {"run": cancelled, "result": {"status": "cancelled", "reason": reason}}
148
+ self._results[run_id] = payload
149
+ return cancelled
150
+
151
+ # ── workflow runs ────────────────────────────────────────────────────
152
+
153
+ async def start_workflow(
154
+ self,
155
+ workflow: Dict[str, Any],
156
+ *,
157
+ workflow_id: str,
158
+ user_email: Optional[str],
159
+ scope: Optional[str],
160
+ inputs: Optional[Dict[str, Any]] = None,
161
+ ) -> Dict[str, Any]:
162
+ run = self.store.record_workflow_run(
163
+ workflow_id=workflow_id,
164
+ name=workflow.get("name") or "workflow",
165
+ status="queued",
166
+ timeline=[{"event": "workflow_started", "status": "queued", "timestamp": _now()}],
167
+ outputs={},
168
+ user_email=user_email,
169
+ graph=None,
170
+ workspace_id=scope,
171
+ mode="live",
172
+ )
173
+ run = self.store.update_workflow_run(
174
+ run["id"],
175
+ workspace_id=scope,
176
+ execution_mode="async",
177
+ inputs=inputs or {},
178
+ )
179
+ handle = _RunHandle(run_id=run["id"], kind="workflow", scope=scope)
180
+ handle.task = asyncio.create_task(
181
+ self._run_workflow(handle, workflow, user_email=user_email, inputs=inputs or {})
182
+ )
183
+ self._handles[run["id"]] = handle
184
+ return {
185
+ "run": run,
186
+ "execution_mode": "async",
187
+ "accepted": True,
188
+ "events_url": f"/workflows/api/runs/{run['id']}/replay",
189
+ "stop_url": f"/workflows/api/runs/{run['id']}/stop",
190
+ }
191
+
192
+ async def _run_workflow(
193
+ self,
194
+ handle: _RunHandle,
195
+ workflow: Dict[str, Any],
196
+ *,
197
+ user_email: Optional[str],
198
+ inputs: Dict[str, Any],
199
+ ) -> None:
200
+ run_id = handle.run_id
201
+ try:
202
+ if handle.cancel_requested:
203
+ self._cancel_workflow_record(run_id, handle.scope, "cancelled before execution started")
204
+ return
205
+ handle.started = True
206
+ run = self.store.get_workflow_run(run_id, workspace_id=handle.scope)
207
+ base_timeline = list(run.get("timeline") or [])
208
+ self.store.update_workflow_run(
209
+ run_id,
210
+ workspace_id=handle.scope,
211
+ status="running",
212
+ started_at=run.get("started_at") or _now(),
213
+ )
214
+ result = await asyncio.to_thread(self._execute_workflow_sync, workflow, user_email, handle.scope, inputs)
215
+ if handle.cancel_requested:
216
+ self._cancel_workflow_record(run_id, handle.scope, "cancelled after the current synchronous step completed")
217
+ return
218
+ pause = (
219
+ {"node": result.paused_node, "pending": result.pending_approval, "context": result.paused_context}
220
+ if result.status == "awaiting_approval" else None
221
+ )
222
+ updated = self.store.update_workflow_run(
223
+ run_id,
224
+ workspace_id=handle.scope,
225
+ graph=self.workspace_graph(),
226
+ status=result.status,
227
+ timeline=base_timeline + list(result.timeline or []),
228
+ outputs=result.outputs,
229
+ pause=pause,
230
+ )
231
+ self.append_audit_event(
232
+ "workflow_run",
233
+ user_email=user_email,
234
+ workflow_id=workflow.get("id"),
235
+ status=result.status,
236
+ )
237
+ self._results[run_id] = {"run": updated, "result": result.as_dict()}
238
+ except Exception as exc:
239
+ run = self.store.get_workflow_run(run_id, workspace_id=handle.scope)
240
+ timeline = list(run.get("timeline") or [])
241
+ timeline.append({"event": "execution_failed", "status": "failed", "detail": str(exc), "timestamp": _now()})
242
+ failed = self.store.update_workflow_run(
243
+ run_id,
244
+ workspace_id=handle.scope,
245
+ graph=self.workspace_graph(),
246
+ status="failed",
247
+ timeline=timeline,
248
+ outputs={"error": str(exc)},
249
+ pause=None,
250
+ )
251
+ self._results[run_id] = {"run": failed, "result": {"status": "failed", "error": str(exc)}}
252
+ finally:
253
+ self._handles.pop(run_id, None)
254
+
255
+ def _execute_workflow_sync(
256
+ self,
257
+ workflow: Dict[str, Any],
258
+ user_email: Optional[str],
259
+ scope: Optional[str],
260
+ inputs: Dict[str, Any],
261
+ ) -> Any:
262
+ runners = self.build_workflow_runners(user_email, scope)
263
+ return WorkflowEngine(runners, hooks=self.hooks).run(workflow, inputs=inputs)
264
+
265
+ def _cancel_workflow_record(self, run_id: str, scope: Optional[str], reason: str) -> Dict[str, Any]:
266
+ run = self.store.get_workflow_run(run_id, workspace_id=scope)
267
+ timeline = list(run.get("timeline") or [])
268
+ timeline.append({"event": "execution_cancelled", "status": "cancelled", "reason": reason, "timestamp": _now()})
269
+ cancelled = self.store.update_workflow_run(
270
+ run_id,
271
+ workspace_id=scope,
272
+ status="cancelled",
273
+ cancel_reason=reason,
274
+ cancelled_at=_now(),
275
+ timeline=timeline,
276
+ pause=None,
277
+ graph=self.workspace_graph(),
278
+ )
279
+ payload = {"run": cancelled, "result": {"status": "cancelled", "reason": reason}}
280
+ self._results[run_id] = payload
281
+ return cancelled
282
+
283
+ # ── cancellation/status ──────────────────────────────────────────────
284
+
285
+ def cancel(self, run_id: str, *, kind: Optional[str] = None, scope: Optional[str] = None) -> Dict[str, Any]:
286
+ handle = self._handles.get(run_id)
287
+ try:
288
+ run = (
289
+ self.store.get_workflow_run(run_id, workspace_id=scope)
290
+ if kind == "workflow" or (handle and handle.kind == "workflow")
291
+ else self.store.get_agent_run(run_id, workspace_id=scope)
292
+ )
293
+ except FileNotFoundError:
294
+ return {"stopped": False, "reason": "run not found", "run_id": run_id}
295
+
296
+ status = str(run.get("status") or "")
297
+ if status not in ACTIVE_STATUSES:
298
+ return {"stopped": False, "reason": "run already finished", "run_id": run_id, "status": status}
299
+
300
+ if handle is not None:
301
+ handle.cancel_requested = True
302
+ target_kind = (kind or (handle.kind if handle else "agent"))
303
+ updater = self.store.update_workflow_run if target_kind == "workflow" else self.store.update_agent_run
304
+ updater(
305
+ run_id,
306
+ workspace_id=scope,
307
+ status="cancelling",
308
+ cancel_requested=True,
309
+ cancel_requested_at=_now(),
310
+ )
311
+ if handle is None:
312
+ if target_kind == "workflow":
313
+ self._cancel_workflow_record(run_id, scope, "cancelled; no active worker owned this run")
314
+ else:
315
+ self._cancel_agent_record(run_id, scope, "cancelled; no active worker owned this run")
316
+ return {
317
+ "stopped": True,
318
+ "run_id": run_id,
319
+ "status": "cancelling" if handle is not None else "cancelled",
320
+ "cancellation": "cooperative",
321
+ "reason": "cancellation requested; synchronous work finishes its current step before the final cancelled status is stored",
322
+ }
323
+
324
+ async def wait(self, run_id: str, *, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]:
325
+ handle = self._handles.get(run_id)
326
+ if handle and handle.task:
327
+ await asyncio.wait_for(handle.task, timeout=timeout)
328
+ return self._results.get(run_id)
@@ -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