File size: 8,111 Bytes
ddd660b
 
 
 
 
 
 
 
 
 
 
 
 
cfea6f8
 
 
 
 
 
 
 
ddd660b
cfea6f8
 
 
 
 
 
 
 
ddd660b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cfea6f8
ddd660b
cfea6f8
ddd660b
cfea6f8
 
 
 
 
 
 
 
 
 
 
 
 
 
ddd660b
cfea6f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddd660b
 
 
 
 
 
 
 
 
 
 
 
cfea6f8
 
 
 
 
 
 
 
 
 
ddd660b
cfea6f8
 
 
 
 
 
 
 
 
ddd660b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cfea6f8
 
 
 
 
 
 
 
 
 
 
ddd660b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Harness app wrapper for per-user research bucket instructions."""

from __future__ import annotations

import asyncio
from contextlib import asynccontextmanager
from dataclasses import replace
from typing import TYPE_CHECKING, Any

from fast_agent import AgentRequest, AppOpenRequest, HarnessAppContext
from mcp.types import TextContent

try:
    from .app_jobs import current_research_job
    from .app_observability import capture_markdown_report
    from .archive_provisioning import ensure_archive_space
    from .research_workspace import (
        ResearchWorkspace,
        current_research_workspace,
        ensure_workspace,
    )
except ImportError:  # loaded as top-level module from the fast-agent home
    from research.app_jobs import current_research_job
    from research.app_observability import capture_markdown_report
    from research.archive_provisioning import ensure_archive_space
    from research.research_workspace import (
        ResearchWorkspace,
        current_research_workspace,
        ensure_workspace,
    )

if TYPE_CHECKING:
    from collections.abc import AsyncIterator, Mapping


class ResearchHarnessApp:
    """Intercept opened harness sessions and wrap invocations."""

    def __init__(self, context: HarnessAppContext) -> None:
        self._default_app = context.default_app

    @asynccontextmanager
    async def open(self, request: AppOpenRequest | None = None) -> AsyncIterator[Any]:
        resolved = request or AppOpenRequest()
        async with self._default_app.open(resolved) as session:
            yield ResearchHarnessSession(session, resolved.metadata)


class ResearchHarnessSession:
    """Per-open-session wrapper that injects bucket instructions per request."""

    def __init__(self, session: Any, open_metadata: Mapping[str, object]) -> None:
        self._session = session
        self._open_metadata = dict(open_metadata)

    @property
    def agent_app(self) -> Any:
        return self._session.agent_app

    @property
    def env(self) -> Any:
        return self._session.env

    async def invoke(self, request: AgentRequest) -> Any:
        workspace = await asyncio.to_thread(
            ensure_workspace,
            auth=request.auth,
            request_metadata={
                **request.metadata,
                "request_session_id": request.session_id,
            },
            open_metadata=self._open_metadata,
        )
        workspace = await self._with_archive_space(workspace)
        forwarded = self._with_bucket_instructions(request, workspace)
        workspace_token = current_research_workspace.set(workspace)
        try:
            if workspace.bearer_token is None:
                response = await self._session.invoke(forwarded)
            else:
                from fast_agent.mcp.auth.context import request_bearer_token

                auth_token = request_bearer_token.set(workspace.bearer_token)
                try:
                    response = await self._session.invoke(forwarded)
                finally:
                    request_bearer_token.reset(auth_token)

            if job := current_research_job.get():
                await capture_markdown_report(job)
            return response
        finally:
            current_research_workspace.reset(workspace_token)

    async def _with_archive_space(
        self,
        workspace: ResearchWorkspace,
    ) -> ResearchWorkspace:
        try:
            archive = await asyncio.to_thread(
                ensure_archive_space,
                username=workspace.username,
                bucket_id=workspace.bucket_id,
                token=workspace.bearer_token,
            )
        except Exception as exc:
            return replace(
                workspace,
                archive_status="error",
                archive_error=f"{type(exc).__name__}: {exc}",
            )
        return replace(
            workspace,
            archive_space_id=archive.space_id,
            archive_space_url=archive.space_url,
            archive_app_url=archive.app_url,
            archive_status=archive.status,
            archive_template_version=archive.template_version,
            archive_installed_version=archive.installed_version,
        )

    def _with_bucket_instructions(
        self, request: AgentRequest, workspace: ResearchWorkspace
    ) -> AgentRequest:
        instructions = "\n".join(
            [
                "Verified research workspace for this request:",
                f"- Hugging Face user: `{workspace.username}`",
                f"- Bucket: `{workspace.bucket_id}`",
                f"- Root: `{workspace.root}`",
                f"- Scratch/workings: `{workspace.scratch}`",
                f"- Final user-facing outputs: `{workspace.output}`",
                *(
                    [
                        f"- Report archive Space: `{workspace.archive_space_id}`",
                        f"- Report archive: {workspace.archive_space_url}",
                        f"- Archive app: {workspace.archive_app_url}",
                        f"- Archive status: `{workspace.archive_status}`",
                    ]
                    if workspace.archive_space_id
                    else []
                ),
                "The workspace was verified before this prompt was sent.",
                f"Hugging Face MCP authentication is verified for `{workspace.username}`.",
                "The same caller bearer token is forwarded to Hugging Face MCP tool calls.",
                "If authentication status must be reported, call `hf__hf_whoami`; do not infer it from cached server instructions.",
                "Write the final Markdown report to the bucket-relative path `output/report.md` unless the user requests another filename.",
                "That path is inside the verified Hugging Face bucket session, not the server's local filesystem.",
                "Use Hugging Face filesystem tools for bucket files. Never create `output/`, `scratch/`, or report artifacts in the local working directory.",
                "When you report a Hugging Face bucket artifact to the user, include both the `hf://` path and the accessible HTTPS URL.",
                "Convert `hf://buckets/<owner>/<bucket>/<path>` to `https://huggingface.co/buckets/<owner>/<bucket>/tree/<path>`.",
                f"Default report URL: `https://huggingface.co/buckets/{workspace.bucket_id}/tree/{workspace.session_id}/output/report.md`",
            ]
        )

        return replace(
            request,
            message=_prepend_text(request.message, instructions),
            metadata={
                **request.metadata,
                "research_username": workspace.username,
                "research_session_id": workspace.session_id,
                "research_bucket_id": workspace.bucket_id,
                "research_bucket_root": workspace.root,
                "research_scratch": workspace.scratch,
                "research_output": workspace.output,
                "research_marker_paths": list(workspace.marker_paths),
                "research_archive_space_id": workspace.archive_space_id,
                "research_archive_space_url": workspace.archive_space_url,
                "research_archive_app_url": workspace.archive_app_url,
                "research_archive_status": workspace.archive_status,
                "research_archive_template_version": (
                    workspace.archive_template_version
                ),
                "research_archive_installed_version": (
                    workspace.archive_installed_version
                ),
                "research_archive_error": workspace.archive_error,
            },
        )


def create_app(context: HarnessAppContext) -> ResearchHarnessApp:
    return ResearchHarnessApp(context)


def _prepend_text(message: Any, text: str) -> Any:
    content = list(message.content)
    content.insert(
        0,
        TextContent(
            type="text",
            text=f"{text}\n\nUser request follows.",
        ),
    )
    return message.model_copy(update={"content": content})