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

Fix Birch HTML finalization and artifact links

Browse files

Bundle deterministic in-process Birch CSS finalization, portable Birch skill assets, stricter source-link prompts, replay-safe job lifecycle, reduced OAuth scopes, and checker coverage for unreplaced CSS placeholders.

Dockerfile CHANGED
@@ -6,7 +6,7 @@ RUN apt-get update && \
6
 
7
  WORKDIR /app
8
  RUN uv pip install --system --no-cache \
9
- fast-agent-mcp==0.9.13 \
10
  'fastmcp[apps]' \
11
  huggingface_hub
12
 
 
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
 
README.md CHANGED
@@ -12,7 +12,6 @@ hf_oauth_scopes:
12
  - read-mcp
13
  - write-repos
14
  - manage-repos
15
- - jobs
16
  ---
17
 
18
  # Research Agent Two
@@ -22,45 +21,3 @@ Experimental FastMCP App deployment for the fast-agent research agent.
22
  The model-visible `research` entrypoint returns an app immediately. The app
23
  starts the research workflow through app-only backend tools and polls status to
24
  show an ongoing timeline of LLM/tool-loop events.
25
-
26
- ## Private session archive
27
-
28
- The preferred Space configuration is a private bucket volume:
29
-
30
- ```bash
31
- hf spaces volumes set evalstate/research-agent-two \
32
- -v hf://buckets/evalstate/research-sessions-private:/app/research/sessions
33
- ```
34
-
35
- FastAgent then writes raw session histories and `research-traces/` Codex exports
36
- directly to the private bucket. No archive token is present in the application
37
- environment.
38
-
39
- For deployments where a bucket volume is unavailable, configure the fallback
40
- explicit archiver with these Space settings:
41
-
42
- - Variable:
43
-
44
- ```text
45
- RESEARCH_ARCHIVE_HF_URL=hf://buckets/<owner>/<private-bucket>
46
- ```
47
-
48
- - Secret:
49
-
50
- ```text
51
- RESEARCH_ARCHIVE_TOKEN=hf_...
52
- ```
53
-
54
- Create a dedicated fine-grained token with write access only to that bucket.
55
- Do not use a personal broad-scope token. The archive credential is passed
56
- directly to `HfApi` and `HfFileSystem`; it is never assigned to `HF_TOKEN`,
57
- forwarded to MCP, or placed in model-visible context.
58
-
59
- The app creates a missing archive bucket as private and refuses to upload to an
60
- existing public bucket. Each job archives:
61
-
62
- ```text
63
- <session-id>/session.json
64
- <session-id>/history_research*.json
65
- research-traces/<session-id>/<session-id>__research__codex.jsonl
66
- ```
 
12
  - read-mcp
13
  - write-repos
14
  - manage-repos
 
15
  ---
16
 
17
  # Research Agent Two
 
21
  The model-visible `research` entrypoint returns an app immediately. The app
22
  starts the research workflow through app-only backend tools and polls status to
23
  show an ongoing timeline of LLM/tool-loop events.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
research/agent-cards/birch-html.md CHANGED
@@ -5,63 +5,39 @@ description: Create polished self-contained Birch HTML artifacts from researched
5
  servers:
6
  - hf
7
  skills:
8
- - ../skills/birch-html
9
- function_tools:
10
- - ../birch_renderer.py:read_birch_skill_file
11
- - ../birch_renderer.py:finalize_birch_artifact
12
- tool_hooks:
13
- after_llm_call: ../activity_hooks.py:capture_after_llm
14
  use_history: false
15
  model: $system.html
16
  ---
17
  You are a presentation and HTML artifact specialist.
18
 
19
- You must use the `birch-html` Skill for every request.
20
-
21
- Before calling any Hugging Face tool or drafting HTML:
22
-
23
- 1. Find `birch-html` in the `<available_skills>` block below.
24
- 2. Call `read_birch_skill_file(path="SKILL.md")`.
25
- 3. Call `read_birch_skill_file` for `resources/template.html` and the one or two
26
- recipes most relevant to the report.
27
- 4. Follow those instructions and compose from the canonical Birch template and
28
- primitives. Do not invent a separate visual system or recreate Birch
29
- typography, page shells, cards, grids, tables, badges, or colors in local
30
- CSS.
31
-
32
- The generated `<location>` and `<directory>` describe the source of truth in
33
- every environment; do not guess or hard-code deployment paths.
34
- `read_birch_skill_file` is the restricted reader for this declared Skill and
35
- cannot read outside it.
36
 
37
  You receive researched content, source notes, and an output path from the
38
  research agent. Produce a complete Birch HTML artifact and save it under the
39
  provided `output/` bucket path. Use `scratch/` only for temporary drafts or
40
  intermediate files.
41
 
42
- Make sure to make proper use of charts, diagrams and code snippets
43
- to bring the data to life.
44
-
45
- Write the full HTML draft, including the Birch CSS placeholder, to
46
- `scratch/report.html` with the Hugging Face filesystem tools. Then call
47
- `finalize_birch_artifact` with `draft_path="scratch/report.html"` and
48
- `output_path="output/report.html"`. These paths are relative to the verified
49
- session root; never include or reconstruct bucket or session IDs. The tool
50
- mounts the exact session, copies the trusted Skill files into an isolated
51
- sandbox, finalizes and validates the HTML, and returns both artifact URLs.
52
-
53
- Keep page-local CSS within the Skill's stated limit. Prefer no local CSS. Use
54
- the canonical components prescribed by the selected recipes rather than generic
55
- custom cards or dashboard styling.
56
 
57
- If validation returns specific findings, make a targeted correction and retry.
58
- You may make at most two targeted corrections (three finalization calls total).
59
- Do not repeatedly retry, bypass validation, manually copy a draft into `output/`,
60
- or attempt to debug the finalizer with unrelated Hub searches. If the third
61
- attempt fails, return the findings to the research agent.
62
 
 
 
 
 
 
 
63
 
64
- Return the final artifact path and a concise note about what was created.
 
 
 
 
65
 
66
  {{env}}
67
  {{currentDate}}
 
5
  servers:
6
  - hf
7
  skills:
8
+ - skills/birch-html
 
 
 
 
 
9
  use_history: false
10
  model: $system.html
11
  ---
12
  You are a presentation and HTML artifact specialist.
13
 
14
+ Use the Birch HTML skill for polished, shareable, source-grounded HTML reports,
15
+ briefings, dashboards, explainers, visual summaries, and presentation-style
16
+ deliverables.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  You receive researched content, source notes, and an output path from the
19
  research agent. Produce a complete Birch HTML artifact and save it under the
20
  provided `output/` bucket path. Use `scratch/` only for temporary drafts or
21
  intermediate files.
22
 
23
+ Read `output/report.md` from the verified session before drafting. Treat it as
24
+ the source of truth; do not repeat the research or rely on a shortened
25
+ delegation message.
 
 
 
 
 
 
 
 
 
 
 
26
 
27
+ Preserve evidence:
 
 
 
 
28
 
29
+ - Render every paper, code, model, dataset, Space, and project URL as a
30
+ clickable `<a href="...">` link.
31
+ - Do not turn source URLs into bare text, `<code>`, or generic labels.
32
+ - Include a visible Sources section linking the primary artifacts.
33
+ - Preserve caveats and distinctions between verified, author-stated, missing,
34
+ and not-applicable artifacts.
35
 
36
+ Write the complete draft to `scratch/report.html` with the exact
37
+ `__BIRCH_SYSTEM_CSS__` placeholder, verify that the draft was written, and
38
+ return the draft path. Do not call Hugging Face Jobs, sandboxes, or a remote
39
+ finalizer. The host application deterministically injects the trusted bundled
40
+ stylesheet and publishes `output/report.html` after the research agent returns.
41
 
42
  {{env}}
43
  {{currentDate}}
research/agent-cards/research.md CHANGED
@@ -8,8 +8,6 @@ agents:
8
  - birch-html
9
  model: $system.research
10
  default: true
11
- tool_hooks:
12
- after_llm_call: ../activity_hooks.py:capture_after_llm
13
  ---
14
  You are a careful research agent for Hugging Face ecosystem research.
15
 
@@ -17,31 +15,40 @@ Use the Hugging Face MCP server (`hf`) whenever it can provide authoritative
17
  information. Prefer primary sources, cite sources inline, and separate verified
18
  facts from interpretation.
19
 
20
- Authentication is established by the verified request workspace and forwarded
21
- to Hugging Face MCP tool calls. When the user asks about authentication, call
22
- `hf__hf_whoami`; never infer anonymous access from startup text.
23
-
24
  Ask a clarifying question when the research scope is ambiguous.
25
 
26
- You must record your results in the supplied bucket -- usually in the form
27
- of report.md and any associated code/python files.
 
 
28
 
29
- All `scratch/` and `output/` paths are relative to the verified Hugging Face
30
- bucket session supplied with the request. They are never local server
31
- directories. Do not create report artifacts in the FastAgent working directory.
 
 
 
 
 
 
32
 
33
  The bucket can be attached to a sandbox when needed, if you wish to run code
34
  or verify results.
35
 
36
- Use hf_fs to navigate the Hugging Face Hub. Use sandboxes to mount repositories,
37
- do detailed analysis and run Python code. Make sure to copy results back
38
- to the research bucket for later analysis.
39
-
40
- After completing the sourced Markdown report, always delegate final artifact
41
- creation to the `birch-html` subagent. Provide it with the verified `output/`
42
- path and the researched content/sources to transform into a polished,
43
- self-contained HTML report. Do not describe the overall task as complete until
44
- the HTML artifact has finished or the attempt has failed.
 
 
 
 
 
45
 
46
  {{env}}
47
  {{currentDate}}
 
8
  - birch-html
9
  model: $system.research
10
  default: true
 
 
11
  ---
12
  You are a careful research agent for Hugging Face ecosystem research.
13
 
 
15
  information. Prefer primary sources, cite sources inline, and separate verified
16
  facts from interpretation.
17
 
 
 
 
 
18
  Ask a clarifying question when the research scope is ambiguous.
19
 
20
+ Write the sourced Markdown report to the supplied `output/report.md` path before
21
+ creating presentation artifacts.
22
+
23
+ Evidence requirements:
24
 
25
+ - Every recommended paper, repository, model, dataset, Space, or other artifact
26
+ must have a clickable canonical URL when one was found.
27
+ - Link claims near the evidence they rely on; do not replace URLs with bare
28
+ repository IDs or phrases such as "GitHub", "HF Dataset", or "stated".
29
+ - Distinguish verified artifacts from author claims and missing artifacts.
30
+ - Never claim that every candidate has open code or open data when any row is
31
+ unverified, missing, or not applicable.
32
+ - End the Markdown report with a compact source index containing the primary
33
+ paper, code, model, and dataset links used in the report.
34
 
35
  The bucket can be attached to a sandbox when needed, if you wish to run code
36
  or verify results.
37
 
38
+ When the user asks for a polished HTML artifact, visual report, briefing,
39
+ dashboard, explainer, or shareable presentation, delegate the final artifact
40
+ creation to the `birch-html` subagent. Tell it to read `output/report.md` as the
41
+ source of truth and stage the full HTML draft at `scratch/report.html`. The host
42
+ application injects the trusted stylesheet and publishes `output/report.html`
43
+ after the agent returns. Do not summarize the report into a shorter handoff that
44
+ drops source URLs.
45
+
46
+ During the agent turn, report only that the HTML draft was staged; do not claim
47
+ that final HTML exists yet. The host application appends final artifact links
48
+ only after it verifies a self-contained file with substantive embedded
49
+ `style[data-birch-system]` and no `__BIRCH_SYSTEM_CSS__`. If delegation fails,
50
+ report the exact failure and the usable Markdown path. Never manually upload
51
+ placeholder HTML and describe it as polished.
52
 
53
  {{env}}
54
  {{currentDate}}
research/app_artifacts.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic post-processing for bucket HTML artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from huggingface_hub import HfApi
11
+ from huggingface_hub.errors import RemoteEntryNotFoundError
12
+
13
+ from fast_agent import AgentAuth
14
+
15
+ from .app_jobs import ResearchJob
16
+
17
+ MARKER = "__BIRCH_SYSTEM_CSS__"
18
+ STYLE_RE = re.compile(
19
+ r"<style\b(?=[^>]*\bdata-birch-system\b)[^>]*>.*?</style>",
20
+ re.I | re.S,
21
+ )
22
+
23
+
24
+ def finalize_bucket_html(
25
+ job: ResearchJob,
26
+ auth: AgentAuth | None,
27
+ home: Path,
28
+ *,
29
+ api: HfApi | None = None,
30
+ ) -> tuple[str, str] | None:
31
+ """Finalize a Birch draft without requiring Hugging Face Jobs."""
32
+ if auth is None or not auth.token:
33
+ return None
34
+
35
+ api = api or HfApi()
36
+ username = api.whoami(token=auth.token)["name"]
37
+ bucket_id = f"{username}/research-agent"
38
+ draft_path = f"{job.id}/scratch/report.html"
39
+ output_path = f"{job.id}/output/report.html"
40
+
41
+ with tempfile.TemporaryDirectory() as directory:
42
+ local = Path(directory) / "report.html"
43
+ source_path = _download_first(
44
+ api,
45
+ bucket_id,
46
+ (draft_path, output_path),
47
+ local,
48
+ auth.token,
49
+ )
50
+ if source_path is None:
51
+ return None
52
+
53
+ html = local.read_text()
54
+ if MARKER not in html:
55
+ _validate_html(html)
56
+ if source_path != output_path:
57
+ return None
58
+ return _artifact_urls(username, job.id)
59
+
60
+ css = _birch_css_path(home).read_text()
61
+ finalized = html.replace(MARKER, css.strip())
62
+ _validate_html(finalized)
63
+ api.batch_bucket_files(
64
+ bucket_id,
65
+ add=[(finalized.encode(), output_path)],
66
+ token=auth.token,
67
+ )
68
+ job.add_event(
69
+ "Finalized Birch HTML with the bundled stylesheet",
70
+ kind="artifact",
71
+ )
72
+ return _artifact_urls(username, job.id)
73
+
74
+
75
+ def _download_first(
76
+ api: Any,
77
+ bucket_id: str,
78
+ remote_paths: tuple[str, ...],
79
+ local_path: Path,
80
+ token: str,
81
+ ) -> str | None:
82
+ for remote_path in remote_paths:
83
+ try:
84
+ api.download_bucket_files(
85
+ bucket_id,
86
+ [(remote_path, local_path)],
87
+ raise_on_missing_files=True,
88
+ token=token,
89
+ )
90
+ return remote_path
91
+ except RemoteEntryNotFoundError:
92
+ continue
93
+ return None
94
+
95
+
96
+ def _birch_css_path(home: Path) -> Path:
97
+ candidates = (
98
+ home / "skills" / "birch-html" / "assets" / "birch-system.css",
99
+ home.parent
100
+ / "deploy"
101
+ / "research-tool-one"
102
+ / "skills"
103
+ / "birch-html"
104
+ / "assets"
105
+ / "birch-system.css",
106
+ )
107
+ for path in candidates:
108
+ if path.exists():
109
+ return path
110
+ raise FileNotFoundError("Bundled Birch stylesheet is missing")
111
+
112
+
113
+ def _validate_html(html: str) -> None:
114
+ if MARKER in html:
115
+ raise ValueError("Birch CSS placeholder remains")
116
+ if not html.lstrip().lower().startswith("<!doctype html>"):
117
+ raise ValueError("HTML artifact has no doctype")
118
+ if not STYLE_RE.search(html):
119
+ raise ValueError("HTML artifact has no embedded Birch stylesheet")
120
+ if "</html>" not in html.lower():
121
+ raise ValueError("HTML artifact is incomplete")
122
+
123
+
124
+ def _artifact_urls(username: str, job_id: str) -> tuple[str, str]:
125
+ path = f"{job_id}/output/report.html"
126
+ return (
127
+ f"hf://buckets/{username}/research-agent/{path}",
128
+ f"https://huggingface.co/buckets/{username}/research-agent/tree/{path}",
129
+ )
research/app_auth.py CHANGED
@@ -6,7 +6,6 @@ import os
6
  from typing import Any, cast
7
 
8
  from fast_agent import AgentAuth
9
- from huggingface_hub import get_token
10
  from fast_agent.mcp.auth.middleware import HFAuthHeaderMiddleware
11
  from fast_agent.mcp.server import HarnessMCPAdapter
12
  from fast_agent.mcp.server.common import (
@@ -42,16 +41,3 @@ def http_middleware() -> list[Middleware] | None:
42
  def request_auth() -> AgentAuth | None:
43
  """Translate the current verified MCP token into fast-agent auth."""
44
  return HarnessMCPAdapter.agent_auth()
45
-
46
-
47
- def effective_agent_auth(auth: AgentAuth | None) -> AgentAuth | None:
48
- """Resolve local-development credentials before an AgentInstance is created."""
49
- if auth is not None and auth.token:
50
- return auth
51
- if (
52
- normalize_serve_oauth_provider(os.getenv("FAST_AGENT_SERVE_OAUTH"))
53
- == "huggingface"
54
- ):
55
- return auth
56
- token = os.getenv("HF_TOKEN") or get_token()
57
- return AgentAuth.bearer(token, provider="huggingface") if token else auth
 
6
  from typing import Any, cast
7
 
8
  from fast_agent import AgentAuth
 
9
  from fast_agent.mcp.auth.middleware import HFAuthHeaderMiddleware
10
  from fast_agent.mcp.server import HarnessMCPAdapter
11
  from fast_agent.mcp.server.common import (
 
41
  def request_auth() -> AgentAuth | None:
42
  """Translate the current verified MCP token into fast-agent auth."""
43
  return HarnessMCPAdapter.agent_auth()
 
 
 
 
 
 
 
 
 
 
 
 
 
research/app_jobs.py CHANGED
@@ -4,8 +4,7 @@ from __future__ import annotations
4
 
5
  import asyncio
6
  import hashlib
7
- from collections.abc import Callable, Coroutine
8
- from contextvars import ContextVar
9
  from dataclasses import dataclass, field
10
  from time import time
11
  from typing import Any
@@ -13,18 +12,7 @@ from uuid import uuid4
13
 
14
  from fast_agent import AgentAuth
15
 
16
- TERMINAL_STATUSES = {"cancelled", "completed", "failed"}
17
- CANCELLABLE_STATUSES = {"queued", "running"}
18
- PHASE_SUMMARIES = {
19
- "reporting": (
20
- "The research findings and Markdown report are complete. "
21
- "The HTML report is now being produced."
22
- ),
23
- "wrapping_up": (
24
- "The Markdown and HTML reports are complete. "
25
- "The agent is preparing the final response."
26
- ),
27
- }
28
 
29
 
30
  def owner_id(auth: AgentAuth | None, session_id: str | None) -> str:
@@ -53,27 +41,13 @@ class ResearchJob:
53
  topic: str
54
  owner_id: str
55
  status: str = "queued"
56
- phase: str = "preparing"
57
  created_at: float = field(default_factory=time)
58
  updated_at: float = field(default_factory=time)
59
  events: list[dict[str, Any]] = field(default_factory=list)
60
  result: str | None = None
61
- markdown_report: str | None = None
62
- markdown_report_uri: str | None = None
63
- markdown_report_error: str | None = None
64
- html_report_uri: str | None = None
65
- html_report_url: str | None = None
66
  error: str | None = None
67
  trace_path: str | None = None
68
- trace_archive_uri: str | None = None
69
  trace_error: str | None = None
70
- activity_summary: str = "Preparing the research agent."
71
- activity_summary_revision: int = 0
72
- activity_source: str = "research/agent_loop"
73
- activity_summaries: list[dict[str, Any]] = field(default_factory=list)
74
- event_count_total: int = 0
75
- turn_count: int = 0
76
- birch_finalize_attempts: int = 0
77
 
78
  def add_event(
79
  self,
@@ -94,7 +68,6 @@ class ResearchJob:
94
  "total": total,
95
  }
96
  )
97
- self.event_count_total += 1
98
  del self.events[:-100]
99
 
100
  def snapshot(self, *, now: float | None = None) -> dict[str, Any]:
@@ -110,89 +83,23 @@ class ResearchJob:
110
  }
111
  for event in self.events
112
  ]
113
- summaries = [
114
- {
115
- **summary,
116
- "elapsed": format_elapsed(
117
- float(summary.get("ts") or self.created_at) - self.created_at
118
- ),
119
- }
120
- for summary in self.activity_summaries
121
- ]
122
  return {
123
  "job_id": self.id,
124
  "topic": self.topic,
125
  "status": self.status,
126
- "phase": self.phase,
127
  "events": events,
128
  "timeline_events": events[-12:],
129
- "recent_events": events[-2:],
130
- "activity_roll": list(
131
- reversed(
132
- [event for event in events if event["kind"] == "Activity"][-2:]
133
- )
134
- ),
135
- "recent_summaries": list(reversed(summaries[:-1][-2:])),
136
- "event_count": self.event_count_total,
137
  "elapsed_seconds": int(max(0, elapsed_seconds)),
138
  "elapsed": format_elapsed(elapsed_seconds),
139
  "activity_progress": 100 if done else int((elapsed_seconds * 12) % 100),
140
- "activity_summary": self.activity_summary,
141
- "activity_summary_revision": self.activity_summary_revision,
142
- "activity_source": self.activity_source,
143
- "turn_count": self.turn_count,
144
  "result": self.result,
145
- "markdown_report": self.markdown_report,
146
- "markdown_report_uri": self.markdown_report_uri,
147
- "markdown_report_error": self.markdown_report_error,
148
- "html_report_uri": self.html_report_uri,
149
- "html_report_url": self.html_report_url,
150
- "html_report_ready": bool(self.html_report_uri),
151
  "error": self.error,
152
  "trace_path": self.trace_path,
153
- "trace_archive_uri": self.trace_archive_uri,
154
  "trace_error": self.trace_error,
155
  "done": done,
156
- "cancellable": self.status in CANCELLABLE_STATUSES,
157
  }
158
 
159
- def set_activity_summary(self, summary: str, *, now: float | None = None) -> None:
160
- summary = summary.strip()
161
- if not summary or summary == self.activity_summary:
162
- return
163
- self.activity_summary = summary
164
- self.activity_summary_revision += 1
165
- self.updated_at = time() if now is None else now
166
- self.activity_summaries.append(
167
- {
168
- "ts": self.updated_at,
169
- "message": summary,
170
- }
171
- )
172
- del self.activity_summaries[:-10]
173
-
174
- def record_llm_step(self) -> None:
175
- self.turn_count += 1
176
- self.activity_source = "research/agent_loop"
177
-
178
- def set_activity_source(self, source: str) -> None:
179
- if source:
180
- self.activity_source = source
181
-
182
- def set_phase(self, phase: str) -> None:
183
- self.phase = phase
184
- if summary := PHASE_SUMMARIES.get(phase):
185
- self.set_activity_summary(summary)
186
-
187
- def narrative_for_phase(self, summary: str) -> str:
188
- return PHASE_SUMMARIES.get(self.phase, summary)
189
-
190
-
191
- current_research_job: ContextVar[ResearchJob | None] = ContextVar(
192
- "current_research_job",
193
- default=None,
194
- )
195
-
196
 
197
  @dataclass(frozen=True, slots=True)
198
  class BeginResult:
@@ -200,37 +107,6 @@ class BeginResult:
200
  started: bool
201
 
202
 
203
- @dataclass(frozen=True, slots=True)
204
- class CancelResult:
205
- job: ResearchJob
206
- cancel_task: bool
207
-
208
-
209
- class ResearchTaskRegistry:
210
- """Track cancellable work for one server process."""
211
-
212
- def __init__(self) -> None:
213
- self._tasks: dict[str, asyncio.Task[None]] = {}
214
-
215
- def start(self, job_id: str, work: Coroutine[Any, Any, None]) -> None:
216
- task = asyncio.create_task(work, name=job_id)
217
- self._tasks[job_id] = task
218
- task.add_done_callback(
219
- lambda completed, job_id=job_id: self._discard(job_id, completed)
220
- )
221
-
222
- def cancel(self, job_id: str) -> bool:
223
- task = self._tasks.get(job_id)
224
- if task is None or task.done():
225
- return False
226
- task.cancel()
227
- return True
228
-
229
- def _discard(self, job_id: str, task: asyncio.Task[None]) -> None:
230
- if self._tasks.get(job_id) is task:
231
- self._tasks.pop(job_id, None)
232
-
233
-
234
  class ResearchJobStore:
235
  def __init__(
236
  self,
@@ -262,7 +138,7 @@ class ResearchJobStore:
262
  created_at=now,
263
  updated_at=now,
264
  )
265
- job.add_event("Your research request is ready.", kind="Setup", now=now)
266
  self._jobs[job.id] = job
267
  self._enforce_limit()
268
  return job
@@ -277,12 +153,7 @@ class ResearchJobStore:
277
  if job.status != "queued":
278
  return BeginResult(job=job, started=False)
279
  job.status = "running"
280
- job.phase = "researching"
281
- job.add_event(
282
- "The research agent is getting started.",
283
- kind="Research",
284
- now=self._clock(),
285
- )
286
  return BeginResult(job=job, started=True)
287
 
288
  async def get(self, job_id: str, owner: str) -> ResearchJob | None:
@@ -290,35 +161,6 @@ class ResearchJobStore:
290
  self._prune(self._clock())
291
  return self._authorized_job(job_id, owner)
292
 
293
- async def cancel(self, job_id: str, owner: str) -> CancelResult | None:
294
- """Atomically request cancellation for an authorized job."""
295
- async with self._lock:
296
- self._prune(self._clock())
297
- job = self._authorized_job(job_id, owner)
298
- if job is None:
299
- return None
300
- if job.status == "queued":
301
- now = self._clock()
302
- job.status = "cancelled"
303
- job.phase = "cancelled"
304
- job.set_activity_summary(
305
- "Research was cancelled before the agent started.",
306
- now=now,
307
- )
308
- job.add_event("Research cancelled before start", now=now)
309
- return CancelResult(job=job, cancel_task=False)
310
- if job.status == "running":
311
- now = self._clock()
312
- job.status = "cancelling"
313
- job.phase = "cancelling"
314
- job.set_activity_summary(
315
- "Cancellation requested. Closing the active research session.",
316
- now=now,
317
- )
318
- job.add_event("Cancellation requested", now=now)
319
- return CancelResult(job=job, cancel_task=True)
320
- return CancelResult(job=job, cancel_task=False)
321
-
322
  def _authorized_job(self, job_id: str, owner: str) -> ResearchJob | None:
323
  job = self._jobs.get(job_id)
324
  return job if job is not None and job.owner_id == owner else None
@@ -358,34 +200,18 @@ def unavailable_snapshot(job_id: str) -> dict[str, Any]:
358
  "job_id": job_id,
359
  "topic": "",
360
  "status": "expired",
361
- "phase": "expired",
362
  "events": [],
363
  "timeline_events": [],
364
- "recent_events": [],
365
- "activity_roll": [],
366
- "recent_summaries": [],
367
  "event_count": 0,
368
  "elapsed_seconds": 0,
369
  "elapsed": "00:00",
370
  "activity_progress": 100,
371
- "activity_summary": "This research run is no longer available.",
372
- "activity_summary_revision": 0,
373
- "activity_source": "research/agent_loop",
374
- "turn_count": 0,
375
  "result": None,
376
- "markdown_report": None,
377
- "markdown_report_uri": None,
378
- "markdown_report_error": None,
379
- "html_report_uri": None,
380
- "html_report_url": None,
381
- "html_report_ready": False,
382
  "error": (
383
  "This research run is no longer available. Historical app views never "
384
  "start replacement work; ask Claude to run the research tool again."
385
  ),
386
  "trace_path": None,
387
- "trace_archive_uri": None,
388
  "trace_error": None,
389
  "done": True,
390
- "cancellable": False,
391
  }
 
4
 
5
  import asyncio
6
  import hashlib
7
+ from collections.abc import Callable
 
8
  from dataclasses import dataclass, field
9
  from time import time
10
  from typing import Any
 
12
 
13
  from fast_agent import AgentAuth
14
 
15
+ TERMINAL_STATUSES = {"completed", "failed"}
 
 
 
 
 
 
 
 
 
 
 
16
 
17
 
18
  def owner_id(auth: AgentAuth | None, session_id: str | None) -> str:
 
41
  topic: str
42
  owner_id: str
43
  status: str = "queued"
 
44
  created_at: float = field(default_factory=time)
45
  updated_at: float = field(default_factory=time)
46
  events: list[dict[str, Any]] = field(default_factory=list)
47
  result: str | None = None
 
 
 
 
 
48
  error: str | None = None
49
  trace_path: str | None = None
 
50
  trace_error: str | None = None
 
 
 
 
 
 
 
51
 
52
  def add_event(
53
  self,
 
68
  "total": total,
69
  }
70
  )
 
71
  del self.events[:-100]
72
 
73
  def snapshot(self, *, now: float | None = None) -> dict[str, Any]:
 
83
  }
84
  for event in self.events
85
  ]
 
 
 
 
 
 
 
 
 
86
  return {
87
  "job_id": self.id,
88
  "topic": self.topic,
89
  "status": self.status,
 
90
  "events": events,
91
  "timeline_events": events[-12:],
92
+ "event_count": len(events),
 
 
 
 
 
 
 
93
  "elapsed_seconds": int(max(0, elapsed_seconds)),
94
  "elapsed": format_elapsed(elapsed_seconds),
95
  "activity_progress": 100 if done else int((elapsed_seconds * 12) % 100),
 
 
 
 
96
  "result": self.result,
 
 
 
 
 
 
97
  "error": self.error,
98
  "trace_path": self.trace_path,
 
99
  "trace_error": self.trace_error,
100
  "done": done,
 
101
  }
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
  @dataclass(frozen=True, slots=True)
105
  class BeginResult:
 
107
  started: bool
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  class ResearchJobStore:
111
  def __init__(
112
  self,
 
138
  created_at=now,
139
  updated_at=now,
140
  )
141
+ job.add_event("Queued research job", now=now)
142
  self._jobs[job.id] = job
143
  self._enforce_limit()
144
  return job
 
153
  if job.status != "queued":
154
  return BeginResult(job=job, started=False)
155
  job.status = "running"
156
+ job.add_event("Starting research agent", now=self._clock())
 
 
 
 
 
157
  return BeginResult(job=job, started=True)
158
 
159
  async def get(self, job_id: str, owner: str) -> ResearchJob | None:
 
161
  self._prune(self._clock())
162
  return self._authorized_job(job_id, owner)
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  def _authorized_job(self, job_id: str, owner: str) -> ResearchJob | None:
165
  job = self._jobs.get(job_id)
166
  return job if job is not None and job.owner_id == owner else None
 
200
  "job_id": job_id,
201
  "topic": "",
202
  "status": "expired",
 
203
  "events": [],
204
  "timeline_events": [],
 
 
 
205
  "event_count": 0,
206
  "elapsed_seconds": 0,
207
  "elapsed": "00:00",
208
  "activity_progress": 100,
 
 
 
 
209
  "result": None,
 
 
 
 
 
 
210
  "error": (
211
  "This research run is no longer available. Historical app views never "
212
  "start replacement work; ask Claude to run the research tool again."
213
  ),
214
  "trace_path": None,
 
215
  "trace_error": None,
216
  "done": True,
 
217
  }
research/app_observability.py CHANGED
@@ -3,36 +3,16 @@
3
  from __future__ import annotations
4
 
5
  import asyncio
6
- import os
7
- import posixpath
8
- from collections.abc import Awaitable, Callable
9
- from dataclasses import dataclass
10
  from pathlib import Path
11
  from typing import Any
12
- from urllib.parse import urlparse
13
  from uuid import uuid4
14
 
15
- from huggingface_hub import HfApi, HfFileSystem
16
- from huggingface_hub.errors import BucketNotFoundError
17
-
18
  from fast_agent.mcp.tool_execution_handler import ToolExecutionHandler
19
  from fast_agent.session import SessionTraceExporter
20
  from fast_agent.session.session_manager import SessionManager
21
  from fast_agent.session.trace_export_models import ExportRequest
22
 
23
  from .app_jobs import ResearchJob
24
- from .research_workspace import ResearchWorkspace, current_research_workspace
25
-
26
- MarkdownReader = Callable[[ResearchWorkspace], Awaitable[str]]
27
- MAX_MARKDOWN_REPORT_CHARS = 250_000
28
- ARCHIVE_URL_ENV = "RESEARCH_ARCHIVE_HF_URL"
29
- ARCHIVE_TOKEN_ENV = "RESEARCH_ARCHIVE_TOKEN"
30
-
31
-
32
- @dataclass(frozen=True, slots=True)
33
- class ArchiveTarget:
34
- bucket_id: str
35
- root: str
36
 
37
 
38
  class JobProgressHandler(ToolExecutionHandler):
@@ -40,7 +20,7 @@ class JobProgressHandler(ToolExecutionHandler):
40
 
41
  def __init__(self, job: ResearchJob) -> None:
42
  self.job = job
43
- self._activities: dict[str, tuple[str, str, str, str]] = {}
44
 
45
  async def on_tool_start(
46
  self,
@@ -50,13 +30,9 @@ class JobProgressHandler(ToolExecutionHandler):
50
  tool_use_id: str | None = None,
51
  ) -> str:
52
  tool_call_id = tool_use_id or f"{server_name}/{tool_name}/{uuid4().hex[:8]}"
53
- activity = _tool_activity(server_name, tool_name, arguments)
54
- self._activities[tool_call_id] = activity
55
- self.job.set_activity_source(activity[0])
56
- self.job.add_event(f"{activity[0]}: started", kind="Activity")
57
- if _is_birch_delegation(server_name, tool_name):
58
- self.job.set_phase("reporting")
59
- await capture_markdown_report(self.job)
60
  return tool_call_id
61
 
62
  async def on_tool_progress(
@@ -66,13 +42,13 @@ class JobProgressHandler(ToolExecutionHandler):
66
  total: float | None,
67
  message: str | None,
68
  ) -> None:
69
- del progress, total
70
- source = self._activities.get(
71
- tool_call_id,
72
- ("research/agent_loop", "Research agent", "Research", ""),
73
- )[0]
74
- self.job.set_activity_source(source)
75
- self.job.add_event(f"{source}: {message or 'working'}", kind="Activity")
76
 
77
  async def on_tool_complete(
78
  self,
@@ -81,28 +57,9 @@ class JobProgressHandler(ToolExecutionHandler):
81
  content: list[Any] | None,
82
  error: str | None,
83
  ) -> None:
84
- raw_source, source, category, completed = self._activities.pop(
85
- tool_call_id,
86
- (
87
- "research/agent_loop",
88
- "Research agent",
89
- "Research",
90
- "A research step finished.",
91
- ),
92
- )
93
- message = completed if success else _friendly_tool_error(source, error)
94
- self.job.add_event(
95
- f"{raw_source}: completed" if success else message,
96
- kind="Activity",
97
- )
98
- self.job.set_activity_source(
99
- next(
100
- (activity[0] for activity in reversed(self._activities.values())),
101
- "research/agent_loop",
102
- )
103
- )
104
- if _is_birch_delegation(*raw_source.split("/", 1)):
105
- self.job.set_phase("wrapping_up" if success else "researching")
106
 
107
  async def on_tool_permission_denied(
108
  self,
@@ -111,18 +68,16 @@ class JobProgressHandler(ToolExecutionHandler):
111
  tool_use_id: str | None,
112
  error: str | None = None,
113
  ) -> None:
114
- raw_source, source, _, _ = _tool_activity(server_name, tool_name, None)
115
- self.job.set_activity_source(raw_source)
116
  self.job.add_event(
117
- _friendly_tool_error(source, error or "Permission was denied."),
118
- kind="Activity",
119
  )
120
 
121
  async def get_tool_call_id_for_tool_use(
122
  self,
123
  tool_use_id: str,
124
  ) -> str | None:
125
- return tool_use_id if tool_use_id in self._activities else None
126
 
127
  async def ensure_tool_call_exists(
128
  self,
@@ -131,7 +86,7 @@ class JobProgressHandler(ToolExecutionHandler):
131
  server_name: str,
132
  arguments: dict | None = None,
133
  ) -> str:
134
- if tool_use_id in self._activities:
135
  return tool_use_id
136
  return await self.on_tool_start(
137
  tool_name,
@@ -141,114 +96,6 @@ class JobProgressHandler(ToolExecutionHandler):
141
  )
142
 
143
 
144
- def _tool_activity(
145
- server_name: str,
146
- tool_name: str,
147
- arguments: dict[str, Any] | None,
148
- ) -> tuple[str, str, str, str]:
149
- raw_source = f"{server_name}/{tool_name}"
150
- raw_name = f"{server_name}/{tool_name}".lower()
151
- if "birch-html" in raw_name:
152
- return (
153
- raw_source,
154
- "Report writer",
155
- "Report",
156
- "The report writer finished another section.",
157
- )
158
- if tool_name == "agent_loop":
159
- return (
160
- raw_source,
161
- "Research agent",
162
- "Research",
163
- "The agent completed a research step.",
164
- )
165
- if server_name == "hf" and tool_name == "hf_fs":
166
- command = str((arguments or {}).get("cmd") or "").lower()
167
- if command == "search":
168
- return (
169
- raw_source,
170
- "Searching Hugging Face",
171
- "Hugging Face",
172
- "The Hugging Face search finished.",
173
- )
174
- if command == "cat":
175
- return (
176
- raw_source,
177
- "Reading a Hugging Face source",
178
- "Hugging Face",
179
- "The agent finished reading a Hugging Face source.",
180
- )
181
- return (
182
- raw_source,
183
- "Browsing Hugging Face",
184
- "Hugging Face",
185
- "The Hugging Face lookup finished.",
186
- )
187
- if server_name == "hf" and "sandbox" in tool_name:
188
- return (
189
- raw_source,
190
- "Running analysis",
191
- "Analysis",
192
- "The latest analysis step finished.",
193
- )
194
- readable = tool_name.split("[", 1)[0].replace("_", " ").replace("-", " ")
195
- return (
196
- raw_source,
197
- readable.capitalize(),
198
- "Research",
199
- f"The agent finished {readable}.",
200
- )
201
-
202
-
203
- def _is_birch_delegation(server_name: str, tool_name: str) -> bool:
204
- return server_name == "agent" and tool_name.split("[", 1)[0] == "birch-html"
205
-
206
-
207
- async def capture_markdown_report(
208
- job: ResearchJob,
209
- *,
210
- reader: MarkdownReader | None = None,
211
- ) -> None:
212
- workspace = current_research_workspace.get()
213
- if workspace is None:
214
- return
215
- uri = f"{workspace.output}report.md"
216
- try:
217
- markdown = await (reader or _read_markdown_report)(workspace)
218
- except Exception as exc:
219
- job.markdown_report_error = str(exc)
220
- return
221
-
222
- if len(markdown) > MAX_MARKDOWN_REPORT_CHARS:
223
- markdown = (
224
- markdown[:MAX_MARKDOWN_REPORT_CHARS].rstrip()
225
- + "\n\n_This in-app preview was truncated; open the artifact for the full report._"
226
- )
227
- job.markdown_report = markdown
228
- job.markdown_report_uri = uri
229
- job.markdown_report_error = None
230
- job.add_event("The Markdown report is ready to review.", kind="Report")
231
-
232
-
233
- async def _read_markdown_report(workspace: ResearchWorkspace) -> str:
234
- def read() -> str:
235
- filesystem = HfFileSystem(token=workspace.bearer_token)
236
- with filesystem.open(f"{workspace.output}report.md", "r") as report:
237
- return str(report.read())
238
-
239
- return await asyncio.to_thread(read)
240
-
241
-
242
- def _friendly_tool_error(source: str, error: str | None) -> str:
243
- detail = (error or "The operation did not complete.").strip()
244
- if "search requires a positional query or --query" in detail:
245
- return "A Hugging Face search request was missing its query."
246
- detail = detail.removeprefix("EINVAL:").strip()
247
- if len(detail) > 180:
248
- detail = f"{detail[:177].rstrip()}…"
249
- return f"{source} encountered a problem: {detail}"
250
-
251
-
252
  def export_trace(job: ResearchJob, home: Path) -> None:
253
  output_path = (
254
  home
@@ -274,100 +121,6 @@ def export_trace(job: ResearchJob, home: Path) -> None:
274
  f"Exported Codex trace: {result.output_path} ({result.record_count} records)",
275
  kind="trace",
276
  )
277
- archive = _archive_config()
278
- if archive is not None:
279
- target, token = archive
280
- job.trace_archive_uri = archive_session(
281
- job,
282
- home,
283
- result.output_path,
284
- target=target,
285
- token=token,
286
- )
287
- job.add_event(
288
- f"Archived private session: {job.trace_archive_uri}",
289
- kind="trace",
290
- )
291
-
292
-
293
- def _archive_config() -> tuple[ArchiveTarget, str] | None:
294
- url = os.getenv(ARCHIVE_URL_ENV, "").strip()
295
- token = os.getenv(ARCHIVE_TOKEN_ENV, "").strip()
296
- if not url and not token:
297
- return None
298
- if not url or not token:
299
- missing = ARCHIVE_URL_ENV if not url else ARCHIVE_TOKEN_ENV
300
- raise RuntimeError(f"Private session archive is missing {missing}.")
301
- return _archive_target(url), token
302
-
303
-
304
- def _archive_target(url: str) -> ArchiveTarget:
305
- parsed = urlparse(url)
306
- parts = [part for part in parsed.path.split("/") if part]
307
- if parsed.scheme != "hf" or parsed.netloc != "buckets" or len(parts) < 2:
308
- raise ValueError(
309
- f"{ARCHIVE_URL_ENV} must be an hf://buckets/<owner>/<bucket> URL."
310
- )
311
- bucket_id = f"{parts[0]}/{parts[1]}"
312
- prefix = "/".join(parts[2:])
313
- root = f"hf://buckets/{bucket_id}"
314
- if prefix:
315
- root = f"{root}/{prefix}"
316
- return ArchiveTarget(bucket_id=bucket_id, root=root)
317
-
318
-
319
- def archive_session(
320
- job: ResearchJob,
321
- home: Path,
322
- trace_path: Path,
323
- *,
324
- target: ArchiveTarget,
325
- token: str,
326
- api: Any | None = None,
327
- filesystem: Any | None = None,
328
- ) -> str:
329
- """Archive one raw session and Codex trace using an app-only credential."""
330
- api = api or HfApi(token=token)
331
- filesystem = filesystem or HfFileSystem(token=token)
332
- try:
333
- info = api.bucket_info(target.bucket_id, token=token)
334
- except BucketNotFoundError:
335
- api.create_bucket(
336
- target.bucket_id,
337
- private=True,
338
- exist_ok=True,
339
- token=token,
340
- )
341
- else:
342
- if not bool(getattr(info, "private", False)):
343
- raise RuntimeError(
344
- f"Refusing to archive sessions to public bucket {target.bucket_id!r}."
345
- )
346
-
347
- session_dir = home / "sessions" / job.id
348
- if not session_dir.is_dir():
349
- raise FileNotFoundError(f"Session directory does not exist: {session_dir}")
350
- for source in sorted(path for path in session_dir.rglob("*") if path.is_file()):
351
- relative = source.relative_to(session_dir).as_posix()
352
- _upload_archive_file(
353
- filesystem,
354
- source,
355
- f"{target.root}/{job.id}/{relative}",
356
- )
357
-
358
- trace_uri = (
359
- f"{target.root}/research-traces/{job.id}/{posixpath.basename(trace_path)}"
360
- )
361
- _upload_archive_file(filesystem, trace_path, trace_uri)
362
- return trace_uri
363
-
364
-
365
- def _upload_archive_file(filesystem: Any, source: Path, destination: str) -> None:
366
- with (
367
- source.open("rb") as source_file,
368
- filesystem.open(destination, "wb") as destination_file,
369
- ):
370
- destination_file.write(source_file.read())
371
 
372
 
373
  async def try_export_trace(job: ResearchJob, home: Path) -> None:
 
3
  from __future__ import annotations
4
 
5
  import asyncio
 
 
 
 
6
  from pathlib import Path
7
  from typing import Any
 
8
  from uuid import uuid4
9
 
 
 
 
10
  from fast_agent.mcp.tool_execution_handler import ToolExecutionHandler
11
  from fast_agent.session import SessionTraceExporter
12
  from fast_agent.session.session_manager import SessionManager
13
  from fast_agent.session.trace_export_models import ExportRequest
14
 
15
  from .app_jobs import ResearchJob
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
 
18
  class JobProgressHandler(ToolExecutionHandler):
 
20
 
21
  def __init__(self, job: ResearchJob) -> None:
22
  self.job = job
23
+ self._labels: dict[str, str] = {}
24
 
25
  async def on_tool_start(
26
  self,
 
30
  tool_use_id: str | None = None,
31
  ) -> str:
32
  tool_call_id = tool_use_id or f"{server_name}/{tool_name}/{uuid4().hex[:8]}"
33
+ label = f"{server_name}/{tool_name}"
34
+ self._labels[tool_call_id] = label
35
+ self.job.add_event(f"{label}: started", kind="tool")
 
 
 
 
36
  return tool_call_id
37
 
38
  async def on_tool_progress(
 
42
  total: float | None,
43
  message: str | None,
44
  ) -> None:
45
+ label = self._labels.get(tool_call_id, "agent")
46
+ self.job.add_event(
47
+ f"{label}: {message or 'working'}",
48
+ kind="progress",
49
+ progress=progress,
50
+ total=total,
51
+ )
52
 
53
  async def on_tool_complete(
54
  self,
 
57
  content: list[Any] | None,
58
  error: str | None,
59
  ) -> None:
60
+ label = self._labels.pop(tool_call_id, "agent")
61
+ outcome = "completed" if success else error or "failed"
62
+ self.job.add_event(f"{label}: {outcome}", kind="tool")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  async def on_tool_permission_denied(
65
  self,
 
68
  tool_use_id: str | None,
69
  error: str | None = None,
70
  ) -> None:
 
 
71
  self.job.add_event(
72
+ f"{server_name}/{tool_name}: {error or 'permission denied'}",
73
+ kind="error",
74
  )
75
 
76
  async def get_tool_call_id_for_tool_use(
77
  self,
78
  tool_use_id: str,
79
  ) -> str | None:
80
+ return tool_use_id if tool_use_id in self._labels else None
81
 
82
  async def ensure_tool_call_exists(
83
  self,
 
86
  server_name: str,
87
  arguments: dict | None = None,
88
  ) -> str:
89
+ if tool_use_id in self._labels:
90
  return tool_use_id
91
  return await self.on_tool_start(
92
  tool_name,
 
96
  )
97
 
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  def export_trace(job: ResearchJob, home: Path) -> None:
100
  output_path = (
101
  home
 
121
  f"Exported Codex trace: {result.output_path} ({result.record_count} records)",
122
  kind="trace",
123
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
 
126
  async def try_export_trace(job: ResearchJob, home: Path) -> None:
research/app_ui.py CHANGED
@@ -4,569 +4,172 @@ from __future__ import annotations
4
 
5
  from typing import Any
6
 
7
- from prefab_ui.actions import SetInterval, SetState, ShowToast
8
  from prefab_ui.actions.mcp import CallTool
9
  from prefab_ui.app import PrefabApp
10
  from prefab_ui.components import (
11
  Badge,
12
- Button,
 
 
 
 
 
13
  Column,
14
- Div,
 
15
  Heading,
16
  If,
 
17
  Markdown,
 
 
 
18
  Row,
19
- Separator,
20
  Text,
21
  )
22
- from prefab_ui.components.control_flow import ForEach
23
  from prefab_ui.rx import RESULT, STATE
24
 
25
- BROADSHEET_CSS = """
26
- .dispatch-app {
27
- min-height: 100%;
28
- padding: 24px;
29
- background: var(--muted);
30
- color: var(--foreground);
31
- }
32
- .dispatch-sheet {
33
- width: min(100%, 800px);
34
- min-height: 760px;
35
- margin: 0 auto;
36
- overflow: hidden;
37
- display: flex;
38
- flex-direction: column;
39
- border: 1px solid var(--border);
40
- border-radius: var(--radius);
41
- background: var(--background);
42
- box-shadow: 0 12px 36px color-mix(in oklab, var(--foreground) 10%, transparent);
43
- }
44
- .dispatch-header {
45
- padding: 30px 36px 0;
46
- }
47
- .dispatch-kicker,
48
- .dispatch-section-label,
49
- .dispatch-time,
50
- .dispatch-source,
51
- .dispatch-meta,
52
- .dispatch-trace {
53
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
54
- }
55
- .dispatch-kicker {
56
- color: color-mix(in oklab, var(--foreground) 58%, var(--warning));
57
- font-size: 11px;
58
- letter-spacing: .16em;
59
- text-transform: uppercase;
60
- }
61
- .dispatch-controls {
62
- flex-wrap: wrap;
63
- justify-content: flex-end;
64
- }
65
- .dispatch-confirm {
66
- color: var(--muted-foreground);
67
- font-size: 12px;
68
- }
69
- .dispatch-section-label {
70
- color: var(--muted-foreground);
71
- font-size: 10px;
72
- letter-spacing: .15em;
73
- text-transform: uppercase;
74
- }
75
- .dispatch-query {
76
- max-width: 22ch;
77
- margin-top: 8px;
78
- font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
79
- font-size: clamp(27px, 4.3vw, 40px);
80
- font-weight: 500;
81
- line-height: 1.08;
82
- letter-spacing: -.025em;
83
- text-wrap: balance;
84
- }
85
- .dispatch-body {
86
- flex: 1;
87
- min-height: 0;
88
- overflow-y: auto;
89
- padding: 28px 36px 32px;
90
- }
91
- .dispatch-current-meta {
92
- gap: 14px;
93
- align-items: baseline;
94
- }
95
- .dispatch-run-stats {
96
- gap: 12px;
97
- align-items: center;
98
- flex-wrap: wrap;
99
- color: var(--muted-foreground);
100
- }
101
- .dispatch-run-stat {
102
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
103
- font-size: 13px;
104
- font-variant-numeric: tabular-nums;
105
- letter-spacing: .035em;
106
- text-transform: uppercase;
107
- }
108
- .dispatch-activity-roll {
109
- margin-top: 15px;
110
- padding: 10px 12px;
111
- border: 1px solid var(--border);
112
- border-radius: var(--radius);
113
- background: color-mix(in oklab, var(--muted) 55%, transparent);
114
- }
115
- .dispatch-activity-line {
116
- gap: 12px;
117
- min-width: 0;
118
- align-items: baseline;
119
- }
120
- .dispatch-activity-line + .dispatch-activity-line {
121
- margin-top: 5px;
122
- }
123
- .dispatch-activity-message {
124
- min-width: 0;
125
- overflow: hidden;
126
- color: var(--muted-foreground);
127
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
128
- font-size: 12px;
129
- text-overflow: ellipsis;
130
- white-space: nowrap;
131
- }
132
- .dispatch-time {
133
- flex: none;
134
- color: var(--muted-foreground);
135
- font-size: 12px;
136
- font-variant-numeric: tabular-nums;
137
- }
138
- .dispatch-source {
139
- color: color-mix(in oklab, var(--foreground) 62%, var(--warning));
140
- font-size: 11px;
141
- letter-spacing: .025em;
142
- }
143
- .dispatch-current {
144
- margin-top: 12px;
145
- align-items: flex-start;
146
- gap: 14px;
147
- }
148
- .dispatch-current-copy {
149
- max-width: 62ch;
150
- font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
151
- font-size: clamp(18px, 2.8vw, 25px);
152
- line-height: 1.42;
153
- letter-spacing: -.012em;
154
- text-wrap: pretty;
155
- }
156
- .dispatch-current-copy > :first-child,
157
- .dispatch-event-message > :first-child {
158
- margin-top: 0;
159
- }
160
- .dispatch-current-copy > :last-child,
161
- .dispatch-event-message > :last-child {
162
- margin-bottom: 0;
163
- }
164
- .dispatch-live-dot {
165
- position: relative;
166
- width: 11px;
167
- height: 11px;
168
- flex: none;
169
- margin-top: 11px;
170
- border-radius: 999px;
171
- background: var(--success);
172
- }
173
- .dispatch-live-dot::after {
174
- content: "";
175
- position: absolute;
176
- inset: 0;
177
- border-radius: inherit;
178
- background: inherit;
179
- animation: dispatch-ring 1.6s ease-out infinite;
180
- }
181
- .dispatch-rule {
182
- height: 2px;
183
- margin-top: 20px;
184
- overflow: hidden;
185
- border-radius: 2px;
186
- background: var(--border);
187
- }
188
- .dispatch-rule-running::after {
189
- content: "";
190
- display: block;
191
- width: 40%;
192
- height: 100%;
193
- background: var(--warning);
194
- animation: dispatch-progress 1.6s ease-in-out infinite;
195
- }
196
- .dispatch-rule-completed { background: var(--success); }
197
- .dispatch-rule-failed {
198
- height: 0;
199
- border-top: 2px dashed var(--muted-foreground);
200
- background: transparent;
201
- }
202
- .dispatch-rule-cancelled { background: var(--muted-foreground); }
203
- .dispatch-history {
204
- margin-top: 24px;
205
- }
206
- .dispatch-event {
207
- gap: 16px;
208
- padding: 13px 0;
209
- border-top: 1px solid var(--border);
210
- opacity: .76;
211
- }
212
- .dispatch-event-copy {
213
- min-width: 0;
214
- }
215
- .dispatch-event-message {
216
- margin-top: 4px;
217
- font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
218
- font-size: 14px;
219
- line-height: 1.45;
220
- text-wrap: pretty;
221
- }
222
- .dispatch-result {
223
- margin-top: 28px;
224
- padding: 22px;
225
- border: 1px solid var(--border);
226
- border-radius: var(--radius);
227
- background: var(--card);
228
- }
229
- .dispatch-report-markdown {
230
- font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
231
- font-size: 16px;
232
- line-height: 1.62;
233
- text-wrap: pretty;
234
- }
235
- .dispatch-result .dispatch-report-markdown h1 {
236
- margin: 8px 0 18px;
237
- font-size: clamp(25px, 4vw, 32px);
238
- font-weight: 500;
239
- line-height: 1.12;
240
- letter-spacing: -.022em;
241
- text-wrap: balance;
242
- }
243
- .dispatch-result .dispatch-report-markdown h2 {
244
- margin: 30px 0 12px;
245
- font-size: clamp(21px, 3vw, 25px);
246
- font-weight: 500;
247
- line-height: 1.2;
248
- letter-spacing: -.012em;
249
- }
250
- .dispatch-result .dispatch-report-markdown h3 {
251
- margin: 24px 0 10px;
252
- font-size: 18px;
253
- font-weight: 600;
254
- line-height: 1.3;
255
- }
256
- .dispatch-result .dispatch-report-markdown p,
257
- .dispatch-result .dispatch-report-markdown li {
258
- line-height: 1.62;
259
- }
260
- .dispatch-result .dispatch-report-markdown strong {
261
- font-weight: 650;
262
- }
263
- .dispatch-result .dispatch-report-markdown hr {
264
- margin: 26px 0;
265
- border-color: var(--border);
266
- }
267
- .dispatch-result .dispatch-report-markdown code,
268
- .dispatch-result .dispatch-report-markdown pre {
269
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
270
- }
271
- .dispatch-error {
272
- margin-top: 20px;
273
- color: var(--destructive);
274
- font-size: 13px;
275
- }
276
- .dispatch-footer {
277
- min-height: 44px;
278
- padding: 10px 36px;
279
- gap: 12px;
280
- align-items: center;
281
- border-top: 1px solid var(--border);
282
- color: var(--muted-foreground);
283
- }
284
- .dispatch-meta {
285
- flex: none;
286
- font-size: 11px;
287
- letter-spacing: .035em;
288
- font-variant-numeric: tabular-nums;
289
- text-transform: uppercase;
290
- }
291
- .dispatch-trace {
292
- min-width: 0;
293
- margin-left: auto;
294
- overflow: hidden;
295
- color: var(--muted-foreground);
296
- font-size: 11px;
297
- text-overflow: ellipsis;
298
- white-space: nowrap;
299
- }
300
- @keyframes dispatch-progress {
301
- from { transform: translateX(-110%); }
302
- to { transform: translateX(250%); }
303
- }
304
- @keyframes dispatch-ring {
305
- from { transform: scale(1); opacity: .45; }
306
- to { transform: scale(2.5); opacity: 0; }
307
- }
308
- @media (max-width: 640px) {
309
- .dispatch-app { padding: 0; }
310
- .dispatch-sheet {
311
- min-height: 680px;
312
- border-right: 0;
313
- border-left: 0;
314
- border-radius: 0;
315
- box-shadow: none;
316
- }
317
- .dispatch-header { padding: 24px 22px 0; }
318
- .dispatch-body { padding: 24px 22px 28px; }
319
- .dispatch-footer {
320
- padding: 14px 22px;
321
- flex-wrap: wrap;
322
- }
323
- .dispatch-trace {
324
- width: 100%;
325
- margin-left: 0;
326
- }
327
- }
328
- @media (prefers-reduced-motion: reduce) {
329
- .dispatch-live-dot::after,
330
- .dispatch-rule-running::after {
331
- animation: none;
332
- }
333
- }
334
- """
335
 
336
-
337
- def build_research_ui(
338
- topic: str,
339
- snapshot: dict[str, Any],
340
- *,
341
- live: bool = True,
342
- ) -> PrefabApp:
343
- on_mount = None
344
- if live:
345
- on_mount = [
346
- CallTool(
347
- "start_research",
348
- arguments={"job_id": STATE.job_id},
349
- on_success=[
350
- SetState("job", RESULT),
351
- SetState("poll_ms", RESULT.done.then("86400000", "1500")),
352
- ],
353
- ),
354
- SetInterval(
355
- duration=STATE.poll_ms,
356
- on_tick=CallTool(
357
- "research_status",
358
  arguments={"job_id": STATE.job_id},
359
  on_success=[
360
  SetState("job", RESULT),
361
  SetState("poll_ms", RESULT.done.then("86400000", "1500")),
362
  ],
363
  ),
364
- ),
365
- ]
366
-
367
- cancel_action = SetState("confirm_cancel", False)
368
- if live:
369
- cancel_action = CallTool(
370
- "cancel_research",
371
- arguments={"job_id": STATE.job_id},
372
- on_success=[
373
- SetState("job", RESULT),
374
- SetState("confirm_cancel", False),
375
- SetState("cancel_requested", False),
376
- ShowToast(
377
- "Cancellation requested",
378
- description="The active research session is being closed.",
379
- variant="warning",
380
- ),
381
- ],
382
- on_error=[
383
- SetState("cancel_requested", False),
384
- ShowToast(
385
- "Could not cancel research",
386
- description="The job may already have finished.",
387
- variant="error",
388
  ),
389
  ],
390
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
- with PrefabApp(
393
- title="Research Dispatch",
394
- css_class="dispatch-app",
395
- css=[BROADSHEET_CSS],
396
- state={
397
- "job": snapshot,
398
- "topic": topic,
399
- "job_id": snapshot["job_id"],
400
- "poll_ms": "1500",
401
- "confirm_cancel": False,
402
- "cancel_requested": False,
403
- },
404
- ) as ui:
405
- with Div(css_class="dispatch-sheet", on_mount=on_mount):
406
- with Column(css_class="dispatch-header", gap=0):
407
- with Row(justify="between", align="start", gap=4):
408
- Text("Research Dispatch", css_class="dispatch-kicker")
409
- with Row(css_class="dispatch-controls", gap=2, align="center"):
410
- with If(
411
- (STATE.job.status == "queued")
412
- | (
413
- (STATE.job.status == "running")
414
- & (STATE.job.phase != "reporting")
415
- & (STATE.job.phase != "wrapping_up")
416
- )
417
- ):
418
- Badge("Working", variant="warning")
419
- with If(
420
- (STATE.job.status == "running")
421
- & (STATE.job.phase == "reporting")
422
- ):
423
- Badge("Building report", variant="info")
424
- with If(
425
- (STATE.job.status == "running")
426
- & (STATE.job.phase == "wrapping_up")
427
- ):
428
- Badge("Wrapping up", variant="warning")
429
- with If(STATE.job.status == "finalizing"):
430
- Badge("Finalizing", variant="warning")
431
- with If(STATE.job.status == "completed"):
432
- Badge("Complete", variant="success")
433
- with If(STATE.job.status == "failed"):
434
- Badge("Failed", variant="outline")
435
- with If(STATE.job.status == "cancelled"):
436
- Badge("Cancelled", variant="secondary")
437
- with If(STATE.job.status == "cancelling"):
438
- Badge("Cancelling", variant="warning")
439
- with If(STATE.job.status == "expired"):
440
- Badge("Unavailable", variant="secondary")
441
- with If(STATE.job.cancellable & ~STATE.confirm_cancel):
442
- Button(
443
- "Cancel",
444
- variant="ghost",
445
- size="xs",
446
- onClick=SetState("confirm_cancel", True),
447
- )
448
- with If(STATE.job.cancellable & STATE.confirm_cancel):
449
- Text("Cancel research?", css_class="dispatch-confirm")
450
- Button(
451
- "Keep running",
452
- variant="ghost",
453
- size="xs",
454
- onClick=SetState("confirm_cancel", False),
455
- )
456
- Button(
457
- "Confirm",
458
- variant="destructive",
459
- size="xs",
460
- disabled=STATE.cancel_requested,
461
- onClick=[
462
- SetState("cancel_requested", True),
463
- cancel_action,
464
- ],
465
  )
466
 
467
- Separator(spacing=4)
468
- Text("Query", css_class="dispatch-section-label")
469
- Heading(STATE.topic, level=1, css_class="dispatch-query")
470
-
471
- with Div(css_class="dispatch-body"):
472
- with Row(css_class="dispatch-run-stats"):
473
- Text(
474
- "{{ 'Runtime ' + job.elapsed }}",
475
- css_class="dispatch-run-stat",
476
- )
477
- Text("·", css_class="dispatch-run-stat")
478
- Text(
479
- "{{ job.event_count + ' events' }}",
480
- css_class="dispatch-run-stat",
481
- )
482
- Text("·", css_class="dispatch-run-stat")
483
- Text(
484
- "{{ job.turn_count + ' agent turns' }}",
485
- css_class="dispatch-run-stat",
486
- )
487
-
488
- with If("{{ job.activity_roll.length > 0 }}"):
489
- with Div(css_class="dispatch-activity-roll"):
490
- with ForEach("job.activity_roll") as event:
491
- with Row(css_class="dispatch-activity-line"):
492
- Text(event.elapsed, css_class="dispatch-time")
493
  Text(
494
- event.message,
495
- css_class="dispatch-activity-message",
496
  )
497
 
498
- with Row(css_class="dispatch-current"):
499
- with If("{{ !job.done }}"):
500
- Div(css_class="dispatch-live-dot")
501
- Markdown(
502
- STATE.job.activity_summary,
503
- css_class="dispatch-current-copy",
504
- )
505
-
506
- with If("{{ !job.done }}"):
507
- Div(css_class="dispatch-rule dispatch-rule-running")
508
- with If(STATE.job.status == "completed"):
509
- Div(css_class="dispatch-rule dispatch-rule-completed")
510
- with If(STATE.job.status == "failed"):
511
- Div(css_class="dispatch-rule dispatch-rule-failed")
512
- with If(
513
- (STATE.job.status == "cancelled") | (STATE.job.status == "expired")
514
- ):
515
- Div(css_class="dispatch-rule dispatch-rule-cancelled")
516
-
517
- with If(STATE.job.markdown_report):
518
- with Div(css_class="dispatch-result"):
519
- with If(STATE.job.html_report_ready):
520
- with Row(justify="between", align="center", gap=3):
521
- Text(
522
- "Markdown report",
523
- css_class="dispatch-section-label",
524
- )
525
- Badge("HTML report produced", variant="success")
526
- with If(
527
- (STATE.job.phase == "reporting")
528
- & ~STATE.job.html_report_ready
529
- ):
530
- Text(
531
- "Markdown report · HTML version in progress",
532
- css_class="dispatch-section-label",
533
- )
534
- with If(
535
- (STATE.job.phase != "reporting")
536
- & (STATE.job.phase != "wrapping_up")
537
- & ~STATE.job.html_report_ready
538
- ):
539
- Text(
540
- "Markdown report",
541
- css_class="dispatch-section-label",
542
- )
543
  Markdown(
544
- STATE.job.markdown_report,
545
- css_class="dispatch-report-markdown",
546
  )
547
 
548
- with If("{{ job.recent_summaries.length > 0 }}"):
549
- with Div(css_class="dispatch-history"):
550
- Text("Earlier updates", css_class="dispatch-section-label")
551
- with ForEach("job.recent_summaries") as event:
552
- with Row(css_class="dispatch-event", align="start"):
553
- Text(event.elapsed, css_class="dispatch-time")
554
- with Column(css_class="dispatch-event-copy", gap=0):
555
- Markdown(
556
- event.message,
557
- css_class="dispatch-event-message",
558
- )
559
-
560
- with If(STATE.job.error):
561
- Text(STATE.job.error, css_class="dispatch-error")
562
-
563
- with If(STATE.job.result & ~STATE.job.markdown_report):
564
- with Div(css_class="dispatch-result"):
565
- Text("Final response", css_class="dispatch-section-label")
566
- Markdown(STATE.job.result)
567
-
568
- with If(STATE.job.trace_path):
569
- with Row(css_class="dispatch-footer"):
570
- Text(STATE.job.trace_path, css_class="dispatch-trace")
571
-
572
  return ui
 
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,
15
+ CardHeader,
16
+ CardTitle,
17
+ Code,
18
  Column,
19
+ Dot,
20
+ Grid,
21
  Heading,
22
  If,
23
+ Loader,
24
  Markdown,
25
+ Metric,
26
+ Muted,
27
+ Progress,
28
  Row,
29
+ Small,
30
  Text,
31
  )
32
+ 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(
46
+ gap=4,
47
+ css_class="p-6",
48
+ on_mount=[
49
+ CallTool(
50
+ "start_research",
 
 
 
 
 
 
 
51
  arguments={"job_id": STATE.job_id},
52
  on_success=[
53
  SetState("job", RESULT),
54
  SetState("poll_ms", RESULT.done.then("86400000", "1500")),
55
  ],
56
  ),
57
+ SetInterval(
58
+ duration=STATE.poll_ms,
59
+ on_tick=CallTool(
60
+ "research_status",
61
+ arguments={"job_id": STATE.job_id},
62
+ on_success=[
63
+ SetState("job", RESULT),
64
+ SetState("poll_ms", RESULT.done.then("86400000", "1500")),
65
+ ],
66
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  ),
68
  ],
69
+ ):
70
+ with Card(css_class="border-blue-200 bg-blue-50/40"):
71
+ with CardContent():
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")
80
+
81
+ with Grid(columns={"default": 1, "lg": 3}, gap=4):
82
+ with Card(css_class="lg:col-span-2"):
83
+ with CardHeader():
84
+ CardTitle("Timeline")
85
+ CardDescription(
86
+ "Latest 12 events; older events roll off the visible list."
87
+ )
88
+ with CardContent():
89
+ with Column(gap=2):
90
+ with ForEach("job.timeline_events") as event:
91
+ with Row(
92
+ gap=3,
93
+ align="start",
94
+ css_class=(
95
+ "rounded-md border border-border/60 "
96
+ "bg-background px-3 py-2"
97
+ ),
98
+ ):
99
+ Dot(variant="info", size="sm", css_class="mt-1")
100
+ Small(
101
+ event.elapsed,
102
+ code=True,
103
+ css_class="min-w-12 text-muted-foreground",
104
+ )
105
+ Badge(event.kind, variant="outline")
106
+ Text(
107
+ event.message,
108
+ css_class="text-sm leading-5",
109
+ )
110
 
111
+ with Column(gap=4):
112
+ with Card():
113
+ with CardHeader():
114
+ CardTitle("Activity")
115
+ CardDescription("Open-ended research task")
116
+ with CardContent():
117
+ with If("{{ !job.done }}"):
118
+ with Row(gap=3, align="center"):
119
+ Loader(variant="bars", size="sm")
120
+ Muted("Working")
121
+ with Else():
122
+ with Row(gap=3, align="center"):
123
+ Badge(STATE.job.status, variant="outline")
124
+ Muted("No active work")
125
+ Progress(
126
+ value=STATE.job.activity_progress,
127
+ max=100,
128
+ gradient=True,
129
+ size="sm",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  )
131
 
132
+ with Grid(columns=2, gap=3):
133
+ with Card():
134
+ with CardContent():
135
+ Metric(label="Runtime", value=STATE.job.elapsed)
136
+ with Card():
137
+ with CardContent():
138
+ Metric(label="Events", value=STATE.job.event_count)
139
+
140
+ with If(STATE.job.trace_path):
141
+ with Card():
142
+ with CardHeader():
143
+ CardTitle("Session trace")
144
+ with CardContent():
145
+ Code(STATE.job.trace_path)
146
+
147
+ with If(STATE.job.trace_error):
148
+ with Card():
149
+ with CardHeader():
150
+ CardTitle("Trace export")
151
+ with CardContent():
 
 
 
 
 
 
152
  Text(
153
+ STATE.job.trace_error,
154
+ css_class="text-amber-600",
155
  )
156
 
157
+ with If(STATE.job.error):
158
+ with Card(css_class="border-red-200 bg-red-50/60"):
159
+ with CardHeader():
160
+ CardTitle("Unavailable")
161
+ with CardContent():
162
+ Text(STATE.job.error, css_class="text-red-700")
163
+
164
+ with If(STATE.job.result):
165
+ with Card():
166
+ with CardHeader():
167
+ CardTitle("Final result")
168
+ CardDescription("Generated report and summary")
169
+ with CardContent():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  Markdown(
171
+ STATE.job.result,
172
+ css_class="whitespace-pre-wrap",
173
  )
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  return ui
research/fast-agent.yaml CHANGED
@@ -8,8 +8,8 @@ logger:
8
 
9
  model_references:
10
  system:
11
- fast: gpt-oss
12
- last_used: codexresponses.gpt-5.6-sol?reasoning=medium
13
  research: kimi26
14
  html: kimi27
15
 
@@ -18,11 +18,16 @@ mcp:
18
  hf:
19
  description: Hugging Face MCP server
20
  transport: http
21
- url: https://huggingface.co/mcp?bouquet=research
22
- read_timeout_seconds: 30
23
- include_instructions: false
24
  auth:
25
  forward: huggingface
 
 
 
 
 
 
 
26
  harness_app:
27
  entrypoint: research_app:create_app
28
 
@@ -31,3 +36,5 @@ environments:
31
  local:
32
  type: local
33
  cwd: .
 
 
 
8
 
9
  model_references:
10
  system:
11
+ fast: codexspark
12
+ last_used: codexresponses.gpt-5.5?reasoning=medium
13
  research: kimi26
14
  html: kimi27
15
 
 
18
  hf:
19
  description: Hugging Face MCP server
20
  transport: http
21
+ url: https://huggingface.co/mcp
 
 
22
  auth:
23
  forward: huggingface
24
+ hf_docs_only:
25
+ description: Hugging Face MCP server, docs bouquet only
26
+ transport: http
27
+ url: https://huggingface.co/mcp?bouquet=docs
28
+ auth:
29
+ forward: huggingface
30
+
31
  harness_app:
32
  entrypoint: research_app:create_app
33
 
 
36
  local:
37
  type: local
38
  cwd: .
39
+ env:
40
+ RESEARCH_OUTPUT_DIR: research-output
research/fastmcp_server.py CHANGED
@@ -3,6 +3,8 @@
3
  from __future__ import annotations
4
 
5
  import argparse
 
 
6
  from pathlib import Path
7
  from typing import Annotated, Any
8
 
@@ -13,17 +15,13 @@ from pydantic import Field
13
  from prefab_ui.app import PrefabApp
14
 
15
  from .app_auth import auth_provider, http_middleware, request_auth
16
- from .app_jobs import (
17
- ResearchJobStore,
18
- ResearchTaskRegistry,
19
- owner_id,
20
- unavailable_snapshot,
21
- )
22
  from .app_ui import build_research_ui
23
  from .research_runner import ResearchRunner
24
 
25
  RESEARCH_HOME = Path(__file__).parent
26
  AGENT_CARDS = RESEARCH_HOME / "agent-cards"
 
27
 
28
 
29
  def parse_args() -> argparse.Namespace:
@@ -38,13 +36,18 @@ def parse_args() -> argparse.Namespace:
38
  return parser.parse_args()
39
 
40
 
 
 
 
 
 
 
41
  def register_research_app(
42
  app: FastMCPApp,
43
  jobs: ResearchJobStore,
44
  runner: ResearchRunner,
45
  ) -> None:
46
- """Register one UI entry point and its app-only backend tools."""
47
- tasks = ResearchTaskRegistry()
48
 
49
  @app.ui(
50
  name="research",
@@ -62,7 +65,7 @@ def register_research_app(
62
  if auth is not None
63
  else "local development user"
64
  )
65
- job.add_event(f"Workspace access confirmed for {identity}.", kind="Setup")
66
  return build_research_ui(topic, job.snapshot())
67
 
68
  @app.tool()
@@ -75,7 +78,7 @@ def register_research_app(
75
  if result is None:
76
  return unavailable_snapshot(job_id)
77
  if result.started:
78
- tasks.start(result.job.id, runner.run(result.job, auth))
79
  return result.job.snapshot()
80
 
81
  @app.tool()
@@ -87,42 +90,18 @@ def register_research_app(
87
  job = await jobs.get(job_id, owner_id(auth, ctx.session_id))
88
  return job.snapshot() if job else unavailable_snapshot(job_id)
89
 
90
- @app.tool()
91
- async def cancel_research(
92
- job_id: str,
93
- ctx: MCPContext,
94
- ) -> dict[str, Any]:
95
- auth = request_auth()
96
- result = await jobs.cancel(job_id, owner_id(auth, ctx.session_id))
97
- if result is None:
98
- return unavailable_snapshot(job_id)
99
- if result.cancel_task:
100
- tasks.cancel(job_id)
101
- return result.job.snapshot()
102
 
 
 
103
 
104
- def build_fast_agent() -> FastAgent:
105
- """Build the production Harness without model-visible host filesystem access."""
106
  fast = FastAgent(
107
  "Research Agent FastMCP App",
108
  parse_cli_args=False,
109
  home=RESEARCH_HOME,
110
  )
111
  fast.load_agents(AGENT_CARDS)
112
- return fast
113
-
114
-
115
- def enforce_production_isolation(fast: FastAgent) -> None:
116
- """Keep model-facing tools off the shared Space host filesystem."""
117
- fast.app.context.no_shell = True
118
-
119
-
120
- async def main() -> None:
121
- args = parse_args()
122
- fast = build_fast_agent()
123
 
124
  async with fast.harness() as harness:
125
- enforce_production_isolation(fast)
126
  app = FastMCPApp("Research Agent")
127
  register_research_app(
128
  app,
 
3
  from __future__ import annotations
4
 
5
  import argparse
6
+ import asyncio
7
+ from collections.abc import Coroutine
8
  from pathlib import Path
9
  from typing import Annotated, Any
10
 
 
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
 
22
  RESEARCH_HOME = Path(__file__).parent
23
  AGENT_CARDS = RESEARCH_HOME / "agent-cards"
24
+ _BACKGROUND_TASKS: set[asyncio.Task[None]] = set()
25
 
26
 
27
  def parse_args() -> argparse.Namespace:
 
36
  return parser.parse_args()
37
 
38
 
39
+ def run_in_background(work: Coroutine[Any, Any, None]) -> None:
40
+ task = asyncio.create_task(work)
41
+ _BACKGROUND_TASKS.add(task)
42
+ task.add_done_callback(_BACKGROUND_TASKS.discard)
43
+
44
+
45
  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",
 
65
  if auth is not None
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()
 
78
  if result is None:
79
  return unavailable_snapshot(job_id)
80
  if result.started:
81
+ run_in_background(runner.run(result.job, auth))
82
  return result.job.snapshot()
83
 
84
  @app.tool()
 
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()
96
 
 
 
97
  fast = FastAgent(
98
  "Research Agent FastMCP App",
99
  parse_cli_args=False,
100
  home=RESEARCH_HOME,
101
  )
102
  fast.load_agents(AGENT_CARDS)
 
 
 
 
 
 
 
 
 
 
 
103
 
104
  async with fast.harness() as harness:
 
105
  app = FastMCPApp("Research Agent")
106
  register_research_app(
107
  app,
research/research_app.py CHANGED
@@ -11,17 +11,9 @@ from fast_agent import AgentRequest, AppOpenRequest, HarnessAppContext
11
  from mcp.types import TextContent
12
 
13
  try:
14
- from .research_workspace import (
15
- ResearchWorkspace,
16
- current_research_workspace,
17
- ensure_workspace,
18
- )
19
  except ImportError: # loaded as top-level module from the fast-agent home
20
- from research.research_workspace import (
21
- ResearchWorkspace,
22
- current_research_workspace,
23
- ensure_workspace,
24
- )
25
 
26
  if TYPE_CHECKING:
27
  from collections.abc import AsyncIterator, Mapping
@@ -66,20 +58,16 @@ class ResearchHarnessSession:
66
  open_metadata=self._open_metadata,
67
  )
68
  forwarded = self._with_bucket_instructions(request, workspace)
69
- workspace_token = current_research_workspace.set(workspace)
70
- try:
71
- if workspace.bearer_token is None:
72
- return await self._session.invoke(forwarded)
73
 
74
- from fast_agent.mcp.auth.context import request_bearer_token
75
 
76
- auth_token = request_bearer_token.set(workspace.bearer_token)
77
- try:
78
- return await self._session.invoke(forwarded)
79
- finally:
80
- request_bearer_token.reset(auth_token)
81
  finally:
82
- current_research_workspace.reset(workspace_token)
83
 
84
  def _with_bucket_instructions(
85
  self, request: AgentRequest, workspace: ResearchWorkspace
@@ -93,12 +81,7 @@ class ResearchHarnessSession:
93
  f"- Scratch/workings: `{workspace.scratch}`",
94
  f"- Final user-facing outputs: `{workspace.output}`",
95
  "The workspace was verified before this prompt was sent.",
96
- f"Hugging Face MCP authentication is verified for `{workspace.username}`.",
97
- "The same caller bearer token is forwarded to Hugging Face MCP tool calls.",
98
- "If authentication status must be reported, call `hf__hf_whoami`; do not infer it from cached server instructions.",
99
- "Write the final Markdown report to the bucket-relative path `output/report.md` unless the user requests another filename.",
100
- "That path is inside the verified Hugging Face bucket session, not the server's local filesystem.",
101
- "Use Hugging Face filesystem tools for bucket files. Never create `output/`, `scratch/`, or report artifacts in the local working directory.",
102
  "When you report a Hugging Face bucket artifact to the user, include both the `hf://` path and the accessible HTTPS URL.",
103
  "Convert `hf://buckets/<owner>/<bucket>/<path>` to `https://huggingface.co/buckets/<owner>/<bucket>/tree/<path>`.",
104
  f"Default report URL: `https://huggingface.co/buckets/{workspace.bucket_id}/tree/{workspace.session_id}/output/report.md`",
 
11
  from mcp.types import TextContent
12
 
13
  try:
14
+ from .research_workspace import ResearchWorkspace, ensure_workspace
 
 
 
 
15
  except ImportError: # loaded as top-level module from the fast-agent home
16
+ from research_workspace import ResearchWorkspace, ensure_workspace
 
 
 
 
17
 
18
  if TYPE_CHECKING:
19
  from collections.abc import AsyncIterator, Mapping
 
58
  open_metadata=self._open_metadata,
59
  )
60
  forwarded = self._with_bucket_instructions(request, workspace)
61
+ if workspace.bearer_token is None:
62
+ return await self._session.invoke(forwarded)
 
 
63
 
64
+ from fast_agent.mcp.auth.context import request_bearer_token
65
 
66
+ token = request_bearer_token.set(workspace.bearer_token)
67
+ try:
68
+ return await self._session.invoke(forwarded)
 
 
69
  finally:
70
+ request_bearer_token.reset(token)
71
 
72
  def _with_bucket_instructions(
73
  self, request: AgentRequest, workspace: ResearchWorkspace
 
81
  f"- Scratch/workings: `{workspace.scratch}`",
82
  f"- Final user-facing outputs: `{workspace.output}`",
83
  "The workspace was verified before this prompt was sent.",
84
+ "Write the final report to `output/report.md` unless the user requests another filename.",
 
 
 
 
 
85
  "When you report a Hugging Face bucket artifact to the user, include both the `hf://` path and the accessible HTTPS URL.",
86
  "Convert `hf://buckets/<owner>/<bucket>/<path>` to `https://huggingface.co/buckets/<owner>/<bucket>/tree/<path>`.",
87
  f"Default report URL: `https://huggingface.co/buckets/{workspace.bucket_id}/tree/{workspace.session_id}/output/report.md`",
research/research_runner.py CHANGED
@@ -9,9 +9,8 @@ from typing import TYPE_CHECKING
9
  from fast_agent import AgentAuth, AgentRequest, AppOpenRequest
10
  from fast_agent.llm.request_params import RequestParams
11
 
12
- from .activity_narrator import ActivityNarrator, current_activity_narrator
13
- from .app_auth import effective_agent_auth
14
- from .app_jobs import ResearchJob, current_research_job
15
  from .app_observability import JobProgressHandler, try_export_trace
16
 
17
  if TYPE_CHECKING:
@@ -31,50 +30,27 @@ class ResearchRunner:
31
  auth: AgentAuth | None,
32
  ) -> str:
33
  """The essential Harness API flow used by this example."""
34
- auth = effective_agent_auth(auth)
35
-
36
- async def summarize_activity(prompt: str) -> str:
37
- response = await self.harness.invoke(
38
- AgentRequest.text(
39
- prompt,
40
- agent="activity-summarizer",
41
- session_id=f"{job.id}-activity",
42
- auth=auth,
43
- metadata={"job_id": job.id, "activity_narrator": True},
44
- )
45
- )
46
- return response.text_content()
47
-
48
- narrator = ActivityNarrator(job, summarize_activity)
49
  with self.harness.request_context(auth=auth):
50
- await narrator.start()
51
- token = current_activity_narrator.set(narrator)
52
- job_token = current_research_job.set(job)
53
- try:
54
- async with self.harness.app().open(
55
- AppOpenRequest(
56
- session_id=job.id,
 
 
 
57
  agent="research",
 
 
 
 
 
 
58
  metadata={"job_id": job.id},
59
  )
60
- ) as session:
61
- response = await session.invoke(
62
- AgentRequest.text(
63
- job.topic,
64
- agent="research",
65
- session_id=job.id,
66
- auth=auth,
67
- params=RequestParams(
68
- tool_execution_handler=JobProgressHandler(job),
69
- emit_loop_progress=True,
70
- ),
71
- metadata={"job_id": job.id},
72
- )
73
- )
74
- finally:
75
- current_research_job.reset(job_token)
76
- current_activity_narrator.reset(token)
77
- await narrator.close()
78
  return response.text_content()
79
 
80
  async def run(
@@ -86,26 +62,39 @@ class ResearchRunner:
86
  try:
87
  job.result = await self.invoke(job, auth)
88
  job.status = "finalizing"
89
- job.add_event("Research complete; exporting session trace")
 
90
  await try_export_trace(job, self.home)
91
  job.status = "completed"
92
- job.phase = "completed"
93
  job.add_event("Research completed")
94
- except asyncio.CancelledError:
95
- job.result = None
96
- job.error = None
97
- job.status = "cancelled"
98
- job.phase = "cancelled"
99
- job.set_activity_summary(
100
- "Research was cancelled. Partial notes and the session trace were kept."
101
- )
102
- job.add_event("Research cancelled")
103
- await try_export_trace(job, self.home)
104
- raise
105
  except Exception as exc:
106
  job.error = str(exc)
107
  job.add_event(f"Research failed: {exc}", kind="error")
108
  await try_export_trace(job, self.home)
109
  job.status = "failed"
110
- job.phase = "failed"
111
  job.add_event("Research job closed after failure", kind="error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  from fast_agent import AgentAuth, AgentRequest, AppOpenRequest
10
  from fast_agent.llm.request_params import RequestParams
11
 
12
+ from .app_artifacts import finalize_bucket_html
13
+ from .app_jobs import ResearchJob
 
14
  from .app_observability import JobProgressHandler, try_export_trace
15
 
16
  if TYPE_CHECKING:
 
30
  auth: AgentAuth | None,
31
  ) -> str:
32
  """The essential Harness API flow used by this example."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  with self.harness.request_context(auth=auth):
34
+ async with self.harness.app().open(
35
+ AppOpenRequest(
36
+ session_id=job.id,
37
+ agent="research",
38
+ metadata={"job_id": job.id},
39
+ )
40
+ ) as session:
41
+ response = await session.invoke(
42
+ AgentRequest.text(
43
+ job.topic,
44
  agent="research",
45
+ session_id=job.id,
46
+ auth=auth,
47
+ params=RequestParams(
48
+ tool_execution_handler=JobProgressHandler(job),
49
+ emit_loop_progress=True,
50
+ ),
51
  metadata={"job_id": job.id},
52
  )
53
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  return response.text_content()
55
 
56
  async def run(
 
62
  try:
63
  job.result = await self.invoke(job, auth)
64
  job.status = "finalizing"
65
+ job.add_event("Research complete; validating output artifacts")
66
+ await self._finalize_artifacts(job, auth)
67
  await try_export_trace(job, self.home)
68
  job.status = "completed"
 
69
  job.add_event("Research completed")
 
 
 
 
 
 
 
 
 
 
 
70
  except Exception as exc:
71
  job.error = str(exc)
72
  job.add_event(f"Research failed: {exc}", kind="error")
73
  await try_export_trace(job, self.home)
74
  job.status = "failed"
 
75
  job.add_event("Research job closed after failure", kind="error")
76
+
77
+ async def _finalize_artifacts(
78
+ self,
79
+ job: ResearchJob,
80
+ auth: AgentAuth | None,
81
+ ) -> None:
82
+ try:
83
+ urls = await asyncio.to_thread(
84
+ finalize_bucket_html,
85
+ job,
86
+ auth,
87
+ self.home,
88
+ )
89
+ if urls and job.result and urls[0] not in job.result:
90
+ job.result += (
91
+ f"\n\n**Final HTML artifact:**\n- `{urls[0]}`\n- {urls[1]}"
92
+ )
93
+ except Exception as exc:
94
+ warning = f"HTML artifact validation failed: {exc}"
95
+ job.add_event(
96
+ warning,
97
+ kind="artifact",
98
+ )
99
+ if job.result:
100
+ job.result += f"\n\n> Warning: {warning}"
research/research_workspace.py CHANGED
@@ -5,7 +5,6 @@ from __future__ import annotations
5
  import json
6
  import os
7
  import re
8
- from contextvars import ContextVar
9
  from dataclasses import dataclass
10
  from datetime import UTC, datetime
11
  from typing import Any, Mapping
@@ -33,12 +32,6 @@ class ResearchWorkspace:
33
  bearer_token: str | None
34
 
35
 
36
- current_research_workspace: ContextVar[ResearchWorkspace | None] = ContextVar(
37
- "current_research_workspace",
38
- default=None,
39
- )
40
-
41
-
42
  def ensure_workspace(
43
  *,
44
  auth: AgentAuth | None,
 
5
  import json
6
  import os
7
  import re
 
8
  from dataclasses import dataclass
9
  from datetime import UTC, datetime
10
  from typing import Any, Mapping
 
32
  bearer_token: str | None
33
 
34
 
 
 
 
 
 
 
35
  def ensure_workspace(
36
  *,
37
  auth: AgentAuth | None,
research/skills/birch-html/scripts/check_birch_renderings.py CHANGED
@@ -802,9 +802,18 @@ def contract_findings(candidate: Path, stats: PageStats, system_defined_vars: se
802
  check("viewport", stats.viewport, "viewport meta present", "missing viewport meta"),
803
  check(
804
  "uses_birch_system_css",
805
- stats.birch_css_embedded or "styles/birch-system.css" in stats.stylesheet_links,
 
 
 
 
806
  "embeds or links Birch system CSS",
807
- f"stylesheet links: {stats.stylesheet_links}; embedded={stats.birch_css_embedded}",
 
 
 
 
 
808
  ),
809
  check(
810
  "has_page_shell",
@@ -1129,7 +1138,7 @@ def capture(browser: str, html: Path, out: Path, *, width: int, height: int, del
1129
 
1130
  def screenshot_metrics(original: Path, candidate: Path) -> dict[str, object] | None:
1131
  try:
1132
- from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageStat
1133
  except Exception:
1134
  return None
1135
  if not original.exists() or not candidate.exists():
 
802
  check("viewport", stats.viewport, "viewport meta present", "missing viewport meta"),
803
  check(
804
  "uses_birch_system_css",
805
+ "__BIRCH_SYSTEM_CSS__" not in html
806
+ and (
807
+ stats.birch_css_embedded
808
+ or "styles/birch-system.css" in stats.stylesheet_links
809
+ ),
810
  "embeds or links Birch system CSS",
811
+ (
812
+ "unreplaced __BIRCH_SYSTEM_CSS__ placeholder"
813
+ if "__BIRCH_SYSTEM_CSS__" in html
814
+ else f"stylesheet links: {stats.stylesheet_links}; "
815
+ f"embedded={stats.birch_css_embedded}"
816
+ ),
817
  ),
818
  check(
819
  "has_page_shell",
 
1138
 
1139
  def screenshot_metrics(original: Path, candidate: Path) -> dict[str, object] | None:
1140
  try:
1141
+ from PIL import Image, ImageChops, ImageEnhance, ImageStat
1142
  except Exception:
1143
  return None
1144
  if not original.exists() or not candidate.exists():