Version renderer and continue research in chat
Browse files- Dockerfile +3 -2
- research/app_artifacts.py +26 -0
- research/app_renderer.py +70 -0
- research/app_ui.py +44 -4
- research/fastmcp_server.py +30 -2
Dockerfile
CHANGED
|
@@ -6,8 +6,9 @@ RUN apt-get update && \
|
|
| 6 |
|
| 7 |
WORKDIR /app
|
| 8 |
RUN uv pip install --system --no-cache \
|
| 9 |
-
fast-agent-mcp==0.9.
|
| 10 |
-
'fastmcp[apps]' \
|
|
|
|
| 11 |
huggingface_hub
|
| 12 |
|
| 13 |
COPY --link ./ /app
|
|
|
|
| 6 |
|
| 7 |
WORKDIR /app
|
| 8 |
RUN uv pip install --system --no-cache \
|
| 9 |
+
fast-agent-mcp==0.9.17 \
|
| 10 |
+
'fastmcp[apps]==3.4.4' \
|
| 11 |
+
prefab-ui==0.20.2 \
|
| 12 |
huggingface_hub
|
| 13 |
|
| 14 |
COPY --link ./ /app
|
research/app_artifacts.py
CHANGED
|
@@ -72,6 +72,32 @@ def finalize_bucket_html(
|
|
| 72 |
return _artifact_urls(username, job.id)
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
def _download_first(
|
| 76 |
api: Any,
|
| 77 |
bucket_id: str,
|
|
|
|
| 72 |
return _artifact_urls(username, job.id)
|
| 73 |
|
| 74 |
|
| 75 |
+
def read_bucket_markdown(
|
| 76 |
+
job: ResearchJob,
|
| 77 |
+
auth: AgentAuth | None,
|
| 78 |
+
*,
|
| 79 |
+
api: HfApi | None = None,
|
| 80 |
+
) -> str:
|
| 81 |
+
"""Read the completed Markdown report with the caller's token."""
|
| 82 |
+
if auth is None or not auth.token:
|
| 83 |
+
raise RuntimeError("Caller authentication is required to read the report")
|
| 84 |
+
|
| 85 |
+
api = api or HfApi()
|
| 86 |
+
username = api.whoami(token=auth.token)["name"]
|
| 87 |
+
bucket_id = f"{username}/research-agent"
|
| 88 |
+
report_path = f"{job.id}/output/report.md"
|
| 89 |
+
|
| 90 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 91 |
+
local = Path(directory) / "report.md"
|
| 92 |
+
api.download_bucket_files(
|
| 93 |
+
bucket_id,
|
| 94 |
+
[(report_path, local)],
|
| 95 |
+
raise_on_missing_files=True,
|
| 96 |
+
token=auth.token,
|
| 97 |
+
)
|
| 98 |
+
return local.read_text()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
def _download_first(
|
| 102 |
api: Any,
|
| 103 |
bucket_id: str,
|
research/app_renderer.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Versioned Prefab renderer resource for reliable host cache invalidation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
from collections.abc import Sequence
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from fastmcp import FastMCP
|
| 9 |
+
from fastmcp.apps.config import UI_MIME_TYPE, AppConfig, ResourceCSP
|
| 10 |
+
from fastmcp.server.providers.addressing import hash_tool
|
| 11 |
+
from fastmcp.server.transforms import Transform
|
| 12 |
+
from fastmcp.tools.base import Tool
|
| 13 |
+
from prefab_ui.renderer import get_renderer_csp, get_renderer_html
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class RendererUriTransform(Transform):
|
| 17 |
+
"""Point one UI tool at a content-addressed renderer resource."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, tool_name: str, resource_uri: str) -> None:
|
| 20 |
+
self.tool_name = tool_name
|
| 21 |
+
self.resource_uri = resource_uri
|
| 22 |
+
|
| 23 |
+
async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
|
| 24 |
+
return [
|
| 25 |
+
self._with_renderer(tool) if tool.name == self.tool_name else tool
|
| 26 |
+
for tool in tools
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
def _with_renderer(self, tool: Tool) -> Tool:
|
| 30 |
+
meta = dict(tool.meta or {})
|
| 31 |
+
ui = dict(meta.get("ui") or {})
|
| 32 |
+
ui["resourceUri"] = self.resource_uri
|
| 33 |
+
meta["ui"] = ui
|
| 34 |
+
return tool.model_copy(update={"meta": meta})
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def install_versioned_renderer(
|
| 38 |
+
mcp: FastMCP,
|
| 39 |
+
*,
|
| 40 |
+
app_name: str,
|
| 41 |
+
tool_name: str,
|
| 42 |
+
) -> str:
|
| 43 |
+
"""Register the Prefab renderer at a URI derived from its exact content."""
|
| 44 |
+
html = get_renderer_html()
|
| 45 |
+
digest = hashlib.sha256(html.encode()).hexdigest()[:12]
|
| 46 |
+
tool_digest = hash_tool(app_name, tool_name)
|
| 47 |
+
resource_uri = f"ui://prefab/tool/{tool_digest}/renderer-{digest}.html"
|
| 48 |
+
csp = ResourceCSP(**(get_renderer_csp() or {}))
|
| 49 |
+
|
| 50 |
+
@mcp.resource(
|
| 51 |
+
resource_uri,
|
| 52 |
+
name="Versioned Prefab Renderer",
|
| 53 |
+
mime_type=UI_MIME_TYPE,
|
| 54 |
+
app=AppConfig(csp=csp),
|
| 55 |
+
)
|
| 56 |
+
def prefab_renderer() -> str:
|
| 57 |
+
return html
|
| 58 |
+
|
| 59 |
+
mcp.add_transform(RendererUriTransform(tool_name, resource_uri))
|
| 60 |
+
return digest
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def app_build_id(home: Path) -> str:
|
| 64 |
+
"""Hash the app code and prompts that shape a rendered research view."""
|
| 65 |
+
digest = hashlib.sha256()
|
| 66 |
+
paths = sorted(home.glob("*.py")) + sorted((home / "agent-cards").glob("*.md"))
|
| 67 |
+
for path in paths:
|
| 68 |
+
digest.update(path.relative_to(home).as_posix().encode())
|
| 69 |
+
digest.update(path.read_bytes())
|
| 70 |
+
return digest.hexdigest()[:8]
|
research/app_ui.py
CHANGED
|
@@ -4,11 +4,12 @@ from __future__ import annotations
|
|
| 4 |
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
-
from prefab_ui.actions import SetInterval, SetState
|
| 8 |
-
from prefab_ui.actions.mcp import CallTool
|
| 9 |
from prefab_ui.app import PrefabApp
|
| 10 |
from prefab_ui.components import (
|
| 11 |
Badge,
|
|
|
|
| 12 |
Card,
|
| 13 |
CardContent,
|
| 14 |
CardDescription,
|
|
@@ -33,13 +34,20 @@ from prefab_ui.components.control_flow import Else, ForEach
|
|
| 33 |
from prefab_ui.rx import RESULT, STATE
|
| 34 |
|
| 35 |
|
| 36 |
-
def build_research_ui(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
with PrefabApp(
|
| 38 |
state={
|
| 39 |
"job": snapshot,
|
| 40 |
"topic": topic,
|
| 41 |
"job_id": snapshot["job_id"],
|
| 42 |
"poll_ms": "1500",
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
) as ui:
|
| 45 |
with Column(
|
|
@@ -72,8 +80,9 @@ def build_research_ui(topic: str, snapshot: dict[str, Any]) -> PrefabApp:
|
|
| 72 |
with Row(justify="between", align="center", gap=4):
|
| 73 |
with Column(gap=1):
|
| 74 |
Heading("🤗 Research Agent")
|
| 75 |
-
Muted(STATE.topic)
|
| 76 |
with Row(gap=2, align="center"):
|
|
|
|
| 77 |
with If("{{ !job.done }}"):
|
| 78 |
Loader(variant="dots", size="sm")
|
| 79 |
Badge(STATE.job.status, variant="info")
|
|
@@ -171,5 +180,36 @@ def build_research_ui(topic: str, snapshot: dict[str, Any]) -> PrefabApp:
|
|
| 171 |
STATE.job.result,
|
| 172 |
css_class="whitespace-pre-wrap",
|
| 173 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
return ui
|
|
|
|
| 4 |
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
+
from prefab_ui.actions import SetInterval, SetState, ShowToast
|
| 8 |
+
from prefab_ui.actions.mcp import CallTool, SendMessage, UpdateContext
|
| 9 |
from prefab_ui.app import PrefabApp
|
| 10 |
from prefab_ui.components import (
|
| 11 |
Badge,
|
| 12 |
+
Button,
|
| 13 |
Card,
|
| 14 |
CardContent,
|
| 15 |
CardDescription,
|
|
|
|
| 34 |
from prefab_ui.rx import RESULT, STATE
|
| 35 |
|
| 36 |
|
| 37 |
+
def build_research_ui(
|
| 38 |
+
topic: str,
|
| 39 |
+
snapshot: dict[str, Any],
|
| 40 |
+
*,
|
| 41 |
+
build_id: str,
|
| 42 |
+
) -> PrefabApp:
|
| 43 |
with PrefabApp(
|
| 44 |
state={
|
| 45 |
"job": snapshot,
|
| 46 |
"topic": topic,
|
| 47 |
"job_id": snapshot["job_id"],
|
| 48 |
"poll_ms": "1500",
|
| 49 |
+
"chat_sent": False,
|
| 50 |
+
"app_version": f"build {build_id}",
|
| 51 |
}
|
| 52 |
) as ui:
|
| 53 |
with Column(
|
|
|
|
| 80 |
with Row(justify="between", align="center", gap=4):
|
| 81 |
with Column(gap=1):
|
| 82 |
Heading("🤗 Research Agent")
|
| 83 |
+
Muted(STATE.topic, css_class="text-xs leading-4")
|
| 84 |
with Row(gap=2, align="center"):
|
| 85 |
+
Badge(STATE.app_version, variant="outline")
|
| 86 |
with If("{{ !job.done }}"):
|
| 87 |
Loader(variant="dots", size="sm")
|
| 88 |
Badge(STATE.job.status, variant="info")
|
|
|
|
| 180 |
STATE.job.result,
|
| 181 |
css_class="whitespace-pre-wrap",
|
| 182 |
)
|
| 183 |
+
with If("{{ !chat_sent }}"):
|
| 184 |
+
Button(
|
| 185 |
+
"Continue in chat",
|
| 186 |
+
variant="outline",
|
| 187 |
+
size="sm",
|
| 188 |
+
css_class="mt-4",
|
| 189 |
+
on_click=CallTool(
|
| 190 |
+
"research_chat_context",
|
| 191 |
+
arguments={"job_id": STATE.job_id},
|
| 192 |
+
on_success=[
|
| 193 |
+
UpdateContext(content=RESULT.markdown),
|
| 194 |
+
SendMessage(
|
| 195 |
+
RESULT.message,
|
| 196 |
+
on_success=SetState("chat_sent", True),
|
| 197 |
+
on_error=ShowToast(
|
| 198 |
+
"This host could not send the chat message.",
|
| 199 |
+
variant="error",
|
| 200 |
+
),
|
| 201 |
+
),
|
| 202 |
+
],
|
| 203 |
+
on_error=ShowToast(
|
| 204 |
+
"Could not load the Markdown report.",
|
| 205 |
+
variant="error",
|
| 206 |
+
),
|
| 207 |
+
),
|
| 208 |
+
)
|
| 209 |
+
with If(STATE.chat_sent):
|
| 210 |
+
Muted(
|
| 211 |
+
"Report added to model context and sent to chat.",
|
| 212 |
+
css_class="mt-3 text-xs",
|
| 213 |
+
)
|
| 214 |
|
| 215 |
return ui
|
research/fastmcp_server.py
CHANGED
|
@@ -15,7 +15,9 @@ from pydantic import Field
|
|
| 15 |
from prefab_ui.app import PrefabApp
|
| 16 |
|
| 17 |
from .app_auth import auth_provider, http_middleware, request_auth
|
|
|
|
| 18 |
from .app_jobs import ResearchJobStore, owner_id, unavailable_snapshot
|
|
|
|
| 19 |
from .app_ui import build_research_ui
|
| 20 |
from .research_runner import ResearchRunner
|
| 21 |
|
|
@@ -46,8 +48,9 @@ def register_research_app(
|
|
| 46 |
app: FastMCPApp,
|
| 47 |
jobs: ResearchJobStore,
|
| 48 |
runner: ResearchRunner,
|
|
|
|
| 49 |
) -> None:
|
| 50 |
-
"""Register one UI entry point and its
|
| 51 |
|
| 52 |
@app.ui(
|
| 53 |
name="research",
|
|
@@ -66,7 +69,7 @@ def register_research_app(
|
|
| 66 |
else "local development user"
|
| 67 |
)
|
| 68 |
job.add_event(f"Authenticated as {identity}", kind="auth")
|
| 69 |
-
return build_research_ui(topic, job.snapshot())
|
| 70 |
|
| 71 |
@app.tool()
|
| 72 |
async def start_research(
|
|
@@ -90,6 +93,25 @@ def register_research_app(
|
|
| 90 |
job = await jobs.get(job_id, owner_id(auth, ctx.session_id))
|
| 91 |
return job.snapshot() if job else unavailable_snapshot(job_id)
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
async def main() -> None:
|
| 95 |
args = parse_args()
|
|
@@ -107,6 +129,7 @@ async def main() -> None:
|
|
| 107 |
app,
|
| 108 |
jobs=ResearchJobStore(),
|
| 109 |
runner=ResearchRunner(harness, RESEARCH_HOME),
|
|
|
|
| 110 |
)
|
| 111 |
|
| 112 |
mcp = FastMCP(
|
|
@@ -115,6 +138,11 @@ async def main() -> None:
|
|
| 115 |
instructions="Call `research` to open the live research app.",
|
| 116 |
)
|
| 117 |
mcp.add_provider(app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
await mcp.run_http_async(
|
| 119 |
transport=args.transport,
|
| 120 |
host=args.host,
|
|
|
|
| 15 |
from prefab_ui.app import PrefabApp
|
| 16 |
|
| 17 |
from .app_auth import auth_provider, http_middleware, request_auth
|
| 18 |
+
from .app_artifacts import read_bucket_markdown
|
| 19 |
from .app_jobs import ResearchJobStore, owner_id, unavailable_snapshot
|
| 20 |
+
from .app_renderer import app_build_id, install_versioned_renderer
|
| 21 |
from .app_ui import build_research_ui
|
| 22 |
from .research_runner import ResearchRunner
|
| 23 |
|
|
|
|
| 48 |
app: FastMCPApp,
|
| 49 |
jobs: ResearchJobStore,
|
| 50 |
runner: ResearchRunner,
|
| 51 |
+
build_id: str,
|
| 52 |
) -> None:
|
| 53 |
+
"""Register one UI entry point and its app-only backend tools."""
|
| 54 |
|
| 55 |
@app.ui(
|
| 56 |
name="research",
|
|
|
|
| 69 |
else "local development user"
|
| 70 |
)
|
| 71 |
job.add_event(f"Authenticated as {identity}", kind="auth")
|
| 72 |
+
return build_research_ui(topic, job.snapshot(), build_id=build_id)
|
| 73 |
|
| 74 |
@app.tool()
|
| 75 |
async def start_research(
|
|
|
|
| 93 |
job = await jobs.get(job_id, owner_id(auth, ctx.session_id))
|
| 94 |
return job.snapshot() if job else unavailable_snapshot(job_id)
|
| 95 |
|
| 96 |
+
@app.tool()
|
| 97 |
+
async def research_chat_context(
|
| 98 |
+
job_id: str,
|
| 99 |
+
ctx: MCPContext,
|
| 100 |
+
) -> dict[str, str]:
|
| 101 |
+
auth = request_auth()
|
| 102 |
+
job = await jobs.get(job_id, owner_id(auth, ctx.session_id))
|
| 103 |
+
if job is None or not job.snapshot()["done"]:
|
| 104 |
+
raise RuntimeError("Research job is not available or complete")
|
| 105 |
+
markdown = await asyncio.to_thread(read_bucket_markdown, job, auth)
|
| 106 |
+
return {
|
| 107 |
+
"markdown": markdown,
|
| 108 |
+
"message": (
|
| 109 |
+
"The research report is complete and has been added to context. "
|
| 110 |
+
"Please summarize the main findings and include links to the "
|
| 111 |
+
"source artifacts and generated Markdown and HTML reports."
|
| 112 |
+
),
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
|
| 116 |
async def main() -> None:
|
| 117 |
args = parse_args()
|
|
|
|
| 129 |
app,
|
| 130 |
jobs=ResearchJobStore(),
|
| 131 |
runner=ResearchRunner(harness, RESEARCH_HOME),
|
| 132 |
+
build_id=app_build_id(RESEARCH_HOME),
|
| 133 |
)
|
| 134 |
|
| 135 |
mcp = FastMCP(
|
|
|
|
| 138 |
instructions="Call `research` to open the live research app.",
|
| 139 |
)
|
| 140 |
mcp.add_provider(app)
|
| 141 |
+
install_versioned_renderer(
|
| 142 |
+
mcp,
|
| 143 |
+
app_name="Research Agent",
|
| 144 |
+
tool_name="research",
|
| 145 |
+
)
|
| 146 |
await mcp.run_http_async(
|
| 147 |
transport=args.transport,
|
| 148 |
host=args.host,
|