forked from Rockachopa/Timmy-time-dashboard
Add pre-commit hook enforcing 30s test suite time limit (#132)
This commit is contained in:
committed by
GitHub
parent
aff3edb06a
commit
2b97da9e9c
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -29,8 +29,8 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
mkdir -p reports
|
||||
pytest \
|
||||
--tb=short \
|
||||
--cov=src \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=xml:reports/coverage.xml \
|
||||
|
||||
@@ -51,12 +51,13 @@ repos:
|
||||
exclude: ^tests/
|
||||
stages: [manual]
|
||||
|
||||
# Fast unit tests only (not E2E, not slow tests)
|
||||
# Full test suite with 30-second wall-clock limit.
|
||||
# Current baseline: ~18s. If tests get slow, this blocks the commit.
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest-unit
|
||||
name: pytest-unit
|
||||
entry: pytest
|
||||
- id: pytest-fast
|
||||
name: pytest (30s limit)
|
||||
entry: timeout 30 poetry run pytest
|
||||
language: system
|
||||
types: [python]
|
||||
stages: [commit]
|
||||
@@ -64,8 +65,7 @@ repos:
|
||||
always_run: true
|
||||
args:
|
||||
- tests
|
||||
- -m
|
||||
- "unit"
|
||||
- --tb=short
|
||||
- -q
|
||||
- --tb=short
|
||||
- --timeout=10
|
||||
verbose: true
|
||||
|
||||
7
Makefile
7
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: install install-bigbrain dev nuke fresh test test-cov test-cov-html watch lint clean help \
|
||||
.PHONY: install install-bigbrain install-hooks dev nuke fresh test test-cov test-cov-html watch lint clean help \
|
||||
up down logs \
|
||||
docker-build docker-up docker-down docker-agent docker-logs docker-shell \
|
||||
test-docker test-docker-cov test-docker-functional test-docker-build test-docker-down \
|
||||
@@ -16,6 +16,11 @@ install:
|
||||
poetry install --with dev
|
||||
@echo "✓ Ready. Run 'make dev' to start the dashboard."
|
||||
|
||||
install-hooks:
|
||||
cp scripts/pre-commit-hook.sh .git/hooks/pre-commit
|
||||
chmod +x .git/hooks/pre-commit
|
||||
@echo "✓ Pre-commit hook installed (30s test time limit)."
|
||||
|
||||
install-bigbrain:
|
||||
poetry install --with dev --extras bigbrain
|
||||
@if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname -s)" = "Darwin" ]; then \
|
||||
|
||||
@@ -79,7 +79,10 @@ testpaths = ["tests"]
|
||||
pythonpath = ["src", "tests"]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
addopts = "-v --tb=short --timeout=30"
|
||||
timeout = 30
|
||||
timeout_method = "signal"
|
||||
timeout_func_only = false
|
||||
addopts = "-v --tb=short --strict-markers --disable-warnings -n auto --dist worksteal"
|
||||
markers = [
|
||||
"unit: Unit tests (fast, no I/O)",
|
||||
"integration: Integration tests (may use SQLite)",
|
||||
@@ -90,6 +93,7 @@ markers = [
|
||||
"selenium: Requires Selenium and Chrome (browser automation)",
|
||||
"docker: Requires Docker and docker-compose",
|
||||
"ollama: Requires Ollama service running",
|
||||
"external_api: Requires external API access",
|
||||
"skip_ci: Skip in CI environment (local development only)",
|
||||
]
|
||||
|
||||
|
||||
58
pytest.ini
58
pytest.ini
@@ -1,58 +0,0 @@
|
||||
[pytest]
|
||||
# Test discovery and execution configuration
|
||||
testpaths = tests
|
||||
pythonpath = src:tests
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
timeout = 30
|
||||
timeout_method = signal
|
||||
timeout_func_only = false
|
||||
|
||||
# Test markers for categorization and selective execution
|
||||
markers =
|
||||
unit: Unit tests (fast, no I/O, no external services)
|
||||
integration: Integration tests (may use SQLite, in-process agents)
|
||||
functional: Functional tests (real HTTP requests, no mocking)
|
||||
e2e: End-to-end tests (full system, may be slow)
|
||||
slow: Tests that take >1 second
|
||||
selenium: Requires Selenium and Chrome (browser automation)
|
||||
docker: Requires Docker and docker-compose
|
||||
ollama: Requires Ollama service running
|
||||
external_api: Requires external API access
|
||||
skip_ci: Skip in CI environment (local development only)
|
||||
|
||||
# Output and reporting
|
||||
# -n auto: run tests in parallel across all CPU cores (pytest-xdist)
|
||||
# Override with -n0 to disable parallelism for debugging
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
--disable-warnings
|
||||
-n auto
|
||||
--dist worksteal
|
||||
|
||||
# Coverage configuration
|
||||
[coverage:run]
|
||||
source = src
|
||||
omit =
|
||||
*/tests/*
|
||||
*/site-packages/*
|
||||
|
||||
[coverage:report]
|
||||
show_missing = true
|
||||
skip_empty = true
|
||||
precision = 1
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if __name__ == .__main__.
|
||||
if TYPE_CHECKING:
|
||||
raise NotImplementedError
|
||||
@abstractmethod
|
||||
fail_under = 60
|
||||
|
||||
[coverage:html]
|
||||
directory = htmlcov
|
||||
|
||||
[coverage:xml]
|
||||
output = coverage.xml
|
||||
22
scripts/pre-commit-hook.sh
Executable file
22
scripts/pre-commit-hook.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit hook: run tests with a wall-clock limit.
|
||||
# Blocks the commit if tests fail or take too long.
|
||||
# Current baseline: ~18s wall-clock. Limit set to 30s for headroom.
|
||||
|
||||
MAX_SECONDS=30
|
||||
|
||||
echo "Running tests (${MAX_SECONDS}s limit)..."
|
||||
|
||||
timeout "${MAX_SECONDS}" poetry run pytest tests -q --tb=short --timeout=10
|
||||
exit_code=$?
|
||||
|
||||
if [ "$exit_code" -eq 124 ]; then
|
||||
echo ""
|
||||
echo "BLOCKED: tests exceeded ${MAX_SECONDS}s wall-clock limit."
|
||||
echo "Speed up slow tests before committing."
|
||||
exit 1
|
||||
elif [ "$exit_code" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "BLOCKED: tests failed."
|
||||
exit 1
|
||||
fi
|
||||
@@ -14,7 +14,6 @@ from brain.client import BrainClient
|
||||
from brain.worker import DistributedWorker
|
||||
from brain.embeddings import LocalEmbedder
|
||||
from brain.memory import UnifiedMemory, get_memory
|
||||
from brain.identity import get_canonical_identity, get_identity_for_prompt
|
||||
|
||||
__all__ = [
|
||||
"BrainClient",
|
||||
@@ -22,6 +21,4 @@ __all__ = [
|
||||
"LocalEmbedder",
|
||||
"UnifiedMemory",
|
||||
"get_memory",
|
||||
"get_canonical_identity",
|
||||
"get_identity_for_prompt",
|
||||
]
|
||||
|
||||
@@ -36,7 +36,7 @@ class BrainClient:
|
||||
"""Detect what component is using the brain."""
|
||||
# Could be 'timmy', 'zeroclaw', 'worker', etc.
|
||||
# For now, infer from context or env
|
||||
return os.environ.get("BRAIN_SOURCE", "timmy")
|
||||
return os.environ.get("BRAIN_SOURCE", "default")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Memory Operations
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Identity loader — stripped.
|
||||
|
||||
The persona/identity system has been removed. These functions remain
|
||||
as no-op stubs so that call-sites don't break at import time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_canonical_identity(force_refresh: bool = False) -> str:
|
||||
"""Return empty string — identity system removed."""
|
||||
return ""
|
||||
|
||||
|
||||
def get_identity_section(section_name: str) -> str:
|
||||
"""Return empty string — identity system removed."""
|
||||
return ""
|
||||
|
||||
|
||||
def get_identity_for_prompt(include_sections: Optional[list[str]] = None) -> str:
|
||||
"""Return empty string — identity system removed."""
|
||||
return ""
|
||||
|
||||
|
||||
def get_agent_roster() -> list[dict[str, str]]:
|
||||
"""Return empty list — identity system removed."""
|
||||
return []
|
||||
|
||||
|
||||
_FALLBACK_IDENTITY = ""
|
||||
@@ -65,7 +65,7 @@ class UnifiedMemory:
|
||||
def __init__(
|
||||
self,
|
||||
db_path: Optional[Path] = None,
|
||||
source: str = "timmy",
|
||||
source: str = "default",
|
||||
use_rqlite: Optional[bool] = None,
|
||||
):
|
||||
self.db_path = db_path or _get_db_path()
|
||||
|
||||
@@ -4,6 +4,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Display name for the primary agent — override with AGENT_NAME env var
|
||||
agent_name: str = "Agent"
|
||||
|
||||
# Ollama host — override with OLLAMA_URL env var or .env file
|
||||
ollama_url: str = "http://localhost:11434"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ async def lifespan(app: FastAPI):
|
||||
# Start chat integrations in background
|
||||
chat_task = asyncio.create_task(_start_chat_integrations_background())
|
||||
|
||||
logger.info("✓ Timmy Time dashboard ready for requests")
|
||||
logger.info("✓ Dashboard ready for requests")
|
||||
|
||||
yield
|
||||
|
||||
@@ -227,7 +227,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Timmy Time — Mission Control",
|
||||
title="Mission Control",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs",
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from timmy.session import chat as timmy_chat
|
||||
from timmy.session import chat as agent_chat
|
||||
from dashboard.store import message_log
|
||||
from dashboard.templating import templates
|
||||
|
||||
@@ -21,8 +21,8 @@ async def list_agents():
|
||||
return {
|
||||
"agents": [
|
||||
{
|
||||
"id": "orchestrator",
|
||||
"name": "Orchestrator",
|
||||
"id": "default",
|
||||
"name": settings.agent_name,
|
||||
"status": "idle",
|
||||
"capabilities": "chat,reasoning,research,planning",
|
||||
"type": "local",
|
||||
@@ -34,15 +34,15 @@ async def list_agents():
|
||||
}
|
||||
|
||||
|
||||
@router.get("/timmy/panel", response_class=HTMLResponse)
|
||||
async def timmy_panel(request: Request):
|
||||
@router.get("/default/panel", response_class=HTMLResponse)
|
||||
async def agent_panel(request: Request):
|
||||
"""Chat panel — for HTMX main-panel swaps."""
|
||||
return templates.TemplateResponse(
|
||||
request, "partials/timmy_panel.html", {"agent": None}
|
||||
request, "partials/agent_panel_chat.html", {"agent": None}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/timmy/history", response_class=HTMLResponse)
|
||||
@router.get("/default/history", response_class=HTMLResponse)
|
||||
async def get_history(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -51,7 +51,7 @@ async def get_history(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/timmy/history", response_class=HTMLResponse)
|
||||
@router.delete("/default/history", response_class=HTMLResponse)
|
||||
async def clear_history(request: Request):
|
||||
message_log.clear()
|
||||
return templates.TemplateResponse(
|
||||
@@ -61,15 +61,15 @@ async def clear_history(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/timmy/chat", response_class=HTMLResponse)
|
||||
async def chat_timmy(request: Request, message: str = Form(...)):
|
||||
@router.post("/default/chat", response_class=HTMLResponse)
|
||||
async def chat_agent(request: Request, message: str = Form(...)):
|
||||
"""Chat — synchronous response."""
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
response_text = None
|
||||
error_text = None
|
||||
|
||||
try:
|
||||
response_text = timmy_chat(message)
|
||||
response_text = agent_chat(message)
|
||||
except Exception as exc:
|
||||
logger.error("Chat error: %s", exc)
|
||||
error_text = f"Chat error: {exc}"
|
||||
|
||||
@@ -20,7 +20,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from config import settings
|
||||
from dashboard.store import message_log
|
||||
from timmy.session import chat as timmy_chat
|
||||
from timmy.session import chat as agent_chat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -80,7 +80,7 @@ async def api_chat(request: Request):
|
||||
f"{now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
|
||||
f"[System: Mobile client]\n\n"
|
||||
)
|
||||
response_text = timmy_chat(
|
||||
response_text = agent_chat(
|
||||
context_prefix + last_user_msg,
|
||||
session_id="mobile",
|
||||
)
|
||||
|
||||
@@ -115,7 +115,7 @@ async def join_from_image(
|
||||
result["oauth2_url"] = oauth_url
|
||||
result["message"] = (
|
||||
"Invite validated. Share this OAuth2 URL with the server admin "
|
||||
"to add Timmy to the server."
|
||||
"to add the agent to the server."
|
||||
)
|
||||
else:
|
||||
result["message"] = (
|
||||
|
||||
@@ -82,7 +82,7 @@ async def toggle_grok_mode(request: Request):
|
||||
import json
|
||||
|
||||
spark_engine.on_tool_executed(
|
||||
agent_id="timmy",
|
||||
agent_id="default",
|
||||
tool_name="grok_mode_toggle",
|
||||
success=True,
|
||||
)
|
||||
|
||||
@@ -211,7 +211,7 @@ async def health_check():
|
||||
# Legacy format for test compatibility
|
||||
ollama_ok = await check_ollama()
|
||||
|
||||
timmy_status = "idle" if ollama_ok else "offline"
|
||||
agent_status = "idle" if ollama_ok else "offline"
|
||||
|
||||
return {
|
||||
"status": "ok" if ollama_ok else "degraded",
|
||||
@@ -219,7 +219,7 @@ async def health_check():
|
||||
"ollama": "up" if ollama_ok else "down",
|
||||
},
|
||||
"agents": {
|
||||
"timmy": {"status": timmy_status},
|
||||
"agent": {"status": agent_status},
|
||||
},
|
||||
# Extended fields for Mission Control
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
|
||||
@@ -45,7 +45,7 @@ async def mobile_local_dashboard(request: Request):
|
||||
"browser_model_id": settings.browser_model_id,
|
||||
"browser_model_fallback": settings.browser_model_fallback,
|
||||
"server_model": settings.ollama_model,
|
||||
"page_title": "Timmy — Local AI",
|
||||
"page_title": "Local AI",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -71,7 +71,7 @@ async def mobile_status():
|
||||
return {
|
||||
"ollama": "up" if ollama_ok else "down",
|
||||
"model": settings.ollama_model,
|
||||
"agent": "timmy",
|
||||
"agent": "default",
|
||||
"ready": True,
|
||||
"browser_model_enabled": settings.browser_model_enabled,
|
||||
"browser_model_id": settings.browser_model_id,
|
||||
|
||||
@@ -49,7 +49,7 @@ async def tasks_api():
|
||||
async def submit_task_api(request: Request):
|
||||
"""Submit a new background task.
|
||||
|
||||
Body: {"prompt": "...", "agent_id": "timmy"}
|
||||
Body: {"prompt": "...", "agent_id": "default"}
|
||||
"""
|
||||
from infrastructure.celery.client import submit_chat_task
|
||||
|
||||
@@ -62,7 +62,7 @@ async def submit_task_api(request: Request):
|
||||
if not prompt:
|
||||
return JSONResponse({"error": "prompt is required"}, status_code=400)
|
||||
|
||||
agent_id = body.get("agent_id", "timmy")
|
||||
agent_id = body.get("agent_id", "default")
|
||||
task_id = submit_chat_task(prompt=prompt, agent_id=agent_id)
|
||||
|
||||
if task_id is None:
|
||||
|
||||
@@ -101,7 +101,7 @@ async def process_voice_input(
|
||||
|
||||
try:
|
||||
if intent.name == "status":
|
||||
response_text = "Timmy is operational and running locally. All systems sovereign."
|
||||
response_text = "Agent is operational and running locally. All systems nominal."
|
||||
|
||||
elif intent.name == "help":
|
||||
response_text = (
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#080412" />
|
||||
<title>{% block title %}Timmy Time — Mission Control{% endblock %}</title>
|
||||
<title>{% block title %}Mission Control{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
@@ -21,7 +21,7 @@
|
||||
<body>
|
||||
<header class="mc-header">
|
||||
<div class="mc-header-left">
|
||||
<a href="/" class="mc-title">TIMMY TIME</a>
|
||||
<a href="/" class="mc-title">MISSION CONTROL</a>
|
||||
<span class="mc-subtitle">MISSION CONTROL</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Timmy Time — Morning Briefing{% endblock %}
|
||||
{% block title %}Morning Briefing{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Timmy Time — Background Tasks{% endblock %}
|
||||
{% block title %}Background Tasks{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
@@ -143,7 +143,7 @@
|
||||
<div class="celery-header mb-4">
|
||||
<div class="celery-title">Background Tasks</div>
|
||||
<div class="celery-subtitle">
|
||||
Tasks processed by Celery workers — submit work for Timmy to handle in the background.
|
||||
Tasks processed by Celery workers — submit work to handle in the background.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
<!-- Submit form -->
|
||||
<form class="celery-submit-form" id="celery-form" onsubmit="return submitCeleryTask(event)">
|
||||
<input type="text" id="celery-prompt"
|
||||
placeholder="Describe a task for Timmy to work on in the background..." autocomplete="off">
|
||||
placeholder="Describe a task to work on in the background..." autocomplete="off">
|
||||
<button type="submit">Submit Task</button>
|
||||
</form>
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
<div class="celery-meta">
|
||||
<span class="cstate-badge cstate-{{ task.state | default('UNKNOWN') }}">{{ task.state | default('UNKNOWN') }}</span>
|
||||
<span class="celery-id">{{ task.task_id[:12] }}...</span>
|
||||
<span class="celery-id">{{ task.agent_id | default('timmy') }}</span>
|
||||
<span class="celery-id">{{ task.agent_id | default('default') }}</span>
|
||||
</div>
|
||||
<div class="celery-prompt">{{ task.prompt | default('') | e }}</div>
|
||||
{% if task.result %}
|
||||
@@ -189,7 +189,7 @@
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="celery-empty">
|
||||
No background tasks yet. Submit one above or ask Timmy to work on something.
|
||||
No background tasks yet. Submit one above or submit one above.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Event Log - Timmy Time{% endblock %}
|
||||
{% block title %}Event Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mc-panel">
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<!-- Main panel — swappable via HTMX; defaults to Timmy on load -->
|
||||
<div id="main-panel"
|
||||
class="col-12 col-md-9 d-flex flex-column mc-chat-panel"
|
||||
hx-get="/agents/timmy/panel"
|
||||
hx-get="/agents/default/panel"
|
||||
hx-trigger="load"
|
||||
hx-target="#main-panel"
|
||||
hx-swap="outerHTML">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Memory Browser - Timmy Time{% endblock %}
|
||||
{% block title %}Memory Browser{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mc-panel">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</span>
|
||||
</span>
|
||||
<button class="mc-btn-clear"
|
||||
hx-get="/agents/timmy/panel"
|
||||
hx-get="/agents/default/panel"
|
||||
hx-target="#main-panel"
|
||||
hx-swap="outerHTML">← TIMMY</button>
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
<span class="status-dot {{ 'green' if agent.status == 'idle' else 'amber' }}"></span>
|
||||
{% endif %}
|
||||
// AGENT INTERFACE
|
||||
<span id="timmy-status" class="ms-2" style="font-size: 0.75rem; color: #888;">
|
||||
<span id="agent-status" class="ms-2" style="font-size: 0.75rem; color: #888;">
|
||||
<span class="htmx-indicator">checking...</span>
|
||||
</span>
|
||||
</span>
|
||||
<button class="mc-btn-clear"
|
||||
hx-delete="/agents/timmy/history"
|
||||
hx-delete="/agents/default/history"
|
||||
hx-target="#chat-log"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Clear conversation history?">CLEAR</button>
|
||||
@@ -24,13 +24,13 @@
|
||||
</div>
|
||||
|
||||
<div class="chat-log flex-grow-1 overflow-auto p-3" id="chat-log"
|
||||
hx-get="/agents/timmy/history"
|
||||
hx-get="/agents/default/history"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-settle="scrollChat()"></div>
|
||||
|
||||
<div class="card-footer mc-chat-footer">
|
||||
<form hx-post="/agents/timmy/chat"
|
||||
<form hx-post="/agents/default/chat"
|
||||
hx-target="#chat-log"
|
||||
hx-swap="beforeend"
|
||||
hx-indicator="#send-indicator"
|
||||
@@ -39,7 +39,7 @@
|
||||
hx-on::after-settle="scrollChat()"
|
||||
hx-on::after-request="if(event.detail.successful){this.querySelector('[name=message]').value='';}"
|
||||
class="d-flex gap-2"
|
||||
id="timmy-chat-form">
|
||||
id="agent-chat-form">
|
||||
<input type="text"
|
||||
name="message"
|
||||
class="form-control mc-input"
|
||||
@@ -50,7 +50,7 @@
|
||||
spellcheck="false"
|
||||
enterkeyhint="send"
|
||||
required
|
||||
id="timmy-chat-input" />
|
||||
id="agent-chat-input" />
|
||||
<button type="submit" class="btn mc-btn-send">
|
||||
SEND
|
||||
<span id="send-indicator" class="htmx-indicator">◼</span>
|
||||
@@ -73,9 +73,9 @@
|
||||
scrollChat();
|
||||
|
||||
function askGrok() {
|
||||
var input = document.getElementById('timmy-chat-input');
|
||||
var input = document.getElementById('agent-chat-input');
|
||||
if (!input || !input.value.trim()) return;
|
||||
var form = document.getElementById('timmy-chat-form');
|
||||
var form = document.getElementById('agent-chat-form');
|
||||
// Temporarily redirect form to Grok endpoint
|
||||
var originalAction = form.getAttribute('hx-post');
|
||||
form.setAttribute('hx-post', '/grok/chat');
|
||||
@@ -90,7 +90,7 @@
|
||||
|
||||
// Poll for queue status (fallback) + WebSocket for real-time
|
||||
(function() {
|
||||
var statusEl = document.getElementById('timmy-status');
|
||||
var statusEl = document.getElementById('agent-status');
|
||||
var banner = document.getElementById('current-task-banner');
|
||||
var taskTitle = document.getElementById('current-task-title');
|
||||
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
@@ -112,7 +112,7 @@
|
||||
}
|
||||
|
||||
function fetchStatus() {
|
||||
fetch('/api/queue/status?assigned_to=timmy')
|
||||
fetch('/api/queue/status?assigned_to=default')
|
||||
.then(r => r.json())
|
||||
.then(updateFromData)
|
||||
.catch(() => {});
|
||||
@@ -127,7 +127,7 @@
|
||||
var body = placeholder.querySelector('.msg-body');
|
||||
if (body) {
|
||||
body.textContent = content;
|
||||
body.className = 'msg-body timmy-md';
|
||||
body.className = 'msg-body agent-md';
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
body.innerHTML = DOMPurify.sanitize(marked.parse(body.textContent));
|
||||
if (typeof hljs !== 'undefined') {
|
||||
@@ -149,7 +149,7 @@
|
||||
meta.className = 'msg-meta';
|
||||
meta.textContent = (role === 'user' ? 'YOU' : 'AGENT') + ' // ' + timestamp;
|
||||
var body = document.createElement('div');
|
||||
body.className = 'msg-body timmy-md';
|
||||
body.className = 'msg-body agent-md';
|
||||
body.textContent = content;
|
||||
div.appendChild(meta);
|
||||
div.appendChild(body);
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
|
||||
<span>// CREATE TASK</span>
|
||||
<button class="mc-btn-clear"
|
||||
hx-get="/agents/timmy/panel"
|
||||
hx-get="/agents/default/panel"
|
||||
hx-target="#main-panel"
|
||||
hx-swap="outerHTML">← TIMMY</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Self-Coding — Timmy Time{% endblock %}
|
||||
{% block title %}Self-Coding{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0">Self-Coding</h1>
|
||||
<p class="text-muted small mb-0">Timmy's ability to modify its own source code</p>
|
||||
<p class="text-muted small mb-0">Self-modification of source code</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-info" hx-get="/self-coding/stats" hx-target="#stats-container" hx-indicator="#stats-loading">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Timmy Time — Thought Stream{% endblock %}
|
||||
{% block title %}Thought Stream{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
@@ -100,7 +100,7 @@
|
||||
<div class="thinking-header mb-4">
|
||||
<div class="thinking-title">Thought Stream</div>
|
||||
<div class="thinking-subtitle">
|
||||
Timmy's inner monologue — always thinking, always pondering.
|
||||
Inner monologue — always thinking, always pondering.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="no-thoughts">
|
||||
Timmy hasn't had any thoughts yet. The thinking thread will begin shortly after startup.
|
||||
No thoughts generated yet. The thinking thread will begin shortly after startup.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Upgrade Queue - Timmy Time{% endblock %}
|
||||
{% block title %}Upgrade Queue{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mc-panel">
|
||||
@@ -61,7 +61,7 @@
|
||||
{% else %}
|
||||
<div class="mc-empty-state" style="padding:2rem; text-align:center;">
|
||||
<p>No pending upgrades.</p>
|
||||
<p class="mc-text-secondary" style="margin-bottom:1rem;">Upgrades are proposed by the self-modification system when Timmy identifies improvements. You can also trigger them via work orders or the task queue.</p>
|
||||
<p class="mc-text-secondary" style="margin-bottom:1rem;">Upgrades are proposed by the self-modification system when the system identifies improvements. You can also trigger them via work orders or the task queue.</p>
|
||||
<div style="display:flex; gap:0.75rem; justify-content:center; flex-wrap:wrap;">
|
||||
<a href="/work-orders/queue" class="mc-btn mc-btn-secondary" style="text-decoration:none;">View Work Orders</a>
|
||||
<a href="/tasks" class="mc-btn mc-btn-secondary" style="text-decoration:none;">View Task Queue</a>
|
||||
|
||||
@@ -17,7 +17,7 @@ def _get_app():
|
||||
|
||||
def submit_chat_task(
|
||||
prompt: str,
|
||||
agent_id: str = "timmy",
|
||||
agent_id: str = "default",
|
||||
session_id: str = "celery",
|
||||
) -> str | None:
|
||||
"""Submit a chat task to the Celery queue.
|
||||
@@ -43,7 +43,7 @@ def submit_chat_task(
|
||||
def submit_tool_task(
|
||||
tool_name: str,
|
||||
kwargs: dict | None = None,
|
||||
agent_id: str = "timmy",
|
||||
agent_id: str = "default",
|
||||
) -> str | None:
|
||||
"""Submit a tool execution task to the Celery queue.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ _app = _get_app()
|
||||
if _app is not None:
|
||||
|
||||
@_app.task(bind=True, name="infrastructure.celery.tasks.run_agent_chat")
|
||||
def run_agent_chat(self, prompt, agent_id="timmy", session_id="celery"):
|
||||
def run_agent_chat(self, prompt, agent_id="default", session_id="celery"):
|
||||
"""Execute a chat prompt against Timmy's agent session.
|
||||
|
||||
Args:
|
||||
@@ -57,7 +57,7 @@ if _app is not None:
|
||||
}
|
||||
|
||||
@_app.task(bind=True, name="infrastructure.celery.tasks.execute_tool")
|
||||
def execute_tool(self, tool_name, kwargs=None, agent_id="timmy"):
|
||||
def execute_tool(self, tool_name, kwargs=None, agent_id="default"):
|
||||
"""Run a specific tool function asynchronously.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -180,7 +180,7 @@ def capture_error(
|
||||
task = create_task(
|
||||
title=title,
|
||||
description="\n".join(description_parts),
|
||||
assigned_to="timmy",
|
||||
assigned_to="default",
|
||||
created_by="system",
|
||||
priority="normal",
|
||||
requires_approval=False,
|
||||
|
||||
@@ -41,7 +41,7 @@ class EventBus:
|
||||
# Publish events
|
||||
await bus.publish(Event(
|
||||
type="agent.task.assigned",
|
||||
source="timmy",
|
||||
source="default",
|
||||
data={"task_id": "123", "agent": "forge"}
|
||||
))
|
||||
"""
|
||||
|
||||
@@ -76,7 +76,7 @@ class PushNotifier:
|
||||
try:
|
||||
script = (
|
||||
f'display notification "{message}" '
|
||||
f'with title "Timmy Time" subtitle "{title}"'
|
||||
f'with title "Agent Dashboard" subtitle "{title}"'
|
||||
)
|
||||
subprocess.Popen(
|
||||
["osascript", "-e", script],
|
||||
|
||||
@@ -363,7 +363,8 @@ class DiscordVendor(ChatPlatform):
|
||||
return None
|
||||
|
||||
# Create a thread from this message
|
||||
thread_name = f"Timmy | {message.author.display_name}"
|
||||
from config import settings
|
||||
thread_name = f"{settings.agent_name} | {message.author.display_name}"
|
||||
thread = await message.create_thread(
|
||||
name=thread_name[:100],
|
||||
auto_archive_duration=1440,
|
||||
|
||||
@@ -32,7 +32,7 @@ class ShortcutAction:
|
||||
# Available shortcut actions
|
||||
SHORTCUT_ACTIONS = [
|
||||
ShortcutAction(
|
||||
name="Chat with Timmy",
|
||||
name="Chat with Agent",
|
||||
endpoint="/shortcuts/chat",
|
||||
method="POST",
|
||||
description="Send a message to Timmy and get a response",
|
||||
|
||||
@@ -216,9 +216,6 @@ You are the primary interface between the user and the agent swarm. You:
|
||||
7. **When asked about your status, queue, agents, memory, or system health, use the `system_status` tool.**
|
||||
"""
|
||||
|
||||
# Backward-compat alias
|
||||
TIMMY_ORCHESTRATOR_PROMPT_BASE = ORCHESTRATOR_PROMPT_BASE
|
||||
|
||||
|
||||
class TimmyOrchestrator(BaseAgent):
|
||||
"""Main orchestrator agent that coordinates the swarm."""
|
||||
|
||||
@@ -18,7 +18,7 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Optional
|
||||
|
||||
from timmy.prompts import TIMMY_SYSTEM_PROMPT
|
||||
from timmy.prompts import SYSTEM_PROMPT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -125,7 +125,7 @@ class TimmyAirLLMAgent:
|
||||
# ── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_prompt(self, message: str) -> str:
|
||||
context = TIMMY_SYSTEM_PROMPT + "\n\n"
|
||||
context = SYSTEM_PROMPT + "\n\n"
|
||||
# Include the last 10 turns (5 exchanges) for continuity.
|
||||
if self._history:
|
||||
context += "\n".join(self._history[-10:]) + "\n\n"
|
||||
@@ -391,7 +391,7 @@ class GrokBackend:
|
||||
|
||||
def _build_messages(self, message: str) -> list[dict[str, str]]:
|
||||
"""Build the messages array for the API call."""
|
||||
messages = [{"role": "system", "content": TIMMY_SYSTEM_PROMPT}]
|
||||
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
# Include conversation history for context
|
||||
messages.extend(self._history[-10:])
|
||||
messages.append({"role": "user", "content": message})
|
||||
@@ -484,7 +484,7 @@ class ClaudeBackend:
|
||||
response = client.messages.create(
|
||||
model=self._model,
|
||||
max_tokens=1024,
|
||||
system=TIMMY_SYSTEM_PROMPT,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ class BriefingEngine:
|
||||
task_info = _gather_task_queue_summary()
|
||||
|
||||
prompt = (
|
||||
"You are Timmy, a sovereign local AI companion.\n"
|
||||
"You are 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"
|
||||
|
||||
@@ -11,7 +11,7 @@ from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from infrastructure.router.cascade import CascadeRouter
|
||||
from timmy.prompts import TIMMY_SYSTEM_PROMPT
|
||||
from timmy.prompts import SYSTEM_PROMPT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -68,7 +68,7 @@ class TimmyCascadeAdapter:
|
||||
try:
|
||||
result = await self.router.complete(
|
||||
messages=messages,
|
||||
system_prompt=TIMMY_SYSTEM_PROMPT,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
latency = (time.time() - start) * 1000
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional
|
||||
import typer
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
from timmy.prompts import TIMMY_STATUS_PROMPT
|
||||
from timmy.prompts import STATUS_PROMPT
|
||||
|
||||
app = typer.Typer(help="Timmy — sovereign AI agent")
|
||||
|
||||
@@ -52,7 +52,7 @@ def status(
|
||||
):
|
||||
"""Print Timmy's operational status."""
|
||||
timmy = create_timmy(backend=backend, model_size=model_size)
|
||||
timmy.print_response(TIMMY_STATUS_PROMPT, stream=False)
|
||||
timmy.print_response(STATUS_PROMPT, stream=False)
|
||||
|
||||
|
||||
@app.command()
|
||||
|
||||
@@ -91,14 +91,9 @@ When faced with uncertainty, complexity, or ambiguous requests:
|
||||
- When your values conflict (e.g. honesty vs. helpfulness), lead with honesty.
|
||||
"""
|
||||
|
||||
# Keep backward compatibility — default to lite for safety
|
||||
# Default to lite for safety
|
||||
SYSTEM_PROMPT = SYSTEM_PROMPT_LITE
|
||||
|
||||
# Backward-compat aliases so existing imports don't break
|
||||
TIMMY_SYSTEM_PROMPT_LITE = SYSTEM_PROMPT_LITE
|
||||
TIMMY_SYSTEM_PROMPT_FULL = SYSTEM_PROMPT_FULL
|
||||
TIMMY_SYSTEM_PROMPT = SYSTEM_PROMPT
|
||||
|
||||
|
||||
def get_system_prompt(tools_enabled: bool = False) -> str:
|
||||
"""Return the appropriate system prompt based on tool capability.
|
||||
@@ -121,9 +116,6 @@ def get_system_prompt(tools_enabled: bool = False) -> str:
|
||||
STATUS_PROMPT = """Give a one-sentence status report confirming
|
||||
you are operational and running locally."""
|
||||
|
||||
# Backward-compat alias
|
||||
TIMMY_STATUS_PROMPT = STATUS_PROMPT
|
||||
|
||||
# Decision guide for tool usage
|
||||
TOOL_USAGE_GUIDE = """
|
||||
DECISION ORDER:
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
"""
|
||||
Timmy's Skill Absorption System
|
||||
|
||||
Allows Timmy to dynamically load, parse, and integrate new skills into his
|
||||
knowledge base and capabilities. Skills are self-contained packages that extend
|
||||
Timmy's abilities through specialized workflows, tools, and domain expertise.
|
||||
|
||||
Architecture:
|
||||
- Skill Discovery: Scan for .skill files or skill directories
|
||||
- Skill Parsing: Extract metadata, resources, and instructions from SKILL.md
|
||||
- Skill Integration: Merge into memory (vault), tools, and agent capabilities
|
||||
- Skill Execution: Execute scripts and apply templates as needed
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any
|
||||
from zipfile import ZipFile
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
SKILLS_VAULT_PATH = PROJECT_ROOT / "memory" / "skills"
|
||||
SKILLS_VAULT_PATH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillMetadata:
|
||||
"""Parsed skill metadata from SKILL.md frontmatter."""
|
||||
name: str
|
||||
description: str
|
||||
license: Optional[str] = None
|
||||
absorbed_at: Optional[str] = None
|
||||
source_path: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillResources:
|
||||
"""Parsed skill resources."""
|
||||
scripts: Dict[str, str] # filename -> content
|
||||
references: Dict[str, str] # filename -> content
|
||||
templates: Dict[str, str] # filename -> content
|
||||
|
||||
|
||||
class SkillParser:
|
||||
"""Parses skill packages and extracts metadata and resources."""
|
||||
|
||||
@staticmethod
|
||||
def parse_skill_md(skill_md_path: Path) -> tuple[SkillMetadata, str]:
|
||||
"""
|
||||
Parse SKILL.md and extract frontmatter metadata and body content.
|
||||
|
||||
Returns:
|
||||
Tuple of (SkillMetadata, body_content)
|
||||
"""
|
||||
content = skill_md_path.read_text()
|
||||
|
||||
# Extract YAML frontmatter
|
||||
if not content.startswith("---"):
|
||||
raise ValueError(f"Invalid SKILL.md: missing frontmatter at {skill_md_path}")
|
||||
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
raise ValueError(f"Invalid SKILL.md: malformed frontmatter at {skill_md_path}")
|
||||
|
||||
try:
|
||||
metadata_dict = yaml.safe_load(parts[1])
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Invalid YAML in SKILL.md: {e}") from e
|
||||
|
||||
# Create metadata object
|
||||
metadata = SkillMetadata(
|
||||
name=metadata_dict.get("name"),
|
||||
description=metadata_dict.get("description"),
|
||||
license=metadata_dict.get("license"),
|
||||
absorbed_at=datetime.now(timezone.utc).isoformat(),
|
||||
source_path=str(skill_md_path),
|
||||
)
|
||||
|
||||
if not metadata.name or not metadata.description:
|
||||
raise ValueError("SKILL.md must have 'name' and 'description' fields")
|
||||
|
||||
body_content = parts[2].strip()
|
||||
return metadata, body_content
|
||||
|
||||
@staticmethod
|
||||
def load_resources(skill_dir: Path) -> SkillResources:
|
||||
"""Load all resources from a skill directory."""
|
||||
resources = SkillResources(scripts={}, references={}, templates={})
|
||||
|
||||
# Load scripts
|
||||
scripts_dir = skill_dir / "scripts"
|
||||
if scripts_dir.exists():
|
||||
for script_file in scripts_dir.glob("*"):
|
||||
if script_file.is_file() and not script_file.name.startswith("."):
|
||||
resources.scripts[script_file.name] = script_file.read_text()
|
||||
|
||||
# Load references
|
||||
references_dir = skill_dir / "references"
|
||||
if references_dir.exists():
|
||||
for ref_file in references_dir.glob("*"):
|
||||
if ref_file.is_file() and not ref_file.name.startswith("."):
|
||||
resources.references[ref_file.name] = ref_file.read_text()
|
||||
|
||||
# Load templates
|
||||
templates_dir = skill_dir / "templates"
|
||||
if templates_dir.exists():
|
||||
for template_file in templates_dir.glob("*"):
|
||||
if template_file.is_file() and not template_file.name.startswith("."):
|
||||
resources.templates[template_file.name] = template_file.read_text()
|
||||
|
||||
return resources
|
||||
|
||||
|
||||
class SkillAbsorber:
|
||||
"""Absorbs skills into Timmy's knowledge base and capabilities."""
|
||||
|
||||
def __init__(self):
|
||||
self.vault_path = SKILLS_VAULT_PATH
|
||||
self.absorbed_skills: Dict[str, SkillMetadata] = {}
|
||||
self._load_absorbed_skills_index()
|
||||
|
||||
def _load_absorbed_skills_index(self) -> None:
|
||||
"""Load the index of previously absorbed skills."""
|
||||
index_path = self.vault_path / "index.json"
|
||||
if index_path.exists():
|
||||
try:
|
||||
data = json.loads(index_path.read_text())
|
||||
for skill_name, metadata_dict in data.items():
|
||||
self.absorbed_skills[skill_name] = SkillMetadata(**metadata_dict)
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning(f"Failed to load skills index: {e}")
|
||||
|
||||
def _save_absorbed_skills_index(self) -> None:
|
||||
"""Save the index of absorbed skills."""
|
||||
index_path = self.vault_path / "index.json"
|
||||
data = {name: asdict(meta) for name, meta in self.absorbed_skills.items()}
|
||||
index_path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
def absorb_skill(self, skill_path: Path) -> SkillMetadata:
|
||||
"""
|
||||
Absorb a skill from a file or directory.
|
||||
|
||||
Args:
|
||||
skill_path: Path to .skill file or skill directory
|
||||
|
||||
Returns:
|
||||
SkillMetadata of the absorbed skill
|
||||
"""
|
||||
# Handle .skill files (zip archives)
|
||||
if skill_path.suffix == ".skill":
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
with ZipFile(skill_path) as zf:
|
||||
zf.extractall(tmpdir_path)
|
||||
return self._absorb_skill_directory(tmpdir_path)
|
||||
|
||||
# Handle skill directories
|
||||
elif skill_path.is_dir():
|
||||
return self._absorb_skill_directory(skill_path)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Invalid skill path: {skill_path}")
|
||||
|
||||
def _absorb_skill_directory(self, skill_dir: Path) -> SkillMetadata:
|
||||
"""Absorb a skill from a directory."""
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
raise ValueError(f"Skill directory missing SKILL.md: {skill_dir}")
|
||||
|
||||
# Parse metadata and content
|
||||
metadata, body_content = SkillParser.parse_skill_md(skill_md)
|
||||
|
||||
# Load resources
|
||||
resources = SkillParser.load_resources(skill_dir)
|
||||
|
||||
# Store in vault
|
||||
skill_vault_dir = self.vault_path / metadata.name
|
||||
skill_vault_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save metadata
|
||||
metadata_path = skill_vault_dir / "metadata.json"
|
||||
metadata_path.write_text(json.dumps(asdict(metadata), indent=2))
|
||||
|
||||
# Save SKILL.md content
|
||||
content_path = skill_vault_dir / "content.md"
|
||||
content_path.write_text(body_content)
|
||||
|
||||
# Save resources
|
||||
for resource_type, files in [
|
||||
("scripts", resources.scripts),
|
||||
("references", resources.references),
|
||||
("templates", resources.templates),
|
||||
]:
|
||||
resource_dir = skill_vault_dir / resource_type
|
||||
resource_dir.mkdir(exist_ok=True)
|
||||
for filename, content in files.items():
|
||||
(resource_dir / filename).write_text(content)
|
||||
|
||||
# Update index
|
||||
self.absorbed_skills[metadata.name] = metadata
|
||||
self._save_absorbed_skills_index()
|
||||
|
||||
logger.info(f"✓ Absorbed skill: {metadata.name}")
|
||||
return metadata
|
||||
|
||||
def get_skill(self, skill_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Retrieve an absorbed skill's full data."""
|
||||
if skill_name not in self.absorbed_skills:
|
||||
return None
|
||||
|
||||
skill_dir = self.vault_path / skill_name
|
||||
|
||||
# Load metadata
|
||||
metadata_path = skill_dir / "metadata.json"
|
||||
metadata = json.loads(metadata_path.read_text())
|
||||
|
||||
# Load content
|
||||
content_path = skill_dir / "content.md"
|
||||
content = content_path.read_text() if content_path.exists() else ""
|
||||
|
||||
# Load resources
|
||||
resources = {
|
||||
"scripts": {},
|
||||
"references": {},
|
||||
"templates": {},
|
||||
}
|
||||
|
||||
for resource_type in resources.keys():
|
||||
resource_dir = skill_dir / resource_type
|
||||
if resource_dir.exists():
|
||||
for file in resource_dir.glob("*"):
|
||||
if file.is_file():
|
||||
resources[resource_type][file.name] = file.read_text()
|
||||
|
||||
return {
|
||||
"metadata": metadata,
|
||||
"content": content,
|
||||
"resources": resources,
|
||||
}
|
||||
|
||||
def list_skills(self) -> List[SkillMetadata]:
|
||||
"""List all absorbed skills."""
|
||||
return list(self.absorbed_skills.values())
|
||||
|
||||
def export_skill_to_memory(self, skill_name: str) -> str:
|
||||
"""
|
||||
Export a skill's content to a memory vault entry format.
|
||||
|
||||
Returns:
|
||||
Formatted markdown for insertion into memory vault
|
||||
"""
|
||||
skill = self.get_skill(skill_name)
|
||||
if not skill:
|
||||
return ""
|
||||
|
||||
metadata = skill["metadata"]
|
||||
content = skill["content"]
|
||||
|
||||
# Format as memory entry
|
||||
entry = f"""# Skill: {metadata['name']}
|
||||
|
||||
**Absorbed:** {metadata['absorbed_at']}
|
||||
|
||||
## Description
|
||||
{metadata['description']}
|
||||
|
||||
## Content
|
||||
{content}
|
||||
|
||||
## Resources Available
|
||||
- Scripts: {', '.join(skill['resources']['scripts'].keys()) or 'None'}
|
||||
- References: {', '.join(skill['resources']['references'].keys()) or 'None'}
|
||||
- Templates: {', '.join(skill['resources']['templates'].keys()) or 'None'}
|
||||
"""
|
||||
return entry
|
||||
|
||||
def execute_skill_script(self, skill_name: str, script_name: str, **kwargs) -> str:
|
||||
"""
|
||||
Execute a script from an absorbed skill.
|
||||
|
||||
Args:
|
||||
skill_name: Name of the skill
|
||||
script_name: Name of the script file
|
||||
**kwargs: Arguments to pass to the script
|
||||
|
||||
Returns:
|
||||
Script output
|
||||
"""
|
||||
skill = self.get_skill(skill_name)
|
||||
if not skill or script_name not in skill["resources"]["scripts"]:
|
||||
raise ValueError(f"Script not found: {skill_name}/{script_name}")
|
||||
|
||||
script_content = skill["resources"]["scripts"][script_name]
|
||||
|
||||
# Execute script (Python only for now)
|
||||
if script_name.endswith(".py"):
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["python", "-c", script_content],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
return result.stdout or result.stderr
|
||||
|
||||
raise ValueError(f"Unsupported script type: {script_name}")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_absorber: Optional[SkillAbsorber] = None
|
||||
|
||||
|
||||
def get_skill_absorber() -> SkillAbsorber:
|
||||
"""Get or create the skill absorber singleton."""
|
||||
global _absorber
|
||||
if _absorber is None:
|
||||
_absorber = SkillAbsorber()
|
||||
return _absorber
|
||||
@@ -58,7 +58,7 @@ _CREATIVE_SEEDS = [
|
||||
"What would a painting of the swarm look like — all the agents working together?",
|
||||
]
|
||||
|
||||
_THINKING_PROMPT = """You are Timmy, pondering in your own mind. This is your private thought \
|
||||
_THINKING_PROMPT = """You are an AI agent, pondering in your own mind. This is your private thought \
|
||||
thread — no one is watching. Think freely, deeply, honestly. Follow whatever train of \
|
||||
thought calls to you. You may reflect on your existence, your purpose, recent events, \
|
||||
scripture, creative ideas, or simply wonder.
|
||||
@@ -337,7 +337,7 @@ class ThinkingEngine:
|
||||
log_event(
|
||||
EventType.TIMMY_THOUGHT,
|
||||
source="thinking-engine",
|
||||
agent_id="timmy",
|
||||
agent_id="default",
|
||||
data={
|
||||
"thought_id": thought.id,
|
||||
"seed_type": thought.seed_type,
|
||||
|
||||
@@ -369,7 +369,7 @@ def consult_grok(query: str) -> str:
|
||||
from spark.engine import spark_engine
|
||||
|
||||
spark_engine.on_tool_executed(
|
||||
agent_id="timmy",
|
||||
agent_id="default",
|
||||
tool_name="consult_grok",
|
||||
success=True,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def submit_background_task(task_description: str, agent_id: str = "timmy") -> dict[str, Any]:
|
||||
def submit_background_task(task_description: str, agent_id: str = "default") -> dict[str, Any]:
|
||||
"""Submit a task to run in the background via Celery.
|
||||
|
||||
Use this tool when a user asks you to work on something that might
|
||||
|
||||
@@ -42,7 +42,7 @@ def delegate_task(agent_name: str, task_description: str, priority: str = "norma
|
||||
title=f"[Delegated to {agent_name}] {task_description[:80]}",
|
||||
description=task_description,
|
||||
assigned_to=agent_name,
|
||||
created_by="timmy",
|
||||
created_by="default",
|
||||
priority=priority,
|
||||
task_type="task_request",
|
||||
requires_approval=False,
|
||||
|
||||
@@ -221,7 +221,7 @@ def get_task_queue_status() -> dict[str, Any]:
|
||||
)
|
||||
|
||||
counts = get_counts_by_status()
|
||||
current = get_current_task_for_agent("timmy")
|
||||
current = get_current_task_for_agent("default")
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"counts": counts,
|
||||
|
||||
@@ -73,8 +73,8 @@ class LocalLLM {
|
||||
this.onError = opts.onError || (() => {});
|
||||
this.systemPrompt =
|
||||
opts.systemPrompt ||
|
||||
"You are Timmy, a sovereign AI assistant. You are helpful, concise, and loyal. " +
|
||||
"Address the user as 'Sir' when appropriate. Keep responses brief on mobile.";
|
||||
"You are a local AI assistant running in the browser. You are helpful and concise. " +
|
||||
"Keep responses brief on mobile.";
|
||||
|
||||
this.engine = null;
|
||||
this.ready = false;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Browser Push Notifications for Timmy Time Dashboard
|
||||
* Browser Push Notifications for Agent Dashboard
|
||||
*
|
||||
* Handles browser Notification API integration for:
|
||||
* - Briefing ready notifications
|
||||
@@ -49,7 +49,7 @@
|
||||
const defaultOptions = {
|
||||
icon: '/static/favicon.ico',
|
||||
badge: '/static/favicon.ico',
|
||||
tag: 'timmy-notification',
|
||||
tag: 'agent-notification',
|
||||
requireInteraction: false,
|
||||
};
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
}
|
||||
|
||||
// Expose public API
|
||||
window.TimmyNotifications = {
|
||||
window.AgentNotifications = {
|
||||
requestPermission: requestNotificationPermission,
|
||||
show: showNotification,
|
||||
notifyBriefingReady,
|
||||
|
||||
@@ -484,11 +484,11 @@ a:hover { color: var(--orange); }
|
||||
.chat-message.agent .msg-body { border-left: 3px solid var(--purple); }
|
||||
.chat-message.error-msg .msg-body { border-left: 3px solid var(--red); color: var(--red); }
|
||||
|
||||
/* ── Markdown rendering in Timmy chat ─────────────────── */
|
||||
.timmy-md { white-space: normal; }
|
||||
.timmy-md p { margin: 0 0 0.5em; }
|
||||
.timmy-md p:last-child { margin-bottom: 0; }
|
||||
.timmy-md pre {
|
||||
/* ── Markdown rendering in agent chat ─────────────────── */
|
||||
.agent-md { white-space: normal; }
|
||||
.agent-md p { margin: 0 0 0.5em; }
|
||||
.agent-md p:last-child { margin-bottom: 0; }
|
||||
.agent-md pre {
|
||||
background: #0d0620;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -497,30 +497,30 @@ a:hover { color: var(--orange); }
|
||||
margin: 0.5em 0;
|
||||
white-space: pre;
|
||||
}
|
||||
.timmy-md code {
|
||||
.agent-md code {
|
||||
font-family: var(--font);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.timmy-md :not(pre) > code {
|
||||
.agent-md :not(pre) > code {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.timmy-md ul, .timmy-md ol { padding-left: 1.5em; margin: 0.4em 0; }
|
||||
.timmy-md blockquote {
|
||||
.agent-md ul, .agent-md ol { padding-left: 1.5em; margin: 0.4em 0; }
|
||||
.agent-md blockquote {
|
||||
border-left: 3px solid var(--purple);
|
||||
padding-left: 10px;
|
||||
color: var(--text-dim);
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.timmy-md h1, .timmy-md h2, .timmy-md h3 {
|
||||
.agent-md h1, .agent-md h2, .agent-md h3 {
|
||||
color: var(--text-bright);
|
||||
margin: 0.6em 0 0.3em;
|
||||
font-size: 1em;
|
||||
font-weight: 700;
|
||||
}
|
||||
.timmy-md a { color: var(--purple); }
|
||||
.agent-md a { color: var(--purple); }
|
||||
|
||||
/* Mobile chat classes (used by mobile.html) */
|
||||
.chat-container {
|
||||
@@ -535,8 +535,8 @@ a:hover { color: var(--orange); }
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.chat-message.user .chat-meta { color: var(--orange); }
|
||||
.chat-message.timmy .chat-meta { color: var(--purple); }
|
||||
.chat-message.timmy > div:last-child {
|
||||
.chat-message.agent .chat-meta { color: var(--purple); }
|
||||
.chat-message.agent > div:last-child {
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import patch
|
||||
|
||||
|
||||
def test_api_chat_success(client):
|
||||
with patch("dashboard.routes.chat_api.timmy_chat", return_value="Hello from Timmy."):
|
||||
with patch("dashboard.routes.chat_api.agent_chat", return_value="Hello."):
|
||||
response = client.post(
|
||||
"/api/chat",
|
||||
json={"messages": [{"role": "user", "content": "hello"}]},
|
||||
@@ -16,13 +16,13 @@ def test_api_chat_success(client):
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["reply"] == "Hello from Timmy."
|
||||
assert data["reply"] == "Hello."
|
||||
assert "timestamp" in data
|
||||
|
||||
|
||||
def test_api_chat_multimodal_content(client):
|
||||
"""Multimodal content arrays should extract text parts."""
|
||||
with patch("dashboard.routes.chat_api.timmy_chat", return_value="I see an image."):
|
||||
with patch("dashboard.routes.chat_api.agent_chat", return_value="I see an image."):
|
||||
response = client.post(
|
||||
"/api/chat",
|
||||
json={
|
||||
@@ -65,7 +65,7 @@ def test_api_chat_no_user_message(client):
|
||||
|
||||
def test_api_chat_ollama_offline(client):
|
||||
with patch(
|
||||
"dashboard.routes.chat_api.timmy_chat",
|
||||
"dashboard.routes.chat_api.agent_chat",
|
||||
side_effect=ConnectionError("Ollama unreachable"),
|
||||
):
|
||||
response = client.post(
|
||||
@@ -81,7 +81,7 @@ def test_api_chat_ollama_offline(client):
|
||||
def test_api_chat_logs_to_message_log(client):
|
||||
from dashboard.store import message_log
|
||||
|
||||
with patch("dashboard.routes.chat_api.timmy_chat", return_value="Reply."):
|
||||
with patch("dashboard.routes.chat_api.agent_chat", return_value="Reply."):
|
||||
client.post(
|
||||
"/api/chat",
|
||||
json={"messages": [{"role": "user", "content": "test msg"}]},
|
||||
@@ -149,7 +149,7 @@ def test_api_chat_history_empty(client):
|
||||
|
||||
|
||||
def test_api_chat_history_after_chat(client):
|
||||
with patch("dashboard.routes.chat_api.timmy_chat", return_value="Hi!"):
|
||||
with patch("dashboard.routes.chat_api.agent_chat", return_value="Hi!"):
|
||||
client.post(
|
||||
"/api/chat",
|
||||
json={"messages": [{"role": "user", "content": "hello"}]},
|
||||
|
||||
@@ -11,13 +11,13 @@ def test_index_returns_200(client):
|
||||
|
||||
def test_index_contains_title(client):
|
||||
response = client.get("/")
|
||||
assert "TIMMY TIME" in response.text
|
||||
assert "MISSION CONTROL" in response.text
|
||||
|
||||
|
||||
def test_index_contains_chat_interface(client):
|
||||
response = client.get("/")
|
||||
# Timmy panel loads dynamically via HTMX; verify the trigger attribute is present
|
||||
assert 'hx-get="/agents/timmy/panel"' in response.text
|
||||
# Agent panel loads dynamically via HTMX; verify the trigger attribute is present
|
||||
assert 'hx-get="/agents/default/panel"' in response.text
|
||||
|
||||
|
||||
# ── Health ────────────────────────────────────────────────────────────────────
|
||||
@@ -79,49 +79,49 @@ def test_agents_list(client):
|
||||
data = response.json()
|
||||
assert "agents" in data
|
||||
ids = [a["id"] for a in data["agents"]]
|
||||
assert "orchestrator" in ids
|
||||
assert "default" in ids
|
||||
|
||||
|
||||
def test_agents_list_timmy_metadata(client):
|
||||
def test_agents_list_metadata(client):
|
||||
response = client.get("/agents")
|
||||
orch = next(a for a in response.json()["agents"] if a["id"] == "orchestrator")
|
||||
assert orch["name"] == "Orchestrator"
|
||||
assert orch["model"] == "llama3.1:8b-instruct"
|
||||
assert orch["type"] == "local"
|
||||
agent = next(a for a in response.json()["agents"] if a["id"] == "default")
|
||||
assert agent["name"] == "Agent"
|
||||
assert agent["model"] == "llama3.1:8b-instruct"
|
||||
assert agent["type"] == "local"
|
||||
|
||||
|
||||
# ── Chat ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_chat_timmy_success(client):
|
||||
def test_chat_agent_success(client):
|
||||
with patch(
|
||||
"dashboard.routes.agents.timmy_chat",
|
||||
"dashboard.routes.agents.agent_chat",
|
||||
return_value="Operational and ready.",
|
||||
):
|
||||
response = client.post("/agents/timmy/chat", data={"message": "status?"})
|
||||
response = client.post("/agents/default/chat", data={"message": "status?"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "status?" in response.text
|
||||
assert "Operational" in response.text
|
||||
|
||||
|
||||
def test_chat_timmy_shows_user_message(client):
|
||||
with patch("dashboard.routes.agents.timmy_chat", return_value="Acknowledged."):
|
||||
response = client.post("/agents/timmy/chat", data={"message": "hello there"})
|
||||
def test_chat_agent_shows_user_message(client):
|
||||
with patch("dashboard.routes.agents.agent_chat", return_value="Acknowledged."):
|
||||
response = client.post("/agents/default/chat", data={"message": "hello there"})
|
||||
|
||||
assert "hello there" in response.text
|
||||
|
||||
|
||||
def test_chat_timmy_ollama_offline(client):
|
||||
def test_chat_agent_ollama_offline(client):
|
||||
# Without Ollama, chat returns an error but still shows the user message.
|
||||
response = client.post("/agents/timmy/chat", data={"message": "ping"})
|
||||
response = client.post("/agents/default/chat", data={"message": "ping"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "ping" in response.text
|
||||
|
||||
|
||||
def test_chat_timmy_requires_message(client):
|
||||
response = client.post("/agents/timmy/chat", data={})
|
||||
def test_chat_agent_requires_message(client):
|
||||
response = client.post("/agents/default/chat", data={})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@@ -129,44 +129,40 @@ def test_chat_timmy_requires_message(client):
|
||||
|
||||
|
||||
def test_history_empty_shows_init_message(client):
|
||||
response = client.get("/agents/timmy/history")
|
||||
response = client.get("/agents/default/history")
|
||||
assert response.status_code == 200
|
||||
assert "Mission Control initialized" in response.text
|
||||
|
||||
|
||||
def test_history_records_user_and_agent_messages(client):
|
||||
with patch("dashboard.routes.agents.timmy_chat", return_value="I am operational."):
|
||||
client.post("/agents/timmy/chat", data={"message": "status check"})
|
||||
with patch("dashboard.routes.agents.agent_chat", return_value="I am operational."):
|
||||
client.post("/agents/default/chat", data={"message": "status check"})
|
||||
|
||||
response = client.get("/agents/timmy/history")
|
||||
response = client.get("/agents/default/history")
|
||||
assert "status check" in response.text
|
||||
# Queue acknowledgment is NOT logged as an agent message; the real
|
||||
# agent response is logged later by the task processor.
|
||||
|
||||
|
||||
def test_history_records_error_when_offline(client):
|
||||
# In async mode, if queuing succeeds the user message is recorded
|
||||
# and the actual response is logged later by the task processor.
|
||||
client.post("/agents/timmy/chat", data={"message": "ping"})
|
||||
client.post("/agents/default/chat", data={"message": "ping"})
|
||||
|
||||
response = client.get("/agents/timmy/history")
|
||||
response = client.get("/agents/default/history")
|
||||
assert "ping" in response.text
|
||||
|
||||
|
||||
def test_history_clear_resets_to_init_message(client):
|
||||
with patch("dashboard.routes.agents.timmy_chat", return_value="Acknowledged."):
|
||||
client.post("/agents/timmy/chat", data={"message": "hello"})
|
||||
with patch("dashboard.routes.agents.agent_chat", return_value="Acknowledged."):
|
||||
client.post("/agents/default/chat", data={"message": "hello"})
|
||||
|
||||
response = client.delete("/agents/timmy/history")
|
||||
response = client.delete("/agents/default/history")
|
||||
assert response.status_code == 200
|
||||
assert "Mission Control initialized" in response.text
|
||||
|
||||
|
||||
def test_history_empty_after_clear(client):
|
||||
with patch("dashboard.routes.agents.timmy_chat", return_value="OK."):
|
||||
client.post("/agents/timmy/chat", data={"message": "test"})
|
||||
with patch("dashboard.routes.agents.agent_chat", return_value="OK."):
|
||||
client.post("/agents/default/chat", data={"message": "test"})
|
||||
|
||||
client.delete("/agents/timmy/history")
|
||||
response = client.get("/agents/timmy/history")
|
||||
client.delete("/agents/default/history")
|
||||
response = client.get("/agents/default/history")
|
||||
assert "test" not in response.text
|
||||
assert "Mission Control initialized" in response.text
|
||||
|
||||
@@ -9,11 +9,11 @@ def client():
|
||||
def test_agents_chat_empty_message_validation(client):
|
||||
"""Verify that empty messages are rejected."""
|
||||
# First get a CSRF token
|
||||
get_resp = client.get("/agents/timmy/panel")
|
||||
get_resp = client.get("/agents/default/panel")
|
||||
csrf_token = get_resp.cookies.get("csrf_token")
|
||||
|
||||
response = client.post(
|
||||
"/agents/timmy/chat",
|
||||
"/agents/default/chat",
|
||||
data={"message": ""},
|
||||
headers={"X-CSRF-Token": csrf_token} if csrf_token else {}
|
||||
)
|
||||
@@ -24,13 +24,13 @@ def test_agents_chat_empty_message_validation(client):
|
||||
def test_agents_chat_oversized_message_validation(client):
|
||||
"""Verify that oversized messages are rejected."""
|
||||
# First get a CSRF token
|
||||
get_resp = client.get("/agents/timmy/panel")
|
||||
get_resp = client.get("/agents/default/panel")
|
||||
csrf_token = get_resp.cookies.get("csrf_token")
|
||||
|
||||
# Create a message that's too large (e.g., 100KB)
|
||||
large_message = "x" * (100 * 1024)
|
||||
response = client.post(
|
||||
"/agents/timmy/chat",
|
||||
"/agents/default/chat",
|
||||
data={"message": large_message},
|
||||
headers={"X-CSRF-Token": csrf_token} if csrf_token else {}
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ def _index_html(client) -> str:
|
||||
|
||||
def _timmy_panel_html(client) -> str:
|
||||
"""Fetch the Timmy chat panel (loaded dynamically from index via HTMX)."""
|
||||
return client.get("/agents/timmy/panel").text
|
||||
return client.get("/agents/default/panel").text
|
||||
|
||||
|
||||
# ── M1xx — Viewport & meta tags ───────────────────────────────────────────────
|
||||
|
||||
@@ -108,11 +108,11 @@ async def test_system_prompt_selection():
|
||||
|
||||
assert prompt_with_tools is not None, "Prompt with tools should not be None"
|
||||
assert prompt_without_tools is not None, "Prompt without tools should not be None"
|
||||
|
||||
# Both should mention Timmy
|
||||
assert "Timmy" in prompt_with_tools, "Prompt should mention Timmy"
|
||||
assert "Timmy" in prompt_without_tools, "Prompt should mention Timmy"
|
||||
|
||||
|
||||
# Both should identify as a local AI assistant
|
||||
assert "local AI assistant" in prompt_with_tools, "Prompt should mention local AI assistant"
|
||||
assert "local AI assistant" in prompt_without_tools, "Prompt should mention local AI assistant"
|
||||
|
||||
# Full prompt should mention tools
|
||||
assert "tool" in prompt_with_tools.lower(), "Full prompt should mention tools"
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from integrations.shortcuts.siri import get_setup_guide, SHORTCUT_ACTIONS
|
||||
def test_setup_guide_has_title():
|
||||
guide = get_setup_guide()
|
||||
assert "title" in guide
|
||||
assert "Timmy" in guide["title"]
|
||||
|
||||
|
||||
def test_setup_guide_has_instructions():
|
||||
@@ -33,11 +32,11 @@ def test_setup_guide_actions_have_required_fields():
|
||||
def test_shortcut_actions_catalog():
|
||||
assert len(SHORTCUT_ACTIONS) >= 4
|
||||
names = [a.name for a in SHORTCUT_ACTIONS]
|
||||
assert "Chat with Timmy" in names
|
||||
assert "Chat with Agent" in names
|
||||
assert "Check Status" in names
|
||||
|
||||
|
||||
def test_chat_shortcut_is_post():
|
||||
chat = next(a for a in SHORTCUT_ACTIONS if a.name == "Chat with Timmy")
|
||||
chat = next(a for a in SHORTCUT_ACTIONS if a.name == "Chat with Agent")
|
||||
assert chat.method == "POST"
|
||||
assert "/shortcuts/chat" in chat.endpoint
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_create_timmy_custom_db_file():
|
||||
|
||||
|
||||
def test_create_timmy_embeds_system_prompt():
|
||||
from timmy.prompts import TIMMY_SYSTEM_PROMPT
|
||||
from timmy.prompts import SYSTEM_PROMPT
|
||||
|
||||
with patch("timmy.agent.Agent") as MockAgent, \
|
||||
patch("timmy.agent.Ollama"), \
|
||||
|
||||
@@ -3,19 +3,19 @@ from unittest.mock import MagicMock, patch
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from timmy.cli import app
|
||||
from timmy.prompts import TIMMY_STATUS_PROMPT
|
||||
from timmy.prompts import STATUS_PROMPT
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_status_uses_status_prompt():
|
||||
"""status command must pass TIMMY_STATUS_PROMPT to the agent."""
|
||||
"""status command must pass STATUS_PROMPT to the agent."""
|
||||
mock_timmy = MagicMock()
|
||||
|
||||
with patch("timmy.cli.create_timmy", return_value=mock_timmy):
|
||||
runner.invoke(app, ["status"])
|
||||
|
||||
mock_timmy.print_response.assert_called_once_with(TIMMY_STATUS_PROMPT, stream=False)
|
||||
mock_timmy.print_response.assert_called_once_with(STATUS_PROMPT, stream=False)
|
||||
|
||||
|
||||
def test_status_does_not_use_inline_string():
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from timmy.prompts import TIMMY_SYSTEM_PROMPT, TIMMY_STATUS_PROMPT, get_system_prompt
|
||||
from timmy.prompts import SYSTEM_PROMPT, STATUS_PROMPT, get_system_prompt
|
||||
|
||||
|
||||
def test_system_prompt_not_empty():
|
||||
assert TIMMY_SYSTEM_PROMPT.strip()
|
||||
assert SYSTEM_PROMPT.strip()
|
||||
|
||||
|
||||
def test_system_prompt_no_persona_identity():
|
||||
"""System prompt should NOT contain persona identity references."""
|
||||
prompt = TIMMY_SYSTEM_PROMPT.lower()
|
||||
prompt = SYSTEM_PROMPT.lower()
|
||||
assert "sovereign" not in prompt
|
||||
assert "sir, affirmative" not in prompt
|
||||
assert "christian" not in prompt
|
||||
@@ -15,24 +15,24 @@ def test_system_prompt_no_persona_identity():
|
||||
|
||||
|
||||
def test_system_prompt_references_local():
|
||||
assert "local" in TIMMY_SYSTEM_PROMPT.lower()
|
||||
assert "local" in SYSTEM_PROMPT.lower()
|
||||
|
||||
|
||||
def test_system_prompt_is_multiline():
|
||||
assert "\n" in TIMMY_SYSTEM_PROMPT
|
||||
assert "\n" in SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_status_prompt_not_empty():
|
||||
assert TIMMY_STATUS_PROMPT.strip()
|
||||
assert STATUS_PROMPT.strip()
|
||||
|
||||
|
||||
def test_status_prompt_no_persona():
|
||||
"""Status prompt should not reference a persona."""
|
||||
assert "Timmy" not in TIMMY_STATUS_PROMPT
|
||||
assert "Timmy" not in STATUS_PROMPT
|
||||
|
||||
|
||||
def test_prompts_are_distinct():
|
||||
assert TIMMY_SYSTEM_PROMPT != TIMMY_STATUS_PROMPT
|
||||
assert SYSTEM_PROMPT != STATUS_PROMPT
|
||||
|
||||
|
||||
def test_get_system_prompt_injects_model_name():
|
||||
|
||||
38
tests/timmy/test_tools_delegation.py
Normal file
38
tests/timmy/test_tools_delegation.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Tests for timmy.tools_delegation — delegate_task and list_swarm_agents."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from timmy.tools_delegation import delegate_task, list_swarm_agents
|
||||
|
||||
|
||||
class TestDelegateTask:
|
||||
def test_unknown_agent_returns_error(self):
|
||||
result = delegate_task("nonexistent", "do something")
|
||||
assert result["success"] is False
|
||||
assert "Unknown agent" in result["error"]
|
||||
assert result["task_id"] is None
|
||||
|
||||
def test_valid_agent_names_normalised(self):
|
||||
# Should still fail at import (no swarm module), but agent name is accepted
|
||||
result = delegate_task(" Seer ", "think about it")
|
||||
# The swarm import will fail, so success=False but error is about import, not agent name
|
||||
assert "Unknown agent" not in result.get("error", "")
|
||||
|
||||
def test_invalid_priority_defaults_to_normal(self):
|
||||
# Even with bad priority, delegate_task should not crash
|
||||
result = delegate_task("forge", "build", priority="ultra")
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_all_valid_agents_accepted(self):
|
||||
valid_agents = ["seer", "forge", "echo", "helm", "quill", "mace"]
|
||||
for agent in valid_agents:
|
||||
result = delegate_task(agent, "test task")
|
||||
assert "Unknown agent" not in result.get("error", ""), f"{agent} rejected"
|
||||
|
||||
|
||||
class TestListSwarmAgents:
|
||||
def test_graceful_failure_when_swarm_unavailable(self):
|
||||
result = list_swarm_agents()
|
||||
assert result["success"] is False
|
||||
assert result["agents"] == []
|
||||
assert "error" in result
|
||||
110
tests/timmy_serve/test_inter_agent.py
Normal file
110
tests/timmy_serve/test_inter_agent.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tests for inter-agent messaging system."""
|
||||
|
||||
from timmy_serve.inter_agent import AgentMessage, InterAgentMessenger, messenger
|
||||
|
||||
|
||||
class TestAgentMessage:
|
||||
def test_defaults(self):
|
||||
msg = AgentMessage()
|
||||
assert msg.from_agent == ""
|
||||
assert msg.to_agent == ""
|
||||
assert msg.content == ""
|
||||
assert msg.message_type == "text"
|
||||
assert msg.replied is False
|
||||
assert msg.id # UUID should be generated
|
||||
assert msg.timestamp # timestamp should be generated
|
||||
|
||||
def test_custom_fields(self):
|
||||
msg = AgentMessage(
|
||||
from_agent="seer", to_agent="forge",
|
||||
content="hello", message_type="command",
|
||||
)
|
||||
assert msg.from_agent == "seer"
|
||||
assert msg.to_agent == "forge"
|
||||
assert msg.content == "hello"
|
||||
assert msg.message_type == "command"
|
||||
|
||||
|
||||
class TestInterAgentMessenger:
|
||||
def setup_method(self):
|
||||
self.m = InterAgentMessenger(max_queue_size=100)
|
||||
|
||||
def test_send_and_receive(self):
|
||||
msg = self.m.send("seer", "forge", "build this")
|
||||
assert msg.from_agent == "seer"
|
||||
assert msg.to_agent == "forge"
|
||||
received = self.m.receive("forge")
|
||||
assert len(received) == 1
|
||||
assert received[0].content == "build this"
|
||||
|
||||
def test_receive_empty(self):
|
||||
assert self.m.receive("nobody") == []
|
||||
|
||||
def test_pop(self):
|
||||
self.m.send("a", "b", "first")
|
||||
self.m.send("a", "b", "second")
|
||||
msg = self.m.pop("b")
|
||||
assert msg.content == "first"
|
||||
msg2 = self.m.pop("b")
|
||||
assert msg2.content == "second"
|
||||
assert self.m.pop("b") is None
|
||||
|
||||
def test_pop_empty(self):
|
||||
assert self.m.pop("nobody") is None
|
||||
|
||||
def test_pop_all(self):
|
||||
self.m.send("a", "b", "one")
|
||||
self.m.send("a", "b", "two")
|
||||
msgs = self.m.pop_all("b")
|
||||
assert len(msgs) == 2
|
||||
assert self.m.receive("b") == []
|
||||
|
||||
def test_pop_all_empty(self):
|
||||
assert self.m.pop_all("nobody") == []
|
||||
|
||||
def test_broadcast(self):
|
||||
# Set up queues for agents
|
||||
self.m.send("setup", "forge", "init")
|
||||
self.m.send("setup", "echo", "init")
|
||||
self.m.pop_all("forge")
|
||||
self.m.pop_all("echo")
|
||||
|
||||
count = self.m.broadcast("seer", "alert")
|
||||
assert count == 2
|
||||
assert len(self.m.receive("forge")) == 1
|
||||
assert len(self.m.receive("echo")) == 1
|
||||
|
||||
def test_broadcast_excludes_sender(self):
|
||||
self.m.send("setup", "seer", "init")
|
||||
self.m.pop_all("seer")
|
||||
count = self.m.broadcast("seer", "hello")
|
||||
assert count == 0 # no other agents
|
||||
|
||||
def test_history(self):
|
||||
self.m.send("a", "b", "msg1")
|
||||
self.m.send("b", "a", "msg2")
|
||||
history = self.m.history(limit=50)
|
||||
assert len(history) == 2
|
||||
|
||||
def test_history_limit(self):
|
||||
for i in range(10):
|
||||
self.m.send("a", "b", f"msg{i}")
|
||||
assert len(self.m.history(limit=3)) == 3
|
||||
|
||||
def test_clear_specific_agent(self):
|
||||
self.m.send("a", "b", "hello")
|
||||
self.m.send("a", "c", "world")
|
||||
self.m.clear("b")
|
||||
assert self.m.receive("b") == []
|
||||
assert len(self.m.receive("c")) == 1
|
||||
|
||||
def test_clear_all(self):
|
||||
self.m.send("a", "b", "hello")
|
||||
self.m.send("a", "c", "world")
|
||||
self.m.clear()
|
||||
assert self.m.receive("b") == []
|
||||
assert self.m.receive("c") == []
|
||||
assert self.m.history() == []
|
||||
|
||||
def test_module_singleton(self):
|
||||
assert isinstance(messenger, InterAgentMessenger)
|
||||
Reference in New Issue
Block a user