ltcai 2.2.7 → 3.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 (122) hide show
  1. package/README.md +72 -34
  2. package/docs/CHANGELOG.md +119 -0
  3. package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
  4. package/docs/V3_FRONTEND.md +139 -0
  5. package/knowledge_graph.py +649 -21
  6. package/latticeai/__init__.py +1 -1
  7. package/latticeai/api/admin.py +47 -0
  8. package/latticeai/api/agents.py +54 -31
  9. package/latticeai/api/auth.py +5 -2
  10. package/latticeai/api/chat.py +10 -2
  11. package/latticeai/api/search.py +240 -0
  12. package/latticeai/api/static_routes.py +11 -2
  13. package/latticeai/core/config.py +18 -0
  14. package/latticeai/core/embedding_providers.py +625 -0
  15. package/latticeai/core/local_embeddings.py +86 -0
  16. package/latticeai/core/workspace_os.py +1 -1
  17. package/latticeai/server_app.py +65 -1
  18. package/latticeai/services/agent_runtime.py +245 -0
  19. package/latticeai/services/search_service.py +346 -0
  20. package/package.json +13 -6
  21. package/scripts/build_v3_assets.mjs +164 -0
  22. package/scripts/capture/README.md +28 -0
  23. package/scripts/capture/capture_enterprise.js +8 -0
  24. package/scripts/capture/capture_graph.js +8 -0
  25. package/scripts/capture/capture_onboarding.js +8 -0
  26. package/scripts/capture/capture_page.js +43 -0
  27. package/scripts/capture/capture_release_media.js +125 -0
  28. package/scripts/capture/capture_skills.js +8 -0
  29. package/scripts/capture/capture_workspace.js +8 -0
  30. package/scripts/generate_diagrams.py +513 -0
  31. package/scripts/lint_v3.mjs +33 -0
  32. package/scripts/release-0.3.1.sh +105 -0
  33. package/scripts/take_screenshots.js +69 -0
  34. package/scripts/validate_release_artifacts.py +167 -0
  35. package/static/account.html +9 -9
  36. package/static/activity.html +4 -4
  37. package/static/admin.html +8 -8
  38. package/static/agents.html +4 -4
  39. package/static/chat.html +10 -10
  40. package/static/css/reference/account.css +137 -1
  41. package/static/css/reference/chat.css +31 -37
  42. package/static/css/responsive.css +42 -0
  43. package/static/css/tokens.5a595671.css +260 -0
  44. package/static/css/tokens.css +125 -130
  45. package/static/graph.html +9 -9
  46. package/static/manifest.json +3 -3
  47. package/static/plugins.html +4 -4
  48. package/static/scripts/account.js +4 -4
  49. package/static/scripts/chat.js +40 -8
  50. package/static/scripts/workspace.js +78 -0
  51. package/static/sw.js +3 -1
  52. package/static/v3/asset-manifest.json +47 -0
  53. package/static/v3/css/lattice.base.css +128 -0
  54. package/static/v3/css/lattice.base.e4cdd05d.css +128 -0
  55. package/static/v3/css/lattice.components.011e988b.css +447 -0
  56. package/static/v3/css/lattice.components.css +447 -0
  57. package/static/v3/css/lattice.shell.4920f42d.css +407 -0
  58. package/static/v3/css/lattice.shell.css +407 -0
  59. package/static/v3/css/lattice.tokens.c597ff81.css +132 -0
  60. package/static/v3/css/lattice.tokens.css +132 -0
  61. package/static/v3/css/lattice.views.3ee19d4e.css +277 -0
  62. package/static/v3/css/lattice.views.css +277 -0
  63. package/static/v3/index.html +69 -0
  64. package/static/v3/js/app.46fb61d9.js +26 -0
  65. package/static/v3/js/app.js +26 -0
  66. package/static/v3/js/core/api.22a41d42.js +344 -0
  67. package/static/v3/js/core/api.js +344 -0
  68. package/static/v3/js/core/components.4c83e0a9.js +222 -0
  69. package/static/v3/js/core/components.js +222 -0
  70. package/static/v3/js/core/dom.a2773eb0.js +148 -0
  71. package/static/v3/js/core/dom.js +148 -0
  72. package/static/v3/js/core/router.584570f2.js +37 -0
  73. package/static/v3/js/core/router.js +37 -0
  74. package/static/v3/js/core/routes.f935dd50.js +78 -0
  75. package/static/v3/js/core/routes.js +78 -0
  76. package/static/v3/js/core/shell.1b6199d6.js +363 -0
  77. package/static/v3/js/core/shell.js +363 -0
  78. package/static/v3/js/core/store.34ebd5e6.js +113 -0
  79. package/static/v3/js/core/store.js +113 -0
  80. package/static/v3/js/views/admin-audit.660a1fb1.js +185 -0
  81. package/static/v3/js/views/admin-audit.js +185 -0
  82. package/static/v3/js/views/admin-permissions.a7ae5f09.js +177 -0
  83. package/static/v3/js/views/admin-permissions.js +177 -0
  84. package/static/v3/js/views/admin-policies.3658fd86.js +102 -0
  85. package/static/v3/js/views/admin-policies.js +102 -0
  86. package/static/v3/js/views/admin-private-vpc.7d342d36.js +135 -0
  87. package/static/v3/js/views/admin-private-vpc.js +135 -0
  88. package/static/v3/js/views/admin-security.07c66b72.js +180 -0
  89. package/static/v3/js/views/admin-security.js +180 -0
  90. package/static/v3/js/views/admin-users.03bac88c.js +168 -0
  91. package/static/v3/js/views/admin-users.js +168 -0
  92. package/static/v3/js/views/agents.14e48bdd.js +193 -0
  93. package/static/v3/js/views/agents.js +193 -0
  94. package/static/v3/js/views/chat.718144ce.js +449 -0
  95. package/static/v3/js/views/chat.js +449 -0
  96. package/static/v3/js/views/files.4935197e.js +186 -0
  97. package/static/v3/js/views/files.js +186 -0
  98. package/static/v3/js/views/home.cdde3b32.js +119 -0
  99. package/static/v3/js/views/home.js +119 -0
  100. package/static/v3/js/views/hybrid-search.b22b97e0.js +195 -0
  101. package/static/v3/js/views/hybrid-search.js +195 -0
  102. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +237 -0
  103. package/static/v3/js/views/knowledge-graph.js +237 -0
  104. package/static/v3/js/views/models.a1ffa147.js +256 -0
  105. package/static/v3/js/views/models.js +256 -0
  106. package/static/v3/js/views/my-computer.1b2ff621.js +237 -0
  107. package/static/v3/js/views/my-computer.js +237 -0
  108. package/static/v3/js/views/pipeline.c522f1ce.js +157 -0
  109. package/static/v3/js/views/pipeline.js +157 -0
  110. package/static/v3/js/views/settings.4f777210.js +250 -0
  111. package/static/v3/js/views/settings.js +250 -0
  112. package/static/workflows.html +4 -4
  113. package/static/workspace.css +340 -2
  114. package/static/workspace.html +43 -24
  115. package/docs/images/tmp_frames/frame_00.png +0 -0
  116. package/docs/images/tmp_frames/frame_01.png +0 -0
  117. package/docs/images/tmp_frames/frame_02.png +0 -0
  118. package/docs/images/tmp_frames/frame_03.png +0 -0
  119. package/docs/images/tmp_frames/hero_00.png +0 -0
  120. package/docs/images/tmp_frames/hero_01.png +0 -0
  121. package/docs/images/tmp_frames/hero_02.png +0 -0
  122. package/docs/images/tmp_frames/hero_03.png +0 -0
@@ -0,0 +1,86 @@
1
+ """Local deterministic embeddings for Lattice AI search.
2
+
3
+ The v3 backend needs a local-first vector signal without introducing a cloud
4
+ runtime requirement. This module provides a small feature-hashing embedder that
5
+ is deterministic, cheap to run, and good enough for indexing/search tests. A
6
+ future runtime can swap the implementation behind the same interface when a
7
+ local model server is available.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import math
14
+ import os
15
+ import re
16
+ import struct
17
+ from dataclasses import dataclass
18
+ from typing import Iterable, List
19
+
20
+
21
+ DEFAULT_EMBEDDING_DIM = int(os.getenv("LATTICEAI_VECTOR_DIM", "384"))
22
+ EMBEDDING_MODEL_ID = f"lattice-local-hash-v1:{DEFAULT_EMBEDDING_DIM}"
23
+
24
+
25
+ def _tokenize(text: str) -> List[str]:
26
+ raw = str(text or "").lower()
27
+ tokens = re.findall(r"[a-z0-9][a-z0-9_.:/+-]{1,}|[가-힣]{2,}", raw)
28
+ features: List[str] = []
29
+ for token in tokens:
30
+ features.append(f"tok:{token}")
31
+ if len(token) >= 5 and re.search(r"[a-z]", token):
32
+ for i in range(0, len(token) - 2):
33
+ features.append(f"tri:{token[i:i+3]}")
34
+ if re.search(r"[가-힣]", token) and len(token) >= 3:
35
+ for i in range(0, len(token) - 1):
36
+ features.append(f"ko:{token[i:i+2]}")
37
+ return features
38
+
39
+
40
+ def _hash_to_index(feature: str, dim: int) -> tuple[int, float]:
41
+ digest = hashlib.blake2b(feature.encode("utf-8"), digest_size=8).digest()
42
+ value = int.from_bytes(digest, "big", signed=False)
43
+ sign = 1.0 if (value & 1) == 0 else -1.0
44
+ return value % dim, sign
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class LocalEmbeddingModel:
49
+ """Deterministic feature-hashing embedder.
50
+
51
+ The output vectors are L2-normalized, so cosine similarity is just a dot
52
+ product. No network access, model download, or global mutable state is
53
+ required.
54
+ """
55
+
56
+ dim: int = DEFAULT_EMBEDDING_DIM
57
+ model_id: str = EMBEDDING_MODEL_ID
58
+
59
+ def embed(self, text: str) -> List[float]:
60
+ vector = [0.0] * self.dim
61
+ features = _tokenize(text)
62
+ if not features:
63
+ return vector
64
+ for feature in features:
65
+ index, sign = _hash_to_index(feature, self.dim)
66
+ vector[index] += sign
67
+ norm = math.sqrt(sum(value * value for value in vector))
68
+ if norm <= 0:
69
+ return vector
70
+ return [value / norm for value in vector]
71
+
72
+ def similarity(self, left: Iterable[float], right: Iterable[float]) -> float:
73
+ return float(sum(a * b for a, b in zip(left, right)))
74
+
75
+ def encode(self, vector: Iterable[float]) -> bytes:
76
+ values = list(vector)
77
+ return struct.pack(f"<{len(values)}f", *values)
78
+
79
+ def decode(self, payload: bytes, dim: int | None = None) -> List[float]:
80
+ if not payload:
81
+ return []
82
+ count = int(dim or self.dim)
83
+ expected = count * 4
84
+ if len(payload) != expected:
85
+ count = len(payload) // 4
86
+ return list(struct.unpack(f"<{count}f", payload[: count * 4]))
@@ -18,7 +18,7 @@ from pathlib import Path
18
18
  from typing import Any, Callable, Dict, Iterable, List, Optional
19
19
 
20
20
 
21
- WORKSPACE_OS_VERSION = "2.2.7"
21
+ WORKSPACE_OS_VERSION = "3.1.0"
22
22
 
23
23
  # Workspace types separate single-user Personal workspaces from shared
24
24
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -72,6 +72,9 @@ from latticeai.core.enterprise import (
72
72
  from latticeai.services.workspace_service import WorkspaceService
73
73
  from latticeai.services.model_service import ModelService
74
74
  from latticeai.services.chat_service import ChatService
75
+ from latticeai.services.search_service import SearchService
76
+ from latticeai.core.embedding_providers import resolve_embedder, resolve_embedding_profile
77
+ from latticeai.services.agent_runtime import AgentRuntime
75
78
  from latticeai.services.model_runtime import (
76
79
  CLOUD_VERIFY_TTL_SECONDS,
77
80
  ENGINE_MODEL_CATALOG,
@@ -105,6 +108,7 @@ from latticeai.api.realtime import create_realtime_router
105
108
  from latticeai.api.marketplace import create_marketplace_router
106
109
  from latticeai.api.models import create_models_router
107
110
  from latticeai.api.chat import create_chat_router
111
+ from latticeai.api.search import create_search_router
108
112
  from latticeai.api.tools import create_tools_router
109
113
  from latticeai.api.static_routes import create_static_routes_router
110
114
  from latticeai.api.garden import create_garden_router
@@ -244,7 +248,37 @@ VPC_FILE = DATA_DIR / "vpc_config.json"
244
248
  MCP_FILE = DATA_DIR / "mcp_installs.json"
245
249
  AUDIT_FILE = DATA_DIR / "audit_log.json"
246
250
  SSO_FILE = DATA_DIR / "sso_config.json"
247
- KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
251
+ # Resolve the configured embedding provider once at startup. Degrades to the
252
+ # offline hash fallback when the requested provider is unavailable, while
253
+ # recording the requested-vs-active provider for the Embeddings status surface.
254
+ try:
255
+ EMBEDDING_PROFILE = resolve_embedding_profile(CONFIG.embedding_profile)
256
+ except ValueError as exc:
257
+ logging.warning("Embedding profile ignored: %s", exc)
258
+ EMBEDDING_PROFILE = {}
259
+ _embedding_provider = CONFIG.embedding_provider
260
+ _embedding_model = CONFIG.embedding_model or str(EMBEDDING_PROFILE.get("model") or "")
261
+ _embedding_dim = CONFIG.embedding_dim or int(EMBEDDING_PROFILE.get("dimensions") or 0)
262
+ if CONFIG.embedding_profile and CONFIG.embedding_provider in {"", "hash", "local", "fallback"}:
263
+ _embedding_provider = str(EMBEDDING_PROFILE.get("provider") or CONFIG.embedding_provider)
264
+
265
+ EMBEDDER = resolve_embedder(
266
+ _embedding_provider,
267
+ model=_embedding_model,
268
+ base_url=CONFIG.embedding_base_url,
269
+ api_key=CONFIG.embedding_api_key,
270
+ dim=_embedding_dim,
271
+ timeout=CONFIG.embedding_timeout,
272
+ extra={"target": CONFIG.embedding_custom_target},
273
+ probe=_embedding_provider not in {"", "hash", "local", "fallback"},
274
+ )
275
+ if EMBEDDER.fell_back:
276
+ logging.warning("Embedding provider %s unavailable: %s", EMBEDDER.requested, EMBEDDER.detail)
277
+ KNOWLEDGE_GRAPH = KnowledgeGraphStore(
278
+ DATA_DIR / "knowledge_graph.sqlite",
279
+ DATA_DIR / "knowledge_graph_blobs",
280
+ embedder=EMBEDDER.provider,
281
+ ) if ENABLE_GRAPH else None
248
282
  LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH) if ENABLE_GRAPH else None
249
283
  # ── v2 Realtime bus: constructed first so the store can fan every timeline
250
284
  # event into the realtime feed via a single additive sink (no per-call wiring).
@@ -782,6 +816,8 @@ def _require_local_approval(
782
816
 
783
817
  def require_admin(request: Request) -> tuple[str, Dict]:
784
818
  users = load_users()
819
+ if not REQUIRE_AUTH:
820
+ return "", users
785
821
  token = _extract_bearer_token(request)
786
822
  if token:
787
823
  email = get_session_email(token)
@@ -1077,6 +1113,7 @@ app.include_router(create_auth_router(
1077
1113
  get_sso_settings=get_sso_settings, get_sso_discovery=_get_sso_discovery,
1078
1114
  public_sso_config=public_sso_config,
1079
1115
  open_registration=OPEN_REGISTRATION, session_ttl=_SESSION_TTL,
1116
+ require_auth=REQUIRE_AUTH,
1080
1117
  ))
1081
1118
 
1082
1119
  def _graph_stats_safe():
@@ -1171,6 +1208,9 @@ def _workspace_graph():
1171
1208
  return KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None
1172
1209
 
1173
1210
 
1211
+ SEARCH_SERVICE = SearchService(graph_store=_workspace_graph())
1212
+
1213
+
1174
1214
  # ── Workspace OS + Organization router (latticeai.api.workspace, v1.2.0) ──────
1175
1215
  app.include_router(create_workspace_router(
1176
1216
  service=WORKSPACE_SERVICE,
@@ -1217,6 +1257,14 @@ PLATFORM = PlatformRuntime(
1217
1257
  get_tool_permission=get_tool_permission,
1218
1258
  )
1219
1259
 
1260
+ # Single AgentRuntime boundary over the orchestrator + run store.
1261
+ AGENT_RUNTIME = AgentRuntime(
1262
+ store=WORKSPACE_OS,
1263
+ orchestrator_factory=PLATFORM.build_orchestrator,
1264
+ workspace_graph=_workspace_graph,
1265
+ append_audit_event=append_audit_event,
1266
+ )
1267
+
1220
1268
  app.include_router(create_plugins_router(
1221
1269
  registry=PLUGIN_REGISTRY,
1222
1270
  require_user=require_user,
@@ -1252,6 +1300,7 @@ app.include_router(create_agents_router(
1252
1300
  append_audit_event=append_audit_event,
1253
1301
  ui_file_response=ui_file_response,
1254
1302
  static_dir=STATIC_DIR,
1303
+ agent_runtime=AGENT_RUNTIME,
1255
1304
  ))
1256
1305
 
1257
1306
  app.include_router(create_marketplace_router(
@@ -1351,6 +1400,21 @@ app.include_router(create_chat_router(
1351
1400
  base_dir=BASE_DIR,
1352
1401
  ))
1353
1402
 
1403
+ def _embedding_info() -> dict:
1404
+ from latticeai.core.embedding_providers import PROVIDER_TYPES, embedding_provider_profiles
1405
+ info = EMBEDDER.as_dict()
1406
+ info["available_providers"] = list(PROVIDER_TYPES)
1407
+ info["profile"] = CONFIG.embedding_profile or ""
1408
+ info["profiles"] = embedding_provider_profiles()
1409
+ return info
1410
+
1411
+
1412
+ app.include_router(create_search_router(
1413
+ service=SEARCH_SERVICE,
1414
+ require_user=require_user,
1415
+ embedding_info=_embedding_info,
1416
+ ))
1417
+
1354
1418
  app.include_router(create_tools_router(
1355
1419
  config=CONFIG,
1356
1420
  data_dir=DATA_DIR,
@@ -0,0 +1,245 @@
1
+ """AgentRuntime — the single boundary for agent execution and observability.
2
+
3
+ Before this module the agent concern was spread across three places: the
4
+ :class:`~latticeai.core.multi_agent.MultiAgentOrchestrator` (role pipeline),
5
+ the :class:`~latticeai.services.platform_runtime.PlatformRuntime` (cross-system
6
+ wiring + an ad-hoc ``run_agent``), and ``api/agents.py`` (HTTP transport that
7
+ also owned orchestration + persistence + audit). The frontend reached past all
8
+ of them into the workspace store via ``/workspace/agents``.
9
+
10
+ ``AgentRuntime`` collapses that into one façade with a small, stable surface:
11
+
12
+ * **configuration** — :meth:`config`, :meth:`roles`
13
+ * **status / health** — :meth:`status`, :meth:`health`
14
+ * **execution** — :meth:`start`, :meth:`stop`
15
+ * **events / state** — :meth:`list_runs`, :meth:`get_run`, :meth:`events`, :meth:`replay`
16
+
17
+ It *wraps* the existing orchestrator and run store rather than reimplementing
18
+ them — execution semantics are unchanged, but every caller (HTTP router and, via
19
+ it, the frontend) now depends on this boundary instead of internal paths.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Callable, Dict, List, Optional
25
+
26
+ from latticeai.core.multi_agent import (
27
+ AGENT_ROLES,
28
+ CORE_PIPELINE,
29
+ MULTI_AGENT_VERSION,
30
+ ROLE_AGENT_IDS,
31
+ )
32
+
33
+ ROLE_DESCRIPTIONS = {
34
+ "researcher": "Gathers workspace context and memory for the goal.",
35
+ "planner": "Decomposes the goal into an ordered, bounded plan.",
36
+ "executor": "Executes each planned step, invoking tools and workflows.",
37
+ "reviewer": "Reviews the executed work and approves, rejects, or retries.",
38
+ "release": "Finalizes and summarizes the approved outcome.",
39
+ }
40
+
41
+ # Run statuses the orchestrator can emit that mean "still working". The default
42
+ # orchestrator runs synchronously, so persisted runs are always terminal; this
43
+ # set lets the runtime report live work if a future async runner lands.
44
+ _ACTIVE_STATUSES = {"running", "in_progress", "queued", "retrying"}
45
+ _TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled"}
46
+
47
+
48
+ class AgentRuntime:
49
+ def __init__(
50
+ self,
51
+ *,
52
+ store: Any,
53
+ orchestrator_factory: Callable[[Optional[str], Optional[str]], Any],
54
+ workspace_graph: Callable[[], Any],
55
+ append_audit_event: Callable[..., None],
56
+ max_retries_cap: int = 5,
57
+ ):
58
+ self._store = store
59
+ self._orchestrator_factory = orchestrator_factory
60
+ self._workspace_graph = workspace_graph
61
+ self._append_audit_event = append_audit_event
62
+ self._max_retries_cap = int(max_retries_cap)
63
+
64
+ # ── configuration ─────────────────────────────────────────────────────
65
+ def config(self) -> Dict[str, Any]:
66
+ return {
67
+ "version": MULTI_AGENT_VERSION,
68
+ "roles": list(AGENT_ROLES),
69
+ "default_pipeline": list(CORE_PIPELINE),
70
+ "max_retries_cap": self._max_retries_cap,
71
+ "execution_mode": "synchronous",
72
+ }
73
+
74
+ def roles(self) -> List[Dict[str, Any]]:
75
+ return [
76
+ {
77
+ "role": role,
78
+ "agent_id": ROLE_AGENT_IDS.get(role, f"agent:{role}"),
79
+ "description": ROLE_DESCRIPTIONS.get(role, ""),
80
+ "terminal": role not in {"researcher", "planner", "executor", "reviewer"},
81
+ }
82
+ for role in AGENT_ROLES
83
+ ]
84
+
85
+ # ── health ────────────────────────────────────────────────────────────
86
+ def health(self) -> Dict[str, Any]:
87
+ checks: Dict[str, Any] = {}
88
+ ok = True
89
+ try:
90
+ self._store.list_agents(workspace_id=None)
91
+ checks["run_store"] = {"status": "ok"}
92
+ except Exception as exc: # pragma: no cover - defensive
93
+ ok = False
94
+ checks["run_store"] = {"status": "error", "detail": str(exc)}
95
+ try:
96
+ self._orchestrator_factory(None, None)
97
+ checks["orchestrator"] = {"status": "ok"}
98
+ except Exception as exc: # pragma: no cover - defensive
99
+ ok = False
100
+ checks["orchestrator"] = {"status": "error", "detail": str(exc)}
101
+ return {"status": "ok" if ok else "degraded", "checks": checks}
102
+
103
+ # ── roster + status ───────────────────────────────────────────────────
104
+ def _roster(self, runs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
105
+ """Canonical role roster enriched with real run statistics."""
106
+ by_agent: Dict[str, Dict[str, Any]] = {}
107
+ for run in runs:
108
+ aid = str(run.get("agent_id") or "")
109
+ entry = by_agent.setdefault(aid, {"runs": 0, "last_status": None, "last_at": None})
110
+ entry["runs"] += 1
111
+ if entry["last_at"] is None: # runs are newest-first
112
+ entry["last_status"] = run.get("status")
113
+ entry["last_at"] = run.get("created_at") or run.get("completed_at")
114
+
115
+ roster: List[Dict[str, Any]] = []
116
+ order = list(CORE_PIPELINE) # planner, executor, reviewer first
117
+ ordered_roles = order + [r for r in AGENT_ROLES if r not in order]
118
+ for role in ordered_roles:
119
+ agent_id = ROLE_AGENT_IDS.get(role, f"agent:{role}")
120
+ stats = by_agent.get(agent_id, {"runs": 0, "last_status": None, "last_at": None})
121
+ handoffs = []
122
+ if role == "planner":
123
+ handoffs = [ROLE_AGENT_IDS["executor"]]
124
+ elif role == "executor":
125
+ handoffs = [ROLE_AGENT_IDS["reviewer"]]
126
+ roster.append({
127
+ "id": agent_id,
128
+ "name": role.capitalize(),
129
+ "role": ROLE_DESCRIPTIONS.get(role, ""),
130
+ "state": "available" if role != "release" else "idle",
131
+ "runs": stats["runs"],
132
+ "last_status": stats["last_status"],
133
+ "last_at": stats["last_at"],
134
+ "handoffs": handoffs,
135
+ })
136
+ return roster
137
+
138
+ def status(self, *, scope: Optional[str] = None) -> Dict[str, Any]:
139
+ try:
140
+ listing = self._store.list_agents(workspace_id=scope)
141
+ except Exception as exc: # pragma: no cover - defensive
142
+ listing = {"agents": [], "runs": [], "error": str(exc)}
143
+ runs = list(listing.get("runs") or [])
144
+ active = sum(1 for r in runs if str(r.get("status")) in _ACTIVE_STATUSES)
145
+ return {
146
+ "runtime": {
147
+ "ready": True,
148
+ "version": MULTI_AGENT_VERSION,
149
+ "execution_mode": "synchronous",
150
+ "default_pipeline": list(CORE_PIPELINE),
151
+ "total_runs": len(runs),
152
+ "active_runs": active,
153
+ },
154
+ "health": self.health(),
155
+ "roles": self.roles(),
156
+ "agents": self._roster(runs),
157
+ "runs": runs[:25],
158
+ }
159
+
160
+ # ── events / state ────────────────────────────────────────────────────
161
+ def list_runs(self, *, scope: Optional[str] = None) -> Dict[str, Any]:
162
+ return self._store.list_agents(workspace_id=scope)
163
+
164
+ def get_run(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
165
+ return {"run": self._store.get_agent_run(run_id, workspace_id=scope)}
166
+
167
+ def replay(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
168
+ return {"replay": self._store.replay_agent_run(run_id, workspace_id=scope)}
169
+
170
+ def events(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
171
+ run = self._store.get_agent_run(run_id, workspace_id=scope)
172
+ status = str(run.get("status") or "")
173
+ return {
174
+ "run_id": run_id,
175
+ "status": status,
176
+ "is_final": status in _TERMINAL_STATUSES or status not in _ACTIVE_STATUSES,
177
+ "current_role": run.get("current_role"),
178
+ "timeline": run.get("timeline") or [],
179
+ "handoffs": run.get("handoffs") or [],
180
+ }
181
+
182
+ # ── execution ─────────────────────────────────────────────────────────
183
+ def start(
184
+ self,
185
+ goal: str,
186
+ *,
187
+ user_email: Optional[str],
188
+ scope: Optional[str],
189
+ roles: Optional[List[str]] = None,
190
+ inputs: Optional[Dict[str, Any]] = None,
191
+ max_retries: int = 2,
192
+ ) -> Dict[str, Any]:
193
+ if not str(goal or "").strip():
194
+ raise ValueError("goal is required")
195
+ orchestrator = self._orchestrator_factory(user_email or None, scope)
196
+ result = orchestrator.run(
197
+ goal,
198
+ user_email=user_email or None,
199
+ workspace_id=scope,
200
+ inputs=inputs or {},
201
+ roles=roles or None,
202
+ max_retries=max(0, min(int(max_retries or 0), self._max_retries_cap)),
203
+ )
204
+ run = self._store.record_agent_run(
205
+ agent_id=result.agent_id,
206
+ status=result.status,
207
+ input_text=goal,
208
+ output_text=result.output,
209
+ timeline=result.timeline,
210
+ relationships=[ROLE_AGENT_IDS.get(r, f"agent:{r}") for r in result.roles_run],
211
+ handoffs=result.handoffs,
212
+ context_packets=result.context_packets,
213
+ plan=result.plan,
214
+ plan_review=result.plan_review,
215
+ review_history=result.review_history,
216
+ retry_history=result.retry_history,
217
+ memory_snapshots=result.memory_snapshots,
218
+ user_email=user_email or None,
219
+ graph=self._workspace_graph(),
220
+ workspace_id=scope,
221
+ )
222
+ self._append_audit_event(
223
+ "multi_agent_run",
224
+ user_email=user_email,
225
+ agent_id=result.agent_id,
226
+ status=result.status,
227
+ retries=result.retries,
228
+ )
229
+ return {"run": run, "result": result.as_dict()}
230
+
231
+ def stop(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
232
+ """Best-effort stop.
233
+
234
+ The default runtime executes synchronously, so by the time a run id
235
+ exists the run has already completed. Report that honestly rather than
236
+ pretending a cancellation occurred.
237
+ """
238
+ try:
239
+ run = self._store.get_agent_run(run_id, workspace_id=scope)
240
+ except FileNotFoundError:
241
+ return {"stopped": False, "reason": "run not found", "run_id": run_id}
242
+ status = str(run.get("status") or "")
243
+ if status in _ACTIVE_STATUSES:
244
+ return {"stopped": False, "reason": "asynchronous cancellation is not supported by the synchronous runtime", "run_id": run_id, "status": status}
245
+ return {"stopped": False, "reason": "run already finished", "run_id": run_id, "status": status}