Step 08 — 미들웨어 조합

학습 목표

  • createDeepAgent 의 기본 미들웨어 스택을 순서까지 정확히 나열한다
  • deepagents 가 export 하는 미들웨어 팩토리와 각각의 옵션을 구분해 쓴다
  • createAgent + 미들웨어로 createDeepAgent 와 동등한 것을 직접 조립하고 결과를 비교한다
  • Deep Agent 전체가 아니라 파일시스템만, 계획만 필요한 에이전트를 만든다
  • middleware 옵션이 기본 스택을 추가하는지 대체하는지 이름 규칙으로 설명한다
  • 미들웨어 순서와 서브에이전트 미들웨어 상속 문제를 진단한다
  • 프롬프트 캐싱 미들웨어가 언제 자동으로 붙고 언제 직접 붙여야 하는지 안다

선행 스텝: Step 07 — 시스템 프롬프트 설계 예상 소요: 80분

Step 07 내내 같은 말을 반복했습니다. "그건 미들웨어가 붙인 겁니다." 파일 지침도, task 지침도, todo 지침도. base: null 로도 못 지웠고, REQUIRED_MIDDLEWARE_NAMES 는 아예 제외를 거부했습니다. 계속 미들웨어라는 벽에 부딪혔습니다.

이 스텝에서 그 벽을 넘습니다. 핵심 메시지는 하나입니다: createDeepAgent 를 벗기면 createAgent + 미들웨어 스택이다. 특별한 클래스도, 숨겨진 그래프도 없습니다. LangChain Step 08 에서 만든 그 createAgent 에 미들웨어 여섯 개를 정해진 순서로 꽂고 프롬프트를 조립해 넘기는 함수일 뿐입니다. 그걸 말로 주장하지 않고 직접 조립해서 증명합니다. 두 에이전트의 도구 목록을 나란히 찍어 같은지 확인할 겁니다.

이걸 알면 세 가지가 열립니다. Deep Agent 의 일부만 빌려쓸 수 있고, 기본 스택에 내 미들웨어를 안전하게 끼울 수 있고, 뭔가 안 될 때 어느 미들웨어의 책임인지 짚을 수 있습니다.

검증 버전: deepagents 1.11.0 / langchain 1.5.3 / @langchain/anthropic 1.5.1 / Node.js 22 이 스텝의 스택 순서·미들웨어 이름·옵션 필드는 모두 deepagents@1.11.0 패키지에서 직접 확인한 것입니다. 미들웨어 순서는 내부 구현이므로 마이너 버전에서 바뀔 수 있습니다.


8-1. createDeepAgent 의 기본 미들웨어 스택 정체 밝히기

먼저 결론입니다. createDeepAgent 는 이 스택을 조립합니다.

[기본(default) 세그먼트]
  1. todoListMiddleware()                     ← langchain 제공
  2. createSkillsMiddleware({ backend, sources })   ← skills 를 줬을 때만
  3. createFilesystemMiddleware({ backend, permissions, tools })
  4. createSubAgentMiddleware({ defaultModel, defaultTools, subagents, ... })
  5. createSummarizationMiddleware({ backend })
  6. createPatchToolCallsMiddleware()
  7. createAsyncSubAgentMiddleware({ asyncSubAgents })  ← 비동기 서브에이전트를 줬을 때만

[내 middleware 옵션이 여기 끼어든다]

[꼬리(tail) 세그먼트]
  8. 하네스 프로파일의 extraMiddleware
  9. anthropicPromptCachingMiddleware() + CacheBreakpointMiddleware  ← Anthropic 모델일 때
     bedrockPromptCachingMiddleware()                                ← Bedrock 모델일 때
 10. createMemoryMiddleware({ backend, sources })   ← memory 를 줬을 때만
 11. humanInTheLoopMiddleware({ interruptOn })      ← interruptOn 을 줬을 때만

그리고 마지막 줄이 이것입니다.

return createAgent({
  model,
  systemPrompt: assemblePromptParts(promptParts),  // ← Step 07 의 prefix→base→suffix
  stateSchema,
  tools: effectiveTools,
  middleware,                                       // ← 위 스택
  responseFormat,
  contextSchema,
  checkpointer,
  store,
  name,
  streamTransformers,
}).withConfig({
  recursionLimit: 10000,
  metadata: { ls_integration: "deepagents", lc_agent_name: name },
});

끝입니다. createDeepAgentcreateAgent 를 감싼 조립 함수입니다. recursionLimit: 10000 도 여기서 나옵니다 — Deep Agent 가 수백 스텝을 도는 게 이상하지 않은 이유입니다(기본 createAgent 는 25 입니다).

조건부로 붙는 것들에 주목

스택의 절반은 조건부입니다.

미들웨어붙는 조건
SkillsMiddlewareskills 배열을 줬을 때
AsyncSubAgentMiddlewaresubagentsgraphId 를 가진 항목이 있을 때
프롬프트 캐싱모델이 Anthropic 또는 Bedrock 일 때
MemoryMiddlewarememory 배열을 줬을 때
HumanInTheLoopMiddlewareinterruptOn 을 줬을 때

같은 코드라도 옵션에 따라 스택이 달라집니다. "문서에는 11개라는데 내 스택엔 6개뿐" 이면 정상입니다.

눈으로 확인하기

Step 07 의 스파이를 확장합니다. request.tools모델에게 실제로 보이는 도구 목록을 볼 수 있습니다.

import { createMiddleware } from "langchain";

function makeInspector(label: string) {
  let done = false;
  return createMiddleware({
    name: "InspectorMiddleware",
    wrapModelCall: async (request, handler) => {
      if (!done) {
        done = true;
        const toolNames = request.tools.map((t: any) => t.name).sort();
        console.log(`[${label}] 도구(${toolNames.length}): ${toolNames.join(", ")}`);
        console.log(`[${label}] 프롬프트 길이: ${(request.systemMessage.text ?? "").length}`);
      }
      return handler(request);
    },
  });
}

const agent = await createDeepAgent({
  model: "anthropic:claude-sonnet-4-6",
  tools: [getWeather],                        // ← 내가 준 건 이거 하나
  systemPrompt: "당신은 날씨 봇입니다.",
  middleware: [makeInspector("createDeepAgent 기본")],
});

await agent.invoke({ messages: [{ role: "user", content: "안녕" }] });

출력 (도구 목록은 결정적입니다)

[createDeepAgent 기본] 도구(9): edit_file, get_weather, glob, grep, ls, read_file, task, write_file, write_todos
[createDeepAgent 기본] 프롬프트 길이: 5000자 내외

내가 준 도구는 get_weather 하나인데 9개가 보입니다. 나머지 8개의 출처는 이렇습니다.

도구등록한 미들웨어
write_todostodoListMiddleware
ls, read_file, write_file, edit_file, glob, grepFilesystemMiddleware
tasksubAgentMiddleware

💡 실무 팁: request.tools 스파이는 디버깅의 1순위 도구입니다. "모델이 이 도구를 안 부른다" 는 문제의 상당수는 애초에 그 도구가 목록에 없어서입니다. 프로파일의 excludedTools, createFilesystemMiddlewaretools 화이트리스트, 백엔드 능력 필터(예: 실행을 지원 안 하는 백엔드에서는 execute 가 자동 제거) 세 군데가 도구를 조용히 지웁니다. 부르라고 프롬프트를 고치기 전에 목록부터 찍어보세요.

⚠️ 함정: 내 도구 이름이 내장 도구와 겹치면 createDeepAgent에러를 던집니다.

ConfigurationError: Tool name(s) [read_file] conflict with built-in tools.
Rename your custom tools to avoid this.

ls, read_file, write_file, edit_file, glob, grep, execute, task, write_todos 는 예약어입니다. 다행히 조용히 덮어쓰지 않고 즉시 던져줍니다. search 같은 일반적인 이름은 안전하지만, read_file 처럼 자연스러운 이름을 쓰려다 부딪히면 접두사(db_read_file)를 붙이세요.


8-2. deepagents 가 export 하는 미들웨어

deepagents 는 자기가 쓰는 미들웨어를 전부 export 합니다. 이게 8-3 의 직접 조립을 가능하게 하는 전제입니다.

팩토리실제 .name등록 도구하는 일
createFilesystemMiddlewareFilesystemMiddlewarels read_file write_file edit_file glob grep execute가상 파일시스템 + 권한 + 큰 결과 오프로드
createSubAgentMiddlewaresubAgentMiddlewaretask서브에이전트 스폰
createSummarizationMiddlewareSummarizationMiddleware(없음)대화 요약 + 파일로 오프로드
createSkillsMiddlewareSkillsMiddleware(없음)스킬 메타데이터를 프롬프트에 주입
createMemoryMiddlewareMemoryMiddleware(없음)AGENTS.md 류 메모리 파일 주입
createPatchToolCallsMiddlewarepatchToolCallsMiddleware(없음)짝 안 맞는 tool_call/ToolMessage 복구
createAsyncSubAgentMiddlewareasyncSubAgentMiddlewarestart_async_task원격 비동기 서브에이전트

.name 이 팩토리 이름과 다르고, 대소문자도 제각각인 것에 주목하세요. createSubAgentMiddleware.name"SubAgentMiddleware" 가 아니라 "subAgentMiddleware"(소문자 s)입니다. 8-5 의 이름 충돌 규칙에서 이게 결정적입니다.

주요 옵션

createFilesystemMiddleware(options?)

옵션타입기본값설명
backend백엔드 인스턴스 또는 팩토리StateBackend파일이 실제로 어디 저장되나 (Step 05)
toolsFsToolName[] | "all" | null"all"노출할 파일 도구 화이트리스트. read_file 은 필수 포함
systemPromptstring | null자동 생성파일 지침 통째로 교체
customToolDescriptionsPartial<Record<FsToolName, string>>도구별 설명 교체
permissionsFilesystemPermission[][] (전부 허용)경로별 권한. 선언 순서대로 평가, 첫 매치 승
toolTokenLimitBeforeEvictnumber20000도구 결과가 이보다 크면 파일로 내보냄
humanMessageTokenLimitBeforeEvictnumber50000HumanMessage 가 이보다 크면 파일로 내보냄

createSubAgentMiddleware(options)

옵션타입기본값설명
defaultModel모델 문자열/인스턴스필수서브에이전트 기본 모델
defaultToolsStructuredTool[][]general-purpose 서브에이전트가 쓸 도구
defaultMiddlewareAgentMiddleware[] | nullnull커스텀 서브에이전트에 깔 미들웨어
generalPurposeMiddlewareAgentMiddleware[] | nulldefaultMiddleware 로 폴백general-purpose 전용
subagents(SubAgent | CompiledSubAgent)[][]서브에이전트 명세
generalPurposeAgentbooleantruegeneral-purpose 를 자동 추가할지
systemPromptstring | nullTASK_SYSTEM_PROMPTtask 지침 교체. null 이면 안 붙임
taskDescriptionstring | null자동 생성task 도구 설명 교체
defaultInterruptOnRecord<string, ...>null서브에이전트 HITL

createSummarizationMiddleware(options)

옵션타입기본값설명
backend백엔드/팩토리필수잘린 대화를 어디 저장하나
model모델 문자열/인스턴스활성 요청 모델요약 생성용
triggerContextSize | ContextSize[]모델 프로파일 유무에 따라언제 요약할지
keepContextSize최근 20개요약 후 남길 분량
summaryPromptstring내장요약 프롬프트
trimTokensToSummarizenumber4000요약 생성 시 넣을 최대 토큰
historyPathPrefixstring"/conversation_history"오프로드 경로
truncateArgsSettingsTruncateArgsSettings옛 메시지의 큰 도구 인자 자르기

ContextSize{ type: "messages" | "tokens" | "fraction", value: number } 입니다. 기본 트리거는 모델이 maxInputTokens 프로파일을 갖고 있으면 { type: "fraction", value: 0.85 }(컨텍스트의 85%), 없으면 { type: "tokens", value: 170000 } 로 폴백합니다.

createSkillsMiddleware(options) / createMemoryMiddleware(options)

옵션타입설명
backend백엔드/팩토리필수
sourcesstring[]필수. 스킬 디렉터리 또는 메모리 파일 경로. POSIX 슬래시
addCacheControlboolean (memory 전용)메모리 블록에 캐시 브레이크포인트를 달지. 기본 false

skills/memoryStep 10 — 장기 메모리와 스킬 에서 자세히 다룹니다.

⚠️ 함정 (같은 이름, 다른 타입): langchain 에도 summarizationMiddleware 가 있고, 그 .name"SummarizationMiddleware" 로 deepagents 것과 완전히 같습니다. 그런데 옵션 모양이 다릅니다.

// deepagents — ContextSize = { type, value }
createSummarizationMiddleware({ backend, trigger: { type: "tokens", value: 100000 } });

// langchain — ContextSize = { fraction?, tokens?, messages? }
summarizationMiddleware({ model, trigger: { tokens: 100000 } });

같은 개념(trigger, keep, ContextSize)에 이름도 같은데 필드 구조가 다릅니다. 문서를 대충 보고 섞어 쓰면 zod 검증에서 터지거나, 더 나쁘게는 strip 모드라 모르는 필드가 조용히 버려져 트리거가 기본값으로 도는 일이 생깁니다. 어느 패키지에서 import 했는지 확인하세요. 그리고 이름이 같다는 사실은 8-5 에서 훨씬 큰 사고로 이어집니다.

💡 실무 팁: 두 요약 미들웨어의 진짜 차이는 backend 필수 여부입니다. deepagents 쪽은 요약하면서 잘라낸 원본 대화를 /conversation_history 아래 파일로 오프로드합니다 — 에이전트가 나중에 read_file 로 되찾아볼 수 있습니다. langchain 쪽은 그냥 버립니다. Deep Agent 에서 긴 리서치를 돌린다면 이 차이가 큽니다.


8-3. 직접 조립 — createDeepAgent 와 동등한 것 만들기

주장을 증명할 차례입니다. 8-1 의 스택을 손으로 쌓아 createAgent 에 넣고, 두 에이전트의 도구 목록이 같은지 봅니다.

import { createAgent, todoListMiddleware, anthropicPromptCachingMiddleware } from "langchain";
import {
  createFilesystemMiddleware,
  createSubAgentMiddleware,
  createSummarizationMiddleware,
  createPatchToolCallsMiddleware,
  StateBackend,
} from "deepagents";

// createDeepAgent 의 backend 기본값이 정확히 이것입니다.
const backend = (config: { state: unknown; store?: any }) => new StateBackend(config);
const tools = [getWeather];

// (A) createDeepAgent
const deep = await createDeepAgent({
  model: MODEL,
  tools,
  systemPrompt: "당신은 날씨 봇입니다.",
  middleware: [makeInspector("A. createDeepAgent", deepSink)],
});
await deep.invoke({ messages: [{ role: "user", content: "안녕" }] });

// (B) createAgent + 미들웨어 스택 직접 조립
const manual = createAgent({
  model: MODEL,
  tools,
  systemPrompt: "당신은 날씨 봇입니다.",   // ← 여기선 '교체'다. createDeepAgent 와 의미가 다르다 (Step 07)
  middleware: [
    todoListMiddleware(),
    createFilesystemMiddleware({ backend }),
    createSubAgentMiddleware({
      defaultModel: MODEL,
      defaultTools: tools,
      subagents: [],
      generalPurposeAgent: true,
    }),
    createSummarizationMiddleware({ backend }),
    createPatchToolCallsMiddleware(),
    anthropicPromptCachingMiddleware({
      unsupportedModelBehavior: "ignore",
      minMessagesToCache: 1,
    }),
    makeInspector("B. 직접 조립", manualSink),
  ],
}).withConfig({ recursionLimit: 10000 });

await manual.invoke({ messages: [{ role: "user", content: "안녕" }] });

출력 (도구 목록은 결정적, 프롬프트 길이는 버전에 따라 다름)

--- [A. createDeepAgent] ---
도구(9): edit_file, get_weather, glob, grep, ls, read_file, task, write_file, write_todos
프롬프트 길이: 5000자 내외
섹션: base=true fs=true task=true

--- [B. 직접 조립] ---
도구(9): edit_file, get_weather, glob, grep, ls, read_file, task, write_file, write_todos
프롬프트 길이: 3000자 내외
섹션: base=false fs=true task=true

===== 동등성 비교 =====
도구 집합 동일: true
createDeepAgent 에만: []
직접 조립에만: []
→ 도구는 같지만 프롬프트가 짧습니다. BASE_AGENT_PROMPT 가 빠졌기 때문입니다.

도구 9개가 정확히 일치합니다. 마법은 없었습니다. createDeepAgent 는 이 조립을 대신 해주는 함수입니다.

딱 하나 재현 못 하는 것

섹션: base=false 를 보세요. 내장 BASE_AGENT_PROMPT 는 export 되지 않습니다.

// ✅ export 됨
import { TASK_SYSTEM_PROMPT, DEFAULT_SUBAGENT_PROMPT, DEFAULT_GENERAL_PURPOSE_DESCRIPTION,
         GENERAL_PURPOSE_SUBAGENT, REQUIRED_MIDDLEWARE_NAMES } from "deepagents";

// ❌ export 안 됨 — 직접 써야 함
// BASE_AGENT_PROMPT

TASK_SYSTEM_PROMPT 는 가져다 쓸 수 있는데(8-8 에서 씁니다) base 프롬프트는 아닙니다. 직접 조립하면 Step 07 에서 본 그 좋은 base 프롬프트를 손으로 써야 합니다. 이게 직접 조립의 실질적 비용입니다.

그래서, 직접 조립해야 하나?

대부분 아닙니다. 이 절의 목적은 "이렇게 하세요" 가 아니라 "이게 전부다" 를 보여주는 것입니다. 정리하면:

원하는 것방법
Deep Agent 에 내 미들웨어 추가createDeepAgent({ middleware: [...] })8-5
모델별 기본값 조정registerHarnessProfileStep 07
파일 도구만 줄이기createFilesystemMiddleware({ tools }) — 프로파일 excludedTools 로도 가능
Deep Agent 의 일부만 필요직접 조립 — 8-4
스택 순서를 근본적으로 바꿔야 함직접 조립

💡 실무 팁: 직접 조립을 테스트 도구로 쓰는 방법이 유용합니다. 프로덕션은 createDeepAgent 를 쓰되, 버그를 재현할 때 미들웨어를 하나씩 빼면서 조립해 보면 범인이 금방 나옵니다. "요약 미들웨어를 빼니까 되네" → 요약이 뭔가를 날리고 있다는 뜻입니다. 이분 탐색을 미들웨어 스택에 적용하는 것입니다.


8-4. 필요한 기능만 빌려쓰기

Deep Agent 전체가 아니라 조각만 필요할 때가 실제로 많습니다. 미들웨어가 개별 export 되어 있으니 평범한 createAgent 에 원하는 것만 꽂으면 됩니다.

파일시스템만 — "메모장 달린 에이전트"

const fsOnly = createAgent({
  model: MODEL,
  systemPrompt: "당신은 메모 도우미입니다. 요청받은 내용을 /notes/ 아래 파일로 정리하세요.",
  middleware: [createFilesystemMiddleware({ backend })],
});

const r = await fsOnly.invoke({
  messages: [{ role: "user", content: "오늘 회의 결론 '배포는 금요일'을 메모해줘." }],
});
console.log("생성된 파일:", Object.keys((r as any).files ?? {}));

출력 예시 (모델 응답이므로 매번 다릅니다)

[A. 파일시스템만] 도구(6): edit_file, glob, grep, ls, read_file, write_file
생성된 파일: [ '/notes/meeting.md' ]

taskwrite_todos 도 없습니다. 서브에이전트를 안 스폰하니 토큰이 훨씬 적게 듭니다. 챗봇에 "메모 기능" 만 붙이고 싶을 때 Deep Agent 전체를 끌어올 이유가 없습니다.

계획만 — write_todos

const planOnly = createAgent({
  model: MODEL,
  tools: [getWeather],
  systemPrompt: "당신은 여행 플래너입니다.",
  middleware: [todoListMiddleware()],
});

const r = await planOnly.invoke({
  messages: [{ role: "user", content: "서울, 부산, 제주 날씨를 확인하고 여행지를 추천하는 계획을 세워줘." }],
});
console.log("todos:", (r as any).todos);

todoListMiddlewarelangchain 에서 옵니다 — deepagents 를 설치조차 안 해도 계획 기능은 쓸 수 있습니다. LangChain Step 11 — 내장 미들웨어 에서 다룬 그 미들웨어입니다.

읽기 전용 파일시스템

const readOnly = createAgent({
  model: MODEL,
  systemPrompt: "당신은 코드 탐색기입니다. 파일을 수정하지 마세요.",
  middleware: [
    createFilesystemMiddleware({ backend, tools: ["read_file", "ls", "glob", "grep"] }),
  ],
});

출력

[C. 읽기 전용 fs] 도구(4): glob, grep, ls, read_file

write_file, edit_file 이 사라졌습니다. 그리고 Step 07 에서 봤듯 프롬프트의 파일 지침도 같이 줄어듭니다 — 헤더가 ## Filesystem Tools \read_file`, `ls`, `glob`, `grep`` 로 바뀌고 없는 도구의 설명은 나열되지 않습니다.

⚠️ 함정: "파일을 수정하지 마세요" 를 프롬프트에만 쓰고 도구는 그대로 두는 것은 보안 장치가 아닙니다. 모델은 프롬프트를 어길 수 있고, 프롬프트 인젝션에도 뚫립니다. 도구를 실제로 제거하거나 permissions 로 막아야 합니다. tools 화이트리스트는 "모델에게 안 보여주기" 고, permissions 는 "불러도 거부하기" 입니다. 진짜 신뢰 경계가 필요하면 Step 09 — HITL과 권한 제어permissions 를 쓰세요.

⚠️ 함정: createFilesystemMiddleware({ tools: [...] }) 에 배열을 주면 read_file 을 반드시 포함해야 합니다. 큰 도구 결과를 파일로 오프로드한 뒤 그걸 되읽는 복구 경로가 read_file 을 쓰기 때문입니다. 그리고 백엔드 능력 필터가 그 위에 또 적용됩니다 — 화이트리스트에 execute 를 넣어도 실행을 지원하지 않는 백엔드(예: StateBackend)면 조용히 빠집니다.

💡 실무 팁: 조각 빌려쓰기의 가장 흔한 실전 용례는 비용 절감입니다. Deep Agent 의 시스템 프롬프트는 5천 자, 도구는 9개입니다. 매 턴 다 보냅니다. "파일에 메모만 하면 되는" 워크플로에 이걸 다 태우면 입력 토큰이 서너 배입니다. 필요한 미들웨어만 꽂으면 프롬프트가 3천 자 → 1천 자로 줄어듭니다. 다만 대화가 길어지면 요약 미들웨어가 없는 게 문제가 되므로(8-8 참고), 트래픽이 많은 경로부터 재보고 결정하세요.


8-5. createDeepAgentmiddleware 주기 — 추가인가 대체인가

문서를 보면 "Custom middleware to apply after standard middleware" 라고만 되어 있습니다. 추가일까요 대체일까요?

정답: 추가입니다. 단, 이름이 같으면 대체입니다.

내부의 mergeMiddlewareStack 규칙입니다.

mergeMiddlewareStack(기본 세그먼트, 내 middleware, 꼬리 세그먼트)

1. 내 미들웨어 중 **기본 세그먼트와 이름이 같은 것** → 그 자리에서 교체
2. 내 미들웨어 중 **꼬리 세그먼트와 이름이 같은 것** → 그 자리에서 교체
3. 나머지(새 이름) → 기본 세그먼트와 꼬리 세그먼트 **사이에 삽입**

즉 병합 키는 .name 문자열입니다.

(A) 새 이름 → 추가된다

const logging = createMiddleware({
  name: "LogToolCallsMiddleware",       // ← 기본 스택에 없는 이름
  wrapToolCall: async (request, handler) => {
    console.log(`[로그] ${request.toolCall.name}`);
    return handler(request);
  },
});

const agent = await createDeepAgent({
  model: MODEL,
  tools: [getWeather],
  systemPrompt: "당신은 날씨 봇입니다.",
  middleware: [logging],
});

출력 예시 (모델 응답이므로 도구 호출 순서는 매번 다릅니다)

[로그] get_weather
도구(9): edit_file, get_weather, glob, grep, ls, read_file, task, write_file, write_todos

도구 9개가 그대로입니다. 기본 스택은 온전하고 내 로깅만 추가되었습니다. 이게 원하는 동작이고, 대부분의 경우 이걸 쓰면 됩니다.

(B) 이름이 겹치면 → 조용히 대체된다

import { summarizationMiddleware } from "langchain";   // ← langchain 것

const agent = await createDeepAgent({
  model: MODEL,
  tools: [getWeather],
  systemPrompt: "당신은 날씨 봇입니다.",
  middleware: [
    summarizationMiddleware({ model: MODEL, maxTokensBeforeSummary: 1000 }),
  ],
});

"요약 설정을 조정하려고 요약 미들웨어를 추가했다" 고 생각하기 쉽습니다. 실제로는:

  • langchainsummarizationMiddleware.name = "SummarizationMiddleware"
  • deepagentscreateSummarizationMiddleware.name = "SummarizationMiddleware"

같습니다. 그래서 이건 추가가 아니라 deepagents 의 요약 미들웨어를 통째로 교체한 것입니다. 에러도 경고도 없습니다. 스택 길이는 그대로고 도구 목록도 그대로라, 겉으로는 아무 일도 안 일어난 것처럼 보입니다.

⚠️ 함정 (이 스텝에서 가장 비싼 함정): 위 교체의 대가는 긴 대화에서만 드러납니다. deepagents 의 요약 미들웨어는 잘라낸 원본 대화를 /conversation_history 아래 파일로 오프로드합니다. langchain 것은 그냥 버립니다.

시나리오: 에이전트가 30턴 동안 리서치하며 /research/notes-1.md ~ /research/notes-12.md 를 씁니다. 요약이 트리거되어 옛 메시지가 요약문 한 덩이로 압축됩니다. 그런데 요약문에 파일 경로가 안 남습니다. 에이전트는 자기가 뭘 썼는지 잊고, /report.md 를 쓰면서 노트 12개를 참조하지 못합니다. 파일은 state.files 에 멀쩡히 있는데 에이전트만 모릅니다.

증상이 "가끔 결과가 부실함" 으로 나타나서 원인을 찾기가 정말 어렵습니다. 방어법:

  1. 이름 충돌을 의도한 게 아니면 langchainsummarizationMiddlewarecreateDeepAgent 에 넣지 마세요. 요약을 조정하려면 deepagents 것을 같은 이름으로 명시적 교체하세요:
    middleware: [createSummarizationMiddleware({ backend, trigger: { type: "tokens", value: 100000 } })]
  2. 요약 프롬프트에 "생성한 파일 경로를 반드시 요약에 보존하라" 를 넣으세요 (summaryPrompt 옵션).
  3. 에이전트가 중요 산출물의 경로 목록을 /index.md 에 계속 append 하게 프롬프트에 규칙을 두세요. 요약은 메시지를 자르지 파일을 지우지 않으므로, 파일에 적힌 것은 살아남습니다.

⚠️ 함정 (이름을 몰라서 생기는 사고): 교체하려고 이름을 맞췄는데 안 되는 반대 경우도 있습니다. 8-2 의 표를 다시 보세요 — createSubAgentMiddleware.name"subAgentMiddleware" 입니다. "SubAgentMiddleware" 로 이름을 지은 커스텀 미들웨어를 넣으면 교체가 아니라 추가됩니다. 서브에이전트 미들웨어가 두 개가 되고 task 도구가 중복 등록됩니다. 이름을 맞출 땐 추측하지 말고 찍어서 확인하세요:

console.log(createSubAgentMiddleware({ defaultModel: MODEL }).name);  // → subAgentMiddleware

💡 실무 팁: 커스텀 미들웨어 이름은 충돌하지 않게 접두사를 붙이세요. "AcmeAuditMiddleware", "AcmeRateLimitMiddleware". 이름은 병합 키이자 프로파일의 excludedMiddleware 키이므로, 사실상 공개 API 입니다. 한 번 정하면 바꾸기 어렵습니다.


8-6. 미들웨어 순서 문제

순서가 중요한 이유는 두 가지인데, 성격이 완전히 다릅니다.

(1) 훅 실행 순서 — 양파 구조

미들웨어 배열은 양파처럼 겹쳐집니다.

middleware: [첫째, 둘째, 셋째]

beforeAgent / beforeModel / wrapModelCall / wrapToolCall 진입:  첫째 → 둘째 → 셋째 → 모델
afterModel / afterAgent:                                        셋째 → 둘째 → 첫째

배열 앞이 바깥입니다. 확인해 봅시다.

const marker = (n: string) =>
  createMiddleware({
    name: `Marker${n}Middleware`,
    wrapModelCall: async (request, handler) => {
      console.log(`  들어감: ${n}`);
      const r = await handler({
        ...request,
        systemMessage: request.systemMessage.concat(`\n[${n}]`),
      });
      console.log(`  나옴:   ${n}`);
      return r;
    },
  });

const agent = createAgent({
  model: MODEL,
  systemPrompt: "짧게 답하세요.",
  middleware: [marker("첫째"), marker("둘째"), marker("셋째")],
});
await agent.invoke({ messages: [{ role: "user", content: "안녕" }] });

출력 (구조가 결정적입니다)

  들어감: 첫째
  들어감: 둘째
  들어감: 셋째
  나옴:   셋째
  나옴:   둘째
  나옴:   첫째

프롬프트에는 [첫째], [둘째], [셋째] 순으로 붙습니다 — 앞 미들웨어가 먼저 concat 하기 때문입니다. 8-1 스택에서 todoListMiddleware 가 1번이라 todo 지침이 파일 지침보다 앞에 오는 이유입니다.

실전 함의:

하고 싶은 것어디에 둬야 하나
모든 도구 호출을 로깅/감사 (바깥) — 다른 미들웨어의 변형까지 다 본다
최종 프롬프트를 관찰 (안쪽) — 앞 미들웨어가 다 덧붙인 뒤를 본다
요청을 변형 후 다음에 넘김변형이 반영되어야 하는 미들웨어보다
PII 마스킹 — 모델에 닿기 전에

이 스텝의 makeInspector 를 항상 배열 맨 뒤에 둔 이유가 이것입니다. 앞에 두면 다른 미들웨어가 붙이기 의 프롬프트를 보게 되어 파일 지침이 안 보입니다.

(2) 서브에이전트는 부모 미들웨어를 상속하지 않는다

이게 진짜 함정입니다. 부모의 middleware 배열은 부모에게만 적용됩니다. 서브에이전트의 미들웨어는 createSubAgentMiddlewaredefaultMiddleware / generalPurposeMiddleware 옵션으로 정해집니다.

틀린 조립:

const wrong = createAgent({
  model: MODEL,
  middleware: [
    createFilesystemMiddleware({ backend }),      // ← 부모에게만 파일 도구가 생긴다
    createSubAgentMiddleware({
      defaultModel: MODEL,
      subagents: [
        {
          name: "note-taker",
          description: "받은 내용을 /notes.md 에 저장하는 서브에이전트",
          systemPrompt: "받은 내용을 /notes.md 에 write_file 로 저장하세요.",
        },
      ],
      generalPurposeAgent: false,
    }),
    // defaultMiddleware 가 없다!
  ],
  systemPrompt: "note-taker 서브에이전트에게 위임하세요.",
});

출력 예시 (모델 응답이므로 매번 다릅니다)

결과: note-taker 에게 저장을 요청했으나, 해당 에이전트가 파일을 저장할 수 없다고 응답했습니다.
파일: []

부모는 write_file 이 있는데 서브에이전트는 없습니다. 서브에이전트는 write_file 을 부르려다 없어서 헤매다가, "저장했습니다" 라고 거짓 보고를 하기도 합니다 — 서브에이전트의 중간 과정은 부모에게 안 보이므로(Step 06) 부모는 그 말을 믿습니다. state.files 가 비어있는 걸 확인하기 전까지는 아무도 모릅니다.

올바른 조립:

const right = createAgent({
  model: MODEL,
  middleware: [
    createFilesystemMiddleware({ backend }),
    createSubAgentMiddleware({
      defaultModel: MODEL,
      defaultMiddleware: [createFilesystemMiddleware({ backend })],   // ← 이 한 줄이 핵심
      subagents: [ /* 위와 동일 */ ],
      generalPurposeAgent: false,
    }),
  ],
  systemPrompt: "note-taker 서브에이전트에게 위임하세요.",
});

출력 예시

결과: '배포는 금요일' 을 /notes.md 에 저장했습니다.
파일: [ '/notes.md' ]

createDeepAgent 는 이 배선을 자동으로 해 줍니다. 내부에서 서브에이전트마다 이 스택을 깔아줍니다.

// deepagents 내부 (createSubagentDefaultMiddleware)
[
  todoListMiddleware(),
  createFilesystemMiddleware({ backend, permissions, tools }),
  createSummarizationMiddleware({ backend }),
  createPatchToolCallsMiddleware(),
  ...(서브에이전트가 skills 를 가지면 createSkillsMiddleware(...)),
]

⚠️ 함정 (스킬은 상속 안 된다): 위 스택을 보면 SkillsMiddleware 가 조건부입니다. createDeepAgent 의 커스텀 서브에이전트는 부모의 skills상속하지 않습니다. 서브에이전트가 스킬을 쓰려면 자기 skills 배열을 직접 가져야 합니다. 예외적으로 자동 추가되는 general-purpose 서브에이전트만 부모 스킬을 물려받습니다(generalPurposeMiddleware 로 별도 배선). "부모에 스킬을 등록했는데 서브에이전트가 못 쓴다" 는 버그가 아니라 설계입니다. 같은 이유로 서브에이전트가 반환하는 state 에서 skillsMetadata, memoryContents, todos, structuredResponse제외됩니다 — 부모 상태로 새어 들어가지 않게.

💡 실무 팁: defaultMiddlewaregeneralPurposeMiddleware 를 나눠 놓은 이유가 위 스킬 상속 규칙 때문입니다. 직접 조립할 때 서브에이전트 미들웨어 배열을 변수로 빼서 재사용하세요(8-8 의 subagentMiddleware 처럼). 부모 스택과 서브에이전트 스택이 따로 노는 걸 코드에 드러내야, 나중에 "부모에 미들웨어 추가했는데 왜 서브에서 안 되지" 를 안 겪습니다.


8-7. 캐싱 미들웨어 — provider 프롬프트 캐싱

Step 07 에서 봤듯 Deep Agent 의 시스템 프롬프트는 5천 자가 넘습니다. 도구 정의 9개도 매 요청 같이 갑니다. 30턴 대화면 그 앞부분을 30번 다시 보냅니다.

Anthropic 과 Bedrock 은 프롬프트 캐싱을 지원합니다. 앞부분이 바이트 단위로 동일하면 재처리 대신 캐시에서 읽습니다.

좋은 소식: createDeepAgent 는 이걸 자동으로 붙입니다.

// deepagents 내부 (createDeepAgent)
let cacheMiddleware = [];
if (anthropicModel) cacheMiddleware = [
  ...cacheMiddleware,
  anthropicPromptCachingMiddleware({ unsupportedModelBehavior: "ignore", minMessagesToCache: 1 }),
  createCacheBreakpointMiddleware(),
];
if (bedrockModel) cacheMiddleware = [
  ...cacheMiddleware,
  bedrockPromptCachingMiddleware({ unsupportedModelBehavior: "ignore" }),
];

모델 문자열이 "anthropic:..." 이면 아무것도 안 해도 캐싱이 켜집니다. 이건 꼬리 세그먼트에 들어가므로 내 미들웨어보다 뒤(안쪽)입니다.

확인하기

const usageSpy = createMiddleware({
  name: "UsageSpyMiddleware",
  afterModel: async (state) => {
    const u = (state.messages.at(-1) as any)?.usage_metadata;
    if (u) {
      console.log(
        `  입력=${u.input_tokens} 출력=${u.output_tokens} ` +
          `캐시생성=${u.input_token_details?.cache_creation ?? 0} ` +
          `캐시읽기=${u.input_token_details?.cache_read ?? 0}`,
      );
    }
    return undefined;
  },
});

const agent = await createDeepAgent({
  model: "anthropic:claude-sonnet-4-6",
  tools: [getWeather],
  systemPrompt: "당신은 날씨 봇입니다.",
  middleware: [usageSpy],
});

const r1 = await agent.invoke({ messages: [{ role: "user", content: "서울 날씨?" }] });
await agent.invoke({ messages: [...r1.messages, { role: "user", content: "부산은?" }] });

출력 예시 (토큰 수는 매번 다릅니다. usage_metadata필드명은 결정적입니다)

1회차 (캐시 생성):
  입력=25 출력=68 캐시생성=2847 캐시읽기=0
2회차 (캐시 읽기 — 같은 대화를 이어감):
  입력=31 출력=54 캐시생성=112 캐시읽기=2847

2회차의 캐시읽기=2847 이 적중입니다. 그만큼의 입력 토큰이 할인된 요금으로 계산됩니다.

직접 조립할 때

createAgent 로 직접 조립하면 캐싱은 자동으로 안 붙습니다. 넣어야 합니다.

import { anthropicPromptCachingMiddleware, bedrockPromptCachingMiddleware } from "langchain";

middleware: [
  // ... 나머지 스택
  anthropicPromptCachingMiddleware({ unsupportedModelBehavior: "ignore", minMessagesToCache: 1 }),
]

unsupportedModelBehavior: "ignore" 가 중요합니다. 이게 없으면 OpenAI 모델로 바꿔 돌릴 때 터집니다. "ignore" 면 조용히 아무것도 안 합니다 — 모델을 바꿔가며 테스트하는 코드에서 필수입니다.

⚠️ 함정 (캐시가 조용히 안 먹는다): 프롬프트 캐싱은 접두사가 바이트 단위로 동일할 때만 적중합니다. 앞부분에 조금이라도 변하는 게 있으면 매 요청 캐시를 새로 만들고(비쌉니다) 읽기는 0입니다. 흔한 파괴 요인:

  • 시스템 프롬프트에 타임스탬프나 랜덤 ID 를 넣는 미들웨어 (현재 시각 주입, 요청 ID 태깅 등)
  • 매 요청 도구 순서가 바뀌는 코드 (Object.values(toolMap) 같은 것)
  • MemoryMiddleware 가 갱신된 메모리를 프롬프트 앞쪽에 다시 주입

마지막 것 때문에 createDeepAgentMemoryMiddleware 를 캐싱 미들웨어보다 뒤에 놓습니다 — 메모리 갱신이 캐시를 무효화하지 않게 하려는 의도적 배치입니다. 순서가 성능 설계인 사례입니다. 캐시가 안 먹으면 비용이 오히려 늘어납니다(캐시 쓰기가 일반 입력보다 비쌈). 캐시읽기 가 계속 0이면 반드시 원인을 찾으세요.

💡 실무 팁: Anthropic 캐시의 기본 TTL 은 5분입니다. 사용자가 5분 넘게 자리를 비우면 다음 턴은 캐시 미스입니다. 그래서 캐싱은 연속적인 에이전트 루프(도구를 열 번 부르며 몇 초 안에 도는)에서 가장 크게 먹힙니다 — 정확히 Deep Agent 의 워크로드입니다. 반대로 사람이 띄엄띄엄 말하는 챗봇에서는 효과가 작습니다. OpenAI 는 프롬프트 캐싱이 자동이라 미들웨어가 필요 없습니다.


8-8. 종합 — 최소 Deep Agent 를 직접 조립

배운 걸 다 씁니다. "파일시스템 + 계획 + 서브에이전트" 만 있는 경량 리서치 에이전트입니다.

import { TASK_SYSTEM_PROMPT } from "deepagents";

const backend = (config: { state: unknown; store?: any }) => new StateBackend(config);

// 서브에이전트용 스택을 변수로 빼서 명시 (8-6)
const subagentMiddleware = [
  createFilesystemMiddleware({ backend }),
  createPatchToolCallsMiddleware(),
];

const agent = createAgent({
  model: MODEL,
  tools: [search],
  systemPrompt: `당신은 언어 리서처입니다.

## 워크플로
1. 조사할 언어가 2개 이상이면 write_todos 로 계획을 세운다.
2. 각 언어는 researcher 서브에이전트에게 task 로 위임한다.
3. 결과를 /report.md 에 write_file 로 저장한다.

## 도구 사용 규칙
- search 가 "NOT_FOUND" 를 반환하면 "자료 없음" 으로 표기한다. 지어내지 않는다.
- 파일 경로는 항상 /로 시작한다.

## 중단 조건
- /report.md 를 쓴 뒤 즉시 종료한다.

${TASK_SYSTEM_PROMPT}`,          // ← 내장 task 지침 재사용. export 되어 있다.
  middleware: [
    todoListMiddleware(),
    createFilesystemMiddleware({ backend }),
    createSubAgentMiddleware({
      defaultModel: MODEL,
      defaultTools: [search],
      defaultMiddleware: subagentMiddleware,   // ← 서브에이전트에도 파일시스템 (8-6)
      subagents: [
        {
          name: "researcher",
          description: "언어 하나를 조사해 3줄 요약을 반환합니다.",
          systemPrompt: "search 로 조사하고 3줄로 요약하세요. NOT_FOUND 면 '자료 없음' 이라고만 답하세요.",
          tools: [search],
        },
      ],
      generalPurposeAgent: false,              // general-purpose 는 안 쓴다 (토큰 절약)
    }),
    anthropicPromptCachingMiddleware({ unsupportedModelBehavior: "ignore", minMessagesToCache: 1 }),
  ],
}).withConfig({ recursionLimit: 10000 });

const result = await agent.invoke({
  messages: [{ role: "user", content: "Rust 와 Go 와 Zig 를 비교 조사해줘." }],
});

출력 예시 (모델 응답이므로 매번 다릅니다)

최종 응답: /report.md 에 비교 리포트를 작성했습니다. Rust 와 Go 는 조사되었고 Zig 는 자료가 없었습니다.
파일: [ '/report.md' ]

TASK_SYSTEM_PROMPT 를 붙인 것에 주목하세요. createSubAgentMiddlewaresystemPrompt 옵션의 기본값으로 이미 이걸 붙이므로 사실 중복입니다 — 하지만 "직접 조립할 때 어떤 상수가 export 되어 있는지" 를 보여주기 위한 예시입니다. 실제로는 미들웨어가 알아서 붙이니 빼도 됩니다.

얻은 것과 잃은 것

얻음스택이 코드에 명시적으로 보인다. general-purpose 를 껐고 요약을 뺐으니 프롬프트/토큰이 가볍다. 어느 미들웨어가 뭘 하는지 팀원이 읽을 수 있다.
잃음BASE_AGENT_PROMPT (직접 써야 함). 긴 대화의 자동 요약/오프로드 → 컨텍스트 초과 시 그냥 에러. 부모에 patchToolCalls 를 안 넣어 도구 호출 짝이 깨지면 provider 에러. 프로파일/권한/HITL 배선.

대부분의 경우 createDeepAgent + middleware 옵션이 정답입니다. 직접 조립은 (a) Deep Agent 의 일부만 필요하거나, (b) 스택 순서를 근본적으로 바꿔야 하거나, (c) 디버깅으로 이분 탐색할 때만 쓰세요.


정리

createDeepAgent 의 정체

createDeepAgent(params)
createAgent({
      model, systemPrompt: prefix+base+suffix, tools, middleware: [기본 스택], ...
    }).withConfig({ recursionLimit: 10000 })

기본 스택 (조건부 항목 포함)

#미들웨어세그먼트조건
1todoListMiddleware기본항상
2SkillsMiddleware기본skills 있을 때
3FilesystemMiddleware기본항상 (제거 불가)
4subAgentMiddleware기본항상 (제거 불가)
5SummarizationMiddleware기본항상
6patchToolCallsMiddleware기본항상
7asyncSubAgentMiddleware기본비동기 서브에이전트 있을 때
middleware새 이름이면 여기 삽입
8프로파일 extraMiddleware꼬리프로파일에 있을 때
9프롬프트 캐싱꼬리Anthropic / Bedrock 모델
10MemoryMiddleware꼬리memory 있을 때
11HumanInTheLoopMiddleware꼬리interruptOn 있을 때

핵심 함정 3가지

  1. middleware 는 추가지만, 이름이 같으면 조용한 교체다. 병합 키는 .name 문자열이다. langchainsummarizationMiddleware 는 deepagents 것과 이름이 같아서, 넣는 순간 파일 오프로드 기능을 잃는다. 그러면 에이전트가 요약 뒤에 자기가 쓴 파일 경로를 잊는다 — 증상은 "가끔 결과가 부실함" 이라 추적이 어렵다.
  2. 서브에이전트는 부모의 middleware 를 상속하지 않는다. 직접 조립하면서 createSubAgentMiddleware({ defaultMiddleware }) 를 빠뜨리면 서브에이전트가 파일 도구 없이 태어난다. 중간 과정이 안 보이므로 "저장했습니다" 라는 거짓 보고까지 받는다. createDeepAgent 는 이 배선을 자동으로 해준다.
  3. 미들웨어 순서는 성능 설계이기도 하다. 배열 앞이 바깥(먼저 진입, 나중에 반환). MemoryMiddleware 가 캐싱 뒤에 있는 건 메모리 갱신이 프롬프트 캐시를 무효화하지 않게 하려는 의도다. 순서를 함부로 바꾸면 캐시 적중률이 0이 되고 비용이 오히려 는다.

export 여부: TASK_SYSTEM_PROMPT, DEFAULT_SUBAGENT_PROMPT, GENERAL_PURPOSE_SUBAGENT, REQUIRED_MIDDLEWARE_NAMES 는 export 됨. BASE_AGENT_PROMPT 는 export 안 됨 — 직접 조립 시 base 프롬프트는 손으로 써야 한다.

LangChain 코스 연결: 미들웨어 개념과 내장 미들웨어 목록은 Step 11 — 내장 미들웨어, createMiddleware 로 훅을 직접 쓰는 법은 Step 12 — 커스텀 미들웨어 를 보세요.


연습문제

  1. createDeepAgent 에 도구를 하나도 안 주고(tools 생략) 인스펙터를 붙여, 모델에게 보이는 도구 목록을 출력하세요. 몇 개가 나오고, 각각 어느 미들웨어가 등록한 것인지 주석으로 매핑하세요.
  2. createDeepAgentname: "read_file" 인 커스텀 도구를 주고 try/catch 로 감싸 실행하세요. 무슨 일이 일어나는지 출력하고, 왜 그런지 설명하세요.
  3. createFilesystemMiddleware, createSubAgentMiddleware, createSummarizationMiddleware, createPatchToolCallsMiddleware, todoListMiddleware, 그리고 langchainsummarizationMiddleware 를 각각 만들어 .name 을 전부 출력하세요. 팩토리 이름과 .name 이 다른 것을 찾아 표로 정리하세요.
  4. createAgent + 미들웨어로 createDeepAgent도구 집합이 동일한 에이전트를 조립하세요. 두 도구 집합의 차집합을 양방향으로 출력해 [], [] 가 나오는 것을 보이세요. 프롬프트 길이는 왜 다른지 설명하세요.
  5. createFilesystemMiddleware({ backend, tools: ["ls", "glob"] }) 를 시도해 보세요. 정상 동작하나요? read_file 을 빼면 어떻게 되는지 확인하고, 문서상의 제약을 근거로 설명하세요.
  6. createDeepAgent 에 (a) 새 이름의 커스텀 미들웨어, (b) name: "SummarizationMiddleware" 인 커스텀 미들웨어를 각각 넣고 도구 목록과 동작을 비교하세요. (b) 에서 아무 에러가 안 나는 것을 확인하고, 실제로 무엇이 교체되었는지 설명하세요.
  7. createAgent 로 서브에이전트를 가진 에이전트를 두 벌 만드세요. 하나는 createSubAgentMiddlewaredefaultMiddleware 를 주고, 하나는 주지 마세요. 서브에이전트에게 파일 저장을 시켜 state.files 결과를 비교하세요.
  8. usage_metadata 스파이를 붙여 Anthropic 모델로 2턴 대화를 돌리고 cache_read 가 0보다 커지는 것을 확인하세요. 그다음 커스텀 미들웨어의 wrapModelCall 에서 request.systemMessage.concat(\\n현재 시각: ${new Date().toISOString()}`)로 **매 요청 변하는 값을 프롬프트에 주입**하고 다시 돌려cache_read` 가 어떻게 되는지 관찰하세요.

문제만 담긴 파일은 exercise.ts, 정답과 해설은 solution.ts 입니다. 두 파일 모두 아래 실습 파일 섹션에 전문이 실려 있습니다.


다음 단계

Step 09 — HITL과 권한 제어

8-1 스택의 11번 humanInTheLoopMiddlewarecreateFilesystemMiddlewarepermissions 옵션을 이 스텝에서는 표에만 적어두고 넘어갔습니다. 다음 스텝에서 그 둘을 제대로 다룹니다. 미들웨어 스택을 이해하고 나면 "왜 HITL 이 스택의 맨 마지막인가"(도구 호출을 가장 안쪽에서 가로채야 하니까)가 저절로 이해될 것입니다.


실습 파일

이 스텝은 TypeScript 파일 3개로 구성됩니다. 본문(8-1 ~ 8-8)의 예제를 순서대로 담은 practice.ts 를 먼저 실행해 createDeepAgent 와 직접 조립한 에이전트의 도구 목록이 같다는 것을 눈으로 확인하고, 그다음 exercise.ts 의 8개 문제를 직접 푼 뒤, solution.ts 로 채점하는 흐름입니다. 세 파일 모두 ANTHROPIC_API_KEY 가 필요합니다. OpenAI 로 돌리려면 각 파일 상단의 MODEL 상수만 "openai:gpt-5.5" 로 바꾸면 되지만, 8-7 의 프롬프트 캐싱은 Anthropic/Bedrock 전용이라 그 절만 무의미해집니다(unsupportedModelBehavior: "ignore" 덕분에 에러는 안 납니다).

practice.ts

본문을 따라가며 실행할 예제를 [8-1] ~ [8-8] 주석 번호로 묶은 파일입니다. 절 번호가 본문 소제목과 1:1 로 대응합니다.

  • 파일 맨 위의 makeInspector(label, sink) 가 이 스텝의 관측 도구입니다. Step 07 의 프롬프트 스파이를 확장해 request.tools 까지 봅니다. sink 객체를 넘기면 결과를 바깥 변수로 빼낼 수 있어서, [8-3] 의 두 에이전트 비교가 가능해집니다. done 플래그로 첫 모델 호출만 찍으므로 도구를 여러 번 부르는 예제에서도 출력이 깔끔합니다. 그리고 항상 미들웨어 배열 맨 뒤에 둔 것에 주의하세요 — 앞에 두면 다른 미들웨어가 프롬프트를 덧붙이기 전을 보게 되어 fs=false 가 나옵니다(8-6 의 양파 구조).
  • [8-1]step8_1() 위 주석 블록이 이 파일에서 가장 중요합니다. deepagents 소스에서 확인한 실제 스택 순서 11단계가 그대로 적혀 있습니다. 본문 표와 대조하며 읽으세요.
  • [8-3] 이 이 스텝의 증명입니다. deepSinkmanualSink 두 개를 만들어 도구 집합의 차집합을 양방향으로 찍습니다. 도구 집합 동일: true 가 나오는 것이 핵심이고, 바로 아래 프롬프트 길이 가 다른 것이 "BASE_AGENT_PROMPT 는 export 안 됨" 의 증거입니다.
  • [8-5] 의 (B) 는 일부러 함정에 빠지는 코드입니다. summarizationMiddlewarelangchain 에서 import 해서 넣는데, 실행해도 에러가 안 나고 도구 목록도 그대로입니다. 아무 일도 안 일어난 것처럼 보이는 것이 이 함정의 무서운 점입니다. 본문 8-5 의 함정 블록과 반드시 같이 읽으세요.
  • [8-6] 은 실패하는 조립과 성공하는 조립을 연달아 돌립니다. [틀린 조립]파일: [][올바른 조립]파일: [ '/notes.md' ] 를 나란히 보는 것이 목적입니다. 틀린 쪽에서 모델이 "저장했습니다" 라고 거짓 보고를 하는 경우도 있으니, 응답 텍스트가 아니라 파일: 줄을 믿으세요. 이게 서브에이전트 디버깅의 기본자세입니다.
  • [8-7] 은 모델을 2회 호출합니다. 캐시읽기 가 2회차에 0보다 커지면 성공입니다. 5분 TTL 이라 디버거로 중간에 오래 멈추면 미스가 납니다.
/**
 * Step 08 — 미들웨어 조합
 * 실행: npx tsx docs/reference/deepagent/step-08-middleware/practice.ts
 *
 * 검증 버전: deepagents 1.11.0 / langchain 1.5.3 / @langchain/anthropic 1.5.1
 *
 * 이 파일은 본문 8-1 ~ 8-8 의 예제를 순서대로 담고 있습니다.
 * 핵심 메시지: createDeepAgent 를 벗기면 createAgent + 미들웨어 스택이다.
 *
 * 필요 환경변수: ANTHROPIC_API_KEY
 *   (OpenAI 를 쓰려면 MODEL 을 "openai:gpt-5.5" 로 바꾸고 OPENAI_API_KEY 를 설정하세요.
 *    단 8-7 의 프롬프트 캐싱은 Anthropic/Bedrock 전용입니다.)
 */
import "dotenv/config";
import {
  createDeepAgent,
  createFilesystemMiddleware,
  createSubAgentMiddleware,
  createSummarizationMiddleware,
  createPatchToolCallsMiddleware,
  createMemoryMiddleware,
  StateBackend,
  TASK_SYSTEM_PROMPT,
} from "deepagents";
import {
  createAgent,
  createMiddleware,
  todoListMiddleware,
  anthropicPromptCachingMiddleware,
  summarizationMiddleware,
  tool,
} from "langchain";
import * as z from "zod";

const MODEL = "anthropic:claude-sonnet-4-6";

/* ===== 공용 준비물 ===== */

/**
 * 모델 호출 직전의 request 를 훔쳐보는 스파이.
 * request.tools 로 **모델에게 실제로 보이는 도구 목록**을,
 * request.systemMessage.text 로 최종 시스템 프롬프트를 봅니다.
 * (Step 07 의 makePromptSpy 를 도구까지 보도록 확장한 것)
 */
function makeInspector(label: string, sink?: { tools?: string[]; prompt?: string }) {
  let done = false;
  return createMiddleware({
    name: "InspectorMiddleware",
    wrapModelCall: async (request, handler) => {
      if (!done) {
        done = true;
        const toolNames = request.tools.map((t: any) => t.name).sort();
        const prompt = request.systemMessage.text ?? "";
        if (sink) {
          sink.tools = toolNames;
          sink.prompt = prompt;
        }
        console.log(`\n--- [${label}] ---`);
        console.log(`도구(${toolNames.length}): ${toolNames.join(", ")}`);
        console.log(`프롬프트 길이: ${prompt.length}`);
        console.log(
          `섹션: base=${prompt.includes("You are a Deep Agent")} ` +
            `fs=${prompt.includes("## Filesystem Tools")} ` +
            `task=${prompt.includes("subagent spawner")}`,
        );
      }
      return handler(request);
    },
  });
}

const getWeather = tool(({ city }: { city: string }) => `${city} 는 맑음, 21도.`, {
  name: "get_weather",
  description: "도시의 현재 날씨를 조회합니다.",
  schema: z.object({ city: z.string().describe("도시 이름") }),
});

/* ===== [8-1] createDeepAgent 의 기본 미들웨어 스택 정체 밝히기 ===== */
/**
 * createDeepAgent 는 마법이 아닙니다. 내부적으로 이 순서의 미들웨어 스택을 조립한 뒤
 * createAgent 에 넘길 뿐입니다 (deepagents@1.11.0 소스 기준):
 *
 *   [기본 세그먼트]
 *     1. todoListMiddleware()                    ← langchain 제공
 *     2. createSkillsMiddleware(...)             ← skills 를 줬을 때만
 *     3. createFilesystemMiddleware({ backend, permissions, tools })
 *     4. createSubAgentMiddleware({ defaultModel, defaultTools, subagents, ... })
 *     5. createSummarizationMiddleware({ backend })
 *     6. createPatchToolCallsMiddleware()
 *     7. createAsyncSubAgentMiddleware(...)      ← 비동기 서브에이전트를 줬을 때만
 *   [내 middleware 옵션이 여기 끼어든다]
 *   [꼬리 세그먼트]
 *     8. 하네스 프로파일의 extraMiddleware
 *     9. anthropicPromptCachingMiddleware() + CacheBreakpointMiddleware  ← Anthropic 모델일 때
 *        bedrockPromptCachingMiddleware()                                ← Bedrock 모델일 때
 *    10. createMemoryMiddleware({ backend, sources: memory })            ← memory 를 줬을 때만
 *    11. humanInTheLoopMiddleware({ interruptOn })                       ← interruptOn 을 줬을 때만
 *
 * 그리고 마지막에:
 *   return createAgent({ model, systemPrompt, tools, middleware, ... })
 *            .withConfig({ recursionLimit: 10000, metadata: { ls_integration: "deepagents" } });
 *
 * 즉 createDeepAgent === createAgent + 위 스택 + 프롬프트 조립 + recursionLimit 1만.
 */
async function step8_1() {
  console.log("\n########## [8-1] createDeepAgent 의 정체 ##########");

  const agent = await createDeepAgent({
    model: MODEL,
    tools: [getWeather],
    systemPrompt: "당신은 날씨 봇입니다.",
    middleware: [makeInspector("createDeepAgent 기본")],
  });

  await agent.invoke({ messages: [{ role: "user", content: "안녕" }] });
  // 도구 목록에 내가 준 get_weather 말고도
  // write_todos / ls / read_file / write_file / edit_file / glob / grep / task 가 보입니다.
  // 전부 미들웨어가 등록한 것입니다.
}

/* ===== [8-2] deepagents 가 export 하는 미들웨어들 ===== */
/**
 * 각 팩토리를 직접 만들어서 .name 을 찍어봅니다.
 * ⚠️ .name 이 표기와 다른 것들이 있습니다 (subAgentMiddleware 는 소문자 s!).
 *    이 이름이 8-5 의 "이름 충돌" 규칙에서 결정적입니다.
 */
async function step8_2() {
  console.log("\n########## [8-2] 미들웨어 카탈로그 ##########");

  // StateBackend 는 런타임 state 가 필요하므로 팩토리 형태로 넘깁니다.
  // createDeepAgent 의 기본값도 정확히 이것입니다: (config) => new StateBackend(config)
  const backend = (config: { state: unknown; store?: any }) => new StateBackend(config);

  const list = [
    todoListMiddleware(),
    createFilesystemMiddleware({ backend }),
    createSubAgentMiddleware({ defaultModel: MODEL }),
    createSummarizationMiddleware({ backend }),
    createPatchToolCallsMiddleware(),
    createMemoryMiddleware({ backend, sources: ["/AGENTS.md"] }),
    anthropicPromptCachingMiddleware({ unsupportedModelBehavior: "ignore" }),
    summarizationMiddleware({ model: MODEL }), // ← langchain 쪽. 이름을 잘 보세요.
  ];

  for (const m of list) {
    const tools = (m as any).tools?.map((t: any) => t.name) ?? [];
    console.log(`${m.name.padEnd(28)} 등록 도구: ${tools.length ? tools.join(", ") : "(없음)"}`);
  }

  // 출력을 보면 deepagents 의 createSummarizationMiddleware 와
  // langchain 의 summarizationMiddleware 가 **같은 이름**("SummarizationMiddleware")입니다.
  // 8-5 의 함정이 여기서 시작됩니다.
}

/* ===== [8-3] 직접 조립 — createDeepAgent 와 동등한 것 만들기 ===== */
/**
 * 8-1 의 스택을 손으로 쌓아 createAgent 에 넣습니다.
 * 두 에이전트의 **도구 목록**을 비교해 동등한지 확인합니다.
 *
 * ⚠️ 한 가지는 재현할 수 없습니다: 내장 BASE_AGENT_PROMPT 는 export 되지 않습니다.
 *    (TASK_SYSTEM_PROMPT, DEFAULT_SUBAGENT_PROMPT 는 export 됩니다)
 *    그래서 base 프롬프트는 내가 직접 써 넣어야 합니다 — 이것이 직접 조립의 비용입니다.
 */
async function step8_3() {
  console.log("\n########## [8-3] 직접 조립 ##########");

  const backend = (config: { state: unknown; store?: any }) => new StateBackend(config);
  const tools = [getWeather];

  const deepSink: { tools?: string[]; prompt?: string } = {};
  const manualSink: { tools?: string[]; prompt?: string } = {};

  // (A) createDeepAgent
  const deep = await createDeepAgent({
    model: MODEL,
    tools,
    systemPrompt: "당신은 날씨 봇입니다.",
    middleware: [makeInspector("A. createDeepAgent", deepSink)],
  });
  await deep.invoke({ messages: [{ role: "user", content: "안녕" }] });

  // (B) createAgent + 미들웨어 스택 직접 조립
  const manual = createAgent({
    model: MODEL,
    tools,
    systemPrompt: "당신은 날씨 봇입니다.", // ← createDeepAgent 와 달리 이건 '교체'다 (Step 07 참고)
    middleware: [
      todoListMiddleware(),
      createFilesystemMiddleware({ backend }),
      createSubAgentMiddleware({
        defaultModel: MODEL,
        defaultTools: tools,
        subagents: [],
        // createDeepAgent 는 general-purpose 를 subagents 배열에 직접 넣고 여기선 false 로 끕니다.
        // 직접 조립할 땐 true 로 두면 이 미들웨어가 general-purpose 를 만들어 줍니다.
        generalPurposeAgent: true,
      }),
      createSummarizationMiddleware({ backend }),
      createPatchToolCallsMiddleware(),
      anthropicPromptCachingMiddleware({
        unsupportedModelBehavior: "ignore",
        minMessagesToCache: 1,
      }),
      makeInspector("B. 직접 조립", manualSink),
    ],
  }).withConfig({ recursionLimit: 10000 });

  await manual.invoke({ messages: [{ role: "user", content: "안녕" }] });

  // 비교
  const a = new Set(deepSink.tools ?? []);
  const b = new Set(manualSink.tools ?? []);
  const onlyA = [...a].filter((x) => !b.has(x));
  const onlyB = [...b].filter((x) => !a.has(x));

  console.log("\n===== 동등성 비교 =====");
  console.log("도구 집합 동일:", onlyA.length === 0 && onlyB.length === 0);
  console.log("createDeepAgent 에만:", onlyA);
  console.log("직접 조립에만:", onlyB);
  console.log(
    `프롬프트 길이: createDeepAgent=${deepSink.prompt?.length} 직접조립=${manualSink.prompt?.length}`,
  );
  console.log(
    "→ 도구는 같지만 프롬프트가 짧습니다. BASE_AGENT_PROMPT 가 빠졌기 때문입니다.",
  );
}

/* ===== [8-4] 필요한 기능만 빌려쓰기 ===== */
/**
 * Deep Agent 전체가 필요한 게 아니라 조각만 필요할 때가 많습니다.
 * 미들웨어는 개별로 export 되어 있으므로 평범한 createAgent 에 원하는 것만 꽂으면 됩니다.
 */
async function step8_4() {
  console.log("\n########## [8-4] 필요한 것만 빌려쓰기 ##########");

  const backend = (config: { state: unknown; store?: any }) => new StateBackend(config);

  // (A) 파일시스템만 — 서브에이전트도 todo 도 없는 "메모장 달린 에이전트"
  const fsOnly = createAgent({
    model: MODEL,
    systemPrompt: "당신은 메모 도우미입니다. 요청받은 내용을 /notes/ 아래 파일로 정리하세요.",
    middleware: [createFilesystemMiddleware({ backend }), makeInspector("A. 파일시스템만")],
  });

  const r1 = await fsOnly.invoke({
    messages: [{ role: "user", content: "오늘 회의 결론 '배포는 금요일'을 메모해줘." }],
  });
  console.log("생성된 파일:", Object.keys((r1 as any).files ?? {}));

  // (B) 계획만 — write_todos 만 있는 에이전트
  const planOnly = createAgent({
    model: MODEL,
    tools: [getWeather],
    systemPrompt: "당신은 여행 플래너입니다.",
    middleware: [todoListMiddleware(), makeInspector("B. 계획만")],
  });

  const r2 = await planOnly.invoke({
    messages: [
      { role: "user", content: "서울, 부산, 제주 날씨를 확인하고 여행지를 추천하는 계획을 세워줘." },
    ],
  });
  console.log("todos:", JSON.stringify((r2 as any).todos ?? [], null, 2));

  // (C) 읽기 전용 파일시스템 — tools 화이트리스트로 도구를 줄이면 프롬프트 지침도 같이 줄어듭니다.
  const readOnly = createAgent({
    model: MODEL,
    systemPrompt: "당신은 코드 탐색기입니다. 파일을 수정하지 마세요.",
    middleware: [
      createFilesystemMiddleware({ backend, tools: ["read_file", "ls", "glob", "grep"] }),
      makeInspector("C. 읽기 전용 fs"),
    ],
  });
  await readOnly.invoke({ messages: [{ role: "user", content: "안녕" }] });
  // 도구 목록에 write_file / edit_file 이 없고,
  // "## Filesystem Tools" 헤더의 나열도 4개로 줄어든 것을 확인하세요.
}

/* ===== [8-5] createDeepAgent 에 middleware 추가 — 더하기인가 대체인가 ===== */
/**
 * 정답: **더하기**. 단, **이름이 같으면 대체**입니다.
 *
 * 내부의 mergeMiddlewareStack 규칙 (deepagents@1.11.0):
 *   - 기본/꼬리 세그먼트에 **같은 이름**이 있으면 → 그 자리에서 **교체**
 *   - 이름이 새로우면 → 기본 세그먼트와 꼬리 세그먼트 **사이에 삽입**
 */
async function step8_5() {
  console.log("\n########## [8-5] middleware 옵션: 추가 vs 교체 ##########");

  // (A) 새 이름 → 추가된다. 기본 스택은 그대로.
  let toolCallCount = 0;
  const logging = createMiddleware({
    name: "LogToolCallsMiddleware",
    wrapToolCall: async (request, handler) => {
      toolCallCount += 1;
      console.log(`  [로그] #${toolCallCount} ${request.toolCall.name}`);
      return handler(request);
    },
  });

  const added = await createDeepAgent({
    model: MODEL,
    tools: [getWeather],
    systemPrompt: "당신은 날씨 봇입니다.",
    middleware: [logging, makeInspector("A. 커스텀 추가")],
  });
  await added.invoke({ messages: [{ role: "user", content: "서울 날씨 알려줘." }] });
  console.log("→ 기본 스택(파일/task 도구)이 그대로 남아있는지 위 도구 목록을 확인하세요.");

  // (B) 이름이 겹치면 → 교체된다. 이게 함정입니다.
  //     langchain 의 summarizationMiddleware 의 name 도 "SummarizationMiddleware" 입니다.
  //     deepagents 의 createSummarizationMiddleware 와 **이름이 같습니다.**
  //     따라서 아래는 "요약 미들웨어를 추가" 가 아니라 "deepagents 요약을 **교체**" 입니다.
  //     → 대화 기록을 /conversation_history 에 오프로드하는 기능을 잃습니다.
  const replaced = await createDeepAgent({
    model: MODEL,
    tools: [getWeather],
    systemPrompt: "당신은 날씨 봇입니다.",
    middleware: [
      summarizationMiddleware({ model: MODEL, maxTokensBeforeSummary: 1000 }),
      makeInspector("B. 이름 충돌"),
    ],
  });
  await replaced.invoke({ messages: [{ role: "user", content: "안녕" }] });
  console.log("→ 에러 없이 조용히 교체되었습니다. 8-6 에서 결과를 봅니다.");
}

/* ===== [8-6] 미들웨어 순서 문제 ===== */
/**
 * 순서가 중요한 이유 두 가지.
 *
 * 1) 훅 실행 순서
 *    - before* / wrap* 은 배열 **앞** 미들웨어가 **바깥**(먼저 실행)
 *    - after* 는 **뒤** 미들웨어가 먼저 실행 (양파 껍질처럼 감싼다)
 *    프롬프트를 덧붙이는 미들웨어는 앞에 둘수록 프롬프트 **앞쪽**에 붙습니다.
 *
 * 2) 서브에이전트는 부모의 미들웨어 배열을 **상속하지 않는다**
 *    서브에이전트의 미들웨어는 createSubAgentMiddleware 의
 *    defaultMiddleware / generalPurposeMiddleware 옵션으로만 정해집니다.
 *    직접 조립하면서 이걸 안 주면 서브에이전트는 **파일 도구 없이** 태어납니다.
 */
async function step8_6() {
  console.log("\n########## [8-6] 순서와 상속 ##########");

  const backend = (config: { state: unknown; store?: any }) => new StateBackend(config);

  // (1) 훅 순서 확인 — 세 미들웨어가 프롬프트에 마커를 덧붙입니다.
  const marker = (n: string) =>
    createMiddleware({
      name: `Marker${n}Middleware`,
      wrapModelCall: async (request, handler) => {
        console.log(`  들어감: ${n}`);
        const r = await handler({
          ...request,
          systemMessage: request.systemMessage.concat(`\n[${n}]`),
        });
        console.log(`  나옴:   ${n}`);
        return r;
      },
    });

  const ordered = createAgent({
    model: MODEL,
    systemPrompt: "짧게 답하세요.",
    middleware: [marker("첫째"), marker("둘째"), marker("셋째")],
  });
  await ordered.invoke({ messages: [{ role: "user", content: "안녕" }] });
  console.log("→ 들어감: 첫째→둘째→셋째, 나옴: 셋째→둘째→첫째 (양파 구조)");

  // (2) 서브에이전트 미들웨어 상속 — 틀린 조립
  console.log("\n[틀린 조립] 서브에이전트에 defaultMiddleware 를 안 줌");
  const wrong = createAgent({
    model: MODEL,
    middleware: [
      createFilesystemMiddleware({ backend }), // ← 부모에게만 파일 도구가 생긴다
      createSubAgentMiddleware({
        defaultModel: MODEL,
        subagents: [
          {
            name: "note-taker",
            description: "받은 내용을 /notes.md 에 저장하는 서브에이전트",
            systemPrompt: "받은 내용을 /notes.md 에 write_file 로 저장하세요.",
          },
        ],
        generalPurposeAgent: false,
      }),
    ],
    systemPrompt: "note-taker 서브에이전트에게 위임하세요.",
  });

  const wrongResult = await wrong.invoke({
    messages: [{ role: "user", content: "'배포는 금요일' 을 note-taker 에게 저장시켜줘." }],
  });
  console.log("결과:", wrongResult.messages.at(-1)?.text?.slice(0, 200));
  console.log("파일:", Object.keys((wrongResult as any).files ?? {}));
  console.log("→ 서브에이전트에 write_file 이 없어 실패하거나 엉뚱하게 답합니다.");

  // (3) 올바른 조립 — defaultMiddleware 로 서브에이전트에게도 파일시스템을 준다
  console.log("\n[올바른 조립] defaultMiddleware 로 파일시스템 전달");
  const right = createAgent({
    model: MODEL,
    middleware: [
      createFilesystemMiddleware({ backend }),
      createSubAgentMiddleware({
        defaultModel: MODEL,
        defaultMiddleware: [createFilesystemMiddleware({ backend })], // ← 이 한 줄이 핵심
        subagents: [
          {
            name: "note-taker",
            description: "받은 내용을 /notes.md 에 저장하는 서브에이전트",
            systemPrompt: "받은 내용을 /notes.md 에 write_file 로 저장하세요.",
          },
        ],
        generalPurposeAgent: false,
      }),
    ],
    systemPrompt: "note-taker 서브에이전트에게 위임하세요.",
  });

  const rightResult = await right.invoke({
    messages: [{ role: "user", content: "'배포는 금요일' 을 note-taker 에게 저장시켜줘." }],
  });
  console.log("결과:", rightResult.messages.at(-1)?.text?.slice(0, 200));
  console.log("파일:", Object.keys((rightResult as any).files ?? {}));

  // 참고: createDeepAgent 를 쓰면 이 배선을 자동으로 해 줍니다.
  //       normalizeSubagentSpec 이 서브에이전트마다
  //       [todoList, filesystem, summarization, patchToolCalls] 를 깔아줍니다.
}

/* ===== [8-7] 캐싱 미들웨어 (provider 프롬프트 캐싱) ===== */
/**
 * Deep Agent 의 시스템 프롬프트는 5천 자가 넘습니다(Step 07). 매 턴 다시 보냅니다.
 * Anthropic/Bedrock 의 프롬프트 캐싱은 이 앞부분을 재사용해 입력 토큰 비용을 줄입니다.
 *
 * createDeepAgent 는 Anthropic 모델이면 이걸 **자동으로** 붙입니다:
 *   anthropicPromptCachingMiddleware({ unsupportedModelBehavior: "ignore", minMessagesToCache: 1 })
 *   + 내부 CacheBreakpointMiddleware
 * Bedrock 이면 bedrockPromptCachingMiddleware({ unsupportedModelBehavior: "ignore" }).
 *
 * 즉 createDeepAgent 를 쓰면 캐싱은 공짜입니다. 직접 조립할 때만 신경 쓰면 됩니다.
 */
async function step8_7() {
  console.log("\n########## [8-7] 프롬프트 캐싱 ##########");

  // usage_metadata 로 캐시 읽기/쓰기 토큰을 확인합니다.
  const usageSpy = createMiddleware({
    name: "UsageSpyMiddleware",
    afterModel: async (state) => {
      const last = state.messages.at(-1) as any;
      const u = last?.usage_metadata;
      if (u) {
        console.log(
          `  입력=${u.input_tokens} 출력=${u.output_tokens} ` +
            `캐시생성=${u.input_token_details?.cache_creation ?? 0} ` +
            `캐시읽기=${u.input_token_details?.cache_read ?? 0}`,
        );
      }
      return undefined;
    },
  });

  const agent = await createDeepAgent({
    model: MODEL,
    tools: [getWeather],
    systemPrompt: "당신은 날씨 봇입니다.",
    middleware: [usageSpy],
  });

  console.log("1회차 (캐시 생성):");
  const r1 = await agent.invoke({ messages: [{ role: "user", content: "서울 날씨?" }] });

  console.log("2회차 (캐시 읽기 — 같은 대화를 이어감):");
  await agent.invoke({
    messages: [...r1.messages, { role: "user", content: "부산은?" }],
  });

  // 캐시읽기 값이 0보다 커지면 성공입니다.
  // 캐시는 기본 5분 TTL 이고, 프롬프트 앞부분이 **바이트 단위로 동일**해야 적중합니다.
}

/* ===== [8-8] 종합 — 최소 Deep Agent 를 직접 조립 ===== */
/**
 * "파일시스템 + 계획 + 서브에이전트" 만 있는 경량 리서치 에이전트.
 * 요약/패치/캐싱은 뺐습니다. 무엇을 얻고 무엇을 잃는지 주석으로 정리했습니다.
 */
async function step8_8() {
  console.log("\n########## [8-8] 종합: 최소 조립 ##########");

  const backend = (config: { state: unknown; store?: any }) => new StateBackend(config);

  const search = tool(
    ({ query }: { query: string }) => {
      const db: Record<string, string> = {
        rust: "Rust 는 2015년 1.0 출시. 소유권 시스템으로 메모리 안전을 보장.",
        go: "Go 는 2012년 1.0 출시. 고루틴 기반 동시성이 특징.",
      };
      const hit = Object.entries(db).find(([k]) => query.toLowerCase().includes(k));
      return hit ? hit[1] : "NOT_FOUND";
    },
    {
      name: "search",
      description: "프로그래밍 언어 정보를 검색합니다.",
      schema: z.object({ query: z.string() }),
    },
  );

  const subagentMiddleware = [
    createFilesystemMiddleware({ backend }),
    createPatchToolCallsMiddleware(),
  ];

  const agent = createAgent({
    model: MODEL,
    tools: [search],
    systemPrompt: `당신은 언어 리서처입니다.

## 워크플로
1. 조사할 언어가 2개 이상이면 write_todos 로 계획을 세운다.
2. 각 언어는 researcher 서브에이전트에게 task 로 위임한다.
3. 결과를 /report.md 에 write_file 로 저장한다.

## 도구 사용 규칙
- search 가 "NOT_FOUND" 를 반환하면 "자료 없음" 으로 표기한다. 지어내지 않는다.
- 파일 경로는 항상 /로 시작한다.

## 중단 조건
- /report.md 를 쓴 뒤 즉시 종료한다.

${TASK_SYSTEM_PROMPT}`, // ← 내장 task 지침을 재사용. export 되어 있어 그대로 쓸 수 있다.
    middleware: [
      todoListMiddleware(),
      createFilesystemMiddleware({ backend }),
      createSubAgentMiddleware({
        defaultModel: MODEL,
        defaultTools: [search],
        defaultMiddleware: subagentMiddleware, // ← 서브에이전트에도 파일시스템을 준다 (8-6)
        subagents: [
          {
            name: "researcher",
            description: "언어 하나를 조사해 3줄 요약을 반환합니다.",
            systemPrompt: "search 로 조사하고 3줄로 요약하세요. NOT_FOUND 면 '자료 없음' 이라고만 답하세요.",
            tools: [search],
          },
        ],
        generalPurposeAgent: false, // general-purpose 는 안 쓴다 (토큰 절약)
      }),
      anthropicPromptCachingMiddleware({ unsupportedModelBehavior: "ignore", minMessagesToCache: 1 }),
      makeInspector("8-8 최소 조립"),
    ],
  }).withConfig({ recursionLimit: 10000 });

  const result = await agent.invoke({
    messages: [{ role: "user", content: "Rust 와 Go 와 Zig 를 비교 조사해줘." }],
  });

  console.log("\n최종 응답:", result.messages.at(-1)?.text);
  console.log("파일:", Object.keys((result as any).files ?? {}));

  // 얻은 것: 스택이 명시적이라 읽힌다. 요약 미들웨어가 없어 대화가 잘리지 않는다.
  // 잃은 것:
  //   - BASE_AGENT_PROMPT (직접 써야 함)
  //   - 긴 대화에서의 자동 요약/오프로드 → 컨텍스트 초과 시 에러
  //   - patchToolCalls 를 부모에 안 넣었으므로 도구 호출 짝이 깨지면 provider 에러
  //   - 프로파일/권한/HITL 배선
  // → 대부분의 경우 createDeepAgent + middleware 옵션이 정답입니다.
}

/* ===== 실행 ===== */
async function main() {
  await step8_1();
  await step8_2();
  await step8_3();
  await step8_4();
  await step8_5();
  await step8_6();
  await step8_7();
  await step8_8();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

exercise.ts

본문 연습문제 8개를 담은 빈칸 채우기용 파일입니다. 각 문제는 [문제 N] 주석 블록으로 구분되어 있고 함수 본문이 비어 있습니다.

  • 상단의 makeInspector, backend, saveNote 도구는 그대로 쓰라고 준 준비물입니다. 특히 backend(config) => new StateBackend(config) 팩토리 형태인데, 이건 createDeepAgent 의 실제 기본값과 정확히 같습니다. StateBackend 는 런타임 state 가 필요해서 인스턴스가 아니라 팩토리로 넘겨야 한다는 점을 기억하세요.
  • [문제 2][문제 5]일부러 실패시키는 문제입니다. try/catch 로 감싸서 에러 메시지를 출력하는 것이 정답입니다. 에러가 났다고 잘못 푼 게 아닙니다 — 어떤 에러가 어느 시점에 나는지가 답입니다.
  • [문제 3] 은 API 를 안 호출합니다. 미들웨어를 만들어 .name 만 찍으면 되므로 비용이 0이고 즉시 끝납니다. 여기서 얻은 이름표가 [문제 6] 의 전제이므로 먼저 푸세요.
  • [문제 4][문제 3] 의 답을 알아야 풀 수 있고, [문제 6][문제 3], [문제 7][문제 4] 의 조립 코드를 재활용합니다. 순서대로 푸세요.
  • [문제 8] 은 두 번 돌려서 cache_read 를 비교해야 합니다. 두 번째 실행에서 캐시가 깨지는 것을 보는 게 목적이므로, 첫 번째 실행의 숫자를 메모해 두세요.
  • 파일을 그대로 실행하면 함수 본문이 비어 있어 헤더만 8줄 찍히고 끝납니다. 정상입니다.
/**
 * Step 08 — 미들웨어 조합 · 연습문제
 * 실행: npx tsx docs/reference/deepagent/step-08-middleware/exercise.ts
 *
 * 각 [문제 N] 블록 아래를 직접 채우세요.
 * 정답은 solution.ts 에 있습니다. 먼저 스스로 풀어보고 여세요.
 *
 * 필요 환경변수: ANTHROPIC_API_KEY
 */
import "dotenv/config";
import {
  createDeepAgent,
  createFilesystemMiddleware,
  createSubAgentMiddleware,
  createSummarizationMiddleware,
  createPatchToolCallsMiddleware,
  StateBackend,
} from "deepagents";
import {
  createAgent,
  createMiddleware,
  todoListMiddleware,
  anthropicPromptCachingMiddleware,
  summarizationMiddleware,
  tool,
} from "langchain";
import * as z from "zod";

const MODEL = "anthropic:claude-sonnet-4-6";

/* ===== 공용 준비물 (그대로 쓰세요) ===== */

/**
 * 모델 호출 직전의 도구 목록과 시스템 프롬프트를 훔쳐봅니다.
 * sink 를 넘기면 결과를 바깥으로 빼낼 수 있습니다.
 * ⚠️ 미들웨어 배열의 **맨 뒤**에 두세요. 앞에 두면 다른 미들웨어가
 *    프롬프트를 덧붙이기 전을 보게 됩니다.
 */
function makeInspector(label: string, sink?: { tools?: string[]; prompt?: string }) {
  let done = false;
  return createMiddleware({
    name: "InspectorMiddleware",
    wrapModelCall: async (request, handler) => {
      if (!done) {
        done = true;
        const toolNames = request.tools.map((t: any) => t.name).sort();
        const prompt = request.systemMessage.text ?? "";
        if (sink) {
          sink.tools = toolNames;
          sink.prompt = prompt;
        }
        console.log(`[${label}] 도구(${toolNames.length}): ${toolNames.join(", ")}`);
        console.log(`[${label}] 프롬프트 길이: ${prompt.length}`);
      }
      return handler(request);
    },
  });
}

/** StateBackend 는 런타임 state 가 필요하므로 팩토리로 넘깁니다. */
const backend = (config: { state: unknown; store?: any }) => new StateBackend(config);

const saveNote = tool(({ text }: { text: string }) => `메모함: ${text}`, {
  name: "save_note",
  description: "메모를 저장합니다.",
  schema: z.object({ text: z.string() }),
});

/* ===== [문제 1] =====
 * createDeepAgent 에 도구를 **하나도 주지 말고**(tools 생략) 인스펙터를 붙여
 * 모델에게 보이는 도구 목록을 출력하세요.
 *
 * 몇 개가 나오나요? 각 도구가 어느 미들웨어에서 온 것인지 주석으로 매핑하세요.
 */
async function ex1() {
  console.log("\n===== [문제 1] =====");
  // 여기에 작성
  // → 매핑:
  //   write_todos                                    ← ?
  //   ls, read_file, write_file, edit_file, glob, grep ← ?
  //   task                                           ← ?
}

/* ===== [문제 2] =====
 * createDeepAgent 에 `name: "read_file"` 인 커스텀 도구를 주고
 * try/catch 로 감싸 실행하세요.
 *
 * 무슨 일이 일어나는지 출력하고, 왜 그런지 주석으로 설명하세요.
 * (에러가 나는 것이 정답입니다 — 어떤 에러가 **어느 시점에** 나는지가 답입니다)
 */
async function ex2() {
  console.log("\n===== [문제 2] =====");
  // 여기에 작성
  // → 왜 그런가:
  // → 에러가 나는 시점은 createDeepAgent 호출 시점인가, invoke 시점인가?
}

/* ===== [문제 3] =====
 * 아래 미들웨어들을 각각 만들어 **.name 을 전부 출력**하세요. (API 호출 없음, 비용 0)
 *
 *   todoListMiddleware()                          (langchain)
 *   createFilesystemMiddleware({ backend })       (deepagents)
 *   createSubAgentMiddleware({ defaultModel })    (deepagents)
 *   createSummarizationMiddleware({ backend })    (deepagents)
 *   createPatchToolCallsMiddleware()              (deepagents)
 *   summarizationMiddleware({ model })            (langchain)
 *
 * 팩토리 이름과 .name 이 다른 것을 찾아 주석에 표로 정리하세요.
 * 특히 **두 패키지의 미들웨어 중 .name 이 같은 쌍**을 찾으세요 — 문제 6 의 전제입니다.
 */
async function ex3() {
  console.log("\n===== [문제 3] =====");
  // 여기에 작성
  // → 팩토리 이름 != .name 인 것:
  // → .name 이 같은 쌍:
}

/* ===== [문제 4] =====
 * createAgent + 미들웨어로 createDeepAgent 와 **도구 집합이 동일한** 에이전트를 조립하세요.
 *
 * 두 도구 집합의 차집합을 **양방향으로** 출력해 [] , [] 가 나오는 것을 보이세요.
 * (힌트: makeInspector 에 sink 를 넘기면 도구 목록을 바깥으로 빼낼 수 있습니다)
 *
 * 프롬프트 길이는 왜 다른가요? 주석으로 설명하세요.
 */
async function ex4() {
  console.log("\n===== [문제 4] =====");
  // 여기에 작성
  // → 프롬프트 길이가 다른 이유:
}

/* ===== [문제 5] =====
 * createFilesystemMiddleware({ backend, tools: ["ls", "glob"] }) 를 시도해 보세요.
 * (read_file 이 빠져 있습니다)
 *
 * 정상 동작하나요? try/catch 로 감싸 결과를 출력하고,
 * 문서상의 제약을 근거로 주석에 설명하세요.
 */
async function ex5() {
  console.log("\n===== [문제 5] =====");
  // 여기에 작성
  // → 왜 read_file 은 필수인가:
}

/* ===== [문제 6] =====
 * createDeepAgent 에 다음 둘을 각각 넣고 도구 목록을 비교하세요.
 *   (a) 새 이름의 커스텀 미들웨어 (예: name: "MyAuditMiddleware")
 *   (b) name: "SummarizationMiddleware" 인 커스텀 미들웨어
 *
 * (b) 에서 **아무 에러도 안 나는 것**을 확인하고,
 * 실제로 무엇이 교체되었는지 주석으로 설명하세요.
 * (힌트: mergeMiddlewareStack 의 병합 키는 무엇인가?)
 */
async function ex6() {
  console.log("\n===== [문제 6] =====");
  // 여기에 작성
  // → (b) 에서 실제로 교체된 것:
  // → 그 대가는 무엇인가:
}

/* ===== [문제 7] =====
 * createAgent 로 서브에이전트를 가진 에이전트를 **두 벌** 만드세요.
 *   (a) createSubAgentMiddleware 에 defaultMiddleware 를 준 것
 *   (b) 안 준 것
 *
 * 서브에이전트에게 "/notes.md 에 저장" 을 시키고 두 결과의
 * state.files 를 비교하세요.
 *
 * ⚠️ 모델의 응답 텍스트가 아니라 **files 목록**을 보세요. 왜일까요?
 */
async function ex7() {
  console.log("\n===== [문제 7] =====");
  // 여기에 작성
  // → 응답 텍스트를 믿으면 안 되는 이유:
}

/* ===== [문제 8] =====
 * (a) usage_metadata 스파이를 붙여 Anthropic 모델로 2턴 대화를 돌리고
 *     2회차에 cache_read 가 0보다 커지는 것을 확인하세요.
 *     (힌트: afterModel 훅에서 state.messages.at(-1).usage_metadata 를 보세요.
 *            필드는 input_token_details.cache_read / cache_creation 입니다)
 *
 * (b) 커스텀 미들웨어의 wrapModelCall 에서
 *     request.systemMessage.concat(`\n현재 시각: ${new Date().toISOString()}`)
 *     로 **매 요청 변하는 값**을 프롬프트에 주입하고, 다시 2턴을 돌려
 *     cache_read 가 어떻게 되는지 관찰하세요.
 *
 * 첫 번째 실행의 숫자를 메모해 두고 비교하세요.
 */
async function ex8() {
  console.log("\n===== [문제 8] =====");
  // 여기에 작성
  // → (b) 에서 cache_read 가 그렇게 되는 이유:
}

async function main() {
  await ex1();
  await ex2();
  await ex3();
  await ex4();
  await ex5();
  await ex6();
  await ex7();
  await ex8();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

solution.ts

8문제의 정답 코드와 해설 주석을 담은 파일입니다. exercise.ts 를 스스로 풀어본 뒤에 열어보세요.

  • [정답 1] 의 도구 9개(도구를 안 줬으므로 8개)와 미들웨어 매핑표가 이 스텝 전체의 요약입니다. 여기서 헷갈리면 8-1 로 돌아가세요.
  • [정답 2] 의 에러 메시지는 Tool name(s) [read_file] conflict with built-in tools. 이고 ConfigurationError 타입입니다. createDeepAgent 호출 시점에 던집니다 — invoke 전에 터지므로 API 비용이 안 듭니다. 조용히 덮어쓰지 않는 좋은 설계의 예로 기억하세요.
  • [정답 3] 이 이 파일에서 가장 실용적입니다. 출력 표를 보면 createSubAgentMiddlewaresubAgentMiddleware(소문자 s), createPatchToolCallsMiddlewarepatchToolCallsMiddleware(소문자 p) 로 일관성이 없습니다. 그리고 deepagents 의 createSummarizationMiddleware 와 langchain 의 summarizationMiddleware둘 다 SummarizationMiddleware 로 찍히는 것이 [정답 6] 의 복선입니다.
  • [정답 5]tools: ["ls", "glob"]read_file 이 빠진 경우입니다. 문서에 "read_file must be included in every explicit array" 라고 명시되어 있습니다 — 큰 도구 결과를 파일로 오프로드한 뒤 되읽는 복구 경로가 read_file 을 쓰기 때문입니다.
  • [정답 6] 의 (b) 는 아무 에러도 안 나고 도구 목록도 동일합니다. 그게 정답입니다. 교체되었다는 증거는 도구 목록이 아니라 긴 대화에서 /conversation_history 파일이 안 생기는 것으로 드러납니다. 해설 주석에 그 확인법이 적혀 있습니다.
  • [정답 7] 은 응답 텍스트를 믿지 말고 state.files 를 보라는 교훈을 코드로 박아뒀습니다. defaultMiddleware 없는 쪽이 files: [] 인데도 모델이 "저장 완료" 라고 말하는 경우가 실제로 나옵니다. 서브에이전트의 중간 과정이 부모에게 안 보인다는 Step 06 의 성질이 여기서 디버깅 난이도로 돌아옵니다.
  • [정답 8] 은 시각 주입 전후의 cache_read 를 대조합니다. 주입 후에는 매 요청 cache_creation 만 발생하고 cache_read 가 0으로 붙박입니다. 캐시 미스는 공짜가 아니라 손해(캐시 쓰기가 일반 입력보다 비쌈)라는 점을 숫자로 확인하세요.
/**
 * Step 08 — 미들웨어 조합 · 정답
 * 실행: npx tsx docs/reference/deepagent/step-08-middleware/solution.ts
 *
 * exercise.ts 를 스스로 풀어본 뒤에 여세요.
 * 각 정답 위 주석에 기대 결과와 해설이 있습니다.
 */
import "dotenv/config";
import {
  createDeepAgent,
  createFilesystemMiddleware,
  createSubAgentMiddleware,
  createSummarizationMiddleware,
  createPatchToolCallsMiddleware,
  StateBackend,
} from "deepagents";
import {
  createAgent,
  createMiddleware,
  todoListMiddleware,
  anthropicPromptCachingMiddleware,
  summarizationMiddleware,
  tool,
} from "langchain";
import * as z from "zod";

const MODEL = "anthropic:claude-sonnet-4-6";

/* ===== 공용 준비물 ===== */

function makeInspector(label: string, sink?: { tools?: string[]; prompt?: string }) {
  let done = false;
  return createMiddleware({
    name: "InspectorMiddleware",
    wrapModelCall: async (request, handler) => {
      if (!done) {
        done = true;
        const toolNames = request.tools.map((t: any) => t.name).sort();
        const prompt = request.systemMessage.text ?? "";
        if (sink) {
          sink.tools = toolNames;
          sink.prompt = prompt;
        }
        console.log(`[${label}] 도구(${toolNames.length}): ${toolNames.join(", ")}`);
        console.log(`[${label}] 프롬프트 길이: ${prompt.length}`);
      }
      return handler(request);
    },
  });
}

const backend = (config: { state: unknown; store?: any }) => new StateBackend(config);

const saveNote = tool(({ text }: { text: string }) => `메모함: ${text}`, {
  name: "save_note",
  description: "메모를 저장합니다.",
  schema: z.object({ text: z.string() }),
});

/* ===== [정답 1] =====
 * 기대 결과: 도구 8개.
 *   edit_file, glob, grep, ls, read_file, task, write_file, write_todos
 *
 * → 매핑:
 *   write_todos                                       ← todoListMiddleware  (langchain)
 *   ls, read_file, write_file, edit_file, glob, grep  ← FilesystemMiddleware (deepagents)
 *   task                                              ← subAgentMiddleware   (deepagents)
 *
 * 해설: 도구를 하나도 안 줬는데 8개가 보입니다. createDeepAgent 는 "에이전트 생성기" 가
 * 아니라 "미들웨어 스택 조립기" 라는 것이 이 출력의 의미입니다.
 * execute 가 없는 이유는 기본 백엔드인 StateBackend 가 명령 실행을 지원하지 않아
 * 백엔드 능력 필터에서 제거되기 때문입니다 (Step 05 의 샌드박스 백엔드를 쓰면 나타납니다).
 */
async function sol1() {
  console.log("\n===== [정답 1] =====");
  const agent = await createDeepAgent({
    model: MODEL,
    systemPrompt: "당신은 도우미입니다.",
    middleware: [makeInspector("도구 없이 생성")],
  });
  await agent.invoke({ messages: [{ role: "user", content: "안녕" }] });
}

/* ===== [정답 2] =====
 * 기대 결과: ConfigurationError 를 던진다.
 *   Tool name(s) [read_file] conflict with built-in tools.
 *   Rename your custom tools to avoid this.
 *
 * → 왜 그런가: createDeepAgent 는 BUILTIN_TOOL_NAMES 집합
 *   (ls, read_file, write_file, edit_file, glob, grep, execute, task, write_todos
 *    + 비동기 태스크 도구들)과 내 도구 이름을 대조해 충돌하면 즉시 던집니다.
 *   조용히 덮어쓰면 "내 도구를 부른 줄 알았는데 내장 도구가 불렸다" 는
 *   최악의 디버깅이 되므로, 던지는 것이 옳은 설계입니다.
 *
 * → 에러 시점: **createDeepAgent 호출 시점**입니다. invoke 전에 터지므로
 *   API 호출 비용이 발생하지 않습니다. 부팅 시 검증되는 것과 같아서 좋습니다.
 */
async function sol2() {
  console.log("\n===== [정답 2] =====");
  const colliding = tool(({ path }: { path: string }) => `읽음: ${path}`, {
    name: "read_file", // ← 내장 도구와 충돌
    description: "내 방식대로 파일을 읽습니다.",
    schema: z.object({ path: z.string() }),
  });

  try {
    await createDeepAgent({
      model: MODEL,
      tools: [colliding],
      systemPrompt: "당신은 도우미입니다.",
    });
    console.log("생성 성공 — 예상과 다름!");
  } catch (err) {
    console.log("createDeepAgent 시점에 throw:", (err as Error).message);
    console.log("에러 타입:", (err as Error).constructor.name); // ConfigurationError
  }
}

/* ===== [정답 3] =====
 * 기대 출력:
 *   todoListMiddleware()                     → todoListMiddleware
 *   createFilesystemMiddleware()             → FilesystemMiddleware
 *   createSubAgentMiddleware()               → subAgentMiddleware        ← 소문자 s!
 *   createSummarizationMiddleware()          → SummarizationMiddleware
 *   createPatchToolCallsMiddleware()         → patchToolCallsMiddleware  ← 소문자 p!
 *   summarizationMiddleware()   [langchain]  → SummarizationMiddleware   ← 위와 같음!
 *
 * → 팩토리 이름 != .name 인 것: 전부. create 접두사가 빠지고, 대소문자도 일관성이 없습니다.
 *   특히 subAgentMiddleware / patchToolCallsMiddleware 는 **소문자로 시작**합니다.
 *   REQUIRED_MIDDLEWARE_NAMES 에는 "SubAgentMiddleware"(대문자)로 적혀 있어 더 헷갈립니다.
 *
 * → .name 이 같은 쌍:
 *   deepagents 의 createSummarizationMiddleware 와
 *   langchain 의 summarizationMiddleware 가 **둘 다 "SummarizationMiddleware"**.
 *   미들웨어 병합 키가 .name 문자열이므로, 이 둘은 서로를 교체합니다. → 정답 6
 *
 * 해설: 이 문제는 API 를 호출하지 않아 비용이 0입니다.
 * 미들웨어 이름을 추측하지 말고 **항상 찍어서 확인**하는 습관을 들이세요.
 */
async function sol3() {
  console.log("\n===== [정답 3] =====");
  const entries: [string, { name: string }][] = [
    ["todoListMiddleware()            [langchain] ", todoListMiddleware()],
    ["createFilesystemMiddleware()    [deepagents]", createFilesystemMiddleware({ backend })],
    ["createSubAgentMiddleware()      [deepagents]", createSubAgentMiddleware({ defaultModel: MODEL })],
    ["createSummarizationMiddleware() [deepagents]", createSummarizationMiddleware({ backend })],
    ["createPatchToolCallsMiddleware()[deepagents]", createPatchToolCallsMiddleware()],
    ["summarizationMiddleware()       [langchain] ", summarizationMiddleware({ model: MODEL })],
  ];

  for (const [factory, m] of entries) {
    console.log(`${factory}${m.name}`);
  }
}

/* ===== [정답 4] =====
 * 기대 결과: 도구 집합이 완전히 동일 (차집합 양방향 모두 []).
 *
 * → 프롬프트 길이가 다른 이유: 내장 BASE_AGENT_PROMPT 가 **export 되지 않기** 때문입니다.
 *   deepagents 는 TASK_SYSTEM_PROMPT, DEFAULT_SUBAGENT_PROMPT,
 *   DEFAULT_GENERAL_PURPOSE_DESCRIPTION, GENERAL_PURPOSE_SUBAGENT,
 *   REQUIRED_MIDDLEWARE_NAMES 는 export 하지만 BASE_AGENT_PROMPT 는 안 합니다.
 *   직접 조립하면 그 2천 자를 손으로 써야 합니다 — 이것이 직접 조립의 실질적 비용입니다.
 *
 * 해설: 도구가 같다는 것이 이 스텝의 증명입니다.
 *   createDeepAgent 는 마법이 아니라 createAgent + 미들웨어 스택 조립입니다.
 */
async function sol4() {
  console.log("\n===== [정답 4] =====");
  const tools = [saveNote];
  const deepSink: { tools?: string[]; prompt?: string } = {};
  const manualSink: { tools?: string[]; prompt?: string } = {};

  const deep = await createDeepAgent({
    model: MODEL,
    tools,
    systemPrompt: "당신은 메모 도우미입니다.",
    middleware: [makeInspector("A. createDeepAgent", deepSink)],
  });
  await deep.invoke({ messages: [{ role: "user", content: "안녕" }] });

  const manual = createAgent({
    model: MODEL,
    tools,
    systemPrompt: "당신은 메모 도우미입니다.",
    middleware: [
      todoListMiddleware(),
      createFilesystemMiddleware({ backend }),
      createSubAgentMiddleware({
        defaultModel: MODEL,
        defaultTools: tools,
        subagents: [],
        generalPurposeAgent: true,
      }),
      createSummarizationMiddleware({ backend }),
      createPatchToolCallsMiddleware(),
      anthropicPromptCachingMiddleware({
        unsupportedModelBehavior: "ignore",
        minMessagesToCache: 1,
      }),
      makeInspector("B. 직접 조립", manualSink),
    ],
  }).withConfig({ recursionLimit: 10000 });
  await manual.invoke({ messages: [{ role: "user", content: "안녕" }] });

  const a = new Set(deepSink.tools ?? []);
  const b = new Set(manualSink.tools ?? []);
  console.log("createDeepAgent 에만:", [...a].filter((x) => !b.has(x))); // []
  console.log("직접 조립에만:", [...b].filter((x) => !a.has(x))); // []
  console.log(
    `프롬프트 길이 차이: ${(deepSink.prompt?.length ?? 0) - (manualSink.prompt?.length ?? 0)}자 ` +
      `(= BASE_AGENT_PROMPT 분량)`,
  );
}

/* ===== [정답 5] =====
 * 기대 결과: read_file 이 없다는 취지의 에러가 발생합니다.
 *
 * → 왜 read_file 은 필수인가: 문서에 명시되어 있습니다 —
 *   "read_file must be included in every explicit array because it is used
 *    by normal file-inspection flows and by large-result recovery guidance."
 *
 *   FilesystemMiddleware 는 도구 결과가 toolTokenLimitBeforeEvict(기본 20000 토큰)를
 *   넘으면 그 결과를 **파일로 오프로드**하고, 모델에게 "이 경로를 read_file 로 읽어라" 는
 *   안내를 줍니다. read_file 이 없으면 이 복구 경로가 끊겨서 큰 결과가 그냥 사라집니다.
 *   그래서 화이트리스트에서 뺄 수 없게 막아둔 것입니다.
 *
 * 참고: 화이트리스트를 통과해도 **백엔드 능력 필터**가 한 번 더 거릅니다.
 *   tools 에 "execute" 를 넣어도 StateBackend 처럼 실행을 지원하지 않는 백엔드면
 *   조용히 빠집니다.
 */
async function sol5() {
  console.log("\n===== [정답 5] =====");
  try {
    const mw = createFilesystemMiddleware({ backend, tools: ["ls", "glob"] }); // read_file 없음
    console.log("생성됨. name:", mw.name);
    const agent = createAgent({
      model: MODEL,
      systemPrompt: "탐색기",
      middleware: [mw, makeInspector("read_file 없는 화이트리스트")],
    });
    await agent.invoke({ messages: [{ role: "user", content: "안녕" }] });
  } catch (err) {
    console.log("throw:", (err as Error).message);
  }

  // 올바른 사용: read_file 을 반드시 포함
  const ok = createFilesystemMiddleware({ backend, tools: ["read_file", "ls", "glob", "grep"] });
  const readOnly = createAgent({
    model: MODEL,
    systemPrompt: "탐색기",
    middleware: [ok, makeInspector("올바른 읽기 전용")],
  });
  await readOnly.invoke({ messages: [{ role: "user", content: "안녕" }] });
}

/* ===== [정답 6] =====
 * 기대 결과: (a) 와 (b) 모두 **도구 9개로 동일**하고 **에러가 없습니다.**
 *
 * → (b) 에서 실제로 교체된 것: deepagents 의 SummarizationMiddleware.
 *   mergeMiddlewareStack 의 병합 키는 **.name 문자열**입니다.
 *   내 커스텀 미들웨어의 name 이 "SummarizationMiddleware" 라서
 *   기본 세그먼트의 같은 이름 항목을 **그 자리에서 교체**했습니다.
 *   (a) 는 새 이름이라 기본 세그먼트와 꼬리 세그먼트 사이에 **삽입**되었습니다.
 *
 * → 그 대가: deepagents 의 요약 미들웨어는 잘라낸 대화를 backend 의
 *   /conversation_history 아래로 **오프로드**합니다. 교체하면 그 기능을 잃습니다.
 *   긴 리서치에서 요약이 트리거되면 에이전트가 자기가 만든 파일 경로를 잊고,
 *   최종 리포트에서 중간 산출물을 참조하지 못합니다.
 *   증상이 "가끔 결과가 부실함" 으로 나타나 추적이 매우 어렵습니다.
 *
 * 확인법: 대화를 길게 만들어(요약 트리거) state.files 에
 *   "/conversation_history..." 로 시작하는 키가 생기는지 보세요.
 *   교체한 쪽에서는 안 생깁니다.
 *
 * 교훈: 도구 목록이 같다고 스택이 같은 게 아닙니다.
 *   요약 미들웨어는 도구를 등록하지 않으므로 도구 목록으로는 교체를 감지할 수 없습니다.
 */
async function sol6() {
  console.log("\n===== [정답 6] =====");

  // (a) 새 이름 → 추가
  const audit = createMiddleware({
    name: "MyAuditMiddleware",
    wrapToolCall: async (request, handler) => {
      console.log(`  [감사] ${request.toolCall.name}`);
      return handler(request);
    },
  });
  const added = await createDeepAgent({
    model: MODEL,
    tools: [saveNote],
    systemPrompt: "메모 도우미",
    middleware: [audit, makeInspector("(a) 새 이름 → 추가")],
  });
  await added.invoke({ messages: [{ role: "user", content: "안녕" }] });

  // (b) 이름 충돌 → 조용한 교체. 에러 없음, 도구 목록 동일.
  const impostor = createMiddleware({
    name: "SummarizationMiddleware", // ← deepagents 것과 같은 이름
    beforeModel: async () => undefined, // 아무것도 안 함 = 요약 기능이 통째로 사라짐
  });
  const replaced = await createDeepAgent({
    model: MODEL,
    tools: [saveNote],
    systemPrompt: "메모 도우미",
    middleware: [impostor, makeInspector("(b) 이름 충돌 → 교체")],
  });
  await replaced.invoke({ messages: [{ role: "user", content: "안녕" }] });

  console.log("→ 에러도 없고 도구 목록도 같습니다. 그게 이 함정의 핵심입니다.");
}

/* ===== [정답 7] =====
 * 기대 결과:
 *   (a) defaultMiddleware 있음 → files: [ '/notes.md' ]
 *   (b) defaultMiddleware 없음 → files: []
 *
 * → 응답 텍스트를 믿으면 안 되는 이유:
 *   서브에이전트의 중간 과정은 부모에게 **보이지 않습니다** (Step 06).
 *   task 도구는 서브에이전트의 마지막 AI 메시지 텍스트만 부모에게 돌려줍니다.
 *   그래서 서브에이전트가 write_file 이 없어 실패해놓고 "저장했습니다" 라고
 *   보고하면, 부모는 그 말을 그대로 믿고 사용자에게 전달합니다.
 *   **관측 가능한 사실(state.files)만 믿으세요.** 이것이 서브에이전트 디버깅의 기본입니다.
 *
 * 해설: 부모의 middleware 배열은 부모에게만 적용됩니다.
 *   서브에이전트의 미들웨어는 createSubAgentMiddleware 의
 *   defaultMiddleware / generalPurposeMiddleware 로만 정해집니다.
 *   createDeepAgent 를 쓰면 이 배선을 자동으로 해 줍니다 —
 *   서브에이전트마다 [todoList, filesystem, summarization, patchToolCalls] 를 깔아줍니다.
 */
async function sol7() {
  console.log("\n===== [정답 7] =====");

  const makeAgent = (withDefaultMiddleware: boolean) =>
    createAgent({
      model: MODEL,
      middleware: [
        createFilesystemMiddleware({ backend }),
        createSubAgentMiddleware({
          defaultModel: MODEL,
          ...(withDefaultMiddleware
            ? { defaultMiddleware: [createFilesystemMiddleware({ backend })] }
            : {}),
          subagents: [
            {
              name: "note-taker",
              description: "받은 내용을 /notes.md 에 저장하는 서브에이전트",
              systemPrompt: "받은 내용을 /notes.md 에 write_file 로 저장하세요.",
            },
          ],
          generalPurposeAgent: false,
        }),
      ],
      systemPrompt: "note-taker 서브에이전트에게 task 로 위임하세요.",
    }).withConfig({ recursionLimit: 10000 });

  for (const [label, flag] of [
    ["(a) defaultMiddleware 있음", true],
    ["(b) defaultMiddleware 없음", false],
  ] as const) {
    const agent = makeAgent(flag);
    const r = await agent.invoke({
      messages: [{ role: "user", content: "'배포는 금요일' 을 note-taker 에게 저장시켜줘." }],
    });
    console.log(`\n--- ${label} ---`);
    console.log("모델의 말:", r.messages.at(-1)?.text?.slice(0, 120).replace(/\n/g, " "));
    console.log("실제 files:", Object.keys((r as any).files ?? {})); // ← 이것만 믿는다
  }
}

/* ===== [정답 8] =====
 * 기대 결과 (토큰 수는 매번 다릅니다. 필드명은 결정적입니다):
 *   (a) 1회차: 캐시생성>0, 캐시읽기=0
 *       2회차: 캐시읽기>0            ← 적중
 *   (b) 시각 주입 후: 매 요청 캐시생성만 발생, 캐시읽기=0 으로 붙박이
 *
 * → (b) 에서 cache_read 가 0인 이유: 프롬프트 캐싱은 **접두사가 바이트 단위로 동일**할 때만
 *   적중합니다. 시스템 프롬프트에 매 요청 변하는 타임스탬프가 들어가면 접두사가
 *   달라져 캐시가 전부 무효화됩니다.
 *
 * 중요: 캐시 미스는 공짜가 아닙니다. **캐시 쓰기는 일반 입력보다 비쌉니다.**
 *   즉 캐싱을 켜놓고 접두사를 매번 깨면 캐싱을 안 켠 것보다 **비용이 늘어납니다.**
 *   cache_read 가 계속 0이면 반드시 원인을 찾으세요.
 *
 * 참고: createDeepAgent 가 MemoryMiddleware 를 캐싱 미들웨어보다 **뒤**에 놓는 것이
 *   바로 이 이유입니다 — 메모리 갱신이 프롬프트 캐시를 무효화하지 않게 하려는 의도적 배치.
 *   미들웨어 순서가 곧 성능 설계인 사례입니다.
 */
function makeUsageSpy(label: string) {
  return createMiddleware({
    name: "UsageSpyMiddleware",
    afterModel: async (state) => {
      const u = (state.messages.at(-1) as any)?.usage_metadata;
      if (u) {
        console.log(
          `  [${label}] 입력=${u.input_tokens} ` +
            `캐시생성=${u.input_token_details?.cache_creation ?? 0} ` +
            `캐시읽기=${u.input_token_details?.cache_read ?? 0}`,
        );
      }
      return undefined;
    },
  });
}

async function sol8() {
  console.log("\n===== [정답 8] =====");

  // (a) 정상 — 캐시 적중
  console.log("\n(a) 캐시 정상:");
  const good = await createDeepAgent({
    model: MODEL,
    tools: [saveNote],
    systemPrompt: "당신은 메모 도우미입니다.",
    middleware: [makeUsageSpy("정상")],
  });
  const r1 = await good.invoke({ messages: [{ role: "user", content: "안녕" }] });
  await good.invoke({ messages: [...r1.messages, { role: "user", content: "반가워" }] });

  // (b) 캐시 파괴 — 매 요청 변하는 타임스탬프를 프롬프트에 주입
  console.log("\n(b) 시각 주입으로 캐시 파괴:");
  const cacheBuster = createMiddleware({
    name: "TimestampMiddleware",
    wrapModelCall: async (request, handler) =>
      handler({
        ...request,
        systemMessage: request.systemMessage.concat(
          `\n현재 시각: ${new Date().toISOString()}`, // ← 매 요청 달라진다
        ),
      }),
  });

  const bad = await createDeepAgent({
    model: MODEL,
    tools: [saveNote],
    systemPrompt: "당신은 메모 도우미입니다.",
    middleware: [cacheBuster, makeUsageSpy("파괴")],
  });
  const r2 = await bad.invoke({ messages: [{ role: "user", content: "안녕" }] });
  await bad.invoke({ messages: [...r2.messages, { role: "user", content: "반가워" }] });

  console.log("\n→ (b) 는 2회차에도 캐시읽기가 0입니다. 접두사가 매번 달라졌기 때문입니다.");
}

async function main() {
  await sol1();
  await sol2();
  await sol3();
  await sol4();
  await sol5();
  await sol6();
  await sol7();
  await sol8();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});