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.
- package/README.md +31 -21
- package/docs/CHANGELOG.md +65 -0
- package/docs/EDITION_STRATEGY.md +10 -4
- package/docs/ENTERPRISE.md +3 -1
- package/docs/MULTI_AGENT_RUNTIME.md +410 -0
- package/docs/PLUGIN_SDK.md +651 -0
- package/docs/REALTIME_COLLABORATION.md +410 -0
- package/docs/V2_ARCHITECTURE.md +528 -0
- package/docs/WORKFLOW_DESIGNER.md +475 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +98 -0
- package/latticeai/api/plugins.py +115 -0
- package/latticeai/api/realtime.py +91 -0
- package/latticeai/api/workflow_designer.py +207 -0
- package/latticeai/core/multi_agent.py +270 -0
- package/latticeai/core/plugins.py +400 -0
- package/latticeai/core/realtime.py +190 -0
- package/latticeai/core/workflow_engine.py +329 -0
- package/latticeai/core/workspace_os.py +155 -2
- package/latticeai/server_app.py +76 -2
- package/latticeai/services/platform_runtime.py +200 -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 +92 -0
- package/static/platform.css +75 -0
- package/static/plugins.html +82 -0
- package/static/scripts/platform.js +64 -0
- package/static/workflows.html +121 -0
- package/static/workspace.html +5 -1
|
@@ -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
|
+
)
|