서브에이전트

서브에이전트 (Subagents)

AG-UI에서 실행(run)의 출력을 그것을 만든 서브에이전트에 귀속(attribute)시키는 방법을 설명해 드릴게요. 서브에이전트 지원이 정확히 무엇을 하고, 무엇을 하지 않는지 짚어 드릴게요.

출처: 문서

본문

많은 에이전트 프레임워크가 에이전트가 자식 에이전트에게 작업을 위임하게 해요 — 감독자(supervisor)가 연구 작업을 배치하거나, 도구 호출 자체가 중첩 에이전트인 agents-as-tools 패턴, 또는 플래너가 하위 작업을 병렬로 나눠주는 식이죠.

프런트엔드 입장에서 이 모든 것은 하나의 이벤트 스트림으로 도착해요. 추가 정보가 없으면 어떤 서브에이전트가 주어진 메시지를 만들었는지 알 방법이 없어서, 동시에 돌아가는 세 명의 연구원이 하나의 구분 없는 텍스트 벽으로 렌더링돼요.

AG-UI의 서브에이전트 지원은 정확히 그 문제만 해결해요: 각 이벤트를 그것을 만든 서브에이전트에 귀속시키고, 서브에이전트가 시작·종료되는 시점을 보고합니다. 오케스트레이션이나 스케줄링, 서브에이전트 정의는 하지 않아요 — 그건 전적으로 프레임워크 몫이에요.

subagentRunId는 정의가 아니라 호출 하나를 식별해요

이것이 가장 중요하게 이해해야 할 것이면서, 가장 쉽게 틀리는 부분이에요.

subagentRunId는 서브에이전트 **한 번의 호출(invocation)**을 위한 **불투명한 핸들(opaque handle)**이에요. 같은 서브에이전트를 두 번 실행하면 두 개의 서로 다른 값이 나옵니다. 재사용 가능한 서브에이전트 정의에 대한 안정적인 식별자가 아니고, 이름도 아니에요.

최상위 실행(run)과의 대칭이 그 차이를 더 명확하게 보여줘요.

최상위 레벨 서브에이전트
agentId — 설정된 재사용 가능한 에이전트 서브에이전트의 name — 재사용 가능한 서브에이전트 타입
runId — 한 번의 호출 라이프사이클 subagentRunId — 한 번의 중첩 호출 라이프사이클

그래서:

  • 다음은 하세요: 접을 수 있는 그룹, 스피너, 진행 행 같은 일시적 UI 상태를 subagentRunId로 키잉하세요.
  • 다음은 하지 마세요: subagentRunId로 무언가를 영속화하며 나중 실행에서도 의미가 있기를 기대하지 말고, 한 서브에이전트의 두 호출이 값을 공유한다고 취급하지 마세요. "이게 어떤 종류의 서브에이전트인지"를 말하려면 name을 쓰세요.
  • 하나의 예외: outcome: { type: "suspended" }로 끝난 서브에이전트는 재개하는 실행에서 자기 id를 재사용할 수 있어요. 재개된 작업을 일시 중단된 호출과 연결할 수 있는 프로듀서는 그렇게 해야 하고, 클라이언트는 이후의 SubagentStarted를 중복이 아니라 연속(continuation)(대기 → 실행)으로 취급해야 해요. 연결할 수 없는 프로듀서는 새 id를 발행하고, 그러면 클라이언트의 대기 상태는 id 재사용이 아니라 자기가 응답한 인터럽트 id들을 통해 해소돼요(Suspension 참고).

이 필드는 프리릴리스 빌드에서 subagentId라고 불렸어요. 옛 이름이 재사용 가능한 정의를 암시했기 때문에 이름이 바뀐 거예요. 여전히 subagentId를 쓰는 canary 빌드를 쓰고 있다면 값의 의미는 같아요 — 이름만 달라진 겁니다.

라이프사이클 이벤트

세 개의 이벤트가 서브에이전트의 활동을 감싸요.

sequenceDiagram
    participant Agent
    participant Client

    Note over Agent,Client: Subagent begins
    Agent->>Client: SubagentStarted

    Note over Agent,Client: Attributed output
    Agent->>Client: TextMessageStart / Content / End
    Agent->>Client: ToolCallStart / Args / End

    Note over Agent,Client: Subagent concludes
    alt Success
        Agent->>Client: SubagentFinished
    else Failure
        Agent->>Client: SubagentError
    end

SubagentStarted

새 서브에이전트 호출을 알리고, UI가 표시할 수 있는 이름을 부여해요.

속성 설명
subagentRunId 이 호출을 위한 불투명한 id. 필수
name 표시용으로 선언된 서브에이전트 타입 또는 이름. 필수
description 선택적 사람이 읽을 수 있는 설명
parentSubagentRunId 선택 — 서브에이전트가 중첩될 때 감싸는 서브에이전트
parentToolCallId 선택 — 이 서브에이전트를 만들어 낸 도구 호출
parentMessageId 선택 — 그 도구 호출을 담은 메시지

parentToolCallId와 parentMessageId는 agents-as-tools 패턴을 위해 존재해요. 클라이언트가 rawEvent를 들여다보지 않고도 서브에이전트를 그것을 만든 호출과 연결할 수 있게 해 주죠 — 예를 들어 도구 호출 카드 안에 서브에이전트 출력을 렌더링하는 용도로요.

SubagentFinished

이 실행에서 서브에이전트 호출의 스트림 세그먼트를 닫아요.

속성 설명
subagentRunId SubagentStarted의 id와 일치. 필수
result 선택적 완료 페이로드, RunFinished.result를 미러링
outcome 선택적 타입화된 결과, RunFinished.outcome을 미러링: { type: "success" } 또는 { type: "suspended", interruptIds?: [...] }. 생략 시 성공을 뜻함
Suspension (일시 중단)

서브에이전트는 외부 입력을 기다리며 작업 중간에 멈출 수 있어요 — 예를 들어 안에서 발생한 인간 승인(human approval) 같은 것요. 그러면 실행은 인터럽트 결과로 끝나고(Interrupts 참고), 시작된 모든 서브에이전트는 RunFinished 전에 닫히므로 일시 중단된 서브에이전트도 SubagentFinished를 내보내요 — 다만 outcome: { type: "suspended" }를 달고요. 그래서 UI가 "완료(done)" 대신 "대기(waiting)"로 렌더링할 수 있어요. interruptIds는 이 서브에이전트가 직접 소유한 실행 레벨 인터럽트를 지목해요(각 Interrupt도 subagentRunId 역참조를 담아요); 후손이 인터럽트해서 일시 중단된 조상의 경우 비어 있거나 생략될 수 있어요.

Suspension은 subagentRunId가 의도적으로 실행을 가로지를 수 있는 유일한 경우로, 양쪽에 서로 다른 의무가 있어요.

  • 클라이언트는 수용해야 해요: 일시 중단된 호출의 id를 재사용하는 이후 실행의 SubagentStarted를 받아들이고, 그것을 연속으로 취급하세요 — 기존 그룹을 대기에서 실행으로 전이하고, 중복을 렌더링하지 마세요.
  • 프로듀서는 연결할 수 있을 때 id를 재사용해야 해요: LangGraph 통합이 그렇게 해요. 그 id는 일시 중단을 견디는 체크포인트 작업 정체성에서 파생되거든요. 재사용이야말로 클라이언트가 그룹을 매끄럽게 이어갈 수 있게 하는 것입니다.
  • 연결할 수 없는 프로듀서는 새 id를 발행하고, 그것도 유효해요. 클라이언트의 대기 상태는 매달리지 않아요: 일시 중단 결과의 interruptIds와 클라이언트 자신이 보낸 재개 항목이 어떤 승인에 응답됐는지 식별하므로, 옛 id 아래로 연속이 도착하지 않아도 대기 배지가 그 기준으로 해소돼요.

SubagentError

서브에이전트 호출을 실패로 표시해요.

속성 설명
subagentRunId SubagentStarted의 id와 일치. 필수
message 사람이 읽을 수 있는 오류 메시지. 필수
code 선택적 오류 코드

귀속 (Attribution)

라이프사이클 이벤트 외에도, 대부분의 이벤트는 누가 만들었는지 말해 주는 선택적 subagentRunId를 담을 수 있어요.

subagentRunId가 없는 이벤트는 부모 에이전트에 속해요. 귀속은 덧붙여지는(additive) 방식이에요: 이 필드를 전혀 설정하지 않는 스트림은 서브에이전트가 존재하기 전과 정확히 똑같이 동작합니다.

귀속은 또한 단독으로도 성립해요. 프로듀서는 SubagentStarted/SubagentFinished를 내보내지 않고도 이벤트에 태그를 붙일 수 있어요 — 라이프사이클 보고를 약속하지 않고 프로듀서별로 출력을 그룹화하기에 충분하죠. 클라이언트는 절대 발표된 적 없는 식별자를 받아들여야 해요. 클라이언트가 강제하는 규칙을 참고하세요.

귀속을 담을 수 있는 이벤트: 텍스트 메시지 패밀리, 도구 호출 패밀리, 액티비티 이벤트, 추론 패밀리, 스텝 이벤트, 상태 이벤트, Raw/Custom.

담을 수 없는 이벤트: RunStarted, RunFinished, RunError — 이것들은 실행 전체를 서술하니까요 — 그리고 MessagesSnapshot, 이것은 하나의 스냅샷이 여러 프로듀서의 메시지를 섞으므로 메시지별로 귀속을 담아요.

귀속은 소유(ownership)가 아니라 출처(provenance)예요

이 구분은 상태 이벤트에서 가장 중요하니 분명히 밝혀 둘게요.

StateSnapshot과 StateDelta는 귀속 가능해요. 이들에 대한 귀속은 어느 서브에이전트가 그 업데이트를 만들었는지를 기록해요 — 서브에이전트가 자기만의 상태를 가진다는 뜻이 아니에요. AG-UI 상태는 실행 범위(run-scoped)예요: 실행마다 상태 문서가 하나 있고, 귀속된 스냅샷이나 델타도 여전히 그 한 문서에 적용됩니다. 태그는 표면화할 수 있는 출처("연구원이 공유 스크래치패드를 업데이트했어요")일 뿐, 별도의 스코프가 아니에요.

그것이 다른 단독 이벤트에서 귀속이 담는 의미와 정확히 같아요. 누구도 귀속된 Custom 이벤트를 서브에이전트가 개인 커스텀 이벤트를 가진 것으로 읽지 않으며, 상태도 다르지 않아요.

서브에이전트별 상태(per-subagent state)라는 것은 존재하지 않아요. 부모 상태를 그대로 두길 기대하며 스냅샷에 귀속을 붙여도 그렇게 되지 않아요 — 스냅샷은 어떤 스냅샷이든 그렇듯 실행의 상태를 대체합니다. 서브에이전트 데이터를 분리해야 한다면 실행 상태 안의 별도 키를 사용하세요.

참고로 프로듀서가 상태에 귀속을 붙일 의무는 없어요. 실제로 안 하는 경우도 있어요: LangGraph 통합 같은 경우, 서브에이전트가 활성 상태인 동안에는 상태를 내보내지 않아요. 상태가 하나의 공유 문서이고 위임 중간 스냅샷이 부분 뷰를 담게 되기 때문이에요. 그것은 합리적인 프로듀서 측 선택이지, 프로토콜 요구사항이 아니에요.

귀속은 메시지로 전이돼요

귀속된 이벤트가 메시지를 만들면 subagentRunId가 그 메시지로 전이됩니다. 덕분에 렌더링 레이어가 이벤트 스트림을 다시 재생하지 않고도 서브에이전트별로 대화를 그룹화할 수 있어요 — 메시지 자체가 자기 출처를 담고, 턴과 스냅샷을 가로질러 그것을 유지하니까요.

도구 결과는 자기만의 귀속을 담아요

ToolCallResult는 그것이 응답하는 호출과 독립적으로 귀속돼요. 이것은 실수라기보다 의도적인 설계예요: 도구 호출을 실행하는 주체는 그것을 요청한 서브에이전트와 다를 수 있으니까요. 프런트엔드에서 실행되는 도구, 또는 서브에이전트 대신 호출을 실행하는 감독자, 둘 다 호출자가 아닌 주인이 있는 결과를 만듭니다. 호출자의 귀속을 상속하면 그런 경우를 잘못 보고하므로, 각 결과는 자기 귀속을 명시해요.

중첩과 동시성

서브에이전트는 중첩됩니다. SubagentStarted의 parentSubagentRunId는 자식을 감싸는 서브에이전트에 연결하고, 부모 링크가 없는 서브에이전트는 실행에 직접 속해요.

서브에이전트는 또한 동시에 실행되며, 이것이 동작하는 구현과 그럴듯한 구현을 가르는 경우예요. 세 명의 서브에이전트가 동시에 스트리밍하면 이벤트가 인터리브되고, 귀속이 그것들을 구분하는 유일한 수단이에요. 특히:

  • 두 서브에이전트가 동시에 열린 텍스트 메시지를 가질 수 있어요. 클라이언트는 한 번에 열린 메시지가 하나라고 가정하지 말고 각각을 독립적으로 추적해야 해요.
  • 서브에이전트의 SubagentFinished는 그 서브에이전트 자신의 열린 스트림만 닫지, 형제나 부모의 것은 절대 닫지 않아요.
  • 부모가 자식보다 먼저 끝날 수 있어요. 그래서 parentSubagentRunId가 이미 끝난 서브에이전트를 지목할 수 있는데, 그것도 유효합니다.

동시성과 청크 축약(shorthand)

TextMessageChunk/ToolCallChunk/ReasoningMessageChunk 이벤트는 축약 표기예요: 클라이언트가 START/CONTENT/END 경계를 합성하고, id를 생략한 청크는 "이전 것과 같다"는 뜻이에요. 동시성 아래에서 "이전"은 서브에이전트별로만 의미가 있어요 — 그래서 축약은 실행 전체가 아니라 보내는 서브에이전트 자신의 스트림 안에서 그것을 해결합니다.

실용적 귀결은 프로듀서를 위한 규칙 하나예요: id도 subagentRunId도 담지 않은 청크는, 해당 종류의 부모 열린 스트림이 있으면 그것으로 해결되고(태그 없음 = 부모), 아니면 해당 종류의 유일한 열린 스트림으로 해결돼요. 여러 서브에이전트의 스트림이 모두 그것을 주장할 수 있을 때는 해결할 대상이 없으므로, 클라이언트는 추측하지 않고 거부합니다. 동시에 스트리밍할 때는 모든 청크에 귀속을 붙이거나, id를 반복하세요.

클라이언트가 강제하는 규칙

아래 모든 규칙은 이벤트가 존재한다는 조건 하에 적용돼요. 귀속만으로도 서브에이전트 지원의 완전하고 유효한 사용이므로, 여기 어떤 것도 스트림이 라이프사이클 이벤트를 보내도록 요구하지 않아요.

  • SubagentFinished와 SubagentError는 현재 활성 상태인 서브에이전트를 지목하고, 서브에이전트는 한 실행 안에서 두 번 시작되지 않아요. 라이프사이클 이벤트는 스키마 필수 필드를 담아요(세 개 모두 subagentRunId, SubagentStarted엔 name, SubagentError엔 message) — 클라이언트는 와이어 레벨 스키마 검증을 우회하는 인프로세스 프로듀서에 대해서도 이것을 강제해요.
  • 연속·종료 이벤트는 엔티티가 생성된 주인과 일치해요. 한 서브에이전트가 연 텍스트 메시지를 다른 서브에이전트가 이어갈 수 없어요.
  • 소유는 재생된 히스토리로도 확립됩니다: MessagesSnapshot의 메시지(그리고 그것이 담은 각 도구 호출)는 그것이 담은 subagentRunId가 소유하죠(없으면 부모) — 그리고 다른 주인 아래서 그 id를 다시 여는 이후 이벤트는, 충돌하는 두 번째 오프너와 정확히 마찬가지로 거부돼요.
  • 도구 호출은 그것의 parentMessageId가 지목하는 어시스턴트 메시지에 속해요. ToolCall 자체는 귀속 필드를 담지 않으므로, 명시적 subagentRunId가 그 메시지의 주인과 일치하지 않는 ToolCallStart는 충실히 표현될 수 없고 거부돼요; 태그 없는 도구 호출은 부모 메시지의 주인을 상속합니다.
  • 스텝은 그것을 연 에이전트로 범위가 한정돼요: 서브에이전트는 부모의 스텝이나 형제의 스텝을 닫을 수 없어요.
  • 시작된 모든 서브에이전트는 RunFinished 전에 닫혀요.

몇 가지는 의도적으로 강제하지 않아요. 프로토콜이 요구하지 않기 때문이에요.

  • 귀속에 쓰인 subagentRunId가 시작됐을 필요는 없어요. 라이프사이클 이벤트 없는 귀속은 지원되는 모드이므로, 클라이언트는 발표되지 않은 식별자를 오류로 취급하면 안 돼요. UI는 보이는 어떤 id로든 그룹화하고, name이 없으면 id로 폴백해야 해요.
  • parentSubagentRunId는 시작된 서브에이전트만 지목하면 되지, 여전히 활성인 것까지는 요구하지 않아요 — 부모는 정당하게 자식보다 먼저 끝납니다.
  • 이미 끝난 서브에이전트에 귀속된 이벤트는 받아들여져요. 연속 이벤트는 그것이 끝난 뒤에도 속한 서브에이전트의 태그를 담아요.
  • 종료는 RunFinished 전에만 요구되지, RunError 전에는 아니에요. 닫히지 않은 서브에이전트는 중단된 실행의 예상된 모습이에요.
  • 귀속된 상태 이벤트는 받아들여져요. 위임 중간에 그것을 내보내지 않기로 한 프로듀서는 규칙이 아니라 프로듀서 측 결정이에요 — 귀속은 소유가 아니라 출처를 참고하세요.

호환성

오래된 클라이언트는 라이프사이클 이벤트를 완전히 거부해요. 귀속은 덧붙여지고 안전해요 — subagentRunId는 클라이언트가 용인하는 알 수 없는 필드예요 — 하지만 SubagentStarted, SubagentFinished, SubagentError는 알 수 없는 이벤트 타입이라, 서브에이전트 지원보다 오래된 클라이언트는 어떤 애플리케이션 코드도 실행되기 전에 디코딩 중 실패해요. 클라이언트 측에서 그것들을 걸러낼 방법이 없어요.

소비자 중 일부가 서브에이전트 지원보다 오래됐다면, 프로듀서는 그들에게 라이프사이클 이벤트를 내보내면 안 돼요.

두 방향은 대칭이 아니므로 분리해서 보는 게 좋아요.

새 에이전트가 오래된 클라이언트와 대화하기

라이프사이클 이벤트는 그것을 깨뜨리므로, 내보내는 것이 프로듀서에서 **옵트인(opt-in)**이어야 해요. 서브에이전트를 지원하는 통합은 그래서 그것을 켜는 플래그를 기본 꺼짐으로 노출해요 — LangGraph 통합에서는 subagent_visibility예요.

agent = LangGraphAgent(
    name="my-agent",
    graph=graph,
    subagent_visibility="attributed",
)

그 값은 클라이언트가 보는 것을 지칭해요. "inline"(기본값)은 라이프사이클 이벤트도 subagentRunId도 내보내지 않고, 서브에이전트 관련 그 무엇도 MessagesSnapshot에 닿지 않아요 — 스트림은 서브에이전트 지원이 존재하기 전과 정확히 같아요, 서브에이전트 자신의 텍스트가 부모의 작업으로 도착하는 것까지요. "attributed"는 이 문서가 서술하는 완전한 표면을 내보내요. "hidden"은 서브에이전트의 내부 스트림을 완전히 억제해요: 클라이언트가 보는 것은 부모의 스폰 도구 호출, 그 결과, 부모 자신의 답변뿐이에요. 모든 소비자가 충분히 새로워지면 "attributed"를 켜세요.

귀속만은 더 안전한 중간 단계예요, 그 필드를 모르는 클라이언트가 무시하기 때문이에요. 호환성 깨짐 없이 그룹화를 원하는 프로듀서는 이벤트에 귀속을 붙이고 라이프사이클을 완전히 건너뛸 수 있어요.

새 클라이언트가 오래된 에이전트와 대화하기

자동으로 처리돼요. TypeScript 클라이언트는 에이전트의 보고된 버전에 기반한 호환성 심(shim)을 삽입하고, 그것은 양방향으로 동작해요.

  • 클라이언트 → 에이전트. 심은 나가는 입력 메시지에서 subagentRunId를 제거해서, 오래된 에이전트가 해석할 수 없는 귀속을 받지 않게 해요. 이것이 실질적인 핵심 절반이에요: 재생된 메시지 히스토리나 더 새 클라이언트가 쓴 저장된 스레드는 정말로 그 필드를 담을 수 있거든요.
  • 에이전트 → 클라이언트. 심은 SubagentStarted, SubagentFinished, SubagentError를 버리고, 남은 모든 이벤트( MessagesSnapshot 메시지와 RunStarted 입력 에코 포함)에서 subagentRunId를 제거해요. 이것은 번역보다 방어적 정규화예요 — 서브에이전트 이전 버전을 보고하는 에이전트는 애초에 그 중 어느 것도 내보내면 안 되므로, 그것이 정말로 지키는 것은 혼합·프록시된 파이프라인에요.

두 번째 방향의 귀결을 알아둘 가치가 있어요: 이 심 뒤에 앉은 소비자는, 업스트림에서 무언가가 귀속을 붙였어도 평탄화되고 귀속 없는 스트림을 봐요. 귀속이 당신의 UI에 도달하길 원한다면, 에이전트는 서브에이전트를 지원하는 버전을 보고해야 해요.

지원

SDK 상태
TypeScript 이벤트, 귀속, 검증, 구독자 훅, protobuf
Python 이벤트와 귀속
.NET 이벤트, 귀속, 검증, protobuf

이진 protobuf 전송은 같은 스키마에서 생성된 서브에이전트 귀속을 TypeScript와 .NET 양쪽에 담아요. 그래서 서브에이전트 귀속 스트림이 언어를 가로지르는 왕복에도 살아남습니다.

알려진 제한

  • Protobuf는 이벤트 타입의 일부만 다룹니다. 36개 이벤트 타입 중 19개가 protobuf 매핑을 가져요: 세 개의 서브에이전트 이벤트 전부, 그리고 텍스트 메시지, 도구 호출 start/args/end, 상태, 스텝, 실행 패밀리, MessagesSnapshot, Raw, Custom. 나머지는 전혀 인코딩할 수 없어요 — 그리고 그 중 몇몇이 귀속을 담아요: ToolCallResult, 추론 패밀리, 액티비티 이벤트. 청크 축약도 인코딩 불가능해요: TextMessageChunk와 ToolCallChunk는 subagent_run_id를 담는 메시지 정의가 있지만 그것을 선택할 EventType enum 항목이 없고, ReasoningMessageChunk는 proto 메시지가 아예 없어요. 이 모든 것은 서브에이전트 지원보다 앞선 것들이에요.
  • .NET은 서로 다른 서브에이전트의 병렬 도구 호출에서 두 번째 주인을 잃습니다. 실행을 Microsoft.Extensions.AI 메시지로 변환하면 연속된 도구 호출을 하나의 ChatMessage로 병합하고, AG-UI는 메시지별로 귀속하므로 첫 주인만 살아남아요. 이것은 내재적 충돌이라기보다 현재의 제한이에요 — 프로바이더 제약은 인접성(adjacency)이고, 인터리빙도 귀속을 보존하면서 그것을 만족시킬 테니까요.

See also

  • Events — 서브에이전트 이벤트를 포함한 전체 이벤트 카탈로그
  • Messages — 메시지에서 귀속이 어떻게 나타나는지
  • State — 상태가 왜 실행 범위인지

더 알아보기 (Learn more)

  • 이벤트 (Events) — 서브에이전트 이벤트가 전체 이벤트 카탈로그에서 어디에 속하는지 확인해 보세요.
  • 메시지 (Messages) — 귀속이 메시지에 어떻게 전이되는지 살펴보세요.
  • 상태 (State) — 상태가 왜 실행 범위(run-scoped)인지 알아보세요.