gajae-code · 컨텍스트·프롬프트 조립 (System Prompt + AGENTS.md 주입)
gajae-code · 컨텍스트·프롬프트 조립 (System Prompt + AGENTS.md 주입)
한 줄 요약
AI 코딩 에이전트(GJC)가 일을 시작하기 전에 “넌 누구고, 이 프로젝트는 뭐고, 어떤 규칙을 지켜야 하는지”를 적은 지시문 한 뭉치(system prompt) 를 자동으로 조립해 주는 부품이다. 왜 배우나 — 프롬프트를 코드에 박지 않고 파일·폴더에서 자동으로 긁어모으는 이 방식을 알아야, AI가 “왜 이 프로젝트 규칙을 알고 있는지”와 “왜 가까운 폴더 규칙이 이기는지”를 이해할 수 있다.
그림
flowchart TD
T["buildSystemPrompt() 호출"] --> P{"NULL_PROMPT?"}
P -->|"예 (테스트용)"| Z["빈 배열 반환"]
P -->|아니오| W["6개 준비 작업 병렬 실행 (각각 5초 제한)"]
W --> A["프로젝트 컨텍스트 수집 (폴더 거슬러 올라가기)"]
W --> B["SYSTEM.md 읽기"]
W --> C["작업공간 트리 만들기 (최대 200개)"]
W --> D["스킬 목록 읽기"]
A --> DD["깊이 순 정렬 + 똑같은 본문 제거"]
DD --> M["data 객체 조립"]
B --> M
C --> M
D --> M
M --> R["prompt.render(.md 템플릿, data)"]
R --> S["systemPrompt = ["정체성 블록, 프로젝트 블록"]"]
S --> LLM["모델의 system 메시지로 전달"]
LLM --> RUN["대화 진행"]
RUN --> PR{"토큰 압박?"}
PR -->|예| PRUNE["낡은 도구 출력부터 잘라내기 (영수증 한 줄로 치환)"]
쉽게 풀기
새 직원이 출근 첫날 받는 사내 안내문 한 묶음을 떠올리면 쉽다. “당신의 역할은 이것입니다(정체성), 이 회사 규칙은 이렇습니다(권한·도구), 그리고 이 팀의 별도 규칙은 이렇습니다(프로젝트 규칙).” 이 부품은 그 안내문을 매번 손으로 쓰지 않고 자동으로 묶어 준다. 핵심은 두 가지 습관이다.
1) 안내문 문장을 코드에 직접 타이핑하지 않는다.
프롬프트 문장은 전부 .md 파일에 따로 두고, 코드는 그 파일을 통째로 불러와(with { type: "text" }) 빈칸(변수)만 채운다(prompt.render). 회사 안내문 양식을 워드 파일로 관리하고, 직원 이름·부서만 끼워 넣는 것과 같다. 양식 고칠 때 코드를 안 건드려도 된다.
2) 흩어진 규칙 파일을 폴더를 거슬러 올라가며 자동으로 줍는다. 프로젝트 폴더에서 시작해 그 위 부모·조부모 폴더까지 올라가면서 AGENTS.md, CLAUDE.md, GEMINI.md 같은 여러 AI 도구의 표준 규칙 파일을 긁어모은다(walk-up). 큰 회사로 치면 “전사 규정 → 본부 규정 → 우리 팀 규정”을 다 모으는 것이다. 그런데 충돌이 나면? 가까운 폴더(우리 팀) 규칙이 먼 폴더(전사) 규칙을 이긴다(deeper overrides higher). 우리 팀에 더 구체적인 사정이 있으니까.
가까운 규칙이 이기게 만드는 기술은 의외로 단순하다. 프롬프트는 뒤에 적힌 내용일수록 더 강하게 작동하므로, 일부러 먼 파일을 앞에, 가까운 파일을 뒤에 배치한다(깊이 내림차순 정렬). 같은 내용이 두 곳에 똑같이 적혀 있으면 가까운 것 하나만 남기고 버린다.
3) 대화가 길어지면 낡은 메모를 지운다(프루닝). 일하다 보면 “아까 읽은 파일 내용”이 쌓이는데, 그 파일을 나중에 또 읽거나 수정했다면 옛날에 읽은 결과는 낡은(stale) 정보다. 토큰(기억 용량)이 부족해지면 이런 낡은 메모부터 잘라내고, 그 자리에 “여기 N토큰 잘렸음, 요약: …” 영수증 한 줄만 남긴다. 단, 잘라서 절약되는 양이 너무 적으면 아예 자르지 않는다(괜히 흔들지 않기).
핵심 정리
ContextFile 한 장의 구조 (capability/context-file.ts)
| 필드 | 타입 | 설명 |
|---|---|---|
path | string | 컨텍스트 파일 절대경로 (필수) |
content | string | 파일 본문 전체 (필수) |
level | "user" | "project" | 홈 레벨인지 프로젝트 레벨인지 (필수) |
depth | number? | cwd로부터 거리. 0=cwd, 1=부모, 2=조부모 (project만) |
[!note] 중복 제거 key 규칙 (가장 중요)
- user 파일이면
"user"하나로 묶는다.- project 파일이면
`project:${Math.max(0, depth ?? 0)}`— 즉 깊이 단위로 1개씩만 채택.- 같은 깊이에서는 priority 높은 provider가 낮은 provider를 가린다(shadow).
- depth가 음수면 0으로 clamp → 조상의 설정 하위폴더(예:
.github/)는 조상 자신과 같은 스코프로 취급._source(SourceMeta) 필드는 “어느 provider가 어디서 읽었는지” 출처 영수증으로 항상 붙는다.
어떤 AI 도구의 파일을 줍나 (멀티벤더, 실측)
| Provider 파일 | 읽는 파일 | priority |
|---|---|---|
claude.ts | .claude/CLAUDE.md | 80 |
codex.ts | .codex/AGENTS.md | 70 |
agents-md.ts | 루트 AGENTS.md(walk-up) | 10 |
gemini.ts | ~/.gemini/GEMINI.md, .gemini/GEMINI.md | (자체) |
cursor.ts | .cursor/rules/*.mdc, .cursorrules | rule capability |
windsurf.ts | .windsurf/rules/*.md 등 | rule capability |
[!note] context-file vs rule 구분 (본 것만) cursor / windsurf는
contextFileCapability가 아니라ruleCapability로 등록된다. AGENTS.md / CLAUDE.md / GEMINI.md / .codex/AGENTS.md 만 context-file로 들어와<context>블록에 직접 박힌다.
프루닝(컨텍스트 창 관리) 설정 (compaction/pruning.ts)
| 필드 | 기본값 | 설명 |
|---|---|---|
protectTokens | 40,000 | 최근 도구 출력 이 토큰만큼은 무조건 보존 |
minimumSavings | 20,000 | 절약량이 이 미만이면 프루닝 안 함(히스테리시스) |
protectedTools | ["skill","read"] | 절대 안 자르는 도구 |
staleOverridableTools | ["read"] | 낡으면 보호 해제되는 도구 |
[!note] 정렬·우세·프루닝 동작 요약
- 정렬:
files.sort((a,b)=> (b.depth ?? -1) - (a.depth ?? -1))— depth 큰(먼) 파일이 앞, 가까운 파일이 뒤. 프롬프트는 뒤쪽=더 prominent → 이것이 “deeper overrides higher”의 물리적 구현.project-prompt.md의<dir-context>에도 “Deeper rules override higher ones” 명시.- stale 판정: 같은 target key의 더 나중 결과가 있으면 이전 결과는 stale.
read결과는 그 파일을 나중에 edit/write/apply_patch/ast_edit 하면 stale. 단 target별 마지막(최신) 결과는 절대 stale 아님.- 절단: protectTokens 윈도우 안이라도 stale이면 잘라낸다. 잘린 자리는
[Output truncated - N tokens; <digest>]영수증으로 치환. 총 절약이 minimumSavings 미만이면 통째로 취소.
실제 예시
1) 정적 .md 임포트 + 조립 (인라인 프롬프트 금지)
// packages/coding-agent/src/system-prompt.ts
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" };
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
// ...
const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
const systemPrompt = [rendered];
const projectPrompt = resolvedCustomPrompt ? "" : prompt.render(projectPromptTemplate, data).trim();
if (projectPrompt) {
systemPrompt.push(projectPrompt);
}
return { systemPrompt }; // 블록 배열 — provider가 각각 별도 메시지로 보존
프롬프트 본문은 코드에 없고 전부 .md 파일에 있다. 코드는 data 객체만 만들어 Handlebars-류 prompt.render에 넘긴다. customPrompt가 있으면 custom-system-prompt.md 한 장으로 대체, 없으면 system-prompt.md(정체성/도구/워크플로) + project-prompt.md(환경/컨텍스트파일/트리) 2블록 구조.
2) depth 계산 (helpers.ts:513)
// packages/coding-agent/src/discovery/helpers.ts
export function calculateDepth(cwd: string, targetDir: string, separator: string): number {
return cwd.split(separator).length - targetDir.split(separator).length;
}
// cwd보다 위(조상)면 양수, cwd면 0. → 깊이가 클수록 cwd에서 먼 파일.
3) 계층 컨텍스트 수집 (walk-up)
// packages/coding-agent/src/discovery/agents-md.ts (요지)
let current = ctx.cwd;
while (true) {
const candidate = path.join(current, "AGENTS.md");
const content = await readFile(candidate);
if (content !== null && !path.basename(current).startsWith(".")) {
items.push({ path: candidate, content, level: "project",
depth: calculateDepth(ctx.cwd, current, path.sep),
_source: createSourceMeta("agents-md", candidate, "project") });
}
if (current === (ctx.repoRoot ?? ctx.home)) break; // 레포 루트/홈에서 정지
const parent = path.dirname(current);
if (parent === current) break; // 파일시스템 루트
current = parent;
}
4) always-apply rule 중복 제거 로직
// packages/coding-agent/src/system-prompt.ts
function splitComparablePromptBlocks(content) { // 정규화 후 빈 줄 2개 기준으로 블록 분할
return normalizePromptBlock(content).split(/\n{2,}/).map(b => b.trim()).filter(Boolean);
}
function promptSourceContainsRule(source, ruleContent) { // rule 블록 시퀀스가 source 안에 연속으로 존재?
const sourceBlocks = splitComparablePromptBlocks(source);
const ruleBlocks = splitComparablePromptBlocks(ruleContent);
if (ruleBlocks.length > sourceBlocks.length) return false;
for (let s = 0; s <= sourceBlocks.length - ruleBlocks.length; s++)
if (ruleBlocks.every((b, o) => sourceBlocks[s + o] === b)) return true;
return false;
}
// alwaysApplyRules 중 customPrompt/append/SYSTEM.md 어디에도 안 들어있는 것만 살림 → 같은 규칙 두 번 안 박음
5) .md 템플릿 직접 만들 때 (인라인 금지, 파일로 분리)
<!-- prompts/system/system-prompt.md -->
<my-agent>
<identity>You are X. Optimize for correctness first.</identity>
{{#if systemPromptCustomization}}
<custom>{{systemPromptCustomization}}</custom>
{{/if}}
{{#if toolInfo.length}}
<tools>{{#each toolInfo}}- `{{name}}`{{/each}}</tools>
{{/if}}
</my-agent>
직접 만들 때 체크리스트
- 프롬프트 문장은 전부
.md로, 코드에는with { type: "text" }임포트만. - custom 있으면 custom 템플릿 1장으로 대체, 없으면 system+project 2블록.
- context 파일은 cwd→repoRoot/home walk-up,
.으로 시작하는 폴더의 루트 AGENTS.md는 제외. - depth 부여 후 내림차순 정렬(가까운 파일이 뒤=우세), 바이트 동일 본문은 가장 가까운 1개만.
- capability key로 user 1개 + depth별 1개만 채택, 동일 depth는 priority로 shadow.
- always-apply rule은 customPrompt/append/SYSTEM.md에 이미 있으면 주입 생략.
- 준비 단계는 deadline(예: 5s)으로 게이팅, 타임아웃 시 최소 fallback.
- AGENTS.md 노출 수는 상한(예: 200,
AGENTS_MD_LIMIT)으로 cap. - 프루닝은 protectTokens 윈도우 + minimumSavings 히스테리시스 + stale 우선, 잘린 자리에 digest 영수증.
요약 & 셀프체크
3줄 요약:
- 프롬프트 문장은 코드가 아니라
.md파일에 두고, 코드는 변수만 끼워prompt.render로 조립한다(인라인 금지). - 폴더를 거슬러 올라가며(walk-up) 여러 AI 도구의 규칙 파일을 줍고, 깊이 내림차순 정렬로 가까운 폴더 규칙이 이기게(deeper overrides higher) 만든다.
- 대화가 길어져 토큰이 부족하면 낡은(stale) 도구 출력부터 잘라내되, protectTokens 보호 윈도우와 minimumSavings 히스테리시스로 과도한 절단을 막는다.
스스로 답해 보기:
- 같은 규칙이 전사 AGENTS.md와 우리 팀 AGENTS.md에 둘 다 있을 때, 왜 우리 팀 것이 이기는가? (힌트: 정렬 방향과 “프롬프트는 뒤쪽이 더 강하다”)
- cursor/windsurf 규칙은 왜
<context>블록에 안 들어가는가? - 방금 read한 파일을 나중에 edit하면 그 read 결과는 어떻게 되는가? 토큰 보호 윈도우 안이어도 잘릴 수 있는 이유는?
연결
GJ_개요 · _분석축_루브릭 · GJ_10_agent-loop · GJ_30_extension-points-hooks-skills-commands · GJ_80_gajae-receipts-and-workflow-state
근거 파일
/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/system-prompt.ts/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/discovery/agents-md.ts/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/discovery/claude.ts/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/discovery/codex.ts(priority 70 확인)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/discovery/gemini.ts/cursor.ts/windsurf.ts(벤더/capability 구분 확인)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/discovery/helpers.ts(calculateDepth, createSourceMeta)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/capability/context-file.ts/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/prompts/system/system-prompt.md/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/prompts/system/project-prompt.md/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/prompts/system/custom-system-prompt.md/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/workspace-tree.ts(AGENTS_MD_LIMIT=200)/home/seunghyeong/harness-work/gajae-code/packages/agent/src/compaction/pruning.ts
[!tip] Codex 교차검증 (원본 분석 보존) 본 노트의 동작·수치는 원본 기능분해 노트에서 실측·근거 파일과 함께 확인된 내용이다. 정렬은
files.sort((a,b)=> (b.depth ?? -1) - (a.depth ?? -1))로 depth 큰(먼) 파일이 앞·가까운 파일이 뒤=더 prominent하게 배치되어 “deeper overrides higher”를 물리적으로 구현한다. 멀티벤더 priority(claude 80 / codex 70 / agents-md 10)와 cursor·windsurf의 ruleCapability 분리, 프루닝 기본값(protectTokens 40,000 / minimumSavings 20,000 / protectedTools["skill","read"]/ staleOverridableTools["read"])도 근거 파일 기준 실측치다. 추측으로 사실을 바꾸지 않았다.