Python Tool 작성을 해보자. OpenAI Agent에서 사용할 Tool을 만들고 싶어. 너가 만들어준 Tool에 agents.function_tool decorator를 붙일거니깐 내부 documentation과 Annotation이 된 상태로 되면 좋겠어. 그런데 root 폴더는 자체적으로 root_dir 로 지정을 해서 그에 따라 만들어서 나가게 하고 싶어. File System 관련 툴들 - list_files - read_file - save_file 만 일단 만들어줘.

좋아. 아래는 OpenAI Agent에서 바로 쓸 수 있게 @agents.function_tool 데코레이터를 붙인, 루트 디렉터리를 고정해서 동작하는 파일 시스템 툴 3종 세트야. 경로 탈출(..)을 막고, 바이너리/텍스트 모두 안전하게 다룰 수 있도록 설계했어.

간단한 설계 요약 (한 줄씩):

  1. 모듈 전역의 ROOT_DIR로 작업 루트를 고정하고, 모든 입출력은 여기에 상대 경로로만 접근.

  2. _resolve_safe()에서 Path.resolve()로 정규화한 뒤 is_relative_to로 루트 내 경로인지 검증.

  3. list_files는 재귀/글롭/숨김파일 포함 여부, 최대 개수 제한을 제공.

  4. read_file은 텍스트 또는 바이너리(베이스64) 반환을 선택 가능. 파일 크기 상한(max_bytes)으로 과도한 읽기 방지.

  5. save_file은 텍스트/베이스64 쓰기, 상위 폴더 자동 생성, 덮어쓰기 가드 제공.

  6. 모든 함수는 풍부한 타입 힌트와 독스트링을 포함하고, JSON 직렬화 가능한 결과를 반환.

from __future__ import annotations

import base64
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable, List, Literal, Optional, Union

# 이 모듈이 불리는 시점에 기본 root 설정 (필요하면 set_root_dir로 재설정)
ROOT_DIR: Path = Path.cwd() / "agent_root"
ROOT_DIR.mkdir(parents=True, exist_ok=True)

def set_root_dir(path: Union[str, Path]) -> str:
    """
    루트 디렉터리를 설정한다. 모든 툴은 이 디렉터리 하위에서만 동작한다.

    Parameters
    ----------
    path : str | Path
        작업 루트로 사용할 디렉터리 경로.

    Returns
    -------
    str
        정규화된 루트 디렉터리의 절대 경로 문자열.
    """
    global ROOT_DIR
    ROOT_DIR = Path(path).expanduser().resolve()
    ROOT_DIR.mkdir(parents=True, exist_ok=True)
    return str(ROOT_DIR)

def _resolve_safe(relpath: Union[str, Path]) -> Path:
    """
    루트 디렉터리 내부의 안전한 절대 경로를 계산한다.
    경로 탈출(../ 등)을 방지하기 위해 resolve 후 ROOT_DIR 하위인지 검사한다.
    """
    p = (ROOT_DIR / Path(relpath)).expanduser().resolve()
    try:
        # Python 3.9+: Path.is_relative_to 지원. 미지원 환경 대비 수동 체크.
        if not p.is_relative_to(ROOT_DIR):  # type: ignore[attr-defined]
            raise ValueError("Path escapes ROOT_DIR")
    except AttributeError:
        # 수동 구현
        if ROOT_DIR not in p.parents and p != ROOT_DIR:
            raise ValueError("Path escapes ROOT_DIR")
    return p

@dataclass
class FileEntry:
    path: str
    type: Literal["file", "dir"]
    size: Optional[int] = None

    @staticmethod
    def from_path(p: Path) -> "FileEntry":
        t: Literal["file", "dir"] = "dir" if p.is_dir() else "file"
        size = p.stat().st_size if p.is_file() else None
        return FileEntry(path=str(p.relative_to(ROOT_DIR)), type=t, size=size)

# ===== OpenAI Agent Tool Definitions =====
# 프레임워크에 따라 import 경로가 다를 수 있음. 예:
# from openai import agents
# 여기서는 사용자가 말한 데코레이터 이름을 그대로 쓴다.
try:
    from openai import agents  # type: ignore
except Exception:  # 런타임 환경에서 데코레이터가 없을 수 있으니 대체 정의
    class _NoopDecorator:
        def function_tool(self, *args, **kwargs):
            def wrapper(fn):
                return fn
            return wrapper
    agents = _NoopDecorator()  # type: ignore

@agents.function_tool(
    name="list_files",
    description=(
        "루트 디렉터리(root_dir) 기준으로 파일/폴더 목록을 가져온다. "
        "재귀 탐색, 글롭 패턴, 숨김파일 포함 여부, 최대 개수 제한을 지원한다."
    ),
)
def list_files(
    relative_path: str = ".",
    glob: Optional[str] = None,
    recursive: bool = True,
    include_hidden: bool = False,
    max_items: Optional[int] = None,
) -> dict:
    """
    파일/폴더 목록 조회.

    Parameters
    ----------
    relative_path : str, default="."
        ROOT_DIR 기준 시작 경로.
    glob : str | None, default=None
        글롭 패턴 (예: "*.py"). None이면 전체.
    recursive : bool, default=True
        하위 폴더 재귀 탐색 여부.
    include_hidden : bool, default=False
        점(.)으로 시작하는 숨김 항목 포함 여부.
    max_items : int | None, default=None
        반환 항목 최대 개수. None이면 제한 없음.

    Returns
    -------
    dict
        {
          "root": "<ROOT_DIR>",
          "base": "<relative_path>",
          "count": <int>,
          "entries": [{"path": "...", "type": "file|dir", "size": <int|null>}, ...]
        }

    Notes
    -----
    - 모든 경로는 ROOT_DIR 하위로 제한된다.
    - 경로 탈출이 감지되면 ValueError를 발생시킨다.
    """
    base = _resolve_safe(relative_path)
    if not base.exists():
        return {
            "root": str(ROOT_DIR),
            "base": str(relative_path),
            "count": 0,
            "entries": [],
            "warning": "Base path does not exist.",
        }

    # 후보 경로 이터레이션
    def iter_paths() -> Iterable[Path]:
        if glob:
            pattern = f"**/{glob}" if recursive else glob
            yield from base.glob(pattern)
        else:
            if recursive:
                yield from base.rglob("*")
            else:
                yield from base.iterdir()

    entries: List[FileEntry] = []
    for p in iter_paths():
        name = p.name
        if not include_hidden and name.startswith("."):
            continue
        if p == base:
            continue
        if not p.exists():
            continue
        # ROOT_DIR 범위 재확인 (심볼릭 링크 등 대비)
        safe_p = _resolve_safe(p.relative_to(ROOT_DIR))
        entries.append(FileEntry.from_path(safe_p))
        if max_items is not None and len(entries) >= max_items:
            break

    result = {
        "root": str(ROOT_DIR),
        "base": str(Path(relative_path)),
        "count": len(entries),
        "entries": [asdict(e) for e in entries],
    }
    return result

@agents.function_tool(
    name="read_file",
    description=(
        "루트 디렉터리(root_dir) 기준 경로의 파일을 읽는다. "
        "텍스트(encoding 사용) 또는 바이너리(Base64) 모드로 반환할 수 있다."
    ),
)
def read_file(
    relative_path: str,
    mode: Literal["text", "binary"] = "text",
    encoding: str = "utf-8",
    max_bytes: Optional[int] = 2 * 1024 * 1024,
) -> dict:
    """
    파일 읽기.

    Parameters
    ----------
    relative_path : str
        ROOT_DIR 기준 파일 경로.
    mode : {"text", "binary"}, default="text"
        "text"는 문자열로 반환(encoding 사용), "binary"는 Base64 문자열로 반환.
    encoding : str, default="utf-8"
        텍스트 모드에서 사용할 인코딩.
    max_bytes : int | None, default=2*1024*1024
        읽기 최대 바이트. None이면 제한 없음.

    Returns
    -------
    dict
        텍스트 모드:
          {"path": "...", "size": <int>, "mode": "text", "content": "<str>"}
        바이너리 모드:
          {"path": "...", "size": <int>, "mode": "binary", "content_b64": "<str>"}

    Raises
    ------
    FileNotFoundError
        대상 파일이 없을 때.
    IsADirectoryError
        디렉터리를 읽으려 할 때.
    ValueError
        경로 탈출 등 보안 위반.
    """
    p = _resolve_safe(relative_path)
    if not p.exists():
        raise FileNotFoundError(f"File not found: {relative_path}")
    if p.is_dir():
        raise IsADirectoryError(f"Path is a directory: {relative_path}")

    size = p.stat().st_size
    if max_bytes is not None and size > max_bytes:
        raise ValueError(f"File too large ({size} bytes) > max_bytes={max_bytes}")

    if mode == "text":
        content = p.read_text(encoding=encoding)
        return {"path": str(p.relative_to(ROOT_DIR)), "size": size, "mode": "text", "content": content}
    elif mode == "binary":
        data = p.read_bytes()
        return {
            "path": str(p.relative_to(ROOT_DIR)),
            "size": size,
            "mode": "binary",
            "content_b64": base64.b64encode(data).decode("ascii"),
        }
    else:
        raise ValueError("mode must be 'text' or 'binary'")

@agents.function_tool(
    name="save_file",
    description=(
        "루트 디렉터리(root_dir) 기준 경로에 파일을 저장한다. "
        "텍스트(encoding) 또는 Base64 바이너리를 지원하고, "
        "덮어쓰기/상위 경로 자동 생성 옵션을 제공한다."
    ),
)
def save_file(
    relative_path: str,
    content: Optional[str] = None,
    *,
    content_b64: Optional[str] = None,
    mode: Literal["text", "binary"] = "text",
    encoding: str = "utf-8",
    overwrite: bool = False,
    create_parents: bool = True,
) -> dict:
    """
    파일 저장.

    Parameters
    ----------
    relative_path : str
        ROOT_DIR 기준 저장 경로.
    content : str | None, default=None
        텍스트 모드에서 저장할 문자열. mode="text"일 때 필요.
    content_b64 : str | None, keyword-only, default=None
        바이너리 모드에서 저장할 Base64 문자열. mode="binary"일 때 필요.
    mode : {"text", "binary"}, default="text"
        저장 모드.
    encoding : str, default="utf-8"
        텍스트 모드 인코딩.
    overwrite : bool, default=False
        True면 기존 파일을 덮어쓴다. False면 기존 파일이 있으면 예외 발생.
    create_parents : bool, default=True
        상위 디렉터리를 자동 생성할지 여부.

    Returns
    -------
    dict
        {"path": "...", "size": <int>, "mode": "text|binary", "created": <bool>, "overwrote": <bool>}

    Raises
    ------
    IsADirectoryError
        디렉터리 경로에 파일을 쓰려 할 때.
    FileExistsError
        overwrite=False이고 파일이 이미 존재할 때.
    ValueError
        잘못된 모드/입력 또는 경로 탈출 등 보안 위반.
    """
    p = _resolve_safe(relative_path)
    if p.exists() and p.is_dir():
        raise IsADirectoryError(f"Path is a directory: {relative_path}")

    if create_parents:
        p.parent.mkdir(parents=True, exist_ok=True)

    created = not p.exists()
    if p.exists() and not overwrite:
        raise FileExistsError(f"File already exists (overwrite=False): {relative_path}")

    if mode == "text":
        if content is None:
            raise ValueError("content is required when mode='text'")
        p.write_text(content, encoding=encoding)
    elif mode == "binary":
        if content_b64 is None:
            raise ValueError("content_b64 is required when mode='binary'")
        data = base64.b64decode(content_b64.encode("ascii"))
        p.write_bytes(data)
    else:
        raise ValueError("mode must be 'text' or 'binary'")

    size = p.stat().st_size
    return {
        "path": str(p.relative_to(ROOT_DIR)),
        "size": size,
        "mode": mode,
        "created": created,
        "overwrote": (not created),
    }

# 사용 예시 (Agent 외부 초기화 코드)
# set_root_dir("./workspace")  # 필요 시 루트 디렉터리 지정
# list_files(relative_path=".", glob="*.py", recursive=True)
# save_file("docs/readme.txt", content="hello", overwrite=True)
# read_file("docs/readme.txt", mode="text")

연동 팁:

  • 에이전트 시작 시점에 set_root_dir("/desired/root")를 한 번 호출해 루트 고정.

  • 보안상 모든 외부 입력 경로는 위 3개 툴의 relative_path에 그대로 넣지 말고, 꼭 이 툴 안에서 _resolve_safe가 검증하도록 유지.

  • 큰 파일을 다룬다면 read_file(max_bytes=None)처럼 상한을 조절하되, 에이전트 정책에 맞춰 적절한 제한을 두는 게 안전하다.

다음으로 원하면, 삭제/이동/복사 같은 추가 툴이나 파일 잠금, 트랜잭션성 저장(임시 파일로 쓰고 원자적 교체)도 확장해줄 수 있어.

음 이런 형식이야. 너가 찾아볼 줄 알았지. from agents import function_tool @function_tool def get_weather(city: str) -> str: """returns weather info for the specified city.""" return f"The weather in {city} is sunny" 그런데 루트를 함수로 입력 받고 그에 따른 툴을 리턴하면 잘 동작할까? 예를 들어 def get_systemfile_tools(root_dir: str): def list_files(…): f"root dir: {root_dir} …" … def save_file(…): return [list_files, save_file, …]

가능해. 함수가 클로저로 root_dir를 캡처해서 툴로 내려가도, 에이전트가 “파이썬 콜러블을 호출”만 하면 문제없다. 다만 몇 가지 현실적 주의점이 있다.

핵심 포인트 먼저 정리

  1. 대부분의 런타임(단일 프로세스, 인메모리)은 클로저 툴을 잘 지원한다.

  2. 멀티프로세스/원격 실행(예: 프로세스 간 큐, 서버리스 워커)에서는 “피클/직렬화 가능한 최상위 함수”를 요구할 수 있다. 그땐 클로저 대신 functools.partial이나 클래스 바인딩 패턴이 안전하다.

  3. 데코레이터는 정의 시점에 적용된다. 팩토리 내부에서 “데코레이터를 함수처럼 호출”해 적용하면 된다.

아래는 네가 제시한 스타일에 맞춘 최소-의존성 구현이다. from agents import function_tool 가정, get_systemfile_tools(root_dir)가 안전 경로 검증까지 포함한 3개 툴(list_files, read_file, save_file)을 만들어 리스트로 반환한다.

from __future__ import annotations

import base64
from pathlib import Path
from typing import Iterable, List, Literal, Optional

from agents import function_tool  # 사용자 환경의 데코레이터

# ===== 내부 유틸 =====

def _resolve_safe(root: Path, relpath: str) -> Path:
    """
    root 하위의 안전한 절대 경로 생성. 경로 탈출 방지.
    """
    root = root.resolve()
    p = (root / relpath).expanduser().resolve()
    # Path.is_relative_to: 3.9+ 지원. 하위 호환 수동 체크
    try:
        if not p.is_relative_to(root):  # type: ignore[attr-defined]
            raise ValueError("Path escapes root_dir")
    except AttributeError:
        if root not in p.parents and p != root:
            raise ValueError("Path escapes root_dir")
    return p

# ===== 툴 팩토리 =====

def get_systemfile_tools(root_dir: str):
    """
    지정한 root_dir를 기준으로 동작하는 파일 시스템 툴 3종을 생성해 반환한다.
    반환 값은 에이전트에 그대로 등록 가능한 데코레이트된 함수 리스트.
    """
    root = Path(root_dir).expanduser().resolve()
    root.mkdir(parents=True, exist_ok=True)

    # --- list_files ---------------------------------------------------------
    def _list_files_impl(
        relative_path: str = ".",
        glob: Optional[str] = None,
        recursive: bool = True,
        include_hidden: bool = False,
        max_items: Optional[int] = None,
    ) -> dict:
        """
        루트(root_dir) 기준으로 파일/폴더 목록을 가져온다.
        글롭 패턴, 재귀 탐색, 숨김 포함 여부, 최대 개수 제한을 지원한다.
        """
        base = _resolve_safe(root, relative_path)
        if not base.exists():
            return {
                "root": str(root),
                "base": relative_path,
                "count": 0,
                "entries": [],
                "warning": "Base path does not exist.",
            }

        def iter_paths() -> Iterable[Path]:
            if glob:
                pattern = f"**/{glob}" if recursive else glob
                yield from base.glob(pattern)
            else:
                if recursive:
                    yield from base.rglob("*")
                else:
                    yield from base.iterdir()

        entries: List[dict] = []
        for p in iter_paths():
            if p == base or not p.exists():
                continue
            if not include_hidden and p.name.startswith("."):
                continue
            safe = _resolve_safe(root, str(p.relative_to(root)))
            entry = {
                "path": str(safe.relative_to(root)),
                "type": "dir" if safe.is_dir() else "file",
                "size": safe.stat().st_size if safe.is_file() else None,
            }
            entries.append(entry)
            if max_items is not None and len(entries) >= max_items:
                break

        return {
            "root": str(root),
            "base": relative_path,
            "count": len(entries),
            "entries": entries,
        }

    list_files = function_tool(
        name="list_files",
        description="root_dir 기준으로 파일/폴더 목록을 가져온다. 글롭/재귀/숨김/최대개수 옵션 지원."
    )(_list_files_impl)

    # --- read_file ----------------------------------------------------------
    def _read_file_impl(
        relative_path: str,
        mode: Literal["text", "binary"] = "text",
        encoding: str = "utf-8",
        max_bytes: Optional[int] = 2 * 1024 * 1024,
    ) -> dict:
        """
        파일 읽기. 텍스트(encoding) 또는 바이너리(Base64) 모드 선택.
        파일 크기 상한(max_bytes)으로 과도한 읽기 방지.
        """
        p = _resolve_safe(root, relative_path)
        if not p.exists():
            raise FileNotFoundError(f"File not found: {relative_path}")
        if p.is_dir():
            raise IsADirectoryError(f"Path is a directory: {relative_path}")

        size = p.stat().st_size
        if max_bytes is not None and size > max_bytes:
            raise ValueError(f"File too large ({size}) > max_bytes={max_bytes}")

        if mode == "text":
            return {
                "path": str(p.relative_to(root)),
                "size": size,
                "mode": "text",
                "content": p.read_text(encoding=encoding),
            }
        elif mode == "binary":
            data = p.read_bytes()
            return {
                "path": str(p.relative_to(root)),
                "size": size,
                "mode": "binary",
                "content_b64": base64.b64encode(data).decode("ascii"),
            }
        else:
            raise ValueError("mode must be 'text' or 'binary'")

    read_file = function_tool(
        name="read_file",
        description="root_dir 기준 경로의 파일을 읽는다. 텍스트 또는 Base64 바이너리 반환."
    )(_read_file_impl)

    # --- save_file ----------------------------------------------------------
    def _save_file_impl(
        relative_path: str,
        content: Optional[str] = None,
        *,
        content_b64: Optional[str] = None,
        mode: Literal["text", "binary"] = "text",
        encoding: str = "utf-8",
        overwrite: bool = False,
        create_parents: bool = True,
    ) -> dict:
        """
        파일 저장. 텍스트(content) 또는 Base64(content_b64) 지원.
        overwrite와 상위 경로 자동 생성(create_parents) 옵션 제공.
        """
        p = _resolve_safe(root, relative_path)
        if p.exists() and p.is_dir():
            raise IsADirectoryError(f"Path is a directory: {relative_path}")

        if create_parents:
            p.parent.mkdir(parents=True, exist_ok=True)

        created = not p.exists()
        if p.exists() and not overwrite:
            raise FileExistsError(f"File already exists (overwrite=False): {relative_path}")

        if mode == "text":
            if content is None:
                raise ValueError("content is required when mode='text'")
            p.write_text(content, encoding=encoding)
        elif mode == "binary":
            if content_b64 is None:
                raise ValueError("content_b64 is required when mode='binary'")
            p.write_bytes(base64.b64decode(content_b64.encode("ascii")))
        else:
            raise ValueError("mode must be 'text' or 'binary'")

        return {
            "path": str(p.relative_to(root)),
            "size": p.stat().st_size,
            "mode": mode,
            "created": created,
            "overwrote": (not created),
        }

    save_file = function_tool(
        name="save_file",
        description="root_dir 기준 경로에 파일을 저장한다. 텍스트/바이너리, 덮어쓰기, 상위 폴더 생성 지원."
    )(_save_file_impl)

    return [list_files, read_file, save_file]

사용 예시

tools = get_systemfile_tools("./workspace")
# 에이전트 초기화 시 등록
# agent = Agent(tools=tools)  # 프레임워크에 맞게 등록

# 또는 직접 호출도 가능(내부적으로 root="./workspace"로 고정됨)
print(tools[0](relative_path=".", glob="*.py"))

클로저 툴의 현실적 주의사항과 대안

  • 워커 프로세스가 별도에서 툴을 로드해야 하는 아키텍처라면 클로저가 직렬화되지 않아 실패할 수 있다. 이 경우 두 가지 대안이 안전하다.

    1. functools.partial 패턴: 최상위 함수에 root 인자를 두고, partial(fn, root=my_root)로 바인딩한 뒤 그 partial에 데코레이터를 씌우거나, 데코레이터가 “함수처럼 호출 가능”하다면 팩토리에서 function_tool(...)(partial(...))로 등록.

    2. 클래스 패턴: class FS: 안에 __init__(root)로 상태를 들고, 최상위 래퍼 함수를 만들어 fs = FS(root)의 메서드를 호출하게 래핑. 이 래퍼만 데코레이트해 등록하면 직렬화 제약을 피하기 쉽다.

지금 제공한 구현은 가장 간결한 클로저-팩토리 버전이다. 만약 네 런타임이 멀티프로세스 직렬화를 요구한다면, 위 대안 패턴으로 바로 바꿔줄 수도 있다.

function_tool이 자동으로 첫줄을 description으로 만드는데 그걸 f string으로 만들고 싶다는거지

좋아. 핵심은 “데코레이터가 첫 줄 docstring을 읽는 타이밍 전에, 함수의 __doc__을 동적으로 채워넣기”야. 파이썬에서 함수 정의 블록 맨 처음의 문자열 리터럴은 컴파일 시 고정되지만, 런타임에 fn.__doc__을 다시 할당하는 건 가능하다. 그러니 팩토리에서 클로저를 만들고, 그 함수들의 __doc__을 f-string 결과로 대입한 다음, function_tool을 “데코레이터처럼 호출”해서 적용하면 된다. 포인트는 “docstring 수정 → function_tool 적용”의 순서.

아래는 동작하는 패턴을 단계별로 풀어쓴 코드다.

  1. 클로저로 root_dir를 캡처

  2. 내부 함수의 원본 docstring은 포맷용 플레이스홀더를 둔다

  3. 정의가 끝난 뒤 .__doc__ = f"...{root_dir}..." 또는 .format(root=root_dir)로 주입

  4. 마지막에 function_tool(...)로 래핑해서 리턴

from __future__ import annotations

import base64
from pathlib import Path
from typing import Iterable, List, Literal, Optional

from agents import function_tool

def _resolve_safe(root: Path, relpath: str) -> Path:
    root = root.resolve()
    p = (root / relpath).expanduser().resolve()
    try:
        if not p.is_relative_to(root):  # py3.9+
            raise ValueError("Path escapes root_dir")
    except AttributeError:
        if root not in p.parents and p != root:
            raise ValueError("Path escapes root_dir")
    return p

def get_systemfile_tools(root_dir: str):
    """
    root_dir를 캡처한 파일 시스템 툴들을 만들고, docstring의 첫 줄을 동적으로 채운 뒤 function_tool로 래핑해 반환한다.
    """
    root = Path(root_dir).expanduser().resolve()
    root.mkdir(parents=True, exist_ok=True)

    # --- list_files ---------------------------------------------------------
    def _list_files_impl(
        relative_path: str = ".",
        glob: Optional[str] = None,
        recursive: bool = True,
        include_hidden: bool = False,
        max_items: Optional[int] = None,
    ) -> dict:
        """List files under {root}: glob/recursive/hidden/max_items supported."""
        base = _resolve_safe(root, relative_path)
        if not base.exists():
            return {
                "root": str(root),
                "base": relative_path,
                "count": 0,
                "entries": [],
                "warning": "Base path does not exist.",
            }

        def iter_paths() -> Iterable[Path]:
            if glob:
                pattern = f"**/{glob}" if recursive else glob
                yield from base.glob(pattern)
            else:
                if recursive:
                    yield from base.rglob("*")
                else:
                    yield from base.iterdir()

        items: List[dict] = []
        for p in iter_paths():
            if p == base or not p.exists():
                continue
            if not include_hidden and p.name.startswith("."):
                continue
            safe = _resolve_safe(root, str(p.relative_to(root)))
            items.append({
                "path": str(safe.relative_to(root)),
                "type": "dir" if safe.is_dir() else "file",
                "size": safe.stat().st_size if safe.is_file() else None,
            })
            if max_items is not None and len(items) >= max_items:
                break

        return {"root": str(root), "base": relative_path, "count": len(items), "entries": items}

    # docstring 첫 줄을 동적으로 주입
    _list_files_impl.__doc__ = f"List files under {root}: glob/recursive/hidden/max_items supported.\n" + (
        _list_files_impl.__doc__.split("\n", 1)[1] if _list_files_impl.__doc__ else ""
    )
    list_files = function_tool(_list_files_impl)  # 이 시점에 첫 줄을 읽어 description으로 사용

    # --- read_file ----------------------------------------------------------
    def _read_file_impl(
        relative_path: str,
        mode: Literal["text", "binary"] = "text",
        encoding: str = "utf-8",
        max_bytes: Optional[int] = 2 * 1024 * 1024,
    ) -> dict:
        """Read a file from {root}: return text or Base64 binary with size guard."""
        p = _resolve_safe(root, relative_path)
        if not p.exists():
            raise FileNotFoundError(f"File not found: {relative_path}")
        if p.is_dir():
            raise IsADirectoryError(f"Path is a directory: {relative_path}")

        size = p.stat().st_size
        if max_bytes is not None and size > max_bytes:
            raise ValueError(f"File too large ({size}) > max_bytes={max_bytes}")

        if mode == "text":
            return {"path": str(p.relative_to(root)), "size": size, "mode": "text", "content": p.read_text(encoding=encoding)}
        if mode == "binary":
            data = p.read_bytes()
            return {
                "path": str(p.relative_to(root)),
                "size": size,
                "mode": "binary",
                "content_b64": base64.b64encode(data).decode("ascii"),
            }
        raise ValueError("mode must be 'text' or 'binary'")

    _read_file_impl.__doc__ = f"Read a file from {root}: return text or Base64 binary with size guard.\n" + (
        _read_file_impl.__doc__.split("\n", 1)[1] if _read_file_impl.__doc__ else ""
    )
    read_file = function_tool(_read_file_impl)

    # --- save_file ----------------------------------------------------------
    def _save_file_impl(
        relative_path: str,
        content: Optional[str] = None,
        *,
        content_b64: Optional[str] = None,
        mode: Literal["text", "binary"] = "text",
        encoding: str = "utf-8",
        overwrite: bool = False,
        create_parents: bool = True,
    ) -> dict:
        """Save a file under {root}: text/binary, overwrite and parent creation options."""
        p = _resolve_safe(root, relative_path)
        if p.exists() and p.is_dir():
            raise IsADirectoryError(f"Path is a directory: {relative_path}")

        if create_parents:
            p.parent.mkdir(parents=True, exist_ok=True)

        existed = p.exists()
        if existed and not overwrite:
            raise FileExistsError(f"File already exists (overwrite=False): {relative_path}")

        if mode == "text":
            if content is None:
                raise ValueError("content is required when mode='text'")
            p.write_text(content, encoding=encoding)
        elif mode == "binary":
            if content_b64 is None:
                raise ValueError("content_b64 is required when mode='binary'")
            p.write_bytes(base64.b64decode(content_b64.encode("ascii")))
        else:
            raise ValueError("mode must be 'text' or 'binary'")

        return {
            "path": str(p.relative_to(root)),
            "size": p.stat().st_size,
            "mode": mode,
            "created": not existed,
            "overwrote": existed,
        }

    _save_file_impl.__doc__ = f"Save a file under {root}: text/binary, overwrite and parent creation options.\n" + (
        _save_file_impl.__doc__.split("\n", 1)[1] if _save_file_impl.__doc__ else ""
    )
    save_file = function_tool(_save_file_impl)

    return [list_files, read_file, save_file]

이 패턴의 이유와 주의점 정리

  1. 대부분의 function_tool 구현은 “래핑 시점”에 fn.__doc__의 첫 줄을 읽어 description으로 씀. 따라서 반드시 docstring을 바꾼 뒤에 function_tool(fn)을 호출해야 한다.

  2. 데코레이터 구문 @function_tool를 함수 정의 위에 바로 붙이면, 데코레이터가 먼저 실행되므로 그 뒤의 __doc__ 주입이 반영되지 않는다. 지금처럼 “call-style 데코레이션”으로 해결한다.

  3. f-string을 함수 본문 첫 줄 리터럴에 직접 쓸 수는 없다. 대신 런타임에 __doc__에 f-string 결과를 대입하거나, 템플릿 문자열을 두고 .format(root=root_dir)로 치환하면 된다.

  4. 만약 네 function_tool이 description 인자를 직접 받는다면 더 간단히 function_tool(description=f"...{root}...")(fn)로 끝낼 수 있다. 하지만 “첫 줄 docstring 자동 사용” 규약을 유지하려면 지금 방식이 가장 호환성이 좋다.

확장 팁

  • 첫 줄 외에 상세 도움말을 계속 남기고 싶으면, 원래 docstring을 읽어 두고 첫 줄만 덮어쓴 뒤 나머지를 이어붙이는 지금 방식을 쓰면 된다.

  • 직렬화가 필요한 환경에서는 이 클로저를 최상위 함수+functools.partial로 바꿔도 동일한 방식으로 __doc__ 주입 후 래핑이 가능하다.

Categories:

Updated: