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
|
- 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 \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 \
|
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 \
|
||||||
|
|||||||
@@ -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)",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
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.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",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__(
|
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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"] = (
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 — submit work for Timmy to handle in the background.
|
Tasks processed by Celery workers — 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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">◼</span>
|
<span id="send-indicator" class="htmx-indicator">◼</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);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 — always thinking, always pondering.
|
Inner monologue — 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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"}
|
||||||
))
|
))
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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?",
|
"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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}]},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"), \
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
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