ltcai 1.7.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.
@@ -0,0 +1,91 @@
1
+ """Realtime Collaboration API router (v2.0).
2
+
3
+ Server-Sent-Events stream + presence + activity feed over
4
+ :class:`latticeai.core.realtime.RealtimeBus`. Workspace isolation is enforced by
5
+ resolving each caller's allowed workspace scope before subscribing; single-user
6
+ local mode works with no scope restriction.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import secrets
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, Optional, Set
14
+
15
+ from fastapi import APIRouter, HTTPException, Request
16
+ from fastapi.responses import StreamingResponse
17
+ from pydantic import BaseModel
18
+
19
+
20
+ class PresenceRequest(BaseModel):
21
+ client_id: Optional[str] = None
22
+ workspace_id: Optional[str] = None
23
+
24
+
25
+ def create_realtime_router(
26
+ *,
27
+ bus,
28
+ require_user: Callable[[Request], str],
29
+ get_current_user: Callable[[Request], Optional[str]],
30
+ allowed_scopes: Callable[[Optional[str]], Optional[Set[str]]],
31
+ ui_file_response: Optional[Callable[[Path], Any]] = None,
32
+ static_dir: Optional[Path] = None,
33
+ ) -> APIRouter:
34
+ router = APIRouter()
35
+
36
+ @router.get("/activity")
37
+ async def activity_page(request: Request):
38
+ require_user(request)
39
+ if ui_file_response is None or static_dir is None:
40
+ raise HTTPException(status_code=404, detail="Activity UI not available.")
41
+ page = static_dir / "activity.html"
42
+ if not page.exists():
43
+ raise HTTPException(status_code=404, detail="Activity UI not found.")
44
+ return ui_file_response(page)
45
+
46
+ @router.get("/realtime/stream")
47
+ async def realtime_stream(request: Request):
48
+ user = require_user(request)
49
+ scope = allowed_scopes(user or None)
50
+ sub_id = secrets.token_urlsafe(12)
51
+ sub = bus.add_subscriber(sub_id, workspace_scope=scope, user=user or None)
52
+
53
+ async def event_gen():
54
+ async for frame in bus.stream(sub):
55
+ if await request.is_disconnected():
56
+ break
57
+ yield frame
58
+
59
+ return StreamingResponse(
60
+ event_gen(),
61
+ media_type="text/event-stream",
62
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
63
+ )
64
+
65
+ @router.get("/realtime/feed")
66
+ async def realtime_feed(request: Request, limit: int = 50):
67
+ user = require_user(request)
68
+ scope = allowed_scopes(user or None)
69
+ return {"events": bus.recent(limit=limit, workspace_scope=scope), "stats": bus.stats()}
70
+
71
+ @router.get("/realtime/presence")
72
+ async def realtime_presence(request: Request):
73
+ user = require_user(request)
74
+ scope = allowed_scopes(user or None)
75
+ return {"presence": bus.presence(workspace_scope=scope), "stats": bus.stats()}
76
+
77
+ @router.post("/realtime/presence/join")
78
+ async def realtime_join(req: PresenceRequest, request: Request):
79
+ user = require_user(request)
80
+ client_id = req.client_id or secrets.token_urlsafe(8)
81
+ record = bus.join(client_id, user=user or None, workspace_id=req.workspace_id)
82
+ return {"presence": record}
83
+
84
+ @router.post("/realtime/presence/leave")
85
+ async def realtime_leave(req: PresenceRequest, request: Request):
86
+ require_user(request)
87
+ if req.client_id:
88
+ bus.leave(req.client_id)
89
+ return {"status": "ok"}
90
+
91
+ return router
@@ -0,0 +1,207 @@
1
+ """Workflow Designer API router (v2.0).
2
+
3
+ Create / edit / validate / execute / inspect / export / import workflows plus
4
+ run history, layered on :mod:`latticeai.core.workflow_engine` and the existing
5
+ ``WorkspaceOSStore`` workflow persistence (so pre-2.0 workflow history is
6
+ preserved). Paths are namespaced under ``/workflows`` to avoid colliding with
7
+ ``/workspace/workflows``.
8
+
9
+ server_app injects a ``build_runners`` callable that returns the executable
10
+ runner map (tool / skill / plugin / agent), which is what lets a workflow
11
+ actually drive plugins, skills, and multi-agent runs.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Any, Callable, Dict, List, Optional
18
+
19
+ from fastapi import APIRouter, HTTPException, Request
20
+ from pydantic import BaseModel
21
+
22
+
23
+ class WorkflowDefinitionRequest(BaseModel):
24
+ name: str
25
+ nodes: List[Dict[str, Any]] = []
26
+ metadata: Dict[str, Any] = {}
27
+
28
+
29
+ class WorkflowUpdateRequest(BaseModel):
30
+ name: Optional[str] = None
31
+ nodes: Optional[List[Dict[str, Any]]] = None
32
+ metadata: Optional[Dict[str, Any]] = None
33
+
34
+
35
+ class WorkflowRunRequest(BaseModel):
36
+ inputs: Dict[str, Any] = {}
37
+
38
+
39
+ class WorkflowValidateRequest(BaseModel):
40
+ name: str = "Draft"
41
+ nodes: List[Dict[str, Any]] = []
42
+
43
+
44
+ class WorkflowImportRequest(BaseModel):
45
+ data: Dict[str, Any] = {}
46
+
47
+
48
+ def create_workflow_designer_router(
49
+ *,
50
+ store,
51
+ require_user: Callable[[Request], str],
52
+ get_current_user: Callable[[Request], Optional[str]],
53
+ gate_read: Callable[[Request], Optional[str]],
54
+ gate_write: Callable[[Request], Optional[str]],
55
+ workspace_graph: Callable[[], Any],
56
+ build_runners: Callable[[Optional[str], Optional[str]], Dict[str, Callable[..., Any]]],
57
+ append_audit_event: Callable[..., None],
58
+ ui_file_response: Optional[Callable[[Path], Any]] = None,
59
+ static_dir: Optional[Path] = None,
60
+ ) -> APIRouter:
61
+ from latticeai.core.workflow_engine import (
62
+ WorkflowEngine,
63
+ validate_definition,
64
+ export_workflow,
65
+ import_workflow,
66
+ WorkflowError,
67
+ )
68
+
69
+ router = APIRouter()
70
+
71
+ @router.get("/workflows")
72
+ async def workflows_page(request: Request):
73
+ require_user(request)
74
+ if ui_file_response is None or static_dir is None:
75
+ raise HTTPException(status_code=404, detail="Workflow Designer UI not available.")
76
+ page = static_dir / "workflows.html"
77
+ if not page.exists():
78
+ raise HTTPException(status_code=404, detail="Workflow Designer UI not found.")
79
+ return ui_file_response(page)
80
+
81
+ @router.get("/workflows/api/definitions")
82
+ async def list_definitions(request: Request, q: str = ""):
83
+ require_user(request)
84
+ scope = gate_read(request)
85
+ return store.list_workflows(query=q, workspace_id=scope)
86
+
87
+ @router.post("/workflows/api/definitions")
88
+ async def create_definition(req: WorkflowDefinitionRequest, request: Request):
89
+ current_user = require_user(request)
90
+ scope = gate_write(request)
91
+ errors = validate_definition({"name": req.name, "nodes": req.nodes})
92
+ if errors:
93
+ raise HTTPException(status_code=400, detail={"validation_errors": errors})
94
+ workflow = store.create_workflow(
95
+ name=req.name,
96
+ steps=[{"action": n.get("type"), "node": n.get("id")} for n in req.nodes],
97
+ nodes=req.nodes,
98
+ metadata=req.metadata,
99
+ user_email=current_user or None,
100
+ graph=workspace_graph(),
101
+ workspace_id=scope,
102
+ )
103
+ append_audit_event("workflow_created", user_email=current_user, workflow_id=workflow["id"])
104
+ return {"workflow": workflow}
105
+
106
+ @router.get("/workflows/api/definitions/{workflow_id}")
107
+ async def get_definition(workflow_id: str, request: Request):
108
+ require_user(request)
109
+ scope = gate_read(request)
110
+ try:
111
+ return {"workflow": store.get_workflow(workflow_id, workspace_id=scope)}
112
+ except FileNotFoundError as exc:
113
+ raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
114
+
115
+ @router.patch("/workflows/api/definitions/{workflow_id}")
116
+ async def update_definition(workflow_id: str, req: WorkflowUpdateRequest, request: Request):
117
+ require_user(request)
118
+ scope = gate_write(request)
119
+ if req.nodes is not None:
120
+ errors = validate_definition({"name": req.name or "wf", "nodes": req.nodes})
121
+ if errors:
122
+ raise HTTPException(status_code=400, detail={"validation_errors": errors})
123
+ try:
124
+ workflow = store.update_workflow_definition(
125
+ workflow_id,
126
+ name=req.name,
127
+ nodes=req.nodes,
128
+ metadata=req.metadata,
129
+ workspace_id=scope,
130
+ )
131
+ except FileNotFoundError as exc:
132
+ raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
133
+ return {"workflow": workflow}
134
+
135
+ @router.post("/workflows/api/validate")
136
+ async def validate_workflow(req: WorkflowValidateRequest, request: Request):
137
+ require_user(request)
138
+ errors = validate_definition({"name": req.name, "nodes": req.nodes})
139
+ return {"ok": not errors, "errors": errors}
140
+
141
+ @router.post("/workflows/api/definitions/{workflow_id}/run")
142
+ async def run_definition(workflow_id: str, req: WorkflowRunRequest, request: Request):
143
+ current_user = require_user(request)
144
+ scope = gate_write(request)
145
+ try:
146
+ workflow = store.get_workflow(workflow_id, workspace_id=scope)
147
+ except FileNotFoundError as exc:
148
+ raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
149
+ runners = build_runners(current_user or None, scope)
150
+ engine = WorkflowEngine(runners)
151
+ result = engine.run(workflow, inputs=req.inputs)
152
+ run = store.record_workflow_run(
153
+ workflow_id=workflow_id,
154
+ name=workflow.get("name") or "workflow",
155
+ status=result.status,
156
+ timeline=result.timeline,
157
+ outputs=result.outputs,
158
+ user_email=current_user or None,
159
+ graph=workspace_graph(),
160
+ workspace_id=scope,
161
+ )
162
+ append_audit_event("workflow_run", user_email=current_user, workflow_id=workflow_id, status=result.status)
163
+ return {"run": run, "result": result.as_dict()}
164
+
165
+ @router.get("/workflows/api/definitions/{workflow_id}/runs")
166
+ async def list_runs(workflow_id: str, request: Request, limit: int = 50):
167
+ require_user(request)
168
+ scope = gate_read(request)
169
+ return store.list_workflow_runs(workflow_id=workflow_id, limit=limit, workspace_id=scope)
170
+
171
+ @router.get("/workflows/api/runs")
172
+ async def list_all_runs(request: Request, limit: int = 50):
173
+ require_user(request)
174
+ scope = gate_read(request)
175
+ return store.list_workflow_runs(limit=limit, workspace_id=scope)
176
+
177
+ @router.get("/workflows/api/export/{workflow_id}")
178
+ async def export_definition(workflow_id: str, request: Request):
179
+ require_user(request)
180
+ scope = gate_read(request)
181
+ try:
182
+ workflow = store.get_workflow(workflow_id, workspace_id=scope)
183
+ except FileNotFoundError as exc:
184
+ raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
185
+ return export_workflow(workflow)
186
+
187
+ @router.post("/workflows/api/import")
188
+ async def import_definition(req: WorkflowImportRequest, request: Request):
189
+ current_user = require_user(request)
190
+ scope = gate_write(request)
191
+ try:
192
+ definition = import_workflow(req.data)
193
+ except WorkflowError as exc:
194
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
195
+ workflow = store.create_workflow(
196
+ name=definition["name"],
197
+ steps=[{"action": n.get("type"), "node": n.get("id")} for n in definition["nodes"]],
198
+ nodes=definition["nodes"],
199
+ metadata=definition.get("metadata") or {},
200
+ user_email=current_user or None,
201
+ graph=workspace_graph(),
202
+ workspace_id=scope,
203
+ )
204
+ append_audit_event("workflow_imported", user_email=current_user, workflow_id=workflow["id"])
205
+ return {"workflow": workflow}
206
+
207
+ return router
@@ -0,0 +1,270 @@
1
+ """Multi-Agent Runtime 2.0 — role orchestration with handoff, retry, and a
2
+ fully observable timeline.
3
+
4
+ v1.x shipped a single-agent state machine (:class:`latticeai.core.agent.AgentRuntime`:
5
+ PLAN → EXECUTE → VERIFY → DONE). v2.0 adds the *orchestration* layer above it:
6
+ a pipeline of named roles that hand off to one another, retry on a failing
7
+ review, and emit a structured timeline that drops straight into the Workspace
8
+ timeline / Knowledge Graph.
9
+
10
+ Built-in roles (ids match :data:`latticeai.core.workspace_os.DEFAULT_AGENTS`):
11
+
12
+ * ``researcher`` — gathers relevant context (workspace memory / graph)
13
+ * ``planner`` — decomposes the goal into ordered steps
14
+ * ``executor`` — carries out steps (may call workflows / plugins / tools)
15
+ * ``reviewer`` — judges the result → pass / retry
16
+ * ``release`` — finalizes / packages the outcome (optional)
17
+
18
+ Like the v1 runtime, the orchestrator is pure logic over an injected
19
+ ``role_runner`` port, so it runs with no LLM and no server. The default runner
20
+ (:func:`default_role_runner`) is deterministic and genuinely useful: it produces
21
+ real plans, executes steps (optionally driving an injected workflow / plugin
22
+ runner — this is the agent→workflow / agent→plugin integration), and reviews
23
+ results. Production may swap in an LLM-backed runner without touching the
24
+ orchestration logic.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime
31
+ from typing import Any, Callable, Dict, List, Optional
32
+
33
+
34
+ MULTI_AGENT_VERSION = "2.0.0"
35
+
36
+ # Ordered default pipeline. ``researcher`` and ``release`` are optional stages
37
+ # (skipped unless requested) so a quick run is planner → executor → reviewer.
38
+ AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
39
+ CORE_PIPELINE = ("planner", "executor", "reviewer")
40
+
41
+ ROLE_AGENT_IDS = {
42
+ "researcher": "agent:researcher",
43
+ "planner": "agent:planner",
44
+ "executor": "agent:executor",
45
+ "reviewer": "agent:reviewer",
46
+ "release": "agent:release",
47
+ }
48
+
49
+
50
+ def _now() -> str:
51
+ return datetime.now().isoformat(timespec="seconds")
52
+
53
+
54
+ @dataclass
55
+ class OrchestrationContext:
56
+ """Mutable carrier threaded through every role stage."""
57
+
58
+ goal: str
59
+ user_email: Optional[str] = None
60
+ workspace_id: Optional[str] = None
61
+ inputs: Dict[str, Any] = field(default_factory=dict)
62
+ plan: List[Dict[str, Any]] = field(default_factory=list)
63
+ research: List[str] = field(default_factory=list)
64
+ executed: List[Dict[str, Any]] = field(default_factory=list)
65
+ review: Dict[str, Any] = field(default_factory=dict)
66
+ timeline: List[Dict[str, Any]] = field(default_factory=list)
67
+ retries: int = 0
68
+ output: str = ""
69
+
70
+ def handoff(self, frm: str, to: str, note: str = "") -> None:
71
+ self.timeline.append({
72
+ "event": "handoff",
73
+ "from": frm,
74
+ "to": to,
75
+ "note": note,
76
+ "timestamp": _now(),
77
+ })
78
+
79
+
80
+ @dataclass
81
+ class AgentRunResult:
82
+ agent_id: str
83
+ status: str # ok | failed | retried_ok
84
+ output: str
85
+ timeline: List[Dict[str, Any]]
86
+ plan: List[Dict[str, Any]]
87
+ review: Dict[str, Any]
88
+ roles_run: List[str]
89
+ retries: int = 0
90
+
91
+ def as_dict(self) -> Dict[str, Any]:
92
+ return {
93
+ "agent_id": self.agent_id,
94
+ "status": self.status,
95
+ "output": self.output,
96
+ "timeline": self.timeline,
97
+ "plan": self.plan,
98
+ "review": self.review,
99
+ "roles_run": self.roles_run,
100
+ "retries": self.retries,
101
+ }
102
+
103
+
104
+ def default_role_runner(
105
+ *,
106
+ workflow_runner: Optional[Callable[..., Any]] = None,
107
+ plugin_runner: Optional[Callable[..., Any]] = None,
108
+ context_provider: Optional[Callable[[str], List[str]]] = None,
109
+ ) -> Callable[[str, OrchestrationContext], Dict[str, Any]]:
110
+ """Build a deterministic, dependency-free role runner.
111
+
112
+ The returned callable implements every built-in role with real (non-LLM)
113
+ behavior, and — when ``workflow_runner`` / ``plugin_runner`` are supplied —
114
+ lets the executor role actually drive workflows / plugins. This is what
115
+ makes "agent runs can execute workflows / plugins" true in the community
116
+ edition without requiring a model.
117
+ """
118
+
119
+ def runner(role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
120
+ if role == "researcher":
121
+ found = context_provider(ctx.goal) if context_provider else []
122
+ ctx.research = list(found)
123
+ return {"role": role, "context_items": len(ctx.research), "items": ctx.research[:10]}
124
+
125
+ if role == "planner":
126
+ # Decompose the goal into ordered, inspectable steps.
127
+ goal = ctx.goal.strip() or "Complete the requested task"
128
+ requested = ctx.inputs.get("steps")
129
+ if isinstance(requested, list) and requested:
130
+ steps = [
131
+ {"index": i, "description": str(s), "status": "planned"}
132
+ for i, s in enumerate(requested)
133
+ ]
134
+ else:
135
+ steps = [
136
+ {"index": 0, "description": f"Analyze: {goal}", "status": "planned"},
137
+ {"index": 1, "description": f"Execute: {goal}", "status": "planned"},
138
+ {"index": 2, "description": "Verify the result", "status": "planned"},
139
+ ]
140
+ ctx.plan = steps
141
+ return {"role": role, "steps": len(steps), "plan": steps}
142
+
143
+ if role == "executor":
144
+ results = []
145
+ # Optional: a plan step can request a workflow or plugin run.
146
+ for step in ctx.plan:
147
+ outcome: Dict[str, Any] = {"index": step["index"], "description": step["description"]}
148
+ wf = step.get("workflow") or ctx.inputs.get("workflow")
149
+ pl = step.get("plugin")
150
+ if wf and workflow_runner is not None and step["index"] == 0:
151
+ try:
152
+ outcome["workflow_result"] = workflow_runner(wf, ctx)
153
+ except Exception as exc:
154
+ outcome["workflow_error"] = str(exc)
155
+ if pl and plugin_runner is not None:
156
+ try:
157
+ outcome["plugin_result"] = plugin_runner(pl, ctx)
158
+ except Exception as exc:
159
+ outcome["plugin_error"] = str(exc)
160
+ step["status"] = "done"
161
+ outcome["status"] = "done"
162
+ results.append(outcome)
163
+ ctx.executed = results
164
+ ctx.output = f"Completed {len(results)} planned step(s) for: {ctx.goal}"
165
+ return {"role": role, "executed": len(results), "results": results}
166
+
167
+ if role == "reviewer":
168
+ ok = bool(ctx.executed) and all(r.get("status") == "done" for r in ctx.executed)
169
+ ctx.review = {
170
+ "verdict": "pass" if ok else "retry",
171
+ "reason": "all steps completed" if ok else "no steps executed",
172
+ "confidence": 0.9 if ok else 0.3,
173
+ }
174
+ return {"role": role, **ctx.review}
175
+
176
+ if role == "release":
177
+ ctx.output = ctx.output or f"Released outcome for: {ctx.goal}"
178
+ return {"role": role, "released": True, "summary": ctx.output}
179
+
180
+ return {"role": role, "status": "noop"}
181
+
182
+ return runner
183
+
184
+
185
+ class MultiAgentOrchestrator:
186
+ """Drives a role pipeline with handoff + bounded retry over a role runner."""
187
+
188
+ def __init__(self, role_runner: Optional[Callable[[str, OrchestrationContext], Dict[str, Any]]] = None):
189
+ self.role_runner = role_runner or default_role_runner()
190
+
191
+ def _run_role(self, role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
192
+ started = _now()
193
+ try:
194
+ result = self.role_runner(role, ctx) or {}
195
+ status = result.get("status", "ok")
196
+ except Exception as exc:
197
+ result = {"error": str(exc)}
198
+ status = "error"
199
+ ctx.timeline.append({
200
+ "event": "role",
201
+ "role": role,
202
+ "agent_id": ROLE_AGENT_IDS.get(role, f"agent:{role}"),
203
+ "status": status,
204
+ "result": result,
205
+ "started_at": started,
206
+ "timestamp": _now(),
207
+ })
208
+ return result
209
+
210
+ def run(
211
+ self,
212
+ goal: str,
213
+ *,
214
+ user_email: Optional[str] = None,
215
+ workspace_id: Optional[str] = None,
216
+ inputs: Optional[Dict[str, Any]] = None,
217
+ roles: Optional[List[str]] = None,
218
+ max_retries: int = 2,
219
+ ) -> AgentRunResult:
220
+ ctx = OrchestrationContext(
221
+ goal=goal or "",
222
+ user_email=user_email,
223
+ workspace_id=workspace_id,
224
+ inputs=inputs or {},
225
+ )
226
+ pipeline = [r for r in (roles or list(CORE_PIPELINE)) if r in AGENT_ROLES]
227
+ if not pipeline:
228
+ pipeline = list(CORE_PIPELINE)
229
+
230
+ ctx.timeline.append({"event": "start", "goal": ctx.goal, "pipeline": pipeline, "timestamp": _now()})
231
+
232
+ roles_run: List[str] = []
233
+ previous: Optional[str] = None
234
+ index = 0
235
+ # Walk the pipeline; the reviewer can rewind to the executor on a retry.
236
+ while index < len(pipeline):
237
+ role = pipeline[index]
238
+ if previous is not None:
239
+ ctx.handoff(previous, role)
240
+ self._run_role(role, ctx)
241
+ roles_run.append(role)
242
+ previous = role
243
+
244
+ if role == "reviewer" and ctx.review.get("verdict") == "retry" and ctx.retries < max_retries:
245
+ ctx.retries += 1
246
+ exec_index = pipeline.index("executor") if "executor" in pipeline else None
247
+ if exec_index is not None:
248
+ ctx.handoff("reviewer", "executor", note=f"retry #{ctx.retries}: {ctx.review.get('reason')}")
249
+ index = exec_index
250
+ previous = "reviewer"
251
+ continue
252
+ index += 1
253
+
254
+ final_verdict = ctx.review.get("verdict", "pass")
255
+ if final_verdict == "pass":
256
+ status = "retried_ok" if ctx.retries else "ok"
257
+ else:
258
+ status = "failed"
259
+ ctx.timeline.append({"event": "end", "status": status, "retries": ctx.retries, "timestamp": _now()})
260
+
261
+ return AgentRunResult(
262
+ agent_id=ROLE_AGENT_IDS.get("executor", "agent:executor"),
263
+ status=status,
264
+ output=ctx.output or f"Processed goal: {ctx.goal}",
265
+ timeline=ctx.timeline,
266
+ plan=ctx.plan,
267
+ review=ctx.review,
268
+ roles_run=roles_run,
269
+ retries=ctx.retries,
270
+ )