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.
- package/README.md +72 -34
- package/docs/CHANGELOG.md +119 -0
- package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
- package/docs/V3_FRONTEND.md +139 -0
- package/knowledge_graph.py +649 -21
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +47 -0
- package/latticeai/api/agents.py +54 -31
- package/latticeai/api/auth.py +5 -2
- package/latticeai/api/chat.py +10 -2
- package/latticeai/api/search.py +240 -0
- package/latticeai/api/static_routes.py +11 -2
- package/latticeai/core/config.py +18 -0
- package/latticeai/core/embedding_providers.py +625 -0
- package/latticeai/core/local_embeddings.py +86 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +65 -1
- package/latticeai/services/agent_runtime.py +245 -0
- package/latticeai/services/search_service.py +346 -0
- package/package.json +13 -6
- package/scripts/build_v3_assets.mjs +164 -0
- package/scripts/capture/README.md +28 -0
- package/scripts/capture/capture_enterprise.js +8 -0
- package/scripts/capture/capture_graph.js +8 -0
- package/scripts/capture/capture_onboarding.js +8 -0
- package/scripts/capture/capture_page.js +43 -0
- package/scripts/capture/capture_release_media.js +125 -0
- package/scripts/capture/capture_skills.js +8 -0
- package/scripts/capture/capture_workspace.js +8 -0
- package/scripts/generate_diagrams.py +513 -0
- package/scripts/lint_v3.mjs +33 -0
- package/scripts/release-0.3.1.sh +105 -0
- package/scripts/take_screenshots.js +69 -0
- package/scripts/validate_release_artifacts.py +167 -0
- package/static/account.html +9 -9
- package/static/activity.html +4 -4
- package/static/admin.html +8 -8
- package/static/agents.html +4 -4
- package/static/chat.html +10 -10
- package/static/css/reference/account.css +137 -1
- package/static/css/reference/chat.css +31 -37
- package/static/css/responsive.css +42 -0
- package/static/css/tokens.5a595671.css +260 -0
- package/static/css/tokens.css +125 -130
- package/static/graph.html +9 -9
- package/static/manifest.json +3 -3
- package/static/plugins.html +4 -4
- package/static/scripts/account.js +4 -4
- package/static/scripts/chat.js +40 -8
- package/static/scripts/workspace.js +78 -0
- package/static/sw.js +3 -1
- package/static/v3/asset-manifest.json +47 -0
- package/static/v3/css/lattice.base.css +128 -0
- package/static/v3/css/lattice.base.e4cdd05d.css +128 -0
- package/static/v3/css/lattice.components.011e988b.css +447 -0
- package/static/v3/css/lattice.components.css +447 -0
- package/static/v3/css/lattice.shell.4920f42d.css +407 -0
- package/static/v3/css/lattice.shell.css +407 -0
- package/static/v3/css/lattice.tokens.c597ff81.css +132 -0
- package/static/v3/css/lattice.tokens.css +132 -0
- package/static/v3/css/lattice.views.3ee19d4e.css +277 -0
- package/static/v3/css/lattice.views.css +277 -0
- package/static/v3/index.html +69 -0
- package/static/v3/js/app.46fb61d9.js +26 -0
- package/static/v3/js/app.js +26 -0
- package/static/v3/js/core/api.22a41d42.js +344 -0
- package/static/v3/js/core/api.js +344 -0
- package/static/v3/js/core/components.4c83e0a9.js +222 -0
- package/static/v3/js/core/components.js +222 -0
- package/static/v3/js/core/dom.a2773eb0.js +148 -0
- package/static/v3/js/core/dom.js +148 -0
- package/static/v3/js/core/router.584570f2.js +37 -0
- package/static/v3/js/core/router.js +37 -0
- package/static/v3/js/core/routes.f935dd50.js +78 -0
- package/static/v3/js/core/routes.js +78 -0
- package/static/v3/js/core/shell.1b6199d6.js +363 -0
- package/static/v3/js/core/shell.js +363 -0
- package/static/v3/js/core/store.34ebd5e6.js +113 -0
- package/static/v3/js/core/store.js +113 -0
- package/static/v3/js/views/admin-audit.660a1fb1.js +185 -0
- package/static/v3/js/views/admin-audit.js +185 -0
- package/static/v3/js/views/admin-permissions.a7ae5f09.js +177 -0
- package/static/v3/js/views/admin-permissions.js +177 -0
- package/static/v3/js/views/admin-policies.3658fd86.js +102 -0
- package/static/v3/js/views/admin-policies.js +102 -0
- package/static/v3/js/views/admin-private-vpc.7d342d36.js +135 -0
- package/static/v3/js/views/admin-private-vpc.js +135 -0
- package/static/v3/js/views/admin-security.07c66b72.js +180 -0
- package/static/v3/js/views/admin-security.js +180 -0
- package/static/v3/js/views/admin-users.03bac88c.js +168 -0
- package/static/v3/js/views/admin-users.js +168 -0
- package/static/v3/js/views/agents.14e48bdd.js +193 -0
- package/static/v3/js/views/agents.js +193 -0
- package/static/v3/js/views/chat.718144ce.js +449 -0
- package/static/v3/js/views/chat.js +449 -0
- package/static/v3/js/views/files.4935197e.js +186 -0
- package/static/v3/js/views/files.js +186 -0
- package/static/v3/js/views/home.cdde3b32.js +119 -0
- package/static/v3/js/views/home.js +119 -0
- package/static/v3/js/views/hybrid-search.b22b97e0.js +195 -0
- package/static/v3/js/views/hybrid-search.js +195 -0
- package/static/v3/js/views/knowledge-graph.a14ea7e7.js +237 -0
- package/static/v3/js/views/knowledge-graph.js +237 -0
- package/static/v3/js/views/models.a1ffa147.js +256 -0
- package/static/v3/js/views/models.js +256 -0
- package/static/v3/js/views/my-computer.1b2ff621.js +237 -0
- package/static/v3/js/views/my-computer.js +237 -0
- package/static/v3/js/views/pipeline.c522f1ce.js +157 -0
- package/static/v3/js/views/pipeline.js +157 -0
- package/static/v3/js/views/settings.4f777210.js +250 -0
- package/static/v3/js/views/settings.js +250 -0
- package/static/workflows.html +4 -4
- package/static/workspace.css +340 -2
- package/static/workspace.html +43 -24
- package/docs/images/tmp_frames/frame_00.png +0 -0
- package/docs/images/tmp_frames/frame_01.png +0 -0
- package/docs/images/tmp_frames/frame_02.png +0 -0
- package/docs/images/tmp_frames/frame_03.png +0 -0
- package/docs/images/tmp_frames/hero_00.png +0 -0
- package/docs/images/tmp_frames/hero_01.png +0 -0
- package/docs/images/tmp_frames/hero_02.png +0 -0
- 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 = "
|
|
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
|
package/latticeai/server_app.py
CHANGED
|
@@ -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
|
-
|
|
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}
|