ltcai 2.2.2 → 3.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 (78) hide show
  1. package/README.md +66 -27
  2. package/codex_telegram_bot.py +6 -2
  3. package/docs/CHANGELOG.md +154 -0
  4. package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
  5. package/docs/V3_FRONTEND.md +136 -0
  6. package/knowledge_graph.py +649 -21
  7. package/latticeai/__init__.py +1 -1
  8. package/latticeai/api/admin.py +47 -0
  9. package/latticeai/api/agents.py +54 -31
  10. package/latticeai/api/auth.py +1 -1
  11. package/latticeai/api/chat.py +10 -2
  12. package/latticeai/api/search.py +236 -0
  13. package/latticeai/api/static_routes.py +21 -2
  14. package/latticeai/core/config.py +16 -0
  15. package/latticeai/core/embedding_providers.py +502 -0
  16. package/latticeai/core/local_embeddings.py +86 -0
  17. package/latticeai/core/logging_safety.py +62 -0
  18. package/latticeai/core/workspace_os.py +1 -1
  19. package/latticeai/server_app.py +49 -1
  20. package/latticeai/services/agent_runtime.py +245 -0
  21. package/latticeai/services/search_service.py +346 -0
  22. package/package.json +8 -4
  23. package/static/account.html +9 -4
  24. package/static/activity.html +4 -4
  25. package/static/admin.html +8 -3
  26. package/static/agents.html +4 -4
  27. package/static/chat.html +16 -11
  28. package/static/css/reference/account.css +439 -0
  29. package/static/css/reference/admin.css +610 -0
  30. package/static/css/reference/base.css +1658 -0
  31. package/static/{lattice-reference.css → css/reference/chat.css} +271 -3633
  32. package/static/css/reference/graph.css +1016 -0
  33. package/static/css/responsive.css +248 -1
  34. package/static/css/tokens.css +132 -126
  35. package/static/favicon.ico +0 -0
  36. package/static/graph.html +9 -4
  37. package/static/manifest.json +3 -3
  38. package/static/platform.css +1 -1
  39. package/static/plugins.html +4 -4
  40. package/static/scripts/account.js +4 -4
  41. package/static/scripts/chat.js +227 -77
  42. package/static/scripts/workspace.js +78 -0
  43. package/static/sw.js +5 -3
  44. package/static/v3/css/lattice.base.css +128 -0
  45. package/static/v3/css/lattice.components.css +447 -0
  46. package/static/v3/css/lattice.shell.css +407 -0
  47. package/static/v3/css/lattice.tokens.css +132 -0
  48. package/static/v3/css/lattice.views.css +277 -0
  49. package/static/v3/index.html +40 -0
  50. package/static/v3/js/app.js +26 -0
  51. package/static/v3/js/core/api.js +327 -0
  52. package/static/v3/js/core/components.js +215 -0
  53. package/static/v3/js/core/dom.js +148 -0
  54. package/static/v3/js/core/fixtures.js +171 -0
  55. package/static/v3/js/core/router.js +37 -0
  56. package/static/v3/js/core/routes.js +73 -0
  57. package/static/v3/js/core/shell.js +363 -0
  58. package/static/v3/js/core/store.js +113 -0
  59. package/static/v3/js/views/admin-audit.js +185 -0
  60. package/static/v3/js/views/admin-permissions.js +178 -0
  61. package/static/v3/js/views/admin-policies.js +103 -0
  62. package/static/v3/js/views/admin-private-vpc.js +138 -0
  63. package/static/v3/js/views/admin-security.js +181 -0
  64. package/static/v3/js/views/admin-users.js +168 -0
  65. package/static/v3/js/views/agents.js +194 -0
  66. package/static/v3/js/views/chat.js +450 -0
  67. package/static/v3/js/views/files.js +180 -0
  68. package/static/v3/js/views/home.js +119 -0
  69. package/static/v3/js/views/hybrid-search.js +195 -0
  70. package/static/v3/js/views/knowledge-graph.js +238 -0
  71. package/static/v3/js/views/models.js +247 -0
  72. package/static/v3/js/views/my-computer.js +237 -0
  73. package/static/v3/js/views/pipeline.js +161 -0
  74. package/static/v3/js/views/settings.js +258 -0
  75. package/static/workflows.html +4 -4
  76. package/static/workspace.css +408 -14
  77. package/static/workspace.html +43 -24
  78. package/telegram_bot.py +18 -14
@@ -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
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,26 @@ 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
+ EMBEDDER = resolve_embedder(
255
+ CONFIG.embedding_provider,
256
+ model=CONFIG.embedding_model,
257
+ base_url=CONFIG.embedding_base_url,
258
+ api_key=CONFIG.embedding_api_key,
259
+ dim=CONFIG.embedding_dim,
260
+ timeout=CONFIG.embedding_timeout,
261
+ extra={"target": CONFIG.embedding_custom_target},
262
+ probe=CONFIG.embedding_provider not in {"", "hash", "local", "fallback"},
263
+ )
264
+ if EMBEDDER.fell_back:
265
+ logging.warning("Embedding provider %s unavailable: %s", EMBEDDER.requested, EMBEDDER.detail)
266
+ KNOWLEDGE_GRAPH = KnowledgeGraphStore(
267
+ DATA_DIR / "knowledge_graph.sqlite",
268
+ DATA_DIR / "knowledge_graph_blobs",
269
+ embedder=EMBEDDER.provider,
270
+ ) if ENABLE_GRAPH else None
248
271
  LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH) if ENABLE_GRAPH else None
249
272
  # ── v2 Realtime bus: constructed first so the store can fan every timeline
250
273
  # event into the realtime feed via a single additive sink (no per-call wiring).
@@ -1171,6 +1194,9 @@ def _workspace_graph():
1171
1194
  return KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None
1172
1195
 
1173
1196
 
1197
+ SEARCH_SERVICE = SearchService(graph_store=_workspace_graph())
1198
+
1199
+
1174
1200
  # ── Workspace OS + Organization router (latticeai.api.workspace, v1.2.0) ──────
1175
1201
  app.include_router(create_workspace_router(
1176
1202
  service=WORKSPACE_SERVICE,
@@ -1217,6 +1243,14 @@ PLATFORM = PlatformRuntime(
1217
1243
  get_tool_permission=get_tool_permission,
1218
1244
  )
1219
1245
 
1246
+ # Single AgentRuntime boundary over the orchestrator + run store.
1247
+ AGENT_RUNTIME = AgentRuntime(
1248
+ store=WORKSPACE_OS,
1249
+ orchestrator_factory=PLATFORM.build_orchestrator,
1250
+ workspace_graph=_workspace_graph,
1251
+ append_audit_event=append_audit_event,
1252
+ )
1253
+
1220
1254
  app.include_router(create_plugins_router(
1221
1255
  registry=PLUGIN_REGISTRY,
1222
1256
  require_user=require_user,
@@ -1252,6 +1286,7 @@ app.include_router(create_agents_router(
1252
1286
  append_audit_event=append_audit_event,
1253
1287
  ui_file_response=ui_file_response,
1254
1288
  static_dir=STATIC_DIR,
1289
+ agent_runtime=AGENT_RUNTIME,
1255
1290
  ))
1256
1291
 
1257
1292
  app.include_router(create_marketplace_router(
@@ -1351,6 +1386,19 @@ app.include_router(create_chat_router(
1351
1386
  base_dir=BASE_DIR,
1352
1387
  ))
1353
1388
 
1389
+ def _embedding_info() -> dict:
1390
+ from latticeai.core.embedding_providers import PROVIDER_TYPES
1391
+ info = EMBEDDER.as_dict()
1392
+ info["available_providers"] = list(PROVIDER_TYPES)
1393
+ return info
1394
+
1395
+
1396
+ app.include_router(create_search_router(
1397
+ service=SEARCH_SERVICE,
1398
+ require_user=require_user,
1399
+ embedding_info=_embedding_info,
1400
+ ))
1401
+
1354
1402
  app.include_router(create_tools_router(
1355
1403
  config=CONFIG,
1356
1404
  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}