forked from Rockachopa/Timmy-time-dashboard
feat: dockerize OpenFang as vendored tool runtime sidecar (#96)
This commit is contained in:
committed by
GitHub
parent
d7d7a5a80a
commit
b7c89d1101
@@ -147,6 +147,16 @@ class Settings(BaseSettings):
|
||||
thinking_enabled: bool = True
|
||||
thinking_interval_seconds: int = 300 # 5 minutes between thoughts
|
||||
|
||||
# ── OpenFang — vendored agent runtime ─────────────────────────────
|
||||
# URL where the OpenFang sidecar listens. Set to the Docker service
|
||||
# name when running in compose, or localhost for bare-metal dev.
|
||||
openfang_url: str = "http://localhost:8080"
|
||||
# Enable/disable OpenFang integration. When disabled, the tool
|
||||
# executor falls back to Timmy's native (simulated) execution.
|
||||
openfang_enabled: bool = False
|
||||
# Timeout in seconds for OpenFang hand execution (some hands are slow).
|
||||
openfang_timeout: int = 120
|
||||
|
||||
# ── Error Logging ─────────────────────────────────────────────────
|
||||
error_log_enabled: bool = True
|
||||
error_log_dir: str = "logs"
|
||||
|
||||
@@ -424,6 +424,16 @@ async def lifespan(app: FastAPI):
|
||||
# Bootstrap MCP tools in background
|
||||
mcp_task = asyncio.create_task(_bootstrap_mcp_background())
|
||||
|
||||
# Register OpenFang vendor tools (if enabled)
|
||||
if settings.openfang_enabled:
|
||||
try:
|
||||
from infrastructure.openfang.tools import register_openfang_tools
|
||||
|
||||
count = register_openfang_tools()
|
||||
logger.info("OpenFang: registered %d vendor tools", count)
|
||||
except Exception as exc:
|
||||
logger.warning("OpenFang tool registration failed: %s", exc)
|
||||
|
||||
# Initialize Spark Intelligence engine
|
||||
from spark.engine import spark_engine
|
||||
if spark_engine.enabled:
|
||||
|
||||
18
src/infrastructure/openfang/__init__.py
Normal file
18
src/infrastructure/openfang/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""OpenFang — vendored binary sidecar for agent tool execution.
|
||||
|
||||
OpenFang is a Rust-compiled Agent OS that provides real tool execution
|
||||
(browser automation, OSINT, forecasting, social management) in a
|
||||
WASM-sandboxed runtime. Timmy's coordinator dispatches to it as a
|
||||
tool vendor rather than a co-orchestrator.
|
||||
|
||||
Usage:
|
||||
from infrastructure.openfang import openfang_client
|
||||
|
||||
# Check if OpenFang is available
|
||||
if openfang_client.healthy:
|
||||
result = await openfang_client.execute_hand("browser", params)
|
||||
"""
|
||||
|
||||
from infrastructure.openfang.client import OpenFangClient, openfang_client
|
||||
|
||||
__all__ = ["OpenFangClient", "openfang_client"]
|
||||
216
src/infrastructure/openfang/client.py
Normal file
216
src/infrastructure/openfang/client.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""OpenFang HTTP client — bridge between Timmy coordinator and OpenFang runtime.
|
||||
|
||||
Follows project conventions:
|
||||
- Graceful degradation (log error, return fallback, never crash)
|
||||
- Config via ``from config import settings``
|
||||
- Singleton pattern for module-level import
|
||||
|
||||
The client wraps OpenFang's REST API and exposes its Hands
|
||||
(Browser, Collector, Predictor, Lead, Twitter, Researcher, Clip)
|
||||
as callable tool endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hand names that OpenFang ships out of the box
|
||||
OPENFANG_HANDS = (
|
||||
"browser",
|
||||
"collector",
|
||||
"predictor",
|
||||
"lead",
|
||||
"twitter",
|
||||
"researcher",
|
||||
"clip",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandResult:
|
||||
"""Result from an OpenFang Hand execution."""
|
||||
|
||||
hand: str
|
||||
success: bool
|
||||
output: str = ""
|
||||
error: str = ""
|
||||
latency_ms: float = 0.0
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class OpenFangClient:
|
||||
"""HTTP client for the OpenFang sidecar.
|
||||
|
||||
All methods degrade gracefully — if OpenFang is down the client
|
||||
returns a ``HandResult(success=False)`` rather than raising.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: Optional[str] = None, timeout: int = 60) -> None:
|
||||
self._base_url = (base_url or settings.openfang_url).rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._healthy = False
|
||||
self._last_health_check: float = 0.0
|
||||
self._health_cache_ttl = 30.0 # seconds
|
||||
logger.info("OpenFangClient initialised → %s", self._base_url)
|
||||
|
||||
# ── Health ───────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def healthy(self) -> bool:
|
||||
"""Cached health check — hits /health at most once per TTL."""
|
||||
now = time.time()
|
||||
if now - self._last_health_check > self._health_cache_ttl:
|
||||
self._healthy = self._check_health()
|
||||
self._last_health_check = now
|
||||
return self._healthy
|
||||
|
||||
def _check_health(self) -> bool:
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{self._base_url}/health",
|
||||
method="GET",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return resp.status == 200
|
||||
except Exception as exc:
|
||||
logger.debug("OpenFang health check failed: %s", exc)
|
||||
return False
|
||||
|
||||
# ── Hand execution ───────────────────────────────────────────────────────
|
||||
|
||||
async def execute_hand(
|
||||
self,
|
||||
hand: str,
|
||||
params: dict[str, Any],
|
||||
timeout: Optional[int] = None,
|
||||
) -> HandResult:
|
||||
"""Execute an OpenFang Hand and return the result.
|
||||
|
||||
Args:
|
||||
hand: Hand name (browser, collector, predictor, etc.)
|
||||
params: Parameters for the hand (task-specific)
|
||||
timeout: Override default timeout for long-running hands
|
||||
|
||||
Returns:
|
||||
HandResult with output or error details.
|
||||
"""
|
||||
if hand not in OPENFANG_HANDS:
|
||||
return HandResult(
|
||||
hand=hand,
|
||||
success=False,
|
||||
error=f"Unknown hand: {hand}. Available: {', '.join(OPENFANG_HANDS)}",
|
||||
)
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
payload = json.dumps({"hand": hand, "params": params}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{self._base_url}/api/v1/hands/{hand}/execute",
|
||||
data=payload,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
effective_timeout = timeout or self._timeout
|
||||
with urllib.request.urlopen(req, timeout=effective_timeout) as resp:
|
||||
body = json.loads(resp.read().decode())
|
||||
latency = (time.time() - start) * 1000
|
||||
|
||||
return HandResult(
|
||||
hand=hand,
|
||||
success=body.get("success", True),
|
||||
output=body.get("output", body.get("result", "")),
|
||||
latency_ms=latency,
|
||||
metadata=body.get("metadata", {}),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
latency = (time.time() - start) * 1000
|
||||
logger.warning(
|
||||
"OpenFang hand '%s' failed (%.0fms): %s",
|
||||
hand,
|
||||
latency,
|
||||
exc,
|
||||
)
|
||||
return HandResult(
|
||||
hand=hand,
|
||||
success=False,
|
||||
error=str(exc),
|
||||
latency_ms=latency,
|
||||
)
|
||||
|
||||
# ── Convenience wrappers for common hands ────────────────────────────────
|
||||
|
||||
async def browse(self, url: str, instruction: str = "") -> HandResult:
|
||||
"""Web automation via OpenFang's Browser hand."""
|
||||
return await self.execute_hand(
|
||||
"browser", {"url": url, "instruction": instruction}
|
||||
)
|
||||
|
||||
async def collect(self, target: str, depth: str = "shallow") -> HandResult:
|
||||
"""OSINT collection via OpenFang's Collector hand."""
|
||||
return await self.execute_hand(
|
||||
"collector", {"target": target, "depth": depth}
|
||||
)
|
||||
|
||||
async def predict(self, question: str, horizon: str = "1w") -> HandResult:
|
||||
"""Superforecasting via OpenFang's Predictor hand."""
|
||||
return await self.execute_hand(
|
||||
"predictor", {"question": question, "horizon": horizon}
|
||||
)
|
||||
|
||||
async def find_leads(self, icp: str, max_results: int = 10) -> HandResult:
|
||||
"""Prospect discovery via OpenFang's Lead hand."""
|
||||
return await self.execute_hand(
|
||||
"lead", {"icp": icp, "max_results": max_results}
|
||||
)
|
||||
|
||||
async def research(self, topic: str, depth: str = "standard") -> HandResult:
|
||||
"""Deep research via OpenFang's Researcher hand."""
|
||||
return await self.execute_hand(
|
||||
"researcher", {"topic": topic, "depth": depth}
|
||||
)
|
||||
|
||||
# ── Inventory ────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_hands(self) -> list[dict]:
|
||||
"""Query OpenFang for its available hands and their status."""
|
||||
try:
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{self._base_url}/api/v1/hands",
|
||||
method="GET",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to list OpenFang hands: %s", exc)
|
||||
return []
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Return a status summary for the dashboard."""
|
||||
return {
|
||||
"url": self._base_url,
|
||||
"healthy": self.healthy,
|
||||
"available_hands": list(OPENFANG_HANDS),
|
||||
}
|
||||
|
||||
|
||||
# ── Module-level singleton ──────────────────────────────────────────────────
|
||||
openfang_client = OpenFangClient()
|
||||
223
src/infrastructure/openfang/tools.py
Normal file
223
src/infrastructure/openfang/tools.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Register OpenFang Hands as MCP tools in Timmy's tool registry.
|
||||
|
||||
Each OpenFang Hand becomes a callable MCP tool that personas can use
|
||||
during task execution. The mapping ensures the right personas get
|
||||
access to the right hands:
|
||||
|
||||
Mace (Security) → collector (OSINT), browser
|
||||
Seer (Analytics) → predictor, researcher
|
||||
Echo (Research) → researcher, browser, collector
|
||||
Helm (DevOps) → browser
|
||||
Lead hand → available to all personas via direct request
|
||||
|
||||
Call ``register_openfang_tools()`` during app startup (after config
|
||||
is loaded) to populate the tool registry.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from infrastructure.openfang.client import OPENFANG_HANDS, openfang_client
|
||||
from mcp.schemas.base import create_tool_schema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Tool schemas ─────────────────────────────────────────────────────────────
|
||||
|
||||
_HAND_SCHEMAS: dict[str, dict] = {
|
||||
"browser": create_tool_schema(
|
||||
name="openfang_browser",
|
||||
description=(
|
||||
"Web automation via OpenFang's Browser hand. "
|
||||
"Navigates URLs, extracts content, fills forms. "
|
||||
"Includes mandatory purchase confirmation gates."
|
||||
),
|
||||
parameters={
|
||||
"url": {"type": "string", "description": "URL to navigate to"},
|
||||
"instruction": {
|
||||
"type": "string",
|
||||
"description": "What to do on the page",
|
||||
},
|
||||
},
|
||||
required=["url"],
|
||||
),
|
||||
"collector": create_tool_schema(
|
||||
name="openfang_collector",
|
||||
description=(
|
||||
"OSINT intelligence and continuous monitoring via OpenFang's "
|
||||
"Collector hand. Gathers public information on targets."
|
||||
),
|
||||
parameters={
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Target to investigate (domain, org, person)",
|
||||
},
|
||||
"depth": {
|
||||
"type": "string",
|
||||
"description": "Collection depth: shallow | standard | deep",
|
||||
"default": "shallow",
|
||||
},
|
||||
},
|
||||
required=["target"],
|
||||
),
|
||||
"predictor": create_tool_schema(
|
||||
name="openfang_predictor",
|
||||
description=(
|
||||
"Superforecasting with calibrated reasoning via OpenFang's "
|
||||
"Predictor hand. Produces probability estimates with reasoning."
|
||||
),
|
||||
parameters={
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "Forecasting question to evaluate",
|
||||
},
|
||||
"horizon": {
|
||||
"type": "string",
|
||||
"description": "Time horizon: 1d | 1w | 1m | 3m | 1y",
|
||||
"default": "1w",
|
||||
},
|
||||
},
|
||||
required=["question"],
|
||||
),
|
||||
"lead": create_tool_schema(
|
||||
name="openfang_lead",
|
||||
description=(
|
||||
"Prospect discovery and ICP-based qualification via OpenFang's "
|
||||
"Lead hand. Finds and scores potential leads."
|
||||
),
|
||||
parameters={
|
||||
"icp": {
|
||||
"type": "string",
|
||||
"description": "Ideal Customer Profile description",
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum leads to return",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
required=["icp"],
|
||||
),
|
||||
"twitter": create_tool_schema(
|
||||
name="openfang_twitter",
|
||||
description=(
|
||||
"Social account management via OpenFang's Twitter hand. "
|
||||
"Includes approval gates for sensitive actions."
|
||||
),
|
||||
parameters={
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Action: post | reply | search | analyze",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Content for the action",
|
||||
},
|
||||
},
|
||||
required=["action", "content"],
|
||||
),
|
||||
"researcher": create_tool_schema(
|
||||
name="openfang_researcher",
|
||||
description=(
|
||||
"Deep autonomous research with source verification via "
|
||||
"OpenFang's Researcher hand. Produces cited reports."
|
||||
),
|
||||
parameters={
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"description": "Research topic or question",
|
||||
},
|
||||
"depth": {
|
||||
"type": "string",
|
||||
"description": "Research depth: quick | standard | deep",
|
||||
"default": "standard",
|
||||
},
|
||||
},
|
||||
required=["topic"],
|
||||
),
|
||||
"clip": create_tool_schema(
|
||||
name="openfang_clip",
|
||||
description=(
|
||||
"Video processing and social media publishing via OpenFang's "
|
||||
"Clip hand. Edits, captions, and publishes video content."
|
||||
),
|
||||
parameters={
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Source video path or URL",
|
||||
},
|
||||
"instruction": {
|
||||
"type": "string",
|
||||
"description": "What to do with the video",
|
||||
},
|
||||
},
|
||||
required=["source"],
|
||||
),
|
||||
}
|
||||
|
||||
# Map personas to the OpenFang hands they should have access to
|
||||
PERSONA_HAND_MAP: dict[str, list[str]] = {
|
||||
"echo": ["researcher", "browser", "collector"],
|
||||
"seer": ["predictor", "researcher"],
|
||||
"mace": ["collector", "browser"],
|
||||
"helm": ["browser"],
|
||||
"forge": ["browser", "researcher"],
|
||||
"quill": ["researcher"],
|
||||
"pixel": ["clip", "browser"],
|
||||
"lyra": [],
|
||||
"reel": ["clip"],
|
||||
}
|
||||
|
||||
|
||||
def _make_hand_handler(hand_name: str):
|
||||
"""Create an async handler that delegates to the OpenFang client."""
|
||||
|
||||
async def handler(**kwargs: Any) -> str:
|
||||
result = await openfang_client.execute_hand(hand_name, kwargs)
|
||||
if result.success:
|
||||
return result.output
|
||||
return f"[OpenFang {hand_name} error] {result.error}"
|
||||
|
||||
handler.__name__ = f"openfang_{hand_name}"
|
||||
handler.__doc__ = _HAND_SCHEMAS.get(hand_name, {}).get(
|
||||
"description", f"OpenFang {hand_name} hand"
|
||||
)
|
||||
return handler
|
||||
|
||||
|
||||
def register_openfang_tools() -> int:
|
||||
"""Register all OpenFang Hands as MCP tools.
|
||||
|
||||
Returns the number of tools registered.
|
||||
"""
|
||||
from mcp.registry import tool_registry
|
||||
|
||||
count = 0
|
||||
for hand_name in OPENFANG_HANDS:
|
||||
schema = _HAND_SCHEMAS.get(hand_name)
|
||||
if not schema:
|
||||
logger.warning("No schema for OpenFang hand: %s", hand_name)
|
||||
continue
|
||||
|
||||
tool_name = f"openfang_{hand_name}"
|
||||
handler = _make_hand_handler(hand_name)
|
||||
|
||||
tool_registry.register(
|
||||
name=tool_name,
|
||||
schema=schema,
|
||||
handler=handler,
|
||||
category="openfang",
|
||||
tags=["openfang", hand_name, "vendor"],
|
||||
source_module="infrastructure.openfang.tools",
|
||||
requires_confirmation=(hand_name in ("twitter",)),
|
||||
)
|
||||
count += 1
|
||||
|
||||
logger.info("Registered %d OpenFang tools in MCP registry", count)
|
||||
return count
|
||||
|
||||
|
||||
def get_hands_for_persona(persona_id: str) -> list[str]:
|
||||
"""Return the OpenFang tool names available to a persona."""
|
||||
hand_names = PERSONA_HAND_MAP.get(persona_id, [])
|
||||
return [f"openfang_{h}" for h in hand_names]
|
||||
@@ -8,10 +8,12 @@ Usage:
|
||||
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
|
||||
|
||||
@@ -274,6 +276,70 @@ Response:"""
|
||||
]
|
||||
|
||||
|
||||
# ── 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user