File size: 7,479 Bytes
ceccf86 13b5f12 ceccf86 7a2b591 ceccf86 5891bca a7a84d2 5891bca ceccf86 7a2b591 ceccf86 7a2b591 6639f9b 7a2b591 ceccf86 6639f9b ceccf86 5891bca ceccf86 5891bca a7a84d2 ceccf86 881204c 1bbb589 881204c ceccf86 881204c 1bbb589 9ea665e 881204c ceccf86 a7a84d2 7a2b591 ceccf86 a7a84d2 ceccf86 a7a84d2 5891bca e5b68d1 5891bca 7c5cfc2 5891bca 7c5cfc2 5891bca e5b68d1 5891bca ceccf86 2c26821 ceccf86 2c26821 ceccf86 2c26821 9c370dc ceccf86 9c370dc ceccf86 5891bca 9c370dc 5891bca ceccf86 6639f9b ceccf86 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | """FastMCP App boundary for the research agent."""
from __future__ import annotations
import argparse
import asyncio
from pathlib import Path
from typing import Annotated, Any
from fast_agent import FastAgent
from fastmcp import Context as MCPContext
from fastmcp import FastMCP, FastMCPApp
from fastmcp.apps import ResourceCSP
from pydantic import Field
from prefab_ui.app import PrefabApp
from .app_auth import auth_provider, http_middleware, request_auth
from .app_artifacts import read_bucket_markdown
from .app_jobs import (
ResearchJobStore,
ResearchTaskRegistry,
owner_id,
unavailable_snapshot,
)
from .app_renderer import app_build_id, install_versioned_renderer
from .app_ui import build_research_ui
from .hf_design import HF_RESOURCE_DOMAINS, HFDesign
from .research_runner import ResearchRunner
RESEARCH_HOME = Path(__file__).parent
AGENT_CARDS = RESEARCH_HOME / "agent-cards"
PRODUCTION_UI_DESIGN: HFDesign = "hub-classic"
STATELESS_TRANSPORTS = {"http", "streamable-http"}
def configure_research_ui_csp(app: FastMCPApp, tool_name: str = "research") -> None:
"""Allow the Google-hosted Hub fonts used by design variants."""
csp = ResourceCSP(resource_domains=list(HF_RESOURCE_DOMAINS)).model_dump(
by_alias=True,
exclude_none=True,
)
for tool in app._local._components.values():
if getattr(tool, "name", None) == tool_name:
tool.meta.setdefault("ui", {})["csp"] = csp
return
raise RuntimeError(f"Prefab UI tool was not registered: {tool_name}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Serve the research FastMCP App.")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8724)
parser.add_argument(
"--transport",
choices=("http", "streamable-http", "sse"),
default="http",
)
return parser.parse_args()
def use_stateless_http(transport: str) -> bool:
"""Avoid stale in-memory MCP sessions on restartable HTTP deployments."""
return transport in STATELESS_TRANSPORTS
def register_research_app(
app: FastMCPApp,
jobs: ResearchJobStore,
runner: ResearchRunner,
build_id: str,
) -> None:
"""Register one UI entry point and its app-only backend tools."""
tasks = ResearchTaskRegistry()
@app.ui(
name="research",
title="🤗 Research Agent",
description=(
"Conduct sourced research on Hugging Face. Provide specific, "
"goal-focused requests or tasks that state what should be established, "
"analysed, reproduced, or compared. State authoritative sources, "
"timeframes, and constraints. Produces a comprehensive report with "
"references, citations, and reproductions."
),
)
async def research(
topic: Annotated[
str,
Field(
description=(
"The clearly stated goal of the research or reproduction task. "
"Provide important constraints and the preferred approach when "
"specified. Work with the user to refine this before calling if needed. "
)
),
],
ctx: MCPContext,
) -> PrefabApp:
auth = request_auth()
job = await jobs.create(topic, owner_id(auth, ctx.session_id))
identity = (
auth.subject or "authenticated user"
if auth is not None
else "local development user"
)
job.add_event(f"Workspace access confirmed for {identity}.", kind="Setup")
return build_research_ui(
topic,
job.snapshot(),
build_id=build_id,
design=PRODUCTION_UI_DESIGN,
)
configure_research_ui_csp(app)
@app.tool()
async def start_research(
job_id: str,
ctx: MCPContext,
) -> dict[str, Any]:
auth = request_auth()
result = await jobs.begin(job_id, owner_id(auth, ctx.session_id))
if result is None:
return unavailable_snapshot(job_id)
if result.started:
tasks.start(result.job.id, runner.run(result.job, auth))
return result.job.snapshot()
@app.tool()
async def research_status(
job_id: str,
ctx: MCPContext,
) -> dict[str, Any]:
auth = request_auth()
job = await jobs.get(job_id, owner_id(auth, ctx.session_id))
return job.snapshot() if job else unavailable_snapshot(job_id)
@app.tool()
async def cancel_research(
job_id: str,
ctx: MCPContext,
) -> dict[str, Any]:
auth = request_auth()
result = await jobs.cancel(job_id, owner_id(auth, ctx.session_id))
if result is None:
return unavailable_snapshot(job_id)
if result.cancel_task:
tasks.cancel(job_id)
return result.job.snapshot()
@app.tool()
async def research_chat_context(
job_id: str,
ctx: MCPContext,
) -> dict[str, str]:
auth = request_auth()
job = await jobs.get(job_id, owner_id(auth, ctx.session_id))
if job is None or not job.markdown_report_uri:
raise RuntimeError("Research Markdown report is not available yet")
markdown = await asyncio.to_thread(read_bucket_markdown, job, auth)
context_markdown = (
f"{markdown.rstrip()}\n\n---\n\n"
f"## Research run metadata\n\n"
f"- App build: `{build_id}`\n\n"
f"{job.result or ''}"
)
return {
"markdown": context_markdown,
"message": (
"The research report has been added to context. "
"Please summarize the main findings and include links to the "
"source artifacts and generated Markdown and HTML reports."
),
}
def build_fast_agent() -> FastAgent:
"""Build the production Harness without model-visible host filesystem access."""
fast = FastAgent(
"Research Agent FastMCP App",
parse_cli_args=False,
home=RESEARCH_HOME,
)
fast.load_agents(AGENT_CARDS)
return fast
def enforce_production_isolation(fast: FastAgent) -> None:
"""Keep model-facing tools off the shared Space host filesystem."""
fast.app.context.no_shell = True
async def main() -> None:
args = parse_args()
fast = build_fast_agent()
async with fast.harness() as harness:
enforce_production_isolation(fast)
build_id = app_build_id(RESEARCH_HOME)
app = FastMCPApp("Research Agent")
register_research_app(
app,
jobs=ResearchJobStore(),
runner=ResearchRunner(harness, RESEARCH_HOME),
build_id=build_id,
)
mcp = FastMCP(
"research-agent-app",
auth=auth_provider(),
instructions="Call `research` to open the live research app.",
)
mcp.add_provider(app)
install_versioned_renderer(
mcp,
app_name="Research Agent",
tool_name="research",
build_id=build_id,
)
await mcp.run_http_async(
transport=args.transport,
host=args.host,
port=args.port,
middleware=http_middleware(),
stateless_http=use_stateless_http(args.transport),
)
|