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

9강: 이벤트 시스템: 모든 것은 이벤트다

한 줄 요약: DSH는 에이전트의 핵심 동작 하나하나를 이벤트로「브로드캐스트」합니다. 이벤트가 바로 서비스의 확장 API입니다. 소스를 fork하지 않고 커스텀 로직을 끼워 넣고 싶다면 해당 이벤트를 리스닝하기만 하면 됩니다. waterfall(폭포식) 이벤트를 만나면 next()를 호출해 제어권을 하류에 위임하고, 호출하지 않으면 숏서킷으로 장악합니다.


1. 사용자 스토리: 소스를 fork하지 않고 모델 요청 전후에 커스텀 로직 끼워 넣기

이미 돌아가고 있는 DSH 배포를 인수인계받았다고 가정해 봅시다. 상사가 세 가지 요구 사항을 줍니다:

  1. 모든 모델 요청은 기본적으로 저렴한 모델을 쓰고, 특별한 작업만 비싼 모델을 쓸 것.
  2. 모델 요청 전에 컨텍스트에 팀이 정한 워크스페이스 정보가 있는지 먼저 확인할 것.
  3. 도구 호출이 끝날 때마다 구조화된 로그를 한 건 기록해서 문제 추적을 쉽게 할 것.

전통적인 프레임워크라면 이런 요구 사항은 거의 모두 같은 답을 가리킵니다. 소스를 fork해서 메인 루프를 수정하는 것. 그러면 업스트림이 업그레이드될 때마다 패치를 다시 머지하느라 고통받게 됩니다.

DSH의 답은: 아무것도 fork할 필요가 없습니다. 에이전트 메인 루프의 각 단계(메시지 수령, 요청 조립, 모델 호출, 도구 디스패치, 턴 종료)는 모두 이벤트를 발생시키며, 여러분은 해당 이벤트를 리스닝하는 작은 플러그인 하나만 작성하면 됩니다:

export const name = 'team-hooks'

export function apply(ctx: Context) {
  // 요구 사항 1: 모델 요청 전에 기본 설정을 저렴한 모델로 교체
  ctx.on('agent/request', async (_payload, next) => {
    const config = await next() // 하류(머신 기본값)의 호출 설정을 받아온다
    return { ...config, model: 'cheap-model' } // 모델을 바꿔서 되돌려 준다
  })

  // 요구 사항 3: 도구 호출 후 로그 한 건 기록
  ctx.on('tools/result', (exec, result) => {
    console.log(`[tool] ${exec.name} 완료, ${result.content.length}개의 콘텐츠 블록 반환`)
  })
}

이 코드는 실제 문서에 있는 예제 플러그인에서 가져온 것입니다(출처: docs/user/develop/framework/events.zh.md). 프레임워크 소스를 전혀 건드리지 않고 런타임에「걸려」있을 뿐입니다. 이벤트 리스너 자체가 하나의 이펙트이며, 플러그인이 언로드되면 리스너도 자동으로 제거되어 어떤 잔여물도 남지 않습니다.

💡 이 문형을 기억하세요. 동작을 추가하려면 이벤트를 리스닝하고, 동작을 바꾸려면 waterfall 이벤트를 리스닝해서 장악하라. 이것이「모든 것은 이벤트다」의 첫 번째 의미입니다.


2. 이벤트는 서비스의 확장 API: 세 가지 이벤트 도메인

아키텍처 문서는 단도직입적으로 말합니다:

이벤트는 서비스의 확장 API다. (출처: docs/architecture.zh.md)

즉, 이벤트는「덤으로 알려 주는」보조 메커니즘이 아니라, DSH가 일부러 플러그인 작성자에게 열어 둔 확장 인터페이스입니다. DSH는 이벤트를 세 가지 도메인으로 나눕니다:

  • 세션 이벤트session/event를 통해 발생하는 영속 로그 사실입니다.
  • Agent 이벤트는 활성 Agent를 운송하며 inbox, 스텝, 상태, 요청, 검증, 재개에 사용됩니다.
  • 케이퍼빌리티 이벤트는 임포트 사이클 없이 정책과 어댑터를 부착할 수 있습니다. (출처: docs/architecture.zh.md)
이벤트 도메인어떤 모습인가무엇을 담당하는가실제 예시
세션 이벤트session/event(여러 종류의 로그 사실을 운송하는 하나의 이벤트)「무슨 일이 일어났는가」를 기록: 세션 로그에 추가되며 단일 진실 공급원이 된다turn/start, step/end, tool/call, tool/result
Agent 이벤트agent/*활성 Agent를 운송하며 스텝, 요청, 상태, 중지를 관리agent/pre-step, agent/request, agent/status, agent/turn-stopping
케이퍼빌리티 이벤트tools/*, fs/*, llm/*메인 루프를 건드리지 않고 특정 케이퍼빌리티에 정책과 어댑터를 부착tools/pre-execute, fs/write-intent, llm/stream

초보자가 반드시 밟는 함정을 미리 분명히 해 두겠습니다. tool/call, turn/start 등은 영속화되는 세션 이벤트 타입이며, 같은 이름의 런타임 이벤트가 아닙니다. 이것들을 관찰하려면 session/event를 리스닝한 뒤 event.type을 확인해야 합니다. 런타임에 브로드캐스트되는 Cordis 이벤트는 tools/*, agent/* 계열입니다(출처: docs/user/develop/framework/events.zh.md).


3. waterfall: next()로 위임하고, 호출하지 않으면 장악(핵심)

Cordis 이벤트에는 네 가지 디스패치 모드가 있으며, 지난 두 강에서 본 ctx.on() 리스닝은 그중 하나일 뿐입니다:

모드한 줄 설명반환값이 있는가
emit브로드캐스트 알림: 모든 리스너가 등록 순서대로「한 번씩」본다없음
waterfall래핑 미들웨어: 각 리스너가 결과를 감쌀 수도, 숏서킷할 수도 있다있음
parallel모든 리스너가 병렬로 실행된다없음
serial등록 순서대로 실행되고, 첫 번째 비어 있지 않은 결과가 나머지를 종료시킨다있음

이 가욍데 waterfall은 확장 능력이 가장 강력하고, 가장 이해가 필요한 방식입니다. 문서는 이렇게 정의합니다:

ctx.waterfall은 래핑 미들웨어다. 리스너는 (...args, next)를 받는다. next()를 호출하면 하류 리스너가 실행된다. 하류의 반환값은 next()를 통해 현재 래핑 계층으로 돌아오며, 이 계층에서 감싼 뒤 계속 바깥으로 반환할 수 있다. next()를 호출하지 않고 바로 반환하면 숏서킷한다. (출처: docs/cordis-primer.zh.md)

한 문장으로: waterfall은 양파 사슬과 같아서, 이벤트가 발생지에서 각 리스너를 차례로 통과해 최종적으로 소비자에게 도달합니다.

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

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

그림 속 규칙을 풀어 설명하면:

  • 모든 리스너는 미들웨어입니다. 먼저 자기 할 일(파라미터 수정, 로그 기록, 검사)을 하고, next()를 호출해 다음 리스너에게 제어권을 넘깁니다.
  • 하류의 반환값은 온 길 그대로 돌아옵니다. await next()로 받는 것은「뒤쪽 모든 리스너가 처리를 마친 뒤」의 결과이며, 한 겹 더 감싸서(예: 모델 설정 교체) 바깥으로 반환할 수 있습니다.
  • next()를 호출하지 않고 바로 반환 = 숏서킷 = 장악. 뒤쪽 리스너와 소비자 모두 이 이벤트를 볼 수 없게 됩니다. 이것은「규칙 위반」처럼 보이지만 실제로는 의도된 설계입니다:

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

개발자 문서는 이것을 경고로까지 적어 두었습니다:

waterfall 리스너는 반드시 next()를 호출해야 한다. next를 호출하지 않으면 파이프라인 전체가 숏서킷되는데, 이것은 의도된 설계로 인터셉션/게이트웨이 로직을 구현하는 데 사용된다. (출처: docs/user/develop/framework/events.zh.md)

실제 인터셉션 시나리오를 살펴 봅시다. 파일 쓰기에 보안 정책을 거는 예(예시)입니다:

ctx.on('fs/write-intent', async (payload, next) => {
  // 나는「정책」이다: 결정권을 가진다
  if (isDangerousWrite(payload)) {
    return { allowed: false } // next()를 호출하지 않고 바로 장악: 이 쓰기를 거부
  }
  return next() // 허용: 결정권을 하류에 위임
})

이 판단 구호를 기억하세요. 「내가 결정한다」면 next()를 호출하지 않고, 「그냥 보기만 한다」면 반드시 next()를 호출한다.


4. 실제 이벤트와 재구축 가능성: 어느 단계에 무엇을 끼워 넣을 수 있는가

4.1 실제 이벤트: 어느 단계에 무엇을 끼워 넣을 수 있는가

아래 이벤트들은 모두 리포지터리의「이벤트 생산자와 소비자 매트릭스」(출처: docs/event-producer-consumer.md)와 서브시스템 문서(출처: docs/subsystems/core.md)에서 가져온 것입니다:

이벤트모드어느 단계에서 발생하는가여기에 무엇을 끼워 넣을 수 있는가
agent/pre-stepwaterfall각 스텝 시작 전, 이번 스텝에 들어갈 메시지 배치를 지님스텝 전체를 거부(reject)하거나 메시지를 교체/주입——plan-mode(계획 모드)와 agent-instructions(워크스페이스 컨텍스트)가 바로 여기서 일한다
agent/requestwaterfall모델 요청이 발송되기 전, 동결된 호출 설정을 지님provider, model, maxTokens 등의 설정 교체. 단, 이 폭포는 메시지 내용을 바꿀 수 없다는 점에 주의
agent/request-errorwaterfall모델 요청 실패 후, 재시도 또는 스텝 종료 전retry를 반환해 재시도를 장악하거나 하류에 위임——llm-retry(재시도) 플러그인이 여기 있다
tools/pre-executewaterfall도구 실행 전사전 검사, 인자 재작성
agent/turn-stoppingserial턴이 닫히기 직전(모델이 더 이상 응답을 빚지지 않음)중지 방지: agent.steer()로 새 입력을 밀어 넣으면 머신이 한 스텝 더 돈다——문서가 공인한「중지 경계」
fs/write-intentwaterfall파일 쓰기 의도가 생겼을 때보안 정책: 허용, 거부 또는 재작성——fs-observation-policy(파일 정책) 플러그인이 여기 있다
session/eventemit영속 로그 사실이 기록될 때마다로그 스트림 관찰: UI 렌더링, 텔레메트리 전송, 토큰 집계, 영속화 백업이 모두 이것을 듣고 있다

이 가욍데 agent/pre-step요청 디스패치 전의 유일한 직렬 경계이고, agent/turn-stopping중지 경계입니다. 이 두 문장은 각각 docs/subsystems/core.mddocs/architecture.zh.md에서 나온 것으로, 「슬롯 위치」에 대한 공식 정의입니다.

4.2 이벤트와 재구축 가능성: 세션 이벤트가 곧 로그

3강 『에이전트 루프와 세션: 모든 것이 기록에 남는다』에서 배운「실행 재구축 가능」을 기억하시나요? 이번 강은 그 담체를 확실히 설명합니다:

세션 로그가 권위 있는 근거다. deriveMessages()가 모델 히스토리를 투영하고, 원본 assistant/chunk 이벤트가 리플레이와 UI 충실도를 보장한다. fork, 재개, transcript(텍스트 기록) 렌더링, 텔레메트리, 영속화는 모두 이 이벤트 스트림에서 파생된다. (출처: docs/architecture.zh.md)

풀어 볼까요:

  • 하나의 세션은 추가 전용(append-only) 이벤트 로그이며, 안에는 12종의 영속 이벤트가 있습니다: turn/start, turn/end, step/start, step/end, user/message, assistant/chunk, assistant/message, tool/call, tool/result, steering/message, todo/write, request/header(출처: docs/subsystems/core.md).
  • 모델이 보는 대화 히스토리는 따로 저장된 사본이 아닙니다. 매번 deriveMessages()로 로그에서 그때그때 계산해 낸 투영입니다.
  • 리플레이, UI, 텔레메트리, fork, 재개——모두 같은 하나의 이벤트 스트림에서 파생되며, 두 번째 진실은 없습니다.

그래서「모든 것은 이벤트다」의 두 번째 의미는: 이벤트는 확장 API인 동시에 데이터의 진실이다라는 것입니다. 런타임 이벤트는 개입을 가능하게 하고(앞의 세 절), 로그 속 이벤트는 재구축을 가능하게 합니다(이 절). 두 가지 모두 같은「이벤트」라는 언어를 씁니다.


5. 핵심 포인트 정리

  1. 이벤트는 서비스의 확장 API: 소스를 fork하지 않고, 이벤트 리스닝만으로 커스텀 로직을 끼워 넣는다(출처: docs/architecture.zh.md)
  2. 세 가지 이벤트 도메인: 세션 이벤트(session/event, 영속 로그 사실), Agent 이벤트(agent/*, 활성 Agent를 운송), 케이퍼빌리티 이벤트(tools/*, fs/*, llm/*, 정책과 어댑터 부착)
  3. waterfall은 래핑 미들웨어: next()를 호출해 하류에 위임하고 반환값을 감싼다. 호출하지 않고 바로 반환 = 숏서킷 장악——정책 리스너는 결정권을 가질 때 이것을 쓰고, 관찰형 리스너는 반드시 위임해야 한다
  4. 실제 슬롯: agent/pre-step은 스텝 메시지 인터셉트/주입, agent/request는 모델 설정 교체, agent/request-error는 재시도 결정, agent/turn-stopping은 턴 종료 방지, fs/write-intent는 쓰기 정책 부착
  5. 세션 이벤트가 곧 로그: 추가 전용, 투영 가능, 리플레이 가능——리플레이, UI, 텔레메트리, fork, 재개가 모두 여기서 파생되며, 3강의「실행 재구축 가능」과 호응한다

🚀 다음 강에서는 DSH의 코드 지도를 펼칩니다. 이벤트 선언, 서비스 정의, 플러그인 엔트리가 소스의 어느 디렉터리에 사는지——다 읽고 나면 직접 첫 플러그인을 작성할 수 있게 됩니다.

셀프 테스트 · 이벤트 시스템

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

1. DSH는 이벤트를 어떤 세 가지 이벤트 도메인으로 나눕니까?
2. waterfall(폭포식) 이벤트에 대한 설명으로 옳은 것은?
3. 모델 요청이 발송되기 전에 기본 모델을 더 저렴한 모델로 바꾸려면 어떤 이벤트를 리스닝해야 합니까?
4. 세션 이벤트와 로그의 관계에 대한 설명으로 옳은 것은?