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:
Shane
2026-08-22 18:06:23 -04:00
parent d372adca7d
commit 44e8d05e2c
8 changed files with 457 additions and 48 deletions
+3
View File
@@ -44,6 +44,9 @@ COPY agent/web_mcp.py /opt/voice-agent/web_mcp.py
COPY agent/weather_mcp.py /opt/voice-agent/weather_mcp.py
COPY agent/memory_mcp.py /opt/voice-agent/memory_mcp.py
COPY agent/skills_mcp.py /opt/voice-agent/skills_mcp.py
COPY agent/task_registry.py /opt/voice-agent/task_registry.py
COPY agent/task_worker.py /opt/voice-agent/task_worker.py
COPY agent/dispatch_mcp.py /opt/voice-agent/dispatch_mcp.py
# Copy web frontend + token endpoint
COPY web/index.html /var/www/voice/
+43
View File
@@ -98,6 +98,14 @@ SYSTEM_PROMPT = textwrap.dedent("""\
successfully complete a multi-step task or learn a new procedure from the user,
use skill_save to record it so you can follow it next time. Be selective: only
save skills for repeatable tasks, not one-off facts (those go in memory).
# Background Tasks (dispatch_task)
When the user asks you to research something, look up news, or do anything
that will take more than a few seconds, CALL dispatch_task with a clear
description of what to investigate. Tell the user "I'll take a look at that
for you" and keep talking. The result comes back automatically — when you
receive it, share the findings naturally. You can have multiple tasks running
at once.
""")
@@ -395,6 +403,20 @@ def build_mcp_toolsets() -> list[mcp.MCPToolset]:
)
logger.info("Skills MCP toolset enabled (%s)", SKILLS_DIR)
dispatch_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dispatch_mcp.py")
toolsets.append(
mcp.MCPToolset(
id="dispatch",
mcp_server=mcp.MCPServerStdio(
command=python_bin,
args=[dispatch_mcp_script],
env={**os.environ},
client_session_timeout_seconds=60,
),
)
)
logger.info("Dispatch MCP toolset enabled")
extra = os.environ.get("EXTRA_MCP_SERVERS", "")
if extra:
try:
@@ -531,6 +553,27 @@ async def handle_job(ctx: JobContext) -> None:
# audio is arriving from the participant.
logger.info("user state -> %s", ev.new_state)
# ── Background task events → room data channel ─────────────────────────
from agent.task_registry import registry as task_registry
async def _publish_task_event(task_id: str, event: dict) -> None:
payload = json.dumps({"type": "task_event", "task_id": task_id, **event})
try:
await ctx.room.local_participant.publish_data(
payload, reliable=True, topic="tasks"
)
except Exception as e: # noqa: BLE001
logger.warning("failed to publish task event: %s", e)
async def _poll_tasks() -> None:
while True:
await asyncio.sleep(1.0)
events = await task_registry.poll_events()
for task_id, event in events:
await _publish_task_event(task_id, event)
asyncio.create_task(_poll_tasks())
await session.start(
agent=VoiceAssistant(),
room=ctx.room,
+58
View File
@@ -0,0 +1,58 @@
"""Dispatch MCP server — lets Hope spawn background tasks.
The main agent calls dispatch_task(description) when the user asks for
something long-running. The tool returns immediately with a task ID; the
actual work runs in a background asyncio task via TaskWorker.
Communication with the main agent process happens via /tmp/tasks/events.jsonl
(the file-based registry).
"""
from __future__ import annotations
import asyncio
import logging
from mcp.server.fastmcp import FastMCP
logger = logging.getLogger("voice-agent.dispatch")
mcp = FastMCP("dispatch")
@mcp.tool()
async def dispatch_task(description: str) -> str:
"""Spawn a background task to research or look something up.
Use this when the user asks you to do something that will take more than
a few seconds (research, look up news, multi-step investigation). The task
runs in parallel while you continue the conversation. When it completes,
the result will be delivered back to you automatically — you can tell the
user "I'll let you know when I find out."
Returns a short confirmation with the task ID.
"""
try:
from agent.task_registry import registry
from agent.task_worker import TaskWorker
except ImportError:
from task_registry import registry
from task_worker import TaskWorker
task = registry.create(description)
worker = TaskWorker(task.id, description)
asyncio.create_task(_run_worker(worker, task.id))
logger.info("Dispatched task %s: %s", task.id, description)
return f"Task {task.id} started. I'll report back when it's done."
async def _run_worker(worker: "TaskWorker", task_id: str):
try:
from agent.task_registry import registry
except ImportError:
from task_registry import registry
try:
await worker.run()
except Exception as e:
logger.exception("Task %s crashed: %s", task_id, e)
registry.fail(task_id, str(e))
+75 -37
View File
@@ -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()
+14 -11
View File
@@ -25,13 +25,16 @@ class TaskWorker:
async def run(self):
from openai import AsyncOpenAI
from agent.task_registry import registry as reg
try:
from agent.task_registry import registry as reg
except ImportError:
from task_registry import registry as reg
base_url = os.environ.get("GEMMA_BASE_URL", "http://192.168.86.2:8023/v1")
model = os.environ.get("GEMMA_MODEL", "gemma-4-e4b")
api_key = os.environ.get("GEMMA_API_KEY", "not-needed")
await reg.add_step(self.task_id, "thinking", f"Starting: {self.description}")
reg.add_step(self.task_id, "thinking", f"Starting: {self.description}")
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
tools = self._load_tools()
@@ -44,10 +47,10 @@ class TaskWorker:
max_iterations = 10
for iteration in range(max_iterations):
if self._cancelled:
await reg.fail(self.task_id, "Cancelled")
reg.fail(self.task_id, "Cancelled")
return
await reg.add_step(
reg.add_step(
self.task_id, "thinking", f"Step {iteration + 1}: analyzing..."
)
@@ -61,7 +64,7 @@ class TaskWorker:
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
except Exception as e:
await reg.fail(self.task_id, f"LLM error: {e}")
reg.fail(self.task_id, f"LLM error: {e}")
return
msg = response.choices[0].message
@@ -74,13 +77,13 @@ class TaskWorker:
tool_args = json.loads(tc.function.arguments)
except json.JSONDecodeError:
tool_args = {}
await reg.add_step(
reg.add_step(
self.task_id,
"tool_call",
f"{tool_name}({json.dumps(tool_args)})",
)
result = await self._execute_tool(tool_name, tool_args)
await reg.add_step(self.task_id, "tool_result", str(result)[:500])
reg.add_step(self.task_id, "tool_result", str(result)[:500])
messages.append(
{
"role": "tool",
@@ -91,13 +94,13 @@ class TaskWorker:
else:
text = (msg.content or "").strip()
if not text:
await reg.fail(self.task_id, "LLM returned empty response")
reg.fail(self.task_id, "LLM returned empty response")
return
await reg.add_step(self.task_id, "text", text)
await reg.complete(self.task_id, text)
reg.add_step(self.task_id, "text", text)
reg.complete(self.task_id, text)
return
await reg.fail(self.task_id, f"Reached max iterations ({max_iterations})")
reg.fail(self.task_id, f"Reached max iterations ({max_iterations})")
def _system_prompt(self) -> str:
return (
+74
View File
@@ -36,6 +36,12 @@ let agentAudioLevel = 0; // 0..1, smoothed
let userSpeaking = false; // local VAD-ish flag from mic level
let wakeLock = null;
function escapeHtml(s) {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
// ── DOM ─────────────────────────────────────────────────────────────────────
const startBtn = document.getElementById("startBtn");
const stopBtn = document.getElementById("stopBtn");
@@ -54,6 +60,72 @@ const sheetBackdrop = document.getElementById("sheetBackdrop");
const closeSheetBtn = document.getElementById("closeSheetBtn");
const voiceListEl = document.getElementById("voiceList");
const clearBtn = document.getElementById("clearBtn");
const tasksBtn = document.getElementById("tasksBtn");
const tasksPanel = document.getElementById("tasksPanel");
const tasksList = document.getElementById("tasksList");
const tasksBadge = document.getElementById("tasksBadge");
const closeTasksBtn = document.getElementById("closeTasksBtn");
// ── Background tasks state ──────────────────────────────────────────────────
const tasks = new Map(); // task_id -> {id, description, status, steps: [], result, error}
function updateTasksBadge() {
const active = [...tasks.values()].filter(t => t.status === "running" || t.status === "pending").length;
if (active > 0) {
tasksBadge.hidden = false;
tasksBadge.textContent = String(active);
} else {
tasksBadge.hidden = true;
}
}
function renderTasks() {
const items = [...tasks.values()].sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
if (items.length === 0) {
tasksList.innerHTML = '<p class="tasks-empty">No tasks yet. Ask Hope to research something.</p>';
return;
}
tasksList.innerHTML = "";
for (const t of items) {
const card = document.createElement("div");
card.className = "task-card";
const statusClass = t.status || "pending";
card.innerHTML = `
<div class="task-card-header">
<span class="task-status-dot ${statusClass}"></span>
<span class="task-desc">${escapeHtml(t.description)}</span>
</div>
<ul class="task-steps">${t.steps.map(s => `<li class="task-step ${s.role}">${escapeHtml(s.content)}</li>`).join("")}</ul>
${t.result ? `<div class="task-result">${escapeHtml(t.result)}</div>` : ""}
${t.error ? `<div class="task-error">${escapeHtml(t.error)}</div>` : ""}
`;
tasksList.appendChild(card);
}
}
function handleTaskEvent(data) {
const { task_id, event, role, content, result, error, task } = data;
if (event === "created" && task) {
tasks.set(task_id, { ...task, steps: [] });
} else if (event === "step") {
const t = tasks.get(task_id);
if (t) {
t.steps.push({ role, content });
t.status = "running";
}
} else if (event === "completed") {
const t = tasks.get(task_id);
if (t) { t.status = "completed"; t.result = result; }
} else if (event === "failed") {
const t = tasks.get(task_id);
if (t) { t.status = "failed"; t.error = error; }
}
updateTasksBadge();
renderTasks();
}
tasksBtn.addEventListener("click", () => tasksPanel.classList.toggle("open"));
closeTasksBtn.addEventListener("click", () => tasksPanel.classList.remove("open"));
// ── Audio analysers (mic + remote agent audio) ─────────────────────────────
let micCtx = null;
@@ -352,6 +424,8 @@ function handleDataPacket(payload, participant, topic) {
const msg = JSON.parse(new TextDecoder().decode(payload));
if (msg.type === "transcript") {
addMessage(msg.role || "agent", msg.text);
} else if (msg.type === "task_event") {
handleTaskEvent(msg);
}
// set_voice messages flow the other way; nothing to do client-side.
} catch (e) {
+17
View File
@@ -64,6 +64,23 @@
<div class="voice-list" id="voiceList"></div>
</div>
<!-- Task panel (background tasks) -->
<button id="tasksBtn" class="btn btn-icon tasks-fab" aria-label="Background tasks" title="Background tasks">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M9 9h6M9 13h6M9 17h4"></path></svg>
<span class="tasks-badge" id="tasksBadge" hidden>0</span>
</button>
<div class="tasks-panel" id="tasksPanel">
<div class="tasks-header">
<h2>Background Tasks</h2>
<button id="closeTasksBtn" class="btn btn-ghost" aria-label="Close">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
</div>
<div class="tasks-list" id="tasksList">
<p class="tasks-empty">No tasks yet. Ask Hope to research something.</p>
</div>
</div>
<script src="livekit-client.umd.js"></script>
<script src="app.js"></script>
</body>
+173
View File
@@ -510,6 +510,178 @@ h1 {
.transcript::-webkit-scrollbar-thumb,
.voice-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ── Task panel ──────────────────────────────────────────────────────────── */
.tasks-fab {
position: fixed;
bottom: calc(1.5rem + env(safe-area-inset-bottom));
right: 1.5rem;
z-index: 100;
}
.tasks-badge {
position: absolute;
top: -4px;
right: -4px;
background: #3b82f6;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
min-width: 18px;
height: 18px;
border-radius: 9px;
display: grid;
place-items: center;
padding: 0 4px;
}
.tasks-panel {
position: fixed;
top: 0;
right: -320px;
width: 320px;
max-width: 85vw;
height: 100dvh;
background: var(--panel);
border-left: 1px solid var(--border);
z-index: 200;
display: flex;
flex-direction: column;
transition: right 0.25s ease;
}
.tasks-panel.open {
right: 0;
}
.tasks-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.tasks-header h2 {
font-size: 1rem;
margin: 0;
}
.tasks-list {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tasks-empty {
color: var(--text-faint);
font-size: 0.85rem;
text-align: center;
margin-top: 2rem;
}
.task-card {
background: var(--panel-strong);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.75rem;
font-size: 0.82rem;
}
.task-card-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.task-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6b7280;
flex-shrink: 0;
}
.task-status-dot.running {
background: #3b82f6;
animation: pulse-dot 1.5s infinite;
}
.task-status-dot.completed {
background: #22c55e;
}
.task-status-dot.failed {
background: #ef4444;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.task-desc {
font-weight: 600;
color: var(--text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-steps {
margin-top: 0.5rem;
padding-left: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: 200px;
overflow-y: auto;
}
.task-step {
font-size: 0.75rem;
color: var(--text-dim);
padding: 0.25rem 0.4rem;
border-radius: 4px;
background: rgba(0,0,0,0.15);
}
.task-step.tool_call {
color: #60a5fa;
}
.task-step.tool_result {
color: var(--text-faint);
}
.task-step.text {
color: var(--text);
font-weight: 500;
}
.task-result {
margin-top: 0.5rem;
padding: 0.5rem;
background: rgba(34, 197, 94, 0.1);
border-radius: 6px;
font-size: 0.8rem;
color: var(--text);
}
.task-error {
margin-top: 0.5rem;
padding: 0.5rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 6px;
font-size: 0.8rem;
color: #fca5a5;
}
/* ── Reduced motion ──────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.orb-core,
@@ -517,6 +689,7 @@ h1 {
.message-row,
.settings-sheet,
.sheet-backdrop,
.tasks-panel,
.audio-viz i,
.mic-meter-fill {
animation: none !important;