ltcai 3.1.0 → 3.2.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 (44) hide show
  1. package/README.md +34 -8
  2. package/docs/CHANGELOG.md +53 -0
  3. package/docs/V3_2_AUDIT.md +82 -0
  4. package/docs/V3_FRONTEND.md +1 -1
  5. package/docs/architecture.md +6 -0
  6. package/latticeai/__init__.py +1 -1
  7. package/latticeai/api/agent_registry.py +103 -0
  8. package/latticeai/api/hooks.py +113 -0
  9. package/latticeai/api/marketplace.py +13 -0
  10. package/latticeai/api/memory.py +109 -0
  11. package/latticeai/core/agent_registry.py +234 -0
  12. package/latticeai/core/hooks.py +284 -0
  13. package/latticeai/core/marketplace.py +87 -2
  14. package/latticeai/core/multi_agent.py +1 -1
  15. package/latticeai/core/workspace_os.py +1 -1
  16. package/latticeai/server_app.py +41 -0
  17. package/latticeai/services/memory_service.py +324 -0
  18. package/package.json +2 -2
  19. package/scripts/build_v3_assets.mjs +1 -1
  20. package/static/v3/asset-manifest.json +16 -8
  21. package/static/v3/js/{app.46fb61d9.js → app.a5adc0f3.js} +1 -1
  22. package/static/v3/js/core/{api.22a41d42.js → api.603b978f.js} +64 -0
  23. package/static/v3/js/core/api.js +64 -0
  24. package/static/v3/js/core/{routes.f935dd50.js → routes.07ad6696.js} +11 -0
  25. package/static/v3/js/core/routes.js +11 -0
  26. package/static/v3/js/core/{shell.1b6199d6.js → shell.ea0b9ae5.js} +2 -2
  27. package/static/v3/js/views/{agents.14e48bdd.js → agents.c373d48c.js} +100 -0
  28. package/static/v3/js/views/agents.js +100 -0
  29. package/static/v3/js/views/hooks.f3edebca.js +99 -0
  30. package/static/v3/js/views/hooks.js +99 -0
  31. package/static/v3/js/views/marketplace.ab0583d4.js +141 -0
  32. package/static/v3/js/views/marketplace.js +141 -0
  33. package/static/v3/js/views/mcp.99b5c6a7.js +114 -0
  34. package/static/v3/js/views/mcp.js +114 -0
  35. package/static/v3/js/views/memory.d2ed7a7c.js +146 -0
  36. package/static/v3/js/views/memory.js +146 -0
  37. package/static/v3/js/views/planning.9ac3e313.js +153 -0
  38. package/static/v3/js/views/planning.js +153 -0
  39. package/static/v3/js/views/skills.c6c2f965.js +109 -0
  40. package/static/v3/js/views/skills.js +109 -0
  41. package/static/v3/js/views/tools.e4f11276.js +108 -0
  42. package/static/v3/js/views/tools.js +108 -0
  43. package/static/v3/js/views/workflows.26c57290.js +128 -0
  44. package/static/v3/js/views/workflows.js +128 -0
@@ -0,0 +1,234 @@
1
+ """Agent Registry — registration, discovery, metadata, versioning, capabilities.
2
+
3
+ Part 2 of the v3.2.0 platform. Before this module the agent roster was derived
4
+ ad-hoc from the hardcoded ``AGENT_ROLES`` tuple wherever it was needed. The
5
+ registry makes every agent — built-in role and user-registered custom agent — a
6
+ first-class entry with stable metadata: ``id``, ``type``, ``version``,
7
+ ``capabilities``, ``description`` and a mutable ``config``. The HTTP surface and
8
+ the /app views read this registry instead of any hardcoded list.
9
+
10
+ Built-in role agents are projected from the single source of truth in
11
+ :mod:`latticeai.core.multi_agent` (so adding a role there flows through here).
12
+ Custom agents and config overrides are persisted to
13
+ ``data_dir/agent_registry.json``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import tempfile
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ from latticeai.core.multi_agent import (
26
+ AGENT_ROLES,
27
+ CORE_PIPELINE,
28
+ MULTI_AGENT_VERSION,
29
+ ROLE_AGENT_IDS,
30
+ )
31
+
32
+ AGENT_TYPES = ("planner", "researcher", "executor", "reviewer", "release", "custom")
33
+
34
+
35
+ def _now() -> str:
36
+ return datetime.now().isoformat(timespec="seconds")
37
+
38
+
39
+ # Capabilities + descriptions for the built-in role agents. Kept here as the
40
+ # registry's metadata projection of the roles defined in multi_agent.py.
41
+ ROLE_META: Dict[str, Dict[str, Any]] = {
42
+ "researcher": {
43
+ "description": "Gathers workspace context, memory, and graph signal for the goal.",
44
+ "capabilities": ["context-retrieval", "memory-recall", "graph-read", "hybrid-search"],
45
+ },
46
+ "planner": {
47
+ "description": "Decomposes the goal into an ordered, bounded, reviewable plan.",
48
+ "capabilities": ["task-decomposition", "plan-review", "delegation"],
49
+ },
50
+ "executor": {
51
+ "description": "Executes each planned step, invoking tools, workflows, and plugins.",
52
+ "capabilities": ["tool-use", "workflow-run", "plugin-run", "file-write"],
53
+ },
54
+ "reviewer": {
55
+ "description": "Reviews executed work and approves, rejects, or requests a retry.",
56
+ "capabilities": ["verification", "retry-control", "approval"],
57
+ },
58
+ "release": {
59
+ "description": "Finalizes and summarizes the approved outcome.",
60
+ "capabilities": ["summarize", "finalize"],
61
+ },
62
+ }
63
+
64
+
65
+ class AgentRegistry:
66
+ """Persisted registry of built-in role agents + user-registered agents."""
67
+
68
+ def __init__(self, path: Path):
69
+ self.path = Path(path)
70
+ self._state: Dict[str, Any] = self._load()
71
+
72
+ # ── persistence ───────────────────────────────────────────────────────
73
+ def _load(self) -> Dict[str, Any]:
74
+ if self.path.exists():
75
+ try:
76
+ with open(self.path, "r", encoding="utf-8") as fh:
77
+ data = json.load(fh)
78
+ if isinstance(data, dict):
79
+ data.setdefault("custom", [])
80
+ data.setdefault("config_overrides", {})
81
+ return data
82
+ except Exception:
83
+ pass
84
+ return {"custom": [], "config_overrides": {}}
85
+
86
+ def _save(self) -> None:
87
+ self.path.parent.mkdir(parents=True, exist_ok=True)
88
+ fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), suffix=".tmp")
89
+ try:
90
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
91
+ json.dump(self._state, fh, ensure_ascii=False, indent=2)
92
+ os.replace(tmp, self.path)
93
+ finally:
94
+ if os.path.exists(tmp):
95
+ os.unlink(tmp)
96
+
97
+ # ── projection ────────────────────────────────────────────────────────
98
+ def _builtin_agents(self) -> List[Dict[str, Any]]:
99
+ overrides = self._state.get("config_overrides", {})
100
+ agents: List[Dict[str, Any]] = []
101
+ for role in AGENT_ROLES:
102
+ meta = ROLE_META.get(role, {"description": "", "capabilities": []})
103
+ agent_id = ROLE_AGENT_IDS.get(role, f"agent:{role}")
104
+ handoffs: List[str] = []
105
+ if role == "planner":
106
+ handoffs = [ROLE_AGENT_IDS["executor"]]
107
+ elif role == "executor":
108
+ handoffs = [ROLE_AGENT_IDS["reviewer"]]
109
+ ov = overrides.get(agent_id, {})
110
+ agents.append({
111
+ "id": agent_id,
112
+ "name": role.capitalize(),
113
+ "type": role,
114
+ "version": MULTI_AGENT_VERSION,
115
+ "description": meta["description"],
116
+ "capabilities": list(meta["capabilities"]),
117
+ "handoffs": handoffs,
118
+ "in_default_pipeline": role in CORE_PIPELINE,
119
+ "source": "builtin",
120
+ "removable": False,
121
+ "enabled": bool(ov.get("enabled", True)),
122
+ "config": ov.get("config", {}),
123
+ })
124
+ return agents
125
+
126
+ def _custom_agents(self) -> List[Dict[str, Any]]:
127
+ out: List[Dict[str, Any]] = []
128
+ for entry in self._state.get("custom", []):
129
+ agent = dict(entry)
130
+ agent["source"] = "user"
131
+ agent["removable"] = True
132
+ agent.setdefault("enabled", True)
133
+ agent.setdefault("handoffs", [])
134
+ out.append(agent)
135
+ return out
136
+
137
+ def all(self) -> List[Dict[str, Any]]:
138
+ return self._builtin_agents() + self._custom_agents()
139
+
140
+ # ── reads ─────────────────────────────────────────────────────────────
141
+ def list(self, agent_type: Optional[str] = None) -> Dict[str, Any]:
142
+ agents = self.all()
143
+ if agent_type:
144
+ agents = [a for a in agents if a["type"] == agent_type]
145
+ counts: Dict[str, int] = {}
146
+ for a in self.all():
147
+ counts[a["type"]] = counts.get(a["type"], 0) + 1
148
+ return {
149
+ "agents": agents,
150
+ "types": list(AGENT_TYPES),
151
+ "counts": counts,
152
+ "total": len(agents),
153
+ "version": MULTI_AGENT_VERSION,
154
+ "default_pipeline": list(CORE_PIPELINE),
155
+ "generated_at": _now(),
156
+ }
157
+
158
+ def get(self, agent_id: str) -> Optional[Dict[str, Any]]:
159
+ return next((a for a in self.all() if a["id"] == agent_id), None)
160
+
161
+ def capabilities(self) -> Dict[str, List[str]]:
162
+ """Inverted index: capability -> [agent_id, …]."""
163
+ index: Dict[str, List[str]] = {}
164
+ for a in self.all():
165
+ for cap in a.get("capabilities", []):
166
+ index.setdefault(cap, []).append(a["id"])
167
+ return index
168
+
169
+ def discover(self, capability: str) -> List[Dict[str, Any]]:
170
+ cap = str(capability or "").lower().strip()
171
+ return [a for a in self.all() if any(cap == c.lower() for c in a.get("capabilities", []))]
172
+
173
+ # ── mutations ─────────────────────────────────────────────────────────
174
+ def register(
175
+ self,
176
+ *,
177
+ name: str,
178
+ agent_type: str = "custom",
179
+ description: str = "",
180
+ capabilities: Optional[List[str]] = None,
181
+ config: Optional[Dict[str, Any]] = None,
182
+ version: str = "1.0.0",
183
+ ) -> Dict[str, Any]:
184
+ if not str(name).strip():
185
+ raise ValueError("name is required")
186
+ if agent_type not in AGENT_TYPES:
187
+ raise ValueError(f"type must be one of {', '.join(AGENT_TYPES)}")
188
+ slug = str(name).strip().lower().replace(" ", "-")
189
+ agent_id = f"agent:custom:{slug}"
190
+ existing = {c["id"] for c in self._state.get("custom", [])}
191
+ if agent_id in existing:
192
+ agent_id = f"agent:custom:{slug}-{len(existing) + 1}"
193
+ entry = {
194
+ "id": agent_id,
195
+ "name": str(name).strip(),
196
+ "type": agent_type,
197
+ "version": str(version or "1.0.0"),
198
+ "description": str(description or "").strip(),
199
+ "capabilities": list(capabilities or []),
200
+ "config": dict(config or {}),
201
+ "enabled": True,
202
+ "created_at": _now(),
203
+ }
204
+ self._state.setdefault("custom", []).append(entry)
205
+ self._save()
206
+ return entry
207
+
208
+ def update_config(self, agent_id: str, config: Dict[str, Any], *, enabled: Optional[bool] = None) -> Dict[str, Any]:
209
+ if self.get(agent_id) is None:
210
+ raise KeyError(agent_id)
211
+ if agent_id.startswith("agent:custom:"):
212
+ for entry in self._state.get("custom", []):
213
+ if entry["id"] == agent_id:
214
+ entry["config"] = dict(config or {})
215
+ if enabled is not None:
216
+ entry["enabled"] = bool(enabled)
217
+ entry["updated_at"] = _now()
218
+ else:
219
+ ov = self._state.setdefault("config_overrides", {}).setdefault(agent_id, {})
220
+ ov["config"] = dict(config or {})
221
+ if enabled is not None:
222
+ ov["enabled"] = bool(enabled)
223
+ self._save()
224
+ return self.get(agent_id) # type: ignore[return-value]
225
+
226
+ def remove(self, agent_id: str) -> Dict[str, Any]:
227
+ if not agent_id.startswith("agent:custom:"):
228
+ raise ValueError("Built-in role agents cannot be removed; disable them via config instead.")
229
+ before = len(self._state.get("custom", []))
230
+ self._state["custom"] = [c for c in self._state.get("custom", []) if c["id"] != agent_id]
231
+ if len(self._state["custom"]) == before:
232
+ raise KeyError(agent_id)
233
+ self._save()
234
+ return {"removed": agent_id}
@@ -0,0 +1,284 @@
1
+ """Hooks platform — a persisted registry of lifecycle extension points.
2
+
3
+ Lattice AI runs several behaviours at well-defined points in the agent / tool /
4
+ workflow lifecycle (audit logging, secret redaction, sensitive-data
5
+ classification, tool-permission gating, memory snapshots, workflow replay
6
+ logging). v3.2.0 makes those points *first-class and inspectable*: every hook is
7
+ listed, ordered, and individually enable/disable-able, and users can register
8
+ their own custom hooks alongside the built-ins.
9
+
10
+ The registry owns metadata, ordering and the enabled flag (persisted to
11
+ ``data_dir/hooks.json``). Built-in hooks carry ``source="builtin"`` and map onto
12
+ behaviour the platform already performs; ``managed`` records whether the
13
+ behaviour is enforced by the platform (``platform``) or is an advisory hook the
14
+ user has registered (``user``). The registry never silently drops a hook: the
15
+ full set is always returned so the UI can show exactly what runs and in which
16
+ order.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import tempfile
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional
27
+
28
+
29
+ HOOK_KINDS = (
30
+ "pre_run",
31
+ "post_run",
32
+ "pre_tool",
33
+ "post_tool",
34
+ "agent",
35
+ "pipeline",
36
+ "workflow",
37
+ )
38
+
39
+
40
+ def _now() -> str:
41
+ return datetime.now().isoformat(timespec="seconds")
42
+
43
+
44
+ # Built-in hooks describe lifecycle points the platform already exercises. They
45
+ # are honest reflections of existing behaviour (see the `binding` field), made
46
+ # visible and orderable here. Disabling a `managed="platform"` hook is recorded
47
+ # and surfaced, but core safety behaviours remain enforced by their owning
48
+ # subsystem — the UI states this explicitly so nothing is misrepresented.
49
+ BUILTIN_HOOKS: List[Dict[str, Any]] = [
50
+ {
51
+ "id": "builtin:redact-secrets",
52
+ "name": "Redact secrets",
53
+ "kind": "pre_run",
54
+ "order": 10,
55
+ "description": "Strip secret-like fields (token, password, api_key…) from agent context packets before a run.",
56
+ "binding": "latticeai.core.multi_agent._redact",
57
+ "managed": "platform",
58
+ },
59
+ {
60
+ "id": "builtin:research-memory-snapshot",
61
+ "name": "Research memory snapshot",
62
+ "kind": "agent",
63
+ "order": 20,
64
+ "description": "Capture a short-term memory snapshot after the researcher stage gathers context.",
65
+ "binding": "latticeai.core.multi_agent.default_role_runner",
66
+ "managed": "platform",
67
+ },
68
+ {
69
+ "id": "builtin:tool-permission-gate",
70
+ "name": "Tool permission gate",
71
+ "kind": "pre_tool",
72
+ "order": 10,
73
+ "description": "Require explicit approval for tools whose governance policy is not auto-approve.",
74
+ "binding": "latticeai.core.tool_registry.ToolRegistry.permission",
75
+ "managed": "platform",
76
+ },
77
+ {
78
+ "id": "builtin:sensitive-data-guard",
79
+ "name": "Sensitive-data guard",
80
+ "kind": "pre_tool",
81
+ "order": 20,
82
+ "description": "Classify outgoing content for sensitive data before tool execution.",
83
+ "binding": "server_app.classify_sensitive_message",
84
+ "managed": "platform",
85
+ },
86
+ {
87
+ "id": "builtin:audit-agent-run",
88
+ "name": "Audit agent run",
89
+ "kind": "post_run",
90
+ "order": 10,
91
+ "description": "Append every completed agent run to the workspace audit log.",
92
+ "binding": "latticeai.services.agent_runtime.AgentRuntime.start",
93
+ "managed": "platform",
94
+ },
95
+ {
96
+ "id": "builtin:workflow-replay-log",
97
+ "name": "Workflow replay log",
98
+ "kind": "workflow",
99
+ "order": 10,
100
+ "description": "Record each workflow run's timeline so it can be replayed step by step.",
101
+ "binding": "latticeai.api.workflow_designer",
102
+ "managed": "platform",
103
+ },
104
+ {
105
+ "id": "builtin:pipeline-index-status",
106
+ "name": "Pipeline index status",
107
+ "kind": "pipeline",
108
+ "order": 10,
109
+ "description": "Publish ingest / embed / graph-build pipeline state to the retrieval index status.",
110
+ "binding": "latticeai.api.search",
111
+ "managed": "platform",
112
+ },
113
+ ]
114
+
115
+
116
+ class HooksRegistry:
117
+ """Persisted registry of lifecycle hooks (built-in + user-registered)."""
118
+
119
+ def __init__(self, path: Path):
120
+ self.path = Path(path)
121
+ self._state: Dict[str, Any] = self._load()
122
+
123
+ # ── persistence ───────────────────────────────────────────────────────
124
+ def _load(self) -> Dict[str, Any]:
125
+ if self.path.exists():
126
+ try:
127
+ with open(self.path, "r", encoding="utf-8") as fh:
128
+ data = json.load(fh)
129
+ if isinstance(data, dict):
130
+ data.setdefault("custom", [])
131
+ data.setdefault("overrides", {})
132
+ return data
133
+ except Exception:
134
+ pass
135
+ return {"custom": [], "overrides": {}}
136
+
137
+ def _save(self) -> None:
138
+ self.path.parent.mkdir(parents=True, exist_ok=True)
139
+ fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), suffix=".tmp")
140
+ try:
141
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
142
+ json.dump(self._state, fh, ensure_ascii=False, indent=2)
143
+ os.replace(tmp, self.path)
144
+ finally:
145
+ if os.path.exists(tmp):
146
+ os.unlink(tmp)
147
+
148
+ # ── views ─────────────────────────────────────────────────────────────
149
+ def _materialize(self) -> List[Dict[str, Any]]:
150
+ overrides: Dict[str, Any] = self._state.get("overrides", {})
151
+ hooks: List[Dict[str, Any]] = []
152
+ for base in BUILTIN_HOOKS:
153
+ ov = overrides.get(base["id"], {})
154
+ hook = dict(base)
155
+ hook["source"] = "builtin"
156
+ hook["enabled"] = bool(ov.get("enabled", True))
157
+ if "order" in ov:
158
+ hook["order"] = ov["order"]
159
+ hook["removable"] = False
160
+ hooks.append(hook)
161
+ for custom in self._state.get("custom", []):
162
+ hook = dict(custom)
163
+ hook["source"] = "user"
164
+ hook.setdefault("managed", "user")
165
+ hook.setdefault("binding", "advisory")
166
+ hook["enabled"] = bool(custom.get("enabled", True))
167
+ hook["removable"] = True
168
+ hooks.append(hook)
169
+ hooks.sort(key=lambda h: (HOOK_KINDS.index(h["kind"]) if h["kind"] in HOOK_KINDS else 99, h.get("order", 100), h["id"]))
170
+ return hooks
171
+
172
+ def list(self, kind: Optional[str] = None) -> Dict[str, Any]:
173
+ hooks = self._materialize()
174
+ if kind:
175
+ hooks = [h for h in hooks if h["kind"] == kind]
176
+ counts: Dict[str, Dict[str, int]] = {}
177
+ for h in self._materialize():
178
+ bucket = counts.setdefault(h["kind"], {"total": 0, "enabled": 0})
179
+ bucket["total"] += 1
180
+ if h["enabled"]:
181
+ bucket["enabled"] += 1
182
+ return {
183
+ "hooks": hooks,
184
+ "kinds": list(HOOK_KINDS),
185
+ "counts": counts,
186
+ "total": len(hooks),
187
+ "enabled": sum(1 for h in hooks if h["enabled"]),
188
+ "generated_at": _now(),
189
+ }
190
+
191
+ def get(self, hook_id: str) -> Optional[Dict[str, Any]]:
192
+ return next((h for h in self._materialize() if h["id"] == hook_id), None)
193
+
194
+ def inspect(self, hook_id: str) -> Dict[str, Any]:
195
+ hook = self.get(hook_id)
196
+ if hook is None:
197
+ raise KeyError(hook_id)
198
+ detail = dict(hook)
199
+ detail["advisory"] = hook.get("managed") != "platform"
200
+ detail["note"] = (
201
+ "Enforced by its owning subsystem; the registry controls visibility and ordering."
202
+ if hook.get("managed") == "platform"
203
+ else "User-registered hook: listed, ordered and inspectable; runs advisory in this build."
204
+ )
205
+ return detail
206
+
207
+ # ── mutations ─────────────────────────────────────────────────────────
208
+ def set_enabled(self, hook_id: str, enabled: bool) -> Dict[str, Any]:
209
+ if self.get(hook_id) is None:
210
+ raise KeyError(hook_id)
211
+ if hook_id.startswith("builtin:"):
212
+ self._state.setdefault("overrides", {}).setdefault(hook_id, {})["enabled"] = bool(enabled)
213
+ else:
214
+ for custom in self._state.get("custom", []):
215
+ if custom["id"] == hook_id:
216
+ custom["enabled"] = bool(enabled)
217
+ self._save()
218
+ return self.get(hook_id) # type: ignore[return-value]
219
+
220
+ def set_order(self, hook_id: str, order: int) -> Dict[str, Any]:
221
+ if self.get(hook_id) is None:
222
+ raise KeyError(hook_id)
223
+ order = int(order)
224
+ if hook_id.startswith("builtin:"):
225
+ self._state.setdefault("overrides", {}).setdefault(hook_id, {})["order"] = order
226
+ else:
227
+ for custom in self._state.get("custom", []):
228
+ if custom["id"] == hook_id:
229
+ custom["order"] = order
230
+ self._save()
231
+ return self.get(hook_id) # type: ignore[return-value]
232
+
233
+ def reorder(self, kind: str, ordered_ids: List[str]) -> Dict[str, Any]:
234
+ for idx, hook_id in enumerate(ordered_ids):
235
+ try:
236
+ self.set_order(hook_id, (idx + 1) * 10)
237
+ except KeyError:
238
+ continue
239
+ return self.list(kind=kind)
240
+
241
+ def register(
242
+ self,
243
+ *,
244
+ name: str,
245
+ kind: str,
246
+ description: str = "",
247
+ command: str = "",
248
+ order: Optional[int] = None,
249
+ enabled: bool = True,
250
+ ) -> Dict[str, Any]:
251
+ if not str(name).strip():
252
+ raise ValueError("name is required")
253
+ if kind not in HOOK_KINDS:
254
+ raise ValueError(f"kind must be one of {', '.join(HOOK_KINDS)}")
255
+ slug = str(name).strip().lower().replace(" ", "-")
256
+ hook_id = f"user:{slug}"
257
+ existing = {c["id"] for c in self._state.get("custom", [])}
258
+ if hook_id in existing:
259
+ hook_id = f"user:{slug}-{len(existing) + 1}"
260
+ entry = {
261
+ "id": hook_id,
262
+ "name": str(name).strip(),
263
+ "kind": kind,
264
+ "description": str(description or "").strip(),
265
+ "command": str(command or "").strip(),
266
+ "order": int(order) if order is not None else 100,
267
+ "enabled": bool(enabled),
268
+ "managed": "user",
269
+ "binding": "advisory",
270
+ "created_at": _now(),
271
+ }
272
+ self._state.setdefault("custom", []).append(entry)
273
+ self._save()
274
+ return entry
275
+
276
+ def remove(self, hook_id: str) -> Dict[str, Any]:
277
+ if hook_id.startswith("builtin:"):
278
+ raise ValueError("Built-in hooks cannot be removed; disable them instead.")
279
+ before = len(self._state.get("custom", []))
280
+ self._state["custom"] = [c for c in self._state.get("custom", []) if c["id"] != hook_id]
281
+ if len(self._state["custom"]) == before:
282
+ raise KeyError(hook_id)
283
+ self._save()
284
+ return {"removed": hook_id}
@@ -11,10 +11,39 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "2.2.0"
14
+ MARKETPLACE_VERSION = "3.2.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
18
+ def _agent_template(
19
+ template_id: str,
20
+ name: str,
21
+ description: str,
22
+ *,
23
+ roles: List[str],
24
+ capabilities: List[str],
25
+ suggested_tools: List[str],
26
+ category: str,
27
+ max_retries: int = 2,
28
+ ) -> Dict[str, Any]:
29
+ """Build a portable agent template entry (Part 4 reusable templates)."""
30
+ return {
31
+ "id": template_id,
32
+ "kind": "agent",
33
+ "name": name,
34
+ "version": "1.0.0",
35
+ "description": description,
36
+ "metadata": {"category": category, "installable": True, "agent_template": True},
37
+ "definition": {
38
+ "roles": roles,
39
+ "max_retries": max_retries,
40
+ "capabilities": capabilities,
41
+ "suggested_tools": suggested_tools,
42
+ "constraints": ["workspace scoped", "no secret leakage", "replayable timeline"],
43
+ },
44
+ }
45
+
46
+
18
47
  class MarketplaceError(Exception):
19
48
  """Raised for invalid template operations."""
20
49
 
@@ -73,7 +102,47 @@ BUILTIN_TEMPLATES: Dict[str, List[Dict[str, Any]]] = {
73
102
  "max_retries": 2,
74
103
  "constraints": ["workspace scoped", "no secret leakage", "replayable timeline"],
75
104
  },
76
- }
105
+ },
106
+ _agent_template(
107
+ "agent-research-assistant", "Research Assistant",
108
+ "Retrieves workspace context, plans an inquiry, and synthesizes a reviewed answer.",
109
+ roles=["researcher", "planner", "reviewer"],
110
+ capabilities=["context-retrieval", "hybrid-search", "memory-recall", "synthesis"],
111
+ suggested_tools=["knowledge_search", "knowledge_graph_search", "read_file"],
112
+ category="research",
113
+ ),
114
+ _agent_template(
115
+ "agent-coding-assistant", "Coding Assistant",
116
+ "Plans a change, edits files, runs the build, and reviews the result before finishing.",
117
+ roles=["planner", "executor", "reviewer"],
118
+ capabilities=["task-decomposition", "tool-use", "file-write", "verification"],
119
+ suggested_tools=["edit_file", "write_file", "run_command", "build_project", "git_diff"],
120
+ category="coding",
121
+ ),
122
+ _agent_template(
123
+ "agent-knowledge-curator", "Knowledge Curator",
124
+ "Captures, structures, and saves knowledge into the graph and memory.",
125
+ roles=["researcher", "executor"],
126
+ capabilities=["context-retrieval", "graph-read", "knowledge-save"],
127
+ suggested_tools=["knowledge_save", "knowledge_graph_ingest", "knowledge_tree"],
128
+ category="knowledge",
129
+ ),
130
+ _agent_template(
131
+ "agent-documentation-writer", "Documentation Writer",
132
+ "Plans a document, drafts and writes it, and reviews for completeness.",
133
+ roles=["planner", "executor", "reviewer"],
134
+ capabilities=["task-decomposition", "file-write", "summarize", "verification"],
135
+ suggested_tools=["create_docx", "create_pdf", "write_file", "read_document"],
136
+ category="documentation",
137
+ ),
138
+ _agent_template(
139
+ "agent-workflow-builder", "Workflow Builder",
140
+ "Plans and assembles a multi-step workflow definition for repeatable automation.",
141
+ roles=["planner", "executor"],
142
+ capabilities=["task-decomposition", "workflow-run", "delegation"],
143
+ suggested_tools=["todo_write", "workspace_tree"],
144
+ category="automation",
145
+ ),
77
146
  ],
78
147
  }
79
148
 
@@ -110,6 +179,22 @@ class TemplateCatalog:
110
179
  return deepcopy(template)
111
180
  raise MarketplaceError(f"template not found: {item_kind}/{template_id}")
112
181
 
182
+ def clone_template(self, kind: str, template_id: str, new_name: Optional[str] = None) -> Dict[str, Any]:
183
+ """Return an editable copy of a template with a fresh id (Part 4 clone)."""
184
+ template = self.get_template(kind, template_id)
185
+ name = (new_name or f"{template['name']} (Copy)").strip()
186
+ slug = name.lower().replace(" ", "-").replace("(", "").replace(")", "")
187
+ clone = deepcopy(template)
188
+ clone["id"] = f"{template['id']}-copy-{slug}"[:80]
189
+ clone["name"] = name
190
+ clone["version"] = "1.0.0"
191
+ clone["metadata"] = {
192
+ **(template.get("metadata") or {}),
193
+ "cloned_from": template_id,
194
+ "editable": True,
195
+ }
196
+ return clone
197
+
113
198
  def export_template(self, kind: str, template_id: str) -> Dict[str, Any]:
114
199
  template = self.get_template(kind, template_id)
115
200
  return {
@@ -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 = "2.2.0"
17
+ MULTI_AGENT_VERSION = "3.2.0"
18
18
 
19
19
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
20
20
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -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.1.0"
21
+ WORKSPACE_OS_VERSION = "3.2.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