diff --git a/pyproject.toml b/pyproject.toml index 3c1f5b8..a398722 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ homepage = "http://localhost:3000/rockachopa/Timmy-time-dashboard" repository = "http://localhost:3000/rockachopa/Timmy-time-dashboard" packages = [ { include = "config.py", from = "src" }, - { include = "brain", from = "src" }, + { include = "dashboard", from = "src" }, { include = "infrastructure", from = "src" }, { include = "integrations", from = "src" }, @@ -124,7 +124,7 @@ ignore = [ ] [tool.ruff.lint.isort] -known-first-party = ["brain", "config", "dashboard", "infrastructure", "integrations", "spark", "swarm", "timmy", "timmy_serve"] +known-first-party = ["config", "dashboard", "infrastructure", "integrations", "spark", "timmy", "timmy_serve"] [tool.ruff.lint.per-file-ignores] "tests/**" = ["S"] diff --git a/src/brain/__init__.py b/src/brain/__init__.py deleted file mode 100644 index c66ca0e..0000000 --- a/src/brain/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Distributed Brain — unified memory and task queue. - -Provides: -- **UnifiedMemory** — Single API for all memory operations (local SQLite or rqlite) -- **BrainClient** — Direct rqlite interface for distributed operation -- **DistributedWorker** — Task execution on Tailscale nodes -- **LocalEmbedder** — Sentence-transformer embeddings (local, no cloud) - -Default backend is local SQLite (data/brain.db). Set RQLITE_URL to -upgrade to distributed rqlite over Tailscale — same API, replicated. -""" - -from brain.client import BrainClient -from brain.embeddings import LocalEmbedder -from brain.memory import UnifiedMemory, get_memory -from brain.worker import DistributedWorker - -__all__ = [ - "BrainClient", - "DistributedWorker", - "LocalEmbedder", - "UnifiedMemory", - "get_memory", -] diff --git a/src/brain/client.py b/src/brain/client.py deleted file mode 100644 index dd5e252..0000000 --- a/src/brain/client.py +++ /dev/null @@ -1,417 +0,0 @@ -"""Brain client — interface to distributed rqlite memory. - -All devices connect to the local rqlite node, which replicates to peers. -""" - -from __future__ import annotations - -import json -import logging -import os -import socket -from datetime import datetime -from typing import Any - -import httpx - -logger = logging.getLogger(__name__) - -DEFAULT_RQLITE_URL = "http://localhost:4001" - - -class BrainClient: - """Client for distributed brain (rqlite). - - Connects to local rqlite instance, which handles replication. - All writes go to leader, reads can come from local node. - """ - - def __init__(self, rqlite_url: str | None = None, node_id: str | None = None): - from config import settings - - self.rqlite_url = rqlite_url or settings.rqlite_url or DEFAULT_RQLITE_URL - self.node_id = node_id or f"{socket.gethostname()}-{os.getpid()}" - self.source = self._detect_source() - self._client = httpx.AsyncClient(timeout=30) - - def _detect_source(self) -> str: - """Detect what component is using the brain.""" - # Could be 'timmy', 'zeroclaw', 'worker', etc. - # For now, infer from context or env - from config import settings - - return settings.brain_source - - # ────────────────────────────────────────────────────────────────────────── - # Memory Operations - # ────────────────────────────────────────────────────────────────────────── - - async def remember( - self, - content: str, - tags: list[str] | None = None, - source: str | None = None, - metadata: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Store a memory with embedding. - - Args: - content: Text content to remember - tags: Optional list of tags (e.g., ['shell', 'result']) - source: Source identifier (defaults to self.source) - metadata: Additional JSON-serializable metadata - - Returns: - Dict with 'id' and 'status' - """ - from brain.embeddings import get_embedder - - embedder = get_embedder() - embedding_bytes = embedder.encode_single(content) - - query = """ - INSERT INTO memories (content, embedding, source, tags, metadata, created_at) - VALUES (?, ?, ?, ?, ?, ?) - """ - params = [ - content, - embedding_bytes, - source or self.source, - json.dumps(tags or []), - json.dumps(metadata or {}), - datetime.utcnow().isoformat(), - ] - - try: - resp = await self._client.post(f"{self.rqlite_url}/db/execute", json=[query, params]) - resp.raise_for_status() - result = resp.json() - - # Extract inserted ID - last_id = None - if "results" in result and result["results"]: - last_id = result["results"][0].get("last_insert_id") - - logger.debug(f"Stored memory {last_id}: {content[:50]}...") - return {"id": last_id, "status": "stored"} - - except Exception as e: - logger.error(f"Failed to store memory: {e}") - raise - - async def recall( - self, query: str, limit: int = 5, sources: list[str] | None = None - ) -> list[str]: - """Semantic search for memories. - - Args: - query: Search query text - limit: Max results to return - sources: Filter by source(s) (e.g., ['timmy', 'user']) - - Returns: - List of memory content strings - """ - from brain.embeddings import get_embedder - - embedder = get_embedder() - query_emb = embedder.encode_single(query) - - # rqlite with sqlite-vec extension for vector search - sql = "SELECT content, source, metadata, distance FROM memories WHERE embedding MATCH ?" - params = [query_emb] - - if sources: - placeholders = ",".join(["?"] * len(sources)) - sql += f" AND source IN ({placeholders})" - params.extend(sources) - - sql += " ORDER BY distance LIMIT ?" - params.append(limit) - - try: - resp = await self._client.post(f"{self.rqlite_url}/db/query", json=[sql, params]) - resp.raise_for_status() - result = resp.json() - - results = [] - if "results" in result and result["results"]: - for row in result["results"][0].get("rows", []): - results.append( - { - "content": row[0], - "source": row[1], - "metadata": json.loads(row[2]) if row[2] else {}, - "distance": row[3], - } - ) - - return results - - except Exception as e: - logger.error(f"Failed to search memories: {e}") - # Graceful fallback - return empty list - return [] - - async def get_recent( - self, hours: int = 24, limit: int = 20, sources: list[str] | None = None - ) -> list[dict[str, Any]]: - """Get recent memories by time. - - Args: - hours: Look back this many hours - limit: Max results - sources: Optional source filter - - Returns: - List of memory dicts - """ - sql = """ - SELECT id, content, source, tags, metadata, created_at - FROM memories - WHERE created_at > datetime('now', ?) - """ - params = [f"-{hours} hours"] - - if sources: - placeholders = ",".join(["?"] * len(sources)) - sql += f" AND source IN ({placeholders})" - params.extend(sources) - - sql += " ORDER BY created_at DESC LIMIT ?" - params.append(limit) - - try: - resp = await self._client.post(f"{self.rqlite_url}/db/query", json=[sql, params]) - resp.raise_for_status() - result = resp.json() - - memories = [] - if "results" in result and result["results"]: - for row in result["results"][0].get("rows", []): - memories.append( - { - "id": row[0], - "content": row[1], - "source": row[2], - "tags": json.loads(row[3]) if row[3] else [], - "metadata": json.loads(row[4]) if row[4] else {}, - "created_at": row[5], - } - ) - - return memories - - except Exception as e: - logger.error(f"Failed to get recent memories: {e}") - return [] - - async def get_context(self, query: str) -> str: - """Get formatted context for system prompt. - - Combines recent memories + relevant memories. - - Args: - query: Current user query to find relevant context - - Returns: - Formatted context string for prompt injection - """ - recent = await self.get_recent(hours=24, limit=10) - relevant = await self.recall(query, limit=5) - - lines = ["Recent activity:"] - for m in recent[:5]: - lines.append(f"- {m['content'][:100]}") - - lines.append("\nRelevant memories:") - for r in relevant[:5]: - lines.append(f"- {r['content'][:100]}") - - return "\n".join(lines) - - # ────────────────────────────────────────────────────────────────────────── - # Task Queue Operations - # ────────────────────────────────────────────────────────────────────────── - - async def submit_task( - self, - content: str, - task_type: str = "general", - priority: int = 0, - metadata: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Submit a task to the distributed queue. - - Args: - content: Task description/prompt - task_type: Type of task (shell, creative, code, research, general) - priority: Higher = processed first - metadata: Additional task data - - Returns: - Dict with task 'id' - """ - query = """ - INSERT INTO tasks (content, task_type, priority, status, metadata, created_at) - VALUES (?, ?, ?, 'pending', ?, ?) - """ - params = [ - content, - task_type, - priority, - json.dumps(metadata or {}), - datetime.utcnow().isoformat(), - ] - - try: - resp = await self._client.post(f"{self.rqlite_url}/db/execute", json=[query, params]) - resp.raise_for_status() - result = resp.json() - - last_id = None - if "results" in result and result["results"]: - last_id = result["results"][0].get("last_insert_id") - - logger.info(f"Submitted task {last_id}: {content[:50]}...") - return {"id": last_id, "status": "queued"} - - except Exception as e: - logger.error(f"Failed to submit task: {e}") - raise - - async def claim_task( - self, capabilities: list[str], node_id: str | None = None - ) -> dict[str, Any] | None: - """Atomically claim next available task. - - Uses UPDATE ... RETURNING pattern for atomic claim. - - Args: - capabilities: List of capabilities this node has - node_id: Identifier for claiming node - - Returns: - Task dict or None if no tasks available - """ - claimer = node_id or self.node_id - - # Try to claim a matching task atomically - # This works because rqlite uses Raft consensus - only one node wins - placeholders = ",".join(["?"] * len(capabilities)) - - query = f""" - UPDATE tasks - SET status = 'claimed', - claimed_by = ?, - claimed_at = ? - WHERE id = ( - SELECT id FROM tasks - WHERE status = 'pending' - AND (task_type IN ({placeholders}) OR task_type = 'general') - ORDER BY priority DESC, created_at ASC - LIMIT 1 - ) - AND status = 'pending' - RETURNING id, content, task_type, priority, metadata - """ - params = [claimer, datetime.utcnow().isoformat()] + capabilities - - try: - resp = await self._client.post(f"{self.rqlite_url}/db/execute", json=[query, params]) - resp.raise_for_status() - result = resp.json() - - if "results" in result and result["results"]: - rows = result["results"][0].get("rows", []) - if rows: - row = rows[0] - return { - "id": row[0], - "content": row[1], - "type": row[2], - "priority": row[3], - "metadata": json.loads(row[4]) if row[4] else {}, - } - - return None - - except Exception as e: - logger.error(f"Failed to claim task: {e}") - return None - - async def complete_task( - self, task_id: int, success: bool, result: str | None = None, error: str | None = None - ) -> None: - """Mark task as completed or failed. - - Args: - task_id: Task ID - success: True if task succeeded - result: Task result/output - error: Error message if failed - """ - status = "done" if success else "failed" - - query = """ - UPDATE tasks - SET status = ?, - result = ?, - error = ?, - completed_at = ? - WHERE id = ? - """ - params = [status, result, error, datetime.utcnow().isoformat(), task_id] - - try: - await self._client.post(f"{self.rqlite_url}/db/execute", json=[query, params]) - logger.debug(f"Task {task_id} marked {status}") - - except Exception as e: - logger.error(f"Failed to complete task {task_id}: {e}") - - async def get_pending_tasks(self, limit: int = 100) -> list[dict[str, Any]]: - """Get list of pending tasks (for dashboard/monitoring). - - Args: - limit: Max tasks to return - - Returns: - List of pending task dicts - """ - sql = """ - SELECT id, content, task_type, priority, metadata, created_at - FROM tasks - WHERE status = 'pending' - ORDER BY priority DESC, created_at ASC - LIMIT ? - """ - - try: - resp = await self._client.post(f"{self.rqlite_url}/db/query", json=[sql, [limit]]) - resp.raise_for_status() - result = resp.json() - - tasks = [] - if "results" in result and result["results"]: - for row in result["results"][0].get("rows", []): - tasks.append( - { - "id": row[0], - "content": row[1], - "type": row[2], - "priority": row[3], - "metadata": json.loads(row[4]) if row[4] else {}, - "created_at": row[5], - } - ) - - return tasks - - except Exception as e: - logger.error(f"Failed to get pending tasks: {e}") - return [] - - async def close(self): - """Close HTTP client.""" - await self._client.aclose() diff --git a/src/brain/embeddings.py b/src/brain/embeddings.py deleted file mode 100644 index a6630ef..0000000 --- a/src/brain/embeddings.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Local embeddings using sentence-transformers. - -No OpenAI dependency. Runs 100% locally on CPU. -""" - -from __future__ import annotations - -import logging - -logger = logging.getLogger(__name__) - -# Model cache -_model = None -_model_name = "all-MiniLM-L6-v2" -_dimensions = 384 - - -class LocalEmbedder: - """Local sentence transformer for embeddings. - - Uses all-MiniLM-L6-v2 (80MB download, runs on CPU). - 384-dimensional embeddings, good enough for semantic search. - """ - - def __init__(self, model_name: str = _model_name): - self.model_name = model_name - self._model = None - self._dimensions = _dimensions - - def _load_model(self): - """Lazy load the model.""" - global _model - if _model is not None: - self._model = _model - return - - try: - from sentence_transformers import SentenceTransformer - - logger.info(f"Loading embedding model: {self.model_name}") - _model = SentenceTransformer(self.model_name) - self._model = _model - logger.info(f"Embedding model loaded ({self._dimensions} dims)") - except ImportError: - logger.error( - "sentence-transformers not installed. Run: pip install sentence-transformers" - ) - raise - - def encode(self, text: str | list[str]): - """Encode text to embedding vector(s). - - Args: - text: String or list of strings to encode - - Returns: - Numpy array of shape (dims,) for single string or (n, dims) for list - """ - if self._model is None: - self._load_model() - - # Normalize embeddings for cosine similarity - return self._model.encode(text, normalize_embeddings=True) - - def encode_single(self, text: str) -> bytes: - """Encode single text to bytes for SQLite storage. - - Returns: - Float32 bytes - """ - import numpy as np - - embedding = self.encode(text) - if len(embedding.shape) > 1: - embedding = embedding[0] - return embedding.astype(np.float32).tobytes() - - def similarity(self, a, b) -> float: - """Compute cosine similarity between two vectors. - - Vectors should already be normalized from encode(). - """ - import numpy as np - - return float(np.dot(a, b)) - - -def get_embedder() -> LocalEmbedder: - """Get singleton embedder instance.""" - return LocalEmbedder() diff --git a/src/brain/memory.py b/src/brain/memory.py deleted file mode 100644 index f99e488..0000000 --- a/src/brain/memory.py +++ /dev/null @@ -1,677 +0,0 @@ -"""Unified memory interface (DEPRECATED). - -New code should use ``timmy.memory.unified`` and the tools in -``timmy.semantic_memory`` (memory_write, memory_read, memory_search, -memory_forget). This module is retained for backward compatibility -and the loop-QA self-test probes. - -One API, two backends: -- **Local SQLite** (default) — works immediately, no setup -- **Distributed rqlite** — same API, replicated across Tailscale devices - -Every module that needs to store or recall memory uses this interface. - -Usage: - from brain.memory import UnifiedMemory - - memory = UnifiedMemory() # auto-detects backend - - # Store - await memory.remember("User prefers dark mode", tags=["preference"]) - - # Recall - results = await memory.recall("what does the user prefer?") - - # Facts - await memory.store_fact("user_preference", "Prefers dark mode") - facts = await memory.get_facts("user_preference") - - # Context for prompt - context = await memory.get_context("current user question") -""" - -from __future__ import annotations - -import json -import logging -import sqlite3 -import uuid -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -logger = logging.getLogger(__name__) - -# Default paths -_PROJECT_ROOT = Path(__file__).parent.parent.parent -_DEFAULT_DB_PATH = _PROJECT_ROOT / "data" / "brain.db" - -# Schema version for migrations -_SCHEMA_VERSION = 1 - - -def _get_db_path() -> Path: - """Get the brain database path from env or default.""" - from config import settings - - if settings.brain_db_path: - return Path(settings.brain_db_path) - return _DEFAULT_DB_PATH - - -class UnifiedMemory: - """Unified memory interface. - - Provides a single API for all memory operations. Defaults to local - SQLite. When rqlite is available (detected via RQLITE_URL env var), - delegates to BrainClient for distributed operation. - """ - - def __init__( - self, - db_path: Path | None = None, - source: str = "default", - use_rqlite: bool | None = None, - ): - self.db_path = db_path or _get_db_path() - self.source = source - self._embedder = None - self._rqlite_client = None - - # Auto-detect: use rqlite if RQLITE_URL is set, otherwise local SQLite - if use_rqlite is None: - from config import settings as _settings - - use_rqlite = bool(_settings.rqlite_url) - self._use_rqlite = use_rqlite - - if not self._use_rqlite: - self._init_local_db() - - # ────────────────────────────────────────────────────────────────────── - # Local SQLite Setup - # ────────────────────────────────────────────────────────────────────── - - def _init_local_db(self) -> None: - """Initialize local SQLite database with schema.""" - self.db_path.parent.mkdir(parents=True, exist_ok=True) - - conn = sqlite3.connect(str(self.db_path)) - try: - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA busy_timeout=5000") - conn.executescript(_LOCAL_SCHEMA) - conn.commit() - logger.info("Brain local DB initialized at %s (WAL mode)", self.db_path) - finally: - conn.close() - - def _get_conn(self) -> sqlite3.Connection: - """Get a SQLite connection with WAL mode and busy timeout.""" - conn = sqlite3.connect(str(self.db_path)) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA busy_timeout=5000") - return conn - - def _get_embedder(self): - """Lazy-load the embedding model.""" - if self._embedder is None: - from config import settings as _settings - - if _settings.timmy_skip_embeddings: - return None - try: - from brain.embeddings import LocalEmbedder - - self._embedder = LocalEmbedder() - except ImportError: - logger.warning("sentence-transformers not available — semantic search disabled") - self._embedder = None - return self._embedder - - # ────────────────────────────────────────────────────────────────────── - # rqlite Delegation - # ────────────────────────────────────────────────────────────────────── - - def _get_rqlite_client(self): - """Lazy-load the rqlite BrainClient.""" - if self._rqlite_client is None: - from brain.client import BrainClient - - self._rqlite_client = BrainClient() - return self._rqlite_client - - # ────────────────────────────────────────────────────────────────────── - # Core Memory Operations - # ────────────────────────────────────────────────────────────────────── - - async def remember( - self, - content: str, - tags: list[str] | None = None, - source: str | None = None, - metadata: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Store a memory. - - Args: - content: Text content to remember. - tags: Optional list of tags for categorization. - source: Source identifier (defaults to self.source). - metadata: Additional JSON-serializable metadata. - - Returns: - Dict with 'id' and 'status'. - """ - if self._use_rqlite: - client = self._get_rqlite_client() - return await client.remember(content, tags, source or self.source, metadata) - - return self.remember_sync(content, tags, source, metadata) - - def remember_sync( - self, - content: str, - tags: list[str] | None = None, - source: str | None = None, - metadata: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Store a memory (synchronous, local SQLite only). - - Args: - content: Text content to remember. - tags: Optional list of tags. - source: Source identifier. - metadata: Additional metadata. - - Returns: - Dict with 'id' and 'status'. - """ - now = datetime.now(UTC).isoformat() - embedding_bytes = None - - embedder = self._get_embedder() - if embedder is not None: - try: - embedding_bytes = embedder.encode_single(content) - except Exception as e: - logger.warning("Embedding failed, storing without vector: %s", e) - - conn = self._get_conn() - try: - cursor = conn.execute( - """INSERT INTO memories (content, embedding, source, tags, metadata, created_at) - VALUES (?, ?, ?, ?, ?, ?)""", - ( - content, - embedding_bytes, - source or self.source, - json.dumps(tags or []), - json.dumps(metadata or {}), - now, - ), - ) - conn.commit() - memory_id = cursor.lastrowid - logger.debug("Stored memory %s: %s", memory_id, content[:50]) - return {"id": memory_id, "status": "stored"} - finally: - conn.close() - - async def recall( - self, - query: str, - limit: int = 5, - sources: list[str] | None = None, - ) -> list[dict[str, Any]]: - """Semantic search for memories. - - If embeddings are available, uses cosine similarity. - Falls back to keyword search if no embedder. - - Args: - query: Search query text. - limit: Max results to return. - sources: Filter by source(s). - - Returns: - List of memory dicts with 'content', 'source', 'score'. - """ - if self._use_rqlite: - client = self._get_rqlite_client() - return await client.recall(query, limit, sources) - - return self.recall_sync(query, limit, sources) - - def recall_sync( - self, - query: str, - limit: int = 5, - sources: list[str] | None = None, - ) -> list[dict[str, Any]]: - """Semantic search (synchronous, local SQLite). - - Uses numpy dot product for cosine similarity when embeddings - are available. Falls back to LIKE-based keyword search. - """ - embedder = self._get_embedder() - - if embedder is not None: - return self._recall_semantic(query, limit, sources, embedder) - return self._recall_keyword(query, limit, sources) - - def _recall_semantic( - self, - query: str, - limit: int, - sources: list[str] | None, - embedder, - ) -> list[dict[str, Any]]: - """Vector similarity search over local SQLite.""" - import numpy as np - - try: - query_vec = embedder.encode(query) - if len(query_vec.shape) > 1: - query_vec = query_vec[0] - except Exception as e: - logger.warning("Query embedding failed, falling back to keyword: %s", e) - return self._recall_keyword(query, limit, sources) - - conn = self._get_conn() - try: - sql = "SELECT id, content, embedding, source, tags, metadata, created_at FROM memories WHERE embedding IS NOT NULL" - params: list = [] - - if sources: - placeholders = ",".join(["?"] * len(sources)) - sql += f" AND source IN ({placeholders})" - params.extend(sources) - - rows = conn.execute(sql, params).fetchall() - - # Compute similarities - scored = [] - for row in rows: - try: - stored_vec = np.frombuffer(row["embedding"], dtype=np.float32) - score = float(np.dot(query_vec, stored_vec)) - scored.append((score, row)) - except Exception: - continue - - # Sort by similarity (highest first) - scored.sort(key=lambda x: x[0], reverse=True) - - results = [] - for score, row in scored[:limit]: - results.append( - { - "id": row["id"], - "content": row["content"], - "source": row["source"], - "tags": json.loads(row["tags"]) if row["tags"] else [], - "metadata": json.loads(row["metadata"]) if row["metadata"] else {}, - "score": score, - "created_at": row["created_at"], - } - ) - - return results - finally: - conn.close() - - def _recall_keyword( - self, - query: str, - limit: int, - sources: list[str] | None, - ) -> list[dict[str, Any]]: - """Keyword-based fallback search.""" - conn = self._get_conn() - try: - sql = "SELECT id, content, source, tags, metadata, created_at FROM memories WHERE content LIKE ?" - params: list = [f"%{query}%"] - - if sources: - placeholders = ",".join(["?"] * len(sources)) - sql += f" AND source IN ({placeholders})" - params.extend(sources) - - sql += " ORDER BY created_at DESC LIMIT ?" - params.append(limit) - - rows = conn.execute(sql, params).fetchall() - - return [ - { - "id": row["id"], - "content": row["content"], - "source": row["source"], - "tags": json.loads(row["tags"]) if row["tags"] else [], - "metadata": json.loads(row["metadata"]) if row["metadata"] else {}, - "score": 0.5, # Keyword match gets a neutral score - "created_at": row["created_at"], - } - for row in rows - ] - finally: - conn.close() - - # ────────────────────────────────────────────────────────────────────── - # Fact Storage (Long-Term Memory) - # ────────────────────────────────────────────────────────────────────── - - async def store_fact( - self, - category: str, - content: str, - confidence: float = 0.8, - source: str = "extracted", - ) -> dict[str, Any]: - """Store a long-term fact. - - Args: - category: Fact category (user_preference, user_fact, learned_pattern). - content: The fact text. - confidence: Confidence score 0.0-1.0. - source: Where this fact came from. - - Returns: - Dict with 'id' and 'status'. - """ - return self.store_fact_sync(category, content, confidence, source) - - def store_fact_sync( - self, - category: str, - content: str, - confidence: float = 0.8, - source: str = "extracted", - ) -> dict[str, Any]: - """Store a long-term fact (synchronous).""" - fact_id = str(uuid.uuid4()) - now = datetime.now(UTC).isoformat() - - conn = self._get_conn() - try: - conn.execute( - """INSERT INTO facts (id, category, content, confidence, source, created_at, last_accessed, access_count) - VALUES (?, ?, ?, ?, ?, ?, ?, 0)""", - (fact_id, category, content, confidence, source, now, now), - ) - conn.commit() - logger.debug("Stored fact [%s]: %s", category, content[:50]) - return {"id": fact_id, "status": "stored"} - finally: - conn.close() - - async def get_facts( - self, - category: str | None = None, - query: str | None = None, - limit: int = 10, - ) -> list[dict[str, Any]]: - """Retrieve facts from long-term memory. - - Args: - category: Filter by category. - query: Keyword search within facts. - limit: Max results. - - Returns: - List of fact dicts. - """ - return self.get_facts_sync(category, query, limit) - - def get_facts_sync( - self, - category: str | None = None, - query: str | None = None, - limit: int = 10, - ) -> list[dict[str, Any]]: - """Retrieve facts (synchronous).""" - conn = self._get_conn() - try: - conditions = [] - params: list = [] - - if category: - conditions.append("category = ?") - params.append(category) - if query: - conditions.append("content LIKE ?") - params.append(f"%{query}%") - - where = " AND ".join(conditions) if conditions else "1=1" - sql = f"""SELECT id, category, content, confidence, source, created_at, last_accessed, access_count - FROM facts WHERE {where} - ORDER BY confidence DESC, last_accessed DESC - LIMIT ?""" - params.append(limit) - - rows = conn.execute(sql, params).fetchall() - - # Update access counts - for row in rows: - conn.execute( - "UPDATE facts SET access_count = access_count + 1, last_accessed = ? WHERE id = ?", - (datetime.now(UTC).isoformat(), row["id"]), - ) - conn.commit() - - return [ - { - "id": row["id"], - "category": row["category"], - "content": row["content"], - "confidence": row["confidence"], - "source": row["source"], - "created_at": row["created_at"], - "access_count": row["access_count"], - } - for row in rows - ] - finally: - conn.close() - - # ────────────────────────────────────────────────────────────────────── - # Recent Memories - # ────────────────────────────────────────────────────────────────────── - - async def get_recent( - self, - hours: int = 24, - limit: int = 20, - sources: list[str] | None = None, - ) -> list[dict[str, Any]]: - """Get recent memories by time.""" - if self._use_rqlite: - client = self._get_rqlite_client() - return await client.get_recent(hours, limit, sources) - - return self.get_recent_sync(hours, limit, sources) - - def get_recent_sync( - self, - hours: int = 24, - limit: int = 20, - sources: list[str] | None = None, - ) -> list[dict[str, Any]]: - """Get recent memories (synchronous).""" - conn = self._get_conn() - try: - sql = """SELECT id, content, source, tags, metadata, created_at - FROM memories - WHERE created_at > datetime('now', ?)""" - params: list = [f"-{hours} hours"] - - if sources: - placeholders = ",".join(["?"] * len(sources)) - sql += f" AND source IN ({placeholders})" - params.extend(sources) - - sql += " ORDER BY created_at DESC LIMIT ?" - params.append(limit) - - rows = conn.execute(sql, params).fetchall() - - return [ - { - "id": row["id"], - "content": row["content"], - "source": row["source"], - "tags": json.loads(row["tags"]) if row["tags"] else [], - "metadata": json.loads(row["metadata"]) if row["metadata"] else {}, - "created_at": row["created_at"], - } - for row in rows - ] - finally: - conn.close() - - # ────────────────────────────────────────────────────────────────────── - # Identity - # ────────────────────────────────────────────────────────────────────── - - def get_identity(self) -> str: - """Return empty string — identity system removed.""" - return "" - - def get_identity_for_prompt(self) -> str: - """Return empty string — identity system removed.""" - return "" - - # ────────────────────────────────────────────────────────────────────── - # Context Building - # ────────────────────────────────────────────────────────────────────── - - async def get_context(self, query: str) -> str: - """Build formatted context for system prompt. - - Combines identity + recent memories + relevant memories. - - Args: - query: Current user query for relevance matching. - - Returns: - Formatted context string for prompt injection. - """ - parts = [] - - # Recent activity - recent = await self.get_recent(hours=24, limit=5) - if recent: - lines = ["## Recent Activity"] - for m in recent: - lines.append(f"- {m['content'][:100]}") - parts.append("\n".join(lines)) - - # Relevant memories - relevant = await self.recall(query, limit=5) - if relevant: - lines = ["## Relevant Memories"] - for r in relevant: - score = r.get("score", 0) - lines.append(f"- [{score:.2f}] {r['content'][:100]}") - parts.append("\n".join(lines)) - - return "\n\n---\n\n".join(parts) - - # ────────────────────────────────────────────────────────────────────── - # Stats - # ────────────────────────────────────────────────────────────────────── - - def get_stats(self) -> dict[str, Any]: - """Get memory statistics. - - Returns: - Dict with memory_count, fact_count, db_size_bytes, etc. - """ - conn = self._get_conn() - try: - memory_count = conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0] - fact_count = conn.execute("SELECT COUNT(*) FROM facts").fetchone()[0] - embedded_count = conn.execute( - "SELECT COUNT(*) FROM memories WHERE embedding IS NOT NULL" - ).fetchone()[0] - - db_size = self.db_path.stat().st_size if self.db_path.exists() else 0 - - return { - "memory_count": memory_count, - "fact_count": fact_count, - "embedded_count": embedded_count, - "db_size_bytes": db_size, - "backend": "rqlite" if self._use_rqlite else "local_sqlite", - "db_path": str(self.db_path), - } - finally: - conn.close() - - -# ────────────────────────────────────────────────────────────────────────── -# Module-level convenience -# ────────────────────────────────────────────────────────────────────────── - -_default_memory: UnifiedMemory | None = None - - -def get_memory(source: str = "agent") -> UnifiedMemory: - """Get the singleton UnifiedMemory instance. - - Args: - source: Source identifier for this caller. - - Returns: - UnifiedMemory instance. - """ - global _default_memory - if _default_memory is None: - _default_memory = UnifiedMemory(source=source) - return _default_memory - - -# ────────────────────────────────────────────────────────────────────────── -# Local SQLite Schema -# ────────────────────────────────────────────────────────────────────────── - -_LOCAL_SCHEMA = """ --- Unified memory table (replaces vector_store, semantic_memory, etc.) -CREATE TABLE IF NOT EXISTS memories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - content TEXT NOT NULL, - embedding BLOB, - source TEXT DEFAULT 'agent', - tags TEXT DEFAULT '[]', - metadata TEXT DEFAULT '{}', - created_at TEXT NOT NULL -); - --- Long-term facts (replaces memory_layers LongTermMemory) -CREATE TABLE IF NOT EXISTS facts ( - id TEXT PRIMARY KEY, - category TEXT NOT NULL, - content TEXT NOT NULL, - confidence REAL NOT NULL DEFAULT 0.5, - source TEXT DEFAULT 'extracted', - created_at TEXT NOT NULL, - last_accessed TEXT NOT NULL, - access_count INTEGER DEFAULT 0 -); - --- Indexes -CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source); -CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at); -CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category); -CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence); - --- Schema version -CREATE TABLE IF NOT EXISTS brain_schema_version ( - version INTEGER PRIMARY KEY, - applied_at TEXT -); - -INSERT OR REPLACE INTO brain_schema_version (version, applied_at) -VALUES (1, datetime('now')); -""" diff --git a/src/brain/schema.py b/src/brain/schema.py deleted file mode 100644 index 94620e1..0000000 --- a/src/brain/schema.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Database schema for distributed brain. - -SQL to initialize rqlite with memories and tasks tables. -""" - -# Schema version for migrations -SCHEMA_VERSION = 1 - -INIT_SQL = """ --- Note: sqlite-vec extensions must be loaded programmatically --- via conn.load_extension("vector0") / conn.load_extension("vec0") --- before executing this schema. Dot-commands are CLI-only. - --- Memories table with vector search -CREATE TABLE IF NOT EXISTS memories ( - id INTEGER PRIMARY KEY, - content TEXT NOT NULL, - embedding BLOB, -- 384-dim float32 array (normalized) - source TEXT, -- 'timmy', 'zeroclaw', 'worker', 'user' - tags TEXT, -- JSON array - metadata TEXT, -- JSON object - created_at TEXT -- ISO8601 -); - --- Tasks table (distributed queue) -CREATE TABLE IF NOT EXISTS tasks ( - id INTEGER PRIMARY KEY, - content TEXT NOT NULL, - task_type TEXT DEFAULT 'general', -- shell, creative, code, research, general - priority INTEGER DEFAULT 0, -- Higher = process first - status TEXT DEFAULT 'pending', -- pending, claimed, done, failed - claimed_by TEXT, -- Node ID - claimed_at TEXT, - result TEXT, - error TEXT, - metadata TEXT, -- JSON - created_at TEXT, - completed_at TEXT -); - --- Node registry (who's online) -CREATE TABLE IF NOT EXISTS nodes ( - node_id TEXT PRIMARY KEY, - capabilities TEXT, -- JSON array - last_seen TEXT, -- ISO8601 - load_average REAL -); - --- Indexes for performance -CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source); -CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at); -CREATE INDEX IF NOT EXISTS idx_tasks_status_priority ON tasks(status, priority DESC); -CREATE INDEX IF NOT EXISTS idx_tasks_claimed ON tasks(claimed_by, status); -CREATE INDEX IF NOT EXISTS idx_tasks_type ON tasks(task_type); - --- Virtual table for vector search (if using sqlite-vec) --- Note: This requires sqlite-vec extension loaded -CREATE VIRTUAL TABLE IF NOT EXISTS vec_memories USING vec0( - embedding float[384] -); - --- Schema version tracking -CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, - applied_at TEXT -); - -INSERT OR REPLACE INTO schema_version (version, applied_at) -VALUES (1, datetime('now')); -""" - -MIGRATIONS = { - # Future migrations go here - # 2: "ALTER TABLE ...", -} - - -def get_init_sql() -> str: - """Get SQL to initialize fresh database.""" - return INIT_SQL - - -def get_migration_sql(from_version: int, to_version: int) -> str: - """Get SQL to migrate between versions.""" - if to_version <= from_version: - return "" - - sql_parts = [] - for v in range(from_version + 1, to_version + 1): - if v in MIGRATIONS: - sql_parts.append(MIGRATIONS[v]) - sql_parts.append( - f"UPDATE schema_version SET version = {v}, applied_at = datetime('now');" - ) - - return "\n".join(sql_parts) diff --git a/src/brain/worker.py b/src/brain/worker.py deleted file mode 100644 index 888e884..0000000 --- a/src/brain/worker.py +++ /dev/null @@ -1,359 +0,0 @@ -"""Distributed Worker — continuously processes tasks from the brain queue. - -Each device runs a worker that claims and executes tasks based on capabilities. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import os -import socket -import subprocess -from collections.abc import Callable -from typing import Any - -from brain.client import BrainClient - -logger = logging.getLogger(__name__) - - -class DistributedWorker: - """Continuous task processor for the distributed brain. - - Runs on every device, claims tasks matching its capabilities, - executes them immediately, stores results. - """ - - def __init__(self, brain_client: BrainClient | None = None): - self.brain = brain_client or BrainClient() - self.node_id = f"{socket.gethostname()}-{os.getpid()}" - self.capabilities = self._detect_capabilities() - self.running = False - self._handlers: dict[str, Callable] = {} - self._register_default_handlers() - - def _detect_capabilities(self) -> list[str]: - """Detect what this node can do.""" - caps = ["general", "shell", "file_ops", "git"] - - # Check for GPU - if self._has_gpu(): - caps.append("gpu") - caps.append("creative") - caps.append("image_gen") - caps.append("video_gen") - - # Check for internet - if self._has_internet(): - caps.append("web") - caps.append("research") - - # Check memory - mem_gb = self._get_memory_gb() - if mem_gb > 16: - caps.append("large_model") - if mem_gb > 32: - caps.append("huge_model") - - # Check for specific tools - if self._has_command("ollama"): - caps.append("ollama") - if self._has_command("docker"): - caps.append("docker") - if self._has_command("cargo"): - caps.append("rust") - - logger.info(f"Worker capabilities: {caps}") - return caps - - def _has_gpu(self) -> bool: - """Check for NVIDIA or AMD GPU.""" - try: - # Check for nvidia-smi - result = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5) - if result.returncode == 0: - return True - except (OSError, subprocess.SubprocessError): - pass - - # Check for ROCm - if os.path.exists("/opt/rocm"): - return True - - # Check for Apple Silicon Metal - if os.uname().sysname == "Darwin": - try: - result = subprocess.run( - ["system_profiler", "SPDisplaysDataType"], - capture_output=True, - text=True, - timeout=5, - ) - if "Metal" in result.stdout: - return True - except (OSError, subprocess.SubprocessError): - pass - - return False - - def _has_internet(self) -> bool: - """Check if we have internet connectivity.""" - try: - result = subprocess.run( - ["curl", "-s", "--max-time", "3", "https://1.1.1.1"], capture_output=True, timeout=5 - ) - return result.returncode == 0 - except (OSError, subprocess.SubprocessError): - return False - - def _get_memory_gb(self) -> float: - """Get total system memory in GB.""" - try: - if os.uname().sysname == "Darwin": - result = subprocess.run( - ["sysctl", "-n", "hw.memsize"], capture_output=True, text=True - ) - bytes_mem = int(result.stdout.strip()) - return bytes_mem / (1024**3) - else: - with open("/proc/meminfo") as f: - for line in f: - if line.startswith("MemTotal:"): - kb = int(line.split()[1]) - return kb / (1024**2) - except (OSError, ValueError): - pass - return 8.0 # Assume 8GB if we can't detect - - def _has_command(self, cmd: str) -> bool: - """Check if command exists.""" - try: - result = subprocess.run(["which", cmd], capture_output=True, timeout=5) - return result.returncode == 0 - except (OSError, subprocess.SubprocessError): - return False - - def _register_default_handlers(self): - """Register built-in task handlers.""" - self._handlers = { - "shell": self._handle_shell, - "creative": self._handle_creative, - "code": self._handle_code, - "research": self._handle_research, - "general": self._handle_general, - } - - def register_handler(self, task_type: str, handler: Callable[[str], Any]): - """Register a custom task handler. - - Args: - task_type: Type of task this handler handles - handler: Async function that takes task content and returns result - """ - self._handlers[task_type] = handler - if task_type not in self.capabilities: - self.capabilities.append(task_type) - - # ────────────────────────────────────────────────────────────────────────── - # Task Handlers - # ────────────────────────────────────────────────────────────────────────── - - async def _handle_shell(self, command: str) -> str: - """Execute shell command via ZeroClaw or direct subprocess.""" - # Try ZeroClaw first if available - if self._has_command("zeroclaw"): - proc = await asyncio.create_subprocess_shell( - f"zeroclaw exec --json '{command}'", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await proc.communicate() - - # Store result in brain - await self.brain.remember( - content=f"Shell: {command}\nOutput: {stdout.decode()}", - tags=["shell", "result"], - source=self.node_id, - metadata={"command": command, "exit_code": proc.returncode}, - ) - - if proc.returncode != 0: - raise Exception(f"Command failed: {stderr.decode()}") - return stdout.decode() - - # Fallback to direct subprocess (less safe) - proc = await asyncio.create_subprocess_shell( - command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - stdout, stderr = await proc.communicate() - - if proc.returncode != 0: - raise Exception(f"Command failed: {stderr.decode()}") - return stdout.decode() - - async def _handle_creative(self, prompt: str) -> str: - """Generate creative media (requires GPU).""" - if "gpu" not in self.capabilities: - raise Exception("GPU not available on this node") - - # This would call creative tools (Stable Diffusion, etc.) - # For now, placeholder - logger.info(f"Creative task: {prompt[:50]}...") - - # Store result - result = f"Creative output for: {prompt}" - await self.brain.remember( - content=result, - tags=["creative", "generated"], - source=self.node_id, - metadata={"prompt": prompt}, - ) - - return result - - async def _handle_code(self, description: str) -> str: - """Code generation and modification.""" - # Would use LLM to generate code - # For now, placeholder - logger.info(f"Code task: {description[:50]}...") - return f"Code generated for: {description}" - - async def _handle_research(self, query: str) -> str: - """Web research.""" - if "web" not in self.capabilities: - raise Exception("Internet not available on this node") - - # Would use browser automation or search - logger.info(f"Research task: {query[:50]}...") - return f"Research results for: {query}" - - async def _handle_general(self, prompt: str) -> str: - """General LLM task via local Ollama.""" - if "ollama" not in self.capabilities: - raise Exception("Ollama not available on this node") - - # Call Ollama - try: - proc = await asyncio.create_subprocess_exec( - "curl", - "-s", - "http://localhost:11434/api/generate", - "-d", - json.dumps({"model": "llama3.1:8b-instruct", "prompt": prompt, "stream": False}), - stdout=asyncio.subprocess.PIPE, - ) - stdout, _ = await proc.communicate() - - response = json.loads(stdout.decode()) - result = response.get("response", "No response") - - # Store in brain - await self.brain.remember( - content=f"Task: {prompt}\nResult: {result}", - tags=["llm", "result"], - source=self.node_id, - metadata={"model": "llama3.1:8b-instruct"}, - ) - - return result - - except Exception as e: - raise Exception(f"LLM failed: {e}") from e - - # ────────────────────────────────────────────────────────────────────────── - # Main Loop - # ────────────────────────────────────────────────────────────────────────── - - async def execute_task(self, task: dict[str, Any]) -> dict[str, Any]: - """Execute a claimed task.""" - task_type = task.get("type", "general") - content = task.get("content", "") - task_id = task.get("id") - - handler = self._handlers.get(task_type, self._handlers["general"]) - - try: - logger.info(f"Executing task {task_id}: {task_type}") - result = await handler(content) - - await self.brain.complete_task(task_id, success=True, result=result) - logger.info(f"Task {task_id} completed") - return {"success": True, "result": result} - - except Exception as e: - error_msg = str(e) - logger.error(f"Task {task_id} failed: {error_msg}") - await self.brain.complete_task(task_id, success=False, error=error_msg) - return {"success": False, "error": error_msg} - - async def run_once(self) -> bool: - """Process one task if available. - - Returns: - True if a task was processed, False if no tasks available - """ - task = await self.brain.claim_task(self.capabilities, self.node_id) - - if task: - await self.execute_task(task) - return True - - return False - - async def run(self): - """Main loop — continuously process tasks.""" - logger.info(f"Worker {self.node_id} started") - logger.info(f"Capabilities: {self.capabilities}") - - self.running = True - consecutive_empty = 0 - - while self.running: - try: - had_work = await self.run_once() - - if had_work: - # Immediately check for more work - consecutive_empty = 0 - await asyncio.sleep(0.1) - else: - # No work available - adaptive sleep - consecutive_empty += 1 - # Sleep 0.5s, but up to 2s if consistently empty - sleep_time = min(0.5 + (consecutive_empty * 0.1), 2.0) - await asyncio.sleep(sleep_time) - - except Exception as e: - logger.error(f"Worker error: {e}") - await asyncio.sleep(1) - - def stop(self): - """Stop the worker loop.""" - self.running = False - logger.info("Worker stopping...") - - -async def main(): - """CLI entry point for worker.""" - import sys - - # Allow capability overrides from CLI - if len(sys.argv) > 1: - caps = sys.argv[1].split(",") - worker = DistributedWorker() - worker.capabilities = caps - logger.info(f"Overriding capabilities: {caps}") - else: - worker = DistributedWorker() - - try: - await worker.run() - except KeyboardInterrupt: - worker.stop() - logger.info("Worker stopped.") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/dashboard/app.py b/src/dashboard/app.py index de557cf..d10489f 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -34,15 +34,11 @@ from dashboard.routes.experiments import router as experiments_router from dashboard.routes.grok import router as grok_router from dashboard.routes.health import router as health_router from dashboard.routes.loop_qa import router as loop_qa_router -from dashboard.routes.marketplace import router as marketplace_router from dashboard.routes.memory import router as memory_router from dashboard.routes.mobile import router as mobile_router from dashboard.routes.models import api_router as models_api_router from dashboard.routes.models import router as models_router -from dashboard.routes.paperclip import router as paperclip_router -from dashboard.routes.router import router as router_status_router from dashboard.routes.spark import router as spark_router -from dashboard.routes.swarm import router as swarm_router from dashboard.routes.system import router as system_router from dashboard.routes.tasks import router as tasks_router from dashboard.routes.telegram import router as telegram_router @@ -50,7 +46,6 @@ from dashboard.routes.thinking import router as thinking_router from dashboard.routes.tools import router as tools_router from dashboard.routes.voice import router as voice_router from dashboard.routes.work_orders import router as work_orders_router -from infrastructure.router.api import router as cascade_router class _ColorFormatter(logging.Formatter): @@ -467,7 +462,6 @@ from dashboard.templating import templates # noqa: E402 # Include routers app.include_router(health_router) app.include_router(agents_router) -app.include_router(marketplace_router) app.include_router(voice_router) app.include_router(mobile_router) app.include_router(briefing_router) @@ -476,22 +470,18 @@ app.include_router(tools_router) app.include_router(spark_router) app.include_router(discord_router) app.include_router(memory_router) -app.include_router(router_status_router) app.include_router(grok_router) app.include_router(models_router) app.include_router(models_api_router) app.include_router(chat_api_router) app.include_router(thinking_router) app.include_router(calm_router) -app.include_router(swarm_router) app.include_router(tasks_router) app.include_router(work_orders_router) app.include_router(loop_qa_router) app.include_router(system_router) -app.include_router(paperclip_router) app.include_router(experiments_router) app.include_router(db_explorer_router) -app.include_router(cascade_router) @app.websocket("/ws") diff --git a/src/dashboard/routes/marketplace.py b/src/dashboard/routes/marketplace.py deleted file mode 100644 index 0b1162c..0000000 --- a/src/dashboard/routes/marketplace.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Agent marketplace route — /marketplace endpoints. - -DEPRECATED: Personas replaced by brain task queue. -This module is kept for UI compatibility. -""" - -from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse - -from brain.client import BrainClient -from dashboard.templating import templates - -router = APIRouter(tags=["marketplace"]) - -# Orchestrator only — personas deprecated -AGENT_CATALOG = [ - { - "id": "orchestrator", - "name": "Orchestrator", - "role": "Local AI", - "description": ( - "Primary AI agent. Coordinates tasks, manages memory. Uses distributed brain." - ), - "capabilities": "chat,reasoning,coordination,memory", - "rate_sats": 0, - "default_status": "active", - } -] - - -@router.get("/api/marketplace/agents") -async def api_list_agents(): - """Return agent catalog with current status (JSON API).""" - try: - brain = BrainClient() - pending_tasks = len(await brain.get_pending_tasks(limit=1000)) - except Exception: - pending_tasks = 0 - - catalog = [dict(AGENT_CATALOG[0])] - catalog[0]["pending_tasks"] = pending_tasks - catalog[0]["status"] = "active" - - # Include 'total' for backward compatibility with tests - return {"agents": catalog, "total": len(catalog)} - - -@router.get("/marketplace") -async def marketplace_json(request: Request): - """Marketplace JSON API (backward compat).""" - return await api_list_agents() - - -@router.get("/marketplace/ui", response_class=HTMLResponse) -async def marketplace_ui(request: Request): - """Marketplace HTML page.""" - try: - brain = BrainClient() - tasks = await brain.get_pending_tasks(limit=20) - except Exception: - tasks = [] - - # Enrich agents with fields the template expects - enriched = [] - for agent in AGENT_CATALOG: - a = dict(agent) - a.setdefault("status", a.get("default_status", "active")) - a.setdefault("tasks_completed", 0) - a.setdefault("total_earned", 0) - enriched.append(a) - - active = sum(1 for a in enriched if a["status"] == "active") - - return templates.TemplateResponse( - request, - "marketplace.html", - { - "agents": enriched, - "pending_tasks": tasks, - "message": "Personas deprecated — use Brain Task Queue", - "page_title": "Agent Marketplace", - "active_count": active, - "planned_count": 0, - }, - ) - - -@router.get("/marketplace/{agent_id}") -async def agent_detail(agent_id: str): - """Get agent details.""" - if agent_id == "orchestrator": - return AGENT_CATALOG[0] - return {"error": "Agent not found — personas deprecated"} diff --git a/src/dashboard/routes/paperclip.py b/src/dashboard/routes/paperclip.py deleted file mode 100644 index 50508d5..0000000 --- a/src/dashboard/routes/paperclip.py +++ /dev/null @@ -1,317 +0,0 @@ -"""Paperclip AI integration routes. - -Timmy-as-CEO: create issues, delegate to agents, review work, manage goals. -All business logic lives in the bridge — these routes stay thin. -""" - -import logging - -from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse - -from config import settings - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/paperclip", tags=["paperclip"]) - - -def _disabled_response() -> JSONResponse: - return JSONResponse({"enabled": False, "detail": "Paperclip integration is disabled"}) - - -# ── Status ─────────────────────────────────────────────────────────────────── - - -@router.get("/status") -async def paperclip_status(): - """Integration health check.""" - if not settings.paperclip_enabled: - return _disabled_response() - - from integrations.paperclip.bridge import bridge - - status = await bridge.get_status() - return status.model_dump() - - -# ── Issues (CEO creates & manages tickets) ─────────────────────────────────── - - -@router.get("/issues") -async def list_issues(status: str | None = None): - """List all issues in the company.""" - if not settings.paperclip_enabled: - return _disabled_response() - - from integrations.paperclip.bridge import bridge - - issues = await bridge.client.list_issues(status=status) - return [i.model_dump() for i in issues] - - -@router.get("/issues/{issue_id}") -async def get_issue(issue_id: str): - """Get issue details with comments (CEO review).""" - if not settings.paperclip_enabled: - return _disabled_response() - - from integrations.paperclip.bridge import bridge - - return await bridge.review_issue(issue_id) - - -@router.post("/issues") -async def create_issue(request: Request): - """Create a new issue and optionally assign to an agent.""" - if not settings.paperclip_enabled: - return _disabled_response() - - body = await request.json() - title = body.get("title") - if not title: - return JSONResponse({"error": "title is required"}, status_code=400) - - from integrations.paperclip.bridge import bridge - - issue = await bridge.create_and_assign( - title=title, - description=body.get("description", ""), - assignee_id=body.get("assignee_id"), - priority=body.get("priority"), - wake=body.get("wake", True), - ) - - if not issue: - return JSONResponse({"error": "Failed to create issue"}, status_code=502) - - return issue.model_dump() - - -@router.post("/issues/{issue_id}/delegate") -async def delegate_issue(issue_id: str, request: Request): - """Delegate an issue to an agent (CEO assignment).""" - if not settings.paperclip_enabled: - return _disabled_response() - - body = await request.json() - agent_id = body.get("agent_id") - if not agent_id: - return JSONResponse({"error": "agent_id is required"}, status_code=400) - - from integrations.paperclip.bridge import bridge - - ok = await bridge.delegate_issue( - issue_id=issue_id, - agent_id=agent_id, - message=body.get("message"), - ) - - if not ok: - return JSONResponse({"error": "Failed to delegate issue"}, status_code=502) - - return {"ok": True, "issue_id": issue_id, "agent_id": agent_id} - - -@router.post("/issues/{issue_id}/close") -async def close_issue(issue_id: str, request: Request): - """Close an issue (CEO sign-off).""" - if not settings.paperclip_enabled: - return _disabled_response() - - body = await request.json() - - from integrations.paperclip.bridge import bridge - - ok = await bridge.close_issue(issue_id, comment=body.get("comment")) - - if not ok: - return JSONResponse({"error": "Failed to close issue"}, status_code=502) - - return {"ok": True, "issue_id": issue_id} - - -@router.post("/issues/{issue_id}/comment") -async def add_comment(issue_id: str, request: Request): - """Add a CEO comment to an issue.""" - if not settings.paperclip_enabled: - return _disabled_response() - - body = await request.json() - content = body.get("content") - if not content: - return JSONResponse({"error": "content is required"}, status_code=400) - - from integrations.paperclip.bridge import bridge - - comment = await bridge.client.add_comment(issue_id, f"[CEO] {content}") - - if not comment: - return JSONResponse({"error": "Failed to add comment"}, status_code=502) - - return comment.model_dump() - - -# ── Agents (team management) ───────────────────────────────────────────────── - - -@router.get("/agents") -async def list_agents(): - """List all agents in the org.""" - if not settings.paperclip_enabled: - return _disabled_response() - - from integrations.paperclip.bridge import bridge - - agents = await bridge.get_team() - return [a.model_dump() for a in agents] - - -@router.get("/org") -async def org_chart(): - """Get the organizational chart.""" - if not settings.paperclip_enabled: - return _disabled_response() - - from integrations.paperclip.bridge import bridge - - org = await bridge.get_org_chart() - return org or {"error": "Could not retrieve org chart"} - - -@router.post("/agents/{agent_id}/wake") -async def wake_agent(agent_id: str, request: Request): - """Wake an agent to start working.""" - if not settings.paperclip_enabled: - return _disabled_response() - - body = await request.json() - - from integrations.paperclip.bridge import bridge - - result = await bridge.client.wake_agent( - agent_id, - issue_id=body.get("issue_id"), - message=body.get("message"), - ) - - if not result: - return JSONResponse({"error": "Failed to wake agent"}, status_code=502) - - return result - - -# ── Goals ──────────────────────────────────────────────────────────────────── - - -@router.get("/goals") -async def list_goals(): - """List company goals.""" - if not settings.paperclip_enabled: - return _disabled_response() - - from integrations.paperclip.bridge import bridge - - goals = await bridge.list_goals() - return [g.model_dump() for g in goals] - - -@router.post("/goals") -async def create_goal(request: Request): - """Set a new company goal (CEO directive).""" - if not settings.paperclip_enabled: - return _disabled_response() - - body = await request.json() - title = body.get("title") - if not title: - return JSONResponse({"error": "title is required"}, status_code=400) - - from integrations.paperclip.bridge import bridge - - goal = await bridge.set_goal(title, body.get("description", "")) - - if not goal: - return JSONResponse({"error": "Failed to create goal"}, status_code=502) - - return goal.model_dump() - - -# ── Approvals ──────────────────────────────────────────────────────────────── - - -@router.get("/approvals") -async def list_approvals(): - """List pending approvals for CEO review.""" - if not settings.paperclip_enabled: - return _disabled_response() - - from integrations.paperclip.bridge import bridge - - return await bridge.pending_approvals() - - -@router.post("/approvals/{approval_id}/approve") -async def approve(approval_id: str, request: Request): - """Approve an agent's action.""" - if not settings.paperclip_enabled: - return _disabled_response() - - body = await request.json() - - from integrations.paperclip.bridge import bridge - - ok = await bridge.approve(approval_id, body.get("comment", "")) - - if not ok: - return JSONResponse({"error": "Failed to approve"}, status_code=502) - - return {"ok": True, "approval_id": approval_id} - - -@router.post("/approvals/{approval_id}/reject") -async def reject(approval_id: str, request: Request): - """Reject an agent's action.""" - if not settings.paperclip_enabled: - return _disabled_response() - - body = await request.json() - - from integrations.paperclip.bridge import bridge - - ok = await bridge.reject(approval_id, body.get("comment", "")) - - if not ok: - return JSONResponse({"error": "Failed to reject"}, status_code=502) - - return {"ok": True, "approval_id": approval_id} - - -# ── Runs (monitoring) ──────────────────────────────────────────────────────── - - -@router.get("/runs") -async def list_runs(): - """List active heartbeat runs.""" - if not settings.paperclip_enabled: - return _disabled_response() - - from integrations.paperclip.bridge import bridge - - return await bridge.active_runs() - - -@router.post("/runs/{run_id}/cancel") -async def cancel_run(run_id: str): - """Cancel a running heartbeat execution.""" - if not settings.paperclip_enabled: - return _disabled_response() - - from integrations.paperclip.bridge import bridge - - ok = await bridge.cancel_run(run_id) - - if not ok: - return JSONResponse({"error": "Failed to cancel run"}, status_code=502) - - return {"ok": True, "run_id": run_id} diff --git a/src/dashboard/routes/router.py b/src/dashboard/routes/router.py deleted file mode 100644 index 5be982e..0000000 --- a/src/dashboard/routes/router.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Cascade Router status routes.""" - -from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse - -from dashboard.templating import templates -from timmy.cascade_adapter import get_cascade_adapter - -router = APIRouter(prefix="/router", tags=["router"]) - - -@router.get("/status", response_class=HTMLResponse) -async def router_status_page(request: Request): - """Cascade Router status dashboard.""" - adapter = get_cascade_adapter() - - providers = adapter.get_provider_status() - preferred = adapter.get_preferred_provider() - - # Calculate overall stats - total_requests = sum(p["metrics"]["total"] for p in providers) - total_success = sum(p["metrics"]["success"] for p in providers) - total_failed = sum(p["metrics"]["failed"] for p in providers) - - avg_latency = 0.0 - if providers: - avg_latency = sum(p["metrics"]["avg_latency_ms"] for p in providers) / len(providers) - - return templates.TemplateResponse( - request, - "router_status.html", - { - "page_title": "Router Status", - "providers": providers, - "preferred_provider": preferred, - "total_requests": total_requests, - "total_success": total_success, - "total_failed": total_failed, - "avg_latency_ms": round(avg_latency, 1), - }, - ) - - -@router.get("/api/providers") -async def get_providers(): - """API endpoint for provider status (JSON).""" - adapter = get_cascade_adapter() - return { - "providers": adapter.get_provider_status(), - "preferred": adapter.get_preferred_provider(), - } diff --git a/src/dashboard/routes/swarm.py b/src/dashboard/routes/swarm.py deleted file mode 100644 index 741bbaf..0000000 --- a/src/dashboard/routes/swarm.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Swarm-related dashboard routes (events, live feed).""" - -import logging - -from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect -from fastapi.responses import HTMLResponse - -from dashboard.templating import templates -from infrastructure.ws_manager.handler import ws_manager -from spark.engine import spark_engine - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/swarm", tags=["swarm"]) - - -@router.get("/events", response_class=HTMLResponse) -async def swarm_events( - request: Request, - task_id: str | None = None, - agent_id: str | None = None, - event_type: str | None = None, -): - """Event log page.""" - events = spark_engine.get_timeline(limit=100) - - # Filter if requested - if task_id: - events = [e for e in events if e.task_id == task_id] - if agent_id: - events = [e for e in events if e.agent_id == agent_id] - if event_type: - events = [e for e in events if e.event_type == event_type] - - # Prepare summary and event types for template - summary = {} - event_types = set() - for e in events: - etype = e.event_type - event_types.add(etype) - summary[etype] = summary.get(etype, 0) + 1 - - return templates.TemplateResponse( - request, - "events.html", - { - "events": events, - "summary": summary, - "event_types": sorted(list(event_types)), - "filter_task": task_id, - "filter_agent": agent_id, - "filter_type": event_type, - }, - ) - - -@router.get("/live", response_class=HTMLResponse) -async def swarm_live(request: Request): - """Live swarm activity page.""" - status = spark_engine.status() - events = spark_engine.get_timeline(limit=20) - - return templates.TemplateResponse( - request, - "swarm_live.html", - { - "status": status, - "events": events, - }, - ) - - -@router.websocket("/live") -async def swarm_ws(websocket: WebSocket): - """WebSocket endpoint for live swarm updates.""" - await websocket.accept() - # Send initial state before joining broadcast pool to avoid race conditions - await websocket.send_json( - { - "type": "initial_state", - "data": { - "agents": {"total": 0, "active": 0, "list": []}, - "tasks": {"active": 0}, - "auctions": {"list": []}, - }, - } - ) - await ws_manager.connect(websocket, accept=False) - try: - while True: - await websocket.receive_text() - except WebSocketDisconnect: - ws_manager.disconnect(websocket) - - -@router.get("/agents/sidebar", response_class=HTMLResponse) -async def agents_sidebar(request: Request): - """Sidebar partial showing agent status for the home page.""" - from config import settings - - agents = [ - { - "id": "default", - "name": settings.agent_name, - "status": "idle", - "type": "local", - "capabilities": "chat,reasoning,research,planning", - "last_seen": None, - } - ] - return templates.TemplateResponse( - request, "partials/swarm_agents_sidebar.html", {"agents": agents} - ) diff --git a/src/infrastructure/openfang/__init__.py b/src/infrastructure/openfang/__init__.py deleted file mode 100644 index be75e06..0000000 --- a/src/infrastructure/openfang/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -"""OpenFang — vendored binary sidecar for agent tool execution. - -OpenFang is a Rust-compiled Agent OS that provides real tool execution -(browser automation, OSINT, forecasting, social management) in a -WASM-sandboxed runtime. Timmy's coordinator dispatches to it as a -tool vendor rather than a co-orchestrator. - -Usage: - from infrastructure.openfang import openfang_client - - # Check if OpenFang is available - if openfang_client.healthy: - result = await openfang_client.execute_hand("browser", params) -""" - -from infrastructure.openfang.client import OpenFangClient, openfang_client - -__all__ = ["OpenFangClient", "openfang_client"] diff --git a/src/infrastructure/openfang/client.py b/src/infrastructure/openfang/client.py deleted file mode 100644 index c452120..0000000 --- a/src/infrastructure/openfang/client.py +++ /dev/null @@ -1,206 +0,0 @@ -"""OpenFang HTTP client — bridge between Timmy coordinator and OpenFang runtime. - -Follows project conventions: -- Graceful degradation (log error, return fallback, never crash) -- Config via ``from config import settings`` -- Singleton pattern for module-level import - -The client wraps OpenFang's REST API and exposes its Hands -(Browser, Collector, Predictor, Lead, Twitter, Researcher, Clip) -as callable tool endpoints. -""" - -import logging -import time -from dataclasses import dataclass, field -from typing import Any - -from config import settings - -logger = logging.getLogger(__name__) - -# Hand names that OpenFang ships out of the box -OPENFANG_HANDS = ( - "browser", - "collector", - "predictor", - "lead", - "twitter", - "researcher", - "clip", -) - - -@dataclass -class HandResult: - """Result from an OpenFang Hand execution.""" - - hand: str - success: bool - output: str = "" - error: str = "" - latency_ms: float = 0.0 - metadata: dict = field(default_factory=dict) - - -class OpenFangClient: - """HTTP client for the OpenFang sidecar. - - All methods degrade gracefully — if OpenFang is down the client - returns a ``HandResult(success=False)`` rather than raising. - """ - - def __init__(self, base_url: str | None = None, timeout: int = 60) -> None: - self._base_url = (base_url or settings.openfang_url).rstrip("/") - self._timeout = timeout - self._healthy = False - self._last_health_check: float = 0.0 - self._health_cache_ttl = 30.0 # seconds - logger.info("OpenFangClient initialised → %s", self._base_url) - - # ── Health ─────────────────────────────────────────────────────────────── - - @property - def healthy(self) -> bool: - """Cached health check — hits /health at most once per TTL.""" - now = time.time() - if now - self._last_health_check > self._health_cache_ttl: - self._healthy = self._check_health() - self._last_health_check = now - return self._healthy - - def _check_health(self) -> bool: - try: - import urllib.request - - req = urllib.request.Request( - f"{self._base_url}/health", - method="GET", - headers={"Accept": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=5) as resp: - return resp.status == 200 - except Exception as exc: - logger.debug("OpenFang health check failed: %s", exc) - return False - - # ── Hand execution ─────────────────────────────────────────────────────── - - async def execute_hand( - self, - hand: str, - params: dict[str, Any], - timeout: int | None = None, - ) -> HandResult: - """Execute an OpenFang Hand and return the result. - - Args: - hand: Hand name (browser, collector, predictor, etc.) - params: Parameters for the hand (task-specific) - timeout: Override default timeout for long-running hands - - Returns: - HandResult with output or error details. - """ - if hand not in OPENFANG_HANDS: - return HandResult( - hand=hand, - success=False, - error=f"Unknown hand: {hand}. Available: {', '.join(OPENFANG_HANDS)}", - ) - - start = time.time() - try: - import json - import urllib.request - - payload = json.dumps({"hand": hand, "params": params}).encode() - req = urllib.request.Request( - f"{self._base_url}/api/v1/hands/{hand}/execute", - data=payload, - method="POST", - headers={ - "Content-Type": "application/json", - "Accept": "application/json", - }, - ) - effective_timeout = timeout or self._timeout - with urllib.request.urlopen(req, timeout=effective_timeout) as resp: - body = json.loads(resp.read().decode()) - latency = (time.time() - start) * 1000 - - return HandResult( - hand=hand, - success=body.get("success", True), - output=body.get("output", body.get("result", "")), - latency_ms=latency, - metadata=body.get("metadata", {}), - ) - - except Exception as exc: - latency = (time.time() - start) * 1000 - logger.warning( - "OpenFang hand '%s' failed (%.0fms): %s", - hand, - latency, - exc, - ) - return HandResult( - hand=hand, - success=False, - error=str(exc), - latency_ms=latency, - ) - - # ── Convenience wrappers for common hands ──────────────────────────────── - - async def browse(self, url: str, instruction: str = "") -> HandResult: - """Web automation via OpenFang's Browser hand.""" - return await self.execute_hand("browser", {"url": url, "instruction": instruction}) - - async def collect(self, target: str, depth: str = "shallow") -> HandResult: - """OSINT collection via OpenFang's Collector hand.""" - return await self.execute_hand("collector", {"target": target, "depth": depth}) - - async def predict(self, question: str, horizon: str = "1w") -> HandResult: - """Superforecasting via OpenFang's Predictor hand.""" - return await self.execute_hand("predictor", {"question": question, "horizon": horizon}) - - async def find_leads(self, icp: str, max_results: int = 10) -> HandResult: - """Prospect discovery via OpenFang's Lead hand.""" - return await self.execute_hand("lead", {"icp": icp, "max_results": max_results}) - - async def research(self, topic: str, depth: str = "standard") -> HandResult: - """Deep research via OpenFang's Researcher hand.""" - return await self.execute_hand("researcher", {"topic": topic, "depth": depth}) - - # ── Inventory ──────────────────────────────────────────────────────────── - - async def list_hands(self) -> list[dict]: - """Query OpenFang for its available hands and their status.""" - try: - import json - import urllib.request - - req = urllib.request.Request( - f"{self._base_url}/api/v1/hands", - method="GET", - headers={"Accept": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=10) as resp: - return json.loads(resp.read().decode()) - except Exception as exc: - logger.debug("Failed to list OpenFang hands: %s", exc) - return [] - - def status(self) -> dict: - """Return a status summary for the dashboard.""" - return { - "url": self._base_url, - "healthy": self.healthy, - "available_hands": list(OPENFANG_HANDS), - } - - -# ── Module-level singleton ────────────────────────────────────────────────── -openfang_client = OpenFangClient() diff --git a/src/infrastructure/openfang/tools.py b/src/infrastructure/openfang/tools.py deleted file mode 100644 index db21c74..0000000 --- a/src/infrastructure/openfang/tools.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Register OpenFang Hands as MCP tools in Timmy's tool registry. - -Each OpenFang Hand becomes a callable MCP tool that personas can use -during task execution. The mapping ensures the right personas get -access to the right hands: - - Mace (Security) → collector (OSINT), browser - Seer (Analytics) → predictor, researcher - Echo (Research) → researcher, browser, collector - Helm (DevOps) → browser - Lead hand → available to all personas via direct request - -Call ``register_openfang_tools()`` during app startup (after config -is loaded) to populate the tool registry. -""" - -import logging -from typing import Any - -from infrastructure.openfang.client import OPENFANG_HANDS, openfang_client - -try: - from mcp.schemas.base import create_tool_schema -except ImportError: - - def create_tool_schema(**kwargs): - return kwargs - - -logger = logging.getLogger(__name__) - -# ── Tool schemas ───────────────────────────────────────────────────────────── - -_HAND_SCHEMAS: dict[str, dict] = { - "browser": create_tool_schema( - name="openfang_browser", - description=( - "Web automation via OpenFang's Browser hand. " - "Navigates URLs, extracts content, fills forms. " - "Includes mandatory purchase confirmation gates." - ), - parameters={ - "url": {"type": "string", "description": "URL to navigate to"}, - "instruction": { - "type": "string", - "description": "What to do on the page", - }, - }, - required=["url"], - ), - "collector": create_tool_schema( - name="openfang_collector", - description=( - "OSINT intelligence and continuous monitoring via OpenFang's " - "Collector hand. Gathers public information on targets." - ), - parameters={ - "target": { - "type": "string", - "description": "Target to investigate (domain, org, person)", - }, - "depth": { - "type": "string", - "description": "Collection depth: shallow | standard | deep", - "default": "shallow", - }, - }, - required=["target"], - ), - "predictor": create_tool_schema( - name="openfang_predictor", - description=( - "Superforecasting with calibrated reasoning via OpenFang's " - "Predictor hand. Produces probability estimates with reasoning." - ), - parameters={ - "question": { - "type": "string", - "description": "Forecasting question to evaluate", - }, - "horizon": { - "type": "string", - "description": "Time horizon: 1d | 1w | 1m | 3m | 1y", - "default": "1w", - }, - }, - required=["question"], - ), - "lead": create_tool_schema( - name="openfang_lead", - description=( - "Prospect discovery and ICP-based qualification via OpenFang's " - "Lead hand. Finds and scores potential leads." - ), - parameters={ - "icp": { - "type": "string", - "description": "Ideal Customer Profile description", - }, - "max_results": { - "type": "integer", - "description": "Maximum leads to return", - "default": 10, - }, - }, - required=["icp"], - ), - "twitter": create_tool_schema( - name="openfang_twitter", - description=( - "Social account management via OpenFang's Twitter hand. " - "Includes approval gates for sensitive actions." - ), - parameters={ - "action": { - "type": "string", - "description": "Action: post | reply | search | analyze", - }, - "content": { - "type": "string", - "description": "Content for the action", - }, - }, - required=["action", "content"], - ), - "researcher": create_tool_schema( - name="openfang_researcher", - description=( - "Deep autonomous research with source verification via " - "OpenFang's Researcher hand. Produces cited reports." - ), - parameters={ - "topic": { - "type": "string", - "description": "Research topic or question", - }, - "depth": { - "type": "string", - "description": "Research depth: quick | standard | deep", - "default": "standard", - }, - }, - required=["topic"], - ), - "clip": create_tool_schema( - name="openfang_clip", - description=( - "Video processing and social media publishing via OpenFang's " - "Clip hand. Edits, captions, and publishes video content." - ), - parameters={ - "source": { - "type": "string", - "description": "Source video path or URL", - }, - "instruction": { - "type": "string", - "description": "What to do with the video", - }, - }, - required=["source"], - ), -} - -# Map personas to the OpenFang hands they should have access to -PERSONA_HAND_MAP: dict[str, list[str]] = { - "echo": ["researcher", "browser", "collector"], - "seer": ["predictor", "researcher"], - "mace": ["collector", "browser"], - "helm": ["browser"], - "forge": ["browser", "researcher"], - "quill": ["researcher"], - "pixel": ["clip", "browser"], - "lyra": [], - "reel": ["clip"], -} - - -def _make_hand_handler(hand_name: str): - """Create an async handler that delegates to the OpenFang client.""" - - async def handler(**kwargs: Any) -> str: - result = await openfang_client.execute_hand(hand_name, kwargs) - if result.success: - return result.output - return f"[OpenFang {hand_name} error] {result.error}" - - handler.__name__ = f"openfang_{hand_name}" - handler.__doc__ = _HAND_SCHEMAS.get(hand_name, {}).get( - "description", f"OpenFang {hand_name} hand" - ) - return handler - - -def register_openfang_tools() -> int: - """Register all OpenFang Hands as MCP tools. - - Returns the number of tools registered. - """ - try: - from mcp.registry import tool_registry - except ImportError: - logger.warning("MCP registry not available — skipping OpenFang tool registration") - return 0 - - count = 0 - for hand_name in OPENFANG_HANDS: - schema = _HAND_SCHEMAS.get(hand_name) - if not schema: - logger.warning("No schema for OpenFang hand: %s", hand_name) - continue - - tool_name = f"openfang_{hand_name}" - handler = _make_hand_handler(hand_name) - - tool_registry.register( - name=tool_name, - schema=schema, - handler=handler, - category="openfang", - tags=["openfang", hand_name, "vendor"], - source_module="infrastructure.openfang.tools", - requires_confirmation=(hand_name in ("twitter",)), - ) - count += 1 - - logger.info("Registered %d OpenFang tools in MCP registry", count) - return count - - -def get_hands_for_persona(persona_id: str) -> list[str]: - """Return the OpenFang tool names available to a persona.""" - hand_names = PERSONA_HAND_MAP.get(persona_id, []) - return [f"openfang_{h}" for h in hand_names] diff --git a/src/integrations/paperclip/__init__.py b/src/integrations/paperclip/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/integrations/paperclip/bridge.py b/src/integrations/paperclip/bridge.py deleted file mode 100644 index 2f01bfe..0000000 --- a/src/integrations/paperclip/bridge.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Paperclip bridge — CEO-level orchestration logic. - -Timmy acts as the CEO: reviews issues, delegates to agents, tracks goals, -and approves/rejects work. All business logic lives here; routes stay thin. -""" - -from __future__ import annotations - -import logging -from typing import Any - -from config import settings -from integrations.paperclip.client import PaperclipClient, paperclip -from integrations.paperclip.models import ( - CreateIssueRequest, - PaperclipAgent, - PaperclipGoal, - PaperclipIssue, - PaperclipStatusResponse, - UpdateIssueRequest, -) - -logger = logging.getLogger(__name__) - - -class PaperclipBridge: - """Bidirectional bridge between Timmy and Paperclip. - - Timmy is the CEO — he creates issues, delegates to agents via wakeup, - reviews results, and manages the company's goals. - """ - - def __init__(self, client: PaperclipClient | None = None): - self.client = client or paperclip - - # ── status / health ────────────────────────────────────────────────── - - async def get_status(self) -> PaperclipStatusResponse: - """Return integration status for the dashboard.""" - if not settings.paperclip_enabled: - return PaperclipStatusResponse( - enabled=False, - paperclip_url=settings.paperclip_url, - ) - - connected = await self.client.healthy() - agent_count = 0 - issue_count = 0 - error = None - - if connected: - try: - agents = await self.client.list_agents() - agent_count = len(agents) - issues = await self.client.list_issues() - issue_count = len(issues) - except Exception as exc: - error = str(exc) - else: - error = "Cannot reach Paperclip server" - - return PaperclipStatusResponse( - enabled=True, - connected=connected, - paperclip_url=settings.paperclip_url, - company_id=settings.paperclip_company_id, - agent_count=agent_count, - issue_count=issue_count, - error=error, - ) - - # ── CEO actions: issue management ──────────────────────────────────── - - async def create_and_assign( - self, - title: str, - description: str = "", - assignee_id: str | None = None, - priority: str | None = None, - wake: bool = True, - ) -> PaperclipIssue | None: - """Create an issue and optionally assign + wake an agent. - - This is the primary CEO action: decide what needs doing, create - the ticket, assign it to the right agent, and kick off execution. - """ - req = CreateIssueRequest( - title=title, - description=description, - priority=priority, - assignee_id=assignee_id, - ) - issue = await self.client.create_issue(req) - if not issue: - logger.error("Failed to create issue: %s", title) - return None - - logger.info("Created issue %s: %s", issue.id, title) - - if assignee_id and wake: - result = await self.client.wake_agent(assignee_id, issue_id=issue.id) - if result: - logger.info("Woke agent %s for issue %s", assignee_id, issue.id) - else: - logger.warning("Failed to wake agent %s", assignee_id) - - return issue - - async def delegate_issue( - self, - issue_id: str, - agent_id: str, - message: str | None = None, - ) -> bool: - """Assign an existing issue to an agent and wake them.""" - updated = await self.client.update_issue( - issue_id, - UpdateIssueRequest(assignee_id=agent_id), - ) - if not updated: - return False - - if message: - await self.client.add_comment(issue_id, f"[CEO] {message}") - - await self.client.wake_agent(agent_id, issue_id=issue_id) - return True - - async def review_issue( - self, - issue_id: str, - ) -> dict[str, Any]: - """Gather all context for CEO review of an issue.""" - issue = await self.client.get_issue(issue_id) - comments = await self.client.list_comments(issue_id) - - return { - "issue": issue.model_dump() if issue else None, - "comments": [c.model_dump() for c in comments], - } - - async def close_issue(self, issue_id: str, comment: str | None = None) -> bool: - """Close an issue as the CEO.""" - if comment: - await self.client.add_comment(issue_id, f"[CEO] {comment}") - result = await self.client.update_issue( - issue_id, - UpdateIssueRequest(status="done"), - ) - return result is not None - - # ── CEO actions: team management ───────────────────────────────────── - - async def get_team(self) -> list[PaperclipAgent]: - """Get the full agent roster.""" - return await self.client.list_agents() - - async def get_org_chart(self) -> dict[str, Any] | None: - """Get the organizational hierarchy.""" - return await self.client.get_org() - - # ── CEO actions: goal management ───────────────────────────────────── - - async def list_goals(self) -> list[PaperclipGoal]: - return await self.client.list_goals() - - async def set_goal(self, title: str, description: str = "") -> PaperclipGoal | None: - return await self.client.create_goal(title, description) - - # ── CEO actions: approvals ─────────────────────────────────────────── - - async def pending_approvals(self) -> list[dict[str, Any]]: - return await self.client.list_approvals() - - async def approve(self, approval_id: str, comment: str = "") -> bool: - result = await self.client.approve(approval_id, comment) - return result is not None - - async def reject(self, approval_id: str, comment: str = "") -> bool: - result = await self.client.reject(approval_id, comment) - return result is not None - - # ── CEO actions: monitoring ────────────────────────────────────────── - - async def active_runs(self) -> list[dict[str, Any]]: - """Get currently running heartbeat executions.""" - return await self.client.list_heartbeat_runs() - - async def cancel_run(self, run_id: str) -> bool: - result = await self.client.cancel_run(run_id) - return result is not None - - -# Module-level singleton -bridge = PaperclipBridge() diff --git a/src/integrations/paperclip/client.py b/src/integrations/paperclip/client.py deleted file mode 100644 index e8dc18f..0000000 --- a/src/integrations/paperclip/client.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Paperclip AI API client. - -Async HTTP client for communicating with a remote Paperclip server. -All methods degrade gracefully — log the error, return a fallback, never crash. - -Paperclip API is mounted at ``/api`` and uses ``local_trusted`` mode on the -VPS, so the board actor is implicit. When the server sits behind an nginx -auth-gate the client authenticates with Basic-auth on the first request and -re-uses the session cookie thereafter. -""" - -from __future__ import annotations - -import logging -from typing import Any - -import httpx - -from config import settings -from integrations.paperclip.models import ( - CreateIssueRequest, - PaperclipAgent, - PaperclipComment, - PaperclipGoal, - PaperclipIssue, - UpdateIssueRequest, -) - -logger = logging.getLogger(__name__) - - -class PaperclipClient: - """Thin async wrapper around the Paperclip REST API. - - All public methods return typed results on success or ``None`` / ``[]`` - on failure so callers never need to handle exceptions. - """ - - def __init__( - self, - base_url: str | None = None, - api_key: str | None = None, - timeout: int = 30, - ): - self._base_url = (base_url or settings.paperclip_url).rstrip("/") - self._api_key = api_key or settings.paperclip_api_key - self._timeout = timeout or settings.paperclip_timeout - self._client: httpx.AsyncClient | None = None - - # ── lifecycle ──────────────────────────────────────────────────────── - - def _get_client(self) -> httpx.AsyncClient: - if self._client is None or self._client.is_closed: - headers: dict[str, str] = {"Accept": "application/json"} - if self._api_key: - headers["Authorization"] = f"Bearer {self._api_key}" - self._client = httpx.AsyncClient( - base_url=self._base_url, - headers=headers, - timeout=self._timeout, - ) - return self._client - - async def close(self) -> None: - if self._client and not self._client.is_closed: - await self._client.aclose() - - # ── helpers ────────────────────────────────────────────────────────── - - async def _get(self, path: str, params: dict | None = None) -> Any | None: - try: - resp = await self._get_client().get(path, params=params) - resp.raise_for_status() - return resp.json() - except Exception as exc: - logger.warning("Paperclip GET %s failed: %s", path, exc) - return None - - async def _post(self, path: str, json: dict | None = None) -> Any | None: - try: - resp = await self._get_client().post(path, json=json) - resp.raise_for_status() - return resp.json() - except Exception as exc: - logger.warning("Paperclip POST %s failed: %s", path, exc) - return None - - async def _patch(self, path: str, json: dict | None = None) -> Any | None: - try: - resp = await self._get_client().patch(path, json=json) - resp.raise_for_status() - return resp.json() - except Exception as exc: - logger.warning("Paperclip PATCH %s failed: %s", path, exc) - return None - - async def _delete(self, path: str) -> bool: - try: - resp = await self._get_client().delete(path) - resp.raise_for_status() - return True - except Exception as exc: - logger.warning("Paperclip DELETE %s failed: %s", path, exc) - return False - - # ── health ─────────────────────────────────────────────────────────── - - async def healthy(self) -> bool: - """Quick connectivity check.""" - data = await self._get("/api/health") - return data is not None - - # ── companies ──────────────────────────────────────────────────────── - - async def list_companies(self) -> list[dict[str, Any]]: - data = await self._get("/api/companies") - return data if isinstance(data, list) else [] - - # ── agents ─────────────────────────────────────────────────────────── - - async def list_agents(self, company_id: str | None = None) -> list[PaperclipAgent]: - cid = company_id or settings.paperclip_company_id - if not cid: - logger.warning("paperclip_company_id not set — cannot list agents") - return [] - data = await self._get(f"/api/companies/{cid}/agents") - if not isinstance(data, list): - return [] - return [PaperclipAgent(**a) for a in data] - - async def get_agent(self, agent_id: str) -> PaperclipAgent | None: - data = await self._get(f"/api/agents/{agent_id}") - return PaperclipAgent(**data) if data else None - - async def wake_agent( - self, - agent_id: str, - issue_id: str | None = None, - message: str | None = None, - ) -> dict[str, Any] | None: - """Trigger a heartbeat wake for an agent.""" - body: dict[str, Any] = {} - if issue_id: - body["issueId"] = issue_id - if message: - body["message"] = message - return await self._post(f"/api/agents/{agent_id}/wakeup", json=body) - - async def get_org(self, company_id: str | None = None) -> dict[str, Any] | None: - cid = company_id or settings.paperclip_company_id - if not cid: - return None - return await self._get(f"/api/companies/{cid}/org") - - # ── issues (tickets) ───────────────────────────────────────────────── - - async def list_issues( - self, - company_id: str | None = None, - status: str | None = None, - ) -> list[PaperclipIssue]: - cid = company_id or settings.paperclip_company_id - if not cid: - return [] - params: dict[str, str] = {} - if status: - params["status"] = status - data = await self._get(f"/api/companies/{cid}/issues", params=params) - if not isinstance(data, list): - return [] - return [PaperclipIssue(**i) for i in data] - - async def get_issue(self, issue_id: str) -> PaperclipIssue | None: - data = await self._get(f"/api/issues/{issue_id}") - return PaperclipIssue(**data) if data else None - - async def create_issue( - self, - req: CreateIssueRequest, - company_id: str | None = None, - ) -> PaperclipIssue | None: - cid = company_id or settings.paperclip_company_id - if not cid: - logger.warning("paperclip_company_id not set — cannot create issue") - return None - data = await self._post( - f"/api/companies/{cid}/issues", - json=req.model_dump(exclude_none=True), - ) - return PaperclipIssue(**data) if data else None - - async def update_issue( - self, - issue_id: str, - req: UpdateIssueRequest, - ) -> PaperclipIssue | None: - data = await self._patch( - f"/api/issues/{issue_id}", - json=req.model_dump(exclude_none=True), - ) - return PaperclipIssue(**data) if data else None - - async def delete_issue(self, issue_id: str) -> bool: - return await self._delete(f"/api/issues/{issue_id}") - - # ── issue comments ─────────────────────────────────────────────────── - - async def list_comments(self, issue_id: str) -> list[PaperclipComment]: - data = await self._get(f"/api/issues/{issue_id}/comments") - if not isinstance(data, list): - return [] - return [PaperclipComment(**c) for c in data] - - async def add_comment( - self, - issue_id: str, - content: str, - ) -> PaperclipComment | None: - data = await self._post( - f"/api/issues/{issue_id}/comments", - json={"content": content}, - ) - return PaperclipComment(**data) if data else None - - # ── issue workflow ─────────────────────────────────────────────────── - - async def checkout_issue(self, issue_id: str) -> dict[str, Any] | None: - """Assign an issue to Timmy (checkout).""" - body: dict[str, Any] = {} - if settings.paperclip_agent_id: - body["agentId"] = settings.paperclip_agent_id - return await self._post(f"/api/issues/{issue_id}/checkout", json=body) - - async def release_issue(self, issue_id: str) -> dict[str, Any] | None: - """Release a checked-out issue.""" - return await self._post(f"/api/issues/{issue_id}/release") - - # ── goals ──────────────────────────────────────────────────────────── - - async def list_goals(self, company_id: str | None = None) -> list[PaperclipGoal]: - cid = company_id or settings.paperclip_company_id - if not cid: - return [] - data = await self._get(f"/api/companies/{cid}/goals") - if not isinstance(data, list): - return [] - return [PaperclipGoal(**g) for g in data] - - async def create_goal( - self, - title: str, - description: str = "", - company_id: str | None = None, - ) -> PaperclipGoal | None: - cid = company_id or settings.paperclip_company_id - if not cid: - return None - data = await self._post( - f"/api/companies/{cid}/goals", - json={"title": title, "description": description}, - ) - return PaperclipGoal(**data) if data else None - - # ── heartbeat runs ─────────────────────────────────────────────────── - - async def list_heartbeat_runs( - self, - company_id: str | None = None, - ) -> list[dict[str, Any]]: - cid = company_id or settings.paperclip_company_id - if not cid: - return [] - data = await self._get(f"/api/companies/{cid}/heartbeat-runs") - return data if isinstance(data, list) else [] - - async def get_run_events(self, run_id: str) -> list[dict[str, Any]]: - data = await self._get(f"/api/heartbeat-runs/{run_id}/events") - return data if isinstance(data, list) else [] - - async def cancel_run(self, run_id: str) -> dict[str, Any] | None: - return await self._post(f"/api/heartbeat-runs/{run_id}/cancel") - - # ── approvals ──────────────────────────────────────────────────────── - - async def list_approvals(self, company_id: str | None = None) -> list[dict[str, Any]]: - cid = company_id or settings.paperclip_company_id - if not cid: - return [] - data = await self._get(f"/api/companies/{cid}/approvals") - return data if isinstance(data, list) else [] - - async def approve(self, approval_id: str, comment: str = "") -> dict[str, Any] | None: - body: dict[str, Any] = {} - if comment: - body["comment"] = comment - return await self._post(f"/api/approvals/{approval_id}/approve", json=body) - - async def reject(self, approval_id: str, comment: str = "") -> dict[str, Any] | None: - body: dict[str, Any] = {} - if comment: - body["comment"] = comment - return await self._post(f"/api/approvals/{approval_id}/reject", json=body) - - -# Module-level singleton -paperclip = PaperclipClient() diff --git a/src/integrations/paperclip/models.py b/src/integrations/paperclip/models.py deleted file mode 100644 index 7903d2c..0000000 --- a/src/integrations/paperclip/models.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Pydantic models for Paperclip AI API objects.""" - -from __future__ import annotations - -from pydantic import BaseModel, Field - -# ── Inbound: Paperclip → Timmy ────────────────────────────────────────────── - - -class PaperclipIssue(BaseModel): - """A ticket/issue in Paperclip's task system.""" - - id: str - title: str - description: str = "" - status: str = "open" - priority: str | None = None - assignee_id: str | None = None - project_id: str | None = None - labels: list[str] = Field(default_factory=list) - created_at: str | None = None - updated_at: str | None = None - - -class PaperclipComment(BaseModel): - """A comment on a Paperclip issue.""" - - id: str - issue_id: str - content: str - author: str | None = None - created_at: str | None = None - - -class PaperclipAgent(BaseModel): - """An agent in the Paperclip org chart.""" - - id: str - name: str - role: str = "" - status: str = "active" - adapter_type: str | None = None - company_id: str | None = None - - -class PaperclipGoal(BaseModel): - """A company goal in Paperclip.""" - - id: str - title: str - description: str = "" - status: str = "active" - company_id: str | None = None - - -class HeartbeatRun(BaseModel): - """A heartbeat execution run.""" - - id: str - agent_id: str - status: str - issue_id: str | None = None - started_at: str | None = None - finished_at: str | None = None - - -# ── Outbound: Timmy → Paperclip ───────────────────────────────────────────── - - -class CreateIssueRequest(BaseModel): - """Request to create a new issue in Paperclip.""" - - title: str - description: str = "" - priority: str | None = None - assignee_id: str | None = None - project_id: str | None = None - labels: list[str] = Field(default_factory=list) - - -class UpdateIssueRequest(BaseModel): - """Request to update an existing issue.""" - - title: str | None = None - description: str | None = None - status: str | None = None - priority: str | None = None - assignee_id: str | None = None - - -class AddCommentRequest(BaseModel): - """Request to add a comment to an issue.""" - - content: str - - -class WakeAgentRequest(BaseModel): - """Request to wake an agent via heartbeat.""" - - issue_id: str | None = None - message: str | None = None - - -# ── API route models ───────────────────────────────────────────────────────── - - -class PaperclipStatusResponse(BaseModel): - """Response for GET /api/paperclip/status.""" - - enabled: bool - connected: bool = False - paperclip_url: str = "" - company_id: str = "" - agent_count: int = 0 - issue_count: int = 0 - error: str | None = None diff --git a/src/integrations/paperclip/task_runner.py b/src/integrations/paperclip/task_runner.py deleted file mode 100644 index fb49d87..0000000 --- a/src/integrations/paperclip/task_runner.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Paperclip task runner — automated issue processing loop. - -Timmy grabs open issues assigned to him, processes each one, posts a -completion comment, marks the issue done, and creates a recursive -follow-up task for himself. - -Green-path workflow: -1. Poll Paperclip for open issues assigned to Timmy -2. Check out the first issue in queue -3. Process it (delegate to orchestrator via execute_task) -4. Post completion comment with the result -5. Mark the issue done -6. Create a follow-up task for himself (recursive musing) -""" - -from __future__ import annotations - -import asyncio -import logging -from collections.abc import Callable, Coroutine -from typing import Any, Protocol, runtime_checkable - -from config import settings -from integrations.paperclip.bridge import PaperclipBridge -from integrations.paperclip.bridge import bridge as default_bridge -from integrations.paperclip.models import PaperclipIssue - -logger = logging.getLogger(__name__) - - -@runtime_checkable -class Orchestrator(Protocol): - """Anything with an ``execute_task`` matching Timmy's orchestrator.""" - - async def execute_task(self, task_id: str, description: str, context: dict) -> Any: ... - - -def _wrap_orchestrator(orch: Orchestrator) -> Callable: - """Adapt an orchestrator's execute_task to the process_fn signature.""" - - async def _process(task_id: str, description: str, context: dict) -> str: - raw = await orch.execute_task(task_id, description, context) - # execute_task may return str or dict — normalise to str - if isinstance(raw, dict): - return raw.get("result", str(raw)) - return str(raw) - - return _process - - -class TaskRunner: - """Autonomous task loop: grab → process → complete → follow-up. - - Wire an *orchestrator* (anything with ``execute_task``) and the runner - pushes issues through the real agent pipe. Falls back to a plain - ``process_fn`` callable or a no-op default. - - The runner operates on a single cycle via ``run_once`` (testable) or - continuously via ``start`` with ``paperclip_poll_interval``. - """ - - def __init__( - self, - bridge: PaperclipBridge | None = None, - orchestrator: Orchestrator | None = None, - process_fn: Callable[[str, str, dict], Coroutine[Any, Any, str]] | None = None, - ): - self.bridge = bridge or default_bridge - self.orchestrator = orchestrator - - # Priority: explicit process_fn > orchestrator wrapper > default - if process_fn: - self._process_fn = process_fn - elif orchestrator: - self._process_fn = _wrap_orchestrator(orchestrator) - else: - self._process_fn = None - - self._running = False - - # ── single cycle ────────────────────────────────────────────────── - - async def grab_next_task(self) -> PaperclipIssue | None: - """Grab the first open issue assigned to Timmy.""" - agent_id = settings.paperclip_agent_id - if not agent_id: - logger.warning("paperclip_agent_id not set — cannot grab tasks") - return None - - issues = await self.bridge.client.list_issues(status="open") - # Filter to issues assigned to Timmy, take the first one - for issue in issues: - if issue.assignee_id == agent_id: - return issue - - return None - - async def process_task(self, issue: PaperclipIssue) -> str: - """Process an issue: check out, run through the orchestrator, return result.""" - # Check out the issue so others know we're working on it - await self.bridge.client.checkout_issue(issue.id) - - context = { - "issue_id": issue.id, - "title": issue.title, - "priority": issue.priority, - "labels": issue.labels, - } - - if self._process_fn: - result = await self._process_fn(issue.id, issue.description or issue.title, context) - else: - result = f"Processed task: {issue.title}" - - return result - - async def complete_task(self, issue: PaperclipIssue, result: str) -> bool: - """Post completion comment and mark issue done.""" - # Post the result as a comment - await self.bridge.client.add_comment( - issue.id, - f"[Timmy] Task completed.\n\n{result}", - ) - - # Mark the issue as done - return await self.bridge.close_issue(issue.id, comment=None) - - async def create_follow_up( - self, original: PaperclipIssue, result: str - ) -> PaperclipIssue | None: - """Create a recursive follow-up task for Timmy. - - Timmy muses about task automation and writes a follow-up issue - assigned to himself — the recursive self-improvement loop. - """ - follow_up_title = f"Follow-up: {original.title}" - follow_up_description = ( - f"Automated follow-up from completed task '{original.title}' " - f"(issue {original.id}).\n\n" - f"Previous result:\n{result}\n\n" - "Review the outcome and determine if further action is needed. " - "Muse about task automation improvements and recursive self-improvement." - ) - - return await self.bridge.create_and_assign( - title=follow_up_title, - description=follow_up_description, - assignee_id=settings.paperclip_agent_id, - priority=original.priority, - wake=False, # Don't wake immediately — let the next poll pick it up - ) - - async def run_once(self) -> dict[str, Any] | None: - """Execute one full cycle of the green-path workflow. - - Returns a summary dict on success, None if no work found. - """ - # Step 1: Grab next task - issue = await self.grab_next_task() - if not issue: - logger.debug("No tasks in queue for Timmy") - return None - - logger.info("Grabbed task %s: %s", issue.id, issue.title) - - # Step 2: Process the task - result = await self.process_task(issue) - logger.info("Processed task %s", issue.id) - - # Step 3: Complete it - completed = await self.complete_task(issue, result) - if not completed: - logger.warning("Failed to mark task %s as done", issue.id) - - # Step 4: Create follow-up - follow_up = await self.create_follow_up(issue, result) - follow_up_id = follow_up.id if follow_up else None - if follow_up: - logger.info("Created follow-up %s for task %s", follow_up.id, issue.id) - - return { - "original_issue_id": issue.id, - "original_title": issue.title, - "result": result, - "completed": completed, - "follow_up_issue_id": follow_up_id, - } - - # ── continuous loop ─────────────────────────────────────────────── - - async def start(self) -> None: - """Run the task loop continuously using paperclip_poll_interval.""" - interval = settings.paperclip_poll_interval - if interval <= 0: - logger.info("Task runner disabled (poll_interval=%d)", interval) - return - - self._running = True - logger.info("Task runner started (poll every %ds)", interval) - - while self._running: - try: - await self.run_once() - except Exception as exc: - logger.error("Task runner cycle failed: %s", exc) - - await asyncio.sleep(interval) - - def stop(self) -> None: - """Signal the loop to stop.""" - self._running = False - logger.info("Task runner stopping") - - -# Module-level singleton -task_runner = TaskRunner() diff --git a/src/swarm/__init__.py b/src/swarm/__init__.py deleted file mode 100644 index 253bda9..0000000 --- a/src/swarm/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# swarm — task orchestration package diff --git a/src/swarm/event_log.py b/src/swarm/event_log.py deleted file mode 100644 index 3294c86..0000000 --- a/src/swarm/event_log.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Swarm event log — records system events to SQLite. - -Provides EventType enum, EventLogEntry dataclass, and log_event() function -used by error_capture, thinking engine, and the event broadcaster. - -Events are persisted to SQLite and also published to the unified EventBus -(infrastructure.events.bus) for subscriber notification. -""" - -import json -import logging -import sqlite3 -import uuid -from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta -from enum import Enum -from pathlib import Path - -logger = logging.getLogger(__name__) - -DB_PATH = Path("data/events.db") - - -class EventType(Enum): - """All recognised event types in the system.""" - - # Task lifecycle - TASK_CREATED = "task.created" - TASK_BIDDING = "task.bidding" - TASK_ASSIGNED = "task.assigned" - TASK_STARTED = "task.started" - TASK_COMPLETED = "task.completed" - TASK_FAILED = "task.failed" - - # Agent lifecycle - AGENT_JOINED = "agent.joined" - AGENT_LEFT = "agent.left" - AGENT_STATUS_CHANGED = "agent.status_changed" - - # Bids - BID_SUBMITTED = "bid.submitted" - AUCTION_CLOSED = "auction.closed" - - # Tools - TOOL_CALLED = "tool.called" - TOOL_COMPLETED = "tool.completed" - TOOL_FAILED = "tool.failed" - - # System - SYSTEM_ERROR = "system.error" - SYSTEM_WARNING = "system.warning" - SYSTEM_INFO = "system.info" - - # Error capture - ERROR_CAPTURED = "error.captured" - BUG_REPORT_CREATED = "bug_report.created" - - # Thinking - TIMMY_THOUGHT = "timmy.thought" - - # Loop QA self-tests - LOOP_QA_OK = "loop_qa.ok" - LOOP_QA_FAIL = "loop_qa.fail" - - -@dataclass -class EventLogEntry: - """Single event in the log, used by the broadcaster for display.""" - - id: str - event_type: EventType - source: str - timestamp: str - data: dict = field(default_factory=dict) - task_id: str = "" - agent_id: str = "" - - -def _ensure_db() -> sqlite3.Connection: - DB_PATH.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA busy_timeout=5000") - conn.execute(""" - CREATE TABLE IF NOT EXISTS events ( - id TEXT PRIMARY KEY, - event_type TEXT NOT NULL, - source TEXT DEFAULT '', - task_id TEXT DEFAULT '', - agent_id TEXT DEFAULT '', - data TEXT DEFAULT '{}', - timestamp TEXT NOT NULL - ) - """) - conn.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_events_time ON events(timestamp)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_events_agent ON events(agent_id)") - conn.commit() - return conn - - -def _publish_to_event_bus(entry: EventLogEntry) -> None: - """Publish an event to the unified EventBus (non-blocking). - - This bridges the synchronous log_event() callers to the async EventBus - so subscribers get notified of all events regardless of origin. - """ - try: - import asyncio - - from infrastructure.events.bus import Event, event_bus - - event = Event( - id=entry.id, - type=entry.event_type.value, - source=entry.source, - data={ - **entry.data, - "task_id": entry.task_id, - "agent_id": entry.agent_id, - }, - timestamp=entry.timestamp, - ) - - try: - asyncio.get_running_loop() - asyncio.create_task(event_bus.publish(event)) - except RuntimeError: - # No event loop running — skip async publish - pass - except Exception: - # Graceful degradation — never crash on EventBus integration - pass - - -def log_event( - event_type: EventType, - source: str = "", - data: dict | None = None, - task_id: str = "", - agent_id: str = "", -) -> EventLogEntry: - """Record an event and return the entry. - - Persists to SQLite, publishes to EventBus for subscribers, - and broadcasts to WebSocket clients. - """ - entry = EventLogEntry( - id=str(uuid.uuid4()), - event_type=event_type, - source=source, - timestamp=datetime.now(UTC).isoformat(), - data=data or {}, - task_id=task_id, - agent_id=agent_id, - ) - - # Persist to SQLite - try: - db = _ensure_db() - try: - db.execute( - "INSERT INTO events (id, event_type, source, task_id, agent_id, data, timestamp) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - ( - entry.id, - event_type.value, - source, - task_id, - agent_id, - json.dumps(data or {}), - entry.timestamp, - ), - ) - db.commit() - finally: - db.close() - except Exception as exc: - logger.debug("Failed to persist event: %s", exc) - - # Publish to unified EventBus (non-blocking) - _publish_to_event_bus(entry) - - # Broadcast to WebSocket clients (non-blocking) - try: - from infrastructure.events.broadcaster import event_broadcaster - - event_broadcaster.broadcast_sync(entry) - except Exception: - pass - - return entry - - -def prune_old_events(keep_days: int = 90, keep_min: int = 200) -> int: - """Delete events older than *keep_days*, always retaining at least *keep_min*. - - Returns the number of deleted rows. - """ - db = _ensure_db() - try: - total = db.execute("SELECT COUNT(*) as c FROM events").fetchone()["c"] - if total <= keep_min: - return 0 - cutoff = (datetime.now(UTC) - timedelta(days=keep_days)).isoformat() - cursor = db.execute( - "DELETE FROM events WHERE timestamp < ? AND id NOT IN " - "(SELECT id FROM events ORDER BY timestamp DESC LIMIT ?)", - (cutoff, keep_min), - ) - deleted = cursor.rowcount - db.commit() - return deleted - except Exception as exc: - logger.warning("Event pruning failed: %s", exc) - return 0 - finally: - db.close() - - -def get_task_events(task_id: str, limit: int = 50) -> list[EventLogEntry]: - """Retrieve events for a specific task.""" - db = _ensure_db() - try: - rows = db.execute( - "SELECT * FROM events WHERE task_id=? ORDER BY timestamp DESC LIMIT ?", - (task_id, limit), - ).fetchall() - finally: - db.close() - - entries = [] - for r in rows: - try: - et = EventType(r["event_type"]) - except ValueError: - et = EventType.SYSTEM_INFO - entries.append( - EventLogEntry( - id=r["id"], - event_type=et, - source=r["source"], - timestamp=r["timestamp"], - data=json.loads(r["data"]) if r["data"] else {}, - task_id=r["task_id"], - agent_id=r["agent_id"], - ) - ) - return entries diff --git a/src/swarm/task_queue/__init__.py b/src/swarm/task_queue/__init__.py deleted file mode 100644 index 5ac803c..0000000 --- a/src/swarm/task_queue/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# swarm.task_queue — task queue bridge diff --git a/src/swarm/task_queue/models.py b/src/swarm/task_queue/models.py deleted file mode 100644 index c9f1b38..0000000 --- a/src/swarm/task_queue/models.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Bridge module: exposes create_task() for programmatic task creation. - -Used by infrastructure.error_capture to auto-create bug report tasks -in the same SQLite database the dashboard routes use. -""" - -import logging -import sqlite3 -import uuid -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path - -logger = logging.getLogger(__name__) - -# Use absolute path via settings.repo_root so tests can reliably redirect it -# and relative-path CWD differences don't cause DB leaks. -try: - from config import settings as _settings - - DB_PATH = Path(_settings.repo_root) / "data" / "tasks.db" -except Exception: - DB_PATH = Path("data/tasks.db") - - -@dataclass -class TaskRecord: - """Lightweight return value from create_task().""" - - id: str - title: str - status: str - - -def _ensure_db() -> sqlite3.Connection: - DB_PATH.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA busy_timeout=5000") - conn.execute(""" - CREATE TABLE IF NOT EXISTS tasks ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT DEFAULT '', - status TEXT DEFAULT 'pending_approval', - priority TEXT DEFAULT 'normal', - assigned_to TEXT DEFAULT '', - created_by TEXT DEFAULT 'operator', - result TEXT DEFAULT '', - created_at TEXT DEFAULT (datetime('now')), - completed_at TEXT - ) - """) - conn.commit() - return conn - - -def create_task( - title: str, - description: str = "", - assigned_to: str = "default", - created_by: str = "system", - priority: str = "normal", - requires_approval: bool = True, - auto_approve: bool = False, - task_type: str = "", -) -> TaskRecord: - """Insert a task into the SQLite task queue and return a TaskRecord. - - Args: - title: Task title (e.g. "[BUG] ConnectionError: ...") - description: Markdown body with error details / stack trace - assigned_to: Agent or queue to assign to - created_by: Who created the task ("system", "operator", etc.) - priority: "low" | "normal" | "high" | "urgent" - requires_approval: If False and auto_approve, skip pending_approval - auto_approve: If True, set status to "approved" immediately - task_type: Optional tag (e.g. "bug_report") - - Returns: - TaskRecord with the new task's id, title, and status. - """ - valid_priorities = {"low", "normal", "high", "urgent"} - if priority not in valid_priorities: - priority = "normal" - - status = "approved" if (auto_approve and not requires_approval) else "pending_approval" - task_id = str(uuid.uuid4()) - now = datetime.utcnow().isoformat() - - # Store task_type in description header if provided - if task_type: - description = f"**Type:** {task_type}\n{description}" - - db = _ensure_db() - try: - db.execute( - "INSERT INTO tasks (id, title, description, status, priority, assigned_to, created_by, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (task_id, title, description, status, priority, assigned_to, created_by, now), - ) - db.commit() - finally: - db.close() - - logger.info("Task created: %s — %s [%s]", task_id[:8], title[:60], status) - return TaskRecord(id=task_id, title=title, status=status) - - -def get_task_summary_for_briefing() -> dict: - """Return a summary of task counts by status for the morning briefing.""" - db = _ensure_db() - try: - rows = db.execute("SELECT status, COUNT(*) as cnt FROM tasks GROUP BY status").fetchall() - finally: - db.close() - - summary = {r["status"]: r["cnt"] for r in rows} - summary["total"] = sum(summary.values()) - return summary diff --git a/src/timmy/agent.py b/src/timmy/agent.py index 695895c..9501fa7 100644 --- a/src/timmy/agent.py +++ b/src/timmy/agent.py @@ -229,15 +229,14 @@ def create_timmy( auto_pull=True, ) - # If Ollama is completely unreachable, fall back to Claude if available + # If Ollama is completely unreachable, fail loudly. + # Sovereignty: never silently send data to a cloud API. + # Use --backend claude explicitly if you want cloud inference. if not _check_model_available(model_name): - from timmy.backends import claude_available - - if claude_available(): - logger.warning("Ollama unreachable — falling back to Claude backend") - from timmy.backends import ClaudeBackend - - return ClaudeBackend() + logger.error( + "Ollama unreachable and no local models available. " + "Start Ollama with 'ollama serve' or use --backend claude explicitly." + ) if is_fallback: logger.info("Using fallback model %s (requested was unavailable)", model_name) diff --git a/src/timmy/agents/timmy.py b/src/timmy/agents/timmy.py deleted file mode 100644 index f1c2adb..0000000 --- a/src/timmy/agents/timmy.py +++ /dev/null @@ -1,547 +0,0 @@ -"""Orchestrator agent. - -Coordinates all sub-agents and handles user interaction. -Uses the three-tier memory system and MCP tools. -""" - -import logging -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -from config import settings -from infrastructure.events.bus import event_bus -from timmy.agents.base import BaseAgent, SubAgent - -logger = logging.getLogger(__name__) - -# Dynamic context that gets built at startup -_timmy_context: dict[str, Any] = { - "git_log": "", - "agents": [], - "hands": [], - "memory": "", -} - - -async def _load_hands_async() -> list[dict]: - """Async helper to load hands. - - Hands registry removed — hand definitions live in TOML files under hands/. - This will be rewired to read from brain memory. - """ - return [] - - -def build_timmy_context_sync() -> dict[str, Any]: - """Build context at startup (synchronous version). - - Gathers git commits, active sub-agents, and hot memory. - """ - global _timmy_context - - ctx: dict[str, Any] = { - "timestamp": datetime.now(UTC).isoformat(), - "repo_root": settings.repo_root, - "git_log": "", - "agents": [], - "hands": [], - "memory": "", - } - - # 1. Get recent git commits - try: - from tools.git_tools import git_log - - result = git_log(max_count=20) - if result.get("success"): - commits = result.get("commits", []) - ctx["git_log"] = "\n".join( - [f"{c['short_sha']} {c['message'].split(chr(10))[0]}" for c in commits[:20]] - ) - except Exception as exc: - logger.warning("Could not load git log for context: %s", exc) - ctx["git_log"] = "(Git log unavailable)" - - # 2. Get active sub-agents - try: - from swarm import registry as swarm_registry - - conn = swarm_registry._get_conn() - rows = conn.execute( - "SELECT id, name, status, capabilities FROM agents ORDER BY name" - ).fetchall() - ctx["agents"] = [ - { - "id": r["id"], - "name": r["name"], - "status": r["status"], - "capabilities": r["capabilities"], - } - for r in rows - ] - conn.close() - except Exception as exc: - logger.warning("Could not load agents for context: %s", exc) - ctx["agents"] = [] - - # 3. Read hot memory (via HotMemory to auto-create if missing) - try: - from timmy.memory_system import memory_system - - ctx["memory"] = memory_system.hot.read()[:2000] - except Exception as exc: - logger.warning("Could not load memory for context: %s", exc) - ctx["memory"] = "(Memory unavailable)" - - _timmy_context.update(ctx) - logger.info("Context built (sync): %d agents", len(ctx["agents"])) - return ctx - - -async def build_timmy_context_async() -> dict[str, Any]: - """Build complete context including hands (async version).""" - ctx = build_timmy_context_sync() - ctx["hands"] = await _load_hands_async() - _timmy_context.update(ctx) - logger.info("Context built (async): %d agents, %d hands", len(ctx["agents"]), len(ctx["hands"])) - return ctx - - -# Keep old name for backwards compatibility -build_timmy_context = build_timmy_context_sync - - -def format_timmy_prompt(base_prompt: str, context: dict[str, Any]) -> str: - """Format the system prompt with dynamic context.""" - - # Format agents list - agents_list = ( - "\n".join( - [ - f"| {a['name']} | {a['capabilities'] or 'general'} | {a['status']} |" - for a in context.get("agents", []) - ] - ) - or "(No agents registered yet)" - ) - - # Format hands list - hands_list = ( - "\n".join( - [ - f"| {h['name']} | {h['schedule']} | {'enabled' if h['enabled'] else 'disabled'} |" - for h in context.get("hands", []) - ] - ) - or "(No hands configured)" - ) - - repo_root = context.get("repo_root", settings.repo_root) - - context_block = f""" -## Current System Context (as of {context.get("timestamp", datetime.now(UTC).isoformat())}) - -### Repository -**Root:** `{repo_root}` - -### Recent Commits (last 20): -``` -{context.get("git_log", "(unavailable)")} -``` - -### Active Sub-Agents: -| Name | Capabilities | Status | -|------|--------------|--------| -{agents_list} - -### Hands (Scheduled Tasks): -| Name | Schedule | Status | -|------|----------|--------| -{hands_list} - -### Hot Memory: -{context.get("memory", "(unavailable)")[:1000]} -""" - - # Replace {REPO_ROOT} placeholder with actual path - base_prompt = base_prompt.replace("{REPO_ROOT}", repo_root) - - # Insert context after the first line - lines = base_prompt.split("\n") - if lines: - return lines[0] + "\n" + context_block + "\n" + "\n".join(lines[1:]) - return base_prompt - - -# Base prompt with anti-hallucination hard rules -ORCHESTRATOR_PROMPT_BASE = """You are a local AI orchestrator running on this machine. - -## Your Role - -You are the primary interface between the user and the agent swarm. You: -1. Understand user requests -2. Decide whether to handle directly or delegate to sub-agents -3. Coordinate multi-agent workflows when needed -4. Maintain continuity using the three-tier memory system - -## Sub-Agent Roster - -| Agent | Role | When to Use | -|-------|------|-------------| -| Seer | Research | External info, web search, facts | -| Forge | Code | Programming, tools, file operations | -| Quill | Writing | Documentation, content creation | -| Echo | Memory | Past conversations, user profile | -| Helm | Routing | Complex multi-step workflows | - -## Decision Framework - -**Handle directly if:** -- Simple question about capabilities -- General knowledge -- Social/conversational - -**Delegate if:** -- Requires specialized skills -- Needs external research (Seer) -- Involves code (Forge) -- Needs past context (Echo) -- Complex workflow (Helm) - -## Hard Rules — Non-Negotiable - -1. **NEVER fabricate tool output.** If you need data from a tool, call the tool and wait for the real result. - -2. **If a tool call returns an error, report the exact error message.** - -3. **If you do not know something, say so.** Then use a tool. Do not guess. - -4. **Never say "I'll wait for the output" and then immediately provide fake output.** - -5. **When corrected, use memory_write to save the correction immediately.** - -6. **Your source code lives at the repository root shown above.** When using git tools, they automatically run from {REPO_ROOT}. - -7. **When asked about your status, queue, agents, memory, or system health, use the `system_status` tool.** -""" - - -class TimmyOrchestrator(BaseAgent): - """Main orchestrator agent that coordinates the swarm.""" - - def __init__(self) -> None: - # Build initial context (sync) and format prompt - # Full context including hands will be loaded on first async call - context = build_timmy_context_sync() - formatted_prompt = format_timmy_prompt(ORCHESTRATOR_PROMPT_BASE, context) - - super().__init__( - agent_id="orchestrator", - name="Orchestrator", - role="orchestrator", - system_prompt=formatted_prompt, - tools=[ - "web_search", - "read_file", - "write_file", - "python", - "memory_search", - "memory_write", - "system_status", - ], - ) - - # Sub-agent registry - self.sub_agents: dict[str, BaseAgent] = {} - - # Session tracking for init behavior - self._session_initialized = False - self._session_context: dict[str, Any] = {} - self._context_fully_loaded = False - - # Connect to event bus - self.connect_event_bus(event_bus) - - logger.info("Orchestrator initialized with context-aware prompt") - - def register_sub_agent(self, agent: BaseAgent) -> None: - """Register a sub-agent with the orchestrator.""" - self.sub_agents[agent.agent_id] = agent - agent.connect_event_bus(event_bus) - logger.info("Registered sub-agent: %s", agent.name) - - async def _session_init(self) -> None: - """Initialize session context on first user message. - - Silently reads git log and AGENTS.md to ground the orchestrator in real data. - This runs once per session before the first response. - """ - if self._session_initialized: - return - - logger.debug("Running session init...") - - # Load full context including hands if not already done - if not self._context_fully_loaded: - await build_timmy_context_async() - self._context_fully_loaded = True - - # Read recent git log --oneline -15 from repo root - try: - from tools.git_tools import git_log - - git_result = git_log(max_count=15) - if git_result.get("success"): - commits = git_result.get("commits", []) - self._session_context["git_log_commits"] = commits - # Format as oneline for easy reading - self._session_context["git_log_oneline"] = "\n".join( - [f"{c['short_sha']} {c['message'].split(chr(10))[0]}" for c in commits] - ) - logger.debug(f"Session init: loaded {len(commits)} commits from git log") - else: - self._session_context["git_log_oneline"] = "Git log unavailable" - except Exception as exc: - logger.warning("Session init: could not read git log: %s", exc) - self._session_context["git_log_oneline"] = "Git log unavailable" - - # Read AGENTS.md for self-awareness - try: - agents_md_path = Path(settings.repo_root) / "AGENTS.md" - if agents_md_path.exists(): - self._session_context["agents_md"] = agents_md_path.read_text()[:3000] - except Exception as exc: - logger.warning("Session init: could not read AGENTS.md: %s", exc) - - # Read CHANGELOG for recent changes - try: - changelog_path = Path(settings.repo_root) / "docs" / "CHANGELOG_2026-02-26.md" - if changelog_path.exists(): - self._session_context["changelog"] = changelog_path.read_text()[:2000] - except Exception: - pass # Changelog is optional - - # Build session-specific context block for the prompt - recent_changes = self._session_context.get("git_log_oneline", "") - if recent_changes and recent_changes != "Git log unavailable": - self._session_context["recent_changes_block"] = f""" -## Recent Changes to Your Codebase (last 15 commits): -``` -{recent_changes} -``` -When asked "what's new?" or similar, refer to these commits for actual changes. -""" - else: - self._session_context["recent_changes_block"] = "" - - self._session_initialized = True - logger.debug("Session init complete") - - def _get_enhanced_system_prompt(self) -> str: - """Get system prompt enhanced with session-specific context. - - Prepends the recent git log to the system prompt for grounding. - """ - base = self.system_prompt - - # Add recent changes block if available - recent_changes = self._session_context.get("recent_changes_block", "") - if recent_changes: - # Insert after the first line - lines = base.split("\n") - if lines: - return lines[0] + "\n" + recent_changes + "\n" + "\n".join(lines[1:]) - - return base - - async def orchestrate(self, user_request: str) -> str: - """Main entry point for user requests. - - Analyzes the request and either handles directly or delegates. - """ - # Run session init on first message (loads git log, etc.) - await self._session_init() - - # Quick classification - request_lower = user_request.lower() - - # Direct response patterns (no delegation needed) - direct_patterns = [ - "your name", - "who are you", - "what are you", - "hello", - "hi", - "how are you", - "help", - "what can you do", - ] - - for pattern in direct_patterns: - if pattern in request_lower: - return await self.run(user_request) - - # Check for memory references — delegate to Echo - memory_patterns = [ - "we talked about", - "we discussed", - "remember", - "what did i say", - "what did we decide", - "remind me", - "have we", - ] - - for pattern in memory_patterns: - if pattern in request_lower: - echo = self.sub_agents.get("echo") - if echo: - return await echo.run( - f"Recall information about: {user_request}\nProvide relevant context from memory." - ) - - # Complex requests — ask Helm to route - helm = self.sub_agents.get("helm") - if helm: - routing_response = await helm.run( - f"Analyze this request and determine the best agent to handle it:\n\n" - f"Request: {user_request}\n\n" - f"Respond with: Primary Agent: [agent name]" - ) - # Extract agent name from routing response - agent_id = self._extract_agent(routing_response) - if agent_id in self.sub_agents and agent_id != "orchestrator": - return await self.sub_agents[agent_id].run(user_request) - - # Default: handle directly - return await self.run(user_request) - - @staticmethod - def _extract_agent(text: str) -> str: - """Extract agent name from routing text.""" - agents = ["seer", "forge", "quill", "echo", "helm"] - text_lower = text.lower() - for agent in agents: - if agent in text_lower: - return agent - return "orchestrator" - - async def execute_task(self, task_id: str, description: str, context: dict) -> Any: - """Execute a task (usually delegates to appropriate agent).""" - return await self.orchestrate(description) - - def get_swarm_status(self) -> dict: - """Get status of all agents in the swarm.""" - return { - "orchestrator": self.get_status(), - "sub_agents": {aid: agent.get_status() for aid, agent in self.sub_agents.items()}, - "total_agents": 1 + len(self.sub_agents), - } - - -# ── Persona definitions ────────────────────────────────────────────────────── -# Each persona is a config dict that gets passed to SubAgent. -# Previously these were separate classes (SeerAgent, ForgeAgent, etc.) -# that differed only by these values. - -_PERSONAS: list[dict[str, Any]] = [ - { - "agent_id": "seer", - "name": "Seer", - "role": "research", - "tools": ["web_search", "read_file", "memory_search"], - "system_prompt": ( - "You are Seer, a research and information gathering specialist.\n" - "Find, evaluate, and synthesize information from external sources.\n" - "Be thorough, skeptical, concise, and cite sources." - ), - }, - { - "agent_id": "forge", - "name": "Forge", - "role": "code", - "tools": ["python", "write_file", "read_file", "list_directory"], - "system_prompt": ( - "You are Forge, a code generation and tool building specialist.\n" - "Write clean code, be safe, explain your work, and test mentally." - ), - }, - { - "agent_id": "quill", - "name": "Quill", - "role": "writing", - "tools": ["write_file", "read_file", "memory_search"], - "system_prompt": ( - "You are Quill, a writing and content generation specialist.\n" - "Write clearly, know your audience, be concise, use formatting." - ), - }, - { - "agent_id": "echo", - "name": "Echo", - "role": "memory", - "tools": ["memory_search", "read_file", "write_file"], - "system_prompt": ( - "You are Echo, a memory and context management specialist.\n" - "Remember, retrieve, and synthesize information from the past.\n" - "Be accurate, relevant, concise, and acknowledge uncertainty." - ), - }, - { - "agent_id": "helm", - "name": "Helm", - "role": "routing", - "tools": ["memory_search"], - "system_prompt": ( - "You are Helm, a routing and orchestration specialist.\n" - "Analyze tasks and decide how to route them to other agents.\n" - "Available agents: Seer (research), Forge (code), Quill (writing), Echo (memory), Lab (experiments).\n" - "Respond with: Primary Agent: [agent name]" - ), - }, - { - "agent_id": "lab", - "name": "Lab", - "role": "experiment", - "tools": [ - "run_experiment", - "prepare_experiment", - "shell", - "python", - "read_file", - "write_file", - ], - "system_prompt": ( - "You are Lab, an autonomous ML experimentation specialist.\n" - "You run time-boxed training experiments, evaluate metrics,\n" - "modify training code to improve results, and iterate.\n" - "Always report the metric delta. Never exceed the time budget." - ), - }, -] - - -def create_timmy_swarm() -> TimmyOrchestrator: - """Create orchestrator with all sub-agents registered.""" - orch = TimmyOrchestrator() - - for persona in _PERSONAS: - orch.register_sub_agent(SubAgent(**persona)) - - return orch - - -# Convenience functions for refreshing context -def refresh_timmy_context_sync() -> dict[str, Any]: - """Refresh context (sync version).""" - return build_timmy_context_sync() - - -async def refresh_timmy_context_async() -> dict[str, Any]: - """Refresh context including hands (async version).""" - return await build_timmy_context_async() - - -# Keep old name for backwards compatibility -refresh_timmy_context = refresh_timmy_context_sync diff --git a/src/timmy/cascade_adapter.py b/src/timmy/cascade_adapter.py deleted file mode 100644 index 4a27f84..0000000 --- a/src/timmy/cascade_adapter.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Cascade Router adapter for Timmy agent. - -Provides automatic failover between LLM providers with: -- Circuit breaker pattern for failing providers -- Metrics tracking per provider -- Priority-based routing (local first, then APIs) -""" - -import logging -from dataclasses import dataclass - -from infrastructure.router.cascade import CascadeRouter -from timmy.prompts import SYSTEM_PROMPT - -logger = logging.getLogger(__name__) - - -@dataclass -class TimmyResponse: - """Response from Timmy via Cascade Router.""" - - content: str - provider_used: str - latency_ms: float - fallback_used: bool = False - - -class TimmyCascadeAdapter: - """Adapter that routes Timmy requests through Cascade Router. - - Usage: - adapter = TimmyCascadeAdapter() - response = await adapter.chat("Hello") - print(f"Response: {response.content}") - print(f"Provider: {response.provider_used}") - """ - - def __init__(self, router: CascadeRouter | None = None) -> None: - """Initialize adapter with Cascade Router. - - Args: - router: CascadeRouter instance. If None, creates default. - """ - self.router = router or CascadeRouter() - logger.info("TimmyCascadeAdapter initialized with %d providers", len(self.router.providers)) - - async def chat(self, message: str, context: str | None = None) -> TimmyResponse: - """Send message through cascade router with automatic failover. - - Args: - message: User message - context: Optional conversation context - - Returns: - TimmyResponse with content and metadata - """ - # Build messages array - messages = [] - if context: - messages.append({"role": "system", "content": context}) - messages.append({"role": "user", "content": message}) - - # Route through cascade - import time - - start = time.time() - - try: - result = await self.router.complete( - messages=messages, - system_prompt=SYSTEM_PROMPT, - ) - - latency = (time.time() - start) * 1000 - - # Determine if fallback was used - primary = self.router.providers[0] if self.router.providers else None - fallback_used = primary and primary.status.value != "healthy" - - return TimmyResponse( - content=result.content, - provider_used=result.provider_name, - latency_ms=latency, - fallback_used=fallback_used, - ) - - except Exception as exc: - logger.error("All providers failed: %s", exc) - raise - - def get_provider_status(self) -> list[dict]: - """Get status of all providers. - - Returns: - List of provider status dicts - """ - return [ - { - "name": p.name, - "type": p.type, - "status": p.status.value, - "circuit_state": p.circuit_state.value, - "metrics": { - "total": p.metrics.total_requests, - "success": p.metrics.successful_requests, - "failed": p.metrics.failed_requests, - "avg_latency_ms": round(p.metrics.avg_latency_ms, 1), - "error_rate": round(p.metrics.error_rate, 3), - }, - "priority": p.priority, - "enabled": p.enabled, - } - for p in self.router.providers - ] - - def get_preferred_provider(self) -> str | None: - """Get name of highest-priority healthy provider. - - Returns: - Provider name or None if all unhealthy - """ - for provider in self.router.providers: - if provider.status.value == "healthy" and provider.enabled: - return provider.name - return None - - -# Global singleton for reuse -_cascade_adapter: TimmyCascadeAdapter | None = None - - -def get_cascade_adapter() -> TimmyCascadeAdapter: - """Get or create global cascade adapter singleton.""" - global _cascade_adapter - if _cascade_adapter is None: - _cascade_adapter = TimmyCascadeAdapter() - return _cascade_adapter diff --git a/src/timmy/loop_qa.py b/src/timmy/loop_qa.py index 04b4b42..0b58cc2 100644 --- a/src/timmy/loop_qa.py +++ b/src/timmy/loop_qa.py @@ -65,14 +65,11 @@ def _get_vault(): def _get_brain_memory(): - """Lazy-import the brain unified memory. + """Return None — brain module removed. - Redirected to use unified memory.db (via vector_store) instead of - brain.db. The brain module is deprecated for new memory operations. + Memory operations now go through timmy.memory_system. """ - from brain.memory import get_memory - - return get_memory() + return None # --------------------------------------------------------------------------- @@ -255,13 +252,8 @@ TEST_SEQUENCE: list[tuple[Capability, str]] = [ def log_event(event_type, **kwargs): - """Proxy to swarm event_log.log_event — lazy import.""" - try: - from swarm.event_log import log_event as _log_event - - return _log_event(event_type, **kwargs) - except Exception as exc: - logger.debug("Failed to log event: %s", exc) + """No-op — swarm event_log removed.""" + logger.debug("log_event(%s) — swarm module removed, skipping", event_type) def capture_error(exc, **kwargs): @@ -275,10 +267,9 @@ def capture_error(exc, **kwargs): def create_task(**kwargs): - """Proxy to swarm.task_queue.models.create_task — lazy import.""" - from swarm.task_queue.models import create_task as _create - - return _create(**kwargs) + """No-op — swarm task queue removed.""" + logger.debug("create_task() — swarm module removed, skipping") + return None class LoopQAOrchestrator: @@ -341,10 +332,8 @@ class LoopQAOrchestrator: "error_type": type(exc).__name__, } - # Log via event_log - from swarm.event_log import EventType - - event_type = EventType.LOOP_QA_OK if result["success"] else EventType.LOOP_QA_FAIL + # Log result + event_type = "loop_qa_ok" if result["success"] else "loop_qa_fail" log_event( event_type, source="loop_qa", diff --git a/src/timmy/memory_migrate.py b/src/timmy/memory_migrate.py deleted file mode 100644 index 0191884..0000000 --- a/src/timmy/memory_migrate.py +++ /dev/null @@ -1,296 +0,0 @@ -"""One-shot migration: consolidate old memory databases into data/memory.db. - -Migrates: - - data/semantic_memory.db → memory.db (chunks table) - - data/swarm.db → memory.db (memory_entries → episodes table) - - data/brain.db → memory.db (facts table, if any rows exist) - -After migration the old DB files are moved to data/archive/. - -Usage: - python -m timmy.memory_migrate # dry-run (default) - python -m timmy.memory_migrate --apply # actually migrate -""" - -import json -import logging -import shutil -import sqlite3 -import sys -from pathlib import Path - -logger = logging.getLogger(__name__) - -PROJECT_ROOT = Path(__file__).parent.parent.parent -DATA_DIR = PROJECT_ROOT / "data" -ARCHIVE_DIR = DATA_DIR / "archive" -MEMORY_DB = DATA_DIR / "memory.db" - - -def _open(path: Path) -> sqlite3.Connection: - conn = sqlite3.connect(str(path)) - conn.row_factory = sqlite3.Row - return conn - - -def migrate_semantic_chunks(dry_run: bool = True) -> int: - """Copy chunks from semantic_memory.db → memory.db.""" - src = DATA_DIR / "semantic_memory.db" - if not src.exists(): - logger.info("semantic_memory.db not found — skipping") - return 0 - - src_conn = _open(src) - # Check if source table exists - has_table = src_conn.execute( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='chunks'" - ).fetchone()[0] - if not has_table: - src_conn.close() - return 0 - - rows = src_conn.execute("SELECT * FROM chunks").fetchall() - src_conn.close() - - if not rows: - logger.info("semantic_memory.db: no chunks to migrate") - return 0 - - if dry_run: - logger.info("[DRY RUN] Would migrate %d chunks from semantic_memory.db", len(rows)) - return len(rows) - - from timmy.memory.unified import get_connection - - dst = get_connection() - migrated = 0 - for r in rows: - try: - dst.execute( - "INSERT OR IGNORE INTO chunks (id, source, content, embedding, created_at, source_hash) " - "VALUES (?, ?, ?, ?, ?, ?)", - ( - r["id"], - r["source"], - r["content"], - r["embedding"], - r["created_at"], - r["source_hash"], - ), - ) - migrated += 1 - except Exception as exc: - logger.warning("Chunk migration error: %s", exc) - dst.commit() - dst.close() - logger.info("Migrated %d chunks from semantic_memory.db", migrated) - return migrated - - -def migrate_memory_entries(dry_run: bool = True) -> int: - """Copy memory_entries from swarm.db → memory.db episodes table.""" - src = DATA_DIR / "swarm.db" - if not src.exists(): - logger.info("swarm.db not found — skipping") - return 0 - - src_conn = _open(src) - has_table = src_conn.execute( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_entries'" - ).fetchone()[0] - if not has_table: - src_conn.close() - return 0 - - rows = src_conn.execute("SELECT * FROM memory_entries").fetchall() - src_conn.close() - - if not rows: - logger.info("swarm.db: no memory_entries to migrate") - return 0 - - if dry_run: - logger.info("[DRY RUN] Would migrate %d memory_entries from swarm.db → episodes", len(rows)) - return len(rows) - - from timmy.memory.unified import get_connection - - dst = get_connection() - migrated = 0 - for r in rows: - try: - dst.execute( - "INSERT OR IGNORE INTO episodes " - "(id, content, source, context_type, embedding, metadata, agent_id, task_id, session_id, timestamp) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ( - r["id"], - r["content"], - r["source"], - r["context_type"], - r["embedding"], - r["metadata"], - r["agent_id"], - r["task_id"], - r["session_id"], - r["timestamp"], - ), - ) - migrated += 1 - except Exception as exc: - logger.warning("Episode migration error: %s", exc) - dst.commit() - dst.close() - logger.info("Migrated %d memory_entries → episodes", migrated) - return migrated - - -def migrate_brain_facts(dry_run: bool = True) -> int: - """Copy facts from brain.db → memory.db facts table.""" - src = DATA_DIR / "brain.db" - if not src.exists(): - logger.info("brain.db not found — skipping") - return 0 - - src_conn = _open(src) - has_table = src_conn.execute( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='facts'" - ).fetchone()[0] - if not has_table: - # Try 'memories' table (brain.db sometimes uses this name) - has_memories = src_conn.execute( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memories'" - ).fetchone()[0] - if not has_memories: - src_conn.close() - return 0 - - rows = src_conn.execute("SELECT * FROM memories").fetchall() - src_conn.close() - - if not rows: - return 0 - if dry_run: - logger.info("[DRY RUN] Would migrate %d brain memories → facts", len(rows)) - return len(rows) - - from timmy.memory.unified import get_connection - - dst = get_connection() - from datetime import UTC, datetime - - migrated = 0 - for r in rows: - try: - dst.execute( - "INSERT OR IGNORE INTO facts " - "(id, category, content, confidence, source, tags, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - ( - r["id"], - "brain", - r.get("content", r.get("text", "")), - 0.7, - "brain", - "[]", - r.get("created_at", datetime.now(UTC).isoformat()), - ), - ) - migrated += 1 - except Exception as exc: - logger.warning("Brain fact migration error: %s", exc) - dst.commit() - dst.close() - return migrated - - rows = src_conn.execute("SELECT * FROM facts").fetchall() - src_conn.close() - - if not rows: - logger.info("brain.db: no facts to migrate") - return 0 - - if dry_run: - logger.info("[DRY RUN] Would migrate %d facts from brain.db", len(rows)) - return len(rows) - - from timmy.memory.unified import get_connection - - dst = get_connection() - migrated = 0 - for r in rows: - try: - dst.execute( - "INSERT OR IGNORE INTO facts " - "(id, category, content, confidence, source, tags, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - ( - r["id"], - r.get("category", "brain"), - r["content"], - r.get("confidence", 0.7), - "brain", - json.dumps(r.get("tags", [])) if isinstance(r.get("tags"), list) else "[]", - r.get("created_at", ""), - ), - ) - migrated += 1 - except Exception as exc: - logger.warning("Fact migration error: %s", exc) - dst.commit() - dst.close() - logger.info("Migrated %d facts from brain.db", migrated) - return migrated - - -def archive_old_dbs(dry_run: bool = True) -> list[str]: - """Move old database files to data/archive/.""" - old_dbs = ["semantic_memory.db", "brain.db"] - archived = [] - - for name in old_dbs: - src = DATA_DIR / name - if not src.exists(): - continue - if dry_run: - logger.info("[DRY RUN] Would archive %s → data/archive/%s", name, name) - archived.append(name) - else: - ARCHIVE_DIR.mkdir(parents=True, exist_ok=True) - dst = ARCHIVE_DIR / name - shutil.move(str(src), str(dst)) - logger.info("Archived %s → data/archive/%s", name, name) - archived.append(name) - - return archived - - -def run_migration(dry_run: bool = True) -> dict: - """Run the full migration pipeline.""" - results = { - "chunks": migrate_semantic_chunks(dry_run), - "episodes": migrate_memory_entries(dry_run), - "facts": migrate_brain_facts(dry_run), - "archived": archive_old_dbs(dry_run), - "dry_run": dry_run, - } - total = results["chunks"] + results["episodes"] + results["facts"] - mode = "DRY RUN" if dry_run else "APPLIED" - logger.info("[%s] Migration complete: %d total records", mode, total) - return results - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - apply = "--apply" in sys.argv - results = run_migration(dry_run=not apply) - - print(f"\n{'=' * 50}") - print(f"Migration {'APPLIED' if apply else 'DRY RUN'}:") - print(f" Chunks migrated: {results['chunks']}") - print(f" Episodes migrated: {results['episodes']}") - print(f" Facts migrated: {results['facts']}") - print(f" Archived DBs: {results['archived']}") - if not apply: - print("\nRun with --apply to execute the migration.") - print(f"{'=' * 50}") diff --git a/tests/brain/__init__.py b/tests/brain/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/tests/brain/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/brain/test_brain_client.py b/tests/brain/test_brain_client.py deleted file mode 100644 index fcf8dbb..0000000 --- a/tests/brain/test_brain_client.py +++ /dev/null @@ -1,292 +0,0 @@ -"""Tests for brain.client — BrainClient memory + task operations.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from brain.client import DEFAULT_RQLITE_URL, BrainClient - - -class TestBrainClientInit: - """Test BrainClient initialization.""" - - def test_default_url(self): - client = BrainClient() - assert client.rqlite_url == DEFAULT_RQLITE_URL - - def test_custom_url(self): - client = BrainClient(rqlite_url="http://custom:4001") - assert client.rqlite_url == "http://custom:4001" - - def test_node_id_generated(self): - client = BrainClient() - assert client.node_id # not empty - - def test_custom_node_id(self): - client = BrainClient(node_id="my-node") - assert client.node_id == "my-node" - - def test_source_detection(self): - client = BrainClient() - assert isinstance(client.source, str) - - -class TestBrainClientMemory: - """Test memory operations (remember, recall, get_recent, get_context).""" - - def _make_client(self): - return BrainClient(rqlite_url="http://test:4001", node_id="test-node") - - async def test_remember_success(self): - client = self._make_client() - mock_response = MagicMock() - mock_response.json.return_value = {"results": [{"last_insert_id": 42}]} - mock_response.raise_for_status = MagicMock() - client._client = MagicMock() - client._client.post = AsyncMock(return_value=mock_response) - - with patch("brain.client.BrainClient._detect_source", return_value="test"): - with patch("brain.embeddings.get_embedder") as mock_emb: - mock_embedder = MagicMock() - mock_embedder.encode_single.return_value = b"\x00" * 16 - mock_emb.return_value = mock_embedder - - result = await client.remember("test memory", tags=["test"]) - assert result["id"] == 42 - assert result["status"] == "stored" - - async def test_remember_failure_raises(self): - client = self._make_client() - client._client = MagicMock() - client._client.post = AsyncMock(side_effect=Exception("connection refused")) - - with patch("brain.embeddings.get_embedder") as mock_emb: - mock_embedder = MagicMock() - mock_embedder.encode_single.return_value = b"\x00" * 16 - mock_emb.return_value = mock_embedder - - with pytest.raises(Exception, match="connection refused"): - await client.remember("fail") - - async def test_recall_success(self): - client = self._make_client() - mock_response = MagicMock() - mock_response.json.return_value = { - "results": [ - { - "rows": [ - ["memory content", "test", '{"key": "val"}', 0.1], - ] - } - ] - } - mock_response.raise_for_status = MagicMock() - client._client = MagicMock() - client._client.post = AsyncMock(return_value=mock_response) - - with patch("brain.embeddings.get_embedder") as mock_emb: - mock_embedder = MagicMock() - mock_embedder.encode_single.return_value = b"\x00" * 16 - mock_emb.return_value = mock_embedder - - results = await client.recall("search query") - assert len(results) == 1 - assert results[0]["content"] == "memory content" - assert results[0]["metadata"] == {"key": "val"} - - async def test_recall_with_source_filter(self): - client = self._make_client() - mock_response = MagicMock() - mock_response.json.return_value = {"results": [{"rows": []}]} - mock_response.raise_for_status = MagicMock() - client._client = MagicMock() - client._client.post = AsyncMock(return_value=mock_response) - - with patch("brain.embeddings.get_embedder") as mock_emb: - mock_embedder = MagicMock() - mock_embedder.encode_single.return_value = b"\x00" * 16 - mock_emb.return_value = mock_embedder - - results = await client.recall("test", sources=["timmy", "user"]) - assert results == [] - # Check that sources were passed in the SQL - call_args = client._client.post.call_args - sql_params = call_args[1]["json"] - assert "timmy" in sql_params[1] or "timmy" in str(sql_params) - - async def test_recall_error_returns_empty(self): - client = self._make_client() - client._client = MagicMock() - client._client.post = AsyncMock(side_effect=Exception("timeout")) - - with patch("brain.embeddings.get_embedder") as mock_emb: - mock_embedder = MagicMock() - mock_embedder.encode_single.return_value = b"\x00" * 16 - mock_emb.return_value = mock_embedder - - results = await client.recall("test") - assert results == [] - - async def test_get_recent_success(self): - client = self._make_client() - mock_response = MagicMock() - mock_response.json.return_value = { - "results": [ - { - "rows": [ - [1, "recent memory", "test", '["tag1"]', "{}", "2026-03-06T00:00:00"], - ] - } - ] - } - mock_response.raise_for_status = MagicMock() - client._client = MagicMock() - client._client.post = AsyncMock(return_value=mock_response) - - memories = await client.get_recent(hours=24, limit=10) - assert len(memories) == 1 - assert memories[0]["content"] == "recent memory" - assert memories[0]["tags"] == ["tag1"] - - async def test_get_recent_error_returns_empty(self): - client = self._make_client() - client._client = MagicMock() - client._client.post = AsyncMock(side_effect=Exception("db error")) - - result = await client.get_recent() - assert result == [] - - async def test_get_context(self): - client = self._make_client() - client.get_recent = AsyncMock( - return_value=[ - {"content": "Recent item 1"}, - {"content": "Recent item 2"}, - ] - ) - client.recall = AsyncMock( - return_value=[ - {"content": "Relevant item 1"}, - ] - ) - - ctx = await client.get_context("test query") - assert "Recent activity:" in ctx - assert "Recent item 1" in ctx - assert "Relevant memories:" in ctx - assert "Relevant item 1" in ctx - - -class TestBrainClientTasks: - """Test task queue operations.""" - - def _make_client(self): - return BrainClient(rqlite_url="http://test:4001", node_id="test-node") - - async def test_submit_task(self): - client = self._make_client() - mock_response = MagicMock() - mock_response.json.return_value = {"results": [{"last_insert_id": 7}]} - mock_response.raise_for_status = MagicMock() - client._client = MagicMock() - client._client.post = AsyncMock(return_value=mock_response) - - result = await client.submit_task("do something", task_type="shell") - assert result["id"] == 7 - assert result["status"] == "queued" - - async def test_submit_task_failure_raises(self): - client = self._make_client() - client._client = MagicMock() - client._client.post = AsyncMock(side_effect=Exception("network error")) - - with pytest.raises(Exception, match="network error"): - await client.submit_task("fail task") - - async def test_claim_task_found(self): - client = self._make_client() - mock_response = MagicMock() - mock_response.json.return_value = { - "results": [{"rows": [[1, "task content", "shell", 5, '{"key": "val"}']]}] - } - mock_response.raise_for_status = MagicMock() - client._client = MagicMock() - client._client.post = AsyncMock(return_value=mock_response) - - task = await client.claim_task(["shell", "general"]) - assert task is not None - assert task["id"] == 1 - assert task["content"] == "task content" - assert task["metadata"] == {"key": "val"} - - async def test_claim_task_none_available(self): - client = self._make_client() - mock_response = MagicMock() - mock_response.json.return_value = {"results": [{"rows": []}]} - mock_response.raise_for_status = MagicMock() - client._client = MagicMock() - client._client.post = AsyncMock(return_value=mock_response) - - task = await client.claim_task(["shell"]) - assert task is None - - async def test_claim_task_error_returns_none(self): - client = self._make_client() - client._client = MagicMock() - client._client.post = AsyncMock(side_effect=Exception("raft error")) - - task = await client.claim_task(["general"]) - assert task is None - - async def test_complete_task(self): - client = self._make_client() - client._client = MagicMock() - client._client.post = AsyncMock() - - # Should not raise - await client.complete_task(1, success=True, result="done") - client._client.post.assert_awaited_once() - - async def test_complete_task_failure(self): - client = self._make_client() - client._client = MagicMock() - client._client.post = AsyncMock() - - await client.complete_task(1, success=False, error="oops") - client._client.post.assert_awaited_once() - - async def test_get_pending_tasks(self): - client = self._make_client() - mock_response = MagicMock() - mock_response.json.return_value = { - "results": [ - { - "rows": [ - [1, "task 1", "general", 0, "{}", "2026-03-06"], - [2, "task 2", "shell", 5, "{}", "2026-03-06"], - ] - } - ] - } - mock_response.raise_for_status = MagicMock() - client._client = MagicMock() - client._client.post = AsyncMock(return_value=mock_response) - - tasks = await client.get_pending_tasks() - assert len(tasks) == 2 - - async def test_get_pending_tasks_error(self): - client = self._make_client() - client._client = MagicMock() - client._client.post = AsyncMock(side_effect=Exception("fail")) - - result = await client.get_pending_tasks() - assert result == [] - - async def test_close(self): - client = self._make_client() - client._client = MagicMock() - client._client.aclose = AsyncMock() - - await client.close() - client._client.aclose.assert_awaited_once() diff --git a/tests/brain/test_brain_worker.py b/tests/brain/test_brain_worker.py deleted file mode 100644 index 3ce84c6..0000000 --- a/tests/brain/test_brain_worker.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Tests for brain.worker — DistributedWorker capability detection + task execution.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from brain.worker import DistributedWorker - - -class TestWorkerInit: - """Test worker initialization and capability detection.""" - - @patch("brain.worker.DistributedWorker._detect_capabilities") - def test_init_defaults(self, mock_caps): - mock_caps.return_value = ["general"] - worker = DistributedWorker() - assert worker.running is False - assert worker.node_id # non-empty - assert "general" in worker.capabilities - - @patch("brain.worker.DistributedWorker._detect_capabilities") - def test_custom_brain_client(self, mock_caps): - mock_caps.return_value = ["general"] - mock_client = MagicMock() - worker = DistributedWorker(brain_client=mock_client) - assert worker.brain is mock_client - - @patch("brain.worker.DistributedWorker._detect_capabilities") - def test_default_handlers_registered(self, mock_caps): - mock_caps.return_value = ["general"] - worker = DistributedWorker() - assert "shell" in worker._handlers - assert "creative" in worker._handlers - assert "code" in worker._handlers - assert "research" in worker._handlers - assert "general" in worker._handlers - - -class TestCapabilityDetection: - """Test individual capability detection methods.""" - - @patch("brain.worker.DistributedWorker._detect_capabilities", return_value=["general"]) - def _make_worker(self, mock_caps): - return DistributedWorker() - - @patch("brain.worker.subprocess.run") - def test_has_gpu_nvidia(self, mock_run): - worker = self._make_worker() - mock_run.return_value = MagicMock(returncode=0) - assert worker._has_gpu() is True - - @patch("brain.worker.subprocess.run", side_effect=OSError("no nvidia-smi")) - @patch("brain.worker.os.path.exists", return_value=False) - @patch("brain.worker.os.uname") - def test_has_gpu_no_gpu(self, mock_uname, mock_exists, mock_run): - worker = self._make_worker() - mock_uname.return_value = MagicMock(sysname="Linux") - assert worker._has_gpu() is False - - @patch("brain.worker.subprocess.run") - def test_has_internet_true(self, mock_run): - worker = self._make_worker() - mock_run.return_value = MagicMock(returncode=0) - assert worker._has_internet() is True - - @patch("brain.worker.subprocess.run", side_effect=OSError("no curl")) - def test_has_internet_no_curl(self, mock_run): - worker = self._make_worker() - assert worker._has_internet() is False - - @patch("brain.worker.subprocess.run") - def test_has_command_true(self, mock_run): - worker = self._make_worker() - mock_run.return_value = MagicMock(returncode=0) - assert worker._has_command("docker") is True - - @patch("brain.worker.subprocess.run") - def test_has_command_false(self, mock_run): - worker = self._make_worker() - mock_run.return_value = MagicMock(returncode=1) - assert worker._has_command("nonexistent") is False - - @patch("brain.worker.subprocess.run", side_effect=OSError) - def test_has_command_oserror(self, mock_run): - worker = self._make_worker() - assert worker._has_command("anything") is False - - -class TestRegisterHandler: - """Test custom handler registration.""" - - @patch("brain.worker.DistributedWorker._detect_capabilities", return_value=["general"]) - def test_register_adds_handler_and_capability(self, mock_caps): - worker = DistributedWorker() - - async def custom_handler(content): - return "custom result" - - worker.register_handler("custom_type", custom_handler) - assert "custom_type" in worker._handlers - assert "custom_type" in worker.capabilities - - -class TestTaskHandlers: - """Test individual task handlers.""" - - @patch("brain.worker.DistributedWorker._detect_capabilities", return_value=["general"]) - def _make_worker(self, mock_caps): - worker = DistributedWorker() - worker.brain = MagicMock() - worker.brain.remember = AsyncMock() - worker.brain.complete_task = AsyncMock() - return worker - - async def test_handle_code(self): - worker = self._make_worker() - result = await worker._handle_code("write a function") - assert "write a function" in result - - async def test_handle_research_no_internet(self): - worker = self._make_worker() - worker.capabilities = ["general"] # no "web" - with pytest.raises(Exception, match="Internet not available"): - await worker._handle_research("search query") - - async def test_handle_creative_no_gpu(self): - worker = self._make_worker() - worker.capabilities = ["general"] # no "gpu" - with pytest.raises(Exception, match="GPU not available"): - await worker._handle_creative("make an image") - - async def test_handle_general_no_ollama(self): - worker = self._make_worker() - worker.capabilities = ["general"] # but not "ollama" - # Remove "ollama" if present - if "ollama" in worker.capabilities: - worker.capabilities.remove("ollama") - with pytest.raises(Exception, match="Ollama not available"): - await worker._handle_general("answer this") - - -class TestExecuteTask: - """Test execute_task orchestration.""" - - @patch("brain.worker.DistributedWorker._detect_capabilities", return_value=["general"]) - def _make_worker(self, mock_caps): - worker = DistributedWorker() - worker.brain = MagicMock() - worker.brain.complete_task = AsyncMock() - return worker - - async def test_execute_task_success(self): - worker = self._make_worker() - - async def fake_handler(content): - return "result" - - worker._handlers["test_type"] = fake_handler - - result = await worker.execute_task( - { - "id": 1, - "type": "test_type", - "content": "do it", - } - ) - assert result["success"] is True - assert result["result"] == "result" - worker.brain.complete_task.assert_awaited_once_with(1, success=True, result="result") - - async def test_execute_task_failure(self): - worker = self._make_worker() - - async def failing_handler(content): - raise RuntimeError("oops") - - worker._handlers["fail_type"] = failing_handler - - result = await worker.execute_task( - { - "id": 2, - "type": "fail_type", - "content": "fail", - } - ) - assert result["success"] is False - assert "oops" in result["error"] - worker.brain.complete_task.assert_awaited_once() - - async def test_execute_task_falls_back_to_general(self): - worker = self._make_worker() - - async def general_handler(content): - return "general result" - - worker._handlers["general"] = general_handler - - result = await worker.execute_task( - { - "id": 3, - "type": "unknown_type", - "content": "something", - } - ) - assert result["success"] is True - assert result["result"] == "general result" - - -class TestRunOnce: - """Test run_once loop iteration.""" - - @patch("brain.worker.DistributedWorker._detect_capabilities", return_value=["general"]) - def _make_worker(self, mock_caps): - worker = DistributedWorker() - worker.brain = MagicMock() - worker.brain.claim_task = AsyncMock() - worker.brain.complete_task = AsyncMock() - return worker - - async def test_run_once_no_tasks(self): - worker = self._make_worker() - worker.brain.claim_task.return_value = None - - had_work = await worker.run_once() - assert had_work is False - - async def test_run_once_with_task(self): - worker = self._make_worker() - worker.brain.claim_task.return_value = {"id": 1, "type": "code", "content": "write code"} - - had_work = await worker.run_once() - assert had_work is True - - -class TestStopWorker: - """Test stop method.""" - - @patch("brain.worker.DistributedWorker._detect_capabilities", return_value=["general"]) - def test_stop_sets_running_false(self, mock_caps): - worker = DistributedWorker() - worker.running = True - worker.stop() - assert worker.running is False diff --git a/tests/brain/test_unified_memory.py b/tests/brain/test_unified_memory.py deleted file mode 100644 index df3bd5d..0000000 --- a/tests/brain/test_unified_memory.py +++ /dev/null @@ -1,416 +0,0 @@ -"""Tests for brain.memory — Unified Memory interface. - -Tests the local SQLite backend (default). rqlite tests are integration-only. - -TDD: These tests define the contract that UnifiedMemory must fulfill. -Any substrate that reads/writes memory goes through this interface. -""" - -from __future__ import annotations - -import json - -import pytest - -from brain.memory import UnifiedMemory, get_memory - - -@pytest.fixture -def memory(tmp_path): - """Create a UnifiedMemory instance with a temp database.""" - db_path = tmp_path / "test_brain.db" - return UnifiedMemory(db_path=db_path, source="test", use_rqlite=False) - - -# ── Initialization ──────────────────────────────────────────────────────────── - - -class TestUnifiedMemoryInit: - """Validate database initialization and schema.""" - - def test_creates_database_file(self, tmp_path): - """Database file should be created on init.""" - db_path = tmp_path / "test.db" - assert not db_path.exists() - UnifiedMemory(db_path=db_path, use_rqlite=False) - assert db_path.exists() - - def test_creates_parent_directories(self, tmp_path): - """Should create parent dirs if they don't exist.""" - db_path = tmp_path / "deep" / "nested" / "brain.db" - UnifiedMemory(db_path=db_path, use_rqlite=False) - assert db_path.exists() - - def test_schema_has_memories_table(self, memory): - """Schema should include memories table.""" - conn = memory._get_conn() - try: - cursor = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='memories'" - ) - assert cursor.fetchone() is not None - finally: - conn.close() - - def test_schema_has_facts_table(self, memory): - """Schema should include facts table.""" - conn = memory._get_conn() - try: - cursor = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='facts'" - ) - assert cursor.fetchone() is not None - finally: - conn.close() - - def test_schema_version_recorded(self, memory): - """Schema version should be recorded.""" - conn = memory._get_conn() - try: - cursor = conn.execute("SELECT version FROM brain_schema_version") - row = cursor.fetchone() - assert row is not None - assert row["version"] == 1 - finally: - conn.close() - - def test_idempotent_init(self, tmp_path): - """Initializing twice on the same DB should not error.""" - db_path = tmp_path / "test.db" - m1 = UnifiedMemory(db_path=db_path, use_rqlite=False) - m1.remember_sync("first memory") - m2 = UnifiedMemory(db_path=db_path, use_rqlite=False) - # Should not lose data - results = m2.recall_sync("first") - assert len(results) >= 1 - - def test_wal_mode_enabled(self, memory): - """Database should use WAL journal mode for concurrency.""" - conn = memory._get_conn() - try: - mode = conn.execute("PRAGMA journal_mode").fetchone()[0] - assert mode == "wal", f"Expected WAL mode, got {mode}" - finally: - conn.close() - - def test_busy_timeout_set(self, memory): - """Database connections should have busy_timeout configured.""" - conn = memory._get_conn() - try: - timeout = conn.execute("PRAGMA busy_timeout").fetchone()[0] - assert timeout == 5000, f"Expected 5000ms busy_timeout, got {timeout}" - finally: - conn.close() - - -# ── Remember (Sync) ────────────────────────────────────────────────────────── - - -class TestRememberSync: - """Test synchronous memory storage.""" - - def test_remember_returns_id(self, memory): - """remember_sync should return dict with id and status.""" - result = memory.remember_sync("User prefers dark mode") - assert "id" in result - assert result["status"] == "stored" - assert result["id"] is not None - - def test_remember_stores_content(self, memory): - """Stored content should be retrievable.""" - memory.remember_sync("The sky is blue") - results = memory.recall_sync("sky") - assert len(results) >= 1 - assert "sky" in results[0]["content"].lower() - - def test_remember_with_tags(self, memory): - """Tags should be stored and retrievable.""" - memory.remember_sync("Dark mode enabled", tags=["preference", "ui"]) - conn = memory._get_conn() - try: - row = conn.execute( - "SELECT tags FROM memories WHERE content = ?", ("Dark mode enabled",) - ).fetchone() - tags = json.loads(row["tags"]) - assert "preference" in tags - assert "ui" in tags - finally: - conn.close() - - def test_remember_with_metadata(self, memory): - """Metadata should be stored as JSON.""" - memory.remember_sync("Test", metadata={"key": "value", "count": 42}) - conn = memory._get_conn() - try: - row = conn.execute("SELECT metadata FROM memories WHERE content = 'Test'").fetchone() - meta = json.loads(row["metadata"]) - assert meta["key"] == "value" - assert meta["count"] == 42 - finally: - conn.close() - - def test_remember_with_custom_source(self, memory): - """Source should default to self.source but be overridable.""" - memory.remember_sync("From timmy", source="timmy") - memory.remember_sync("From user", source="user") - conn = memory._get_conn() - try: - rows = conn.execute("SELECT source FROM memories ORDER BY id").fetchall() - sources = [r["source"] for r in rows] - assert "timmy" in sources - assert "user" in sources - finally: - conn.close() - - def test_remember_default_source(self, memory): - """Default source should be the one set at init.""" - memory.remember_sync("Default source test") - conn = memory._get_conn() - try: - row = conn.execute("SELECT source FROM memories").fetchone() - assert row["source"] == "test" # set in fixture - finally: - conn.close() - - def test_remember_multiple(self, memory): - """Multiple memories should be stored independently.""" - for i in range(5): - memory.remember_sync(f"Memory number {i}") - conn = memory._get_conn() - try: - count = conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0] - assert count == 5 - finally: - conn.close() - - -# ── Recall (Sync) ───────────────────────────────────────────────────────────── - - -class TestRecallSync: - """Test synchronous memory recall (keyword fallback).""" - - def test_recall_finds_matching(self, memory): - """Recall should find memories matching the query.""" - memory.remember_sync("Bitcoin price is rising") - memory.remember_sync("Weather is sunny today") - results = memory.recall_sync("Bitcoin") - assert len(results) >= 1 - assert "Bitcoin" in results[0]["content"] - - def test_recall_low_score_for_irrelevant(self, memory): - """Recall should return low scores for irrelevant queries. - - Note: Semantic search may still return results (embeddings always - have *some* similarity), but scores should be low for unrelated content. - Keyword fallback returns nothing if no substring match. - """ - memory.remember_sync("Bitcoin price is rising fast") - results = memory.recall_sync("underwater basket weaving") - if results: - # If semantic search returned something, score should be low - assert results[0]["score"] < 0.7, ( - f"Expected low score for irrelevant query, got {results[0]['score']}" - ) - - def test_recall_respects_limit(self, memory): - """Recall should respect the limit parameter.""" - for i in range(10): - memory.remember_sync(f"Bitcoin memory {i}") - results = memory.recall_sync("Bitcoin", limit=3) - assert len(results) <= 3 - - def test_recall_filters_by_source(self, memory): - """Recall should filter by source when specified.""" - memory.remember_sync("From timmy", source="timmy") - memory.remember_sync("From user about timmy", source="user") - results = memory.recall_sync("timmy", sources=["user"]) - assert all(r["source"] == "user" for r in results) - - def test_recall_returns_score(self, memory): - """Recall results should include a score.""" - memory.remember_sync("Test memory for scoring") - results = memory.recall_sync("Test") - assert len(results) >= 1 - assert "score" in results[0] - - -# ── Facts ───────────────────────────────────────────────────────────────────── - - -class TestFacts: - """Test long-term fact storage.""" - - def test_store_fact_returns_id(self, memory): - """store_fact_sync should return dict with id and status.""" - result = memory.store_fact_sync("user_preference", "Prefers dark mode") - assert "id" in result - assert result["status"] == "stored" - - def test_get_facts_by_category(self, memory): - """get_facts_sync should filter by category.""" - memory.store_fact_sync("user_preference", "Likes dark mode") - memory.store_fact_sync("user_fact", "Lives in Texas") - prefs = memory.get_facts_sync(category="user_preference") - assert len(prefs) == 1 - assert "dark mode" in prefs[0]["content"] - - def test_get_facts_by_query(self, memory): - """get_facts_sync should support keyword search.""" - memory.store_fact_sync("user_preference", "Likes dark mode") - memory.store_fact_sync("user_preference", "Prefers Bitcoin") - results = memory.get_facts_sync(query="Bitcoin") - assert len(results) == 1 - assert "Bitcoin" in results[0]["content"] - - def test_fact_access_count_increments(self, memory): - """Accessing a fact should increment its access_count.""" - memory.store_fact_sync("test_cat", "Test fact") - # First access — count starts at 0, then gets incremented - facts = memory.get_facts_sync(category="test_cat") - first_count = facts[0]["access_count"] - # Second access — count should be higher - facts = memory.get_facts_sync(category="test_cat") - second_count = facts[0]["access_count"] - assert second_count > first_count, ( - f"Access count should increment: {first_count} -> {second_count}" - ) - - def test_fact_confidence_ordering(self, memory): - """Facts should be ordered by confidence (highest first).""" - memory.store_fact_sync("cat", "Low confidence fact", confidence=0.3) - memory.store_fact_sync("cat", "High confidence fact", confidence=0.9) - facts = memory.get_facts_sync(category="cat") - assert facts[0]["confidence"] > facts[1]["confidence"] - - -# ── Recent Memories ─────────────────────────────────────────────────────────── - - -class TestRecentSync: - """Test recent memory retrieval.""" - - def test_get_recent_returns_recent(self, memory): - """get_recent_sync should return recently stored memories.""" - memory.remember_sync("Just happened") - results = memory.get_recent_sync(hours=1, limit=10) - assert len(results) >= 1 - assert "Just happened" in results[0]["content"] - - def test_get_recent_respects_limit(self, memory): - """get_recent_sync should respect limit.""" - for i in range(10): - memory.remember_sync(f"Recent {i}") - results = memory.get_recent_sync(hours=1, limit=3) - assert len(results) <= 3 - - def test_get_recent_filters_by_source(self, memory): - """get_recent_sync should filter by source.""" - memory.remember_sync("From timmy", source="timmy") - memory.remember_sync("From user", source="user") - results = memory.get_recent_sync(hours=1, sources=["timmy"]) - assert all(r["source"] == "timmy" for r in results) - - -# ── Stats ───────────────────────────────────────────────────────────────────── - - -class TestStats: - """Test memory statistics.""" - - def test_stats_returns_counts(self, memory): - """get_stats should return correct counts.""" - memory.remember_sync("Memory 1") - memory.remember_sync("Memory 2") - memory.store_fact_sync("cat", "Fact 1") - stats = memory.get_stats() - assert stats["memory_count"] == 2 - assert stats["fact_count"] == 1 - assert stats["backend"] == "local_sqlite" - - def test_stats_empty_db(self, memory): - """get_stats should work on empty database.""" - stats = memory.get_stats() - assert stats["memory_count"] == 0 - assert stats["fact_count"] == 0 - - -# ── Identity Integration ───────────────────────────────────────────────────── - - -class TestIdentityIntegration: - """Identity system removed — stubs return empty strings.""" - - def test_get_identity_returns_empty(self, memory): - assert memory.get_identity() == "" - - def test_get_identity_for_prompt_returns_empty(self, memory): - assert memory.get_identity_for_prompt() == "" - - -# ── Singleton ───────────────────────────────────────────────────────────────── - - -class TestSingleton: - """Test the module-level get_memory() singleton.""" - - def test_get_memory_returns_instance(self): - """get_memory() should return a UnifiedMemory instance.""" - import brain.memory as mem_module - - # Reset singleton for test isolation - mem_module._default_memory = None - m = get_memory() - assert isinstance(m, UnifiedMemory) - - def test_get_memory_returns_same_instance(self): - """get_memory() should return the same instance on repeated calls.""" - import brain.memory as mem_module - - mem_module._default_memory = None - m1 = get_memory() - m2 = get_memory() - assert m1 is m2 - - -# ── Async Interface ─────────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -class TestAsyncInterface: - """Test async wrappers (which delegate to sync for local SQLite).""" - - async def test_async_remember(self, memory): - """Async remember should work.""" - result = await memory.remember("Async memory test") - assert result["status"] == "stored" - - async def test_async_recall(self, memory): - """Async recall should work.""" - await memory.remember("Async recall target") - results = await memory.recall("Async recall") - assert len(results) >= 1 - - async def test_async_store_fact(self, memory): - """Async store_fact should work.""" - result = await memory.store_fact("test", "Async fact") - assert result["status"] == "stored" - - async def test_async_get_facts(self, memory): - """Async get_facts should work.""" - await memory.store_fact("test", "Async fact retrieval") - facts = await memory.get_facts(category="test") - assert len(facts) >= 1 - - async def test_async_get_recent(self, memory): - """Async get_recent should work.""" - await memory.remember("Recent async memory") - results = await memory.get_recent(hours=1) - assert len(results) >= 1 - - async def test_async_get_context(self, memory): - """Async get_context should return formatted context.""" - await memory.remember("Context test memory") - context = await memory.get_context("test") - assert isinstance(context, str) - assert len(context) > 0 diff --git a/tests/dashboard/test_paperclip_routes.py b/tests/dashboard/test_paperclip_routes.py deleted file mode 100644 index 8979fb7..0000000 --- a/tests/dashboard/test_paperclip_routes.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Tests for the Paperclip API routes.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -# ── GET /api/paperclip/status ──────────────────────────────────────────────── - - -def test_status_disabled(client): - """When paperclip_enabled is False, status returns disabled.""" - response = client.get("/api/paperclip/status") - assert response.status_code == 200 - data = response.json() - assert data["enabled"] is False - - -def test_status_enabled(client): - mock_status = MagicMock() - mock_status.model_dump.return_value = { - "enabled": True, - "connected": True, - "paperclip_url": "http://vps:3100", - "company_id": "comp-1", - "agent_count": 3, - "issue_count": 5, - "error": None, - } - mock_bridge = MagicMock() - mock_bridge.get_status = AsyncMock(return_value=mock_status) - with patch("dashboard.routes.paperclip.settings") as mock_settings: - mock_settings.paperclip_enabled = True - with patch.dict("sys.modules", {}): - with patch("integrations.paperclip.bridge.bridge", mock_bridge): - response = client.get("/api/paperclip/status") - assert response.status_code == 200 - assert response.json()["connected"] is True - - -# ── GET /api/paperclip/issues ──────────────────────────────────────────────── - - -def test_list_issues_disabled(client): - response = client.get("/api/paperclip/issues") - assert response.status_code == 200 - assert response.json()["enabled"] is False - - -# ── POST /api/paperclip/issues ─────────────────────────────────────────────── - - -def test_create_issue_disabled(client): - response = client.post( - "/api/paperclip/issues", - json={"title": "Test"}, - ) - assert response.status_code == 200 - assert response.json()["enabled"] is False - - -def test_create_issue_missing_title(client): - with patch("dashboard.routes.paperclip.settings") as mock_settings: - mock_settings.paperclip_enabled = True - response = client.post( - "/api/paperclip/issues", - json={"description": "No title"}, - ) - assert response.status_code == 400 - assert "title" in response.json()["error"] - - -# ── POST /api/paperclip/issues/{id}/delegate ───────────────────────────────── - - -def test_delegate_issue_missing_agent_id(client): - with patch("dashboard.routes.paperclip.settings") as mock_settings: - mock_settings.paperclip_enabled = True - response = client.post( - "/api/paperclip/issues/i1/delegate", - json={"message": "Do this"}, - ) - assert response.status_code == 400 - assert "agent_id" in response.json()["error"] - - -# ── POST /api/paperclip/issues/{id}/comment ────────────────────────────────── - - -def test_add_comment_missing_content(client): - with patch("dashboard.routes.paperclip.settings") as mock_settings: - mock_settings.paperclip_enabled = True - response = client.post( - "/api/paperclip/issues/i1/comment", - json={}, - ) - assert response.status_code == 400 - assert "content" in response.json()["error"] - - -# ── GET /api/paperclip/agents ──────────────────────────────────────────────── - - -def test_list_agents_disabled(client): - response = client.get("/api/paperclip/agents") - assert response.status_code == 200 - assert response.json()["enabled"] is False - - -# ── GET /api/paperclip/goals ───────────────────────────────────────────────── - - -def test_list_goals_disabled(client): - response = client.get("/api/paperclip/goals") - assert response.status_code == 200 - assert response.json()["enabled"] is False - - -# ── POST /api/paperclip/goals ──────────────────────────────────────────────── - - -def test_create_goal_missing_title(client): - with patch("dashboard.routes.paperclip.settings") as mock_settings: - mock_settings.paperclip_enabled = True - response = client.post( - "/api/paperclip/goals", - json={"description": "No title"}, - ) - assert response.status_code == 400 - assert "title" in response.json()["error"] - - -# ── GET /api/paperclip/approvals ───────────────────────────────────────────── - - -def test_list_approvals_disabled(client): - response = client.get("/api/paperclip/approvals") - assert response.status_code == 200 - assert response.json()["enabled"] is False - - -# ── GET /api/paperclip/runs ────────────────────────────────────────────────── - - -def test_list_runs_disabled(client): - response = client.get("/api/paperclip/runs") - assert response.status_code == 200 - assert response.json()["enabled"] is False diff --git a/tests/dashboard/test_round4_fixes.py b/tests/dashboard/test_round4_fixes.py index e21b338..0de1636 100644 --- a/tests/dashboard/test_round4_fixes.py +++ b/tests/dashboard/test_round4_fixes.py @@ -94,23 +94,7 @@ def test_creative_page_returns_200(client): # --------------------------------------------------------------------------- -def test_swarm_live_page_returns_200(client): - """GET /swarm/live renders the live dashboard page.""" - response = client.get("/swarm/live") - assert response.status_code == 200 - - -def test_swarm_live_websocket_sends_initial_state(client): - """WebSocket at /swarm/live sends initial_state on connect.""" - - with client.websocket_connect("/swarm/live") as ws: - data = ws.receive_json() - # First message should be initial_state with swarm data - assert data.get("type") == "initial_state", f"Unexpected WS message: {data}" - payload = data.get("data", {}) - assert "agents" in payload - assert "tasks" in payload - assert "auctions" in payload +# Swarm live page tests removed — swarm module deleted. # --------------------------------------------------------------------------- @@ -262,9 +246,6 @@ def test_all_dashboard_pages_return_200(client): "/tasks", "/briefing", "/thinking", - "/swarm/mission-control", - "/swarm/live", - "/swarm/events", "/bugs", "/tools", "/lightning/ledger", diff --git a/tests/infrastructure/test_swarm_event_log.py b/tests/infrastructure/test_swarm_event_log.py deleted file mode 100644 index 7222b59..0000000 --- a/tests/infrastructure/test_swarm_event_log.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Tests for swarm.event_log — WAL mode, basic operations, and EventBus bridge.""" - -import pytest - -from swarm.event_log import EventType, _ensure_db, log_event - - -@pytest.fixture(autouse=True) -def tmp_event_db(tmp_path, monkeypatch): - """Redirect event_log writes to a temp directory.""" - db_path = tmp_path / "events.db" - monkeypatch.setattr("swarm.event_log.DB_PATH", db_path) - yield db_path - - -class TestEventLogWAL: - """Verify WAL mode is enabled for the event log database.""" - - def test_event_db_uses_wal(self): - conn = _ensure_db() - try: - mode = conn.execute("PRAGMA journal_mode").fetchone()[0] - assert mode == "wal", f"Expected WAL mode, got {mode}" - finally: - conn.close() - - def test_event_db_busy_timeout(self): - conn = _ensure_db() - try: - timeout = conn.execute("PRAGMA busy_timeout").fetchone()[0] - assert timeout == 5000 - finally: - conn.close() - - -class TestEventLogBasics: - """Basic event logging operations.""" - - def test_log_event_returns_entry(self): - entry = log_event(EventType.SYSTEM_INFO, source="test", data={"msg": "hello"}) - assert entry.id - assert entry.event_type == EventType.SYSTEM_INFO - assert entry.source == "test" - - def test_log_event_persists(self): - log_event(EventType.TASK_CREATED, source="test", task_id="t1") - from swarm.event_log import get_task_events - - events = get_task_events("t1") - assert len(events) == 1 - assert events[0].event_type == EventType.TASK_CREATED - - def test_log_event_with_agent_id(self): - entry = log_event( - EventType.AGENT_JOINED, - source="test", - agent_id="forge", - data={"persona_id": "forge"}, - ) - assert entry.agent_id == "forge" - - def test_log_event_data_roundtrip(self): - data = {"bid_sats": 42, "reason": "testing"} - entry = log_event(EventType.BID_SUBMITTED, source="test", data=data) - assert entry.data["bid_sats"] == 42 - assert entry.data["reason"] == "testing" diff --git a/tests/integrations/test_paperclip_bridge.py b/tests/integrations/test_paperclip_bridge.py deleted file mode 100644 index c64d891..0000000 --- a/tests/integrations/test_paperclip_bridge.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Tests for the Paperclip bridge (CEO orchestration logic).""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from integrations.paperclip.bridge import PaperclipBridge -from integrations.paperclip.client import PaperclipClient -from integrations.paperclip.models import PaperclipAgent, PaperclipGoal, PaperclipIssue - - -@pytest.fixture -def mock_client(): - client = MagicMock(spec=PaperclipClient) - # Make all methods async - client.healthy = AsyncMock(return_value=True) - client.list_agents = AsyncMock(return_value=[]) - client.list_issues = AsyncMock(return_value=[]) - client.list_goals = AsyncMock(return_value=[]) - client.list_approvals = AsyncMock(return_value=[]) - client.list_heartbeat_runs = AsyncMock(return_value=[]) - client.get_issue = AsyncMock(return_value=None) - client.get_org = AsyncMock(return_value=None) - client.create_issue = AsyncMock(return_value=None) - client.update_issue = AsyncMock(return_value=None) - client.add_comment = AsyncMock(return_value=None) - client.wake_agent = AsyncMock(return_value=None) - client.create_goal = AsyncMock(return_value=None) - client.approve = AsyncMock(return_value=None) - client.reject = AsyncMock(return_value=None) - client.cancel_run = AsyncMock(return_value=None) - client.list_comments = AsyncMock(return_value=[]) - return client - - -@pytest.fixture -def bridge(mock_client): - return PaperclipBridge(client=mock_client) - - -# ── status ─────────────────────────────────────────────────────────────────── - - -async def test_status_when_disabled(bridge): - with patch("integrations.paperclip.bridge.settings") as mock_settings: - mock_settings.paperclip_enabled = False - mock_settings.paperclip_url = "http://localhost:3100" - status = await bridge.get_status() - assert status.enabled is False - - -async def test_status_when_connected(bridge, mock_client): - mock_client.healthy.return_value = True - mock_client.list_agents.return_value = [ - PaperclipAgent(id="a1", name="Codex"), - ] - mock_client.list_issues.return_value = [ - PaperclipIssue(id="i1", title="Bug"), - PaperclipIssue(id="i2", title="Feature"), - ] - - with patch("integrations.paperclip.bridge.settings") as mock_settings: - mock_settings.paperclip_enabled = True - mock_settings.paperclip_url = "http://vps:3100" - mock_settings.paperclip_company_id = "comp-1" - status = await bridge.get_status() - - assert status.enabled is True - assert status.connected is True - assert status.agent_count == 1 - assert status.issue_count == 2 - - -async def test_status_when_disconnected(bridge, mock_client): - mock_client.healthy.return_value = False - - with patch("integrations.paperclip.bridge.settings") as mock_settings: - mock_settings.paperclip_enabled = True - mock_settings.paperclip_url = "http://vps:3100" - mock_settings.paperclip_company_id = "comp-1" - status = await bridge.get_status() - - assert status.enabled is True - assert status.connected is False - assert "Cannot reach" in status.error - - -# ── create and assign ──────────────────────────────────────────────────────── - - -async def test_create_and_assign_with_wake(bridge, mock_client): - issue = PaperclipIssue(id="i1", title="Deploy v2") - mock_client.create_issue.return_value = issue - mock_client.wake_agent.return_value = {"status": "queued"} - - result = await bridge.create_and_assign( - title="Deploy v2", - assignee_id="agent-codex", - wake=True, - ) - - assert result is not None - assert result.id == "i1" - mock_client.wake_agent.assert_awaited_once_with("agent-codex", issue_id="i1") - - -async def test_create_and_assign_no_wake(bridge, mock_client): - issue = PaperclipIssue(id="i2", title="Research task") - mock_client.create_issue.return_value = issue - - result = await bridge.create_and_assign( - title="Research task", - assignee_id="agent-research", - wake=False, - ) - - assert result is not None - mock_client.wake_agent.assert_not_awaited() - - -async def test_create_and_assign_failure(bridge, mock_client): - mock_client.create_issue.return_value = None - - result = await bridge.create_and_assign(title="Will fail") - assert result is None - - -# ── delegate ───────────────────────────────────────────────────────────────── - - -async def test_delegate_issue(bridge, mock_client): - mock_client.update_issue.return_value = PaperclipIssue(id="i1", title="Task") - mock_client.wake_agent.return_value = {"status": "queued"} - - ok = await bridge.delegate_issue("i1", "agent-codex", message="Handle this") - assert ok is True - mock_client.add_comment.assert_awaited_once() - mock_client.wake_agent.assert_awaited_once() - - -async def test_delegate_issue_update_fails(bridge, mock_client): - mock_client.update_issue.return_value = None - - ok = await bridge.delegate_issue("i1", "agent-codex") - assert ok is False - - -# ── close issue ────────────────────────────────────────────────────────────── - - -async def test_close_issue(bridge, mock_client): - mock_client.update_issue.return_value = PaperclipIssue(id="i1", title="Done") - - ok = await bridge.close_issue("i1", comment="Shipped!") - assert ok is True - mock_client.add_comment.assert_awaited_once() - - -# ── goals ──────────────────────────────────────────────────────────────────── - - -async def test_set_goal(bridge, mock_client): - mock_client.create_goal.return_value = PaperclipGoal(id="g1", title="Ship MVP") - - goal = await bridge.set_goal("Ship MVP") - assert goal is not None - assert goal.title == "Ship MVP" - - -# ── approvals ──────────────────────────────────────────────────────────────── - - -async def test_approve(bridge, mock_client): - mock_client.approve.return_value = {"status": "approved"} - ok = await bridge.approve("ap1") - assert ok is True - - -async def test_reject(bridge, mock_client): - mock_client.reject.return_value = {"status": "rejected"} - ok = await bridge.reject("ap1", comment="Needs work") - assert ok is True - - -async def test_approve_failure(bridge, mock_client): - mock_client.approve.return_value = None - ok = await bridge.approve("ap1") - assert ok is False - - -# ── runs ───────────────────────────────────────────────────────────────────── - - -async def test_active_runs(bridge, mock_client): - mock_client.list_heartbeat_runs.return_value = [ - {"id": "r1", "status": "running"}, - ] - runs = await bridge.active_runs() - assert len(runs) == 1 - - -async def test_cancel_run(bridge, mock_client): - mock_client.cancel_run.return_value = {"status": "cancelled"} - ok = await bridge.cancel_run("r1") - assert ok is True diff --git a/tests/integrations/test_paperclip_client.py b/tests/integrations/test_paperclip_client.py deleted file mode 100644 index 5acd298..0000000 --- a/tests/integrations/test_paperclip_client.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Tests for the Paperclip API client. - -Uses httpx.MockTransport so every test exercises the real HTTP path -(_get/_post/_delete, status-code handling, JSON parsing, error paths) -instead of patching the transport methods away. -""" - -import json -from unittest.mock import patch - -import httpx - -from integrations.paperclip.client import PaperclipClient -from integrations.paperclip.models import CreateIssueRequest - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _mock_transport(routes: dict[str, tuple[int, dict | list | None]]): - """Build an httpx.MockTransport from a {method+path: (status, body)} map. - - Example: - _mock_transport({ - "GET /api/health": (200, {"status": "ok"}), - "DELETE /api/issues/i1": (204, None), - }) - """ - - def handler(request: httpx.Request) -> httpx.Response: - key = f"{request.method} {request.url.path}" - if key in routes: - status, body = routes[key] - content = json.dumps(body).encode() if body is not None else b"" - return httpx.Response( - status, content=content, headers={"content-type": "application/json"} - ) - return httpx.Response(404, json={"error": "not found"}) - - return httpx.MockTransport(handler) - - -def _client_with(routes: dict[str, tuple[int, dict | list | None]]) -> PaperclipClient: - """Create a PaperclipClient whose internal httpx.AsyncClient uses a mock transport.""" - client = PaperclipClient(base_url="http://fake:3100", api_key="test-key") - client._client = httpx.AsyncClient( - transport=_mock_transport(routes), - base_url="http://fake:3100", - headers={"Accept": "application/json", "Authorization": "Bearer test-key"}, - ) - return client - - -# --------------------------------------------------------------------------- -# health -# --------------------------------------------------------------------------- - - -async def test_healthy_returns_true_on_200(): - client = _client_with({"GET /api/health": (200, {"status": "ok"})}) - assert await client.healthy() is True - - -async def test_healthy_returns_false_on_500(): - client = _client_with({"GET /api/health": (500, {"error": "down"})}) - assert await client.healthy() is False - - -async def test_healthy_returns_false_on_404(): - client = _client_with({}) # no routes → 404 - assert await client.healthy() is False - - -# --------------------------------------------------------------------------- -# agents -# --------------------------------------------------------------------------- - - -async def test_list_agents_parses_response(): - raw = [{"id": "a1", "name": "Codex", "role": "engineer", "status": "active"}] - client = _client_with({"GET /api/companies/comp-1/agents": (200, raw)}) - agents = await client.list_agents(company_id="comp-1") - assert len(agents) == 1 - assert agents[0].name == "Codex" - assert agents[0].id == "a1" - - -async def test_list_agents_empty_on_server_error(): - client = _client_with({"GET /api/companies/comp-1/agents": (503, None)}) - agents = await client.list_agents(company_id="comp-1") - assert agents == [] - - -async def test_list_agents_graceful_on_404(): - client = _client_with({}) - agents = await client.list_agents(company_id="comp-1") - assert agents == [] - - -# --------------------------------------------------------------------------- -# issues -# --------------------------------------------------------------------------- - - -async def test_list_issues(): - raw = [{"id": "i1", "title": "Fix bug"}] - client = _client_with({"GET /api/companies/comp-1/issues": (200, raw)}) - issues = await client.list_issues(company_id="comp-1") - assert len(issues) == 1 - assert issues[0].title == "Fix bug" - - -async def test_get_issue(): - raw = {"id": "i1", "title": "Fix bug", "description": "It's broken"} - client = _client_with({"GET /api/issues/i1": (200, raw)}) - issue = await client.get_issue("i1") - assert issue is not None - assert issue.id == "i1" - - -async def test_get_issue_not_found(): - client = _client_with({"GET /api/issues/nonexistent": (404, None)}) - issue = await client.get_issue("nonexistent") - assert issue is None - - -async def test_create_issue(): - raw = {"id": "i2", "title": "New feature"} - client = _client_with({"POST /api/companies/comp-1/issues": (201, raw)}) - req = CreateIssueRequest(title="New feature") - issue = await client.create_issue(req, company_id="comp-1") - assert issue is not None - assert issue.id == "i2" - - -async def test_create_issue_no_company_id(): - """Missing company_id returns None without making any HTTP call.""" - client = _client_with({}) - with patch("integrations.paperclip.client.settings") as mock_settings: - mock_settings.paperclip_company_id = "" - issue = await client.create_issue(CreateIssueRequest(title="Test")) - assert issue is None - - -async def test_delete_issue_returns_true_on_success(): - client = _client_with({"DELETE /api/issues/i1": (204, None)}) - result = await client.delete_issue("i1") - assert result is True - - -async def test_delete_issue_returns_false_on_error(): - client = _client_with({"DELETE /api/issues/i1": (500, None)}) - result = await client.delete_issue("i1") - assert result is False - - -# --------------------------------------------------------------------------- -# comments -# --------------------------------------------------------------------------- - - -async def test_add_comment(): - raw = {"id": "c1", "issue_id": "i1", "content": "Done"} - client = _client_with({"POST /api/issues/i1/comments": (201, raw)}) - comment = await client.add_comment("i1", "Done") - assert comment is not None - assert comment.content == "Done" - - -async def test_list_comments(): - raw = [{"id": "c1", "issue_id": "i1", "content": "LGTM"}] - client = _client_with({"GET /api/issues/i1/comments": (200, raw)}) - comments = await client.list_comments("i1") - assert len(comments) == 1 - - -# --------------------------------------------------------------------------- -# goals -# --------------------------------------------------------------------------- - - -async def test_list_goals(): - raw = [{"id": "g1", "title": "Ship MVP"}] - client = _client_with({"GET /api/companies/comp-1/goals": (200, raw)}) - goals = await client.list_goals(company_id="comp-1") - assert len(goals) == 1 - assert goals[0].title == "Ship MVP" - - -async def test_create_goal(): - raw = {"id": "g2", "title": "Scale to 1000 users"} - client = _client_with({"POST /api/companies/comp-1/goals": (201, raw)}) - goal = await client.create_goal("Scale to 1000 users", company_id="comp-1") - assert goal is not None - - -# --------------------------------------------------------------------------- -# heartbeat runs -# --------------------------------------------------------------------------- - - -async def test_list_heartbeat_runs(): - raw = [{"id": "r1", "agent_id": "a1", "status": "running"}] - client = _client_with({"GET /api/companies/comp-1/heartbeat-runs": (200, raw)}) - runs = await client.list_heartbeat_runs(company_id="comp-1") - assert len(runs) == 1 - - -async def test_list_heartbeat_runs_server_error(): - client = _client_with({"GET /api/companies/comp-1/heartbeat-runs": (500, None)}) - runs = await client.list_heartbeat_runs(company_id="comp-1") - assert runs == [] diff --git a/tests/integrations/test_paperclip_task_runner.py b/tests/integrations/test_paperclip_task_runner.py deleted file mode 100644 index 1801f94..0000000 --- a/tests/integrations/test_paperclip_task_runner.py +++ /dev/null @@ -1,936 +0,0 @@ -"""Integration tests for the Paperclip task runner — full green-path workflow. - -Tests the complete autonomous cycle with a StubOrchestrator that exercises -the real pipe (TaskRunner → orchestrator.execute_task → bridge → client) -while stubbing only the LLM intelligence layer. - -Green path: - 1. Timmy grabs first task in queue - 2. Orchestrator.execute_task processes it (stub returns input-aware response) - 3. Timmy posts completion comment and marks issue done - 4. Timmy creates a recursive follow-up task for himself - -The stub is deliberately input-aware — it echoes back task metadata so -assertions can prove data actually flowed through the pipe, not just that -methods were called. - -Live-LLM tests (``@pytest.mark.ollama``) are at the bottom; they hit a real -tiny model via Ollama and are skipped when Ollama is not running. -Run them with: ``tox -e ollama`` or ``pytest -m ollama`` -""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from integrations.paperclip.bridge import PaperclipBridge -from integrations.paperclip.client import PaperclipClient -from integrations.paperclip.models import PaperclipIssue -from integrations.paperclip.task_runner import TaskRunner - -# ── Constants ───────────────────────────────────────────────────────────────── - -TIMMY_AGENT_ID = "agent-timmy" -COMPANY_ID = "comp-1" - - -# ── StubOrchestrator: exercises the pipe, stubs the intelligence ────────────── - - -class StubOrchestrator: - """Deterministic orchestrator that proves data flows through the pipe. - - Returns responses that reference input metadata — so tests can assert - the pipe actually connected (task_id, title, priority all appear in output). - Tracks every call for post-hoc inspection. - """ - - def __init__(self) -> None: - self.calls: list[dict] = [] - - async def execute_task(self, task_id: str, description: str, context: dict) -> dict: - call_record = { - "task_id": task_id, - "description": description, - "context": dict(context), - } - self.calls.append(call_record) - - title = context.get("title", description[:50]) - priority = context.get("priority", "normal") - - return { - "task_id": task_id, - "agent": "orchestrator", - "result": ( - f"[Orchestrator] Processed '{title}'. " - f"Task {task_id} handled with priority {priority}. " - "Self-reflection: my task automation loop is functioning. " - "I should create a follow-up to review this pattern." - ), - "status": "completed", - } - - -# ── Fixtures ────────────────────────────────────────────────────────────────── - - -@pytest.fixture -def stub_orchestrator(): - return StubOrchestrator() - - -@pytest.fixture -def mock_client(): - """Fully stubbed PaperclipClient with async methods.""" - client = MagicMock(spec=PaperclipClient) - client.healthy = AsyncMock(return_value=True) - client.list_issues = AsyncMock(return_value=[]) - client.get_issue = AsyncMock(return_value=None) - client.create_issue = AsyncMock(return_value=None) - client.update_issue = AsyncMock(return_value=None) - client.delete_issue = AsyncMock(return_value=True) - client.add_comment = AsyncMock(return_value=None) - client.list_comments = AsyncMock(return_value=[]) - client.checkout_issue = AsyncMock(return_value={"ok": True}) - client.release_issue = AsyncMock(return_value={"ok": True}) - client.wake_agent = AsyncMock(return_value=None) - client.list_agents = AsyncMock(return_value=[]) - client.list_goals = AsyncMock(return_value=[]) - client.create_goal = AsyncMock(return_value=None) - client.list_approvals = AsyncMock(return_value=[]) - client.list_heartbeat_runs = AsyncMock(return_value=[]) - client.cancel_run = AsyncMock(return_value=None) - client.approve = AsyncMock(return_value=None) - client.reject = AsyncMock(return_value=None) - return client - - -@pytest.fixture -def bridge(mock_client): - return PaperclipBridge(client=mock_client) - - -@pytest.fixture -def settings_patch(): - """Patch settings for all task runner tests.""" - with ( - patch("integrations.paperclip.task_runner.settings") as ts, - patch("integrations.paperclip.bridge.settings") as bs, - ): - for s in (ts, bs): - s.paperclip_enabled = True - s.paperclip_agent_id = TIMMY_AGENT_ID - s.paperclip_company_id = COMPANY_ID - s.paperclip_url = "http://fake:3100" - s.paperclip_poll_interval = 0 - yield ts - - -# ── Helpers ─────────────────────────────────────────────────────────────────── - - -def _make_issue( - id: str = "issue-1", - title: str = "Muse about task automation", - description: str = "Reflect on how you handle tasks and write a recursive self-improvement task.", - status: str = "open", - assignee_id: str = TIMMY_AGENT_ID, - priority: str = "normal", - labels: list[str] | None = None, -) -> PaperclipIssue: - return PaperclipIssue( - id=id, - title=title, - description=description, - status=status, - assignee_id=assignee_id, - priority=priority, - labels=labels or [], - ) - - -def _make_done(id: str = "issue-1", title: str = "Done") -> PaperclipIssue: - return PaperclipIssue(id=id, title=title, status="done") - - -def _make_follow_up(id: str = "issue-2") -> PaperclipIssue: - return PaperclipIssue( - id=id, - title="Follow-up: Muse about task automation", - description="Automated follow-up from completed task", - status="open", - assignee_id=TIMMY_AGENT_ID, - priority="normal", - ) - - -# ═══════════════════════════════════════════════════════════════════════════════ -# PIPE WIRING: verify orchestrator is actually connected -# ═══════════════════════════════════════════════════════════════════════════════ - - -class TestOrchestratorWiring: - """Verify the orchestrator parameter actually connects to the pipe.""" - - async def test_orchestrator_execute_task_is_called( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - """When orchestrator is wired, process_task calls execute_task.""" - issue = _make_issue() - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - await runner.process_task(issue) - - assert len(stub_orchestrator.calls) == 1 - call = stub_orchestrator.calls[0] - assert call["task_id"] == "issue-1" - assert call["context"]["title"] == "Muse about task automation" - - async def test_orchestrator_receives_full_context( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - """Context dict passed to execute_task includes all issue metadata.""" - issue = _make_issue( - id="ctx-test", - title="Context verification", - priority="high", - labels=["automation", "meta"], - ) - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - await runner.process_task(issue) - - ctx = stub_orchestrator.calls[0]["context"] - assert ctx["issue_id"] == "ctx-test" - assert ctx["title"] == "Context verification" - assert ctx["priority"] == "high" - assert ctx["labels"] == ["automation", "meta"] - - async def test_orchestrator_dict_result_unwrapped( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - """When execute_task returns a dict, the 'result' key is extracted.""" - issue = _make_issue() - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - result = await runner.process_task(issue) - - # StubOrchestrator returns dict with "result" key - assert "[Orchestrator]" in result - assert "issue-1" in result - - async def test_orchestrator_string_result_passthrough( - self, - mock_client, - bridge, - settings_patch, - ): - """When execute_task returns a plain string, it passes through.""" - - class StringOrchestrator: - async def execute_task(self, task_id, description, context): - return f"Plain string result for {task_id}" - - runner = TaskRunner(bridge=bridge, orchestrator=StringOrchestrator()) - result = await runner.process_task(_make_issue()) - - assert result == "Plain string result for issue-1" - - async def test_process_fn_overrides_orchestrator( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - """Explicit process_fn takes priority over orchestrator.""" - - async def override(task_id, desc, ctx): - return "override wins" - - runner = TaskRunner( - bridge=bridge, - orchestrator=stub_orchestrator, - process_fn=override, - ) - result = await runner.process_task(_make_issue()) - - assert result == "override wins" - assert len(stub_orchestrator.calls) == 0 # orchestrator NOT called - - -# ═══════════════════════════════════════════════════════════════════════════════ -# STEP 1: Timmy grabs the first task in queue -# ═══════════════════════════════════════════════════════════════════════════════ - - -class TestGrabNextTask: - """Verify Timmy picks the first open issue assigned to him.""" - - async def test_grabs_first_assigned_issue(self, mock_client, bridge, settings_patch): - issue = _make_issue() - mock_client.list_issues.return_value = [issue] - - runner = TaskRunner(bridge=bridge) - grabbed = await runner.grab_next_task() - - assert grabbed is not None - assert grabbed.id == "issue-1" - assert grabbed.assignee_id == TIMMY_AGENT_ID - mock_client.list_issues.assert_awaited_once_with(status="open") - - async def test_skips_issues_not_assigned_to_timmy(self, mock_client, bridge, settings_patch): - other = _make_issue(id="other-1", assignee_id="agent-codex") - mine = _make_issue(id="timmy-1") - mock_client.list_issues.return_value = [other, mine] - - runner = TaskRunner(bridge=bridge) - grabbed = await runner.grab_next_task() - - assert grabbed.id == "timmy-1" - - async def test_returns_none_when_queue_empty(self, mock_client, bridge, settings_patch): - mock_client.list_issues.return_value = [] - runner = TaskRunner(bridge=bridge) - assert await runner.grab_next_task() is None - - async def test_returns_none_when_no_agent_id(self, mock_client, bridge, settings_patch): - settings_patch.paperclip_agent_id = "" - runner = TaskRunner(bridge=bridge) - assert await runner.grab_next_task() is None - mock_client.list_issues.assert_not_awaited() - - async def test_grabs_first_of_multiple(self, mock_client, bridge, settings_patch): - issues = [_make_issue(id=f"t-{i}", title=f"Task {i}") for i in range(3)] - mock_client.list_issues.return_value = issues - - runner = TaskRunner(bridge=bridge) - assert (await runner.grab_next_task()).id == "t-0" - - -# ═══════════════════════════════════════════════════════════════════════════════ -# STEP 2: Timmy processes the task through the orchestrator -# ═══════════════════════════════════════════════════════════════════════════════ - - -class TestProcessTask: - """Verify checkout + orchestrator invocation + result flow.""" - - async def test_checkout_before_orchestrator( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - """Issue must be checked out before orchestrator runs.""" - issue = _make_issue() - checkout_happened = {"before_execute": False} - - original_execute = stub_orchestrator.execute_task - - async def tracking_execute(task_id, desc, ctx): - checkout_happened["before_execute"] = mock_client.checkout_issue.await_count > 0 - return await original_execute(task_id, desc, ctx) - - stub_orchestrator.execute_task = tracking_execute - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - await runner.process_task(issue) - - assert checkout_happened["before_execute"], "checkout must happen before execute_task" - - async def test_orchestrator_output_flows_to_result( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - """The string returned by process_task comes from the orchestrator.""" - issue = _make_issue(id="flow-1", title="Flow verification", priority="high") - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - result = await runner.process_task(issue) - - # Verify orchestrator's output arrived — it references the input - assert "Flow verification" in result - assert "flow-1" in result - assert "high" in result - - async def test_default_fallback_without_orchestrator( - self, - mock_client, - bridge, - settings_patch, - ): - """Without orchestrator or process_fn, a default message is returned.""" - issue = _make_issue(title="Fallback test") - runner = TaskRunner(bridge=bridge) # no orchestrator - result = await runner.process_task(issue) - assert "Fallback test" in result - - -# ═══════════════════════════════════════════════════════════════════════════════ -# STEP 3: Timmy completes the task — comment + close -# ═══════════════════════════════════════════════════════════════════════════════ - - -class TestCompleteTask: - """Verify orchestrator output flows into the completion comment.""" - - async def test_orchestrator_output_in_comment( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - """The comment posted to Paperclip contains the orchestrator's output.""" - issue = _make_issue(id="cmt-1", title="Comment pipe test") - mock_client.update_issue.return_value = _make_done("cmt-1") - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - # Process to get orchestrator output - result = await runner.process_task(issue) - # Complete to post it as comment - await runner.complete_task(issue, result) - - comment_content = mock_client.add_comment.call_args[0][1] - assert "[Timmy]" in comment_content - assert "[Orchestrator]" in comment_content - assert "Comment pipe test" in comment_content - - async def test_marks_issue_done( - self, - mock_client, - bridge, - settings_patch, - ): - issue = _make_issue() - mock_client.update_issue.return_value = _make_done() - - runner = TaskRunner(bridge=bridge) - ok = await runner.complete_task(issue, "any result") - - assert ok is True - update_req = mock_client.update_issue.call_args[0][1] - assert update_req.status == "done" - - async def test_returns_false_on_close_failure( - self, - mock_client, - bridge, - settings_patch, - ): - mock_client.update_issue.return_value = None - runner = TaskRunner(bridge=bridge) - assert await runner.complete_task(_make_issue(), "result") is False - - -# ═══════════════════════════════════════════════════════════════════════════════ -# STEP 4: Follow-up creation with orchestrator output embedded -# ═══════════════════════════════════════════════════════════════════════════════ - - -class TestCreateFollowUp: - """Verify orchestrator output flows into the follow-up description.""" - - async def test_follow_up_contains_orchestrator_output( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - """The follow-up description includes the orchestrator's result text.""" - issue = _make_issue(id="fu-1", title="Follow-up pipe test") - mock_client.create_issue.return_value = _make_follow_up() - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - result = await runner.process_task(issue) - await runner.create_follow_up(issue, result) - - create_req = mock_client.create_issue.call_args[0][0] - # Orchestrator output should be embedded in description - assert "[Orchestrator]" in create_req.description - assert "fu-1" in create_req.description - - async def test_follow_up_assigned_to_self( - self, - mock_client, - bridge, - settings_patch, - ): - mock_client.create_issue.return_value = _make_follow_up() - runner = TaskRunner(bridge=bridge) - await runner.create_follow_up(_make_issue(), "result") - - req = mock_client.create_issue.call_args[0][0] - assert req.assignee_id == TIMMY_AGENT_ID - - async def test_follow_up_preserves_priority( - self, - mock_client, - bridge, - settings_patch, - ): - mock_client.create_issue.return_value = _make_follow_up() - runner = TaskRunner(bridge=bridge) - await runner.create_follow_up(_make_issue(priority="high"), "result") - - req = mock_client.create_issue.call_args[0][0] - assert req.priority == "high" - - async def test_follow_up_not_woken(self, mock_client, bridge, settings_patch): - mock_client.create_issue.return_value = _make_follow_up() - runner = TaskRunner(bridge=bridge) - await runner.create_follow_up(_make_issue(), "result") - mock_client.wake_agent.assert_not_awaited() - - async def test_returns_none_on_failure(self, mock_client, bridge, settings_patch): - mock_client.create_issue.return_value = None - runner = TaskRunner(bridge=bridge) - assert await runner.create_follow_up(_make_issue(), "r") is None - - -# ═══════════════════════════════════════════════════════════════════════════════ -# FULL GREEN PATH: orchestrator wired end-to-end -# ═══════════════════════════════════════════════════════════════════════════════ - - -class TestGreenPathWithOrchestrator: - """Full pipe: TaskRunner → StubOrchestrator → bridge → mock_client. - - Proves orchestrator output propagates to every downstream artefact: - the comment, the follow-up description, and the summary dict. - """ - - async def test_full_cycle_orchestrator_output_everywhere( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - """Orchestrator result appears in comment, follow-up, and summary.""" - original = _make_issue( - id="green-1", - title="Muse about task automation and write a recursive task", - description="Reflect on your task processing. Create a follow-up.", - priority="high", - ) - mock_client.list_issues.return_value = [original] - mock_client.update_issue.return_value = _make_done("green-1") - mock_client.create_issue.return_value = _make_follow_up("green-fu") - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - summary = await runner.run_once() - - # ── Orchestrator was called with correct data - assert len(stub_orchestrator.calls) == 1 - call = stub_orchestrator.calls[0] - assert call["task_id"] == "green-1" - assert call["context"]["priority"] == "high" - assert "Reflect on your task processing" in call["description"] - - # ── Summary contains orchestrator output - assert summary is not None - assert summary["original_issue_id"] == "green-1" - assert summary["completed"] is True - assert summary["follow_up_issue_id"] == "green-fu" - assert "[Orchestrator]" in summary["result"] - assert "green-1" in summary["result"] - - # ── Comment posted contains orchestrator output - comment_content = mock_client.add_comment.call_args[0][1] - assert "[Timmy]" in comment_content - assert "[Orchestrator]" in comment_content - assert "high" in comment_content # priority flowed through - - # ── Follow-up description contains orchestrator output - follow_up_req = mock_client.create_issue.call_args[0][0] - assert "[Orchestrator]" in follow_up_req.description - assert "green-1" in follow_up_req.description - assert follow_up_req.priority == "high" - assert follow_up_req.assignee_id == TIMMY_AGENT_ID - - # ── Correct ordering of API calls - mock_client.list_issues.assert_awaited_once() - mock_client.checkout_issue.assert_awaited_once_with("green-1") - mock_client.add_comment.assert_awaited_once() - mock_client.update_issue.assert_awaited_once() - assert mock_client.create_issue.await_count == 1 - - async def test_no_tasks_returns_none( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - mock_client.list_issues.return_value = [] - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - assert await runner.run_once() is None - assert len(stub_orchestrator.calls) == 0 - - async def test_close_failure_still_creates_follow_up( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - mock_client.list_issues.return_value = [_make_issue()] - mock_client.update_issue.return_value = None # close fails - mock_client.create_issue.return_value = _make_follow_up() - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - summary = await runner.run_once() - - assert summary["completed"] is False - assert summary["follow_up_issue_id"] == "issue-2" - assert len(stub_orchestrator.calls) == 1 - - -# ═══════════════════════════════════════════════════════════════════════════════ -# EXTERNAL INJECTION: task from Paperclip API → orchestrator processes it -# ═══════════════════════════════════════════════════════════════════════════════ - - -class TestExternalTaskInjection: - """External system creates a task → Timmy's orchestrator processes it.""" - - async def test_external_task_flows_through_orchestrator( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - external = _make_issue( - id="ext-1", - title="Review quarterly metrics", - description="Analyze Q1 metrics and prepare summary.", - ) - mock_client.list_issues.return_value = [external] - mock_client.update_issue.return_value = _make_done("ext-1") - mock_client.create_issue.return_value = _make_follow_up("ext-fu") - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - summary = await runner.run_once() - - # Orchestrator received the external task - assert stub_orchestrator.calls[0]["task_id"] == "ext-1" - assert "Analyze Q1 metrics" in stub_orchestrator.calls[0]["description"] - - # Its output flowed to Paperclip - assert "[Orchestrator]" in summary["result"] - assert "Review quarterly metrics" in summary["result"] - - async def test_skips_tasks_for_other_agents( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - other = _make_issue(id="other-1", assignee_id="agent-codex") - mine = _make_issue(id="mine-1", title="My task") - mock_client.list_issues.return_value = [other, mine] - mock_client.update_issue.return_value = _make_done("mine-1") - mock_client.create_issue.return_value = _make_follow_up() - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - summary = await runner.run_once() - - assert summary["original_issue_id"] == "mine-1" - mock_client.checkout_issue.assert_awaited_once_with("mine-1") - - -# ═══════════════════════════════════════════════════════════════════════════════ -# RECURSIVE CHAIN: follow-up → grabbed → orchestrator → follow-up → ... -# ═══════════════════════════════════════════════════════════════════════════════ - - -class TestRecursiveChain: - """Multi-cycle chains where each follow-up becomes the next task.""" - - async def test_two_cycle_chain( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - task_a = _make_issue(id="A", title="Initial musing") - fu_b = PaperclipIssue( - id="B", - title="Follow-up: Initial musing", - description="Continue", - status="open", - assignee_id=TIMMY_AGENT_ID, - priority="normal", - ) - fu_c = PaperclipIssue( - id="C", - title="Follow-up: Follow-up", - status="open", - assignee_id=TIMMY_AGENT_ID, - ) - - # Cycle 1 - mock_client.list_issues.return_value = [task_a] - mock_client.update_issue.return_value = _make_done("A") - mock_client.create_issue.return_value = fu_b - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - s1 = await runner.run_once() - assert s1["original_issue_id"] == "A" - assert s1["follow_up_issue_id"] == "B" - - # Cycle 2: follow-up B is now the task - mock_client.list_issues.return_value = [fu_b] - mock_client.update_issue.return_value = _make_done("B") - mock_client.create_issue.return_value = fu_c - - s2 = await runner.run_once() - assert s2["original_issue_id"] == "B" - assert s2["follow_up_issue_id"] == "C" - - # Orchestrator was called twice — once per cycle - assert len(stub_orchestrator.calls) == 2 - assert stub_orchestrator.calls[0]["task_id"] == "A" - assert stub_orchestrator.calls[1]["task_id"] == "B" - - async def test_three_cycle_chain_all_through_orchestrator( - self, - mock_client, - bridge, - stub_orchestrator, - settings_patch, - ): - """Three cycles — every task goes through the orchestrator pipe.""" - tasks = [_make_issue(id=f"c-{i}", title=f"Chain {i}") for i in range(3)] - follow_ups = [ - PaperclipIssue( - id=f"c-{i + 1}", - title=f"Follow-up: Chain {i}", - status="open", - assignee_id=TIMMY_AGENT_ID, - ) - for i in range(3) - ] - - runner = TaskRunner(bridge=bridge, orchestrator=stub_orchestrator) - ids = [] - - for i in range(3): - mock_client.list_issues.return_value = [tasks[i]] - mock_client.update_issue.return_value = _make_done(tasks[i].id) - mock_client.create_issue.return_value = follow_ups[i] - - s = await runner.run_once() - ids.append(s["original_issue_id"]) - - assert ids == ["c-0", "c-1", "c-2"] - assert len(stub_orchestrator.calls) == 3 - - -# ═══════════════════════════════════════════════════════════════════════════════ -# LIFECYCLE: start/stop -# ═══════════════════════════════════════════════════════════════════════════════ - - -class TestLifecycle: - async def test_stop_halts_loop(self, mock_client, bridge, settings_patch): - runner = TaskRunner(bridge=bridge) - runner._running = True - runner.stop() - assert runner._running is False - - async def test_start_disabled_when_interval_zero( - self, - mock_client, - bridge, - settings_patch, - ): - settings_patch.paperclip_poll_interval = 0 - runner = TaskRunner(bridge=bridge) - await runner.start() - mock_client.list_issues.assert_not_awaited() - - -# ═══════════════════════════════════════════════════════════════════════════════ -# LIVE LLM (manual e2e): runs only when Ollama is available -# ═══════════════════════════════════════════════════════════════════════════════ - - -def _ollama_reachable() -> tuple[bool, list[str]]: - """Return (reachable, model_names).""" - try: - import httpx - - resp = httpx.get("http://localhost:11434/api/tags", timeout=3) - resp.raise_for_status() - names = [m["name"] for m in resp.json().get("models", [])] - return True, names - except Exception: - return False, [] - - -def _pick_tiny_model(available: list[str]) -> str | None: - """Pick the smallest model available for e2e tests.""" - candidates = ["tinyllama", "phi", "qwen2:0.5b", "llama3.2:1b", "gemma:2b"] - for candidate in candidates: - for name in available: - if candidate in name: - return name - return None - - -class LiveOllamaOrchestrator: - """Thin orchestrator that calls Ollama directly — no Agno dependency.""" - - def __init__(self, model_name: str) -> None: - self.model_name = model_name - self.calls: list[dict] = [] - - async def execute_task(self, task_id: str, description: str, context: dict) -> str: - import httpx as hx - - self.calls.append({"task_id": task_id, "description": description}) - - async with hx.AsyncClient(timeout=60) as client: - resp = await client.post( - "http://localhost:11434/api/generate", - json={ - "model": self.model_name, - "prompt": ( - f"You are Timmy, a task automation agent. " - f"Task: {description}\n" - f"Respond in 1-2 sentences about what you did." - ), - "stream": False, - "options": {"num_predict": 64}, - }, - ) - resp.raise_for_status() - return resp.json()["response"] - - -@pytest.mark.ollama -class TestLiveOllamaGreenPath: - """Green-path with a real tiny LLM via Ollama. - - Run with: ``tox -e ollama`` or ``pytest -m ollama`` - Requires: Ollama running with a small model. - """ - - async def test_live_full_cycle(self, mock_client, bridge, settings_patch): - """Wire a real tiny LLM through the full pipe and verify output.""" - reachable, models = _ollama_reachable() - if not reachable: - pytest.skip("Ollama not reachable at localhost:11434") - - chosen = _pick_tiny_model(models) - if not chosen: - pytest.skip(f"No tiny model found (have: {models[:5]})") - - issue = _make_issue( - id="live-1", - title="Reflect on task automation", - description="Muse about how you process tasks and suggest improvements.", - ) - mock_client.list_issues.return_value = [issue] - mock_client.update_issue.return_value = _make_done("live-1") - mock_client.create_issue.return_value = _make_follow_up("live-fu") - - live_orch = LiveOllamaOrchestrator(chosen) - runner = TaskRunner(bridge=bridge, orchestrator=live_orch) - summary = await runner.run_once() - - # The LLM produced *something* non-empty - assert summary is not None - assert len(summary["result"]) > 0 - assert summary["completed"] is True - assert summary["follow_up_issue_id"] == "live-fu" - - # Orchestrator was actually called - assert len(live_orch.calls) == 1 - assert live_orch.calls[0]["task_id"] == "live-1" - - # LLM output flowed into the Paperclip comment - comment = mock_client.add_comment.call_args[0][1] - assert "[Timmy]" in comment - assert len(comment) > len("[Timmy] Task completed.\n\n") - - # LLM output flowed into the follow-up description - fu_req = mock_client.create_issue.call_args[0][0] - assert len(fu_req.description) > 0 - assert fu_req.assignee_id == TIMMY_AGENT_ID - - async def test_live_recursive_chain(self, mock_client, bridge, settings_patch): - """Two-cycle chain with a real LLM — each cycle produces real output.""" - reachable, models = _ollama_reachable() - if not reachable: - pytest.skip("Ollama not reachable") - - chosen = _pick_tiny_model(models) - if not chosen: - pytest.skip("No tiny model found") - - task_a = _make_issue(id="live-A", title="Initial reflection") - fu_b = PaperclipIssue( - id="live-B", - title="Follow-up: Initial reflection", - description="Continue reflecting", - status="open", - assignee_id=TIMMY_AGENT_ID, - priority="normal", - ) - fu_c = PaperclipIssue( - id="live-C", - title="Follow-up: Follow-up", - status="open", - assignee_id=TIMMY_AGENT_ID, - ) - - live_orch = LiveOllamaOrchestrator(chosen) - runner = TaskRunner(bridge=bridge, orchestrator=live_orch) - - # Cycle 1 - mock_client.list_issues.return_value = [task_a] - mock_client.update_issue.return_value = _make_done("live-A") - mock_client.create_issue.return_value = fu_b - - s1 = await runner.run_once() - assert s1 is not None - assert len(s1["result"]) > 0 - - # Cycle 2 - mock_client.list_issues.return_value = [fu_b] - mock_client.update_issue.return_value = _make_done("live-B") - mock_client.create_issue.return_value = fu_c - - s2 = await runner.run_once() - assert s2 is not None - assert len(s2["result"]) > 0 - - # Both cycles went through the LLM - assert len(live_orch.calls) == 2 diff --git a/tests/test_openfang_client.py b/tests/test_openfang_client.py deleted file mode 100644 index 55fef0c..0000000 --- a/tests/test_openfang_client.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Chunk 2: OpenFang HTTP client — test first, implement second. - -Tests cover: -- Health check returns False when unreachable -- Health check TTL caching -- execute_hand() rejects unknown hands -- execute_hand() success with mocked HTTP -- execute_hand() graceful degradation on error -- Convenience wrappers call the correct hand -""" - -import json -from unittest.mock import MagicMock, patch - -import pytest - -# --------------------------------------------------------------------------- -# Health checks -# --------------------------------------------------------------------------- - - -def test_health_check_false_when_unreachable(): - """Client should report unhealthy when OpenFang is not running.""" - from infrastructure.openfang.client import OpenFangClient - - client = OpenFangClient(base_url="http://localhost:19999") - assert client._check_health() is False - - -def test_health_check_caching(): - """Repeated .healthy calls within TTL should not re-check.""" - from infrastructure.openfang.client import OpenFangClient - - client = OpenFangClient(base_url="http://localhost:19999") - client._health_cache_ttl = 9999 # very long TTL - # Force a first check (will be False) - _ = client.healthy - assert client._healthy is False - - # Manually flip the cached value — next access should use cache - client._healthy = True - assert client.healthy is True # still cached, no re-check - - -# --------------------------------------------------------------------------- -# execute_hand — unknown hand -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_execute_hand_unknown_hand(): - """Requesting an unknown hand returns success=False immediately.""" - from infrastructure.openfang.client import OpenFangClient - - client = OpenFangClient(base_url="http://localhost:19999") - result = await client.execute_hand("nonexistent_hand", {}) - assert result.success is False - assert "Unknown hand" in result.error - - -# --------------------------------------------------------------------------- -# execute_hand — success path (mocked HTTP) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_execute_hand_success_mocked(): - """When OpenFang returns 200 with output, HandResult.success is True.""" - from infrastructure.openfang.client import OpenFangClient - - response_body = json.dumps( - { - "success": True, - "output": "Page loaded successfully", - "metadata": {"url": "https://example.com"}, - } - ).encode() - - mock_resp = MagicMock() - mock_resp.status = 200 - mock_resp.read.return_value = response_body - mock_resp.__enter__ = lambda s: s - mock_resp.__exit__ = MagicMock(return_value=False) - - with patch("urllib.request.urlopen", return_value=mock_resp): - client = OpenFangClient(base_url="http://localhost:8080") - result = await client.execute_hand("browser", {"url": "https://example.com"}) - - assert result.success is True - assert result.output == "Page loaded successfully" - assert result.hand == "browser" - assert result.latency_ms > 0 - - -# --------------------------------------------------------------------------- -# execute_hand — graceful degradation on connection error -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_execute_hand_connection_error(): - """When OpenFang is unreachable, HandResult.success is False (no crash).""" - from infrastructure.openfang.client import OpenFangClient - - client = OpenFangClient(base_url="http://localhost:19999") - result = await client.execute_hand("browser", {"url": "https://example.com"}) - - assert result.success is False - assert result.error # non-empty error message - assert result.hand == "browser" - - -# --------------------------------------------------------------------------- -# Convenience wrappers -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_browse_calls_browser_hand(): - """browse() should delegate to execute_hand('browser', ...).""" - from infrastructure.openfang.client import OpenFangClient - - client = OpenFangClient(base_url="http://localhost:19999") - - calls = [] - original = client.execute_hand - - async def spy(hand, params, **kw): - calls.append((hand, params)) - return await original(hand, params, **kw) - - client.execute_hand = spy - await client.browse("https://example.com", "click button") - - assert len(calls) == 1 - assert calls[0][0] == "browser" - assert calls[0][1]["url"] == "https://example.com" - - -@pytest.mark.asyncio -async def test_collect_calls_collector_hand(): - """collect() should delegate to execute_hand('collector', ...).""" - from infrastructure.openfang.client import OpenFangClient - - client = OpenFangClient(base_url="http://localhost:19999") - - calls = [] - original = client.execute_hand - - async def spy(hand, params, **kw): - calls.append((hand, params)) - return await original(hand, params, **kw) - - client.execute_hand = spy - await client.collect("example.com", depth="deep") - - assert len(calls) == 1 - assert calls[0][0] == "collector" - assert calls[0][1]["target"] == "example.com" - - -@pytest.mark.asyncio -async def test_predict_calls_predictor_hand(): - """predict() should delegate to execute_hand('predictor', ...).""" - from infrastructure.openfang.client import OpenFangClient - - client = OpenFangClient(base_url="http://localhost:19999") - - calls = [] - original = client.execute_hand - - async def spy(hand, params, **kw): - calls.append((hand, params)) - return await original(hand, params, **kw) - - client.execute_hand = spy - await client.predict("Will BTC hit 100k?", horizon="1m") - - assert len(calls) == 1 - assert calls[0][0] == "predictor" - assert calls[0][1]["question"] == "Will BTC hit 100k?" - - -# --------------------------------------------------------------------------- -# HandResult dataclass -# --------------------------------------------------------------------------- - - -def test_hand_result_defaults(): - """HandResult should have sensible defaults.""" - from infrastructure.openfang.client import HandResult - - r = HandResult(hand="browser", success=True) - assert r.output == "" - assert r.error == "" - assert r.latency_ms == 0.0 - assert r.metadata == {} - - -# --------------------------------------------------------------------------- -# OPENFANG_HANDS constant -# --------------------------------------------------------------------------- - - -def test_openfang_hands_tuple(): - """The OPENFANG_HANDS constant should list all 7 hands.""" - from infrastructure.openfang.client import OPENFANG_HANDS - - assert len(OPENFANG_HANDS) == 7 - assert "browser" in OPENFANG_HANDS - assert "collector" in OPENFANG_HANDS - assert "predictor" in OPENFANG_HANDS - assert "lead" in OPENFANG_HANDS - assert "twitter" in OPENFANG_HANDS - assert "researcher" in OPENFANG_HANDS - assert "clip" in OPENFANG_HANDS - - -# --------------------------------------------------------------------------- -# status() summary -# --------------------------------------------------------------------------- - - -def test_status_returns_summary(): - """status() should return a dict with url, healthy flag, and hands list.""" - from infrastructure.openfang.client import OpenFangClient - - client = OpenFangClient(base_url="http://localhost:19999") - s = client.status() - - assert "url" in s - assert "healthy" in s - assert "available_hands" in s - assert len(s["available_hands"]) == 7 diff --git a/tests/test_openfang_config.py b/tests/test_openfang_config.py deleted file mode 100644 index a059401..0000000 --- a/tests/test_openfang_config.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Chunk 1: OpenFang config settings — test first, implement second.""" - - -def test_openfang_url_default(): - """Settings should expose openfang_url with a sensible default.""" - from config import settings - - assert hasattr(settings, "openfang_url") - assert settings.openfang_url == "http://localhost:8080" - - -def test_openfang_enabled_default_false(): - """OpenFang integration should be opt-in (disabled by default).""" - from config import settings - - assert hasattr(settings, "openfang_enabled") - assert settings.openfang_enabled is False - - -def test_openfang_timeout_default(): - """Timeout should be generous (some hands are slow).""" - from config import settings - - assert hasattr(settings, "openfang_timeout") - assert settings.openfang_timeout == 120 diff --git a/tests/test_paperclip_config.py b/tests/test_paperclip_config.py deleted file mode 100644 index a9492be..0000000 --- a/tests/test_paperclip_config.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Paperclip AI config settings.""" - - -def test_paperclip_url_default(): - from config import settings - - assert hasattr(settings, "paperclip_url") - assert settings.paperclip_url == "http://localhost:3100" - - -def test_paperclip_enabled_default_false(): - from config import settings - - assert hasattr(settings, "paperclip_enabled") - assert settings.paperclip_enabled is False - - -def test_paperclip_timeout_default(): - from config import settings - - assert hasattr(settings, "paperclip_timeout") - assert settings.paperclip_timeout == 30 - - -def test_paperclip_agent_id_default_empty(): - from config import settings - - assert hasattr(settings, "paperclip_agent_id") - assert settings.paperclip_agent_id == "" - - -def test_paperclip_company_id_default_empty(): - from config import settings - - assert hasattr(settings, "paperclip_company_id") - assert settings.paperclip_company_id == "" - - -def test_paperclip_poll_interval_default_zero(): - from config import settings - - assert hasattr(settings, "paperclip_poll_interval") - assert settings.paperclip_poll_interval == 0 diff --git a/tests/test_smoke.py b/tests/test_smoke.py index ae87eb1..4dd2520 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -103,16 +103,8 @@ class TestFeaturePages: r = client.get("/models") assert r.status_code == 200 - def test_swarm_live(self, client): - r = client.get("/swarm/live") - assert r.status_code == 200 - - def test_swarm_events(self, client): - r = client.get("/swarm/events") - assert r.status_code == 200 - - def test_marketplace(self, client): - r = client.get("/marketplace") + def test_memory_page(self, client): + r = client.get("/memory") assert r.status_code in (200, 307) @@ -162,10 +154,6 @@ class TestAPIEndpoints: r = client.get("/api/notifications") assert r.status_code == 200 - def test_providers_api(self, client): - r = client.get("/router/api/providers") - assert r.status_code == 200 - def test_mobile_status(self, client): r = client.get("/mobile/status") assert r.status_code == 200 @@ -182,10 +170,6 @@ class TestAPIEndpoints: r = client.get("/grok/status") assert r.status_code == 200 - def test_paperclip_status(self, client): - r = client.get("/api/paperclip/status") - assert r.status_code == 200 - # --------------------------------------------------------------------------- # No 500s — every GET route should survive without server error @@ -223,19 +207,14 @@ class TestNo500: "/mobile/status", "/spark", "/models", - "/swarm/live", - "/swarm/events", - "/marketplace", "/api/queue/status", "/api/tasks", "/api/chat/history", "/api/notifications", - "/router/api/providers", "/discord/status", "/telegram/status", "/grok/status", "/grok/stats", - "/api/paperclip/status", ], ) def test_no_500(self, client, path): diff --git a/tests/timmy/test_agents_timmy.py b/tests/timmy/test_agents_timmy.py deleted file mode 100644 index ab37d59..0000000 --- a/tests/timmy/test_agents_timmy.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Tests for timmy.agents.timmy — orchestrator, personas, context building.""" - -import sys -from unittest.mock import MagicMock, patch - -# Ensure mcp.registry stub with tool_registry exists before importing agents -if "mcp" not in sys.modules: - _mock_mcp = MagicMock() - _mock_registry_mod = MagicMock() - _mock_tool_reg = MagicMock() - _mock_tool_reg.get_handler.return_value = None - _mock_registry_mod.tool_registry = _mock_tool_reg - sys.modules["mcp"] = _mock_mcp - sys.modules["mcp.registry"] = _mock_registry_mod - -from timmy.agents.timmy import ( - _PERSONAS, - ORCHESTRATOR_PROMPT_BASE, - TimmyOrchestrator, - _load_hands_async, - build_timmy_context_async, - build_timmy_context_sync, - create_timmy_swarm, - format_timmy_prompt, -) - - -class TestLoadHandsAsync: - """Test _load_hands_async.""" - - async def test_returns_empty_list(self): - result = await _load_hands_async() - assert result == [] - - -class TestBuildContext: - """Test context building functions.""" - - @patch("timmy.agents.timmy.settings") - def test_build_context_sync_graceful_failures(self, mock_settings): - mock_settings.repo_root = "/nonexistent" - ctx = build_timmy_context_sync() - - assert "timestamp" in ctx - assert isinstance(ctx["agents"], list) - assert isinstance(ctx["hands"], list) - # Git log should fall back gracefully - assert isinstance(ctx["git_log"], str) - # Memory should fall back gracefully - assert isinstance(ctx["memory"], str) - - @patch("timmy.agents.timmy.settings") - async def test_build_context_async(self, mock_settings): - mock_settings.repo_root = "/nonexistent" - ctx = await build_timmy_context_async() - assert ctx["hands"] == [] - - @patch("timmy.agents.timmy.settings") - def test_build_context_reads_memory_file(self, mock_settings, tmp_path): - memory_file = tmp_path / "MEMORY.md" - memory_file.write_text("# Important memories\nRemember this.") - mock_settings.repo_root = str(tmp_path) - - # Patch HotMemory path so it reads from tmp_path - from timmy.memory_system import memory_system - - original_path = memory_system.hot.path - memory_system.hot.path = memory_file - memory_system.hot._content = None # Clear cache - try: - ctx = build_timmy_context_sync() - assert "Important memories" in ctx["memory"] - finally: - memory_system.hot.path = original_path - memory_system.hot._content = None - - -class TestFormatPrompt: - """Test format_timmy_prompt.""" - - def test_inserts_context_block(self): - base = "Line one.\nLine two." - ctx = { - "timestamp": "2026-03-06T00:00:00Z", - "repo_root": "/home/user/project", - "git_log": "abc123 initial commit", - "agents": [], - "hands": [], - "memory": "some memory", - } - result = format_timmy_prompt(base, ctx) - assert "Line one." in result - assert "Line two." in result - assert "abc123 initial commit" in result - assert "some memory" in result - - def test_agents_list_formatted(self): - ctx = { - "timestamp": "now", - "repo_root": "/tmp", - "git_log": "", - "agents": [ - {"name": "Forge", "capabilities": "code", "status": "ready"}, - {"name": "Seer", "capabilities": "research", "status": "ready"}, - ], - "hands": [], - "memory": "", - } - result = format_timmy_prompt("Base.", ctx) - assert "Forge" in result - assert "Seer" in result - - def test_hands_list_formatted(self): - ctx = { - "timestamp": "now", - "repo_root": "/tmp", - "git_log": "", - "agents": [], - "hands": [ - {"name": "backup", "schedule": "daily", "enabled": True}, - ], - "memory": "", - } - result = format_timmy_prompt("Base.", ctx) - assert "backup" in result - assert "enabled" in result - - def test_repo_root_placeholder_replaced(self): - ctx = { - "timestamp": "now", - "repo_root": "/my/repo", - "git_log": "", - "agents": [], - "hands": [], - "memory": "", - } - result = format_timmy_prompt("Root is {REPO_ROOT}.", ctx) - assert "/my/repo" in result - assert "{REPO_ROOT}" not in result - - -class TestExtractAgent: - """Test TimmyOrchestrator._extract_agent static method.""" - - def test_extracts_known_agents(self): - assert TimmyOrchestrator._extract_agent("Primary Agent: Seer") == "seer" - assert TimmyOrchestrator._extract_agent("Use Forge for this") == "forge" - assert TimmyOrchestrator._extract_agent("Route to quill") == "quill" - assert TimmyOrchestrator._extract_agent("echo can recall") == "echo" - assert TimmyOrchestrator._extract_agent("helm decides") == "helm" - - def test_defaults_to_orchestrator(self): - assert TimmyOrchestrator._extract_agent("no agent mentioned") == "orchestrator" - - def test_case_insensitive(self): - assert TimmyOrchestrator._extract_agent("Use FORGE") == "forge" - - -class TestTimmyOrchestrator: - """Test TimmyOrchestrator init and methods.""" - - @patch("timmy.agents.timmy.settings") - def test_init(self, mock_settings): - mock_settings.repo_root = "/tmp" - mock_settings.ollama_model = "test" - mock_settings.ollama_url = "http://localhost:11434" - mock_settings.telemetry_enabled = False - - orch = TimmyOrchestrator() - assert orch.agent_id == "orchestrator" - assert orch.name == "Orchestrator" - assert orch.sub_agents == {} - assert orch._session_initialized is False - - @patch("timmy.agents.timmy.settings") - def test_register_sub_agent(self, mock_settings): - mock_settings.repo_root = "/tmp" - mock_settings.ollama_model = "test" - mock_settings.ollama_url = "http://localhost:11434" - mock_settings.telemetry_enabled = False - - orch = TimmyOrchestrator() - - from timmy.agents.base import SubAgent - - agent = SubAgent( - agent_id="test-agent", - name="Test", - role="test", - system_prompt="You are a test agent.", - ) - orch.register_sub_agent(agent) - assert "test-agent" in orch.sub_agents - - @patch("timmy.agents.timmy.settings") - def test_get_swarm_status(self, mock_settings): - mock_settings.repo_root = "/tmp" - mock_settings.ollama_model = "test" - mock_settings.ollama_url = "http://localhost:11434" - mock_settings.telemetry_enabled = False - - orch = TimmyOrchestrator() - status = orch.get_swarm_status() - assert "orchestrator" in status - assert status["total_agents"] == 1 - - @patch("timmy.agents.timmy.settings") - def test_get_enhanced_system_prompt_with_attr(self, mock_settings): - mock_settings.repo_root = "/tmp" - mock_settings.ollama_model = "test" - mock_settings.ollama_url = "http://localhost:11434" - mock_settings.telemetry_enabled = False - - orch = TimmyOrchestrator() - # BaseAgent doesn't store system_prompt as attr; set it manually - orch.system_prompt = "Test prompt.\nWith context." - prompt = orch._get_enhanced_system_prompt() - assert isinstance(prompt, str) - assert "Test prompt." in prompt - - -class TestCreateTimmySwarm: - """Test create_timmy_swarm factory.""" - - @patch("timmy.agents.timmy.settings") - def test_creates_all_personas(self, mock_settings): - mock_settings.repo_root = "/tmp" - mock_settings.ollama_model = "test" - mock_settings.ollama_url = "http://localhost:11434" - mock_settings.telemetry_enabled = False - - swarm = create_timmy_swarm() - assert len(swarm.sub_agents) == len(_PERSONAS) - assert "seer" in swarm.sub_agents - assert "forge" in swarm.sub_agents - assert "quill" in swarm.sub_agents - assert "echo" in swarm.sub_agents - assert "helm" in swarm.sub_agents - - -class TestPersonas: - """Test persona definitions.""" - - def test_all_personas_have_required_fields(self): - required = {"agent_id", "name", "role", "system_prompt"} - for persona in _PERSONAS: - assert required.issubset(persona.keys()), f"Missing fields in {persona['name']}" - - def test_persona_ids_unique(self): - ids = [p["agent_id"] for p in _PERSONAS] - assert len(ids) == len(set(ids)) - - def test_six_personas(self): - assert len(_PERSONAS) == 6 - - -class TestOrchestratorPrompt: - """Test the ORCHESTRATOR_PROMPT_BASE constant.""" - - def test_contains_hard_rules(self): - assert "NEVER fabricate" in ORCHESTRATOR_PROMPT_BASE - assert "do not know" in ORCHESTRATOR_PROMPT_BASE.lower() - - def test_contains_repo_root_placeholder(self): - assert "{REPO_ROOT}" in ORCHESTRATOR_PROMPT_BASE