feat: dockerize OpenFang as vendored tool runtime sidecar (#96)

This commit is contained in:
Alexander Whitestone
2026-02-28 19:27:48 -05:00
committed by GitHub
parent d7d7a5a80a
commit b7c89d1101
18 changed files with 1383 additions and 23 deletions

View File

@@ -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"

View File

@@ -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:

View 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"]

View 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()

View 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]

View File

@@ -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.