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.
@@ -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
+ )