evalstate HF Staff commited on
Commit
3c6baa4
·
verified ·
1 Parent(s): cfea6f8

Deploy 928494e archive cache and packaging fix

Browse files

Source commit: 928494e329ae12b309700a3b05a7037f15eb0037

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