ltcai 1.7.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -21
- package/docs/CHANGELOG.md +119 -0
- package/docs/EDITION_STRATEGY.md +10 -4
- package/docs/ENTERPRISE.md +3 -1
- package/docs/MULTI_AGENT_RUNTIME.md +428 -0
- package/docs/PLUGIN_SDK.md +664 -0
- package/docs/REALTIME_COLLABORATION.md +423 -0
- package/docs/V2_ARCHITECTURE.md +540 -0
- package/docs/WORKFLOW_DESIGNER.md +485 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +154 -0
- package/latticeai/api/marketplace.py +81 -0
- package/latticeai/api/plugins.py +115 -0
- package/latticeai/api/realtime.py +91 -0
- package/latticeai/api/workflow_designer.py +216 -0
- package/latticeai/core/marketplace.py +178 -0
- package/latticeai/core/multi_agent.py +561 -0
- package/latticeai/core/plugins.py +416 -0
- package/latticeai/core/realtime.py +190 -0
- package/latticeai/core/workflow_engine.py +329 -0
- package/latticeai/core/workspace_os.py +406 -6
- package/latticeai/server_app.py +88 -2
- package/latticeai/services/platform_runtime.py +204 -0
- package/package.json +8 -2
- package/plugins/README.md +35 -0
- package/plugins/git-insights/plugin.json +15 -0
- package/plugins/hello-world/plugin.json +16 -0
- package/plugins/hello-world/skills/hello_skill/SKILL.md +15 -0
- package/static/activity.html +70 -0
- package/static/agents.html +136 -0
- package/static/platform.css +75 -0
- package/static/plugins.html +133 -0
- package/static/scripts/platform.js +64 -0
- package/static/workflows.html +143 -0
- package/static/workspace.html +5 -1
|
@@ -0,0 +1,561 @@
|
|
|
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.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MULTI_AGENT_VERSION = "2.1.0"
|
|
18
|
+
|
|
19
|
+
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
20
|
+
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
21
|
+
|
|
22
|
+
ROLE_AGENT_IDS = {
|
|
23
|
+
"researcher": "agent:researcher",
|
|
24
|
+
"planner": "agent:planner",
|
|
25
|
+
"executor": "agent:executor",
|
|
26
|
+
"reviewer": "agent:reviewer",
|
|
27
|
+
"release": "agent:release",
|
|
28
|
+
}
|
|
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
|
+
|
|
45
|
+
|
|
46
|
+
def _now() -> str:
|
|
47
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
48
|
+
|
|
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
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class OrchestrationContext:
|
|
148
|
+
"""Mutable carrier threaded through every role stage."""
|
|
149
|
+
|
|
150
|
+
goal: str
|
|
151
|
+
user_email: Optional[str] = None
|
|
152
|
+
workspace_id: Optional[str] = None
|
|
153
|
+
inputs: Dict[str, Any] = field(default_factory=dict)
|
|
154
|
+
plan: List[Dict[str, Any]] = field(default_factory=list)
|
|
155
|
+
plan_id: str = ""
|
|
156
|
+
plan_review: Dict[str, Any] = field(default_factory=dict)
|
|
157
|
+
research: List[str] = field(default_factory=list)
|
|
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)
|
|
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)
|
|
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)
|
|
168
|
+
retries: int = 0
|
|
169
|
+
output: str = ""
|
|
170
|
+
|
|
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.
|
|
254
|
+
self.timeline.append({
|
|
255
|
+
"event": "handoff",
|
|
256
|
+
"handoff_id": handoff_id,
|
|
257
|
+
"from": frm,
|
|
258
|
+
"to": to,
|
|
259
|
+
"note": note,
|
|
260
|
+
"status": status,
|
|
261
|
+
"timestamp": now,
|
|
262
|
+
})
|
|
263
|
+
return record
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass
|
|
267
|
+
class AgentRunResult:
|
|
268
|
+
agent_id: str
|
|
269
|
+
status: str # ok | failed | retried_ok
|
|
270
|
+
output: str
|
|
271
|
+
timeline: List[Dict[str, Any]]
|
|
272
|
+
plan: List[Dict[str, Any]]
|
|
273
|
+
review: Dict[str, Any]
|
|
274
|
+
roles_run: List[str]
|
|
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)
|
|
282
|
+
|
|
283
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
284
|
+
return {
|
|
285
|
+
"agent_id": self.agent_id,
|
|
286
|
+
"status": self.status,
|
|
287
|
+
"output": self.output,
|
|
288
|
+
"timeline": self.timeline,
|
|
289
|
+
"plan": self.plan,
|
|
290
|
+
"review": self.review,
|
|
291
|
+
"roles_run": self.roles_run,
|
|
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,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def default_role_runner(
|
|
303
|
+
*,
|
|
304
|
+
workflow_runner: Optional[Callable[..., Any]] = None,
|
|
305
|
+
plugin_runner: Optional[Callable[..., Any]] = None,
|
|
306
|
+
context_provider: Optional[Callable[[str], List[str]]] = None,
|
|
307
|
+
) -> Callable[[str, OrchestrationContext], Dict[str, Any]]:
|
|
308
|
+
"""Build a deterministic, dependency-free role runner."""
|
|
309
|
+
|
|
310
|
+
def runner(role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
|
|
311
|
+
if role == "researcher":
|
|
312
|
+
found = context_provider(ctx.goal) if context_provider else []
|
|
313
|
+
ctx.research = list(found)
|
|
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}
|
|
322
|
+
|
|
323
|
+
if role == "planner":
|
|
324
|
+
goal = ctx.goal.strip() or "Complete the requested task"
|
|
325
|
+
requested = ctx.inputs.get("steps")
|
|
326
|
+
steps: List[Dict[str, Any]]
|
|
327
|
+
if isinstance(requested, list) and requested:
|
|
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)
|
|
338
|
+
else:
|
|
339
|
+
steps = [
|
|
340
|
+
{"index": 0, "description": f"Analyze: {goal}", "status": "planned"},
|
|
341
|
+
{"index": 1, "description": f"Execute: {goal}", "status": "planned"},
|
|
342
|
+
{"index": 2, "description": "Verify the result", "status": "planned"},
|
|
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")
|
|
348
|
+
ctx.plan = steps
|
|
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}
|
|
357
|
+
|
|
358
|
+
if role == "executor":
|
|
359
|
+
results = []
|
|
360
|
+
for step in ctx.plan:
|
|
361
|
+
outcome: Dict[str, Any] = {"index": step["index"], "description": step["description"]}
|
|
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:
|
|
365
|
+
try:
|
|
366
|
+
workflow_result = workflow_runner(wf, ctx)
|
|
367
|
+
outcome["workflow_result"] = workflow_result
|
|
368
|
+
ctx.workflow_outputs.append(workflow_result)
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
outcome["workflow_error"] = str(exc)
|
|
371
|
+
if pl and plugin_runner is not None:
|
|
372
|
+
try:
|
|
373
|
+
plugin_result = plugin_runner(pl, ctx)
|
|
374
|
+
outcome["plugin_result"] = plugin_result
|
|
375
|
+
ctx.plugin_outputs.append(plugin_result)
|
|
376
|
+
except Exception as exc:
|
|
377
|
+
outcome["plugin_error"] = str(exc)
|
|
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"
|
|
384
|
+
results.append(outcome)
|
|
385
|
+
ctx.executed = results
|
|
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:]}
|
|
389
|
+
|
|
390
|
+
if role == "reviewer":
|
|
391
|
+
ok = bool(ctx.executed) and all(r.get("status") == "done" for r in ctx.executed)
|
|
392
|
+
ctx.review = {
|
|
393
|
+
"outcome": "approve" if ok else "retry",
|
|
394
|
+
"verdict": "pass" if ok else "retry",
|
|
395
|
+
"reason": "all steps completed" if ok else "one or more steps failed or no steps executed",
|
|
396
|
+
"confidence": 0.9 if ok else 0.3,
|
|
397
|
+
"notes": [] if ok else ["executor should retry with preserved context"],
|
|
398
|
+
"reviewed_at": _now(),
|
|
399
|
+
}
|
|
400
|
+
return {"role": role, **ctx.review}
|
|
401
|
+
|
|
402
|
+
if role == "release":
|
|
403
|
+
ctx.output = ctx.output or f"Released outcome for: {ctx.goal}"
|
|
404
|
+
return {"role": role, "released": True, "summary": ctx.output}
|
|
405
|
+
|
|
406
|
+
return {"role": role, "status": "noop"}
|
|
407
|
+
|
|
408
|
+
return runner
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class MultiAgentOrchestrator:
|
|
412
|
+
"""Drives a role pipeline with handoff, planning, review, and retry."""
|
|
413
|
+
|
|
414
|
+
def __init__(self, role_runner: Optional[Callable[[str, OrchestrationContext], Dict[str, Any]]] = None):
|
|
415
|
+
self.role_runner = role_runner or default_role_runner()
|
|
416
|
+
|
|
417
|
+
def _run_role(self, role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
|
|
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
|
+
})
|
|
426
|
+
try:
|
|
427
|
+
result = self.role_runner(role, ctx) or {}
|
|
428
|
+
status = result.get("status", "ok")
|
|
429
|
+
except Exception as exc:
|
|
430
|
+
result = {"error": str(exc)}
|
|
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
|
+
})
|
|
449
|
+
ctx.timeline.append({
|
|
450
|
+
"event": "role",
|
|
451
|
+
"role": role,
|
|
452
|
+
"agent_id": ROLE_AGENT_IDS.get(role, f"agent:{role}"),
|
|
453
|
+
"status": status,
|
|
454
|
+
"result": result,
|
|
455
|
+
"started_at": started,
|
|
456
|
+
"timestamp": _now(),
|
|
457
|
+
})
|
|
458
|
+
return result
|
|
459
|
+
|
|
460
|
+
def run(
|
|
461
|
+
self,
|
|
462
|
+
goal: str,
|
|
463
|
+
*,
|
|
464
|
+
user_email: Optional[str] = None,
|
|
465
|
+
workspace_id: Optional[str] = None,
|
|
466
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
467
|
+
roles: Optional[List[str]] = None,
|
|
468
|
+
max_retries: int = 2,
|
|
469
|
+
) -> AgentRunResult:
|
|
470
|
+
ctx = OrchestrationContext(
|
|
471
|
+
goal=goal or "",
|
|
472
|
+
user_email=user_email,
|
|
473
|
+
workspace_id=workspace_id,
|
|
474
|
+
inputs=inputs or {},
|
|
475
|
+
)
|
|
476
|
+
pipeline = [r for r in (roles or list(CORE_PIPELINE)) if r in AGENT_ROLES]
|
|
477
|
+
if not pipeline:
|
|
478
|
+
pipeline = list(CORE_PIPELINE)
|
|
479
|
+
max_retries = max(0, int(max_retries or 0))
|
|
480
|
+
|
|
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
|
+
})
|
|
490
|
+
|
|
491
|
+
roles_run: List[str] = []
|
|
492
|
+
previous: Optional[str] = None
|
|
493
|
+
index = 0
|
|
494
|
+
while index < len(pipeline):
|
|
495
|
+
role = pipeline[index]
|
|
496
|
+
if previous is not None:
|
|
497
|
+
ctx.handoff(previous, role)
|
|
498
|
+
self._run_role(role, ctx)
|
|
499
|
+
roles_run.append(role)
|
|
500
|
+
|
|
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
|
|
535
|
+
index += 1
|
|
536
|
+
|
|
537
|
+
final_outcome = _review_outcome(ctx.review or {})
|
|
538
|
+
if final_outcome == "approve":
|
|
539
|
+
status = "retried_ok" if ctx.retries else "ok"
|
|
540
|
+
else:
|
|
541
|
+
status = "failed"
|
|
542
|
+
if status == "failed":
|
|
543
|
+
ctx.timeline.append({"event": "execution_failed", "status": status, "retries": ctx.retries, "timestamp": _now()})
|
|
544
|
+
ctx.timeline.append({"event": "end", "status": status, "retries": ctx.retries, "timestamp": _now()})
|
|
545
|
+
|
|
546
|
+
return AgentRunResult(
|
|
547
|
+
agent_id=ROLE_AGENT_IDS.get("executor", "agent:executor"),
|
|
548
|
+
status=status,
|
|
549
|
+
output=ctx.output or f"Processed goal: {ctx.goal}",
|
|
550
|
+
timeline=ctx.timeline,
|
|
551
|
+
plan=ctx.plan,
|
|
552
|
+
review=ctx.review,
|
|
553
|
+
roles_run=roles_run,
|
|
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,
|
|
561
|
+
)
|