@elizaos/plugin-agent-orchestrator 2.0.0-alpha.1

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +248 -0
  3. package/package.json +83 -0
  4. package/python/README.md +95 -0
  5. package/python/dist/elizaos_plugin_agent_orchestrator-2.0.0-py3-none-any.whl +0 -0
  6. package/python/dist/elizaos_plugin_agent_orchestrator-2.0.0.tar.gz +0 -0
  7. package/python/elizaos_plugin_agent_orchestrator/__init__.py +84 -0
  8. package/python/elizaos_plugin_agent_orchestrator/__pycache__/__init__.cpython-313.pyc +0 -0
  9. package/python/elizaos_plugin_agent_orchestrator/__pycache__/config.cpython-313.pyc +0 -0
  10. package/python/elizaos_plugin_agent_orchestrator/__pycache__/service.cpython-313.pyc +0 -0
  11. package/python/elizaos_plugin_agent_orchestrator/__pycache__/types.cpython-313.pyc +0 -0
  12. package/python/elizaos_plugin_agent_orchestrator/actions/__init__.py +23 -0
  13. package/python/elizaos_plugin_agent_orchestrator/actions/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/python/elizaos_plugin_agent_orchestrator/actions/__pycache__/task_management.cpython-313.pyc +0 -0
  15. package/python/elizaos_plugin_agent_orchestrator/actions/task_management.py +404 -0
  16. package/python/elizaos_plugin_agent_orchestrator/config.py +28 -0
  17. package/python/elizaos_plugin_agent_orchestrator/providers/__init__.py +7 -0
  18. package/python/elizaos_plugin_agent_orchestrator/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  19. package/python/elizaos_plugin_agent_orchestrator/providers/__pycache__/task_context.cpython-313.pyc +0 -0
  20. package/python/elizaos_plugin_agent_orchestrator/providers/task_context.py +58 -0
  21. package/python/elizaos_plugin_agent_orchestrator/py.typed +0 -0
  22. package/python/elizaos_plugin_agent_orchestrator/service.py +649 -0
  23. package/python/elizaos_plugin_agent_orchestrator/types.py +309 -0
  24. package/python/elizaos_plugin_agent_orchestrator.egg-info/PKG-INFO +119 -0
  25. package/python/elizaos_plugin_agent_orchestrator.egg-info/SOURCES.txt +17 -0
  26. package/python/elizaos_plugin_agent_orchestrator.egg-info/dependency_links.txt +1 -0
  27. package/python/elizaos_plugin_agent_orchestrator.egg-info/requires.txt +5 -0
  28. package/python/elizaos_plugin_agent_orchestrator.egg-info/top_level.txt +1 -0
  29. package/python/elizaos_plugin_discord/generated/specs/__init__.py +1 -0
  30. package/python/elizaos_plugin_discord/generated/specs/specs.py +77 -0
  31. package/python/pyproject.toml +56 -0
  32. package/python/tests/__init__.py +1 -0
  33. package/python/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  34. package/python/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc +0 -0
  35. package/python/tests/__pycache__/test_service.cpython-313-pytest-9.0.2.pyc +0 -0
  36. package/python/tests/conftest.py +130 -0
  37. package/python/tests/test_service.py +140 -0
  38. package/rust/Cargo.toml +33 -0
  39. package/rust/README.md +112 -0
  40. package/rust/src/actions/mod.rs +173 -0
  41. package/rust/src/config.rs +111 -0
  42. package/rust/src/error.rs +30 -0
  43. package/rust/src/generated/specs/mod.rs +3 -0
  44. package/rust/src/generated/specs/specs.rs +27 -0
  45. package/rust/src/lib.rs +48 -0
  46. package/rust/src/providers/mod.rs +5 -0
  47. package/rust/src/providers/task_context.rs +50 -0
  48. package/rust/src/service.rs +771 -0
  49. package/rust/src/types.rs +275 -0
  50. package/typescript/dist/index.d.ts +9 -0
  51. package/typescript/dist/index.d.ts.map +1 -0
  52. package/typescript/dist/index.js +817 -0
  53. package/typescript/dist/index.js.map +18 -0
  54. package/typescript/dist/src/actions/task-management.d.ts +9 -0
  55. package/typescript/dist/src/actions/task-management.d.ts.map +1 -0
  56. package/typescript/dist/src/config.d.ts +4 -0
  57. package/typescript/dist/src/config.d.ts.map +1 -0
  58. package/typescript/dist/src/providers/task-context.d.ts +3 -0
  59. package/typescript/dist/src/providers/task-context.d.ts.map +1 -0
  60. package/typescript/dist/src/services/agent-orchestrator-service.d.ts +59 -0
  61. package/typescript/dist/src/services/agent-orchestrator-service.d.ts.map +1 -0
  62. package/typescript/dist/src/types.d.ts +113 -0
  63. package/typescript/dist/src/types.d.ts.map +1 -0
@@ -0,0 +1,649 @@
1
+ """
2
+ Agent Orchestrator Service - manages task lifecycle and delegates to providers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import os
9
+ import time
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from .config import get_configured_options
14
+ from .types import (
15
+ AgentProvider,
16
+ AgentProviderId,
17
+ JsonValue,
18
+ OrchestratedTask,
19
+ OrchestratedTaskMetadata,
20
+ ProviderTaskExecutionContext,
21
+ TaskEvent,
22
+ TaskEventType,
23
+ TaskResult,
24
+ TaskStatus,
25
+ TaskStep,
26
+ TaskUserStatus,
27
+ )
28
+
29
+
30
+ def _now() -> int:
31
+ """Current time in milliseconds."""
32
+ return int(time.time() * 1000)
33
+
34
+
35
+ def _clamp_progress(n: int | float) -> int:
36
+ """Clamp progress to 0-100."""
37
+ if not isinstance(n, (int, float)):
38
+ return 0
39
+ return min(100, max(0, round(n)))
40
+
41
+
42
+ class ControlState:
43
+ """Per-task cancellation/pause state."""
44
+
45
+ def __init__(self) -> None:
46
+ self.cancelled = False
47
+ self.paused = False
48
+
49
+
50
+ class AgentOrchestratorService:
51
+ """
52
+ Orchestrates tasks across registered agent providers.
53
+
54
+ This service manages task lifecycle (create, pause, resume, cancel)
55
+ and delegates actual execution to registered AgentProviders.
56
+ """
57
+
58
+ service_type = "CODE_TASK"
59
+ capability_description = "Orchestrates tasks across registered agent providers"
60
+
61
+ def __init__(self, runtime: Any) -> None:
62
+ self.runtime = runtime
63
+ self._current_task_id: str | None = None
64
+ self._control_states: dict[str, ControlState] = {}
65
+ self._executions: dict[str, asyncio.Task[None]] = {}
66
+ self._event_handlers: dict[str, list[Callable[[TaskEvent], None]]] = {}
67
+
68
+ @classmethod
69
+ async def start(cls, runtime: Any) -> AgentOrchestratorService:
70
+ """Start the service."""
71
+ return cls(runtime)
72
+
73
+ async def stop(self) -> None:
74
+ """Stop the service."""
75
+ self._event_handlers.clear()
76
+ self._control_states.clear()
77
+ for task in self._executions.values():
78
+ task.cancel()
79
+ self._executions.clear()
80
+
81
+ # ========================================================================
82
+ # Provider resolution
83
+ # ========================================================================
84
+
85
+ def _get_options(self) -> Any:
86
+ opts = get_configured_options()
87
+ if opts is None:
88
+ raise RuntimeError(
89
+ "AgentOrchestratorService not configured. "
90
+ "Call configure_agent_orchestrator_plugin(...) before runtime.initialize()."
91
+ )
92
+ return opts
93
+
94
+ def _get_active_provider_id(self) -> AgentProviderId:
95
+ opts = self._get_options()
96
+ env_var = opts.active_provider_env_var
97
+ raw = os.environ.get(env_var, "").strip()
98
+ return raw if raw else opts.default_provider_id
99
+
100
+ def _get_provider_by_id(self, provider_id: AgentProviderId) -> AgentProvider | None:
101
+ opts = self._get_options()
102
+ for p in opts.providers:
103
+ if p.id == provider_id:
104
+ return p
105
+ return None
106
+
107
+ # ========================================================================
108
+ # Current task
109
+ # ========================================================================
110
+
111
+ def get_current_task_id(self) -> str | None:
112
+ """Get the current task ID."""
113
+ return self._current_task_id
114
+
115
+ def set_current_task(self, task_id: str | None) -> None:
116
+ """Set the current task."""
117
+ self._current_task_id = task_id
118
+ if task_id:
119
+ self._emit(TaskEventType.PROGRESS, task_id, {"selected": True})
120
+
121
+ async def get_current_task(self) -> OrchestratedTask | None:
122
+ """Get the current task."""
123
+ if not self._current_task_id:
124
+ return None
125
+ return await self.get_task(self._current_task_id)
126
+
127
+ # ========================================================================
128
+ # CRUD
129
+ # ========================================================================
130
+
131
+ async def create_task(
132
+ self,
133
+ name: str,
134
+ description: str,
135
+ room_id: str | None = None,
136
+ provider_id: AgentProviderId | None = None,
137
+ ) -> OrchestratedTask:
138
+ """Create a new orchestrated task."""
139
+ opts = self._get_options()
140
+ chosen_provider_id = provider_id or self._get_active_provider_id()
141
+ provider = self._get_provider_by_id(chosen_provider_id)
142
+
143
+ if provider is None:
144
+ available = ", ".join(p.id for p in opts.providers)
145
+ raise ValueError(f'Unknown provider "{chosen_provider_id}". Available: {available}')
146
+
147
+ world_id = await self._resolve_world_id(room_id)
148
+ working_directory = opts.get_working_directory()
149
+
150
+ metadata = OrchestratedTaskMetadata(
151
+ status=TaskStatus.PENDING,
152
+ progress=0,
153
+ output=[],
154
+ steps=[],
155
+ working_directory=working_directory,
156
+ provider_id=provider.id,
157
+ provider_label=provider.label,
158
+ sub_agent_type=provider.id,
159
+ user_status=TaskUserStatus.OPEN,
160
+ user_status_updated_at=_now(),
161
+ files_created=[],
162
+ files_modified=[],
163
+ created_at=_now(),
164
+ )
165
+
166
+ task_input = {
167
+ "name": name,
168
+ "description": description,
169
+ "worldId": world_id,
170
+ "tags": ["code", "queue", "orchestrator", "task"],
171
+ "metadata": metadata.to_dict(),
172
+ }
173
+ if room_id:
174
+ task_input["roomId"] = room_id
175
+
176
+ task_id = await self.runtime.create_task(task_input)
177
+ task = await self.get_task(task_id)
178
+ if task is None:
179
+ raise RuntimeError("Failed to create task")
180
+
181
+ if not self._current_task_id:
182
+ self._current_task_id = task_id
183
+
184
+ self._emit(TaskEventType.CREATED, task_id, {"name": task.name, "providerId": provider.id})
185
+ return task
186
+
187
+ async def _resolve_world_id(self, room_id: str | None) -> str:
188
+ if room_id:
189
+ room = await self.runtime.get_room(room_id)
190
+ if room and hasattr(room, "world_id") and room.world_id:
191
+ return room.world_id
192
+ return self.runtime.agent_id
193
+
194
+ async def get_task(self, task_id: str) -> OrchestratedTask | None:
195
+ """Get a task by ID."""
196
+ t = await self.runtime.get_task(task_id)
197
+ if t is None:
198
+ return None
199
+ return self._to_orchestrated_task(t)
200
+
201
+ def _to_orchestrated_task(self, raw: Any) -> OrchestratedTask:
202
+ """Convert runtime task to OrchestratedTask."""
203
+ metadata_raw = getattr(raw, "metadata", {}) or {}
204
+ if isinstance(metadata_raw, dict):
205
+ metadata = OrchestratedTaskMetadata.from_dict(metadata_raw)
206
+ else:
207
+ metadata = metadata_raw
208
+
209
+ return OrchestratedTask(
210
+ id=getattr(raw, "id", ""),
211
+ name=getattr(raw, "name", ""),
212
+ description=getattr(raw, "description", ""),
213
+ metadata=metadata,
214
+ tags=getattr(raw, "tags", []),
215
+ room_id=getattr(raw, "room_id", None) or getattr(raw, "roomId", None),
216
+ world_id=getattr(raw, "world_id", None) or getattr(raw, "worldId", None),
217
+ )
218
+
219
+ async def get_tasks(self) -> list[OrchestratedTask]:
220
+ """Get all orchestrated tasks."""
221
+ tasks = await self.runtime.get_tasks({"tags": ["orchestrator"]})
222
+ return [self._to_orchestrated_task(t) for t in tasks]
223
+
224
+ async def get_recent_tasks(self, limit: int = 20) -> list[OrchestratedTask]:
225
+ """Get recent tasks sorted by creation time."""
226
+ tasks = await self.get_tasks()
227
+ tasks.sort(key=lambda t: t.metadata.created_at or 0, reverse=True)
228
+ return tasks[:limit]
229
+
230
+ async def get_tasks_by_status(self, status: TaskStatus) -> list[OrchestratedTask]:
231
+ """Get tasks by status."""
232
+ tasks = await self.get_tasks()
233
+ return [t for t in tasks if t.metadata.status == status]
234
+
235
+ async def search_tasks(self, query: str) -> list[OrchestratedTask]:
236
+ """Search tasks by query."""
237
+ q = query.strip().lower()
238
+ if not q:
239
+ return []
240
+ tasks = await self.get_tasks()
241
+ results = []
242
+ for t in tasks:
243
+ task_id = (t.id or "").lower()
244
+ if (
245
+ task_id.startswith(q)
246
+ or q in t.name.lower()
247
+ or q in (t.description or "").lower()
248
+ or any(q in tag.lower() for tag in t.tags)
249
+ ):
250
+ results.append(t)
251
+ return results
252
+
253
+ # ========================================================================
254
+ # Updates
255
+ # ========================================================================
256
+
257
+ async def update_task_status(self, task_id: str, status: TaskStatus) -> None:
258
+ """Update task status."""
259
+ task = await self.get_task(task_id)
260
+ if task is None:
261
+ return
262
+
263
+ metadata = task.metadata
264
+ metadata.status = status
265
+
266
+ if status == TaskStatus.RUNNING and metadata.started_at is None:
267
+ metadata.started_at = _now()
268
+ if status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED):
269
+ metadata.completed_at = _now()
270
+
271
+ await self.runtime.update_task(task_id, {"metadata": metadata.to_dict()})
272
+
273
+ # Map status to event type
274
+ event_map = {
275
+ TaskStatus.RUNNING: TaskEventType.STARTED,
276
+ TaskStatus.COMPLETED: TaskEventType.COMPLETED,
277
+ TaskStatus.FAILED: TaskEventType.FAILED,
278
+ TaskStatus.PAUSED: TaskEventType.PAUSED,
279
+ TaskStatus.CANCELLED: TaskEventType.CANCELLED,
280
+ }
281
+ event_type = event_map.get(status, TaskEventType.PROGRESS)
282
+ self._emit(event_type, task_id, {"status": status.value})
283
+
284
+ async def update_task_progress(self, task_id: str, progress: int) -> None:
285
+ """Update task progress."""
286
+ task = await self.get_task(task_id)
287
+ if task is None:
288
+ return
289
+
290
+ metadata = task.metadata
291
+ metadata.progress = _clamp_progress(progress)
292
+ await self.runtime.update_task(task_id, {"metadata": metadata.to_dict()})
293
+ self._emit(TaskEventType.PROGRESS, task_id, {"progress": metadata.progress})
294
+
295
+ async def append_output(self, task_id: str, output: str) -> None:
296
+ """Append output to task."""
297
+ task = await self.get_task(task_id)
298
+ if task is None:
299
+ return
300
+
301
+ lines = [line for line in output.split("\n") if line.strip()]
302
+ metadata = task.metadata
303
+ metadata.output = (metadata.output + lines)[-500:]
304
+ await self.runtime.update_task(task_id, {"metadata": metadata.to_dict()})
305
+ self._emit(TaskEventType.OUTPUT, task_id, {"output": lines})
306
+
307
+ async def add_step(self, task_id: str, description: str) -> TaskStep:
308
+ """Add a step to a task."""
309
+ task = await self.get_task(task_id)
310
+ if task is None:
311
+ raise ValueError(f"Task {task_id} not found")
312
+
313
+ step = TaskStep.create(description)
314
+ metadata = task.metadata
315
+ metadata.steps.append(step)
316
+ await self.runtime.update_task(task_id, {"metadata": metadata.to_dict()})
317
+ return step
318
+
319
+ async def update_step(
320
+ self,
321
+ task_id: str,
322
+ step_id: str,
323
+ status: TaskStatus,
324
+ output: str | None = None,
325
+ ) -> None:
326
+ """Update a step's status."""
327
+ task = await self.get_task(task_id)
328
+ if task is None:
329
+ return
330
+
331
+ metadata = task.metadata
332
+ step = next((s for s in metadata.steps if s.id == step_id), None)
333
+ if step is None:
334
+ return
335
+
336
+ step.status = status
337
+ if output:
338
+ step.output = output
339
+
340
+ total = len(metadata.steps)
341
+ if total > 0:
342
+ completed = sum(1 for s in metadata.steps if s.status == TaskStatus.COMPLETED)
343
+ metadata.progress = _clamp_progress((completed / total) * 100)
344
+
345
+ await self.runtime.update_task(task_id, {"metadata": metadata.to_dict()})
346
+ self._emit(TaskEventType.PROGRESS, task_id, {"progress": metadata.progress})
347
+
348
+ async def set_task_result(self, task_id: str, result: TaskResult) -> None:
349
+ """Set the task result."""
350
+ task = await self.get_task(task_id)
351
+ if task is None:
352
+ return
353
+
354
+ metadata = task.metadata
355
+ metadata.result = result
356
+ metadata.files_created = result.files_created
357
+ metadata.files_modified = result.files_modified
358
+
359
+ if metadata.status != TaskStatus.CANCELLED:
360
+ metadata.status = TaskStatus.COMPLETED if result.success else TaskStatus.FAILED
361
+ metadata.completed_at = _now()
362
+
363
+ if not result.success and result.error:
364
+ metadata.error = result.error
365
+
366
+ await self.runtime.update_task(task_id, {"metadata": metadata.to_dict()})
367
+ event_type = TaskEventType.COMPLETED if result.success else TaskEventType.FAILED
368
+ self._emit(
369
+ event_type,
370
+ task_id,
371
+ {
372
+ "success": result.success,
373
+ "summary": result.summary,
374
+ "error": result.error,
375
+ },
376
+ )
377
+
378
+ async def set_task_error(self, task_id: str, error: str) -> None:
379
+ """Set task error."""
380
+ task = await self.get_task(task_id)
381
+ if task is None:
382
+ return
383
+
384
+ metadata = task.metadata
385
+ metadata.error = error
386
+ if metadata.status != TaskStatus.CANCELLED:
387
+ metadata.status = TaskStatus.FAILED
388
+ metadata.completed_at = _now()
389
+
390
+ await self.runtime.update_task(task_id, {"metadata": metadata.to_dict()})
391
+ event_type = (
392
+ TaskEventType.CANCELLED
393
+ if metadata.status == TaskStatus.CANCELLED
394
+ else TaskEventType.FAILED
395
+ )
396
+ self._emit(event_type, task_id, {"error": error})
397
+
398
+ async def set_user_status(self, task_id: str, user_status: TaskUserStatus) -> None:
399
+ """Set user-controlled status."""
400
+ task = await self.get_task(task_id)
401
+ if task is None:
402
+ return
403
+
404
+ metadata = task.metadata
405
+ metadata.user_status = user_status
406
+ metadata.user_status_updated_at = _now()
407
+ await self.runtime.update_task(task_id, {"metadata": metadata.to_dict()})
408
+ self._emit(TaskEventType.PROGRESS, task_id, {"userStatus": user_status.value})
409
+
410
+ async def set_task_sub_agent_type(self, task_id: str, next_provider_id: str) -> None:
411
+ """Change the provider for a task."""
412
+ task = await self.get_task(task_id)
413
+ if task is None:
414
+ return
415
+
416
+ provider = self._get_provider_by_id(next_provider_id)
417
+ metadata = task.metadata
418
+ metadata.provider_id = next_provider_id
419
+ metadata.sub_agent_type = next_provider_id
420
+ metadata.provider_label = (
421
+ provider.label if provider else metadata.provider_label or next_provider_id
422
+ )
423
+
424
+ await self.runtime.update_task(task_id, {"metadata": metadata.to_dict()})
425
+ self._emit(TaskEventType.MESSAGE, task_id, {"providerId": next_provider_id})
426
+ await self.append_output(
427
+ task_id, f"Provider: {metadata.provider_label} ({metadata.provider_id})"
428
+ )
429
+
430
+ # ========================================================================
431
+ # Control
432
+ # ========================================================================
433
+
434
+ async def pause_task(self, task_id: str) -> None:
435
+ """Pause a task."""
436
+ self._set_control(task_id, paused=True)
437
+ await self.update_task_status(task_id, TaskStatus.PAUSED)
438
+ self._emit(TaskEventType.PAUSED, task_id)
439
+
440
+ async def resume_task(self, task_id: str) -> None:
441
+ """Resume a paused task."""
442
+ self._set_control(task_id, paused=False)
443
+ await self.update_task_status(task_id, TaskStatus.RUNNING)
444
+ self._emit(TaskEventType.RESUMED, task_id)
445
+
446
+ async def cancel_task(self, task_id: str) -> None:
447
+ """Cancel a task."""
448
+ self._set_control(task_id, cancelled=True, paused=False)
449
+ task = await self.get_task(task_id)
450
+ if task is None:
451
+ return
452
+
453
+ metadata = task.metadata
454
+ metadata.status = TaskStatus.CANCELLED
455
+ metadata.completed_at = _now()
456
+ metadata.error = metadata.error or "Cancelled by user"
457
+ await self.runtime.update_task(task_id, {"metadata": metadata.to_dict()})
458
+ self._emit(TaskEventType.CANCELLED, task_id, {"status": "cancelled"})
459
+
460
+ async def delete_task(self, task_id: str) -> None:
461
+ """Delete a task."""
462
+ self._set_control(task_id, cancelled=True, paused=False)
463
+ await self.runtime.delete_task(task_id)
464
+ if self._current_task_id == task_id:
465
+ self._current_task_id = None
466
+ self._emit(TaskEventType.MESSAGE, task_id, {"deleted": True})
467
+
468
+ def is_task_cancelled(self, task_id: str) -> bool:
469
+ """Check if task is cancelled."""
470
+ state = self._control_states.get(task_id)
471
+ return state.cancelled if state else False
472
+
473
+ def is_task_paused(self, task_id: str) -> bool:
474
+ """Check if task is paused."""
475
+ state = self._control_states.get(task_id)
476
+ return state.paused if state else False
477
+
478
+ def _set_control(
479
+ self,
480
+ task_id: str,
481
+ cancelled: bool | None = None,
482
+ paused: bool | None = None,
483
+ ) -> None:
484
+ state = self._control_states.get(task_id)
485
+ if state is None:
486
+ state = ControlState()
487
+ self._control_states[task_id] = state
488
+ if cancelled is not None:
489
+ state.cancelled = cancelled
490
+ if paused is not None:
491
+ state.paused = paused
492
+
493
+ def _clear_control(self, task_id: str) -> None:
494
+ self._control_states.pop(task_id, None)
495
+
496
+ # ========================================================================
497
+ # Execution
498
+ # ========================================================================
499
+
500
+ def start_task_execution(self, task_id: str) -> asyncio.Task[None]:
501
+ """Start task execution in background."""
502
+ existing = self._executions.get(task_id)
503
+ if existing and not existing.done():
504
+ return existing
505
+
506
+ async def run_and_cleanup() -> None:
507
+ try:
508
+ await self._run_task_execution(task_id)
509
+ finally:
510
+ self._executions.pop(task_id, None)
511
+
512
+ task = asyncio.create_task(run_and_cleanup())
513
+ self._executions[task_id] = task
514
+ return task
515
+
516
+ async def detect_and_pause_interrupted_tasks(self) -> list[OrchestratedTask]:
517
+ """Pause tasks that were left running after a restart."""
518
+ running = await self.get_tasks_by_status(TaskStatus.RUNNING)
519
+ candidates = [t for t in running if t.metadata.user_status != TaskUserStatus.DONE]
520
+
521
+ paused: list[OrchestratedTask] = []
522
+ for t in candidates:
523
+ if not t.id:
524
+ continue
525
+ await self.pause_task(t.id)
526
+ await self.append_output(t.id, "Paused due to restart.")
527
+ updated = await self.get_task(t.id)
528
+ if updated:
529
+ paused.append(updated)
530
+ return paused
531
+
532
+ async def create_code_task(
533
+ self,
534
+ name: str,
535
+ description: str,
536
+ room_id: str | None = None,
537
+ sub_agent_type: str = "eliza",
538
+ ) -> OrchestratedTask:
539
+ """Compatibility alias for create_task."""
540
+ return await self.create_task(name, description, room_id, sub_agent_type)
541
+
542
+ async def _run_task_execution(self, task_id: str) -> None:
543
+ """Run task execution."""
544
+ try:
545
+ task = await self.get_task(task_id)
546
+ if task is None:
547
+ return
548
+
549
+ self._clear_control(task_id)
550
+ self._set_control(task_id, cancelled=False, paused=False)
551
+
552
+ provider = self._get_provider_by_id(task.metadata.provider_id)
553
+ if provider is None:
554
+ raise ValueError(f"Provider not found: {task.metadata.provider_id}")
555
+
556
+ await self.update_task_status(task_id, TaskStatus.RUNNING)
557
+ await self.append_output(
558
+ task_id, f"Starting: {task.name}\nProvider: {provider.label} ({provider.id})"
559
+ )
560
+
561
+ ctx = ProviderTaskExecutionContext(
562
+ runtime_agent_id=self.runtime.agent_id,
563
+ working_directory=task.metadata.working_directory,
564
+ append_output=lambda line: self.append_output(task_id, line),
565
+ update_progress=lambda p: self.update_task_progress(task_id, p),
566
+ update_step=lambda sid, status, out: self.update_step(task_id, sid, status, out),
567
+ is_cancelled=lambda: self.is_task_cancelled(task_id),
568
+ is_paused=lambda: self.is_task_paused(task_id),
569
+ room_id=task.room_id,
570
+ world_id=task.world_id,
571
+ )
572
+
573
+ result = await provider.execute_task(task, ctx)
574
+ await self.set_task_result(task_id, result)
575
+
576
+ except Exception as e:
577
+ await self.set_task_error(task_id, str(e))
578
+ finally:
579
+ self._clear_control(task_id)
580
+
581
+ # ========================================================================
582
+ # Events
583
+ # ========================================================================
584
+
585
+ def on(self, event: str, handler: Callable[[TaskEvent], None]) -> None:
586
+ """Register an event handler."""
587
+ if event not in self._event_handlers:
588
+ self._event_handlers[event] = []
589
+ self._event_handlers[event].append(handler)
590
+
591
+ def off(self, event: str, handler: Callable[[TaskEvent], None]) -> None:
592
+ """Remove an event handler."""
593
+ if event in self._event_handlers:
594
+ self._event_handlers[event] = [h for h in self._event_handlers[event] if h != handler]
595
+
596
+ def _emit(
597
+ self,
598
+ event_type: TaskEventType,
599
+ task_id: str,
600
+ data: dict[str, JsonValue] | None = None,
601
+ ) -> None:
602
+ event = TaskEvent(type=event_type, task_id=task_id, data=data)
603
+ for handler in self._event_handlers.get(event_type.value, []):
604
+ handler(event)
605
+ for handler in self._event_handlers.get("task", []):
606
+ handler(event)
607
+
608
+ # ========================================================================
609
+ # Context
610
+ # ========================================================================
611
+
612
+ async def get_task_context(self) -> str:
613
+ """Get task context for prompting."""
614
+ current = await self.get_current_task()
615
+ tasks = await self.get_recent_tasks(10)
616
+
617
+ if not tasks:
618
+ return "No tasks have been created yet."
619
+
620
+ lines: list[str] = []
621
+ active = current or (tasks[0] if tasks else None)
622
+
623
+ if active:
624
+ m = active.metadata
625
+ lines.append(f"## Current Task (selected): {active.name}")
626
+ lines.append(f"- **Execution status**: {m.status.value}")
627
+ lines.append(f"- **Progress**: {m.progress}%")
628
+ lines.append(f"- **Provider**: {m.provider_label or m.provider_id}")
629
+ lines.append("")
630
+
631
+ if active.description:
632
+ lines.append("### Description")
633
+ lines.append(active.description)
634
+ lines.append("")
635
+
636
+ if m.steps:
637
+ lines.append("### Plan / Steps")
638
+ for s in m.steps:
639
+ lines.append(f"- [{s.status.value}] {s.description}")
640
+ lines.append("")
641
+
642
+ if m.output:
643
+ lines.append("### Task Output (history)")
644
+ lines.append("```")
645
+ lines.extend(m.output[-200:])
646
+ lines.append("```")
647
+ lines.append("")
648
+
649
+ return "\n".join(lines).strip()