"""Optional timeline and trace-export hooks for research jobs.""" from __future__ import annotations import asyncio import os import posixpath from collections.abc import Awaitable, Callable from dataclasses import dataclass from pathlib import Path from typing import Any from urllib.parse import urlparse from uuid import uuid4 from fast_agent.mcp.tool_execution_handler import ToolExecutionHandler from fast_agent.session import SessionTraceExporter from fast_agent.session.session_manager import SessionManager from fast_agent.session.trace_export_models import ExportRequest from huggingface_hub import HfApi, HfFileSystem from huggingface_hub.errors import BucketNotFoundError from .app_jobs import ResearchJob from .report_preview import ImageReader, build_report_preview from .research_workspace import ResearchWorkspace, current_research_workspace MarkdownReader = Callable[[ResearchWorkspace], Awaitable[str]] MAX_MARKDOWN_REPORT_CHARS = 250_000 ARCHIVE_URL_ENV = "RESEARCH_ARCHIVE_HF_URL" ARCHIVE_TOKEN_ENV = "RESEARCH_ARCHIVE_TOKEN" @dataclass(frozen=True, slots=True) class ArchiveTarget: bucket_id: str root: str class JobProgressHandler(ToolExecutionHandler): """Project fast-agent tool events into the app's timeline.""" def __init__(self, job: ResearchJob) -> None: self.job = job self._activities: dict[str, tuple[str, str, str, str]] = {} async def on_tool_start( self, tool_name: str, server_name: str, arguments: dict | None, tool_use_id: str | None = None, ) -> str: tool_call_id = tool_use_id or f"{server_name}/{tool_name}/{uuid4().hex[:8]}" activity = _tool_activity(server_name, tool_name, arguments) self._activities[tool_call_id] = activity self.job.set_activity_source(activity[0]) self.job.add_event(f"{activity[0]}: started", kind="Activity") if _is_birch_delegation(server_name, tool_name): self.job.set_phase("reporting") await capture_markdown_report(self.job) return tool_call_id async def on_tool_progress( self, tool_call_id: str, progress: float, total: float | None, message: str | None, ) -> None: del progress, total source = self._activities.get( tool_call_id, ("researcher/agent_loop", "Researcher", "Research", ""), )[0] self.job.set_activity_source(source) self.job.add_event(f"{source}: {message or 'working'}", kind="Activity") async def on_tool_complete( self, tool_call_id: str, success: bool, content: list[Any] | None, error: str | None, ) -> None: raw_source, source, category, completed = self._activities.pop( tool_call_id, ( "researcher/agent_loop", "Researcher", "Research", "A research step finished.", ), ) message = completed if success else _friendly_tool_error(source, error) self.job.add_event( f"{raw_source}: completed" if success else message, kind="Activity", ) self.job.set_activity_source( next( (activity[0] for activity in reversed(self._activities.values())), "researcher/agent_loop", ) ) if _is_birch_delegation(*raw_source.split("/", 1)): self.job.set_phase("wrapping_up" if success else "researching") async def on_tool_permission_denied( self, tool_name: str, server_name: str, tool_use_id: str | None, error: str | None = None, ) -> None: raw_source, source, _, _ = _tool_activity(server_name, tool_name, None) self.job.set_activity_source(raw_source) self.job.add_event( _friendly_tool_error(source, error or "Permission was denied."), kind="Activity", ) async def get_tool_call_id_for_tool_use( self, tool_use_id: str, ) -> str | None: return tool_use_id if tool_use_id in self._activities else None async def ensure_tool_call_exists( self, tool_use_id: str, tool_name: str, server_name: str, arguments: dict | None = None, ) -> str: if tool_use_id in self._activities: return tool_use_id return await self.on_tool_start( tool_name, server_name, arguments, tool_use_id, ) def _tool_activity( server_name: str, tool_name: str, arguments: dict[str, Any] | None, ) -> tuple[str, str, str, str]: raw_source = f"{server_name}/{tool_name}" raw_name = f"{server_name}/{tool_name}".lower() if "birch-html" in raw_name: return ( raw_source, "Report writer", "Report", "The report writer finished another section.", ) if tool_name == "agent_loop": return ( raw_source, "Researcher", "Research", "The agent completed a research step.", ) if server_name == "hf" and tool_name == "hf_fs": command = str((arguments or {}).get("cmd") or "").lower() if command == "search": return ( raw_source, "Searching Hugging Face", "Hugging Face", "The Hugging Face search finished.", ) if command == "cat": return ( raw_source, "Reading a Hugging Face source", "Hugging Face", "The agent finished reading a Hugging Face source.", ) return ( raw_source, "Browsing Hugging Face", "Hugging Face", "The Hugging Face lookup finished.", ) if server_name == "hf" and "sandbox" in tool_name: return ( raw_source, "Running analysis", "Analysis", "The latest analysis step finished.", ) readable = tool_name.split("[", 1)[0].replace("_", " ").replace("-", " ") return ( raw_source, readable.capitalize(), "Research", f"The agent finished {readable}.", ) def _is_birch_delegation(server_name: str, tool_name: str) -> bool: return server_name == "agent" and tool_name.split("[", 1)[0] == "birch-html" async def capture_markdown_report( job: ResearchJob, *, reader: MarkdownReader | None = None, image_reader: ImageReader | None = None, ) -> None: workspace = current_research_workspace.get() if workspace is None: return uri = f"{workspace.output}report.md" try: markdown = await (reader or _read_markdown_report)(workspace) except Exception as exc: job.markdown_report_error = str(exc) return if len(markdown) > MAX_MARKDOWN_REPORT_CHARS: markdown = ( markdown[:MAX_MARKDOWN_REPORT_CHARS].rstrip() + "\n\n_This in-app preview was truncated; open the artifact for the full report._" ) job.markdown_report = markdown job.markdown_report_blocks = await build_report_preview( markdown, workspace, reader=image_reader, ) job.markdown_report_revision += 1 job.markdown_report_uri = uri job.markdown_report_error = None job.archive_space_url = workspace.archive_space_url job.archive_app_url = workspace.archive_app_url job.archive_template_version = workspace.archive_installed_version job.add_event("The Markdown report is ready to review.", kind="Report") async def _read_markdown_report(workspace: ResearchWorkspace) -> str: def read() -> str: filesystem = HfFileSystem(token=workspace.bearer_token) with filesystem.open(f"{workspace.output}report.md", "r") as report: return str(report.read()) return await asyncio.to_thread(read) def _friendly_tool_error(source: str, error: str | None) -> str: detail = (error or "The operation did not complete.").strip() if "search requires a positional query or --query" in detail: return "A Hugging Face search request was missing its query." detail = detail.removeprefix("EINVAL:").strip() if len(detail) > 180: detail = f"{detail[:177].rstrip()}…" return f"{source} encountered a problem: {detail}" def export_trace(job: ResearchJob, home: Path) -> None: output_path = ( home / "sessions" / "research-traces" / job.id / f"{job.id}__research__codex.jsonl" ) output_path.parent.mkdir(parents=True, exist_ok=True) exporter = SessionTraceExporter( session_manager=SessionManager(home_override=home), progress_callback=lambda message: job.add_event(message, kind="trace"), ) result = exporter.export( ExportRequest( target=job.harness_session_id, agent_name="researcher", output_path=output_path, ) ) job.trace_path = str(result.output_path) job.add_event( f"Exported Codex trace: {result.output_path} ({result.record_count} records)", kind="trace", ) archive = _archive_config() if archive is not None: target, token = archive job.trace_archive_uri = archive_session( job, home, result.output_path, target=target, token=token, ) job.add_event( f"Archived private session: {job.trace_archive_uri}", kind="trace", ) def _archive_config() -> tuple[ArchiveTarget, str] | None: url = os.getenv(ARCHIVE_URL_ENV, "").strip() token = os.getenv(ARCHIVE_TOKEN_ENV, "").strip() if not url and not token: return None if not url or not token: missing = ARCHIVE_URL_ENV if not url else ARCHIVE_TOKEN_ENV raise RuntimeError(f"Private session archive is missing {missing}.") return _archive_target(url), token def _archive_target(url: str) -> ArchiveTarget: parsed = urlparse(url) parts = [part for part in parsed.path.split("/") if part] if parsed.scheme != "hf" or parsed.netloc != "buckets" or len(parts) < 2: raise ValueError( f"{ARCHIVE_URL_ENV} must be an hf://buckets// URL." ) bucket_id = f"{parts[0]}/{parts[1]}" prefix = "/".join(parts[2:]) root = f"hf://buckets/{bucket_id}" if prefix: root = f"{root}/{prefix}" return ArchiveTarget(bucket_id=bucket_id, root=root) def archive_session( job: ResearchJob, home: Path, trace_path: Path, *, target: ArchiveTarget, token: str, api: Any | None = None, filesystem: Any | None = None, ) -> str: """Archive one raw session and Codex trace using an app-only credential.""" api = api or HfApi(token=token) filesystem = filesystem or HfFileSystem(token=token) try: info = api.bucket_info(target.bucket_id, token=token) except BucketNotFoundError: api.create_bucket( target.bucket_id, private=True, exist_ok=True, token=token, ) else: if not bool(getattr(info, "private", False)): raise RuntimeError( f"Refusing to archive sessions to public bucket {target.bucket_id!r}." ) session_dir = home / "sessions" / job.harness_session_id if not session_dir.is_dir(): raise FileNotFoundError(f"Session directory does not exist: {session_dir}") for source in sorted(path for path in session_dir.rglob("*") if path.is_file()): relative = source.relative_to(session_dir).as_posix() _upload_archive_file( filesystem, source, f"{target.root}/{job.id}/{relative}", ) trace_uri = ( f"{target.root}/research-traces/{job.id}/{posixpath.basename(trace_path)}" ) _upload_archive_file(filesystem, trace_path, trace_uri) return trace_uri def _upload_archive_file(filesystem: Any, source: Path, destination: str) -> None: with ( source.open("rb") as source_file, filesystem.open(destination, "wb") as destination_file, ): destination_file.write(source_file.read()) async def try_export_trace(job: ResearchJob, home: Path) -> None: try: await asyncio.to_thread(export_trace, job, home) except Exception as exc: job.trace_error = str(exc) job.add_event(f"Trace export failed: {exc}", kind="trace")