Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
671 changes: 671 additions & 0 deletions docs/superpowers/plans/2026-06-15-context-management.md

Large diffs are not rendered by default.

269 changes: 269 additions & 0 deletions docs/superpowers/specs/2026-06-15-context-management-design.md

Large diffs are not rendered by default.

63 changes: 57 additions & 6 deletions src/main/core/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
assertNever,
defaultHttp,
requireApiKey,
sendWithSchemaFallback,
textOf,
type ApiCallOptions,
type ApiProvider,
Expand Down Expand Up @@ -250,6 +249,7 @@ export function createAnthropicProvider(config: ApiProviderConfig, http: HttpCli
id: config.id,
provider: 'anthropic',
model: config.model,
nativeContextManagement: true,
async chat(messages: ChatTurn[], opts: ApiCallOptions = {}): Promise<ChatResult> {
const apiKey = requireApiKey(config)
const system = messages
Expand Down Expand Up @@ -312,24 +312,75 @@ export function createAnthropicProvider(config: ApiProviderConfig, http: HttpCli
if (thinking.effort) outputConfig.effort = thinking.effort
}
if (Object.keys(outputConfig).length > 0) body.output_config = outputConfig
// context management(도구루프 경로): clear_tool_uses edit. native 위임 = 서버 실측 토큰 트리거로
// 오래된 tool 결과를 per-request 클리어(cache_control·thinking 과 공존). CM 있을 때만 → 무회귀.
if (opts.contextManagement) {
body.context_management = {
edits: [
{
type: 'clear_tool_uses_20250919',
trigger: { type: 'input_tokens', value: opts.contextManagement.triggerInputTokens },
keep: { type: 'tool_uses', value: opts.contextManagement.keepRecentToolUses },
},
],
}
}
if (streaming) body.stream = true

const headers = {
const headers: Record<string, string> = {
'content-type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': API_VERSION,
}
// CM beta 헤더는 context_management 동봉 시에만(비-CM 호출 헤더 부재 = 무회귀).
if (opts.contextManagement) headers['anthropic-beta'] = 'context-management-2025-06-27'
const send = (): Promise<HttpResponse> =>
http(ENDPOINT, { method: 'POST', headers, body: JSON.stringify(body), signal: opts.signal })
// 스트리밍도 동일 가드 — 400 재시도 응답이 OK 면 아래 readStream 경로가 그대로 동작한다(#26 후속 b).
const res = await sendWithSchemaFallback(send, !!opts.responseSchema, () => {
// 구조화-출력 400 폴백: format 만 제거하고 effort 등 다른 output_config 필드는 보존한다.
// 두 opt-in 필드(context_management·output_config.format) + 불투명 400 → 한 번에 하나씩 제거해 무고한
// 필드를 보존한다(PR #63 필드별 격리). 둘 다 set + schema-유발 400 에서 CM 까지 함께 지워 회귀하던
// 갭(Codex P2)을 막는다. removeSchema 는 format 만 빼고 effort 등은 보존, 복원은 opts 에서 재구성.
const removeCM = (): void => {
delete body.context_management
delete headers['anthropic-beta']
}
const removeSchema = (): void => {
const oc = body.output_config as Record<string, unknown> | undefined
if (oc) {
delete oc.format
if (Object.keys(oc).length === 0) delete body.output_config
}
})
}
const addSchema = (): void => {
const oc = (body.output_config as Record<string, unknown> | undefined) ?? {}
oc.format = { type: 'json_schema', schema: opts.responseSchema!.schema }
body.output_config = oc
}
const hasCM = !!opts.contextManagement
const hasSchema = !!opts.responseSchema
// 스트리밍도 동일 — 최종 OK 응답이 body 를 가지면 아래 readStream 경로가 그대로 동작한다(#26 후속 b).
let res = await send()
if (!res.ok && res.status === 400 && (hasCM || hasSchema)) {
if (hasCM && hasSchema) {
// ① schema 만 제거(CM 보존) → ② 그래도 400 이면 schema 복원·CM 제거(schema 보존) → ③ 둘 다 제거.
removeSchema()
res = await send()
if (!res.ok && res.status === 400) {
addSchema()
removeCM()
res = await send()
if (!res.ok && res.status === 400) {
removeSchema()
res = await send()
}
}
} else if (hasCM) {
removeCM()
res = await send()
} else {
removeSchema()
res = await send()
}
}

if (streaming && res.ok && res.body) return readStream(res.body, opts.onToken!)

Expand Down
117 changes: 117 additions & 0 deletions src/main/core/providers/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,123 @@ describe('AnthropicProvider', () => {
{ type: 'tool_use', id: 'tu1', name: 'lookup', input: { id: 1 } },
])
})

// ── context management (도구루프 경로) ─────────────────────────────────────
const cmResp = JSON.stringify({ content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 } })

it('contextManagement → context_management.clear_tool_uses + beta 헤더를 싣는다', async () => {
const { http, calls } = mockHttp(() => ({ body: cmResp }))
const p = createAnthropicProvider(baseAnthropic, http)
await p.chat([{ role: 'user', content: 'x' }], { contextManagement: { triggerInputTokens: 150000, keepRecentToolUses: 3 } })
const body = JSON.parse(calls[0].init.body)
expect(body.context_management).toEqual({
edits: [{
type: 'clear_tool_uses_20250919',
trigger: { type: 'input_tokens', value: 150000 },
keep: { type: 'tool_uses', value: 3 },
}],
})
expect(calls[0].init.headers['anthropic-beta']).toBe('context-management-2025-06-27')
})

it('contextManagement 미지정 → context_management·beta 헤더 부재(무회귀)', async () => {
const { http, calls } = mockHttp(() => ({ body: cmResp }))
const p = createAnthropicProvider(baseAnthropic, http)
await p.chat([{ role: 'user', content: 'x' }], {})
const body = JSON.parse(calls[0].init.body)
expect(body.context_management).toBeUndefined()
expect(calls[0].init.headers['anthropic-beta']).toBeUndefined()
})

it('CM 동봉 요청 400 → context_management·beta 제거 후 1회 재시도', async () => {
const { http, calls } = mockHttp((_url, init) => {
const body = JSON.parse(init.body)
if (body.context_management) return { ok: false, status: 400, body: 'unsupported beta' }
return { body: cmResp }
})
const p = createAnthropicProvider(baseAnthropic, http)
const out = await p.chat([{ role: 'user', content: 'x' }], { contextManagement: { triggerInputTokens: 150000, keepRecentToolUses: 3 } })
expect(out.text).toBe('ok')
expect(calls).toHaveLength(2)
expect(JSON.parse(calls[1].init.body).context_management).toBeUndefined()
expect(calls[1].init.headers['anthropic-beta']).toBeUndefined()
})

it('CM + streaming: 400 fallback 후 스트리밍으로 응답을 돌려준다', async () => {
const chunks = [
'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":5}}}\n\n',
'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"ok"}}\n\n',
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n',
]
const { http, calls } = mock400ThenStream(chunks)
const p = createAnthropicProvider(baseAnthropic, http)
const seen: string[] = []
const out = await p.chat([{ role: 'user', content: 'x' }], {
onToken: (d) => seen.push(d),
contextManagement: { triggerInputTokens: 150000, keepRecentToolUses: 3 },
})
expect(out.text).toBe('ok')
expect(seen.join('')).toBe('ok')
expect(calls).toHaveLength(2) // 1차 CM 동봉 400 → 2차 CM 제거 후 스트리밍 성공
expect(JSON.parse(calls[1].init.body).context_management).toBeUndefined()
expect(calls[1].init.headers['anthropic-beta']).toBeUndefined()
})

it('CM+schema 둘 다 set·schema 가 400 유발 → schema 만 제거하고 CM 은 보존한다(Codex P2 — 필드 격리)', async () => {
const { http, calls } = mockHttp((_url, init) => {
const body = JSON.parse(init.body)
// schema(output_config.format) 가 원인인 400 모사 — format 있으면 거부, 없으면 성공.
if (body.output_config?.format) return { ok: false, status: 400, body: 'format unsupported' }
return { body: cmResp }
})
const p = createAnthropicProvider(baseAnthropic, http)
const out = await p.chat([{ role: 'user', content: 'x' }], {
contextManagement: { triggerInputTokens: 150000, keepRecentToolUses: 3 },
responseSchema: { name: 'v', schema: { type: 'object' } },
})
expect(out.text).toBe('ok')
expect(calls).toHaveLength(2) // 1차(format+CM) 400 → 2차(format 제거·CM 보존) 성공
const b2 = JSON.parse(calls[1].init.body)
expect(b2.output_config?.format).toBeUndefined() // schema 제거됨
expect(b2.context_management).toBeDefined() // CM 보존(회귀 차단)
expect(calls[1].init.headers['anthropic-beta']).toBe('context-management-2025-06-27') // beta 헤더 보존
})

it('CM+schema 둘 다 set·CM 이 400 유발 → CM 만 제거하고 schema 는 복원·보존한다(필드 격리 ②③)', async () => {
const { http, calls } = mockHttp((_url, init) => {
const body = JSON.parse(init.body)
if (body.context_management) return { ok: false, status: 400, body: 'cm unsupported' } // CM 이 원인
return { body: cmResp }
})
const p = createAnthropicProvider(baseAnthropic, http)
const out = await p.chat([{ role: 'user', content: 'x' }], {
contextManagement: { triggerInputTokens: 150000, keepRecentToolUses: 3 },
responseSchema: { name: 'v', schema: { type: 'object' } },
})
expect(out.text).toBe('ok')
expect(calls).toHaveLength(3) // ①schema 제거(여전히 CM→400) ②schema 복원·CM 제거 → 성공
const b3 = JSON.parse(calls[2].init.body)
expect(b3.context_management).toBeUndefined() // CM 제거됨
expect(b3.output_config?.format).toBeDefined() // schema 복원·보존됨
})

it('CM+schema 둘 다 제거해도 400 이면 ApiProviderError 를 던진다(필드 격리 최종)', async () => {
const { http, calls } = mockHttp(() => ({ ok: false, status: 400, body: 'always 400' }))
const p = createAnthropicProvider(baseAnthropic, http)
await expect(
p.chat([{ role: 'user', content: 'x' }], {
contextManagement: { triggerInputTokens: 150000, keepRecentToolUses: 3 },
responseSchema: { name: 'v', schema: { type: 'object' } },
}),
).rejects.toThrow(/HTTP 400/)
expect(calls).toHaveLength(4) // 원본 + ①schema ②CM ③둘다 = 4 시도
})

it('nativeContextManagement 플래그를 노출한다(anthropic=true·openai/google 부재)', () => {
expect(createAnthropicProvider(baseAnthropic).nativeContextManagement).toBe(true)
expect(createOpenAiProvider(baseOpenai).nativeContextManagement).toBeUndefined()
expect(createGoogleProvider(baseGoogle).nativeContextManagement).toBeUndefined()
})
})

describe('OpenAiProvider', () => {
Expand Down
18 changes: 18 additions & 0 deletions src/main/core/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ export interface ToolDefinition {
}

// ── 응답 메타 ────────────────────────────────────────────────────────────────
/**
* provider-중립 context management 정책. anthropic 은 native `context_management` wire 로,
* native 미지원 provider 는 loop 의 client-side 가지치기로 해석한다(동일 정책·실행만 분기).
*/
export interface ContextManagementPolicy {
/** 누적 입력토큰(anthropic=서버 실측·그 외=client 추정)이 이 값을 넘으면 정리. */
triggerInputTokens: number
/** 유지할 최근 도구결과 수(≥ 0). 이보다 오래된 tool_result 부터 정리한다. 0이면 가능한 전부 정리. */
keepRecentToolUses: number
}

export interface TokenUsage {
inputTokens?: number
outputTokens?: number
Expand Down Expand Up @@ -155,6 +166,11 @@ export interface ApiCallOptions {
* Anthropic(adaptive thinking)·OpenAI(reasoning_effort) 매핑. Gemini 는 후속. #11-thinking.
*/
thinking?: { effort?: ReasoningEffort }
/**
* provider-중립 context management 정책. native 지원 provider(anthropic)는 이를 wire
* `context_management` 로 변환한다. native 미지원 provider 는 무시한다(루프가 client-side 처리).
*/
contextManagement?: ContextManagementPolicy
}

// ── 주입 가능한 최소 HTTP 클라이언트 (테스트에서 mock) ──────────────────────
Expand Down Expand Up @@ -192,6 +208,8 @@ export interface ApiProvider {
readonly id: string
readonly provider: ApiProviderConfig['provider']
readonly model: string
/** native server-side context management(예: anthropic Messages API context_management) 지원 여부. */
readonly nativeContextManagement?: boolean
/** 대화 턴 배열 → 구조화된 어시스턴트 응답(text · 도구호출 · 종료사유 · 사용량). */
chat(messages: ChatTurn[], opts?: ApiCallOptions): Promise<ChatResult>
}
Expand Down
Loading
Loading