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