ltcai 9.0.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +80 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +146 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +24 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +302 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +19 -9
  71. package/latticeai/core/agent_registry.py +1 -5
  72. package/latticeai/core/config.py +23 -3
  73. package/latticeai/core/context_builder.py +21 -3
  74. package/latticeai/core/invitations.py +1 -4
  75. package/latticeai/core/io_utils.py +9 -1
  76. package/latticeai/core/legacy_compatibility.py +24 -3
  77. package/latticeai/core/marketplace.py +1 -1
  78. package/latticeai/core/plugins.py +15 -0
  79. package/latticeai/core/policy.py +1 -1
  80. package/latticeai/core/realtime.py +38 -15
  81. package/latticeai/core/sessions.py +7 -2
  82. package/latticeai/core/timeutil.py +10 -0
  83. package/latticeai/core/tool_registry.py +90 -18
  84. package/latticeai/core/users.py +1 -5
  85. package/latticeai/core/workspace_graph_trace.py +31 -5
  86. package/latticeai/core/workspace_memory.py +3 -1
  87. package/latticeai/core/workspace_os.py +18 -7
  88. package/latticeai/core/workspace_os_utils.py +0 -5
  89. package/latticeai/core/workspace_permissions.py +2 -1
  90. package/latticeai/core/workspace_plugins.py +1 -1
  91. package/latticeai/core/workspace_runs.py +14 -5
  92. package/latticeai/core/workspace_skills.py +1 -1
  93. package/latticeai/core/workspace_snapshots.py +2 -1
  94. package/latticeai/core/workspace_timeline.py +2 -1
  95. package/latticeai/integrations/telegram_bot.py +94 -43
  96. package/latticeai/models/router.py +130 -36
  97. package/latticeai/runtime/access_runtime.py +62 -4
  98. package/latticeai/runtime/automation_runtime.py +13 -7
  99. package/latticeai/runtime/brain_runtime.py +19 -7
  100. package/latticeai/runtime/chat_wiring.py +2 -0
  101. package/latticeai/runtime/config_runtime.py +58 -26
  102. package/latticeai/runtime/context_runtime.py +16 -4
  103. package/latticeai/runtime/hooks_runtime.py +1 -1
  104. package/latticeai/runtime/model_wiring.py +33 -5
  105. package/latticeai/runtime/namespace_runtime.py +62 -72
  106. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  107. package/latticeai/runtime/review_wiring.py +1 -1
  108. package/latticeai/runtime/router_registration.py +34 -1
  109. package/latticeai/runtime/security_runtime.py +110 -13
  110. package/latticeai/runtime/stages.py +27 -0
  111. package/latticeai/server_app.py +5 -1
  112. package/latticeai/services/architecture_readiness.py +74 -5
  113. package/latticeai/services/brain_automation.py +3 -26
  114. package/latticeai/services/chat_service.py +205 -15
  115. package/latticeai/services/local_knowledge.py +423 -0
  116. package/latticeai/services/memory_service.py +55 -21
  117. package/latticeai/services/model_engines.py +48 -33
  118. package/latticeai/services/model_errors.py +17 -0
  119. package/latticeai/services/model_loading.py +28 -25
  120. package/latticeai/services/model_runtime.py +228 -162
  121. package/latticeai/services/p_reinforce.py +22 -3
  122. package/latticeai/services/platform_runtime.py +83 -23
  123. package/latticeai/services/product_readiness.py +19 -17
  124. package/latticeai/services/review_queue.py +12 -0
  125. package/latticeai/services/router_context.py +1 -0
  126. package/latticeai/services/run_executor.py +4 -9
  127. package/latticeai/services/search_service.py +203 -28
  128. package/latticeai/services/tool_dispatch.py +28 -5
  129. package/latticeai/services/triggers.py +53 -4
  130. package/latticeai/services/upload_service.py +13 -2
  131. package/latticeai/setup/__init__.py +25 -0
  132. package/latticeai/setup/auto_setup.py +857 -0
  133. package/latticeai/setup/wizard.py +1264 -0
  134. package/latticeai/tools/__init__.py +278 -0
  135. package/{tools → latticeai/tools}/commands.py +67 -7
  136. package/{tools → latticeai/tools}/computer.py +1 -1
  137. package/{tools → latticeai/tools}/documents.py +1 -1
  138. package/{tools → latticeai/tools}/filesystem.py +3 -3
  139. package/latticeai/tools/knowledge.py +178 -0
  140. package/{tools → latticeai/tools}/local_files.py +1 -1
  141. package/{tools → latticeai/tools}/network.py +0 -1
  142. package/local_knowledge_api.py +4 -342
  143. package/package.json +22 -2
  144. package/scripts/brain_quality_eval.py +3 -1
  145. package/scripts/bump_version.py +3 -0
  146. package/scripts/capture_release_evidence.mjs +180 -0
  147. package/scripts/check_current_release_docs.mjs +142 -0
  148. package/scripts/check_i18n_literals.mjs +107 -31
  149. package/scripts/check_openapi_drift.mjs +56 -0
  150. package/scripts/export_openapi.py +67 -7
  151. package/scripts/i18n_literal_allowlist.json +22 -24
  152. package/scripts/run_integration_tests.mjs +72 -10
  153. package/scripts/validate_release_artifacts.py +49 -7
  154. package/scripts/wheel_smoke.py +5 -0
  155. package/setup_wizard.py +3 -1260
  156. package/src-tauri/Cargo.lock +1 -1
  157. package/src-tauri/Cargo.toml +1 -1
  158. package/src-tauri/tauri.conf.json +1 -1
  159. package/static/app/asset-manifest.json +11 -11
  160. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  161. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  162. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  163. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  164. package/static/app/assets/System-80lHW0Ux.js +1 -0
  165. package/static/app/assets/index-BiMofBTM.js +17 -0
  166. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  167. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  168. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  169. package/static/app/index.html +2 -2
  170. package/static/sw.js +1 -1
  171. package/tools/__init__.py +21 -269
  172. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  173. package/latticeai/runtime/app_context_runtime.py +0 -13
  174. package/latticeai/runtime/tail_wiring.py +0 -21
  175. package/scripts/com.pts.claudecode.discord.plist +0 -31
  176. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  177. package/scripts/start-pts-claudecode-discord.sh +0 -51
  178. package/static/app/assets/Act-21lIXx2E.js +0 -1
  179. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  180. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  181. package/static/app/assets/Library-bFMtyni3.js +0 -1
  182. package/static/app/assets/System-K6krGCqn.js +0 -1
  183. package/static/app/assets/index-C4R3ws30.js +0 -17
  184. package/static/app/assets/index-ChSeOB02.css +0 -2
  185. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  186. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  187. package/tools/knowledge.py +0 -95
@@ -19,7 +19,6 @@ import shutil
19
19
  import sqlite3
20
20
  import tempfile
21
21
  import zipfile
22
- from datetime import datetime, timezone
23
22
  from pathlib import Path, PurePosixPath
24
23
  from typing import Any, Dict, Optional
25
24
 
@@ -30,18 +29,15 @@ from .storage import (
30
29
  SQLiteToPostgresMigrator,
31
30
  )
32
31
  from .utils import sha256_file as _sha256_file
32
+ from .utils import utc_now_iso
33
33
 
34
34
  FORMAT = "latticeai.kg.export"
35
35
  FORMAT_VERSION = 1
36
36
  BACKUP_FORMAT = "latticeai.kg.backup"
37
37
 
38
38
 
39
- def _now_iso() -> str:
40
- return datetime.now(timezone.utc).isoformat()
41
-
42
-
43
39
  def _stamp() -> str:
44
- return _now_iso().replace(":", "").replace("-", "").replace(".", "")[:15]
40
+ return utc_now_iso().replace(":", "").replace("-", "").replace(".", "")[:15]
45
41
 
46
42
 
47
43
  def _safe_zip_names(names) -> None:
@@ -160,15 +156,24 @@ class KGPortabilityService:
160
156
  raise RuntimeError("Knowledge Graph is disabled (LATTICEAI_ENABLE_GRAPH).")
161
157
 
162
158
  # ── logical export / import ──────────────────────────────────────────────
163
- def export(self, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
159
+ def export(
160
+ self,
161
+ *,
162
+ workspace_id: Optional[str] = None,
163
+ include_legacy_global: bool = False,
164
+ ) -> Dict[str, Any]:
164
165
  self._require()
165
- data = self._kg.export_graph_data(workspace_id=workspace_id)
166
+ data = self._kg.export_graph_data(
167
+ workspace_id=workspace_id,
168
+ include_legacy_global=include_legacy_global,
169
+ )
166
170
  header = {
167
171
  "format": FORMAT,
168
172
  "format_version": FORMAT_VERSION,
169
173
  **self._kg.schema_versions(),
170
- "exported_at": _now_iso(),
174
+ "exported_at": utc_now_iso(),
171
175
  "workspace_id": workspace_id,
176
+ "include_legacy_global": bool(include_legacy_global),
172
177
  "counts": data.get("counts"),
173
178
  }
174
179
  artifact = {"header": header, **data}
@@ -176,8 +181,17 @@ class KGPortabilityService:
176
181
  artifact["signature"] = self._identity.sign_manifest(header)
177
182
  return artifact
178
183
 
179
- def export_to_file(self, path=None, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
180
- artifact = self.export(workspace_id=workspace_id)
184
+ def export_to_file(
185
+ self,
186
+ path=None,
187
+ *,
188
+ workspace_id: Optional[str] = None,
189
+ include_legacy_global: bool = False,
190
+ ) -> Dict[str, Any]:
191
+ artifact = self.export(
192
+ workspace_id=workspace_id,
193
+ include_legacy_global=include_legacy_global,
194
+ )
181
195
  self._exports_dir.mkdir(parents=True, exist_ok=True)
182
196
  path = Path(path) if path else self._exports_dir / f"kg-export-{_stamp()}.json"
183
197
  path.write_text(json.dumps(artifact, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -204,7 +218,7 @@ class KGPortabilityService:
204
218
  if not dry_run:
205
219
  try:
206
220
  self._kg.record_provenance(
207
- node_id="import:" + str((artifact.get("header") or {}).get("exported_at") or _now_iso()),
221
+ node_id="import:" + str((artifact.get("header") or {}).get("exported_at") or utc_now_iso()),
208
222
  source_type="bundle_import",
209
223
  pipeline="kg-portability",
210
224
  owner=None,
@@ -232,7 +246,7 @@ class KGPortabilityService:
232
246
  "format": BACKUP_FORMAT,
233
247
  "format_version": FORMAT_VERSION,
234
248
  **self._kg.schema_versions(),
235
- "created_at": _now_iso(),
249
+ "created_at": utc_now_iso(),
236
250
  "db_sha256": _sha256_file(db_copy),
237
251
  "has_blobs": Path(self._kg.blob_dir).exists(),
238
252
  }
@@ -333,7 +347,7 @@ class KGPortabilityService:
333
347
  "storage": self.storage_status().get("active", {}),
334
348
  "snapshot": self.snapshot_metadata(),
335
349
  "device_identity": self._identity.describe() if self._identity is not None else {},
336
- "provenance": {"exported_at": _now_iso(), "source": "kg-portability"},
350
+ "provenance": {"exported_at": utc_now_iso(), "source": "kg-portability"},
337
351
  }
338
352
  archive = EncryptedBrainArchive(
339
353
  BrainArchivePaths(
@@ -28,7 +28,6 @@ it, the frontend) now depends on this boundary instead of internal paths.
28
28
 
29
29
  from __future__ import annotations
30
30
 
31
- from datetime import datetime
32
31
  from typing import Any, Callable, Dict, List, Optional
33
32
 
34
33
  from .multi_agent import (
@@ -45,6 +44,11 @@ from .contracts import (
45
44
  run_record_contract,
46
45
  runtime_boundary_contract,
47
46
  )
47
+ from .statuses import (
48
+ RUN_ACTIVE_STATUSES as _ACTIVE_STATUSES,
49
+ RUN_TERMINAL_STATUSES as _TERMINAL_STATUSES,
50
+ )
51
+ from ..utils import now_iso as _now
48
52
 
49
53
  ROLE_DESCRIPTIONS = {
50
54
  "researcher": "Gathers workspace context and memory for the goal.",
@@ -57,14 +61,6 @@ ROLE_DESCRIPTIONS = {
57
61
  # Run statuses the orchestrator can emit that mean "still working". The default
58
62
  # orchestrator runs synchronously, so persisted runs are always terminal; this
59
63
  # set lets the runtime report live work if a future async runner lands.
60
- _ACTIVE_STATUSES = {"running", "in_progress", "queued", "retrying", "cancelling"}
61
- _TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
62
-
63
-
64
- def _now() -> str:
65
- return datetime.now().isoformat(timespec="seconds")
66
-
67
-
68
64
  def _compact_text(value: Any, *, limit: int) -> str:
69
65
  return " ".join(str(value or "").split())[:limit]
70
66
 
@@ -25,6 +25,7 @@ order.
25
25
  from __future__ import annotations
26
26
 
27
27
  import json
28
+ import logging
28
29
  import os
29
30
  import shlex
30
31
  import subprocess
@@ -32,10 +33,14 @@ import tempfile
32
33
  import threading
33
34
  import time
34
35
  from collections import deque
35
- from datetime import datetime
36
36
  from pathlib import Path
37
37
  from typing import Any, Callable, Dict, List, Optional
38
38
 
39
+ from ..utils import now_iso as _now
40
+
41
+
42
+ LOGGER = logging.getLogger(__name__)
43
+
39
44
 
40
45
  HOOK_KINDS = (
41
46
  "pre_run",
@@ -63,10 +68,6 @@ LEGACY_KIND_ALIASES = {
63
68
  HOOK_STATUSES = ("ok", "blocked", "error", "skipped", "advisory")
64
69
 
65
70
 
66
- def _now() -> str:
67
- return datetime.now().isoformat(timespec="seconds")
68
-
69
-
70
71
  class HookContext:
71
72
  """Mutable execution context handed to every hook in a dispatch.
72
73
 
@@ -213,7 +214,8 @@ def dispatch_tool(
213
214
  return run_fn()
214
215
  try:
215
216
  arg_keys = list(args.keys()) if isinstance(args, dict) else []
216
- except Exception:
217
+ except Exception as exc:
218
+ LOGGER.debug("tool argument metadata could not be inspected: %s", exc)
217
219
  arg_keys = []
218
220
  pre = hooks.fire_hook(
219
221
  "pre_tool", f"tool.{tool_name}",
@@ -341,8 +343,8 @@ class HooksRegistry:
341
343
  data.setdefault("custom", [])
342
344
  data.setdefault("overrides", {})
343
345
  return data
344
- except Exception:
345
- pass
346
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
347
+ LOGGER.warning("hook registry state is unreadable at %s: %s", self._path, exc)
346
348
  return {"custom": [], "overrides": {}}
347
349
 
348
350
  def _save(self) -> None:
@@ -626,6 +628,7 @@ class HooksRegistry:
626
628
  user_email=user_email, workspace_id=workspace_id, metadata=metadata,
627
629
  )
628
630
  except Exception as exc: # pragma: no cover - defensive
631
+ LOGGER.exception("hook dispatch failed before execution: %s", kind)
629
632
  return {"kind": kind, "event": event or kind, "ran": 0, "blocked": False,
630
633
  "block_reason": "", "error": str(exc), "results": [], "generated_at": _now()}
631
634
 
@@ -694,7 +697,21 @@ class HooksRegistry:
694
697
  if not argv:
695
698
  return "skipped", "empty command", "", False
696
699
  ctx_json = json.dumps(context.as_dict(), ensure_ascii=False)
697
- env = dict(os.environ)
700
+ # Command hooks run with an intentionally small environment. In
701
+ # particular, provider keys, database credentials, session secrets,
702
+ # and arbitrary PYTHON*/NODE* injection variables from the server
703
+ # process must never be inherited by a child process. The retained
704
+ # entries are the minimum needed for executable lookup, home-relative
705
+ # tools, locale handling, temporary files, and Windows process startup.
706
+ allowed_env_keys = {
707
+ "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE",
708
+ "TMPDIR", "TMP", "TEMP", "SYSTEMROOT", "WINDIR", "PATHEXT",
709
+ }
710
+ env = {
711
+ key: value
712
+ for key, value in os.environ.items()
713
+ if key.upper() in allowed_env_keys
714
+ }
698
715
  env.update({
699
716
  "LATTICE_HOOK_KIND": context.kind,
700
717
  "LATTICE_HOOK_EVENT": context.event,
@@ -726,8 +743,8 @@ class HooksRegistry:
726
743
  data = json.load(fh)
727
744
  if isinstance(data, list):
728
745
  return data
729
- except Exception:
730
- pass
746
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
747
+ LOGGER.warning("hook run history is unreadable at %s: %s", self._runs_path, exc)
731
748
  return []
732
749
 
733
750
  def _save_runs(self) -> None:
@@ -737,8 +754,8 @@ class HooksRegistry:
737
754
  with os.fdopen(fd, "w", encoding="utf-8") as fh:
738
755
  json.dump(list(self._runs), fh, ensure_ascii=False, indent=2)
739
756
  os.replace(tmp, self._runs_path)
740
- except Exception: # pragma: no cover - the run log is best-effort
741
- pass
757
+ except OSError as exc: # pragma: no cover - the run log is best-effort
758
+ LOGGER.warning("hook run history write failed at %s: %s", self._runs_path, exc)
742
759
 
743
760
  def _record_run(self, result: HookResult, context: HookContext) -> None:
744
761
  entry = result.as_dict()
@@ -15,13 +15,13 @@ installations can exercise the full Planner -> Executor -> Reviewer loop.
15
15
  from __future__ import annotations
16
16
 
17
17
  from dataclasses import dataclass, field
18
- from datetime import datetime
19
18
  from typing import Any, Callable, Dict, List, Optional
20
19
 
21
20
  from .contracts import multi_agent_contract
21
+ from ..utils import now_iso as _now
22
22
 
23
23
 
24
- MULTI_AGENT_VERSION = "9.0.0"
24
+ MULTI_AGENT_VERSION = "9.1.0"
25
25
 
26
26
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
27
27
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -50,10 +50,6 @@ REVIEW_OUTCOMES = ("approve", "reject", "retry")
50
50
  _SECRET_KEYS = ("secret", "token", "password", "api_key", "apikey", "credential")
51
51
 
52
52
 
53
- def _now() -> str:
54
- return datetime.now().isoformat(timespec="seconds")
55
-
56
-
57
53
  def _redact(value: Any) -> Any:
58
54
  """Return a JSON-safe value with obvious secret fields redacted."""
59
55
  if isinstance(value, dict):
@@ -668,7 +664,7 @@ class MultiAgentOrchestrator:
668
664
  result = self.role_runner(role, ctx) or {}
669
665
  status = result.get("status", "ok")
670
666
  except Exception as exc:
671
- result = {"error": str(exc)}
667
+ result = {"status": "error", "error": str(exc)}
672
668
  status = "error"
673
669
  if role == "reviewer":
674
670
  review = dict(ctx.review or result)
@@ -739,9 +735,32 @@ class MultiAgentOrchestrator:
739
735
  role = pipeline[index]
740
736
  if previous is not None:
741
737
  ctx.handoff(previous, role)
742
- self._run_role(role, ctx)
738
+ role_result = self._run_role(role, ctx)
743
739
  roles_run.append(role)
744
740
 
741
+ # A role exception is terminal for this attempt. Continuing would
742
+ # let downstream roles review stale or missing output and could
743
+ # incorrectly convert a failed run into an approved one.
744
+ if str(role_result.get("status") or "").lower() == "error":
745
+ reason = str(role_result.get("error") or f"{role} role failed")
746
+ existing_review = dict(ctx.review or {})
747
+ ctx.review = existing_review or {
748
+ "outcome": "reject",
749
+ "verdict": "fail",
750
+ "reason": reason,
751
+ "notes": [f"{role} did not complete"],
752
+ "reviewed_at": _now(),
753
+ }
754
+ ctx.timeline.append(
755
+ {
756
+ "event": "execution_failed",
757
+ "role": role,
758
+ "reason": reason,
759
+ "timestamp": _now(),
760
+ }
761
+ )
762
+ break
763
+
745
764
  if role == "reviewer":
746
765
  review = dict(ctx.review or {})
747
766
  outcome = _review_outcome(review)
@@ -0,0 +1,10 @@
1
+ """Canonical lifecycle states shared by persisted agent and workflow runs."""
2
+
3
+ RUN_ACTIVE_STATUSES = frozenset(
4
+ {"queued", "running", "in_progress", "retrying", "cancelling"}
5
+ )
6
+ RUN_TERMINAL_STATUSES = frozenset(
7
+ {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
8
+ )
9
+
10
+ __all__ = ["RUN_ACTIVE_STATUSES", "RUN_TERMINAL_STATUSES"]
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import hashlib
6
- from datetime import datetime
6
+ from datetime import datetime, timezone
7
7
  from pathlib import Path
8
8
  from typing import Optional
9
9
 
@@ -17,6 +17,24 @@ def parse_iso(value: Optional[str]) -> Optional[datetime]:
17
17
  return None
18
18
 
19
19
 
20
+ def local_now() -> datetime:
21
+ """Return local wall-clock time for legacy local persistence formats."""
22
+
23
+ return datetime.now()
24
+
25
+
26
+ def now_iso(*, timespec: str = "seconds") -> str:
27
+ """Return one consistently formatted local timestamp."""
28
+
29
+ return local_now().isoformat(timespec=timespec)
30
+
31
+
32
+ def utc_now_iso(*, timespec: str = "auto") -> str:
33
+ """Return an offset-aware UTC timestamp."""
34
+
35
+ return datetime.now(timezone.utc).isoformat(timespec=timespec)
36
+
37
+
20
38
  def sha256_file(path: Path) -> str:
21
39
  digest = hashlib.sha256()
22
40
  with open(path, "rb") as fh:
@@ -25,4 +43,4 @@ def sha256_file(path: Path) -> str:
25
43
  return digest.hexdigest()
26
44
 
27
45
 
28
- __all__ = ["parse_iso", "sha256_file"]
46
+ __all__ = ["local_now", "now_iso", "parse_iso", "sha256_file", "utc_now_iso"]
@@ -24,10 +24,10 @@ a linear node chain so existing workflow history keeps working.
24
24
  from __future__ import annotations
25
25
 
26
26
  from dataclasses import dataclass, field
27
- from datetime import datetime
28
27
  from typing import Any, Callable, Dict, List, Optional
29
28
 
30
29
  from lattice_brain.runtime.contracts import runtime_boundary_contract, workflow_run_contract
30
+ from lattice_brain.utils import now_iso as _now
31
31
 
32
32
 
33
33
  WORKFLOW_ENGINE_VERSION = "2.2.0"
@@ -59,10 +59,6 @@ class WorkflowError(Exception):
59
59
  """Raised for invalid workflow definitions."""
60
60
 
61
61
 
62
- def _now() -> str:
63
- return datetime.now().isoformat(timespec="seconds")
64
-
65
-
66
62
  def normalize_definition(workflow: Dict[str, Any]) -> Dict[str, Any]:
67
63
  """Return a node-based definition, lifting legacy ``steps`` lists if needed.
68
64
 
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "9.0.0"
3
+ __version__ = "9.1.0"
@@ -259,7 +259,7 @@ def create_admin_router(
259
259
  {"id": "model_egress", "label": "Model egress",
260
260
  "value": "Local-only by default (no external inference in local mode)", "enforced": True},
261
261
  {"id": "invite_gate", "label": "Invite gate",
262
- "value": "Required for new accounts" if invite_gate_enabled else "Open registration",
262
+ "value": "Signed access gate" if invite_gate_enabled else "Disabled",
263
263
  "enforced": bool(invite_gate_enabled)},
264
264
  {"id": "log_retention", "label": "Log retention",
265
265
  "value": "90 day local audit window with manual export before pruning", "enforced": True},
@@ -14,6 +14,7 @@ from fastapi import APIRouter, HTTPException, Request
14
14
  from pydantic import BaseModel
15
15
 
16
16
  from latticeai.core.agent_registry import AgentRegistry
17
+ from latticeai.core.security import redact_secrets
17
18
 
18
19
 
19
20
  class AgentRegisterRequest(BaseModel):
@@ -34,6 +35,7 @@ def create_agent_registry_router(
34
35
  *,
35
36
  registry: AgentRegistry,
36
37
  require_user: Callable[[Request], str],
38
+ require_admin: Callable[[Request], Any],
37
39
  append_audit_event: Callable[..., None],
38
40
  ) -> APIRouter:
39
41
  router = APIRouter()
@@ -41,7 +43,7 @@ def create_agent_registry_router(
41
43
  @router.get("/agents/api/registry")
42
44
  async def list_registry(request: Request, type: Optional[str] = None):
43
45
  require_user(request)
44
- return registry.list(agent_type=type)
46
+ return redact_secrets(registry.list(agent_type=type))
45
47
 
46
48
  @router.get("/agents/api/registry/capabilities")
47
49
  async def registry_capabilities(request: Request):
@@ -51,11 +53,11 @@ def create_agent_registry_router(
51
53
  @router.get("/agents/api/registry/discover")
52
54
  async def registry_discover(request: Request, capability: str = ""):
53
55
  require_user(request)
54
- return {"capability": capability, "agents": registry.discover(capability)}
56
+ return {"capability": capability, "agents": redact_secrets(registry.discover(capability))}
55
57
 
56
58
  @router.post("/agents/api/registry")
57
59
  async def register_agent(req: AgentRegisterRequest, request: Request):
58
- user = require_user(request)
60
+ user, _ = require_admin(request)
59
61
  try:
60
62
  entry = registry.register(
61
63
  name=req.name,
@@ -68,7 +70,7 @@ def create_agent_registry_router(
68
70
  except ValueError as exc:
69
71
  raise HTTPException(status_code=400, detail=str(exc)) from exc
70
72
  append_audit_event("agent_register", user_email=user, agent_id=entry["id"], type=entry["type"])
71
- return {"agent": entry}
73
+ return {"agent": redact_secrets(entry)}
72
74
 
73
75
  @router.get("/agents/api/registry/{agent_id:path}")
74
76
  async def get_agent(agent_id: str, request: Request):
@@ -76,21 +78,21 @@ def create_agent_registry_router(
76
78
  agent = registry.get(agent_id)
77
79
  if agent is None:
78
80
  raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}")
79
- return {"agent": agent}
81
+ return {"agent": redact_secrets(agent)}
80
82
 
81
83
  @router.patch("/agents/api/registry/{agent_id:path}")
82
84
  async def update_agent(agent_id: str, req: AgentConfigRequest, request: Request):
83
- user = require_user(request)
85
+ user, _ = require_admin(request)
84
86
  try:
85
87
  agent = registry.update_config(agent_id, req.config, enabled=req.enabled)
86
88
  except KeyError as exc:
87
89
  raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") from exc
88
90
  append_audit_event("agent_config", user_email=user, agent_id=agent_id)
89
- return {"agent": agent}
91
+ return {"agent": redact_secrets(agent)}
90
92
 
91
93
  @router.delete("/agents/api/registry/{agent_id:path}")
92
94
  async def remove_agent(agent_id: str, request: Request):
93
- user = require_user(request)
95
+ user, _ = require_admin(request)
94
96
  try:
95
97
  result = registry.remove(agent_id)
96
98
  except KeyError as exc:
@@ -17,6 +17,24 @@ from pydantic import BaseModel
17
17
  from latticeai.api.ui_redirects import app_redirect
18
18
 
19
19
 
20
+ _CORE_EXECUTION_ROLES = ["planner", "executor", "reviewer"]
21
+ _MEMORY_GROUNDED_ROLES = ["researcher", *_CORE_EXECUTION_ROLES]
22
+
23
+
24
+ def _memory_grounded_roles(roles: List[str]) -> Optional[List[str]]:
25
+ """Ground standard user-initiated agent runs in Brain recall first.
26
+
27
+ Explicit specialist/custom pipelines keep their requested shape. The
28
+ desktop Brain and Work surfaces both send the historical three-role core
29
+ sequence, so normalizing it at the API boundary upgrades existing clients
30
+ without a frontend-only compatibility branch.
31
+ """
32
+ requested = list(roles or _CORE_EXECUTION_ROLES)
33
+ if requested == _CORE_EXECUTION_ROLES:
34
+ return list(_MEMORY_GROUNDED_ROLES)
35
+ return requested or None
36
+
37
+
20
38
  class AgentRunRequest(BaseModel):
21
39
  goal: str
22
40
  roles: List[str] = []
@@ -160,13 +178,14 @@ def create_agents_router(
160
178
  async def agent_run(req: AgentRunRequest, request: Request):
161
179
  current_user = require_user(request)
162
180
  scope = gate_write(request)
181
+ grounded_roles = _memory_grounded_roles(req.roles)
163
182
  try:
164
183
  if run_executor is not None:
165
184
  return await run_executor.start_agent(
166
185
  req.goal,
167
186
  user_email=current_user or None,
168
187
  scope=scope,
169
- roles=req.roles or None,
188
+ roles=grounded_roles,
170
189
  inputs=req.inputs,
171
190
  max_retries=req.max_retries,
172
191
  )
@@ -180,7 +199,7 @@ def create_agents_router(
180
199
  req.goal,
181
200
  user_email=current_user or None,
182
201
  scope=scope,
183
- roles=req.roles or None,
202
+ roles=grounded_roles,
184
203
  inputs=req.inputs,
185
204
  max_retries=req.max_retries,
186
205
  )
@@ -199,7 +218,7 @@ def create_agents_router(
199
218
  return runtime.preview(
200
219
  req.goal,
201
220
  scope=scope,
202
- roles=req.roles or None,
221
+ roles=_memory_grounded_roles(req.roles),
203
222
  inputs=req.inputs,
204
223
  max_retries=req.max_retries,
205
224
  )