휴먼 인 더 루프
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.ts의 jiraFlow는 이걸 두 번 써요. 한 번은 불명확한 이슈를 사람에게 명확히 해달라고 물을 때고, 한 번은 어떤 베이스 브랜치를 대상으로 할지 물을 때예요:
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.clarification은 Channel<UserClarification | null, ...>이에요. 이건 examples/consumer.ts에서 실제 컴파일 어서션으로 검사돼요: StateOf<typeof jiraFlow.state>["clarification"]은 .d.ts 패키지 경계를 넘어서도 정말로 UserClarification | null과 같아요.
팀 안에서: 툴을 통해 라우팅하기
섹션 제목: “팀 안에서: 툴을 통해 라우팅하기”agent()에는 작성자가 직접 쓴 본문이 없어요. 그 “본문”에 해당하는 게 바로 모델 루프라서, step()처럼 ctx.interrupt를 직접 호출할 수 없어요. 대신 사람의 승인이 필요한 팀은 run에서 interrupt를 호출하는 툴을 에이전트에게 줘요. ToolCtx가 interrupt를 갖고 있는 이유가 바로 이거예요:
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],});reviewer가 requestApproval을 호출하기로 결정하면, 팀 실행 전체가 바로 그 지점에서 서스펜드돼요. “승인을 요청해야겠다”는 판단까지 포함한 모델 자신의 추론 과정이 이벤트 로그에 그대로 보존되기 때문에, 재개할 때 모델을 다시 실행하지 않고 interrupt 이후만 이어서 진행해요.
재개는 어떤 모습일까 (설계 의도)
섹션 제목: “재개는 어떤 모습일까 (설계 의도)”// runtime.resume(threadId, value) — 며칠 뒤, 다른 프로세스에서 호출해도 괜찮아요await runtime.resume("th_1", { approved: true });interrupt 이전의 모든 건 캐시 히트로 리플레이돼요(LLM 호출도, 다시 실행되는 툴도 없어요). 진짜 작업은 interrupt 이후부터만 일어나요. 전체 예시는 이벤트 소싱과 리플레이에서 확인하세요.