File size: 12,452 Bytes
ceccf86
 
 
 
 
a7a84d2
 
 
 
ceccf86
 
a7a84d2
ceccf86
 
a7a84d2
 
 
ceccf86
 
 
 
 
 
a7a84d2
 
 
 
 
 
 
 
 
 
 
 
ceccf86
 
 
 
 
 
 
a7a84d2
ceccf86
 
 
 
 
 
 
 
 
a7a84d2
 
 
 
 
 
 
ceccf86
 
 
 
 
 
 
 
 
a7a84d2
 
 
 
 
 
 
ceccf86
 
 
 
 
 
 
 
a7a84d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ceccf86
 
 
 
 
 
 
 
a7a84d2
 
ceccf86
a7a84d2
 
ceccf86
 
 
 
 
 
a7a84d2
ceccf86
 
 
 
 
 
 
 
a7a84d2
ceccf86
 
 
 
 
 
 
 
 
a7a84d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cef97ee
 
 
a7a84d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ceccf86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438220f
ceccf86
 
 
 
 
 
 
 
 
a7a84d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438220f
a7a84d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ceccf86
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
"""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 huggingface_hub import HfApi, HfFileSystem
from huggingface_hub.errors import BucketNotFoundError

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 .app_jobs import ResearchJob
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,
            ("research/agent_loop", "Research agent", "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,
            (
                "research/agent_loop",
                "Research agent",
                "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())),
                "research/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,
            "Research agent",
            "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,
) -> 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_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="research",
            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/<owner>/<bucket> 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")