본문 바로가기

개발/AI

Spring AI로 Pi 스타일 에이전트 하네스 만들기 (6) - Tool 실행 정책

이번 글의 목적

M5에서 read-only Tool을 만들었다면, 이번에는 Tool 실행 정책을 분리합니다.

Tool 자체와 Tool 실행 정책을 분리한다.

 

Spring 개발자에게는 비즈니스 로직과 보안 검사를 분리하는 것과 비슷합니다.

Tool은 실제 일을 합니다.
정책은 그 Tool을 실행해도 되는지 판단합니다.

이번 단계에서는 Spring AI Tool Calling과는 아직 연결하지 않습니다.

먼저 내부 Tool 실행 경로에 정책 계층을 얇게 올립니다.

왜 ToolPolicy가 필요한가

처음에는 각 Tool 안에 검증 코드를 넣을 수 있습니다.

readFile(path) {
    validatePath(path);
    checkSecret(path);
    checkSize(path);
    return content;
}

 

작을 때는 괜찮습니다.

M5의 read-only Tool도 기본 안전장치를 각자 가지고 있습니다.

예를 들어 ReadTool은 workspace 밖 경로를 막고, secret 파일을 막고, 큰 파일과 binary 파일을 거부합니다.

LsTool, FindTool, GrepTool도 secret path를 결과에서 제외합니다.

하지만 이것만으로는 부족합니다.

Tool이 늘어나면 이런 질문이 생깁니다.

이 Tool은 read-only인가?
write Tool은 언제 허용할 것인가?
bash Tool은 어떤 옵션이 켜져야 실행할 수 있는가?
모든 Tool 결과에 같은 output limit을 어떻게 적용할 것인가?
정책 거부는 어떤 형태로 모델에게 돌려줄 것인가?

 

이 판단을 각 Tool 안에 흩어 놓으면 실행 흐름을 따라가기 어려워집니다.

그래서 M6에서는 공통 실행 계층을 둡니다.

ToolPolicy

이번 구현에서 정책 인터페이스는 ToolPolicy입니다.

Tool 실행 전에 검사한다
→ 허용 또는 차단을 결정한다
→ 실행된 결과에 공통 후처리를 적용한다

 

코드 형태는 다음과 같습니다.

public interface ToolPolicy {

    ToolPolicyDecision before(
            AgentTool tool,
            ToolArguments arguments,
            ToolExecutionContext context
    );

    ToolResult after(
            AgentTool tool,
            ToolArguments arguments,
            ToolResult result,
            ToolExecutionContext context
    );
}

 

before는 실행 전 정책입니다.
after는 실행된 결과에 적용하는 후처리입니다.

여기서 중요한 점은 거부된 Tool에는 after를 호출하지 않는다는 것입니다.

after는 말 그대로 실행 이후의 hook입니다.

정책이 Tool 실행을 거부하면 즉시 error ToolResult를 반환합니다.

ToolPolicyDecision

정책 판단 결과는 ToolPolicyDecision으로 표현합니다.

public record ToolPolicyDecision(
        boolean allowed,
        String reason
) {
}

 

허용이면 allowed=true입니다.
차단이면 allowed=false이고, 사람이 읽을 수 있는 이유가 들어갑니다.

예를 들어 write Tool이 꺼져 있으면 이런 식입니다.

tool requires allowWrite=true: write

 

bash Tool도 마찬가지입니다.

tool requires allowBash=true: bash

 

정책 실패는 로그에만 묻히면 안 됩니다.

Tool 실행 결과로도 남아야 하고, 나중에 모델에게도 전달할 수 있어야 합니다.

ToolExecutionContext

정책은 실행 옵션을 알아야 합니다.

M6에서는 ToolExecutionContext가 그 역할을 합니다.

public record ToolExecutionContext(
        AgentRunOptions options
) {
}

 

AgentRunOptions에는 이미 다음 옵션이 있습니다.

allowWrite = false
allowBash = false

 

기본값은 read-only입니다.

그래서 read-only Tool은 기본 허용됩니다.
write Tool은 allowWrite=true일 때만 허용됩니다.
bash Tool은 allowBash=true일 때만 허용됩니다.

아직 실제 write/edit/bash Tool은 만들지는 않았습니다.

대신 테스트용 stub tool로 정책이 제대로 동작하는지 검증했습니다.

ToolExecutionService

Tool 실행은 이제 ToolExecutionService를 거칩니다.

흐름은 다음과 같습니다.

ToolExecutionService
→ ToolPolicy.before(...)
→ 거부면 error ToolResult 반환
→ 허용이면 tool.execute(...)
→ 실행 중 RuntimeException은 error ToolResult로 변환
→ ToolPolicy.after(...)로 공통 후처리

 

코드로 보면 이런 모양입니다.

public ToolResult execute(
        AgentTool tool,
        ToolArguments arguments,
        ToolExecutionContext context
) {
    ToolPolicyDecision decision = toolPolicy.before(tool, arguments, context);

    if (decision.denied()) {
        return ToolResult.error(decision.reason(), Map.of("policyDenied", true));
    }

    ToolResult result;
    try {
        result = tool.execute(arguments);
    } catch (RuntimeException exception) {
        result = ToolResult.error(
                "tool execution failed: " + exceptionMessage(exception),
                Map.of(
                        "exception", exception.getClass().getName(),
                        "exceptionMessage", exceptionMessage(exception)
                )
        );
    }

    return toolPolicy.after(tool, arguments, result, context);
}

 

 

첫째, 정책으로 거부된 Tool은 실행하지 않습니다.
둘째, Tool 실행 중 발생한 런타임 예외는 agent loop를 깨지 않고 error ToolResult로 바꿉니다.

Tool은 앞으로 더 늘어날 예정입니다.

공통 실행 경계에서 실패를 일정한 형태로 다루는 편이 안전합니다.

output limit도 정책이다

출력 제한은 단순 편의 기능이 아닙니다.

AI Agent에서 출력 제한은 비용, 성능, 안전성과 연결됩니다.

예를 들어 large.log 전체를 모델에 보내면 다음 문제가 생깁니다.

  • 토큰 비용 증가
  • context window 낭비
  • 응답 지연
  • 중요한 정보가 묻힘

M5에서도 각 Tool은 자체 output limit을 가지고 있었습니다.

M6에서는 한 번 더 공통 후처리를 둡니다.

DefaultToolPolicy.after(...)는 모든 실행 결과에 maxToolOutputChars를 적용합니다.

결과가 너무 길면 텍스트를 자르고 metadata에 표시합니다.

truncated = true

 

이렇게 하면 Tool마다 실수로 긴 출력을 반환하더라도, 공통 실행 계층에서 한 번 더 막을 수 있습니다.

ToolSafetyLevel

M5에서는 READ_ONLY만 있었습니다.

M6에서는 safety level을 최소한으로 확장했습니다.

public enum ToolSafetyLevel {
    READ_ONLY,
    WRITE,
    BASH
}

 

실제 write/edit/bash Tool은 아직 없습니다.

하지만 정책 계층이 먼저 있어야 다음 단계에서 안전하게 붙일 수 있습니다.

ToolRegistryAgentRunOptions를 보고 활성 Tool을 걸러낼 수 있게 바뀌었습니다.

READ_ONLY → enabledByDefault 기준
WRITE     → allowWrite=true일 때
BASH      → allowBash=true일 때

 

이 구조 덕분에 나중에 write/edit/bash Tool을 추가해도 실행 옵션을 같은 방식으로 적용할 수 있습니다.

Audit log는 아직 하지 않았다

원래 생각으로는 M6에서 audit log까지 넣을 수 있었습니다.

언제
어떤 session에서
어떤 tool이
어떤 argument로
허용/차단되었는지

하지만 이번 구현에서는 넣지 않았습니다.

아직 AgentRuntime과 session/event 흐름이 연결되지 않았기 때문입니다.

지금 audit log를 억지로 넣으면 나중에 이벤트/세션 저장과 중복될 가능성이 큽니다.

그래서 M6에서는 정책 판단과 실행 경계까지만 구현했습니다.

Tool 실행 이벤트와 audit trail은 AgentRuntime과 SSE를 붙이는 단계에서 다루는 편이 자연스럽습니다.

이 단계의 완료 기준

이번 M6의 완료 기준은 다음과 같습니다.

- ToolPolicy 정의
- ToolPolicyDecision 정의
- DefaultToolPolicy 구현
- ToolExecutionContext 정의
- ToolExecutionService 구현
- AgentRunOptions의 allowWrite/allowBash 반영
- READ_ONLY Tool은 기본 허용
- WRITE Tool은 allowWrite=false에서 거부
- BASH Tool은 allowBash=false에서 거부
- 정책 거부 시 실제 Tool을 실행하지 않음
- 허용된 Tool 결과에 공통 output truncation 적용
- Tool 실행 중 RuntimeException을 error ToolResult로 변환
- 관련 unit test 추가

 

반대로 하지 않은 것도 명확합니다.

- Spring AI ToolCallback adapter는 만들지 않음
- 실제 write/edit/bash Tool은 만들지 않음
- audit log는 아직 만들지 않음
- AgentRuntime과 연결하지 않음

마무리

AI Agent에서 Tool은 강력합니다.

하지만 Tool이 강력해질수록 먼저 필요한 것은 더 많은 Tool이 아니라 실행 경계입니다.

M6에서는 그 경계를 만들었습니다.

Tool은 일을 하고, 정책은 실행 여부를 판단합니다.

실행 서비스는 둘 사이를 이어 주고, 실패를 일정한 결과로 정리합니다.

이제 다음 단계에서는 이 내부 Tool 실행 구조를 Spring AI Tool Calling에 연결할 수 있습니다.