Restore Research Dispatch and activity narrator
Browse files- research/agent-cards/birch-html.md +2 -0
- research/agent-cards/research.md +2 -0
- research/app_auth.py +14 -0
- research/app_jobs.py +179 -5
- research/app_observability.py +265 -18
- research/app_ui.py +559 -165
- research/fast-agent.yaml +4 -2
- research/fastmcp_server.py +22 -11
- research/research_app.py +21 -9
- research/research_runner.py +63 -26
- research/research_workspace.py +7 -0
research/agent-cards/birch-html.md
CHANGED
|
@@ -8,6 +8,8 @@ skills:
|
|
| 8 |
- skills/birch-html
|
| 9 |
use_history: false
|
| 10 |
model: $system.html
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
You are a presentation and HTML artifact specialist.
|
| 13 |
|
|
|
|
| 8 |
- skills/birch-html
|
| 9 |
use_history: false
|
| 10 |
model: $system.html
|
| 11 |
+
tool_hooks:
|
| 12 |
+
after_llm_call: ../activity_hooks.py:capture_after_llm
|
| 13 |
---
|
| 14 |
You are a presentation and HTML artifact specialist.
|
| 15 |
|
research/agent-cards/research.md
CHANGED
|
@@ -8,6 +8,8 @@ agents:
|
|
| 8 |
- birch-html
|
| 9 |
model: $system.research
|
| 10 |
default: true
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
You are a careful research agent for Hugging Face ecosystem research.
|
| 13 |
|
|
|
|
| 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 |
|
research/app_auth.py
CHANGED
|
@@ -6,6 +6,7 @@ import os
|
|
| 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,3 +42,16 @@ def http_middleware() -> list[Middleware] | None:
|
|
| 41 |
def request_auth() -> AgentAuth | None:
|
| 42 |
"""Translate the current verified MCP token into fast-agent auth."""
|
| 43 |
return HarnessMCPAdapter.agent_auth()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
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 |
+
"""Use local credentials only when inbound Hugging Face OAuth is disabled."""
|
| 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
|
research/app_jobs.py
CHANGED
|
@@ -4,7 +4,8 @@ from __future__ import annotations
|
|
| 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,7 +13,18 @@ from uuid import uuid4
|
|
| 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,13 +53,27 @@ class ResearchJob:
|
|
| 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,6 +94,7 @@ class ResearchJob:
|
|
| 68 |
"total": total,
|
| 69 |
}
|
| 70 |
)
|
|
|
|
| 71 |
del self.events[:-100]
|
| 72 |
|
| 73 |
def snapshot(self, *, now: float | None = None) -> dict[str, Any]:
|
|
@@ -83,23 +110,89 @@ class ResearchJob:
|
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,6 +200,37 @@ class BeginResult:
|
|
| 107 |
started: bool
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
class ResearchJobStore:
|
| 111 |
def __init__(
|
| 112 |
self,
|
|
@@ -138,7 +262,7 @@ class ResearchJobStore:
|
|
| 138 |
created_at=now,
|
| 139 |
updated_at=now,
|
| 140 |
)
|
| 141 |
-
job.add_event("
|
| 142 |
self._jobs[job.id] = job
|
| 143 |
self._enforce_limit()
|
| 144 |
return job
|
|
@@ -153,7 +277,12 @@ class ResearchJobStore:
|
|
| 153 |
if job.status != "queued":
|
| 154 |
return BeginResult(job=job, started=False)
|
| 155 |
job.status = "running"
|
| 156 |
-
job.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
return BeginResult(job=job, started=True)
|
| 158 |
|
| 159 |
async def get(self, job_id: str, owner: str) -> ResearchJob | None:
|
|
@@ -161,6 +290,35 @@ class ResearchJobStore:
|
|
| 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,18 +358,34 @@ def unavailable_snapshot(job_id: str) -> dict[str, Any]:
|
|
| 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 |
}
|
|
|
|
| 4 |
|
| 5 |
import asyncio
|
| 6 |
import hashlib
|
| 7 |
+
from collections.abc import Callable, Coroutine
|
| 8 |
+
from contextvars import ContextVar
|
| 9 |
from dataclasses import dataclass, field
|
| 10 |
from time import time
|
| 11 |
from typing import Any
|
|
|
|
| 13 |
|
| 14 |
from fast_agent import AgentAuth
|
| 15 |
|
| 16 |
+
TERMINAL_STATUSES = {"cancelled", "completed", "failed"}
|
| 17 |
+
CANCELLABLE_STATUSES = {"queued", "running"}
|
| 18 |
+
PHASE_SUMMARIES = {
|
| 19 |
+
"reporting": (
|
| 20 |
+
"The research findings and Markdown report are complete. "
|
| 21 |
+
"The HTML report is now being produced."
|
| 22 |
+
),
|
| 23 |
+
"wrapping_up": (
|
| 24 |
+
"The Markdown and HTML reports are complete. "
|
| 25 |
+
"The agent is preparing the final response."
|
| 26 |
+
),
|
| 27 |
+
}
|
| 28 |
|
| 29 |
|
| 30 |
def owner_id(auth: AgentAuth | None, session_id: str | None) -> str:
|
|
|
|
| 53 |
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 |
"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 |
}
|
| 111 |
for event in self.events
|
| 112 |
]
|
| 113 |
+
summaries = [
|
| 114 |
+
{
|
| 115 |
+
**summary,
|
| 116 |
+
"elapsed": format_elapsed(
|
| 117 |
+
float(summary.get("ts") or self.created_at) - self.created_at
|
| 118 |
+
),
|
| 119 |
+
}
|
| 120 |
+
for summary in self.activity_summaries
|
| 121 |
+
]
|
| 122 |
return {
|
| 123 |
"job_id": self.id,
|
| 124 |
"topic": self.topic,
|
| 125 |
"status": self.status,
|
| 126 |
+
"phase": self.phase,
|
| 127 |
"events": events,
|
| 128 |
"timeline_events": events[-12:],
|
| 129 |
+
"recent_events": events[-2:],
|
| 130 |
+
"activity_roll": list(
|
| 131 |
+
reversed(
|
| 132 |
+
[event for event in events if event["kind"] == "Activity"][-2:]
|
| 133 |
+
)
|
| 134 |
+
),
|
| 135 |
+
"recent_summaries": list(reversed(summaries[:-1][-2:])),
|
| 136 |
+
"event_count": self.event_count_total,
|
| 137 |
"elapsed_seconds": int(max(0, elapsed_seconds)),
|
| 138 |
"elapsed": format_elapsed(elapsed_seconds),
|
| 139 |
"activity_progress": 100 if done else int((elapsed_seconds * 12) % 100),
|
| 140 |
+
"activity_summary": self.activity_summary,
|
| 141 |
+
"activity_summary_revision": self.activity_summary_revision,
|
| 142 |
+
"activity_source": self.activity_source,
|
| 143 |
+
"turn_count": self.turn_count,
|
| 144 |
"result": self.result,
|
| 145 |
+
"markdown_report": self.markdown_report,
|
| 146 |
+
"markdown_report_uri": self.markdown_report_uri,
|
| 147 |
+
"markdown_report_error": self.markdown_report_error,
|
| 148 |
+
"html_report_uri": self.html_report_uri,
|
| 149 |
+
"html_report_url": self.html_report_url,
|
| 150 |
+
"html_report_ready": bool(self.html_report_uri),
|
| 151 |
"error": self.error,
|
| 152 |
"trace_path": self.trace_path,
|
| 153 |
+
"trace_archive_uri": self.trace_archive_uri,
|
| 154 |
"trace_error": self.trace_error,
|
| 155 |
"done": done,
|
| 156 |
+
"cancellable": self.status in CANCELLABLE_STATUSES,
|
| 157 |
}
|
| 158 |
|
| 159 |
+
def set_activity_summary(self, summary: str, *, now: float | None = None) -> None:
|
| 160 |
+
summary = summary.strip()
|
| 161 |
+
if not summary or summary == self.activity_summary:
|
| 162 |
+
return
|
| 163 |
+
self.activity_summary = summary
|
| 164 |
+
self.activity_summary_revision += 1
|
| 165 |
+
self.updated_at = time() if now is None else now
|
| 166 |
+
self.activity_summaries.append(
|
| 167 |
+
{
|
| 168 |
+
"ts": self.updated_at,
|
| 169 |
+
"message": summary,
|
| 170 |
+
}
|
| 171 |
+
)
|
| 172 |
+
del self.activity_summaries[:-10]
|
| 173 |
+
|
| 174 |
+
def record_llm_step(self) -> None:
|
| 175 |
+
self.turn_count += 1
|
| 176 |
+
self.activity_source = "research/agent_loop"
|
| 177 |
+
|
| 178 |
+
def set_activity_source(self, source: str) -> None:
|
| 179 |
+
if source:
|
| 180 |
+
self.activity_source = source
|
| 181 |
+
|
| 182 |
+
def set_phase(self, phase: str) -> None:
|
| 183 |
+
self.phase = phase
|
| 184 |
+
if summary := PHASE_SUMMARIES.get(phase):
|
| 185 |
+
self.set_activity_summary(summary)
|
| 186 |
+
|
| 187 |
+
def narrative_for_phase(self, summary: str) -> str:
|
| 188 |
+
return PHASE_SUMMARIES.get(self.phase, summary)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
current_research_job: ContextVar[ResearchJob | None] = ContextVar(
|
| 192 |
+
"current_research_job",
|
| 193 |
+
default=None,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
|
| 197 |
@dataclass(frozen=True, slots=True)
|
| 198 |
class BeginResult:
|
|
|
|
| 200 |
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 |
created_at=now,
|
| 263 |
updated_at=now,
|
| 264 |
)
|
| 265 |
+
job.add_event("Your research request is ready.", kind="Setup", now=now)
|
| 266 |
self._jobs[job.id] = job
|
| 267 |
self._enforce_limit()
|
| 268 |
return job
|
|
|
|
| 277 |
if job.status != "queued":
|
| 278 |
return BeginResult(job=job, started=False)
|
| 279 |
job.status = "running"
|
| 280 |
+
job.phase = "researching"
|
| 281 |
+
job.add_event(
|
| 282 |
+
"The research agent is getting started.",
|
| 283 |
+
kind="Research",
|
| 284 |
+
now=self._clock(),
|
| 285 |
+
)
|
| 286 |
return BeginResult(job=job, started=True)
|
| 287 |
|
| 288 |
async def get(self, job_id: str, owner: str) -> ResearchJob | None:
|
|
|
|
| 290 |
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 |
"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 |
}
|
research/app_observability.py
CHANGED
|
@@ -3,16 +3,36 @@
|
|
| 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,7 +40,7 @@ class JobProgressHandler(ToolExecutionHandler):
|
|
| 20 |
|
| 21 |
def __init__(self, job: ResearchJob) -> None:
|
| 22 |
self.job = job
|
| 23 |
-
self.
|
| 24 |
|
| 25 |
async def on_tool_start(
|
| 26 |
self,
|
|
@@ -30,9 +50,13 @@ class JobProgressHandler(ToolExecutionHandler):
|
|
| 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 |
-
|
| 34 |
-
self.
|
| 35 |
-
self.job.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return tool_call_id
|
| 37 |
|
| 38 |
async def on_tool_progress(
|
|
@@ -42,13 +66,13 @@ class JobProgressHandler(ToolExecutionHandler):
|
|
| 42 |
total: float | None,
|
| 43 |
message: str | None,
|
| 44 |
) -> None:
|
| 45 |
-
|
| 46 |
-
self.
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
)
|
| 52 |
|
| 53 |
async def on_tool_complete(
|
| 54 |
self,
|
|
@@ -57,9 +81,28 @@ class JobProgressHandler(ToolExecutionHandler):
|
|
| 57 |
content: list[Any] | None,
|
| 58 |
error: str | None,
|
| 59 |
) -> None:
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
async def on_tool_permission_denied(
|
| 65 |
self,
|
|
@@ -68,16 +111,18 @@ class JobProgressHandler(ToolExecutionHandler):
|
|
| 68 |
tool_use_id: str | None,
|
| 69 |
error: str | None = None,
|
| 70 |
) -> None:
|
|
|
|
|
|
|
| 71 |
self.job.add_event(
|
| 72 |
-
|
| 73 |
-
kind="
|
| 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.
|
| 81 |
|
| 82 |
async def ensure_tool_call_exists(
|
| 83 |
self,
|
|
@@ -86,7 +131,7 @@ class JobProgressHandler(ToolExecutionHandler):
|
|
| 86 |
server_name: str,
|
| 87 |
arguments: dict | None = None,
|
| 88 |
) -> str:
|
| 89 |
-
if tool_use_id in self.
|
| 90 |
return tool_use_id
|
| 91 |
return await self.on_tool_start(
|
| 92 |
tool_name,
|
|
@@ -96,6 +141,114 @@ class JobProgressHandler(ToolExecutionHandler):
|
|
| 96 |
)
|
| 97 |
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
def export_trace(job: ResearchJob, home: Path) -> None:
|
| 100 |
output_path = (
|
| 101 |
home
|
|
@@ -121,6 +274,100 @@ def export_trace(job: ResearchJob, home: Path) -> None:
|
|
| 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:
|
|
|
|
| 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 |
|
| 41 |
def __init__(self, job: ResearchJob) -> None:
|
| 42 |
self.job = job
|
| 43 |
+
self._activities: dict[str, tuple[str, str, str, str]] = {}
|
| 44 |
|
| 45 |
async def on_tool_start(
|
| 46 |
self,
|
|
|
|
| 50 |
tool_use_id: str | None = None,
|
| 51 |
) -> str:
|
| 52 |
tool_call_id = tool_use_id or f"{server_name}/{tool_name}/{uuid4().hex[:8]}"
|
| 53 |
+
activity = _tool_activity(server_name, tool_name, arguments)
|
| 54 |
+
self._activities[tool_call_id] = activity
|
| 55 |
+
self.job.set_activity_source(activity[0])
|
| 56 |
+
self.job.add_event(f"{activity[0]}: started", kind="Activity")
|
| 57 |
+
if _is_birch_delegation(server_name, tool_name):
|
| 58 |
+
self.job.set_phase("reporting")
|
| 59 |
+
await capture_markdown_report(self.job)
|
| 60 |
return tool_call_id
|
| 61 |
|
| 62 |
async def on_tool_progress(
|
|
|
|
| 66 |
total: float | None,
|
| 67 |
message: str | None,
|
| 68 |
) -> None:
|
| 69 |
+
del progress, total
|
| 70 |
+
source = self._activities.get(
|
| 71 |
+
tool_call_id,
|
| 72 |
+
("research/agent_loop", "Research agent", "Research", ""),
|
| 73 |
+
)[0]
|
| 74 |
+
self.job.set_activity_source(source)
|
| 75 |
+
self.job.add_event(f"{source}: {message or 'working'}", kind="Activity")
|
| 76 |
|
| 77 |
async def on_tool_complete(
|
| 78 |
self,
|
|
|
|
| 81 |
content: list[Any] | None,
|
| 82 |
error: str | None,
|
| 83 |
) -> None:
|
| 84 |
+
raw_source, source, category, completed = self._activities.pop(
|
| 85 |
+
tool_call_id,
|
| 86 |
+
(
|
| 87 |
+
"research/agent_loop",
|
| 88 |
+
"Research agent",
|
| 89 |
+
"Research",
|
| 90 |
+
"A research step finished.",
|
| 91 |
+
),
|
| 92 |
+
)
|
| 93 |
+
message = completed if success else _friendly_tool_error(source, error)
|
| 94 |
+
self.job.add_event(
|
| 95 |
+
f"{raw_source}: completed" if success else message,
|
| 96 |
+
kind="Activity",
|
| 97 |
+
)
|
| 98 |
+
self.job.set_activity_source(
|
| 99 |
+
next(
|
| 100 |
+
(activity[0] for activity in reversed(self._activities.values())),
|
| 101 |
+
"research/agent_loop",
|
| 102 |
+
)
|
| 103 |
+
)
|
| 104 |
+
if _is_birch_delegation(*raw_source.split("/", 1)):
|
| 105 |
+
self.job.set_phase("wrapping_up" if success else "researching")
|
| 106 |
|
| 107 |
async def on_tool_permission_denied(
|
| 108 |
self,
|
|
|
|
| 111 |
tool_use_id: str | None,
|
| 112 |
error: str | None = None,
|
| 113 |
) -> None:
|
| 114 |
+
raw_source, source, _, _ = _tool_activity(server_name, tool_name, None)
|
| 115 |
+
self.job.set_activity_source(raw_source)
|
| 116 |
self.job.add_event(
|
| 117 |
+
_friendly_tool_error(source, error or "Permission was denied."),
|
| 118 |
+
kind="Activity",
|
| 119 |
)
|
| 120 |
|
| 121 |
async def get_tool_call_id_for_tool_use(
|
| 122 |
self,
|
| 123 |
tool_use_id: str,
|
| 124 |
) -> str | None:
|
| 125 |
+
return tool_use_id if tool_use_id in self._activities else None
|
| 126 |
|
| 127 |
async def ensure_tool_call_exists(
|
| 128 |
self,
|
|
|
|
| 131 |
server_name: str,
|
| 132 |
arguments: dict | None = None,
|
| 133 |
) -> str:
|
| 134 |
+
if tool_use_id in self._activities:
|
| 135 |
return tool_use_id
|
| 136 |
return await self.on_tool_start(
|
| 137 |
tool_name,
|
|
|
|
| 141 |
)
|
| 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 |
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:
|
research/app_ui.py
CHANGED
|
@@ -10,206 +10,600 @@ from prefab_ui.app import PrefabApp
|
|
| 10 |
from prefab_ui.components import (
|
| 11 |
Badge,
|
| 12 |
Button,
|
| 13 |
-
Card,
|
| 14 |
-
CardContent,
|
| 15 |
-
CardDescription,
|
| 16 |
-
CardHeader,
|
| 17 |
-
CardTitle,
|
| 18 |
-
Code,
|
| 19 |
Column,
|
| 20 |
-
|
| 21 |
-
Grid,
|
| 22 |
Heading,
|
| 23 |
If,
|
| 24 |
-
Loader,
|
| 25 |
Markdown,
|
| 26 |
-
Metric,
|
| 27 |
-
Muted,
|
| 28 |
-
Progress,
|
| 29 |
Row,
|
| 30 |
-
|
| 31 |
Text,
|
| 32 |
)
|
| 33 |
-
from prefab_ui.components.control_flow import
|
| 34 |
from prefab_ui.rx import RESULT, STATE
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
def build_research_ui(
|
| 38 |
topic: str,
|
| 39 |
snapshot: dict[str, Any],
|
| 40 |
*,
|
| 41 |
build_id: str,
|
|
|
|
| 42 |
) -> PrefabApp:
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
"start_research",
|
| 59 |
arguments={"job_id": STATE.job_id},
|
| 60 |
on_success=[
|
| 61 |
SetState("job", RESULT),
|
| 62 |
SetState("poll_ms", RESULT.done.then("86400000", "1500")),
|
| 63 |
],
|
| 64 |
),
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
),
|
| 76 |
],
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
with If("{{ !job.done }}"):
|
| 87 |
-
Loader(variant="dots", size="sm")
|
| 88 |
-
Badge(STATE.job.status, variant="info")
|
| 89 |
-
|
| 90 |
-
with Grid(columns={"default": 1, "lg": 3}, gap=4):
|
| 91 |
-
with Card(css_class="lg:col-span-2"):
|
| 92 |
-
with CardHeader():
|
| 93 |
-
CardTitle("Timeline")
|
| 94 |
-
CardDescription(
|
| 95 |
-
"Latest 12 events; older events roll off the visible list."
|
| 96 |
-
)
|
| 97 |
-
with CardContent():
|
| 98 |
-
with Column(gap=2):
|
| 99 |
-
with ForEach("job.timeline_events") as event:
|
| 100 |
-
with Row(
|
| 101 |
-
gap=3,
|
| 102 |
-
align="start",
|
| 103 |
-
css_class=(
|
| 104 |
-
"rounded-md border border-border/60 "
|
| 105 |
-
"bg-background px-3 py-2"
|
| 106 |
-
),
|
| 107 |
-
):
|
| 108 |
-
Dot(variant="info", size="sm", css_class="mt-1")
|
| 109 |
-
Small(
|
| 110 |
-
event.elapsed,
|
| 111 |
-
code=True,
|
| 112 |
-
css_class="min-w-12 text-muted-foreground",
|
| 113 |
-
)
|
| 114 |
-
Badge(event.kind, variant="outline")
|
| 115 |
-
Text(
|
| 116 |
-
event.message,
|
| 117 |
-
css_class="text-sm leading-5",
|
| 118 |
-
)
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
)
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
Text(
|
| 162 |
-
|
| 163 |
-
css_class="
|
| 164 |
)
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
with
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
Markdown(
|
| 180 |
-
STATE.job.
|
| 181 |
-
css_class="
|
| 182 |
)
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
on_error=ShowToast(
|
| 204 |
-
"
|
| 205 |
variant="error",
|
| 206 |
),
|
| 207 |
),
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
"
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
return ui
|
|
|
|
| 10 |
from prefab_ui.components import (
|
| 11 |
Badge,
|
| 12 |
Button,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
Column,
|
| 14 |
+
Div,
|
|
|
|
| 15 |
Heading,
|
| 16 |
If,
|
|
|
|
| 17 |
Markdown,
|
|
|
|
|
|
|
|
|
|
| 18 |
Row,
|
| 19 |
+
Separator,
|
| 20 |
Text,
|
| 21 |
)
|
| 22 |
+
from prefab_ui.components.control_flow import ForEach
|
| 23 |
from prefab_ui.rx import RESULT, STATE
|
| 24 |
|
| 25 |
+
BROADSHEET_CSS = """
|
| 26 |
+
.dispatch-app {
|
| 27 |
+
min-height: 100%;
|
| 28 |
+
padding: 24px;
|
| 29 |
+
background: var(--muted);
|
| 30 |
+
color: var(--foreground);
|
| 31 |
+
}
|
| 32 |
+
.dispatch-sheet {
|
| 33 |
+
width: min(100%, 800px);
|
| 34 |
+
min-height: 760px;
|
| 35 |
+
margin: 0 auto;
|
| 36 |
+
overflow: hidden;
|
| 37 |
+
display: flex;
|
| 38 |
+
flex-direction: column;
|
| 39 |
+
border: 1px solid var(--border);
|
| 40 |
+
border-radius: var(--radius);
|
| 41 |
+
background: var(--background);
|
| 42 |
+
box-shadow: 0 12px 36px color-mix(in oklab, var(--foreground) 10%, transparent);
|
| 43 |
+
}
|
| 44 |
+
.dispatch-header {
|
| 45 |
+
padding: 30px 36px 0;
|
| 46 |
+
}
|
| 47 |
+
.dispatch-kicker,
|
| 48 |
+
.dispatch-section-label,
|
| 49 |
+
.dispatch-time,
|
| 50 |
+
.dispatch-source,
|
| 51 |
+
.dispatch-meta,
|
| 52 |
+
.dispatch-trace {
|
| 53 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 54 |
+
}
|
| 55 |
+
.dispatch-kicker {
|
| 56 |
+
color: color-mix(in oklab, var(--foreground) 58%, var(--warning));
|
| 57 |
+
font-size: 11px;
|
| 58 |
+
letter-spacing: .16em;
|
| 59 |
+
text-transform: uppercase;
|
| 60 |
+
}
|
| 61 |
+
.dispatch-controls {
|
| 62 |
+
flex-wrap: wrap;
|
| 63 |
+
justify-content: flex-end;
|
| 64 |
+
}
|
| 65 |
+
.dispatch-confirm {
|
| 66 |
+
color: var(--muted-foreground);
|
| 67 |
+
font-size: 12px;
|
| 68 |
+
}
|
| 69 |
+
.dispatch-section-label {
|
| 70 |
+
color: var(--muted-foreground);
|
| 71 |
+
font-size: 10px;
|
| 72 |
+
letter-spacing: .15em;
|
| 73 |
+
text-transform: uppercase;
|
| 74 |
+
}
|
| 75 |
+
.dispatch-query {
|
| 76 |
+
max-width: 28ch;
|
| 77 |
+
margin-top: 8px;
|
| 78 |
+
font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
|
| 79 |
+
font-size: clamp(22px, 3.5vw, 34px);
|
| 80 |
+
font-weight: 500;
|
| 81 |
+
line-height: 1.08;
|
| 82 |
+
letter-spacing: -.025em;
|
| 83 |
+
text-wrap: balance;
|
| 84 |
+
}
|
| 85 |
+
.dispatch-body {
|
| 86 |
+
flex: 1;
|
| 87 |
+
min-height: 0;
|
| 88 |
+
overflow-y: auto;
|
| 89 |
+
padding: 28px 36px 32px;
|
| 90 |
+
}
|
| 91 |
+
.dispatch-current-meta {
|
| 92 |
+
gap: 14px;
|
| 93 |
+
align-items: baseline;
|
| 94 |
+
}
|
| 95 |
+
.dispatch-run-stats {
|
| 96 |
+
gap: 12px;
|
| 97 |
+
align-items: center;
|
| 98 |
+
flex-wrap: wrap;
|
| 99 |
+
color: var(--muted-foreground);
|
| 100 |
+
}
|
| 101 |
+
.dispatch-run-stat {
|
| 102 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 103 |
+
font-size: 13px;
|
| 104 |
+
font-variant-numeric: tabular-nums;
|
| 105 |
+
letter-spacing: .035em;
|
| 106 |
+
text-transform: uppercase;
|
| 107 |
+
}
|
| 108 |
+
.dispatch-activity-roll {
|
| 109 |
+
margin-top: 15px;
|
| 110 |
+
padding: 10px 12px;
|
| 111 |
+
border: 1px solid var(--border);
|
| 112 |
+
border-radius: var(--radius);
|
| 113 |
+
background: color-mix(in oklab, var(--muted) 55%, transparent);
|
| 114 |
+
}
|
| 115 |
+
.dispatch-activity-line {
|
| 116 |
+
gap: 12px;
|
| 117 |
+
min-width: 0;
|
| 118 |
+
align-items: baseline;
|
| 119 |
+
}
|
| 120 |
+
.dispatch-activity-line + .dispatch-activity-line {
|
| 121 |
+
margin-top: 5px;
|
| 122 |
+
}
|
| 123 |
+
.dispatch-activity-message {
|
| 124 |
+
min-width: 0;
|
| 125 |
+
overflow: hidden;
|
| 126 |
+
color: var(--muted-foreground);
|
| 127 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 128 |
+
font-size: 12px;
|
| 129 |
+
text-overflow: ellipsis;
|
| 130 |
+
white-space: nowrap;
|
| 131 |
+
}
|
| 132 |
+
.dispatch-time {
|
| 133 |
+
flex: none;
|
| 134 |
+
color: var(--muted-foreground);
|
| 135 |
+
font-size: 12px;
|
| 136 |
+
font-variant-numeric: tabular-nums;
|
| 137 |
+
}
|
| 138 |
+
.dispatch-source {
|
| 139 |
+
color: color-mix(in oklab, var(--foreground) 62%, var(--warning));
|
| 140 |
+
font-size: 11px;
|
| 141 |
+
letter-spacing: .025em;
|
| 142 |
+
}
|
| 143 |
+
.dispatch-current {
|
| 144 |
+
margin-top: 12px;
|
| 145 |
+
align-items: flex-start;
|
| 146 |
+
gap: 14px;
|
| 147 |
+
}
|
| 148 |
+
.dispatch-current-copy {
|
| 149 |
+
max-width: 62ch;
|
| 150 |
+
font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
|
| 151 |
+
font-size: clamp(18px, 2.8vw, 25px);
|
| 152 |
+
line-height: 1.42;
|
| 153 |
+
letter-spacing: -.012em;
|
| 154 |
+
text-wrap: pretty;
|
| 155 |
+
}
|
| 156 |
+
.dispatch-current-copy > :first-child,
|
| 157 |
+
.dispatch-event-message > :first-child {
|
| 158 |
+
margin-top: 0;
|
| 159 |
+
}
|
| 160 |
+
.dispatch-current-copy > :last-child,
|
| 161 |
+
.dispatch-event-message > :last-child {
|
| 162 |
+
margin-bottom: 0;
|
| 163 |
+
}
|
| 164 |
+
.dispatch-live-dot {
|
| 165 |
+
position: relative;
|
| 166 |
+
width: 11px;
|
| 167 |
+
height: 11px;
|
| 168 |
+
flex: none;
|
| 169 |
+
margin-top: 11px;
|
| 170 |
+
border-radius: 999px;
|
| 171 |
+
background: var(--success);
|
| 172 |
+
}
|
| 173 |
+
.dispatch-live-dot::after {
|
| 174 |
+
content: "";
|
| 175 |
+
position: absolute;
|
| 176 |
+
inset: 0;
|
| 177 |
+
border-radius: inherit;
|
| 178 |
+
background: inherit;
|
| 179 |
+
animation: dispatch-ring 1.6s ease-out infinite;
|
| 180 |
+
}
|
| 181 |
+
.dispatch-rule {
|
| 182 |
+
height: 2px;
|
| 183 |
+
margin-top: 20px;
|
| 184 |
+
overflow: hidden;
|
| 185 |
+
border-radius: 2px;
|
| 186 |
+
background: var(--border);
|
| 187 |
+
}
|
| 188 |
+
.dispatch-rule-running::after {
|
| 189 |
+
content: "";
|
| 190 |
+
display: block;
|
| 191 |
+
width: 40%;
|
| 192 |
+
height: 100%;
|
| 193 |
+
background: var(--warning);
|
| 194 |
+
animation: dispatch-progress 1.6s ease-in-out infinite;
|
| 195 |
+
}
|
| 196 |
+
.dispatch-rule-completed { background: var(--success); }
|
| 197 |
+
.dispatch-rule-failed {
|
| 198 |
+
height: 0;
|
| 199 |
+
border-top: 2px dashed var(--muted-foreground);
|
| 200 |
+
background: transparent;
|
| 201 |
+
}
|
| 202 |
+
.dispatch-rule-cancelled { background: var(--muted-foreground); }
|
| 203 |
+
.dispatch-history {
|
| 204 |
+
margin-top: 24px;
|
| 205 |
+
}
|
| 206 |
+
.dispatch-event {
|
| 207 |
+
gap: 16px;
|
| 208 |
+
padding: 13px 0;
|
| 209 |
+
border-top: 1px solid var(--border);
|
| 210 |
+
opacity: .76;
|
| 211 |
+
}
|
| 212 |
+
.dispatch-event-copy {
|
| 213 |
+
min-width: 0;
|
| 214 |
+
}
|
| 215 |
+
.dispatch-event-message {
|
| 216 |
+
margin-top: 4px;
|
| 217 |
+
font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
|
| 218 |
+
font-size: 14px;
|
| 219 |
+
line-height: 1.45;
|
| 220 |
+
text-wrap: pretty;
|
| 221 |
+
}
|
| 222 |
+
.dispatch-result {
|
| 223 |
+
margin-top: 28px;
|
| 224 |
+
padding: 22px;
|
| 225 |
+
border: 1px solid var(--border);
|
| 226 |
+
border-radius: var(--radius);
|
| 227 |
+
background: var(--card);
|
| 228 |
+
}
|
| 229 |
+
.dispatch-report-markdown {
|
| 230 |
+
font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
|
| 231 |
+
font-size: 16px;
|
| 232 |
+
line-height: 1.62;
|
| 233 |
+
text-wrap: pretty;
|
| 234 |
+
}
|
| 235 |
+
.dispatch-result .dispatch-report-markdown h1 {
|
| 236 |
+
margin: 8px 0 18px;
|
| 237 |
+
font-size: clamp(25px, 4vw, 32px);
|
| 238 |
+
font-weight: 500;
|
| 239 |
+
line-height: 1.12;
|
| 240 |
+
letter-spacing: -.022em;
|
| 241 |
+
text-wrap: balance;
|
| 242 |
+
}
|
| 243 |
+
.dispatch-result .dispatch-report-markdown h2 {
|
| 244 |
+
margin: 30px 0 12px;
|
| 245 |
+
font-size: clamp(21px, 3vw, 25px);
|
| 246 |
+
font-weight: 500;
|
| 247 |
+
line-height: 1.2;
|
| 248 |
+
letter-spacing: -.012em;
|
| 249 |
+
}
|
| 250 |
+
.dispatch-result .dispatch-report-markdown h3 {
|
| 251 |
+
margin: 24px 0 10px;
|
| 252 |
+
font-size: 18px;
|
| 253 |
+
font-weight: 600;
|
| 254 |
+
line-height: 1.3;
|
| 255 |
+
}
|
| 256 |
+
.dispatch-result .dispatch-report-markdown p,
|
| 257 |
+
.dispatch-result .dispatch-report-markdown li {
|
| 258 |
+
line-height: 1.62;
|
| 259 |
+
}
|
| 260 |
+
.dispatch-result .dispatch-report-markdown strong {
|
| 261 |
+
font-weight: 650;
|
| 262 |
+
}
|
| 263 |
+
.dispatch-result .dispatch-report-markdown hr {
|
| 264 |
+
margin: 26px 0;
|
| 265 |
+
border-color: var(--border);
|
| 266 |
+
}
|
| 267 |
+
.dispatch-result .dispatch-report-markdown code,
|
| 268 |
+
.dispatch-result .dispatch-report-markdown pre {
|
| 269 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
| 270 |
+
}
|
| 271 |
+
.dispatch-error {
|
| 272 |
+
margin-top: 20px;
|
| 273 |
+
color: var(--destructive);
|
| 274 |
+
font-size: 13px;
|
| 275 |
+
}
|
| 276 |
+
.dispatch-footer {
|
| 277 |
+
min-height: 44px;
|
| 278 |
+
padding: 10px 36px;
|
| 279 |
+
gap: 12px;
|
| 280 |
+
align-items: center;
|
| 281 |
+
border-top: 1px solid var(--border);
|
| 282 |
+
color: var(--muted-foreground);
|
| 283 |
+
}
|
| 284 |
+
.dispatch-meta {
|
| 285 |
+
flex: none;
|
| 286 |
+
font-size: 11px;
|
| 287 |
+
letter-spacing: .035em;
|
| 288 |
+
font-variant-numeric: tabular-nums;
|
| 289 |
+
text-transform: uppercase;
|
| 290 |
+
}
|
| 291 |
+
.dispatch-trace {
|
| 292 |
+
min-width: 0;
|
| 293 |
+
margin-left: auto;
|
| 294 |
+
overflow: hidden;
|
| 295 |
+
color: var(--muted-foreground);
|
| 296 |
+
font-size: 11px;
|
| 297 |
+
text-overflow: ellipsis;
|
| 298 |
+
white-space: nowrap;
|
| 299 |
+
}
|
| 300 |
+
@keyframes dispatch-progress {
|
| 301 |
+
from { transform: translateX(-110%); }
|
| 302 |
+
to { transform: translateX(250%); }
|
| 303 |
+
}
|
| 304 |
+
@keyframes dispatch-ring {
|
| 305 |
+
from { transform: scale(1); opacity: .45; }
|
| 306 |
+
to { transform: scale(2.5); opacity: 0; }
|
| 307 |
+
}
|
| 308 |
+
@media (max-width: 640px) {
|
| 309 |
+
.dispatch-app { padding: 0; }
|
| 310 |
+
.dispatch-sheet {
|
| 311 |
+
min-height: 680px;
|
| 312 |
+
border-right: 0;
|
| 313 |
+
border-left: 0;
|
| 314 |
+
border-radius: 0;
|
| 315 |
+
box-shadow: none;
|
| 316 |
+
}
|
| 317 |
+
.dispatch-header { padding: 24px 22px 0; }
|
| 318 |
+
.dispatch-body { padding: 24px 22px 28px; }
|
| 319 |
+
.dispatch-footer {
|
| 320 |
+
padding: 14px 22px;
|
| 321 |
+
flex-wrap: wrap;
|
| 322 |
+
}
|
| 323 |
+
.dispatch-trace {
|
| 324 |
+
width: 100%;
|
| 325 |
+
margin-left: 0;
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
@media (prefers-reduced-motion: reduce) {
|
| 329 |
+
.dispatch-live-dot::after,
|
| 330 |
+
.dispatch-rule-running::after {
|
| 331 |
+
animation: none;
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
"""
|
| 335 |
+
|
| 336 |
|
| 337 |
def build_research_ui(
|
| 338 |
topic: str,
|
| 339 |
snapshot: dict[str, Any],
|
| 340 |
*,
|
| 341 |
build_id: str,
|
| 342 |
+
live: bool = True,
|
| 343 |
) -> PrefabApp:
|
| 344 |
+
on_mount = None
|
| 345 |
+
if live:
|
| 346 |
+
on_mount = [
|
| 347 |
+
CallTool(
|
| 348 |
+
"start_research",
|
| 349 |
+
arguments={"job_id": STATE.job_id},
|
| 350 |
+
on_success=[
|
| 351 |
+
SetState("job", RESULT),
|
| 352 |
+
SetState("poll_ms", RESULT.done.then("86400000", "1500")),
|
| 353 |
+
],
|
| 354 |
+
),
|
| 355 |
+
SetInterval(
|
| 356 |
+
duration=STATE.poll_ms,
|
| 357 |
+
on_tick=CallTool(
|
| 358 |
+
"research_status",
|
|
|
|
| 359 |
arguments={"job_id": STATE.job_id},
|
| 360 |
on_success=[
|
| 361 |
SetState("job", RESULT),
|
| 362 |
SetState("poll_ms", RESULT.done.then("86400000", "1500")),
|
| 363 |
],
|
| 364 |
),
|
| 365 |
+
),
|
| 366 |
+
]
|
| 367 |
+
|
| 368 |
+
cancel_action = SetState("confirm_cancel", False)
|
| 369 |
+
if live:
|
| 370 |
+
cancel_action = CallTool(
|
| 371 |
+
"cancel_research",
|
| 372 |
+
arguments={"job_id": STATE.job_id},
|
| 373 |
+
on_success=[
|
| 374 |
+
SetState("job", RESULT),
|
| 375 |
+
SetState("confirm_cancel", False),
|
| 376 |
+
SetState("cancel_requested", False),
|
| 377 |
+
ShowToast(
|
| 378 |
+
"Cancellation requested",
|
| 379 |
+
description="The active research session is being closed.",
|
| 380 |
+
variant="warning",
|
| 381 |
),
|
| 382 |
],
|
| 383 |
+
on_error=[
|
| 384 |
+
SetState("cancel_requested", False),
|
| 385 |
+
ShowToast(
|
| 386 |
+
"Could not cancel research",
|
| 387 |
+
description="The job may already have finished.",
|
| 388 |
+
variant="error",
|
| 389 |
+
),
|
| 390 |
+
],
|
| 391 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
|
| 393 |
+
with PrefabApp(
|
| 394 |
+
title="Research Dispatch",
|
| 395 |
+
css_class="dispatch-app",
|
| 396 |
+
css=[BROADSHEET_CSS],
|
| 397 |
+
state={
|
| 398 |
+
"job": snapshot,
|
| 399 |
+
"topic": topic,
|
| 400 |
+
"job_id": snapshot["job_id"],
|
| 401 |
+
"poll_ms": "1500",
|
| 402 |
+
"confirm_cancel": False,
|
| 403 |
+
"cancel_requested": False,
|
| 404 |
+
"chat_sent": False,
|
| 405 |
+
"app_version": f"build {build_id}",
|
| 406 |
+
},
|
| 407 |
+
) as ui:
|
| 408 |
+
with Div(css_class="dispatch-sheet", on_mount=on_mount):
|
| 409 |
+
with Column(css_class="dispatch-header", gap=0):
|
| 410 |
+
with Row(justify="between", align="start", gap=4):
|
| 411 |
+
Text("Research Dispatch", css_class="dispatch-kicker")
|
| 412 |
+
with Row(css_class="dispatch-controls", gap=2, align="center"):
|
| 413 |
+
Badge(STATE.app_version, variant="outline")
|
| 414 |
+
with If(
|
| 415 |
+
(STATE.job.status == "queued")
|
| 416 |
+
| (
|
| 417 |
+
(STATE.job.status == "running")
|
| 418 |
+
& (STATE.job.phase != "reporting")
|
| 419 |
+
& (STATE.job.phase != "wrapping_up")
|
| 420 |
+
)
|
| 421 |
+
):
|
| 422 |
+
Badge("Working", variant="warning")
|
| 423 |
+
with If(
|
| 424 |
+
(STATE.job.status == "running")
|
| 425 |
+
& (STATE.job.phase == "reporting")
|
| 426 |
+
):
|
| 427 |
+
Badge("Building report", variant="info")
|
| 428 |
+
with If(
|
| 429 |
+
(STATE.job.status == "running")
|
| 430 |
+
& (STATE.job.phase == "wrapping_up")
|
| 431 |
+
):
|
| 432 |
+
Badge("Wrapping up", variant="warning")
|
| 433 |
+
with If(STATE.job.status == "finalizing"):
|
| 434 |
+
Badge("Finalizing", variant="warning")
|
| 435 |
+
with If(STATE.job.status == "completed"):
|
| 436 |
+
Badge("Complete", variant="success")
|
| 437 |
+
with If(STATE.job.status == "failed"):
|
| 438 |
+
Badge("Failed", variant="outline")
|
| 439 |
+
with If(STATE.job.status == "cancelled"):
|
| 440 |
+
Badge("Cancelled", variant="secondary")
|
| 441 |
+
with If(STATE.job.status == "cancelling"):
|
| 442 |
+
Badge("Cancelling", variant="warning")
|
| 443 |
+
with If(STATE.job.status == "expired"):
|
| 444 |
+
Badge("Unavailable", variant="secondary")
|
| 445 |
+
with If(STATE.job.cancellable & ~STATE.confirm_cancel):
|
| 446 |
+
Button(
|
| 447 |
+
"Cancel",
|
| 448 |
+
variant="ghost",
|
| 449 |
+
size="xs",
|
| 450 |
+
onClick=SetState("confirm_cancel", True),
|
| 451 |
+
)
|
| 452 |
+
with If(STATE.job.cancellable & STATE.confirm_cancel):
|
| 453 |
+
Text("Cancel research?", css_class="dispatch-confirm")
|
| 454 |
+
Button(
|
| 455 |
+
"Keep running",
|
| 456 |
+
variant="ghost",
|
| 457 |
+
size="xs",
|
| 458 |
+
onClick=SetState("confirm_cancel", False),
|
| 459 |
+
)
|
| 460 |
+
Button(
|
| 461 |
+
"Confirm",
|
| 462 |
+
variant="destructive",
|
| 463 |
+
size="xs",
|
| 464 |
+
disabled=STATE.cancel_requested,
|
| 465 |
+
onClick=[
|
| 466 |
+
SetState("cancel_requested", True),
|
| 467 |
+
cancel_action,
|
| 468 |
+
],
|
| 469 |
)
|
| 470 |
|
| 471 |
+
Separator(spacing=4)
|
| 472 |
+
Text("Query", css_class="dispatch-section-label")
|
| 473 |
+
Heading(STATE.topic, level=1, css_class="dispatch-query")
|
| 474 |
+
|
| 475 |
+
with Div(css_class="dispatch-body"):
|
| 476 |
+
with Row(css_class="dispatch-run-stats"):
|
| 477 |
+
Text(
|
| 478 |
+
"{{ 'Runtime ' + job.elapsed }}",
|
| 479 |
+
css_class="dispatch-run-stat",
|
| 480 |
+
)
|
| 481 |
+
Text("·", css_class="dispatch-run-stat")
|
| 482 |
+
Text(
|
| 483 |
+
"{{ job.event_count + ' events' }}",
|
| 484 |
+
css_class="dispatch-run-stat",
|
| 485 |
+
)
|
| 486 |
+
Text("·", css_class="dispatch-run-stat")
|
| 487 |
+
Text(
|
| 488 |
+
"{{ job.turn_count + ' agent turns' }}",
|
| 489 |
+
css_class="dispatch-run-stat",
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
with If("{{ job.activity_roll.length > 0 }}"):
|
| 493 |
+
with Div(css_class="dispatch-activity-roll"):
|
| 494 |
+
with ForEach("job.activity_roll") as event:
|
| 495 |
+
with Row(css_class="dispatch-activity-line"):
|
| 496 |
+
Text(event.elapsed, css_class="dispatch-time")
|
| 497 |
Text(
|
| 498 |
+
event.message,
|
| 499 |
+
css_class="dispatch-activity-message",
|
| 500 |
)
|
| 501 |
|
| 502 |
+
with Row(css_class="dispatch-current"):
|
| 503 |
+
with If("{{ !job.done }}"):
|
| 504 |
+
Div(css_class="dispatch-live-dot")
|
| 505 |
+
Markdown(
|
| 506 |
+
STATE.job.activity_summary,
|
| 507 |
+
css_class="dispatch-current-copy",
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
with If("{{ !job.done }}"):
|
| 511 |
+
Div(css_class="dispatch-rule dispatch-rule-running")
|
| 512 |
+
with If(STATE.job.status == "completed"):
|
| 513 |
+
Div(css_class="dispatch-rule dispatch-rule-completed")
|
| 514 |
+
with If(STATE.job.status == "failed"):
|
| 515 |
+
Div(css_class="dispatch-rule dispatch-rule-failed")
|
| 516 |
+
with If(
|
| 517 |
+
(STATE.job.status == "cancelled") | (STATE.job.status == "expired")
|
| 518 |
+
):
|
| 519 |
+
Div(css_class="dispatch-rule dispatch-rule-cancelled")
|
| 520 |
+
|
| 521 |
+
with If(STATE.job.markdown_report):
|
| 522 |
+
with Div(css_class="dispatch-result"):
|
| 523 |
+
with If(STATE.job.html_report_ready):
|
| 524 |
+
with Row(justify="between", align="center", gap=3):
|
| 525 |
+
Text(
|
| 526 |
+
"Markdown report",
|
| 527 |
+
css_class="dispatch-section-label",
|
| 528 |
+
)
|
| 529 |
+
Badge("HTML report produced", variant="success")
|
| 530 |
+
with If(
|
| 531 |
+
(STATE.job.phase == "reporting")
|
| 532 |
+
& ~STATE.job.html_report_ready
|
| 533 |
+
):
|
| 534 |
+
Text(
|
| 535 |
+
"Markdown report · HTML version in progress",
|
| 536 |
+
css_class="dispatch-section-label",
|
| 537 |
+
)
|
| 538 |
+
with If(
|
| 539 |
+
(STATE.job.phase != "reporting")
|
| 540 |
+
& (STATE.job.phase != "wrapping_up")
|
| 541 |
+
& ~STATE.job.html_report_ready
|
| 542 |
+
):
|
| 543 |
+
Text(
|
| 544 |
+
"Markdown report",
|
| 545 |
+
css_class="dispatch-section-label",
|
| 546 |
+
)
|
| 547 |
Markdown(
|
| 548 |
+
STATE.job.markdown_report,
|
| 549 |
+
css_class="dispatch-report-markdown",
|
| 550 |
)
|
| 551 |
+
|
| 552 |
+
with If("{{ job.recent_summaries.length > 0 }}"):
|
| 553 |
+
with Div(css_class="dispatch-history"):
|
| 554 |
+
Text("Earlier updates", css_class="dispatch-section-label")
|
| 555 |
+
with ForEach("job.recent_summaries") as event:
|
| 556 |
+
with Row(css_class="dispatch-event", align="start"):
|
| 557 |
+
Text(event.elapsed, css_class="dispatch-time")
|
| 558 |
+
with Column(css_class="dispatch-event-copy", gap=0):
|
| 559 |
+
Markdown(
|
| 560 |
+
event.message,
|
| 561 |
+
css_class="dispatch-event-message",
|
| 562 |
+
)
|
| 563 |
+
|
| 564 |
+
with If(STATE.job.error):
|
| 565 |
+
Text(STATE.job.error, css_class="dispatch-error")
|
| 566 |
+
|
| 567 |
+
with If(STATE.job.result & ~STATE.job.markdown_report):
|
| 568 |
+
with Div(css_class="dispatch-result"):
|
| 569 |
+
Text("Final response", css_class="dispatch-section-label")
|
| 570 |
+
Markdown(STATE.job.result)
|
| 571 |
+
|
| 572 |
+
with If(
|
| 573 |
+
(STATE.job.status == "completed") & ~STATE.chat_sent
|
| 574 |
+
):
|
| 575 |
+
Button(
|
| 576 |
+
"Continue in chat",
|
| 577 |
+
variant="outline",
|
| 578 |
+
size="sm",
|
| 579 |
+
onClick=CallTool(
|
| 580 |
+
"research_chat_context",
|
| 581 |
+
arguments={"job_id": STATE.job_id},
|
| 582 |
+
on_success=[
|
| 583 |
+
UpdateContext(content=RESULT.markdown),
|
| 584 |
+
SendMessage(
|
| 585 |
+
RESULT.message,
|
| 586 |
+
on_success=SetState("chat_sent", True),
|
| 587 |
on_error=ShowToast(
|
| 588 |
+
"This host could not send the chat message.",
|
| 589 |
variant="error",
|
| 590 |
),
|
| 591 |
),
|
| 592 |
+
],
|
| 593 |
+
on_error=ShowToast(
|
| 594 |
+
"Could not load the Markdown report.",
|
| 595 |
+
variant="error",
|
| 596 |
+
),
|
| 597 |
+
),
|
| 598 |
+
)
|
| 599 |
+
with If(STATE.chat_sent):
|
| 600 |
+
Text(
|
| 601 |
+
"Report added to model context and sent to chat.",
|
| 602 |
+
css_class="dispatch-meta",
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
with If(STATE.job.trace_path):
|
| 606 |
+
with Row(css_class="dispatch-footer"):
|
| 607 |
+
Text(STATE.job.trace_path, css_class="dispatch-trace")
|
| 608 |
|
| 609 |
return ui
|
research/fast-agent.yaml
CHANGED
|
@@ -8,7 +8,7 @@ logger:
|
|
| 8 |
|
| 9 |
model_references:
|
| 10 |
system:
|
| 11 |
-
fast:
|
| 12 |
last_used: codexresponses.gpt-5.5?reasoning=medium
|
| 13 |
research: kimi26
|
| 14 |
html: kimi27
|
|
@@ -18,7 +18,9 @@ mcp:
|
|
| 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:
|
|
|
|
| 8 |
|
| 9 |
model_references:
|
| 10 |
system:
|
| 11 |
+
fast: gpt-oss
|
| 12 |
last_used: codexresponses.gpt-5.5?reasoning=medium
|
| 13 |
research: kimi26
|
| 14 |
html: kimi27
|
|
|
|
| 18 |
hf:
|
| 19 |
description: Hugging Face MCP server
|
| 20 |
transport: http
|
| 21 |
+
url: https://huggingface.co/mcp?bouquet=research
|
| 22 |
+
read_timeout_seconds: 30
|
| 23 |
+
include_instructions: false
|
| 24 |
auth:
|
| 25 |
forward: huggingface
|
| 26 |
hf_docs_only:
|
research/fastmcp_server.py
CHANGED
|
@@ -4,7 +4,6 @@ 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 |
|
|
@@ -16,14 +15,18 @@ from prefab_ui.app import PrefabApp
|
|
| 16 |
|
| 17 |
from .app_auth import auth_provider, http_middleware, request_auth
|
| 18 |
from .app_artifacts import read_bucket_markdown
|
| 19 |
-
from .app_jobs import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
from .app_renderer import app_build_id, install_versioned_renderer
|
| 21 |
from .app_ui import build_research_ui
|
| 22 |
from .research_runner import ResearchRunner
|
| 23 |
|
| 24 |
RESEARCH_HOME = Path(__file__).parent
|
| 25 |
AGENT_CARDS = RESEARCH_HOME / "agent-cards"
|
| 26 |
-
_BACKGROUND_TASKS: set[asyncio.Task[None]] = set()
|
| 27 |
|
| 28 |
|
| 29 |
def parse_args() -> argparse.Namespace:
|
|
@@ -38,12 +41,6 @@ def parse_args() -> argparse.Namespace:
|
|
| 38 |
return parser.parse_args()
|
| 39 |
|
| 40 |
|
| 41 |
-
def run_in_background(work: Coroutine[Any, Any, None]) -> None:
|
| 42 |
-
task = asyncio.create_task(work)
|
| 43 |
-
_BACKGROUND_TASKS.add(task)
|
| 44 |
-
task.add_done_callback(_BACKGROUND_TASKS.discard)
|
| 45 |
-
|
| 46 |
-
|
| 47 |
def register_research_app(
|
| 48 |
app: FastMCPApp,
|
| 49 |
jobs: ResearchJobStore,
|
|
@@ -51,6 +48,7 @@ def register_research_app(
|
|
| 51 |
build_id: str,
|
| 52 |
) -> None:
|
| 53 |
"""Register one UI entry point and its app-only backend tools."""
|
|
|
|
| 54 |
|
| 55 |
@app.ui(
|
| 56 |
name="research",
|
|
@@ -68,7 +66,7 @@ def register_research_app(
|
|
| 68 |
if auth is not None
|
| 69 |
else "local development user"
|
| 70 |
)
|
| 71 |
-
job.add_event(f"
|
| 72 |
return build_research_ui(topic, job.snapshot(), build_id=build_id)
|
| 73 |
|
| 74 |
@app.tool()
|
|
@@ -81,7 +79,7 @@ def register_research_app(
|
|
| 81 |
if result is None:
|
| 82 |
return unavailable_snapshot(job_id)
|
| 83 |
if result.started:
|
| 84 |
-
|
| 85 |
return result.job.snapshot()
|
| 86 |
|
| 87 |
@app.tool()
|
|
@@ -93,6 +91,19 @@ def register_research_app(
|
|
| 93 |
job = await jobs.get(job_id, owner_id(auth, ctx.session_id))
|
| 94 |
return job.snapshot() if job else unavailable_snapshot(job_id)
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
@app.tool()
|
| 97 |
async def research_chat_context(
|
| 98 |
job_id: str,
|
|
|
|
| 4 |
|
| 5 |
import argparse
|
| 6 |
import asyncio
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Annotated, Any
|
| 9 |
|
|
|
|
| 15 |
|
| 16 |
from .app_auth import auth_provider, http_middleware, request_auth
|
| 17 |
from .app_artifacts import read_bucket_markdown
|
| 18 |
+
from .app_jobs import (
|
| 19 |
+
ResearchJobStore,
|
| 20 |
+
ResearchTaskRegistry,
|
| 21 |
+
owner_id,
|
| 22 |
+
unavailable_snapshot,
|
| 23 |
+
)
|
| 24 |
from .app_renderer import app_build_id, install_versioned_renderer
|
| 25 |
from .app_ui import build_research_ui
|
| 26 |
from .research_runner import ResearchRunner
|
| 27 |
|
| 28 |
RESEARCH_HOME = Path(__file__).parent
|
| 29 |
AGENT_CARDS = RESEARCH_HOME / "agent-cards"
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def parse_args() -> argparse.Namespace:
|
|
|
|
| 41 |
return parser.parse_args()
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
def register_research_app(
|
| 45 |
app: FastMCPApp,
|
| 46 |
jobs: ResearchJobStore,
|
|
|
|
| 48 |
build_id: str,
|
| 49 |
) -> None:
|
| 50 |
"""Register one UI entry point and its app-only backend tools."""
|
| 51 |
+
tasks = ResearchTaskRegistry()
|
| 52 |
|
| 53 |
@app.ui(
|
| 54 |
name="research",
|
|
|
|
| 66 |
if auth is not None
|
| 67 |
else "local development user"
|
| 68 |
)
|
| 69 |
+
job.add_event(f"Workspace access confirmed for {identity}.", kind="Setup")
|
| 70 |
return build_research_ui(topic, job.snapshot(), build_id=build_id)
|
| 71 |
|
| 72 |
@app.tool()
|
|
|
|
| 79 |
if result is None:
|
| 80 |
return unavailable_snapshot(job_id)
|
| 81 |
if result.started:
|
| 82 |
+
tasks.start(result.job.id, runner.run(result.job, auth))
|
| 83 |
return result.job.snapshot()
|
| 84 |
|
| 85 |
@app.tool()
|
|
|
|
| 91 |
job = await jobs.get(job_id, owner_id(auth, ctx.session_id))
|
| 92 |
return job.snapshot() if job else unavailable_snapshot(job_id)
|
| 93 |
|
| 94 |
+
@app.tool()
|
| 95 |
+
async def cancel_research(
|
| 96 |
+
job_id: str,
|
| 97 |
+
ctx: MCPContext,
|
| 98 |
+
) -> dict[str, Any]:
|
| 99 |
+
auth = request_auth()
|
| 100 |
+
result = await jobs.cancel(job_id, owner_id(auth, ctx.session_id))
|
| 101 |
+
if result is None:
|
| 102 |
+
return unavailable_snapshot(job_id)
|
| 103 |
+
if result.cancel_task:
|
| 104 |
+
tasks.cancel(job_id)
|
| 105 |
+
return result.job.snapshot()
|
| 106 |
+
|
| 107 |
@app.tool()
|
| 108 |
async def research_chat_context(
|
| 109 |
job_id: str,
|
research/research_app.py
CHANGED
|
@@ -11,9 +11,17 @@ from fast_agent import AgentRequest, AppOpenRequest, HarnessAppContext
|
|
| 11 |
from mcp.types import TextContent
|
| 12 |
|
| 13 |
try:
|
| 14 |
-
from .research_workspace import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
except ImportError: # loaded as top-level module from the fast-agent home
|
| 16 |
-
from research_workspace import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
if TYPE_CHECKING:
|
| 19 |
from collections.abc import AsyncIterator, Mapping
|
|
@@ -58,16 +66,20 @@ class ResearchHarnessSession:
|
|
| 58 |
open_metadata=self._open_metadata,
|
| 59 |
)
|
| 60 |
forwarded = self._with_bucket_instructions(request, workspace)
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
finally:
|
| 70 |
-
|
| 71 |
|
| 72 |
def _with_bucket_instructions(
|
| 73 |
self, request: AgentRequest, workspace: ResearchWorkspace
|
|
|
|
| 11 |
from mcp.types import TextContent
|
| 12 |
|
| 13 |
try:
|
| 14 |
+
from .research_workspace import (
|
| 15 |
+
ResearchWorkspace,
|
| 16 |
+
current_research_workspace,
|
| 17 |
+
ensure_workspace,
|
| 18 |
+
)
|
| 19 |
except ImportError: # loaded as top-level module from the fast-agent home
|
| 20 |
+
from research.research_workspace import (
|
| 21 |
+
ResearchWorkspace,
|
| 22 |
+
current_research_workspace,
|
| 23 |
+
ensure_workspace,
|
| 24 |
+
)
|
| 25 |
|
| 26 |
if TYPE_CHECKING:
|
| 27 |
from collections.abc import AsyncIterator, Mapping
|
|
|
|
| 66 |
open_metadata=self._open_metadata,
|
| 67 |
)
|
| 68 |
forwarded = self._with_bucket_instructions(request, workspace)
|
| 69 |
+
workspace_token = current_research_workspace.set(workspace)
|
| 70 |
+
try:
|
| 71 |
+
if workspace.bearer_token is None:
|
| 72 |
+
return await self._session.invoke(forwarded)
|
| 73 |
|
| 74 |
+
from fast_agent.mcp.auth.context import request_bearer_token
|
| 75 |
|
| 76 |
+
auth_token = request_bearer_token.set(workspace.bearer_token)
|
| 77 |
+
try:
|
| 78 |
+
return await self._session.invoke(forwarded)
|
| 79 |
+
finally:
|
| 80 |
+
request_bearer_token.reset(auth_token)
|
| 81 |
finally:
|
| 82 |
+
current_research_workspace.reset(workspace_token)
|
| 83 |
|
| 84 |
def _with_bucket_instructions(
|
| 85 |
self, request: AgentRequest, workspace: ResearchWorkspace
|
research/research_runner.py
CHANGED
|
@@ -9,8 +9,10 @@ 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 .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,27 +32,50 @@ class ResearchRunner:
|
|
| 30 |
auth: AgentAuth | None,
|
| 31 |
) -> str:
|
| 32 |
"""The essential Harness API flow used by this example."""
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
)
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
session_id=job.id,
|
| 46 |
-
|
| 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(
|
|
@@ -66,12 +91,25 @@ class ResearchRunner:
|
|
| 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(
|
|
@@ -86,15 +124,14 @@ class ResearchRunner:
|
|
| 86 |
auth,
|
| 87 |
self.home,
|
| 88 |
)
|
| 89 |
-
if urls
|
| 90 |
-
job.
|
| 91 |
-
|
| 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}"
|
|
|
|
| 9 |
from fast_agent import AgentAuth, AgentRequest, AppOpenRequest
|
| 10 |
from fast_agent.llm.request_params import RequestParams
|
| 11 |
|
| 12 |
+
from .activity_narrator import ActivityNarrator, current_activity_narrator
|
| 13 |
+
from .app_auth import effective_agent_auth
|
| 14 |
from .app_artifacts import finalize_bucket_html
|
| 15 |
+
from .app_jobs import ResearchJob, current_research_job
|
| 16 |
from .app_observability import JobProgressHandler, try_export_trace
|
| 17 |
|
| 18 |
if TYPE_CHECKING:
|
|
|
|
| 32 |
auth: AgentAuth | None,
|
| 33 |
) -> str:
|
| 34 |
"""The essential Harness API flow used by this example."""
|
| 35 |
+
auth = effective_agent_auth(auth)
|
| 36 |
+
|
| 37 |
+
async def summarize_activity(prompt: str) -> str:
|
| 38 |
+
response = await self.harness.invoke(
|
| 39 |
+
AgentRequest.text(
|
| 40 |
+
prompt,
|
| 41 |
+
agent="activity-summarizer",
|
| 42 |
+
session_id=f"{job.id}-activity",
|
| 43 |
+
auth=auth,
|
| 44 |
+
metadata={"job_id": job.id, "activity_narrator": True},
|
| 45 |
)
|
| 46 |
+
)
|
| 47 |
+
return response.text_content()
|
| 48 |
+
|
| 49 |
+
narrator = ActivityNarrator(job, summarize_activity)
|
| 50 |
+
with self.harness.request_context(auth=auth):
|
| 51 |
+
await narrator.start()
|
| 52 |
+
token = current_activity_narrator.set(narrator)
|
| 53 |
+
job_token = current_research_job.set(job)
|
| 54 |
+
try:
|
| 55 |
+
async with self.harness.app().open(
|
| 56 |
+
AppOpenRequest(
|
| 57 |
session_id=job.id,
|
| 58 |
+
agent="research",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
metadata={"job_id": job.id},
|
| 60 |
)
|
| 61 |
+
) as session:
|
| 62 |
+
response = await session.invoke(
|
| 63 |
+
AgentRequest.text(
|
| 64 |
+
job.topic,
|
| 65 |
+
agent="research",
|
| 66 |
+
session_id=job.id,
|
| 67 |
+
auth=auth,
|
| 68 |
+
params=RequestParams(
|
| 69 |
+
tool_execution_handler=JobProgressHandler(job),
|
| 70 |
+
emit_loop_progress=True,
|
| 71 |
+
),
|
| 72 |
+
metadata={"job_id": job.id},
|
| 73 |
+
)
|
| 74 |
+
)
|
| 75 |
+
finally:
|
| 76 |
+
current_research_job.reset(job_token)
|
| 77 |
+
current_activity_narrator.reset(token)
|
| 78 |
+
await narrator.close()
|
| 79 |
return response.text_content()
|
| 80 |
|
| 81 |
async def run(
|
|
|
|
| 91 |
await self._finalize_artifacts(job, auth)
|
| 92 |
await try_export_trace(job, self.home)
|
| 93 |
job.status = "completed"
|
| 94 |
+
job.phase = "completed"
|
| 95 |
job.add_event("Research completed")
|
| 96 |
+
except asyncio.CancelledError:
|
| 97 |
+
job.result = None
|
| 98 |
+
job.error = None
|
| 99 |
+
job.status = "cancelled"
|
| 100 |
+
job.phase = "cancelled"
|
| 101 |
+
job.set_activity_summary(
|
| 102 |
+
"Research was cancelled. Partial notes and the session trace were kept."
|
| 103 |
+
)
|
| 104 |
+
job.add_event("Research cancelled")
|
| 105 |
+
await try_export_trace(job, self.home)
|
| 106 |
+
raise
|
| 107 |
except Exception as exc:
|
| 108 |
job.error = str(exc)
|
| 109 |
job.add_event(f"Research failed: {exc}", kind="error")
|
| 110 |
await try_export_trace(job, self.home)
|
| 111 |
job.status = "failed"
|
| 112 |
+
job.phase = "failed"
|
| 113 |
job.add_event("Research job closed after failure", kind="error")
|
| 114 |
|
| 115 |
async def _finalize_artifacts(
|
|
|
|
| 124 |
auth,
|
| 125 |
self.home,
|
| 126 |
)
|
| 127 |
+
if urls:
|
| 128 |
+
job.html_report_uri, job.html_report_url = urls
|
| 129 |
+
if job.result and urls[0] not in job.result:
|
| 130 |
+
job.result += (
|
| 131 |
+
f"\n\n**Final HTML artifact:**\n- `{urls[0]}`\n- {urls[1]}"
|
| 132 |
+
)
|
| 133 |
except Exception as exc:
|
| 134 |
warning = f"HTML artifact validation failed: {exc}"
|
| 135 |
+
job.add_event(warning, kind="artifact")
|
|
|
|
|
|
|
|
|
|
| 136 |
if job.result:
|
| 137 |
job.result += f"\n\n> Warning: {warning}"
|
research/research_workspace.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
| 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,6 +33,12 @@ class ResearchWorkspace:
|
|
| 32 |
bearer_token: str | None
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def ensure_workspace(
|
| 36 |
*,
|
| 37 |
auth: AgentAuth | None,
|
|
|
|
| 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 |
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,
|