BoxAgnts 툴 시스템 (3) — 툴 등록 및 핫 리로드 전체 흐름
Source: Dev.to
툴 등록은 가벼운 모듈처럼 보일 수 있습니다 — 디렉터리를 스캔하고, 파일을 읽고, 해시 테이블을 채우는 정도죠. 하지만 이를 올바르게, 그리고 신뢰성 있게 구현하려면 인코딩 감지, 텍스트 파싱, 레이스 컨디션, 시작 성능 등 처음엔 눈에 띄지 않는 문제들을 다뤄야 합니다. 이 글에서는 .wasm 파일이 AI가 호출할 수 있는 툴이 되기까지의 전체 흐름을 추적하며 각 단계를 상세히 살펴봅니다.
등록이 해결해야 할 문제들
이 모듈이 해야 할 일을 명확히 합시다. .wasm 파일이 extensions 디렉터리에 놓이면 시스템은 다음을 알아야 합니다:
- 툴 이름이 무엇인지
- 어떤 파라미터를 가지고 있는지, 타입은 무엇이며 각각이 필수인지 여부
- 어떤 권한 레벨에 속하는지
- 기능 설명은 무엇인지 (AI 모델이 호출 결정을 할 때 사용)
- 키워드는 무엇인지 (AI 모델 검색에 사용)
전통적인 접근 방식은 개발자가 .wasm 옆에 JSON Schema 파일을 제공하도록 하는 것입니다. 이 방식에는 동기화 문제가 있습니다: 스키마에서는 파라미터가 string이라고 명시했지만 실제 코드는 number로 처리한다든가, 스키마는 업데이트되지 않았지만 툴에 새로운 파라미터가 추가된 경우, 스키마에 오류가 있어도 툴이 정상적으로 등록되고 실행 시 영원히 실패한다든가. 게다가 스키마를 만들기 위해서는 개발자가 BoxAgnts의 스키마 포맷을 별도로 이해해야 합니다.
BoxAgnts는 스키마 소스를 “수동으로 작성”에서 “툴 자체 기술”로 바꿉니다 — WASM 툴을 직접 실행하고 --help를 전달해 출력되는 도움말 텍스트를 파싱합니다. 즉, 툴 개발자는 표준 CLI 프로그램 관례만 따르면 됩니다. Rust의 clap, Go의 cobra, Python의 argparse 등 어떤 언어의 CLI 인자 파싱 라이브러리를 사용해 파라미터를 정의하면, BoxAgnts가 모든 정보를 자동으로 추출합니다.
인코딩 감지
첫 번째 기술적 디테일은 stdout을 읽은 뒤 등장합니다. WASM 툴의 --help 출력은 문자열이 아니라 바이트 스트림이므로, 디코딩하기 전에 인코딩을 감지해야 합니다. UTF‑8이라고 무조건 가정하면 GBK나 Shift‑JIS로 인코딩된 툴은 파싱에 실패합니다.
BoxAgnts는 chardetng를 인코딩 감지에 사용합니다:
// wasm-tools/src/decode.rs
pub fn decode_bytes(bytes: Bytes) -> (String, &'static str, bool) {
let mut detector = chardetng::EncodingDetector::new(
chardetng::Iso2022JpDetection::Allow
);
detector.feed(&bytes, true);
let encoding = detector.guess(None, chardetng::Utf8Detection::Allow);
let (cow, _, had_errors) = encoding.decode(&bytes);
(cow.into_owned(), encoding.name(), had_errors)
}
chardetng는 Mozilla가 개발한 인코딩 감지 라이브러리로, Firefox에서 웹 페이지 인코딩을 자동으로 판단할 때 사용됩니다. 짧은 텍스트(--help 출력은 보통 몇 KB 이하)에서도 매우 높은 정확도를 보입니다. Iso2022JpDetection::Allow는 일본 환경에서 온 WASM 툴을 위해 ISO‑2022‑JP 감지를 활성화하고, Utf8Detection::Allow는 무작위 바이너리 데이터를 UTF‑8 텍스트로 오인식하는 것을 방지하기 위해 UTF‑8 무결성을 검증합니다.
디코딩이 끝나면 문자열, 인코딩 이름, 디코딩 오류 여부 세 가지가 반환됩니다. 이후 파서는 깨끗한 UTF‑8 텍스트를 받게 됩니다.
도움말 텍스트 파서
--help 출력을 파싱하는 일은 결코 간단하지 않습니다. CLI 라이브러리마다 출력 형식이 다르기 때문입니다. 예를 들어 clap의 --help와 -h는 상세 수준이 다르고(전자는 long_about을 포함, 후자는 about만 포함), 일부 라이브러리는 Options:와 Arguments: 블록 사이에 들여쓰기가 일관되지 않으며, 서브커맨드는 Commands: 혹은 Subcommands: 헤더 아래에 나타날 수 있습니다.
BoxAgnts 파서는 wasm-tools/src/registry/parser.rs에 위치하며 다음 흐름을 따릅니다:
1. 두 개의 도움말 텍스트 가져오기
pub async fn fetch_help_texts(program: &str) -> Result {
let short_candidates = vec![vec!["-h"], vec!["--help"]];
let long_candidates = vec![vec!["--help"], vec!["-h"]];
let short_help = run_first_help_candidate(program, &short_candidates).await?;
let long_help = run_first_help_candidate(program, &long_candidates).await?;
Ok(HelpTextPair { short_help, long_help })
}
왜 두 개를 가져오나요? 많은 CLI 프로그램이 -h(짧은 도움말)와 --help(긴 도움말)에서 서로 다른 출력을 제공하기 때문입니다. -h는 파라미터 이름과 한 줄 설명만 나열하고, --help는 더 상세한 long_about을 포함합니다. BoxAgnts는 두 출력을 병합합니다:
- 툴 이름과 버전은 짧은 도움말에서 추출(가장 간결하고 신뢰할 수 있음)
- 긴 설명(
long_about), 키워드(Keywords:), 권한 레벨(PermissionLevel:)은 긴 도움말을 우선 사용 - 파라미터 목록(
properties)과 필수 항목(required)은 두 도움말을 모두 합쳐서 구성—긴 도움말을 기본, 짧은 도움말을 보조로 활용
2. 출력 정당성 검증
모든 WASM 프로그램이 툴이 되는 것은 아닙니다. run_first_help_candidate는 출력이 도착한 뒤 정당성을 검사합니다:
pub fn looks_like_help_output(text: &str) -> bool {
let has_usage = text.lines().any(|l| l.trim_start().starts_with("Usage:"));
let has_options = text.lines().any(|l| l.trim() == "Options:");
let has_arguments = text.lines().any(|l| l.trim() == "Arguments:");
let has_commands = text.lines().any(|l| {
let t = l.trim();
t == "Commands:" || t == "Subcommands:"
});
has_usage || has_options || has_arguments || has_commands
}
출력에는 최소 하나 이상의 Usage:, Options:, Arguments:, Commands: 블록 헤더가 포함돼야 합니다. 예를 들어 HTTP 서버와 같이 CLI 툴이 아닌 WASM 프로그램은 이 조건을 만족하지 못하므로 파서는 등록을 거부하고 오류를 로그합니다.
3. 필드별 추출
fn parse_help_text(help: &str) -> Result {
let lines: Vec = help.lines().collect();
let (name, version) = parse_name_version(lines[0])?;
// First line format: "base64 1.0.0" → name="base64", version="1.0.0"
let about = lines.iter().skip(1)
.find(|l| !l.trim().is_empty())
.ok_or("missing about line")?
.trim().to_string();
let keywords = extract_single_line_field(help, "Keywords:");
let permission_level = extract_single_line_field(help, "PermissionLevel:");
let properties = parse_options_section(&lines)?; // Options: block
let (arg_props, arg_required) = parse_arguments_section(&lines)?; // Arguments: block
let commands = parse_commands_section(&lines)?; // Commands: block
// ...
}
파라미터 파싱의 핵심은 두 함수에 있습니다:
-
parse_options_section
Options:라인을 찾은 뒤, 그 다음 라인들을 옵션 정의(--mode혹은-m, --mode형식)로 해석합니다. 여기서 파라미터 이름, 타입(“ 안에서 추출), 그리고 설명(라인 끝의 자유 텍스트)을 추출합니다. -
parse_arguments_section
Arguments:라인을 찾고, 위치 인자를 “ 형식으로 파싱합니다. 대괄호([])가 있으면 선택적임을 의미합니다.
두 함수 모두 정규식 매칭을 사용합니다. 옵션 파싱 패턴은 --([a-zA-Z][a-zA-Z0-9_-]*)이며, 필요에 따라 형태의 각괄호를 포함할 수 있습니다. 인자 파싱은 형태를 매치하고, 대괄호 존재 여부로 옵션 여부를 판단합니다.
4. 병합 및 중복 제거
merge_required는 -h와 --help에서 추출한 필수 파라미터 리스트를 합칩니다:
fn merge_required(short: &[String], long: &[String]) -> Vec