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
@@ -16,7 +16,7 @@ import logging
16
16
  import re
17
17
  import threading
18
18
  import time
19
- from dataclasses import dataclass, field, asdict
19
+ from dataclasses import dataclass, asdict
20
20
  from typing import Any, Dict, List, Optional, Tuple
21
21
 
22
22
  logger = logging.getLogger(__name__)
@@ -19,7 +19,6 @@
19
19
  from __future__ import annotations
20
20
 
21
21
  import logging
22
- import re
23
22
  from dataclasses import dataclass, field, asdict
24
23
  from enum import Enum
25
24
  from typing import Any, Dict, List, Optional
@@ -14,7 +14,7 @@ from datetime import datetime
14
14
  from typing import Any, Callable, Dict, List, Optional
15
15
 
16
16
 
17
- MULTI_AGENT_VERSION = "3.6.0"
17
+ MULTI_AGENT_VERSION = "4.0.1"
18
18
 
19
19
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
20
20
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -279,10 +279,13 @@ class AgentRunResult:
279
279
  retry_history: List[Dict[str, Any]] = field(default_factory=list)
280
280
  plan_review: Dict[str, Any] = field(default_factory=dict)
281
281
  memory_snapshots: List[Dict[str, Any]] = field(default_factory=list)
282
+ # "simulation" = deterministic LLM-free runner; "llm" = model-driven (v4 runtime).
283
+ mode: str = "simulation"
282
284
 
283
285
  def as_dict(self) -> Dict[str, Any]:
284
286
  return {
285
287
  "agent_id": self.agent_id,
288
+ "mode": self.mode,
286
289
  "status": self.status,
287
290
  "output": self.output,
288
291
  "timeline": self.timeline,
@@ -403,7 +406,221 @@ def default_role_runner(
403
406
  ctx.output = ctx.output or f"Released outcome for: {ctx.goal}"
404
407
  return {"role": role, "released": True, "summary": ctx.output}
405
408
 
406
- return {"role": role, "status": "noop"}
409
+ return {
410
+ "role": role,
411
+ "status": "skipped",
412
+ "reason": "this role has no deterministic behaviour (custom agents require a loaded model)",
413
+ }
414
+
415
+ return runner
416
+
417
+
418
+ def _extract_json_object(raw: str) -> Dict[str, Any]:
419
+ """Parse one JSON object out of an LLM response (fences/prose tolerated)."""
420
+ import json as _json
421
+ import re as _re
422
+
423
+ text = str(raw or "").strip()
424
+ fenced = _re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=_re.DOTALL)
425
+ if fenced:
426
+ text = fenced.group(1)
427
+ elif not text.startswith("{"):
428
+ start, end = text.find("{"), text.rfind("}")
429
+ if start >= 0 and end > start:
430
+ text = text[start : end + 1]
431
+ parsed = _json.loads(text)
432
+ if not isinstance(parsed, dict):
433
+ raise ValueError("model returned JSON that is not an object")
434
+ return parsed
435
+
436
+
437
+ def llm_role_runner(
438
+ *,
439
+ generate: Callable[..., str],
440
+ planner_prompt: str,
441
+ critic_prompt: str,
442
+ context_provider: Optional[Callable[[str], List[str]]] = None,
443
+ workflow_runner: Optional[Callable[..., Any]] = None,
444
+ plugin_runner: Optional[Callable[..., Any]] = None,
445
+ custom_agents: Optional[Dict[str, Dict[str, Any]]] = None,
446
+ ) -> Callable[[str, OrchestrationContext], Dict[str, Any]]:
447
+ """Model-driven role runner — the real Multi-Agent Runtime (T7b).
448
+
449
+ ``generate(message, context, max_tokens, temperature) -> str`` is a
450
+ synchronous bridge to the loaded model. Honesty contract (design-review
451
+ amendment): when the model responds but its plan/critique cannot be
452
+ parsed, the RUN FAILS with the raw output preserved in the records —
453
+ it never silently falls back to fabricated deterministic artifacts.
454
+ """
455
+ base = default_role_runner(
456
+ workflow_runner=workflow_runner,
457
+ plugin_runner=plugin_runner,
458
+ context_provider=context_provider,
459
+ )
460
+
461
+ def _fail(ctx: OrchestrationContext, role: str, reason: str, raw: str) -> Dict[str, Any]:
462
+ ctx.inputs["__llm_failure__"] = {"role": role, "reason": reason, "raw": raw[:2000]}
463
+ ctx.review = {
464
+ "outcome": "reject",
465
+ "verdict": "fail",
466
+ "reason": f"{role}: {reason}",
467
+ "raw_output": raw[:2000],
468
+ "reviewed_at": _now(),
469
+ }
470
+ return {"role": role, "status": "error", "reason": reason, "raw": raw[:2000]}
471
+
472
+ def runner(role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
473
+ failure = ctx.inputs.get("__llm_failure__")
474
+
475
+ custom = (custom_agents or {}).get(role)
476
+ if custom is not None:
477
+ # Executable registry entry (T7e): the agent's persisted config
478
+ # (system_prompt, max_tokens, temperature) is actually loaded.
479
+ cfg = custom.get("config") or {}
480
+ system = str(
481
+ cfg.get("system_prompt")
482
+ or custom.get("description")
483
+ or f"You are {custom.get('name') or role}."
484
+ )
485
+ try:
486
+ out = str(generate(
487
+ ctx.output or ctx.goal,
488
+ context=system,
489
+ max_tokens=int(cfg.get("max_tokens") or 1024),
490
+ temperature=float(cfg.get("temperature") or 0.2),
491
+ ))
492
+ except Exception as exc:
493
+ return _fail(ctx, role, f"custom agent generation failed ({exc})", "")
494
+ ctx.output = out
495
+ return {"role": role, "agent": custom.get("name"), "status": "ok",
496
+ "output": out[:2000]}
497
+
498
+ if role == "planner":
499
+ research = "\n".join(f"- {item}" for item in (ctx.research or [])[:8])
500
+ raw = generate(
501
+ "Produce a JSON execution plan for this goal. Respond with one JSON "
502
+ 'object: {"goal": str, "steps": [{"description": str}, ...]} and nothing else.',
503
+ context=f"{planner_prompt}\n\nGoal: {ctx.goal}\n\nKnown context:\n{research}",
504
+ max_tokens=1024,
505
+ temperature=0.1,
506
+ )
507
+ try:
508
+ parsed = _extract_json_object(str(raw))
509
+ except Exception as exc:
510
+ return _fail(ctx, role, f"plan output unparseable ({exc})", str(raw))
511
+ steps = []
512
+ for i, step in enumerate(parsed.get("steps") or []):
513
+ description = step.get("description") if isinstance(step, dict) else str(step)
514
+ steps.append({"index": i, "description": str(description or f"Step {i + 1}"), "status": "planned"})
515
+ if not steps:
516
+ return _fail(ctx, role, "model returned a plan with no steps", str(raw))
517
+ if ctx.inputs.get("workflow"):
518
+ steps[0]["workflow"] = ctx.inputs.get("workflow")
519
+ if ctx.inputs.get("plugin"):
520
+ steps[0]["plugin"] = ctx.inputs.get("plugin")
521
+ ctx.plan = steps
522
+ ctx.plan_id = f"plan-{abs(hash((ctx.goal, len(steps)))) % 10_000_000}"
523
+ ctx.plan_review = {
524
+ "plan_id": ctx.plan_id,
525
+ "outcome": "approve",
526
+ "reason": "model-generated plan parsed and bounded",
527
+ "reviewed_at": _now(),
528
+ }
529
+ return {"role": role, "plan_id": ctx.plan_id, "steps": len(steps), "plan": steps, "plan_review": ctx.plan_review}
530
+
531
+ if role == "executor":
532
+ if failure:
533
+ return {"role": role, "status": "error", "reason": f"skipped — {failure['role']} failed"}
534
+ results = []
535
+ for step in ctx.plan:
536
+ outcome: Dict[str, Any] = {"index": step["index"], "description": step["description"]}
537
+ wf = step.get("workflow")
538
+ pl = step.get("plugin")
539
+ if wf and workflow_runner is not None:
540
+ try:
541
+ outcome["workflow_result"] = workflow_runner(wf, ctx)
542
+ ctx.workflow_outputs.append(outcome["workflow_result"])
543
+ except Exception as exc:
544
+ outcome["workflow_error"] = str(exc)
545
+ if pl and plugin_runner is not None:
546
+ try:
547
+ outcome["plugin_result"] = plugin_runner(pl, ctx)
548
+ ctx.plugin_outputs.append(outcome["plugin_result"])
549
+ except Exception as exc:
550
+ outcome["plugin_error"] = str(exc)
551
+ try:
552
+ outcome["result"] = str(generate(
553
+ f"Execute this step and return the concrete result only.\n"
554
+ f"Goal: {ctx.goal}\nStep: {step['description']}",
555
+ context="",
556
+ max_tokens=1024,
557
+ temperature=0.2,
558
+ ))[:4000]
559
+ except Exception as exc:
560
+ outcome["error"] = str(exc)
561
+ if outcome.get("workflow_error") or outcome.get("plugin_error") or outcome.get("error"):
562
+ step["status"] = "failed"
563
+ outcome["status"] = "error"
564
+ else:
565
+ step["status"] = "done"
566
+ outcome["status"] = "done"
567
+ results.append(outcome)
568
+ ctx.executed = results
569
+ done = [r for r in results if r.get("status") == "done"]
570
+ ctx.output = "\n\n".join(str(r.get("result") or "") for r in done).strip() or (
571
+ f"Completed {len(done)}/{len(results)} step(s) for: {ctx.goal}"
572
+ )
573
+ return {"role": role, "executed": len(results), "results": results}
574
+
575
+ if role == "reviewer":
576
+ if failure:
577
+ # Fail-closed: an upstream unparseable model output means this
578
+ # run is failed, with the raw output preserved — never rescued
579
+ # by a rubber-stamp review.
580
+ ctx.review = {
581
+ "outcome": "reject",
582
+ "verdict": "fail",
583
+ "reason": f"{failure['role']} output unparseable",
584
+ "raw_output": failure.get("raw"),
585
+ "reviewed_at": _now(),
586
+ }
587
+ return {"role": role, **ctx.review}
588
+ raw = generate(
589
+ "Review this execution. Respond with one JSON object: "
590
+ '{"approve": bool, "reason": str} and nothing else.',
591
+ context=(
592
+ f"{critic_prompt}\n\nGoal: {ctx.goal}\n\n"
593
+ f"Steps: {[s.get('status') for s in ctx.plan]}\n\nOutput:\n{(ctx.output or '')[:3000]}"
594
+ ),
595
+ max_tokens=512,
596
+ temperature=0.1,
597
+ )
598
+ try:
599
+ parsed = _extract_json_object(str(raw))
600
+ approve = bool(parsed.get("approve"))
601
+ reason = str(parsed.get("reason") or "")
602
+ except Exception as exc:
603
+ ctx.review = {
604
+ "outcome": "reject",
605
+ "verdict": "fail",
606
+ "reason": f"critic output unparseable ({exc})",
607
+ "raw_output": str(raw)[:2000],
608
+ "reviewed_at": _now(),
609
+ }
610
+ return {"role": role, **ctx.review}
611
+ ctx.review = {
612
+ "outcome": "approve" if approve else "retry",
613
+ "verdict": "pass" if approve else "retry",
614
+ "reason": reason or ("model approved the result" if approve else "model requested a retry"),
615
+ "confidence": 0.9 if approve else 0.4,
616
+ "notes": [],
617
+ "reviewed_at": _now(),
618
+ }
619
+ return {"role": role, **ctx.review}
620
+
621
+ # researcher / release / anything else: the deterministic behaviour is
622
+ # real work (memory recall, bookkeeping) — reuse it.
623
+ return base(role, ctx)
407
624
 
408
625
  return runner
409
626
 
@@ -411,8 +628,21 @@ def default_role_runner(
411
628
  class MultiAgentOrchestrator:
412
629
  """Drives a role pipeline with handoff, planning, review, and retry."""
413
630
 
414
- def __init__(self, role_runner: Optional[Callable[[str, OrchestrationContext], Dict[str, Any]]] = None):
631
+ def __init__(
632
+ self,
633
+ role_runner: Optional[Callable[[str, OrchestrationContext], Dict[str, Any]]] = None,
634
+ mode: str = "simulation",
635
+ custom_agents: Optional[Dict[str, Dict[str, Any]]] = None,
636
+ ):
415
637
  self.role_runner = role_runner or default_role_runner()
638
+ # Executable registry entries (T7e): a requested role may be a
639
+ # registered custom agent id; its config (system_prompt, …) is
640
+ # actually loaded at run time — registration is no longer a UI illusion.
641
+ self.custom_agents = dict(custom_agents or {})
642
+ # Honest execution-mode label persisted on every run record. The
643
+ # built-in runner never calls a model, so the default is "simulation";
644
+ # an LLM-backed runner must declare mode="llm" explicitly.
645
+ self.mode = mode
416
646
 
417
647
  def _run_role(self, role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
418
648
  started = _now()
@@ -473,7 +703,10 @@ class MultiAgentOrchestrator:
473
703
  workspace_id=workspace_id,
474
704
  inputs=inputs or {},
475
705
  )
476
- pipeline = [r for r in (roles or list(CORE_PIPELINE)) if r in AGENT_ROLES]
706
+ pipeline = [
707
+ r for r in (roles or list(CORE_PIPELINE))
708
+ if r in AGENT_ROLES or r in self.custom_agents
709
+ ]
477
710
  if not pipeline:
478
711
  pipeline = list(CORE_PIPELINE)
479
712
  max_retries = max(0, int(max_retries or 0))
@@ -558,4 +791,5 @@ class MultiAgentOrchestrator:
558
791
  retry_history=ctx.retry_history,
559
792
  plan_review=ctx.plan_review,
560
793
  memory_snapshots=ctx.memory_snapshots,
794
+ mode=self.mode,
561
795
  )
@@ -0,0 +1,54 @@
1
+ """Enforced community RBAC policy for Lattice AI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, Iterable, List, Set
6
+
7
+
8
+ ROLE_CAPABILITIES: Dict[str, Set[str]] = {
9
+ "owner": {"all"},
10
+ "admin": {
11
+ "admin:users",
12
+ "admin:roles",
13
+ "admin:policies",
14
+ "admin:audit",
15
+ "admin:security",
16
+ "workspace:read",
17
+ "workspace:write",
18
+ "workspace:manage",
19
+ "workspace:members",
20
+ "chat",
21
+ "search",
22
+ "files",
23
+ "pipeline",
24
+ },
25
+ "member": {"workspace:read", "workspace:write", "chat", "search", "files", "pipeline"},
26
+ "user": {"workspace:read", "workspace:write", "chat", "search", "files", "pipeline"},
27
+ "viewer": {"workspace:read", "chat", "search"},
28
+ }
29
+
30
+
31
+ def normalize_role(role: str) -> str:
32
+ role = str(role or "user").lower()
33
+ return role if role in ROLE_CAPABILITIES else "user"
34
+
35
+
36
+ def capabilities_for_role(role: str) -> List[str]:
37
+ caps = ROLE_CAPABILITIES.get(normalize_role(role), ROLE_CAPABILITIES["user"])
38
+ return sorted(caps)
39
+
40
+
41
+ def role_has_capability(role: str, capability: str) -> bool:
42
+ caps = ROLE_CAPABILITIES.get(normalize_role(role), ROLE_CAPABILITIES["user"])
43
+ return "all" in caps or capability in caps
44
+
45
+
46
+ def require_capability(role: str, capability: str) -> None:
47
+ if not role_has_capability(role, capability):
48
+ raise PermissionError(f"role '{normalize_role(role)}' lacks capability '{capability}'")
49
+
50
+
51
+ def policy_matrix(roles: Iterable[str] | None = None) -> List[Dict[str, object]]:
52
+ selected = list(roles or ROLE_CAPABILITIES.keys())
53
+ return [{"role": normalize_role(role), "caps": capabilities_for_role(role)} for role in selected]
54
+
@@ -28,6 +28,7 @@ from __future__ import annotations
28
28
 
29
29
  import asyncio
30
30
  import json
31
+ import threading
31
32
  from datetime import datetime
32
33
  from typing import Any, AsyncIterator, Dict, List, Optional, Set
33
34
 
@@ -47,7 +48,7 @@ def sse_format(event: Dict[str, Any]) -> str:
47
48
 
48
49
 
49
50
  class _Subscriber:
50
- __slots__ = ("id", "queue", "workspace_scope", "user", "joined_at")
51
+ __slots__ = ("id", "queue", "workspace_scope", "user", "joined_at", "loop")
51
52
 
52
53
  def __init__(self, sub_id: str, workspace_scope: Optional[Set[str]], user: Optional[str]):
53
54
  self.id = sub_id
@@ -55,6 +56,10 @@ class _Subscriber:
55
56
  self.workspace_scope = workspace_scope
56
57
  self.user = user
57
58
  self.joined_at = _now()
59
+ try:
60
+ self.loop: Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop()
61
+ except RuntimeError:
62
+ self.loop = None
58
63
 
59
64
  def accepts(self, workspace_id: Optional[str]) -> bool:
60
65
  # ``None`` scope = see everything the local user can (personal/unscoped).
@@ -73,6 +78,7 @@ class RealtimeBus:
73
78
  self._feed: List[Dict[str, Any]] = []
74
79
  self._presence: Dict[str, Dict[str, Any]] = {}
75
80
  self._seq = 0
81
+ self._lock = threading.RLock()
76
82
 
77
83
  # ── publishing ────────────────────────────────────────────────────────
78
84
 
@@ -82,34 +88,41 @@ class RealtimeBus:
82
88
  Safe to call from sync code (e.g. the store's timeline hook). Never
83
89
  raises and never blocks the caller.
84
90
  """
85
- self._seq += 1
86
- enriched = {
87
- "seq": self._seq,
88
- "received_at": _now(),
89
- "area": event.get("area", "workspace"),
90
- "event_type": event.get("event_type", "event"),
91
- "workspace_id": event.get("workspace_id"),
92
- "payload": event.get("payload", {}),
93
- **{k: v for k, v in event.items() if k not in {"area", "event_type", "workspace_id", "payload"}},
94
- }
95
- self._feed.append(enriched)
96
- if len(self._feed) > _FEED_LIMIT:
97
- self._feed = self._feed[-_FEED_LIMIT:]
98
-
99
- workspace_id = enriched.get("workspace_id")
100
- for sub in list(self._subscribers.values()):
101
- if not sub.accepts(workspace_id):
102
- continue
103
- try:
104
- sub.queue.put_nowait(enriched)
105
- except asyncio.QueueFull:
106
- try:
107
- sub.queue.get_nowait() # drop oldest
108
- sub.queue.put_nowait(enriched)
109
- except Exception:
110
- pass
91
+ with self._lock:
92
+ self._seq += 1
93
+ enriched = {
94
+ "seq": self._seq,
95
+ "received_at": _now(),
96
+ "area": event.get("area", "workspace"),
97
+ "event_type": event.get("event_type", "event"),
98
+ "workspace_id": event.get("workspace_id"),
99
+ "payload": event.get("payload", {}),
100
+ **{k: v for k, v in event.items() if k not in {"area", "event_type", "workspace_id", "payload"}},
101
+ }
102
+ self._feed.append(enriched)
103
+ if len(self._feed) > _FEED_LIMIT:
104
+ self._feed = self._feed[-_FEED_LIMIT:]
105
+
106
+ workspace_id = enriched.get("workspace_id")
107
+ subscribers = [sub for sub in self._subscribers.values() if sub.accepts(workspace_id)]
108
+ for sub in subscribers:
109
+ if sub.loop is not None and sub.loop.is_running():
110
+ sub.loop.call_soon_threadsafe(self._enqueue, sub, enriched)
111
+ else:
112
+ self._enqueue(sub, enriched)
111
113
  return enriched
112
114
 
115
+ @staticmethod
116
+ def _enqueue(sub: _Subscriber, event: Dict[str, Any]) -> None:
117
+ try:
118
+ sub.queue.put_nowait(event)
119
+ except asyncio.QueueFull:
120
+ try:
121
+ sub.queue.get_nowait() # drop oldest
122
+ sub.queue.put_nowait(event)
123
+ except Exception:
124
+ pass
125
+
113
126
  # The store calls ``event_sink(event)`` positionally; expose a stable alias.
114
127
  def __call__(self, event: Dict[str, Any]) -> Dict[str, Any]:
115
128
  return self.publish(event)
@@ -118,11 +131,13 @@ class RealtimeBus:
118
131
 
119
132
  def add_subscriber(self, sub_id: str, *, workspace_scope: Optional[Set[str]] = None, user: Optional[str] = None) -> _Subscriber:
120
133
  sub = _Subscriber(sub_id, workspace_scope, user)
121
- self._subscribers[sub_id] = sub
134
+ with self._lock:
135
+ self._subscribers[sub_id] = sub
122
136
  return sub
123
137
 
124
138
  def remove_subscriber(self, sub_id: str) -> None:
125
- self._subscribers.pop(sub_id, None)
139
+ with self._lock:
140
+ self._subscribers.pop(sub_id, None)
126
141
 
127
142
  async def stream(self, sub: _Subscriber, *, heartbeat: float = 15.0) -> AsyncIterator[str]:
128
143
  """Yield SSE frames for a subscriber until the client disconnects.
@@ -146,7 +161,8 @@ class RealtimeBus:
146
161
  # ── feed + presence ─────────────────────────────────────────────────────
147
162
 
148
163
  def recent(self, *, limit: int = 50, workspace_scope: Optional[Set[str]] = None) -> List[Dict[str, Any]]:
149
- events = self._feed
164
+ with self._lock:
165
+ events = list(self._feed)
150
166
  if workspace_scope is not None:
151
167
  events = [e for e in events if e.get("workspace_id") is None or e.get("workspace_id") in workspace_scope]
152
168
  return list(reversed(events[-max(1, min(limit, _FEED_LIMIT)):]))
@@ -159,32 +175,37 @@ class RealtimeBus:
159
175
  "joined_at": _now(),
160
176
  "last_seen": _now(),
161
177
  }
162
- self._presence[client_id] = record
178
+ with self._lock:
179
+ self._presence[client_id] = record
163
180
  self.publish({"area": "presence", "event_type": "join", "workspace_id": workspace_id, "payload": {"user": user, "client_id": client_id}})
164
181
  return record
165
182
 
166
183
  def heartbeat(self, client_id: str) -> Optional[Dict[str, Any]]:
167
- record = self._presence.get(client_id)
168
- if record:
169
- record["last_seen"] = _now()
170
- return record
184
+ with self._lock:
185
+ record = self._presence.get(client_id)
186
+ if record:
187
+ record["last_seen"] = _now()
188
+ return record
171
189
 
172
190
  def leave(self, client_id: str) -> None:
173
- record = self._presence.pop(client_id, None)
191
+ with self._lock:
192
+ record = self._presence.pop(client_id, None)
174
193
  if record:
175
194
  self.publish({"area": "presence", "event_type": "leave", "workspace_id": record.get("workspace_id"), "payload": {"client_id": client_id}})
176
195
 
177
196
  def presence(self, *, workspace_scope: Optional[Set[str]] = None) -> List[Dict[str, Any]]:
178
- records = list(self._presence.values())
197
+ with self._lock:
198
+ records = list(self._presence.values())
179
199
  if workspace_scope is not None:
180
200
  records = [r for r in records if r.get("workspace_id") is None or r.get("workspace_id") in workspace_scope]
181
201
  return records
182
202
 
183
203
  def stats(self) -> Dict[str, Any]:
184
- return {
185
- "version": REALTIME_VERSION,
186
- "subscribers": len(self._subscribers),
187
- "presence": len(self._presence),
188
- "feed_size": len(self._feed),
189
- "transport": "sse",
190
- }
204
+ with self._lock:
205
+ return {
206
+ "version": REALTIME_VERSION,
207
+ "subscribers": len(self._subscribers),
208
+ "presence": len(self._presence),
209
+ "feed_size": len(self._feed),
210
+ "transport": "sse",
211
+ }
@@ -6,7 +6,7 @@ import re
6
6
  import secrets
7
7
  import threading
8
8
  import time
9
- from typing import Dict, List, Optional
9
+ from typing import Dict, List
10
10
 
11
11
  from fastapi import HTTPException
12
12
 
@@ -1,5 +1,12 @@
1
- """File-backed session store with sliding-window TTL."""
1
+ """File-backed session store with sliding-window TTL.
2
2
 
3
+ v4: bearer tokens are stored **hashed** (sha256) at rest — a process that can
4
+ read ``sessions.json`` must not be able to hijack every session. Pre-v4 files
5
+ holding raw tokens are migrated transparently on load (sessions survive the
6
+ upgrade; the raw token never touches disk again).
7
+ """
8
+
9
+ import hashlib
3
10
  import json
4
11
  import logging
5
12
  import os
@@ -13,6 +20,16 @@ SESSION_TTL = 60 * 60 * 24 # 24 hours
13
20
  SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump
14
21
  _lock = threading.Lock()
15
22
 
23
+ _HEX64 = frozenset("0123456789abcdef")
24
+
25
+
26
+ def _hash_token(token: str) -> str:
27
+ return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
28
+
29
+
30
+ def _looks_hashed(key: str) -> bool:
31
+ return len(key) == 64 and set(key) <= _HEX64
32
+
16
33
 
17
34
  def _sessions_file(data_dir: Optional[Path] = None) -> Path:
18
35
  d = data_dir or Path(os.getenv("LATTICEAI_DATA_DIR") or (Path.home() / ".ltcai"))
@@ -25,7 +42,19 @@ def load_sessions(data_dir: Optional[Path] = None) -> Dict[str, tuple]:
25
42
  f = _sessions_file(data_dir)
26
43
  if f.exists():
27
44
  raw = json.loads(f.read_text())
28
- return {k: tuple(v) for k, v in raw.items()}
45
+ sessions: Dict[str, tuple] = {}
46
+ migrated = False
47
+ for key, value in raw.items():
48
+ if _looks_hashed(key):
49
+ sessions[key] = tuple(value)
50
+ else:
51
+ # Pre-v4 entry: the key IS the raw bearer token. Re-key it
52
+ # under its hash so the plaintext stops living on disk.
53
+ sessions[_hash_token(key)] = tuple(value)
54
+ migrated = True
55
+ if migrated:
56
+ persist_sessions(sessions, data_dir)
57
+ return sessions
29
58
  except Exception as e:
30
59
  logging.warning("load_sessions failed (starting empty): %s", e)
31
60
  return {}
@@ -38,35 +67,62 @@ def persist_sessions(sessions: Dict[str, tuple], data_dir: Optional[Path] = None
38
67
  logging.warning("persist_sessions failed: %s", e)
39
68
 
40
69
 
70
+ def _entry_subject(entry: tuple) -> Optional[str]:
71
+ return entry[0] if entry else None
72
+
73
+
74
+ def _entry_email(entry: tuple) -> Optional[str]:
75
+ if len(entry) >= 3 and entry[2]:
76
+ return entry[2]
77
+ return entry[0] if entry else None
78
+
79
+
80
+ def _entry_created_at(entry: tuple) -> float:
81
+ if len(entry) >= 2:
82
+ return float(entry[1])
83
+ return 0.0
84
+
85
+
41
86
  class SessionStore:
42
87
  def __init__(self, data_dir: Optional[Path] = None):
43
88
  self._data_dir = data_dir
44
89
  self._sessions: Dict[str, tuple] = load_sessions(data_dir)
45
90
 
46
- def create(self, email: str) -> str:
91
+ def create(self, subject: str, *, email: Optional[str] = None) -> str:
47
92
  token = secrets.token_urlsafe(32)
48
93
  with _lock:
49
- self._sessions[token] = (email, time.time())
94
+ self._sessions[_hash_token(token)] = (subject, time.time(), email or subject)
50
95
  persist_sessions(self._sessions, self._data_dir)
51
96
  return token
52
97
 
53
98
  def get_email(self, token: str) -> Optional[str]:
99
+ entry = self._get_entry(token)
100
+ return _entry_email(entry) if entry else None
101
+
102
+ def get_subject(self, token: str) -> Optional[str]:
103
+ entry = self._get_entry(token)
104
+ return _entry_subject(entry) if entry else None
105
+
106
+ def _get_entry(self, token: str) -> Optional[tuple]:
54
107
  now = time.time()
108
+ key = _hash_token(token)
55
109
  with _lock:
56
- entry = self._sessions.get(token)
110
+ entry = self._sessions.get(key)
57
111
  if entry is None:
58
112
  return None
59
- email, created_at = entry
113
+ created_at = _entry_created_at(entry)
60
114
  if now - created_at > SESSION_TTL:
61
- self._sessions.pop(token, None)
115
+ self._sessions.pop(key, None)
62
116
  persist_sessions(self._sessions, self._data_dir)
63
117
  return None
64
118
  if now - created_at > SESSION_REFRESH_THRESHOLD:
65
- self._sessions[token] = (email, now)
119
+ refreshed = (_entry_subject(entry), now, _entry_email(entry))
120
+ self._sessions[key] = refreshed
66
121
  persist_sessions(self._sessions, self._data_dir)
67
- return email
122
+ return refreshed
123
+ return entry
68
124
 
69
125
  def invalidate(self, token: str) -> None:
70
126
  with _lock:
71
- self._sessions.pop(token, None)
127
+ self._sessions.pop(_hash_token(token), None)
72
128
  persist_sessions(self._sessions, self._data_dir)