ltcai 2.0.0 → 2.2.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 +140 -589
- package/auto_setup.py +17 -17
- package/docs/CHANGELOG.md +99 -0
- package/docs/MULTI_AGENT_RUNTIME.md +23 -5
- package/docs/PLUGIN_SDK.md +21 -8
- package/docs/REALTIME_COLLABORATION.md +19 -6
- package/docs/V2_ARCHITECTURE.md +65 -33
- package/docs/WORKFLOW_DESIGNER.md +18 -8
- package/docs/architecture.md +127 -135
- package/docs/kg-schema.md +3 -3
- package/docs/public-deploy.md +2 -3
- package/knowledge_graph.py +2 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +57 -1
- package/latticeai/api/marketplace.py +81 -0
- package/latticeai/api/models.py +8 -0
- package/latticeai/api/plugins.py +1 -1
- package/latticeai/api/realtime.py +1 -1
- package/latticeai/api/workflow_designer.py +10 -1
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/marketplace.py +178 -0
- package/latticeai/core/model_compat.py +7 -63
- package/latticeai/core/model_resolution.py +1 -1
- package/latticeai/core/multi_agent.py +359 -68
- package/latticeai/core/plugins.py +29 -13
- package/latticeai/core/realtime.py +1 -1
- package/latticeai/core/workflow_engine.py +1 -1
- package/latticeai/core/workspace_os.py +257 -10
- package/latticeai/server_app.py +17 -5
- package/latticeai/services/model_catalog.py +105 -153
- package/latticeai/services/model_recommendation.py +28 -17
- package/latticeai/services/model_runtime.py +2 -2
- package/latticeai/services/platform_runtime.py +9 -5
- package/llm_router.py +80 -92
- package/ltcai_cli.py +2 -3
- package/package.json +2 -2
- package/static/agents.html +47 -3
- package/static/chat.html +5 -6
- package/static/plugins.html +51 -0
- package/static/scripts/chat.js +34 -36
- package/static/workflows.html +22 -0
- package/static/workspace.html +1 -1
- package/telegram_bot.py +1 -1
|
@@ -1,27 +1,10 @@
|
|
|
1
|
-
"""Multi-Agent Runtime 2.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
1
|
+
"""Multi-Agent Runtime 2.1.
|
|
2
|
+
|
|
3
|
+
The runtime remains a small, dependency-injected orchestrator, but v2.1 makes
|
|
4
|
+
the operational objects first-class: handoffs, context packets, review/retry
|
|
5
|
+
history, replayable timeline events, and explicit planning records. The default
|
|
6
|
+
runner is still deterministic and LLM-free so tests, local demos, and Community
|
|
7
|
+
installations can exercise the full Planner -> Executor -> Reviewer loop.
|
|
25
8
|
"""
|
|
26
9
|
|
|
27
10
|
from __future__ import annotations
|
|
@@ -31,10 +14,8 @@ from datetime import datetime
|
|
|
31
14
|
from typing import Any, Callable, Dict, List, Optional
|
|
32
15
|
|
|
33
16
|
|
|
34
|
-
MULTI_AGENT_VERSION = "2.
|
|
17
|
+
MULTI_AGENT_VERSION = "2.2.0"
|
|
35
18
|
|
|
36
|
-
# Ordered default pipeline. ``researcher`` and ``release`` are optional stages
|
|
37
|
-
# (skipped unless requested) so a quick run is planner → executor → reviewer.
|
|
38
19
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
39
20
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
40
21
|
|
|
@@ -46,11 +27,122 @@ ROLE_AGENT_IDS = {
|
|
|
46
27
|
"release": "agent:release",
|
|
47
28
|
}
|
|
48
29
|
|
|
30
|
+
HANDOFF_STATUSES = (
|
|
31
|
+
"created",
|
|
32
|
+
"accepted",
|
|
33
|
+
"running",
|
|
34
|
+
"blocked",
|
|
35
|
+
"completed",
|
|
36
|
+
"rejected",
|
|
37
|
+
"retry_requested",
|
|
38
|
+
"cancelled",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
REVIEW_OUTCOMES = ("approve", "reject", "retry")
|
|
42
|
+
|
|
43
|
+
_SECRET_KEYS = ("secret", "token", "password", "api_key", "apikey", "credential")
|
|
44
|
+
|
|
49
45
|
|
|
50
46
|
def _now() -> str:
|
|
51
47
|
return datetime.now().isoformat(timespec="seconds")
|
|
52
48
|
|
|
53
49
|
|
|
50
|
+
def _redact(value: Any) -> Any:
|
|
51
|
+
"""Return a JSON-safe value with obvious secret fields redacted."""
|
|
52
|
+
if isinstance(value, dict):
|
|
53
|
+
clean: Dict[str, Any] = {}
|
|
54
|
+
for key, item in value.items():
|
|
55
|
+
if any(part in str(key).lower() for part in _SECRET_KEYS):
|
|
56
|
+
clean[key] = "[redacted]"
|
|
57
|
+
else:
|
|
58
|
+
clean[key] = _redact(item)
|
|
59
|
+
return clean
|
|
60
|
+
if isinstance(value, list):
|
|
61
|
+
return [_redact(item) for item in value[:100]]
|
|
62
|
+
if isinstance(value, tuple):
|
|
63
|
+
return [_redact(item) for item in value[:100]]
|
|
64
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
65
|
+
return value
|
|
66
|
+
return str(value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _review_outcome(review: Dict[str, Any]) -> str:
|
|
70
|
+
raw = str(review.get("outcome") or review.get("verdict") or "").lower().strip()
|
|
71
|
+
if raw in {"approve", "approved", "pass", "passed", "ok"}:
|
|
72
|
+
return "approve"
|
|
73
|
+
if raw in {"reject", "rejected", "fail", "failed"}:
|
|
74
|
+
return "reject"
|
|
75
|
+
if raw == "retry":
|
|
76
|
+
return "retry"
|
|
77
|
+
return "approve"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class AgentContextPacket:
|
|
82
|
+
"""Structured, replay-safe context transferred between agent roles."""
|
|
83
|
+
|
|
84
|
+
packet_id: str
|
|
85
|
+
objective: str
|
|
86
|
+
task_summary: str
|
|
87
|
+
workspace_context: Dict[str, Any] = field(default_factory=dict)
|
|
88
|
+
graph_context: Dict[str, Any] = field(default_factory=dict)
|
|
89
|
+
memory_context: List[Any] = field(default_factory=list)
|
|
90
|
+
workflow_context: Dict[str, Any] = field(default_factory=dict)
|
|
91
|
+
plugin_outputs: List[Any] = field(default_factory=list)
|
|
92
|
+
constraints: List[str] = field(default_factory=list)
|
|
93
|
+
reviewer_notes: List[str] = field(default_factory=list)
|
|
94
|
+
retry_metadata: Dict[str, Any] = field(default_factory=dict)
|
|
95
|
+
created_at: str = field(default_factory=_now)
|
|
96
|
+
|
|
97
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
98
|
+
return _redact({
|
|
99
|
+
"packet_id": self.packet_id,
|
|
100
|
+
"objective": self.objective,
|
|
101
|
+
"task_summary": self.task_summary,
|
|
102
|
+
"workspace_context": self.workspace_context,
|
|
103
|
+
"graph_context": self.graph_context,
|
|
104
|
+
"memory_context": self.memory_context,
|
|
105
|
+
"workflow_context": self.workflow_context,
|
|
106
|
+
"plugin_outputs": self.plugin_outputs,
|
|
107
|
+
"constraints": self.constraints,
|
|
108
|
+
"reviewer_notes": self.reviewer_notes,
|
|
109
|
+
"retry_metadata": self.retry_metadata,
|
|
110
|
+
"created_at": self.created_at,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class AgentHandoff:
|
|
116
|
+
"""Inspectable handoff between two agent roles."""
|
|
117
|
+
|
|
118
|
+
handoff_id: str
|
|
119
|
+
source_agent: str
|
|
120
|
+
target_agent: str
|
|
121
|
+
reason: str
|
|
122
|
+
task_summary: str
|
|
123
|
+
context_packet: Dict[str, Any]
|
|
124
|
+
status: str = "created"
|
|
125
|
+
created_at: str = field(default_factory=_now)
|
|
126
|
+
accepted_at: Optional[str] = None
|
|
127
|
+
started_at: Optional[str] = None
|
|
128
|
+
completed_at: Optional[str] = None
|
|
129
|
+
|
|
130
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
131
|
+
return {
|
|
132
|
+
"handoff_id": self.handoff_id,
|
|
133
|
+
"source_agent": self.source_agent,
|
|
134
|
+
"target_agent": self.target_agent,
|
|
135
|
+
"reason": self.reason,
|
|
136
|
+
"task_summary": self.task_summary,
|
|
137
|
+
"context_packet": self.context_packet,
|
|
138
|
+
"status": self.status,
|
|
139
|
+
"created_at": self.created_at,
|
|
140
|
+
"accepted_at": self.accepted_at,
|
|
141
|
+
"started_at": self.started_at,
|
|
142
|
+
"completed_at": self.completed_at,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
54
146
|
@dataclass
|
|
55
147
|
class OrchestrationContext:
|
|
56
148
|
"""Mutable carrier threaded through every role stage."""
|
|
@@ -60,21 +152,115 @@ class OrchestrationContext:
|
|
|
60
152
|
workspace_id: Optional[str] = None
|
|
61
153
|
inputs: Dict[str, Any] = field(default_factory=dict)
|
|
62
154
|
plan: List[Dict[str, Any]] = field(default_factory=list)
|
|
155
|
+
plan_id: str = ""
|
|
156
|
+
plan_review: Dict[str, Any] = field(default_factory=dict)
|
|
63
157
|
research: List[str] = field(default_factory=list)
|
|
64
158
|
executed: List[Dict[str, Any]] = field(default_factory=list)
|
|
159
|
+
plugin_outputs: List[Any] = field(default_factory=list)
|
|
160
|
+
workflow_outputs: List[Any] = field(default_factory=list)
|
|
65
161
|
review: Dict[str, Any] = field(default_factory=dict)
|
|
162
|
+
review_history: List[Dict[str, Any]] = field(default_factory=list)
|
|
163
|
+
retry_history: List[Dict[str, Any]] = field(default_factory=list)
|
|
66
164
|
timeline: List[Dict[str, Any]] = field(default_factory=list)
|
|
165
|
+
handoffs: List[Dict[str, Any]] = field(default_factory=list)
|
|
166
|
+
context_packets: List[Dict[str, Any]] = field(default_factory=list)
|
|
167
|
+
memory_snapshots: List[Dict[str, Any]] = field(default_factory=list)
|
|
67
168
|
retries: int = 0
|
|
68
169
|
output: str = ""
|
|
69
170
|
|
|
70
|
-
def
|
|
171
|
+
def build_context_packet(
|
|
172
|
+
self,
|
|
173
|
+
*,
|
|
174
|
+
target_agent: Optional[str] = None,
|
|
175
|
+
reviewer_notes: Optional[List[str]] = None,
|
|
176
|
+
retry_metadata: Optional[Dict[str, Any]] = None,
|
|
177
|
+
) -> Dict[str, Any]:
|
|
178
|
+
packet = AgentContextPacket(
|
|
179
|
+
packet_id=f"context-packet-{len(self.context_packets) + 1}",
|
|
180
|
+
objective=self.goal,
|
|
181
|
+
task_summary=(self.output or self.goal or "Agent task")[:500],
|
|
182
|
+
workspace_context={
|
|
183
|
+
"workspace_id": self.workspace_id,
|
|
184
|
+
"user_email": self.user_email,
|
|
185
|
+
"target_agent": target_agent,
|
|
186
|
+
},
|
|
187
|
+
graph_context=_redact(self.inputs.get("graph_context") or {}),
|
|
188
|
+
memory_context=list(self.research[:20]),
|
|
189
|
+
workflow_context={
|
|
190
|
+
"requested_workflow": self.inputs.get("workflow"),
|
|
191
|
+
"workflow_outputs": self.workflow_outputs[-10:],
|
|
192
|
+
},
|
|
193
|
+
plugin_outputs=self.plugin_outputs[-10:],
|
|
194
|
+
constraints=list(self.inputs.get("constraints") or []),
|
|
195
|
+
reviewer_notes=reviewer_notes or [],
|
|
196
|
+
retry_metadata=retry_metadata or {"retry_count": self.retries},
|
|
197
|
+
).as_dict()
|
|
198
|
+
self.context_packets.append(packet)
|
|
199
|
+
return packet
|
|
200
|
+
|
|
201
|
+
def handoff(self, frm: str, to: str, note: str = "", *, status: str = "completed") -> Dict[str, Any]:
|
|
202
|
+
if status not in HANDOFF_STATUSES:
|
|
203
|
+
status = "completed"
|
|
204
|
+
handoff_id = f"handoff-{len(self.handoffs) + 1}"
|
|
205
|
+
packet = self.build_context_packet(target_agent=to)
|
|
206
|
+
now = _now()
|
|
207
|
+
record = AgentHandoff(
|
|
208
|
+
handoff_id=handoff_id,
|
|
209
|
+
source_agent=ROLE_AGENT_IDS.get(frm, f"agent:{frm}"),
|
|
210
|
+
target_agent=ROLE_AGENT_IDS.get(to, f"agent:{to}"),
|
|
211
|
+
reason=note or f"{frm} completed work for {to}",
|
|
212
|
+
task_summary=(self.output or self.goal or "Agent handoff")[:500],
|
|
213
|
+
context_packet=packet,
|
|
214
|
+
status=status,
|
|
215
|
+
created_at=now,
|
|
216
|
+
accepted_at=now if status in {"accepted", "running", "completed", "retry_requested"} else None,
|
|
217
|
+
started_at=now if status in {"running", "completed", "retry_requested"} else None,
|
|
218
|
+
completed_at=now if status in {"completed", "retry_requested"} else None,
|
|
219
|
+
).as_dict()
|
|
220
|
+
self.handoffs.append(record)
|
|
221
|
+
|
|
222
|
+
self.timeline.append({
|
|
223
|
+
"event": "handoff_created",
|
|
224
|
+
"handoff_id": handoff_id,
|
|
225
|
+
"from": frm,
|
|
226
|
+
"to": to,
|
|
227
|
+
"source_agent": record["source_agent"],
|
|
228
|
+
"target_agent": record["target_agent"],
|
|
229
|
+
"reason": record["reason"],
|
|
230
|
+
"context_packet": packet,
|
|
231
|
+
"status": "created",
|
|
232
|
+
"timestamp": now,
|
|
233
|
+
})
|
|
234
|
+
if record["accepted_at"]:
|
|
235
|
+
self.timeline.append({
|
|
236
|
+
"event": "handoff_accepted",
|
|
237
|
+
"handoff_id": handoff_id,
|
|
238
|
+
"from": frm,
|
|
239
|
+
"to": to,
|
|
240
|
+
"status": "accepted",
|
|
241
|
+
"timestamp": record["accepted_at"],
|
|
242
|
+
})
|
|
243
|
+
if status in {"completed", "retry_requested"}:
|
|
244
|
+
self.timeline.append({
|
|
245
|
+
"event": "handoff_completed",
|
|
246
|
+
"handoff_id": handoff_id,
|
|
247
|
+
"from": frm,
|
|
248
|
+
"to": to,
|
|
249
|
+
"status": status,
|
|
250
|
+
"timestamp": record["completed_at"],
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
# Backward-compatible compact event used by v2.0 UI/tests.
|
|
71
254
|
self.timeline.append({
|
|
72
255
|
"event": "handoff",
|
|
256
|
+
"handoff_id": handoff_id,
|
|
73
257
|
"from": frm,
|
|
74
258
|
"to": to,
|
|
75
259
|
"note": note,
|
|
76
|
-
"
|
|
260
|
+
"status": status,
|
|
261
|
+
"timestamp": now,
|
|
77
262
|
})
|
|
263
|
+
return record
|
|
78
264
|
|
|
79
265
|
|
|
80
266
|
@dataclass
|
|
@@ -87,6 +273,12 @@ class AgentRunResult:
|
|
|
87
273
|
review: Dict[str, Any]
|
|
88
274
|
roles_run: List[str]
|
|
89
275
|
retries: int = 0
|
|
276
|
+
handoffs: List[Dict[str, Any]] = field(default_factory=list)
|
|
277
|
+
context_packets: List[Dict[str, Any]] = field(default_factory=list)
|
|
278
|
+
review_history: List[Dict[str, Any]] = field(default_factory=list)
|
|
279
|
+
retry_history: List[Dict[str, Any]] = field(default_factory=list)
|
|
280
|
+
plan_review: Dict[str, Any] = field(default_factory=dict)
|
|
281
|
+
memory_snapshots: List[Dict[str, Any]] = field(default_factory=list)
|
|
90
282
|
|
|
91
283
|
def as_dict(self) -> Dict[str, Any]:
|
|
92
284
|
return {
|
|
@@ -98,6 +290,12 @@ class AgentRunResult:
|
|
|
98
290
|
"review": self.review,
|
|
99
291
|
"roles_run": self.roles_run,
|
|
100
292
|
"retries": self.retries,
|
|
293
|
+
"handoffs": self.handoffs,
|
|
294
|
+
"context_packets": self.context_packets,
|
|
295
|
+
"review_history": self.review_history,
|
|
296
|
+
"retry_history": self.retry_history,
|
|
297
|
+
"plan_review": self.plan_review,
|
|
298
|
+
"memory_snapshots": self.memory_snapshots,
|
|
101
299
|
}
|
|
102
300
|
|
|
103
301
|
|
|
@@ -107,69 +305,97 @@ def default_role_runner(
|
|
|
107
305
|
plugin_runner: Optional[Callable[..., Any]] = None,
|
|
108
306
|
context_provider: Optional[Callable[[str], List[str]]] = None,
|
|
109
307
|
) -> 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
|
-
"""
|
|
308
|
+
"""Build a deterministic, dependency-free role runner."""
|
|
118
309
|
|
|
119
310
|
def runner(role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
|
|
120
311
|
if role == "researcher":
|
|
121
312
|
found = context_provider(ctx.goal) if context_provider else []
|
|
122
313
|
ctx.research = list(found)
|
|
123
|
-
|
|
314
|
+
snapshot = {
|
|
315
|
+
"snapshot_id": f"memory-snapshot-{len(ctx.memory_snapshots) + 1}",
|
|
316
|
+
"scope": "short_term",
|
|
317
|
+
"items": ctx.research[:10],
|
|
318
|
+
"created_at": _now(),
|
|
319
|
+
}
|
|
320
|
+
ctx.memory_snapshots.append(snapshot)
|
|
321
|
+
return {"role": role, "context_items": len(ctx.research), "items": ctx.research[:10], "memory_snapshot": snapshot}
|
|
124
322
|
|
|
125
323
|
if role == "planner":
|
|
126
|
-
# Decompose the goal into ordered, inspectable steps.
|
|
127
324
|
goal = ctx.goal.strip() or "Complete the requested task"
|
|
128
325
|
requested = ctx.inputs.get("steps")
|
|
326
|
+
steps: List[Dict[str, Any]]
|
|
129
327
|
if isinstance(requested, list) and requested:
|
|
130
|
-
steps = [
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
328
|
+
steps = []
|
|
329
|
+
for i, step in enumerate(requested):
|
|
330
|
+
if isinstance(step, dict):
|
|
331
|
+
item = dict(step)
|
|
332
|
+
item.setdefault("index", i)
|
|
333
|
+
item.setdefault("description", str(step.get("description") or step.get("name") or f"Step {i + 1}"))
|
|
334
|
+
item.setdefault("status", "planned")
|
|
335
|
+
else:
|
|
336
|
+
item = {"index": i, "description": str(step), "status": "planned"}
|
|
337
|
+
steps.append(item)
|
|
134
338
|
else:
|
|
135
339
|
steps = [
|
|
136
340
|
{"index": 0, "description": f"Analyze: {goal}", "status": "planned"},
|
|
137
341
|
{"index": 1, "description": f"Execute: {goal}", "status": "planned"},
|
|
138
342
|
{"index": 2, "description": "Verify the result", "status": "planned"},
|
|
139
343
|
]
|
|
344
|
+
if ctx.inputs.get("workflow") and steps:
|
|
345
|
+
steps[0]["workflow"] = ctx.inputs.get("workflow")
|
|
346
|
+
if ctx.inputs.get("plugin") and steps:
|
|
347
|
+
steps[0]["plugin"] = ctx.inputs.get("plugin")
|
|
140
348
|
ctx.plan = steps
|
|
141
|
-
|
|
349
|
+
ctx.plan_id = f"plan-{abs(hash((ctx.goal, len(steps)))) % 10_000_000}"
|
|
350
|
+
ctx.plan_review = {
|
|
351
|
+
"plan_id": ctx.plan_id,
|
|
352
|
+
"outcome": "approve",
|
|
353
|
+
"reason": "deterministic plan is bounded and executable",
|
|
354
|
+
"reviewed_at": _now(),
|
|
355
|
+
}
|
|
356
|
+
return {"role": role, "plan_id": ctx.plan_id, "steps": len(steps), "plan": steps, "plan_review": ctx.plan_review}
|
|
142
357
|
|
|
143
358
|
if role == "executor":
|
|
144
359
|
results = []
|
|
145
|
-
# Optional: a plan step can request a workflow or plugin run.
|
|
146
360
|
for step in ctx.plan:
|
|
147
361
|
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
|
|
362
|
+
wf = step.get("workflow") or (ctx.inputs.get("workflow") if step["index"] == 0 else None)
|
|
363
|
+
pl = step.get("plugin") or (ctx.inputs.get("plugin") if step["index"] == 0 else None)
|
|
364
|
+
if wf and workflow_runner is not None:
|
|
151
365
|
try:
|
|
152
|
-
|
|
366
|
+
workflow_result = workflow_runner(wf, ctx)
|
|
367
|
+
outcome["workflow_result"] = workflow_result
|
|
368
|
+
ctx.workflow_outputs.append(workflow_result)
|
|
153
369
|
except Exception as exc:
|
|
154
370
|
outcome["workflow_error"] = str(exc)
|
|
155
371
|
if pl and plugin_runner is not None:
|
|
156
372
|
try:
|
|
157
|
-
|
|
373
|
+
plugin_result = plugin_runner(pl, ctx)
|
|
374
|
+
outcome["plugin_result"] = plugin_result
|
|
375
|
+
ctx.plugin_outputs.append(plugin_result)
|
|
158
376
|
except Exception as exc:
|
|
159
377
|
outcome["plugin_error"] = str(exc)
|
|
160
|
-
|
|
161
|
-
|
|
378
|
+
if outcome.get("workflow_error") or outcome.get("plugin_error"):
|
|
379
|
+
step["status"] = "failed"
|
|
380
|
+
outcome["status"] = "error"
|
|
381
|
+
else:
|
|
382
|
+
step["status"] = "done"
|
|
383
|
+
outcome["status"] = "done"
|
|
162
384
|
results.append(outcome)
|
|
163
385
|
ctx.executed = results
|
|
164
|
-
|
|
165
|
-
|
|
386
|
+
done = sum(1 for item in results if item.get("status") == "done")
|
|
387
|
+
ctx.output = f"Completed {done}/{len(results)} planned step(s) for: {ctx.goal}"
|
|
388
|
+
return {"role": role, "executed": len(results), "results": results, "plugin_outputs": ctx.plugin_outputs[-10:]}
|
|
166
389
|
|
|
167
390
|
if role == "reviewer":
|
|
168
391
|
ok = bool(ctx.executed) and all(r.get("status") == "done" for r in ctx.executed)
|
|
169
392
|
ctx.review = {
|
|
393
|
+
"outcome": "approve" if ok else "retry",
|
|
170
394
|
"verdict": "pass" if ok else "retry",
|
|
171
|
-
"reason": "all steps completed" if ok else "no steps executed",
|
|
395
|
+
"reason": "all steps completed" if ok else "one or more steps failed or no steps executed",
|
|
172
396
|
"confidence": 0.9 if ok else 0.3,
|
|
397
|
+
"notes": [] if ok else ["executor should retry with preserved context"],
|
|
398
|
+
"reviewed_at": _now(),
|
|
173
399
|
}
|
|
174
400
|
return {"role": role, **ctx.review}
|
|
175
401
|
|
|
@@ -183,19 +409,43 @@ def default_role_runner(
|
|
|
183
409
|
|
|
184
410
|
|
|
185
411
|
class MultiAgentOrchestrator:
|
|
186
|
-
"""Drives a role pipeline with handoff
|
|
412
|
+
"""Drives a role pipeline with handoff, planning, review, and retry."""
|
|
187
413
|
|
|
188
414
|
def __init__(self, role_runner: Optional[Callable[[str, OrchestrationContext], Dict[str, Any]]] = None):
|
|
189
415
|
self.role_runner = role_runner or default_role_runner()
|
|
190
416
|
|
|
191
417
|
def _run_role(self, role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
|
|
192
418
|
started = _now()
|
|
419
|
+
if role == "reviewer":
|
|
420
|
+
ctx.timeline.append({
|
|
421
|
+
"event": "review_requested",
|
|
422
|
+
"role": role,
|
|
423
|
+
"agent_id": ROLE_AGENT_IDS.get(role, f"agent:{role}"),
|
|
424
|
+
"timestamp": started,
|
|
425
|
+
})
|
|
193
426
|
try:
|
|
194
427
|
result = self.role_runner(role, ctx) or {}
|
|
195
428
|
status = result.get("status", "ok")
|
|
196
429
|
except Exception as exc:
|
|
197
430
|
result = {"error": str(exc)}
|
|
198
431
|
status = "error"
|
|
432
|
+
if role == "reviewer":
|
|
433
|
+
review = dict(ctx.review or result)
|
|
434
|
+
outcome = _review_outcome(review)
|
|
435
|
+
event = {
|
|
436
|
+
"approve": "review_approved",
|
|
437
|
+
"reject": "review_rejected",
|
|
438
|
+
"retry": "retry_requested",
|
|
439
|
+
}[outcome]
|
|
440
|
+
ctx.timeline.append({
|
|
441
|
+
"event": event,
|
|
442
|
+
"role": role,
|
|
443
|
+
"agent_id": ROLE_AGENT_IDS.get(role, f"agent:{role}"),
|
|
444
|
+
"outcome": outcome,
|
|
445
|
+
"reason": review.get("reason", ""),
|
|
446
|
+
"review": review,
|
|
447
|
+
"timestamp": _now(),
|
|
448
|
+
})
|
|
199
449
|
ctx.timeline.append({
|
|
200
450
|
"event": "role",
|
|
201
451
|
"role": role,
|
|
@@ -226,36 +476,71 @@ class MultiAgentOrchestrator:
|
|
|
226
476
|
pipeline = [r for r in (roles or list(CORE_PIPELINE)) if r in AGENT_ROLES]
|
|
227
477
|
if not pipeline:
|
|
228
478
|
pipeline = list(CORE_PIPELINE)
|
|
479
|
+
max_retries = max(0, int(max_retries or 0))
|
|
229
480
|
|
|
230
481
|
ctx.timeline.append({"event": "start", "goal": ctx.goal, "pipeline": pipeline, "timestamp": _now()})
|
|
482
|
+
ctx.timeline.append({
|
|
483
|
+
"event": "agent_started",
|
|
484
|
+
"agent_id": ROLE_AGENT_IDS.get(pipeline[0], "agent:planner"),
|
|
485
|
+
"goal": ctx.goal,
|
|
486
|
+
"pipeline": pipeline,
|
|
487
|
+
"workspace_id": workspace_id,
|
|
488
|
+
"timestamp": _now(),
|
|
489
|
+
})
|
|
231
490
|
|
|
232
491
|
roles_run: List[str] = []
|
|
233
492
|
previous: Optional[str] = None
|
|
234
493
|
index = 0
|
|
235
|
-
# Walk the pipeline; the reviewer can rewind to the executor on a retry.
|
|
236
494
|
while index < len(pipeline):
|
|
237
495
|
role = pipeline[index]
|
|
238
496
|
if previous is not None:
|
|
239
497
|
ctx.handoff(previous, role)
|
|
240
498
|
self._run_role(role, ctx)
|
|
241
499
|
roles_run.append(role)
|
|
242
|
-
previous = role
|
|
243
500
|
|
|
244
|
-
if role == "reviewer"
|
|
245
|
-
ctx.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
501
|
+
if role == "reviewer":
|
|
502
|
+
review = dict(ctx.review or {})
|
|
503
|
+
outcome = _review_outcome(review)
|
|
504
|
+
review_entry = {
|
|
505
|
+
"index": len(ctx.review_history),
|
|
506
|
+
"outcome": outcome,
|
|
507
|
+
"verdict": review.get("verdict") or ("pass" if outcome == "approve" else outcome),
|
|
508
|
+
"reason": review.get("reason", ""),
|
|
509
|
+
"notes": review.get("notes") or review.get("reviewer_notes") or [],
|
|
510
|
+
"retry_count": ctx.retries,
|
|
511
|
+
"timestamp": _now(),
|
|
512
|
+
}
|
|
513
|
+
ctx.review_history.append(review_entry)
|
|
514
|
+
if outcome == "retry" and ctx.retries < max_retries:
|
|
515
|
+
ctx.retries += 1
|
|
516
|
+
retry_entry = {
|
|
517
|
+
"retry": ctx.retries,
|
|
518
|
+
"limit": max_retries,
|
|
519
|
+
"reason": review_entry["reason"],
|
|
520
|
+
"reviewer_notes": review_entry["notes"],
|
|
521
|
+
"timestamp": _now(),
|
|
522
|
+
}
|
|
523
|
+
ctx.retry_history.append(retry_entry)
|
|
524
|
+
exec_index = pipeline.index("executor") if "executor" in pipeline else None
|
|
525
|
+
if exec_index is not None:
|
|
526
|
+
ctx.handoff("reviewer", "executor", note=f"retry #{ctx.retries}: {review_entry['reason']}", status="retry_requested")
|
|
527
|
+
index = exec_index
|
|
528
|
+
previous = "reviewer"
|
|
529
|
+
continue
|
|
530
|
+
if outcome == "reject":
|
|
531
|
+
ctx.timeline.append({"event": "execution_failed", "reason": review_entry["reason"], "timestamp": _now()})
|
|
532
|
+
break
|
|
533
|
+
|
|
534
|
+
previous = role
|
|
252
535
|
index += 1
|
|
253
536
|
|
|
254
|
-
|
|
255
|
-
if
|
|
537
|
+
final_outcome = _review_outcome(ctx.review or {})
|
|
538
|
+
if final_outcome == "approve":
|
|
256
539
|
status = "retried_ok" if ctx.retries else "ok"
|
|
257
540
|
else:
|
|
258
541
|
status = "failed"
|
|
542
|
+
if status == "failed":
|
|
543
|
+
ctx.timeline.append({"event": "execution_failed", "status": status, "retries": ctx.retries, "timestamp": _now()})
|
|
259
544
|
ctx.timeline.append({"event": "end", "status": status, "retries": ctx.retries, "timestamp": _now()})
|
|
260
545
|
|
|
261
546
|
return AgentRunResult(
|
|
@@ -267,4 +552,10 @@ class MultiAgentOrchestrator:
|
|
|
267
552
|
review=ctx.review,
|
|
268
553
|
roles_run=roles_run,
|
|
269
554
|
retries=ctx.retries,
|
|
555
|
+
handoffs=ctx.handoffs,
|
|
556
|
+
context_packets=ctx.context_packets,
|
|
557
|
+
review_history=ctx.review_history,
|
|
558
|
+
retry_history=ctx.retry_history,
|
|
559
|
+
plan_review=ctx.plan_review,
|
|
560
|
+
memory_snapshots=ctx.memory_snapshots,
|
|
270
561
|
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Plugin SDK — manifest, registry, lifecycle, permissions, validation, and a
|
|
2
2
|
safe execution boundary.
|
|
3
3
|
|
|
4
|
-
The Plugin SDK is the v2
|
|
4
|
+
The Plugin SDK is the v2 extension layer. It is intentionally additive:
|
|
5
5
|
a plugin is a directory under the configured ``plugins`` root that ships a
|
|
6
6
|
``plugin.json`` manifest and *extends* the existing Skill / Tool / Workflow
|
|
7
7
|
surfaces rather than replacing them. Installed standalone skills keep working
|
|
@@ -30,7 +30,7 @@ from pathlib import Path
|
|
|
30
30
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
PLUGIN_SDK_VERSION = "2.
|
|
33
|
+
PLUGIN_SDK_VERSION = "2.2.0"
|
|
34
34
|
|
|
35
35
|
# Capability-style permissions a plugin can request. Kept deliberately small so
|
|
36
36
|
# the Enterprise seam can layer finer-grained policy on top without changing the
|
|
@@ -348,6 +348,7 @@ class PluginRegistry:
|
|
|
348
348
|
args: Optional[Dict[str, Any]] = None,
|
|
349
349
|
*,
|
|
350
350
|
runners: Optional[Dict[str, Callable[..., Any]]] = None,
|
|
351
|
+
workspace_id: Optional[str] = None,
|
|
351
352
|
) -> PluginExecutionResult:
|
|
352
353
|
"""Run a plugin-provided action through the permission boundary.
|
|
353
354
|
|
|
@@ -355,17 +356,32 @@ class PluginRegistry:
|
|
|
355
356
|
"agents") to a callable the host injects. The boundary refuses any
|
|
356
357
|
capability the plugin did not *declare in its manifest*; without a
|
|
357
358
|
matching runner the action is reported ``skipped`` (never crashes the
|
|
358
|
-
caller). This keeps
|
|
359
|
+
caller). This keeps plugins safe-by-default.
|
|
359
360
|
"""
|
|
360
361
|
args = args or {}
|
|
361
362
|
runners = runners or {}
|
|
363
|
+
|
|
364
|
+
def emit(event_type: str, payload: Dict[str, Any]) -> None:
|
|
365
|
+
if self.store is not None and hasattr(self.store, "record_timeline_event"):
|
|
366
|
+
try:
|
|
367
|
+
self.store.record_timeline_event("plugins", event_type, payload, workspace_id=workspace_id)
|
|
368
|
+
except Exception:
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
emit("plugin_started", {"plugin_id": plugin_id, "action": action})
|
|
372
|
+
|
|
373
|
+
def finish(result: PluginExecutionResult) -> PluginExecutionResult:
|
|
374
|
+
event_type = "plugin_completed" if result.status in {"ok", "skipped"} else "execution_failed"
|
|
375
|
+
emit(event_type, {"plugin_id": plugin_id, "action": action, "status": result.status, "reason": result.reason})
|
|
376
|
+
return result
|
|
377
|
+
|
|
362
378
|
manifest = self.get_manifest(plugin_id)
|
|
363
379
|
if manifest is None:
|
|
364
|
-
return PluginExecutionResult(plugin_id, action, "error", reason="plugin not found or invalid")
|
|
380
|
+
return finish(PluginExecutionResult(plugin_id, action, "error", reason="plugin not found or invalid"))
|
|
365
381
|
|
|
366
382
|
registry_state = self.store.list_plugin_registry().get(plugin_id, {}) if self.store else {}
|
|
367
383
|
if self.store is not None and not registry_state.get("enabled", registry_state.get("installed")):
|
|
368
|
-
return PluginExecutionResult(plugin_id, action, "blocked", reason="plugin is not enabled")
|
|
384
|
+
return finish(PluginExecutionResult(plugin_id, action, "blocked", reason="plugin is not enabled"))
|
|
369
385
|
|
|
370
386
|
# Map an action to the capability + permission it needs.
|
|
371
387
|
capability_for: Dict[str, Tuple[str, str]] = {
|
|
@@ -377,24 +393,24 @@ class PluginRegistry:
|
|
|
377
393
|
capability, permission = capability_for.get(action, ("actions", ""))
|
|
378
394
|
|
|
379
395
|
if permission and permission not in manifest.permissions:
|
|
380
|
-
return PluginExecutionResult(
|
|
396
|
+
return finish(PluginExecutionResult(
|
|
381
397
|
plugin_id, action, "blocked",
|
|
382
398
|
reason=f"plugin did not declare required permission '{permission}'",
|
|
383
|
-
)
|
|
399
|
+
))
|
|
384
400
|
if permission and self.store is not None and permission not in self._granted_permissions(plugin_id):
|
|
385
|
-
return PluginExecutionResult(
|
|
401
|
+
return finish(PluginExecutionResult(
|
|
386
402
|
plugin_id, action, "blocked",
|
|
387
403
|
reason=f"permission '{permission}' not granted at install time",
|
|
388
|
-
)
|
|
404
|
+
))
|
|
389
405
|
|
|
390
406
|
runner = runners.get(capability)
|
|
391
407
|
if runner is None:
|
|
392
|
-
return PluginExecutionResult(
|
|
408
|
+
return finish(PluginExecutionResult(
|
|
393
409
|
plugin_id, action, "skipped",
|
|
394
410
|
reason=f"no host runner for capability '{capability}'",
|
|
395
|
-
)
|
|
411
|
+
))
|
|
396
412
|
try:
|
|
397
413
|
output = runner(plugin_id=plugin_id, action=action, args=args, manifest=manifest)
|
|
398
|
-
return PluginExecutionResult(plugin_id, action, "ok", output=output)
|
|
414
|
+
return finish(PluginExecutionResult(plugin_id, action, "ok", output=output))
|
|
399
415
|
except Exception as exc:
|
|
400
|
-
return PluginExecutionResult(plugin_id, action, "error", reason=str(exc))
|
|
416
|
+
return finish(PluginExecutionResult(plugin_id, action, "error", reason=str(exc)))
|