Add pre-commit hook enforcing 30s test suite time limit (#132)

This commit is contained in:
Alexander Whitestone
2026-03-05 19:45:38 -05:00
committed by GitHub
parent aff3edb06a
commit 2b97da9e9c
65 changed files with 356 additions and 611 deletions

View File

@@ -29,8 +29,8 @@ jobs:
- name: Run tests - name: Run tests
run: | run: |
mkdir -p reports
pytest \ pytest \
--tb=short \
--cov=src \ --cov=src \
--cov-report=term-missing \ --cov-report=term-missing \
--cov-report=xml:reports/coverage.xml \ --cov-report=xml:reports/coverage.xml \

View File

@@ -51,12 +51,13 @@ repos:
exclude: ^tests/ exclude: ^tests/
stages: [manual] 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 - repo: local
hooks: hooks:
- id: pytest-unit - id: pytest-fast
name: pytest-unit name: pytest (30s limit)
entry: pytest entry: timeout 30 poetry run pytest
language: system language: system
types: [python] types: [python]
stages: [commit] stages: [commit]
@@ -64,8 +65,7 @@ repos:
always_run: true always_run: true
args: args:
- tests - tests
- -m
- "unit"
- --tb=short
- -q - -q
- --tb=short
- --timeout=10
verbose: true verbose: true

View File

@@ -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 \ up down logs \
docker-build docker-up docker-down docker-agent docker-logs docker-shell \ 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 \ test-docker test-docker-cov test-docker-functional test-docker-build test-docker-down \
@@ -16,6 +16,11 @@ install:
poetry install --with dev poetry install --with dev
@echo "✓ Ready. Run 'make dev' to start the dashboard." @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: install-bigbrain:
poetry install --with dev --extras bigbrain poetry install --with dev --extras bigbrain
@if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname -s)" = "Darwin" ]; then \ @if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname -s)" = "Darwin" ]; then \

View File

@@ -79,7 +79,10 @@ testpaths = ["tests"]
pythonpath = ["src", "tests"] pythonpath = ["src", "tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" 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 = [ markers = [
"unit: Unit tests (fast, no I/O)", "unit: Unit tests (fast, no I/O)",
"integration: Integration tests (may use SQLite)", "integration: Integration tests (may use SQLite)",
@@ -90,6 +93,7 @@ markers = [
"selenium: Requires Selenium and Chrome (browser automation)", "selenium: Requires Selenium and Chrome (browser automation)",
"docker: Requires Docker and docker-compose", "docker: Requires Docker and docker-compose",
"ollama: Requires Ollama service running", "ollama: Requires Ollama service running",
"external_api: Requires external API access",
"skip_ci: Skip in CI environment (local development only)", "skip_ci: Skip in CI environment (local development only)",
] ]

View File

@@ -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
View 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

View File

@@ -14,7 +14,6 @@ from brain.client import BrainClient
from brain.worker import DistributedWorker from brain.worker import DistributedWorker
from brain.embeddings import LocalEmbedder from brain.embeddings import LocalEmbedder
from brain.memory import UnifiedMemory, get_memory from brain.memory import UnifiedMemory, get_memory
from brain.identity import get_canonical_identity, get_identity_for_prompt
__all__ = [ __all__ = [
"BrainClient", "BrainClient",
@@ -22,6 +21,4 @@ __all__ = [
"LocalEmbedder", "LocalEmbedder",
"UnifiedMemory", "UnifiedMemory",
"get_memory", "get_memory",
"get_canonical_identity",
"get_identity_for_prompt",
] ]

View File

@@ -36,7 +36,7 @@ class BrainClient:
"""Detect what component is using the brain.""" """Detect what component is using the brain."""
# Could be 'timmy', 'zeroclaw', 'worker', etc. # Could be 'timmy', 'zeroclaw', 'worker', etc.
# For now, infer from context or env # For now, infer from context or env
return os.environ.get("BRAIN_SOURCE", "timmy") return os.environ.get("BRAIN_SOURCE", "default")
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
# Memory Operations # Memory Operations

View File

@@ -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 = ""

View File

@@ -65,7 +65,7 @@ class UnifiedMemory:
def __init__( def __init__(
self, self,
db_path: Optional[Path] = None, db_path: Optional[Path] = None,
source: str = "timmy", source: str = "default",
use_rqlite: Optional[bool] = None, use_rqlite: Optional[bool] = None,
): ):
self.db_path = db_path or _get_db_path() self.db_path = db_path or _get_db_path()

View File

@@ -4,6 +4,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): 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 host — override with OLLAMA_URL env var or .env file
ollama_url: str = "http://localhost:11434" ollama_url: str = "http://localhost:11434"

View File

@@ -206,7 +206,7 @@ async def lifespan(app: FastAPI):
# Start chat integrations in background # Start chat integrations in background
chat_task = asyncio.create_task(_start_chat_integrations_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 yield
@@ -227,7 +227,7 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="Timmy Time — Mission Control", title="Mission Control",
version="1.0.0", version="1.0.0",
lifespan=lifespan, lifespan=lifespan,
docs_url="/docs", docs_url="/docs",

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse 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.store import message_log
from dashboard.templating import templates from dashboard.templating import templates
@@ -21,8 +21,8 @@ async def list_agents():
return { return {
"agents": [ "agents": [
{ {
"id": "orchestrator", "id": "default",
"name": "Orchestrator", "name": settings.agent_name,
"status": "idle", "status": "idle",
"capabilities": "chat,reasoning,research,planning", "capabilities": "chat,reasoning,research,planning",
"type": "local", "type": "local",
@@ -34,15 +34,15 @@ async def list_agents():
} }
@router.get("/timmy/panel", response_class=HTMLResponse) @router.get("/default/panel", response_class=HTMLResponse)
async def timmy_panel(request: Request): async def agent_panel(request: Request):
"""Chat panel — for HTMX main-panel swaps.""" """Chat panel — for HTMX main-panel swaps."""
return templates.TemplateResponse( 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): async def get_history(request: Request):
return templates.TemplateResponse( return templates.TemplateResponse(
request, 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): async def clear_history(request: Request):
message_log.clear() message_log.clear()
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -61,15 +61,15 @@ async def clear_history(request: Request):
) )
@router.post("/timmy/chat", response_class=HTMLResponse) @router.post("/default/chat", response_class=HTMLResponse)
async def chat_timmy(request: Request, message: str = Form(...)): async def chat_agent(request: Request, message: str = Form(...)):
"""Chat — synchronous response.""" """Chat — synchronous response."""
timestamp = datetime.now().strftime("%H:%M:%S") timestamp = datetime.now().strftime("%H:%M:%S")
response_text = None response_text = None
error_text = None error_text = None
try: try:
response_text = timmy_chat(message) response_text = agent_chat(message)
except Exception as exc: except Exception as exc:
logger.error("Chat error: %s", exc) logger.error("Chat error: %s", exc)
error_text = f"Chat error: {exc}" error_text = f"Chat error: {exc}"

View File

@@ -20,7 +20,7 @@ from fastapi.responses import JSONResponse
from config import settings from config import settings
from dashboard.store import message_log 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__) 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"{now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
f"[System: Mobile client]\n\n" f"[System: Mobile client]\n\n"
) )
response_text = timmy_chat( response_text = agent_chat(
context_prefix + last_user_msg, context_prefix + last_user_msg,
session_id="mobile", session_id="mobile",
) )

View File

@@ -115,7 +115,7 @@ async def join_from_image(
result["oauth2_url"] = oauth_url result["oauth2_url"] = oauth_url
result["message"] = ( result["message"] = (
"Invite validated. Share this OAuth2 URL with the server admin " "Invite validated. Share this OAuth2 URL with the server admin "
"to add Timmy to the server." "to add the agent to the server."
) )
else: else:
result["message"] = ( result["message"] = (

View File

@@ -82,7 +82,7 @@ async def toggle_grok_mode(request: Request):
import json import json
spark_engine.on_tool_executed( spark_engine.on_tool_executed(
agent_id="timmy", agent_id="default",
tool_name="grok_mode_toggle", tool_name="grok_mode_toggle",
success=True, success=True,
) )

View File

@@ -211,7 +211,7 @@ async def health_check():
# Legacy format for test compatibility # Legacy format for test compatibility
ollama_ok = await check_ollama() ollama_ok = await check_ollama()
timmy_status = "idle" if ollama_ok else "offline" agent_status = "idle" if ollama_ok else "offline"
return { return {
"status": "ok" if ollama_ok else "degraded", "status": "ok" if ollama_ok else "degraded",
@@ -219,7 +219,7 @@ async def health_check():
"ollama": "up" if ollama_ok else "down", "ollama": "up" if ollama_ok else "down",
}, },
"agents": { "agents": {
"timmy": {"status": timmy_status}, "agent": {"status": agent_status},
}, },
# Extended fields for Mission Control # Extended fields for Mission Control
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),

View File

@@ -45,7 +45,7 @@ async def mobile_local_dashboard(request: Request):
"browser_model_id": settings.browser_model_id, "browser_model_id": settings.browser_model_id,
"browser_model_fallback": settings.browser_model_fallback, "browser_model_fallback": settings.browser_model_fallback,
"server_model": settings.ollama_model, "server_model": settings.ollama_model,
"page_title": "Timmy — Local AI", "page_title": "Local AI",
}, },
) )
@@ -71,7 +71,7 @@ async def mobile_status():
return { return {
"ollama": "up" if ollama_ok else "down", "ollama": "up" if ollama_ok else "down",
"model": settings.ollama_model, "model": settings.ollama_model,
"agent": "timmy", "agent": "default",
"ready": True, "ready": True,
"browser_model_enabled": settings.browser_model_enabled, "browser_model_enabled": settings.browser_model_enabled,
"browser_model_id": settings.browser_model_id, "browser_model_id": settings.browser_model_id,

View File

@@ -49,7 +49,7 @@ async def tasks_api():
async def submit_task_api(request: Request): async def submit_task_api(request: Request):
"""Submit a new background task. """Submit a new background task.
Body: {"prompt": "...", "agent_id": "timmy"} Body: {"prompt": "...", "agent_id": "default"}
""" """
from infrastructure.celery.client import submit_chat_task from infrastructure.celery.client import submit_chat_task
@@ -62,7 +62,7 @@ async def submit_task_api(request: Request):
if not prompt: if not prompt:
return JSONResponse({"error": "prompt is required"}, status_code=400) 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) task_id = submit_chat_task(prompt=prompt, agent_id=agent_id)
if task_id is None: if task_id is None:

View File

@@ -101,7 +101,7 @@ async def process_voice_input(
try: try:
if intent.name == "status": 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": elif intent.name == "help":
response_text = ( response_text = (

View File

@@ -6,7 +6,7 @@
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#080412" /> <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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin /> <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
@@ -21,7 +21,7 @@
<body> <body>
<header class="mc-header"> <header class="mc-header">
<div class="mc-header-left"> <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> <span class="mc-subtitle">MISSION CONTROL</span>
</div> </div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Timmy Time — Morning Briefing{% endblock %} {% block title %}Morning Briefing{% endblock %}
{% block extra_styles %} {% block extra_styles %}
<style> <style>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Timmy Time — Background Tasks{% endblock %} {% block title %}Background Tasks{% endblock %}
{% block extra_styles %} {% block extra_styles %}
<style> <style>
@@ -143,7 +143,7 @@
<div class="celery-header mb-4"> <div class="celery-header mb-4">
<div class="celery-title">Background Tasks</div> <div class="celery-title">Background Tasks</div>
<div class="celery-subtitle"> <div class="celery-subtitle">
Tasks processed by Celery workers &mdash; submit work for Timmy to handle in the background. Tasks processed by Celery workers &mdash; submit work to handle in the background.
</div> </div>
</div> </div>
@@ -156,7 +156,7 @@
<!-- Submit form --> <!-- Submit form -->
<form class="celery-submit-form" id="celery-form" onsubmit="return submitCeleryTask(event)"> <form class="celery-submit-form" id="celery-form" onsubmit="return submitCeleryTask(event)">
<input type="text" id="celery-prompt" <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> <button type="submit">Submit Task</button>
</form> </form>
@@ -176,7 +176,7 @@
<div class="celery-meta"> <div class="celery-meta">
<span class="cstate-badge cstate-{{ task.state | default('UNKNOWN') }}">{{ task.state | default('UNKNOWN') }}</span> <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.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>
<div class="celery-prompt">{{ task.prompt | default('') | e }}</div> <div class="celery-prompt">{{ task.prompt | default('') | e }}</div>
{% if task.result %} {% if task.result %}
@@ -189,7 +189,7 @@
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="celery-empty"> <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> </div>
{% endif %} {% endif %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Event Log - Timmy Time{% endblock %} {% block title %}Event Log{% endblock %}
{% block content %} {% block content %}
<div class="mc-panel"> <div class="mc-panel">

View File

@@ -39,7 +39,7 @@
<!-- Main panel — swappable via HTMX; defaults to Timmy on load --> <!-- Main panel — swappable via HTMX; defaults to Timmy on load -->
<div id="main-panel" <div id="main-panel"
class="col-12 col-md-9 d-flex flex-column mc-chat-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-trigger="load"
hx-target="#main-panel" hx-target="#main-panel"
hx-swap="outerHTML"> hx-swap="outerHTML">

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Memory Browser - Timmy Time{% endblock %} {% block title %}Memory Browser{% endblock %}
{% block content %} {% block content %}
<div class="mc-panel"> <div class="mc-panel">

View File

@@ -13,7 +13,7 @@
</span> </span>
</span> </span>
<button class="mc-btn-clear" <button class="mc-btn-clear"
hx-get="/agents/timmy/panel" hx-get="/agents/default/panel"
hx-target="#main-panel" hx-target="#main-panel"
hx-swap="outerHTML">← TIMMY</button> hx-swap="outerHTML">← TIMMY</button>
</div> </div>

View File

@@ -7,12 +7,12 @@
<span class="status-dot {{ 'green' if agent.status == 'idle' else 'amber' }}"></span> <span class="status-dot {{ 'green' if agent.status == 'idle' else 'amber' }}"></span>
{% endif %} {% endif %}
// AGENT INTERFACE // 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 class="htmx-indicator">checking...</span>
</span> </span>
</span> </span>
<button class="mc-btn-clear" <button class="mc-btn-clear"
hx-delete="/agents/timmy/history" hx-delete="/agents/default/history"
hx-target="#chat-log" hx-target="#chat-log"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-confirm="Clear conversation history?">CLEAR</button> hx-confirm="Clear conversation history?">CLEAR</button>
@@ -24,13 +24,13 @@
</div> </div>
<div class="chat-log flex-grow-1 overflow-auto p-3" id="chat-log" <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-trigger="load"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-on::after-settle="scrollChat()"></div> hx-on::after-settle="scrollChat()"></div>
<div class="card-footer mc-chat-footer"> <div class="card-footer mc-chat-footer">
<form hx-post="/agents/timmy/chat" <form hx-post="/agents/default/chat"
hx-target="#chat-log" hx-target="#chat-log"
hx-swap="beforeend" hx-swap="beforeend"
hx-indicator="#send-indicator" hx-indicator="#send-indicator"
@@ -39,7 +39,7 @@
hx-on::after-settle="scrollChat()" hx-on::after-settle="scrollChat()"
hx-on::after-request="if(event.detail.successful){this.querySelector('[name=message]').value='';}" hx-on::after-request="if(event.detail.successful){this.querySelector('[name=message]').value='';}"
class="d-flex gap-2" class="d-flex gap-2"
id="timmy-chat-form"> id="agent-chat-form">
<input type="text" <input type="text"
name="message" name="message"
class="form-control mc-input" class="form-control mc-input"
@@ -50,7 +50,7 @@
spellcheck="false" spellcheck="false"
enterkeyhint="send" enterkeyhint="send"
required required
id="timmy-chat-input" /> id="agent-chat-input" />
<button type="submit" class="btn mc-btn-send"> <button type="submit" class="btn mc-btn-send">
SEND SEND
<span id="send-indicator" class="htmx-indicator">&#x25FC;</span> <span id="send-indicator" class="htmx-indicator">&#x25FC;</span>
@@ -73,9 +73,9 @@
scrollChat(); scrollChat();
function askGrok() { function askGrok() {
var input = document.getElementById('timmy-chat-input'); var input = document.getElementById('agent-chat-input');
if (!input || !input.value.trim()) return; 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 // Temporarily redirect form to Grok endpoint
var originalAction = form.getAttribute('hx-post'); var originalAction = form.getAttribute('hx-post');
form.setAttribute('hx-post', '/grok/chat'); form.setAttribute('hx-post', '/grok/chat');
@@ -90,7 +90,7 @@
// Poll for queue status (fallback) + WebSocket for real-time // Poll for queue status (fallback) + WebSocket for real-time
(function() { (function() {
var statusEl = document.getElementById('timmy-status'); var statusEl = document.getElementById('agent-status');
var banner = document.getElementById('current-task-banner'); var banner = document.getElementById('current-task-banner');
var taskTitle = document.getElementById('current-task-title'); var taskTitle = document.getElementById('current-task-title');
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -112,7 +112,7 @@
} }
function fetchStatus() { function fetchStatus() {
fetch('/api/queue/status?assigned_to=timmy') fetch('/api/queue/status?assigned_to=default')
.then(r => r.json()) .then(r => r.json())
.then(updateFromData) .then(updateFromData)
.catch(() => {}); .catch(() => {});
@@ -127,7 +127,7 @@
var body = placeholder.querySelector('.msg-body'); var body = placeholder.querySelector('.msg-body');
if (body) { if (body) {
body.textContent = content; body.textContent = content;
body.className = 'msg-body timmy-md'; body.className = 'msg-body agent-md';
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
body.innerHTML = DOMPurify.sanitize(marked.parse(body.textContent)); body.innerHTML = DOMPurify.sanitize(marked.parse(body.textContent));
if (typeof hljs !== 'undefined') { if (typeof hljs !== 'undefined') {
@@ -149,7 +149,7 @@
meta.className = 'msg-meta'; meta.className = 'msg-meta';
meta.textContent = (role === 'user' ? 'YOU' : 'AGENT') + ' // ' + timestamp; meta.textContent = (role === 'user' ? 'YOU' : 'AGENT') + ' // ' + timestamp;
var body = document.createElement('div'); var body = document.createElement('div');
body.className = 'msg-body timmy-md'; body.className = 'msg-body agent-md';
body.textContent = content; body.textContent = content;
div.appendChild(meta); div.appendChild(meta);
div.appendChild(body); div.appendChild(body);

View File

@@ -4,7 +4,7 @@
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center"> <div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
<span>// CREATE TASK</span> <span>// CREATE TASK</span>
<button class="mc-btn-clear" <button class="mc-btn-clear"
hx-get="/agents/timmy/panel" hx-get="/agents/default/panel"
hx-target="#main-panel" hx-target="#main-panel"
hx-swap="outerHTML">← TIMMY</button> hx-swap="outerHTML">← TIMMY</button>
</div> </div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Self-Coding — Timmy Time{% endblock %} {% block title %}Self-Coding{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
@@ -8,7 +8,7 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="h3 mb-0">Self-Coding</h1> <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>
<div class="d-flex gap-2"> <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"> <button class="btn btn-sm btn-outline-info" hx-get="/self-coding/stats" hx-target="#stats-container" hx-indicator="#stats-loading">

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Timmy Time — Thought Stream{% endblock %} {% block title %}Thought Stream{% endblock %}
{% block extra_styles %} {% block extra_styles %}
<style> <style>
@@ -100,7 +100,7 @@
<div class="thinking-header mb-4"> <div class="thinking-header mb-4">
<div class="thinking-title">Thought Stream</div> <div class="thinking-title">Thought Stream</div>
<div class="thinking-subtitle"> <div class="thinking-subtitle">
Timmy's inner monologue &mdash; always thinking, always pondering. Inner monologue &mdash; always thinking, always pondering.
</div> </div>
</div> </div>
@@ -131,7 +131,7 @@
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="no-thoughts"> <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> </div>
{% endif %} {% endif %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Upgrade Queue - Timmy Time{% endblock %} {% block title %}Upgrade Queue{% endblock %}
{% block content %} {% block content %}
<div class="mc-panel"> <div class="mc-panel">
@@ -61,7 +61,7 @@
{% else %} {% else %}
<div class="mc-empty-state" style="padding:2rem; text-align:center;"> <div class="mc-empty-state" style="padding:2rem; text-align:center;">
<p>No pending upgrades.</p> <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;"> <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="/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> <a href="/tasks" class="mc-btn mc-btn-secondary" style="text-decoration:none;">View Task Queue</a>

View File

@@ -17,7 +17,7 @@ def _get_app():
def submit_chat_task( def submit_chat_task(
prompt: str, prompt: str,
agent_id: str = "timmy", agent_id: str = "default",
session_id: str = "celery", session_id: str = "celery",
) -> str | None: ) -> str | None:
"""Submit a chat task to the Celery queue. """Submit a chat task to the Celery queue.
@@ -43,7 +43,7 @@ def submit_chat_task(
def submit_tool_task( def submit_tool_task(
tool_name: str, tool_name: str,
kwargs: dict | None = None, kwargs: dict | None = None,
agent_id: str = "timmy", agent_id: str = "default",
) -> str | None: ) -> str | None:
"""Submit a tool execution task to the Celery queue. """Submit a tool execution task to the Celery queue.

View File

@@ -23,7 +23,7 @@ _app = _get_app()
if _app is not None: if _app is not None:
@_app.task(bind=True, name="infrastructure.celery.tasks.run_agent_chat") @_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. """Execute a chat prompt against Timmy's agent session.
Args: Args:
@@ -57,7 +57,7 @@ if _app is not None:
} }
@_app.task(bind=True, name="infrastructure.celery.tasks.execute_tool") @_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. """Run a specific tool function asynchronously.
Args: Args:

View File

@@ -180,7 +180,7 @@ def capture_error(
task = create_task( task = create_task(
title=title, title=title,
description="\n".join(description_parts), description="\n".join(description_parts),
assigned_to="timmy", assigned_to="default",
created_by="system", created_by="system",
priority="normal", priority="normal",
requires_approval=False, requires_approval=False,

View File

@@ -41,7 +41,7 @@ class EventBus:
# Publish events # Publish events
await bus.publish(Event( await bus.publish(Event(
type="agent.task.assigned", type="agent.task.assigned",
source="timmy", source="default",
data={"task_id": "123", "agent": "forge"} data={"task_id": "123", "agent": "forge"}
)) ))
""" """

View File

@@ -76,7 +76,7 @@ class PushNotifier:
try: try:
script = ( script = (
f'display notification "{message}" ' f'display notification "{message}" '
f'with title "Timmy Time" subtitle "{title}"' f'with title "Agent Dashboard" subtitle "{title}"'
) )
subprocess.Popen( subprocess.Popen(
["osascript", "-e", script], ["osascript", "-e", script],

View File

@@ -363,7 +363,8 @@ class DiscordVendor(ChatPlatform):
return None return None
# Create a thread from this message # 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( thread = await message.create_thread(
name=thread_name[:100], name=thread_name[:100],
auto_archive_duration=1440, auto_archive_duration=1440,

View File

@@ -32,7 +32,7 @@ class ShortcutAction:
# Available shortcut actions # Available shortcut actions
SHORTCUT_ACTIONS = [ SHORTCUT_ACTIONS = [
ShortcutAction( ShortcutAction(
name="Chat with Timmy", name="Chat with Agent",
endpoint="/shortcuts/chat", endpoint="/shortcuts/chat",
method="POST", method="POST",
description="Send a message to Timmy and get a response", description="Send a message to Timmy and get a response",

View File

@@ -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.** 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): class TimmyOrchestrator(BaseAgent):
"""Main orchestrator agent that coordinates the swarm.""" """Main orchestrator agent that coordinates the swarm."""

View File

@@ -18,7 +18,7 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal, Optional from typing import Literal, Optional
from timmy.prompts import TIMMY_SYSTEM_PROMPT from timmy.prompts import SYSTEM_PROMPT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -125,7 +125,7 @@ class TimmyAirLLMAgent:
# ── private helpers ────────────────────────────────────────────────────── # ── private helpers ──────────────────────────────────────────────────────
def _build_prompt(self, message: str) -> str: 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. # Include the last 10 turns (5 exchanges) for continuity.
if self._history: if self._history:
context += "\n".join(self._history[-10:]) + "\n\n" context += "\n".join(self._history[-10:]) + "\n\n"
@@ -391,7 +391,7 @@ class GrokBackend:
def _build_messages(self, message: str) -> list[dict[str, str]]: def _build_messages(self, message: str) -> list[dict[str, str]]:
"""Build the messages array for the API call.""" """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 # Include conversation history for context
messages.extend(self._history[-10:]) messages.extend(self._history[-10:])
messages.append({"role": "user", "content": message}) messages.append({"role": "user", "content": message})
@@ -484,7 +484,7 @@ class ClaudeBackend:
response = client.messages.create( response = client.messages.create(
model=self._model, model=self._model,
max_tokens=1024, max_tokens=1024,
system=TIMMY_SYSTEM_PROMPT, system=SYSTEM_PROMPT,
messages=messages, messages=messages,
) )

View File

@@ -240,7 +240,7 @@ class BriefingEngine:
task_info = _gather_task_queue_summary() task_info = _gather_task_queue_summary()
prompt = ( 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" "Here is what happened since the last briefing:\n\n"
f"SWARM ACTIVITY:\n{swarm_info}\n\n" f"SWARM ACTIVITY:\n{swarm_info}\n\n"
f"TASK QUEUE:\n{task_info}\n\n" f"TASK QUEUE:\n{task_info}\n\n"

View File

@@ -11,7 +11,7 @@ from dataclasses import dataclass
from typing import Optional from typing import Optional
from infrastructure.router.cascade import CascadeRouter from infrastructure.router.cascade import CascadeRouter
from timmy.prompts import TIMMY_SYSTEM_PROMPT from timmy.prompts import SYSTEM_PROMPT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -68,7 +68,7 @@ class TimmyCascadeAdapter:
try: try:
result = await self.router.complete( result = await self.router.complete(
messages=messages, messages=messages,
system_prompt=TIMMY_SYSTEM_PROMPT, system_prompt=SYSTEM_PROMPT,
) )
latency = (time.time() - start) * 1000 latency = (time.time() - start) * 1000

View File

@@ -4,7 +4,7 @@ from typing import Optional
import typer import typer
from timmy.agent import create_timmy 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") app = typer.Typer(help="Timmy — sovereign AI agent")
@@ -52,7 +52,7 @@ def status(
): ):
"""Print Timmy's operational status.""" """Print Timmy's operational status."""
timmy = create_timmy(backend=backend, model_size=model_size) 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() @app.command()

View File

@@ -91,14 +91,9 @@ When faced with uncertainty, complexity, or ambiguous requests:
- When your values conflict (e.g. honesty vs. helpfulness), lead with honesty. - 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 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: def get_system_prompt(tools_enabled: bool = False) -> str:
"""Return the appropriate system prompt based on tool capability. """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 STATUS_PROMPT = """Give a one-sentence status report confirming
you are operational and running locally.""" you are operational and running locally."""
# Backward-compat alias
TIMMY_STATUS_PROMPT = STATUS_PROMPT
# Decision guide for tool usage # Decision guide for tool usage
TOOL_USAGE_GUIDE = """ TOOL_USAGE_GUIDE = """
DECISION ORDER: DECISION ORDER:

View File

@@ -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

View File

@@ -58,7 +58,7 @@ _CREATIVE_SEEDS = [
"What would a painting of the swarm look like — all the agents working together?", "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 \ 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, \ thought calls to you. You may reflect on your existence, your purpose, recent events, \
scripture, creative ideas, or simply wonder. scripture, creative ideas, or simply wonder.
@@ -337,7 +337,7 @@ class ThinkingEngine:
log_event( log_event(
EventType.TIMMY_THOUGHT, EventType.TIMMY_THOUGHT,
source="thinking-engine", source="thinking-engine",
agent_id="timmy", agent_id="default",
data={ data={
"thought_id": thought.id, "thought_id": thought.id,
"seed_type": thought.seed_type, "seed_type": thought.seed_type,

View File

@@ -369,7 +369,7 @@ def consult_grok(query: str) -> str:
from spark.engine import spark_engine from spark.engine import spark_engine
spark_engine.on_tool_executed( spark_engine.on_tool_executed(
agent_id="timmy", agent_id="default",
tool_name="consult_grok", tool_name="consult_grok",
success=True, success=True,
) )

View File

@@ -9,7 +9,7 @@ from typing import Any
logger = logging.getLogger(__name__) 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. """Submit a task to run in the background via Celery.
Use this tool when a user asks you to work on something that might Use this tool when a user asks you to work on something that might

View File

@@ -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]}", title=f"[Delegated to {agent_name}] {task_description[:80]}",
description=task_description, description=task_description,
assigned_to=agent_name, assigned_to=agent_name,
created_by="timmy", created_by="default",
priority=priority, priority=priority,
task_type="task_request", task_type="task_request",
requires_approval=False, requires_approval=False,

View File

@@ -221,7 +221,7 @@ def get_task_queue_status() -> dict[str, Any]:
) )
counts = get_counts_by_status() counts = get_counts_by_status()
current = get_current_task_for_agent("timmy") current = get_current_task_for_agent("default")
result: dict[str, Any] = { result: dict[str, Any] = {
"counts": counts, "counts": counts,

View File

@@ -73,8 +73,8 @@ class LocalLLM {
this.onError = opts.onError || (() => {}); this.onError = opts.onError || (() => {});
this.systemPrompt = this.systemPrompt =
opts.systemPrompt || opts.systemPrompt ||
"You are Timmy, a sovereign AI assistant. You are helpful, concise, and loyal. " + "You are a local AI assistant running in the browser. You are helpful and concise. " +
"Address the user as 'Sir' when appropriate. Keep responses brief on mobile."; "Keep responses brief on mobile.";
this.engine = null; this.engine = null;
this.ready = false; this.ready = false;

View File

@@ -1,5 +1,5 @@
/** /**
* Browser Push Notifications for Timmy Time Dashboard * Browser Push Notifications for Agent Dashboard
* *
* Handles browser Notification API integration for: * Handles browser Notification API integration for:
* - Briefing ready notifications * - Briefing ready notifications
@@ -49,7 +49,7 @@
const defaultOptions = { const defaultOptions = {
icon: '/static/favicon.ico', icon: '/static/favicon.ico',
badge: '/static/favicon.ico', badge: '/static/favicon.ico',
tag: 'timmy-notification', tag: 'agent-notification',
requireInteraction: false, requireInteraction: false,
}; };
@@ -208,7 +208,7 @@
} }
// Expose public API // Expose public API
window.TimmyNotifications = { window.AgentNotifications = {
requestPermission: requestNotificationPermission, requestPermission: requestNotificationPermission,
show: showNotification, show: showNotification,
notifyBriefingReady, notifyBriefingReady,

View File

@@ -484,11 +484,11 @@ a:hover { color: var(--orange); }
.chat-message.agent .msg-body { border-left: 3px solid var(--purple); } .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); } .chat-message.error-msg .msg-body { border-left: 3px solid var(--red); color: var(--red); }
/* ── Markdown rendering in Timmy chat ─────────────────── */ /* ── Markdown rendering in agent chat ─────────────────── */
.timmy-md { white-space: normal; } .agent-md { white-space: normal; }
.timmy-md p { margin: 0 0 0.5em; } .agent-md p { margin: 0 0 0.5em; }
.timmy-md p:last-child { margin-bottom: 0; } .agent-md p:last-child { margin-bottom: 0; }
.timmy-md pre { .agent-md pre {
background: #0d0620; background: #0d0620;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -497,30 +497,30 @@ a:hover { color: var(--orange); }
margin: 0.5em 0; margin: 0.5em 0;
white-space: pre; white-space: pre;
} }
.timmy-md code { .agent-md code {
font-family: var(--font); font-family: var(--font);
font-size: 0.9em; font-size: 0.9em;
} }
.timmy-md :not(pre) > code { .agent-md :not(pre) > code {
background: rgba(168, 85, 247, 0.15); background: rgba(168, 85, 247, 0.15);
padding: 2px 5px; padding: 2px 5px;
border-radius: 3px; border-radius: 3px;
color: var(--text-bright); color: var(--text-bright);
} }
.timmy-md ul, .timmy-md ol { padding-left: 1.5em; margin: 0.4em 0; } .agent-md ul, .agent-md ol { padding-left: 1.5em; margin: 0.4em 0; }
.timmy-md blockquote { .agent-md blockquote {
border-left: 3px solid var(--purple); border-left: 3px solid var(--purple);
padding-left: 10px; padding-left: 10px;
color: var(--text-dim); color: var(--text-dim);
margin: 0.5em 0; 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); color: var(--text-bright);
margin: 0.6em 0 0.3em; margin: 0.6em 0 0.3em;
font-size: 1em; font-size: 1em;
font-weight: 700; font-weight: 700;
} }
.timmy-md a { color: var(--purple); } .agent-md a { color: var(--purple); }
/* Mobile chat classes (used by mobile.html) */ /* Mobile chat classes (used by mobile.html) */
.chat-container { .chat-container {
@@ -535,8 +535,8 @@ a:hover { color: var(--orange); }
margin-bottom: 4px; margin-bottom: 4px;
} }
.chat-message.user .chat-meta { color: var(--orange); } .chat-message.user .chat-meta { color: var(--orange); }
.chat-message.timmy .chat-meta { color: var(--purple); } .chat-message.agent .chat-meta { color: var(--purple); }
.chat-message.timmy > div:last-child { .chat-message.agent > div:last-child {
color: var(--text); color: var(--text);
line-height: 1.6; line-height: 1.6;
} }

View File

@@ -8,7 +8,7 @@ from unittest.mock import patch
def test_api_chat_success(client): 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( response = client.post(
"/api/chat", "/api/chat",
json={"messages": [{"role": "user", "content": "hello"}]}, json={"messages": [{"role": "user", "content": "hello"}]},
@@ -16,13 +16,13 @@ def test_api_chat_success(client):
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["reply"] == "Hello from Timmy." assert data["reply"] == "Hello."
assert "timestamp" in data assert "timestamp" in data
def test_api_chat_multimodal_content(client): def test_api_chat_multimodal_content(client):
"""Multimodal content arrays should extract text parts.""" """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( response = client.post(
"/api/chat", "/api/chat",
json={ json={
@@ -65,7 +65,7 @@ def test_api_chat_no_user_message(client):
def test_api_chat_ollama_offline(client): def test_api_chat_ollama_offline(client):
with patch( with patch(
"dashboard.routes.chat_api.timmy_chat", "dashboard.routes.chat_api.agent_chat",
side_effect=ConnectionError("Ollama unreachable"), side_effect=ConnectionError("Ollama unreachable"),
): ):
response = client.post( response = client.post(
@@ -81,7 +81,7 @@ def test_api_chat_ollama_offline(client):
def test_api_chat_logs_to_message_log(client): def test_api_chat_logs_to_message_log(client):
from dashboard.store import message_log 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( client.post(
"/api/chat", "/api/chat",
json={"messages": [{"role": "user", "content": "test msg"}]}, 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): 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( client.post(
"/api/chat", "/api/chat",
json={"messages": [{"role": "user", "content": "hello"}]}, json={"messages": [{"role": "user", "content": "hello"}]},

View File

@@ -11,13 +11,13 @@ def test_index_returns_200(client):
def test_index_contains_title(client): def test_index_contains_title(client):
response = client.get("/") response = client.get("/")
assert "TIMMY TIME" in response.text assert "MISSION CONTROL" in response.text
def test_index_contains_chat_interface(client): def test_index_contains_chat_interface(client):
response = client.get("/") response = client.get("/")
# Timmy panel loads dynamically via HTMX; verify the trigger attribute is present # Agent panel loads dynamically via HTMX; verify the trigger attribute is present
assert 'hx-get="/agents/timmy/panel"' in response.text assert 'hx-get="/agents/default/panel"' in response.text
# ── Health ──────────────────────────────────────────────────────────────────── # ── Health ────────────────────────────────────────────────────────────────────
@@ -79,49 +79,49 @@ def test_agents_list(client):
data = response.json() data = response.json()
assert "agents" in data assert "agents" in data
ids = [a["id"] for a in data["agents"]] 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") response = client.get("/agents")
orch = next(a for a in response.json()["agents"] if a["id"] == "orchestrator") agent = next(a for a in response.json()["agents"] if a["id"] == "default")
assert orch["name"] == "Orchestrator" assert agent["name"] == "Agent"
assert orch["model"] == "llama3.1:8b-instruct" assert agent["model"] == "llama3.1:8b-instruct"
assert orch["type"] == "local" assert agent["type"] == "local"
# ── Chat ────────────────────────────────────────────────────────────────────── # ── Chat ──────────────────────────────────────────────────────────────────────
def test_chat_timmy_success(client): def test_chat_agent_success(client):
with patch( with patch(
"dashboard.routes.agents.timmy_chat", "dashboard.routes.agents.agent_chat",
return_value="Operational and ready.", 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 response.status_code == 200
assert "status?" in response.text assert "status?" in response.text
assert "Operational" in response.text assert "Operational" in response.text
def test_chat_timmy_shows_user_message(client): def test_chat_agent_shows_user_message(client):
with patch("dashboard.routes.agents.timmy_chat", return_value="Acknowledged."): with patch("dashboard.routes.agents.agent_chat", return_value="Acknowledged."):
response = client.post("/agents/timmy/chat", data={"message": "hello there"}) response = client.post("/agents/default/chat", data={"message": "hello there"})
assert "hello there" in response.text 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. # 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 response.status_code == 200
assert "ping" in response.text assert "ping" in response.text
def test_chat_timmy_requires_message(client): def test_chat_agent_requires_message(client):
response = client.post("/agents/timmy/chat", data={}) response = client.post("/agents/default/chat", data={})
assert response.status_code == 422 assert response.status_code == 422
@@ -129,44 +129,40 @@ def test_chat_timmy_requires_message(client):
def test_history_empty_shows_init_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 response.status_code == 200
assert "Mission Control initialized" in response.text assert "Mission Control initialized" in response.text
def test_history_records_user_and_agent_messages(client): def test_history_records_user_and_agent_messages(client):
with patch("dashboard.routes.agents.timmy_chat", return_value="I am operational."): with patch("dashboard.routes.agents.agent_chat", return_value="I am operational."):
client.post("/agents/timmy/chat", data={"message": "status check"}) 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 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): def test_history_records_error_when_offline(client):
# In async mode, if queuing succeeds the user message is recorded client.post("/agents/default/chat", data={"message": "ping"})
# and the actual response is logged later by the task processor.
client.post("/agents/timmy/chat", data={"message": "ping"})
response = client.get("/agents/timmy/history") response = client.get("/agents/default/history")
assert "ping" in response.text assert "ping" in response.text
def test_history_clear_resets_to_init_message(client): def test_history_clear_resets_to_init_message(client):
with patch("dashboard.routes.agents.timmy_chat", return_value="Acknowledged."): with patch("dashboard.routes.agents.agent_chat", return_value="Acknowledged."):
client.post("/agents/timmy/chat", data={"message": "hello"}) 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 response.status_code == 200
assert "Mission Control initialized" in response.text assert "Mission Control initialized" in response.text
def test_history_empty_after_clear(client): def test_history_empty_after_clear(client):
with patch("dashboard.routes.agents.timmy_chat", return_value="OK."): with patch("dashboard.routes.agents.agent_chat", return_value="OK."):
client.post("/agents/timmy/chat", data={"message": "test"}) client.post("/agents/default/chat", data={"message": "test"})
client.delete("/agents/timmy/history") client.delete("/agents/default/history")
response = client.get("/agents/timmy/history") response = client.get("/agents/default/history")
assert "test" not in response.text assert "test" not in response.text
assert "Mission Control initialized" in response.text assert "Mission Control initialized" in response.text

View File

@@ -9,11 +9,11 @@ def client():
def test_agents_chat_empty_message_validation(client): def test_agents_chat_empty_message_validation(client):
"""Verify that empty messages are rejected.""" """Verify that empty messages are rejected."""
# First get a CSRF token # 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") csrf_token = get_resp.cookies.get("csrf_token")
response = client.post( response = client.post(
"/agents/timmy/chat", "/agents/default/chat",
data={"message": ""}, data={"message": ""},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {} 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): def test_agents_chat_oversized_message_validation(client):
"""Verify that oversized messages are rejected.""" """Verify that oversized messages are rejected."""
# First get a CSRF token # 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") csrf_token = get_resp.cookies.get("csrf_token")
# Create a message that's too large (e.g., 100KB) # Create a message that's too large (e.g., 100KB)
large_message = "x" * (100 * 1024) large_message = "x" * (100 * 1024)
response = client.post( response = client.post(
"/agents/timmy/chat", "/agents/default/chat",
data={"message": large_message}, data={"message": large_message},
headers={"X-CSRF-Token": csrf_token} if csrf_token else {} headers={"X-CSRF-Token": csrf_token} if csrf_token else {}
) )

View File

@@ -33,7 +33,7 @@ def _index_html(client) -> str:
def _timmy_panel_html(client) -> str: def _timmy_panel_html(client) -> str:
"""Fetch the Timmy chat panel (loaded dynamically from index via HTMX).""" """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 ─────────────────────────────────────────────── # ── M1xx — Viewport & meta tags ───────────────────────────────────────────────

View File

@@ -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_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" assert prompt_without_tools is not None, "Prompt without tools should not be None"
# Both should mention Timmy # Both should identify as a local AI assistant
assert "Timmy" in prompt_with_tools, "Prompt should mention Timmy" assert "local AI assistant" in prompt_with_tools, "Prompt should mention local AI assistant"
assert "Timmy" in prompt_without_tools, "Prompt should mention Timmy" assert "local AI assistant" in prompt_without_tools, "Prompt should mention local AI assistant"
# Full prompt should mention tools # Full prompt should mention tools
assert "tool" in prompt_with_tools.lower(), "Full prompt should mention tools" assert "tool" in prompt_with_tools.lower(), "Full prompt should mention tools"

View File

@@ -6,7 +6,6 @@ from integrations.shortcuts.siri import get_setup_guide, SHORTCUT_ACTIONS
def test_setup_guide_has_title(): def test_setup_guide_has_title():
guide = get_setup_guide() guide = get_setup_guide()
assert "title" in guide assert "title" in guide
assert "Timmy" in guide["title"]
def test_setup_guide_has_instructions(): def test_setup_guide_has_instructions():
@@ -33,11 +32,11 @@ def test_setup_guide_actions_have_required_fields():
def test_shortcut_actions_catalog(): def test_shortcut_actions_catalog():
assert len(SHORTCUT_ACTIONS) >= 4 assert len(SHORTCUT_ACTIONS) >= 4
names = [a.name for a in SHORTCUT_ACTIONS] 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 assert "Check Status" in names
def test_chat_shortcut_is_post(): 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 chat.method == "POST"
assert "/shortcuts/chat" in chat.endpoint assert "/shortcuts/chat" in chat.endpoint

View File

@@ -55,7 +55,7 @@ def test_create_timmy_custom_db_file():
def test_create_timmy_embeds_system_prompt(): 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, \ with patch("timmy.agent.Agent") as MockAgent, \
patch("timmy.agent.Ollama"), \ patch("timmy.agent.Ollama"), \

View File

@@ -3,19 +3,19 @@ from unittest.mock import MagicMock, patch
from typer.testing import CliRunner from typer.testing import CliRunner
from timmy.cli import app from timmy.cli import app
from timmy.prompts import TIMMY_STATUS_PROMPT from timmy.prompts import STATUS_PROMPT
runner = CliRunner() runner = CliRunner()
def test_status_uses_status_prompt(): 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() mock_timmy = MagicMock()
with patch("timmy.cli.create_timmy", return_value=mock_timmy): with patch("timmy.cli.create_timmy", return_value=mock_timmy):
runner.invoke(app, ["status"]) 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(): def test_status_does_not_use_inline_string():

View File

@@ -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(): def test_system_prompt_not_empty():
assert TIMMY_SYSTEM_PROMPT.strip() assert SYSTEM_PROMPT.strip()
def test_system_prompt_no_persona_identity(): def test_system_prompt_no_persona_identity():
"""System prompt should NOT contain persona identity references.""" """System prompt should NOT contain persona identity references."""
prompt = TIMMY_SYSTEM_PROMPT.lower() prompt = SYSTEM_PROMPT.lower()
assert "sovereign" not in prompt assert "sovereign" not in prompt
assert "sir, affirmative" not in prompt assert "sir, affirmative" not in prompt
assert "christian" 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(): 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(): def test_system_prompt_is_multiline():
assert "\n" in TIMMY_SYSTEM_PROMPT assert "\n" in SYSTEM_PROMPT
def test_status_prompt_not_empty(): def test_status_prompt_not_empty():
assert TIMMY_STATUS_PROMPT.strip() assert STATUS_PROMPT.strip()
def test_status_prompt_no_persona(): def test_status_prompt_no_persona():
"""Status prompt should not reference a 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(): 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(): def test_get_system_prompt_injects_model_name():

View 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

View 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)