diff --git a/src/dashboard/routes/agents.py b/src/dashboard/routes/agents.py index 7bbd5de..9fbcc52 100644 --- a/src/dashboard/routes/agents.py +++ b/src/dashboard/routes/agents.py @@ -1,3 +1,5 @@ +import logging +import re from datetime import datetime from pathlib import Path @@ -8,9 +10,35 @@ from fastapi.templating import Jinja2Templates from timmy.session import chat as timmy_chat from dashboard.store import message_log +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/agents", tags=["agents"]) templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) +# ── Task queue detection ────────────────────────────────────────────────── +# Patterns that indicate the user wants to queue a task rather than chat +_QUEUE_PATTERNS = [ + re.compile(r"\b(?:add|put|schedule|queue|submit)\b.*\b(?:to the|on the|in the)?\s*(?:queue|task(?:\s*queue)?|task list)\b", re.IGNORECASE), + re.compile(r"\bschedule\s+(?:this|that|a)\b", re.IGNORECASE), + re.compile(r"\bcreate\s+(?:a\s+)?task\b", re.IGNORECASE), +] + + +def _extract_task_from_message(message: str) -> dict | None: + """If the message looks like a task-queue request, return task details.""" + for pattern in _QUEUE_PATTERNS: + if pattern.search(message): + # Strip the queue instruction to get the actual task description + title = re.sub( + r"\b(?:add|put|schedule|queue|submit|create)\b.*?\b(?:to the|on the|in the|a)?\s*(?:queue|task(?:\s*queue)?|task list)\b", + "", message, flags=re.IGNORECASE, + ).strip(" ,:;-") + # If stripping removed everything, use the full message + if not title or len(title) < 5: + title = message + return {"title": title[:120], "description": message} + return None + # Static metadata for known agents — enriched onto live registry entries. _AGENT_METADATA: dict[str, dict] = { "timmy": { @@ -74,10 +102,36 @@ async def chat_timmy(request: Request, message: str = Form(...)): response_text = None error_text = None - try: - response_text = timmy_chat(message) - except Exception as exc: - error_text = f"Timmy is offline: {exc}" + # Check if the user wants to queue a task instead of chatting + task_info = _extract_task_from_message(message) + if task_info: + try: + from task_queue.models import create_task + task = create_task( + title=task_info["title"], + description=task_info["description"], + created_by="user", + assigned_to="timmy", + priority="normal", + requires_approval=True, + ) + response_text = ( + f"Task queued for approval: **{task.title}**\n\n" + f"Status: `{task.status.value}` | " + f"[View Task Queue](/tasks)" + ) + logger.info("Chat → task queue: %s (id=%s)", task.title, task.id) + except Exception as exc: + logger.error("Failed to create task from chat: %s", exc) + # Fall through to normal chat if task creation fails + task_info = None + + # Normal chat path (also used as fallback if task creation failed) + if not task_info: + try: + response_text = timmy_chat(message) + except Exception as exc: + error_text = f"Timmy is offline: {exc}" message_log.append(role="user", content=message, timestamp=timestamp) if response_text is not None: diff --git a/src/timmy/briefing.py b/src/timmy/briefing.py index 8bd22ee..9b3503b 100644 --- a/src/timmy/briefing.py +++ b/src/timmy/briefing.py @@ -166,6 +166,30 @@ def _gather_swarm_summary(since: datetime) -> str: return "Swarm data unavailable." +def _gather_task_queue_summary() -> str: + """Pull task queue stats for the briefing. Graceful if unavailable.""" + try: + from task_queue.models import get_task_summary_for_briefing + stats = get_task_summary_for_briefing() + parts = [] + if stats["pending_approval"]: + parts.append(f"{stats['pending_approval']} task(s) pending approval") + if stats["running"]: + parts.append(f"{stats['running']} task(s) running") + if stats["completed"]: + parts.append(f"{stats['completed']} task(s) completed") + if stats["failed"]: + parts.append(f"{stats['failed']} task(s) failed") + for fail in stats.get("recent_failures", []): + parts.append(f" - Failed: {fail['title']}") + if stats["vetoed"]: + parts.append(f"{stats['vetoed']} task(s) vetoed") + return "; ".join(parts) if parts else "No tasks in the queue." + except Exception as exc: + logger.debug("Task queue summary error: %s", exc) + return "Task queue data unavailable." + + def _gather_chat_summary(since: datetime) -> str: """Pull recent chat messages from the in-memory log.""" try: @@ -213,16 +237,20 @@ class BriefingEngine: swarm_info = _gather_swarm_summary(period_start) chat_info = _gather_chat_summary(period_start) + task_info = _gather_task_queue_summary() prompt = ( "You are Timmy, a sovereign local AI companion.\n" "Here is what happened since the last briefing:\n\n" f"SWARM ACTIVITY:\n{swarm_info}\n\n" + f"TASK QUEUE:\n{task_info}\n\n" f"RECENT CONVERSATIONS:\n{chat_info}\n\n" "Summarize the last period of activity into a 5-minute morning briefing. " "Be concise, warm, and direct. " "Use plain prose — no bullet points. " "Maximum 300 words. " + "If there are tasks pending approval, mention them prominently. " + "If there are failed tasks, flag them as needing attention. " "End with a short paragraph listing any items that need the owner's approval, " "or say 'No approvals needed today.' if there are none." ) diff --git a/tests/test_task_queue.py b/tests/test_task_queue.py index 2726093..5b5365f 100644 --- a/tests/test_task_queue.py +++ b/tests/test_task_queue.py @@ -304,3 +304,81 @@ def test_api_approve_nonexistent(client): def test_api_veto_nonexistent(client): resp = client.patch("/api/tasks/nonexistent/veto") assert resp.status_code == 404 + + +# ── Chat → Task Queue Integration ───────────────────────────────────────── + + +def test_chat_queue_detection_add_to_queue(): + """'add X to the queue' should be detected as a task request.""" + from dashboard.routes.agents import _extract_task_from_message + + result = _extract_task_from_message("add run the tests to the task queue") + assert result is not None + assert "title" in result + assert "description" in result + + +def test_chat_queue_detection_schedule(): + """'schedule this' should be detected as a task request.""" + from dashboard.routes.agents import _extract_task_from_message + + result = _extract_task_from_message("schedule this for later") + assert result is not None + + +def test_chat_queue_detection_create_task(): + """'create a task' should be detected.""" + from dashboard.routes.agents import _extract_task_from_message + + result = _extract_task_from_message("create a task to refactor the login page") + assert result is not None + assert "refactor" in result["title"].lower() + + +def test_chat_queue_detection_normal_message(): + """Normal messages should NOT be detected as task requests.""" + from dashboard.routes.agents import _extract_task_from_message + + assert _extract_task_from_message("hello how are you") is None + assert _extract_task_from_message("what is the weather today") is None + assert _extract_task_from_message("tell me a joke") is None + + +def test_chat_creates_task_on_queue_request(client): + """Posting 'add X to the queue' via chat should create a task.""" + with patch("dashboard.routes.agents.timmy_chat") as mock_chat: + mock_chat.return_value = "Sure, I'll do that." + resp = client.post( + "/agents/timmy/chat", + data={"message": "add deploy the new feature to the task queue"}, + ) + assert resp.status_code == 200 + assert "Task queued" in resp.text or "task queue" in resp.text.lower() + # timmy_chat should NOT have been called — task was intercepted + mock_chat.assert_not_called() + + +def test_chat_normal_message_uses_timmy(client): + """Normal messages should go through to Timmy as usual.""" + with patch("dashboard.routes.agents.timmy_chat") as mock_chat: + mock_chat.return_value = "Hello there!" + resp = client.post( + "/agents/timmy/chat", + data={"message": "hello how are you"}, + ) + assert resp.status_code == 200 + mock_chat.assert_called_once() + + +# ── Briefing Integration ────────────────────────────────────────────────── + + +def test_briefing_task_queue_summary(): + """Briefing engine should include task queue data.""" + from task_queue.models import create_task + from timmy.briefing import _gather_task_queue_summary + + create_task(title="Briefing integration test", created_by="test") + summary = _gather_task_queue_summary() + assert "pending" in summary.lower() or "task" in summary.lower()