Open Loop
Open Loop는 대화에서 흘린 약속(open loop)과 미완 작업을 기억했다가 성찰 때 다시 보고, Web HUD 의 AGENDA 카드에 보여 주는 기능이다. 사용자가 따로 할 일 목록을 적지 않아도 "내일까지 보내야 해" 같은 말이 챙길 일로 남고, 끝났다거나 취소됐다는 말, 지난 기한에 맞춰 스스로 닫힌다. 열린 약속은 Inner Thought 와 Proactive Dialogue 의 성찰이 함께 보는 맥락이 되어, 기한이 다가온 약속을 먼저 알려 주는 근거가 된다.
정한 시각에 도는 일은 Scheduling, MCP 도구로 가져오는 외부 이벤트는 MCP Events 에서 다룬다.
Open Loop가 주목한 점
사람 비서라면 "내일 오후 3시까지 분기 보고서 초안을 팀장님께 보내야 해" 라는 말을 흘려듣지 않는다. 하지만 기존의 Chatbot Assistant에게 이 말을 하고 다른 이야기로 넘어가면, 그 말은 대화 기록 속에 묻히고 다음 날 아무도 챙기지 않는다. 사용자가 할 일 앱에 직접 옮겨 적는 것이 흔한 우회 방안이지만, 그러면 챙기는 일이 다시 사용자 몫이 된다. 반대로 "그건 취소됐어, 안 챙겨도 돼" 라고 했는데도 계속 남아 있거나, 같은 약속을 조금 다르게 다시 말할 때마다 항목이 늘어나면 목록 자체를 믿을 수 없게 된다. Jarvis-Agent는 대화 속 약속을 스스로 잡아 두되, 같은 약속은 하나로 묶고, 끝나거나 취소되거나 기한이 지나면 스스로 닫는 방식을 택했다.
어떤 기능을 가지고 있나요?
판정은 순수 함수 하나, 저장소는 셋
- 같은 약속인지, 저장할 만한 약속인지는
openloop/openloop_types.py의 순수 함수(same_loop,merge_loop)가 정하고, 메모리, JSONL, Postgres 세 저장소가 이 함수를 그대로 쓴다. 임베딩은 판정 함수 안에서 부르지 않고 저장소가 경계에서 계산해 넘긴다. - 대안으로는 저장소마다 판정을 따로 두는 방식이 있었다.
- 판정이 저장소마다 다르면 같은 대화가 저장소에 따라 다른 AGENDA 를 만든다. 임베딩을 경계로 뺐기 때문에 판정은 테스트에서 네트워크 없이 돌고, 임베딩 호출이 실패해도 글자 규칙으로 계속 판정한다.
작은 사고 모델의 실수를 규칙으로 받친다
- 약속을 만들고 닫는 일은 사고 모델의 출력에 기대지만, 그 실수는 규칙으로 받친다. 사용자 입력과 글자가 거의 겹치지 않는 새 약속은 저장하지 않고, 닫을 약속은 문구가 아니라 번호(
[1],[2])로 받으며, AGENDA 판단이 놓쳐도 사용자가 "취소", "안 해도" 같은 말로 약속을 가리키면 코드가 닫는다. - 대안으로는 더 큰 모델을 써서 판단 자체를 맞히는 방식이 있었다.
- 로컬 소형 모델(gemma4:e2b)은 회상된 기억 속 약속을 이번 입력의 약속처럼 옮겨 적거나, 취소 턴에 닫을 약속을 놓쳤다. 문구로 받을 때는 작은 모델이 문구를 조금씩 바꿔 적어 닫히지 않았다. 글자 겹침 규칙 때문에 바꿔 말한 진짜 약속까지 버려지는 문제는, AGENDA 판단이 약속 끝에 입력 구절을
(근거: ...)로 인용하게 해서 풀었다.
같은 약속 문턱은 잘못 합치지 않는 쪽으로
- 같은 약속으로 볼 임베딩 코사인 문턱을 0.85 로 높게 잡았다.
- 대안으로는 문턱을 낮춰 바꿔 말한 중복을 더 많이 합치는 방식이 있었다.
- 놓친 중복은 사람이 ✕ 로 닫으면 되지만, 잘못 합친 약속은 하나가 조용히 사라진다. 근거는 약속 판정의 실측과 임베딩 모델별 같은 약속 가르기 실험에 있다.
세부 동작
한 눈에 보기
housing_budget 은 추천 1순위 공고의 마감 약속을 이 검사 없이 바로 남기고, 사용자와 나눈 대화 세션에서만 남긴다. 같은 약속 판정의 임베딩은 저장소가 Ollama bge-m3 로 계산한다. (b) 열린 약속은 AGENDA 판단의 closed_loops 나 ✓ 로 done, 거두는 말이나 ✕ 로 dismissed, 기한이 지나면 expired 로 닫힌다. 닫힌 약속은 ↺ 로 다시 open 으로 되살린다.- 약속은 요청마다 내적 사고와 따로 도는 AGENDA 판단(
agent_layer/openloop/judge.py)이 잡고 닫는다. 응답과 함께 돌아 응답을 늦추지 않고, Thought Router 가 사고를 건너뛴 요청에서도 돈다. 응답보다 늦게 끝나 약속이 바뀌면 화면에agenda_changed를 보낸다. 성찰은 약속을 보기만 하고, 만들지도 닫지도 않는다 - 새 약속은 사용자 입력에서 나온 것만 저장한다. 판정 방법은 약속 판정에 있다
- AGENDA 판단과 내적 사고에는 열린 약속이 최신순으로
[1] 설명 (기한 ...)처럼 번호와 함께 보인다 - 기한: AGENDA 판단이 약속 끝에
@YYYY-MM-DD HH:MM을 붙이면due_ms로 저장한다. 다음 판단과 사고에는설명 (기한 2026-10-12 10:00)으로 보인다 - 같은 약속이 다시 오면 새로 만들지 않는다. 기한이 새로 붙었을 때만 기존 항목의 기한을 갱신한다. 같은 약속인지는 약속 판정으로 정한다
- 약속은 다음 경우에 저절로 닫힌다.
- AGENDA 판단의
closed_loops: 이번 입력으로 끝났거나, 하지 말라고 했거나, 취소된 약속의 번호(또는 문구)를 판단이 짚으면done으로 닫는다 - 사용자의 거두는 말: 입력에
취소,안 해도,하지 마,그만해,필요 없,안 챙겨같은 말이 있고 그 입력이 열린 약속을 가리키면(짧은 쪽 기준 bigram 겹침 0.5 이상) 판단이 놓쳐도dismissed로 닫는다. 완료 표현("보냈어", "다 했어")은 질문과 구분이 안 되므로 코드로 닫지 않고 AGENDA 판단으로만 닫는다 - 같은 판단 결과에서 닫으라고 한 약속은
open_loops에 다시 있어도 새로 열지 않는다 - 기한: 판단과 사고 전,
GET /api/agenda때, 기한 뒤JARVIS_OPENLOOP__EXPIRE_AFTER_SEC(6시간)가 지났거나 기한 없이JARVIS_OPENLOOP__MAX_AGE_SEC(7일)보다 오래되면expired로 닫는다
- AGENDA 판단의
- 상태는
open,done,dismissed,expired넷이다 - 저장소:
~/.jarvis/openloops.jsonl(append 로그, id 기준 마지막이 이긴다), Postgresjarvis_open_loops, 메모리 - 조작:
/openloop,/openloop done <id>(id 앞 8자로도 찾는다),/openloop dismiss <id>,POST /api/openloops/{id}로 약속을 보고 닫는다 - 되살리기: AGENDA 카드는 최근 닫힌 약속 10개를 함께 보여 주고, ↺ 를 누르면
POST /api/openloops/{id}에status: open을 보내 다시 연다. 다시 열자마자 기한이나 수명으로 만료될 약속은 409 로 거절한다./openloop명령에는 되살리기가 없다
약속 판정
AGENDA 판단이 적어 낸 약속 하나는 두 번 판정을 거친다. 판정 규칙은 모두 agent_layer/openloop/openloop_types.py 의 순수 함수이고, 임베딩 호출만 저장소(OpenLoopStore)가 경계에서 한다.
AGENDA 판단은 약속마다 기한과 근거를 붙여 낸다.
보고서 보내기 @2026-10-16 15:00 (근거: 내일 오후 3시까지 보고서 보내야 해)
저장할 약속인가 (grounded). 작은 사고 모델은 회상된 기억 속 다른 약속을 이번에 새로 흘린 것처럼 옮겨 적는다. 그런 항목은 입력과 글자 겹침이 0 이었다. 그래서 아래 둘 중 하나를 만족해야 저장한다.
| 순서 | 조건 | 왜 |
|---|---|---|
| 먼저 | 근거의 글자 bigram 중 50% 이상이 사용자 입력에 있다 | 근거는 입력을 옮긴 문장이다. 약속 문구를 "결과 공유" 처럼 바꿔 말해도 근거가 입력에 있으면 받는다. 기억에서 지어낸 근거는 입력에 없다 |
| 근거가 없거나 어긋나면 | 약속 문구의 글자 bigram 중 20% 이상이 사용자 입력에 있다 | 사고가 근거를 빠뜨려도 입력을 거의 그대로 옮긴 약속은 받는다 |
gemma4:e2b-it-qat 에 입력 5개를 두 번씩 넣었을 때 약속을 낸 9번 모두 입력을 그대로 옮긴 근거를 붙였다.
이미 있는 약속인가 (same_loop). 같은 세션의 열린 약속과 하나씩 비교해 아래 중 하나면 같은 약속이다.
| 조건 | 예 |
|---|---|
| 공백과 대소문자를 정리하면 같다 | "배포 결과 공유" 와 "배포 결과 공유" |
| 짧은 쪽(6자 이상)이 긴 쪽에 통째로 들어간다 | "로그 확인해줘" 와 "내일 배포 전에 로그 확인해줘" |
| 임베딩 코사인이 0.85 이상이다 | "참석자 목록 확정" 과 "참석자들의 최종 리스트 확정" (bge-m3 0.92) |
| 공백을 뺀 글자 bigram 의 Jaccard 가 0.4 이상이다 | 단어 일부만 바꾼 중복 (실측 0.43~0.85) |
문턱 0.85 는 bge-m3 로 잰 값에서 골랐다. 바꿔 말한 같은 약속은 0.87~0.95, 같은 주제의 다른 약속은 0.80 이하였다(가장 높은 쌍이 "PR 리뷰 해주기" 와 "PR 머지하기"). 다른 약속을 하나로 합치면 약속이 사라지므로, 같은 약속을 조금 놓치더라도 잘못 합치지 않는 쪽으로 잡았다.
임베딩 설정이 없거나 Ollama 호출이 실패하면 임베딩 조건만 빠지고 나머지 세 조건으로 판정한다. 대화는 막히지 않는다.
임베딩 켜기
약속 판정은 메모리 의미 검색과 같은 JARVIS_MEMORY__EMBEDDING_* 설정을 쓴다. 하나를 켜면 둘 다 켜진다.
-
호스트 Ollama 에 임베딩 모델을 받는다.
ollama pull bge-m3 -
.env에 설정을 넣는다. 컨테이너에서는 호스트 Ollama 주소를 적는다.JARVIS_MEMORY__EMBEDDING_PROVIDER=ollamaJARVIS_MEMORY__EMBEDDING_MODEL=bge-m3JARVIS_MEMORY__EMBEDDING_DIMENSIONS=1024JARVIS_MEMORY__EMBEDDING_BASE_URL=http://host.docker.internal:11434 -
백엔드를 다시 띄운다.
docker compose up -d --build jarvis-backend -
이미 저장된 메모리 항목에 벡터를 채운다. Web HUD 에서
/remember reembed를 입력한다. 열린 약속은 판정 때 임베딩해 프로세스 메모리에 기억해 두므로 따로 채울 것이 없다(재시작 뒤 첫 판정에서 다시 임베딩한다).
ollama 기본값인 nomic-embed-text 는 한국어 문장끼리 코사인이 0.68~0.97 로 몰렸다. "PR 리뷰 해주기" 와 "PR 머지하기" 가 0.93 이라 0.85 문턱에서 다른 약속이 합쳐진다. 한국어로 대화한다면 bge-m3 를 쓴다.
한계
자동 닫힘과 중복 판정은 작은 사고 모델의 출력과 글자 겹침에 기댄다. 그래서 아래 경우는 사람이 ✓ / ✕ 로 정리해야 한다. 측정은 gemma4:e4b(ollama)로 "내일 오후 3시까지 분기 보고서 초안을 팀장님께 보내야 해" 뒤 "그건 취소됐어, 안 챙겨도 돼" 를 새 세션에서 반복한 것이다.
| 한계 | 왜 | 실측 또는 예 | 우회 |
|---|---|---|---|
| 뜻이 멀게 바꾼 같은 약속은 따로 쌓인다 | 임베딩 문턱 0.85 를 다른 약속을 합치지 않는 쪽으로 잡았다. 임베딩 설정이 없으면 글자 겹침만 본다 | bge-m3 에서 "운동 등록하기" 와 "헬스장 회원권 끊기" 0.55, "엄마 생신 선물 사기" 와 "어머니 생일 선물 구매" 0.75 | ✕ 로 하나를 닫는다 |
| 완료 표현으로는 코드가 닫지 않는다 | "보냈어", "다 했어" 는 "보냈어?" 같은 질문과 글자로 구분이 안 된다 | 완료는 AGENDA 판단의 closed_loops 로만 닫힌다 | ✓ 로 닫는다 |
| AGENDA 판단이 닫을 번호를 놓친다 | 작은 모델은 취소 턴에 closed_loops 를 비우거나 [열린 약속] 에 없는 문구를 적는다 | 번호 방식 전에는 문구가 맞지 않아 닫히지 않았다. 지금은 판단이 놓쳐도 거두는 말이 약속을 가리키면 닫힌다 | 거두는 말에 약속을 가리키는 낱말을 넣는다("보고서는 취소됐어") |
| 거두는 말이 약속을 가리키지 않으면 안 닫힌다 | 입력과 약속의 글자 겹침이 짧은 쪽 기준 0.5 미만이면 다른 약속으로 본다 | "그건 취소됐어" 만으로는 닫히지 않는다 | 약속 이름을 같이 말하거나 ✕ |
| 기한이 절반쯤 빠진다 | 사고 모델이 @YYYY-MM-DD HH:MM 표기를 자주 빼먹는다 | 약속을 만든 7번 중 4번만 기한이 붙었다 | 기한 없는 약속은 MAX_AGE_SEC(7일)에 닫힌다 |
| 약속이 생기지 않거나 취소 턴에 생긴다 | 사고 모델이 약속을 놓쳤다가 다음 턴에 적는다 | 한 번은 첫 턴에 약속이 없고 "취소됐어" 턴에 생겼다. 같은 프레임에서 닫으라고 한 것은 열지 않지만 이 경우는 막지 못한다 | ✕ |
| 근거 없이 바꿔 말한 새 약속은 버려진다 | 사고가 (근거: ...) 를 빠뜨리면 약속 문구와 입력의 글자 겹침 20% 로만 본다 | "배포 끝나면 알려줄게" 를 근거 없이 "결과 공유" 로만 적으면 버려진다 | 다시 말한다 |
| 닫힘이 답보다 늦을 수 있다 | AGENDA 판단은 응답과 함께 돌아 응답보다 늦게 끝날 수 있다 | 측정 전(이 표의 다른 수치는 약속 판단이 사고 안에 있을 때 잰 것이다) | 없음. 약속이 바뀌면 agenda_changed 로 AGENDA 가 다시 불린다 |
| 기한이 지난 약속은 되살리지 못한다 | 다시 열면 곧바로 expired 로 닫히므로 API 가 409 로 거절한다 | 기한 뒤 6시간이 지났거나, 기한 없이 7일이 지난 약속 | 기한을 붙여 다시 말하면 새 약속으로 생긴다 |
| 기한은 서버 시간대로 계산한다 | [지금] 과 기한 해석 모두 프로세스 로컬 시각이다 | 컨테이너 TZ=Asia/Seoul | 컨테이너 TZ 를 사용자 시간대로 맞춘다 |
구현 위치
왼쪽이 사용자와 화면, 가운데가 코드 모듈, 오른쪽이 저장소다. 화살표는 호출이나 데이터의 방향이다.
- 핵심 모듈:
agent_layer/openloop/ - 저장:
openloops.jsonl또는 Postgres
Performance Experiments
같은 약속을 가르는 임베딩과 문턱을 고르기 위해 임베딩 모델별 판정 결과를 비교했다.
임베딩 모델별 같은 약속 가르기
- 질문: 어떤 임베딩과 문턱이 바꿔 말한 같은 약속은 합치고, 주제만 같은 다른 약속은 따로 두는가
- 설정: 바꿔 말한 같은 약속 8쌍과, 주제는 같지만 다른 약속 10쌍(예: "팀장님께 분기 보고서 보내기" 와 "팀장님께 휴가 신청서 보내기")의 코사인을 재고, 실제 판정 함수
same_loop로 같은 약속인지 판정했다. 비교한 것은 bge-m3, nomic-embed-text, text-embedding-3-small, 임베딩 없음(--embedder none, 글자 규칙만) 넷이다 - 결과:
| 임베딩 | 판정 정확도 | 잘못 합친 쌍 |
|---|---|---|
| bge-m3 | 0.83 | 0 |
| nomic-embed-text | 0.61 | 5 |
| text-embedding-3-small | 0.56 | 0 |
| 없음(글자 규칙만) | 0.56 | 0 |
- 해석: 코사인 문턱 하나로는 어느 임베딩도 완전히 가를 수 없다. 그래서 문턱은 다른 약속을 합치지 않도록 높게 잡았다. 놓친 중복은 사람이 ✕ 로 닫으면 되지만, 잘못 합친 약속은 하나가 조용히 사라지기 때문이다. 권장값을 bge-m3 와 0.85 로 정했다. OpenAI 임베딩은 같은 약속끼리도 코사인이 낮아(평균 0.64) 0.85 문턱에서는 임베딩이 없는 것과 같다. 쓰려면 문턱을 0.6 근처로 따로 잡아야 한다(이 데이터에서 0.58 일 때 0.72)
- 한계: 18쌍은 작은 표본이고 직접 만든 문장이다. "운동하러 헬스 끊기" 처럼 뜻이 갈릴 수 있는 문장도 섞여 있다. 권장값(bge-m3, 0.85)을 고르는 근거로만 쓴다
- 재현:
uv run python -m scripts.experiments.loop_embedding --embedder bge-m3