콘텐츠로 이동

휴먼 인 더 루프

loopy에서 휴먼 인 더 루프에 필요한 도구는 ctx.interrupt<T>(payload) 하나예요. 워크플로우의 step()에서 쓰거나, team() 안에서는 tool()에서 써요. 왜 이게 멈춘 함수가 아니라 그래프 위치를 서스펜드하는 방식으로 설계됐는지는 이벤트 소싱과 리플레이에서 확인하세요.

워크플로우 안에서: NodeCtx.interrupt

섹션 제목: “워크플로우 안에서: NodeCtx.interrupt”

워크플로우 step()의 실행 컨텍스트는 평범한 툴 컨텍스트에 interrupt를 더 얹은 거예요:

export interface NodeCtx<D extends keyof LoopyDeps> {
readonly deps: Pick<LoopyDeps, D>;
/** 실행을 서스펜드해요. 재개 값(페이로드 채널에 지정된 타입)으로 resolve돼요. */
interrupt<T>(payload: unknown): Promise<T>;
}

examples/workflows.tsjiraFlow는 이걸 두 번 써요. 한 번은 불명확한 이슈를 사람에게 명확히 해달라고 물을 때고, 한 번은 어떤 베이스 브랜치를 대상으로 할지 물을 때예요:

export interface UserClarification {
readonly answers: readonly string[];
readonly by: string;
}
const needsInput = step({
name: "needsInput",
input: io<{ missing: readonly string[] }>(),
output: io<{ clarified: UserClarification }>(),
run: async (_i, ctx) => {
const clarified = await ctx.interrupt<UserClarification>({ kind: "clarify" });
return { clarified };
},
});

ctx.interrupt<UserClarification>({ kind: "clarify" })는 실행을 서스펜드해요. UserClarification에 맞는 값으로 재개되면 그 값을 반환하고 needsInput이 정상적으로 끝나도록 해요. { kind: "clarify" } 페이로드는 승인 UI(대시보드, Slack 메시지, CLI 프롬프트 등)를 보여주는 쪽에 넘기고 싶은 컨텍스트라면 뭐든 될 수 있어요. loopy 입장에서는 그냥 불투명한 값일 뿐이에요.

이 값이 흘러들어가는 채널은 워크플로우 전체에서 unknown으로 뭉개지지 않고 정확한 타입을 유지해요. jiraFlow.state.clarificationChannel<UserClarification | null, ...>이에요. 이건 examples/consumer.ts에서 실제 컴파일 어서션으로 검사돼요: StateOf<typeof jiraFlow.state>["clarification"].d.ts 패키지 경계를 넘어서도 정말로 UserClarification | null과 같아요.

팀 안에서: 툴을 통해 라우팅하기

섹션 제목: “팀 안에서: 툴을 통해 라우팅하기”

agent()에는 작성자가 직접 쓴 본문이 없어요. 그 “본문”에 해당하는 게 바로 모델 루프라서, step()처럼 ctx.interrupt를 직접 호출할 수 없어요. 대신 사람의 승인이 필요한 팀은 run에서 interrupt를 호출하는 툴을 에이전트에게 줘요. ToolCtxinterrupt를 갖고 있는 이유가 바로 이거예요:

export const requestApproval = tool({
name: "requestApproval",
description: "Pause for human approval.",
input: io<{ summary: string }>(),
output: io<{ approved: boolean }>(),
run: async (i, ctx) => ctx.interrupt<{ approved: boolean }>({ ask: i.summary }),
});
export const reviewer = agent({
name: "reviewer", model: "opus", instructions: "Review; approve or reassign.",
input: io<{ issue: Issue }>(), output: io<ReviewResult>(),
tools: [requestApproval],
});

reviewerrequestApproval을 호출하기로 결정하면, 팀 실행 전체가 바로 그 지점에서 서스펜드돼요. “승인을 요청해야겠다”는 판단까지 포함한 모델 자신의 추론 과정이 이벤트 로그에 그대로 보존되기 때문에, 재개할 때 모델을 다시 실행하지 않고 interrupt 이후만 이어서 진행해요.

재개는 어떤 모습일까 (설계 의도)

섹션 제목: “재개는 어떤 모습일까 (설계 의도)”
// runtime.resume(threadId, value) — 며칠 뒤, 다른 프로세스에서 호출해도 괜찮아요
await runtime.resume("th_1", { approved: true });

interrupt 이전의 모든 건 캐시 히트로 리플레이돼요(LLM 호출도, 다시 실행되는 툴도 없어요). 진짜 작업은 interrupt 이후부터만 일어나요. 전체 예시는 이벤트 소싱과 리플레이에서 확인하세요.