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
+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 (