모델 컨텍스트에 넣기 전에, 먼저 타입을 만드세요
승인 접두사, 작업 공간, AGENTS.md, 중단된 turn은 모두 모델이 스스로 볼 수 없는 사실을 알려 줍니다. Codex는 주입마다 먼저 타입을 만들고, 타입은 자신의 role, marker, body를 알아요.
- role을 읽어 user인지 developer인지 정합니다fragment.rs L15
- 따로 한 줄이 필요한지 묻습니다fragment.rs L18
- 인스턴스가 marker를 원하고, 그걸로 render합니다fragment.rs L22
- 빈 marker는 body만 내고 절대 매칭되지 않습니다fragment.rs L41
- 창 정체는 루프 전에 단독 칸으로 밀어 넣습니다session/mod.rs L3661
- 나머지 fragment는 role과 marker로 분류합니다session/mod.rs L3677
- user 쪽은 레지스트리 .any()로 다시 알아봅니다contextual_user_message.rs L18
- developer 쪽은 접두사 표로 다시 알아봅니다event_mapping.rs L40
build_initial_context_with_world_state에 맞췄어요. 논리 궤적 오른쪽 행 번호는 openai/codex 저장소 commit 4f39251a01에 대응합니다.에이전트에 이런 기능을 넣어 본 적 있을 거예요. 사용자가 방금 npm *를 승인했는데 다음 라운드 모델은 아직 npm test를 돌려도 되냐고 묻습니다. 아니면 압축이 막 끝났는데 모델이 작업 공간이 어디인지, 샌드박스가 읽기 전용인지 갑자기 잊어요. 아니면 세션을 fork했는데 옛 AGENTS.md가 그대로 있고 새 디렉터리 힌트가 겹쳐, 모델이 서로 싸우는 규칙 두 벌을 동시에 봅니다.
세 일의 공통 출처는 같은 종류의 연산입니다. 런타임에 모델 컨텍스트에 글 한 토막을 넣는 것이죠. 승인 접두사, 작업 공간, 권한 프로필, 중단된 turn, 현재 UTC 시간은 모두 모델이 스스로 못 보는 사실을 알려 줍니다. format! 한 줄이면 쓸 수 있어요.
문제는 그다음입니다. 그 글이 히스토리에 들어간 뒤, 압축이 남길지, 세션 복원이 알아볼지, UI가 숨길지, world state가 모델이 이미 아는 증거로 쓸지 — 전부 한 가지에 달렸어요. 순수 텍스트에서 그걸 다시 알아낼 수 있는가. 곳곳에 startsWith를 또 쓰면 몇 주 뒤 사방이 어긋납니다. 모르는 태그는 사용자 메시지 파싱으로도 새어 들어가요.
Codex는 주입마다 ContextualUserFragment를 구현한 타입으로 거둡니다. trait는 codex-core가 아니라 독립 crate codex-context-fragments에 있어요. 구현마다 한꺼번에 답해야 합니다. 이 토막이 Responses API에 user로 들어가나 developer로 들어가나, 앞뒤 marker는 무엇인가, 가운데 body는 어떻게 쓰나, 따로 한 메시지가 필요한가요.
render()는 앞뒤 marker와 body를 구분자 없이 이어 붙입니다. 공백과 줄바꿈은 body의 것이에요. 빈 marker는 body만 냅니다. into()는 렌더 결과를 content가 InputText 하나인 ResponseItem::Message로 거둡니다. 주입의 종점은 프로토콜 객체예요.
fn render(&self) -> String {
let (start_marker, end_marker) = self.markers();
let body = self.body();
if start_marker.is_empty() && end_marker.is_empty() {
return body;
}
format!("{start_marker}{body}{end_marker}")
}
openai/codex를 기준으로 codex-rs/context-fragments/src/fragment.rs를 대조했어요. commit 4f39251a01, 대조일 2026-08-22. 코드 블록은 소스 원문을 유지합니다. 타입이 스스로를 주입 가능한 텍스트로 접는 규칙의 전부가 이 구간이에요.markers()는 self를 받아서 렌더는 인스턴스를 탑니다. type_markers()는 self가 없어 식별은 타입을 탑니다. 손에 히스토리 텍스트만 있고 원본 객체가 없어도, 이 토막이 어떤 fragment처럼 보이는지 물을 수 있어요. dyn ContextualUserFragment는 matches_text를 호출하지 못하고, 역방향 식별은 구체 타입을 짚어야 합니다.
주입을 하나 더하려면 파일을 만들고, trait를 구현하고, core/src/context/mod.rs에 걸어야 해요. 그 디렉터리에 모듈이 39개입니다. 이 마찰 자체가 규율이에요. 임시 format!는 통과하지 못합니다. 조립 함수가 받는 것이 Box<dyn ContextualUserFragment>이기 때문이죠.출처: codex-rs/core/src/context/mod.rs 3–41행; codex-rs/core/src/session/mod.rs 3677–3707행
렌더와 식별이 정의 하나를 공유합니다. 다른 언어로 다시 써도 최소 형태는 인터페이스 하나, 레지스트리 하나, 같은 marker 쌍이에요. 조립 함수 인자를 fragment 타입으로 쓰면, 업무 쪽은 XML을 아무렇게나 붙이는 호출점을 잃습니다. 식별 함수는 레지스트리에서만 맞추고, text.startsWith를 또 쓰면 안 돼요.
주입마다 표시를 달면, 일회성 알림도 압축 뒤에 다시 알아보고 한 번 더 먹입니다. 표시를 전부 빼면, 압축된 히스토리에서 사용자 원문과 런타임에 넣은 설명을 가릴 수 없어요. fork는 만료된 <environment_context>를 사용자 입력으로 다시 제출합니다.
창 정체, 환경, 중단, 이미지 축소, 관리 측 개발자 지시는 나중에 알아보고, diff하고, 압축 뒤 다시 넣을지 정해야 해서 begin과 end를 답니다. 일회성 알림 — 승인 접두사, 네트워크 규칙 등록, 남은 token 한 줄 알림 — 은 type_markers()가 빈 문자열 둘을 돌려줍니다. 기본 matches_text는 언제나 false예요.
매칭 규칙은 텍스트 전체의 앞뒤만 봅니다. 앞을 trim해 접두사를 비교하고, 뒤를 trim해 접미사를 비교하며, ASCII 대소문자는 가리지 않아요. 둘 다 맞아야 히트입니다. 가운데가 무엇인지는 상관없고, 표시 없는 fragment는 가역성을 스스로 포기합니다.출처: codex-rs/context-fragments/src/fragment.rs 89–103행
developer 쪽에는 event_mapping.rs에 접두사 표가 하나 더 있어, 표시 없는 식별을 일부 메웁니다. 범위는 user 쪽 matcher 목록보다 좁아요. 접두사 표에는 옛 <token_budget> 태그가 아직 남아 있고, 주석은 옛 버전이 남겨 둔 포장을 알아보기 위해서라고 적습니다. marker가 rollout에 쓰이는 순간 복원 계약의 일부가 되고, 태그를 바꾸는 것은 프로토콜을 바꾸는 것과 같아요.
UserInstructions에는 눈을 놓치기 쉬운 곳이 있어요. begin marker는 마크다운 제목 # AGENTS.md instructions이고, end marker는 </INSTRUCTIONS>입니다. 프로토콜에는 <user_instructions> 쌍이 따로 있지만 이 struct는 쓰지 않아요. 프로토콜 상수로 matches_text를 쓰면, 저장소가 실제로 렌더하는 텍스트를 놓칩니다.출처: codex-rs/core/src/context/user_instructions.rs 18–19행; codex-rs/protocol/src/protocol.rs 112–113행
나중에 알아봐야 하는 것은 프로토콜로 만들고, 일회성은 스스로 포기하며 타입에 분명히 적어요. 압축 뒤 다시 알아봐야 하면 begin과 end를 강제하고, 일회성 알림은 표시가 없어도 되지만 주석에 가역성 포기를 써야 합니다.
곳곳에서 문자열을 아무렇게나 push하고, 순서는 약속에, 단독 줄은 주석에 기대요. TokenBudgetContext는 권한 설명과 같은 developer 메시지에 끼어 듭니다. 압축 필터는 줄 단위로 처리하지 못하고, 섞인 뒤에는 롤백도 쪼개지 못해요. event_mapping.rs 주석도 인정합니다. build_initial_context가 contextual fragment와 지속 developer 텍스트를 묶을 수 있다고.출처: codex-rs/core/src/event_mapping.rs 69–71행
첫 조립은 Session::build_initial_context_with_world_state에서 일어나요. fragment를 role(), markers()의 앞, requires_separate_message()로 나눕니다. 합칠 수 있는 developer 구간, 따로 서야 하는 developer 구간, user 구간, 맨 위나 맨 아래에 둘 특별 구간이요.
창 정체 한 줄은 조금 특별해요. Feature::TokenBudget이 켜지고 모델에 context window가 있으면, world_state 루프 전에 단독 칸으로 밀어 넣습니다. 출력 순서는 합쳐진 developer 메시지 하나, 그다음 단독 developer 메시지들, 그다음 contextual user 메시지 하나예요. 분류 근거는 타입 필드입니다.출처: codex-rs/core/src/session/mod.rs 3630–3728행
requires_separate_message()는 다른 것과 같은 developer 메시지에 끼일 수 있는지도 타입에 넣어요. TokenBudgetContext, ImageResizeNotice, ManagedDeveloperInstructions는 따로 서기를 고릅니다. 대가는 메시지 한 줄과 role 전환 한 번이 더 드는 것이고, 이점은 압축 필터가 줄 단위로 처리할 수 있다는 점입니다.
조립 입구는 등록된 타입만 받아요. 등록되지 않은 주입을 문 밖에 두는 흔한 모양입니다. 타입은 미등록을 막지만, 등록되고도 라운드마다 40KB를 넣는 것은 막지 못해요. 그 층은 리뷰 규칙이고, 다음 장에서 펼칩니다.
게이트는 어디에: DeepSeek Harness는 요청을 보낼 때 대사합니다
DSH는 저장소 루트 AGENTS.md 107행에 적혀 있고, 중국어 원칙은 “모델에 보이면 이미 기록됨”으로 접혀요. 모델 요청에 닿는 모든 것은 세션 로그에서 다시 만들 수 있어야 하고, 모델이 볼 입력 하나를 더하면 세션 이벤트도 하나 더 필요합니다.
실행 면은 invariant.ts예요. llm/stream에 리스너를 걸고, 세션 로그에서 deriveMessages()로 기댓값을 얻은 뒤 나가려는 options.messages와 JSON.stringify로 비교합니다. 안 맞으면 fail이에요. 게이트는 붕괴 지점에 있어, 조립 뒤에 messages를 고치는 식의 런타임에만 나타나는 드리프트를 잡을 수 있어요. invariants 서비스를 끄면 이 게이트도 사라집니다.
참된 출처가 다름: 이벤트 로그인가, 닫힌 타입 집합인가
DSH는 fragment trait가 없어도 됩니다. 참된 출처가 이벤트 로그이고, messages는 투영이니까요. Codex는 컨텍스트에 나타날 수 있는 것을 먼저 닫힌 타입 집합으로 거둔 뒤, marker로 나중에 알아봅니다.
대가도 다릅니다. Codex의 타입은 trait 없이 render_full에 넣는 길을 막지만, impl 안에서 format!한 동적 문자열은 막지 못해요. matches_text가 알아보는 것은 텍스트 모양입니다. 양쪽 모두 추적 가능성에 값을 치르고, 한쪽은 요청마다 어서션으로, 다른 쪽은 주입을 더할 때마다 타입 마찰로 냅니다.
놓치는 토막은 어느 쪽일까요
UserInstructions의 begin marker는 # AGENTS.md instructions인데, 프로토콜 상수는 <user_instructions>예요. 누군가 프로토콜 상수로 matches_text를 쓰면, 지금 저장소가 실제로 렌더하는 어떤 텍스트를 놓칠까요?
한 걸음 더: 위 데모에서 승인 접두사를 켜고, 압축 뒤 기본 matches_text가 그걸 다시 알아볼 수 있는지 보세요. developer 쪽은 어느 표로 이 한 칼을 메우며, 상수를 잘못 바꾸면 컴파일러가 울릴까요.