도구는 LLM이 부를 수 있는 함수입니다. 새 능력을 루프 안에 직접 넣지 말고 도구로 추가하면, 루프는 얇게 유지되고 권한 게이트와 훅이 그 도구에도 그대로 적용됩니다. 도구 하나를 추가하는 작업은 네 단계로 나뉩니다. 정의, 구현, 등록, 권한 분류입니다.
1. 정의를 등록합니다
LLM이 보는 스키마는 core/tools/definitions.json에 모읍니다. 항목은 리스트의 한 객체이고, name(snake_case), description, input_schema(JSON Schema), 그리고 분류 메타데이터인 category와 cost_tier를 가집니다.
{
"name": "weather_lookup",
"description": "Look up the current weather for a city.",
"category": "external",
"cost_tier": "free",
"input_schema": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name" }
},
"required": ["city"]
}
}category와 cost_tier의 허용 값은 core/tools/base.py의 VALID_CATEGORIES와 VALID_COST_TIERS(free / cheap / expensive)에 정의되어 있습니다.
2. 핸들러를 구현합니다
도구는 core/tools/base.py의 Tool 프로토콜을 따릅니다. name, description, parameters 프로퍼티와 aexecute() 코루틴 네 가지면 유효한 도구입니다. 상속이 아니라 덕 타이핑이므로 클래스를 상속할 필요가 없습니다. 실패는 raise 대신 tool_error()로 구조화된 dict을 돌려줘서 LLM이 분류하고 복구할 수 있게 합니다. 기존 구현은 core/tools/web_tools.py의 WebFetchTool을 참고하세요.
# core/tools/weather_tools.py
from typing import Any
class WeatherLookupTool:
@property
def name(self) -> str:
return "weather_lookup"
@property
def description(self) -> str:
return "Look up the current weather for a city."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
},
"required": ["city"],
}
async def aexecute(self, **kwargs: Any) -> dict[str, Any]:
city = kwargs["city"]
if not city:
from core.tools.base import tool_error
return tool_error("city is required", error_type="validation")
# ... fetch and shape ...
return {"result": {"city": city, "summary": "..."}}3. 핸들러 맵에 등록합니다
핸들러가 존재한다고 자동으로 호출 대상이 되지는 않습니다. 실행은 ToolExecutor가 이름과 핸들러 함수의 dict에서 찾아 일어나고, 그 dict은 core/cli/tool_handlers/의 _build_tool_handlers()가 그룹별 빌더를 합쳐 만듭니다. 기존 단일 도구들과 같은 모양으로, 클래스를 인스턴스화해 aexecute를 감싼 클로저를 돌려주는 빌더를 추가하고 병합 목록에 넣습니다 (core/cli/tool_handlers/single_tool.py의 패턴).
# core/cli/tool_handlers/single_tool.py 패턴
def _build_weather_handlers() -> dict[str, Any]:
from core.tools.weather_tools import WeatherLookupTool
tool = WeatherLookupTool()
async def handle_weather_lookup(**kwargs: Any) -> dict[str, Any]:
return await tool.aexecute(**kwargs)
return {"weather_lookup": handle_weather_lookup}
# core/cli/tool_handlers/__init__.py — _build_tool_handlers()
handlers.update(_build_weather_handlers())definitions.json의 name과 dict 키가 정확히 같아야 합니다. 스키마만 있고 핸들러가 없으면 LLM이 도구를 부를 때 "No handler for tool" 경고와 함께 실패합니다.
4. 권한 등급을 정합니다
권한 분류는 core/agent/safety.py의 frozenset에서 결정됩니다. 읽기 전용 도구는 SAFE_TOOLS에 둡니다. 승인 없이 실행됩니다. 영속 상태(메모리, 파일, 자격증명)를 바꾸면 WRITE_TOOLS에 넣어 사용자 확인을 받게 하고, 시스템 접근이면 DANGEROUS_TOOLS에 넣습니다. 비용이 큰 호출이면 EXPENSIVE_TOOLS dict에 예상 비용을 적어 비용 확인 게이트를 켭니다. 이 set들을 ApprovalWorkflow(core/agent/approval.py)가 읽어서 실행 직전에 HITL 프롬프트를 띄웁니다.
# core/agent/safety.py
SAFE_TOOLS = frozenset({
...,
"weather_lookup", # read-only — no approval prompt
})모드별·노드별 추가 차단이 필요하면 core/tools/policy.py의 PolicyChain으로denied_tools / allowed_tools를 거는 6-layer 정책 체인을 사용합니다.
확인
스키마와 핸들러 양쪽이 실제로 연결됐는지 확인합니다.
uv run python -c "
from core.tools.base import load_tool_definition
from core.cli.tool_handlers import _build_tool_handlers
print(load_tool_definition('weather_lookup')['name'])
print('weather_lookup' in _build_tool_handlers())
"둘 다 통과하면 LLM에 스키마가 노출되고 호출이 실행됩니다. 마지막으로 대화형 세션에서 한 번 불러봅니다. 셸 원샷은 지원하지 않습니다.
geode > 서울 날씨 알려줘
참조: Tool protocol, MCP tools.