Fix Birch HTML finalization and artifact links
Browse filesBundle 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 +1 -1
- README.md +0 -43
- research/agent-cards/birch-html.md +19 -43
- research/agent-cards/research.md +27 -20
- research/app_artifacts.py +129 -0
- research/app_auth.py +0 -14
- research/app_jobs.py +5 -179
- research/app_observability.py +18 -265
- research/app_ui.py +137 -534
- research/fast-agent.yaml +12 -5
- research/fastmcp_server.py +15 -36
- research/research_app.py +10 -27
- research/research_runner.py +46 -57
- research/research_workspace.py +0 -7
- research/skills/birch-html/scripts/check_birch_renderings.py +12 -3
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.
|
| 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 |
-
-
|
| 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 |
-
|
| 20 |
-
|
| 21 |
-
|
| 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 |
-
|
| 43 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 27 |
-
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
The bucket can be attached to a sandbox when needed, if you wish to run code
|
| 34 |
or verify results.
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
to the
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
the
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 = {"
|
| 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 |
-
"
|
| 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("
|
| 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.
|
| 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.
|
| 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 |
-
|
| 54 |
-
self.
|
| 55 |
-
self.job.
|
| 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 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 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 |
-
|
| 85 |
-
|
| 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 |
-
|
| 118 |
-
kind="
|
| 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.
|
| 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.
|
| 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
|
| 8 |
from prefab_ui.actions.mcp import CallTool
|
| 9 |
from prefab_ui.app import PrefabApp
|
| 10 |
from prefab_ui.components import (
|
| 11 |
Badge,
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
Column,
|
| 14 |
-
|
|
|
|
| 15 |
Heading,
|
| 16 |
If,
|
|
|
|
| 17 |
Markdown,
|
|
|
|
|
|
|
|
|
|
| 18 |
Row,
|
| 19 |
-
|
| 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 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 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 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 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 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 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 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 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 |
-
|
| 495 |
-
css_class="
|
| 496 |
)
|
| 497 |
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
css_class="
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
with
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 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.
|
| 545 |
-
css_class="
|
| 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:
|
| 12 |
-
last_used: codexresponses.gpt-5.
|
| 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
|
| 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"
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 70 |
-
|
| 71 |
-
if workspace.bearer_token is None:
|
| 72 |
-
return await self._session.invoke(forwarded)
|
| 73 |
|
| 74 |
-
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
finally:
|
| 80 |
-
request_bearer_token.reset(auth_token)
|
| 81 |
finally:
|
| 82 |
-
|
| 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 |
-
|
| 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 .
|
| 13 |
-
from .
|
| 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 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
| 57 |
agent="research",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
metadata={"job_id": job.id},
|
| 59 |
)
|
| 60 |
-
)
|
| 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;
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 806 |
"embeds or links Birch system CSS",
|
| 807 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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():
|