evalstate HF Staff commited on
Commit
5891bca
·
verified ·
1 Parent(s): 13b5f12

Version renderer and continue research in chat

Browse files
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.7 \
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(topic: str, snapshot: dict[str, Any]) -> PrefabApp:
 
 
 
 
 
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 two app-only backend tools."""
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,