콘텐츠로 이동

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 퍼지 매칭 은 점진적으로 완화된 매칭 전략을 순서대로 시도합니다.

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(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 전에 호출해 동적 툴이 항상 최신 상태로 유지되도록 합니다.