OpenSpace/openspace/agents/task_manager.py
2026-07-17 11:43:42 +08:00

1439 lines
51 KiB
Python

from __future__ import annotations
import asyncio
import inspect
import json
import os
import secrets
import tempfile
import time
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Awaitable, Callable, Mapping
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
class TaskStatus(str, Enum):
"""OpenSpace ``TaskStatus`` values from ``Task.ts``."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
KILLED = "killed"
class TaskType(str, Enum):
"""OpenSpace task types plus the OS coordinator-worker specialisation."""
LOCAL_BASH = "local_bash"
LOCAL_AGENT = "local_agent"
IN_PROCESS_TEAMMATE = "in_process_teammate"
DREAM = "dream"
COORDINATOR_WORKER = "coordinator_worker"
SESSION_MEMORY = "session_memory"
MEMORY_EXTRACT = "memory_extract"
TASK_ID_PREFIXES: dict[str, str] = {
TaskType.LOCAL_BASH.value: "b",
TaskType.LOCAL_AGENT.value: "a",
TaskType.IN_PROCESS_TEAMMATE.value: "t",
TaskType.DREAM.value: "d",
TaskType.COORDINATOR_WORKER.value: "c",
TaskType.SESSION_MEMORY.value: "s",
TaskType.MEMORY_EXTRACT.value: "e",
}
TASK_ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
DEFAULT_TASK_OUTPUT_DIR = Path(tempfile.gettempdir()) / "openspace-agent-tasks"
_REGISTERED_INTERNAL_OUTPUT_DIRS: set[str] = set()
def _register_internal_output_dir(output_dir: Path) -> None:
"""Allow read access to TaskManager-owned output files.
Background shell/agent output lives outside the workspace in normal
sessions. The directory may be under ``~/.openspace``, which is otherwise
treated as sensitive. This narrow internal carve-out keeps the model able
to inspect outputs it just created without opening broader config access.
"""
try:
root = output_dir.expanduser().resolve()
except OSError:
root = output_dir.expanduser().absolute()
key = str(root)
if key in _REGISTERED_INTERNAL_OUTPUT_DIRS:
return
_REGISTERED_INTERNAL_OUTPUT_DIRS.add(key)
try:
from openspace.grounding.core.permissions import register_internal_path_predicate
except Exception:
logger.debug("Could not register internal task output dir", exc_info=True)
return
def _is_task_output_path(path: str, *, _root: Path = root) -> bool:
try:
candidate = Path(path).expanduser().resolve()
except OSError:
candidate = Path(path).expanduser().absolute()
try:
return candidate.is_relative_to(_root) and candidate.is_file()
except AttributeError: # pragma: no cover - Python < 3.9 fallback
try:
candidate.relative_to(_root)
return candidate.is_file()
except ValueError:
return False
register_internal_path_predicate(
category="readable",
reason="TaskManager-owned background task output is readable",
predicate=_is_task_output_path,
)
def is_terminal_task_status(status: TaskStatus | str) -> bool:
status_value = status.value if isinstance(status, TaskStatus) else str(status)
return status_value in {
TaskStatus.COMPLETED.value,
TaskStatus.FAILED.value,
TaskStatus.KILLED.value,
}
def generate_task_id(task_type: TaskType | str) -> str:
"""Generate OpenSpace compact task IDs (prefix + 8 base36 chars)."""
task_type_value = str(getattr(task_type, "value", task_type))
prefix = TASK_ID_PREFIXES.get(task_type_value, "x")
return prefix + "".join(secrets.choice(TASK_ID_ALPHABET) for _ in range(8))
def get_task_output_path(task_id: str, output_dir: str | Path | None = None) -> Path:
"""Stable output path for a task ID.
OpenSpace stores this via ``utils/task/diskOutput.ts::getTaskOutputPath``.
OpenSpace keeps the same one-file-per-task contract but uses a portable
temp directory until the session store step gives tasks a persisted home.
"""
root = Path(output_dir) if output_dir is not None else DEFAULT_TASK_OUTPUT_DIR
return root / f"{task_id}.out"
@dataclass(slots=True)
class TaskStateBase:
id: str
type: TaskType
status: TaskStatus
description: str
tool_use_id: str | None = None
start_time: float = field(default_factory=lambda: time.time() * 1000)
end_time: float | None = None
total_paused_ms: float | None = None
output_file: str = ""
output_offset: int = 0
notified: bool = False
@dataclass(slots=True)
class ToolActivity:
tool_name: str
input: dict[str, Any]
activity_description: str | None = None
is_search: bool | None = None
is_read: bool | None = None
@dataclass(slots=True)
class AgentProgress:
tool_use_count: int = 0
token_count: int = 0
last_activity: ToolActivity | None = None
recent_activities: list[ToolActivity] = field(default_factory=list)
summary: str | None = None
@dataclass(slots=True)
class AgentTask(TaskStateBase):
"""Unified runtime state for local-agent and teammate tasks."""
agent_id: str = ""
prompt: str = ""
agent_type: str = "general-purpose"
model: str | None = None
selected_agent: Any | None = None
abort_event: asyncio.Event | None = None
asyncio_task: asyncio.Task[Any] | None = None
error: str | None = None
result: Any | None = None
progress: AgentProgress | None = None
retrieved: bool = False
messages: list[dict[str, Any]] = field(default_factory=list)
last_reported_tool_count: int = 0
last_reported_token_count: int = 0
is_backgrounded: bool = True
pending_messages: list[Any] = field(default_factory=list)
inbox: asyncio.Queue[Any] | None = None
retain: bool = False
disk_loaded: bool = False
evict_after: float | None = None
team_name: str | None = None
parent_task_id: str | None = None
parent_inbox: asyncio.Queue[Any] | None = None
shutdown_requested: bool = False
@dataclass(slots=True)
class TaskOutput:
task_id: str
task_type: str
status: str
description: str
output: str
output_tail: str | None = None
output_file: str | None = None
command: str | None = None
pid: int | None = None
interrupted: bool | None = None
exit_code: int | None = None
is_backgrounded: bool | None = None
kind: str | None = None
backgrounded_by_user: bool | None = None
assistant_auto_backgrounded: bool | None = None
duration_ms: float | None = None
error: str | None = None
prompt: str | None = None
result: str | None = None
@dataclass(slots=True)
class TaskOutputResult:
retrieval_status: str
task: TaskOutput | None
class StopTaskError(Exception):
def __init__(self, message: str, code: str) -> None:
super().__init__(message)
self.code = code
TaskRunner = Callable[[AgentTask], Awaitable[Any]]
EventSink = Callable[[str, dict[str, Any]], Awaitable[None] | None]
class TaskManager:
"""Unified lifecycle manager for background agent tasks.
OpenSpace spreads this behavior across ``Task.ts``, ``tasks.ts``,
``LocalAgentTask.tsx``, ``InProcessTeammateTask`` and
``utils/task/framework.ts``. OpenSpace keeps it backend-neutral so Agent,
teammate and future shell tasks share one registry.
"""
def __init__(
self,
*,
event_sink: EventSink | None = None,
output_dir: str | Path | None = None,
) -> None:
self._tasks: dict[str, Any] = {}
self._agent_name_registry: dict[str, str] = {}
self._event_sink = event_sink
self._output_dir = Path(output_dir) if output_dir is not None else DEFAULT_TASK_OUTPUT_DIR
self._output_dir.mkdir(parents=True, exist_ok=True)
_register_internal_output_dir(self._output_dir)
self._active_team_name: str | None = None
self._team_metadata: dict[str, Any] | None = None
@property
def output_dir(self) -> Path:
return self._output_dir
@property
def active_team_name(self) -> str | None:
return self._active_team_name
@property
def team_metadata(self) -> dict[str, Any] | None:
return dict(self._team_metadata) if self._team_metadata else None
def create_team(
self,
team_name: str,
*,
description: str = "",
agent_type: str = "team-lead",
lead_agent_id: str | None = None,
) -> dict[str, Any]:
"""Create a session-local team context for in-process teammates.
OpenSpace persists a team file and task-list directory. OpenSpace keeps the
runtime state in this session-scoped manager because 12.4 deliberately
chose in-process queues over mailbox files.
"""
if self._active_team_name:
raise ValueError(
f'Already leading team "{self._active_team_name}". '
"Use TeamDelete before creating a new team."
)
final_name = str(team_name).strip()
self._active_team_name = final_name
self._team_metadata = {
"team_name": final_name,
"description": description,
"lead_agent_id": lead_agent_id or f"team-lead@{final_name}",
"lead_agent_type": agent_type,
"created_at": time.time() * 1000,
}
self.register_alias("team-lead", self._team_metadata["lead_agent_id"])
return dict(self._team_metadata)
def delete_team(self) -> dict[str, Any]:
team_name = self._active_team_name
self._active_team_name = None
self._team_metadata = None
if team_name:
aliases_to_drop = [
alias
for alias, task_id in self._agent_name_registry.items()
if alias.endswith(f"@{team_name}") or task_id.endswith(f"@{team_name}")
]
for alias in aliases_to_drop:
self._agent_name_registry.pop(alias, None)
return {
"success": True,
"message": (
f'Cleaned up team "{team_name}"'
if team_name
else "No team name found, nothing to clean up"
),
"team_name": team_name,
}
def set_event_sink(self, sink: EventSink | None) -> None:
self._event_sink = sink
def create_task_state_base(
self,
task_id: str,
task_type: TaskType | str,
description: str,
*,
tool_use_id: str | None = None,
) -> TaskStateBase:
task_type_enum = _coerce_task_type(task_type)
return TaskStateBase(
id=task_id,
type=task_type_enum,
status=TaskStatus.PENDING,
description=description,
tool_use_id=tool_use_id,
output_file=str(get_task_output_path(task_id, self._output_dir)),
)
async def register_async_agent(
self,
*,
runner: TaskRunner,
prompt: str,
description: str,
agent_type: str = "general-purpose",
selected_agent: Any | None = None,
model: str | None = None,
task_type: TaskType | str = TaskType.LOCAL_AGENT,
team_name: str | None = None,
parent_task_id: str | None = None,
parent_abort_event: asyncio.Event | None = None,
parent_inbox: asyncio.Queue[Any] | None = None,
tool_use_id: str | None = None,
is_backgrounded: bool = True,
agent_id: str | None = None,
) -> AgentTask:
task_type_enum = _coerce_task_type(task_type)
task_id = agent_id or generate_task_id(task_type_enum)
base = self.create_task_state_base(
task_id,
task_type_enum,
description,
tool_use_id=tool_use_id,
)
task = AgentTask(
id=base.id,
type=base.type,
status=base.status,
description=base.description,
tool_use_id=base.tool_use_id,
start_time=base.start_time,
end_time=base.end_time,
total_paused_ms=base.total_paused_ms,
output_file=base.output_file,
output_offset=base.output_offset,
notified=base.notified,
agent_id=task_id,
prompt=prompt,
agent_type=agent_type,
model=model,
selected_agent=selected_agent,
abort_event=asyncio.Event(),
inbox=asyncio.Queue(),
team_name=team_name,
parent_task_id=parent_task_id,
parent_inbox=parent_inbox,
is_backgrounded=is_backgrounded,
)
self._register_task(task)
self._write_task_output(task, {"status": "running", **self._task_event_payload(task)})
task.asyncio_task = asyncio.create_task(
self._run_agent_task(task, runner, parent_abort_event=parent_abort_event)
)
task.status = TaskStatus.RUNNING
await self._emit_task_event("task_started", task)
return task
async def register_foreground_agent(
self,
*,
runner: TaskRunner,
prompt: str,
description: str,
agent_type: str = "general-purpose",
selected_agent: Any | None = None,
model: str | None = None,
parent_abort_event: asyncio.Event | None = None,
tool_use_id: str | None = None,
agent_id: str | None = None,
) -> AgentTask:
task = await self.register_async_agent(
runner=runner,
prompt=prompt,
description=description,
agent_type=agent_type,
selected_agent=selected_agent,
model=model,
task_type=TaskType.LOCAL_AGENT,
parent_abort_event=parent_abort_event,
tool_use_id=tool_use_id,
is_backgrounded=False,
agent_id=agent_id,
)
if task.asyncio_task is not None:
await task.asyncio_task
return task
async def register_external_task(
self,
*,
runner_task: asyncio.Task[Any],
description: str,
task_type: TaskType | str = TaskType.LOCAL_AGENT,
agent_type: str = "background",
agent_id: str | None = None,
model: str | None = None,
prompt: str = "",
parent_task_id: str | None = None,
tool_use_id: str | None = None,
is_backgrounded: bool = True,
) -> AgentTask:
"""Register an already-created asyncio task in the OpenSpace task registry.
Memory housekeeping tasks are scheduled by their own services so they
can preserve OpenSpace's fire-and-forget semantics. This method gives those
tasks the same TaskGet/TaskList/TaskStop and TUI lifecycle surface as
regular background agents.
"""
task_type_enum = _coerce_task_type(task_type)
task_id = agent_id or generate_task_id(task_type_enum)
base = self.create_task_state_base(
task_id,
task_type_enum,
description,
tool_use_id=tool_use_id,
)
task = AgentTask(
id=base.id,
type=base.type,
status=TaskStatus.RUNNING,
description=base.description,
tool_use_id=base.tool_use_id,
start_time=base.start_time,
end_time=base.end_time,
total_paused_ms=base.total_paused_ms,
output_file=base.output_file,
output_offset=base.output_offset,
notified=base.notified,
agent_id=task_id,
prompt=prompt,
agent_type=agent_type,
model=model,
asyncio_task=runner_task,
is_backgrounded=is_backgrounded,
parent_task_id=parent_task_id,
)
self._register_task(task)
self._write_task_output(task, {"status": "running", **self._task_event_payload(task)})
await self._emit_task_event("task_started", task)
def _done(done: asyncio.Task[Any]) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(self._finalize_external_task(task, done))
runner_task.add_done_callback(_done)
return task
async def spawn_local_shell_task(
self,
*,
command: str,
description: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
conda_env: str | None = None,
argv: list[str] | None = None,
cleanup_callbacks: list[Any] | None = None,
output_transform: Any | None = None,
tool_use_id: str | None = None,
agent_id: str | None = None,
is_backgrounded: bool = True,
backgrounded_by_user: bool = False,
assistant_auto_backgrounded: bool = False,
notification_queue: asyncio.Queue[Any] | None = None,
kind: str = "bash",
) -> Any:
"""Spawn and register a OpenSpace ``local_bash`` task.
Unlike ``register_external_task``, this owns the subprocess handle so
TaskStop can kill the process group and the output file remains the raw
merged shell stream.
"""
from openspace.grounding.backends.shell.shell_command_handle import (
BackgroundShellHandle,
)
task_id = generate_task_id(TaskType.LOCAL_BASH)
output_path = get_task_output_path(task_id, self._output_dir)
shell_command = await BackgroundShellHandle.spawn(
command,
task_id=task_id,
output_path=output_path,
cwd=cwd,
env=env,
conda_env=conda_env,
argv=argv,
cleanup_callbacks=cleanup_callbacks,
output_transform=output_transform,
)
return await self.register_local_shell_task(
command=command,
description=description,
shell_command=shell_command,
task_id=task_id,
tool_use_id=tool_use_id,
agent_id=agent_id,
is_backgrounded=is_backgrounded,
backgrounded_by_user=backgrounded_by_user,
assistant_auto_backgrounded=assistant_auto_backgrounded,
notification_queue=notification_queue,
kind=kind,
finalize_on_completion=True,
)
async def register_local_shell_task(
self,
*,
command: str,
description: str,
shell_command: Any,
task_id: str | None = None,
tool_use_id: str | None = None,
agent_id: str | None = None,
is_backgrounded: bool = True,
backgrounded_by_user: bool = False,
assistant_auto_backgrounded: bool = False,
notification_queue: asyncio.Queue[Any] | None = None,
kind: str = "bash",
finalize_on_completion: bool = True,
) -> Any:
"""Register an already-started local shell command.
24.2 needs this because foreground commands start immediately but only
become TaskManager-visible after the OpenSpace 2s progress threshold. If the
command is later backgrounded, the same process handle is flipped in
place rather than respawned.
"""
from openspace.agents.shell_task import LocalShellTaskState
final_task_id = task_id or generate_task_id(TaskType.LOCAL_BASH)
if is_backgrounded:
shell_command.background(final_task_id)
output_path = getattr(shell_command, "output_path", None)
if output_path is None:
output_path = get_task_output_path(final_task_id, self._output_dir)
base = self.create_task_state_base(
final_task_id,
TaskType.LOCAL_BASH,
description,
tool_use_id=tool_use_id,
)
task = LocalShellTaskState(
id=base.id,
type=base.type,
status=TaskStatus.RUNNING,
description=base.description,
tool_use_id=base.tool_use_id,
start_time=base.start_time,
end_time=base.end_time,
total_paused_ms=base.total_paused_ms,
output_file=str(output_path),
output_offset=base.output_offset,
notified=base.notified,
command=command,
shell_command=shell_command,
is_backgrounded=is_backgrounded,
agent_id=agent_id,
kind="monitor" if kind == "monitor" else "bash",
pid=shell_command.pid,
backgrounded_by_user=backgrounded_by_user,
assistant_auto_backgrounded=assistant_auto_backgrounded,
notification_queue=notification_queue,
)
self._register_task(task)
await self._emit_task_event("task_started", task)
if is_backgrounded:
self._ensure_shell_stall_watchdog(task)
if finalize_on_completion:
self._ensure_shell_finalizer(task)
return task
def _ensure_shell_finalizer(self, task: Any) -> None:
if getattr(task, "finalizer_task", None) is not None:
return
task.finalizer_task = asyncio.create_task(self._finalize_shell_task(task))
async def background_existing_foreground_shell_task(
self,
task_id: str,
*,
backgrounded_by_user: bool = False,
assistant_auto_backgrounded: bool = False,
) -> bool:
task = self._tasks.get(task_id)
if task is None or task.type != TaskType.LOCAL_BASH:
return False
if task.status != TaskStatus.RUNNING:
return False
shell_command = getattr(task, "shell_command", None)
if shell_command is None:
return False
shell_command.background(task.id)
task.is_backgrounded = True
task.backgrounded_by_user = bool(
task.backgrounded_by_user or backgrounded_by_user
)
task.assistant_auto_backgrounded = bool(
task.assistant_auto_backgrounded or assistant_auto_backgrounded
)
self._ensure_shell_stall_watchdog(task)
self._ensure_shell_finalizer(task)
await self._emit_task_event("agent_task_update", task)
return True
async def background_all_foreground_tasks(self) -> list[str]:
backgrounded: list[str] = []
for task_id, task in list(self._tasks.items()):
if (
task.type == TaskType.LOCAL_BASH
and task.status == TaskStatus.RUNNING
and getattr(task, "is_backgrounded", True) is False
):
did_background = await self.background_existing_foreground_shell_task(
task_id,
backgrounded_by_user=True,
)
if did_background:
backgrounded.append(task_id)
return backgrounded
def mark_task_notified(self, task_id: str) -> None:
task = self._tasks.get(task_id)
if task is not None:
task.notified = True
async def unregister_foreground_shell_task(
self,
task_id: str,
*,
result: Any | None = None,
) -> bool:
task = self._tasks.get(task_id)
if task is None or task.type != TaskType.LOCAL_BASH:
return False
if getattr(task, "is_backgrounded", True):
return False
if result is not None:
task.result = result
task.status = (
TaskStatus.COMPLETED
if getattr(result, "code", 1) == 0
else TaskStatus.FAILED
)
else:
task.status = TaskStatus.COMPLETED
task.end_time = time.time() * 1000
task.notified = True
await self._emit_task_event(
"task_completed"
if task.status == TaskStatus.COMPLETED
else "task_failed",
task,
)
self._tasks.pop(task_id, None)
return True
def _ensure_shell_stall_watchdog(self, task: Any) -> None:
if getattr(task, "kind", "bash") == "monitor":
return
if getattr(task, "stall_watchdog_task", None) is not None:
return
task.stall_watchdog_task = asyncio.create_task(
self._watch_shell_stall(task)
)
async def _watch_shell_stall(self, task: Any) -> None:
interval_ms = _env_int("OPENSPACE_SHELL_STALL_INTERVAL_MS", 5_000)
threshold_ms = _env_int("OPENSPACE_SHELL_STALL_THRESHOLD_MS", 45_000)
tail_bytes = _env_int("OPENSPACE_SHELL_STALL_TAIL_BYTES", 1024)
last_size = 0
last_growth = time.time() * 1000
try:
while task.status == TaskStatus.RUNNING and getattr(
task, "is_backgrounded", False
):
await asyncio.sleep(max(0.01, interval_ms / 1000))
try:
size = Path(task.output_file).stat().st_size
except OSError:
continue
if size > last_size:
last_size = size
last_growth = time.time() * 1000
continue
if time.time() * 1000 - last_growth < threshold_ms:
continue
tail = _tail_text_file(task.output_file, tail_bytes)
from openspace.agents.shell_task import (
build_shell_stall_notification_xml,
looks_like_prompt,
)
if not looks_like_prompt(tail):
last_growth = time.time() * 1000
continue
queue = getattr(task, "notification_queue", None)
if queue is not None:
queue.put_nowait(
{
"type": "notification",
"content": build_shell_stall_notification_xml(task, tail),
}
)
return
except asyncio.CancelledError:
raise
except Exception:
logger.debug("Shell stall watchdog failed for %s", task.id, exc_info=True)
def _register_task(self, task: Any) -> None:
self._tasks[task.id] = task
agent_type = getattr(task, "agent_type", None)
if agent_type:
self._agent_name_registry[agent_type] = task.id
team_name = getattr(task, "team_name", None)
if agent_type and team_name:
self._agent_name_registry[f"{agent_type}@{team_name}"] = task.id
def register_alias(self, alias: str, task_id: str) -> None:
"""Register a lookup alias for SendMessage-style routing.
OpenSpace teammates can be addressed by human name, agent id, or
``name@team``. Task IDs stay canonical; aliases only point at existing
tasks and are intentionally session-local through the owning
``TaskManager``.
"""
if alias and (task_id in self._tasks or str(alias) == "team-lead"):
self._agent_name_registry[str(alias)] = task_id
async def stop_task(self, task_id: str) -> bool:
try:
await self.stop_task_or_raise(task_id)
return True
except StopTaskError:
return False
async def stop_task_or_raise(
self,
task_id: str,
*,
signal_name: str = "TERM",
) -> dict[str, Any]:
task = self._tasks.get(task_id)
if task is None:
raise StopTaskError(f"No task found with ID: {task_id}", "not_found")
if task.status != TaskStatus.RUNNING:
raise StopTaskError(
f"Task {task_id} is not running (status: {task.status.value})",
"not_running",
)
if task.type == TaskType.LOCAL_BASH:
shell_command = getattr(task, "shell_command", None)
if shell_command is not None:
await shell_command.kill(signal_name)
await shell_command.cleanup()
watchdog = getattr(task, "stall_watchdog_task", None)
if watchdog is not None and not watchdog.done():
watchdog.cancel()
task.status = TaskStatus.KILLED
task.end_time = time.time() * 1000
task.notified = True
task.shell_command = None
await self._emit_task_event("task_stopped", task)
return {
"task_id": task.id,
"task_type": task.type.value,
"command": getattr(task, "command", task.description),
}
task.abort_event.set() if task.abort_event is not None else None
if task.asyncio_task is not None and not task.asyncio_task.done():
task.asyncio_task.cancel()
try:
await asyncio.wait_for(asyncio.shield(task.asyncio_task), timeout=5.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
task.status = TaskStatus.KILLED
task.end_time = time.time() * 1000
task.notified = True
self._write_task_output(task, self._serialize_task(task))
if task.asyncio_task is None:
await self._emit_task_event("task_stopped", task)
return {
"task_id": task.id,
"task_type": task.type.value,
"command": task.description,
}
async def stop_all(self) -> int:
count = 0
for task_id, task in list(self._tasks.items()):
if task.status == TaskStatus.RUNNING and await self.stop_task(task_id):
count += 1
return count
def get_task(self, task_id: str) -> Any | None:
return self._tasks.get(task_id)
def find_by_name(self, agent_name: str) -> AgentTask | None:
task_id = self._agent_name_registry.get(agent_name)
return self._tasks.get(task_id) if task_id else None
def find_by_agent_id(self, agent_id: str) -> AgentTask | None:
return self._tasks.get(agent_id)
def list_running(self) -> list[Any]:
return [task for task in self._tasks.values() if task.status == TaskStatus.RUNNING]
def list_all(self) -> list[Any]:
return list(self._tasks.values())
def list_by_team(self, team_name: str) -> list[AgentTask]:
return [
task
for task in self._tasks.values()
if getattr(task, "team_name", None) == team_name
]
async def send_message(self, to_agent: str, message: Any) -> bool:
task = self.find_by_name(to_agent) or self.find_by_agent_id(to_agent)
if task is None or is_terminal_task_status(task.status):
return False
inbox = getattr(task, "inbox", None)
if inbox is not None:
await inbox.put(message)
else:
pending_messages = getattr(task, "pending_messages", None)
if isinstance(pending_messages, list):
pending_messages.append(message)
else:
return False
return True
async def broadcast_message(
self,
message: Any,
*,
team_name: str | None = None,
exclude_agent: str | None = None,
) -> int:
count = 0
targets = self.list_by_team(team_name) if team_name else self.list_running()
for task in targets:
if exclude_agent and getattr(task, "agent_type", None) == exclude_agent:
continue
if await self.send_message(task.id, message):
count += 1
return count
def enqueue_notification(self, task: AgentTask | str, notification: str) -> bool:
target = self.get_task(task) if isinstance(task, str) else task
if target is None or is_terminal_task_status(target.status):
return False
xml_notification = (
"<task-notification>\n"
f"{notification}\n"
"</task-notification>"
)
pending_messages = getattr(target, "pending_messages", None)
if isinstance(pending_messages, list):
pending_messages.append({"type": "notification", "content": xml_notification})
inbox = getattr(target, "inbox", None)
if inbox is not None:
try:
inbox.put_nowait({"type": "notification", "content": xml_notification})
except asyncio.QueueFull:
return False
return True
async def wait_for_task_completion(
self,
task_id: str,
*,
timeout_ms: int = 30_000,
abort_event: asyncio.Event | None = None,
) -> AgentTask | None:
start = time.time() * 1000
while time.time() * 1000 - start < timeout_ms:
if abort_event is not None and abort_event.is_set():
raise asyncio.CancelledError()
task = self.get_task(task_id)
if task is None:
return None
if is_terminal_task_status(task.status):
return task
await asyncio.sleep(0.1)
return self.get_task(task_id)
async def get_task_output(
self,
task_id: str,
*,
block: bool = True,
timeout_ms: int = 30_000,
abort_event: asyncio.Event | None = None,
) -> TaskOutputResult:
task = self.get_task(task_id)
if task is None:
raise KeyError(f"No task found with ID: {task_id}")
if not block:
status = "success" if is_terminal_task_status(task.status) else "not_ready"
if status == "success":
task.notified = True
return TaskOutputResult(status, self._task_output_data(task))
completed = await self.wait_for_task_completion(
task_id,
timeout_ms=timeout_ms,
abort_event=abort_event,
)
if completed is None:
return TaskOutputResult("timeout", None)
if not is_terminal_task_status(completed.status):
return TaskOutputResult("timeout", self._task_output_data(completed))
completed.notified = True
return TaskOutputResult("success", self._task_output_data(completed))
async def _run_agent_task(
self,
task: AgentTask,
runner: TaskRunner,
*,
parent_abort_event: asyncio.Event | None = None,
) -> None:
parent_watcher: asyncio.Task[Any] | None = None
if parent_abort_event is not None and task.abort_event is not None:
parent_watcher = asyncio.create_task(
self._mirror_parent_abort(parent_abort_event, task.abort_event)
)
try:
result = await runner(task)
task.result = result
result_status = str(_mapping_get(result, "status", "completed"))
task.status = (
TaskStatus.COMPLETED
if result_status in {"completed", "success"}
else TaskStatus.FAILED
)
except asyncio.CancelledError:
task.status = TaskStatus.KILLED
task.error = "Task was stopped."
except Exception as exc:
task.status = TaskStatus.FAILED
task.error = str(exc)
logger.debug("Agent task %s failed", task.id, exc_info=True)
finally:
if parent_watcher is not None:
parent_watcher.cancel()
task.end_time = time.time() * 1000
self._write_task_output(task, self._serialize_task(task))
event = (
"task_completed"
if task.status == TaskStatus.COMPLETED
else "task_failed"
if task.status == TaskStatus.FAILED
else "task_stopped"
)
await self._emit_task_event(event, task)
if task.parent_task_id:
parent = self.get_task(task.parent_task_id)
if parent is not None and parent.status == TaskStatus.RUNNING:
self.enqueue_notification(parent, _build_task_notification_body(task))
if task.parent_inbox is not None:
await task.parent_inbox.put(
{
"type": "notification",
"content": _build_task_notification_xml(task),
}
)
async def _finalize_external_task(
self,
task: AgentTask,
done: asyncio.Task[Any],
) -> None:
try:
result = done.result()
task.result = result
result_status = str(_mapping_get(result, "status", "") or "").lower()
result_error = _mapping_get(result, "error", None)
if task.status == TaskStatus.KILLED:
pass
elif result_error:
task.status = TaskStatus.FAILED
task.error = str(result_error)
elif result_status in {"failed", "error"}:
task.status = TaskStatus.FAILED
elif result_status in {"killed", "cancelled", "canceled"}:
task.status = TaskStatus.KILLED
else:
task.status = TaskStatus.COMPLETED
except asyncio.CancelledError:
task.status = TaskStatus.KILLED
task.error = task.error or "Task was stopped."
except Exception as exc:
task.status = TaskStatus.FAILED
task.error = str(exc)
logger.debug("External task %s failed", task.id, exc_info=True)
finally:
task.end_time = time.time() * 1000
self._write_task_output(task, self._serialize_task(task))
event = (
"task_completed"
if task.status == TaskStatus.COMPLETED
else "task_failed"
if task.status == TaskStatus.FAILED
else "task_stopped"
)
await self._emit_task_event(event, task)
async def _finalize_shell_task(self, task: Any) -> None:
shell_command = getattr(task, "shell_command", None)
try:
if shell_command is None:
return
result = await shell_command.result
if task.status == TaskStatus.KILLED:
return
task.result = result
task.status = TaskStatus.COMPLETED if result.code == 0 else TaskStatus.FAILED
except asyncio.CancelledError:
task.status = TaskStatus.KILLED
except Exception as exc:
task.status = TaskStatus.FAILED
task.result = None
logger.debug("Local shell task %s failed", task.id, exc_info=True)
try:
with open(task.output_file, "ab") as output_file:
output_file.write(f"\n[openspace] shell task failed: {exc}\n".encode())
except Exception:
pass
finally:
task.end_time = time.time() * 1000
if shell_command is not None:
try:
await shell_command.cleanup()
except Exception:
pass
watchdog = getattr(task, "stall_watchdog_task", None)
if watchdog is not None and not watchdog.done():
watchdog.cancel()
task.shell_command = None
if task.status == TaskStatus.KILLED and task.notified:
return
if not task.notified:
from openspace.agents.shell_task import build_shell_notification_xml
task.notified = True
queue = getattr(task, "notification_queue", None)
if queue is not None:
try:
queue.put_nowait(
{
"type": "notification",
"content": build_shell_notification_xml(task),
}
)
except asyncio.QueueFull:
task.notified = False
event = (
"task_completed"
if task.status == TaskStatus.COMPLETED
else "task_failed"
if task.status == TaskStatus.FAILED
else "task_stopped"
)
await self._emit_task_event(event, task)
async def _mirror_parent_abort(
self,
parent: asyncio.Event,
child: asyncio.Event,
) -> None:
await parent.wait()
child.set()
def _task_output_data(self, task: Any) -> TaskOutput:
if task.type == TaskType.LOCAL_BASH:
is_terminal = is_terminal_task_status(task.status)
output_tail = _tail_text_file(task.output_file, 8192)
output = _read_text_file(task.output_file) if is_terminal else output_tail
result = getattr(task, "result", None)
return TaskOutput(
task_id=task.id,
task_type=task.type.value,
status=task.status.value,
description=task.description,
output=output,
output_tail=output_tail,
output_file=task.output_file,
command=task.command,
pid=task.pid,
interrupted=getattr(result, "interrupted", None),
exit_code=getattr(result, "code", None),
is_backgrounded=task.is_backgrounded,
kind=task.kind,
backgrounded_by_user=task.backgrounded_by_user,
assistant_auto_backgrounded=task.assistant_auto_backgrounded,
duration_ms=(
(task.end_time or time.time() * 1000) - task.start_time
if task.start_time
else None
),
error=None if task.status != TaskStatus.FAILED else "Shell command failed",
result=output,
)
output = _read_text_file(task.output_file)
result_text = _extract_result_text(task.result)
if result_text:
output = result_text
return TaskOutput(
task_id=task.id,
task_type=task.type.value,
status=task.status.value,
description=task.description,
output=output,
error=task.error,
prompt=task.prompt,
result=result_text or output,
)
def _serialize_task(self, task: Any) -> dict[str, Any]:
payload = self._task_event_payload(task)
if task.type == TaskType.LOCAL_BASH:
result = getattr(task, "result", None)
payload.update(
{
"status": task.status.value,
"command": task.command,
"exit_code": getattr(result, "code", None),
"interrupted": getattr(result, "interrupted", None),
}
)
return payload
payload.update(
{
"status": task.status.value,
"prompt": task.prompt,
"error": task.error,
"result": _json_safe_result(task.result),
}
)
return payload
def _task_event_payload(self, task: Any) -> dict[str, Any]:
if task.type == TaskType.LOCAL_BASH:
result = getattr(task, "result", None)
return {
"task_id": task.id,
"agent_id": task.agent_id,
"description": task.description,
"command": task.command,
"task_type": task.type.value,
"output_file": task.output_file,
"output_tail": _tail_text_file(task.output_file, 2048),
"is_backgrounded": task.is_backgrounded,
"backgrounded_by_user": task.backgrounded_by_user,
"assistant_auto_backgrounded": task.assistant_auto_backgrounded,
"kind": task.kind,
"start_time": task.start_time,
"end_time": task.end_time,
"elapsed_ms": (
(task.end_time or time.time() * 1000) - task.start_time
if task.start_time
else None
),
"current_operation": task.description or task.command,
"pid": task.pid,
"exit_code": getattr(result, "code", None),
"interrupted": getattr(result, "interrupted", None),
"progress": {},
}
progress = _progress_payload(task.progress)
return {
"task_id": task.id,
"agent_id": task.agent_id,
"agent_type": task.agent_type,
"description": task.description,
"task_type": task.type.value,
"team_name": task.team_name,
"parent_task_id": task.parent_task_id,
"output_file": task.output_file,
"model": task.model,
"is_backgrounded": task.is_backgrounded,
"start_time": task.start_time,
"end_time": task.end_time,
"elapsed_ms": (
(task.end_time or time.time() * 1000) - task.start_time
if task.start_time
else None
),
"current_operation": progress.get("current_operation") or task.description,
"progress": progress,
}
def _write_task_output(self, task: AgentTask, payload: Mapping[str, Any]) -> None:
path = Path(task.output_file)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
async def _emit_task_event(self, event_type: str, task: Any) -> None:
if self._event_sink is None:
return
data = {"status": task.status.value, **self._task_event_payload(task)}
try:
result = self._event_sink(event_type, data)
if inspect.isawaitable(result):
await result
agent_result = self._event_sink(
"agent_event",
{
"agent_id": getattr(task, "agent_id", None) or task.id,
"event": "agent_task_update",
"payload": data,
},
)
if inspect.isawaitable(agent_result):
await agent_result
update_result = self._event_sink("agent_task_update", data)
if inspect.isawaitable(update_result):
await update_result
except Exception:
logger.debug("Task event sink failed for %s", event_type, exc_info=True)
def _coerce_task_type(value: TaskType | str) -> TaskType:
if isinstance(value, TaskType):
return value
raw = str(value)
try:
return TaskType(raw)
except ValueError:
return TaskType.LOCAL_AGENT
def _mapping_get(value: Any, key: str, default: Any = None) -> Any:
if isinstance(value, Mapping):
return value.get(key, default)
return getattr(value, key, default)
def _json_safe_result(result: Any) -> Any:
if result is None:
return None
if isinstance(result, Mapping):
return dict(result)
if hasattr(result, "__dataclass_fields__"):
return {
field_name: getattr(result, field_name)
for field_name in getattr(result, "__dataclass_fields__", {})
}
if hasattr(result, "__dict__"):
return dict(getattr(result, "__dict__"))
return str(result)
def _progress_payload(progress: AgentProgress | None) -> dict[str, Any]:
if progress is None:
return {}
last_activity = progress.last_activity
recent = [_activity_payload(activity) for activity in progress.recent_activities]
current_operation = (
last_activity.activity_description
if last_activity and last_activity.activity_description
else last_activity.tool_name
if last_activity
else progress.summary
)
return {
"tool_use_count": progress.tool_use_count,
"token_count": progress.token_count,
"summary": progress.summary,
"current_operation": current_operation,
"last_activity": _activity_payload(last_activity) if last_activity else None,
"recent_activities": recent,
}
def _activity_payload(activity: ToolActivity | None) -> dict[str, Any] | None:
if activity is None:
return None
return {
"tool_name": activity.tool_name,
"input": dict(activity.input),
"activity_description": activity.activity_description,
"is_search": activity.is_search,
"is_read": activity.is_read,
}
def _extract_result_text(result: Any) -> str:
if result is None:
return ""
content = _mapping_get(result, "content", None)
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, Mapping):
text = block.get("text")
if isinstance(text, str):
parts.append(text)
elif isinstance(block, str):
parts.append(block)
return "\n".join(part for part in parts if part)
return ""
def _read_text_file(path: str) -> str:
try:
return Path(path).read_text(encoding="utf-8")
except FileNotFoundError:
return ""
except UnicodeDecodeError:
return Path(path).read_text(encoding="utf-8", errors="replace")
def _tail_text_file(path: str, max_bytes: int) -> str:
try:
with open(path, "rb") as handle:
handle.seek(0, os.SEEK_END)
size = handle.tell()
handle.seek(max(0, size - max_bytes), os.SEEK_SET)
return handle.read(max_bytes).decode("utf-8", errors="replace")
except OSError:
return ""
def _env_int(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None:
return default
try:
return max(1, int(raw))
except ValueError:
return default
def _build_task_notification_body(task: AgentTask) -> str:
output = _extract_result_text(task.result) or task.error or _read_text_file(task.output_file)
status = task.status.value
if task.status == TaskStatus.COMPLETED:
summary = f'Agent "{task.description}" completed'
elif task.status == TaskStatus.FAILED:
summary = f'Agent "{task.description}" failed: {task.error or "unknown error"}'
elif task.status == TaskStatus.KILLED:
summary = f'Agent "{task.description}" was stopped'
else:
summary = f'Agent "{task.description}" {status}'
parts = [
f"<task-id>{task.agent_id or task.id}</task-id>",
f"<status>{status}</status>",
f"<summary>{summary}</summary>",
]
if output:
parts.append(f"<result>{str(output).strip()}</result>")
return "\n".join(parts)
def _build_task_notification_xml(task: AgentTask) -> str:
return (
"<task-notification>\n"
f"{_build_task_notification_body(task)}\n"
"</task-notification>"
)
__all__ = [
"AgentProgress",
"AgentTask",
"TaskManager",
"TaskOutput",
"TaskOutputResult",
"TaskStateBase",
"TaskStatus",
"TaskType",
"ToolActivity",
"StopTaskError",
"generate_task_id",
"get_task_output_path",
"is_terminal_task_status",
]