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.
Files changed (78) hide show
  1. package/README.md +87 -67
  2. package/docs/CHANGELOG.md +36 -0
  3. package/docs/architecture.md +2 -1
  4. package/docs/assets/v3.4.0/agent-run.png +0 -0
  5. package/docs/assets/v3.4.0/agents.png +0 -0
  6. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  7. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  8. package/docs/assets/v3.4.0/chat.png +0 -0
  9. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  10. package/docs/assets/v3.4.0/files.png +0 -0
  11. package/docs/assets/v3.4.0/home.png +0 -0
  12. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  13. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  14. package/docs/assets/v3.4.0/local-agent.png +0 -0
  15. package/docs/assets/v3.4.0/memory.png +0 -0
  16. package/docs/assets/v3.4.0/settings.png +0 -0
  17. package/docs/assets/v3.4.0/vision-input.png +0 -0
  18. package/docs/assets/v3.4.0/workflows.png +0 -0
  19. package/knowledge_graph.py +45 -0
  20. package/knowledge_graph_api.py +10 -0
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/agents.py +3 -0
  23. package/latticeai/api/hooks.py +39 -0
  24. package/latticeai/api/local_files.py +41 -0
  25. package/latticeai/api/models.py +36 -1
  26. package/latticeai/api/tools.py +16 -1
  27. package/latticeai/api/workflow_designer.py +2 -1
  28. package/latticeai/core/hooks.py +398 -2
  29. package/latticeai/core/marketplace.py +1 -1
  30. package/latticeai/core/multi_agent.py +1 -1
  31. package/latticeai/core/workflow_engine.py +21 -1
  32. package/latticeai/core/workspace_os.py +1 -1
  33. package/latticeai/server_app.py +40 -0
  34. package/latticeai/services/agent_runtime.py +46 -1
  35. package/latticeai/services/upload_service.py +17 -0
  36. package/package.json +1 -1
  37. package/scripts/build_v3_assets.mjs +7 -1
  38. package/scripts/capture/capture_v340.js +88 -0
  39. package/static/css/{tokens.5a595671.css → tokens.3ba22e37.css} +109 -109
  40. package/static/css/tokens.css +109 -109
  41. package/static/v3/asset-manifest.json +25 -25
  42. package/static/v3/css/{lattice.components.011e988b.css → lattice.components.9b49d614.css} +57 -32
  43. package/static/v3/css/lattice.components.css +57 -32
  44. package/static/v3/css/{lattice.shell.4920f42d.css → lattice.shell.6ceea7c8.css} +75 -31
  45. package/static/v3/css/lattice.shell.css +75 -31
  46. package/static/v3/css/lattice.tokens.css +13 -13
  47. package/static/v3/css/{lattice.tokens.c597ff81.css → lattice.tokens.e7018963.css} +13 -13
  48. package/static/v3/css/{lattice.views.3ee19d4e.css → lattice.views.22f69117.css} +98 -15
  49. package/static/v3/css/lattice.views.css +98 -15
  50. package/static/v3/js/{app.a5adc0f3.js → app.c4acfdd8.js} +1 -1
  51. package/static/v3/js/core/{api.603b978f.js → api.12b568ad.js} +126 -4
  52. package/static/v3/js/core/api.js +126 -4
  53. package/static/v3/js/core/{components.4c83e0a9.js → components.35f02e4c.js} +8 -0
  54. package/static/v3/js/core/components.js +8 -0
  55. package/static/v3/js/core/{routes.07ad6696.js → routes.d214b399.js} +16 -12
  56. package/static/v3/js/core/routes.js +16 -12
  57. package/static/v3/js/core/{shell.ea0b9ae5.js → shell.80a6ad82.js} +37 -9
  58. package/static/v3/js/core/shell.js +34 -6
  59. package/static/v3/js/views/agents.014d0b74.js +541 -0
  60. package/static/v3/js/views/agents.js +305 -57
  61. package/static/v3/js/views/{chat.718144ce.js → chat.e6dd7dd0.js} +162 -10
  62. package/static/v3/js/views/chat.js +162 -10
  63. package/static/v3/js/views/files.adad14c1.js +365 -0
  64. package/static/v3/js/views/files.js +269 -90
  65. package/static/v3/js/views/home.24f8b8ae.js +200 -0
  66. package/static/v3/js/views/home.js +96 -15
  67. package/static/v3/js/views/hooks.13845954.js +215 -0
  68. package/static/v3/js/views/hooks.js +117 -1
  69. package/static/v3/js/views/{memory.d2ed7a7c.js → memory.4ebdf474.js} +5 -4
  70. package/static/v3/js/views/memory.js +5 -4
  71. package/static/v3/js/views/{my-computer.1b2ff621.js → my-computer.c3ef5283.js} +224 -1
  72. package/static/v3/js/views/my-computer.js +224 -1
  73. package/static/v3/js/views/{settings.4f777210.js → settings.8631fa5e.js} +70 -2
  74. package/static/v3/js/views/settings.js +70 -2
  75. package/static/v3/js/views/agents.c373d48c.js +0 -293
  76. package/static/v3/js/views/files.4935197e.js +0 -186
  77. package/static/v3/js/views/home.cdde3b32.js +0 -119
  78. package/static/v3/js/views/hooks.f3edebca.js +0 -99
@@ -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
+ }
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "3.2.0"
14
+ MARKETPLACE_VERSION = "3.4.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -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.2.0"
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.2.0"
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
@@ -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
- return {"run": run, "result": result.as_dict()}
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