"""Background-task management tool — list/check/wait/cancel detached jobs."""
from __future__ import annotations
import asyncio
from navi.tools._internal.base import (
Tool,
ToolContext,
ToolResult,
current_session_id,
)
_MAX_CHECK_OUTPUT = 2000
_MAX_WAIT_TIMEOUT = 120.0
def _sid(ctx: ToolContext | None) -> str | None:
return (ctx.session_id if ctx else None) or current_session_id.get()
class TasksTool(Tool):
name = "tasks"
description = (
"Manage background tasks started with background=true on long-running "
"tool calls (terminal, ssh_exec, peer ask, spawn_agent, code_exec). "
"list — show this session's tasks; "
"check — status + result + recent progress of one task; "
"wait — block until a task finishes (timeout cap 120s) and return its result; "
"cancel — stop a running task. "
"Prefer check + continue working over wait: wait blocks your turn."
)
parameters = {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "check", "wait", "cancel"],
"description": "What to do with background tasks.",
},
"task_id": {
"type": "string",
"description": "Task id (bt-...) — required for check/wait/cancel.",
},
"timeout": {
"type": "number",
"description": "Max seconds to wait in the 'wait' action (default 120).",
},
},
"required": ["action"],
}
async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
from navi.core.tasks import get_task_manager
action = params.get("action")
manager = get_task_manager()
session_id = _sid(ctx)
if action == "list":
jobs = manager.list(session_id or "__default__")
if not jobs:
return ToolResult(success=True, output="No background tasks in this session.")
lines = [
f"{j.task_id} {j.tool:<12} {j.status:<10} {self._age(j)}s"
+ (f" {j.preview(limit=120)}" if j.status != "running" else "")
for j in jobs
]
return ToolResult(success=True, output="\n".join(lines))
task_id = params.get("task_id")
if not task_id:
return ToolResult(success=False, output="", error="'task_id' is required")
job = manager.get(task_id, session_id or "__default__")
if job is None:
return ToolResult(
success=False,
output=f"Task '{task_id}' not found in this session (it may have been reaped).",
error="task_not_found",
)
if action == "check":
return ToolResult(success=True, output=self._render_check(job))
if action == "wait":
timeout = min(float(params.get("timeout") or _MAX_WAIT_TIMEOUT), _MAX_WAIT_TIMEOUT)
try:
await asyncio.wait_for(job.done.wait(), timeout=timeout)
except asyncio.TimeoutError:
return ToolResult(
success=False,
output=(
f"Task {job.task_id} is still running after {timeout:g}s "
f"(status: {job.status}). Continue other work and check again later."
),
error="wait_timeout",
)
return ToolResult(success=True, output=self._render_check(job))
if action == "cancel":
if manager.cancel(job):
return ToolResult(success=True, output=f"Task {job.task_id} cancelled.")
return ToolResult(
success=False,
output=f"Task {job.task_id} is not running (status: {job.status}).",
error="not_running",
)
return ToolResult(success=False, output="", error=f"Unknown action '{action}'")
# -- rendering ----------------------------------------------------------
@staticmethod
def _age(job) -> int:
import time
end = job.finished_at if job.finished_at is not None else time.time()
return int(end - job.created_at)
def _render_check(self, job) -> str:
parts = [
f"Task {job.task_id} ({job.tool}) — status: {job.status}, age: {self._age(job)}s",
]
if job.subagent_tokens is not None:
parts.append(f"Sub-agent tokens: {job.subagent_tokens}")
if job.status == "running":
progress = self._recent_events(job)
if progress:
parts.append("Recent progress:\n" + progress)
else:
parts.append("No progress events yet.")
else:
if job.result is not None:
out = job.result.to_message_content()
if len(out) > _MAX_CHECK_OUTPUT:
out = out[:_MAX_CHECK_OUTPUT] + "\n... (truncated — full result is in the completion note)"
parts.append(("Result:\n" if job.result.success else "Result (failed):\n") + out)
if job.error and job.result is None:
parts.append(f"Error: {job.error}")
return "\n".join(parts)
@staticmethod
def _recent_events(job, limit: int = 8) -> str:
"""Compact summary of the last events in the task's ring buffer."""
if job.ring is None:
return ""
items = list(job.ring._queue)[-limit:]
lines = []
for item in items:
kind = type(item).__name__
detail = ""
if kind == "ToolStarted" or kind == "ToolEvent":
detail = getattr(item, "tool_name", "")
if kind == "ToolEvent":
detail += " ✓" if getattr(item, "success", False) else " ✗"
elif kind == "TurnThinking":
text = (getattr(item, "thinking", "") or "").strip().replace("\n", " ")
detail = text[:100]
elif kind == "PlanReady":
detail = "plan ready"
lines.append(f" {kind}{(': ' + detail) if detail else ''}")
return "\n".join(lines) if lines else ""