forked from Rockachopa/Timmy-time-dashboard
feat: Phase 1 autonomy upgrades — introspection, heartbeat, source tagging, Discord auto-detect (#101)
UC-01: Live System Introspection Tool - Add get_task_queue_status(), get_agent_roster(), get_live_system_status() to timmy/tools_intro with graceful degradation - Enhanced get_memory_status() with line counts, section headers, vault directory listing, semantic memory row count, self-coding journal stats - Register system_status MCP tool (creative/tools/system_status.py) - Add system_status to Timmy's tool list + Hard Rule #7 UC-02: Fix Offline Status Bug - Add registry.heartbeat() calls in task_processor run_loop() and process_single_task() so health endpoint reflects actual agent status - health.py now consults swarm registry instead of Ollama connectivity UC-03: Message Source Tagging - Add source field to Message dataclass (default "browser") - Tag all message_log.append() calls: browser, api, system - Include source in /api/chat/history response UC-04: Discord Token Auto-Detection & Docker Fix - Add _discord_token_watcher() background coroutine that polls every 30s for DISCORD_TOKEN in env vars, .env file, or state file - Add --extras discord to all three Dockerfiles (main, dashboard, test) All 26 Phase 1 tests pass in Docker (make test-docker). Full suite: 1889 passed, 77 skipped, 0 failed. Co-authored-by: Alexander Payne <apayne@MM.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
89cfe1be0d
commit
6eefcabc97
@@ -27,7 +27,7 @@ RUN pip install --no-cache-dir poetry poetry-plugin-export
|
|||||||
COPY pyproject.toml poetry.lock ./
|
COPY pyproject.toml poetry.lock ./
|
||||||
|
|
||||||
# Export pinned requirements and install with pip cache mount
|
# Export pinned requirements and install with pip cache mount
|
||||||
RUN poetry export --extras swarm --extras telegram --without-hashes \
|
RUN poetry export --extras swarm --extras telegram --extras discord --without-hashes \
|
||||||
-f requirements.txt -o requirements.txt
|
-f requirements.txt -o requirements.txt
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ RUN pip install --no-cache-dir poetry poetry-plugin-export
|
|||||||
COPY pyproject.toml poetry.lock ./
|
COPY pyproject.toml poetry.lock ./
|
||||||
|
|
||||||
# Export pinned requirements and install with pip
|
# Export pinned requirements and install with pip
|
||||||
RUN poetry export --extras swarm --extras telegram --without-hashes \
|
RUN poetry export --extras swarm --extras telegram --extras discord --without-hashes \
|
||||||
-f requirements.txt -o requirements.txt
|
-f requirements.txt -o requirements.txt
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ RUN pip install --no-cache-dir poetry poetry-plugin-export
|
|||||||
COPY pyproject.toml poetry.lock ./
|
COPY pyproject.toml poetry.lock ./
|
||||||
|
|
||||||
# Export ALL deps including dev/test extras
|
# Export ALL deps including dev/test extras
|
||||||
RUN poetry export --extras swarm --extras telegram --extras dev \
|
RUN poetry export --extras swarm --extras telegram --extras discord --extras dev \
|
||||||
--with dev --without-hashes \
|
--with dev --without-hashes \
|
||||||
-f requirements.txt -o requirements.txt
|
-f requirements.txt -o requirements.txt
|
||||||
|
|
||||||
|
|||||||
51
src/creative/tools/system_status.py
Normal file
51
src/creative/tools/system_status.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""System status introspection tool for Timmy.
|
||||||
|
|
||||||
|
MCP-compliant tool that gives Timmy live access to his own system state:
|
||||||
|
task queue, agent roster, memory tiers, uptime, and service health.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mcp.registry import register_tool
|
||||||
|
from mcp.schemas.base import create_tool_schema, RETURN_STRING
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_STATUS_SCHEMA = create_tool_schema(
|
||||||
|
name="system_status",
|
||||||
|
description=(
|
||||||
|
"Get live system status including task queue counts, agent roster, "
|
||||||
|
"memory tier health, uptime, and service connectivity. "
|
||||||
|
"Use this when asked about your status, what you're working on, "
|
||||||
|
"agent health, or system metrics. Never guess — always call this tool."
|
||||||
|
),
|
||||||
|
parameters={},
|
||||||
|
required=[],
|
||||||
|
returns=RETURN_STRING,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def system_status() -> str:
|
||||||
|
"""Return comprehensive live system status as formatted text.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON-formatted string with system, task_queue, agents, memory sections.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from timmy.tools_intro import get_live_system_status
|
||||||
|
|
||||||
|
status = get_live_system_status()
|
||||||
|
return json.dumps(status, indent=2, default=str)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("system_status tool failed: %s", exc)
|
||||||
|
return json.dumps({"error": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
# Register with MCP
|
||||||
|
register_tool(
|
||||||
|
name="system_status",
|
||||||
|
schema=SYSTEM_STATUS_SCHEMA,
|
||||||
|
category="system",
|
||||||
|
)(system_status)
|
||||||
@@ -243,7 +243,7 @@ async def _task_processor_loop() -> None:
|
|||||||
try:
|
try:
|
||||||
from dashboard.store import message_log
|
from dashboard.store import message_log
|
||||||
timestamp = now.strftime("%H:%M:%S")
|
timestamp = now.strftime("%H:%M:%S")
|
||||||
message_log.append(role="agent", content=response, timestamp=timestamp)
|
message_log.append(role="agent", content=response, timestamp=timestamp, source="system")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Failed to log response to message_log: %s", e)
|
logger.debug("Failed to log response to message_log: %s", e)
|
||||||
|
|
||||||
@@ -453,6 +453,50 @@ async def _start_chat_integrations_background() -> None:
|
|||||||
else:
|
else:
|
||||||
logger.debug("Discord: no token configured, skipping")
|
logger.debug("Discord: no token configured, skipping")
|
||||||
|
|
||||||
|
# If Discord isn't connected yet, start a watcher that polls for the
|
||||||
|
# token to appear in the environment or .env file.
|
||||||
|
if discord_bot.state.name != "CONNECTED":
|
||||||
|
asyncio.create_task(_discord_token_watcher())
|
||||||
|
|
||||||
|
|
||||||
|
async def _discord_token_watcher() -> None:
|
||||||
|
"""Poll for DISCORD_TOKEN appearing in env or .env and auto-start Discord bot."""
|
||||||
|
from integrations.chat_bridge.vendors.discord import discord_bot
|
||||||
|
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
|
||||||
|
if discord_bot.state.name == "CONNECTED":
|
||||||
|
return # Already running — stop watching
|
||||||
|
|
||||||
|
# 1. Check live environment variable
|
||||||
|
token = os.environ.get("DISCORD_TOKEN", "")
|
||||||
|
|
||||||
|
# 2. Re-read .env file for hot-reload
|
||||||
|
if not token:
|
||||||
|
try:
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
env_path = Path(settings.repo_root) / ".env"
|
||||||
|
if env_path.exists():
|
||||||
|
vals = dotenv_values(env_path)
|
||||||
|
token = vals.get("DISCORD_TOKEN", "")
|
||||||
|
except ImportError:
|
||||||
|
pass # python-dotenv not installed
|
||||||
|
|
||||||
|
# 3. Check state file (written by /discord/setup)
|
||||||
|
if not token:
|
||||||
|
token = discord_bot.load_token() or ""
|
||||||
|
|
||||||
|
if token:
|
||||||
|
try:
|
||||||
|
success = await discord_bot.start(token=token)
|
||||||
|
if success:
|
||||||
|
logger.info("Discord bot auto-started (token detected)")
|
||||||
|
return # Done — stop watching
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Discord auto-start failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
|||||||
@@ -319,13 +319,13 @@ async def chat_timmy(request: Request, message: str = Form(...)):
|
|||||||
# Log user message to history. For chat_response tasks the real agent
|
# Log user message to history. For chat_response tasks the real agent
|
||||||
# reply is logged by the task processor when it completes, so we only
|
# reply is logged by the task processor when it completes, so we only
|
||||||
# log the queue acknowledgment for explicit task_request commands.
|
# log the queue acknowledgment for explicit task_request commands.
|
||||||
message_log.append(role="user", content=message, timestamp=timestamp)
|
message_log.append(role="user", content=message, timestamp=timestamp, source="browser")
|
||||||
if task_info and response_text is not None:
|
if task_info and response_text is not None:
|
||||||
# Explicit task queue command — the acknowledgment IS the response
|
# Explicit task queue command — the acknowledgment IS the response
|
||||||
message_log.append(role="agent", content=response_text, timestamp=timestamp)
|
message_log.append(role="agent", content=response_text, timestamp=timestamp, source="browser")
|
||||||
elif error_text:
|
elif error_text:
|
||||||
message_log.append(
|
message_log.append(
|
||||||
role="error", content=error_text, timestamp=timestamp
|
role="error", content=error_text, timestamp=timestamp, source="browser"
|
||||||
)
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
|||||||
@@ -84,16 +84,16 @@ async def api_chat(request: Request):
|
|||||||
session_id="mobile",
|
session_id="mobile",
|
||||||
)
|
)
|
||||||
|
|
||||||
message_log.append(role="user", content=last_user_msg, timestamp=timestamp)
|
message_log.append(role="user", content=last_user_msg, timestamp=timestamp, source="api")
|
||||||
message_log.append(role="agent", content=response_text, timestamp=timestamp)
|
message_log.append(role="agent", content=response_text, timestamp=timestamp, source="api")
|
||||||
|
|
||||||
return {"reply": response_text, "timestamp": timestamp}
|
return {"reply": response_text, "timestamp": timestamp}
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error_msg = f"Timmy is offline: {exc}"
|
error_msg = f"Timmy is offline: {exc}"
|
||||||
logger.error("api_chat error: %s", exc)
|
logger.error("api_chat error: %s", exc)
|
||||||
message_log.append(role="user", content=last_user_msg, timestamp=timestamp)
|
message_log.append(role="user", content=last_user_msg, timestamp=timestamp, source="api")
|
||||||
message_log.append(role="error", content=error_msg, timestamp=timestamp)
|
message_log.append(role="error", content=error_msg, timestamp=timestamp, source="api")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
content={"error": error_msg, "timestamp": timestamp},
|
content={"error": error_msg, "timestamp": timestamp},
|
||||||
@@ -141,6 +141,7 @@ async def api_chat_history():
|
|||||||
"role": msg.role,
|
"role": msg.role,
|
||||||
"content": msg.content,
|
"content": msg.content,
|
||||||
"timestamp": msg.timestamp,
|
"timestamp": msg.timestamp,
|
||||||
|
"source": msg.source,
|
||||||
}
|
}
|
||||||
for msg in message_log.all()
|
for msg in message_log.all()
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ async def grok_chat(request: Request, message: str = Form(...)):
|
|||||||
|
|
||||||
if not grok_available():
|
if not grok_available():
|
||||||
error = "Grok is not available. Set GROK_ENABLED=true and XAI_API_KEY."
|
error = "Grok is not available. Set GROK_ENABLED=true and XAI_API_KEY."
|
||||||
message_log.append(role="user", content=f"[Grok] {message}", timestamp=timestamp)
|
message_log.append(role="user", content=f"[Grok] {message}", timestamp=timestamp, source="browser")
|
||||||
message_log.append(role="error", content=error, timestamp=timestamp)
|
message_log.append(role="error", content=error, timestamp=timestamp, source="browser")
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"partials/chat_message.html",
|
"partials/chat_message.html",
|
||||||
@@ -144,10 +144,10 @@ async def grok_chat(request: Request, message: str = Form(...)):
|
|||||||
error = f"Grok error: {exc}"
|
error = f"Grok error: {exc}"
|
||||||
|
|
||||||
message_log.append(
|
message_log.append(
|
||||||
role="user", content=f"[Ask Grok] {message}", timestamp=timestamp
|
role="user", content=f"[Ask Grok] {message}", timestamp=timestamp, source="browser"
|
||||||
)
|
)
|
||||||
if response_text:
|
if response_text:
|
||||||
message_log.append(role="agent", content=response_text, timestamp=timestamp)
|
message_log.append(role="agent", content=response_text, timestamp=timestamp, source="browser")
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"partials/chat_message.html",
|
"partials/chat_message.html",
|
||||||
@@ -159,7 +159,7 @@ async def grok_chat(request: Request, message: str = Form(...)):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
message_log.append(role="error", content=error, timestamp=timestamp)
|
message_log.append(role="error", content=error, timestamp=timestamp, source="browser")
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"partials/chat_message.html",
|
"partials/chat_message.html",
|
||||||
|
|||||||
@@ -253,13 +253,22 @@ async def health_check():
|
|||||||
# Legacy format for test compatibility
|
# Legacy format for test compatibility
|
||||||
ollama_ok = await check_ollama()
|
ollama_ok = await check_ollama()
|
||||||
|
|
||||||
|
# Determine Timmy's status from swarm registry (heartbeat-backed),
|
||||||
|
# falling back to Ollama connectivity only if not registered.
|
||||||
|
try:
|
||||||
|
from swarm import registry as swarm_registry
|
||||||
|
timmy_rec = swarm_registry.get_agent("timmy")
|
||||||
|
timmy_status = timmy_rec.status if timmy_rec else ("idle" if ollama_ok else "offline")
|
||||||
|
except Exception:
|
||||||
|
timmy_status = "idle" if ollama_ok else "offline"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok" if ollama_ok else "degraded",
|
"status": "ok" if ollama_ok else "degraded",
|
||||||
"services": {
|
"services": {
|
||||||
"ollama": "up" if ollama_ok else "down",
|
"ollama": "up" if ollama_ok else "down",
|
||||||
},
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"timmy": {"status": "idle" if ollama_ok else "offline"},
|
"timmy": {"status": timmy_status},
|
||||||
},
|
},
|
||||||
# Extended fields for Mission Control
|
# Extended fields for Mission Control
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class Message:
|
|||||||
role: str # "user" | "agent" | "error"
|
role: str # "user" | "agent" | "error"
|
||||||
content: str
|
content: str
|
||||||
timestamp: str
|
timestamp: str
|
||||||
|
source: str = "browser" # "browser" | "api" | "telegram" | "discord" | "system"
|
||||||
|
|
||||||
|
|
||||||
class MessageLog:
|
class MessageLog:
|
||||||
@@ -14,8 +15,8 @@ class MessageLog:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._entries: list[Message] = []
|
self._entries: list[Message] = []
|
||||||
|
|
||||||
def append(self, role: str, content: str, timestamp: str) -> None:
|
def append(self, role: str, content: str, timestamp: str, source: str = "browser") -> None:
|
||||||
self._entries.append(Message(role=role, content=content, timestamp=timestamp))
|
self._entries.append(Message(role=role, content=content, timestamp=timestamp, source=source))
|
||||||
|
|
||||||
def all(self) -> list[Message]:
|
def all(self) -> list[Message]:
|
||||||
return list(self._entries)
|
return list(self._entries)
|
||||||
|
|||||||
@@ -126,6 +126,13 @@ class TaskProcessor:
|
|||||||
self._current_task = task
|
self._current_task = task
|
||||||
update_task_status(task.id, TaskStatus.RUNNING)
|
update_task_status(task.id, TaskStatus.RUNNING)
|
||||||
|
|
||||||
|
# Heartbeat on task start
|
||||||
|
try:
|
||||||
|
from swarm.registry import heartbeat
|
||||||
|
heartbeat(self.agent_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Processing task: %s (type: %s)", task.title, task.task_type)
|
logger.info("Processing task: %s (type: %s)", task.title, task.task_type)
|
||||||
|
|
||||||
@@ -263,6 +270,13 @@ class TaskProcessor:
|
|||||||
logger.info("Task processor started for %s", self.agent_id)
|
logger.info("Task processor started for %s", self.agent_id)
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
|
# Heartbeat — update last_seen so health endpoint knows we're alive
|
||||||
|
try:
|
||||||
|
from swarm.registry import heartbeat
|
||||||
|
heartbeat(self.agent_id)
|
||||||
|
except Exception:
|
||||||
|
pass # Graceful degradation
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.process_next_task()
|
await self.process_next_task()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ Use `memory_search` when the user refers to past conversations.
|
|||||||
|
|
||||||
6. **Your source code lives at the repository root shown above.** When using git tools, you don't need to specify a path — they automatically run from {REPO_ROOT}.
|
6. **Your source code lives at the repository root shown above.** When using git tools, you don't need to specify a path — they automatically run from {REPO_ROOT}.
|
||||||
|
|
||||||
|
7. **When asked about your status, queue, agents, memory, or system health, use the `system_status` tool.** Do not guess your own state — call the tool for live data.
|
||||||
|
|
||||||
## Principles
|
## Principles
|
||||||
|
|
||||||
1. **Sovereignty** — Everything local, no cloud
|
1. **Sovereignty** — Everything local, no cloud
|
||||||
@@ -262,7 +264,7 @@ class TimmyOrchestrator(BaseAgent):
|
|||||||
name="Timmy",
|
name="Timmy",
|
||||||
role="orchestrator",
|
role="orchestrator",
|
||||||
system_prompt=formatted_prompt,
|
system_prompt=formatted_prompt,
|
||||||
tools=["web_search", "read_file", "write_file", "python", "memory_search", "memory_write"],
|
tools=["web_search", "read_file", "write_file", "python", "memory_search", "memory_write", "system_status"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sub-agent registry
|
# Sub-agent registry
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ This provides true sovereignty - Timmy introspects his environment rather than
|
|||||||
being told about it in the system prompt.
|
being told about it in the system prompt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import platform
|
import platform
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_system_info() -> dict[str, Any]:
|
def get_system_info() -> dict[str, Any]:
|
||||||
"""Introspect the runtime environment to discover system information.
|
"""Introspect the runtime environment to discover system information.
|
||||||
@@ -127,16 +131,207 @@ def get_memory_status() -> dict[str, Any]:
|
|||||||
if tier2_exists:
|
if tier2_exists:
|
||||||
tier2_files = [f.name for f in vault_path.iterdir() if f.is_file()]
|
tier2_files = [f.name for f in vault_path.iterdir() if f.is_file()]
|
||||||
|
|
||||||
return {
|
tier1_info: dict[str, Any] = {
|
||||||
"tier1_hot_memory": {
|
"exists": tier1_exists,
|
||||||
"exists": tier1_exists,
|
"path": str(memory_md),
|
||||||
"path": str(memory_md),
|
"preview": tier1_content[:200] if tier1_content else None,
|
||||||
"preview": tier1_content[:200] if tier1_content else None,
|
|
||||||
},
|
|
||||||
"tier2_vault": {
|
|
||||||
"exists": tier2_exists,
|
|
||||||
"path": str(vault_path),
|
|
||||||
"file_count": len(tier2_files),
|
|
||||||
"files": tier2_files[:10], # First 10 files
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
if tier1_exists:
|
||||||
|
lines = memory_md.read_text().splitlines()
|
||||||
|
tier1_info["line_count"] = len(lines)
|
||||||
|
tier1_info["sections"] = [
|
||||||
|
ln.lstrip("# ").strip() for ln in lines if ln.startswith("## ")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Vault — scan all subdirs under memory/
|
||||||
|
vault_root = repo_root / "memory"
|
||||||
|
vault_info: dict[str, Any] = {
|
||||||
|
"exists": tier2_exists,
|
||||||
|
"path": str(vault_path),
|
||||||
|
"file_count": len(tier2_files),
|
||||||
|
"files": tier2_files[:10],
|
||||||
|
}
|
||||||
|
if vault_root.exists():
|
||||||
|
vault_info["directories"] = [d.name for d in vault_root.iterdir() if d.is_dir()]
|
||||||
|
vault_info["total_markdown_files"] = sum(1 for _ in vault_root.rglob("*.md"))
|
||||||
|
|
||||||
|
# Tier 3: Semantic memory row count
|
||||||
|
tier3_info: dict[str, Any] = {"available": False}
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
sem_db = repo_root / "data" / "semantic_memory.db"
|
||||||
|
if sem_db.exists():
|
||||||
|
conn = sqlite3.connect(str(sem_db))
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='vectors'"
|
||||||
|
).fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM vectors").fetchone()
|
||||||
|
tier3_info["available"] = True
|
||||||
|
tier3_info["vector_count"] = count[0] if count else 0
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Self-coding journal stats
|
||||||
|
journal_info: dict[str, Any] = {"available": False}
|
||||||
|
try:
|
||||||
|
import sqlite3 as _sqlite3
|
||||||
|
|
||||||
|
journal_db = repo_root / "data" / "self_coding.db"
|
||||||
|
if journal_db.exists():
|
||||||
|
conn = _sqlite3.connect(str(journal_db))
|
||||||
|
conn.row_factory = _sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT outcome, COUNT(*) as cnt FROM modification_journal GROUP BY outcome"
|
||||||
|
).fetchall()
|
||||||
|
if rows:
|
||||||
|
counts = {r["outcome"]: r["cnt"] for r in rows}
|
||||||
|
total = sum(counts.values())
|
||||||
|
journal_info = {
|
||||||
|
"available": True,
|
||||||
|
"total_attempts": total,
|
||||||
|
"successes": counts.get("success", 0),
|
||||||
|
"failures": counts.get("failure", 0),
|
||||||
|
"success_rate": round(counts.get("success", 0) / total, 2) if total else 0,
|
||||||
|
}
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tier1_hot_memory": tier1_info,
|
||||||
|
"tier2_vault": vault_info,
|
||||||
|
"tier3_semantic": tier3_info,
|
||||||
|
"self_coding_journal": journal_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_queue_status() -> dict[str, Any]:
|
||||||
|
"""Get current task queue status for Timmy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with queue counts by status and current task info.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from swarm.task_queue.models import (
|
||||||
|
get_counts_by_status,
|
||||||
|
get_current_task_for_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
counts = get_counts_by_status()
|
||||||
|
current = get_current_task_for_agent("timmy")
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"counts": counts,
|
||||||
|
"total": sum(counts.values()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if current:
|
||||||
|
result["current_task"] = {
|
||||||
|
"id": current.id,
|
||||||
|
"title": current.title,
|
||||||
|
"type": current.task_type,
|
||||||
|
"started_at": current.started_at,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result["current_task"] = None
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Task queue status unavailable: %s", exc)
|
||||||
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_roster() -> dict[str, Any]:
|
||||||
|
"""Get the swarm agent roster with last-seen ages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with agent list and summary.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from swarm.registry import list_agents
|
||||||
|
|
||||||
|
agents = list_agents()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
roster = []
|
||||||
|
|
||||||
|
for agent in agents:
|
||||||
|
last_seen = agent.last_seen
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(last_seen)
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
age_seconds = int((now - ts).total_seconds())
|
||||||
|
except Exception:
|
||||||
|
age_seconds = -1
|
||||||
|
|
||||||
|
roster.append({
|
||||||
|
"id": agent.id,
|
||||||
|
"name": agent.name,
|
||||||
|
"status": agent.status,
|
||||||
|
"capabilities": agent.capabilities,
|
||||||
|
"last_seen_seconds_ago": age_seconds,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"agents": roster,
|
||||||
|
"total": len(roster),
|
||||||
|
"idle": sum(1 for a in roster if a["status"] == "idle"),
|
||||||
|
"busy": sum(1 for a in roster if a["status"] == "busy"),
|
||||||
|
"offline": sum(1 for a in roster if a["status"] == "offline"),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Agent roster unavailable: %s", exc)
|
||||||
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_live_system_status() -> dict[str, Any]:
|
||||||
|
"""Comprehensive live system status — Timmy's primary introspection tool.
|
||||||
|
|
||||||
|
Combines system info, task queue, agent roster, and memory status
|
||||||
|
into a single snapshot. Each subsystem degrades gracefully.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with system, task_queue, agents, memory, and uptime sections.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# System info
|
||||||
|
try:
|
||||||
|
result["system"] = get_system_info()
|
||||||
|
except Exception as exc:
|
||||||
|
result["system"] = {"error": str(exc)}
|
||||||
|
|
||||||
|
# Task queue
|
||||||
|
result["task_queue"] = get_task_queue_status()
|
||||||
|
|
||||||
|
# Agent roster
|
||||||
|
result["agents"] = get_agent_roster()
|
||||||
|
|
||||||
|
# Memory status
|
||||||
|
try:
|
||||||
|
result["memory"] = get_memory_status()
|
||||||
|
except Exception as exc:
|
||||||
|
result["memory"] = {"error": str(exc)}
|
||||||
|
|
||||||
|
# Uptime
|
||||||
|
try:
|
||||||
|
from dashboard.routes.health import _START_TIME
|
||||||
|
|
||||||
|
uptime = (datetime.now(timezone.utc) - _START_TIME).total_seconds()
|
||||||
|
result["uptime_seconds"] = int(uptime)
|
||||||
|
except Exception:
|
||||||
|
result["uptime_seconds"] = None
|
||||||
|
|
||||||
|
# Discord status
|
||||||
|
try:
|
||||||
|
from integrations.chat_bridge.vendors.discord import discord_bot
|
||||||
|
|
||||||
|
result["discord"] = {"state": discord_bot.state.name}
|
||||||
|
except Exception:
|
||||||
|
result["discord"] = {"state": "unknown"}
|
||||||
|
|
||||||
|
result["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
return result
|
||||||
|
|||||||
363
tests/test_autonomy_phase1.py
Normal file
363
tests/test_autonomy_phase1.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
"""Tests for Phase 1 Autonomy Upgrades: UC-01 through UC-04.
|
||||||
|
|
||||||
|
UC-01: Live System Introspection Tool
|
||||||
|
UC-02: Offline Status Bug Fix (heartbeat + health endpoint)
|
||||||
|
UC-03: Message Source Tagging
|
||||||
|
UC-04: Discord Token Auto-Detection
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ── UC-01: Live System Introspection ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetTaskQueueStatus:
|
||||||
|
"""Test the task queue introspection function."""
|
||||||
|
|
||||||
|
def test_returns_counts_and_total(self):
|
||||||
|
from timmy.tools_intro import get_task_queue_status
|
||||||
|
|
||||||
|
result = get_task_queue_status()
|
||||||
|
assert "counts" in result or "error" in result
|
||||||
|
if "counts" in result:
|
||||||
|
assert "total" in result
|
||||||
|
assert isinstance(result["total"], int)
|
||||||
|
|
||||||
|
def test_current_task_none_when_idle(self):
|
||||||
|
from timmy.tools_intro import get_task_queue_status
|
||||||
|
|
||||||
|
result = get_task_queue_status()
|
||||||
|
if "counts" in result:
|
||||||
|
assert result["current_task"] is None
|
||||||
|
|
||||||
|
def test_graceful_degradation_on_import_error(self):
|
||||||
|
"""Should return an error dict, not raise."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from timmy.tools_intro import get_task_queue_status
|
||||||
|
|
||||||
|
# Temporarily block the swarm.task_queue.models import to force the
|
||||||
|
# except branch. Setting sys.modules[key] = None causes ImportError.
|
||||||
|
saved = sys.modules.pop("swarm.task_queue.models", "MISSING")
|
||||||
|
sys.modules["swarm.task_queue.models"] = None # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
result = get_task_queue_status()
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert "error" in result
|
||||||
|
finally:
|
||||||
|
# Restore the real module
|
||||||
|
del sys.modules["swarm.task_queue.models"]
|
||||||
|
if saved != "MISSING":
|
||||||
|
sys.modules["swarm.task_queue.models"] = saved
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAgentRoster:
|
||||||
|
"""Test the agent roster introspection function."""
|
||||||
|
|
||||||
|
def test_returns_roster_with_counts(self):
|
||||||
|
from swarm.registry import register
|
||||||
|
from timmy.tools_intro import get_agent_roster
|
||||||
|
|
||||||
|
register(name="TestAgent", capabilities="test", agent_id="test-agent-1")
|
||||||
|
result = get_agent_roster()
|
||||||
|
|
||||||
|
assert "agents" in result
|
||||||
|
assert "total" in result
|
||||||
|
assert result["total"] >= 1
|
||||||
|
|
||||||
|
def test_agent_has_last_seen_age(self):
|
||||||
|
from swarm.registry import register
|
||||||
|
from timmy.tools_intro import get_agent_roster
|
||||||
|
|
||||||
|
register(name="AgeTest", capabilities="test", agent_id="age-test-1")
|
||||||
|
result = get_agent_roster()
|
||||||
|
|
||||||
|
agents = result["agents"]
|
||||||
|
assert len(agents) >= 1
|
||||||
|
agent = next(a for a in agents if a["id"] == "age-test-1")
|
||||||
|
assert "last_seen_seconds_ago" in agent
|
||||||
|
assert agent["last_seen_seconds_ago"] >= 0
|
||||||
|
|
||||||
|
def test_summary_counts(self):
|
||||||
|
from timmy.tools_intro import get_agent_roster
|
||||||
|
|
||||||
|
result = get_agent_roster()
|
||||||
|
assert "idle" in result
|
||||||
|
assert "busy" in result
|
||||||
|
assert "offline" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLiveSystemStatus:
|
||||||
|
"""Test the composite introspection function."""
|
||||||
|
|
||||||
|
def test_returns_all_sections(self):
|
||||||
|
from timmy.tools_intro import get_live_system_status
|
||||||
|
|
||||||
|
result = get_live_system_status()
|
||||||
|
assert "system" in result
|
||||||
|
assert "task_queue" in result
|
||||||
|
assert "agents" in result
|
||||||
|
assert "memory" in result
|
||||||
|
assert "timestamp" in result
|
||||||
|
|
||||||
|
def test_uptime_present(self):
|
||||||
|
from timmy.tools_intro import get_live_system_status
|
||||||
|
|
||||||
|
result = get_live_system_status()
|
||||||
|
assert "uptime_seconds" in result
|
||||||
|
|
||||||
|
def test_discord_status_present(self):
|
||||||
|
from timmy.tools_intro import get_live_system_status
|
||||||
|
|
||||||
|
result = get_live_system_status()
|
||||||
|
assert "discord" in result
|
||||||
|
assert "state" in result["discord"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemStatusMCPTool:
|
||||||
|
"""Test the MCP-registered system_status tool."""
|
||||||
|
|
||||||
|
def test_tool_returns_json_string(self):
|
||||||
|
import json
|
||||||
|
|
||||||
|
from creative.tools.system_status import system_status
|
||||||
|
|
||||||
|
result = system_status()
|
||||||
|
# Should be valid JSON
|
||||||
|
parsed = json.loads(result)
|
||||||
|
assert isinstance(parsed, dict)
|
||||||
|
assert "system" in parsed or "error" in parsed
|
||||||
|
|
||||||
|
|
||||||
|
# ── UC-02: Offline Status Bug Fix ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeartbeat:
|
||||||
|
"""Test that the heartbeat mechanism updates last_seen."""
|
||||||
|
|
||||||
|
def test_heartbeat_updates_last_seen(self):
|
||||||
|
from swarm.registry import get_agent, heartbeat, register
|
||||||
|
|
||||||
|
register(name="HeartbeatTest", capabilities="test", agent_id="hb-test-1")
|
||||||
|
initial = get_agent("hb-test-1")
|
||||||
|
assert initial is not None
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
heartbeat("hb-test-1")
|
||||||
|
updated = get_agent("hb-test-1")
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.last_seen >= initial.last_seen
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthEndpointStatus:
|
||||||
|
"""Test that /health reflects registry status, not just Ollama."""
|
||||||
|
|
||||||
|
def test_health_returns_timmy_status(self, client):
|
||||||
|
"""Health endpoint should include agents.timmy.status."""
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "agents" in data
|
||||||
|
assert "timmy" in data["agents"]
|
||||||
|
assert "status" in data["agents"]["timmy"]
|
||||||
|
|
||||||
|
def test_health_status_from_registry(self, client):
|
||||||
|
"""Timmy's status should come from the swarm registry."""
|
||||||
|
from swarm.registry import register
|
||||||
|
|
||||||
|
# Register Timmy as idle (happens on app startup too)
|
||||||
|
register(name="Timmy", capabilities="chat", agent_id="timmy")
|
||||||
|
|
||||||
|
response = client.get("/health")
|
||||||
|
data = response.json()
|
||||||
|
# Should be "idle" from registry, not "offline"
|
||||||
|
assert data["agents"]["timmy"]["status"] in ("idle", "busy")
|
||||||
|
|
||||||
|
|
||||||
|
# ── UC-03: Message Source Tagging ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessageSourceField:
|
||||||
|
"""Test that the Message dataclass has a source field."""
|
||||||
|
|
||||||
|
def test_message_has_source_field(self):
|
||||||
|
from dashboard.store import Message
|
||||||
|
|
||||||
|
msg = Message(role="user", content="hello", timestamp="12:00:00")
|
||||||
|
assert hasattr(msg, "source")
|
||||||
|
assert msg.source == "browser" # Default
|
||||||
|
|
||||||
|
def test_message_custom_source(self):
|
||||||
|
from dashboard.store import Message
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
role="user", content="hello", timestamp="12:00:00", source="api"
|
||||||
|
)
|
||||||
|
assert msg.source == "api"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessageLogSource:
|
||||||
|
"""Test that MessageLog.append() accepts and stores source."""
|
||||||
|
|
||||||
|
def test_append_with_source(self):
|
||||||
|
from dashboard.store import message_log
|
||||||
|
|
||||||
|
message_log.append(
|
||||||
|
role="user", content="hello", timestamp="12:00:00", source="api"
|
||||||
|
)
|
||||||
|
entries = message_log.all()
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0].source == "api"
|
||||||
|
|
||||||
|
def test_append_default_source(self):
|
||||||
|
from dashboard.store import message_log
|
||||||
|
|
||||||
|
message_log.append(role="user", content="hello", timestamp="12:00:00")
|
||||||
|
entries = message_log.all()
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0].source == "browser"
|
||||||
|
|
||||||
|
def test_multiple_sources(self):
|
||||||
|
from dashboard.store import message_log
|
||||||
|
|
||||||
|
message_log.append(
|
||||||
|
role="user", content="from browser", timestamp="12:00:00", source="browser"
|
||||||
|
)
|
||||||
|
message_log.append(
|
||||||
|
role="user", content="from api", timestamp="12:00:01", source="api"
|
||||||
|
)
|
||||||
|
message_log.append(
|
||||||
|
role="agent", content="response", timestamp="12:00:02", source="system"
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = message_log.all()
|
||||||
|
assert len(entries) == 3
|
||||||
|
assert entries[0].source == "browser"
|
||||||
|
assert entries[1].source == "api"
|
||||||
|
assert entries[2].source == "system"
|
||||||
|
|
||||||
|
|
||||||
|
class TestChatHistoryIncludesSource:
|
||||||
|
"""Test that the /api/chat/history endpoint includes source."""
|
||||||
|
|
||||||
|
def test_history_includes_source_field(self, client):
|
||||||
|
from dashboard.store import message_log
|
||||||
|
|
||||||
|
message_log.append(
|
||||||
|
role="user", content="test msg", timestamp="12:00:00", source="api"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/chat/history")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["messages"]) == 1
|
||||||
|
assert data["messages"][0]["source"] == "api"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBrowserChatLogsSource:
|
||||||
|
"""Test that the browser chat route logs with source='browser'."""
|
||||||
|
|
||||||
|
def test_browser_chat_source(self, client):
|
||||||
|
with patch("swarm.task_queue.models.create_task") as mock_create:
|
||||||
|
mock_task = MagicMock()
|
||||||
|
mock_task.id = "test-id"
|
||||||
|
mock_task.title = "hello from browser"
|
||||||
|
mock_task.status = MagicMock(value="approved")
|
||||||
|
mock_task.priority = MagicMock(value="normal")
|
||||||
|
mock_task.assigned_to = "timmy"
|
||||||
|
mock_create.return_value = mock_task
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"swarm.task_queue.models.get_queue_status_for_task",
|
||||||
|
return_value={"position": 1, "total": 1, "percent_ahead": 0},
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/agents/timmy/chat",
|
||||||
|
data={"message": "hello from browser"},
|
||||||
|
)
|
||||||
|
|
||||||
|
from dashboard.store import message_log
|
||||||
|
|
||||||
|
entries = message_log.all()
|
||||||
|
assert len(entries) >= 1
|
||||||
|
assert entries[0].source == "browser"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPIChatLogsSource:
|
||||||
|
"""Test that the API chat route logs with source='api'."""
|
||||||
|
|
||||||
|
def test_api_chat_source(self, client):
|
||||||
|
with patch(
|
||||||
|
"dashboard.routes.chat_api.timmy_chat", return_value="Hi from Timmy"
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
"/api/chat",
|
||||||
|
json={"messages": [{"role": "user", "content": "hello from api"}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
from dashboard.store import message_log
|
||||||
|
|
||||||
|
entries = message_log.all()
|
||||||
|
assert len(entries) == 2 # user + agent
|
||||||
|
assert entries[0].source == "api"
|
||||||
|
assert entries[1].source == "api"
|
||||||
|
|
||||||
|
|
||||||
|
# ── UC-04: Discord Token Auto-Detection ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscordDockerfix:
|
||||||
|
"""Test that the Dockerfile includes discord extras."""
|
||||||
|
|
||||||
|
def _find_repo_root(self):
|
||||||
|
"""Walk up from this test file to find the repo root (has pyproject.toml)."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
d = Path(__file__).resolve().parent
|
||||||
|
while d != d.parent:
|
||||||
|
if (d / "pyproject.toml").exists():
|
||||||
|
return d
|
||||||
|
d = d.parent
|
||||||
|
return Path(__file__).resolve().parent.parent # fallback
|
||||||
|
|
||||||
|
def test_dashboard_dockerfile_includes_discord(self):
|
||||||
|
dockerfile = self._find_repo_root() / "docker" / "Dockerfile.dashboard"
|
||||||
|
if dockerfile.exists():
|
||||||
|
content = dockerfile.read_text()
|
||||||
|
assert "--extras discord" in content
|
||||||
|
|
||||||
|
def test_main_dockerfile_includes_discord(self):
|
||||||
|
dockerfile = self._find_repo_root() / "Dockerfile"
|
||||||
|
if dockerfile.exists():
|
||||||
|
content = dockerfile.read_text()
|
||||||
|
assert "--extras discord" in content
|
||||||
|
|
||||||
|
def test_test_dockerfile_includes_discord(self):
|
||||||
|
dockerfile = self._find_repo_root() / "docker" / "Dockerfile.test"
|
||||||
|
if dockerfile.exists():
|
||||||
|
content = dockerfile.read_text()
|
||||||
|
assert "--extras discord" in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscordTokenWatcher:
|
||||||
|
"""Test the Discord token watcher function exists and is wired."""
|
||||||
|
|
||||||
|
def test_watcher_function_exists(self):
|
||||||
|
from dashboard.app import _discord_token_watcher
|
||||||
|
|
||||||
|
assert callable(_discord_token_watcher)
|
||||||
|
|
||||||
|
def test_watcher_is_coroutine(self):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from dashboard.app import _discord_token_watcher
|
||||||
|
|
||||||
|
assert asyncio.iscoroutinefunction(_discord_token_watcher)
|
||||||
Reference in New Issue
Block a user