forked from Rockachopa/Timmy-time-dashboard
Implement MCP system, Event Bus, and Sub-Agents
## 1. MCP (Model Context Protocol) Implementation ### Registry (src/mcp/registry.py) - Tool registration with JSON schemas - Dynamic tool discovery - Health tracking per tool - Metrics collection (latency, error rates) - @register_tool decorator for easy registration ### Server (src/mcp/server.py) - MCPServer class implementing MCP protocol - MCPHTTPServer for FastAPI integration - Standard endpoints: list_tools, call_tool, get_schema ### Schemas (src/mcp/schemas/base.py) - create_tool_schema() helper - Common parameter types - Standard return types ### Bootstrap (src/mcp/bootstrap.py) - Automatic tool module loading - Status reporting ## 2. MCP-Compliant Tools (src/tools/) | Tool | Purpose | Category | |------|---------|----------| | web_search | DuckDuckGo search | research | | read_file | File reading | files | | write_file | File writing (confirmation) | files | | list_directory | Directory listing | files | | python | Python code execution | code | | memory_search | Vector memory search | memory | All tools have proper schemas, error handling, and MCP registration. ## 3. Event Bus (src/events/bus.py) - Async publish/subscribe pattern - Pattern matching with wildcards (agent.task.*) - Event history tracking - Concurrent handler execution - Module-level singleton for system-wide use ## 4. Sub-Agents (src/agents/) All agents inherit from BaseAgent with: - Agno Agent integration - MCP tool registry access - Event bus connectivity - Structured logging ### Agent Roster | Agent | Role | Tools | Purpose | |-------|------|-------|---------| | Seer | Research | web_search, read_file, memory_search | Information gathering | | Forge | Code | python, write_file, read_file | Code generation | | Quill | Writing | write_file, read_file, memory_search | Content creation | | Echo | Memory | memory_search, read_file, write_file | Context retrieval | | Helm | Routing | memory_search | Task routing decisions | | Timmy | Orchestrator | All tools | Coordination & user interface | ### Timmy Orchestrator - Analyzes user requests - Routes to appropriate sub-agent - Handles direct queries - Manages swarm coordination - create_timmy_swarm() factory function ## 5. Integration All components wired together: - Tools auto-register on import - Agents connect to event bus - MCP server provides HTTP API - Ready for dashboard integration ## Tests - All 973 existing tests pass - New components tested manually - Import verification successful Next steps: Cascade Router, Self-Upgrade Loop, Dashboard integration
This commit is contained in:
124
src/tools/code_exec.py
Normal file
124
src/tools/code_exec.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Code execution tool.
|
||||
|
||||
MCP-compliant tool for executing Python code.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
from mcp.registry import register_tool
|
||||
from mcp.schemas.base import create_tool_schema, PARAM_STRING, PARAM_BOOLEAN, RETURN_STRING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PYTHON_SCHEMA = create_tool_schema(
|
||||
name="python",
|
||||
description="Execute Python code. Use for calculations, data processing, or when precise computation is needed. Code runs in a restricted environment.",
|
||||
parameters={
|
||||
"code": {
|
||||
**PARAM_STRING,
|
||||
"description": "Python code to execute",
|
||||
},
|
||||
"return_output": {
|
||||
**PARAM_BOOLEAN,
|
||||
"description": "Return the value of the last expression",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
required=["code"],
|
||||
returns=RETURN_STRING,
|
||||
)
|
||||
|
||||
|
||||
def python(code: str, return_output: bool = True) -> str:
|
||||
"""Execute Python code in restricted environment.
|
||||
|
||||
Args:
|
||||
code: Python code to execute
|
||||
return_output: Whether to return last expression value
|
||||
|
||||
Returns:
|
||||
Execution result or error message
|
||||
"""
|
||||
# Safe globals for code execution
|
||||
safe_globals = {
|
||||
"__builtins__": {
|
||||
"abs": abs,
|
||||
"all": all,
|
||||
"any": any,
|
||||
"bin": bin,
|
||||
"bool": bool,
|
||||
"dict": dict,
|
||||
"enumerate": enumerate,
|
||||
"filter": filter,
|
||||
"float": float,
|
||||
"format": format,
|
||||
"hex": hex,
|
||||
"int": int,
|
||||
"isinstance": isinstance,
|
||||
"issubclass": issubclass,
|
||||
"len": len,
|
||||
"list": list,
|
||||
"map": map,
|
||||
"max": max,
|
||||
"min": min,
|
||||
"next": next,
|
||||
"oct": oct,
|
||||
"ord": ord,
|
||||
"pow": pow,
|
||||
"print": lambda *args, **kwargs: None, # Disabled
|
||||
"range": range,
|
||||
"repr": repr,
|
||||
"reversed": reversed,
|
||||
"round": round,
|
||||
"set": set,
|
||||
"slice": slice,
|
||||
"sorted": sorted,
|
||||
"str": str,
|
||||
"sum": sum,
|
||||
"tuple": tuple,
|
||||
"type": type,
|
||||
"zip": zip,
|
||||
}
|
||||
}
|
||||
|
||||
# Allowed modules
|
||||
allowed_modules = ["math", "random", "statistics", "datetime", "json"]
|
||||
|
||||
for mod_name in allowed_modules:
|
||||
try:
|
||||
safe_globals[mod_name] = __import__(mod_name)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Compile and execute
|
||||
compiled = compile(code, "<string>", "eval" if return_output else "exec")
|
||||
|
||||
if return_output:
|
||||
result = eval(compiled, safe_globals, {})
|
||||
return f"Result: {result}"
|
||||
else:
|
||||
exec(compiled, safe_globals, {})
|
||||
return "Code executed successfully."
|
||||
|
||||
except SyntaxError:
|
||||
# Try as exec if eval fails
|
||||
try:
|
||||
compiled = compile(code, "<string>", "exec")
|
||||
exec(compiled, safe_globals, {})
|
||||
return "Code executed successfully."
|
||||
except Exception as exc:
|
||||
error_msg = traceback.format_exc()
|
||||
logger.error("Python execution failed: %s", exc)
|
||||
return f"Error: {exc}\n\n{error_msg}"
|
||||
except Exception as exc:
|
||||
error_msg = traceback.format_exc()
|
||||
logger.error("Python execution failed: %s", exc)
|
||||
return f"Error: {exc}\n\n{error_msg}"
|
||||
|
||||
|
||||
# Register with MCP
|
||||
register_tool(name="python", schema=PYTHON_SCHEMA, category="code")(python)
|
||||
179
src/tools/file_ops.py
Normal file
179
src/tools/file_ops.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""File operations tool.
|
||||
|
||||
MCP-compliant tool for reading, writing, and listing files.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mcp.registry import register_tool
|
||||
from mcp.schemas.base import create_tool_schema, PARAM_STRING, PARAM_BOOLEAN, RETURN_STRING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Read File Schema
|
||||
READ_FILE_SCHEMA = create_tool_schema(
|
||||
name="read_file",
|
||||
description="Read contents of a file. Use when user explicitly asks to read a file.",
|
||||
parameters={
|
||||
"path": {
|
||||
**PARAM_STRING,
|
||||
"description": "Path to file (relative to project root or absolute)",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum lines to read (0 = all)",
|
||||
"default": 0,
|
||||
},
|
||||
},
|
||||
required=["path"],
|
||||
returns=RETURN_STRING,
|
||||
)
|
||||
|
||||
# Write File Schema
|
||||
WRITE_FILE_SCHEMA = create_tool_schema(
|
||||
name="write_file",
|
||||
description="Write content to a file. Use when user explicitly asks to save content.",
|
||||
parameters={
|
||||
"path": {
|
||||
**PARAM_STRING,
|
||||
"description": "Path to file",
|
||||
},
|
||||
"content": {
|
||||
**PARAM_STRING,
|
||||
"description": "Content to write",
|
||||
},
|
||||
"append": {
|
||||
**PARAM_BOOLEAN,
|
||||
"description": "Append to file instead of overwrite",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
required=["path", "content"],
|
||||
returns=RETURN_STRING,
|
||||
)
|
||||
|
||||
# List Directory Schema
|
||||
LIST_DIR_SCHEMA = create_tool_schema(
|
||||
name="list_directory",
|
||||
description="List files in a directory.",
|
||||
parameters={
|
||||
"path": {
|
||||
**PARAM_STRING,
|
||||
"description": "Directory path (default: current)",
|
||||
"default": ".",
|
||||
},
|
||||
"pattern": {
|
||||
**PARAM_STRING,
|
||||
"description": "File pattern filter (e.g., '*.py')",
|
||||
"default": "*",
|
||||
},
|
||||
},
|
||||
returns=RETURN_STRING,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_path(path: str) -> Path:
|
||||
"""Resolve path relative to project root."""
|
||||
from config import settings
|
||||
|
||||
p = Path(path)
|
||||
if p.is_absolute():
|
||||
return p
|
||||
|
||||
# Try relative to project root
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / p
|
||||
|
||||
|
||||
def read_file(path: str, limit: int = 0) -> str:
|
||||
"""Read file contents."""
|
||||
try:
|
||||
filepath = _resolve_path(path)
|
||||
|
||||
if not filepath.exists():
|
||||
return f"Error: File not found: {path}"
|
||||
|
||||
if not filepath.is_file():
|
||||
return f"Error: Path is not a file: {path}"
|
||||
|
||||
content = filepath.read_text()
|
||||
|
||||
if limit > 0:
|
||||
lines = content.split('\n')[:limit]
|
||||
content = '\n'.join(lines)
|
||||
if len(content.split('\n')) == limit:
|
||||
content += f"\n\n... [{limit} lines shown]"
|
||||
|
||||
return content
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Read file failed: %s", exc)
|
||||
return f"Error reading file: {exc}"
|
||||
|
||||
|
||||
def write_file(path: str, content: str, append: bool = False) -> str:
|
||||
"""Write content to file."""
|
||||
try:
|
||||
filepath = _resolve_path(path)
|
||||
|
||||
# Ensure directory exists
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mode = "a" if append else "w"
|
||||
filepath.write_text(content)
|
||||
|
||||
action = "appended to" if append else "wrote"
|
||||
return f"Successfully {action} {filepath}"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Write file failed: %s", exc)
|
||||
return f"Error writing file: {exc}"
|
||||
|
||||
|
||||
def list_directory(path: str = ".", pattern: str = "*") -> str:
|
||||
"""List directory contents."""
|
||||
try:
|
||||
dirpath = _resolve_path(path)
|
||||
|
||||
if not dirpath.exists():
|
||||
return f"Error: Directory not found: {path}"
|
||||
|
||||
if not dirpath.is_dir():
|
||||
return f"Error: Path is not a directory: {path}"
|
||||
|
||||
items = list(dirpath.glob(pattern))
|
||||
|
||||
files = []
|
||||
dirs = []
|
||||
|
||||
for item in items:
|
||||
if item.is_dir():
|
||||
dirs.append(f"📁 {item.name}/")
|
||||
else:
|
||||
size = item.stat().st_size
|
||||
size_str = f"{size}B" if size < 1024 else f"{size//1024}KB"
|
||||
files.append(f"📄 {item.name} ({size_str})")
|
||||
|
||||
result = [f"Contents of {dirpath}:", ""]
|
||||
result.extend(sorted(dirs))
|
||||
result.extend(sorted(files))
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("List directory failed: %s", exc)
|
||||
return f"Error listing directory: {exc}"
|
||||
|
||||
|
||||
# Register with MCP
|
||||
register_tool(name="read_file", schema=READ_FILE_SCHEMA, category="files")(read_file)
|
||||
register_tool(
|
||||
name="write_file",
|
||||
schema=WRITE_FILE_SCHEMA,
|
||||
category="files",
|
||||
requires_confirmation=True,
|
||||
)(write_file)
|
||||
register_tool(name="list_directory", schema=LIST_DIR_SCHEMA, category="files")(list_directory)
|
||||
70
src/tools/memory_tool.py
Normal file
70
src/tools/memory_tool.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Memory search tool.
|
||||
|
||||
MCP-compliant tool for searching Timmy's memory.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp.registry import register_tool
|
||||
from mcp.schemas.base import create_tool_schema, PARAM_STRING, PARAM_INTEGER, RETURN_STRING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MEMORY_SEARCH_SCHEMA = create_tool_schema(
|
||||
name="memory_search",
|
||||
description="Search Timmy's memory for past conversations, facts, and context. Use when user asks about previous discussions or when you need to recall something from memory.",
|
||||
parameters={
|
||||
"query": {
|
||||
**PARAM_STRING,
|
||||
"description": "What to search for in memory",
|
||||
},
|
||||
"top_k": {
|
||||
**PARAM_INTEGER,
|
||||
"description": "Number of results to return (1-10)",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
},
|
||||
},
|
||||
required=["query"],
|
||||
returns=RETURN_STRING,
|
||||
)
|
||||
|
||||
|
||||
def memory_search(query: str, top_k: int = 5) -> str:
|
||||
"""Search Timmy's memory.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
top_k: Number of results
|
||||
|
||||
Returns:
|
||||
Relevant memories from past conversations
|
||||
"""
|
||||
try:
|
||||
from timmy.semantic_memory import memory_search as semantic_search
|
||||
|
||||
results = semantic_search(query, top_k=top_k)
|
||||
|
||||
if not results:
|
||||
return "No relevant memories found."
|
||||
|
||||
formatted = ["Relevant memories from past conversations:", ""]
|
||||
|
||||
for i, (content, score) in enumerate(results, 1):
|
||||
relevance = "🔥" if score > 0.8 else "⭐" if score > 0.5 else "📄"
|
||||
formatted.append(f"{relevance} [{i}] (score: {score:.2f})")
|
||||
formatted.append(f" {content[:300]}...")
|
||||
formatted.append("")
|
||||
|
||||
return "\n".join(formatted)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Memory search failed: %s", exc)
|
||||
return f"Memory search error: {exc}"
|
||||
|
||||
|
||||
# Register with MCP
|
||||
register_tool(name="memory_search", schema=MEMORY_SEARCH_SCHEMA, category="memory")(memory_search)
|
||||
74
src/tools/web_search.py
Normal file
74
src/tools/web_search.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Web search tool using DuckDuckGo.
|
||||
|
||||
MCP-compliant tool for searching the web.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp.registry import register_tool
|
||||
from mcp.schemas.base import create_tool_schema, PARAM_STRING, PARAM_INTEGER, RETURN_STRING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
WEB_SEARCH_SCHEMA = create_tool_schema(
|
||||
name="web_search",
|
||||
description="Search the web using DuckDuckGo. Use for current events, news, real-time data, and information not in your training data.",
|
||||
parameters={
|
||||
"query": {
|
||||
**PARAM_STRING,
|
||||
"description": "Search query string",
|
||||
},
|
||||
"max_results": {
|
||||
**PARAM_INTEGER,
|
||||
"description": "Maximum number of results (1-10)",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
},
|
||||
},
|
||||
required=["query"],
|
||||
returns=RETURN_STRING,
|
||||
)
|
||||
|
||||
|
||||
def web_search(query: str, max_results: int = 5) -> str:
|
||||
"""Search the web using DuckDuckGo.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
max_results: Maximum results to return
|
||||
|
||||
Returns:
|
||||
Formatted search results
|
||||
"""
|
||||
try:
|
||||
from duckduckgo_search import DDGS
|
||||
|
||||
with DDGS() as ddgs:
|
||||
results = list(ddgs.text(query, max_results=max_results))
|
||||
|
||||
if not results:
|
||||
return "No results found."
|
||||
|
||||
formatted = []
|
||||
for i, r in enumerate(results, 1):
|
||||
title = r.get("title", "No title")
|
||||
body = r.get("body", "No description")
|
||||
href = r.get("href", "")
|
||||
formatted.append(f"{i}. {title}\n {body[:150]}...\n {href}")
|
||||
|
||||
return "\n\n".join(formatted)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Web search failed: %s", exc)
|
||||
return f"Search error: {exc}"
|
||||
|
||||
|
||||
# Register with MCP
|
||||
register_tool(
|
||||
name="web_search",
|
||||
schema=WEB_SEARCH_SCHEMA,
|
||||
category="research",
|
||||
)(web_search)
|
||||
Reference in New Issue
Block a user