본문으로 건너뛰기

Agent 채팅 흐름 (heymoa-web → heymoa-server → heymoa-ai)

계약: openapi3-server.yml · asyncapi-web-server.yml · asyncapi-server-ai.yml · openapi3-ai.yml 기획: pm/2-product/feature-specs/04-chatbots.md

핵심 구조: server는 유일한 public edge이자 SSE passthrough 중계자다. heymoa-ai가 만든 SSE 이벤트를 변환 없이 web으로 흘려보내고, 중계하면서 히스토리용 메시지만 자기 DB에 저장(tee)한다.

기획 v2로 챗봇은 2종이다 — 흐름은 동일하고 진입점과 게이트만 다르다.

개인 챗봇공유 챗봇
진입점POST /v1/agent-chats/{chatId}/messagesPOST /v1/notes/{noteId}/chat/messages
소유유저 (본인만)노트 (멤버 전원 열람)
게이트없음노트 IN_PROGRESS에만 쓰기 + 입력 잠금(한 번에 한 명). NOT_STARTED·PAUSED·ENDED는 읽기 전용
컨텍스트scope=workspace(agentic 검색) 또는 note항상 해당 note
AI에 보내는 chatKindpersonalshared
toolCredentials워크스페이스 연동 토큰 (기획 v2)워크스페이스 연동 토큰

server는 chatKind로 둘을 구분해 알려준다. scope만으로는 안 갈린다 — 공유 챗봇과 노트 스코프 개인 챗봇이 둘 다 scope=note로 오고, 기획 v2 §3.2가 "진행 중인 회의에 대한 개인 챗봇 질문도 가능하다"고 정해서 meetingStatus로도 못 가른다. AI는 이 조합으로 agent profile을 고른다.

1. 정상 흐름 (도구 호출 포함)

1.5 쓰기 도구 승인 흐름 (기획 v2)

조회(read) 도구는 위 1번 흐름대로 자동 실행된다. 쓰기(write) 도구만 승인을 거친다. 승인 주체는 해당 메시지의 입력자 본인이다 — 도구는 워크스페이스 연동 계정 명의로 실행되지만, 실행 트리거에 대한 동의는 입력자가 한다. 관전자는 상태만 본다.

거절(REJECTED)이면 도구를 실행하지 않고 tool_approval_resolved 후 agent가 거절을 반영해 응답을 이어간다 — 스트림은 정상 종료(message_end)된다.

2. 실패 분기

두 가지 실패는 성격이 다르다 — 도구 실패는 스트림이 계속되고, 치명 오류만 스트림이 끝난다.

3. 경계 규칙 요약

규칙내용
유일한 public edge브라우저는 heymoa-ai에 절대 직접 연결하지 않는다
passthroughserver는 SSE 이벤트를 변환하지 않는다 — 양 구간 포맷 동일
teeserver는 유저 메시지(요청 시), message_end.content(정상 종료 시), 그리고 승인·도구 실행 기록(tool_approval_resolved·tool_call_result 수신 시 role=TOOL 메시지)을 저장 — 관전자 폴링·아카이브 타임라인에서 도구 기록이 보이도록 (APP-107). 공유 챗의 meetingStatus=IN_PROGRESS 게이트는 USER·ASSISTANT tee에만 걸린다 — 그건 새 입력·응답이라 회의 상태에 종속되지만, TOOL tee는 이미 일어난 사실의 기록이라 게이트를 타지 않는다. 게이트를 태우면 승인 직후 회의가 끝났을 때 외부 시스템은 바뀌었는데 히스토리에는 아무 흔적이 없다(감사 불가)
대화 상태full state는 AI의 checkpointer 소유. server는 세션 레지스트리 + 표시용 사본만
toolCredentials워크스페이스 연동의 단기 토큰 (기획 v2). AI의 요청 스코프 메모리에만 존재 — DB·checkpoint·로그 금지. LangGraph runtime context로만 주입한다 (configurable은 checkpoint metadata로 복사된다)
실패 경계도구 401 → tool_call_result(error) + 스트림 계속 / 치명 오류 → error + 스트림 종료
승인 경계쓰기 도구만 승인. 승인 주체는 입력자 본인 (관전자 403). 타임아웃 만료 → REJECTED (대기 상한 300초). 승인은 스트림 밖 별도 HTTP 요청이므로 server는 재개 직전에 현재 멤버십을 다시 검증한다 — 요청 시점 권한만 믿으면 그 사이 탈퇴한 유저가 도구 실행을 승인할 수 있다. 공유 챗은 meetingStatus=IN_PROGRESS 재검증 + 승인 claim을 노트 행 락 하나의 트랜잭션으로 묶어 회의 종료와 직렬화한다(AI 호출은 그 락 — 외부 HTTP를 락 안에 두면 노트가 그만큼 잠긴다). 그래도 "claim 성공 후 AI 호출 전에 회의가 끝나는" 창은 남으며, 그 잔여 창의 피해는 위 tee 규칙(TOOL tee 무게이트)이 없앤다
heartbeat이벤트 없는 구간(승인 대기, 도구 실행 지연, LLM 장고 등)에 두 구간 모두 comment를 흘린다 — ai→server 15초 이하, server→web 20초 이하. 두 구간은 독립이다: server는 upstream 입력 행과 무관하게 자기 타이머(현재 10초 주기)로 발행하므로, upstream이 20초 넘게 조용해도 web 연결은 유지된다. upstream keepalive는 server가 소비하므로 그대로 흘러가지 않는다. 안 하면 프록시 유휴 타임아웃이 승인 대기보다 먼저 브라우저 연결을 끊는다. 구현 주의: 타이머 스레드와 중계 스레드가 같은 emitter에 쓰므로 전송은 emitter별 lock으로 직렬화한다
스트림 수명정상 수명 제어는 upstream 유휴 read timeout(60초) 이 한다 — keepalive(30초 이하)가 있는 한 승인 대기 중에도 스트림이 산다. web SSE emitter 타임아웃은 좀비 방지용 절대 상한일 뿐이며 승인 대기 상한(300초)보다 충분히 커야 한다. 입력 잠금은 스트림이 살아 있는 동안 갱신되므로 이 축과 독립이다
공유 챗봇 게이트서버 권위 meetingStatus=IN_PROGRESS일 때만 쓰기 (아니면 409). NOT_STARTED·PAUSED·ENDED는 히스토리만 읽고 composer를 잠근다. READY에는 meetingStatus=IN_PROGRESS면서 activeSessionStartedAt=null일 수 있고 시간 필드는 gate가 아니다. 입력 잠금 한 명, message_end/error·타임아웃에 해제. 단 pendingApproval 존재 시 잠금 타임아웃 정지 — 구현은 "정지"가 아니라 스트림 생존에 의한 지속 갱신이다: 승인 대기 중 AI가 keepalive comment를 발행(asyncapi-server-ai.yml)하고, server는 SSE 입력 행마다 잠금을 갱신하므로 스트림이 살아 있는 한 잠금이 만료되지 않는다. 승인 타임아웃(만료 REJECTED → 스트림 정상 종료)이 잠금 해제를 트리거 (이전 스트림의 interrupt 대기와 checkpoint 동시 접근 방지)
회의 상태 동기화기존 노트 토픽의 recording.started·recording.stoppedinvalidate-only 신호다. web@fad1d73은 payload를 상태 전이로 쓰지 않고 노트 상세 query와 캐시된 프로젝트 노트 목록 query를 invalidate한다. 활성 query는 즉시, 비활성 목록은 다음 사용 시 REST snapshot에 수렴한다(recording.stopped는 전사 query도 invalidate). 별도 pause/resume REST나 meeting.paused 이벤트는 없다
컨텍스트 전달전사·요약은 요청 payload에 싣지 않는다 — AI가 server 내부 조회 API(/internal/v1/notes/{noteId}/context, /internal/v1/workspaces/{workspaceId}/notes)로 당겨간다. 두 경로 모두 X-Internal-Token 필요
컨텍스트 갱신AI가 매 턴 다시 당겨간다. 진행 중 회의면 그 시점 스냅숏이 반영되고, 전사는 대화 히스토리에 안 쌓인다 — checkpoint에는 메시지만 남는다
agent 종류chatKind(personal|shared)로 구분한다. scope만으로는 공유 챗봇과 노트 스코프 개인 챗봇이 안 갈린다