ltcai 4.0.0 → 4.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 +42 -33
- package/desktop/electron/main.cjs +44 -0
- package/docs/CHANGELOG.md +106 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +65 -0
- package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +70 -0
- package/docs/V4_1_VALIDATION_REPORT.md +47 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +95 -45
- package/docs/kg-schema.md +6 -2
- package/docs/spec-vs-impl.md +10 -10
- package/frontend/index.html +24 -0
- package/frontend/openapi.json +14190 -0
- package/frontend/src/App.tsx +184 -0
- package/frontend/src/api/client.ts +317 -0
- package/frontend/src/api/openapi.ts +16637 -0
- package/frontend/src/components/primitives.tsx +204 -0
- package/frontend/src/components/ui/badge.tsx +27 -0
- package/frontend/src/components/ui/button.tsx +37 -0
- package/frontend/src/components/ui/card.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +16 -0
- package/frontend/src/components/ui/textarea.tsx +16 -0
- package/frontend/src/lib/utils.ts +33 -0
- package/frontend/src/main.tsx +23 -0
- package/frontend/src/pages/Act.tsx +245 -0
- package/frontend/src/pages/Ask.tsx +200 -0
- package/frontend/src/pages/Brain.tsx +267 -0
- package/frontend/src/pages/Capture.tsx +158 -0
- package/frontend/src/pages/Library.tsx +187 -0
- package/frontend/src/pages/System.tsx +344 -0
- package/frontend/src/routes.ts +85 -0
- package/frontend/src/store/appStore.ts +54 -0
- package/frontend/src/styles.css +107 -0
- package/kg_schema.py +2 -603
- package/knowledge_graph.py +37 -4958
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +15 -16
- package/latticeai/api/agents.py +13 -6
- package/latticeai/api/auth.py +19 -11
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +4 -11
- package/latticeai/api/plugins.py +3 -6
- package/latticeai/api/realtime.py +4 -7
- package/latticeai/api/setup.py +5 -4
- package/latticeai/api/static_routes.py +13 -16
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +39 -6
- package/latticeai/api/workspace.py +24 -10
- package/latticeai/app_factory.py +88 -17
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/ingest.py +644 -0
- package/latticeai/brain/projection.py +561 -0
- package/latticeai/brain/provenance.py +401 -0
- package/latticeai/brain/retrieval.py +1316 -0
- package/latticeai/brain/schema.py +640 -0
- package/latticeai/brain/store.py +216 -0
- package/latticeai/brain/write_master.py +225 -0
- package/latticeai/core/invitations.py +131 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/sessions.py +31 -5
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workspace_os.py +420 -20
- package/latticeai/services/agent_runtime.py +242 -4
- package/latticeai/services/run_executor.py +328 -0
- package/latticeai/services/workspace_service.py +27 -19
- package/package.json +54 -27
- package/scripts/build_frontend_assets.mjs +38 -0
- package/scripts/bump_version.py +1 -1
- package/scripts/export_openapi.py +31 -0
- package/scripts/lint_frontend.mjs +86 -0
- package/scripts/run_python.mjs +47 -0
- package/src-tauri/Cargo.lock +4833 -0
- package/src-tauri/Cargo.toml +19 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/default.json +7 -0
- package/src-tauri/src/main.rs +78 -0
- package/src-tauri/tauri.conf.json +36 -0
- package/static/app/asset-manifest.json +32 -0
- package/static/app/assets/core-CwxXejkd.js +2 -0
- package/static/app/assets/core-CwxXejkd.js.map +1 -0
- package/static/app/assets/index-CJRAzNnf.js +333 -0
- package/static/app/assets/index-CJRAzNnf.js.map +1 -0
- package/static/app/assets/index-CSwBBgf4.css +2 -0
- package/static/app/index.html +25 -0
- package/static/manifest.json +2 -2
- package/static/sw.js +4 -4
- package/scripts/build_v3_assets.mjs +0 -170
- package/scripts/lint_v3.mjs +0 -97
- package/static/account.html +0 -113
- package/static/activity.html +0 -73
- package/static/admin.html +0 -486
- package/static/agents.html +0 -139
- package/static/chat.html +0 -841
- package/static/css/reference/account.css +0 -439
- package/static/css/reference/admin.css +0 -610
- package/static/css/reference/base.css +0 -1661
- package/static/css/reference/chat.css +0 -4623
- package/static/css/reference/graph.css +0 -1016
- package/static/css/responsive.css +0 -861
- package/static/graph.html +0 -122
- package/static/platform.css +0 -104
- package/static/plugins.html +0 -136
- package/static/scripts/account.js +0 -238
- package/static/scripts/admin.js +0 -1614
- package/static/scripts/chat.js +0 -5081
- package/static/scripts/graph.js +0 -1804
- package/static/scripts/platform.js +0 -64
- package/static/scripts/ux.js +0 -167
- package/static/scripts/workspace.js +0 -948
- package/static/v3/asset-manifest.json +0 -56
- package/static/v3/css/lattice.base.49deefb5.css +0 -128
- package/static/v3/css/lattice.base.css +0 -128
- package/static/v3/css/lattice.components.cde18231.css +0 -472
- package/static/v3/css/lattice.components.css +0 -472
- package/static/v3/css/lattice.shell.29d36d85.css +0 -452
- package/static/v3/css/lattice.shell.css +0 -452
- package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
- package/static/v3/css/lattice.tokens.css +0 -135
- package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
- package/static/v3/css/lattice.views.css +0 -360
- package/static/v3/index.html +0 -68
- package/static/v3/js/app.356e6452.js +0 -26
- package/static/v3/js/app.js +0 -26
- package/static/v3/js/core/api.7a308b89.js +0 -568
- package/static/v3/js/core/api.js +0 -568
- package/static/v3/js/core/components.f25b3b93.js +0 -230
- package/static/v3/js/core/components.js +0 -230
- package/static/v3/js/core/dom.a2773eb0.js +0 -148
- package/static/v3/js/core/dom.js +0 -148
- package/static/v3/js/core/router.584570f2.js +0 -37
- package/static/v3/js/core/router.js +0 -37
- package/static/v3/js/core/routes.7222343d.js +0 -93
- package/static/v3/js/core/routes.js +0 -93
- package/static/v3/js/core/shell.a1657f20.js +0 -391
- package/static/v3/js/core/shell.js +0 -391
- package/static/v3/js/core/store.204a08b2.js +0 -113
- package/static/v3/js/core/store.js +0 -113
- package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
- package/static/v3/js/views/admin-audit.js +0 -185
- package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
- package/static/v3/js/views/admin-permissions.js +0 -177
- package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
- package/static/v3/js/views/admin-policies.js +0 -102
- package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
- package/static/v3/js/views/admin-private-vpc.js +0 -135
- package/static/v3/js/views/admin-security.07c66b72.js +0 -180
- package/static/v3/js/views/admin-security.js +0 -180
- package/static/v3/js/views/admin-users.03bac88c.js +0 -168
- package/static/v3/js/views/admin-users.js +0 -168
- package/static/v3/js/views/agents.014d0b74.js +0 -541
- package/static/v3/js/views/agents.js +0 -541
- package/static/v3/js/views/chat.e6dd7dd0.js +0 -601
- package/static/v3/js/views/chat.js +0 -601
- package/static/v3/js/views/files.adad14c1.js +0 -365
- package/static/v3/js/views/files.js +0 -365
- package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
- package/static/v3/js/views/graph-canvas.js +0 -509
- package/static/v3/js/views/home.24f8b8ae.js +0 -200
- package/static/v3/js/views/home.js +0 -200
- package/static/v3/js/views/hooks.37895880.js +0 -220
- package/static/v3/js/views/hooks.js +0 -220
- package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
- package/static/v3/js/views/hybrid-search.js +0 -194
- package/static/v3/js/views/knowledge-graph.5e40cbeb.js +0 -509
- package/static/v3/js/views/knowledge-graph.js +0 -509
- package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
- package/static/v3/js/views/marketplace.js +0 -141
- package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
- package/static/v3/js/views/mcp.js +0 -114
- package/static/v3/js/views/memory.4ebdf474.js +0 -147
- package/static/v3/js/views/memory.js +0 -147
- package/static/v3/js/views/models.a1ffa147.js +0 -256
- package/static/v3/js/views/models.js +0 -256
- package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
- package/static/v3/js/views/my-computer.js +0 -463
- package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
- package/static/v3/js/views/pipeline.js +0 -157
- package/static/v3/js/views/planning.9ac3e313.js +0 -153
- package/static/v3/js/views/planning.js +0 -153
- package/static/v3/js/views/settings.8631fa5e.js +0 -318
- package/static/v3/js/views/settings.js +0 -318
- package/static/v3/js/views/skills.c6c2f965.js +0 -109
- package/static/v3/js/views/skills.js +0 -109
- package/static/v3/js/views/tools.e4f11276.js +0 -108
- package/static/v3/js/views/tools.js +0 -108
- package/static/v3/js/views/workflows.26c57290.js +0 -128
- package/static/v3/js/views/workflows.js +0 -128
- package/static/workflows.html +0 -146
- package/static/workspace.css +0 -1121
- package/static/workspace.html +0 -357
|
@@ -21,6 +21,7 @@ it, the frontend) now depends on this boundary instead of internal paths.
|
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
+
from datetime import datetime
|
|
24
25
|
from typing import Any, Callable, Dict, List, Optional
|
|
25
26
|
|
|
26
27
|
from latticeai.core.multi_agent import (
|
|
@@ -41,8 +42,12 @@ ROLE_DESCRIPTIONS = {
|
|
|
41
42
|
# Run statuses the orchestrator can emit that mean "still working". The default
|
|
42
43
|
# orchestrator runs synchronously, so persisted runs are always terminal; this
|
|
43
44
|
# 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"}
|
|
45
|
+
_ACTIVE_STATUSES = {"running", "in_progress", "queued", "retrying", "cancelling"}
|
|
46
|
+
_TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _now() -> str:
|
|
50
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
46
51
|
|
|
47
52
|
|
|
48
53
|
class AgentRuntime:
|
|
@@ -64,6 +69,13 @@ class AgentRuntime:
|
|
|
64
69
|
# Lifecycle hooks registry (optional). When present, ``start`` fires the
|
|
65
70
|
# ``pre_run`` / ``post_run`` hooks; a blocking ``pre_run`` aborts the run.
|
|
66
71
|
self._hooks = hooks
|
|
72
|
+
self._run_executor: Any = None
|
|
73
|
+
|
|
74
|
+
def attach_executor(self, executor: Any) -> None:
|
|
75
|
+
self._run_executor = executor
|
|
76
|
+
|
|
77
|
+
def _execution_mode(self) -> str:
|
|
78
|
+
return "async" if self._run_executor is not None else "synchronous"
|
|
67
79
|
|
|
68
80
|
# ── configuration ─────────────────────────────────────────────────────
|
|
69
81
|
def config(self) -> Dict[str, Any]:
|
|
@@ -72,7 +84,12 @@ class AgentRuntime:
|
|
|
72
84
|
"roles": list(AGENT_ROLES),
|
|
73
85
|
"default_pipeline": list(CORE_PIPELINE),
|
|
74
86
|
"max_retries_cap": self._max_retries_cap,
|
|
75
|
-
"execution_mode":
|
|
87
|
+
"execution_mode": self._execution_mode(),
|
|
88
|
+
"cancellation": (
|
|
89
|
+
"cooperative; running synchronous model/tool calls finish their current step before a cancelled status is persisted"
|
|
90
|
+
if self._run_executor is not None else
|
|
91
|
+
"not supported for the synchronous runtime"
|
|
92
|
+
),
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
def roles(self) -> List[Dict[str, Any]]:
|
|
@@ -150,7 +167,7 @@ class AgentRuntime:
|
|
|
150
167
|
"runtime": {
|
|
151
168
|
"ready": True,
|
|
152
169
|
"version": MULTI_AGENT_VERSION,
|
|
153
|
-
"execution_mode":
|
|
170
|
+
"execution_mode": self._execution_mode(),
|
|
154
171
|
"default_pipeline": list(CORE_PIPELINE),
|
|
155
172
|
"total_runs": len(runs),
|
|
156
173
|
"active_runs": active,
|
|
@@ -184,6 +201,225 @@ class AgentRuntime:
|
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
# ── execution ─────────────────────────────────────────────────────────
|
|
204
|
+
def _fire_pre_run(
|
|
205
|
+
self,
|
|
206
|
+
*,
|
|
207
|
+
goal: str,
|
|
208
|
+
roles: Optional[List[str]],
|
|
209
|
+
max_retries: int,
|
|
210
|
+
user_email: Optional[str],
|
|
211
|
+
scope: Optional[str],
|
|
212
|
+
) -> Optional[Dict[str, Any]]:
|
|
213
|
+
pre_dispatch: Optional[Dict[str, Any]] = None
|
|
214
|
+
if self._hooks is not None:
|
|
215
|
+
pre_dispatch = self._hooks.fire_hook(
|
|
216
|
+
"pre_run", "agent.run",
|
|
217
|
+
payload={"goal": goal, "roles": roles or None, "max_retries": max_retries},
|
|
218
|
+
user_email=user_email, workspace_id=scope,
|
|
219
|
+
)
|
|
220
|
+
if pre_dispatch.get("blocked"):
|
|
221
|
+
self._append_audit_event(
|
|
222
|
+
"multi_agent_run_blocked",
|
|
223
|
+
user_email=user_email,
|
|
224
|
+
reason=pre_dispatch.get("block_reason"),
|
|
225
|
+
)
|
|
226
|
+
raise PermissionError(pre_dispatch.get("block_reason") or "Agent run blocked by a pre_run hook.")
|
|
227
|
+
return pre_dispatch
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _result_patch(result: Any, goal: str) -> Dict[str, Any]:
|
|
231
|
+
return {
|
|
232
|
+
"agent_id": result.agent_id,
|
|
233
|
+
"status": result.status,
|
|
234
|
+
"input": goal,
|
|
235
|
+
"output_text": result.output,
|
|
236
|
+
"timeline": result.timeline,
|
|
237
|
+
"relationships": [ROLE_AGENT_IDS.get(r, f"agent:{r}") for r in result.roles_run],
|
|
238
|
+
"handoffs": result.handoffs,
|
|
239
|
+
"context_packets": result.context_packets,
|
|
240
|
+
"plan": result.plan,
|
|
241
|
+
"plan_review": result.plan_review,
|
|
242
|
+
"review_history": result.review_history,
|
|
243
|
+
"retry_history": result.retry_history,
|
|
244
|
+
"memory_snapshots": result.memory_snapshots,
|
|
245
|
+
"mode": getattr(result, "mode", "simulation"),
|
|
246
|
+
"current_role": None,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def _post_run_hooks(
|
|
250
|
+
self,
|
|
251
|
+
*,
|
|
252
|
+
run_id: Optional[str],
|
|
253
|
+
result: Any,
|
|
254
|
+
user_email: Optional[str],
|
|
255
|
+
scope: Optional[str],
|
|
256
|
+
status: Optional[str] = None,
|
|
257
|
+
) -> Optional[Dict[str, Any]]:
|
|
258
|
+
if self._hooks is None:
|
|
259
|
+
return None
|
|
260
|
+
return self._hooks.fire_hook(
|
|
261
|
+
"post_run", "agent.run",
|
|
262
|
+
payload={
|
|
263
|
+
"run_id": run_id,
|
|
264
|
+
"agent_id": result.agent_id,
|
|
265
|
+
"status": status or result.status,
|
|
266
|
+
"retries": result.retries,
|
|
267
|
+
},
|
|
268
|
+
user_email=user_email, workspace_id=scope,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def reserve_run(
|
|
272
|
+
self,
|
|
273
|
+
goal: str,
|
|
274
|
+
*,
|
|
275
|
+
user_email: Optional[str],
|
|
276
|
+
scope: Optional[str],
|
|
277
|
+
roles: Optional[List[str]] = None,
|
|
278
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
279
|
+
max_retries: int = 2,
|
|
280
|
+
) -> Dict[str, Any]:
|
|
281
|
+
"""Create the durable queued row used by the async executor."""
|
|
282
|
+
if not str(goal or "").strip():
|
|
283
|
+
raise ValueError("goal is required")
|
|
284
|
+
pre_dispatch = self._fire_pre_run(
|
|
285
|
+
goal=goal,
|
|
286
|
+
roles=roles,
|
|
287
|
+
max_retries=max_retries,
|
|
288
|
+
user_email=user_email,
|
|
289
|
+
scope=scope,
|
|
290
|
+
)
|
|
291
|
+
try:
|
|
292
|
+
orchestrator = self._orchestrator_factory(user_email or None, scope)
|
|
293
|
+
mode = getattr(orchestrator, "mode", "simulation")
|
|
294
|
+
except Exception:
|
|
295
|
+
mode = "simulation"
|
|
296
|
+
run = self._store.record_agent_run(
|
|
297
|
+
agent_id=ROLE_AGENT_IDS.get("executor", "agent:executor"),
|
|
298
|
+
status="queued",
|
|
299
|
+
input_text=goal,
|
|
300
|
+
output_text="",
|
|
301
|
+
timeline=[{"event": "agent_started", "status": "queued", "timestamp": _now()}],
|
|
302
|
+
relationships=[],
|
|
303
|
+
handoffs=[],
|
|
304
|
+
context_packets=[],
|
|
305
|
+
plan=[],
|
|
306
|
+
plan_review={},
|
|
307
|
+
review_history=[],
|
|
308
|
+
retry_history=[],
|
|
309
|
+
memory_snapshots=[],
|
|
310
|
+
user_email=user_email or None,
|
|
311
|
+
graph=None,
|
|
312
|
+
workspace_id=scope,
|
|
313
|
+
mode=mode,
|
|
314
|
+
)
|
|
315
|
+
run = self._store.update_agent_run(
|
|
316
|
+
run.get("id"),
|
|
317
|
+
workspace_id=scope,
|
|
318
|
+
execution_mode="async",
|
|
319
|
+
requested_roles=roles or None,
|
|
320
|
+
inputs=inputs or {},
|
|
321
|
+
max_retries=max_retries,
|
|
322
|
+
)
|
|
323
|
+
payload: Dict[str, Any] = {"run": run}
|
|
324
|
+
if pre_dispatch is not None:
|
|
325
|
+
payload["pre_run_hooks"] = pre_dispatch
|
|
326
|
+
return payload
|
|
327
|
+
|
|
328
|
+
def complete_reserved_run(
|
|
329
|
+
self,
|
|
330
|
+
run_id: str,
|
|
331
|
+
goal: str,
|
|
332
|
+
*,
|
|
333
|
+
user_email: Optional[str],
|
|
334
|
+
scope: Optional[str],
|
|
335
|
+
roles: Optional[List[str]] = None,
|
|
336
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
337
|
+
max_retries: int = 2,
|
|
338
|
+
pre_dispatch: Optional[Dict[str, Any]] = None,
|
|
339
|
+
cancel_requested: Optional[Callable[[], bool]] = None,
|
|
340
|
+
) -> Dict[str, Any]:
|
|
341
|
+
"""Execute orchestration and update an existing durable async row."""
|
|
342
|
+
run = self._store.get_agent_run(run_id, workspace_id=scope)
|
|
343
|
+
base_timeline = list(run.get("timeline") or [])
|
|
344
|
+
self._store.update_agent_run(
|
|
345
|
+
run_id,
|
|
346
|
+
workspace_id=scope,
|
|
347
|
+
status="running",
|
|
348
|
+
current_role=(roles or list(CORE_PIPELINE))[0] if (roles or CORE_PIPELINE) else None,
|
|
349
|
+
started_at=run.get("started_at") or _now(),
|
|
350
|
+
)
|
|
351
|
+
try:
|
|
352
|
+
orchestrator = self._orchestrator_factory(user_email or None, scope)
|
|
353
|
+
result = orchestrator.run(
|
|
354
|
+
goal,
|
|
355
|
+
user_email=user_email or None,
|
|
356
|
+
workspace_id=scope,
|
|
357
|
+
inputs=inputs or {},
|
|
358
|
+
roles=roles or None,
|
|
359
|
+
max_retries=max(0, min(int(max_retries or 0), self._max_retries_cap)),
|
|
360
|
+
)
|
|
361
|
+
except Exception as exc:
|
|
362
|
+
failed = self._store.update_agent_run(
|
|
363
|
+
run_id,
|
|
364
|
+
workspace_id=scope,
|
|
365
|
+
graph=self._workspace_graph(),
|
|
366
|
+
status="failed",
|
|
367
|
+
current_role=None,
|
|
368
|
+
error=str(exc),
|
|
369
|
+
output_text=str(exc),
|
|
370
|
+
timeline=base_timeline + [{
|
|
371
|
+
"event": "execution_failed",
|
|
372
|
+
"status": "failed",
|
|
373
|
+
"detail": str(exc),
|
|
374
|
+
"timestamp": _now(),
|
|
375
|
+
}],
|
|
376
|
+
)
|
|
377
|
+
self._append_audit_event("multi_agent_run", user_email=user_email, agent_id=failed.get("agent_id"), status="failed", retries=0)
|
|
378
|
+
return {"run": failed, "result": {"status": "failed", "error": str(exc)}}
|
|
379
|
+
|
|
380
|
+
patch = self._result_patch(result, goal)
|
|
381
|
+
patch["timeline"] = base_timeline + list(result.timeline or [])
|
|
382
|
+
if cancel_requested is not None and cancel_requested():
|
|
383
|
+
patch["status"] = "cancelled"
|
|
384
|
+
patch["current_role"] = None
|
|
385
|
+
patch["cancel_reason"] = "cancelled after the current synchronous step completed"
|
|
386
|
+
patch["cancelled_at"] = _now()
|
|
387
|
+
patch["timeline"] = patch["timeline"] + [{
|
|
388
|
+
"event": "execution_cancelled",
|
|
389
|
+
"status": "cancelled",
|
|
390
|
+
"reason": patch["cancel_reason"],
|
|
391
|
+
"timestamp": _now(),
|
|
392
|
+
}]
|
|
393
|
+
updated = self._store.update_agent_run(
|
|
394
|
+
run_id,
|
|
395
|
+
workspace_id=scope,
|
|
396
|
+
graph=self._workspace_graph(),
|
|
397
|
+
patch=patch,
|
|
398
|
+
)
|
|
399
|
+
self._append_audit_event(
|
|
400
|
+
"multi_agent_run",
|
|
401
|
+
user_email=user_email,
|
|
402
|
+
agent_id=result.agent_id,
|
|
403
|
+
status=updated.get("status") or result.status,
|
|
404
|
+
retries=result.retries,
|
|
405
|
+
)
|
|
406
|
+
post_dispatch = self._post_run_hooks(
|
|
407
|
+
run_id=run_id,
|
|
408
|
+
result=result,
|
|
409
|
+
user_email=user_email,
|
|
410
|
+
scope=scope,
|
|
411
|
+
status=updated.get("status") or result.status,
|
|
412
|
+
)
|
|
413
|
+
result_payload = result.as_dict()
|
|
414
|
+
if updated.get("status") == "cancelled":
|
|
415
|
+
result_payload = {"status": "cancelled", "reason": updated.get("cancel_reason"), "completed_result": result_payload}
|
|
416
|
+
payload = {"run": updated, "result": result_payload}
|
|
417
|
+
if pre_dispatch is not None:
|
|
418
|
+
payload["pre_run_hooks"] = pre_dispatch
|
|
419
|
+
if post_dispatch is not None:
|
|
420
|
+
payload["post_run_hooks"] = post_dispatch
|
|
421
|
+
return payload
|
|
422
|
+
|
|
187
423
|
def start(
|
|
188
424
|
self,
|
|
189
425
|
goal: str,
|
|
@@ -281,6 +517,8 @@ class AgentRuntime:
|
|
|
281
517
|
exists the run has already completed. Report that honestly rather than
|
|
282
518
|
pretending a cancellation occurred.
|
|
283
519
|
"""
|
|
520
|
+
if self._run_executor is not None:
|
|
521
|
+
return self._run_executor.cancel(run_id, kind="agent", scope=scope)
|
|
284
522
|
try:
|
|
285
523
|
run = self._store.get_agent_run(run_id, workspace_id=scope)
|
|
286
524
|
except FileNotFoundError:
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Durable asyncio run executor for v4 Act runtimes.
|
|
2
|
+
|
|
3
|
+
The executor owns server-loop tasks for agent and workflow runs while the
|
|
4
|
+
workspace store remains the durable source of truth. Work is persisted before it
|
|
5
|
+
starts, updated as it moves through queued/running/cancelling/final states, and
|
|
6
|
+
reconciled on startup so orphaned active rows never masquerade as live work.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Any, Callable, Dict, Optional
|
|
15
|
+
|
|
16
|
+
from latticeai.core.workflow_engine import WorkflowEngine
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ACTIVE_STATUSES = {"queued", "running", "in_progress", "retrying", "cancelling"}
|
|
20
|
+
TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _now() -> str:
|
|
24
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class _RunHandle:
|
|
29
|
+
run_id: str
|
|
30
|
+
kind: str
|
|
31
|
+
scope: Optional[str]
|
|
32
|
+
task: Optional[asyncio.Task] = None
|
|
33
|
+
cancel_requested: bool = False
|
|
34
|
+
started: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RunExecutor:
|
|
38
|
+
"""Async task manager for persisted agent/workflow executions."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
store: Any,
|
|
44
|
+
agent_runtime: Any,
|
|
45
|
+
build_workflow_runners: Callable[[Optional[str], Optional[str]], Dict[str, Callable[..., Any]]],
|
|
46
|
+
workspace_graph: Callable[[], Any],
|
|
47
|
+
append_audit_event: Callable[..., None],
|
|
48
|
+
hooks: Any = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.store = store
|
|
51
|
+
self.agent_runtime = agent_runtime
|
|
52
|
+
self.build_workflow_runners = build_workflow_runners
|
|
53
|
+
self.workspace_graph = workspace_graph
|
|
54
|
+
self.append_audit_event = append_audit_event
|
|
55
|
+
self.hooks = hooks
|
|
56
|
+
self._handles: Dict[str, _RunHandle] = {}
|
|
57
|
+
self._results: Dict[str, Dict[str, Any]] = {}
|
|
58
|
+
|
|
59
|
+
# ── startup reconciliation ───────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def reconcile_startup(self) -> Dict[str, Any]:
|
|
62
|
+
return self.store.reconcile_interrupted_runs(reason="server_startup")
|
|
63
|
+
|
|
64
|
+
# ── agent runs ───────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
async def start_agent(
|
|
67
|
+
self,
|
|
68
|
+
goal: str,
|
|
69
|
+
*,
|
|
70
|
+
user_email: Optional[str],
|
|
71
|
+
scope: Optional[str],
|
|
72
|
+
roles: Optional[list[str]] = None,
|
|
73
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
74
|
+
max_retries: int = 2,
|
|
75
|
+
) -> Dict[str, Any]:
|
|
76
|
+
reserved = self.agent_runtime.reserve_run(
|
|
77
|
+
goal,
|
|
78
|
+
user_email=user_email,
|
|
79
|
+
scope=scope,
|
|
80
|
+
roles=roles,
|
|
81
|
+
inputs=inputs or {},
|
|
82
|
+
max_retries=max_retries,
|
|
83
|
+
)
|
|
84
|
+
run_id = reserved["run"]["id"]
|
|
85
|
+
handle = _RunHandle(run_id=run_id, kind="agent", scope=scope)
|
|
86
|
+
handle.task = asyncio.create_task(
|
|
87
|
+
self._run_agent(handle, goal, user_email=user_email, roles=roles, inputs=inputs or {}, max_retries=max_retries)
|
|
88
|
+
)
|
|
89
|
+
self._handles[run_id] = handle
|
|
90
|
+
return {
|
|
91
|
+
**reserved,
|
|
92
|
+
"execution_mode": "async",
|
|
93
|
+
"accepted": True,
|
|
94
|
+
"events_url": f"/agents/api/runs/{run_id}/events",
|
|
95
|
+
"stop_url": f"/agents/api/runs/{run_id}/stop",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async def _run_agent(
|
|
99
|
+
self,
|
|
100
|
+
handle: _RunHandle,
|
|
101
|
+
goal: str,
|
|
102
|
+
*,
|
|
103
|
+
user_email: Optional[str],
|
|
104
|
+
roles: Optional[list[str]],
|
|
105
|
+
inputs: Dict[str, Any],
|
|
106
|
+
max_retries: int,
|
|
107
|
+
) -> None:
|
|
108
|
+
run_id = handle.run_id
|
|
109
|
+
try:
|
|
110
|
+
if handle.cancel_requested:
|
|
111
|
+
self._cancel_agent_record(run_id, handle.scope, "cancelled before execution started")
|
|
112
|
+
return
|
|
113
|
+
handle.started = True
|
|
114
|
+
payload = await asyncio.to_thread(
|
|
115
|
+
self.agent_runtime.complete_reserved_run,
|
|
116
|
+
run_id,
|
|
117
|
+
goal,
|
|
118
|
+
user_email=user_email,
|
|
119
|
+
scope=handle.scope,
|
|
120
|
+
roles=roles,
|
|
121
|
+
inputs=inputs,
|
|
122
|
+
max_retries=max_retries,
|
|
123
|
+
cancel_requested=lambda: handle.cancel_requested,
|
|
124
|
+
)
|
|
125
|
+
if handle.cancel_requested and (payload.get("run") or {}).get("status") != "cancelled":
|
|
126
|
+
self._cancel_agent_record(run_id, handle.scope, "cancelled after the final result was persisted")
|
|
127
|
+
else:
|
|
128
|
+
self._results[run_id] = payload
|
|
129
|
+
finally:
|
|
130
|
+
self._handles.pop(run_id, None)
|
|
131
|
+
|
|
132
|
+
def _cancel_agent_record(self, run_id: str, scope: Optional[str], reason: str) -> Dict[str, Any]:
|
|
133
|
+
run = self.store.get_agent_run(run_id, workspace_id=scope)
|
|
134
|
+
timeline = list(run.get("timeline") or [])
|
|
135
|
+
timeline.append({"event": "execution_cancelled", "status": "cancelled", "reason": reason, "timestamp": _now()})
|
|
136
|
+
cancelled = self.store.update_agent_run(
|
|
137
|
+
run_id,
|
|
138
|
+
workspace_id=scope,
|
|
139
|
+
status="cancelled",
|
|
140
|
+
current_role=None,
|
|
141
|
+
cancel_reason=reason,
|
|
142
|
+
cancelled_at=_now(),
|
|
143
|
+
output_text=run.get("output_preview") or reason,
|
|
144
|
+
timeline=timeline,
|
|
145
|
+
graph=self.workspace_graph(),
|
|
146
|
+
)
|
|
147
|
+
payload = {"run": cancelled, "result": {"status": "cancelled", "reason": reason}}
|
|
148
|
+
self._results[run_id] = payload
|
|
149
|
+
return cancelled
|
|
150
|
+
|
|
151
|
+
# ── workflow runs ────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
async def start_workflow(
|
|
154
|
+
self,
|
|
155
|
+
workflow: Dict[str, Any],
|
|
156
|
+
*,
|
|
157
|
+
workflow_id: str,
|
|
158
|
+
user_email: Optional[str],
|
|
159
|
+
scope: Optional[str],
|
|
160
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
161
|
+
) -> Dict[str, Any]:
|
|
162
|
+
run = self.store.record_workflow_run(
|
|
163
|
+
workflow_id=workflow_id,
|
|
164
|
+
name=workflow.get("name") or "workflow",
|
|
165
|
+
status="queued",
|
|
166
|
+
timeline=[{"event": "workflow_started", "status": "queued", "timestamp": _now()}],
|
|
167
|
+
outputs={},
|
|
168
|
+
user_email=user_email,
|
|
169
|
+
graph=None,
|
|
170
|
+
workspace_id=scope,
|
|
171
|
+
mode="live",
|
|
172
|
+
)
|
|
173
|
+
run = self.store.update_workflow_run(
|
|
174
|
+
run["id"],
|
|
175
|
+
workspace_id=scope,
|
|
176
|
+
execution_mode="async",
|
|
177
|
+
inputs=inputs or {},
|
|
178
|
+
)
|
|
179
|
+
handle = _RunHandle(run_id=run["id"], kind="workflow", scope=scope)
|
|
180
|
+
handle.task = asyncio.create_task(
|
|
181
|
+
self._run_workflow(handle, workflow, user_email=user_email, inputs=inputs or {})
|
|
182
|
+
)
|
|
183
|
+
self._handles[run["id"]] = handle
|
|
184
|
+
return {
|
|
185
|
+
"run": run,
|
|
186
|
+
"execution_mode": "async",
|
|
187
|
+
"accepted": True,
|
|
188
|
+
"events_url": f"/workflows/api/runs/{run['id']}/replay",
|
|
189
|
+
"stop_url": f"/workflows/api/runs/{run['id']}/stop",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async def _run_workflow(
|
|
193
|
+
self,
|
|
194
|
+
handle: _RunHandle,
|
|
195
|
+
workflow: Dict[str, Any],
|
|
196
|
+
*,
|
|
197
|
+
user_email: Optional[str],
|
|
198
|
+
inputs: Dict[str, Any],
|
|
199
|
+
) -> None:
|
|
200
|
+
run_id = handle.run_id
|
|
201
|
+
try:
|
|
202
|
+
if handle.cancel_requested:
|
|
203
|
+
self._cancel_workflow_record(run_id, handle.scope, "cancelled before execution started")
|
|
204
|
+
return
|
|
205
|
+
handle.started = True
|
|
206
|
+
run = self.store.get_workflow_run(run_id, workspace_id=handle.scope)
|
|
207
|
+
base_timeline = list(run.get("timeline") or [])
|
|
208
|
+
self.store.update_workflow_run(
|
|
209
|
+
run_id,
|
|
210
|
+
workspace_id=handle.scope,
|
|
211
|
+
status="running",
|
|
212
|
+
started_at=run.get("started_at") or _now(),
|
|
213
|
+
)
|
|
214
|
+
result = await asyncio.to_thread(self._execute_workflow_sync, workflow, user_email, handle.scope, inputs)
|
|
215
|
+
if handle.cancel_requested:
|
|
216
|
+
self._cancel_workflow_record(run_id, handle.scope, "cancelled after the current synchronous step completed")
|
|
217
|
+
return
|
|
218
|
+
pause = (
|
|
219
|
+
{"node": result.paused_node, "pending": result.pending_approval, "context": result.paused_context}
|
|
220
|
+
if result.status == "awaiting_approval" else None
|
|
221
|
+
)
|
|
222
|
+
updated = self.store.update_workflow_run(
|
|
223
|
+
run_id,
|
|
224
|
+
workspace_id=handle.scope,
|
|
225
|
+
graph=self.workspace_graph(),
|
|
226
|
+
status=result.status,
|
|
227
|
+
timeline=base_timeline + list(result.timeline or []),
|
|
228
|
+
outputs=result.outputs,
|
|
229
|
+
pause=pause,
|
|
230
|
+
)
|
|
231
|
+
self.append_audit_event(
|
|
232
|
+
"workflow_run",
|
|
233
|
+
user_email=user_email,
|
|
234
|
+
workflow_id=workflow.get("id"),
|
|
235
|
+
status=result.status,
|
|
236
|
+
)
|
|
237
|
+
self._results[run_id] = {"run": updated, "result": result.as_dict()}
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
run = self.store.get_workflow_run(run_id, workspace_id=handle.scope)
|
|
240
|
+
timeline = list(run.get("timeline") or [])
|
|
241
|
+
timeline.append({"event": "execution_failed", "status": "failed", "detail": str(exc), "timestamp": _now()})
|
|
242
|
+
failed = self.store.update_workflow_run(
|
|
243
|
+
run_id,
|
|
244
|
+
workspace_id=handle.scope,
|
|
245
|
+
graph=self.workspace_graph(),
|
|
246
|
+
status="failed",
|
|
247
|
+
timeline=timeline,
|
|
248
|
+
outputs={"error": str(exc)},
|
|
249
|
+
pause=None,
|
|
250
|
+
)
|
|
251
|
+
self._results[run_id] = {"run": failed, "result": {"status": "failed", "error": str(exc)}}
|
|
252
|
+
finally:
|
|
253
|
+
self._handles.pop(run_id, None)
|
|
254
|
+
|
|
255
|
+
def _execute_workflow_sync(
|
|
256
|
+
self,
|
|
257
|
+
workflow: Dict[str, Any],
|
|
258
|
+
user_email: Optional[str],
|
|
259
|
+
scope: Optional[str],
|
|
260
|
+
inputs: Dict[str, Any],
|
|
261
|
+
) -> Any:
|
|
262
|
+
runners = self.build_workflow_runners(user_email, scope)
|
|
263
|
+
return WorkflowEngine(runners, hooks=self.hooks).run(workflow, inputs=inputs)
|
|
264
|
+
|
|
265
|
+
def _cancel_workflow_record(self, run_id: str, scope: Optional[str], reason: str) -> Dict[str, Any]:
|
|
266
|
+
run = self.store.get_workflow_run(run_id, workspace_id=scope)
|
|
267
|
+
timeline = list(run.get("timeline") or [])
|
|
268
|
+
timeline.append({"event": "execution_cancelled", "status": "cancelled", "reason": reason, "timestamp": _now()})
|
|
269
|
+
cancelled = self.store.update_workflow_run(
|
|
270
|
+
run_id,
|
|
271
|
+
workspace_id=scope,
|
|
272
|
+
status="cancelled",
|
|
273
|
+
cancel_reason=reason,
|
|
274
|
+
cancelled_at=_now(),
|
|
275
|
+
timeline=timeline,
|
|
276
|
+
pause=None,
|
|
277
|
+
graph=self.workspace_graph(),
|
|
278
|
+
)
|
|
279
|
+
payload = {"run": cancelled, "result": {"status": "cancelled", "reason": reason}}
|
|
280
|
+
self._results[run_id] = payload
|
|
281
|
+
return cancelled
|
|
282
|
+
|
|
283
|
+
# ── cancellation/status ──────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
def cancel(self, run_id: str, *, kind: Optional[str] = None, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
286
|
+
handle = self._handles.get(run_id)
|
|
287
|
+
try:
|
|
288
|
+
run = (
|
|
289
|
+
self.store.get_workflow_run(run_id, workspace_id=scope)
|
|
290
|
+
if kind == "workflow" or (handle and handle.kind == "workflow")
|
|
291
|
+
else self.store.get_agent_run(run_id, workspace_id=scope)
|
|
292
|
+
)
|
|
293
|
+
except FileNotFoundError:
|
|
294
|
+
return {"stopped": False, "reason": "run not found", "run_id": run_id}
|
|
295
|
+
|
|
296
|
+
status = str(run.get("status") or "")
|
|
297
|
+
if status not in ACTIVE_STATUSES:
|
|
298
|
+
return {"stopped": False, "reason": "run already finished", "run_id": run_id, "status": status}
|
|
299
|
+
|
|
300
|
+
if handle is not None:
|
|
301
|
+
handle.cancel_requested = True
|
|
302
|
+
target_kind = (kind or (handle.kind if handle else "agent"))
|
|
303
|
+
updater = self.store.update_workflow_run if target_kind == "workflow" else self.store.update_agent_run
|
|
304
|
+
updater(
|
|
305
|
+
run_id,
|
|
306
|
+
workspace_id=scope,
|
|
307
|
+
status="cancelling",
|
|
308
|
+
cancel_requested=True,
|
|
309
|
+
cancel_requested_at=_now(),
|
|
310
|
+
)
|
|
311
|
+
if handle is None:
|
|
312
|
+
if target_kind == "workflow":
|
|
313
|
+
self._cancel_workflow_record(run_id, scope, "cancelled; no active worker owned this run")
|
|
314
|
+
else:
|
|
315
|
+
self._cancel_agent_record(run_id, scope, "cancelled; no active worker owned this run")
|
|
316
|
+
return {
|
|
317
|
+
"stopped": True,
|
|
318
|
+
"run_id": run_id,
|
|
319
|
+
"status": "cancelling" if handle is not None else "cancelled",
|
|
320
|
+
"cancellation": "cooperative",
|
|
321
|
+
"reason": "cancellation requested; synchronous work finishes its current step before the final cancelled status is stored",
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async def wait(self, run_id: str, *, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
|
325
|
+
handle = self._handles.get(run_id)
|
|
326
|
+
if handle and handle.task:
|
|
327
|
+
await asyncio.wait_for(handle.task, timeout=timeout)
|
|
328
|
+
return self._results.get(run_id)
|