Tool Registry 구현
Tool Registry의 역할
섹션 제목: “Tool Registry의 역할”툴 시스템은 에이전트가 실제 세계와 상호작용하는 유일한 통로입니다. 잘 설계된 Tool Registry는 세 가지 관심사를 분리합니다.
| 관심사 | 담당 | 역할 |
|---|---|---|
| 정의 (Definition) | Tool 클래스 | 툴의 이름, 설명, 입력 스키마 선언 |
| 스키마 (Schema) | JSON Schema | LLM에 노출되는 툴 명세 생성 |
| 디스패치 (Dispatch) | ToolRegistry | 툴 이름으로 올바른 핸들러 찾아 실행 |
이 세 가지를 한 클래스에 몰아넣으면, 툴 추가 시 레지스트리 코드를 수정해야 하는 결합이 발생합니다.
기본 Tool 인터페이스
섹션 제목: “기본 Tool 인터페이스”interface ToolDefinition<TInput = unknown, TOutput = unknown> { name: string; description: string; inputSchema: JSONSchema; execute(input: TInput): Promise<TOutput>;}
// 구체적인 툴 구현 예시class ReadFileTool implements ToolDefinition<{ path: string }, string> { name = 'read_file'; description = '파일 내용을 읽어 반환합니다'; inputSchema = { type: 'object', properties: { path: { type: 'string', description: '읽을 파일의 절대 경로' }, }, required: ['path'], };
async execute({ path }: { path: string }): Promise<string> { try { return await fs.readFile(path, 'utf-8'); } catch (error) { throw new Error(`파일 읽기 실패: ${path} — ${error}`); } }}ToolRegistry 구현
섹션 제목: “ToolRegistry 구현”class ToolRegistry { private tools = new Map<string, ToolDefinition>();
register(tool: ToolDefinition): void { this.tools.set(tool.name, tool); }
// LLM API에 전달할 스키마 목록 생성 getSchemas(allowedNames?: string[]): LLMToolSchema[] { const entries = allowedNames ? [...this.tools.entries()].filter(([name]) => allowedNames.includes(name)) : [...this.tools.entries()];
return entries.map(([, tool]) => ({ name: tool.name, description: tool.description, input_schema: tool.inputSchema, })); }
// 이름으로 툴 접근 (허용 목록 필터링 포함) getTools(allowedNames?: string[]): ToolDefinition[] { if (!allowedNames) return [...this.tools.values()]; return allowedNames .map(name => this.tools.get(name)) .filter((t): t is ToolDefinition => t !== undefined); }
// 허용 툴만 노출하는 제한된 레지스트리 반환 restricted(allowedNames: string[]): RestrictedToolRegistry { return new RestrictedToolRegistry(this, allowedNames); }
async dispatch(call: ToolCall): Promise<ToolResult> { const tool = this.tools.get(call.name); if (!tool) { return { toolUseId: call.id, content: `알 수 없는 툴: ${call.name}`, isError: true }; }
try { const output = await tool.execute(call.input); return { toolUseId: call.id, content: JSON.stringify(output) }; } catch (error) { return { toolUseId: call.id, content: String(error), isError: true }; } }}승인 체크 (Approval Gate)
섹션 제목: “승인 체크 (Approval Gate)”파일 쓰기, 명령 실행 등 되돌리기 어려운 작업은 사용자 승인을 거쳐야 합니다. ApprovalRegistry 는 기존 레지스트리를 데코레이터 패턴으로 감쌉니다.
class ApprovalRegistry extends ToolRegistry { constructor( private inner: ToolRegistry, private approvalManager: ApprovalManager, private requireApproval: string[], // 승인 필요 툴 이름 목록 ) { super(); }
async dispatch(call: ToolCall): Promise<ToolResult> { if (this.requireApproval.includes(call.name)) { const approved = await this.approvalManager.request({ toolName: call.name, input: call.input, description: `${call.name} 실행을 승인하시겠습니까?`, });
if (!approved) { return { toolUseId: call.id, content: '사용자가 실행을 거부했습니다', isError: true, }; } }
return this.inner.dispatch(call); }}파일 편집 퍼지 매칭 (9-Pass Strategy)
섹션 제목: “파일 편집 퍼지 매칭 (9-Pass Strategy)”파일 편집은 LLM이 제시한 old_string 이 실제 파일 내용과 정확히 일치하지 않을 때 실패합니다. 공백, 인덴트, 유니코드 차이 등이 원인입니다. 9-pass 퍼지 매칭 은 점진적으로 완화된 매칭 전략을 순서대로 시도합니다.
type MatchStrategy = (content: string, target: string) => number;
const MATCH_PASSES: MatchStrategy[] = [ // Pass 1: 정확 매칭 (content, target) => content.indexOf(target),
// Pass 2: 앞뒤 공백 제거 후 매칭 (content, target) => content.indexOf(target.trim()),
// Pass 3: 줄별 공백 정규화 후 매칭 (content, target) => normalizeLines(content).indexOf(normalizeLines(target)),
// Pass 4: 탭/스페이스 통일 후 매칭 (content, target) => unifyIndent(content).indexOf(unifyIndent(target)),
// Pass 5: 줄 끝 통일 (CRLF → LF) 후 매칭 (content, target) => normalizeCRLF(content).indexOf(normalizeCRLF(target)),
// Pass 6: 연속 공백 단일화 후 매칭 (content, target) => collapseSpaces(content).indexOf(collapseSpaces(target)),
// Pass 7: 주석 무시 매칭 (content, target) => stripComments(content).indexOf(stripComments(target)),
// Pass 8: 퍼지 라인 매칭 (레벤슈타인 거리 기반) (content, target) => fuzzyLineMatch(content, target),
// Pass 9: 핵심 토큰만 비교 (content, target) => tokenMatch(content, target),];
async function editFile(path: string, oldString: string, newString: string): Promise<void> { const content = await fs.readFile(path, 'utf-8');
for (const strategy of MATCH_PASSES) { const index = strategy(content, oldString); if (index !== -1) { const updated = content.slice(0, index) + newString + content.slice(index + oldString.length); await fs.writeFile(path, updated, 'utf-8'); return; } }
throw new Error(`편집 대상을 찾을 수 없습니다: "${oldString.slice(0, 50)}..."`);}MCP 툴 동적 등록
섹션 제목: “MCP 툴 동적 등록”MCP(Model Context Protocol) 서버에서 제공하는 툴은 런타임에 동적으로 발견됩니다. lazy 초기화로 필요한 시점까지 MCP 연결을 지연시킵니다.
class MCPToolLoader { private loaded = false;
constructor( private registry: ToolRegistry, private mcpClient: MCPClient, ) {}
async ensureLoaded(): Promise<void> { if (this.loaded) return;
const mcpTools = await this.mcpClient.listTools(); for (const tool of mcpTools) { this.registry.register(new MCPToolAdapter(tool, this.mcpClient)); } this.loaded = true; }}툴 레지스트리는 MCP 로더를 dispatch 전에 호출해 동적 툴이 항상 최신 상태로 유지되도록 합니다.