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

369 lines
13 KiB
Python

"""Task control tools for OpenSpace background agents.
Task get/list/stop names map to the TaskManager runtime because session
checklist writes are handled by ``TodoWriteTool`` until a write-capable
task-list tool family exists.
"""
from __future__ import annotations
import json
import time
from dataclasses import asdict, is_dataclass
from typing import Any
from openspace.agents.task_manager import (
StopTaskError,
TaskManager,
TaskOutput,
TaskOutputResult,
TaskStatus,
is_terminal_task_status,
)
from openspace.grounding.core.permissions import (
PermissionAsk,
deny_missing_permission_context,
)
from openspace.grounding.core.tool.base import BaseTool
from openspace.grounding.core.types import BackendType, ToolResult, ToolStatus
TASK_GET_TOOL_NAME = "TaskGet"
TASK_LIST_TOOL_NAME = "TaskList"
TASK_STOP_TOOL_NAME = "TaskStop"
class _TaskTool(BaseTool):
backend_type = BackendType.META
max_result_size_chars = 100_000
should_defer = True
def __init__(self) -> None:
self._current_context: Any | None = None
super().__init__(verbose=False, handle_errors=False)
def set_context(self, context: Any) -> None:
self._current_context = context
def _task_manager(self) -> TaskManager | None:
manager = getattr(self._current_context, "task_manager", None)
return manager if isinstance(manager, TaskManager) else None
async def _arun(self, **_: Any) -> ToolResult:
raise NotImplementedError
class TaskGetTool(_TaskTool):
"""Retrieve status and output for a background task."""
_name = TASK_GET_TOOL_NAME
_description = "Get a background task's status and output by task_id."
_is_read_only = True
_is_concurrency_safe = True
aliases = ["TaskOutput", "AgentOutputTool", "BashOutputTool"]
search_hint = "read output logs from background task"
parameter_descriptions = {
"task_id": "The ID of the background task to inspect.",
"block": "Whether to wait for task completion. Defaults to true.",
"timeout": "Maximum wait time in milliseconds when block=true.",
}
async def _arun(
self,
task_id: str,
block: bool = True,
timeout: int = 30_000,
) -> ToolResult:
manager = self._task_manager()
if manager is None:
return _error("TaskGet requires a session-scoped TaskManager.")
if not task_id:
return _error("Task ID is required.")
try:
timeout_ms = max(0, min(int(timeout), 600_000))
except (TypeError, ValueError):
timeout_ms = 30_000
try:
result = await manager.get_task_output(
task_id,
block=bool(block),
timeout_ms=timeout_ms,
abort_event=getattr(self._current_context, "abort_event", None),
)
except KeyError:
return _error(f"No task found with ID: {task_id}")
data = _task_output_result_to_dict(result)
return ToolResult(
status=ToolStatus.SUCCESS,
content=_format_task_get_result(result),
metadata={"tool": self.name, "task_result": data},
)
class TaskListTool(_TaskTool):
"""List background tasks in the current OpenSpace session."""
_name = TASK_LIST_TOOL_NAME
_description = "List background tasks for the current session."
_is_read_only = True
_is_concurrency_safe = True
search_hint = "list running background tasks"
parameter_descriptions = {
"status": "Optional status filter: pending, running, completed, failed, or killed.",
"task_type": "Optional task type filter, for example local_bash or local_agent.",
"include_bash": "Whether to include local_bash shell tasks. Defaults to true.",
"team_name": "Optional team name filter.",
"include_completed": "Whether to include terminal tasks. Defaults to true.",
}
async def _arun(
self,
status: str | None = None,
task_type: str | None = None,
include_bash: bool = True,
team_name: str | None = None,
include_completed: bool = True,
) -> ToolResult:
manager = self._task_manager()
if manager is None:
return _error("TaskList requires a session-scoped TaskManager.")
tasks = manager.list_all()
if status:
wanted = str(status)
tasks = [task for task in tasks if task.status.value == wanted]
if task_type:
wanted_type = str(task_type)
tasks = [task for task in tasks if task.type.value == wanted_type]
if not include_bash:
tasks = [task for task in tasks if task.type.value != "local_bash"]
if team_name:
tasks = [task for task in tasks if getattr(task, "team_name", None) == team_name]
if not include_completed:
tasks = [task for task in tasks if not is_terminal_task_status(task.status)]
payload = {
"tasks": [_task_summary(task) for task in tasks],
"total": len(tasks),
"running": sum(1 for task in tasks if task.status == TaskStatus.RUNNING),
}
lines = [
f"{item['task_id']} [{item['status']}] {item['description']}"
for item in payload["tasks"]
]
content = "\n".join(lines) if lines else "No background tasks found"
return ToolResult(
status=ToolStatus.SUCCESS,
content=content,
metadata={"tool": self.name, "task_list": payload},
)
class TaskStopTool(_TaskTool):
"""Stop a running background task."""
_name = TASK_STOP_TOOL_NAME
_description = "Stop a running background task by task_id."
_is_read_only = False
_is_concurrency_safe = True
search_hint = "stop kill background task"
parameter_descriptions = {
"task_id": "The ID of the background task to stop.",
"signal": "Signal to send for shell tasks: TERM or KILL. Defaults to TERM.",
}
async def check_permissions(
self,
input: dict[str, Any],
context: Any = None,
):
if getattr(context, "permission_context", None) is None:
return deny_missing_permission_context(self._name)
normalized = dict(input or {})
signal = str(normalized.get("signal") or "TERM").upper()
task_id = str(normalized.get("task_id") or "").strip()
target = f" {task_id}" if task_id else ""
return PermissionAsk(
message=f"Stop background task{target} with signal {signal}.",
updated_input=normalized,
)
async def _arun(
self,
task_id: str | None = None,
signal: str = "TERM",
) -> ToolResult:
manager = self._task_manager()
if manager is None:
return _error("TaskStop requires a session-scoped TaskManager.")
if not task_id:
return _error("Missing required parameter: task_id", code="missing_task_id")
normalized_signal = str(signal or "TERM").upper()
if normalized_signal not in {"TERM", "KILL"}:
return _error(
"Invalid signal. Use TERM or KILL.",
code="invalid_signal",
)
try:
result = await manager.stop_task_or_raise(
task_id,
signal_name=normalized_signal,
)
except StopTaskError as exc:
return _error(str(exc), code=exc.code)
payload = {
"message": f"Successfully stopped task: {result['task_id']} ({result.get('command')})",
"task_id": result["task_id"],
"task_type": result["task_type"],
"command": result.get("command"),
}
return ToolResult(
status=ToolStatus.SUCCESS,
content=json.dumps(payload, ensure_ascii=False),
metadata={"tool": self.name, "task_stop": payload},
)
def _task_summary(task: Any) -> dict[str, Any]:
data = {
"task_id": task.id,
"task_type": task.type.value,
"status": task.status.value,
"state": task.status.value,
"description": task.description,
"output_file": task.output_file,
"output_path": task.output_file,
"start_time": task.start_time,
"end_time": task.end_time,
"duration_ms": (
(task.end_time or time.time() * 1000) - task.start_time
if task.start_time
else None
),
}
if task.type.value == "local_bash":
result = getattr(task, "result", None)
data.update(
{
"agent_id": getattr(task, "agent_id", None),
"command": getattr(task, "command", ""),
"kind": getattr(task, "kind", "bash"),
"pid": getattr(task, "pid", None),
"exit_code": getattr(result, "code", None),
"interrupted": getattr(result, "interrupted", None),
"is_backgrounded": getattr(task, "is_backgrounded", True),
"backgrounded_by_user": getattr(task, "backgrounded_by_user", False),
"assistant_auto_backgrounded": getattr(
task, "assistant_auto_backgrounded", False
),
}
)
return data
data.update(
{
"agent_id": getattr(task, "agent_id", None),
"agent_type": getattr(task, "agent_type", None),
"team_name": getattr(task, "team_name", None),
"parent_task_id": getattr(task, "parent_task_id", None),
}
)
return data
def _task_output_result_to_dict(result: TaskOutputResult) -> dict[str, Any]:
return {
"retrieval_status": result.retrieval_status,
"task": _task_output_to_dict(result.task) if result.task else None,
}
def _task_output_to_dict(output: TaskOutput) -> dict[str, Any]:
if is_dataclass(output):
data = asdict(output)
else:
data = dict(getattr(output, "__dict__", {}))
if "exit_code" in data:
data["exitCode"] = data.pop("exit_code")
if data.get("output_file") is not None:
data["output_path"] = data["output_file"]
if data.get("status") is not None:
data["state"] = data["status"]
return data
def _format_task_get_result(result: TaskOutputResult) -> str:
parts = [f"<retrieval_status>{result.retrieval_status}</retrieval_status>"]
task = result.task
if task is None:
return "\n\n".join(parts)
parts.extend(
[
f"<task_id>{task.task_id}</task_id>",
f"<task_type>{task.task_type}</task_type>",
f"<status>{task.status}</status>",
f"<state>{task.status}</state>",
]
)
if task.command:
parts.append(f"<command>{task.command}</command>")
if task.output_file:
parts.append(f"<output_file>{task.output_file}</output_file>")
parts.append(f"<output_path>{task.output_file}</output_path>")
if task.pid is not None:
parts.append(f"<pid>{task.pid}</pid>")
if task.is_backgrounded is not None:
parts.append(
f"<is_backgrounded>{str(task.is_backgrounded).lower()}</is_backgrounded>"
)
if task.kind:
parts.append(f"<kind>{task.kind}</kind>")
if task.backgrounded_by_user is not None:
parts.append(
"<backgrounded_by_user>"
f"{str(task.backgrounded_by_user).lower()}"
"</backgrounded_by_user>"
)
if task.assistant_auto_backgrounded is not None:
parts.append(
"<assistant_auto_backgrounded>"
f"{str(task.assistant_auto_backgrounded).lower()}"
"</assistant_auto_backgrounded>"
)
if task.duration_ms is not None:
parts.append(f"<duration_ms>{int(task.duration_ms)}</duration_ms>")
if task.exit_code is not None:
parts.append(f"<exit_code>{task.exit_code}</exit_code>")
if task.interrupted is not None:
parts.append(f"<interrupted>{str(task.interrupted).lower()}</interrupted>")
if task.output_tail and task.output_tail.strip() and task.output_tail != task.output:
parts.append(f"<output_tail>\n{task.output_tail.rstrip()}\n</output_tail>")
if task.output and task.output.strip():
parts.append(f"<output>\n{task.output.rstrip()}\n</output>")
if task.error:
parts.append(f"<error>{task.error}</error>")
return "\n\n".join(parts)
def _error(message: str, *, code: str | None = None) -> ToolResult:
metadata: dict[str, Any] = {"tool": "task_tools"}
if code:
metadata["code"] = code
return ToolResult(
status=ToolStatus.ERROR,
content=f"Error: {message}",
error=message,
metadata=metadata,
)
__all__ = [
"TASK_GET_TOOL_NAME",
"TASK_LIST_TOOL_NAME",
"TASK_STOP_TOOL_NAME",
"TaskGetTool",
"TaskListTool",
"TaskStopTool",
]