feat: background task dispatch system with live UI panel
- agent/task_registry.py: file-based JSONL event registry (cross-process) - agent/task_worker.py: autonomous LLM loop with weather/time/memory/web tools - agent/dispatch_mcp.py: MCP tool exposing dispatch_task to the main agent - agent/agent.py: registers dispatch toolset, polls task events → room data - web: slide-out task panel (FAB button + badge), live step streaming via data channel topic 'tasks', status dots (running/completed/failed) - Dockerfile: copies new task_*.py and dispatch_mcp.py files The dispatch MCP runs in its own process; events flow through /tmp/tasks/events.jsonl which the main agent tails every second and forwards to the browser. Tasks run up to 10 LLM iterations with tool calls.
This commit is contained in:
+75
-37
@@ -1,18 +1,29 @@
|
||||
"""In-memory task registry for background dispatch tasks.
|
||||
"""File-based task registry for background dispatch tasks.
|
||||
|
||||
One global instance per agent process. Workers add steps as they progress;
|
||||
the agent subscribes to push updates to the room (UI) and to speak results.
|
||||
The dispatch MCP server runs in a separate process, so we use a JSONL event
|
||||
file as the communication channel. The main agent process tails this file
|
||||
and pushes events to the room (UI) and speaks results.
|
||||
|
||||
Event file: /tmp/tasks/events.jsonl
|
||||
Task state: /tmp/tasks/{task_id}.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
TASKS_DIR = Path(os.environ.get("TASKS_DIR", "/tmp/tasks"))
|
||||
EVENTS_FILE = TASKS_DIR / "events.jsonl"
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
@@ -22,7 +33,7 @@ class TaskStatus(str, Enum):
|
||||
|
||||
@dataclass
|
||||
class TaskStep:
|
||||
role: str # "thinking", "tool_call", "tool_result", "text"
|
||||
role: str
|
||||
content: str
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
@@ -40,63 +51,81 @@ class Task:
|
||||
|
||||
|
||||
class TaskRegistry:
|
||||
"""Thread-safe registry of background tasks with pub/sub notifications."""
|
||||
"""File-backed registry. Writers (MCP process) append to events.jsonl.
|
||||
Readers (main agent) tail the file and dispatch to subscribers."""
|
||||
|
||||
def __init__(self):
|
||||
TASKS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not EVENTS_FILE.exists():
|
||||
EVENTS_FILE.touch()
|
||||
self._tasks: dict[str, Task] = {}
|
||||
self._listeners: list[Callable[[str, dict], Any]] = []
|
||||
self._lock = asyncio.Lock()
|
||||
self._tail_pos = 0
|
||||
|
||||
async def create(self, description: str) -> Task:
|
||||
def create(self, description: str) -> Task:
|
||||
task = Task(id=uuid.uuid4().hex[:8], description=description)
|
||||
async with self._lock:
|
||||
self._tasks[task.id] = task
|
||||
await self._notify(task.id, {"event": "created", "task": self._task_dict(task)})
|
||||
self._tasks[task.id] = task
|
||||
self._write_event(task.id, {"event": "created", "task": self._task_dict(task)})
|
||||
return task
|
||||
|
||||
async def add_step(self, task_id: str, role: str, content: str):
|
||||
async with self._lock:
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
def add_step(self, task_id: str, role: str, content: str):
|
||||
task = self._tasks.get(task_id)
|
||||
if task:
|
||||
task.steps.append(TaskStep(role=role, content=content))
|
||||
if task.status == TaskStatus.PENDING:
|
||||
task.status = TaskStatus.RUNNING
|
||||
await self._notify(task_id, {"event": "step", "role": role, "content": content})
|
||||
self._save_task(task)
|
||||
self._write_event(task_id, {"event": "step", "role": role, "content": content})
|
||||
|
||||
async def complete(self, task_id: str, result: str):
|
||||
async with self._lock:
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
def complete(self, task_id: str, result: str):
|
||||
task = self._tasks.get(task_id)
|
||||
if task:
|
||||
task.status = TaskStatus.COMPLETED
|
||||
task.result = result
|
||||
task.completed_at = time.time()
|
||||
await self._notify(task_id, {"event": "completed", "result": result})
|
||||
self._save_task(task)
|
||||
self._write_event(task_id, {"event": "completed", "result": result})
|
||||
|
||||
async def fail(self, task_id: str, error: str):
|
||||
async with self._lock:
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
def fail(self, task_id: str, error: str):
|
||||
task = self._tasks.get(task_id)
|
||||
if task:
|
||||
task.status = TaskStatus.FAILED
|
||||
task.error = error
|
||||
task.completed_at = time.time()
|
||||
await self._notify(task_id, {"event": "failed", "error": error})
|
||||
self._save_task(task)
|
||||
self._write_event(task_id, {"event": "failed", "error": error})
|
||||
|
||||
async def get(self, task_id: str) -> dict | None:
|
||||
async with self._lock:
|
||||
t = self._tasks.get(task_id)
|
||||
return self._task_dict(t) if t else None
|
||||
def get(self, task_id: str) -> dict | None:
|
||||
t = self._tasks.get(task_id)
|
||||
return self._task_dict(t) if t else None
|
||||
|
||||
async def list_all(self) -> list[dict]:
|
||||
async with self._lock:
|
||||
return [self._task_dict(t) for t in self._tasks.values()]
|
||||
def list_all(self) -> list[dict]:
|
||||
return [self._task_dict(t) for t in self._tasks.values()]
|
||||
|
||||
def subscribe(self, callback: Callable[[str, dict], Any]):
|
||||
self._listeners.append(callback)
|
||||
|
||||
async def _notify(self, task_id: str, event: dict):
|
||||
async def poll_events(self) -> list[tuple[str, dict]]:
|
||||
"""Read new events from the file. Call periodically from the agent."""
|
||||
events = []
|
||||
try:
|
||||
with open(EVENTS_FILE, "r") as f:
|
||||
f.seek(self._tail_pos)
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
events.append((ev.get("task_id", ""), ev))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
self._tail_pos = f.tell()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
return events
|
||||
|
||||
async def notify_subscribers(self, task_id: str, event: dict):
|
||||
for cb in self._listeners:
|
||||
try:
|
||||
result = cb(task_id, event)
|
||||
@@ -105,6 +134,15 @@ class TaskRegistry:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _write_event(self, task_id: str, event: dict):
|
||||
payload = json.dumps({"task_id": task_id, **event})
|
||||
with open(EVENTS_FILE, "a") as f:
|
||||
f.write(payload + "\n")
|
||||
|
||||
def _save_task(self, task: Task):
|
||||
path = TASKS_DIR / f"{task.id}.json"
|
||||
path.write_text(json.dumps(self._task_dict(task)))
|
||||
|
||||
@staticmethod
|
||||
def _task_dict(task: Task) -> dict:
|
||||
return {
|
||||
@@ -122,5 +160,5 @@ class TaskRegistry:
|
||||
}
|
||||
|
||||
|
||||
# Global singleton — one per agent process
|
||||
# Global singleton — one per process
|
||||
registry = TaskRegistry()
|
||||
|
||||
Reference in New Issue
Block a user