Files
hope-voice-api/agent/task_registry.py
T
snowspeeder d4ea4fc984
Rebuild & Deploy / rebuild (push) Successful in 58s
fix: background tasks now fetch real page content; add task-status tools
Background research tasks were producing garbage or failing because the
self-hosted Firecrawl /v1/scrape defaults to a ~30s timeout that's too short
for heavy news/research sites, and the small LLM won't reliably chain a
second scrape call after a search that only returns snippets.

task_worker.py:
- web_search now auto-fetches the top results directly from the container
  (which has working outbound internet) and returns their real text, so the
  model gets actual page content in one call instead of snippet echoes.
- web_scrape requests timeout: 90000 (Firecrawl renders heavy sites but needs
  >30s), and falls back to a direct container fetch with tag-stripping if
  Firecrawl times out or returns nothing.

task_registry.py / dispatch_mcp.py:
- Add task_status(id) and list_tasks() tools so Hope can check the live state
  of any dispatched task (pending / running / completed / failed / STALLED).
- Track last_activity per task and surface a stall heuristic.
- Move the status summary into task_registry (no mcp dependency) so it's
  unit-testable.

agent.py: prompt tells Hope about task_status / list_tasks.
tests: add task-status summary and last-activity regression tests.
2026-08-30 06:54:57 -04:00

202 lines
6.8 KiB
Python

"""File-based task registry for background dispatch tasks.
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"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class TaskStep:
role: str
content: str
timestamp: float = field(default_factory=time.time)
@dataclass
class Task:
id: str
description: str
status: TaskStatus = TaskStatus.PENDING
steps: list[TaskStep] = field(default_factory=list)
result: str | None = None
error: str | None = None
created_at: float = field(default_factory=time.time)
completed_at: float | None = None
last_activity: float = field(default_factory=time.time)
class TaskRegistry:
"""File-backed registry. Writers (MCP process) append to events.jsonl.
Readers (main agent) tail the file and dispatch to subscribers.
A registry can be scoped to a task directory. Voice sessions use a unique
directory so one room can never consume another room's task results.
"""
def __init__(self, tasks_dir: str | Path | None = None):
self.tasks_dir = Path(tasks_dir) if tasks_dir is not None else TASKS_DIR
self.events_file = self.tasks_dir / "events.jsonl"
self.tasks_dir.mkdir(parents=True, exist_ok=True)
if not self.events_file.exists():
self.events_file.touch()
self._tasks: dict[str, Task] = {}
self._listeners: list[Callable[[str, dict], Any]] = []
self._tail_pos = 0
def create(self, description: str) -> Task:
task = Task(id=uuid.uuid4().hex[:8], description=description)
self._tasks[task.id] = task
self._write_event(task.id, {"event": "created", "task": self._task_dict(task)})
return task
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))
task.last_activity = time.time()
if task.status == TaskStatus.PENDING:
task.status = TaskStatus.RUNNING
self._save_task(task)
self._write_event(task_id, {"event": "step", "role": role, "content": content})
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()
self._save_task(task)
self._write_event(task_id, {"event": "completed", "result": result})
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()
self._save_task(task)
self._write_event(task_id, {"event": "failed", "error": error})
def get(self, task_id: str) -> dict | None:
t = self._tasks.get(task_id)
return self._task_dict(t) if t else None
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 poll_events(self) -> list[tuple[str, dict]]:
"""Read new events from the file. Call periodically from the agent."""
events = []
try:
with open(self.events_file, "r", encoding="utf-8") 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)
if asyncio.iscoroutine(result):
await result
except Exception:
pass
def _write_event(self, task_id: str, event: dict):
payload = json.dumps({"task_id": task_id, **event})
with open(self.events_file, "a", encoding="utf-8") as f:
f.write(payload + "\n")
def _save_task(self, task: Task):
path = self.tasks_dir / f"{task.id}.json"
path.write_text(json.dumps(self._task_dict(task)), encoding="utf-8")
@staticmethod
def _task_dict(task: Task) -> dict:
return {
"id": task.id,
"description": task.description,
"status": task.status.value,
"steps": [
{"role": s.role, "content": s.content, "ts": s.timestamp}
for s in task.steps
],
"result": task.result,
"error": task.error,
"created_at": task.created_at,
"completed_at": task.completed_at,
"last_activity": task.last_activity,
}
# Global singleton — one per process
registry = TaskRegistry()
def status_summary(task_id: str, stalled_s: float = 45.0) -> str:
"""Human-readable live status for one task, plus a stall hint.
Kept on the registry module (no MCP dependency) so it can be reused and
unit-tested; the dispatch MCP's task_status/list_tasks tools call this.
"""
t = registry.get(task_id)
if not t:
return f"Task {task_id} was not found."
status = t.get("status", "unknown")
steps = t.get("steps") or []
created = t.get("created_at") or 0
last = t.get("last_activity") or created
elapsed = time.time() - created
idle = time.time() - last
parts = [f"Task {task_id} ({t.get('description', '')[:60]}): {status.upper()}"]
parts.append(f"elapsed {elapsed:,.0f}s, {len(steps)} step(s)")
if idle > 0.5:
parts.append(f"last activity {idle:,.0f}s ago")
if status in ("pending", "running") and idle > stalled_s:
parts.append(f"STALLED (no activity for {idle:,.0f}s)")
if t.get("error"):
parts.append(f"error: {t['error']}")
if t.get("result"):
parts.append(f"result: {t['result'][:240]}")
return ". ".join(parts)