콘텐츠로 이동

Tool 등록·발견·실행 아키텍처

에이전트가 도구를 사용하려면 세 단계가 필요합니다. 어떤 도구가 있는지 알고, 어떻게 호출하는지 알고, 실제로 실행해야 합니다. 이 세 관심사를 하나의 클래스에 넣으면 테스트가 어렵고, 도구 추가 시 많은 코드가 변경됩니다.

레지스트리 패턴은 이를 명확히 분리합니다.

┌────────────────────────────────────────────────┐
│ Tool Registry │
│ │
│ ① Definition Layer → 도구 메타데이터, 설명 │
│ ② Schema Layer → JSON Schema (LLM 호출용) │
│ ③ Dispatch Layer → 실제 실행 + 권한 검사 │
└────────────────────────────────────────────────┘
interface ToolDefinition {
name: string;
description: string;
parameters: JsonSchema;
requiresApproval: boolean;
category: 'read' | 'write' | 'execute' | 'network';
}
type ToolHandler = (params: Record<string, unknown>) => Promise<unknown>;
class ToolRegistry {
private definitions = new Map<string, ToolDefinition>();
private handlers = new Map<string, ToolHandler>();
// ① 도구 등록 (정의 + 핸들러 분리)
register(def: ToolDefinition, handler: ToolHandler): void {
this.definitions.set(def.name, def);
this.handlers.set(def.name, handler);
}
// ② LLM에게 제공할 스키마 생성
toOpenAISchema(): OpenAITool[] {
return Array.from(this.definitions.values()).map((def) => ({
type: 'function',
function: {
name: def.name,
description: def.description,
parameters: def.parameters,
},
}));
}
// ③ 실행 + 권한 검사
async dispatch(
name: string,
params: Record<string, unknown>,
approvalCallback?: (def: ToolDefinition) => Promise<boolean>
): Promise<unknown> {
const def = this.definitions.get(name);
const handler = this.handlers.get(name);
if (!def || !handler) {
throw new Error(`Unknown tool: ${name}`);
}
if (def.requiresApproval && approvalCallback) {
const approved = await approvalCallback(def);
if (!approved) throw new Error(`Tool ${name} not approved`);
}
return handler(params);
}
}

LLM이 파일 편집 도구를 호출할 때, 정확한 라인 번호나 문자열을 항상 올바르게 지정하지는 않습니다. OpenDev에서 개발한 9-pass 퍼지 매칭은 근사적으로 일치하는 파일 영역을 찾아 편집을 허용합니다.

9단계는 정확도가 높은 것부터 낮은 순서로 시도됩니다.

Pass매칭 전략설명
1완전 일치정확히 동일한 문자열
2공백 정규화 후 일치들여쓰기 차이 허용
3첫 줄/마지막 줄 앵커앞뒤 줄로 위치 특정
4라인 번호 힌트제공된 번호 근처 탐색
5Levenshtein 유사도편집 거리 기반
6토큰 겹침단어 집합 유사도
7의미적 임베딩의미 유사도
8구조적 패턴AST 기반 유사 구조
9최선 추측앞선 pass 실패 시 최고 점수 후보
async function fuzzyFind(
fileContent: string,
searchString: string,
hintLineNumber?: number
): Promise<{ start: number; end: number; confidence: number }> {
// Pass 1: 완전 일치
const exactIdx = fileContent.indexOf(searchString);
if (exactIdx !== -1) {
return { start: exactIdx, end: exactIdx + searchString.length, confidence: 1.0 };
}
// Pass 2: 공백 정규화
const normalizedFile = normalizeWhitespace(fileContent);
const normalizedSearch = normalizeWhitespace(searchString);
const normIdx = normalizedFile.indexOf(normalizedSearch);
if (normIdx !== -1) {
return { start: mapNormalizedIndex(normIdx, fileContent), end: ..., confidence: 0.95 };
}
// Pass 3~9: 점진적으로 느슨한 매칭 시도...
return bestGuessMatch(fileContent, searchString, hintLineNumber);
}

에이전트가 셸 명령을 실행할 때는 단순히 exec(command)를 호출하지 않습니다. 보안과 안정성을 위한 6단계 파이프라인을 거칩니다.

┌──────────────────────────────────────────────────┐
│ 1단계: 환경 검증 → 허용된 디렉토리, 권한 확인 │
│ 2단계: 명령 인터셉트 → 위험 패턴 차단 (rm -rf 등) │
│ 3단계: 실행 → 프로세스 생성, 타임아웃 설정 │
│ 4단계: 출력 수집 → stdout/stderr 버퍼링 │
│ 5단계: 의존성 자동설치 → import 오류 감지 → pip/npm │
│ 6단계: 프로세스 추적 → PID 등록, 종료 모니터링 │
└──────────────────────────────────────────────────┘
async function executeShell(command: string): Promise<ShellResult> {
// 1단계: 환경 검증
validateWorkingDirectory(process.cwd());
// 2단계: 명령 인터셉트
const interceptResult = interceptDangerousCommands(command);
if (interceptResult.blocked) {
throw new Error(`Command blocked: ${interceptResult.reason}`);
}
// 3단계: 실행
const proc = spawn('bash', ['-c', command], {
timeout: 30_000,
cwd: process.cwd(),
});
// 4단계: 출력 수집
const { stdout, stderr, exitCode } = await collectOutput(proc);
// 5단계: 의존성 자동 설치
if (isMissingDependencyError(stderr)) {
const pkg = extractMissingPackage(stderr);
await autoInstall(pkg);
return executeShell(command); // 재시도
}
// 6단계: 프로세스 추적 종료
processTracker.deregister(proc.pid);
return { stdout, stderr, exitCode };
}

도구 아키텍처는 정의·스키마·디스패치를 분리한 레지스트리 패턴으로 구현합니다. LLM의 근사적인 파일 위치 지정을 처리하는 9-pass 퍼지 매칭과, 보안과 안정성을 보장하는 6단계 셸 실행 파이프라인이 실용적인 도구 시스템의 핵심입니다.