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.
@@ -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.
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "1.7.0"
3
+ __version__ = "2.1.0"
@@ -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