본문 바로가기

개발/AI

Spring AI로 Pi 스타일 에이전트 하네스 만들기 (7) - Tool Calling 감싸기

이번 글의 목적

M5에서는 read-only tool을 만들었고, M6에서는 tool 실행 정책을 분리했습니다.

이번 단계에서는 그 내부 tool 구조를 Spring AI Tool Calling과 연결합니다.

Spring AI에 tool 구현을 직접 종속시키지 않는다.
내부 AgentTool과 ToolExecutionService는 유지한다.
Spring AI에는 얇은 ToolCallback adapter로만 노출한다.

 

Spring AI는 모델이 tool을 호출할 수 있게 해 줍니다.

하지만 파일 읽기, 경로 검증, secret 차단, 정책 거부, output 제한 같은 규칙까지 대신 설계해 주지는 않습니다.

그래서 우리는 Spring AI의 tool 호출 규격과 내부 tool 실행 구조를 분리합니다.

왜 adapter가 필요한가

가장 쉬운 방식은 Spring AI의 @Tool 메서드 안에 모든 로직을 넣는 것입니다.

@Tool
String readFile(String path) {
    // path 검증
    // 파일 읽기
    // output 제한
}

 

작은 예제라면 이 방식도 충분합니다.

하지만 이 프로젝트에서는 이미 M5와 M6에서 내부 구조를 만들었습니다.

AgentTool
ToolArguments
ToolResult
ToolRegistry
ToolExecutionService
ToolPolicy

 

이 구조를 버리고 @Tool 메서드 안으로 로직을 옮기면, Spring AI 호출 규격과 workspace 안전 정책이 한곳에 섞입니다.

그 대신 M7에서는 Spring AI의 ToolCallback을 직접 구현하는 adapter를 둡니다.

Spring AI ToolCallback
  → SpringAiToolCallbackAdapter
  → ToolExecutionService
  → ToolPolicy
  → AgentTool

 

Controller가 외부 HTTP 요청을 내부 Service로 넘기듯, SpringAiToolCallbackAdapter는 모델의 tool call을 내부 tool 실행 계층으로 넘깁니다.

실제 구조

이번 단계에서 추가한 중심 클래스는 세 개입니다.

model/
  SpringAiToolCallbackAdapter.java
  SpringAiToolCallbackFactory.java
  SpringAiToolCallbackEvents.java

 

기존 내부 tool 구조는 그대로 둡니다.

tool/
  AgentTool.java
  ToolRegistry.java
  ToolExecutionService.java
  ToolResult.java
  ToolPolicy.java

 

SpringAiToolCallbackAdapter는 내부 AgentTool 하나를 Spring AI ToolCallback 하나로 감쌉니다.

코드 흐름은 다음과 같습니다.

ToolCallback.call(json)
  → JSON을 ToolArguments로 변환
  → ToolExecutionService.execute(...)
  → ToolResult를 JSON 문자열로 변환
  → Spring AI 모델에게 반환

 

핵심은 adapter가 직접 파일을 읽거나 정책을 판단하지 않는다는 점입니다.

실제 실행은 항상 ToolExecutionService를 통과합니다.

ToolResult result = toolExecutionService.execute(tool, arguments, context);

 

이 덕분에 Spring AI를 통해 호출된 tool도 M6에서 만든 정책 계층을 그대로 사용합니다.

Tool description이 중요하다

Tool Calling에서 description은 모델에게 주는 API 문서입니다.

개발자가 API 문서를 보고 endpoint를 호출하듯, LLM은 tool name, parameter, description을 보고 어떤 tool을 쓸지 결정합니다.

나쁜 description은 이런 식입니다.

Read file

 

좋은 description은 더 구체적입니다.

Read a UTF-8 text file inside the trusted workspace. Use this when you need to inspect project files. The path must be relative to the workspace root.

 

이번 구현에서는 description을 ToolSpec.description에 두고, adapter가 Spring AI의 ToolDefinition.description으로 옮깁니다.

ToolSpec.description
  → Spring AI ToolDefinition.description

 

초기에는 영어 description을 추천합니다.

대부분의 모델이 tool schema와 description을 영어로 더 안정적으로 처리하기 때문입니다.

Tool schema는 최소한으로 변환한다

M5에서 ToolSpec.inputSchema는 단순한 Map<String, Object>로 시작했습니다.

예를 들면 이런 형태입니다.

Map.of("path", "string")

 

Spring AI의 ToolDefinition은 JSON schema 문자열을 기대합니다.

그래서 adapter는 내부 schema를 Spring AI에 맞는 JSON schema로 변환합니다.

{
  "type": "object",
  "properties": {
    "path": {
      "type": "string"
    }
  },
  "additionalProperties": false
}

 

여기서 일부러 큰 schema 모델을 새로 만들지는 않았습니다.

지금 단계에서 필요한 것은 내부 tool을 Spring AI에 노출하는 것입니다.

required field, enum, nested object 같은 세부 표현은 실제 반복이 생긴 뒤 확장해도 늦지 않습니다.

ToolResult는 JSON으로 반환한다

Spring AI ToolCallback.call(...)의 반환 타입은 문자열입니다.

단순히 성공 텍스트만 반환할 수도 있습니다.

README.md
build.gradle.kts

 

하지만 우리 내부 결과는 ToolResult입니다.

public record ToolResult(
        String text,
        boolean error,
        Map<String, Object> metadata
) {
}

 

정책 거부, truncation, 실행 실패 같은 정보는 errormetadata에 들어갑니다.

이 정보를 잃지 않기 위해 adapter는 ToolResult를 JSON 문자열로 직렬화해서 모델에게 돌려줍니다.

예를 들어 정책이 거부한 경우 모델은 이런 결과를 받습니다.

{
  "text": "tool requires allowWrite=true: write",
  "error": true,
  "metadata": {
    "policyDenied": true
  }
}

 

이렇게 하면 모델은 tool 실행이 실패했다는 사실과 그 이유를 함께 볼 수 있습니다.

Event hook은 얇게 둔다

M7은 아직 AgentRuntime 단계가 아닙니다.

그래도 tool call이 언제 시작되고, 언제 끝났고, 왜 거부되었는지 관찰할 수 있는 hook은 필요합니다.

그래서 SpringAiToolCallbackEvents를 추가했습니다.

public interface SpringAiToolCallbackEvents {

    default void toolCallStarted(String toolName, ToolArguments arguments) {
    }

    default void toolCallFinished(String toolName, ToolResult result) {
    }

    default void toolCallDenied(String toolName, ToolResult result) {
    }

    default void toolCallFailed(String toolName, ToolResult result) {
    }
}

 

기본 구현은 no-op입니다.

테스트에서는 이 hook이 호출되는지 검증합니다.

여기서 중요한 점은 이 hook이 최종 event 모델은 아니라는 것입니다.

기존 AgentEvent에는 sessionId, callId, createdAt 같은 정보가 들어갑니다.

M7 adapter는 아직 session을 모릅니다. 그래서 M7에서는 adapter 경계의 hook만 만들고, 실제 session event 변환은 M8 AgentRuntime에서 처리합니다.

아직 AgentRuntime은 아니다

M7에서 Spring AI Tool Calling adapter를 만들었다고 해서 완전한 AgentRuntime이 생긴 것은 아닙니다.

이번 단계에서는 일부러 여기까지 하지 않았습니다.

- /api/chat-with-tools endpoint
- ChatClient에 tool을 실제로 연결하는 runtime 흐름
- session 저장
- SSE event stream
- AgentRuntime loop

 

 

Spring AI adapter와 AgentRuntime을 한 번에 붙이면, 문제가 생겼을 때 원인을 나누기 어렵습니다.

M7에서는 다음만 확인합니다.

내부 AgentTool 하나가 Spring AI ToolCallback으로 노출되는가?
tool 실행이 ToolExecutionService를 반드시 통과하는가?
정책 거부 결과가 모델에게 error ToolResult로 전달되는가?
잘못된 JSON 입력도 error ToolResult로 정리되는가?

 

ChatClient에 실제 tool 목록을 붙이고, session과 SSE로 흘려보내는 일은 M8에서 다룹니다.

마무리

M7은 내부 세계와 LLM 세계를 연결하는 adapter 단계입니다.

Spring AI는 모델이 tool을 호출할 수 있는 규격을 제공합니다.

그 규격에 내부 tool을 그대로 맞추지 않고, 얇은 ToolCallback adapter를 둡니다.

AgentTool
ToolExecutionService
ToolPolicy
ToolResult

 

Spring AI 쪽 변화가 생겨도 바뀌는 곳은 adapter 경계로 좁아집니다.

내부 tool과 정책 테스트는 그대로 유지할 수 있습니다.

다음 단계에서는 이 adapter를 AgentRuntime에서 사용합니다.

사용자 메시지를 받고, 모델을 호출하고, tool call을 실행하고, 그 과정을 session과 SSE event로 남기는 첫 agent loop를 만들 차례입니다.

 

[codex] Add Spring AI tool callback adapter by dd3ok · Pull Request #10 · dd3ok/pi-spring-ai