스폰서LobeHubLobeHub자세히 알아보기
dshfind

4강: 이벤트 리스닝: 올바른 타이밍에 로직 삽입하기

한 줄 요약: 에이전트의 모든 중요한 순간마다 이벤트가 브로드캐스트되며, 플러그인은 ctx.on으로 리스너 하나만 등록하면 "모델 요청 전", "도구 호출 후", "턴 종료 직전"에 자신의 로직을 삽입할 수 있습니다. 관찰하려면 emit 이벤트를, 가로채거나 동작을 바꾸려면 waterfall 이벤트를 사용하고, next()를 호출하면 통과, 호출하지 않으면 인계(테이크오버)입니다.


1. 사용자 스토리: 프레임워크 소스를 수정하지 않고 "모델 요청 전"과 "도구 호출 후"에 로직 삽입하기

이전 강의에서는 DSH의 이벤트 시스템을 살펴 보았습니다. 메인 루프의 모든 단계가 이벤트를 브로드캐스트하고, 이벤트가 곧 서비스의 확장 API라는 것이었습니다. 이번 강의에서는 그 지식을 코드로 바꿉니다. 실제로 동작하는 리스너 플러그인을 작성해 봅니다.

배경: 여러분이 속한 플랫폼 팀이 이미 운영 중인 DSH 배포를 인수했고, 비즈니스 측에서 두 가지 요구 사항을 제기했습니다.

  1. 모델이 요청할 때마다 로그 한 건을 기록합니다: 누가, 어떤 턴의 몇 번째 단계에서, 어떤 모델을 사용했는지. 월말에 모델 비용을 대조해야 하기 때문입니다.
  2. 도구 호출이 끝날 때마다 구조화된 감사 레코드를 남깁니다: 어떤 도구가, 어떤 파라미터로 호출되었는지. "이 도구가 도대체 무엇을 했는지"를 조사하기 쉽게 하기 위해서입니다.

프레임워크에 확장 포인트가 없다면, 이 두 요구 사항은 같은 답으로 귀결됩니다. 소스를 fork해서 메인 루프를 수정하는 것입니다. 그러면 업스트림이 업그레이드될 때마다 패치를 다시 병합해야 하고, 메인 루프의 핵심을 건드리면 예외 하나로 에이전트 전체가 다울될 수 있습니다.

DSH의 답은 아무것도 fork할 필요가 없다는 것입니다. 메인 루프에는 중요한 타이밍마다 "슬롯"이 미리 마련되어 있고, 모든 타이밍이 이벤트로 브로드캐스트됩니다. agent/request는 모델 요청이 발송되기 전에, tools/result는 도구 호출이 끝난 후에, agent/turn-stopping은 턴이 종료되기 전에 발생합니다. 여러분은 작은 플러그인 하나를 작성해서 ctx.on으로 리스너를 등록하고 해당 슬롯에 "걸기"만 하면 됩니다. 문서는 단도직입적으로 말합니다.

이벤트는 플러그인 간 통신의 핵심 메커니즘입니다. Harness는 이벤트를 대량으로 사용하여 느슨하게 결합된 확장 포인트를 구현합니다. (출처: docs/user/develop/framework/events.zh.md)

이는 이전 강의와 같은 맥락이며, 차이는 하나뿐입니다. 이전 강의는 "읽기"였고, 이번 강의는 "쓰기"입니다.


2. 플러그인에서 이벤트 리스닝하기: ctx.on으로 리스너 등록하기

이벤트를 리스닝하는 기본적인 방법은 두 줄뿐입니다(출처: docs/user/develop/framework/events.zh.md):

ctx.on('event-name', (payload) => {
  // 이벤트 처리
})

ctx는 플러그인 진입점 apply(ctx)가 받는 컨텍스트이며, "리스너"는 평범한 함수입니다. 이벤트가 발생하는 즉시 호출됩니다. 발생시키는 쪽은 프레임워크 자신으로, ctx.emit('event-name', payload)로 발생시킵니다. 플러그인 작성자는 리스너 절반만 작성하면 되고, 발생시키는 쪽은 건드릴 필요가 없습니다.

이제 사용자 스토리를 실제 플러그인으로 작성해 봅니다(events.zh.md의 로그 플러그인 예제를 변형한 것입니다):

export const name = 'audit-logs'

export function apply(ctx: Context) {
  // 요구 사항 1: 모델 요청 전에 요청 로그 한 건 기록
  ctx.on('agent/request', async (_payload, next) => {
    console.log('[audit] 모델 요청이 곧 발송됩니다')
    const config = await next() // 통과: 머신이 원래 사용할 호출 설정을 가져옴
    console.log(`[audit] 이번 요청에 사용된 모델: ${config.model}`)
    return config
  })

  // 요구 사항 2: 도구 호출이 끝난 후 감사 수행
  ctx.on('tools/result', (exec, result) => {
    console.log(`[audit] 도구 ${exec.name} 실행 완료, ${result.content.length}개의 콘텐츠 블록을 반환했습니다`)
  })

  // 부가: 턴이 곧 종료될 때 표시 남기기
  ctx.on('agent/turn-stopping', ({ agent, turn }) => {
    console.log(`[audit] 에이전트 ${agent.id}의 ${turn}번째 턴이 곧 종료됩니다`)
  })
}

한 부분씩 살펴 보겠습니다.

  • agent/request는 waterfall 이벤트로, 모델 요청이 발송되기 전에 발생합니다. 리스너는 페이로드와 next()라는 두 개의 인자를 받습니다. await next()로 머신이 원래 사용할 호출 설정(config.model이 선택된 모델입니다)을 가져오고, 로그를 기록한 뒤 그대로 반환합니다. 보기만 했을 뿐, 어떤 동작도 바꾸지 않았습니다. 이 세 이벤트 이름과 발생 패턴은 저장소의 "이벤트 생산자-소비자 매트릭스"에서 확인할 수 있습니다(출처: docs/event-producer-consumer.md).
  • tools/result는 emit 이벤트로, 도구 호출이 끝난 후 브로드캐스트됩니다. events.zh.md의 예제 로그 플러그인이 리스닝하는 것이 바로 이 이벤트입니다. 첫 번째 인자는 이번 실행(exec.name이 도구 이름)이고, 두 번째 인자는 동결된 결과 스냅샷입니다. 감사 용도로 이보다 적합할 수 없습니다(출처: docs/user/develop/framework/events.zh.md).
  • agent/turn-stopping은 serial 이벤트로, 턴이 종료되기 직전에 발생하며 next 파라미터가 없습니다. 순수한 관찰 지점으로, 리스너는 "보는" 역할만 하고 단락(short-circuit)할 수 없습니다(출처: docs/event-producer-consumer.mdpackages/core/agent/src/runtime-types.ts의 선언).

⚠️ 초보자가 반드시 밟는 함정: tool/call, turn/start 등은 영속화되는 세션 이벤트 타입이지, 같은 이름의 런타임 이벤트가 아닙니다. 이들을 관찰하려면 session/event를 리스닝하고 event.type을 확인해야 합니다(출처: docs/user/develop/framework/events.zh.md):

ctx.on('session/event', (session, event) => {
  if (event.type === 'tool/call') {
    console.log(`[audit] 세션 ${session.id}이(가) 도구 ${event.data.name}을(를) 호출했습니다`)
  }
})

좋은 소식이 하나 더 있습니다. 이벤트 리스너도 이펙트(effect)입니다. ctx.on()으로 등록한 리스너는 플러그인이 언로드될 때 자동으로 제거되므로 수동 정리가 필요 없고 잔여물도 남지 않습니다(출처: docs/user/develop/framework/events.zh.md).


3. waterfall 시맨틱 실전: next()를 호출하면 통과, 호출하지 않으면 인계

이전 섹션의 두 리스너는 모두 "관찰"이었습니다. 데이터를 받아 기록하고 그대로 통과시키는 것입니다. 하지만 리스너의 가치는 관찰에 그치지 않습니다. waterfall(폭포식) 이벤트를 사용하면 핵심 결정 지점에서 가로채고 재작성할 수 있습니다. 이번 강의에서 가장 중요한 부분이니, 다이어그램과 함께 살펴 보세요.

事件如 agent/request监听器 1中间件监听器 2中间件消费方最终处理next()next()不调用 next() = 直接返回 → 接管/短路

waterfall = 环绕中间件:监听器用 next() 把控制权交给下一位,不调用就是接管

waterfall의 시맨틱은 입문 문서에 명확히 적혀 있습니다.

ctx.waterfall은 래핑 미들웨어입니다. 리스너는 (...args, next)를 받습니다. next()를 호출하면 다운스트림 리스너가 실행되고, 다운스트림 반환값은 next()를 통해 현재 래핑 레이어로 돌아와서 해당 레이어가 래핑한 뒤 계속 바깥으로 반환할 수 있습니다. next()를 호출하지 않고 바로 반환하면 단락됩니다. (출처: docs/cordis-primer.zh.md)

세 가지 규칙으로 나누어 보겠습니다.

  1. 모든 리스너는 미들웨어입니다. 이벤트는 발생 지점에서 출발해 각 리스너를 순서대로 지나고, 마지막으로 소비자(예: 모델 호출, 도구 실행)에 도달합니다.
  2. next()를 호출하는 것이 곧 통과입니다. await next()로 얻는 것은 "다운스트림의 모든 리스너가 처리를 마친 후"의 결과입니다. 이를 래핑하거나 수정해서 바깥으로 반환할 수 있습니다.
  3. next()를 호출하지 않고 바로 반환 = 단락 = 인계. 이 뒤의 리스너와 소비자는 더 이상 이 이벤트를 볼 수 없습니다. 이것은 "규칙 위반"처럼 보이지만, 사실 의도된 설계입니다.

단일 결정 이벤트에서는 단락이 설계 의도입니다. 결정권을 가진 정책 리스너는 next()를 호출하지 않고 바로 반환할 수 있지만, 주석을 달거나 관찰만 하는 리스너는 반드시 위임해야 합니다. (출처: docs/cordis-primer.zh.md)

개발자 문서는 이를 경고로까지 명시했습니다.

waterfall 리스너는 반드시 next()를 호출해야 합니다. next를 호출하지 않으면 전체 파이프라인이 단락되며, 이는 의도된 설계입니다. 인터셉션/게이트웨이 로직을 구현하기 위한 것입니다. (출처: docs/user/develop/framework/events.zh.md)

두 가지 실제 시나리오를 보겠습니다.

시나리오 1: 모델 요청의 "설정 교체". agent/requestnext()는 머신이 동결한 호출 설정(LlmCallConfig, provider, model, maxTokens 등의 필드 포함)을 반환하며, 리스너가 무엇을 반환하든 머신은 그것을 사용합니다(출처: packages/core/agent/src/runtime-types.ts):

ctx.on('agent/request', async (_payload, next) => {
  const config = await next()          // 머신의 기본 설정 가져오기
  return { ...config, maxTokens: 512 } // 수정한 뒤 돌려주기: 이 폭포는 설정 교체 전용
})

시나리오 2: 도구 호출에 안전 정책 걸기. tools/pre-execute는 waterfall 이벤트로, 결정 타입은 "허용 / 거부 / 확인"입니다. 거부할 때는 next()를 호출하지 않고 deny 결정을 바로 반환하면 도구가 실행되지 않습니다. 통과시킬 때는 결정권을 다운스트림에 위임합니다(출처: packages/core/tools/src/index.ts):

ctx.on('tools/pre-execute', async (exec, next) => {
  if (isBanned(exec.name)) {
    return { kind: 'deny', reason: '이 도구 호출은 정책에 의해 금지되었습니다' } // next()를 호출하지 않음: 바로 인계
  }
  return next() // 통과: 다운스트림에 결정 맡기기
})

💡 판단 요령: "내가 결정한다"면 next()를 호출하지 않고, "보기만 한다"면 반드시 next()를 호출합니다. 방향을 잘못 정하면 통과시켜서는 안 될 것을 조용히 통과시키거나, 막아서는 안 될 것을 조용히 막아버리게 됩니다.


4. 이벤트 선정과 주의 사항: 어떤 타이밍에 어떤 로직을 삽입할 것인가

4.1 이벤트 선정 표

아래 이벤트는 모두 저장소의 "이벤트 생산자-소비자 매트릭스"에서 가져온 것입니다(출처: docs/event-producer-consumer.md):

이벤트모드발생하는 단계주요 용도
agent/pre-stepwaterfall각 단계 시작 전, 이번 단계에 들어갈 메시지 배치를 담고 있음단계 전체를 거부하거나 메시지를 교체/주입. plan-mode(계획 모드), agent-instructions(워크스페이스 컨텍스트)가 여기에 있습니다
agent/requestwaterfall모델 요청 발송 전, 동결된 호출 설정을 담고 있음provider, model, maxTokens 등의 설정 교체. 단, 이 폭포는 메시지 내용을 바꿀 수 없습니다
agent/request-errorwaterfall모델 요청 실패 후, 재시도 또는 단계를 닫기 전재시도 여부를 결정. llm-retry(재시도 플러그인)가 여기에 있습니다
agent/turn-stoppingserial턴 종료 직전(모델이 더 이상 응답 의무가 없음)턴 종료 관찰. 중지를 막으려면 agent.steer()로 새 입력을 하나 넣으면 머신이 한 단계 더 실행합니다
tools/pre-executewaterfall도구 실행 전사전 검사, 위험한 호출 가로채기. 결정은 허용 / 거부 / 확인
tools/post-executewaterfall도구 실행 후, 결과가 확정되기 전도구 결과 교체 또는 풍부화(너무 긴 결과 요약, 컨텍스트 첨부)
tools/resultemit도구 호출이 완전히 끝나고 결과가 동결된 후감사, 로그, 통계. 보기만 하고 건드리지 않음
session/eventemit영속 로그 사실이 기록될 때마다로그 스트림 관찰: UI 렌더링, 텔레메트리 보고, 토큰 통계가 모두 이를 리스닝합니다
fs/write-intentwaterfall파일 쓰기 의도가 발생할 때파일 안전 정책: fs-observation-policy(파일 정책 플러그인)가 여기에 있습니다

선정 시 판단 순서: 먼저 "동작을 바꾸고 싶은가?"를 묻습니다. 동작을 바꾸고 싶다면 → waterfall 이벤트를 선택(가로채기/재작성). 관찰만 하고 싶다면 → emit 이벤트(또는 serial 관찰 지점)를 선택합니다.

4.2 주의 사항 1: 리스너 내의 에러 처리

리스너는 프레임워크의 메인 루프 경로 위에서 실행되므로, 던져진 예외 하나가 전체 요청을 중단시킬 수 있습니다. 두 가지 실천 규칙이 있습니다.

  • 관찰형 리스너(emit)는 자신의 로직을 try/catch로 감쌉니다: 감사 로그 쓰기 실패나 네트워크 보고 타임아웃으로 모델 요청까지 실패하게 해서는 안 됩니다. tools/result 같은 emit 이벤트의 리스너 실패는 격리되지만(출처: packages/core/tools/src/index.ts), "프레임워크가 받쳐 주겠지"에 의존하는 것은 좋은 습관이 아닙니다. 자신의 예외는 스스로 받아내세요.
  • waterfall 리스너는 통과시키거나 명확한 결정을 내리거나 둘 중 하나여야 하며, 중간에 예외를 던지지 않습니다: 여러분의 레이어에서 던지면 다운스트림이 next()의 값을 받지 못합니다. 정말로 오류가 발생했다면 파이프라인을 여러분의 손에서 죽이는 것보다 return next()로 통과시키는 편이 낫습니다.

4.3 주의 사항 2: 읽기 전용 리스닝 vs 페이로드 수정

  • emit 이벤트는 브로드캐스트입니다: 모든 리스너가 등록 순서대로 동기적으로 "한 번씩 보기"만 하고, 반환값은 무시됩니다. 페이로드가 전달되기는 하지만, 계약상 이것은 읽기 전용 관찰 지점입니다. 데이터를 바꾸고 싶다면 emit의 페이로드를 몰래 수정할 것이 아니라 waterfall 이벤트를 선택해야 합니다.
  • 수정이 허용되는 것은 waterfall 이벤트뿐입니다: 여러분이 반환하는 것이 다운스트림이 보는 것입니다. agent/request의 선언에는 "모델에 보이는 콘텐츠는 기록된 채널을 사용해야 하며, 이 폭포는 메시지를 변경해서는 안 된다"고까지 명시되어 있습니다(출처: packages/core/agent/src/runtime-types.ts). 바꿀 수 있다고 해서 모든 것을 바꿔야 하는 것은 아닙니다.

한 문장으로 정리하면: emit은 보는 것, waterfall은 바꾸는 것. 모드를 잘못 고륾면 플러그인은 효과가 없거나(가로채고 싶었는데 emit을 쓴 경우) 권한을 넘어서게 됩니다(관찰만 하고 싶었는데 페이로드를 몰래 바꾼 경우).


5. 핵심 포인트 복습

  1. 리스닝 = 삽입: ctx.on('event-name', handler)로 리스너를 등록하면 프레임워크 메인 루프의 어떤 슬롯에든 로직을 삽입할 수 있으며, 프레임워크 소스는 전혀 건드리지 않습니다(출처: docs/user/develop/framework/events.zh.md)
  2. 세 가지 실제 슬롯: agent/request(모델 요청 전, waterfall), tools/result(도구 호출 후, emit), agent/turn-stopping(턴 종료 전, serial 관찰 지점). 모드는 "이벤트 생산자-소비자 매트릭스"에서 확인할 수 있습니다(출처: docs/event-producer-consumer.md)
  3. waterfall의 두 갈래 길: next()를 호출해서 통과시키고 반환값을 래핑하기. 호출하지 않고 바로 반환 = 단락 인계. 결정권을 가진 정책 리스너가 이를 사용하고, 관찰형 리스너는 반드시 위임해야 합니다(출처: docs/cordis-primer.zh.md)
  4. 선정 판단: 동작을 바꾸려면 waterfall(가로채기/재작성), 관찰만 하려면 emit. tool/call 같은 영속 세션 이벤트는 session/event를 통해 관찰합니다
  5. 두 가지 주의 사항: 리스너 에러는 스스로 받아내어 메인 루프가 함께 물리지 않게 합니다. emit은 읽기 전용 관찰 지점이므로, 데이터 변경은 waterfall을 통해 합니다

🚀 다음 강의에서는 플러그인 작성을 이어갑니다. 설정을 공개하는 방법, 즉 코드를 수정하는 대신 사용자가 화면에서 플러그인 파라미터를 조정할 수 있게 하는 방법을 배웁니다.

자가 테스트 · 이벤트 리스닝

답을 모두 선택한 후 「답안 제출」을 클릭하면 정답 여부와 해설을 확인할 수 있습니다.

1. 「모델이 요청할 때마다」 로그 한 건을 기록하려면 어떤 이벤트를 리스닝해야 합니까?
2. waterfall 이벤트에서 next()에 대한 설명으로 올바른 것은 무엇입니까?
3. 「특정 도구 호출을 거부하는」 인터셉션 로직을 구현하려면 어떤 이벤트를 리스닝하고 어떻게 해야 합니까?
4. 「읽기 전용 관찰」과 「페이로드 수정」은 각각 어떤 종류의 이벤트를 선택해야 합니까?