ltcai 3.2.0 → 3.4.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 +87 -67
- package/docs/CHANGELOG.md +36 -0
- package/docs/architecture.md +2 -1
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/knowledge_graph.py +45 -0
- package/knowledge_graph_api.py +10 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +3 -0
- package/latticeai/api/hooks.py +39 -0
- package/latticeai/api/local_files.py +41 -0
- package/latticeai/api/models.py +36 -1
- package/latticeai/api/tools.py +16 -1
- package/latticeai/api/workflow_designer.py +2 -1
- package/latticeai/core/hooks.py +398 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/workflow_engine.py +21 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +40 -0
- package/latticeai/services/agent_runtime.py +46 -1
- package/latticeai/services/upload_service.py +17 -0
- package/package.json +1 -1
- package/scripts/build_v3_assets.mjs +7 -1
- package/scripts/capture/capture_v340.js +88 -0
- package/static/css/{tokens.5a595671.css → tokens.3ba22e37.css} +109 -109
- package/static/css/tokens.css +109 -109
- package/static/v3/asset-manifest.json +25 -25
- package/static/v3/css/{lattice.components.011e988b.css → lattice.components.9b49d614.css} +57 -32
- package/static/v3/css/lattice.components.css +57 -32
- package/static/v3/css/{lattice.shell.4920f42d.css → lattice.shell.6ceea7c8.css} +75 -31
- package/static/v3/css/lattice.shell.css +75 -31
- package/static/v3/css/lattice.tokens.css +13 -13
- package/static/v3/css/{lattice.tokens.c597ff81.css → lattice.tokens.e7018963.css} +13 -13
- package/static/v3/css/{lattice.views.3ee19d4e.css → lattice.views.22f69117.css} +98 -15
- package/static/v3/css/lattice.views.css +98 -15
- package/static/v3/js/{app.a5adc0f3.js → app.c4acfdd8.js} +1 -1
- package/static/v3/js/core/{api.603b978f.js → api.12b568ad.js} +126 -4
- package/static/v3/js/core/api.js +126 -4
- package/static/v3/js/core/{components.4c83e0a9.js → components.35f02e4c.js} +8 -0
- package/static/v3/js/core/components.js +8 -0
- package/static/v3/js/core/{routes.07ad6696.js → routes.d214b399.js} +16 -12
- package/static/v3/js/core/routes.js +16 -12
- package/static/v3/js/core/{shell.ea0b9ae5.js → shell.80a6ad82.js} +37 -9
- package/static/v3/js/core/shell.js +34 -6
- package/static/v3/js/views/agents.014d0b74.js +541 -0
- package/static/v3/js/views/agents.js +305 -57
- package/static/v3/js/views/{chat.718144ce.js → chat.e6dd7dd0.js} +162 -10
- package/static/v3/js/views/chat.js +162 -10
- package/static/v3/js/views/files.adad14c1.js +365 -0
- package/static/v3/js/views/files.js +269 -90
- package/static/v3/js/views/home.24f8b8ae.js +200 -0
- package/static/v3/js/views/home.js +96 -15
- package/static/v3/js/views/hooks.13845954.js +215 -0
- package/static/v3/js/views/hooks.js +117 -1
- package/static/v3/js/views/{memory.d2ed7a7c.js → memory.4ebdf474.js} +5 -4
- package/static/v3/js/views/memory.js +5 -4
- package/static/v3/js/views/{my-computer.1b2ff621.js → my-computer.c3ef5283.js} +224 -1
- package/static/v3/js/views/my-computer.js +224 -1
- package/static/v3/js/views/{settings.4f777210.js → settings.8631fa5e.js} +70 -2
- package/static/v3/js/views/settings.js +70 -2
- package/static/v3/js/views/agents.c373d48c.js +0 -293
- package/static/v3/js/views/files.4935197e.js +0 -186
- package/static/v3/js/views/home.cdde3b32.js +0 -119
- package/static/v3/js/views/hooks.f3edebca.js +0 -99
package/latticeai/core/hooks.py
CHANGED
|
@@ -20,10 +20,15 @@ from __future__ import annotations
|
|
|
20
20
|
|
|
21
21
|
import json
|
|
22
22
|
import os
|
|
23
|
+
import shlex
|
|
24
|
+
import subprocess
|
|
23
25
|
import tempfile
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from collections import deque
|
|
24
29
|
from datetime import datetime
|
|
25
30
|
from pathlib import Path
|
|
26
|
-
from typing import Any, Dict, List, Optional
|
|
31
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
27
32
|
|
|
28
33
|
|
|
29
34
|
HOOK_KINDS = (
|
|
@@ -36,11 +41,137 @@ HOOK_KINDS = (
|
|
|
36
41
|
"workflow",
|
|
37
42
|
)
|
|
38
43
|
|
|
44
|
+
# Hook statuses a dispatch can record.
|
|
45
|
+
HOOK_STATUSES = ("ok", "blocked", "error", "skipped", "advisory")
|
|
46
|
+
|
|
39
47
|
|
|
40
48
|
def _now() -> str:
|
|
41
49
|
return datetime.now().isoformat(timespec="seconds")
|
|
42
50
|
|
|
43
51
|
|
|
52
|
+
class HookContext:
|
|
53
|
+
"""Mutable execution context handed to every hook in a dispatch.
|
|
54
|
+
|
|
55
|
+
A bound hook runner may inspect or modify :attr:`payload` (e.g. redact
|
|
56
|
+
secrets in place), attach data via :meth:`set`, or halt a pending action
|
|
57
|
+
with :meth:`block`. Blocking is honoured for the ``pre_*`` kinds that gate
|
|
58
|
+
real work (a blocked ``pre_run`` stops the agent run; a blocked ``pre_tool``
|
|
59
|
+
stops the tool call).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__slots__ = (
|
|
63
|
+
"kind", "event", "payload", "metadata",
|
|
64
|
+
"user_email", "workspace_id", "blocked", "block_reason", "notes",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
kind: str,
|
|
70
|
+
event: str = "",
|
|
71
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
72
|
+
*,
|
|
73
|
+
user_email: Optional[str] = None,
|
|
74
|
+
workspace_id: Optional[str] = None,
|
|
75
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
76
|
+
):
|
|
77
|
+
self.kind = kind
|
|
78
|
+
self.event = event or kind
|
|
79
|
+
self.payload = dict(payload or {})
|
|
80
|
+
self.metadata = dict(metadata or {})
|
|
81
|
+
self.user_email = user_email
|
|
82
|
+
self.workspace_id = workspace_id
|
|
83
|
+
self.blocked = False
|
|
84
|
+
self.block_reason = ""
|
|
85
|
+
self.notes: List[str] = []
|
|
86
|
+
|
|
87
|
+
def set(self, key: str, value: Any) -> "HookContext":
|
|
88
|
+
self.payload[key] = value
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def block(self, reason: str = "") -> "HookContext":
|
|
92
|
+
self.blocked = True
|
|
93
|
+
self.block_reason = reason or "blocked by hook"
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def note(self, message: str) -> "HookContext":
|
|
97
|
+
self.notes.append(str(message))
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
101
|
+
return {
|
|
102
|
+
"kind": self.kind,
|
|
103
|
+
"event": self.event,
|
|
104
|
+
"payload": self.payload,
|
|
105
|
+
"metadata": self.metadata,
|
|
106
|
+
"user_email": self.user_email,
|
|
107
|
+
"workspace_id": self.workspace_id,
|
|
108
|
+
"blocked": self.blocked,
|
|
109
|
+
"block_reason": self.block_reason,
|
|
110
|
+
"notes": list(self.notes),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class HookResult:
|
|
115
|
+
"""The outcome of running a single hook (one entry in a dispatch)."""
|
|
116
|
+
|
|
117
|
+
__slots__ = (
|
|
118
|
+
"hook_id", "name", "kind", "status", "detail", "output",
|
|
119
|
+
"duration_ms", "blocked", "source", "binding", "started_at",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
hook_id: str,
|
|
126
|
+
name: str = "",
|
|
127
|
+
kind: str = "",
|
|
128
|
+
status: str = "ok",
|
|
129
|
+
detail: str = "",
|
|
130
|
+
output: str = "",
|
|
131
|
+
duration_ms: int = 0,
|
|
132
|
+
blocked: bool = False,
|
|
133
|
+
source: str = "",
|
|
134
|
+
binding: str = "",
|
|
135
|
+
started_at: Optional[str] = None,
|
|
136
|
+
):
|
|
137
|
+
self.hook_id = hook_id
|
|
138
|
+
self.name = name
|
|
139
|
+
self.kind = kind
|
|
140
|
+
self.status = status # ok | blocked | error | skipped | advisory
|
|
141
|
+
self.detail = detail
|
|
142
|
+
self.output = output
|
|
143
|
+
self.duration_ms = duration_ms
|
|
144
|
+
self.blocked = blocked
|
|
145
|
+
self.source = source
|
|
146
|
+
self.binding = binding
|
|
147
|
+
self.started_at = started_at or _now()
|
|
148
|
+
|
|
149
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
150
|
+
return {
|
|
151
|
+
"hook_id": self.hook_id,
|
|
152
|
+
"name": self.name,
|
|
153
|
+
"kind": self.kind,
|
|
154
|
+
"status": self.status,
|
|
155
|
+
"detail": self.detail,
|
|
156
|
+
"output": self.output,
|
|
157
|
+
"duration_ms": self.duration_ms,
|
|
158
|
+
"blocked": self.blocked,
|
|
159
|
+
"source": self.source,
|
|
160
|
+
"binding": self.binding,
|
|
161
|
+
"started_at": self.started_at,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def hook_context(kind: str, event: str = "", **kwargs: Any) -> HookContext:
|
|
166
|
+
"""Factory for a :class:`HookContext` (matches the public hook vocabulary)."""
|
|
167
|
+
return HookContext(kind, event, **kwargs)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def hook_result(**kwargs: Any) -> HookResult:
|
|
171
|
+
"""Factory for a :class:`HookResult`."""
|
|
172
|
+
return HookResult(**kwargs)
|
|
173
|
+
|
|
174
|
+
|
|
44
175
|
# Built-in hooks describe lifecycle points the platform already exercises. They
|
|
45
176
|
# are honest reflections of existing behaviour (see the `binding` field), made
|
|
46
177
|
# visible and orderable here. Disabling a `managed="platform"` hook is recorded
|
|
@@ -116,9 +247,17 @@ BUILTIN_HOOKS: List[Dict[str, Any]] = [
|
|
|
116
247
|
class HooksRegistry:
|
|
117
248
|
"""Persisted registry of lifecycle hooks (built-in + user-registered)."""
|
|
118
249
|
|
|
119
|
-
def __init__(self, path: Path):
|
|
250
|
+
def __init__(self, path: Path, *, command_timeout: float = 20.0, run_log_limit: int = 100):
|
|
120
251
|
self.path = Path(path)
|
|
121
252
|
self._state: Dict[str, Any] = self._load()
|
|
253
|
+
# Runtime dispatch state: in-process runners bound to hook ids, plus a
|
|
254
|
+
# bounded, persisted log of recent executions so the UI can show that
|
|
255
|
+
# hooks actually ran (not just that they are registered).
|
|
256
|
+
self._runtime_runners: Dict[str, Callable[[HookContext], Any]] = {}
|
|
257
|
+
self._command_timeout = float(command_timeout)
|
|
258
|
+
self._run_lock = threading.Lock()
|
|
259
|
+
self._runs_path = self.path.parent / "hooks_runs.json"
|
|
260
|
+
self._runs: deque = deque(self._load_runs(), maxlen=int(run_log_limit))
|
|
122
261
|
|
|
123
262
|
# ── persistence ───────────────────────────────────────────────────────
|
|
124
263
|
def _load(self) -> Dict[str, Any]:
|
|
@@ -282,3 +421,260 @@ class HooksRegistry:
|
|
|
282
421
|
raise KeyError(hook_id)
|
|
283
422
|
self._save()
|
|
284
423
|
return {"removed": hook_id}
|
|
424
|
+
|
|
425
|
+
# ── execution engine ──────────────────────────────────────────────────
|
|
426
|
+
# The registry above owns *what* runs and in *what order*. The methods below
|
|
427
|
+
# own *running* it: a hook executes either via an in-process runner bound by
|
|
428
|
+
# its owning subsystem (built-ins) or, for user hooks, by running their
|
|
429
|
+
# ``command`` as a subprocess. ``pre_*`` hooks can block the pending action.
|
|
430
|
+
|
|
431
|
+
def register_hook(self, hook_id: str, runner: Callable[[HookContext], Any]) -> "HooksRegistry":
|
|
432
|
+
"""Bind a callable that *executes* when ``hook_id`` fires.
|
|
433
|
+
|
|
434
|
+
Built-in hooks bind their owning subsystem's real behaviour here at app
|
|
435
|
+
startup so dispatch performs actual work rather than a placeholder.
|
|
436
|
+
Returns ``self`` so registrations can be chained.
|
|
437
|
+
"""
|
|
438
|
+
if not callable(runner):
|
|
439
|
+
raise TypeError("runner must be callable")
|
|
440
|
+
self._runtime_runners[hook_id] = runner
|
|
441
|
+
return self
|
|
442
|
+
|
|
443
|
+
# Alias — descriptive name used by the wiring layer.
|
|
444
|
+
register_runtime_hook = register_hook
|
|
445
|
+
|
|
446
|
+
def unregister_hook(self, hook_id: str) -> None:
|
|
447
|
+
self._runtime_runners.pop(hook_id, None)
|
|
448
|
+
|
|
449
|
+
def has_runner(self, hook_id: str) -> bool:
|
|
450
|
+
return hook_id in self._runtime_runners
|
|
451
|
+
|
|
452
|
+
def run_hooks(
|
|
453
|
+
self,
|
|
454
|
+
kind: str,
|
|
455
|
+
context: Optional[HookContext] = None,
|
|
456
|
+
*,
|
|
457
|
+
event: Optional[str] = None,
|
|
458
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
459
|
+
user_email: Optional[str] = None,
|
|
460
|
+
workspace_id: Optional[str] = None,
|
|
461
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
462
|
+
) -> Dict[str, Any]:
|
|
463
|
+
"""Run every enabled hook of ``kind``, in order, against one context.
|
|
464
|
+
|
|
465
|
+
Returns the dispatch record: which hooks ran, whether the pending action
|
|
466
|
+
was blocked, and a per-hook result list. A ``pre_*`` hook that blocks
|
|
467
|
+
short-circuits the remaining hooks (fail-closed gate semantics).
|
|
468
|
+
"""
|
|
469
|
+
if kind not in HOOK_KINDS:
|
|
470
|
+
raise ValueError(f"kind must be one of {', '.join(HOOK_KINDS)}")
|
|
471
|
+
if context is None:
|
|
472
|
+
context = HookContext(
|
|
473
|
+
kind, event=event or kind, payload=payload,
|
|
474
|
+
user_email=user_email, workspace_id=workspace_id, metadata=metadata,
|
|
475
|
+
)
|
|
476
|
+
hooks = [h for h in self._materialize() if h["kind"] == kind and h.get("enabled")]
|
|
477
|
+
results: List[HookResult] = []
|
|
478
|
+
for hook in hooks:
|
|
479
|
+
res = self._run_one(hook, context)
|
|
480
|
+
results.append(res)
|
|
481
|
+
self._record_run(res, context)
|
|
482
|
+
if res.blocked:
|
|
483
|
+
context.block(res.detail or f"{hook['id']} blocked {context.event}")
|
|
484
|
+
break
|
|
485
|
+
return {
|
|
486
|
+
"kind": kind,
|
|
487
|
+
"event": context.event,
|
|
488
|
+
"ran": len(results),
|
|
489
|
+
"blocked": context.blocked,
|
|
490
|
+
"block_reason": context.block_reason,
|
|
491
|
+
"results": [r.as_dict() for r in results],
|
|
492
|
+
"generated_at": _now(),
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
def run_hook(
|
|
496
|
+
self,
|
|
497
|
+
hook_id: str,
|
|
498
|
+
context: Optional[HookContext] = None,
|
|
499
|
+
*,
|
|
500
|
+
event: Optional[str] = None,
|
|
501
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
502
|
+
user_email: Optional[str] = None,
|
|
503
|
+
workspace_id: Optional[str] = None,
|
|
504
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
505
|
+
) -> Dict[str, Any]:
|
|
506
|
+
"""Run a single hook by id (regardless of kind ordering)."""
|
|
507
|
+
hook = self.get(hook_id)
|
|
508
|
+
if hook is None:
|
|
509
|
+
raise KeyError(hook_id)
|
|
510
|
+
if context is None:
|
|
511
|
+
context = HookContext(
|
|
512
|
+
hook["kind"], event=event or hook_id, payload=payload,
|
|
513
|
+
user_email=user_email, workspace_id=workspace_id, metadata=metadata,
|
|
514
|
+
)
|
|
515
|
+
if not hook.get("enabled"):
|
|
516
|
+
res = HookResult(
|
|
517
|
+
hook_id=hook_id, name=hook.get("name", hook_id), kind=hook["kind"],
|
|
518
|
+
status="skipped", detail="hook disabled",
|
|
519
|
+
source=hook.get("source", ""), binding=hook.get("binding", ""),
|
|
520
|
+
)
|
|
521
|
+
else:
|
|
522
|
+
res = self._run_one(hook, context)
|
|
523
|
+
self._record_run(res, context)
|
|
524
|
+
return res.as_dict()
|
|
525
|
+
|
|
526
|
+
def fire_hook(
|
|
527
|
+
self,
|
|
528
|
+
kind: str,
|
|
529
|
+
event: str = "",
|
|
530
|
+
*,
|
|
531
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
532
|
+
user_email: Optional[str] = None,
|
|
533
|
+
workspace_id: Optional[str] = None,
|
|
534
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
535
|
+
context: Optional[HookContext] = None,
|
|
536
|
+
) -> Dict[str, Any]:
|
|
537
|
+
"""Fire-and-forget convenience over :meth:`run_hooks`.
|
|
538
|
+
|
|
539
|
+
Never raises — a dispatch failure is captured in the returned record so
|
|
540
|
+
a hook misconfiguration can never crash the lifecycle point that fired
|
|
541
|
+
it. Callers that need to *gate* on a block should read ``["blocked"]``.
|
|
542
|
+
"""
|
|
543
|
+
try:
|
|
544
|
+
return self.run_hooks(
|
|
545
|
+
kind, context, event=event or kind, payload=payload,
|
|
546
|
+
user_email=user_email, workspace_id=workspace_id, metadata=metadata,
|
|
547
|
+
)
|
|
548
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
549
|
+
return {"kind": kind, "event": event or kind, "ran": 0, "blocked": False,
|
|
550
|
+
"block_reason": "", "error": str(exc), "results": [], "generated_at": _now()}
|
|
551
|
+
|
|
552
|
+
# ── single-hook execution ─────────────────────────────────────────────
|
|
553
|
+
def _run_one(self, hook: Dict[str, Any], context: HookContext) -> HookResult:
|
|
554
|
+
hook_id = hook["id"]
|
|
555
|
+
start = time.perf_counter()
|
|
556
|
+
runner = self._runtime_runners.get(hook_id)
|
|
557
|
+
try:
|
|
558
|
+
if runner is not None:
|
|
559
|
+
out = runner(context)
|
|
560
|
+
status, detail, output, blocked = self._interpret_runner_output(out, context)
|
|
561
|
+
elif str(hook.get("command") or "").strip():
|
|
562
|
+
status, detail, output, blocked = self._run_command(hook, context)
|
|
563
|
+
else:
|
|
564
|
+
# No bound runner and no command → advisory (listed + ordered only).
|
|
565
|
+
status, detail, output, blocked = "advisory", "", "", False
|
|
566
|
+
except Exception as exc: # a misbehaving hook never breaks the dispatch
|
|
567
|
+
status, detail, output, blocked = "error", str(exc)[:500], "", False
|
|
568
|
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
|
569
|
+
return HookResult(
|
|
570
|
+
hook_id=hook_id,
|
|
571
|
+
name=hook.get("name", hook_id),
|
|
572
|
+
kind=hook["kind"],
|
|
573
|
+
status=status,
|
|
574
|
+
detail=detail,
|
|
575
|
+
output=output,
|
|
576
|
+
duration_ms=duration_ms,
|
|
577
|
+
blocked=blocked,
|
|
578
|
+
source=hook.get("source", ""),
|
|
579
|
+
binding=hook.get("binding", ""),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
@staticmethod
|
|
583
|
+
def _interpret_runner_output(out: Any, context: HookContext):
|
|
584
|
+
"""Normalize a runner's return value into (status, detail, output, blocked).
|
|
585
|
+
|
|
586
|
+
A runner may return ``None`` (ok), a ``str`` (ok + output text), or a
|
|
587
|
+
dict ``{status?, detail?, output?, block?}``. Calling ``context.block()``
|
|
588
|
+
also blocks, regardless of the return value.
|
|
589
|
+
"""
|
|
590
|
+
blocked = bool(getattr(context, "blocked", False))
|
|
591
|
+
if isinstance(out, dict):
|
|
592
|
+
status = str(out.get("status") or ("blocked" if (out.get("block") or blocked) else "ok"))
|
|
593
|
+
block = bool(out.get("block")) or blocked or status == "blocked"
|
|
594
|
+
detail = str(out.get("detail") or context.block_reason or "")[:500]
|
|
595
|
+
return status, detail, str(out.get("output") or "")[:4000], block
|
|
596
|
+
if isinstance(out, str):
|
|
597
|
+
return ("blocked" if blocked else "ok", context.block_reason if blocked else "", out[:4000], blocked)
|
|
598
|
+
# None or anything else → ok (or blocked if the runner called context.block()).
|
|
599
|
+
return ("blocked" if blocked else "ok", context.block_reason if blocked else "", "", blocked)
|
|
600
|
+
|
|
601
|
+
def _run_command(self, hook: Dict[str, Any], context: HookContext):
|
|
602
|
+
"""Run a user hook's ``command`` as a subprocess with the context on stdin.
|
|
603
|
+
|
|
604
|
+
The full context is provided both as the ``LATTICE_HOOK_CONTEXT`` env var
|
|
605
|
+
and on stdin (JSON). Exit code 0 = ok. A non-zero exit from a ``pre_*``
|
|
606
|
+
hook gates (blocks) the pending action; from any other kind it is
|
|
607
|
+
recorded as an error without blocking. A timeout fails closed for gates.
|
|
608
|
+
"""
|
|
609
|
+
cmd = str(hook.get("command") or "").strip()
|
|
610
|
+
try:
|
|
611
|
+
argv = shlex.split(cmd)
|
|
612
|
+
except ValueError as exc:
|
|
613
|
+
return "error", f"invalid command: {exc}", "", False
|
|
614
|
+
if not argv:
|
|
615
|
+
return "skipped", "empty command", "", False
|
|
616
|
+
ctx_json = json.dumps(context.as_dict(), ensure_ascii=False)
|
|
617
|
+
env = dict(os.environ)
|
|
618
|
+
env.update({
|
|
619
|
+
"LATTICE_HOOK_KIND": context.kind,
|
|
620
|
+
"LATTICE_HOOK_EVENT": context.event,
|
|
621
|
+
"LATTICE_HOOK_ID": str(hook.get("id", "")),
|
|
622
|
+
"LATTICE_HOOK_CONTEXT": ctx_json,
|
|
623
|
+
})
|
|
624
|
+
is_gate = context.kind.startswith("pre_")
|
|
625
|
+
try:
|
|
626
|
+
proc = subprocess.run(
|
|
627
|
+
argv, input=ctx_json, capture_output=True, text=True,
|
|
628
|
+
timeout=self._command_timeout, env=env,
|
|
629
|
+
)
|
|
630
|
+
except subprocess.TimeoutExpired:
|
|
631
|
+
return ("blocked" if is_gate else "error",
|
|
632
|
+
f"timed out after {self._command_timeout:.0f}s", "", is_gate)
|
|
633
|
+
except Exception as exc:
|
|
634
|
+
return "error", str(exc)[:500], "", False
|
|
635
|
+
output = (proc.stdout or "")[:4000]
|
|
636
|
+
if proc.returncode == 0:
|
|
637
|
+
return "ok", "", output, False
|
|
638
|
+
detail = (proc.stderr or proc.stdout or f"exit code {proc.returncode}").strip()[:500]
|
|
639
|
+
return ("blocked" if is_gate else "error", detail, output, is_gate)
|
|
640
|
+
|
|
641
|
+
# ── run log ────────────────────────────────────────────────────────────
|
|
642
|
+
def _load_runs(self) -> List[Dict[str, Any]]:
|
|
643
|
+
try:
|
|
644
|
+
if self._runs_path.exists():
|
|
645
|
+
with open(self._runs_path, "r", encoding="utf-8") as fh:
|
|
646
|
+
data = json.load(fh)
|
|
647
|
+
if isinstance(data, list):
|
|
648
|
+
return data
|
|
649
|
+
except Exception:
|
|
650
|
+
pass
|
|
651
|
+
return []
|
|
652
|
+
|
|
653
|
+
def _save_runs(self) -> None:
|
|
654
|
+
try:
|
|
655
|
+
self._runs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
656
|
+
fd, tmp = tempfile.mkstemp(dir=str(self._runs_path.parent), suffix=".tmp")
|
|
657
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
658
|
+
json.dump(list(self._runs), fh, ensure_ascii=False, indent=2)
|
|
659
|
+
os.replace(tmp, self._runs_path)
|
|
660
|
+
except Exception: # pragma: no cover - the run log is best-effort
|
|
661
|
+
pass
|
|
662
|
+
|
|
663
|
+
def _record_run(self, result: HookResult, context: HookContext) -> None:
|
|
664
|
+
entry = result.as_dict()
|
|
665
|
+
entry["target_event"] = context.event
|
|
666
|
+
entry["target_kind"] = context.kind
|
|
667
|
+
with self._run_lock:
|
|
668
|
+
self._runs.appendleft(entry)
|
|
669
|
+
self._save_runs()
|
|
670
|
+
|
|
671
|
+
def recent_runs(self, limit: int = 50, kind: Optional[str] = None) -> Dict[str, Any]:
|
|
672
|
+
with self._run_lock:
|
|
673
|
+
runs = list(self._runs)
|
|
674
|
+
if kind:
|
|
675
|
+
runs = [r for r in runs if r.get("target_kind") == kind or r.get("kind") == kind]
|
|
676
|
+
return {
|
|
677
|
+
"runs": runs[: max(0, int(limit))],
|
|
678
|
+
"total": len(runs),
|
|
679
|
+
"generated_at": _now(),
|
|
680
|
+
}
|
|
@@ -14,7 +14,7 @@ from datetime import datetime
|
|
|
14
14
|
from typing import Any, Callable, Dict, List, Optional
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
MULTI_AGENT_VERSION = "3.
|
|
17
|
+
MULTI_AGENT_VERSION = "3.4.0"
|
|
18
18
|
|
|
19
19
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
20
20
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -214,17 +214,31 @@ class WorkflowEngine:
|
|
|
214
214
|
gracefully (and the gap is visible in the timeline).
|
|
215
215
|
"""
|
|
216
216
|
|
|
217
|
-
def __init__(self, runners: Optional[Dict[str, Callable[..., Any]]] = None):
|
|
217
|
+
def __init__(self, runners: Optional[Dict[str, Callable[..., Any]]] = None, *, hooks: Any = None):
|
|
218
218
|
self.runners = runners or {}
|
|
219
|
+
# Optional lifecycle hooks registry. When present, ``run`` fires the
|
|
220
|
+
# ``workflow`` hooks at workflow start and end so automation registered
|
|
221
|
+
# against the workflow lifecycle actually executes.
|
|
222
|
+
self.hooks = hooks
|
|
219
223
|
|
|
220
224
|
def run(self, workflow: Dict[str, Any], *, inputs: Optional[Dict[str, Any]] = None) -> WorkflowRun:
|
|
221
225
|
definition = normalize_definition(workflow)
|
|
222
226
|
errors = validate_definition(definition)
|
|
223
227
|
run = WorkflowRun(workflow_id=definition.get("id"), name=definition.get("name") or "workflow")
|
|
228
|
+
if self.hooks is not None:
|
|
229
|
+
self.hooks.fire_hook(
|
|
230
|
+
"workflow", "workflow.start",
|
|
231
|
+
payload={"workflow_id": definition.get("id"), "name": definition.get("name"), "valid": not errors},
|
|
232
|
+
)
|
|
224
233
|
if errors:
|
|
225
234
|
run.status = "failed"
|
|
226
235
|
run.timeline.append({"node": None, "type": "validation", "status": "failed", "errors": errors, "timestamp": _now()})
|
|
227
236
|
run.finished_at = _now()
|
|
237
|
+
if self.hooks is not None:
|
|
238
|
+
self.hooks.fire_hook(
|
|
239
|
+
"workflow", "workflow.end",
|
|
240
|
+
payload={"workflow_id": definition.get("id"), "status": run.status},
|
|
241
|
+
)
|
|
228
242
|
return run
|
|
229
243
|
|
|
230
244
|
nodes = {node["id"]: node for node in definition["nodes"]}
|
|
@@ -300,6 +314,12 @@ class WorkflowEngine:
|
|
|
300
314
|
|
|
301
315
|
run.status = "failed" if had_error else ("partial" if had_skip else "ok")
|
|
302
316
|
run.finished_at = _now()
|
|
317
|
+
if self.hooks is not None:
|
|
318
|
+
self.hooks.fire_hook(
|
|
319
|
+
"workflow", "workflow.end",
|
|
320
|
+
payload={"workflow_id": definition.get("id"), "name": definition.get("name"),
|
|
321
|
+
"status": run.status, "steps": steps},
|
|
322
|
+
)
|
|
303
323
|
return run
|
|
304
324
|
|
|
305
325
|
|
|
@@ -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 = "3.
|
|
21
|
+
WORKSPACE_OS_VERSION = "3.4.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
|
@@ -1283,8 +1283,46 @@ AGENT_RUNTIME = AgentRuntime(
|
|
|
1283
1283
|
orchestrator_factory=PLATFORM.build_orchestrator,
|
|
1284
1284
|
workspace_graph=_workspace_graph,
|
|
1285
1285
|
append_audit_event=append_audit_event,
|
|
1286
|
+
hooks=HOOKS_REGISTRY,
|
|
1286
1287
|
)
|
|
1287
1288
|
|
|
1289
|
+
# ── Hooks dispatch: bind real built-in runners ───────────────────────────────
|
|
1290
|
+
# The registry lists built-in hooks; binding a runner here makes them *execute*
|
|
1291
|
+
# real platform behaviour when fired (not a placeholder). Runners take a
|
|
1292
|
+
# HookContext and may mutate its payload, return a status dict, or block.
|
|
1293
|
+
def _hook_redact_secrets(context):
|
|
1294
|
+
"""pre_run — strip secret-like keys from the agent context packet."""
|
|
1295
|
+
secret_re = ("token", "password", "passwd", "secret", "api_key", "apikey",
|
|
1296
|
+
"authorization", "auth", "cookie", "session", "private_key")
|
|
1297
|
+
redacted = []
|
|
1298
|
+
payload = context.payload if isinstance(context.payload, dict) else {}
|
|
1299
|
+
for key in list(payload.keys()):
|
|
1300
|
+
if any(s in str(key).lower() for s in secret_re):
|
|
1301
|
+
payload[key] = "***redacted***"
|
|
1302
|
+
redacted.append(key)
|
|
1303
|
+
return {"status": "ok", "output": f"redacted {len(redacted)} field(s)" if redacted else "no secrets present"}
|
|
1304
|
+
|
|
1305
|
+
def _hook_audit_agent_run(context):
|
|
1306
|
+
"""post_run — append the completed agent run to the workspace audit log."""
|
|
1307
|
+
p = context.payload if isinstance(context.payload, dict) else {}
|
|
1308
|
+
append_audit_event(
|
|
1309
|
+
"hook_post_run",
|
|
1310
|
+
user_email=context.user_email,
|
|
1311
|
+
run_id=p.get("run_id"),
|
|
1312
|
+
agent_id=p.get("agent_id"),
|
|
1313
|
+
status=p.get("status"),
|
|
1314
|
+
)
|
|
1315
|
+
return {"status": "ok", "output": f"audited run {p.get('run_id') or ''}".strip()}
|
|
1316
|
+
|
|
1317
|
+
def _hook_pipeline_index_status(context):
|
|
1318
|
+
"""pipeline — record ingest/embed/graph-build pipeline state for the index."""
|
|
1319
|
+
p = context.payload if isinstance(context.payload, dict) else {}
|
|
1320
|
+
return {"status": "ok", "output": f"pipeline {context.event}: indexed={p.get('indexed')}"}
|
|
1321
|
+
|
|
1322
|
+
HOOKS_REGISTRY.register_hook("builtin:redact-secrets", _hook_redact_secrets)
|
|
1323
|
+
HOOKS_REGISTRY.register_hook("builtin:audit-agent-run", _hook_audit_agent_run)
|
|
1324
|
+
HOOKS_REGISTRY.register_hook("builtin:pipeline-index-status", _hook_pipeline_index_status)
|
|
1325
|
+
|
|
1288
1326
|
app.include_router(create_plugins_router(
|
|
1289
1327
|
registry=PLUGIN_REGISTRY,
|
|
1290
1328
|
require_user=require_user,
|
|
@@ -1307,6 +1345,7 @@ app.include_router(create_workflow_designer_router(
|
|
|
1307
1345
|
append_audit_event=append_audit_event,
|
|
1308
1346
|
ui_file_response=ui_file_response,
|
|
1309
1347
|
static_dir=STATIC_DIR,
|
|
1348
|
+
hooks=HOOKS_REGISTRY,
|
|
1310
1349
|
))
|
|
1311
1350
|
|
|
1312
1351
|
app.include_router(create_agents_router(
|
|
@@ -1457,6 +1496,7 @@ app.include_router(create_tools_router(
|
|
|
1457
1496
|
recommend_mcps=recommend_mcps,
|
|
1458
1497
|
install_mcp=install_mcp,
|
|
1459
1498
|
mcp_public_item=mcp_public_item,
|
|
1499
|
+
hooks=HOOKS_REGISTRY,
|
|
1460
1500
|
))
|
|
1461
1501
|
|
|
1462
1502
|
app.include_router(create_hooks_router(
|
|
@@ -54,12 +54,16 @@ class AgentRuntime:
|
|
|
54
54
|
workspace_graph: Callable[[], Any],
|
|
55
55
|
append_audit_event: Callable[..., None],
|
|
56
56
|
max_retries_cap: int = 5,
|
|
57
|
+
hooks: Any = None,
|
|
57
58
|
):
|
|
58
59
|
self._store = store
|
|
59
60
|
self._orchestrator_factory = orchestrator_factory
|
|
60
61
|
self._workspace_graph = workspace_graph
|
|
61
62
|
self._append_audit_event = append_audit_event
|
|
62
63
|
self._max_retries_cap = int(max_retries_cap)
|
|
64
|
+
# Lifecycle hooks registry (optional). When present, ``start`` fires the
|
|
65
|
+
# ``pre_run`` / ``post_run`` hooks; a blocking ``pre_run`` aborts the run.
|
|
66
|
+
self._hooks = hooks
|
|
63
67
|
|
|
64
68
|
# ── configuration ─────────────────────────────────────────────────────
|
|
65
69
|
def config(self) -> Dict[str, Any]:
|
|
@@ -192,6 +196,26 @@ class AgentRuntime:
|
|
|
192
196
|
) -> Dict[str, Any]:
|
|
193
197
|
if not str(goal or "").strip():
|
|
194
198
|
raise ValueError("goal is required")
|
|
199
|
+
|
|
200
|
+
# ── pre_run hooks ─────────────────────────────────────────────────
|
|
201
|
+
# A blocking pre_run hook (e.g. a policy gate) aborts the run before any
|
|
202
|
+
# orchestration happens. Hook failures never crash the run (fire_hook
|
|
203
|
+
# swallows them); only an explicit block stops it.
|
|
204
|
+
pre_dispatch: Optional[Dict[str, Any]] = None
|
|
205
|
+
if self._hooks is not None:
|
|
206
|
+
pre_dispatch = self._hooks.fire_hook(
|
|
207
|
+
"pre_run", "agent.run",
|
|
208
|
+
payload={"goal": goal, "roles": roles or None, "max_retries": max_retries},
|
|
209
|
+
user_email=user_email, workspace_id=scope,
|
|
210
|
+
)
|
|
211
|
+
if pre_dispatch.get("blocked"):
|
|
212
|
+
self._append_audit_event(
|
|
213
|
+
"multi_agent_run_blocked",
|
|
214
|
+
user_email=user_email,
|
|
215
|
+
reason=pre_dispatch.get("block_reason"),
|
|
216
|
+
)
|
|
217
|
+
raise PermissionError(pre_dispatch.get("block_reason") or "Agent run blocked by a pre_run hook.")
|
|
218
|
+
|
|
195
219
|
orchestrator = self._orchestrator_factory(user_email or None, scope)
|
|
196
220
|
result = orchestrator.run(
|
|
197
221
|
goal,
|
|
@@ -226,7 +250,28 @@ class AgentRuntime:
|
|
|
226
250
|
status=result.status,
|
|
227
251
|
retries=result.retries,
|
|
228
252
|
)
|
|
229
|
-
|
|
253
|
+
|
|
254
|
+
# ── post_run hooks ────────────────────────────────────────────────
|
|
255
|
+
post_dispatch: Optional[Dict[str, Any]] = None
|
|
256
|
+
if self._hooks is not None:
|
|
257
|
+
run_id = run.get("id") or run.get("run_id") if isinstance(run, dict) else None
|
|
258
|
+
post_dispatch = self._hooks.fire_hook(
|
|
259
|
+
"post_run", "agent.run",
|
|
260
|
+
payload={
|
|
261
|
+
"run_id": run_id,
|
|
262
|
+
"agent_id": result.agent_id,
|
|
263
|
+
"status": result.status,
|
|
264
|
+
"retries": result.retries,
|
|
265
|
+
},
|
|
266
|
+
user_email=user_email, workspace_id=scope,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
payload = {"run": run, "result": result.as_dict()}
|
|
270
|
+
if pre_dispatch is not None:
|
|
271
|
+
payload["pre_run_hooks"] = pre_dispatch
|
|
272
|
+
if post_dispatch is not None:
|
|
273
|
+
payload["post_run_hooks"] = post_dispatch
|
|
274
|
+
return payload
|
|
230
275
|
|
|
231
276
|
def stop(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
232
277
|
"""Best-effort stop.
|
|
@@ -23,6 +23,7 @@ async def process_uploaded_document(
|
|
|
23
23
|
classify_sensitive_message,
|
|
24
24
|
append_audit_event,
|
|
25
25
|
enforce_rate_limit,
|
|
26
|
+
hooks=None,
|
|
26
27
|
) -> dict:
|
|
27
28
|
enforce_rate_limit(current_user, "upload")
|
|
28
29
|
suffix = Path(file.filename or "upload").suffix.lower()
|
|
@@ -92,6 +93,22 @@ async def process_uploaded_document(
|
|
|
92
93
|
except OSError:
|
|
93
94
|
pass
|
|
94
95
|
|
|
96
|
+
# ── pipeline hooks ── the ingest → embed → graph-build pipeline just ran for
|
|
97
|
+
# this document; fire the pipeline lifecycle hooks so downstream automation
|
|
98
|
+
# (index-status publishing, custom reindex triggers) executes.
|
|
99
|
+
if hooks is not None:
|
|
100
|
+
kg = result.get("knowledge_graph") or {}
|
|
101
|
+
hooks.fire_hook(
|
|
102
|
+
"pipeline", "document.ingested",
|
|
103
|
+
payload={
|
|
104
|
+
"filename": file.filename,
|
|
105
|
+
"chars": result.get("chars"),
|
|
106
|
+
"graph_node": kg.get("node_id"),
|
|
107
|
+
"indexed": bool(kg.get("node_id")),
|
|
108
|
+
},
|
|
109
|
+
user_email=current_user,
|
|
110
|
+
)
|
|
111
|
+
|
|
95
112
|
result["original_filename"] = file.filename
|
|
96
113
|
return result
|
|
97
114
|
|