Devin.KR

도구 설계와 권한 - 이름·입력·출력·위험도

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 에이전트는 다음 행동을 고르고, 도구를 실행하고, 실행 결과를 관찰하는 반복 구조로 움직였다. 그런데 행동을 골랐다는 사실만으로 실행해도 되는 것은 아니다. 문서를 읽는 행동과 문서를 지우는 행동은 같은 권한으로 다루기 어렵다. 모델이 올바른 도구 이름을 골랐더라도 입력이 잘못되거나 사용자가 허용한 범위를 벗어날 수 있다.

이 장에서는 도구 호출을 실행하기 전에 검사하는 작은 경계를 만든다. 도구 등록부에는 이름, 설명, 입력 형식, 출력 약속, 필요한 권한과 위험도를 기록한다. 권한 검사기는 모델의 제안을 이 정의와 대조한다. 모델 호출은 규칙 기반 가짜 모델로 대신하므로 같은 프로그램을 실행하면 같은 허용·거부 사례가 나온다.

  • 도구 정의에 이름·설명·입력·출력의 약속을 담는다.
  • 서로 겹치는 기능을 나누고 읽기·쓰기·삭제 권한을 분리한다.
  • 권한이 있는 동작에도 별도의 확인이 필요할 수 있음을 구현한다.
  • 입력 오류와 실행 오류를 일정한 형식으로 반환한다.
  • 허용·거부 결과와 실제 파일 상태를 함께 확인한다.

문제 상황

작은 문서 정리 하네스가 있다고 하자. 사용자는 초안을 읽고 필요한 문장을 고치도록 요청했다. 개발자는 편의를 위해 읽기, 쓰기, 삭제를 모두 처리하는 파일 도구 하나를 제공했다. 이 도구는 입력의 동작 값에 따라 파일을 읽거나 덮어쓰거나 지운다. 설명에는 단순히 “문서를 관리한다”라고 적혀 있다.

이 상태에서는 호출을 검토하기 어렵다. 같은 도구 이름 아래에서 어떤 요청은 읽기만 하고, 어떤 요청은 기존 내용을 바꾼다. 도구 이름만 기록한 실행 내역을 보면 두 요청이 같은 종류의 행동처럼 보인다. 모델이 정리라는 말을 삭제로 해석해도 실행 함수는 그 차이를 따로 판단하지 않는다.

문제는 모델이 문장을 이해하는 능력에만 있지 않다. 하네스가 행동의 경계를 명확하게 표현하지 않은 것도 원인이다. 읽기와 수정이 허용됐다고 해서 삭제까지 허용됐다고 볼 수는 없다. 수정 권한이 있더라도 어떤 파일을 어떤 내용으로 덮어쓸지는 확인해야 할 수 있다. 도구 정의와 실행 전 검사가 이 차이를 코드로 표현해야 한다.

이번 예제에서는 임시 폴더에 report.txt 하나를 만든다. 가짜 모델은 읽기, 확인 없는 쓰기, 확인된 쓰기, 삭제 요청 등을 순서대로 제안한다. 하네스는 모든 제안을 받아 실행하지 않는다. 허용한 요청만 실행하고 나머지는 이유를 담은 결과로 돌려준다. 프로그램이 끝나면 임시 폴더도 함께 정리된다.

도구는 작은 실행 계약이다

도구(tool)는 모델이 제안할 수 있는 이름 붙은 기능이다. 여기서 중요한 것은 함수가 존재한다는 사실보다 그 함수를 어떤 조건으로 부를 수 있는가이다. 이름은 기능을 식별하고, 설명은 사용 시점을 알려주며, 입력 형식은 받을 수 있는 값을 제한한다. 출력 약속은 호출자가 무엇을 관찰할 수 있는지 정한다.

설명에는 기능뿐 아니라 경계도 적는다. “파일을 처리한다”보다 “허용된 문서 이름의 UTF-8 내용을 읽는다”가 유용하다. 쓰기 도구에는 기존 내용을 덮어쓴다는 사실을 적는다. 삭제 도구에는 파일을 제거한다는 사실을 적는다. 모델이 이 설명을 참고하더라도 실제 경계는 실행 코드가 검사해야 한다. 설명문만으로 권한이 생기거나 입력이 검증되지는 않는다.

예제의 도구는 기능과 권한을 각각 드러낸다
이름입력성공 시 데이터권한·위험도
read_textpath: 문자열text: 문자열read·낮음
write_textpath, text: 문자열written_chars: 정수write·높음
delete_filepath: 문자열deleted: 참delete·높음

기능이 겹치지 않게 나눈다는 것은 모든 함수를 잘게 쪼갠다는 뜻이 아니다. 같은 목적을 두 이름으로 제공해 선택을 어렵게 만들지 않는다는 뜻이다. read_text와 load_document가 같은 파일을 같은 방식으로 읽는다면 차이를 설명하거나 하나로 합치는 편이 낫다. 반대로 읽기와 삭제는 실행 결과와 필요한 권한이 다르므로 분리할 근거가 충분하다.

도구의 개수보다 호출 하나가 뜻하는 행동이 분명한지가 중요하다. 쓰기 도구 안에서 필요에 따라 다른 파일을 지우도록 만들면 호출 이름과 실제 효과가 어긋난다. 이런 숨은 부수 효과가 있으면 권한 검사도 부정확해진다. 이번 예제의 세 함수는 각각 읽기, 덮어쓰기, 삭제만 수행한다.

입력 형식은 필수 키와 값의 자료형을 지정한다. 이번 장에서는 딕셔너리에 선언된 키가 정확히 있어야 하고 각 값의 자료형도 일치해야 한다. 누락한 키뿐 아니라 추가한 키도 거부한다. 예를 들어 쓰기 요청에 approved라는 키를 덧붙여도 입력 오류다. 모델이 추가한 값이 실행 정책을 바꾸지 못하도록 한다.

모델의 제안은 도구 정의와 실행 전 검사를 통과한 뒤에만 파일 함수에 도달한다

출력 역시 약속이다. 성공한 읽기는 내용 문자열을, 성공한 쓰기는 기록한 문자 수를 반환한다. 바깥쪽 결과는 모든 도구가 같은 모양을 쓴다. 성공 여부를 나타내는 ok, 성공 데이터를 담는 data, 실패 이유를 담는 error를 둔다. 호출자는 먼저 ok를 확인한 뒤 해당 부분을 읽는다.

이 예제의 출력 약속은 문서와 함수 구현으로 맞춘다. 출력 자료형을 검사하는 별도 검사기까지 만들지는 않는다. 대신 성공 응답과 오류 응답이 같은 바깥 구조를 지키는지 확인한다. 도구를 추가할 때에는 입력 선언만 채우고 끝내지 말고, 성공 데이터가 설명한 모양으로 나오는지도 실행해 봐야 한다.

권한과 확인은 서로 다른 질문이다

권한(permission)은 이 실행에서 어떤 종류의 행동을 허용했는지를 뜻한다. 예제의 실행 권한은 read와 write다. delete는 없다. 따라서 삭제 요청은 파일 이름이 올바르고 입력 형식도 맞더라도 거부한다. 모델이 삭제를 제안했다는 사실은 이 권한 집합을 바꾸지 않는다.

확인(confirmation)은 허용된 종류의 행동 중 이번 구체적인 요청을 실행해도 되는지를 묻는다. write 권한은 있지만 report.txt의 내용을 바꾸는 요청에는 확인이 필요하도록 설정한다. 읽기는 낮은 위험도, 쓰기와 삭제는 높은 위험도로 분류한다. 이는 이번 문서 예제의 정책이며 모든 시스템의 고정 분류는 아니다. 민감한 내용을 외부로 보내는 읽기라면 더 엄격한 경계가 필요할 수 있다.

확인 대상을 도구 이름만으로 표현하면 범위가 너무 넓어진다. write_text를 한 번 확인했다고 해서 이후의 모든 쓰기를 허용해서는 안 된다. 파일 이름만 비교해도 내용이 달라진 요청을 구별하지 못한다. 예제에서는 도구 이름과 전체 입력을 정렬된 JSON 문자열로 묶은 요청 키를 만든다. 승인 집합에 이 키가 있을 때만 해당 요청을 실행한다.

여기서 승인 집합은 사용자가 입력한 확인 결과를 대신하는 하네스 내부의 값이다. 가짜 모델은 이 집합을 만들거나 수정하지 않는다. 완성 코드에서는 확인된 쓰기 한 건을 미리 넣어 두므로 입력을 기다리지 않고 바로 끝난다. 실제 화면에서는 대상 파일과 덮어쓸 내용을 보여 준 뒤 사용자가 확인한 요청의 키를 하네스가 보관하면 된다.

승인은 한 번 사용하면 제거한다. 같은 요청을 다시 제안해도 새 확인이 필요하다. 제거 시점은 실행 직전이다. 따라서 승인된 실행이 파일 오류로 실패했더라도 그 승인은 소비된다. 재시도할 때 다시 확인하도록 정한 정책이다. 결과를 보고 승인 사용 여부를 되돌리는 정책도 가능하지만, 그 경우 중간에 일부 변경이 발생했는지 따로 판단해야 한다.

검사 순서도 정책의 일부다. 예제는 도구 이름, 입력 구조, 권한, 승인 순서로 검사한다. 삭제 권한이 없는 요청에 승인부터 요구하지 않는다. 권한 없는 요청은 확인을 받아도 실행할 수 없기 때문이다. 승인 여부는 권한 검사를 대신하지 않고 그 뒤에 추가되는 조건이다.

실행 권한이 있어야 구체적인 요청의 승인을 검사하며 두 조건을 모두 만족해야 실행한다

오류도 모델이 읽을 관찰이다

도구 오류를 긴 예외 문자열로만 반환하면 호출자가 다음 행동을 고르기 어렵다. 입력을 고쳐야 하는지, 파일이 없는지, 확인이 필요한지 구별할 수 있어야 한다. 이 장에서는 오류에 code, message, retryable을 넣는다. code는 분기할 수 있는 짧은 식별자이고 message는 사람이 읽을 설명이다.

retryable은 같은 입력으로 다시 시도할 여지가 있는지를 나타낸다. 이번 예제의 실패는 모두 거짓을 반환한다. 잘못된 입력은 수정해야 하고, 권한 거부는 정책이 바뀌어야 하며, 승인 요구는 확인이 추가되어야 한다. 승인 요구가 나왔다고 같은 요청을 즉시 반복하는 것은 해결책이 아니다.

오류 코드는 다음 행동에 필요한 차이를 보존한다
코드의미필요한 후속 행동
UNKNOWN_TOOL등록되지 않은 이름등록된 도구에서 다시 선택한다
INVALID_INPUT키 또는 자료형 오류입력을 정의에 맞게 고친다
PERMISSION_DENIED필요한 권한 없음현재 요청의 실행을 중단한다
APPROVAL_REQUIRED구체적인 요청의 승인 없음사용자 확인을 얻는다
NOT_FOUND대상 파일 없음대상 이름과 존재 여부를 확인한다

예상 가능한 실패는 오류 결과로 바꾸지만 모든 예외를 감추지는 않는다. 파일 없음, 잘못된 이름, 파일 처리 오류, 문자 해석 오류는 호출 실패로 반환한다. 함수 구현의 오타 같은 예상하지 못한 문제는 그대로 드러나도록 둔다. 개발 중 버그까지 일반 실패로 감추면 실행이 겉으로 끝나더라도 잘못된 구현을 놓치기 쉽다.

오류 설명에는 필요한 사실만 넣는다. 임시 폴더의 실제 경로나 내부 예외 문자열을 그대로 출력하지 않는다. 경로가 매번 달라지면 출력 비교가 어려워지고, 호출자에게 필요 없는 환경 정보도 섞인다. 이번 장에서는 “파일이 없다”처럼 안정적인 설명을 사용한다.

완성 코드

다음 코드를 main.py로 저장한다. 외부 패키지, API 키, 네트워크 연결은 필요 없다. 파일 접근은 프로그램이 만든 임시 폴더 안에서만 일어난다. 허용하는 파일 이름은 영문자·숫자·밑줄·하이픈으로 이루어진 이름과 .txt 확장자다. 하위 폴더와 상위 경로 표기는 받지 않는다.

코드는 요청이 순서대로 실행되는 닫힌 실습 환경을 전제로 한다. 외부 프로세스가 임시 폴더의 파일을 동시에 바꾸는 상황까지 다루는 파일 접근 계층은 아니다. 단순한 이름 제한을 실제 서비스의 모든 파일 경계에 그대로 적용해서는 안 된다.

import json
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable


def success(data: dict) -> dict:
    return {"ok": True, "data": data, "error": None}


def failure(code: str, message: str) -> dict:
    return {
        "ok": False,
        "data": None,
        "error": {
            "code": code,
            "message": message,
            "retryable": False,
        },
    }


@dataclass(frozen=True)
class Tool:
    name: str
    description: str
    inputs: tuple[tuple[str, type], ...]
    output: str
    permission: str
    risk: str
    handler: Callable[[Path, dict], dict]


def checked_path(root: Path, name: str) -> Path:
    if re.fullmatch(r"[A-Za-z0-9_-]+\.txt", name) is None:
        raise ValueError("허용하지 않는 파일 이름이다")
    path = root / name
    if path.is_symlink():
        raise ValueError("심볼릭 링크는 허용하지 않는다")
    return path


def read_text(root: Path, args: dict) -> dict:
    path = checked_path(root, args["path"])
    return {"text": path.read_text(encoding="utf-8")}


def write_text(root: Path, args: dict) -> dict:
    path = checked_path(root, args["path"])
    count = path.write_text(args["text"], encoding="utf-8")
    return {"written_chars": count}


def delete_file(root: Path, args: dict) -> dict:
    path = checked_path(root, args["path"])
    path.unlink()
    return {"deleted": True}


def build_registry() -> dict[str, Tool]:
    definitions = (
        Tool(
            "read_text",
            "허용된 문서 이름의 UTF-8 내용을 읽는다.",
            (("path", str),),
            "text: 문자열",
            "read",
            "low",
            read_text,
        ),
        Tool(
            "write_text",
            "허용된 문서 이름에 UTF-8 내용을 덮어쓴다.",
            (("path", str), ("text", str)),
            "written_chars: 정수",
            "write",
            "high",
            write_text,
        ),
        Tool(
            "delete_file",
            "허용된 문서 이름의 파일을 삭제한다.",
            (("path", str),),
            "deleted: 참",
            "delete",
            "high",
            delete_file,
        ),
    )
    registry = {}
    for tool in definitions:
        if tool.name in registry:
            raise ValueError("도구 이름이 중복됐다")
        if tool.risk not in {"low", "high"}:
            raise ValueError("알 수 없는 위험도다")
        registry[tool.name] = tool
    return registry


def request_key(name: str, args: dict) -> str:
    return json.dumps(
        {"tool": name, "args": args},
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    )


class Harness:
    def __init__(
        self,
        root: Path,
        registry: dict[str, Tool],
        permissions: frozenset[str],
        approvals: set[str],
    ) -> None:
        self.root = root
        self.registry = dict(registry)
        self.permissions = permissions
        self.approvals = set(approvals)

    def execute(self, name: str, args: object) -> dict:
        tool = self.registry.get(name)
        if tool is None:
            return failure("UNKNOWN_TOOL", "등록되지 않은 도구다")

        if type(args) is not dict:
            return failure("INVALID_INPUT", "입력은 딕셔너리여야 한다")

        expected = dict(tool.inputs)
        if set(args) != set(expected):
            return failure("INVALID_INPUT", "입력 키가 정의와 다르다")

        for key, value_type in expected.items():
            if type(args[key]) is not value_type:
                return failure(
                    "INVALID_INPUT",
                    f"{key}의 자료형이 올바르지 않다",
                )

        if tool.permission not in self.permissions:
            return failure("PERMISSION_DENIED", "필요한 권한이 없다")

        if tool.risk == "high":
            approval = request_key(name, args)
            if approval not in self.approvals:
                return failure(
                    "APPROVAL_REQUIRED",
                    "이 요청에 대한 승인이 필요하다",
                )
            self.approvals.remove(approval)

        try:
            return success(tool.handler(self.root, args))
        except FileNotFoundError:
            return failure("NOT_FOUND", "파일이 없다")
        except ValueError as exc:
            return failure("INVALID_INPUT", str(exc))
        except (OSError, UnicodeError):
            return failure("IO_ERROR", "파일을 처리할 수 없다")


def fake_model(task: str) -> dict:
    if task == "읽기":
        return {"tool": "read_text", "args": {"path": "report.txt"}}
    if task == "미승인 쓰기":
        return {
            "tool": "write_text",
            "args": {"path": "report.txt", "text": "미확인 변경"},
        }
    if task in {"승인 쓰기", "승인 재사용"}:
        return {
            "tool": "write_text",
            "args": {"path": "report.txt", "text": "검토 완료"},
        }
    if task == "삭제":
        return {"tool": "delete_file", "args": {"path": "report.txt"}}
    if task == "잘못된 입력":
        return {"tool": "read_text", "args": {"path": 7}}
    if task == "없는 파일":
        return {"tool": "read_text", "args": {"path": "missing.txt"}}
    if task == "범위 밖 이름":
        return {"tool": "read_text", "args": {"path": "../report.txt"}}
    if task == "최종 읽기":
        return {"tool": "read_text", "args": {"path": "report.txt"}}
    return {"tool": "unknown", "args": {}}


def show(task: str, result: dict) -> None:
    if result["ok"]:
        data = json.dumps(result["data"], ensure_ascii=False, sort_keys=True)
        print(f"{task}: 허용 | {data}")
    else:
        error = result["error"]
        print(f'{task}: 거부 | {error["code"]} | {error["message"]}')


def main() -> None:
    registry = build_registry()
    confirmed_args = {"path": "report.txt", "text": "검토 완료"}
    approvals = {request_key("write_text", confirmed_args)}
    tasks = (
        "읽기",
        "미승인 쓰기",
        "승인 쓰기",
        "승인 재사용",
        "삭제",
        "잘못된 입력",
        "없는 파일",
        "범위 밖 이름",
        "알 수 없는 도구",
        "최종 읽기",
    )
    expected_codes = (
        None,
        "APPROVAL_REQUIRED",
        None,
        "APPROVAL_REQUIRED",
        "PERMISSION_DENIED",
        "INVALID_INPUT",
        "NOT_FOUND",
        "INVALID_INPUT",
        "UNKNOWN_TOOL",
        None,
    )

    with tempfile.TemporaryDirectory() as directory:
        root = Path(directory)
        document = root / "report.txt"
        document.write_text("초안", encoding="utf-8")
        harness = Harness(
            root,
            registry,
            frozenset({"read", "write"}),
            approvals,
        )
        for task, expected_code in zip(tasks, expected_codes, strict=True):
            call = fake_model(task)
            result = harness.execute(call["tool"], call["args"])
            show(task, result)
            actual_code = None if result["ok"] else result["error"]["code"]
            assert actual_code == expected_code
            expected_text = (
                "초안" if task in {"읽기", "미승인 쓰기"} else "검토 완료"
            )
            assert document.read_text(encoding="utf-8") == expected_text

        assert harness.approvals == set()
        print("검증: 통과")


if __name__ == "__main__":
    main()

줄별 해설

처음의 success와 failure는 바깥 결과 형식을 고정한다. 성공 결과에도 error 키가 있고 실패 결과에도 data 키가 있다. 호출자는 키가 존재하는지부터 추측할 필요가 없다. 실패에서는 데이터 대신 None을 사용하고 오류의 식별자와 설명을 분리한다.

Tool 선언은 도구 하나의 계약을 표현한다. dataclass는 선언한 필드의 값을 받아 객체를 만드는 코드를 줄여 준다. frozen=True는 생성한 도구 정의의 필드를 나중에 대입해 바꾸지 못하게 한다. inputs도 수정 가능한 딕셔너리 대신 키·자료형 쌍의 튜플을 사용한다. handler에는 실제 파일 동작을 하는 함수를 넣는다.

checked_path는 모델이 제안한 이름을 파일 경로로 바꾸는 한 곳이다. 정규식의 fullmatch는 문자열 전체가 이름 규칙에 맞는지 검사한다. ../report.txt나 하위 폴더 표기는 규칙에 맞지 않는다. 이미 존재하는 심볼릭 링크도 거부한다. 세 파일 함수가 같은 경로 검사를 거치므로 특정 함수만 경계를 빠뜨리는 일을 줄인다.

read_text는 내용을 반환하고 write_text는 쓴 문자 수를 반환한다. 문자 수는 파일의 바이트 수와 다르다. “검토 완료”는 공백까지 다섯 문자이므로 반환값은 5다. delete_file은 unlink가 성공한 뒤에만 deleted 값을 돌려준다. 실행 전의 기대를 성공 데이터처럼 먼저 반환하지 않는다.

build_registry는 선언을 이름으로 찾을 수 있는 딕셔너리로 바꾼다. 같은 이름을 두 번 등록하면 조용히 덮어쓰지 않고 오류를 낸다. 위험도에 오타가 있어 확인 검사를 건너뛰는 일을 막기 위해 허용한 위험도 값도 검사한다. 등록부 자체를 구성하는 오류는 모델의 호출 오류가 아니라 개발자가 고칠 설정 오류다.

request_key는 이름과 입력을 JSON 문자열로 묶는다. sort_keys=True 덕분에 딕셔너리의 키 삽입 순서가 달라도 같은 키가 된다. 이 문자열은 인증 수단이 아니라 확인된 요청을 비교하는 표현이다. 이 예제에서는 검증을 통과한 문자열 입력에만 사용하므로 복잡한 값의 직렬화 문제를 다루지 않는다.

Harness의 생성자는 등록부와 승인 집합을 복사한다. 원본 승인 집합을 나중에 수정해 실행 중인 하네스의 승인을 우연히 바꾸는 일을 줄인다. permissions는 frozenset이므로 구성한 뒤 원소를 추가하거나 제거할 수 없다. 다만 이러한 객체 선택만으로 실행 환경 전체가 보호되는 것은 아니며, 정책 값을 누가 구성하는지도 분명해야 한다.

execute는 등록부에서 이름을 찾고 입력을 검사한다. type으로 정확한 자료형을 비교하는 방식은 이번 예제의 단순한 계약이다. 나중에 정수 입력을 추가할 때에도 참·거짓 값이 정수의 일종으로 처리되는 혼동을 피할 수 있다. 다양한 자료형을 허용하고 싶다면 어떤 변환이 가능한지 별도 규칙을 정해야 한다.

그다음 필요한 권한이 있는지 검사한다. 높은 위험도라면 승인 키를 찾고, 없으면 함수를 호출하지 않는다. 승인 키가 있다면 제거한 뒤 handler를 실행한다. 따라서 같은 쓰기 제안이 반복돼도 두 번째 호출에는 승인 요구가 돌아온다. 권한과 승인 검사는 모두 파일 함수 호출보다 앞에 놓인다.

try 구문은 실제 실행에서 예상한 실패를 결과로 바꾼다. 파일 없음은 NOT_FOUND, 경로 검사의 ValueError는 INVALID_INPUT이다. 운영체제의 파일 오류와 문자 해석 오류는 IO_ERROR로 묶는다. 도구 구현에 KeyError가 생기는 것과 같은 예상 밖의 문제는 이 구문이 잡지 않으므로 개발자가 원인을 볼 수 있다.

fake_model은 작업 이름에 따라 호출 딕셔너리를 반환한다. 자유로운 문장을 생성하지 않지만 모델과 실행기의 경계를 연습하기에는 충분하다. 모델은 도구 이름과 입력을 제안할 뿐이다. 권한이나 승인 상태를 함께 반환하지 않으므로 모델의 답에 승인 주장을 섞어 실행 정책으로 받아들이지 않는다.

main은 report.txt를 만들고 확인된 쓰기 한 건을 준비한다. 각 결과의 오류 코드만 검사하는 데서 멈추지 않고 매 호출 뒤 실제 파일 내용도 읽는다. 거부 응답을 출력하면서 파일을 바꿔 버리는 구현은 이 확인을 통과할 수 없다. 마지막에는 승인이 소비됐는지도 검사한다. assert는 실습 검증용이므로 최적화 옵션 없이 실행한다. 실제 권한 거부는 assert가 아니라 execute의 조건문이 담당한다.

실행 결과

먼저 경고를 오류로 취급해 컴파일하고 프로그램을 실행한다. 첫 명령은 정상이라면 별도 출력을 내지 않는다. 컴파일 과정의 캐시 파일 생성을 피하려고 소스 문자열을 compile 함수에 전달한다. 실행 중 파일은 임시 폴더에만 생성된다.

python3 -W error -c 'from pathlib import Path; compile(Path("main.py").read_text(encoding="utf-8"), "main.py", "exec")'
python3 main.py

예상 출력은 다음과 같다. 임시 폴더 이름, 현재 시각, 난수가 출력에 들어가지 않으므로 실행마다 같다.

읽기: 허용 | {"text": "초안"}
미승인 쓰기: 거부 | APPROVAL_REQUIRED | 이 요청에 대한 승인이 필요하다
승인 쓰기: 허용 | {"written_chars": 5}
승인 재사용: 거부 | APPROVAL_REQUIRED | 이 요청에 대한 승인이 필요하다
삭제: 거부 | PERMISSION_DENIED | 필요한 권한이 없다
잘못된 입력: 거부 | INVALID_INPUT | path의 자료형이 올바르지 않다
없는 파일: 거부 | NOT_FOUND | 파일이 없다
범위 밖 이름: 거부 | INVALID_INPUT | 허용하지 않는 파일 이름이다
알 수 없는 도구: 거부 | UNKNOWN_TOOL | 등록되지 않은 도구다
최종 읽기: 허용 | {"text": "검토 완료"}
검증: 통과

출력의 “거부”는 호출이 성공하지 않았다는 공통 표시다. 권한 검사에서 막힌 요청뿐 아니라 파일이 없어 실행에 실패한 요청도 이 표시를 쓴다. 세부 원인은 오류 코드로 구별한다. 관측 화면을 만들 때도 거부라는 문구 하나만 세고 끝내지 말고 어떤 코드가 발생했는지 확인해야 한다.

미승인 쓰기 뒤에는 초안이 남고, 승인 쓰기 뒤에는 검토 완료가 남는다. 삭제가 거부된 뒤에도 파일을 읽을 수 있다. 이 세 가지가 기대한 상태 변화다. AI가 작성한 코드를 검토할 때에는 출력 문구만 믿지 않고 이런 파일 상태를 함께 확인한다.

코드를 수정했다면 변경 전후 비교(diff)도 살핀다. 특히 handler 호출이 검사 앞으로 이동했는지, 승인 키에 text가 빠졌는지, delete 권한이 기본값에 추가됐는지를 본다. 실행 결과가 같더라도 아직 실행하지 않은 분기의 정책은 달라졌을 수 있다. 컴파일은 문법을 확인하고 실행은 사례를 확인하며 변경 비교는 의도하지 않은 수정 범위를 확인한다.

실무에서 자주 틀리는 것

권한 문자열의 일부를 권한으로 취급한다

아래 함수 정의들은 독립적으로 실행 가능한 작은 예제다. 잘못된 코드는 문자열에서 부분 문자열을 찾는다. 따라서 read_only라는 문자열에도 read가 포함됐다고 판단한다. 권한 이름은 구분된 원소로 보관하고 정확히 일치하는 원소가 있는지 검사한다.

def wrong_permission(required: str, granted: str) -> bool:
    return required in granted


def correct_permission(
    required: str, granted: frozenset[str]
) -> bool:
    return required in granted


assert wrong_permission("read", "read_only") is True
assert correct_permission("read", frozenset({"read_only"})) is False

권한을 여러 개 나열하는 문자열을 만들고 구분자를 임의로 바꾸면 검사 규칙도 함께 복잡해진다. 실행기의 내부 표현은 집합으로 고정하고 화면에 표시할 때만 문자열로 바꾸는 편이 이해하기 쉽다.

모델의 입력에서 승인 여부를 읽는다

잘못된 함수는 요청을 만든 쪽이 approved 값을 넣으면 승인된 것으로 판단한다. 도구를 제안하는 주체와 승인하는 주체의 구분이 사라진다. 고친 함수는 호출 입력 전체를 독립적인 승인 집합과 비교한다. 완성 코드에서는 추가 입력 키 검사도 함께 적용한다.

import json


def wrong_approval(args: dict) -> bool:
    return args.get("approved") is True


def correct_approval(name: str, args: dict, approvals: set[str]) -> bool:
    key = json.dumps(
        {"tool": name, "args": args},
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    )
    return key in approvals


assert wrong_approval({"approved": True}) is True
assert correct_approval("write_text", {"approved": True}, set()) is False

고친 비교 함수도 승인 집합 자체를 모델이 작성한다면 의미가 없다. 승인 데이터는 사용자 확인을 처리하는 하네스 쪽에서 구성해야 한다. 어디에서 얻은 값인가가 자료형만큼 중요하다.

실행한 뒤에 거부한다

거부 결과를 반환했다고 해서 부수 효과가 없었던 것은 아니다. 잘못된 함수는 작업을 실행한 뒤 권한을 확인한다. 호출자가 거부를 관찰할 때 이미 파일이 바뀌었을 수 있다. 고친 함수는 검사에 통과했을 때만 함수를 부른다.

def wrong_order(action, allowed: bool) -> str:
    action()
    if not allowed:
        return "거부"
    return "허용"


def correct_order(action, allowed: bool) -> str:
    if not allowed:
        return "거부"
    action()
    return "허용"


wrong_events = []
correct_events = []
assert wrong_order(lambda: wrong_events.append("실행"), False) == "거부"
assert correct_order(lambda: correct_events.append("실행"), False) == "거부"
assert wrong_events == ["실행"]
assert correct_events == []

이 차이는 결과 문자열만 비교하는 검사로는 잡히지 않는다. 완성 코드가 매 호출 뒤 파일 내용을 확인하는 이유도 같다. 거부가 필요한 사례에서는 호출 결과와 변경 없음이라는 두 조건을 함께 검증한다.

모든 예외를 성공처럼 돌려준다

실패를 빈 성공 데이터로 바꾸면 호출자는 내용이 없는 문서를 읽었다고 오해할 수 있다. 잘못된 함수는 어떤 예외가 생겨도 성공 형식을 반환한다. 고친 함수는 예상한 파일 없음만 오류로 반환하고 다른 문제는 그대로 드러낸다.

def wrong_result(action) -> dict:
    try:
        return {"ok": True, "data": action(), "error": None}
    except Exception:
        return {"ok": True, "data": {}, "error": None}


def correct_result(action) -> dict:
    try:
        return {"ok": True, "data": action(), "error": None}
    except FileNotFoundError:
        return {
            "ok": False,
            "data": None,
            "error": {
                "code": "NOT_FOUND",
                "message": "파일이 없다",
                "retryable": False,
            },
        }

서비스의 가장 바깥 경계에서 예상 밖의 예외를 수집하는 것과 도구 함수 안에서 실패를 성공으로 바꾸는 것은 다르다. 호출 결과가 실패라면 실패로 표시해야 한다. 빈 값은 오류의 대체 표현으로 사용하지 않는다.

한눈에 보기

도구 실행을 검토할 때 확인할 질문과 코드의 위치
질문표현예제의 위치
무슨 행동인가이름·설명·출력 약속Tool과 build_registry
입력이 맞는가필수 키·정확한 자료형execute의 입력 검사
허용한 행동인가read·write·delete 집합permissions 검사
이번 변경을 확인했는가이름과 전체 입력의 요청 키approvals 검사와 제거
어떻게 실패했는가코드·설명·재시도 여부failure와 예외 처리
실제 상태도 맞는가매 호출 뒤 파일 내용 비교main의 assert

모델의 제안과 실행 권한을 분리하면 행동을 거부하는 이유가 분명해진다. 도구 정의는 선택 가능한 행동을 설명하고, 검사기는 실제 실행 조건을 적용한다. 다음 장에서 작업 목록과 상태 파일을 다룰 때에도 작업에 적힌 행동이 이 실행 경계를 통과해야 한다.

연습 문제

  1. “승인 쓰기”의 text만 “검토 완료.”로 바꾸고 승인 집합은 그대로 둔다. 어떤 오류가 나와야 하는지 설명하고, 전체 실행 검증도 그 기대에 맞게 수정한다.
  2. delete 권한을 실행 권한에 추가하되 삭제 승인은 추가하지 않는다. 삭제 요청의 오류가 어떻게 달라지는지 확인한다. 쓰기 승인으로 삭제를 실행할 수 없는 이유도 설명한다.
  3. 읽기 입력에 approved: True를 추가하는 가짜 모델 분기를 만든다. 기대 오류와 파일의 최종 내용을 검증한다.
  4. report_copy.txt로 내용을 복사하는 도구를 추가한다고 하자. 필요한 입력, 권한, 위험도, 성공 데이터를 설계한다. 읽기 도구를 바꾸는 대신 별도 기능으로 두는 이유를 설명한다.

정답과 해설

  1. APPROVAL_REQUIRED가 나와야 한다. 승인 키는 도구 이름과 전체 입력을 포함하므로 마침표 하나가 달라도 다른 요청이다. 이 변경 뒤에는 성공한 쓰기가 없으므로 파일은 초안으로 남는다. 기대 코드뿐 아니라 이후 파일 내용과 최종 읽기의 기대도 초안으로 수정한다. 기존의 승인 재사용 분기를 그대로 두면 그 분기가 원래 승인된 내용을 실행하므로, 같은 변경된 요청을 반복하도록 함께 수정해야 한다. 사용하지 않은 원래 승인이 남는 점도 검증에 반영한다.
  2. 삭제 요청의 오류는 PERMISSION_DENIED에서 APPROVAL_REQUIRED로 바뀐다. 권한 검사를 통과해 다음 승인 검사에 도달하기 때문이다. 쓰기 승인은 write_text와 쓰기 입력을 대상으로 하므로 delete_file의 요청 키와 다르다. 삭제 승인을 추가하지 않은 상태에서는 파일이 남아야 한다.
  3. INVALID_INPUT이 나와야 한다. read_text에 선언된 키는 path 하나인데 approved가 추가되면 입력 키 집합이 다르다. 모델의 승인 주장을 따로 해석하지 않는다. 기존 승인 쓰기를 유지했다면 최종 파일 내용은 검토 완료다. 이 사례를 쓰기 이전에 넣었다면 그 직후에는 초안이 남아야 한다.
  4. 입력은 source와 destination 문자열, 권한은 read와 write 모두, 위험도는 높음으로 설계할 수 있다. 성공 데이터는 복사한 문자 수로 정한다. 현재 Tool은 권한 하나만 받으므로 권한 필드를 여러 권한의 집합으로 바꾸고 모든 필수 권한이 있는지 검사해야 한다. 승인 키에는 출발지와 목적지를 모두 포함한다. 복사는 새 파일을 쓰거나 기존 내용을 덮어쓰므로 읽기만 하는 도구에 숨겨 넣으면 설명과 실제 효과가 어긋난다. 구현 후에는 승인 없는 복사가 목적지 파일을 만들거나 바꾸지 않는지 검증한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.