콘텐츠로 이동

Tool Registry 구현

툴 시스템은 에이전트가 실제 세계와 상호작용하는 유일한 통로입니다. 잘 설계된 Tool Registry는 세 가지 관심사를 분리합니다.

관심사담당역할
정의 (Definition)Tool 클래스툴의 이름, 설명, 입력 스키마 선언
스키마 (Schema)JSON SchemaLLM에 노출되는 툴 명세 생성
디스패치 (Dispatch)ToolRegistry툴 이름으로 올바른 핸들러 찾아 실행

이 세 가지를 한 클래스에 몰아넣으면, 툴 추가 시 레지스트리 코드를 수정해야 하는 결합이 발생합니다.

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}`);
}
}
}
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 };
}
}
}

파일 쓰기, 명령 실행 등 되돌리기 어려운 작업은 사용자 승인을 거쳐야 합니다. 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 퍼지 매칭 은 점진적으로 완화된 매칭 전략을 순서대로 시도합니다.

정규화한 텍스트에서 얻은 인덱스와 길이는 원본의 좌표가 아닙니다. 각 matcher는 원본의 start·end를 반환해야 합니다. 다음 코드는 실행 가능한 정확 매칭 기준선이며, 퍼지 matcher를 추가할 때도 같은 범위 계약을 유지합니다.

interface MatchRange { start: number; end: number }
type MatchStrategy = (content: string, target: string) => MatchRange | null;
const exactMatch: MatchStrategy = (content, target) => {
if (!target) throw new Error('Empty edit target');
const start = content.indexOf(target);
if (start < 0) return null;
if (content.indexOf(target, start + 1) >= 0) throw new Error('Ambiguous edit target');
return { start, end: start + target.length };
};
function applyEdit(content: string, oldString: string, newString: string): string {
const match = exactMatch(content, oldString);
if (!match) throw new Error('Edit target was not found');
return content.slice(0, match.start) + newString + content.slice(match.end);
}

퍼지 matcher는 04장의 실제 9개 전략을 참고해 원본 substring의 범위를 찾습니다. 주석 제거·의미 임베딩·AST 탐색을 OpenDev의 9-pass 목록으로 소개하지 않습니다. 파일을 다시 읽어 기대한 버전인지 확인하고, 변경 범위와 diff가 승인된 뒤 저장합니다.

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 { tools } = await this.mcpClient.listTools();
for (const tool of tools) {
this.registry.register(new MCPToolAdapter(tool, this.mcpClient));
}
this.loaded = true;
}
}

ensureLoaded()는 첫 호출 때 한 번 로드할 뿐 목록을 항상 최신으로 유지하지는 않습니다. 실제 구현은 nextCursor 페이지를 모두 읽고, 목록 변경 알림·캐시 만료·서버 재시작 시 재조회합니다. 원격 도구는 서버 식별자를 포함한 이름으로 등록하고, 스키마·권한 변경 후 허용 정책을 다시 평가합니다.