Files
Timmy-time-dashboard/src/dashboard/routes/agents.py
Alexander Whitestone 18ed6232f9 feat: Timmy fixes and improvements (#72)
* test: remove hardcoded sleeps, add pytest-timeout

- Replace fixed time.sleep() calls with intelligent polling or WebDriverWait
- Add pytest-timeout dependency and --timeout=30 to prevent hangs
- Fixes test flakiness and improves test suite speed

* feat: add Aider AI tool to Forge's toolkit

- Add Aider tool that calls local Ollama (qwen2.5:14b) for AI coding assist
- Register tool in Forge's code toolkit
- Add functional tests for the Aider tool

* config: add opencode.json with local Ollama provider for sovereign AI

* feat: Timmy fixes and improvements

## Bug Fixes
- Fix read_file path resolution: add ~ expansion, proper relative path handling
- Add repo_root to config.py with auto-detection from .git location
- Fix hardcoded llama3.2 - now dynamic from settings.ollama_model

## Timmy's Requests
- Add communication protocol to AGENTS.md (read context first, explain changes)
- Create DECISIONS.md for architectural decision documentation
- Add reasoning guidance to system prompts (step-by-step, state uncertainty)
- Update tests to reflect correct model name (llama3.1:8b-instruct)

## Testing
- All 177 dashboard tests pass
- All 32 prompt/tool tests pass

---------

Co-authored-by: Alexander Payne <apayne@MM.local>
2026-02-26 23:39:13 -05:00

311 lines
10 KiB
Python

import logging
import re
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
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+|an\s+)?(?:\w+\s+){0,3}task\b", re.IGNORECASE),
]
# Questions about tasks/queue should NOT trigger task creation
_QUESTION_PREFIXES = re.compile(
r"^(?:what|how|why|can you explain|could you explain|tell me about|explain|"
r"what(?:'s| is| are| would))\b",
re.IGNORECASE,
)
_QUESTION_FRAMES = re.compile(
r"\b(?:how (?:do|does|would|can|should)|what (?:is|are|would)|"
r"can you (?:explain|describe|tell)|best way to)\b",
re.IGNORECASE,
)
# Known agent names for task assignment parsing
_KNOWN_AGENTS = frozenset(
{
"timmy",
"echo",
"mace",
"helm",
"seer",
"forge",
"quill",
"pixel",
"lyra",
"reel",
}
)
_AGENT_PATTERN = re.compile(
r"\bfor\s+(" + "|".join(_KNOWN_AGENTS) + r")\b", re.IGNORECASE
)
# Priority keywords → task priority mapping
_PRIORITY_MAP = {
"urgent": "urgent",
"critical": "urgent",
"asap": "urgent",
"emergency": "urgent",
"high priority": "high",
"high-priority": "high",
"important": "high",
"low priority": "low",
"low-priority": "low",
"minor": "low",
}
# Queue context detection
_QUEUE_QUERY_PATTERN = re.compile(
r"\b(?:task(?:s|\s+queue)?|queue|what(?:'s| is) (?:in |on )?(?:the )?queue)\b",
re.IGNORECASE,
)
def _extract_agent_from_message(message: str) -> str:
"""Extract target agent name from message, defaulting to 'timmy'."""
m = _AGENT_PATTERN.search(message)
if m:
return m.group(1).lower()
return "timmy"
def _extract_priority_from_message(message: str) -> str:
"""Extract priority level from message, defaulting to 'normal'."""
msg_lower = message.lower()
for keyword, priority in sorted(_PRIORITY_MAP.items(), key=lambda x: -len(x[0])):
if keyword in msg_lower:
return priority
return "normal"
def _extract_task_from_message(message: str) -> dict | None:
"""If the message looks like a task-queue request, return task details.
Returns None for meta-questions about tasks (e.g. "how do I create a task?").
"""
if _QUESTION_PREFIXES.search(message) or _QUESTION_FRAMES.search(message):
return None
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|an?)?(?:\s+\w+){0,3}\s*(?:queue|task(?:\s*queue)?|task list)\b",
"",
message,
flags=re.IGNORECASE,
).strip(" ,:;-")
# Strip "for {agent}" from title
title = _AGENT_PATTERN.sub("", title).strip(" ,:;-")
# Strip priority keywords from title
title = re.sub(
r"\b(?:urgent|critical|asap|emergency|high[- ]priority|important|low[- ]priority|minor)\b",
"",
title,
flags=re.IGNORECASE,
).strip(" ,:;-")
# Strip leading "to " that often remains
title = re.sub(r"^to\s+", "", title, flags=re.IGNORECASE).strip()
# Clean up double spaces
title = re.sub(r"\s{2,}", " ", title).strip()
# Fallback to full message if stripping removed everything
if not title or len(title) < 5:
title = message
# Capitalize first letter
title = title[0].upper() + title[1:] if title else title
agent = _extract_agent_from_message(message)
priority = _extract_priority_from_message(message)
return {
"title": title[:120],
"description": message,
"agent": agent,
"priority": priority,
}
return None
def _build_queue_context() -> str:
"""Build a concise task queue summary for context injection."""
try:
from swarm.task_queue.models import get_counts_by_status, list_tasks, TaskStatus
counts = get_counts_by_status()
pending = counts.get("pending_approval", 0)
running = counts.get("running", 0)
completed = counts.get("completed", 0)
parts = [
f"[System: Task queue — {pending} pending approval, {running} running, {completed} completed."
]
if pending > 0:
tasks = list_tasks(status=TaskStatus.PENDING_APPROVAL, limit=5)
if tasks:
items = ", ".join(f'"{t.title}" ({t.assigned_to})' for t in tasks)
parts.append(f"Pending: {items}.")
if running > 0:
tasks = list_tasks(status=TaskStatus.RUNNING, limit=5)
if tasks:
items = ", ".join(f'"{t.title}" ({t.assigned_to})' for t in tasks)
parts.append(f"Running: {items}.")
return " ".join(parts) + "]"
except Exception as exc:
logger.debug("Failed to build queue context: %s", exc)
return ""
# Static metadata for known agents — enriched onto live registry entries.
_AGENT_METADATA: dict[str, dict] = {
"timmy": {
"type": "sovereign",
"model": "", # Injected dynamically from settings
"backend": "ollama",
"version": "1.0.0",
},
}
@router.get("")
async def list_agents():
"""Return all registered agents with live status from the swarm registry."""
from swarm import registry as swarm_registry
from config import settings
# Inject model name from settings into timmy metadata
metadata = dict(_AGENT_METADATA)
if "timmy" in metadata and not metadata["timmy"].get("model"):
metadata["timmy"]["model"] = settings.ollama_model
agents = swarm_registry.list_agents()
return {
"agents": [
{
"id": a.id,
"name": a.name,
"status": a.status,
"capabilities": a.capabilities,
**metadata.get(a.id, {}),
}
for a in agents
]
}
@router.get("/timmy/panel", response_class=HTMLResponse)
async def timmy_panel(request: Request):
"""Timmy chat panel — for HTMX main-panel swaps."""
from swarm import registry as swarm_registry
agent = swarm_registry.get_agent("timmy")
return templates.TemplateResponse(
request, "partials/timmy_panel.html", {"agent": agent}
)
@router.get("/timmy/history", response_class=HTMLResponse)
async def get_history(request: Request):
return templates.TemplateResponse(
request,
"partials/history.html",
{"messages": message_log.all()},
)
@router.delete("/timmy/history", response_class=HTMLResponse)
async def clear_history(request: Request):
message_log.clear()
return templates.TemplateResponse(
request,
"partials/history.html",
{"messages": []},
)
@router.post("/timmy/chat", response_class=HTMLResponse)
async def chat_timmy(request: Request, message: str = Form(...)):
timestamp = datetime.now().strftime("%H:%M:%S")
response_text = None
error_text = None
# Check if the user wants to queue a task instead of chatting
task_info = _extract_task_from_message(message)
if task_info:
try:
from swarm.task_queue.models import create_task
task = create_task(
title=task_info["title"],
description=task_info["description"],
created_by="user",
assigned_to=task_info.get("agent", "timmy"),
priority=task_info.get("priority", "normal"),
requires_approval=True,
)
priority_label = (
f" | Priority: `{task.priority.value}`"
if task.priority.value != "normal"
else ""
)
response_text = (
f"Task queued for approval: **{task.title}**\n\n"
f"Assigned to: `{task.assigned_to}`{priority_label} | "
f"Status: `{task.status.value}` | "
f"[View Task Queue](/tasks)"
)
logger.info(
"Chat → task queue: %s%s (id=%s)",
task.title,
task.assigned_to,
task.id,
)
except Exception as exc:
logger.error("Failed to create task from chat: %s", exc)
task_info = None
# Normal chat path (also used as fallback if task creation failed)
if not task_info:
try:
now = datetime.now()
context_parts = [
f"[System: Current date/time is {now.strftime('%A, %B %d, %Y at %I:%M %p')}]"
]
if _QUEUE_QUERY_PATTERN.search(message):
queue_ctx = _build_queue_context()
if queue_ctx:
context_parts.append(queue_ctx)
context_prefix = "\n".join(context_parts) + "\n\n"
response_text = timmy_chat(context_prefix + 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:
message_log.append(role="agent", content=response_text, timestamp=timestamp)
else:
message_log.append(role="error", content=error_text, timestamp=timestamp)
return templates.TemplateResponse(
request,
"partials/chat_message.html",
{
"user_message": message,
"response": response_text,
"error": error_text,
"timestamp": timestamp,
},
)