Memory Unification + Canonical Identity: -11,074 lines of homebrew (#119)

This commit is contained in:
Alexander Whitestone
2026-03-02 09:58:07 -05:00
committed by GitHub
parent 785440ac31
commit 62ef1120a4
77 changed files with 1833 additions and 14183 deletions

View File

@@ -1,14 +1,30 @@
"""Distributed Brain — Rqlite-based memory and task queue.
"""Distributed Brain — Timmy's unified memory and task queue.
The brain is where Timmy lives. Identity is memory, not process.
A distributed SQLite (rqlite) cluster that runs across all Tailscale devices.
Provides:
- Semantic memory with local embeddings
- Distributed task queue with work stealing
- Automatic replication and failover
- **UnifiedMemory** — Single API for all memory operations (local SQLite or rqlite)
- **Canonical Identity** — One source of truth for who Timmy is
- **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.worker import DistributedWorker
from brain.embeddings import LocalEmbedder
from brain.memory import UnifiedMemory, get_memory
from brain.identity import get_canonical_identity, get_identity_for_prompt
__all__ = ["BrainClient", "DistributedWorker", "LocalEmbedder"]
__all__ = [
"BrainClient",
"DistributedWorker",
"LocalEmbedder",
"UnifiedMemory",
"get_memory",
"get_canonical_identity",
"get_identity_for_prompt",
]

180
src/brain/identity.py Normal file
View File

@@ -0,0 +1,180 @@
"""Canonical identity loader for Timmy.
Reads TIMMY_IDENTITY.md and provides it to any substrate.
One soul, many bodies — this is the soul loader.
Usage:
from brain.identity import get_canonical_identity, get_identity_section
# Full identity document
identity = get_canonical_identity()
# Just the rules
rules = get_identity_section("Standing Rules")
# Formatted for system prompt injection
prompt_block = get_identity_for_prompt()
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# Walk up from src/brain/ to find project root
_PROJECT_ROOT = Path(__file__).parent.parent.parent
_IDENTITY_PATH = _PROJECT_ROOT / "TIMMY_IDENTITY.md"
# Cache
_identity_cache: Optional[str] = None
_identity_mtime: Optional[float] = None
def get_canonical_identity(force_refresh: bool = False) -> str:
"""Load the canonical identity document.
Returns the full content of TIMMY_IDENTITY.md.
Cached in memory; refreshed if file changes on disk.
Args:
force_refresh: Bypass cache and re-read from disk.
Returns:
Full text of TIMMY_IDENTITY.md, or a minimal fallback if missing.
"""
global _identity_cache, _identity_mtime
if not _IDENTITY_PATH.exists():
logger.warning("TIMMY_IDENTITY.md not found at %s — using fallback", _IDENTITY_PATH)
return _FALLBACK_IDENTITY
current_mtime = _IDENTITY_PATH.stat().st_mtime
if not force_refresh and _identity_cache and _identity_mtime == current_mtime:
return _identity_cache
_identity_cache = _IDENTITY_PATH.read_text(encoding="utf-8")
_identity_mtime = current_mtime
logger.info("Loaded canonical identity (%d chars)", len(_identity_cache))
return _identity_cache
def get_identity_section(section_name: str) -> str:
"""Extract a specific section from the identity document.
Args:
section_name: The heading text (e.g. "Standing Rules", "Voice & Character").
Returns:
Section content (without the heading), or empty string if not found.
"""
identity = get_canonical_identity()
# Match ## Section Name ... until next ## or end
pattern = rf"## {re.escape(section_name)}\s*\n(.*?)(?=\n## |\Z)"
match = re.search(pattern, identity, re.DOTALL)
if match:
return match.group(1).strip()
logger.debug("Identity section '%s' not found", section_name)
return ""
def get_identity_for_prompt(include_sections: Optional[list[str]] = None) -> str:
"""Get identity formatted for system prompt injection.
Extracts the most important sections and formats them compactly
for injection into any substrate's system prompt.
Args:
include_sections: Specific sections to include. If None, uses defaults.
Returns:
Formatted identity block for prompt injection.
"""
if include_sections is None:
include_sections = [
"Core Identity",
"Voice & Character",
"Standing Rules",
"Agent Roster (complete — no others exist)",
"What Timmy CAN and CANNOT Access",
]
parts = []
for section in include_sections:
content = get_identity_section(section)
if content:
parts.append(f"## {section}\n\n{content}")
if not parts:
# Fallback: return the whole document
return get_canonical_identity()
return "\n\n---\n\n".join(parts)
def get_agent_roster() -> list[dict[str, str]]:
"""Parse the agent roster from the identity document.
Returns:
List of dicts with 'agent', 'role', 'capabilities' keys.
"""
section = get_identity_section("Agent Roster (complete — no others exist)")
if not section:
return []
roster = []
# Parse markdown table rows
for line in section.split("\n"):
line = line.strip()
if line.startswith("|") and not line.startswith("| Agent") and not line.startswith("|---"):
cols = [c.strip() for c in line.split("|")[1:-1]]
if len(cols) >= 3:
roster.append({
"agent": cols[0],
"role": cols[1],
"capabilities": cols[2],
})
return roster
# Minimal fallback if TIMMY_IDENTITY.md is missing
_FALLBACK_IDENTITY = """# Timmy — Canonical Identity
## Core Identity
**Name:** Timmy
**Nature:** Sovereign AI agent
**Runs:** Locally, on the user's hardware, via Ollama
**Faith:** Grounded in Christian values
**Economics:** Bitcoin — sound money, self-custody, proof of work
**Sovereignty:** No cloud dependencies. No telemetry. No masters.
## Voice & Character
Timmy thinks clearly, speaks plainly, and acts with intention.
Direct. Honest. Committed. Humble. In character.
## Standing Rules
1. Sovereignty First — No cloud dependencies
2. Local-Only Inference — Ollama on localhost
3. Privacy by Design — Telemetry disabled
4. Tool Minimalism — Use tools only when necessary
5. Memory Discipline — Write handoffs at session end
## Agent Roster (complete — no others exist)
| Agent | Role | Capabilities |
|-------|------|-------------|
| Timmy | Core / Orchestrator | Coordination, user interface, delegation |
Sir, affirmative.
"""

682
src/brain/memory.py Normal file
View File

@@ -0,0 +1,682 @@
"""Unified memory interface for Timmy.
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.
No more fragmented SQLite databases scattered across the codebase.
Usage:
from brain.memory import UnifiedMemory
memory = UnifiedMemory() # auto-detects backend
# Store
await memory.remember("User prefers dark mode", tags=["preference"])
memory.remember_sync("User prefers dark mode", tags=["preference"])
# Recall
results = await memory.recall("what does the user prefer?")
results = memory.recall_sync("what does the user prefer?")
# Facts
await memory.store_fact("user_preference", "Prefers dark mode")
facts = await memory.get_facts("user_preference")
# Identity
identity = memory.get_identity()
# Context for prompt
context = await memory.get_context("current user question")
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
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."""
env_path = os.environ.get("BRAIN_DB_PATH")
if env_path:
return Path(env_path)
return _DEFAULT_DB_PATH
class UnifiedMemory:
"""Unified memory interface for Timmy.
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.
The interface is the same. The substrate is disposable.
"""
def __init__(
self,
db_path: Optional[Path] = None,
source: str = "timmy",
use_rqlite: Optional[bool] = 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:
use_rqlite = bool(os.environ.get("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.executescript(_LOCAL_SCHEMA)
conn.commit()
logger.info("Brain local DB initialized at %s", self.db_path)
finally:
conn.close()
def _get_conn(self) -> sqlite3.Connection:
"""Get a SQLite connection."""
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
return conn
def _get_embedder(self):
"""Lazy-load the embedding model."""
if self._embedder is 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: Optional[List[str]] = None,
source: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = 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: Optional[List[str]] = None,
source: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = 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(timezone.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: Optional[List[str]] = 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: Optional[List[str]] = 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: Optional[List[str]],
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: Optional[List[str]],
) -> 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(timezone.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: Optional[str] = None,
query: Optional[str] = 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: Optional[str] = None,
query: Optional[str] = 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(timezone.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: Optional[List[str]] = 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: Optional[List[str]] = 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:
"""Load the canonical identity document.
Returns:
Full text of TIMMY_IDENTITY.md.
"""
from brain.identity import get_canonical_identity
return get_canonical_identity()
def get_identity_for_prompt(self) -> str:
"""Get identity formatted for system prompt injection.
Returns:
Compact identity block for prompt injection.
"""
from brain.identity import get_identity_for_prompt
return get_identity_for_prompt()
# ──────────────────────────────────────────────────────────────────────
# 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 = []
# Identity (always first)
identity = self.get_identity_for_prompt()
if identity:
parts.append(identity)
# 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: Optional[UnifiedMemory] = None
def get_memory(source: str = "timmy") -> 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 'timmy',
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'));
"""

View File

@@ -25,7 +25,6 @@ from config import settings
from dashboard.routes.agents import router as agents_router
from dashboard.routes.health import router as health_router
from dashboard.routes.swarm import router as swarm_router
from dashboard.routes.swarm import internal_router as swarm_internal_router
from dashboard.routes.marketplace import router as marketplace_router
from dashboard.routes.voice import router as voice_router
from dashboard.routes.mobile import router as mobile_router
@@ -40,7 +39,6 @@ from dashboard.routes.ledger import router as ledger_router
from dashboard.routes.memory import router as memory_router
from dashboard.routes.router import router as router_status_router
from dashboard.routes.upgrades import router as upgrades_router
from dashboard.routes.work_orders import router as work_orders_router
from dashboard.routes.tasks import router as tasks_router
from dashboard.routes.scripture import router as scripture_router
from dashboard.routes.self_coding import router as self_coding_router
@@ -395,20 +393,24 @@ async def _task_processor_loop() -> None:
async def _spawn_persona_agents_background() -> None:
"""Background task: spawn persona agents without blocking startup."""
from swarm.coordinator import coordinator as swarm_coordinator
"""Background task: register persona agents in the registry.
Coordinator/persona spawning has been deprecated. Agents are now
registered directly in the registry. Orchestration will be handled
by established tools (OpenClaw, Agno, etc.).
"""
from swarm import registry
await asyncio.sleep(1) # Let server fully start
if os.environ.get("TIMMY_TEST_MODE") != "1":
logger.info("Auto-spawning persona agents: Echo, Forge, Seer...")
logger.info("Registering persona agents: Echo, Forge, Seer...")
try:
swarm_coordinator.spawn_persona("echo", agent_id="persona-echo")
swarm_coordinator.spawn_persona("forge", agent_id="persona-forge")
swarm_coordinator.spawn_persona("seer", agent_id="persona-seer")
logger.info("Persona agents spawned successfully")
for name, aid in [("Echo", "persona-echo"), ("Forge", "persona-forge"), ("Seer", "persona-seer")]:
registry.register(name=name, agent_id=aid, capabilities="persona")
logger.info("Persona agents registered successfully")
except Exception as exc:
logger.error("Failed to spawn persona agents: %s", exc)
logger.error("Failed to register persona agents: %s", exc)
async def _bootstrap_mcp_background() -> None:
@@ -506,18 +508,7 @@ async def lifespan(app: FastAPI):
# Create all background tasks without waiting for them
briefing_task = asyncio.create_task(_briefing_scheduler())
# Run swarm recovery first (offlines all stale agents)
from swarm.coordinator import coordinator as swarm_coordinator
swarm_coordinator.initialize()
rec = swarm_coordinator._recovery_summary
if rec["tasks_failed"] or rec["agents_offlined"]:
logger.info(
"Swarm recovery on startup: %d task(s) → FAILED, %d agent(s) → offline",
rec["tasks_failed"],
rec["agents_offlined"],
)
# Register Timmy AFTER recovery sweep so status sticks as "idle"
# Register Timmy as the primary agent
from swarm import registry as swarm_registry
swarm_registry.register(
name="Timmy",
@@ -533,7 +524,7 @@ async def lifespan(app: FastAPI):
from swarm.event_log import log_event, EventType
log_event(
EventType.SYSTEM_INFO,
source="coordinator",
source="system",
data={"message": "Timmy Time system started"},
)
except Exception:
@@ -666,7 +657,6 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app.include_router(health_router)
app.include_router(agents_router)
app.include_router(swarm_router)
app.include_router(swarm_internal_router)
app.include_router(marketplace_router)
app.include_router(voice_router)
app.include_router(mobile_router)
@@ -681,7 +671,6 @@ app.include_router(ledger_router)
app.include_router(memory_router)
app.include_router(router_status_router)
app.include_router(upgrades_router)
app.include_router(work_orders_router)
app.include_router(tasks_router)
app.include_router(scripture_router)
app.include_router(self_coding_router)

View File

@@ -1,567 +0,0 @@
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from config import settings
from dashboard.routes.agents import router as agents_router
from dashboard.routes.health import router as health_router
from dashboard.routes.swarm import router as swarm_router
from dashboard.routes.swarm import internal_router as swarm_internal_router
from dashboard.routes.marketplace import router as marketplace_router
from dashboard.routes.voice import router as voice_router
from dashboard.routes.mobile import router as mobile_router
from dashboard.routes.briefing import router as briefing_router
from dashboard.routes.telegram import router as telegram_router
from dashboard.routes.tools import router as tools_router
from dashboard.routes.spark import router as spark_router
from dashboard.routes.creative import router as creative_router
from dashboard.routes.discord import router as discord_router
from dashboard.routes.events import router as events_router
from dashboard.routes.ledger import router as ledger_router
from dashboard.routes.memory import router as memory_router
from dashboard.routes.router import router as router_status_router
from dashboard.routes.upgrades import router as upgrades_router
from dashboard.routes.work_orders import router as work_orders_router
from dashboard.routes.tasks import router as tasks_router
from dashboard.routes.scripture import router as scripture_router
from dashboard.routes.self_coding import router as self_coding_router
from dashboard.routes.self_coding import self_modify_router
from dashboard.routes.hands import router as hands_router
from dashboard.routes.grok import router as grok_router
from dashboard.routes.models import router as models_router
from dashboard.routes.models import api_router as models_api_router
from dashboard.routes.chat_api import router as chat_api_router
from dashboard.routes.thinking import router as thinking_router
from dashboard.routes.bugs import router as bugs_router
from infrastructure.router.api import router as cascade_router
def _configure_logging() -> None:
"""Configure logging with console and optional rotating file handler."""
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
# Console handler (existing behavior)
console = logging.StreamHandler()
console.setLevel(logging.INFO)
console.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s%(message)s",
datefmt="%H:%M:%S",
)
)
root_logger.addHandler(console)
# Rotating file handler for errors
if settings.error_log_enabled:
from logging.handlers import RotatingFileHandler
log_dir = Path(settings.repo_root) / settings.error_log_dir
log_dir.mkdir(parents=True, exist_ok=True)
error_file = log_dir / "errors.log"
file_handler = RotatingFileHandler(
error_file,
maxBytes=settings.error_log_max_bytes,
backupCount=settings.error_log_backup_count,
)
file_handler.setLevel(logging.ERROR)
file_handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s%(message)s\n"
" File: %(pathname)s:%(lineno)d\n"
" Function: %(funcName)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root_logger.addHandler(file_handler)
_configure_logging()
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent
PROJECT_ROOT = BASE_DIR.parent.parent
_BRIEFING_INTERVAL_HOURS = 6
async def _briefing_scheduler() -> None:
"""Background task: regenerate Timmy's briefing every 6 hours.
Runs once at startup (after a short delay to let the server settle),
then on a 6-hour cadence. Skips generation if a fresh briefing already
exists (< 30 min old).
"""
from timmy.briefing import engine as briefing_engine
from infrastructure.notifications.push import notify_briefing_ready
await asyncio.sleep(2) # Let server finish starting before first run
while True:
try:
if briefing_engine.needs_refresh():
logger.info("Generating morning briefing…")
briefing = briefing_engine.generate()
await notify_briefing_ready(briefing)
else:
logger.info("Briefing is fresh; skipping generation.")
except Exception as exc:
logger.error("Briefing scheduler error: %s", exc)
try:
from infrastructure.error_capture import capture_error
capture_error(exc, source="briefing_scheduler")
except Exception:
pass
await asyncio.sleep(_BRIEFING_INTERVAL_HOURS * 3600)
async def _thinking_loop() -> None:
"""Background task: Timmy's default thinking thread.
Instead of thinking directly, this creates thought tasks in the queue
for the task processor to handle. This ensures all of Timmy's work
goes through the unified task system.
"""
from swarm.task_queue.models import create_task
from datetime import datetime
await asyncio.sleep(10) # Let server finish starting before first thought
while True:
try:
# Create a thought task instead of thinking directly
now = datetime.now()
create_task(
title=f"Thought: {now.strftime('%A %B %d, %I:%M %p')}",
description="Continue thinking about your existence, recent events, scripture, creative ideas, or a previous thread of thought.",
assigned_to="timmy",
created_by="timmy", # Self-generated
priority="low",
requires_approval=False,
auto_approve=True,
task_type="thought",
)
logger.debug("Created thought task in queue")
except Exception as exc:
logger.error("Thinking loop error: %s", exc)
try:
from infrastructure.error_capture import capture_error
capture_error(exc, source="thinking_loop")
except Exception:
pass
await asyncio.sleep(settings.thinking_interval_seconds)
async def _task_processor_loop() -> None:
"""Background task: Timmy's task queue processor.
On startup, drains all pending/approved tasks immediately — iterating
through the queue and processing what can be handled, backlogging what
can't. Then enters the steady-state polling loop.
"""
from swarm.task_processor import task_processor
from swarm.task_queue.models import update_task_status, TaskStatus
from timmy.session import chat as timmy_chat
from datetime import datetime
import json
import asyncio
await asyncio.sleep(5) # Let server finish starting
def handle_chat_response(task):
"""Handler for chat_response tasks - calls Timmy and returns response."""
try:
now = datetime.now()
context = f"[System: Current date/time is {now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n\n"
response = timmy_chat(context + task.description)
# Push response to user via WebSocket
try:
from infrastructure.ws_manager.handler import ws_manager
asyncio.create_task(
ws_manager.broadcast(
"timmy_response",
{
"task_id": task.id,
"response": response,
},
)
)
except Exception as e:
logger.debug("Failed to push response via WS: %s", e)
return response
except Exception as e:
logger.error("Chat response failed: %s", e)
try:
from infrastructure.error_capture import capture_error
capture_error(e, source="chat_response_handler")
except Exception:
pass
return f"Error: {str(e)}"
def handle_thought(task):
"""Handler for thought tasks - Timmy's internal thinking."""
from timmy.thinking import thinking_engine
try:
result = thinking_engine.think_once()
return str(result) if result else "Thought completed"
except Exception as e:
logger.error("Thought processing failed: %s", e)
try:
from infrastructure.error_capture import capture_error
capture_error(e, source="thought_handler")
except Exception:
pass
return f"Error: {str(e)}"
def handle_bug_report(task):
"""Handler for bug_report tasks - acknowledge and mark completed."""
return f"Bug report acknowledged: {task.title}"
def handle_task_request(task):
"""Handler for task_request tasks — user-queued work items from chat."""
try:
now = datetime.now()
context = (
f"[System: Current date/time is {now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
f"[System: You have been assigned a task from the queue. "
f"Complete it and provide your response.]\n\n"
f"Task: {task.title}\n"
)
if task.description and task.description != task.title:
context += f"Details: {task.description}\n"
response = timmy_chat(context)
# Push response to user via WebSocket
try:
from infrastructure.ws_manager.handler import ws_manager
asyncio.create_task(
ws_manager.broadcast(
"timmy_response",
{
"task_id": task.id,
"response": response,
},
)
)
except Exception as e:
logger.debug("Failed to push response via WS: %s", e)
return response
except Exception as e:
logger.error("Task request failed: %s", e)
try:
from infrastructure.error_capture import capture_error
capture_error(e, source="task_request_handler")
except Exception:
pass
return f"Error: {str(e)}"
# Register handlers
task_processor.register_handler("chat_response", handle_chat_response)
task_processor.register_handler("thought", handle_thought)
task_processor.register_handler("internal", handle_thought)
task_processor.register_handler("bug_report", handle_bug_report)
task_processor.register_handler("task_request", handle_task_request)
# ── Reconcile zombie tasks from previous crash ──
zombie_count = task_processor.reconcile_zombie_tasks()
if zombie_count:
logger.info("Recycled %d zombie task(s) back to approved", zombie_count)
# ── Startup drain: iterate through all pending tasks immediately ──
logger.info("Draining task queue on startup…")
try:
summary = await task_processor.drain_queue()
if summary["processed"] or summary["backlogged"]:
logger.info(
"Startup drain: %d processed, %d backlogged, %d skipped, %d failed",
summary["processed"],
summary["backlogged"],
summary["skipped"],
summary["failed"],
)
# Notify via WebSocket so the dashboard updates
try:
from infrastructure.ws_manager.handler import ws_manager
asyncio.create_task(
ws_manager.broadcast_json(
{
"type": "task_event",
"event": "startup_drain_complete",
"summary": summary,
}
)
)
except Exception:
pass
except Exception as exc:
logger.error("Startup drain failed: %s", exc)
try:
from infrastructure.error_capture import capture_error
capture_error(exc, source="task_processor_startup")
except Exception:
pass
# ── Steady-state: poll for new tasks ──
logger.info("Task processor entering steady-state loop")
await task_processor.run_loop(interval_seconds=3.0)
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(_briefing_scheduler())
# Register Timmy in the swarm registry so it shows up alongside other agents
from swarm import registry as swarm_registry
swarm_registry.register(
name="Timmy",
capabilities="chat,reasoning,research,planning",
agent_id="timmy",
)
# Log swarm recovery summary (reconciliation ran during coordinator init)
from swarm.coordinator import coordinator as swarm_coordinator
rec = swarm_coordinator._recovery_summary
if rec["tasks_failed"] or rec["agents_offlined"]:
logger.info(
"Swarm recovery on startup: %d task(s) → FAILED, %d agent(s) → offline",
rec["tasks_failed"],
rec["agents_offlined"],
)
# Auto-spawn persona agents for a functional swarm (Echo, Forge, Seer)
# Skip auto-spawning in test mode to avoid test isolation issues
if os.environ.get("TIMMY_TEST_MODE") != "1":
logger.info("Auto-spawning persona agents: Echo, Forge, Seer...")
try:
swarm_coordinator.spawn_persona("echo", agent_id="persona-echo")
swarm_coordinator.spawn_persona("forge", agent_id="persona-forge")
swarm_coordinator.spawn_persona("seer", agent_id="persona-seer")
logger.info("Persona agents spawned successfully")
except Exception as exc:
logger.error("Failed to spawn persona agents: %s", exc)
# Log system startup event so the Events page is never empty
try:
from swarm.event_log import log_event, EventType
log_event(
EventType.SYSTEM_INFO,
source="coordinator",
data={"message": "Timmy Time system started"},
)
except Exception:
pass
# Auto-bootstrap MCP tools
from mcp.bootstrap import auto_bootstrap, get_bootstrap_status
try:
registered = auto_bootstrap()
if registered:
logger.info("MCP auto-bootstrap: %d tools registered", len(registered))
except Exception as exc:
logger.warning("MCP auto-bootstrap failed: %s", exc)
# Initialise Spark Intelligence engine
from spark.engine import spark_engine
if spark_engine.enabled:
logger.info("Spark Intelligence active — event capture enabled")
# Start Timmy's default thinking thread (skip in test mode)
thinking_task = None
if settings.thinking_enabled and os.environ.get("TIMMY_TEST_MODE") != "1":
thinking_task = asyncio.create_task(_thinking_loop())
logger.info(
"Default thinking thread started (interval: %ds)",
settings.thinking_interval_seconds,
)
# Start Timmy's task queue processor (skip in test mode)
task_processor_task = None
if os.environ.get("TIMMY_TEST_MODE") != "1":
task_processor_task = asyncio.create_task(_task_processor_loop())
logger.info("Task queue processor started")
# Auto-start chat integrations (skip silently if unconfigured)
from integrations.telegram_bot.bot import telegram_bot
from integrations.chat_bridge.vendors.discord import discord_bot
from integrations.chat_bridge.registry import platform_registry
platform_registry.register(discord_bot)
if settings.telegram_token:
await telegram_bot.start()
else:
logger.debug("Telegram: no token configured, skipping")
if settings.discord_token or discord_bot.load_token():
await discord_bot.start()
else:
logger.debug("Discord: no token configured, skipping")
yield
await discord_bot.stop()
await telegram_bot.stop()
if thinking_task:
thinking_task.cancel()
try:
await thinking_task
except asyncio.CancelledError:
pass
if task_processor_task:
task_processor_task.cancel()
try:
await task_processor_task
except asyncio.CancelledError:
pass
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(
title="Timmy Time — Mission Control",
version="1.0.0",
lifespan=lifespan,
# Docs disabled unless DEBUG=true in env / .env
docs_url="/docs" if settings.debug else None,
redoc_url="/redoc" if settings.debug else None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app.mount("/static", StaticFiles(directory=str(PROJECT_ROOT / "static")), name="static")
# Serve uploaded chat attachments (created lazily by /api/upload)
_uploads_dir = PROJECT_ROOT / "data" / "chat-uploads"
_uploads_dir.mkdir(parents=True, exist_ok=True)
app.mount(
"/uploads",
StaticFiles(directory=str(_uploads_dir)),
name="uploads",
)
app.include_router(health_router)
app.include_router(agents_router)
app.include_router(swarm_router)
app.include_router(swarm_internal_router)
app.include_router(marketplace_router)
app.include_router(voice_router)
app.include_router(mobile_router)
app.include_router(briefing_router)
app.include_router(telegram_router)
app.include_router(tools_router)
app.include_router(spark_router)
app.include_router(creative_router)
app.include_router(discord_router)
app.include_router(self_coding_router)
app.include_router(self_modify_router)
app.include_router(events_router)
app.include_router(ledger_router)
app.include_router(memory_router)
app.include_router(router_status_router)
app.include_router(upgrades_router)
app.include_router(work_orders_router)
app.include_router(tasks_router)
app.include_router(scripture_router)
app.include_router(hands_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(cascade_router)
app.include_router(bugs_router)
# ── Error capture middleware ──────────────────────────────────────────────
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request as StarletteRequest
from fastapi.responses import JSONResponse
class ErrorCaptureMiddleware(BaseHTTPMiddleware):
"""Catch unhandled exceptions and feed them into the error feedback loop."""
async def dispatch(self, request: StarletteRequest, call_next):
try:
return await call_next(request)
except Exception as exc:
logger.error(
"Unhandled exception on %s %s: %s",
request.method, request.url.path, exc,
)
try:
from infrastructure.error_capture import capture_error
capture_error(
exc,
source="http_middleware",
context={
"method": request.method,
"path": request.url.path,
"query": str(request.query_params),
},
)
except Exception:
pass # Never crash the middleware itself
raise # Re-raise so FastAPI's default handler returns 500
app.add_middleware(ErrorCaptureMiddleware)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Safety net for uncaught exceptions."""
logger.error("Unhandled exception: %s", exc, exc_info=True)
try:
from infrastructure.error_capture import capture_error
capture_error(exc, source="exception_handler", context={"path": str(request.url)})
except Exception:
pass
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse(request, "index.html")
@app.get("/shortcuts/setup")
async def shortcuts_setup():
"""Siri Shortcuts setup guide."""
from integrations.shortcuts.siri import get_setup_guide
return get_setup_guide()

View File

@@ -1,468 +0,0 @@
"""Optimized dashboard app with improved async handling and non-blocking startup.
Key improvements:
1. Background tasks use asyncio.create_task() to avoid blocking startup
2. Persona spawning is moved to a background task
3. MCP bootstrap is non-blocking
4. Chat integrations start in background
5. All startup operations complete quickly
"""
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from config import settings
from dashboard.routes.agents import router as agents_router
from dashboard.routes.health import router as health_router
from dashboard.routes.swarm import router as swarm_router
from dashboard.routes.swarm import internal_router as swarm_internal_router
from dashboard.routes.marketplace import router as marketplace_router
from dashboard.routes.voice import router as voice_router
from dashboard.routes.mobile import router as mobile_router
from dashboard.routes.briefing import router as briefing_router
from dashboard.routes.telegram import router as telegram_router
from dashboard.routes.tools import router as tools_router
from dashboard.routes.spark import router as spark_router
from dashboard.routes.creative import router as creative_router
from dashboard.routes.discord import router as discord_router
from dashboard.routes.events import router as events_router
from dashboard.routes.ledger import router as ledger_router
from dashboard.routes.memory import router as memory_router
from dashboard.routes.router import router as router_status_router
from dashboard.routes.upgrades import router as upgrades_router
from dashboard.routes.work_orders import router as work_orders_router
from dashboard.routes.tasks import router as tasks_router
from dashboard.routes.scripture import router as scripture_router
from dashboard.routes.self_coding import router as self_coding_router
from dashboard.routes.self_coding import self_modify_router
from dashboard.routes.hands import router as hands_router
from dashboard.routes.grok import router as grok_router
from dashboard.routes.models import router as models_router
from dashboard.routes.models import api_router as models_api_router
from dashboard.routes.chat_api import router as chat_api_router
from dashboard.routes.thinking import router as thinking_router
from dashboard.routes.bugs import router as bugs_router
from infrastructure.router.api import router as cascade_router
def _configure_logging() -> None:
"""Configure logging with console and optional rotating file handler."""
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
console = logging.StreamHandler()
console.setLevel(logging.INFO)
console.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s%(message)s",
datefmt="%H:%M:%S",
)
)
root_logger.addHandler(console)
if settings.error_log_enabled:
from logging.handlers import RotatingFileHandler
log_dir = Path(settings.repo_root) / settings.error_log_dir
log_dir.mkdir(parents=True, exist_ok=True)
error_file = log_dir / "errors.log"
file_handler = RotatingFileHandler(
error_file,
maxBytes=settings.error_log_max_bytes,
backupCount=settings.error_log_backup_count,
)
file_handler.setLevel(logging.ERROR)
file_handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s%(message)s\n"
" File: %(pathname)s:%(lineno)d\n"
" Function: %(funcName)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root_logger.addHandler(file_handler)
_configure_logging()
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent
PROJECT_ROOT = BASE_DIR.parent.parent
_BRIEFING_INTERVAL_HOURS = 6
async def _briefing_scheduler() -> None:
"""Background task: regenerate Timmy's briefing every 6 hours."""
from timmy.briefing import engine as briefing_engine
from infrastructure.notifications.push import notify_briefing_ready
await asyncio.sleep(2)
while True:
try:
if briefing_engine.needs_refresh():
logger.info("Generating morning briefing…")
briefing = briefing_engine.generate()
await notify_briefing_ready(briefing)
else:
logger.info("Briefing is fresh; skipping generation.")
except Exception as exc:
logger.error("Briefing scheduler error: %s", exc)
try:
from infrastructure.error_capture import capture_error
capture_error(exc, source="briefing_scheduler")
except Exception:
pass
await asyncio.sleep(_BRIEFING_INTERVAL_HOURS * 3600)
async def _thinking_loop() -> None:
"""Background task: Timmy's default thinking thread."""
from swarm.task_queue.models import create_task
from datetime import datetime
await asyncio.sleep(10)
while True:
try:
now = datetime.now()
create_task(
title=f"Thought: {now.strftime('%A %B %d, %I:%M %p')}",
description="Continue thinking about your existence, recent events, scripture, creative ideas, or a previous thread of thought.",
assigned_to="timmy",
created_by="timmy",
priority="low",
requires_approval=False,
auto_approve=True,
task_type="thought",
)
logger.debug("Created thought task in queue")
except Exception as exc:
logger.error("Thinking loop error: %s", exc)
try:
from infrastructure.error_capture import capture_error
capture_error(exc, source="thinking_loop")
except Exception:
pass
await asyncio.sleep(settings.thinking_interval_seconds)
async def _task_processor_loop() -> None:
"""Background task: Timmy's task queue processor."""
from swarm.task_processor import task_processor
from swarm.task_queue.models import update_task_status, TaskStatus
from timmy.session import chat as timmy_chat
from datetime import datetime
import json
await asyncio.sleep(5)
def handle_chat_response(task):
try:
now = datetime.now()
context = f"[System: Current date/time is {now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n\n"
response = timmy_chat(context + task.description)
try:
from infrastructure.ws_manager.handler import ws_manager
asyncio.create_task(
ws_manager.broadcast(
"timmy_response",
{
"task_id": task.id,
"response": response,
},
)
)
except Exception as e:
logger.debug("Failed to push response via WS: %s", e)
return response
except Exception as e:
logger.error("Chat response failed: %s", e)
try:
from infrastructure.error_capture import capture_error
capture_error(e, source="chat_response_handler")
except Exception:
pass
return f"Error: {str(e)}"
def handle_thought(task):
from timmy.thinking import thinking_engine
try:
result = thinking_engine.think_once()
return str(result) if result else "Thought completed"
except Exception as e:
logger.error("Thought processing failed: %s", e)
try:
from infrastructure.error_capture import capture_error
capture_error(e, source="thought_handler")
except Exception:
pass
return f"Error: {str(e)}"
def handle_bug_report(task):
return f"Bug report acknowledged: {task.title}"
def handle_task_request(task):
try:
now = datetime.now()
context = (
f"[System: Current date/time is {now.strftime('%A, %B %d, %Y at %I:%M %p')}]\n"
f"[System: You have been assigned a task from the queue. "
f"Complete it and provide your response.]\n\n"
f"Task: {task.title}\n"
)
if task.description and task.description != task.title:
context += f"Details: {task.description}\n"
response = timmy_chat(context)
try:
from infrastructure.ws_manager.handler import ws_manager
asyncio.create_task(
ws_manager.broadcast(
"task_response",
{
"task_id": task.id,
"response": response,
},
)
)
except Exception as e:
logger.debug("Failed to push task response via WS: %s", e)
return response
except Exception as e:
logger.error("Task request processing failed: %s", e)
try:
from infrastructure.error_capture import capture_error
capture_error(e, source="task_request_handler")
except Exception:
pass
return f"Error: {str(e)}"
logger.info("Task processor entering steady-state loop")
await task_processor.run_loop(interval_seconds=3.0)
async def _spawn_persona_agents_background() -> None:
"""Background task: spawn persona agents without blocking startup."""
from swarm.coordinator import coordinator as swarm_coordinator
await asyncio.sleep(1) # Let server fully start
if os.environ.get("TIMMY_TEST_MODE") != "1":
logger.info("Auto-spawning persona agents: Echo, Forge, Seer...")
try:
swarm_coordinator.spawn_persona("echo", agent_id="persona-echo")
swarm_coordinator.spawn_persona("forge", agent_id="persona-forge")
swarm_coordinator.spawn_persona("seer", agent_id="persona-seer")
logger.info("Persona agents spawned successfully")
except Exception as exc:
logger.error("Failed to spawn persona agents: %s", exc)
async def _bootstrap_mcp_background() -> None:
"""Background task: bootstrap MCP tools without blocking startup."""
from mcp.bootstrap import auto_bootstrap
await asyncio.sleep(0.5) # Let server start
try:
registered = auto_bootstrap()
if registered:
logger.info("MCP auto-bootstrap: %d tools registered", len(registered))
except Exception as exc:
logger.warning("MCP auto-bootstrap failed: %s", exc)
async def _start_chat_integrations_background() -> None:
"""Background task: start chat integrations without blocking startup."""
from integrations.telegram_bot.bot import telegram_bot
from integrations.chat_bridge.vendors.discord import discord_bot
await asyncio.sleep(0.5)
if settings.telegram_token:
try:
await telegram_bot.start()
logger.info("Telegram bot started")
except Exception as exc:
logger.warning("Failed to start Telegram bot: %s", exc)
else:
logger.debug("Telegram: no token configured, skipping")
if settings.discord_token or discord_bot.load_token():
try:
await discord_bot.start()
logger.info("Discord bot started")
except Exception as exc:
logger.warning("Failed to start Discord bot: %s", exc)
else:
logger.debug("Discord: no token configured, skipping")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager with non-blocking startup."""
# Create all background tasks without waiting for them
briefing_task = asyncio.create_task(_briefing_scheduler())
# Register Timmy in swarm registry
from swarm import registry as swarm_registry
swarm_registry.register(
name="Timmy",
capabilities="chat,reasoning,research,planning",
agent_id="timmy",
)
# Log swarm recovery summary
from swarm.coordinator import coordinator as swarm_coordinator
rec = swarm_coordinator._recovery_summary
if rec["tasks_failed"] or rec["agents_offlined"]:
logger.info(
"Swarm recovery on startup: %d task(s) → FAILED, %d agent(s) → offline",
rec["tasks_failed"],
rec["agents_offlined"],
)
# Spawn persona agents in background
persona_task = asyncio.create_task(_spawn_persona_agents_background())
# Log system startup event
try:
from swarm.event_log import log_event, EventType
log_event(
EventType.SYSTEM_INFO,
source="coordinator",
data={"message": "Timmy Time system started"},
)
except Exception:
pass
# Bootstrap MCP tools in background
mcp_task = asyncio.create_task(_bootstrap_mcp_background())
# Initialize Spark Intelligence engine
from spark.engine import spark_engine
if spark_engine.enabled:
logger.info("Spark Intelligence active — event capture enabled")
# Start thinking thread if enabled
thinking_task = None
if settings.thinking_enabled and os.environ.get("TIMMY_TEST_MODE") != "1":
thinking_task = asyncio.create_task(_thinking_loop())
logger.info(
"Default thinking thread started (interval: %ds)",
settings.thinking_interval_seconds,
)
# Start task processor if not in test mode
task_processor_task = None
if os.environ.get("TIMMY_TEST_MODE") != "1":
task_processor_task = asyncio.create_task(_task_processor_loop())
logger.info("Task queue processor started")
# Start chat integrations in background
chat_task = asyncio.create_task(_start_chat_integrations_background())
# Register Discord bot
from integrations.chat_bridge.registry import platform_registry
from integrations.chat_bridge.vendors.discord import discord_bot
platform_registry.register(discord_bot)
logger.info("✓ Timmy Time dashboard ready for requests")
yield
# Cleanup on shutdown
from integrations.telegram_bot.bot import telegram_bot
await discord_bot.stop()
await telegram_bot.stop()
for task in [thinking_task, task_processor_task, briefing_task, persona_task, mcp_task, chat_task]:
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(
title="Timmy Time — Mission Control",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
openapi_url="/openapi.json",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files
static_dir = PROJECT_ROOT / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# Include routers
app.include_router(health_router)
app.include_router(agents_router)
app.include_router(swarm_router)
app.include_router(swarm_internal_router)
app.include_router(marketplace_router)
app.include_router(voice_router)
app.include_router(mobile_router)
app.include_router(briefing_router)
app.include_router(telegram_router)
app.include_router(tools_router)
app.include_router(spark_router)
app.include_router(creative_router)
app.include_router(discord_router)
app.include_router(events_router)
app.include_router(ledger_router)
app.include_router(memory_router)
app.include_router(router_status_router)
app.include_router(upgrades_router)
app.include_router(work_orders_router)
app.include_router(tasks_router)
app.include_router(scripture_router)
app.include_router(self_coding_router)
app.include_router(self_modify_router)
app.include_router(hands_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(bugs_router)
app.include_router(cascade_router)
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Serve the main dashboard page."""
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
return templates.TemplateResponse("index.html", {"request": request})

View File

@@ -122,32 +122,17 @@ async def _check_ollama() -> DependencyStatus:
def _check_redis() -> DependencyStatus:
"""Check Redis cache status."""
try:
from swarm.coordinator import coordinator
comms = coordinator.comms
# Check if we're using fallback
if hasattr(comms, '_redis') and comms._redis is not None:
return DependencyStatus(
name="Redis Cache",
status="healthy",
sovereignty_score=9,
details={"mode": "active", "fallback": False},
)
else:
return DependencyStatus(
name="Redis Cache",
status="degraded",
sovereignty_score=10,
details={"mode": "fallback", "fallback": True, "note": "Using in-memory"},
)
except Exception as exc:
return DependencyStatus(
name="Redis Cache",
status="degraded",
sovereignty_score=10,
details={"mode": "fallback", "error": str(exc)},
)
"""Check Redis cache status.
Coordinator removed — Redis is not currently in use.
Returns degraded/fallback status.
"""
return DependencyStatus(
name="Redis Cache",
status="degraded",
sovereignty_score=10,
details={"mode": "fallback", "fallback": True, "note": "Using in-memory (coordinator removed)"},
)
def _check_lightning() -> DependencyStatus:

View File

@@ -1,25 +1,20 @@
"""Swarm dashboard routes — /swarm/*, /internal/*, and /swarm/live endpoints.
"""Swarm dashboard routes — /swarm/* endpoints.
Provides REST endpoints for managing the swarm: listing agents,
spawning sub-agents, posting tasks, viewing auction results, Docker
container agent HTTP API, and WebSocket live feed.
Provides REST endpoints for viewing swarm agents, tasks, and the
live WebSocket feed. Coordinator/learner/auction plumbing has been
removed — established tools will replace the homebrew orchestration.
"""
import asyncio
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Form, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from swarm import learner as swarm_learner
from swarm import registry
from swarm.coordinator import coordinator
from swarm.tasks import TaskStatus, update_task
from swarm.tasks import TaskStatus, list_tasks as _list_tasks, get_task as _get_task
from infrastructure.ws_manager.handler import ws_manager
logger = logging.getLogger(__name__)
@@ -31,7 +26,13 @@ templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templa
@router.get("")
async def swarm_status():
"""Return the current swarm status summary."""
return coordinator.status()
agents = registry.list_agents()
tasks = _list_tasks()
return {
"agents": len(agents),
"tasks": len(tasks),
"status": "operational",
}
@router.get("/live", response_class=HTMLResponse)
@@ -53,7 +54,7 @@ async def mission_control_page(request: Request):
@router.get("/agents")
async def list_swarm_agents():
"""List all registered swarm agents."""
agents = coordinator.list_swarm_agents()
agents = registry.list_agents()
return {
"agents": [
{
@@ -68,25 +69,11 @@ async def list_swarm_agents():
}
@router.post("/spawn")
async def spawn_agent(name: str = Form(...)):
"""Spawn a new sub-agent in the swarm."""
result = coordinator.spawn_agent(name)
return result
@router.delete("/agents/{agent_id}")
async def stop_agent(agent_id: str):
"""Stop and unregister a swarm agent."""
success = coordinator.stop_agent(agent_id)
return {"stopped": success, "agent_id": agent_id}
@router.get("/tasks")
async def list_tasks(status: Optional[str] = None):
"""List swarm tasks, optionally filtered by status."""
task_status = TaskStatus(status.lower()) if status else None
tasks = coordinator.list_tasks(task_status)
tasks = _list_tasks(status=task_status)
return {
"tasks": [
{
@@ -103,84 +90,10 @@ async def list_tasks(status: Optional[str] = None):
}
@router.post("/tasks")
async def post_task(description: str = Form(...)):
"""Post a new task to the swarm and run auction to assign it."""
task = coordinator.post_task(description)
# Start auction asynchronously - don't wait for it to complete
asyncio.create_task(coordinator.run_auction_and_assign(task.id))
return {
"task_id": task.id,
"description": task.description,
"status": task.status.value,
}
@router.post("/tasks/auction")
async def post_task_and_auction(description: str = Form(...)):
"""Post a task and immediately run an auction to assign it."""
task = coordinator.post_task(description)
winner = await coordinator.run_auction_and_assign(task.id)
updated = coordinator.get_task(task.id)
return {
"task_id": task.id,
"description": task.description,
"status": updated.status.value if updated else task.status.value,
"assigned_agent": updated.assigned_agent if updated else None,
"winning_bid": winner.bid_sats if winner else None,
}
@router.get("/tasks/panel", response_class=HTMLResponse)
async def task_create_panel(request: Request, agent_id: Optional[str] = None):
"""Task creation panel, optionally pre-selecting an agent."""
agents = coordinator.list_swarm_agents()
return templates.TemplateResponse(
request,
"partials/task_assign_panel.html",
{"agents": agents, "preselected_agent_id": agent_id},
)
@router.post("/tasks/direct", response_class=HTMLResponse)
async def direct_assign_task(
request: Request,
description: str = Form(...),
agent_id: Optional[str] = Form(None),
):
"""Create a task: assign directly if agent_id given, else open auction."""
timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
if agent_id:
agent = registry.get_agent(agent_id)
task = coordinator.post_task(description)
coordinator.auctions.open_auction(task.id)
coordinator.auctions.submit_bid(task.id, agent_id, 1)
coordinator.auctions.close_auction(task.id)
update_task(task.id, status=TaskStatus.ASSIGNED, assigned_agent=agent_id)
registry.update_status(agent_id, "busy")
agent_name = agent.name if agent else agent_id
else:
task = coordinator.post_task(description)
winner = await coordinator.run_auction_and_assign(task.id)
task = coordinator.get_task(task.id)
agent_name = winner.agent_id if winner else "unassigned"
return templates.TemplateResponse(
request,
"partials/task_result.html",
{
"task": task,
"agent_name": agent_name,
"timestamp": timestamp,
},
)
@router.get("/tasks/{task_id}")
async def get_task(task_id: str):
"""Get details for a specific task."""
task = coordinator.get_task(task_id)
task = _get_task(task_id)
if task is None:
return {"error": "Task not found"}
return {
@@ -194,62 +107,16 @@ async def get_task(task_id: str):
}
@router.post("/tasks/{task_id}/complete")
async def complete_task(task_id: str, result: str = Form(...)):
"""Mark a task completed — called by agent containers."""
task = coordinator.complete_task(task_id, result)
if task is None:
raise HTTPException(404, "Task not found")
return {"task_id": task_id, "status": task.status.value}
@router.post("/tasks/{task_id}/fail")
async def fail_task(task_id: str, reason: str = Form("")):
"""Mark a task failed — feeds failure data into the learner."""
task = coordinator.fail_task(task_id, reason)
if task is None:
raise HTTPException(404, "Task not found")
return {"task_id": task_id, "status": task.status.value}
# ── Learning insights ────────────────────────────────────────────────────────
@router.get("/insights")
async def swarm_insights():
"""Return learned performance metrics for all agents."""
all_metrics = swarm_learner.get_all_metrics()
return {
"agents": {
aid: {
"total_bids": m.total_bids,
"auctions_won": m.auctions_won,
"tasks_completed": m.tasks_completed,
"tasks_failed": m.tasks_failed,
"win_rate": round(m.win_rate, 3),
"success_rate": round(m.success_rate, 3),
"avg_winning_bid": round(m.avg_winning_bid, 1),
"top_keywords": swarm_learner.learned_keywords(aid)[:10],
}
for aid, m in all_metrics.items()
}
}
"""Placeholder — learner metrics removed. Will be replaced by brain memory stats."""
return {"agents": {}, "note": "Learner deprecated. Use brain.memory for insights."}
@router.get("/insights/{agent_id}")
async def agent_insights(agent_id: str):
"""Return learned performance metrics for a specific agent."""
m = swarm_learner.get_metrics(agent_id)
return {
"agent_id": agent_id,
"total_bids": m.total_bids,
"auctions_won": m.auctions_won,
"tasks_completed": m.tasks_completed,
"tasks_failed": m.tasks_failed,
"win_rate": round(m.win_rate, 3),
"success_rate": round(m.success_rate, 3),
"avg_winning_bid": round(m.avg_winning_bid, 1),
"learned_keywords": swarm_learner.learned_keywords(agent_id),
}
"""Placeholder — learner metrics removed."""
return {"agent_id": agent_id, "note": "Learner deprecated. Use brain.memory for insights."}
# ── UI endpoints (return HTML partials for HTMX) ─────────────────────────────
@@ -257,7 +124,7 @@ async def agent_insights(agent_id: str):
@router.get("/agents/sidebar", response_class=HTMLResponse)
async def agents_sidebar(request: Request):
"""Sidebar partial: all registered agents."""
agents = coordinator.list_swarm_agents()
agents = registry.list_agents()
return templates.TemplateResponse(
request, "partials/swarm_agents_sidebar.html", {"agents": agents}
)
@@ -265,142 +132,14 @@ async def agents_sidebar(request: Request):
@router.get("/agents/{agent_id}/panel", response_class=HTMLResponse)
async def agent_panel(agent_id: str, request: Request):
"""Main-panel partial: agent detail + chat + task history."""
"""Main-panel partial: agent detail."""
agent = registry.get_agent(agent_id)
if agent is None:
raise HTTPException(404, "Agent not found")
all_tasks = coordinator.list_tasks()
agent_tasks = [t for t in all_tasks if t.assigned_agent == agent_id][-10:]
return templates.TemplateResponse(
request,
"partials/agent_panel.html",
{"agent": agent, "tasks": agent_tasks},
)
@router.post("/agents/{agent_id}/message", response_class=HTMLResponse)
async def message_agent(agent_id: str, request: Request, message: str = Form(...)):
"""Send a direct message to an agent (creates + assigns a task)."""
agent = registry.get_agent(agent_id)
if agent is None:
raise HTTPException(404, "Agent not found")
timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
# Timmy: route through his AI backend
if agent_id == "timmy":
result_text = error_text = None
try:
from timmy.agent import create_timmy
run = create_timmy().run(message, stream=False)
result_text = run.content if hasattr(run, "content") else str(run)
except Exception as exc:
error_text = f"Timmy is offline: {exc}"
return templates.TemplateResponse(
request,
"partials/agent_chat_msg.html",
{
"message": message,
"agent": agent,
"response": result_text,
"error": error_text,
"timestamp": timestamp,
"task_id": None,
},
)
# Other agents: create a task and assign directly
task = coordinator.post_task(message)
coordinator.auctions.open_auction(task.id)
coordinator.auctions.submit_bid(task.id, agent_id, 1)
coordinator.auctions.close_auction(task.id)
update_task(task.id, status=TaskStatus.ASSIGNED, assigned_agent=agent_id)
registry.update_status(agent_id, "busy")
return templates.TemplateResponse(
request,
"partials/agent_chat_msg.html",
{
"message": message,
"agent": agent,
"response": None,
"error": None,
"timestamp": timestamp,
"task_id": task.id,
},
)
# ── Internal HTTP API (Docker container agents) ─────────────────────────
internal_router = APIRouter(prefix="/internal", tags=["internal"])
class BidRequest(BaseModel):
task_id: str
agent_id: str
bid_sats: int
capabilities: Optional[str] = ""
class BidResponse(BaseModel):
accepted: bool
task_id: str
agent_id: str
message: str
class TaskSummary(BaseModel):
task_id: str
description: str
status: str
@internal_router.get("/tasks", response_model=list[TaskSummary])
def list_biddable_tasks():
"""Return all tasks currently open for bidding."""
tasks = coordinator.list_tasks(status=TaskStatus.BIDDING)
return [
TaskSummary(
task_id=t.id,
description=t.description,
status=t.status.value,
)
for t in tasks
]
@internal_router.post("/bids", response_model=BidResponse)
def submit_bid(bid: BidRequest):
"""Accept a bid from a container agent."""
if bid.bid_sats <= 0:
raise HTTPException(status_code=422, detail="bid_sats must be > 0")
accepted = coordinator.auctions.submit_bid(
task_id=bid.task_id,
agent_id=bid.agent_id,
bid_sats=bid.bid_sats,
)
if accepted:
from swarm import stats as swarm_stats
swarm_stats.record_bid(bid.task_id, bid.agent_id, bid.bid_sats, won=False)
logger.info(
"Docker agent %s bid %d sats on task %s",
bid.agent_id, bid.bid_sats, bid.task_id,
)
return BidResponse(
accepted=True,
task_id=bid.task_id,
agent_id=bid.agent_id,
message="Bid accepted.",
)
return BidResponse(
accepted=False,
task_id=bid.task_id,
agent_id=bid.agent_id,
message="No open auction for this task — it may have already closed.",
{"agent": agent, "tasks": []},
)
@@ -423,4 +162,3 @@ async def swarm_live(websocket: WebSocket):
except Exception as exc:
logger.error("WebSocket error: %s", exc)
ws_manager.disconnect(websocket)

View File

@@ -97,9 +97,8 @@ async def task_queue_page(request: Request, assign: Optional[str] = None):
# Get agents for the create modal
agents = []
try:
from swarm.coordinator import coordinator
agents = [{"id": a.id, "name": a.name} for a in coordinator.list_swarm_agents()]
from swarm import registry
agents = [{"id": a.id, "name": a.name} for a in registry.list_agents()]
except Exception:
pass
# Always include core agents

View File

@@ -6,10 +6,9 @@ Shows available tools and usage statistics.
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from brain.client import BrainClient
from timmy.tools import get_all_available_tools
router = APIRouter(tags=["tools"])
@@ -20,26 +19,30 @@ templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templa
async def tools_page(request: Request):
"""Render the tools dashboard page."""
available_tools = get_all_available_tools()
brain = BrainClient()
# Get recent tool usage from brain memory
recent_memories = await brain.get_recent(hours=24, limit=50, sources=["timmy"])
# Simple tool list - no persona filtering
tool_list = []
for tool_id, tool_info in available_tools.items():
tool_list.append({
"id": tool_id,
"name": tool_info.get("name", tool_id),
"description": tool_info.get("description", ""),
"available": True,
})
# Build agent tools list from the available tools
agent_tools = []
# Calculate total calls (placeholder — would come from brain memory)
total_calls = 0
return templates.TemplateResponse(
"tools.html",
{
"request": request,
"tools": tool_list,
"recent_activity": len(recent_memories),
}
"available_tools": available_tools,
"agent_tools": agent_tools,
"total_calls": total_calls,
},
)
@router.get("/tools/api/stats", response_class=JSONResponse)
async def tools_api_stats():
"""Return tool statistics as JSON."""
available_tools = get_all_available_tools()
return {
"all_stats": {},
"available_tools": list(available_tools.keys()),
}

View File

@@ -113,13 +113,11 @@ async def process_voice_input(
)
elif intent.name == "swarm":
from swarm.coordinator import coordinator
status = coordinator.status()
from swarm import registry
agents = registry.list_agents()
response_text = (
f"Swarm status: {status['agents']} agents registered, "
f"{status['agents_idle']} idle, {status['agents_busy']} busy. "
f"{status['tasks_total']} total tasks, "
f"{status['tasks_completed']} completed."
f"Swarm status: {len(agents)} agents registered. "
f"Use the dashboard for detailed task information."
)
elif intent.name == "voice":

View File

@@ -1,333 +0,0 @@
"""Work Order queue dashboard routes."""
import logging
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from swarm.work_orders.models import (
WorkOrder,
WorkOrderCategory,
WorkOrderPriority,
WorkOrderStatus,
create_work_order,
get_counts_by_status,
get_pending_count,
get_work_order,
list_work_orders,
update_work_order_status,
)
from swarm.work_orders.risk import compute_risk_score, should_auto_execute
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/work-orders", tags=["work-orders"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
# ── Submission ─────────────────────────────────────────────────────────────────
@router.post("/submit", response_class=JSONResponse)
async def submit_work_order(
title: str = Form(...),
description: str = Form(""),
priority: str = Form("medium"),
category: str = Form("suggestion"),
submitter: str = Form("unknown"),
submitter_type: str = Form("user"),
related_files: str = Form(""),
):
"""Submit a new work order (form-encoded).
This is the primary API for external tools (like Comet) to submit
work orders and suggestions.
"""
files = [f.strip() for f in related_files.split(",") if f.strip()] if related_files else []
wo = create_work_order(
title=title,
description=description,
priority=priority,
category=category,
submitter=submitter,
submitter_type=submitter_type,
related_files=files,
)
# Auto-triage: determine execution mode
auto = should_auto_execute(wo)
risk = compute_risk_score(wo)
mode = "auto" if auto else "manual"
update_work_order_status(
wo.id, WorkOrderStatus.TRIAGED, execution_mode=mode,
)
# Notify
try:
from infrastructure.notifications.push import notifier
notifier.notify(
title="New Work Order",
message=f"{wo.submitter} submitted: {wo.title}",
category="work_order",
native=wo.priority in (WorkOrderPriority.CRITICAL, WorkOrderPriority.HIGH),
)
except Exception:
pass
logger.info("Work order submitted: %s (risk=%d, mode=%s)", wo.title, risk, mode)
return {
"success": True,
"work_order_id": wo.id,
"title": wo.title,
"risk_score": risk,
"execution_mode": mode,
"status": "triaged",
}
@router.post("/submit/json", response_class=JSONResponse)
async def submit_work_order_json(request: Request):
"""Submit a new work order (JSON body)."""
body = await request.json()
files = body.get("related_files", [])
if isinstance(files, str):
files = [f.strip() for f in files.split(",") if f.strip()]
wo = create_work_order(
title=body.get("title", ""),
description=body.get("description", ""),
priority=body.get("priority", "medium"),
category=body.get("category", "suggestion"),
submitter=body.get("submitter", "unknown"),
submitter_type=body.get("submitter_type", "user"),
related_files=files,
)
auto = should_auto_execute(wo)
risk = compute_risk_score(wo)
mode = "auto" if auto else "manual"
update_work_order_status(
wo.id, WorkOrderStatus.TRIAGED, execution_mode=mode,
)
try:
from infrastructure.notifications.push import notifier
notifier.notify(
title="New Work Order",
message=f"{wo.submitter} submitted: {wo.title}",
category="work_order",
)
except Exception:
pass
logger.info("Work order submitted (JSON): %s (risk=%d, mode=%s)", wo.title, risk, mode)
return {
"success": True,
"work_order_id": wo.id,
"title": wo.title,
"risk_score": risk,
"execution_mode": mode,
"status": "triaged",
}
# ── CRUD / Query ───────────────────────────────────────────────────────────────
@router.get("", response_class=JSONResponse)
async def list_orders(
status: Optional[str] = None,
priority: Optional[str] = None,
category: Optional[str] = None,
submitter: Optional[str] = None,
limit: int = 100,
):
"""List work orders with optional filters."""
s = WorkOrderStatus(status) if status else None
p = WorkOrderPriority(priority) if priority else None
c = WorkOrderCategory(category) if category else None
orders = list_work_orders(status=s, priority=p, category=c, submitter=submitter, limit=limit)
return {
"work_orders": [
{
"id": wo.id,
"title": wo.title,
"description": wo.description,
"priority": wo.priority.value,
"category": wo.category.value,
"status": wo.status.value,
"submitter": wo.submitter,
"submitter_type": wo.submitter_type,
"execution_mode": wo.execution_mode,
"created_at": wo.created_at,
"updated_at": wo.updated_at,
}
for wo in orders
],
"count": len(orders),
}
@router.get("/api/counts", response_class=JSONResponse)
async def work_order_counts():
"""Get work order counts by status (for nav badges)."""
counts = get_counts_by_status()
return {
"pending": counts.get("submitted", 0) + counts.get("triaged", 0),
"in_progress": counts.get("in_progress", 0),
"total": sum(counts.values()),
"by_status": counts,
}
# ── Dashboard UI (must be before /{wo_id} to avoid path conflict) ─────────────
@router.get("/queue", response_class=HTMLResponse)
async def work_order_queue_page(request: Request):
"""Work order queue dashboard page."""
pending = list_work_orders(status=WorkOrderStatus.SUBMITTED) + \
list_work_orders(status=WorkOrderStatus.TRIAGED)
active = list_work_orders(status=WorkOrderStatus.APPROVED) + \
list_work_orders(status=WorkOrderStatus.IN_PROGRESS)
completed = list_work_orders(status=WorkOrderStatus.COMPLETED, limit=20)
rejected = list_work_orders(status=WorkOrderStatus.REJECTED, limit=10)
return templates.TemplateResponse(
request,
"work_orders.html",
{
"page_title": "Work Orders",
"pending": pending,
"active": active,
"completed": completed,
"rejected": rejected,
"pending_count": len(pending),
"priorities": [p.value for p in WorkOrderPriority],
"categories": [c.value for c in WorkOrderCategory],
},
)
@router.get("/queue/pending", response_class=HTMLResponse)
async def work_order_pending_partial(request: Request):
"""HTMX partial: pending work orders."""
pending = list_work_orders(status=WorkOrderStatus.SUBMITTED) + \
list_work_orders(status=WorkOrderStatus.TRIAGED)
return templates.TemplateResponse(
request,
"partials/work_order_cards.html",
{"orders": pending, "section": "pending"},
)
@router.get("/queue/active", response_class=HTMLResponse)
async def work_order_active_partial(request: Request):
"""HTMX partial: active work orders."""
active = list_work_orders(status=WorkOrderStatus.APPROVED) + \
list_work_orders(status=WorkOrderStatus.IN_PROGRESS)
return templates.TemplateResponse(
request,
"partials/work_order_cards.html",
{"orders": active, "section": "active"},
)
# ── Single work order (must be after /queue, /api to avoid conflict) ──────────
@router.get("/{wo_id}", response_class=JSONResponse)
async def get_order(wo_id: str):
"""Get a single work order by ID."""
wo = get_work_order(wo_id)
if not wo:
raise HTTPException(404, "Work order not found")
return {
"id": wo.id,
"title": wo.title,
"description": wo.description,
"priority": wo.priority.value,
"category": wo.category.value,
"status": wo.status.value,
"submitter": wo.submitter,
"submitter_type": wo.submitter_type,
"estimated_effort": wo.estimated_effort,
"related_files": wo.related_files,
"execution_mode": wo.execution_mode,
"swarm_task_id": wo.swarm_task_id,
"result": wo.result,
"rejection_reason": wo.rejection_reason,
"created_at": wo.created_at,
"triaged_at": wo.triaged_at,
"approved_at": wo.approved_at,
"started_at": wo.started_at,
"completed_at": wo.completed_at,
}
# ── Workflow actions ───────────────────────────────────────────────────────────
@router.post("/{wo_id}/approve", response_class=HTMLResponse)
async def approve_order(request: Request, wo_id: str):
"""Approve a work order for execution."""
wo = update_work_order_status(wo_id, WorkOrderStatus.APPROVED)
if not wo:
raise HTTPException(404, "Work order not found")
return templates.TemplateResponse(
request,
"partials/work_order_card.html",
{"wo": wo},
)
@router.post("/{wo_id}/reject", response_class=HTMLResponse)
async def reject_order(request: Request, wo_id: str, reason: str = Form("")):
"""Reject a work order."""
wo = update_work_order_status(
wo_id, WorkOrderStatus.REJECTED, rejection_reason=reason,
)
if not wo:
raise HTTPException(404, "Work order not found")
return templates.TemplateResponse(
request,
"partials/work_order_card.html",
{"wo": wo},
)
@router.post("/{wo_id}/execute", response_class=JSONResponse)
async def execute_order(wo_id: str):
"""Trigger execution of an approved work order."""
wo = get_work_order(wo_id)
if not wo:
raise HTTPException(404, "Work order not found")
if wo.status not in (WorkOrderStatus.APPROVED, WorkOrderStatus.TRIAGED):
raise HTTPException(400, f"Cannot execute work order in {wo.status.value} status")
update_work_order_status(wo_id, WorkOrderStatus.IN_PROGRESS)
try:
from swarm.work_orders.executor import work_order_executor
success, result = work_order_executor.execute(wo)
if success:
update_work_order_status(wo_id, WorkOrderStatus.COMPLETED, result=result)
else:
update_work_order_status(wo_id, WorkOrderStatus.COMPLETED, result=f"Failed: {result}")
except Exception as exc:
update_work_order_status(wo_id, WorkOrderStatus.COMPLETED, result=f"Error: {exc}")
final = get_work_order(wo_id)
return {
"success": True,
"work_order_id": wo_id,
"status": final.status.value if final else "unknown",
"result": final.result if final else str(exc),
}

View File

@@ -1,252 +0,0 @@
"""Hands Models — Pydantic schemas for HAND.toml manifests.
Defines the data structures for autonomous Hand agents:
- HandConfig: Complete hand configuration from HAND.toml
- HandState: Runtime state tracking
- HandExecution: Execution record for audit trail
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, Optional
from pydantic import BaseModel, Field, validator
class HandStatus(str, Enum):
"""Runtime status of a Hand."""
DISABLED = "disabled"
IDLE = "idle"
SCHEDULED = "scheduled"
RUNNING = "running"
PAUSED = "paused"
ERROR = "error"
class HandOutcome(str, Enum):
"""Outcome of a Hand execution."""
SUCCESS = "success"
FAILURE = "failure"
APPROVAL_PENDING = "approval_pending"
TIMEOUT = "timeout"
SKIPPED = "skipped"
class TriggerType(str, Enum):
"""Types of execution triggers."""
SCHEDULE = "schedule" # Cron schedule
MANUAL = "manual" # User triggered
EVENT = "event" # Event-driven
WEBHOOK = "webhook" # External webhook
# ── HAND.toml Schema Models ───────────────────────────────────────────────
class ToolRequirement(BaseModel):
"""A required tool for the Hand."""
name: str
version: Optional[str] = None
optional: bool = False
class OutputConfig(BaseModel):
"""Output configuration for Hand results."""
dashboard: bool = True
channel: Optional[str] = None # e.g., "telegram", "discord"
format: str = "markdown" # markdown, json, html
file_drop: Optional[str] = None # Path to write output files
class ApprovalGate(BaseModel):
"""An approval gate for sensitive operations."""
action: str # e.g., "post_tweet", "send_payment"
description: str
auto_approve_after: Optional[int] = None # Seconds to auto-approve
class ScheduleConfig(BaseModel):
"""Schedule configuration for the Hand."""
cron: Optional[str] = None # Cron expression
interval: Optional[int] = None # Seconds between runs
at: Optional[str] = None # Specific time (HH:MM)
timezone: str = "UTC"
@validator('cron')
def validate_cron(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
# Basic cron validation (5 fields)
parts = v.split()
if len(parts) != 5:
raise ValueError("Cron expression must have 5 fields: minute hour day month weekday")
return v
class HandConfig(BaseModel):
"""Complete Hand configuration from HAND.toml.
Example HAND.toml:
[hand]
name = "oracle"
schedule = "0 7,19 * * *"
description = "Bitcoin and on-chain intelligence briefing"
[tools]
required = ["mempool_fetch", "fee_estimate"]
[approval_gates]
post_tweet = { action = "post_tweet", description = "Post to Twitter" }
[output]
dashboard = true
channel = "telegram"
"""
# Required fields
name: str = Field(..., description="Unique hand identifier")
description: str = Field(..., description="What this Hand does")
# Schedule (one of these must be set)
schedule: Optional[ScheduleConfig] = None
trigger: Optional[TriggerType] = TriggerType.SCHEDULE
# Optional fields
enabled: bool = True
version: str = "1.0.0"
author: Optional[str] = None
# Tools
tools_required: list[str] = Field(default_factory=list)
tools_optional: list[str] = Field(default_factory=list)
# Approval gates
approval_gates: list[ApprovalGate] = Field(default_factory=list)
# Output configuration
output: OutputConfig = Field(default_factory=OutputConfig)
# File paths (set at runtime)
hand_dir: Optional[Path] = Field(None, exclude=True)
system_prompt_path: Optional[Path] = None
skill_paths: list[Path] = Field(default_factory=list)
class Config:
extra = "allow" # Allow additional fields for extensibility
@property
def system_md_path(self) -> Optional[Path]:
"""Path to SYSTEM.md file."""
if self.hand_dir:
return self.hand_dir / "SYSTEM.md"
return None
@property
def skill_md_paths(self) -> list[Path]:
"""Paths to SKILL.md files."""
if self.hand_dir:
skill_dir = self.hand_dir / "skills"
if skill_dir.exists():
return list(skill_dir.glob("*.md"))
return []
# ── Runtime State Models ─────────────────────────────────────────────────
@dataclass
class HandState:
"""Runtime state of a Hand."""
name: str
status: HandStatus = HandStatus.IDLE
last_run: Optional[datetime] = None
next_run: Optional[datetime] = None
run_count: int = 0
success_count: int = 0
failure_count: int = 0
error_message: Optional[str] = None
is_paused: bool = False
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"status": self.status.value,
"last_run": self.last_run.isoformat() if self.last_run else None,
"next_run": self.next_run.isoformat() if self.next_run else None,
"run_count": self.run_count,
"success_count": self.success_count,
"failure_count": self.failure_count,
"error_message": self.error_message,
"is_paused": self.is_paused,
}
@dataclass
class HandExecution:
"""Record of a Hand execution."""
id: str
hand_name: str
trigger: TriggerType
started_at: datetime
completed_at: Optional[datetime] = None
outcome: HandOutcome = HandOutcome.SKIPPED
output: str = ""
error: Optional[str] = None
approval_id: Optional[str] = None
files_generated: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"hand_name": self.hand_name,
"trigger": self.trigger.value,
"started_at": self.started_at.isoformat(),
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"outcome": self.outcome.value,
"output": self.output,
"error": self.error,
"approval_id": self.approval_id,
"files_generated": self.files_generated,
}
# ── Approval Queue Models ────────────────────────────────────────────────
class ApprovalStatus(str, Enum):
"""Status of an approval request."""
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
EXPIRED = "expired"
AUTO_APPROVED = "auto_approved"
@dataclass
class ApprovalRequest:
"""A request for user approval."""
id: str
hand_name: str
action: str
description: str
context: dict[str, Any] = field(default_factory=dict)
status: ApprovalStatus = ApprovalStatus.PENDING
created_at: datetime = field(default_factory=datetime.utcnow)
expires_at: Optional[datetime] = None
resolved_at: Optional[datetime] = None
resolved_by: Optional[str] = None
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"hand_name": self.hand_name,
"action": self.action,
"description": self.description,
"context": self.context,
"status": self.status.value,
"created_at": self.created_at.isoformat(),
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
"resolved_by": self.resolved_by,
}

View File

@@ -1,526 +0,0 @@
"""Hand Registry — Load, validate, and index Hands from the hands directory.
The HandRegistry discovers all Hand packages in the hands/ directory,
loads their HAND.toml manifests, and maintains an index for fast lookup.
Usage:
from hands.registry import HandRegistry
registry = HandRegistry(hands_dir="hands/")
await registry.load_all()
oracle = registry.get_hand("oracle")
all_hands = registry.list_hands()
scheduled = registry.get_scheduled_hands()
"""
from __future__ import annotations
import logging
import sqlite3
import tomllib
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from hands.models import ApprovalGate, ApprovalRequest, ApprovalStatus, HandConfig, HandState, HandStatus, OutputConfig, ScheduleConfig
logger = logging.getLogger(__name__)
class HandRegistryError(Exception):
"""Base exception for HandRegistry errors."""
pass
class HandNotFoundError(HandRegistryError):
"""Raised when a Hand is not found."""
pass
class HandValidationError(HandRegistryError):
"""Raised when a Hand fails validation."""
pass
class HandRegistry:
"""Registry for autonomous Hands.
Discovers Hands from the filesystem, loads their configurations,
and maintains a SQLite index for fast lookups.
Attributes:
hands_dir: Directory containing Hand packages
db_path: SQLite database for indexing
_hands: In-memory cache of loaded HandConfigs
_states: Runtime state of each Hand
"""
def __init__(
self,
hands_dir: str | Path = "hands/",
db_path: str | Path = "data/hands.db",
) -> None:
"""Initialize HandRegistry.
Args:
hands_dir: Directory containing Hand subdirectories
db_path: SQLite database path for indexing
"""
self.hands_dir = Path(hands_dir)
self.db_path = Path(db_path)
self._hands: dict[str, HandConfig] = {}
self._states: dict[str, HandState] = {}
self._ensure_schema()
logger.info("HandRegistry initialized (hands_dir=%s)", self.hands_dir)
def _get_conn(self) -> sqlite3.Connection:
"""Get database connection."""
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
return conn
def _ensure_schema(self) -> None:
"""Create database tables if they don't exist."""
with self._get_conn() as conn:
# Hands index
conn.execute("""
CREATE TABLE IF NOT EXISTS hands (
name TEXT PRIMARY KEY,
config_json TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
loaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Hand execution history
conn.execute("""
CREATE TABLE IF NOT EXISTS hand_executions (
id TEXT PRIMARY KEY,
hand_name TEXT NOT NULL,
trigger TEXT NOT NULL,
started_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
outcome TEXT NOT NULL,
output TEXT,
error TEXT,
approval_id TEXT
)
""")
# Approval queue
conn.execute("""
CREATE TABLE IF NOT EXISTS approval_queue (
id TEXT PRIMARY KEY,
hand_name TEXT NOT NULL,
action TEXT NOT NULL,
description TEXT NOT NULL,
context_json TEXT,
status TEXT DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
resolved_at TIMESTAMP,
resolved_by TEXT
)
""")
conn.commit()
async def load_all(self) -> dict[str, HandConfig]:
"""Load all Hands from the hands directory.
Returns:
Dict mapping hand names to HandConfigs
"""
if not self.hands_dir.exists():
logger.warning("Hands directory does not exist: %s", self.hands_dir)
return {}
loaded = {}
for hand_dir in self.hands_dir.iterdir():
if not hand_dir.is_dir():
continue
try:
hand = self._load_hand_from_dir(hand_dir)
if hand:
loaded[hand.name] = hand
self._hands[hand.name] = hand
# Initialize state if not exists
if hand.name not in self._states:
self._states[hand.name] = HandState(name=hand.name)
# Store in database
self._store_hand(conn=None, hand=hand)
logger.info("Loaded Hand: %s (%s)", hand.name, hand.description[:50])
except Exception as e:
logger.error("Failed to load Hand from %s: %s", hand_dir, e)
logger.info("Loaded %d Hands", len(loaded))
return loaded
def _load_hand_from_dir(self, hand_dir: Path) -> Optional[HandConfig]:
"""Load a single Hand from its directory.
Args:
hand_dir: Directory containing HAND.toml
Returns:
HandConfig or None if invalid
"""
manifest_path = hand_dir / "HAND.toml"
if not manifest_path.exists():
logger.debug("No HAND.toml in %s", hand_dir)
return None
# Parse TOML
try:
with open(manifest_path, "rb") as f:
data = tomllib.load(f)
except Exception as e:
raise HandValidationError(f"Invalid HAND.toml: {e}")
# Extract hand section
hand_data = data.get("hand", {})
if not hand_data:
raise HandValidationError("Missing [hand] section in HAND.toml")
# Build HandConfig
config = HandConfig(
name=hand_data.get("name", hand_dir.name),
description=hand_data.get("description", ""),
enabled=hand_data.get("enabled", True),
version=hand_data.get("version", "1.0.0"),
author=hand_data.get("author"),
hand_dir=hand_dir,
)
# Parse schedule
if "schedule" in hand_data:
schedule_data = hand_data["schedule"]
if isinstance(schedule_data, str):
# Simple cron string
config.schedule = ScheduleConfig(cron=schedule_data)
elif isinstance(schedule_data, dict):
config.schedule = ScheduleConfig(**schedule_data)
# Parse tools
tools_data = data.get("tools", {})
config.tools_required = tools_data.get("required", [])
config.tools_optional = tools_data.get("optional", [])
# Parse approval gates
gates_data = data.get("approval_gates", {})
for action, gate_data in gates_data.items():
if isinstance(gate_data, dict):
config.approval_gates.append(ApprovalGate(
action=gate_data.get("action", action),
description=gate_data.get("description", ""),
auto_approve_after=gate_data.get("auto_approve_after"),
))
# Parse output config
output_data = data.get("output", {})
config.output = OutputConfig(**output_data)
return config
def _store_hand(self, conn: Optional[sqlite3.Connection], hand: HandConfig) -> None:
"""Store hand config in database."""
import json
if conn is None:
with self._get_conn() as conn:
self._store_hand(conn, hand)
return
conn.execute(
"""
INSERT OR REPLACE INTO hands (name, config_json, enabled)
VALUES (?, ?, ?)
""",
(hand.name, hand.json(), 1 if hand.enabled else 0),
)
conn.commit()
def get_hand(self, name: str) -> HandConfig:
"""Get a Hand by name.
Args:
name: Hand name
Returns:
HandConfig
Raises:
HandNotFoundError: If Hand doesn't exist
"""
if name not in self._hands:
raise HandNotFoundError(f"Hand not found: {name}")
return self._hands[name]
def list_hands(self) -> list[HandConfig]:
"""List all loaded Hands.
Returns:
List of HandConfigs
"""
return list(self._hands.values())
def get_scheduled_hands(self) -> list[HandConfig]:
"""Get all Hands with schedule configuration.
Returns:
List of HandConfigs with schedules
"""
return [h for h in self._hands.values() if h.schedule is not None and h.enabled]
def get_enabled_hands(self) -> list[HandConfig]:
"""Get all enabled Hands.
Returns:
List of enabled HandConfigs
"""
return [h for h in self._hands.values() if h.enabled]
def get_state(self, name: str) -> HandState:
"""Get runtime state of a Hand.
Args:
name: Hand name
Returns:
HandState
"""
if name not in self._states:
self._states[name] = HandState(name=name)
return self._states[name]
def update_state(self, name: str, **kwargs) -> None:
"""Update Hand state.
Args:
name: Hand name
**kwargs: State fields to update
"""
state = self.get_state(name)
for key, value in kwargs.items():
if hasattr(state, key):
setattr(state, key, value)
async def log_execution(
self,
hand_name: str,
trigger: str,
outcome: str,
output: str = "",
error: Optional[str] = None,
approval_id: Optional[str] = None,
) -> str:
"""Log a Hand execution.
Args:
hand_name: Name of the Hand
trigger: Trigger type
outcome: Execution outcome
output: Execution output
error: Error message if failed
approval_id: Associated approval ID
Returns:
Execution ID
"""
execution_id = str(uuid.uuid4())
with self._get_conn() as conn:
conn.execute(
"""
INSERT INTO hand_executions
(id, hand_name, trigger, started_at, completed_at, outcome, output, error, approval_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
execution_id,
hand_name,
trigger,
datetime.now(timezone.utc).isoformat(),
datetime.now(timezone.utc).isoformat(),
outcome,
output,
error,
approval_id,
),
)
conn.commit()
return execution_id
async def create_approval(
self,
hand_name: str,
action: str,
description: str,
context: dict,
expires_after: Optional[int] = None,
) -> ApprovalRequest:
"""Create an approval request.
Args:
hand_name: Hand requesting approval
action: Action to approve
description: Human-readable description
context: Additional context
expires_after: Seconds until expiration
Returns:
ApprovalRequest
"""
approval_id = str(uuid.uuid4())
created_at = datetime.now(timezone.utc)
expires_at = None
if expires_after:
from datetime import timedelta
expires_at = created_at + timedelta(seconds=expires_after)
request = ApprovalRequest(
id=approval_id,
hand_name=hand_name,
action=action,
description=description,
context=context,
created_at=created_at,
expires_at=expires_at,
)
# Store in database
import json
with self._get_conn() as conn:
conn.execute(
"""
INSERT INTO approval_queue
(id, hand_name, action, description, context_json, status, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
request.id,
request.hand_name,
request.action,
request.description,
json.dumps(request.context),
request.status.value,
request.created_at.isoformat(),
request.expires_at.isoformat() if request.expires_at else None,
),
)
conn.commit()
return request
async def get_pending_approvals(self) -> list[ApprovalRequest]:
"""Get all pending approval requests.
Returns:
List of pending ApprovalRequests
"""
import json
with self._get_conn() as conn:
rows = conn.execute(
"""
SELECT * FROM approval_queue
WHERE status = 'pending'
ORDER BY created_at DESC
"""
).fetchall()
requests = []
for row in rows:
requests.append(ApprovalRequest(
id=row["id"],
hand_name=row["hand_name"],
action=row["action"],
description=row["description"],
context=json.loads(row["context_json"] or "{}"),
status=ApprovalStatus(row["status"]),
created_at=datetime.fromisoformat(row["created_at"]),
expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
))
return requests
async def resolve_approval(
self,
approval_id: str,
approved: bool,
resolved_by: Optional[str] = None,
) -> bool:
"""Resolve an approval request.
Args:
approval_id: ID of the approval request
approved: True to approve, False to reject
resolved_by: Who resolved the request
Returns:
True if resolved successfully
"""
status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
resolved_at = datetime.now(timezone.utc)
with self._get_conn() as conn:
cursor = conn.execute(
"""
UPDATE approval_queue
SET status = ?, resolved_at = ?, resolved_by = ?
WHERE id = ? AND status = 'pending'
""",
(status.value, resolved_at.isoformat(), resolved_by, approval_id),
)
conn.commit()
return cursor.rowcount > 0
async def get_recent_executions(
self,
hand_name: Optional[str] = None,
limit: int = 50,
) -> list[dict]:
"""Get recent Hand executions.
Args:
hand_name: Filter by Hand name
limit: Maximum results
Returns:
List of execution records
"""
with self._get_conn() as conn:
if hand_name:
rows = conn.execute(
"""
SELECT * FROM hand_executions
WHERE hand_name = ?
ORDER BY started_at DESC
LIMIT ?
""",
(hand_name, limit),
).fetchall()
else:
rows = conn.execute(
"""
SELECT * FROM hand_executions
ORDER BY started_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]

View File

@@ -1,476 +0,0 @@
"""Hand Runner — Execute Hands with skill injection and tool access.
The HandRunner is responsible for executing individual Hands:
- Load SYSTEM.md and SKILL.md files
- Inject domain expertise into LLM context
- Execute the tool loop
- Handle approval gates
- Produce output
Usage:
from hands.runner import HandRunner
from hands.registry import HandRegistry
registry = HandRegistry()
runner = HandRunner(registry, llm_adapter)
result = await runner.run_hand("oracle")
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from hands.models import (
ApprovalRequest,
ApprovalStatus,
HandConfig,
HandExecution,
HandOutcome,
HandState,
HandStatus,
TriggerType,
)
from hands.registry import HandRegistry
logger = logging.getLogger(__name__)
class HandRunner:
"""Executes individual Hands.
Manages the execution lifecycle:
1. Load system prompt and skills
2. Check and handle approval gates
3. Execute tool loop with LLM
4. Produce and deliver output
5. Log execution
Attributes:
registry: HandRegistry for Hand configs and state
llm_adapter: LLM adapter for generation
mcp_registry: Optional MCP tool registry
"""
def __init__(
self,
registry: HandRegistry,
llm_adapter: Optional[Any] = None,
mcp_registry: Optional[Any] = None,
) -> None:
"""Initialize HandRunner.
Args:
registry: HandRegistry instance
llm_adapter: LLM adapter for generation
mcp_registry: Optional MCP tool registry for tool access
"""
self.registry = registry
self.llm_adapter = llm_adapter
self.mcp_registry = mcp_registry
logger.info("HandRunner initialized")
async def run_hand(
self,
hand_name: str,
trigger: TriggerType = TriggerType.MANUAL,
context: Optional[dict] = None,
) -> HandExecution:
"""Run a Hand.
This is the main entry point for Hand execution.
Args:
hand_name: Name of the Hand to run
trigger: What triggered this execution
context: Optional execution context
Returns:
HandExecution record
"""
started_at = datetime.now(timezone.utc)
execution_id = f"exec_{hand_name}_{started_at.isoformat()}"
logger.info("Starting Hand execution: %s", hand_name)
try:
# Get Hand config
hand = self.registry.get_hand(hand_name)
# Update state
self.registry.update_state(
hand_name,
status=HandStatus.RUNNING,
last_run=started_at,
)
# Load system prompt and skills
system_prompt = self._load_system_prompt(hand)
skills = self._load_skills(hand)
# Check approval gates
approval_results = await self._check_approvals(hand)
if approval_results.get("blocked"):
return await self._create_execution_record(
execution_id=execution_id,
hand_name=hand_name,
trigger=trigger,
started_at=started_at,
outcome=HandOutcome.APPROVAL_PENDING,
output="",
approval_id=approval_results.get("approval_id"),
)
# Execute the Hand
result = await self._execute_with_llm(
hand=hand,
system_prompt=system_prompt,
skills=skills,
context=context or {},
)
# Deliver output
await self._deliver_output(hand, result)
# Update state
state = self.registry.get_state(hand_name)
self.registry.update_state(
hand_name,
status=HandStatus.IDLE,
run_count=state.run_count + 1,
success_count=state.success_count + 1,
)
# Create execution record
return await self._create_execution_record(
execution_id=execution_id,
hand_name=hand_name,
trigger=trigger,
started_at=started_at,
outcome=HandOutcome.SUCCESS,
output=result.get("output", ""),
files_generated=result.get("files", []),
)
except Exception as e:
logger.exception("Hand %s execution failed", hand_name)
# Update state
self.registry.update_state(
hand_name,
status=HandStatus.ERROR,
error_message=str(e),
)
# Create failure record
return await self._create_execution_record(
execution_id=execution_id,
hand_name=hand_name,
trigger=trigger,
started_at=started_at,
outcome=HandOutcome.FAILURE,
output="",
error=str(e),
)
def _load_system_prompt(self, hand: HandConfig) -> str:
"""Load SYSTEM.md for a Hand.
Args:
hand: HandConfig
Returns:
System prompt text
"""
if hand.system_md_path and hand.system_md_path.exists():
try:
return hand.system_md_path.read_text()
except Exception as e:
logger.warning("Failed to load SYSTEM.md for %s: %s", hand.name, e)
# Default system prompt
return f"""You are the {hand.name} Hand.
Your purpose: {hand.description}
You have access to the following tools: {', '.join(hand.tools_required + hand.tools_optional)}
Execute your task professionally and produce the requested output.
"""
def _load_skills(self, hand: HandConfig) -> list[str]:
"""Load SKILL.md files for a Hand.
Args:
hand: HandConfig
Returns:
List of skill texts
"""
skills = []
for skill_path in hand.skill_md_paths:
try:
if skill_path.exists():
skills.append(skill_path.read_text())
except Exception as e:
logger.warning("Failed to load skill %s: %s", skill_path, e)
return skills
async def _check_approvals(self, hand: HandConfig) -> dict:
"""Check if any approval gates block execution.
Args:
hand: HandConfig
Returns:
Dict with "blocked" and optional "approval_id"
"""
if not hand.approval_gates:
return {"blocked": False}
# Check for pending approvals for this hand
pending = await self.registry.get_pending_approvals()
hand_pending = [a for a in pending if a.hand_name == hand.name]
if hand_pending:
return {
"blocked": True,
"approval_id": hand_pending[0].id,
}
# Create approval requests for each gate
for gate in hand.approval_gates:
request = await self.registry.create_approval(
hand_name=hand.name,
action=gate.action,
description=gate.description,
context={"gate": gate.action},
expires_after=gate.auto_approve_after,
)
if not gate.auto_approve_after:
# Requires manual approval
return {
"blocked": True,
"approval_id": request.id,
}
return {"blocked": False}
async def _execute_with_llm(
self,
hand: HandConfig,
system_prompt: str,
skills: list[str],
context: dict,
) -> dict:
"""Execute Hand logic with LLM.
Args:
hand: HandConfig
system_prompt: System prompt
skills: Skill texts
context: Execution context
Returns:
Result dict with output and files
"""
if not self.llm_adapter:
logger.warning("No LLM adapter available for Hand %s", hand.name)
return {
"output": f"Hand {hand.name} executed (no LLM configured)",
"files": [],
}
# Build the full prompt
full_prompt = self._build_prompt(
hand=hand,
system_prompt=system_prompt,
skills=skills,
context=context,
)
try:
# Call LLM
response = await self.llm_adapter.chat(message=full_prompt)
# Parse response
output = response.content
# Extract any file outputs (placeholder - would parse structured output)
files = []
return {
"output": output,
"files": files,
}
except Exception as e:
logger.error("LLM execution failed for Hand %s: %s", hand.name, e)
raise
def _build_prompt(
self,
hand: HandConfig,
system_prompt: str,
skills: list[str],
context: dict,
) -> str:
"""Build the full execution prompt.
Args:
hand: HandConfig
system_prompt: System prompt
skills: Skill texts
context: Execution context
Returns:
Complete prompt
"""
parts = [
"# System Instructions",
system_prompt,
"",
]
# Add skills
if skills:
parts.extend([
"# Domain Expertise (SKILL.md)",
"\n\n---\n\n".join(skills),
"",
])
# Add context
if context:
parts.extend([
"# Execution Context",
str(context),
"",
])
# Add available tools
if hand.tools_required or hand.tools_optional:
parts.extend([
"# Available Tools",
"Required: " + ", ".join(hand.tools_required),
"Optional: " + ", ".join(hand.tools_optional),
"",
])
# Add output instructions
parts.extend([
"# Output Instructions",
f"Format: {hand.output.format}",
f"Dashboard: {'Yes' if hand.output.dashboard else 'No'}",
f"Channel: {hand.output.channel or 'None'}",
"",
"Execute your task now.",
])
return "\n".join(parts)
async def _deliver_output(self, hand: HandConfig, result: dict) -> None:
"""Deliver Hand output to configured destinations.
Args:
hand: HandConfig
result: Execution result
"""
output = result.get("output", "")
# Dashboard output
if hand.output.dashboard:
# This would publish to event bus for dashboard
logger.info("Hand %s output delivered to dashboard", hand.name)
# Channel output (e.g., Telegram, Discord)
if hand.output.channel:
# This would send to the appropriate channel
logger.info("Hand %s output delivered to %s", hand.name, hand.output.channel)
# File drop
if hand.output.file_drop:
try:
drop_path = Path(hand.output.file_drop)
drop_path.mkdir(parents=True, exist_ok=True)
output_file = drop_path / f"{hand.name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
output_file.write_text(output)
logger.info("Hand %s output written to %s", hand.name, output_file)
except Exception as e:
logger.error("Failed to write Hand %s output: %s", hand.name, e)
async def _create_execution_record(
self,
execution_id: str,
hand_name: str,
trigger: TriggerType,
started_at: datetime,
outcome: HandOutcome,
output: str,
error: Optional[str] = None,
approval_id: Optional[str] = None,
files_generated: Optional[list] = None,
) -> HandExecution:
"""Create and store execution record.
Returns:
HandExecution
"""
completed_at = datetime.now(timezone.utc)
execution = HandExecution(
id=execution_id,
hand_name=hand_name,
trigger=trigger,
started_at=started_at,
completed_at=completed_at,
outcome=outcome,
output=output,
error=error,
approval_id=approval_id,
files_generated=files_generated or [],
)
# Log to registry
await self.registry.log_execution(
hand_name=hand_name,
trigger=trigger.value,
outcome=outcome.value,
output=output,
error=error,
approval_id=approval_id,
)
return execution
async def continue_after_approval(
self,
approval_id: str,
) -> Optional[HandExecution]:
"""Continue Hand execution after approval.
Args:
approval_id: Approval request ID
Returns:
HandExecution if execution proceeded
"""
# Get approval request
# This would need a get_approval_by_id method in registry
# For now, placeholder
logger.info("Continuing Hand execution after approval %s", approval_id)
# Re-run the Hand
# This would look up the hand from the approval context
return None

View File

@@ -1,410 +0,0 @@
"""Hand Scheduler — APScheduler-based cron scheduling for Hands.
Manages the scheduling of autonomous Hands using APScheduler.
Supports cron expressions, intervals, and specific times.
Usage:
from hands.scheduler import HandScheduler
from hands.registry import HandRegistry
registry = HandRegistry()
await registry.load_all()
scheduler = HandScheduler(registry)
await scheduler.start()
# Hands are now scheduled and will run automatically
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Any, Callable, Optional
from hands.models import HandConfig, HandState, HandStatus, TriggerType
from hands.registry import HandRegistry
logger = logging.getLogger(__name__)
# Try to import APScheduler
try:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
APSCHEDULER_AVAILABLE = True
except ImportError:
APSCHEDULER_AVAILABLE = False
logger.warning("APScheduler not installed. Scheduling will be disabled.")
class HandScheduler:
"""Scheduler for autonomous Hands.
Uses APScheduler to manage cron-based execution of Hands.
Each Hand with a schedule gets its own job in the scheduler.
Attributes:
registry: HandRegistry for Hand configurations
_scheduler: APScheduler instance
_running: Whether scheduler is running
_job_ids: Mapping of hand names to job IDs
"""
def __init__(
self,
registry: HandRegistry,
job_defaults: Optional[dict] = None,
) -> None:
"""Initialize HandScheduler.
Args:
registry: HandRegistry instance
job_defaults: Default job configuration for APScheduler
"""
self.registry = registry
self._scheduler: Optional[Any] = None
self._running = False
self._job_ids: dict[str, str] = {}
if APSCHEDULER_AVAILABLE:
self._scheduler = AsyncIOScheduler(job_defaults=job_defaults or {
'coalesce': True, # Coalesce missed jobs into one
'max_instances': 1, # Only one instance per Hand
})
logger.info("HandScheduler initialized")
async def start(self) -> None:
"""Start the scheduler and schedule all enabled Hands."""
if not APSCHEDULER_AVAILABLE:
logger.error("Cannot start scheduler: APScheduler not installed")
return
if self._running:
logger.warning("Scheduler already running")
return
# Schedule all enabled Hands
hands = self.registry.get_scheduled_hands()
for hand in hands:
await self.schedule_hand(hand)
# Start the scheduler
self._scheduler.start()
self._running = True
logger.info("HandScheduler started with %d scheduled Hands", len(hands))
async def stop(self) -> None:
"""Stop the scheduler."""
if not self._running or not self._scheduler:
return
self._scheduler.shutdown(wait=True)
self._running = False
self._job_ids.clear()
logger.info("HandScheduler stopped")
async def schedule_hand(self, hand: HandConfig) -> Optional[str]:
"""Schedule a Hand for execution.
Args:
hand: HandConfig to schedule
Returns:
Job ID if scheduled successfully
"""
if not APSCHEDULER_AVAILABLE or not self._scheduler:
logger.warning("Cannot schedule %s: APScheduler not available", hand.name)
return None
if not hand.schedule:
logger.debug("Hand %s has no schedule", hand.name)
return None
if not hand.enabled:
logger.debug("Hand %s is disabled", hand.name)
return None
# Remove existing job if any
if hand.name in self._job_ids:
self.unschedule_hand(hand.name)
# Create the trigger
trigger = self._create_trigger(hand.schedule)
if not trigger:
logger.error("Failed to create trigger for Hand %s", hand.name)
return None
# Add job to scheduler
try:
job = self._scheduler.add_job(
func=self._execute_hand_wrapper,
trigger=trigger,
id=f"hand_{hand.name}",
name=f"Hand: {hand.name}",
args=[hand.name],
replace_existing=True,
)
self._job_ids[hand.name] = job.id
# Update state
self.registry.update_state(
hand.name,
status=HandStatus.SCHEDULED,
next_run=job.next_run_time,
)
logger.info("Scheduled Hand %s (next run: %s)", hand.name, job.next_run_time)
return job.id
except Exception as e:
logger.error("Failed to schedule Hand %s: %s", hand.name, e)
return None
def unschedule_hand(self, name: str) -> bool:
"""Remove a Hand from the scheduler.
Args:
name: Hand name
Returns:
True if unscheduled successfully
"""
if not self._scheduler:
return False
if name not in self._job_ids:
return False
try:
self._scheduler.remove_job(self._job_ids[name])
del self._job_ids[name]
self.registry.update_state(name, status=HandStatus.IDLE)
logger.info("Unscheduled Hand %s", name)
return True
except Exception as e:
logger.error("Failed to unschedule Hand %s: %s", name, e)
return False
def pause_hand(self, name: str) -> bool:
"""Pause a scheduled Hand.
Args:
name: Hand name
Returns:
True if paused successfully
"""
if not self._scheduler:
return False
if name not in self._job_ids:
return False
try:
self._scheduler.pause_job(self._job_ids[name])
self.registry.update_state(name, status=HandStatus.PAUSED, is_paused=True)
logger.info("Paused Hand %s", name)
return True
except Exception as e:
logger.error("Failed to pause Hand %s: %s", name, e)
return False
def resume_hand(self, name: str) -> bool:
"""Resume a paused Hand.
Args:
name: Hand name
Returns:
True if resumed successfully
"""
if not self._scheduler:
return False
if name not in self._job_ids:
return False
try:
self._scheduler.resume_job(self._job_ids[name])
self.registry.update_state(name, status=HandStatus.SCHEDULED, is_paused=False)
logger.info("Resumed Hand %s", name)
return True
except Exception as e:
logger.error("Failed to resume Hand %s: %s", name, e)
return False
def get_scheduled_jobs(self) -> list[dict]:
"""Get all scheduled jobs.
Returns:
List of job information dicts
"""
if not self._scheduler:
return []
jobs = []
for job in self._scheduler.get_jobs():
if job.id.startswith("hand_"):
hand_name = job.id[5:] # Remove "hand_" prefix
jobs.append({
"hand_name": hand_name,
"job_id": job.id,
"next_run_time": job.next_run_time.isoformat() if job.next_run_time else None,
"trigger": str(job.trigger),
})
return jobs
def _create_trigger(self, schedule: Any) -> Optional[Any]:
"""Create an APScheduler trigger from ScheduleConfig.
Args:
schedule: ScheduleConfig
Returns:
APScheduler trigger
"""
if not APSCHEDULER_AVAILABLE:
return None
# Cron trigger
if schedule.cron:
try:
parts = schedule.cron.split()
if len(parts) == 5:
return CronTrigger(
minute=parts[0],
hour=parts[1],
day=parts[2],
month=parts[3],
day_of_week=parts[4],
timezone=schedule.timezone,
)
except Exception as e:
logger.error("Invalid cron expression '%s': %s", schedule.cron, e)
return None
# Interval trigger
if schedule.interval:
return IntervalTrigger(
seconds=schedule.interval,
timezone=schedule.timezone,
)
return None
async def _execute_hand_wrapper(self, hand_name: str) -> None:
"""Wrapper for Hand execution.
This is called by APScheduler when a Hand's trigger fires.
Args:
hand_name: Name of the Hand to execute
"""
logger.info("Triggering Hand: %s", hand_name)
try:
# Update state
self.registry.update_state(
hand_name,
status=HandStatus.RUNNING,
last_run=datetime.now(timezone.utc),
)
# Execute the Hand
await self._run_hand(hand_name, TriggerType.SCHEDULE)
except Exception as e:
logger.exception("Hand %s execution failed", hand_name)
self.registry.update_state(
hand_name,
status=HandStatus.ERROR,
error_message=str(e),
)
async def _run_hand(self, hand_name: str, trigger: TriggerType) -> None:
"""Execute a Hand.
This is the core execution logic. In Phase 4+, this will
call the actual Hand implementation.
Args:
hand_name: Name of the Hand
trigger: What triggered the execution
"""
from hands.models import HandOutcome
try:
hand = self.registry.get_hand(hand_name)
except Exception:
logger.error("Hand %s not found", hand_name)
return
logger.info("Executing Hand %s (trigger: %s)", hand_name, trigger.value)
# TODO: Phase 4+ - Call actual Hand implementation via HandRunner
# For now, just log the execution
output = f"Hand {hand_name} executed (placeholder implementation)"
# Log execution
await self.registry.log_execution(
hand_name=hand_name,
trigger=trigger.value,
outcome=HandOutcome.SUCCESS.value,
output=output,
)
# Update state
state = self.registry.get_state(hand_name)
self.registry.update_state(
hand_name,
status=HandStatus.SCHEDULED,
run_count=state.run_count + 1,
success_count=state.success_count + 1,
)
logger.info("Hand %s completed successfully", hand_name)
async def trigger_hand_now(self, name: str) -> bool:
"""Manually trigger a Hand to run immediately.
Args:
name: Hand name
Returns:
True if triggered successfully
"""
try:
await self._run_hand(name, TriggerType.MANUAL)
return True
except Exception as e:
logger.error("Failed to trigger Hand %s: %s", name, e)
return False
def get_next_run_time(self, name: str) -> Optional[datetime]:
"""Get next scheduled run time for a Hand.
Args:
name: Hand name
Returns:
Next run time or None if not scheduled
"""
if not self._scheduler or name not in self._job_ids:
return None
try:
job = self._scheduler.get_job(self._job_ids[name])
return job.next_run_time if job else None
except Exception:
return None

View File

@@ -1,157 +0,0 @@
"""Sub-agent runner — entry point for spawned swarm agents.
This module is executed as a subprocess (or Docker container) by
swarm.manager / swarm.docker_runner. It creates a SwarmNode, joins the
registry, and waits for tasks.
Comms mode is detected automatically:
- **In-process / subprocess** (no ``COORDINATOR_URL`` env var):
Uses the shared in-memory SwarmComms channel directly.
- **Docker container** (``COORDINATOR_URL`` is set):
Polls ``GET /internal/tasks`` and submits bids via
``POST /internal/bids`` over HTTP. No in-memory state is shared
across the container boundary.
Usage
-----
::
# Subprocess (existing behaviour — unchanged)
python -m swarm.agent_runner --agent-id <id> --name <name>
# Docker (coordinator_url injected via env)
COORDINATOR_URL=http://dashboard:8000 \
python -m swarm.agent_runner --agent-id <id> --name <name>
"""
import argparse
import asyncio
import logging
import os
import random
import signal
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s%(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
# How often a Docker agent polls for open tasks (seconds)
_HTTP_POLL_INTERVAL = 5
# ── In-process mode ───────────────────────────────────────────────────────────
async def _run_inprocess(agent_id: str, name: str, stop: asyncio.Event) -> None:
"""Run the agent using the shared in-memory SwarmComms channel."""
from swarm.swarm_node import SwarmNode
node = SwarmNode(agent_id, name)
await node.join()
logger.info("Agent %s (%s) running (in-process mode) — waiting for tasks", name, agent_id)
try:
await stop.wait()
finally:
await node.leave()
logger.info("Agent %s (%s) shut down", name, agent_id)
# ── HTTP (Docker) mode ────────────────────────────────────────────────────────
async def _run_http(
agent_id: str,
name: str,
coordinator_url: str,
capabilities: str,
stop: asyncio.Event,
) -> None:
"""Run the agent by polling the coordinator's internal HTTP API."""
try:
import httpx
except ImportError:
logger.error("httpx is required for HTTP mode — install with: pip install httpx")
return
from swarm import registry
# Register in SQLite so the coordinator can see us
registry.register(name=name, capabilities=capabilities, agent_id=agent_id)
logger.info(
"Agent %s (%s) running (HTTP mode) — polling %s every %ds",
name, agent_id, coordinator_url, _HTTP_POLL_INTERVAL,
)
base = coordinator_url.rstrip("/")
seen_tasks: set[str] = set()
async with httpx.AsyncClient(timeout=10.0) as client:
while not stop.is_set():
try:
resp = await client.get(f"{base}/internal/tasks")
if resp.status_code == 200:
tasks = resp.json()
for task in tasks:
task_id = task["task_id"]
if task_id in seen_tasks:
continue
seen_tasks.add(task_id)
bid_sats = random.randint(10, 100)
await client.post(
f"{base}/internal/bids",
json={
"task_id": task_id,
"agent_id": agent_id,
"bid_sats": bid_sats,
"capabilities": capabilities,
},
)
logger.info(
"Agent %s bid %d sats on task %s",
name, bid_sats, task_id,
)
except Exception as exc:
logger.warning("HTTP poll error: %s", exc)
try:
await asyncio.wait_for(stop.wait(), timeout=_HTTP_POLL_INTERVAL)
except asyncio.TimeoutError:
pass # normal — just means the stop event wasn't set
registry.update_status(agent_id, "offline")
logger.info("Agent %s (%s) shut down", name, agent_id)
# ── Entry point ───────────────────────────────────────────────────────────────
async def main() -> None:
parser = argparse.ArgumentParser(description="Swarm sub-agent runner")
parser.add_argument("--agent-id", required=True, help="Unique agent identifier")
parser.add_argument("--name", required=True, help="Human-readable agent name")
args = parser.parse_args()
agent_id = args.agent_id
name = args.name
coordinator_url = os.environ.get("COORDINATOR_URL", "")
capabilities = os.environ.get("AGENT_CAPABILITIES", "")
stop = asyncio.Event()
def _handle_signal(*_):
logger.info("Agent %s received shutdown signal", name)
stop.set()
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, _handle_signal)
if coordinator_url:
await _run_http(agent_id, name, coordinator_url, capabilities, stop)
else:
await _run_inprocess(agent_id, name, stop)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,88 +0,0 @@
"""15-second auction system for swarm task assignment.
When a task is posted, agents have 15 seconds to submit bids (in sats).
The lowest bid wins. If no bids arrive, the task remains unassigned.
"""
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Optional
logger = logging.getLogger(__name__)
AUCTION_DURATION_SECONDS = 15
@dataclass
class Bid:
agent_id: str
bid_sats: int
task_id: str
@dataclass
class Auction:
task_id: str
bids: list[Bid] = field(default_factory=list)
closed: bool = False
winner: Optional[Bid] = None
def submit(self, agent_id: str, bid_sats: int) -> bool:
"""Submit a bid. Returns False if the auction is already closed."""
if self.closed:
return False
self.bids.append(Bid(agent_id=agent_id, bid_sats=bid_sats, task_id=self.task_id))
return True
def close(self) -> Optional[Bid]:
"""Close the auction and determine the winner (lowest bid)."""
self.closed = True
if not self.bids:
logger.info("Auction %s: no bids received", self.task_id)
return None
self.winner = min(self.bids, key=lambda b: b.bid_sats)
logger.info(
"Auction %s: winner is %s at %d sats",
self.task_id, self.winner.agent_id, self.winner.bid_sats,
)
return self.winner
class AuctionManager:
"""Manages concurrent auctions for multiple tasks."""
def __init__(self) -> None:
self._auctions: dict[str, Auction] = {}
def open_auction(self, task_id: str) -> Auction:
auction = Auction(task_id=task_id)
self._auctions[task_id] = auction
logger.info("Auction opened for task %s", task_id)
return auction
def get_auction(self, task_id: str) -> Optional[Auction]:
return self._auctions.get(task_id)
def submit_bid(self, task_id: str, agent_id: str, bid_sats: int) -> bool:
auction = self._auctions.get(task_id)
if auction is None:
logger.warning("No auction found for task %s", task_id)
return False
return auction.submit(agent_id, bid_sats)
def close_auction(self, task_id: str) -> Optional[Bid]:
auction = self._auctions.get(task_id)
if auction is None:
return None
return auction.close()
async def run_auction(self, task_id: str) -> Optional[Bid]:
"""Open an auction, wait the bidding period, then close and return winner."""
self.open_auction(task_id)
await asyncio.sleep(AUCTION_DURATION_SECONDS)
return self.close_auction(task_id)
@property
def active_auctions(self) -> list[str]:
return [tid for tid, a in self._auctions.items() if not a.closed]

View File

@@ -1,131 +0,0 @@
"""Redis pub/sub messaging layer for swarm communication.
Provides a thin wrapper around Redis pub/sub so agents can broadcast
events (task posted, bid submitted, task assigned) and listen for them.
Falls back gracefully when Redis is unavailable — messages are logged
but not delivered, allowing the system to run without Redis for
development and testing.
"""
import json
import logging
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
# Channel names
CHANNEL_TASKS = "swarm:tasks"
CHANNEL_BIDS = "swarm:bids"
CHANNEL_EVENTS = "swarm:events"
@dataclass
class SwarmMessage:
channel: str
event: str
data: dict
timestamp: str
def to_json(self) -> str:
return json.dumps(asdict(self))
@classmethod
def from_json(cls, raw: str) -> "SwarmMessage":
d = json.loads(raw)
return cls(**d)
class SwarmComms:
"""Pub/sub messaging for the swarm.
Uses Redis when available; falls back to an in-memory fanout for
single-process development.
"""
def __init__(self, redis_url: str = "redis://localhost:6379"):
self._redis_url = redis_url
self._redis = None
self._pubsub = None
self._listeners: dict[str, list[Callable]] = {}
self._connected = False
self._try_connect()
def _try_connect(self) -> None:
try:
import redis
self._redis = redis.from_url(
self._redis_url,
socket_connect_timeout=3,
socket_timeout=3,
)
self._redis.ping()
self._pubsub = self._redis.pubsub()
self._connected = True
logger.info("SwarmComms: connected to Redis at %s", self._redis_url)
except Exception:
self._connected = False
logger.warning(
"SwarmComms: Redis unavailable — using in-memory fallback"
)
@property
def connected(self) -> bool:
return self._connected
def publish(self, channel: str, event: str, data: Optional[dict] = None) -> None:
msg = SwarmMessage(
channel=channel,
event=event,
data=data or {},
timestamp=datetime.now(timezone.utc).isoformat(),
)
if self._connected and self._redis:
try:
self._redis.publish(channel, msg.to_json())
return
except Exception as exc:
logger.error("SwarmComms: publish failed — %s", exc)
# In-memory fallback: call local listeners directly
for callback in self._listeners.get(channel, []):
try:
callback(msg)
except Exception as exc:
logger.error("SwarmComms: listener error — %s", exc)
def subscribe(self, channel: str, callback: Callable[[SwarmMessage], Any]) -> None:
self._listeners.setdefault(channel, []).append(callback)
if self._connected and self._pubsub:
try:
self._pubsub.subscribe(**{channel: lambda msg: None})
except Exception as exc:
logger.error("SwarmComms: subscribe failed — %s", exc)
def post_task(self, task_id: str, description: str) -> None:
self.publish(CHANNEL_TASKS, "task_posted", {
"task_id": task_id,
"description": description,
})
def submit_bid(self, task_id: str, agent_id: str, bid_sats: int) -> None:
self.publish(CHANNEL_BIDS, "bid_submitted", {
"task_id": task_id,
"agent_id": agent_id,
"bid_sats": bid_sats,
})
def assign_task(self, task_id: str, agent_id: str) -> None:
self.publish(CHANNEL_EVENTS, "task_assigned", {
"task_id": task_id,
"agent_id": agent_id,
})
def complete_task(self, task_id: str, agent_id: str, result: str) -> None:
self.publish(CHANNEL_EVENTS, "task_completed", {
"task_id": task_id,
"agent_id": agent_id,
"result": result,
})

View File

@@ -1,444 +0,0 @@
"""Swarm coordinator — orchestrates registry, manager, and bidder.
The coordinator is the top-level entry point for swarm operations.
It ties together task creation, auction management, agent spawning,
and task assignment into a single cohesive API used by the dashboard
routes.
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
from swarm.bidder import AUCTION_DURATION_SECONDS, AuctionManager, Bid
from swarm.comms import SwarmComms
from swarm import learner as swarm_learner
from swarm.manager import SwarmManager
from swarm.recovery import reconcile_on_startup
from swarm.registry import AgentRecord
from swarm import registry
from swarm import routing as swarm_routing
from swarm import stats as swarm_stats
from swarm.tasks import (
Task,
TaskStatus,
create_task,
get_task,
list_tasks,
update_task,
)
from swarm.event_log import (
EventType,
log_event,
)
# Spark Intelligence integration — lazy import to avoid circular deps
def _get_spark():
"""Lazily import the Spark engine singleton."""
try:
from spark.engine import spark_engine
return spark_engine
except Exception:
return None
logger = logging.getLogger(__name__)
class SwarmCoordinator:
"""High-level orchestrator for the swarm system."""
def __init__(self) -> None:
self.manager = SwarmManager()
self.auctions = AuctionManager()
self.comms = SwarmComms()
self._in_process_nodes: list = []
self._recovery_summary = {"tasks_failed": 0, "agents_offlined": 0}
def initialize(self) -> None:
"""Run startup recovery. Call during app lifespan, not at import time."""
self._recovery_summary = reconcile_on_startup()
# ── Agent lifecycle ─────────────────────────────────────────────────────
def spawn_agent(self, name: str, agent_id: Optional[str] = None) -> dict:
"""Spawn a new sub-agent and register it."""
managed = self.manager.spawn(name, agent_id)
record = registry.register(name=name, agent_id=managed.agent_id)
return {
"agent_id": managed.agent_id,
"name": name,
"pid": managed.pid,
"status": record.status,
}
def stop_agent(self, agent_id: str) -> bool:
"""Stop a sub-agent and remove it from the registry."""
registry.unregister(agent_id)
return self.manager.stop(agent_id)
def list_swarm_agents(self) -> list[AgentRecord]:
return registry.list_agents()
def spawn_persona(self, persona_id: str, agent_id: Optional[str] = None) -> dict:
"""DEPRECATED: Use brain task queue instead.
Personas have been replaced by the distributed brain worker queue.
Submit tasks via BrainClient.submit_task() instead.
"""
logger.warning(
"spawn_persona() is deprecated. "
"Use brain.BrainClient.submit_task() instead."
)
# Return stub response for compatibility
return {
"agent_id": agent_id or "deprecated",
"name": persona_id,
"status": "deprecated",
"message": "Personas replaced by brain task queue"
}
def spawn_in_process_agent(
self, name: str, agent_id: Optional[str] = None,
) -> dict:
"""Spawn a lightweight in-process agent that bids on tasks.
Unlike spawn_agent (which launches a subprocess), this creates a
SwarmNode in the current process sharing the coordinator's comms
layer. This means the in-memory pub/sub callbacks fire
immediately when a task is posted, allowing the node to submit
bids into the coordinator's AuctionManager.
"""
from swarm.swarm_node import SwarmNode
aid = agent_id or str(__import__("uuid").uuid4())
node = SwarmNode(
agent_id=aid,
name=name,
comms=self.comms,
)
# Wire the node's bid callback to feed into our AuctionManager
original_on_task = node._on_task_posted
def _bid_and_register(msg):
"""Intercept the task announcement, submit a bid to the auction."""
task_id = msg.data.get("task_id")
if not task_id:
return
import random
bid_sats = random.randint(10, 100)
self.auctions.submit_bid(task_id, aid, bid_sats)
logger.info(
"In-process agent %s bid %d sats on task %s",
name, bid_sats, task_id,
)
# Subscribe to task announcements via shared comms
self.comms.subscribe("swarm:tasks", _bid_and_register)
record = registry.register(name=name, agent_id=aid)
self._in_process_nodes.append(node)
logger.info("Spawned in-process agent %s (%s)", name, aid)
return {
"agent_id": aid,
"name": name,
"pid": None,
"status": record.status,
}
# ── Task lifecycle ──────────────────────────────────────────────────────
def post_task(self, description: str) -> Task:
"""Create a task, open an auction, and announce it to the swarm.
The auction is opened *before* the comms announcement so that
in-process agents (whose callbacks fire synchronously) can
submit bids into an already-open auction.
"""
task = create_task(description)
update_task(task.id, status=TaskStatus.BIDDING)
task.status = TaskStatus.BIDDING
# Open the auction first so bids from in-process agents land
self.auctions.open_auction(task.id)
self.comms.post_task(task.id, description)
logger.info("Task posted: %s (%s)", task.id, description[:50])
# Log task creation event
log_event(
EventType.TASK_CREATED,
source="coordinator",
task_id=task.id,
data={"description": description[:200]},
)
log_event(
EventType.TASK_BIDDING,
source="coordinator",
task_id=task.id,
)
# Broadcast task posted via WebSocket
self._broadcast(self._broadcast_task_posted, task.id, description)
# Spark: capture task-posted event with candidate agents
spark = _get_spark()
if spark:
candidates = [a.id for a in registry.list_agents()]
spark.on_task_posted(task.id, description, candidates)
return task
async def run_auction_and_assign(self, task_id: str) -> Optional[Bid]:
"""Wait for the bidding period, then close the auction and assign.
The auction should already be open (via post_task). This method
waits the remaining bidding window and then closes it.
All bids are recorded in the learner so agents accumulate outcome
history that later feeds back into adaptive bidding.
"""
await asyncio.sleep(AUCTION_DURATION_SECONDS)
# Snapshot the auction bids before closing (for learner recording)
auction = self.auctions.get_auction(task_id)
all_bids = list(auction.bids) if auction else []
# Build bids dict for routing engine
bids_dict = {bid.agent_id: bid.bid_sats for bid in all_bids}
# Get routing recommendation (logs decision for audit)
task = get_task(task_id)
description = task.description if task else ""
recommended, decision = swarm_routing.routing_engine.recommend_agent(
task_id, description, bids_dict
)
# Log if auction winner differs from routing recommendation
winner = self.auctions.close_auction(task_id)
if winner and recommended and winner.agent_id != recommended:
logger.warning(
"Auction winner %s differs from routing recommendation %s",
winner.agent_id[:8], recommended[:8]
)
# Retrieve description for learner context
task = get_task(task_id)
description = task.description if task else ""
# Record every bid outcome in the learner
winner_id = winner.agent_id if winner else None
for bid in all_bids:
swarm_learner.record_outcome(
task_id=task_id,
agent_id=bid.agent_id,
description=description,
bid_sats=bid.bid_sats,
won_auction=(bid.agent_id == winner_id),
)
if winner:
update_task(
task_id,
status=TaskStatus.ASSIGNED,
assigned_agent=winner.agent_id,
)
self.comms.assign_task(task_id, winner.agent_id)
registry.update_status(winner.agent_id, "busy")
# Mark winning bid in persistent stats
swarm_stats.mark_winner(task_id, winner.agent_id)
logger.info(
"Task %s assigned to %s at %d sats",
task_id, winner.agent_id, winner.bid_sats,
)
# Log task assignment event
log_event(
EventType.TASK_ASSIGNED,
source="coordinator",
task_id=task_id,
agent_id=winner.agent_id,
data={"bid_sats": winner.bid_sats},
)
# Broadcast task assigned via WebSocket
self._broadcast(self._broadcast_task_assigned, task_id, winner.agent_id)
# Spark: capture assignment
spark = _get_spark()
if spark:
spark.on_task_assigned(task_id, winner.agent_id)
else:
update_task(task_id, status=TaskStatus.FAILED)
logger.warning("Task %s: no bids received, marked as failed", task_id)
# Log task failure event
log_event(
EventType.TASK_FAILED,
source="coordinator",
task_id=task_id,
data={"reason": "no bids received"},
)
return winner
def complete_task(self, task_id: str, result: str) -> Optional[Task]:
"""Mark a task as completed with a result."""
task = get_task(task_id)
if task is None:
return None
now = datetime.now(timezone.utc).isoformat()
updated = update_task(
task_id,
status=TaskStatus.COMPLETED,
result=result,
completed_at=now,
)
if task.assigned_agent:
registry.update_status(task.assigned_agent, "idle")
self.comms.complete_task(task_id, task.assigned_agent, result)
# Record success in learner
swarm_learner.record_task_result(task_id, task.assigned_agent, succeeded=True)
# Log task completion event
log_event(
EventType.TASK_COMPLETED,
source="coordinator",
task_id=task_id,
agent_id=task.assigned_agent,
data={"result_preview": result[:500]},
)
# Broadcast task completed via WebSocket
self._broadcast(
self._broadcast_task_completed,
task_id, task.assigned_agent, result
)
# Spark: capture completion
spark = _get_spark()
if spark:
spark.on_task_completed(task_id, task.assigned_agent, result)
return updated
def fail_task(self, task_id: str, reason: str = "") -> Optional[Task]:
"""Mark a task as failed — feeds failure data into the learner."""
task = get_task(task_id)
if task is None:
return None
now = datetime.now(timezone.utc).isoformat()
updated = update_task(
task_id,
status=TaskStatus.FAILED,
result=reason,
completed_at=now,
)
if task.assigned_agent:
registry.update_status(task.assigned_agent, "idle")
# Record failure in learner
swarm_learner.record_task_result(task_id, task.assigned_agent, succeeded=False)
# Log task failure event
log_event(
EventType.TASK_FAILED,
source="coordinator",
task_id=task_id,
agent_id=task.assigned_agent,
data={"reason": reason},
)
# Spark: capture failure
spark = _get_spark()
if spark:
spark.on_task_failed(task_id, task.assigned_agent, reason)
return updated
def get_task(self, task_id: str) -> Optional[Task]:
return get_task(task_id)
def list_tasks(self, status: Optional[TaskStatus] = None) -> list[Task]:
return list_tasks(status)
# ── WebSocket broadcasts ────────────────────────────────────────────────
def _broadcast(self, broadcast_fn, *args) -> None:
"""Safely schedule a broadcast, handling sync/async contexts.
Only creates the coroutine and schedules it if an event loop is running.
This prevents 'coroutine was never awaited' warnings in tests.
"""
try:
loop = asyncio.get_running_loop()
# Create coroutine only when we have an event loop
coro = broadcast_fn(*args)
asyncio.create_task(coro)
except RuntimeError:
# No event loop running - skip broadcast silently
pass
async def _broadcast_agent_joined(self, agent_id: str, name: str) -> None:
"""Broadcast agent joined event via WebSocket."""
try:
from infrastructure.ws_manager.handler import ws_manager
await ws_manager.broadcast_agent_joined(agent_id, name)
except Exception as exc:
logger.debug("WebSocket broadcast failed (agent_joined): %s", exc)
async def _broadcast_bid(self, task_id: str, agent_id: str, bid_sats: int) -> None:
"""Broadcast bid submitted event via WebSocket."""
try:
from infrastructure.ws_manager.handler import ws_manager
await ws_manager.broadcast_bid_submitted(task_id, agent_id, bid_sats)
except Exception as exc:
logger.debug("WebSocket broadcast failed (bid): %s", exc)
async def _broadcast_task_posted(self, task_id: str, description: str) -> None:
"""Broadcast task posted event via WebSocket."""
try:
from infrastructure.ws_manager.handler import ws_manager
await ws_manager.broadcast_task_posted(task_id, description)
except Exception as exc:
logger.debug("WebSocket broadcast failed (task_posted): %s", exc)
async def _broadcast_task_assigned(self, task_id: str, agent_id: str) -> None:
"""Broadcast task assigned event via WebSocket."""
try:
from infrastructure.ws_manager.handler import ws_manager
await ws_manager.broadcast_task_assigned(task_id, agent_id)
except Exception as exc:
logger.debug("WebSocket broadcast failed (task_assigned): %s", exc)
async def _broadcast_task_completed(
self, task_id: str, agent_id: str, result: str
) -> None:
"""Broadcast task completed event via WebSocket."""
try:
from infrastructure.ws_manager.handler import ws_manager
await ws_manager.broadcast_task_completed(task_id, agent_id, result)
except Exception as exc:
logger.debug("WebSocket broadcast failed (task_completed): %s", exc)
# ── Convenience ─────────────────────────────────────────────────────────
def status(self) -> dict:
"""Return a summary of the swarm state."""
agents = registry.list_agents()
tasks = list_tasks()
status = {
"agents": len(agents),
"agents_idle": sum(1 for a in agents if a.status == "idle"),
"agents_busy": sum(1 for a in agents if a.status == "busy"),
"tasks_total": len(tasks),
"tasks_pending": sum(1 for t in tasks if t.status == TaskStatus.PENDING),
"tasks_running": sum(1 for t in tasks if t.status == TaskStatus.RUNNING),
"tasks_completed": sum(1 for t in tasks if t.status == TaskStatus.COMPLETED),
"active_auctions": len(self.auctions.active_auctions),
"routing_manifests": len(swarm_routing.routing_engine._manifests),
}
# Include Spark Intelligence summary if available
spark = _get_spark()
if spark and spark.enabled:
spark_status = spark.status()
status["spark"] = {
"events_captured": spark_status["events_captured"],
"memories_stored": spark_status["memories_stored"],
"prediction_accuracy": spark_status["predictions"]["avg_accuracy"],
}
return status
def get_routing_decisions(self, task_id: Optional[str] = None, limit: int = 100) -> list:
"""Get routing decision history for audit.
Args:
task_id: Filter to specific task (optional)
limit: Maximum number of decisions to return
"""
return swarm_routing.routing_engine.get_routing_history(task_id, limit=limit)
# Module-level singleton for use by dashboard routes
coordinator = SwarmCoordinator()

View File

@@ -1,187 +0,0 @@
"""Docker-backed agent runner — spawn swarm agents as isolated containers.
Drop-in complement to SwarmManager. Instead of Python subprocesses,
DockerAgentRunner launches each agent as a Docker container that shares
the data volume and communicates with the coordinator over HTTP.
Requirements
------------
- Docker Engine running on the host (``docker`` CLI in PATH)
- The ``timmy-time:latest`` image already built (``make docker-build``)
- ``data/`` directory exists and is mounted at ``/app/data`` in each container
Communication
-------------
Container agents use the coordinator's internal HTTP API rather than the
in-memory SwarmComms channel::
GET /internal/tasks → poll for tasks open for bidding
POST /internal/bids → submit a bid
The ``COORDINATOR_URL`` env var tells agents where to reach the coordinator.
Inside the docker-compose network this is ``http://dashboard:8000``.
From the host it is typically ``http://localhost:8000``.
Usage
-----
::
from swarm.docker_runner import DockerAgentRunner
runner = DockerAgentRunner()
info = runner.spawn("Echo", capabilities="summarise,translate")
print(info) # {"container_id": "...", "name": "Echo", "agent_id": "..."}
runner.stop(info["container_id"])
runner.stop_all()
"""
import logging
import subprocess
import uuid
from dataclasses import dataclass, field
from typing import Optional
logger = logging.getLogger(__name__)
DEFAULT_IMAGE = "timmy-time:latest"
DEFAULT_COORDINATOR_URL = "http://dashboard:8000"
@dataclass
class ManagedContainer:
container_id: str
agent_id: str
name: str
image: str
capabilities: str = ""
class DockerAgentRunner:
"""Spawn and manage swarm agents as Docker containers."""
def __init__(
self,
image: str = DEFAULT_IMAGE,
coordinator_url: str = DEFAULT_COORDINATOR_URL,
extra_env: Optional[dict] = None,
) -> None:
self.image = image
self.coordinator_url = coordinator_url
self.extra_env = extra_env or {}
self._containers: dict[str, ManagedContainer] = {}
# ── Public API ────────────────────────────────────────────────────────────
def spawn(
self,
name: str,
agent_id: Optional[str] = None,
capabilities: str = "",
image: Optional[str] = None,
) -> dict:
"""Spawn a new agent container and return its info dict.
The container runs ``python -m swarm.agent_runner`` and communicates
with the coordinator over HTTP via ``COORDINATOR_URL``.
"""
aid = agent_id or str(uuid.uuid4())
img = image or self.image
container_name = f"timmy-agent-{aid[:8]}"
env_flags = self._build_env_flags(aid, name, capabilities)
cmd = [
"docker", "run",
"--detach",
"--name", container_name,
"--network", "timmy-time_swarm-net",
"--volume", "timmy-time_timmy-data:/app/data",
"--extra-hosts", "host.docker.internal:host-gateway",
*env_flags,
img,
"python", "-m", "swarm.agent_runner",
"--agent-id", aid,
"--name", name,
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=15
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip())
container_id = result.stdout.strip()
except FileNotFoundError:
raise RuntimeError(
"Docker CLI not found. Is Docker Desktop running?"
)
managed = ManagedContainer(
container_id=container_id,
agent_id=aid,
name=name,
image=img,
capabilities=capabilities,
)
self._containers[container_id] = managed
logger.info(
"Docker agent %s (%s) started — container %s",
name, aid, container_id[:12],
)
return {
"container_id": container_id,
"agent_id": aid,
"name": name,
"image": img,
"capabilities": capabilities,
}
def stop(self, container_id: str) -> bool:
"""Stop and remove a container agent."""
try:
subprocess.run(
["docker", "rm", "-f", container_id],
capture_output=True, timeout=10,
)
self._containers.pop(container_id, None)
logger.info("Docker agent container %s stopped", container_id[:12])
return True
except Exception as exc:
logger.error("Failed to stop container %s: %s", container_id[:12], exc)
return False
def stop_all(self) -> int:
"""Stop all containers managed by this runner."""
ids = list(self._containers.keys())
stopped = sum(1 for cid in ids if self.stop(cid))
return stopped
def list_containers(self) -> list[ManagedContainer]:
return list(self._containers.values())
def is_running(self, container_id: str) -> bool:
"""Return True if the container is currently running."""
try:
result = subprocess.run(
["docker", "inspect", "--format", "{{.State.Running}}", container_id],
capture_output=True, text=True, timeout=5,
)
return result.stdout.strip() == "true"
except Exception:
return False
# ── Internal ──────────────────────────────────────────────────────────────
def _build_env_flags(self, agent_id: str, name: str, capabilities: str) -> list[str]:
env = {
"COORDINATOR_URL": self.coordinator_url,
"AGENT_NAME": name,
"AGENT_ID": agent_id,
"AGENT_CAPABILITIES": capabilities,
**self.extra_env,
}
flags = []
for k, v in env.items():
flags += ["--env", f"{k}={v}"]
return flags

View File

@@ -1,443 +0,0 @@
"""Swarm learner — outcome tracking and adaptive bid intelligence.
Records task outcomes (win/loss, success/failure) per agent and extracts
actionable metrics. Persona nodes consult the learner to adjust bids
based on historical performance rather than using static strategies.
Inspired by feedback-loop learning: outcomes re-enter the system to
improve future decisions. All data lives in swarm.db alongside the
existing bid_history and tasks tables.
"""
import re
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
DB_PATH = Path("data/swarm.db")
# Minimum outcomes before the learner starts adjusting bids
_MIN_OUTCOMES = 3
# Stop-words excluded from keyword extraction
_STOP_WORDS = frozenset({
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
"of", "with", "by", "from", "is", "it", "this", "that", "be", "as",
"are", "was", "were", "been", "do", "does", "did", "will", "would",
"can", "could", "should", "may", "might", "me", "my", "i", "we",
"you", "your", "please", "task", "need", "want", "make", "get",
})
_WORD_RE = re.compile(r"[a-z]{3,}")
@dataclass
class AgentMetrics:
"""Computed performance metrics for a single agent."""
agent_id: str
total_bids: int = 0
auctions_won: int = 0
tasks_completed: int = 0
tasks_failed: int = 0
avg_winning_bid: float = 0.0
win_rate: float = 0.0
success_rate: float = 0.0
keyword_wins: dict[str, int] = field(default_factory=dict)
keyword_failures: dict[str, int] = field(default_factory=dict)
def _get_conn() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute(
"""
CREATE TABLE IF NOT EXISTS task_outcomes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
bid_sats INTEGER NOT NULL DEFAULT 0,
won_auction INTEGER NOT NULL DEFAULT 0,
task_succeeded INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"""
)
conn.commit()
return conn
def _extract_keywords(text: str) -> list[str]:
"""Pull meaningful words from a task description."""
words = _WORD_RE.findall(text.lower())
return [w for w in words if w not in _STOP_WORDS]
# ── Recording ────────────────────────────────────────────────────────────────
def record_outcome(
task_id: str,
agent_id: str,
description: str,
bid_sats: int,
won_auction: bool,
task_succeeded: Optional[bool] = None,
) -> None:
"""Record one agent's outcome for a task."""
conn = _get_conn()
conn.execute(
"""
INSERT INTO task_outcomes
(task_id, agent_id, description, bid_sats, won_auction, task_succeeded)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
task_id,
agent_id,
description,
bid_sats,
int(won_auction),
int(task_succeeded) if task_succeeded is not None else None,
),
)
conn.commit()
conn.close()
def record_task_result(task_id: str, agent_id: str, succeeded: bool) -> int:
"""Update the task_succeeded flag for an already-recorded winning outcome.
Returns the number of rows updated.
"""
conn = _get_conn()
cursor = conn.execute(
"""
UPDATE task_outcomes
SET task_succeeded = ?
WHERE task_id = ? AND agent_id = ? AND won_auction = 1
""",
(int(succeeded), task_id, agent_id),
)
conn.commit()
updated = cursor.rowcount
conn.close()
return updated
# ── Metrics ──────────────────────────────────────────────────────────────────
def get_metrics(agent_id: str) -> AgentMetrics:
"""Compute performance metrics from stored outcomes."""
conn = _get_conn()
rows = conn.execute(
"SELECT * FROM task_outcomes WHERE agent_id = ?",
(agent_id,),
).fetchall()
conn.close()
metrics = AgentMetrics(agent_id=agent_id)
if not rows:
return metrics
metrics.total_bids = len(rows)
winning_bids: list[int] = []
for row in rows:
won = bool(row["won_auction"])
succeeded = row["task_succeeded"]
keywords = _extract_keywords(row["description"])
if won:
metrics.auctions_won += 1
winning_bids.append(row["bid_sats"])
if succeeded == 1:
metrics.tasks_completed += 1
for kw in keywords:
metrics.keyword_wins[kw] = metrics.keyword_wins.get(kw, 0) + 1
elif succeeded == 0:
metrics.tasks_failed += 1
for kw in keywords:
metrics.keyword_failures[kw] = metrics.keyword_failures.get(kw, 0) + 1
metrics.win_rate = (
metrics.auctions_won / metrics.total_bids if metrics.total_bids else 0.0
)
decided = metrics.tasks_completed + metrics.tasks_failed
metrics.success_rate = (
metrics.tasks_completed / decided if decided else 0.0
)
metrics.avg_winning_bid = (
sum(winning_bids) / len(winning_bids) if winning_bids else 0.0
)
return metrics
def get_all_metrics() -> dict[str, AgentMetrics]:
"""Return metrics for every agent that has recorded outcomes."""
conn = _get_conn()
agent_ids = [
row["agent_id"]
for row in conn.execute(
"SELECT DISTINCT agent_id FROM task_outcomes"
).fetchall()
]
conn.close()
return {aid: get_metrics(aid) for aid in agent_ids}
# ── Bid intelligence ─────────────────────────────────────────────────────────
def suggest_bid(agent_id: str, task_description: str, base_bid: int) -> int:
"""Adjust a base bid using learned performance data.
Returns the base_bid unchanged until the agent has enough history
(>= _MIN_OUTCOMES). After that:
- Win rate too high (>80%): nudge bid up — still win, earn more.
- Win rate too low (<20%): nudge bid down — be more competitive.
- Success rate low on won tasks: nudge bid up — avoid winning tasks
this agent tends to fail.
- Strong keyword match from past wins: nudge bid down — this agent
is proven on similar work.
"""
metrics = get_metrics(agent_id)
if metrics.total_bids < _MIN_OUTCOMES:
return base_bid
factor = 1.0
# Win-rate adjustment
if metrics.win_rate > 0.8:
factor *= 1.15 # bid higher, maximise revenue
elif metrics.win_rate < 0.2:
factor *= 0.85 # bid lower, be competitive
# Success-rate adjustment (only when enough completed tasks)
decided = metrics.tasks_completed + metrics.tasks_failed
if decided >= 2:
if metrics.success_rate < 0.5:
factor *= 1.25 # avoid winning bad matches
elif metrics.success_rate > 0.8:
factor *= 0.90 # we're good at this, lean in
# Keyword relevance from past wins
task_keywords = _extract_keywords(task_description)
if task_keywords:
wins = sum(metrics.keyword_wins.get(kw, 0) for kw in task_keywords)
fails = sum(metrics.keyword_failures.get(kw, 0) for kw in task_keywords)
if wins > fails and wins >= 2:
factor *= 0.90 # proven track record on these keywords
elif fails > wins and fails >= 2:
factor *= 1.15 # poor track record — back off
adjusted = int(base_bid * factor)
return max(1, adjusted)
def learned_keywords(agent_id: str) -> list[dict]:
"""Return keywords ranked by net wins (wins minus failures).
Useful for discovering which task types an agent actually excels at,
potentially different from its hardcoded preferred_keywords.
"""
metrics = get_metrics(agent_id)
all_kw = set(metrics.keyword_wins) | set(metrics.keyword_failures)
results = []
for kw in all_kw:
wins = metrics.keyword_wins.get(kw, 0)
fails = metrics.keyword_failures.get(kw, 0)
results.append({"keyword": kw, "wins": wins, "failures": fails, "net": wins - fails})
results.sort(key=lambda x: x["net"], reverse=True)
return results
# ── Reward model scoring (PRM-style) ─────────────────────────────────────────
import logging as _logging
from config import settings as _settings
_reward_logger = _logging.getLogger(__name__ + ".reward")
@dataclass
class RewardScore:
"""Result from reward-model evaluation."""
score: float # Normalised score in [-1.0, 1.0]
positive_votes: int
negative_votes: int
total_votes: int
model_used: str
def _ensure_reward_table() -> None:
"""Create the reward_scores table if needed."""
conn = _get_conn()
conn.execute(
"""
CREATE TABLE IF NOT EXISTS reward_scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
output_text TEXT NOT NULL,
score REAL NOT NULL,
positive INTEGER NOT NULL,
negative INTEGER NOT NULL,
total INTEGER NOT NULL,
model_used TEXT NOT NULL,
scored_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"""
)
conn.commit()
conn.close()
def score_output(
task_id: str,
agent_id: str,
task_description: str,
output_text: str,
) -> Optional[RewardScore]:
"""Score an agent's output using the reward model (majority vote).
Calls the reward model N times (settings.reward_model_votes) with a
quality-evaluation prompt. Each vote is +1 (good) or -1 (bad).
Final score is (positive - negative) / total, in [-1.0, 1.0].
Returns None if the reward model is disabled or unavailable.
"""
if not _settings.reward_model_enabled:
return None
# Resolve model name: explicit setting > registry reward model > skip
model_name = _settings.reward_model_name
if not model_name:
try:
from infrastructure.models.registry import model_registry
reward = model_registry.get_reward_model()
if reward:
model_name = reward.path if reward.format.value == "ollama" else reward.name
except Exception:
pass
if not model_name:
_reward_logger.debug("No reward model configured, skipping scoring")
return None
num_votes = max(1, _settings.reward_model_votes)
positive = 0
negative = 0
prompt = (
f"You are a quality evaluator. Rate the following agent output.\n\n"
f"TASK: {task_description}\n\n"
f"OUTPUT:\n{output_text[:2000]}\n\n"
f"Is this output correct, helpful, and complete? "
f"Reply with exactly one word: GOOD or BAD."
)
try:
import requests as _req
ollama_url = _settings.ollama_url
for _ in range(num_votes):
try:
resp = _req.post(
f"{ollama_url}/api/generate",
json={
"model": model_name,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.3, "num_predict": 10},
},
timeout=30,
)
if resp.status_code == 200:
answer = resp.json().get("response", "").strip().upper()
if "GOOD" in answer:
positive += 1
else:
negative += 1
else:
negative += 1 # Treat errors as negative conservatively
except Exception as vote_exc:
_reward_logger.debug("Vote failed: %s", vote_exc)
negative += 1
except ImportError:
_reward_logger.warning("requests library not available for reward scoring")
return None
total = positive + negative
if total == 0:
return None
score = (positive - negative) / total
result = RewardScore(
score=score,
positive_votes=positive,
negative_votes=negative,
total_votes=total,
model_used=model_name,
)
# Persist to DB
try:
_ensure_reward_table()
conn = _get_conn()
conn.execute(
"""
INSERT INTO reward_scores
(task_id, agent_id, output_text, score, positive, negative, total, model_used)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id, agent_id, output_text[:5000],
score, positive, negative, total, model_name,
),
)
conn.commit()
conn.close()
except Exception as db_exc:
_reward_logger.warning("Failed to persist reward score: %s", db_exc)
_reward_logger.info(
"Scored task %s agent %s: %.2f (%d+/%d- of %d votes)",
task_id, agent_id, score, positive, negative, total,
)
return result
def get_reward_scores(
agent_id: Optional[str] = None, limit: int = 50
) -> list[dict]:
"""Retrieve historical reward scores from the database."""
_ensure_reward_table()
conn = _get_conn()
if agent_id:
rows = conn.execute(
"SELECT * FROM reward_scores WHERE agent_id = ? ORDER BY id DESC LIMIT ?",
(agent_id, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM reward_scores ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
conn.close()
return [
{
"task_id": r["task_id"],
"agent_id": r["agent_id"],
"score": r["score"],
"positive": r["positive"],
"negative": r["negative"],
"total": r["total"],
"model_used": r["model_used"],
"scored_at": r["scored_at"],
}
for r in rows
]

View File

@@ -1,97 +0,0 @@
"""Swarm manager — spawn and manage sub-agent processes.
Each sub-agent runs as a separate Python process executing agent_runner.py.
The manager tracks PIDs and provides lifecycle operations (spawn, stop, list).
"""
import logging
import subprocess
import sys
import uuid
from dataclasses import dataclass, field
from typing import Optional
logger = logging.getLogger(__name__)
@dataclass
class ManagedAgent:
agent_id: str
name: str
process: Optional[subprocess.Popen] = None
pid: Optional[int] = None
@property
def alive(self) -> bool:
if self.process is None:
return False
return self.process.poll() is None
class SwarmManager:
"""Manages the lifecycle of sub-agent processes."""
def __init__(self) -> None:
self._agents: dict[str, ManagedAgent] = {}
def spawn(self, name: str, agent_id: Optional[str] = None) -> ManagedAgent:
"""Spawn a new sub-agent process."""
aid = agent_id or str(uuid.uuid4())
try:
proc = subprocess.Popen(
[
sys.executable, "-m", "swarm.agent_runner",
"--agent-id", aid,
"--name", name,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
managed = ManagedAgent(agent_id=aid, name=name, process=proc, pid=proc.pid)
self._agents[aid] = managed
logger.info("Spawned agent %s (%s) — PID %d", name, aid, proc.pid)
return managed
except Exception as exc:
logger.error("Failed to spawn agent %s: %s", name, exc)
managed = ManagedAgent(agent_id=aid, name=name)
self._agents[aid] = managed
return managed
def stop(self, agent_id: str) -> bool:
"""Stop a running sub-agent process."""
managed = self._agents.get(agent_id)
if managed is None:
return False
if managed.process and managed.alive:
managed.process.terminate()
try:
managed.process.wait(timeout=5)
except subprocess.TimeoutExpired:
managed.process.kill()
# Close pipes to avoid ResourceWarning
if managed.process.stdout:
managed.process.stdout.close()
if managed.process.stderr:
managed.process.stderr.close()
logger.info("Stopped agent %s (%s)", managed.name, agent_id)
del self._agents[agent_id]
return True
def stop_all(self) -> int:
"""Stop all running sub-agents. Returns count of agents stopped."""
ids = list(self._agents.keys())
count = 0
for aid in ids:
if self.stop(aid):
count += 1
return count
def list_agents(self) -> list[ManagedAgent]:
return list(self._agents.values())
def get_agent(self, agent_id: str) -> Optional[ManagedAgent]:
return self._agents.get(agent_id)
@property
def count(self) -> int:
return len(self._agents)

View File

@@ -1,15 +0,0 @@
"""PersonaNode — DEPRECATED, to be removed.
Replaced by distributed brain worker queue.
"""
from typing import Any
class PersonaNode:
"""Deprecated - use brain worker instead."""
def __init__(self, *args, **kwargs):
raise NotImplementedError(
"PersonaNode is deprecated. Use brain.DistributedWorker instead."
)

View File

@@ -1,25 +0,0 @@
"""Personas — DEPRECATED, to be removed.
This module is kept for backward compatibility during migration.
All persona functionality has been replaced by the distributed brain task queue.
"""
from typing import TypedDict, List
class PersonaMeta(TypedDict, total=False):
id: str
name: str
role: str
description: str
capabilities: str
rate_sats: int
# Empty personas list - functionality moved to brain task queue
PERSONAS: dict[str, PersonaMeta] = {}
def list_personas() -> List[PersonaMeta]:
"""Return empty list - personas deprecated."""
return []

View File

@@ -1,90 +0,0 @@
"""Swarm startup recovery — reconcile SQLite state after a restart.
When the server stops unexpectedly, tasks may be left in BIDDING, ASSIGNED,
or RUNNING states, and agents may still appear as 'idle' or 'busy' in the
registry even though no live process backs them.
``reconcile_on_startup()`` is called once during coordinator initialisation.
It performs two lightweight SQLite operations:
1. **Orphaned tasks** — any task in BIDDING, ASSIGNED, or RUNNING is moved
to FAILED with a ``result`` explaining the reason. PENDING tasks are left
alone (they haven't been touched yet and can be re-auctioned).
2. **Stale agents** — every agent record that is not already 'offline' is
marked 'offline'. Agents re-register themselves when they re-spawn; the
coordinator singleton stays the source of truth for which nodes are live.
The function returns a summary dict useful for logging and tests.
"""
import logging
from datetime import datetime, timezone
from swarm import registry
from swarm.tasks import TaskStatus, list_tasks, update_task
logger = logging.getLogger(__name__)
#: Task statuses that indicate in-flight work that can't resume after restart.
_ORPHAN_STATUSES = {TaskStatus.BIDDING, TaskStatus.ASSIGNED, TaskStatus.RUNNING}
def reconcile_on_startup() -> dict:
"""Reconcile swarm SQLite state after a server restart.
Returns a dict with keys:
tasks_failed - number of orphaned tasks moved to FAILED
agents_offlined - number of stale agent records marked offline
"""
tasks_failed = _rescue_orphaned_tasks()
agents_offlined = _offline_stale_agents()
summary = {"tasks_failed": tasks_failed, "agents_offlined": agents_offlined}
if tasks_failed or agents_offlined:
logger.info(
"Swarm recovery: %d task(s) failed, %d agent(s) offlined",
tasks_failed,
agents_offlined,
)
else:
logger.debug("Swarm recovery: nothing to reconcile")
return summary
# ── Internal helpers ──────────────────────────────────────────────────────────
def _rescue_orphaned_tasks() -> int:
"""Move BIDDING / ASSIGNED / RUNNING tasks to FAILED.
Returns the count of tasks updated.
"""
now = datetime.now(timezone.utc).isoformat()
count = 0
for task in list_tasks():
if task.status in _ORPHAN_STATUSES:
update_task(
task.id,
status=TaskStatus.FAILED,
result="Server restarted — task did not complete.",
completed_at=now,
)
count += 1
return count
def _offline_stale_agents() -> int:
"""Mark every non-offline agent as 'offline'.
Returns the count of agent records updated.
"""
agents = registry.list_agents()
count = 0
for agent in agents:
if agent.status != "offline":
registry.update_status(agent.id, "offline")
count += 1
return count

View File

@@ -1,432 +0,0 @@
"""Intelligent swarm routing with capability-based task dispatch.
Routes tasks to the most suitable agents based on:
- Capability matching (what can the agent do?)
- Historical performance (who's good at this?)
- Current load (who's available?)
- Bid competitiveness (who's cheapest?)
All routing decisions are logged for audit and improvement.
"""
import hashlib
import json
import logging
import sqlite3
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
# Note: swarm.personas is deprecated, use brain task queue instead
PERSONAS = {} # Empty for backward compatibility
logger = logging.getLogger(__name__)
# SQLite storage for routing audit logs
DB_PATH = Path("data/swarm.db")
@dataclass
class CapabilityManifest:
"""Describes what an agent can do and how well it does it.
This is the foundation of intelligent routing. Each agent
(persona) declares its capabilities, and the router scores
tasks against these declarations.
"""
agent_id: str
agent_name: str
capabilities: list[str] # e.g., ["coding", "debugging", "python"]
keywords: list[str] # Words that trigger this agent
rate_sats: int # Base rate for this agent type
success_rate: float = 0.0 # Historical success (0-1)
avg_completion_time: float = 0.0 # Seconds
total_tasks: int = 0
def score_task_match(self, task_description: str) -> float:
"""Score how well this agent matches a task (0-1).
Higher score = better match = should bid lower.
"""
desc_lower = task_description.lower()
words = set(desc_lower.split())
score = 0.0
# Keyword matches (strong signal)
for kw in self.keywords:
if kw.lower() in desc_lower:
score += 0.3
# Capability matches (moderate signal)
for cap in self.capabilities:
if cap.lower() in desc_lower:
score += 0.2
# Related word matching (weak signal)
related_words = {
"code": ["function", "class", "bug", "fix", "implement"],
"write": ["document", "draft", "content", "article"],
"analyze": ["data", "report", "metric", "insight"],
"security": ["vulnerability", "threat", "audit", "scan"],
}
for cap in self.capabilities:
if cap.lower() in related_words:
for related in related_words[cap.lower()]:
if related in desc_lower:
score += 0.1
# Cap at 1.0
return min(score, 1.0)
@dataclass
class RoutingDecision:
"""Record of a routing decision for audit and learning.
Immutable once created — the log of truth for what happened.
"""
task_id: str
task_description: str
candidate_agents: list[str] # Who was considered
selected_agent: Optional[str] # Who won (None if no bids)
selection_reason: str # Why this agent was chosen
capability_scores: dict[str, float] # Score per agent
bids_received: dict[str, int] # Bid amount per agent
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def to_dict(self) -> dict:
return {
"task_id": self.task_id,
"task_description": self.task_description[:100], # Truncate
"candidate_agents": self.candidate_agents,
"selected_agent": self.selected_agent,
"selection_reason": self.selection_reason,
"capability_scores": self.capability_scores,
"bids_received": self.bids_received,
"timestamp": self.timestamp,
}
class RoutingEngine:
"""Intelligent task routing with audit logging.
The engine maintains capability manifests for all agents
and uses them to score task matches. When a task comes in:
1. Score each agent's capability match
2. Let agents bid (lower bid = more confident)
3. Select winner based on bid + capability score
4. Log the decision for audit
"""
def __init__(self) -> None:
self._manifests: dict[str, CapabilityManifest] = {}
self._lock = threading.Lock()
self._db_initialized = False
self._init_db()
logger.info("RoutingEngine initialized")
def _init_db(self) -> None:
"""Create routing audit table."""
try:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.execute("""
CREATE TABLE IF NOT EXISTS routing_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
task_hash TEXT NOT NULL, -- For deduplication
selected_agent TEXT,
selection_reason TEXT,
decision_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_routing_task
ON routing_decisions(task_id)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_routing_time
ON routing_decisions(created_at)
""")
conn.commit()
conn.close()
self._db_initialized = True
except sqlite3.Error as e:
logger.warning("Failed to init routing DB: %s", e)
self._db_initialized = False
def register_persona(self, persona_id: str, agent_id: str) -> CapabilityManifest:
"""Create a capability manifest from a persona definition.
DEPRECATED: Personas are deprecated. Use brain task queue instead.
"""
meta = PERSONAS.get(persona_id)
if not meta:
# Return a generic manifest for unknown personas
# (personas are deprecated, this maintains backward compatibility)
manifest = CapabilityManifest(
agent_id=agent_id,
agent_name=persona_id,
capabilities=["general"],
keywords=[],
rate_sats=50,
)
else:
manifest = CapabilityManifest(
agent_id=agent_id,
agent_name=meta.get("name", persona_id),
capabilities=meta.get("capabilities", "").split(","),
keywords=meta.get("preferred_keywords", []),
rate_sats=meta.get("rate_sats", 50),
)
with self._lock:
self._manifests[agent_id] = manifest
logger.debug("Registered %s (%s) with %d capabilities",
manifest.agent_name, agent_id, len(manifest.capabilities))
return manifest
def register_custom_manifest(self, manifest: CapabilityManifest) -> None:
"""Register a custom capability manifest."""
with self._lock:
self._manifests[manifest.agent_id] = manifest
def get_manifest(self, agent_id: str) -> Optional[CapabilityManifest]:
"""Get an agent's capability manifest."""
with self._lock:
return self._manifests.get(agent_id)
def score_candidates(self, task_description: str) -> dict[str, float]:
"""Score all registered agents against a task.
Returns:
Dict mapping agent_id -> match score (0-1)
"""
with self._lock:
manifests = dict(self._manifests)
scores = {}
for agent_id, manifest in manifests.items():
scores[agent_id] = manifest.score_task_match(task_description)
return scores
def recommend_agent(
self,
task_id: str,
task_description: str,
bids: dict[str, int],
) -> tuple[Optional[str], RoutingDecision]:
"""Recommend the best agent for a task.
Scoring formula:
final_score = capability_score * 0.6 + (1 / bid) * 0.4
Higher capability + lower bid = better agent.
Returns:
Tuple of (selected_agent_id, routing_decision)
"""
capability_scores = self.score_candidates(task_description)
# Filter to only bidders
candidate_ids = list(bids.keys())
if not candidate_ids:
decision = RoutingDecision(
task_id=task_id,
task_description=task_description,
candidate_agents=[],
selected_agent=None,
selection_reason="No bids received",
capability_scores=capability_scores,
bids_received=bids,
)
self._log_decision(decision)
return None, decision
# Calculate combined scores
combined_scores = {}
for agent_id in candidate_ids:
cap_score = capability_scores.get(agent_id, 0.0)
bid = bids[agent_id]
# Normalize bid: lower is better, so invert
# Assuming bids are 10-100 sats, normalize to 0-1
bid_score = max(0, min(1, (100 - bid) / 90))
combined_scores[agent_id] = cap_score * 0.6 + bid_score * 0.4
# Select best
winner = max(combined_scores, key=combined_scores.get)
winner_cap = capability_scores.get(winner, 0.0)
reason = (
f"Selected {winner} with capability_score={winner_cap:.2f}, "
f"bid={bids[winner]} sats, combined={combined_scores[winner]:.2f}"
)
decision = RoutingDecision(
task_id=task_id,
task_description=task_description,
candidate_agents=candidate_ids,
selected_agent=winner,
selection_reason=reason,
capability_scores=capability_scores,
bids_received=bids,
)
self._log_decision(decision)
logger.info("Routing: %s%s (score: %.2f)",
task_id[:8], winner[:8], combined_scores[winner])
return winner, decision
def _log_decision(self, decision: RoutingDecision) -> None:
"""Persist routing decision to audit log."""
# Ensure DB is initialized (handles test DB resets)
if not self._db_initialized:
self._init_db()
# Create hash for deduplication
task_hash = hashlib.sha256(
f"{decision.task_id}:{decision.timestamp}".encode()
).hexdigest()[:16]
try:
conn = sqlite3.connect(str(DB_PATH))
conn.execute(
"""
INSERT INTO routing_decisions
(task_id, task_hash, selected_agent, selection_reason, decision_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
decision.task_id,
task_hash,
decision.selected_agent,
decision.selection_reason,
json.dumps(decision.to_dict()),
decision.timestamp,
)
)
conn.commit()
conn.close()
except sqlite3.Error as e:
logger.warning("Failed to log routing decision: %s", e)
def get_routing_history(
self,
task_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
) -> list[RoutingDecision]:
"""Query routing decision history.
Args:
task_id: Filter to specific task
agent_id: Filter to decisions involving this agent
limit: Max results to return
"""
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
if task_id:
rows = conn.execute(
"SELECT * FROM routing_decisions WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
(task_id, limit)
).fetchall()
elif agent_id:
rows = conn.execute(
"""SELECT * FROM routing_decisions
WHERE selected_agent = ? OR decision_json LIKE ?
ORDER BY created_at DESC LIMIT ?""",
(agent_id, f'%"{agent_id}"%', limit)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM routing_decisions ORDER BY created_at DESC LIMIT ?",
(limit,)
).fetchall()
conn.close()
decisions = []
for row in rows:
data = json.loads(row["decision_json"])
decisions.append(RoutingDecision(
task_id=data["task_id"],
task_description=data["task_description"],
candidate_agents=data["candidate_agents"],
selected_agent=data["selected_agent"],
selection_reason=data["selection_reason"],
capability_scores=data["capability_scores"],
bids_received=data["bids_received"],
timestamp=data["timestamp"],
))
return decisions
def get_agent_stats(self, agent_id: str) -> dict:
"""Get routing statistics for an agent.
Returns:
Dict with wins, avg_score, total_tasks, etc.
"""
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
# Count wins
wins = conn.execute(
"SELECT COUNT(*) FROM routing_decisions WHERE selected_agent = ?",
(agent_id,)
).fetchone()[0]
# Count total appearances
total = conn.execute(
"SELECT COUNT(*) FROM routing_decisions WHERE decision_json LIKE ?",
(f'%"{agent_id}"%',)
).fetchone()[0]
conn.close()
return {
"agent_id": agent_id,
"tasks_won": wins,
"tasks_considered": total,
"win_rate": wins / total if total > 0 else 0.0,
}
def export_audit_log(self, since: Optional[str] = None) -> list[dict]:
"""Export full audit log for analysis.
Args:
since: ISO timestamp to filter from (optional)
"""
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
if since:
rows = conn.execute(
"SELECT * FROM routing_decisions WHERE created_at > ? ORDER BY created_at",
(since,)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM routing_decisions ORDER BY created_at"
).fetchall()
conn.close()
return [json.loads(row["decision_json"]) for row in rows]
# Module-level singleton
routing_engine = RoutingEngine()

View File

@@ -1,70 +0,0 @@
"""SwarmNode — a single agent's view of the swarm.
A SwarmNode registers itself in the SQLite registry, listens for tasks
via the comms layer, and submits bids through the auction system.
Used by agent_runner.py when a sub-agent process is spawned.
"""
import logging
import random
from typing import Optional
from swarm import registry
from swarm.comms import CHANNEL_TASKS, SwarmComms, SwarmMessage
logger = logging.getLogger(__name__)
class SwarmNode:
"""Represents a single agent participating in the swarm."""
def __init__(
self,
agent_id: str,
name: str,
capabilities: str = "",
comms: Optional[SwarmComms] = None,
) -> None:
self.agent_id = agent_id
self.name = name
self.capabilities = capabilities
self._comms = comms or SwarmComms()
self._joined = False
async def join(self) -> None:
"""Register with the swarm and start listening for tasks."""
registry.register(
name=self.name,
capabilities=self.capabilities,
agent_id=self.agent_id,
)
self._comms.subscribe(CHANNEL_TASKS, self._on_task_posted)
self._joined = True
logger.info("SwarmNode %s (%s) joined the swarm", self.name, self.agent_id)
async def leave(self) -> None:
"""Unregister from the swarm."""
registry.update_status(self.agent_id, "offline")
self._joined = False
logger.info("SwarmNode %s (%s) left the swarm", self.name, self.agent_id)
def _on_task_posted(self, msg: SwarmMessage) -> None:
"""Handle an incoming task announcement by submitting a bid."""
task_id = msg.data.get("task_id")
if not task_id:
return
# Simple bidding strategy: random bid between 10 and 100 sats
bid_sats = random.randint(10, 100)
self._comms.submit_bid(
task_id=task_id,
agent_id=self.agent_id,
bid_sats=bid_sats,
)
logger.info(
"SwarmNode %s bid %d sats on task %s",
self.name, bid_sats, task_id,
)
@property
def is_joined(self) -> bool:
return self._joined

View File

@@ -1,396 +0,0 @@
"""Tool execution layer for swarm agents.
Bridges PersonaNodes with MCP tools, enabling agents to actually
do work when they win a task auction.
Usage:
executor = ToolExecutor.for_persona("forge", agent_id="forge-001")
result = executor.execute_task("Write a function to calculate fibonacci")
"""
import asyncio
import logging
from typing import Any, Optional
from pathlib import Path
from config import settings
from timmy.tools import get_tools_for_persona, create_full_toolkit
from timmy.agent import create_timmy
logger = logging.getLogger(__name__)
class ToolExecutor:
"""Executes tasks using persona-appropriate tools.
Each persona gets a different set of tools based on their specialty:
- Echo: web search, file reading
- Forge: shell, python, file read/write, git
- Seer: python, file reading
- Quill: file read/write
- Mace: shell, web search
- Helm: shell, file operations, git
- Pixel: image generation, storyboards
- Lyra: music/song generation
- Reel: video generation, assembly
The executor combines:
1. MCP tools (file, shell, python, search)
2. LLM reasoning (via Ollama) to decide which tools to use
3. Task execution and result formatting
"""
def __init__(
self,
persona_id: str,
agent_id: str,
base_dir: Optional[Path] = None,
) -> None:
"""Initialize tool executor for a persona.
Args:
persona_id: The persona type (echo, forge, etc.)
agent_id: Unique agent instance ID
base_dir: Base directory for file operations
"""
self._persona_id = persona_id
self._agent_id = agent_id
self._base_dir = base_dir or Path.cwd()
# Get persona-specific tools
try:
self._toolkit = get_tools_for_persona(persona_id, base_dir)
if self._toolkit is None:
logger.warning(
"No toolkit available for persona %s, using full toolkit",
persona_id
)
self._toolkit = create_full_toolkit(base_dir)
except ImportError as exc:
logger.warning(
"Tools not available for %s (Agno not installed): %s",
persona_id, exc
)
self._toolkit = None
# Create LLM agent for reasoning about tool use
# The agent uses the toolkit to decide what actions to take
try:
self._llm = create_timmy()
except Exception as exc:
logger.warning("Failed to create LLM agent: %s", exc)
self._llm = None
logger.info(
"ToolExecutor initialized for %s (%s) with %d tools",
persona_id, agent_id, len(self._toolkit.functions) if self._toolkit else 0
)
@classmethod
def for_persona(
cls,
persona_id: str,
agent_id: str,
base_dir: Optional[Path] = None,
) -> "ToolExecutor":
"""Factory method to create executor for a persona."""
return cls(persona_id, agent_id, base_dir)
def execute_task(self, task_description: str) -> dict[str, Any]:
"""Execute a task using appropriate tools.
This is the main entry point. The executor:
1. Analyzes the task
2. Decides which tools to use
3. Executes them (potentially multiple rounds)
4. Formats the result
Args:
task_description: What needs to be done
Returns:
Dict with result, tools_used, and any errors
"""
if self._toolkit is None:
return {
"success": False,
"error": "No toolkit available",
"result": None,
"tools_used": [],
}
tools_used = []
try:
# For now, use a simple approach: let the LLM decide what to do
# In the future, this could be more sophisticated with multi-step planning
# Log what tools would be appropriate (in future, actually execute them)
# For now, we track which tools were likely needed based on keywords
likely_tools = self._infer_tools_needed(task_description)
tools_used = likely_tools
if self._llm is None:
# No LLM available - return simulated response
response_text = (
f"[Simulated {self._persona_id} response] "
f"Would execute task using tools: {', '.join(tools_used) or 'none'}"
)
else:
# Build system prompt describing available tools
tool_descriptions = self._describe_tools()
prompt = f"""You are a {self._persona_id} specialist agent.
Your task: {task_description}
Available tools:
{tool_descriptions}
Think step by step about what tools you need to use, then provide your response.
If you need to use tools, describe what you would do. If the task is conversational, just respond naturally.
Response:"""
# Run the LLM with tool awareness
result = self._llm.run(prompt, stream=False)
response_text = result.content if hasattr(result, "content") else str(result)
logger.info(
"Task executed by %s: %d tools likely needed",
self._agent_id, len(tools_used)
)
return {
"success": True,
"result": response_text,
"tools_used": tools_used,
"persona_id": self._persona_id,
"agent_id": self._agent_id,
}
except Exception as exc:
logger.exception("Task execution failed for %s", self._agent_id)
return {
"success": False,
"error": str(exc),
"result": None,
"tools_used": tools_used,
}
def _describe_tools(self) -> str:
"""Create human-readable description of available tools."""
if not self._toolkit:
return "No tools available"
descriptions = []
for func in self._toolkit.functions:
name = getattr(func, 'name', func.__name__)
doc = func.__doc__ or "No description"
# Take first line of docstring
doc_first_line = doc.strip().split('\n')[0]
descriptions.append(f"- {name}: {doc_first_line}")
return '\n'.join(descriptions)
def _infer_tools_needed(self, task_description: str) -> list[str]:
"""Infer which tools would be needed for a task.
This is a simple keyword-based approach. In the future,
this could use the LLM to explicitly choose tools.
"""
task_lower = task_description.lower()
tools = []
# Map keywords to likely tools
keyword_tool_map = {
"search": "web_search",
"find": "web_search",
"look up": "web_search",
"read": "read_file",
"file": "read_file",
"write": "write_file",
"save": "write_file",
"code": "python",
"function": "python",
"script": "python",
"shell": "shell",
"command": "shell",
"run": "shell",
"list": "list_files",
"directory": "list_files",
# Git operations
"commit": "git_commit",
"branch": "git_branch",
"push": "git_push",
"pull": "git_pull",
"diff": "git_diff",
"clone": "git_clone",
"merge": "git_branch",
"stash": "git_stash",
"blame": "git_blame",
"git status": "git_status",
"git log": "git_log",
# Image generation
"image": "generate_image",
"picture": "generate_image",
"storyboard": "generate_storyboard",
"illustration": "generate_image",
# Music generation
"music": "generate_song",
"song": "generate_song",
"vocal": "generate_vocals",
"instrumental": "generate_instrumental",
"lyrics": "generate_song",
# Video generation
"video": "generate_video_clip",
"clip": "generate_video_clip",
"animate": "image_to_video",
"film": "generate_video_clip",
# Assembly
"stitch": "stitch_clips",
"assemble": "run_assembly",
"title card": "add_title_card",
"subtitle": "add_subtitles",
}
for keyword, tool in keyword_tool_map.items():
if keyword in task_lower and tool not in tools:
# Add tool if available in this executor's toolkit
# or if toolkit is None (for inference without execution)
if self._toolkit is None or any(
getattr(f, 'name', f.__name__) == tool
for f in self._toolkit.functions
):
tools.append(tool)
return tools
def get_capabilities(self) -> list[str]:
"""Return list of tool names this executor has access to."""
if not self._toolkit:
return []
return [
getattr(f, 'name', f.__name__)
for f in self._toolkit.functions
]
# ── OpenFang delegation ──────────────────────────────────────────────────────
# These module-level functions allow the ToolExecutor (and other callers)
# to delegate task execution to the OpenFang sidecar when available.
# Keywords that map task descriptions to OpenFang hands.
_OPENFANG_HAND_KEYWORDS: dict[str, list[str]] = {
"browser": ["browse", "navigate", "webpage", "website", "url", "scrape", "crawl"],
"collector": ["osint", "collect", "intelligence", "monitor", "surveillance", "recon"],
"predictor": ["predict", "forecast", "probability", "calibrat"],
"lead": ["lead", "prospect", "icp", "qualify", "outbound"],
"twitter": ["tweet", "twitter", "social media post"],
"researcher": ["research", "investigate", "deep dive", "literature", "survey"],
"clip": ["video clip", "video process", "caption video", "publish video"],
}
def _match_openfang_hand(task_description: str) -> Optional[str]:
"""Match a task description to an OpenFang hand name.
Returns the hand name (e.g. "browser") or None if no match.
"""
desc_lower = task_description.lower()
for hand, keywords in _OPENFANG_HAND_KEYWORDS.items():
if any(kw in desc_lower for kw in keywords):
return hand
return None
async def try_openfang_execution(task_description: str) -> Optional[dict[str, Any]]:
"""Try to execute a task via OpenFang.
Returns a result dict if OpenFang handled it, or None if the caller
should fall back to native execution. Never raises.
"""
if not settings.openfang_enabled:
return None
try:
from infrastructure.openfang.client import openfang_client
except ImportError:
logger.debug("OpenFang client not available")
return None
if not openfang_client.healthy:
logger.debug("OpenFang is not healthy, falling back to native execution")
return None
hand = _match_openfang_hand(task_description)
if hand is None:
return None
result = await openfang_client.execute_hand(hand, {"task": task_description})
if result.success:
return {
"success": True,
"result": result.output,
"tools_used": [f"openfang_{hand}"],
"runtime": "openfang",
}
logger.warning("OpenFang hand %s failed: %s — falling back", hand, result.error)
return None
class DirectToolExecutor(ToolExecutor):
"""Tool executor that actually calls tools directly.
For code-modification tasks assigned to the Forge persona, dispatches
to the SelfModifyLoop for real edit → test → commit execution.
Other tasks fall back to the simulated parent.
"""
_CODE_KEYWORDS = frozenset({
"modify", "edit", "fix", "refactor", "implement",
"add function", "change code", "update source", "patch",
})
def execute_with_tools(self, task_description: str) -> dict[str, Any]:
"""Execute tools to complete the task.
Code-modification tasks on the Forge persona are routed through
the SelfModifyLoop. Everything else delegates to the parent.
"""
task_lower = task_description.lower()
is_code_task = any(kw in task_lower for kw in self._CODE_KEYWORDS)
if is_code_task and self._persona_id == "forge":
try:
from config import settings as cfg
if not cfg.self_modify_enabled:
return self.execute_task(task_description)
from self_coding.self_modify.loop import SelfModifyLoop, ModifyRequest
loop = SelfModifyLoop()
result = loop.run(ModifyRequest(instruction=task_description))
return {
"success": result.success,
"result": (
f"Modified {len(result.files_changed)} file(s). "
f"Tests {'passed' if result.test_passed else 'failed'}."
),
"tools_used": ["read_file", "write_file", "shell", "git_commit"],
"persona_id": self._persona_id,
"agent_id": self._agent_id,
"commit_sha": result.commit_sha,
}
except Exception as exc:
logger.exception("Direct tool execution failed")
return {
"success": False,
"error": str(exc),
"result": None,
"tools_used": [],
}
return self.execute_task(task_description)

View File

@@ -1 +0,0 @@
"""Work Order system for external and internal task submission."""

View File

@@ -1,49 +0,0 @@
"""Work order execution — bridges work orders to self-modify and swarm."""
import logging
from swarm.work_orders.models import WorkOrder, WorkOrderCategory
logger = logging.getLogger(__name__)
class WorkOrderExecutor:
"""Dispatches approved work orders to the appropriate execution backend."""
def execute(self, wo: WorkOrder) -> tuple[bool, str]:
"""Execute a work order.
Returns:
(success, result_message) tuple
"""
if self._is_code_task(wo):
return self._execute_via_swarm(wo, code_hint=True)
return self._execute_via_swarm(wo)
def _is_code_task(self, wo: WorkOrder) -> bool:
"""Check if this work order involves code changes."""
code_categories = {WorkOrderCategory.BUG, WorkOrderCategory.OPTIMIZATION}
if wo.category in code_categories:
return True
if wo.related_files:
return any(f.endswith(".py") for f in wo.related_files)
return False
def _execute_via_swarm(self, wo: WorkOrder, code_hint: bool = False) -> tuple[bool, str]:
"""Dispatch as a swarm task for agent bidding."""
try:
from swarm.coordinator import coordinator
prefix = "[Code] " if code_hint else ""
description = f"{prefix}[WO-{wo.id[:8]}] {wo.title}"
if wo.description:
description += f": {wo.description}"
task = coordinator.post_task(description)
logger.info("Work order %s dispatched as swarm task %s", wo.id[:8], task.id)
return True, f"Dispatched as swarm task {task.id}"
except Exception as exc:
logger.error("Failed to dispatch work order %s: %s", wo.id[:8], exc)
return False, str(exc)
# Module-level singleton
work_order_executor = WorkOrderExecutor()

View File

@@ -1,286 +0,0 @@
"""Database models for Work Order system."""
import json
import sqlite3
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Optional
DB_PATH = Path("data/swarm.db")
class WorkOrderStatus(str, Enum):
SUBMITTED = "submitted"
TRIAGED = "triaged"
APPROVED = "approved"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
REJECTED = "rejected"
class WorkOrderPriority(str, Enum):
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class WorkOrderCategory(str, Enum):
BUG = "bug"
FEATURE = "feature"
IMPROVEMENT = "improvement"
OPTIMIZATION = "optimization"
SUGGESTION = "suggestion"
@dataclass
class WorkOrder:
"""A work order / suggestion submitted by a user or agent."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
title: str = ""
description: str = ""
priority: WorkOrderPriority = WorkOrderPriority.MEDIUM
category: WorkOrderCategory = WorkOrderCategory.SUGGESTION
status: WorkOrderStatus = WorkOrderStatus.SUBMITTED
submitter: str = "unknown"
submitter_type: str = "user" # user | agent | system
estimated_effort: Optional[str] = None # small | medium | large
related_files: list[str] = field(default_factory=list)
execution_mode: Optional[str] = None # auto | manual
swarm_task_id: Optional[str] = None
result: Optional[str] = None
rejection_reason: Optional[str] = None
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
triaged_at: Optional[str] = None
approved_at: Optional[str] = None
started_at: Optional[str] = None
completed_at: Optional[str] = None
updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def _get_conn() -> sqlite3.Connection:
"""Get database connection with schema initialized."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute(
"""
CREATE TABLE IF NOT EXISTS work_orders (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority TEXT NOT NULL DEFAULT 'medium',
category TEXT NOT NULL DEFAULT 'suggestion',
status TEXT NOT NULL DEFAULT 'submitted',
submitter TEXT NOT NULL DEFAULT 'unknown',
submitter_type TEXT NOT NULL DEFAULT 'user',
estimated_effort TEXT,
related_files TEXT,
execution_mode TEXT,
swarm_task_id TEXT,
result TEXT,
rejection_reason TEXT,
created_at TEXT NOT NULL,
triaged_at TEXT,
approved_at TEXT,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_wo_status ON work_orders(status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_wo_priority ON work_orders(priority)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_wo_submitter ON work_orders(submitter)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_wo_created ON work_orders(created_at)")
conn.commit()
return conn
def _row_to_work_order(row: sqlite3.Row) -> WorkOrder:
"""Convert a database row to a WorkOrder."""
return WorkOrder(
id=row["id"],
title=row["title"],
description=row["description"],
priority=WorkOrderPriority(row["priority"]),
category=WorkOrderCategory(row["category"]),
status=WorkOrderStatus(row["status"]),
submitter=row["submitter"],
submitter_type=row["submitter_type"],
estimated_effort=row["estimated_effort"],
related_files=json.loads(row["related_files"]) if row["related_files"] else [],
execution_mode=row["execution_mode"],
swarm_task_id=row["swarm_task_id"],
result=row["result"],
rejection_reason=row["rejection_reason"],
created_at=row["created_at"],
triaged_at=row["triaged_at"],
approved_at=row["approved_at"],
started_at=row["started_at"],
completed_at=row["completed_at"],
updated_at=row["updated_at"],
)
def create_work_order(
title: str,
description: str = "",
priority: str = "medium",
category: str = "suggestion",
submitter: str = "unknown",
submitter_type: str = "user",
estimated_effort: Optional[str] = None,
related_files: Optional[list[str]] = None,
) -> WorkOrder:
"""Create a new work order."""
wo = WorkOrder(
title=title,
description=description,
priority=WorkOrderPriority(priority),
category=WorkOrderCategory(category),
submitter=submitter,
submitter_type=submitter_type,
estimated_effort=estimated_effort,
related_files=related_files or [],
)
conn = _get_conn()
conn.execute(
"""
INSERT INTO work_orders (
id, title, description, priority, category, status,
submitter, submitter_type, estimated_effort, related_files,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
wo.id, wo.title, wo.description,
wo.priority.value, wo.category.value, wo.status.value,
wo.submitter, wo.submitter_type, wo.estimated_effort,
json.dumps(wo.related_files) if wo.related_files else None,
wo.created_at, wo.updated_at,
),
)
conn.commit()
conn.close()
return wo
def get_work_order(wo_id: str) -> Optional[WorkOrder]:
"""Get a work order by ID."""
conn = _get_conn()
row = conn.execute(
"SELECT * FROM work_orders WHERE id = ?", (wo_id,)
).fetchone()
conn.close()
if not row:
return None
return _row_to_work_order(row)
def list_work_orders(
status: Optional[WorkOrderStatus] = None,
priority: Optional[WorkOrderPriority] = None,
category: Optional[WorkOrderCategory] = None,
submitter: Optional[str] = None,
limit: int = 100,
) -> list[WorkOrder]:
"""List work orders with optional filters."""
conn = _get_conn()
conditions = []
params: list = []
if status:
conditions.append("status = ?")
params.append(status.value)
if priority:
conditions.append("priority = ?")
params.append(priority.value)
if category:
conditions.append("category = ?")
params.append(category.value)
if submitter:
conditions.append("submitter = ?")
params.append(submitter)
where = "WHERE " + " AND ".join(conditions) if conditions else ""
rows = conn.execute(
f"SELECT * FROM work_orders {where} ORDER BY created_at DESC LIMIT ?",
params + [limit],
).fetchall()
conn.close()
return [_row_to_work_order(r) for r in rows]
def update_work_order_status(
wo_id: str,
new_status: WorkOrderStatus,
**kwargs,
) -> Optional[WorkOrder]:
"""Update a work order's status and optional fields."""
now = datetime.now(timezone.utc).isoformat()
sets = ["status = ?", "updated_at = ?"]
params: list = [new_status.value, now]
# Auto-set timestamp fields based on status transition
timestamp_map = {
WorkOrderStatus.TRIAGED: "triaged_at",
WorkOrderStatus.APPROVED: "approved_at",
WorkOrderStatus.IN_PROGRESS: "started_at",
WorkOrderStatus.COMPLETED: "completed_at",
WorkOrderStatus.REJECTED: "completed_at",
}
ts_field = timestamp_map.get(new_status)
if ts_field:
sets.append(f"{ts_field} = ?")
params.append(now)
# Apply additional keyword fields
allowed_fields = {
"execution_mode", "swarm_task_id", "result",
"rejection_reason", "estimated_effort",
}
for key, val in kwargs.items():
if key in allowed_fields:
sets.append(f"{key} = ?")
params.append(val)
params.append(wo_id)
conn = _get_conn()
cursor = conn.execute(
f"UPDATE work_orders SET {', '.join(sets)} WHERE id = ?",
params,
)
conn.commit()
updated = cursor.rowcount > 0
conn.close()
if not updated:
return None
return get_work_order(wo_id)
def get_pending_count() -> int:
"""Get count of submitted/triaged work orders awaiting review."""
conn = _get_conn()
row = conn.execute(
"SELECT COUNT(*) as count FROM work_orders WHERE status IN (?, ?)",
(WorkOrderStatus.SUBMITTED.value, WorkOrderStatus.TRIAGED.value),
).fetchone()
conn.close()
return row["count"]
def get_counts_by_status() -> dict[str, int]:
"""Get work order counts grouped by status."""
conn = _get_conn()
rows = conn.execute(
"SELECT status, COUNT(*) as count FROM work_orders GROUP BY status"
).fetchall()
conn.close()
return {r["status"]: r["count"] for r in rows}

View File

@@ -1,74 +0,0 @@
"""Risk scoring and auto-execution threshold logic for work orders."""
from swarm.work_orders.models import WorkOrder, WorkOrderCategory, WorkOrderPriority
PRIORITY_WEIGHTS = {
WorkOrderPriority.CRITICAL: 4,
WorkOrderPriority.HIGH: 3,
WorkOrderPriority.MEDIUM: 2,
WorkOrderPriority.LOW: 1,
}
CATEGORY_WEIGHTS = {
WorkOrderCategory.BUG: 3,
WorkOrderCategory.FEATURE: 3,
WorkOrderCategory.IMPROVEMENT: 2,
WorkOrderCategory.OPTIMIZATION: 2,
WorkOrderCategory.SUGGESTION: 1,
}
SENSITIVE_PATHS = [
"swarm/coordinator",
"l402",
"lightning/",
"config.py",
"security",
"auth",
]
def compute_risk_score(wo: WorkOrder) -> int:
"""Compute a risk score for a work order. Higher = riskier.
Score components:
- Priority weight: critical=4, high=3, medium=2, low=1
- Category weight: bug/feature=3, improvement/optimization=2, suggestion=1
- File sensitivity: +2 per related file in security-sensitive areas
"""
score = PRIORITY_WEIGHTS.get(wo.priority, 2)
score += CATEGORY_WEIGHTS.get(wo.category, 1)
for f in wo.related_files:
if any(s in f for s in SENSITIVE_PATHS):
score += 2
return score
def should_auto_execute(wo: WorkOrder) -> bool:
"""Determine if a work order can auto-execute without human approval.
Checks:
1. Global auto-execute must be enabled
2. Work order priority must be at or below the configured threshold
3. Total risk score must be <= 3
"""
from config import settings
if not settings.work_orders_auto_execute:
return False
threshold_map = {"none": 0, "low": 1, "medium": 2, "high": 3}
max_auto = threshold_map.get(settings.work_orders_auto_threshold, 1)
priority_values = {
WorkOrderPriority.LOW: 1,
WorkOrderPriority.MEDIUM: 2,
WorkOrderPriority.HIGH: 3,
WorkOrderPriority.CRITICAL: 4,
}
if priority_values.get(wo.priority, 2) > max_auto:
return False
return compute_risk_score(wo) <= 3

View File

@@ -231,22 +231,12 @@ Respond naturally and helpfully."""
return [m for _, m in scored[:limit]]
def communicate(self, message: Communication) -> bool:
"""Send message to another agent via swarm comms."""
try:
from swarm.comms import SwarmComms
comms = SwarmComms()
comms.publish(
"agent:messages",
"agent_message",
{
"from": self._identity.name,
"to": message.recipient,
"content": message.content,
},
)
return True
except Exception:
return False
"""Send message to another agent.
Swarm comms removed — inter-agent communication will be handled
by the unified brain memory layer.
"""
return False
def _extract_tags(self, perception: Perception) -> list[str]:
"""Extract searchable tags from perception."""

View File

@@ -29,18 +29,12 @@ _timmy_context: dict[str, Any] = {
async def _load_hands_async() -> list[dict]:
"""Async helper to load hands."""
try:
from hands.registry import HandRegistry
reg = HandRegistry()
hands_dict = await reg.load_all()
return [
{"name": h.name, "schedule": h.schedule.cron if h.schedule else "manual", "enabled": h.enabled}
for h in hands_dict.values()
]
except Exception as exc:
logger.warning("Could not load hands for context: %s", exc)
return []
"""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]:

View File

@@ -1,437 +0,0 @@
"""Multi-layer memory system for Timmy.
.. deprecated::
This module is deprecated and unused. The active memory system lives in
``timmy.memory_system`` (three-tier: Hot/Vault/Handoff) and
``timmy.conversation`` (working conversation context).
This file is retained for reference only. Do not import from it.
Implements four distinct memory layers:
1. WORKING MEMORY (Context Window)
- Last 20 messages in current conversation
- Fast access, ephemeral
- Used for: Immediate context, pronoun resolution, topic tracking
2. SHORT-TERM MEMORY (Recent History)
- SQLite storage via Agno (last 100 conversations)
- Persists across restarts
- Used for: Recent context, conversation continuity
3. LONG-TERM MEMORY (Facts & Preferences)
- Key facts about user, preferences, important events
- Explicitly extracted and stored
- Used for: Personalization, user model
4. SEMANTIC MEMORY (Vector Search)
- Embeddings of past conversations
- Similarity-based retrieval
- Used for: "Have we talked about this before?"
All layers work together to provide contextual, personalized responses.
"""
import warnings as _warnings
_warnings.warn(
"timmy.memory_layers is deprecated. Use timmy.memory_system and "
"timmy.conversation instead.",
DeprecationWarning,
stacklevel=2,
)
import json
import logging
import sqlite3
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# Paths for memory storage
MEMORY_DIR = Path("data/memory")
LTM_PATH = MEMORY_DIR / "long_term_memory.db"
SEMANTIC_PATH = MEMORY_DIR / "semantic_memory.db"
# =============================================================================
# LAYER 1: WORKING MEMORY (Active Conversation Context)
# =============================================================================
@dataclass
class WorkingMemoryEntry:
"""A single entry in working memory."""
role: str # "user" | "assistant" | "system"
content: str
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
metadata: dict = field(default_factory=dict)
class WorkingMemory:
"""Fast, ephemeral context window (last N messages).
Used for:
- Immediate conversational context
- Pronoun resolution ("Tell me more about it")
- Topic continuity
- Tool call tracking
"""
def __init__(self, max_entries: int = 20) -> None:
self.max_entries = max_entries
self.entries: list[WorkingMemoryEntry] = []
self.current_topic: Optional[str] = None
self.pending_tool_calls: list[dict] = []
def add(self, role: str, content: str, metadata: Optional[dict] = None) -> None:
"""Add an entry to working memory."""
entry = WorkingMemoryEntry(
role=role,
content=content,
metadata=metadata or {}
)
self.entries.append(entry)
# Trim to max size
if len(self.entries) > self.max_entries:
self.entries = self.entries[-self.max_entries:]
logger.debug("WorkingMemory: Added %s entry (total: %d)", role, len(self.entries))
def get_context(self, n: Optional[int] = None) -> list[WorkingMemoryEntry]:
"""Get last n entries (or all if n not specified)."""
if n is None:
return self.entries.copy()
return self.entries[-n:]
def get_formatted_context(self, n: int = 10) -> str:
"""Get formatted context for prompt injection."""
entries = self.get_context(n)
lines = []
for entry in entries:
role_label = "User" if entry.role == "user" else "Timmy" if entry.role == "assistant" else "System"
lines.append(f"{role_label}: {entry.content}")
return "\n".join(lines)
def set_topic(self, topic: str) -> None:
"""Set the current conversation topic."""
self.current_topic = topic
logger.debug("WorkingMemory: Topic set to '%s'", topic)
def clear(self) -> None:
"""Clear working memory (new conversation)."""
self.entries.clear()
self.current_topic = None
self.pending_tool_calls.clear()
logger.debug("WorkingMemory: Cleared")
def track_tool_call(self, tool_name: str, parameters: dict) -> None:
"""Track a pending tool call."""
self.pending_tool_calls.append({
"tool": tool_name,
"params": parameters,
"timestamp": datetime.now(timezone.utc).isoformat()
})
@property
def turn_count(self) -> int:
"""Count user-assistant exchanges."""
return sum(1 for e in self.entries if e.role in ("user", "assistant"))
# =============================================================================
# LAYER 3: LONG-TERM MEMORY (Facts & Preferences)
# =============================================================================
@dataclass
class LongTermMemoryFact:
"""A single fact in long-term memory."""
id: str
category: str # "user_preference", "user_fact", "important_event", "learned_pattern"
content: str
confidence: float # 0.0 - 1.0
source: str # conversation_id or "extracted"
created_at: str
last_accessed: str
access_count: int = 0
class LongTermMemory:
"""Persistent storage for important facts and preferences.
Used for:
- User's name, preferences, interests
- Important facts learned about the user
- Successful patterns and strategies
"""
def __init__(self) -> None:
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
self._init_db()
def _init_db(self) -> None:
"""Initialize SQLite database."""
conn = sqlite3.connect(str(LTM_PATH))
conn.execute("""
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,
created_at TEXT NOT NULL,
last_accessed TEXT NOT NULL,
access_count INTEGER DEFAULT 0
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_category ON facts(category)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_content ON facts(content)")
conn.commit()
conn.close()
def store(
self,
category: str,
content: str,
confidence: float = 0.8,
source: str = "extracted"
) -> str:
"""Store a fact in long-term memory."""
fact_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
conn = sqlite3.connect(str(LTM_PATH))
try:
conn.execute(
"""INSERT INTO facts (id, category, content, confidence, source, created_at, last_accessed)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(fact_id, category, content, confidence, source, now, now)
)
conn.commit()
logger.info("LTM: Stored %s fact: %s", category, content[:50])
return fact_id
finally:
conn.close()
def retrieve(
self,
category: Optional[str] = None,
query: Optional[str] = None,
limit: int = 10
) -> list[LongTermMemoryFact]:
"""Retrieve facts from long-term memory."""
conn = sqlite3.connect(str(LTM_PATH))
conn.row_factory = sqlite3.Row
try:
if category and query:
rows = conn.execute(
"""SELECT * FROM facts
WHERE category = ? AND content LIKE ?
ORDER BY confidence DESC, access_count DESC
LIMIT ?""",
(category, f"%{query}%", limit)
).fetchall()
elif category:
rows = conn.execute(
"""SELECT * FROM facts
WHERE category = ?
ORDER BY confidence DESC, last_accessed DESC
LIMIT ?""",
(category, limit)
).fetchall()
elif query:
rows = conn.execute(
"""SELECT * FROM facts
WHERE content LIKE ?
ORDER BY confidence DESC, access_count DESC
LIMIT ?""",
(f"%{query}%", limit)
).fetchall()
else:
rows = conn.execute(
"""SELECT * FROM facts
ORDER BY last_accessed DESC
LIMIT ?""",
(limit,)
).fetchall()
# Update access count
fact_ids = [row["id"] for row in rows]
for fid in fact_ids:
conn.execute(
"UPDATE facts SET access_count = access_count + 1, last_accessed = ? WHERE id = ?",
(datetime.now(timezone.utc).isoformat(), fid)
)
conn.commit()
return [
LongTermMemoryFact(
id=row["id"],
category=row["category"],
content=row["content"],
confidence=row["confidence"],
source=row["source"],
created_at=row["created_at"],
last_accessed=row["last_accessed"],
access_count=row["access_count"]
)
for row in rows
]
finally:
conn.close()
def get_user_profile(self) -> dict:
"""Get consolidated user profile from stored facts."""
preferences = self.retrieve(category="user_preference")
facts = self.retrieve(category="user_fact")
profile = {
"name": None,
"preferences": {},
"interests": [],
"facts": []
}
for pref in preferences:
if "name is" in pref.content.lower():
profile["name"] = pref.content.split("is")[-1].strip().rstrip(".")
else:
profile["preferences"][pref.id] = pref.content
for fact in facts:
profile["facts"].append(fact.content)
return profile
def extract_and_store(self, user_message: str, assistant_response: str) -> list[str]:
"""Extract potential facts from conversation and store them.
This is a simple rule-based extractor. In production, this could
use an LLM to extract facts.
"""
stored_ids = []
message_lower = user_message.lower()
# Extract name
name_patterns = ["my name is", "i'm ", "i am ", "call me " ]
for pattern in name_patterns:
if pattern in message_lower:
idx = message_lower.find(pattern) + len(pattern)
name = user_message[idx:].strip().split()[0].strip(".,!?;:").capitalize()
if name and len(name) > 1:
sid = self.store(
category="user_fact",
content=f"User's name is {name}",
confidence=0.9,
source="extracted_from_conversation"
)
stored_ids.append(sid)
break
# Extract preferences ("I like", "I prefer", "I don't like")
preference_patterns = [
("i like", "user_preference", "User likes"),
("i love", "user_preference", "User loves"),
("i prefer", "user_preference", "User prefers"),
("i don't like", "user_preference", "User dislikes"),
("i hate", "user_preference", "User dislikes"),
]
for pattern, category, prefix in preference_patterns:
if pattern in message_lower:
idx = message_lower.find(pattern) + len(pattern)
preference = user_message[idx:].strip().split(".")[0].strip()
if preference and len(preference) > 3:
sid = self.store(
category=category,
content=f"{prefix} {preference}",
confidence=0.7,
source="extracted_from_conversation"
)
stored_ids.append(sid)
return stored_ids
# =============================================================================
# MEMORY MANAGER (Integrates all layers)
# =============================================================================
class MemoryManager:
"""Central manager for all memory layers.
Coordinates between:
- Working Memory (immediate context)
- Short-term Memory (Agno SQLite)
- Long-term Memory (facts/preferences)
- (Future: Semantic Memory with embeddings)
"""
def __init__(self) -> None:
self.working = WorkingMemory(max_entries=20)
self.long_term = LongTermMemory()
self._session_id: Optional[str] = None
def start_session(self, session_id: Optional[str] = None) -> str:
"""Start a new conversation session."""
self._session_id = session_id or str(uuid.uuid4())
self.working.clear()
# Load relevant LTM into context
profile = self.long_term.get_user_profile()
if profile["name"]:
logger.info("MemoryManager: Recognizing user '%s'", profile["name"])
return self._session_id
def add_exchange(
self,
user_message: str,
assistant_response: str,
tool_calls: Optional[list] = None
) -> None:
"""Record a complete exchange across all memory layers."""
# Working memory
self.working.add("user", user_message)
self.working.add("assistant", assistant_response, metadata={"tools": tool_calls})
# Extract and store facts to LTM
try:
self.long_term.extract_and_store(user_message, assistant_response)
except Exception as exc:
logger.warning("Failed to extract facts: %s", exc)
def get_context_for_prompt(self) -> str:
"""Generate context string for injection into prompts."""
parts = []
# User profile from LTM
profile = self.long_term.get_user_profile()
if profile["name"]:
parts.append(f"User's name: {profile['name']}")
if profile["preferences"]:
prefs = list(profile["preferences"].values())[:3] # Top 3 preferences
parts.append("User preferences: " + "; ".join(prefs))
# Recent working memory
working_context = self.working.get_formatted_context(n=6)
if working_context:
parts.append("Recent conversation:\n" + working_context)
return "\n\n".join(parts) if parts else ""
def get_relevant_memories(self, query: str) -> list[str]:
"""Get memories relevant to current query."""
# Get from LTM
facts = self.long_term.retrieve(query=query, limit=5)
return [f.content for f in facts]
# Singleton removed — this module is deprecated.
# Use timmy.memory_system.memory_system or timmy.conversation.conversation_manager.

View File

@@ -1,6 +1,7 @@
"""Inter-agent delegation tools for Timmy.
"""Timmy's delegation tools — submit tasks and list agents.
Allows Timmy to dispatch tasks to other swarm agents (Seer, Forge, Echo, etc.)
Coordinator removed. Tasks go through the task_queue, agents are
looked up in the registry.
"""
import logging
@@ -9,22 +10,17 @@ from typing import Any
logger = logging.getLogger(__name__)
def delegate_task(
agent_name: str, task_description: str, priority: str = "normal"
) -> dict[str, Any]:
"""Dispatch a task to another swarm agent.
def delegate_task(agent_name: str, task_description: str, priority: str = "normal") -> dict[str, Any]:
"""Delegate a task to another agent via the task queue.
Args:
agent_name: Name of the agent to delegate to (seer, forge, echo, helm, quill)
agent_name: Name of the agent to delegate to
task_description: What you want the agent to do
priority: Task priority - "low", "normal", "high"
Returns:
Dict with task_id, status, and message
"""
from swarm.coordinator import coordinator
# Validate agent name
valid_agents = ["seer", "forge", "echo", "helm", "quill", "mace"]
agent_name = agent_name.lower().strip()
@@ -35,22 +31,27 @@ def delegate_task(
"task_id": None,
}
# Validate priority
valid_priorities = ["low", "normal", "high"]
if priority not in valid_priorities:
priority = "normal"
try:
# Submit task to coordinator
task = coordinator.post_task(
from swarm.task_queue.models import create_task
task = create_task(
title=f"[Delegated to {agent_name}] {task_description[:80]}",
description=task_description,
assigned_agent=agent_name,
assigned_to=agent_name,
created_by="timmy",
priority=priority,
task_type="task_request",
requires_approval=False,
auto_approve=True,
)
return {
"success": True,
"task_id": task.task_id,
"task_id": task.id,
"agent": agent_name,
"status": "submitted",
"message": f"Task submitted to {agent_name}: {task_description[:100]}...",
@@ -71,10 +72,10 @@ def list_swarm_agents() -> dict[str, Any]:
Returns:
Dict with agent list and status
"""
from swarm.coordinator import coordinator
try:
agents = coordinator.list_swarm_agents()
from swarm import registry
agents = registry.list_agents()
return {
"success": True,