forked from Rockachopa/Timmy-time-dashboard
Memory Unification + Canonical Identity: -11,074 lines of homebrew (#119)
This commit is contained in:
committed by
GitHub
parent
785440ac31
commit
62ef1120a4
@@ -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
180
src/brain/identity.py
Normal 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
682
src/brain/memory.py
Normal 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'));
|
||||
"""
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -1 +0,0 @@
|
||||
"""Work Order system for external and internal task submission."""
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user