evalstate HF Staff commited on
Commit
a7a84d2
·
verified ·
1 Parent(s): e6b28b4

Restore Research Dispatch and activity narrator

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