@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.
- package/LICENSE +21 -0
- package/README.md +248 -0
- package/package.json +83 -0
- package/python/README.md +95 -0
- package/python/dist/elizaos_plugin_agent_orchestrator-2.0.0-py3-none-any.whl +0 -0
- package/python/dist/elizaos_plugin_agent_orchestrator-2.0.0.tar.gz +0 -0
- package/python/elizaos_plugin_agent_orchestrator/__init__.py +84 -0
- package/python/elizaos_plugin_agent_orchestrator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/elizaos_plugin_agent_orchestrator/__pycache__/config.cpython-313.pyc +0 -0
- package/python/elizaos_plugin_agent_orchestrator/__pycache__/service.cpython-313.pyc +0 -0
- package/python/elizaos_plugin_agent_orchestrator/__pycache__/types.cpython-313.pyc +0 -0
- package/python/elizaos_plugin_agent_orchestrator/actions/__init__.py +23 -0
- package/python/elizaos_plugin_agent_orchestrator/actions/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/elizaos_plugin_agent_orchestrator/actions/__pycache__/task_management.cpython-313.pyc +0 -0
- package/python/elizaos_plugin_agent_orchestrator/actions/task_management.py +404 -0
- package/python/elizaos_plugin_agent_orchestrator/config.py +28 -0
- package/python/elizaos_plugin_agent_orchestrator/providers/__init__.py +7 -0
- package/python/elizaos_plugin_agent_orchestrator/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/elizaos_plugin_agent_orchestrator/providers/__pycache__/task_context.cpython-313.pyc +0 -0
- package/python/elizaos_plugin_agent_orchestrator/providers/task_context.py +58 -0
- package/python/elizaos_plugin_agent_orchestrator/py.typed +0 -0
- package/python/elizaos_plugin_agent_orchestrator/service.py +649 -0
- package/python/elizaos_plugin_agent_orchestrator/types.py +309 -0
- package/python/elizaos_plugin_agent_orchestrator.egg-info/PKG-INFO +119 -0
- package/python/elizaos_plugin_agent_orchestrator.egg-info/SOURCES.txt +17 -0
- package/python/elizaos_plugin_agent_orchestrator.egg-info/dependency_links.txt +1 -0
- package/python/elizaos_plugin_agent_orchestrator.egg-info/requires.txt +5 -0
- package/python/elizaos_plugin_agent_orchestrator.egg-info/top_level.txt +1 -0
- package/python/elizaos_plugin_discord/generated/specs/__init__.py +1 -0
- package/python/elizaos_plugin_discord/generated/specs/specs.py +77 -0
- package/python/pyproject.toml +56 -0
- package/python/tests/__init__.py +1 -0
- package/python/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc +0 -0
- package/python/tests/__pycache__/test_service.cpython-313-pytest-9.0.2.pyc +0 -0
- package/python/tests/conftest.py +130 -0
- package/python/tests/test_service.py +140 -0
- package/rust/Cargo.toml +33 -0
- package/rust/README.md +112 -0
- package/rust/src/actions/mod.rs +173 -0
- package/rust/src/config.rs +111 -0
- package/rust/src/error.rs +30 -0
- package/rust/src/generated/specs/mod.rs +3 -0
- package/rust/src/generated/specs/specs.rs +27 -0
- package/rust/src/lib.rs +48 -0
- package/rust/src/providers/mod.rs +5 -0
- package/rust/src/providers/task_context.rs +50 -0
- package/rust/src/service.rs +771 -0
- package/rust/src/types.rs +275 -0
- package/typescript/dist/index.d.ts +9 -0
- package/typescript/dist/index.d.ts.map +1 -0
- package/typescript/dist/index.js +817 -0
- package/typescript/dist/index.js.map +18 -0
- package/typescript/dist/src/actions/task-management.d.ts +9 -0
- package/typescript/dist/src/actions/task-management.d.ts.map +1 -0
- package/typescript/dist/src/config.d.ts +4 -0
- package/typescript/dist/src/config.d.ts.map +1 -0
- package/typescript/dist/src/providers/task-context.d.ts +3 -0
- package/typescript/dist/src/providers/task-context.d.ts.map +1 -0
- package/typescript/dist/src/services/agent-orchestrator-service.d.ts +59 -0
- package/typescript/dist/src/services/agent-orchestrator-service.d.ts.map +1 -0
- package/typescript/dist/src/types.d.ts +113 -0
- 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()
|