ltcai 1.6.0 → 2.0.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 (40) hide show
  1. package/README.md +40 -19
  2. package/docs/CHANGELOG.md +107 -0
  3. package/docs/EDITION_STRATEGY.md +14 -4
  4. package/docs/ENTERPRISE.md +11 -3
  5. package/docs/MULTI_AGENT_RUNTIME.md +410 -0
  6. package/docs/PLUGIN_SDK.md +651 -0
  7. package/docs/REALTIME_COLLABORATION.md +410 -0
  8. package/docs/V2_ARCHITECTURE.md +528 -0
  9. package/docs/WORKFLOW_DESIGNER.md +475 -0
  10. package/latticeai/__init__.py +1 -1
  11. package/latticeai/api/agents.py +98 -0
  12. package/latticeai/api/plugins.py +115 -0
  13. package/latticeai/api/realtime.py +91 -0
  14. package/latticeai/api/workflow_designer.py +207 -0
  15. package/latticeai/core/multi_agent.py +270 -0
  16. package/latticeai/core/plugins.py +400 -0
  17. package/latticeai/core/realtime.py +190 -0
  18. package/latticeai/core/workflow_engine.py +329 -0
  19. package/latticeai/core/workspace_os.py +165 -2
  20. package/latticeai/server_app.py +76 -2
  21. package/latticeai/services/platform_runtime.py +200 -0
  22. package/package.json +17 -2
  23. package/plugins/README.md +35 -0
  24. package/plugins/git-insights/plugin.json +15 -0
  25. package/plugins/hello-world/plugin.json +16 -0
  26. package/plugins/hello-world/skills/hello_skill/SKILL.md +15 -0
  27. package/static/activity.html +70 -0
  28. package/static/admin.html +62 -0
  29. package/static/agents.html +92 -0
  30. package/static/graph.html +7 -1
  31. package/static/lattice-reference.css +184 -0
  32. package/static/platform.css +75 -0
  33. package/static/plugins.html +82 -0
  34. package/static/scripts/admin.js +121 -1
  35. package/static/scripts/graph.js +296 -14
  36. package/static/scripts/platform.js +64 -0
  37. package/static/scripts/workspace.js +107 -10
  38. package/static/workflows.html +121 -0
  39. package/static/workspace.css +73 -0
  40. package/static/workspace.html +18 -2
@@ -0,0 +1,475 @@
1
+ # Lattice AI Workflow Designer
2
+
3
+ The Workflow Designer (introduced in **v2.0.0**) lets you build, validate, run,
4
+ inspect, export, and import automations as a small **directed graph of typed
5
+ nodes**. A workflow starts from a single `trigger` node and walks node-to-node
6
+ to an `output`, dispatching each executable node to an injected *runner* that
7
+ calls the real tool registry, skill registry, plugin registry, or multi-agent
8
+ orchestrator.
9
+
10
+ The execution model lives in
11
+ [`latticeai/core/workflow_engine.py`](../latticeai/core/workflow_engine.py)
12
+ (pure logic with injected runners) and the HTTP surface lives in
13
+ [`latticeai/api/workflow_designer.py`](../latticeai/api/workflow_designer.py).
14
+ Persistence reuses the existing `WorkspaceOSStore`, so pre-2.0 workflow history
15
+ is preserved.
16
+
17
+ The engine version is exported as:
18
+
19
+ ```python
20
+ WORKFLOW_ENGINE_VERSION = "2.0.0"
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Node types
26
+
27
+ A workflow is built from a fixed vocabulary of node types
28
+ (`workflow_engine.NODE_TYPES`):
29
+
30
+ ```python
31
+ NODE_TYPES = (
32
+ "trigger",
33
+ "tool",
34
+ "skill",
35
+ "plugin",
36
+ "agent",
37
+ "condition",
38
+ "output",
39
+ )
40
+ ```
41
+
42
+ - `trigger` — structural entry point. Exactly one per workflow.
43
+ - `output` — structural exit point. Records the run output.
44
+ - `condition` — branches based on a safe comparator over the run context.
45
+ - `tool` / `skill` / `plugin` / `agent` — **executable** nodes, each dispatched
46
+ to a runner of the matching family:
47
+
48
+ ```python
49
+ _RUNNER_FOR = {
50
+ "tool": "tool",
51
+ "skill": "skill",
52
+ "plugin": "plugin",
53
+ "agent": "agent",
54
+ }
55
+ ```
56
+
57
+ ### Node shape
58
+
59
+ Every node is a JSON object with an `id`, a `type`, an optional `name`, a
60
+ `config` blob, and a `next` pointer to the id of the next node (`null`
61
+ terminates the branch):
62
+
63
+ ```json
64
+ {
65
+ "id": "n1",
66
+ "type": "tool",
67
+ "name": "Fetch report",
68
+ "config": { "tool": "http_get", "args": { "url": "https://example.com" } },
69
+ "next": "n2"
70
+ }
71
+ ```
72
+
73
+ ### Condition node shape
74
+
75
+ A `condition` node does **not** use `next`. Instead it defines `branches`,
76
+ mapping the evaluation result to the next node id. The standard branches are
77
+ `true` and `false` (either may be `null` to terminate that path):
78
+
79
+ ```json
80
+ {
81
+ "id": "check",
82
+ "type": "condition",
83
+ "name": "Has results?",
84
+ "config": { "left": "count", "op": ">", "right": 0 },
85
+ "branches": { "true": "notify", "false": "output" }
86
+ }
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Validation — `validate_definition`
92
+
93
+ ```python
94
+ def validate_definition(workflow: Dict[str, Any]) -> List[str]: ...
95
+ ```
96
+
97
+ Returns a list of error strings; an empty list (`[]`) means the workflow is
98
+ valid. The definition is normalized first (see below), then checked against
99
+ these rules:
100
+
101
+ 1. **Non-empty** — a workflow with no nodes is invalid (`"workflow has no nodes"`).
102
+ 2. **Unique ids** — duplicate node ids are rejected (`"duplicate node ids"`),
103
+ and a node without an `id` is reported (`"node missing id"`).
104
+ 3. **Exactly one trigger** — zero triggers reports
105
+ `"workflow must have a trigger node"`; more than one reports
106
+ `"workflow must have exactly one trigger node"`.
107
+ 4. **Known types** — a node `type` outside `NODE_TYPES` reports
108
+ `"node '<id>': unknown type '<type>'"`.
109
+ 5. **Edges resolve** — every `next` (and every `condition` branch target) must
110
+ either be `null` or point at a real node id, otherwise
111
+ `"node '<id>' points at unknown node '<target>'"`.
112
+ 6. **Conditions need branches** — a `condition` node missing a non-empty
113
+ `branches` dict reports
114
+ `"condition node '<id>' must define branches (e.g. true/false)"`.
115
+
116
+ ---
117
+
118
+ ## Normalization & backward compatibility — `normalize_definition`
119
+
120
+ ```python
121
+ def normalize_definition(workflow: Dict[str, Any]) -> Dict[str, Any]: ...
122
+ ```
123
+
124
+ `normalize_definition` returns a node-based definition and **never mutates its
125
+ input**.
126
+
127
+ - If the workflow already has a non-empty `nodes` list, it is returned as-is
128
+ (with `id`, `name`, `nodes`, `metadata`).
129
+ - If it only has a legacy `steps` list (the pre-2.0 shape), it is **lifted into
130
+ a linear `trigger -> tool... -> output` node chain**. Each step becomes a
131
+ `tool` node whose `config` carries the original action and args, and a
132
+ `"lifted_from_steps": true` marker is added to `metadata`.
133
+
134
+ > **Compatibility.** This is the mechanism that keeps **pre-2.0 workflow history
135
+ > working under v2.0**. A stored `{"steps": [...]}` workflow validates and runs
136
+ > unchanged — it is interpreted as a `trigger`-headed node chain at validate /
137
+ > run time, without rewriting persisted history. The change is purely additive:
138
+ > v1.x `steps` data is preserved, and `WorkspaceOSStore.create_workflow` stores
139
+ > the new typed `nodes` graph **alongside** the legacy `steps` list.
140
+
141
+ Lifted shape (illustrative):
142
+
143
+ ```json
144
+ {
145
+ "id": "trigger", "type": "trigger", "name": "Start",
146
+ "config": { "trigger": "manual" }, "next": "step-0"
147
+ }
148
+ {
149
+ "id": "step-0", "type": "tool", "name": "<action>",
150
+ "config": { "tool": "<action>", "args": { } }, "next": "output"
151
+ }
152
+ {
153
+ "id": "output", "type": "output", "name": "Output",
154
+ "config": {}, "next": null
155
+ }
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Execution — `WorkflowEngine.run`
161
+
162
+ ```python
163
+ class WorkflowEngine:
164
+ def __init__(self, runners: Optional[Dict[str, Callable[..., Any]]] = None): ...
165
+
166
+ def run(
167
+ self,
168
+ workflow: Dict[str, Any],
169
+ *,
170
+ inputs: Optional[Dict[str, Any]] = None,
171
+ ) -> WorkflowRun: ...
172
+ ```
173
+
174
+ `run` normalizes and validates the definition first. If validation fails, it
175
+ returns immediately with `status = "failed"` and a single `validation` timeline
176
+ entry carrying the error list.
177
+
178
+ Otherwise it walks the graph from the trigger node, building a step-by-step
179
+ `timeline`. A run context is seeded from `inputs`
180
+ (`{"inputs": inputs, **inputs}`), and each executable node's result is stored
181
+ under both `context["last_output"]` and `context[<node id>]` so later nodes and
182
+ conditions can read it.
183
+
184
+ ### Node dispatch and per-node status
185
+
186
+ - **`trigger`** — recorded `ok`; advances to `next`.
187
+ - **`output`** — recorded `ok`; records its output (the node's
188
+ `config.value`, or `context["last_output"]`) into `run.outputs[<id>]`.
189
+ - **`condition`** — recorded `ok`; evaluated safely (see below); follows the
190
+ `true` or `false` branch.
191
+ - **`tool` / `skill` / `plugin` / `agent`** — dispatched to the runner for the
192
+ node's family `runner(node=<node>, context=<context>)`:
193
+ - **No runner configured** for that family → node recorded as `skipped`
194
+ with a `reason` (`"no '<family>' runner configured"`); the run continues.
195
+ - **Runner raises** → node recorded as `error` with the exception text in
196
+ `reason`.
197
+ - **Otherwise** → node recorded as `ok` with the runner's `result`.
198
+
199
+ ### Overall run status
200
+
201
+ The aggregate `WorkflowRun.status` is derived from what happened:
202
+
203
+ - `"failed"` — any executable node errored (or the cycle guard tripped).
204
+ - `"partial"` — no errors, but at least one node was `skipped`.
205
+ - `"ok"` — every node completed successfully.
206
+
207
+ ### Cycle guard
208
+
209
+ A mis-wired `next` cycle can never hang a run. The engine enforces a hard cap:
210
+
211
+ ```python
212
+ _MAX_STEPS = 100 # hard cap so a mis-wired ``next`` cycle can never hang a run.
213
+ ```
214
+
215
+ If the walk reaches `_MAX_STEPS`, a `guard` timeline entry with status `error`
216
+ is appended (`"exceeded 100 steps (cycle?)"`) and the run is marked `failed`.
217
+
218
+ ### Safe condition evaluation — **no `eval`**
219
+
220
+ Conditions are evaluated by a comparator over the run context. **There is no
221
+ `eval` and no arbitrary code execution.** The config shape is:
222
+
223
+ ```json
224
+ { "left": "<context key>", "op": "==", "right": "<literal>" }
225
+ ```
226
+
227
+ Supported `op` values: `==`, `!=`, `>`, `<`, `>=`, `<=`, `contains`, and
228
+ `truthy` (the default). `left` resolves from the run context by key (falling
229
+ back to `config.left_value`). Numeric comparisons coerce both sides with
230
+ `float()`. **Unknown keys or ops, and any evaluation error, fail closed onto the
231
+ `false` branch** — a misconfigured condition never crashes a run.
232
+
233
+ ### `WorkflowRun`
234
+
235
+ ```python
236
+ @dataclass
237
+ class WorkflowRun:
238
+ workflow_id: Optional[str]
239
+ name: str
240
+ status: str = "ok" # ok | failed | partial
241
+ timeline: List[Dict[str, Any]] = field(default_factory=list)
242
+ outputs: Dict[str, Any] = field(default_factory=dict)
243
+ started_at: str = ...
244
+ finished_at: Optional[str] = None
245
+ ```
246
+
247
+ `WorkflowRun.as_dict()` adds a derived `step_count` (the timeline length). This
248
+ is the `result` object returned by the run endpoint.
249
+
250
+ ---
251
+
252
+ ## Runners and tool-node governance
253
+
254
+ In production, `server_app` injects a `build_runners` callable
255
+ (`PlatformRuntime.build_workflow_runners`) that returns one runner per family:
256
+
257
+ ```python
258
+ {
259
+ "tool": <tool node runner>,
260
+ "skill": <skill node runner>,
261
+ "plugin": <plugin node runner>,
262
+ "agent": <agent node runner>,
263
+ }
264
+ ```
265
+
266
+ > **Safety: the tool node never silently executes destructive tools.** The
267
+ > `tool` runner records the invocation **and its governance decision** — it
268
+ > resolves the tool's permission record and returns
269
+ > `{"tool", "args", "recorded": true, "permission": {...}}` rather than
270
+ > executing the tool. Exec / destructive tools require approval and are **not**
271
+ > run implicitly from a workflow node. Skill / plugin / agent nodes dispatch to
272
+ > their respective registries (skill lookup, plugin action dispatch, multi-agent
273
+ > run) through the same injected map.
274
+
275
+ Because runners are injected, the engine is fully testable: tests pass fakes and
276
+ drive a complete `trigger → ... → output` run with no server, no LLM, and no
277
+ network.
278
+
279
+ ---
280
+
281
+ ## Run history & persistence
282
+
283
+ Runs are persisted through `WorkspaceOSStore.record_workflow_run`:
284
+
285
+ ```python
286
+ def record_workflow_run(
287
+ self,
288
+ *,
289
+ workflow_id: Optional[str],
290
+ name: str,
291
+ status: str,
292
+ timeline: List[Dict[str, Any]],
293
+ outputs: Optional[Dict[str, Any]] = None,
294
+ user_email: Optional[str] = None,
295
+ graph: Any = None,
296
+ workspace_id: Optional[str] = None,
297
+ ) -> Dict[str, Any]: ...
298
+ ```
299
+
300
+ Each recorded run is **local-first** and:
301
+
302
+ - is **ingested into the Knowledge Graph** as a `WorkflowRun` event (the
303
+ resulting `graph_node_id` is stored on the run; a `graph_error` is captured if
304
+ ingest fails);
305
+ - emits a **Workspace timeline event** (`record_timeline_event("workflow",
306
+ "workflow_run", ...)`);
307
+ - is **cross-linked** back onto the workflow's own event log (`{"type": "run",
308
+ ...}`);
309
+ - is **capped** — `workflow_runs` retains the most recent **300** runs
310
+ (`state["workflow_runs"][-300:]`).
311
+
312
+ Runs are workspace-scoped, so listings respect the read scope of the caller.
313
+
314
+ ---
315
+
316
+ ## HTTP API
317
+
318
+ All routes are namespaced under `/workflows` (to avoid colliding with
319
+ `/workspace/workflows`) and are created by `create_workflow_designer_router`.
320
+ Every route requires an authenticated user; mutating routes additionally pass
321
+ through the write gate (workspace scope), and reads through the read gate.
322
+
323
+ ### Designer page
324
+
325
+ ```
326
+ GET /workflows
327
+ ```
328
+
329
+ Serves the Workflow Designer UI (`workflows.html`). Returns `404` if the UI
330
+ file / static dir is not available.
331
+
332
+ ### Definitions
333
+
334
+ ```
335
+ GET /workflows/api/definitions # list (optional ?q= search), read-scoped
336
+ POST /workflows/api/definitions # create (validated), write-scoped
337
+ GET /workflows/api/definitions/{id} # fetch one (404 if missing)
338
+ PATCH /workflows/api/definitions/{id} # update name/nodes/metadata (validated if nodes given)
339
+ ```
340
+
341
+ **Create / update request body** (`WorkflowDefinitionRequest` /
342
+ `WorkflowUpdateRequest`):
343
+
344
+ ```json
345
+ {
346
+ "name": "Daily digest",
347
+ "nodes": [
348
+ { "id": "trigger", "type": "trigger", "name": "Start",
349
+ "config": { "trigger": "manual" }, "next": "out" },
350
+ { "id": "out", "type": "output", "name": "Output",
351
+ "config": {}, "next": null }
352
+ ],
353
+ "metadata": {}
354
+ }
355
+ ```
356
+
357
+ On create/update the nodes are validated with `validate_definition`. Validation
358
+ failure returns `400` with:
359
+
360
+ ```json
361
+ { "detail": { "validation_errors": ["..."] } }
362
+ ```
363
+
364
+ A successful create/update returns `{ "workflow": { ... } }`. Creating a
365
+ workflow also writes a legacy `steps` projection
366
+ (`[{"action": <type>, "node": <id>}]`) alongside the typed `nodes`, and emits a
367
+ `workflow_created` audit event.
368
+
369
+ ### Validate
370
+
371
+ ```
372
+ POST /workflows/api/validate
373
+ ```
374
+
375
+ Request body (`WorkflowValidateRequest`, `name` defaults to `"Draft"`):
376
+
377
+ ```json
378
+ { "name": "Draft", "nodes": [ /* ... */ ] }
379
+ ```
380
+
381
+ Response:
382
+
383
+ ```json
384
+ { "ok": true, "errors": [] }
385
+ ```
386
+
387
+ ### Run
388
+
389
+ ```
390
+ POST /workflows/api/definitions/{id}/run
391
+ ```
392
+
393
+ Request body (`WorkflowRunRequest`):
394
+
395
+ ```json
396
+ { "inputs": { "count": 3 } }
397
+ ```
398
+
399
+ Loads the stored workflow (`404` if missing), builds the runner map for the
400
+ current user + scope, executes via `WorkflowEngine.run`, persists the run with
401
+ `record_workflow_run`, and emits a `workflow_run` audit event. Response:
402
+
403
+ ```json
404
+ {
405
+ "run": { "id": "workflow-run-...", "status": "ok", "...": "..." },
406
+ "result": {
407
+ "workflow_id": "workflow-...",
408
+ "name": "Daily digest",
409
+ "status": "ok",
410
+ "timeline": [ /* per-node entries */ ],
411
+ "outputs": { "out": null },
412
+ "started_at": "2026-06-01T09:00:00",
413
+ "finished_at": "2026-06-01T09:00:00",
414
+ "step_count": 2
415
+ }
416
+ }
417
+ ```
418
+
419
+ ### Run history
420
+
421
+ ```
422
+ GET /workflows/api/definitions/{id}/runs # runs for one workflow (?limit=, default 50)
423
+ GET /workflows/api/runs # all runs in scope (?limit=, default 50)
424
+ ```
425
+
426
+ Both are read-scoped and return `{ "runs": [ ... ] }` (most recent first;
427
+ `limit` is clamped to 1–300).
428
+
429
+ ### Export / import (JSON round-trip)
430
+
431
+ ```
432
+ GET /workflows/api/export/{id} # portable JSON (definition only)
433
+ POST /workflows/api/import # create a workflow from exported JSON
434
+ ```
435
+
436
+ `export_workflow` returns a portable, definition-only payload (no run history
437
+ or scope), stamped with the engine version and stripped of the internal
438
+ `lifted_from_steps` marker:
439
+
440
+ ```json
441
+ {
442
+ "lattice_workflow_export": "2.0.0",
443
+ "name": "Daily digest",
444
+ "nodes": [ /* ... */ ],
445
+ "metadata": {}
446
+ }
447
+ ```
448
+
449
+ Import request body (`WorkflowImportRequest`):
450
+
451
+ ```json
452
+ { "data": { "name": "Daily digest", "nodes": [ /* ... */ ], "metadata": {} } }
453
+ ```
454
+
455
+ `import_workflow` validates the payload (raising `WorkflowError` → `400` on
456
+ invalid input), marks `metadata.imported = true`, persists a new workflow, and
457
+ emits a `workflow_imported` audit event. A successful import returns
458
+ `{ "workflow": { ... } }`.
459
+
460
+ > **Note.** Export carries the definition only — importing on another instance
461
+ > produces a fresh workflow id and a fresh, empty run history.
462
+
463
+ ---
464
+
465
+ ## Compatibility summary
466
+
467
+ - **Additive only.** v2.0 introduces the typed-node graph and the `/workflows`
468
+ API surface without removing v1.x behavior or data.
469
+ - **Legacy data preserved.** Pre-2.0 `{"steps": [...]}` workflows still validate
470
+ and run, lifted on the fly into a `trigger -> ... -> output` chain by
471
+ `normalize_definition`. New workflows store typed `nodes` **alongside** the
472
+ legacy `steps` projection.
473
+ - **Same persistence layer.** Workflows and runs are stored by the existing
474
+ `WorkspaceOSStore`, remain workspace-scoped, and continue to feed the
475
+ Knowledge Graph and Workspace timeline.
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "1.6.0"
3
+ __version__ = "2.0.0"
@@ -0,0 +1,98 @@
1
+ """Multi-Agent Runtime 2.0 API router (v2.0).
2
+
3
+ Exposes the built-in agent roles and an orchestrated run endpoint that connects
4
+ to Workspace, Memory, Knowledge Graph, Workflow runs, and the Timeline. Paths
5
+ are namespaced under ``/agents`` (plural) so they never collide with the
6
+ existing single-agent ``/agent`` endpoints.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Dict, List, Optional
13
+
14
+ from fastapi import APIRouter, HTTPException, Request
15
+ from pydantic import BaseModel
16
+
17
+
18
+ class AgentRunRequest(BaseModel):
19
+ goal: str
20
+ roles: List[str] = []
21
+ inputs: Dict[str, Any] = {}
22
+ max_retries: int = 2
23
+
24
+
25
+ def create_agents_router(
26
+ *,
27
+ store,
28
+ orchestrator_factory: Callable[[Optional[str], Optional[str]], Any],
29
+ require_user: Callable[[Request], str],
30
+ get_current_user: Callable[[Request], Optional[str]],
31
+ gate_read: Callable[[Request], Optional[str]],
32
+ gate_write: Callable[[Request], Optional[str]],
33
+ workspace_graph: Callable[[], Any],
34
+ append_audit_event: Callable[..., None],
35
+ ui_file_response: Optional[Callable[[Path], Any]] = None,
36
+ static_dir: Optional[Path] = None,
37
+ ) -> APIRouter:
38
+ from latticeai.core.multi_agent import AGENT_ROLES, ROLE_AGENT_IDS
39
+
40
+ router = APIRouter()
41
+
42
+ @router.get("/agents")
43
+ async def agents_page(request: Request):
44
+ require_user(request)
45
+ if ui_file_response is None or static_dir is None:
46
+ raise HTTPException(status_code=404, detail="Multi-Agent UI not available.")
47
+ page = static_dir / "agents.html"
48
+ if not page.exists():
49
+ raise HTTPException(status_code=404, detail="Multi-Agent UI not found.")
50
+ return ui_file_response(page)
51
+
52
+ @router.get("/agents/api/roles")
53
+ async def agent_roles(request: Request):
54
+ require_user(request)
55
+ return {
56
+ "roles": [
57
+ {"role": role, "agent_id": ROLE_AGENT_IDS.get(role, f"agent:{role}")}
58
+ for role in AGENT_ROLES
59
+ ],
60
+ "default_pipeline": ["planner", "executor", "reviewer"],
61
+ }
62
+
63
+ @router.get("/agents/api/runs")
64
+ async def agent_runs(request: Request):
65
+ require_user(request)
66
+ scope = gate_read(request)
67
+ return store.list_agents(workspace_id=scope)
68
+
69
+ @router.post("/agents/api/run")
70
+ async def agent_run(req: AgentRunRequest, request: Request):
71
+ current_user = require_user(request)
72
+ scope = gate_write(request)
73
+ if not str(req.goal or "").strip():
74
+ raise HTTPException(status_code=400, detail="goal is required")
75
+ orchestrator = orchestrator_factory(current_user or None, scope)
76
+ result = orchestrator.run(
77
+ req.goal,
78
+ user_email=current_user or None,
79
+ workspace_id=scope,
80
+ inputs=req.inputs,
81
+ roles=req.roles or None,
82
+ max_retries=max(0, min(int(req.max_retries or 0), 5)),
83
+ )
84
+ run = store.record_agent_run(
85
+ agent_id=result.agent_id,
86
+ status=result.status,
87
+ input_text=req.goal,
88
+ output_text=result.output,
89
+ timeline=result.timeline,
90
+ relationships=[ROLE_AGENT_IDS.get(r, f"agent:{r}") for r in result.roles_run],
91
+ user_email=current_user or None,
92
+ graph=workspace_graph(),
93
+ workspace_id=scope,
94
+ )
95
+ append_audit_event("multi_agent_run", user_email=current_user, agent_id=result.agent_id, status=result.status, retries=result.retries)
96
+ return {"run": run, "result": result.as_dict()}
97
+
98
+ return router
@@ -0,0 +1,115 @@
1
+ """Plugin SDK API router (v2.0).
2
+
3
+ Surfaces the :class:`latticeai.core.plugins.PluginRegistry` over HTTP using the
4
+ same router-factory convention as the rest of ``latticeai.api`` (server_app
5
+ constructs the dependencies and passes them in; this module never imports the
6
+ app). New paths are namespaced under ``/plugins/registry`` and friends so they
7
+ do not collide with the pre-existing ``/plugins/directory`` marketplace routes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, Optional
14
+
15
+ from fastapi import APIRouter, HTTPException, Request
16
+ from pydantic import BaseModel
17
+
18
+
19
+ class PluginActionRequest(BaseModel):
20
+ plugin_id: str
21
+ enabled: Optional[bool] = None
22
+ version: Optional[str] = None
23
+
24
+
25
+ class PluginValidateRequest(BaseModel):
26
+ manifest: Dict[str, Any] = {}
27
+
28
+
29
+ class PluginExecuteRequest(BaseModel):
30
+ plugin_id: str
31
+ action: str
32
+ args: Dict[str, Any] = {}
33
+
34
+
35
+ def create_plugins_router(
36
+ *,
37
+ registry,
38
+ require_user: Callable[[Request], str],
39
+ require_admin: Callable[[Request], Any],
40
+ append_audit_event: Callable[..., None],
41
+ register_skill: Optional[Callable[[str, str], Any]] = None,
42
+ plugin_runners_factory: Optional[Callable[[], Dict[str, Callable[..., Any]]]] = None,
43
+ ui_file_response: Optional[Callable[[Path], Any]] = None,
44
+ static_dir: Optional[Path] = None,
45
+ ) -> APIRouter:
46
+ from latticeai.core.plugins import validate_manifest
47
+
48
+ router = APIRouter()
49
+
50
+ @router.get("/plugins/sdk")
51
+ async def plugins_sdk_page(request: Request):
52
+ require_user(request)
53
+ if ui_file_response is None or static_dir is None:
54
+ raise HTTPException(status_code=404, detail="Plugin SDK UI not available.")
55
+ page = static_dir / "plugins.html"
56
+ if not page.exists():
57
+ raise HTTPException(status_code=404, detail="Plugin SDK UI not found.")
58
+ return ui_file_response(page)
59
+
60
+ @router.get("/plugins/registry")
61
+ async def plugins_registry(request: Request):
62
+ require_user(request)
63
+ return registry.catalog()
64
+
65
+ @router.get("/plugins/registry/{plugin_id}")
66
+ async def plugin_detail(plugin_id: str, request: Request):
67
+ require_user(request)
68
+ manifest = registry.get_manifest(plugin_id)
69
+ if manifest is None:
70
+ raise HTTPException(status_code=404, detail=f"Plugin not found: {plugin_id}")
71
+ state = registry.store.list_plugin_registry().get(plugin_id, {}) if registry.store else {}
72
+ return {"plugin": manifest.public(), "registry": state}
73
+
74
+ @router.post("/plugins/validate")
75
+ async def plugin_validate(req: PluginValidateRequest, request: Request):
76
+ require_user(request)
77
+ manifest, errors = validate_manifest(req.manifest)
78
+ return {"ok": not errors, "errors": errors, "manifest": manifest.public() if manifest else None}
79
+
80
+ @router.post("/plugins/install")
81
+ async def plugin_install(req: PluginActionRequest, request: Request):
82
+ admin_email, _ = require_admin(request)
83
+ try:
84
+ result = registry.install(req.plugin_id, register_skill=register_skill)
85
+ except Exception as exc:
86
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
87
+ append_audit_event("plugin_install", user_email=admin_email, plugin=req.plugin_id)
88
+ return result
89
+
90
+ @router.post("/plugins/uninstall")
91
+ async def plugin_uninstall(req: PluginActionRequest, request: Request):
92
+ admin_email, _ = require_admin(request)
93
+ result = registry.uninstall(req.plugin_id)
94
+ append_audit_event("plugin_uninstall", user_email=admin_email, plugin=req.plugin_id)
95
+ return result
96
+
97
+ @router.post("/plugins/enable")
98
+ async def plugin_enable(req: PluginActionRequest, request: Request):
99
+ require_user(request)
100
+ return {"plugin": registry.set_enabled(req.plugin_id, True)}
101
+
102
+ @router.post("/plugins/disable")
103
+ async def plugin_disable(req: PluginActionRequest, request: Request):
104
+ require_user(request)
105
+ return {"plugin": registry.set_enabled(req.plugin_id, False)}
106
+
107
+ @router.post("/plugins/execute")
108
+ async def plugin_execute(req: PluginExecuteRequest, request: Request):
109
+ current_user = require_user(request)
110
+ runners = plugin_runners_factory() if plugin_runners_factory else {}
111
+ result = registry.execute_action(req.plugin_id, req.action, req.args, runners=runners)
112
+ append_audit_event("plugin_execute", user_email=current_user, plugin=req.plugin_id, action=req.action, status=result.status)
113
+ return result.as_dict()
114
+
115
+ return router