ltcai 2.0.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.
@@ -1,27 +1,10 @@
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.
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.0.0"
17
+ MULTI_AGENT_VERSION = "2.1.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 handoff(self, frm: str, to: str, note: str = "") -> None:
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
- "timestamp": _now(),
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
- return {"role": role, "context_items": len(ctx.research), "items": ctx.research[:10]}
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
- {"index": i, "description": str(s), "status": "planned"}
132
- for i, s in enumerate(requested)
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
- return {"role": role, "steps": len(steps), "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}
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 and step["index"] == 0:
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
- outcome["workflow_result"] = workflow_runner(wf, ctx)
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
- outcome["plugin_result"] = plugin_runner(pl, ctx)
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
- step["status"] = "done"
161
- outcome["status"] = "done"
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
- ctx.output = f"Completed {len(results)} planned step(s) for: {ctx.goal}"
165
- return {"role": role, "executed": len(results), "results": 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:]}
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 + bounded retry over a role runner."""
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" 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
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
- final_verdict = ctx.review.get("verdict", "pass")
255
- if final_verdict == "pass":
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.0.0 extension layer. It is intentionally additive:
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.0.0"
33
+ PLUGIN_SDK_VERSION = "2.1.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 v2.0.0 plugins safe-by-default.
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)))