Spaces:
Paused
Paused
Deploy 928494e archive cache and packaging fix
Browse filesSource commit: 928494e329ae12b309700a3b05a7037f15eb0037
- research/__init__.py +1 -0
- research/app_jobs.py +423 -0
- research/app_observability.py +381 -0
- research/archive_provisioning.py +241 -0
- research/research_workspace.py +221 -0
research/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Research agent fast-agent home support package."""
|
research/app_jobs.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-memory research job lifecycle with bounded, caller-scoped retention."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import hashlib
|
| 7 |
+
from collections.abc import Callable, Coroutine
|
| 8 |
+
from contextvars import ContextVar
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from time import time
|
| 11 |
+
from typing import Any
|
| 12 |
+
from uuid import uuid4
|
| 13 |
+
|
| 14 |
+
from fast_agent import AgentAuth
|
| 15 |
+
|
| 16 |
+
TERMINAL_STATUSES = {"cancelled", "completed", "failed"}
|
| 17 |
+
CANCELLABLE_STATUSES = {"queued", "running"}
|
| 18 |
+
PHASE_SUMMARIES = {
|
| 19 |
+
"reporting": (
|
| 20 |
+
"The research findings and Markdown report are complete. "
|
| 21 |
+
"The HTML report is now being produced."
|
| 22 |
+
),
|
| 23 |
+
"wrapping_up": (
|
| 24 |
+
"The Markdown and HTML reports are complete. "
|
| 25 |
+
"The agent is preparing the final response."
|
| 26 |
+
),
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def owner_id(auth: AgentAuth | None, session_id: str | None) -> str:
|
| 31 |
+
"""Return a non-secret identity suitable for authorizing app backend calls."""
|
| 32 |
+
if auth is not None:
|
| 33 |
+
if auth.subject:
|
| 34 |
+
return f"{auth.provider or 'unknown'}:{auth.subject}"
|
| 35 |
+
if auth.token:
|
| 36 |
+
digest = hashlib.sha256(auth.token.encode()).hexdigest()
|
| 37 |
+
return f"token:{digest}"
|
| 38 |
+
return f"session:{session_id or 'unknown'}"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def format_elapsed(seconds: float) -> str:
|
| 42 |
+
total = max(0, int(seconds))
|
| 43 |
+
minutes, secs = divmod(total, 60)
|
| 44 |
+
hours, minutes = divmod(minutes, 60)
|
| 45 |
+
if hours:
|
| 46 |
+
return f"{hours:d}:{minutes:02d}:{secs:02d}"
|
| 47 |
+
return f"{minutes:02d}:{secs:02d}"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def display_activity_source(source: str | None) -> str:
|
| 51 |
+
return (source or "research/agent_loop").replace("/", " / ")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass(slots=True)
|
| 55 |
+
class ResearchJob:
|
| 56 |
+
id: str
|
| 57 |
+
topic: str
|
| 58 |
+
owner_id: str
|
| 59 |
+
headline: str = "Briefing the researcher"
|
| 60 |
+
workspace_id: str | None = None
|
| 61 |
+
status: str = "queued"
|
| 62 |
+
phase: str = "preparing"
|
| 63 |
+
created_at: float = field(default_factory=time)
|
| 64 |
+
updated_at: float = field(default_factory=time)
|
| 65 |
+
events: list[dict[str, Any]] = field(default_factory=list)
|
| 66 |
+
result: str | None = None
|
| 67 |
+
markdown_report: str | None = None
|
| 68 |
+
markdown_report_uri: str | None = None
|
| 69 |
+
markdown_report_error: str | None = None
|
| 70 |
+
archive_space_url: str | None = None
|
| 71 |
+
archive_app_url: str | None = None
|
| 72 |
+
archive_template_version: str | None = None
|
| 73 |
+
html_report_uri: str | None = None
|
| 74 |
+
html_report_url: str | None = None
|
| 75 |
+
error: str | None = None
|
| 76 |
+
trace_path: str | None = None
|
| 77 |
+
trace_archive_uri: str | None = None
|
| 78 |
+
trace_error: str | None = None
|
| 79 |
+
activity_summary: str = "Briefing the researcher"
|
| 80 |
+
activity_summary_revision: int = 0
|
| 81 |
+
activity_source: str = "research/agent_loop"
|
| 82 |
+
activity_summaries: list[dict[str, Any]] = field(default_factory=list)
|
| 83 |
+
event_count_total: int = 0
|
| 84 |
+
turn_count: int = 0
|
| 85 |
+
birch_finalize_attempts: int = 0
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def artifact_id(self) -> str:
|
| 89 |
+
return self.workspace_id or self.id
|
| 90 |
+
|
| 91 |
+
@property
|
| 92 |
+
def harness_session_id(self) -> str:
|
| 93 |
+
"""Keep model persistence separate from job and artifact identities."""
|
| 94 |
+
return f"{self.id}-research"
|
| 95 |
+
|
| 96 |
+
def add_event(
|
| 97 |
+
self,
|
| 98 |
+
message: str,
|
| 99 |
+
*,
|
| 100 |
+
kind: str = "status",
|
| 101 |
+
progress: float | None = None,
|
| 102 |
+
total: float | None = None,
|
| 103 |
+
now: float | None = None,
|
| 104 |
+
) -> None:
|
| 105 |
+
self.updated_at = time() if now is None else now
|
| 106 |
+
self.events.append(
|
| 107 |
+
{
|
| 108 |
+
"ts": self.updated_at,
|
| 109 |
+
"kind": kind,
|
| 110 |
+
"message": message,
|
| 111 |
+
"progress": progress,
|
| 112 |
+
"total": total,
|
| 113 |
+
}
|
| 114 |
+
)
|
| 115 |
+
self.event_count_total += 1
|
| 116 |
+
del self.events[:-100]
|
| 117 |
+
|
| 118 |
+
def snapshot(self, *, now: float | None = None) -> dict[str, Any]:
|
| 119 |
+
current = time() if now is None else now
|
| 120 |
+
done = self.status in TERMINAL_STATUSES
|
| 121 |
+
elapsed_seconds = (self.updated_at if done else current) - self.created_at
|
| 122 |
+
events = [
|
| 123 |
+
{
|
| 124 |
+
**event,
|
| 125 |
+
"elapsed": format_elapsed(
|
| 126 |
+
float(event.get("ts") or self.created_at) - self.created_at
|
| 127 |
+
),
|
| 128 |
+
}
|
| 129 |
+
for event in self.events
|
| 130 |
+
]
|
| 131 |
+
summaries = [
|
| 132 |
+
{
|
| 133 |
+
**summary,
|
| 134 |
+
"source_label": display_activity_source(summary.get("source")),
|
| 135 |
+
"elapsed": format_elapsed(
|
| 136 |
+
float(summary.get("ts") or self.created_at) - self.created_at
|
| 137 |
+
),
|
| 138 |
+
}
|
| 139 |
+
for summary in self.activity_summaries
|
| 140 |
+
]
|
| 141 |
+
return {
|
| 142 |
+
"job_id": self.id,
|
| 143 |
+
"topic": self.topic,
|
| 144 |
+
"headline": self.headline,
|
| 145 |
+
"workspace_id": self.workspace_id,
|
| 146 |
+
"status": self.status,
|
| 147 |
+
"phase": self.phase,
|
| 148 |
+
"events": events,
|
| 149 |
+
"timeline_events": events[-12:],
|
| 150 |
+
"recent_events": events[-2:],
|
| 151 |
+
"activity_roll": list(
|
| 152 |
+
reversed(
|
| 153 |
+
[event for event in events if event["kind"] == "Activity"][-6:]
|
| 154 |
+
)
|
| 155 |
+
),
|
| 156 |
+
"recent_summaries": list(reversed(summaries[:-1][-2:])),
|
| 157 |
+
"event_count": self.event_count_total,
|
| 158 |
+
"elapsed_seconds": int(max(0, elapsed_seconds)),
|
| 159 |
+
"elapsed": format_elapsed(elapsed_seconds),
|
| 160 |
+
"activity_progress": 100 if done else int((elapsed_seconds * 12) % 100),
|
| 161 |
+
"activity_summary": self.activity_summary,
|
| 162 |
+
"activity_summary_revision": self.activity_summary_revision,
|
| 163 |
+
"activity_source": self.activity_source,
|
| 164 |
+
"activity_source_label": display_activity_source(self.activity_source),
|
| 165 |
+
"turn_count": self.turn_count,
|
| 166 |
+
"result": self.result,
|
| 167 |
+
"markdown_report": self.markdown_report,
|
| 168 |
+
"markdown_report_uri": self.markdown_report_uri,
|
| 169 |
+
"markdown_report_error": self.markdown_report_error,
|
| 170 |
+
"archive_space_url": self.archive_space_url,
|
| 171 |
+
"archive_app_url": self.archive_app_url,
|
| 172 |
+
"archive_template_version": self.archive_template_version,
|
| 173 |
+
"html_report_uri": self.html_report_uri,
|
| 174 |
+
"html_report_url": self.html_report_url,
|
| 175 |
+
"html_report_ready": bool(self.html_report_uri),
|
| 176 |
+
"error": self.error,
|
| 177 |
+
"trace_path": self.trace_path,
|
| 178 |
+
"trace_archive_uri": self.trace_archive_uri,
|
| 179 |
+
"trace_error": self.trace_error,
|
| 180 |
+
"done": done,
|
| 181 |
+
"cancellable": self.status in CANCELLABLE_STATUSES,
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
def set_activity_summary(self, summary: str, *, now: float | None = None) -> None:
|
| 185 |
+
summary = summary.strip()
|
| 186 |
+
if not summary or summary == self.activity_summary:
|
| 187 |
+
return
|
| 188 |
+
self.activity_summary = summary
|
| 189 |
+
self.activity_summary_revision += 1
|
| 190 |
+
self.updated_at = time() if now is None else now
|
| 191 |
+
self.activity_summaries.append(
|
| 192 |
+
{
|
| 193 |
+
"ts": self.updated_at,
|
| 194 |
+
"message": summary,
|
| 195 |
+
"source": self.activity_source,
|
| 196 |
+
}
|
| 197 |
+
)
|
| 198 |
+
del self.activity_summaries[:-10]
|
| 199 |
+
|
| 200 |
+
def record_llm_step(self) -> None:
|
| 201 |
+
self.turn_count += 1
|
| 202 |
+
self.activity_source = "research/agent_loop"
|
| 203 |
+
|
| 204 |
+
def set_activity_source(self, source: str) -> None:
|
| 205 |
+
if source:
|
| 206 |
+
self.activity_source = source
|
| 207 |
+
|
| 208 |
+
def set_phase(self, phase: str) -> None:
|
| 209 |
+
self.phase = phase
|
| 210 |
+
if summary := PHASE_SUMMARIES.get(phase):
|
| 211 |
+
self.set_activity_summary(summary)
|
| 212 |
+
|
| 213 |
+
def narrative_for_phase(self, summary: str) -> str:
|
| 214 |
+
return PHASE_SUMMARIES.get(self.phase, summary)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
current_research_job: ContextVar[ResearchJob | None] = ContextVar(
|
| 218 |
+
"current_research_job",
|
| 219 |
+
default=None,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@dataclass(frozen=True, slots=True)
|
| 224 |
+
class BeginResult:
|
| 225 |
+
job: ResearchJob
|
| 226 |
+
started: bool
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@dataclass(frozen=True, slots=True)
|
| 230 |
+
class CancelResult:
|
| 231 |
+
job: ResearchJob
|
| 232 |
+
cancel_task: bool
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class ResearchTaskRegistry:
|
| 236 |
+
"""Track cancellable work for one server process."""
|
| 237 |
+
|
| 238 |
+
def __init__(self) -> None:
|
| 239 |
+
self._tasks: dict[str, asyncio.Task[None]] = {}
|
| 240 |
+
|
| 241 |
+
def start(self, job_id: str, work: Coroutine[Any, Any, None]) -> None:
|
| 242 |
+
task = asyncio.create_task(work, name=job_id)
|
| 243 |
+
self._tasks[job_id] = task
|
| 244 |
+
task.add_done_callback(
|
| 245 |
+
lambda completed, job_id=job_id: self._discard(job_id, completed)
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
def cancel(self, job_id: str) -> bool:
|
| 249 |
+
task = self._tasks.get(job_id)
|
| 250 |
+
if task is None or task.done():
|
| 251 |
+
return False
|
| 252 |
+
task.cancel()
|
| 253 |
+
return True
|
| 254 |
+
|
| 255 |
+
def _discard(self, job_id: str, task: asyncio.Task[None]) -> None:
|
| 256 |
+
if self._tasks.get(job_id) is task:
|
| 257 |
+
self._tasks.pop(job_id, None)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class ResearchJobStore:
|
| 261 |
+
def __init__(
|
| 262 |
+
self,
|
| 263 |
+
*,
|
| 264 |
+
completed_ttl: float = 24 * 60 * 60,
|
| 265 |
+
queued_ttl: float = 60 * 60,
|
| 266 |
+
max_jobs: int = 500,
|
| 267 |
+
clock: Callable[[], float] = time,
|
| 268 |
+
) -> None:
|
| 269 |
+
self._jobs: dict[str, ResearchJob] = {}
|
| 270 |
+
self._lock = asyncio.Lock()
|
| 271 |
+
self._completed_ttl = completed_ttl
|
| 272 |
+
self._queued_ttl = queued_ttl
|
| 273 |
+
self._max_jobs = max_jobs
|
| 274 |
+
self._clock = clock
|
| 275 |
+
|
| 276 |
+
async def create(
|
| 277 |
+
self,
|
| 278 |
+
topic: str,
|
| 279 |
+
owner: str,
|
| 280 |
+
) -> ResearchJob:
|
| 281 |
+
async with self._lock:
|
| 282 |
+
now = self._clock()
|
| 283 |
+
self._prune(now)
|
| 284 |
+
job = ResearchJob(
|
| 285 |
+
id=f"research-{uuid4().hex[:12]}",
|
| 286 |
+
topic=topic,
|
| 287 |
+
owner_id=owner,
|
| 288 |
+
created_at=now,
|
| 289 |
+
updated_at=now,
|
| 290 |
+
)
|
| 291 |
+
job.add_event("Your research request is ready.", kind="Setup", now=now)
|
| 292 |
+
self._jobs[job.id] = job
|
| 293 |
+
self._enforce_limit()
|
| 294 |
+
return job
|
| 295 |
+
|
| 296 |
+
async def begin(self, job_id: str, owner: str) -> BeginResult | None:
|
| 297 |
+
"""Atomically claim a queued job."""
|
| 298 |
+
async with self._lock:
|
| 299 |
+
self._prune(self._clock())
|
| 300 |
+
job = self._authorized_job(job_id, owner)
|
| 301 |
+
if job is None:
|
| 302 |
+
return None
|
| 303 |
+
if job.status != "queued":
|
| 304 |
+
return BeginResult(job=job, started=False)
|
| 305 |
+
job.status = "running"
|
| 306 |
+
job.phase = "researching"
|
| 307 |
+
job.add_event(
|
| 308 |
+
"The research agent is getting started.",
|
| 309 |
+
kind="Research",
|
| 310 |
+
now=self._clock(),
|
| 311 |
+
)
|
| 312 |
+
return BeginResult(job=job, started=True)
|
| 313 |
+
|
| 314 |
+
async def get(self, job_id: str, owner: str) -> ResearchJob | None:
|
| 315 |
+
async with self._lock:
|
| 316 |
+
self._prune(self._clock())
|
| 317 |
+
return self._authorized_job(job_id, owner)
|
| 318 |
+
|
| 319 |
+
async def cancel(self, job_id: str, owner: str) -> CancelResult | None:
|
| 320 |
+
"""Atomically request cancellation for an authorized job."""
|
| 321 |
+
async with self._lock:
|
| 322 |
+
self._prune(self._clock())
|
| 323 |
+
job = self._authorized_job(job_id, owner)
|
| 324 |
+
if job is None:
|
| 325 |
+
return None
|
| 326 |
+
if job.status == "queued":
|
| 327 |
+
now = self._clock()
|
| 328 |
+
job.status = "cancelled"
|
| 329 |
+
job.phase = "cancelled"
|
| 330 |
+
job.set_activity_summary(
|
| 331 |
+
"Research was cancelled before the agent started.",
|
| 332 |
+
now=now,
|
| 333 |
+
)
|
| 334 |
+
job.add_event("Research cancelled before start", now=now)
|
| 335 |
+
return CancelResult(job=job, cancel_task=False)
|
| 336 |
+
if job.status == "running":
|
| 337 |
+
now = self._clock()
|
| 338 |
+
job.status = "cancelling"
|
| 339 |
+
job.phase = "cancelling"
|
| 340 |
+
job.set_activity_summary(
|
| 341 |
+
"Cancellation requested. Closing the active research session.",
|
| 342 |
+
now=now,
|
| 343 |
+
)
|
| 344 |
+
job.add_event("Cancellation requested", now=now)
|
| 345 |
+
return CancelResult(job=job, cancel_task=True)
|
| 346 |
+
return CancelResult(job=job, cancel_task=False)
|
| 347 |
+
|
| 348 |
+
def _authorized_job(self, job_id: str, owner: str) -> ResearchJob | None:
|
| 349 |
+
job = self._jobs.get(job_id)
|
| 350 |
+
return job if job is not None and job.owner_id == owner else None
|
| 351 |
+
|
| 352 |
+
def _prune(self, now: float) -> None:
|
| 353 |
+
expired = [
|
| 354 |
+
job_id
|
| 355 |
+
for job_id, job in self._jobs.items()
|
| 356 |
+
if (
|
| 357 |
+
job.status in TERMINAL_STATUSES
|
| 358 |
+
and now - job.updated_at >= self._completed_ttl
|
| 359 |
+
)
|
| 360 |
+
or (job.status == "queued" and now - job.updated_at >= self._queued_ttl)
|
| 361 |
+
]
|
| 362 |
+
for job_id in expired:
|
| 363 |
+
self._jobs.pop(job_id)
|
| 364 |
+
|
| 365 |
+
def _enforce_limit(self) -> None:
|
| 366 |
+
excess = len(self._jobs) - self._max_jobs
|
| 367 |
+
if excess <= 0:
|
| 368 |
+
return
|
| 369 |
+
evictable = sorted(
|
| 370 |
+
(
|
| 371 |
+
job
|
| 372 |
+
for job in self._jobs.values()
|
| 373 |
+
if job.status == "queued" or job.status in TERMINAL_STATUSES
|
| 374 |
+
),
|
| 375 |
+
key=lambda job: job.updated_at,
|
| 376 |
+
)
|
| 377 |
+
for job in evictable[:excess]:
|
| 378 |
+
self._jobs.pop(job.id, None)
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def unavailable_snapshot(job_id: str) -> dict[str, Any]:
|
| 382 |
+
"""Safe response for expired, restarted, or unauthorized historical apps."""
|
| 383 |
+
return {
|
| 384 |
+
"job_id": job_id,
|
| 385 |
+
"topic": "",
|
| 386 |
+
"headline": "Research unavailable",
|
| 387 |
+
"workspace_id": None,
|
| 388 |
+
"status": "expired",
|
| 389 |
+
"phase": "expired",
|
| 390 |
+
"events": [],
|
| 391 |
+
"timeline_events": [],
|
| 392 |
+
"recent_events": [],
|
| 393 |
+
"activity_roll": [],
|
| 394 |
+
"recent_summaries": [],
|
| 395 |
+
"event_count": 0,
|
| 396 |
+
"elapsed_seconds": 0,
|
| 397 |
+
"elapsed": "00:00",
|
| 398 |
+
"activity_progress": 100,
|
| 399 |
+
"activity_summary": "This research run is no longer available.",
|
| 400 |
+
"activity_summary_revision": 0,
|
| 401 |
+
"activity_source": "research/agent_loop",
|
| 402 |
+
"activity_source_label": "research / agent_loop",
|
| 403 |
+
"turn_count": 0,
|
| 404 |
+
"result": None,
|
| 405 |
+
"markdown_report": None,
|
| 406 |
+
"markdown_report_uri": None,
|
| 407 |
+
"markdown_report_error": None,
|
| 408 |
+
"archive_space_url": None,
|
| 409 |
+
"archive_app_url": None,
|
| 410 |
+
"archive_template_version": None,
|
| 411 |
+
"html_report_uri": None,
|
| 412 |
+
"html_report_url": None,
|
| 413 |
+
"html_report_ready": False,
|
| 414 |
+
"error": (
|
| 415 |
+
"This research run is no longer available. Historical app views never "
|
| 416 |
+
"start replacement work; ask Claude to run the research tool again."
|
| 417 |
+
),
|
| 418 |
+
"trace_path": None,
|
| 419 |
+
"trace_archive_uri": None,
|
| 420 |
+
"trace_error": None,
|
| 421 |
+
"done": True,
|
| 422 |
+
"cancellable": False,
|
| 423 |
+
}
|
research/app_observability.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optional timeline and trace-export hooks for research jobs."""
|
| 2 |
+
|
| 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):
|
| 39 |
+
"""Project fast-agent tool events into the app's timeline."""
|
| 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,
|
| 47 |
+
tool_name: str,
|
| 48 |
+
server_name: str,
|
| 49 |
+
arguments: dict | None,
|
| 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(
|
| 63 |
+
self,
|
| 64 |
+
tool_call_id: str,
|
| 65 |
+
progress: float,
|
| 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,
|
| 79 |
+
tool_call_id: str,
|
| 80 |
+
success: bool,
|
| 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,
|
| 109 |
+
tool_name: str,
|
| 110 |
+
server_name: str,
|
| 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,
|
| 129 |
+
tool_use_id: str,
|
| 130 |
+
tool_name: str,
|
| 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,
|
| 138 |
+
server_name,
|
| 139 |
+
arguments,
|
| 140 |
+
tool_use_id,
|
| 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.archive_space_url = workspace.archive_space_url
|
| 231 |
+
job.archive_app_url = workspace.archive_app_url
|
| 232 |
+
job.archive_template_version = workspace.archive_installed_version
|
| 233 |
+
job.add_event("The Markdown report is ready to review.", kind="Report")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
async def _read_markdown_report(workspace: ResearchWorkspace) -> str:
|
| 237 |
+
def read() -> str:
|
| 238 |
+
filesystem = HfFileSystem(token=workspace.bearer_token)
|
| 239 |
+
with filesystem.open(f"{workspace.output}report.md", "r") as report:
|
| 240 |
+
return str(report.read())
|
| 241 |
+
|
| 242 |
+
return await asyncio.to_thread(read)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _friendly_tool_error(source: str, error: str | None) -> str:
|
| 246 |
+
detail = (error or "The operation did not complete.").strip()
|
| 247 |
+
if "search requires a positional query or --query" in detail:
|
| 248 |
+
return "A Hugging Face search request was missing its query."
|
| 249 |
+
detail = detail.removeprefix("EINVAL:").strip()
|
| 250 |
+
if len(detail) > 180:
|
| 251 |
+
detail = f"{detail[:177].rstrip()}…"
|
| 252 |
+
return f"{source} encountered a problem: {detail}"
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def export_trace(job: ResearchJob, home: Path) -> None:
|
| 256 |
+
output_path = (
|
| 257 |
+
home
|
| 258 |
+
/ "sessions"
|
| 259 |
+
/ "research-traces"
|
| 260 |
+
/ job.id
|
| 261 |
+
/ f"{job.id}__research__codex.jsonl"
|
| 262 |
+
)
|
| 263 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 264 |
+
exporter = SessionTraceExporter(
|
| 265 |
+
session_manager=SessionManager(home_override=home),
|
| 266 |
+
progress_callback=lambda message: job.add_event(message, kind="trace"),
|
| 267 |
+
)
|
| 268 |
+
result = exporter.export(
|
| 269 |
+
ExportRequest(
|
| 270 |
+
target=job.harness_session_id,
|
| 271 |
+
agent_name="research",
|
| 272 |
+
output_path=output_path,
|
| 273 |
+
)
|
| 274 |
+
)
|
| 275 |
+
job.trace_path = str(result.output_path)
|
| 276 |
+
job.add_event(
|
| 277 |
+
f"Exported Codex trace: {result.output_path} ({result.record_count} records)",
|
| 278 |
+
kind="trace",
|
| 279 |
+
)
|
| 280 |
+
archive = _archive_config()
|
| 281 |
+
if archive is not None:
|
| 282 |
+
target, token = archive
|
| 283 |
+
job.trace_archive_uri = archive_session(
|
| 284 |
+
job,
|
| 285 |
+
home,
|
| 286 |
+
result.output_path,
|
| 287 |
+
target=target,
|
| 288 |
+
token=token,
|
| 289 |
+
)
|
| 290 |
+
job.add_event(
|
| 291 |
+
f"Archived private session: {job.trace_archive_uri}",
|
| 292 |
+
kind="trace",
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _archive_config() -> tuple[ArchiveTarget, str] | None:
|
| 297 |
+
url = os.getenv(ARCHIVE_URL_ENV, "").strip()
|
| 298 |
+
token = os.getenv(ARCHIVE_TOKEN_ENV, "").strip()
|
| 299 |
+
if not url and not token:
|
| 300 |
+
return None
|
| 301 |
+
if not url or not token:
|
| 302 |
+
missing = ARCHIVE_URL_ENV if not url else ARCHIVE_TOKEN_ENV
|
| 303 |
+
raise RuntimeError(f"Private session archive is missing {missing}.")
|
| 304 |
+
return _archive_target(url), token
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _archive_target(url: str) -> ArchiveTarget:
|
| 308 |
+
parsed = urlparse(url)
|
| 309 |
+
parts = [part for part in parsed.path.split("/") if part]
|
| 310 |
+
if parsed.scheme != "hf" or parsed.netloc != "buckets" or len(parts) < 2:
|
| 311 |
+
raise ValueError(
|
| 312 |
+
f"{ARCHIVE_URL_ENV} must be an hf://buckets/<owner>/<bucket> URL."
|
| 313 |
+
)
|
| 314 |
+
bucket_id = f"{parts[0]}/{parts[1]}"
|
| 315 |
+
prefix = "/".join(parts[2:])
|
| 316 |
+
root = f"hf://buckets/{bucket_id}"
|
| 317 |
+
if prefix:
|
| 318 |
+
root = f"{root}/{prefix}"
|
| 319 |
+
return ArchiveTarget(bucket_id=bucket_id, root=root)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def archive_session(
|
| 323 |
+
job: ResearchJob,
|
| 324 |
+
home: Path,
|
| 325 |
+
trace_path: Path,
|
| 326 |
+
*,
|
| 327 |
+
target: ArchiveTarget,
|
| 328 |
+
token: str,
|
| 329 |
+
api: Any | None = None,
|
| 330 |
+
filesystem: Any | None = None,
|
| 331 |
+
) -> str:
|
| 332 |
+
"""Archive one raw session and Codex trace using an app-only credential."""
|
| 333 |
+
api = api or HfApi(token=token)
|
| 334 |
+
filesystem = filesystem or HfFileSystem(token=token)
|
| 335 |
+
try:
|
| 336 |
+
info = api.bucket_info(target.bucket_id, token=token)
|
| 337 |
+
except BucketNotFoundError:
|
| 338 |
+
api.create_bucket(
|
| 339 |
+
target.bucket_id,
|
| 340 |
+
private=True,
|
| 341 |
+
exist_ok=True,
|
| 342 |
+
token=token,
|
| 343 |
+
)
|
| 344 |
+
else:
|
| 345 |
+
if not bool(getattr(info, "private", False)):
|
| 346 |
+
raise RuntimeError(
|
| 347 |
+
f"Refusing to archive sessions to public bucket {target.bucket_id!r}."
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
session_dir = home / "sessions" / job.harness_session_id
|
| 351 |
+
if not session_dir.is_dir():
|
| 352 |
+
raise FileNotFoundError(f"Session directory does not exist: {session_dir}")
|
| 353 |
+
for source in sorted(path for path in session_dir.rglob("*") if path.is_file()):
|
| 354 |
+
relative = source.relative_to(session_dir).as_posix()
|
| 355 |
+
_upload_archive_file(
|
| 356 |
+
filesystem,
|
| 357 |
+
source,
|
| 358 |
+
f"{target.root}/{job.id}/{relative}",
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
trace_uri = (
|
| 362 |
+
f"{target.root}/research-traces/{job.id}/{posixpath.basename(trace_path)}"
|
| 363 |
+
)
|
| 364 |
+
_upload_archive_file(filesystem, trace_path, trace_uri)
|
| 365 |
+
return trace_uri
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def _upload_archive_file(filesystem: Any, source: Path, destination: str) -> None:
|
| 369 |
+
with (
|
| 370 |
+
source.open("rb") as source_file,
|
| 371 |
+
filesystem.open(destination, "wb") as destination_file,
|
| 372 |
+
):
|
| 373 |
+
destination_file.write(source_file.read())
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
async def try_export_trace(job: ResearchJob, home: Path) -> None:
|
| 377 |
+
try:
|
| 378 |
+
await asyncio.to_thread(export_trace, job, home)
|
| 379 |
+
except Exception as exc:
|
| 380 |
+
job.trace_error = str(exc)
|
| 381 |
+
job.add_event(f"Trace export failed: {exc}", kind="trace")
|
research/archive_provisioning.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provision a private, versioned report-browser Space for one user."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Literal
|
| 10 |
+
|
| 11 |
+
from huggingface_hub import HfApi, Volume
|
| 12 |
+
from huggingface_hub.errors import RepositoryNotFoundError
|
| 13 |
+
|
| 14 |
+
ARCHIVE_TEMPLATE_SPACE = os.getenv(
|
| 15 |
+
"RESEARCH_ARCHIVE_TEMPLATE_SPACE",
|
| 16 |
+
"evalstate/research-archive-template",
|
| 17 |
+
)
|
| 18 |
+
ARCHIVE_TEMPLATE_VERSION = "1.2.1"
|
| 19 |
+
ARCHIVE_MARKER_PATH = "archive-template.json"
|
| 20 |
+
ARCHIVE_SPACE_NAME = "research-agent"
|
| 21 |
+
ARCHIVE_MOUNT_PATH = "/research"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ArchiveProvisioningError(RuntimeError):
|
| 25 |
+
"""Archive Space could not be safely provisioned."""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ArchiveSpaceCollisionError(ArchiveProvisioningError):
|
| 29 |
+
"""The desired Space name exists but is not managed by this application."""
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass(frozen=True, slots=True)
|
| 33 |
+
class ArchiveProvisioning:
|
| 34 |
+
space_id: str
|
| 35 |
+
space_url: str
|
| 36 |
+
app_url: str
|
| 37 |
+
template_space: str
|
| 38 |
+
template_version: str
|
| 39 |
+
installed_version: str
|
| 40 |
+
status: Literal["ready", "provisioning", "version_mismatch"]
|
| 41 |
+
created: bool
|
| 42 |
+
volume_updated: bool
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def ensure_archive_space(
|
| 46 |
+
*,
|
| 47 |
+
username: str,
|
| 48 |
+
bucket_id: str,
|
| 49 |
+
token: str | None,
|
| 50 |
+
api: HfApi | None = None,
|
| 51 |
+
template_space: str = ARCHIVE_TEMPLATE_SPACE,
|
| 52 |
+
) -> ArchiveProvisioning:
|
| 53 |
+
"""Create or verify a user's managed archive Space and bucket mount."""
|
| 54 |
+
expected_bucket = f"{username}/research-agent"
|
| 55 |
+
if bucket_id != expected_bucket:
|
| 56 |
+
raise ArchiveProvisioningError(
|
| 57 |
+
f"Refusing to mount unexpected bucket {bucket_id!r}; "
|
| 58 |
+
f"expected {expected_bucket!r}."
|
| 59 |
+
)
|
| 60 |
+
if not token:
|
| 61 |
+
raise ArchiveProvisioningError(
|
| 62 |
+
"A caller token is required to provision the archive Space."
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
api = api or HfApi()
|
| 66 |
+
space_id = f"{username}/{ARCHIVE_SPACE_NAME}"
|
| 67 |
+
desired_volume = Volume(
|
| 68 |
+
type="bucket",
|
| 69 |
+
source=bucket_id,
|
| 70 |
+
mount_path=ARCHIVE_MOUNT_PATH,
|
| 71 |
+
read_only=False,
|
| 72 |
+
)
|
| 73 |
+
created = False
|
| 74 |
+
try:
|
| 75 |
+
info = api.space_info(space_id, token=token)
|
| 76 |
+
except RepositoryNotFoundError:
|
| 77 |
+
template = _read_marker(api, template_space, token)
|
| 78 |
+
_validate_template_marker(template, template_space)
|
| 79 |
+
api.duplicate_repo(
|
| 80 |
+
from_id=template_space,
|
| 81 |
+
to_id=space_id,
|
| 82 |
+
repo_type="space",
|
| 83 |
+
private=True,
|
| 84 |
+
exist_ok=True,
|
| 85 |
+
space_hardware="cpu-basic",
|
| 86 |
+
space_volumes=[desired_volume],
|
| 87 |
+
space_variables=[
|
| 88 |
+
{
|
| 89 |
+
"key": "RESEARCH_ARCHIVE_MANAGED",
|
| 90 |
+
"value": "true",
|
| 91 |
+
"description": "Managed by the Research Agent provisioner.",
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"key": "RESEARCH_ARCHIVE_TEMPLATE_VERSION",
|
| 95 |
+
"value": ARCHIVE_TEMPLATE_VERSION,
|
| 96 |
+
"description": "Installed archive template version.",
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"key": "RESEARCH_ARCHIVE_BUCKET",
|
| 100 |
+
"value": bucket_id,
|
| 101 |
+
"description": "Mounted Research Agent bucket.",
|
| 102 |
+
},
|
| 103 |
+
],
|
| 104 |
+
token=token,
|
| 105 |
+
)
|
| 106 |
+
created = True
|
| 107 |
+
info = api.space_info(space_id, token=token)
|
| 108 |
+
|
| 109 |
+
installed = _read_marker(api, space_id, token)
|
| 110 |
+
_validate_managed_marker(installed, space_id)
|
| 111 |
+
installed_version = str(installed["template_version"])
|
| 112 |
+
|
| 113 |
+
volume_updated = not _has_expected_volume(info, bucket_id)
|
| 114 |
+
if volume_updated:
|
| 115 |
+
api.set_space_volumes(
|
| 116 |
+
space_id,
|
| 117 |
+
[desired_volume],
|
| 118 |
+
token=token,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
_ensure_variable(
|
| 122 |
+
api,
|
| 123 |
+
space_id,
|
| 124 |
+
"RESEARCH_ARCHIVE_TEMPLATE_VERSION",
|
| 125 |
+
installed_version,
|
| 126 |
+
"Installed archive template version.",
|
| 127 |
+
token,
|
| 128 |
+
)
|
| 129 |
+
_ensure_variable(
|
| 130 |
+
api,
|
| 131 |
+
space_id,
|
| 132 |
+
"RESEARCH_ARCHIVE_BUCKET",
|
| 133 |
+
bucket_id,
|
| 134 |
+
"Mounted Research Agent bucket.",
|
| 135 |
+
token,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
status: Literal["ready", "provisioning", "version_mismatch"]
|
| 139 |
+
if installed_version != ARCHIVE_TEMPLATE_VERSION:
|
| 140 |
+
status = "version_mismatch"
|
| 141 |
+
elif created or volume_updated:
|
| 142 |
+
status = "provisioning"
|
| 143 |
+
else:
|
| 144 |
+
status = "ready"
|
| 145 |
+
|
| 146 |
+
return ArchiveProvisioning(
|
| 147 |
+
space_id=space_id,
|
| 148 |
+
space_url=f"https://huggingface.co/spaces/{space_id}",
|
| 149 |
+
app_url=f"https://{username}-{ARCHIVE_SPACE_NAME}.hf.space",
|
| 150 |
+
template_space=template_space,
|
| 151 |
+
template_version=ARCHIVE_TEMPLATE_VERSION,
|
| 152 |
+
installed_version=installed_version,
|
| 153 |
+
status=status,
|
| 154 |
+
created=created,
|
| 155 |
+
volume_updated=volume_updated,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _read_marker(api: HfApi, repo_id: str, token: str) -> dict[str, Any]:
|
| 160 |
+
try:
|
| 161 |
+
path = api.hf_hub_download(
|
| 162 |
+
repo_id,
|
| 163 |
+
ARCHIVE_MARKER_PATH,
|
| 164 |
+
repo_type="space",
|
| 165 |
+
token=token,
|
| 166 |
+
force_download=True,
|
| 167 |
+
)
|
| 168 |
+
marker = json.loads(Path(path).read_text())
|
| 169 |
+
except Exception as exc:
|
| 170 |
+
raise ArchiveProvisioningError(
|
| 171 |
+
f"Space {repo_id!r} has no readable {ARCHIVE_MARKER_PATH}."
|
| 172 |
+
) from exc
|
| 173 |
+
if not isinstance(marker, dict):
|
| 174 |
+
raise ArchiveProvisioningError(
|
| 175 |
+
f"Space {repo_id!r} has an invalid {ARCHIVE_MARKER_PATH}."
|
| 176 |
+
)
|
| 177 |
+
return marker
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _validate_template_marker(marker: dict[str, Any], template_space: str) -> None:
|
| 181 |
+
_validate_managed_marker(marker, template_space)
|
| 182 |
+
version = str(marker.get("template_version", ""))
|
| 183 |
+
if version != ARCHIVE_TEMPLATE_VERSION:
|
| 184 |
+
raise ArchiveProvisioningError(
|
| 185 |
+
f"Template {template_space!r} is version {version!r}; "
|
| 186 |
+
f"the provisioner expects {ARCHIVE_TEMPLATE_VERSION!r}."
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _validate_managed_marker(marker: dict[str, Any], space_id: str) -> None:
|
| 191 |
+
if (
|
| 192 |
+
marker.get("schema_version") != 1
|
| 193 |
+
or marker.get("managed_by") != "research-agent"
|
| 194 |
+
or marker.get("template") != "research-archive"
|
| 195 |
+
or not marker.get("template_version")
|
| 196 |
+
):
|
| 197 |
+
raise ArchiveSpaceCollisionError(
|
| 198 |
+
f"Space {space_id!r} exists but is not a managed Research Archive."
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _has_expected_volume(info: Any, bucket_id: str) -> bool:
|
| 203 |
+
runtime = getattr(info, "runtime", None)
|
| 204 |
+
raw = getattr(runtime, "raw", None)
|
| 205 |
+
volumes = raw.get("volumes", []) if isinstance(raw, dict) else []
|
| 206 |
+
return any(
|
| 207 |
+
volume.get("type") == "bucket"
|
| 208 |
+
and volume.get("source") == bucket_id
|
| 209 |
+
and volume.get("mountPath") == ARCHIVE_MOUNT_PATH
|
| 210 |
+
and not volume.get("readOnly", False)
|
| 211 |
+
for volume in volumes
|
| 212 |
+
if isinstance(volume, dict)
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _ensure_variable(
|
| 217 |
+
api: HfApi,
|
| 218 |
+
space_id: str,
|
| 219 |
+
key: str,
|
| 220 |
+
value: str,
|
| 221 |
+
description: str,
|
| 222 |
+
token: str,
|
| 223 |
+
) -> None:
|
| 224 |
+
variables = api.get_space_variables(space_id, token=token)
|
| 225 |
+
current = variables.get(key)
|
| 226 |
+
current_value = (
|
| 227 |
+
current.value
|
| 228 |
+
if hasattr(current, "value")
|
| 229 |
+
else current.get("value")
|
| 230 |
+
if isinstance(current, dict)
|
| 231 |
+
else None
|
| 232 |
+
)
|
| 233 |
+
if current_value == value:
|
| 234 |
+
return
|
| 235 |
+
api.add_space_variable(
|
| 236 |
+
space_id,
|
| 237 |
+
key,
|
| 238 |
+
value,
|
| 239 |
+
description=description,
|
| 240 |
+
token=token,
|
| 241 |
+
)
|
research/research_workspace.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Resolve and prepare per-user Hugging Face bucket workspaces."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
from contextvars import ContextVar
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from datetime import UTC, datetime
|
| 12 |
+
from typing import Any, Mapping
|
| 13 |
+
from uuid import uuid4
|
| 14 |
+
|
| 15 |
+
from huggingface_hub import HfApi, get_token
|
| 16 |
+
from huggingface_hub.errors import BucketNotFoundError
|
| 17 |
+
|
| 18 |
+
from fast_agent import AgentAuth
|
| 19 |
+
from fast_agent.mcp.server.common import normalize_serve_oauth_provider
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
_SAFE_SEGMENT = re.compile(r"[^A-Za-z0-9._-]+")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass(frozen=True, slots=True)
|
| 26 |
+
class ResearchWorkspace:
|
| 27 |
+
username: str
|
| 28 |
+
session_id: str
|
| 29 |
+
bucket_id: str
|
| 30 |
+
root: str
|
| 31 |
+
scratch: str
|
| 32 |
+
output: str
|
| 33 |
+
bucket_created: bool
|
| 34 |
+
marker_paths: tuple[str, ...]
|
| 35 |
+
bearer_token: str | None
|
| 36 |
+
archive_space_id: str | None = None
|
| 37 |
+
archive_space_url: str | None = None
|
| 38 |
+
archive_app_url: str | None = None
|
| 39 |
+
archive_status: str | None = None
|
| 40 |
+
archive_template_version: str | None = None
|
| 41 |
+
archive_installed_version: str | None = None
|
| 42 |
+
archive_error: str | None = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
current_research_workspace: ContextVar[ResearchWorkspace | None] = ContextVar(
|
| 46 |
+
"current_research_workspace",
|
| 47 |
+
default=None,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def ensure_workspace(
|
| 52 |
+
*,
|
| 53 |
+
auth: AgentAuth | None,
|
| 54 |
+
request_metadata: Mapping[str, Any],
|
| 55 |
+
open_metadata: Mapping[str, object],
|
| 56 |
+
create_bucket: bool = True,
|
| 57 |
+
write_markers: bool = True,
|
| 58 |
+
api: HfApi | None = None,
|
| 59 |
+
) -> ResearchWorkspace:
|
| 60 |
+
"""Resolve identity/session, ensure the bucket exists, and write markers."""
|
| 61 |
+
token = _token(auth)
|
| 62 |
+
whoami = _whoami(auth, token)
|
| 63 |
+
username = _username(whoami)
|
| 64 |
+
session_id = _session_id(request_metadata, open_metadata)
|
| 65 |
+
bucket_id = f"{username}/research-agent"
|
| 66 |
+
root = f"hf://buckets/{bucket_id}/{session_id}/"
|
| 67 |
+
|
| 68 |
+
api = api or HfApi()
|
| 69 |
+
bucket_created = False
|
| 70 |
+
try:
|
| 71 |
+
api.bucket_info(bucket_id, token=token)
|
| 72 |
+
except BucketNotFoundError as exc:
|
| 73 |
+
if not create_bucket:
|
| 74 |
+
raise RuntimeError(
|
| 75 |
+
f"Bucket {bucket_id!r} is not accessible: {exc}"
|
| 76 |
+
) from exc
|
| 77 |
+
try:
|
| 78 |
+
api.create_bucket(bucket_id, private=True, exist_ok=True, token=token)
|
| 79 |
+
bucket_created = True
|
| 80 |
+
except Exception as create_exc:
|
| 81 |
+
raise RuntimeError(
|
| 82 |
+
f"Could not create/access bucket {bucket_id!r}: {create_exc}"
|
| 83 |
+
) from create_exc
|
| 84 |
+
|
| 85 |
+
marker_paths: tuple[str, ...] = ()
|
| 86 |
+
if write_markers:
|
| 87 |
+
marker = {
|
| 88 |
+
"server": "research-agent",
|
| 89 |
+
"username": username,
|
| 90 |
+
"session_id": session_id,
|
| 91 |
+
"bucket_id": bucket_id,
|
| 92 |
+
"checked_at": datetime.now(UTC).isoformat(),
|
| 93 |
+
}
|
| 94 |
+
try:
|
| 95 |
+
api.batch_bucket_files(
|
| 96 |
+
bucket_id,
|
| 97 |
+
add=[
|
| 98 |
+
(
|
| 99 |
+
json.dumps(marker, indent=2).encode("utf-8"),
|
| 100 |
+
f"{session_id}/scratch/.workspace.json",
|
| 101 |
+
),
|
| 102 |
+
(b"", f"{session_id}/output/.keep"),
|
| 103 |
+
],
|
| 104 |
+
token=token,
|
| 105 |
+
)
|
| 106 |
+
except Exception as exc:
|
| 107 |
+
raise RuntimeError(
|
| 108 |
+
f"Bucket {bucket_id!r} is accessible but marker write failed: {exc}"
|
| 109 |
+
) from exc
|
| 110 |
+
marker_paths = (
|
| 111 |
+
f"{root}scratch/.workspace.json",
|
| 112 |
+
f"{root}output/.keep",
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
return ResearchWorkspace(
|
| 116 |
+
username=username,
|
| 117 |
+
session_id=session_id,
|
| 118 |
+
bucket_id=bucket_id,
|
| 119 |
+
root=root,
|
| 120 |
+
scratch=f"{root}scratch/",
|
| 121 |
+
output=f"{root}output/",
|
| 122 |
+
bucket_created=bucket_created,
|
| 123 |
+
marker_paths=marker_paths,
|
| 124 |
+
bearer_token=token,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _token(auth: AgentAuth | None) -> str | None:
|
| 129 |
+
if auth is not None and auth.token:
|
| 130 |
+
return auth.token
|
| 131 |
+
oauth_provider = normalize_serve_oauth_provider(os.getenv("FAST_AGENT_SERVE_OAUTH"))
|
| 132 |
+
if oauth_provider == "huggingface":
|
| 133 |
+
raise RuntimeError(
|
| 134 |
+
"Hugging Face OAuth is enabled, but this request has no caller token."
|
| 135 |
+
)
|
| 136 |
+
env_token = os.getenv("HF_TOKEN")
|
| 137 |
+
if env_token:
|
| 138 |
+
return env_token
|
| 139 |
+
return get_token()
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _whoami(auth: AgentAuth | None, token: str | bool | None) -> Mapping[str, Any]:
|
| 143 |
+
"""Return the authoritative Hugging Face whoami payload for this caller."""
|
| 144 |
+
claims = dict(auth.claims) if auth is not None else {}
|
| 145 |
+
whoami = claims.get("huggingface_whoami")
|
| 146 |
+
if isinstance(whoami, dict) and whoami:
|
| 147 |
+
return whoami
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
return HfApi().whoami(token=token)
|
| 151 |
+
except Exception as exc:
|
| 152 |
+
raise RuntimeError(
|
| 153 |
+
"Could not determine the Hugging Face user. Provide a bearer token, "
|
| 154 |
+
"enable Hugging Face OAuth, set HF_TOKEN, or run `hf auth login`."
|
| 155 |
+
) from exc
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _username(whoami: Mapping[str, Any]) -> str:
|
| 159 |
+
username = safe_segment(whoami.get("name"))
|
| 160 |
+
if username:
|
| 161 |
+
return username
|
| 162 |
+
raise RuntimeError(
|
| 163 |
+
f"Hugging Face whoami response did not include a usable name: {dict(whoami)!r}."
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _session_id(
|
| 168 |
+
request_metadata: Mapping[str, Any],
|
| 169 |
+
open_metadata: Mapping[str, object],
|
| 170 |
+
) -> str:
|
| 171 |
+
candidates = [
|
| 172 |
+
request_metadata.get("research_workspace_id"),
|
| 173 |
+
open_metadata.get("research_workspace_id"),
|
| 174 |
+
request_metadata.get("request_session_id"),
|
| 175 |
+
request_metadata.get("harness_session_id"),
|
| 176 |
+
request_metadata.get("requested_session_id"),
|
| 177 |
+
request_metadata.get("mcp_session_id"),
|
| 178 |
+
open_metadata.get("harness_session_id"),
|
| 179 |
+
open_metadata.get("requested_session_id"),
|
| 180 |
+
open_metadata.get("mcp_session_id"),
|
| 181 |
+
]
|
| 182 |
+
for candidate in candidates:
|
| 183 |
+
value = _safe_session_segment(candidate)
|
| 184 |
+
if value:
|
| 185 |
+
return value
|
| 186 |
+
# No usable session identity was supplied. Never fall back to a shared
|
| 187 |
+
# constant ("default") — concurrent runs would collide on one bucket path
|
| 188 |
+
# and leak one run's report into another's UI. Mint a unique id instead.
|
| 189 |
+
return f"session-{uuid4().hex}"
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _safe_session_segment(value: object) -> str | None:
|
| 193 |
+
"""Sanitize a session id, keeping distinct inputs on distinct segments.
|
| 194 |
+
|
| 195 |
+
``safe_segment`` truncates to 96 chars and maps disallowed characters to
|
| 196 |
+
``-``, so two different client-supplied ids can collapse to the same
|
| 197 |
+
segment. When sanitization loses information, append a short stable hash of
|
| 198 |
+
the original so the mapping stays collision-resistant (and deterministic, so
|
| 199 |
+
the same input still resolves to the same workspace across requests).
|
| 200 |
+
"""
|
| 201 |
+
if value is None:
|
| 202 |
+
return None
|
| 203 |
+
raw = str(value).strip().strip("/")
|
| 204 |
+
if not raw:
|
| 205 |
+
return None
|
| 206 |
+
safe = safe_segment(raw)
|
| 207 |
+
if safe is None:
|
| 208 |
+
return None
|
| 209 |
+
if safe != raw:
|
| 210 |
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8]
|
| 211 |
+
safe = f"{safe[:87].rstrip('.-_')}-{digest}"
|
| 212 |
+
return safe
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def safe_segment(value: object) -> str | None:
|
| 216 |
+
if value is None:
|
| 217 |
+
return None
|
| 218 |
+
text = str(value).strip().strip("/")
|
| 219 |
+
if not text:
|
| 220 |
+
return None
|
| 221 |
+
return _SAFE_SEGMENT.sub("-", text)[:96].strip(".-_") or None
|