콘텐츠로 이동

스키마 (IO)

문제: 런타임 경계를 넘나드는 정적 타입

섹션 제목: “문제: 런타임 경계를 넘나드는 정적 타입”

툴의 입력과 출력에는 두 가지가 동시에 필요해요. 컴파일러가 검사하는 정적 TypeScript 타입과, 언젠가는 붙어야 할 런타임 검증기예요. 이 검증기는 LLM이 실제로 돌려주는 값(깨진 JSON, 마크다운 코드 펜스, 트레일링 콤마)을 그 타입으로 강제 변환해야 해요. loopy는 이 문제를 IO라는 캐리어 타입 하나로 풀어요. Standard Schema 스펙과 같은 형태라서, 이 스펙을 구현하는 검증기 라이브러리라면 Zod든 Valibot이든 ArkType이든 무엇이든 loopy가 특정 라이브러리에 의존하지 않고도 바로 갈아 끼울 수 있어요.

export interface IO<In, Out = In> {
readonly "~standard": {
readonly version: 1;
readonly vendor: string;
readonly validate: (
value: unknown,
) => { readonly value: Out } | { readonly issues: readonly { readonly message: string }[] };
readonly types?: { readonly input: In; readonly output: Out };
};
}
export type InferIn<S extends IO<any, any>> = NonNullable<S["~standard"]["types"]>["input"];
export type InferOut<S extends IO<any, any>> = NonNullable<S["~standard"]["types"]>["output"];

정적 In/Out 타입은 팬텀 프로퍼티(~standard.types)에 들어 있어요. 런타임에는 실제로 채워지지 않고, InferIn<S> / InferOut<S>가 인덱스 접근으로 타입을 다시 꺼낼 수 있도록 존재할 뿐이에요. loopy 안에서 “이 스키마가 나타내는 실제 TypeScript 타입”이 필요한 곳(툴의 run 매개변수, 워크플로우 노드의 반환 타입 등)은 스키마 객체 자체가 아니라 항상 InferOut<...>을 거쳐요.

loopy는 실제 검증기 의존성을 끌어들이지 않고도 타입 표면을 온전히 쓸 수 있도록 최소한의 생성자를 함께 제공해요:

export function io<Out, In = Out>(vendor: string = "loopy"): IO<In, Out> {
return {
"~standard": {
version: 1,
vendor,
validate: (value: unknown): { readonly value: Out } => ({ value: value as Out }),
},
};
}

io<{ path: string; patch: string }>()는 정적 출력 타입이 { path: string; patch: string }인 스키마를 만들어줘요. 런타임에서 io()validate는 항등 캐스트일 뿐, 실제 검증은 하지 않아요. 이 지점이 실제 검증기(Zod, Valibot, ArkType)가 끼어들어 LLM 출력을 진짜로 강제 변환하는 이음매예요. 조용히 실패를 흘려보내는 대신 타입이 지정된 파싱 에러를 내도록요. InferOut<S>나 그 이후 단계는 전혀 바뀌지 않아요.

이건 에이전트 드라이버가 구조화 출력을 다루는 모든 턴마다 이미 실행하는 **Schema-Aligned Parsing(SAP)**과는 다른 문제예요. SAP는 모델의 원본 텍스트에서 JSON 블록을 강건하게 추출해요. 코드 펜스를 벗기고, 잡다한 서술을 건너뛰고, 짝이 안 맞는 괄호를 되짚어가면서요. 이건 스키마의 validate로 넘기기 전에 일어나요. SAP는 “모델이 JSON을 마크다운으로 감쌌다”를 고치고, 실제 스키마의 validate는 “JSON이 내가 약속한 모양과 실제로 다르다”를 고쳐요.

import { io } from "@loopyjs/core";
const input = io<{ path: string; find: string; replace: string }>();
const output = io<{ applied: boolean }>();

모든 tool(), agent(), 워크플로우 노드는 io<...>()로 만든 inputoutput 스키마를 받아요. 실제 검증기가 연결된 뒤에는, 같은 ~standard 형태를 구현하는 Zod나 Valibot, ArkType 스키마를 대신 받을 수도 있고요.