Python Tool 작성
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종 세트야. 경로 탈출(..)을 막고, 바이너리/텍스트 모두 안전하게 다룰 수 있도록 설계했어.
간단한 설계 요약 (한 줄씩):
-
모듈 전역의
ROOT_DIR로 작업 루트를 고정하고, 모든 입출력은 여기에 상대 경로로만 접근. -
_resolve_safe()에서Path.resolve()로 정규화한 뒤is_relative_to로 루트 내 경로인지 검증. -
list_files는 재귀/글롭/숨김파일 포함 여부, 최대 개수 제한을 제공. -
read_file은 텍스트 또는 바이너리(베이스64) 반환을 선택 가능. 파일 크기 상한(max_bytes)으로 과도한 읽기 방지. -
save_file은 텍스트/베이스64 쓰기, 상위 폴더 자동 생성, 덮어쓰기 가드 제공. -
모든 함수는 풍부한 타입 힌트와 독스트링을 포함하고, 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를 캡처해서 툴로 내려가도, 에이전트가 “파이썬 콜러블을 호출”만 하면 문제없다. 다만 몇 가지 현실적 주의점이 있다.
핵심 포인트 먼저 정리
-
대부분의 런타임(단일 프로세스, 인메모리)은 클로저 툴을 잘 지원한다.
-
멀티프로세스/원격 실행(예: 프로세스 간 큐, 서버리스 워커)에서는 “피클/직렬화 가능한 최상위 함수”를 요구할 수 있다. 그땐 클로저 대신
functools.partial이나 클래스 바인딩 패턴이 안전하다. -
데코레이터는 정의 시점에 적용된다. 팩토리 내부에서 “데코레이터를 함수처럼 호출”해 적용하면 된다.
아래는 네가 제시한 스타일에 맞춘 최소-의존성 구현이다. 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"))
클로저 툴의 현실적 주의사항과 대안
-
워커 프로세스가 별도에서 툴을 로드해야 하는 아키텍처라면 클로저가 직렬화되지 않아 실패할 수 있다. 이 경우 두 가지 대안이 안전하다.
-
functools.partial패턴: 최상위 함수에root인자를 두고,partial(fn, root=my_root)로 바인딩한 뒤 그 partial에 데코레이터를 씌우거나, 데코레이터가 “함수처럼 호출 가능”하다면 팩토리에서function_tool(...)(partial(...))로 등록. -
클래스 패턴:
class FS:안에__init__(root)로 상태를 들고, 최상위 래퍼 함수를 만들어fs = FS(root)의 메서드를 호출하게 래핑. 이 래퍼만 데코레이트해 등록하면 직렬화 제약을 피하기 쉽다.
-
지금 제공한 구현은 가장 간결한 클로저-팩토리 버전이다. 만약 네 런타임이 멀티프로세스 직렬화를 요구한다면, 위 대안 패턴으로 바로 바꿔줄 수도 있다.
function_tool이 자동으로 첫줄을 description으로 만드는데 그걸 f string으로 만들고 싶다는거지
좋아. 핵심은 “데코레이터가 첫 줄 docstring을 읽는 타이밍 전에, 함수의 __doc__을 동적으로 채워넣기”야. 파이썬에서 함수 정의 블록 맨 처음의 문자열 리터럴은 컴파일 시 고정되지만, 런타임에 fn.__doc__을 다시 할당하는 건 가능하다. 그러니 팩토리에서 클로저를 만들고, 그 함수들의 __doc__을 f-string 결과로 대입한 다음, function_tool을 “데코레이터처럼 호출”해서 적용하면 된다. 포인트는 “docstring 수정 → function_tool 적용”의 순서.
아래는 동작하는 패턴을 단계별로 풀어쓴 코드다.
-
클로저로 root_dir를 캡처
-
내부 함수의 원본 docstring은 포맷용 플레이스홀더를 둔다
-
정의가 끝난 뒤
.__doc__ = f"...{root_dir}..."또는.format(root=root_dir)로 주입 -
마지막에
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]
이 패턴의 이유와 주의점 정리
-
대부분의
function_tool구현은 “래핑 시점”에fn.__doc__의 첫 줄을 읽어 description으로 씀. 따라서 반드시 docstring을 바꾼 뒤에function_tool(fn)을 호출해야 한다. -
데코레이터 구문
@function_tool를 함수 정의 위에 바로 붙이면, 데코레이터가 먼저 실행되므로 그 뒤의__doc__주입이 반영되지 않는다. 지금처럼 “call-style 데코레이션”으로 해결한다. -
f-string을 함수 본문 첫 줄 리터럴에 직접 쓸 수는 없다. 대신 런타임에
__doc__에 f-string 결과를 대입하거나, 템플릿 문자열을 두고.format(root=root_dir)로 치환하면 된다. -
만약 네
function_tool이 description 인자를 직접 받는다면 더 간단히function_tool(description=f"...{root}...")(fn)로 끝낼 수 있다. 하지만 “첫 줄 docstring 자동 사용” 규약을 유지하려면 지금 방식이 가장 호환성이 좋다.
확장 팁
-
첫 줄 외에 상세 도움말을 계속 남기고 싶으면, 원래 docstring을 읽어 두고 첫 줄만 덮어쓴 뒤 나머지를 이어붙이는 지금 방식을 쓰면 된다.
-
직렬화가 필요한 환경에서는 이 클로저를 최상위 함수+
functools.partial로 바꿔도 동일한 방식으로__doc__주입 후 래핑이 가능하다.