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}/messages | POST /v1/notes/{noteId}/chat/messages |
| 소유 | 유저 (본인만) | 노트 (멤버 전원 열람) |
| 게이트 | 없음 | 노트 IN_PROGRESS에만 쓰기 + 입력 잠금(한 번에 한 명). NOT_STARTED·PAUSED·ENDED는 읽기 전용 |
| 컨텍스트 | scope=workspace(agentic 검색) 또는 note | 항상 해당 note |
AI에 보내는 chatKind | personal | shared |
| 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에 절대 직접 연결하지 않는다 |
| passthrough | server는 SSE 이벤트를 변환하지 않는다 — 양 구간 포맷 동일 |
| tee | server는 유저 메시지(요청 시), 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.stopped는 invalidate-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만으로는 공유 챗봇과 노트 스코프 개인 챗봇이 안 갈린다 |