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

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