Sessions
세션은 JARVIS 가 사용자와 나누는 대화 한 줄기를 묶어 저장하는 단위다. 대화가 끊기지 않고 이어지는 경험은 Personal Agent에게 가장 기본이 되는 신뢰이므로, 재접속하거나 서버를 다시 띄워도 같은 대화를 이어 가고, 대화를 갈라 다른 방향을 시험하고, 다 쓴 대화는 그 기억까지 함께 지울 수 있도록 설계했다. Conversation 의 모든 요청은 세션 하나 안에서 처리되고, 일화 기억과 절차 학습도 세션의 턴을 재료로 쓴다.
Sessions가 주목한 점
브라우저 탭을 새로고침했더니 방금까지 나누던 대화가 사라지는 일은 대화를 화면 상태에 기대는 Chatbot에서 흔하다. 서버를 다시 띄워도 마찬가지이고, 도구를 여러 번 부른 대화를 다시 열 때 기록이 조금이라도 어긋나면 LLM 프로바이더가 400 으로 거절해 그 대화를 더는 이어 갈 수 없다. 대화 기록을 통째로 다시 보내는 방식으로 우회하기도 하지만, 기록 자체가 깨져 있으면 근본적인 해결이 되지 못한다. 사용자는 대화를 갈라 다른 방향을 시험하고 싶을 때도 있고, 다 쓴 대화를 그 기억까지 깨끗하게 지우고 싶을 때도 있다. Jarvis-Agent는 대화를 저장소에 남는 독립된 단위로 다루고, 다시 열기, 분기, 삭제가 언제 실행되어도 같은 결과가 나오도록 관리한다.
어떤 기능을 가지고 있나요?
tool_call 과 결과는 늘 짝으로 남긴다
- 루프가 쌓은 메시지(호출, 결과, 응답)를 전부 저장하고, 루프가 끊기면 결과 없는 호출에 오류 결과를 붙여 닫고, Postgres 에서는
timestamp, seq순으로 읽어 호출과 결과의 짝이 늘 맞게 한다. - 대안으로는 처음처럼 마지막 응답만 저장하고
timestamp하나로 정렬하는 방식이 있었다. - LLM 프로바이더는 "도구 호출이 있으면 바로 뒤에 그 결과가 있어야 한다" 를 엄격하게 검사해서, 짝이 하나라도 어긋난 기록으로는 그 세션을 다시 열 수 없다(OpenAI 400, Anthropic 400). 이 규칙을 지키려고 아래처럼 세 번 고쳤다.
| 시기 | 깨진 경우 | 고친 방법 |
|---|---|---|
| 초기 | 루프가 마지막 응답만 저장해, 재개하면 앞 턴의 도구 호출이 결과 없이 남았다 | 루프가 쌓은 메시지(호출, 결과, 응답)를 전부 저장한다 |
| 초기 | 최대 턴 소진, 중단, 실행기 없음으로 루프가 끊기면 호출만 남았다 | 루프가 끝날 때 결과 없는 호출에 오류 결과를 붙여 닫는다 |
| 최근 | Postgres 에서 한 턴의 메시지가 같은 ms 에 저장돼, timestamp 정렬만으로는 조회 때마다 순서가 섞였다. 병렬 도구 호출 둘의 결과 사이에 다음 응답이 끼면서 400 이 났다 | seq 삽입 순번 칼럼을 ADD COLUMN IF NOT EXISTS 로 더하고 timestamp, seq 로 읽는다 |
마지막 문제는 앞선 턴은 잘 열리다가 어느 순간 깨져서 찾기 어려웠다. 같은 ms 의 행들이 우연히 바른 순서로 읽히는 동안에는 드러나지 않았기 때문이다. 고친 직후 문제의 세션을 다시 읽어 보니 메시지 29개의 호출과 결과가 모두 짝이 맞았고, 기존 행은 물리 순서대로 순번을 받아 손으로 고칠 것이 없었다.
만들기는 멱등, 분기는 복사
create는 같은 id 로 두 번 불러도 세션이 하나다. 분기는 부모의 메시지를 복사한 새 세션이다.- 대안으로는 분기를 부모를 가리키는 포인터로 두는 방식이 있었다.
- 화면이 재접속하며 같은 세션을 다시 만들어도 중복이 생기지 않는다. 포인터로 두면 부모를 지우거나 고칠 때 분기가 함께 바뀌므로, 저장 공간을 더 쓰는 대신 분기를 독립적으로 만들었다.
세부 동작
한 눈에 보기
세션은 만들 때 에이전트가 정해지고, 그 뒤로 바뀌지 않는다. 한 턴은 아래 순서로 저장된다(Figure 1 (b)).
- 사용자 메시지는 턴 시작 때 바로 저장된다.
- 에이전트 루프가 돈다. 그동안 어시스턴트와 도구 메시지는 메모리에만 쌓인다.
- 루프가 끝나면 어시스턴트와 도구 메시지가 한꺼번에 저장된다.
어시스턴트와 도구 메시지는 루프가 끝나야 저장된다. 루프 도중에 프로세스가 죽으면 그 턴의 사용자 메시지만 남는다.
세션의 생애는 아래와 같다.
저장소
agent_layer/session/ 의 저장소 프로토콜(create, get_meta, list, get_messages, append, delete, create_branch, 그리고 🔔 를 남기고 읽는 append_proactive, get_proactive) 위에 두 구현이 있다. 🔔 는 같은 메시지 id 로 몇 번 남겨도 하나다.
| 백엔드 | 설정 | 저장 |
|---|---|---|
| JSONL (기본) | JARVIS_SESSION__PERSISTENCE_BACKEND=jsonl, SESSIONS_DIR | ~/.jarvis/sessions/<id>.jsonl 에 append, <id>.meta.json 에 메타 |
| Postgres | postgres, POSTGRES_DSN | jarvis_sessions, jarvis_entries(세션 삭제 시 cascade). 테이블과 컬럼은 기동 때 IF NOT EXISTS 로 맞춘다. 기록은 timestamp, seq 순으로 읽는다. 한 턴의 tool_result 와 다음 응답이 같은 ms 에 저장되므로 삽입 순번(seq)이 순서를 정한다 |
create 는 멱등하다. 같은 id 로 두 번 만들어도 세션은 하나다. 사용자 메시지는 턴 시작 때 바로 저장되고, 어시스턴트와 도구 메시지는 루프가 끝난 뒤 한꺼번에 저장된다.
한 세션에 한 요청
세션은 요청을 하나씩 처리한다. 처리 중에 온 요청은 "이전 요청을 아직 처리 중입니다" 로 거절한다(중단하려면 abort). 화면은 agent_end 를 받자마자 다음 말을 보낼 수 있는데, 그때 세션은 대화 저장 같은 마무리를 하는 중일 수 있다(수백 ms). 그래서 바로 거절하지 않고 마무리를 최대 3초(BUSY_GRACE_SEC) 기다렸다가 받는다. 그 안에 끝나지 않는 긴 작업이면 그때 거절한다.
분기
분기는 /branch [session_id] 나 Web 의 ⑂ 로 만든다. /branch 에 id 를 주지 않으면 명령을 친 세션을 분기한다. 분기는 부모의 메시지를 전부 복사한 새 세션을 만들고 parent_id 를 남긴다. 포인터가 아니라 복사이므로 이후 변경은 서로 전파되지 않는다. /branch 는 jsonl 과 Postgres 모두에서 복사한다. Web 의 ⑂(POST /api/sessions/{id}/branch)는 jsonl 에서만 복사하고, 다른 저장소면 같은 에이전트의 빈 새 세션을 열고 parent_id 를 null 로 돌려준다.
Web 의 SESSIONS 카드는 지금 프로세스가 띄운 세션만 보여준다. 다른 세션은 /sessions 나 jarvis --list-sessions 로 본다.
삭제
Web 의 DELETE /api/sessions/{id} 는 메모리 세션, 샌드박스, 승인 브로커, 저장소, 그 세션의 장기 기억(턴 일화, 사고 로그)과 작업 기억을 같이 지운다. 그 세션에서 뽑힌 사용자 프로필 사실과 승인한 절차는 세션에 묶이지 않아 남는다. 붙어 있는 연결이 있으면 409 를 돌려준다. 없는 세션을 지워도 200 을 돌려준다.
세션 삭제는 저장소의 기록과 그 세션의 장기 기억까지 지우므로 되돌릴 수 없다. 열린 연결이 있으면 409 로 거절한다. 연결 확인과 삭제는 그 세션의 잠금 안에서 하므로, 지우는 도중에 같은 세션으로 붙은 연결은 삭제가 끝난 뒤 빈 새 세션으로 열린다. 남겨 둘 대화는 먼저 /export md|json 으로 내보낸다.
서브 에이전트 세션
멀티 에이전트의 서브 에이전트도 세션을 가진다. 세션 id 는 정의의 memory.stm 에 따라 정해진다.
| 정책 | 세션 id | 뜻 |
|---|---|---|
per_parent_session(기본) | agent_<name>__<부모 세션> | 부모 세션마다 하나 |
per_task | agent_<name>__task_<id> | 호출 뒤 삭제 |
per_agent | agent_<name> | 에이전트당 하나, 영속 |
세션 관련 명령
/sessions: 세션 목록을 보여준다. 분기한 세션에는← parent가 붙는다/export md|json: 세션 대화를~/.jarvis/exports/<세션 id>.md나.json으로 내보낸다. 인자가 없으면 md 다/cost: 세션의 토큰 사용량과 모델별 추정 비용을 보여준다/compact: 지금까지의 대화를 요약해 장기 기억에 저장한다/cleanup: 옛 서브 에이전트 세션(agent_<name>,per_agent정책 에이전트의 세션은 남긴다), 옛 STM 테이블(jarvis_stm_messages, Postgres), 시험 흔적을 보여주고,agents confirm,stm confirm,tests confirm으로 지운다
/export, /cost, 인자 없는 /branch 는 명령을 친 세션을 대상으로 한다. 다른 세션은 그 세션으로 옮긴 뒤 친다. 내보낸 파일 이름은 세션 id 에서 영문, 숫자, _, - 밖의 글자를 _ 로 바꾼다.
시험 세션
실험, 라이브 점검, 스모크 테스트가 여는 세션은 test- 로 시작한다(예전 것은 live-, probe-, exp- 등. 목록은 agent_layer/session/session_protocol.py 의 TEST_SESSION_PREFIXES). 시험 세션은 이렇게 다르다.
- 먼저 건네는 말이 밖(푸시, Discord)으로 가지 않는다. 라이브 점검이 사용자 폰에 실제 알림을 보낸 일이 있어서다
- 목표와 스케줄 결과를 건넬 대화를 고를 때 빠진다
/cleanup tests confirm이 세션과 그 세션이 남긴 장기 기억, 작업 기억을 함께 지운다. 기억을 먼저 지워 중간에 죽어도 다시 실행하면 남은 것만 지운다
한계
- 루프 도중에 프로세스가 죽으면 그 턴의 사용자 메시지만 남는다. 어시스턴트와 도구 메시지는 루프가 끝나야 저장된다
- Web 의 ⑂ 는 jsonl 저장소에서만 메시지를 복사한다. Postgres 에서는 빈 새 세션을 연다(
/branch는 둘 다 복사한다) - Web 의 SESSIONS 카드는 지금 프로세스가 띄운 세션만 보여 준다
/export,/cost, 인자 없는/branch는 명령을 친 세션에만 동작한다
구현 위치
왼쪽이 사용자와 화면, 가운데가 코드 모듈, 오른쪽이 저장소다. 화살표는 호출이나 데이터의 방향이다.
- 핵심 모듈:
agent_layer/session/ - 저장: jsonl 또는 Postgres
Performance Experiments
세션을 지우는 요청과 다시 여는 요청이 겹칠 때 끝 상태가 어긋나지 않는지 측정했다.
삭제와 다시 열기가 겹칠 때의 끝 상태
- 질문: 한 세션을 지우는 요청과 같은 id 로 다시 여는 요청이 동시에 오면, 끝 상태가 어긋나는(화면 쪽 세션과 저장된 기록 중 하나만 남는) 경우가 얼마나 생기나
- 설정: 실제
SessionRegistry의get_or_create와remove를 썼다. 저장소는 메모리 dict, 기억의forget_session은 가짜, 세션 객체는 MagicMock 이다. 저장소와 기억 호출마다 이벤트 루프에 03번 양보하고, 두 요청도 03번 양보한 뒤 시작해 실행 순서를 섞었다. 시드 7 로 2,000번 돌렸다(두 번 돌려 결과가 같았다). 세션별 잠금이 없던 #195 전 코드(eb41779)와 잠금을 넣은 코드(d07aa36)를 비교했다 - 결과:
| 코드 | 둘 다 지워짐 | 둘 다 남음 | 기록 없이 세션만 남음 | 어긋난 비율 |
|---|---|---|---|---|
| #195 전 | 726 | 912 | 362 | 18.1% |
| #195 뒤 (지금) | 726 | 1,274 | 0 | 0% |
- 해석: 잠금이 없을 때 생긴 어긋남은 모두 한 종류였다. 다시 연 세션은 화면에 살아 있는데 기록은 이미 지워져, 사용자는 빈 대화를 이어 가게 된다. 세션 id 마다 잠금을 걸어 삭제와 생성을 한 줄로 세우자 사라졌다
- 한계: 실행 순서는 양보 횟수로 흉내 냈고 실제 파일이나 DB 지연은 아니다. 18.1% 는 고른 양보 분포에서 나온 값이라 운영에서 생길 빈도가 아니다. 연결이 살아 있으면 삭제를 거절하는
remove_if_idle검사는 이 실험에 넣지 않았다 - 재현:
uv run python scripts/experiments/session_race.py --label <코드 이름>