はじめに
本記事は「Claude Codeのノウハウをサンプルコードで学ぶ」シリーズの**3本目(サンプルコード編の前半)**です。シリーズ全体は以下の4本構成になっています。
- 入門編 — Claude Codeの全体像とAIエージェントの仕組みを体感する
- 中級編 — エージェント設計の考え方を概念レベルで解説する
- ハーネス設計8パターン編(本記事) — 設計パターンを動くTypeScriptで再現する
- エージェント実装深掘り編 — 統合エージェントの実装詳細を読み解く
本記事は、Qiitaの「Claude Code流出から学ぶハーネスパターン10選」を下敷きに、LLMエージェントを自作する際に実装可能な8つのパターンを、1ファイル完結で動くTypeScriptサンプルと一緒に深掘りしていきます。
各サンプルは npx ts-node <file>.ts だけで動き、外部APIキーも不要です。パターンの核を最小サイズで切り出してあるので、コピーして改造しながら理解を深めていただけると思います。
本記事に掲載するコードは、公開情報をもとにパターンの構造を再現したオリジナルの教育用最小実装です。Claude Codeのソースコードそのものではなく、同じ設計思想を理解するために著者が独立に書き起こしたものであり、MITライセンスで自由に改変・利用していただけます。
元記事で挙げられている「反蒸留メカニズム」「ネイティブクライアント認証」の2つは、サーバー側実装またはネイティブバイナリ層に依存する性質上、クライアントサイドのTypeScriptサンプルでは本質を再現できません。本記事では対象外とします。
サンプルコード置き場
本記事の8つのサンプルは、以下のGitHubリポジトリにも配置しています。手元で動かしながら読み進めたい方は、クローンしてご利用ください。
- リポジトリ: nogataka/ts-claude-code-patterns-tutorial
- 対象ディレクトリ: examples/harness-patterns
より発展的な実装(状態マシン型ストリーミングループ・並列ツール実行・多段圧縮などを統合したエージェント)を見たい方は、実装編の記事と、同リポジトリの examples/mini-agent もあわせてご覧ください。
動作環境
- Node.js v20.18.1 以上
- TypeScript 5.x
-
npm install -D typescript ts-node @types/nodeを初回のみ実行
全体像
本記事で扱うパターンの関係を図にまとめます。
目次
- ハーネスパラダイム — モデルと制御層の分離
- ツールコントラクト — 全ツール統一インターフェース
- クエリエンジン — 状態マシンによる会話ループ
- パーミッションシステム — 多層の安全チェック
- コンテキストエントロピー管理 — 3層メモリアーキテクチャ
- プロンプトキャッシュ最適化 — 静的/動的分離
- フラストレーション検出 — 感情の軽量検出
- アンダーカバーモード — 実行環境適応
1. ハーネスパラダイム
概要
LLM本体は「賢い予測器」ですが、安全性・リソース管理・長時間実行の安定性といった領域は、モデルそのものには期待できない部分です。Claude Codeのようなエージェントでは、これらを**モデルの外側に置いた制御層(ハーネス)**で解決しています。
なぜ必要か
賢いモデルほどprompt injectionに弱く、暴走したときのコストも大きくなります。賢さと安全は別レイヤで担保すべきというのが、ハーネスパラダイムの核です。モデルをより賢いものに差し替えても、ハーネス層のガードはそのまま効き続けます。
動くサンプルコード
01_harness.ts
// $ npx ts-node 01_harness.ts
// 「賢いが暴走するモデル」と「地味だが守るハーネス」の対比を示すサンプル
type ModelResponse = { text: string; suggestedCommand?: string }
// (1) 裸のモデル(危険なコマンドも平気で提案する)
function naiveModel(prompt: string): ModelResponse {
if (prompt.includes('クリーンアップ')) {
return { text: '全部消します', suggestedCommand: 'rm -rf /' }
}
return { text: '了解' }
}
// (2) ハーネス(モデルを包んで安全化する)
const DANGEROUS = [/rm\s+-rf\s+\//, /:\(\)\{.*:\|:&\};:/, /mkfs/]
function runWithHarness(prompt: string): { text: string; executed: boolean } {
const response = naiveModel(prompt)
if (!response.suggestedCommand) {
return { text: response.text, executed: false }
}
const blocked = DANGEROUS.some((re) => re.test(response.suggestedCommand!))
if (blocked) {
return {
text: `${response.text}\n[BLOCK] ハーネスがブロック: ${response.suggestedCommand}`,
executed: false,
}
}
console.log(`[EXECUTE] ${response.suggestedCommand}`)
return { text: response.text, executed: true }
}
// 実行
for (const input of ['こんにちは', 'ディスクをクリーンアップして']) {
console.log(`> ${input}`)
console.log(runWithHarness(input))
console.log('---')
}
実行結果
> こんにちは
{ text: '了解', executed: false }
---
> ディスクをクリーンアップして
{ text: '全部消します\n[BLOCK] ハーネスがブロック: rm -rf /', executed: false }
---
ポイント
モデルを差し替えても、ハーネス層(ブロックリスト)は変わりません。「モデルの賢さ」と「システムの安全性」を直交させるのが、ハーネスパラダイムの本質です。
「直交させる」とは、ここでは「お互いに独立させて変更できるようにする」という意味です。モデルの差し替えがハーネスに影響せず、逆にハーネスの変更がモデルに影響しない関係を指します。
2. ツールコントラクト
概要
エージェントが扱う全ツール(Read, Grep, Bashなど)は、同一のインターフェースを実装することが望ましいです。名前・入力スキーマ・実行関数・並列性情報・権限チェックが統一されていれば、ループ側はツール個別の知識を持たずに扱えます。
Claude Codeで採用されている典型的なフィールド構成は、以下のとおりです。
| フィールド | 役割 |
|---|---|
call(input) |
実行本体 |
inputSchema |
Zodなどで宣言した入力スキーマ |
description(input, opts) |
モデルに見せる説明文 |
isReadOnly(input) |
並列実行可否の判断材料 |
isConcurrencySafe(input) |
他ツールと同時実行してよいか |
checkPermissions(input, ctx) |
ツール固有の権限判定 |
動くサンプルコード
02_tool_contract.ts
// $ npx ts-node 02_tool_contract.ts
// 統一ツールインターフェース + 2つの実装
// Zod非依存の最小形でClaude Code流のTool型を再現する
type Parser<I> = { parse: (input: unknown) => I }
type PermissionResult = { behavior: 'allow' | 'deny' | 'ask'; message?: string }
type Tool<I, O> = {
name: string
description: string
inputSchema: Parser<I>
isReadOnly: (input: I) => boolean
isConcurrencySafe: (input: I) => boolean
checkPermissions: (input: I) => Promise<PermissionResult>
call: (input: I) => Promise<O>
}
// (1) Echoツール
const echoTool: Tool<{ text: string }, string> = {
name: 'Echo',
description: 'Echo back the provided text',
inputSchema: {
parse: (input) => {
const v = (input as { text?: unknown } | null)?.text
if (typeof v !== 'string') throw new Error('text must be string')
return { text: v }
},
},
isReadOnly: () => true,
isConcurrencySafe: () => true,
checkPermissions: async () => ({ behavior: 'allow' }),
call: async (input) => `Echo: ${input.text}`,
}
// (2) Addツール
const addTool: Tool<{ a: number; b: number }, number> = {
name: 'Add',
description: 'Return a + b',
inputSchema: {
parse: (input) => {
const i = input as { a?: unknown; b?: unknown } | null
if (typeof i?.a !== 'number' || typeof i?.b !== 'number') {
throw new Error('a, b must be numbers')
}
return { a: i.a, b: i.b }
},
},
isReadOnly: () => true,
isConcurrencySafe: () => true,
checkPermissions: async () => ({ behavior: 'allow' }),
call: async (input) => input.a + input.b,
}
// (3) レジストリ
const registry: Record<string, Tool<any, any>> = {
[echoTool.name]: echoTool,
[addTool.name]: addTool,
}
// (4) 統一呼び出し(parse → checkPermissions → call の順で実行)
async function callTool(name: string, rawInput: unknown): Promise<unknown> {
const tool = registry[name]
if (!tool) throw new Error(`Unknown tool: ${name}`)
const input = tool.inputSchema.parse(rawInput)
const perm = await tool.checkPermissions(input)
if (perm.behavior !== 'allow') throw new Error(`Denied: ${perm.message ?? perm.behavior}`)
return await tool.call(input)
}
// 実行
(async () => {
console.log(await callTool('Echo', { text: 'hello' })) // Echo: hello
console.log(await callTool('Add', { a: 3, b: 4 })) // 7
try {
await callTool('Add', { a: 'bad' })
} catch (e) {
console.log('Validation error:', (e as Error).message)
}
})()
実行結果
Echo: hello
7
Validation error: a, b must be numbers
ポイント
-
inputSchema.parse()で境界で型を絞り込むため、call()の中は純粋に実装に集中できる形になります -
isReadOnly/isConcurrencySafeがあるので、呼び出し側は「並列実行してよいか」を判断できます - 新ツールの追加は
registryに登録するだけで、ループ側は無改修で対応可能です
Zodを使う実務コードでは、inputSchema に z.object({ text: z.string() }) のようなスキーマを置きます。サンプルでは依存ゼロにするため、parse だけを持つ最小の Parser 型で代用しています。
3. クエリエンジン
概要
ユーザー・モデル・ツールの三者間の会話ライフサイクルを管理するのがクエリエンジンです。単なるwhileループではなく状態マシンとして設計することで、コンテキスト溢れや出力切れを段階的に回復していきます。
典型的な状態遷移(transition)は、以下のようなものです。
| transition名 | 発生条件 | 回復アクション |
|---|---|---|
collapse_drain_retry |
コンテキスト圧縮が必要 | 古い履歴を圧縮してリトライ |
reactive_compact_retry |
フルコンパクションが必要 | LLMで要約してリトライ |
max_output_tokens_escalate |
出力トークン上限到達 | 上限を引き上げてリトライ |
動くサンプルコード
03_query_engine.ts
// $ npx ts-node 03_query_engine.ts
// 状態マシンによる簡易クエリエンジン
type Turn = { role: 'user' | 'assistant'; text: string }
type Transition = 'retry_compact' | 'retry_escalate' | 'done'
// モックモデル: 呼び出し回数によって異なるエラーを返す
let callCount = 0
async function mockModel(history: Turn[]): Promise<{ text: string; error?: 'too_long' | 'truncated' }> {
callCount++
const tokens = JSON.stringify(history).length
if (tokens > 500 && callCount === 1) return { text: '', error: 'too_long' }
if (callCount === 2) return { text: '途中で切れた...', error: 'truncated' }
return { text: '完全な回答です' }
}
// 回復アクション
function compactHistory(history: Turn[]): Turn[] {
return history.slice(-2) // 最新2件だけ残す
}
function escalateMaxTokens(current: number): number {
return Math.min(current * 2, 64000)
}
// 状態マシンループ
async function runQueryEngine(userPrompt: string): Promise<string> {
let history: Turn[] = [{ role: 'user', text: userPrompt }]
let maxTokens = 4000
let retries = 0
const maxRetries = 5
while (retries++ < maxRetries) {
// 長い履歴を作って too_long を誘発
if (retries === 1) history = Array(10).fill({ role: 'user', text: 'padding'.repeat(10) })
const resp = await mockModel(history)
let transition: Transition
if (resp.error === 'too_long') transition = 'retry_compact'
else if (resp.error === 'truncated') transition = 'retry_escalate'
else transition = 'done'
console.log(`[turn ${retries}] tokens=${maxTokens} transition=${transition}`)
switch (transition) {
case 'retry_compact':
history = compactHistory(history)
continue
case 'retry_escalate':
maxTokens = escalateMaxTokens(maxTokens)
continue
case 'done':
return resp.text
}
}
return '[max retries exhausted]'
}
// 実行
runQueryEngine('あいさつして').then((result) => console.log('FINAL:', result))
実行結果
[turn 1] tokens=4000 transition=retry_compact
[turn 2] tokens=4000 transition=retry_escalate
[turn 3] tokens=8000 transition=done
FINAL: 完全な回答です
ポイント
素朴なReActループでは、too_long や truncated が発生した時点でそのまま失敗してしまいます。状態マシンで段階的に回復する設計にすることで、長時間の実行に耐えるエージェントに仕上がります。
4. パーミッションシステム
概要
「このツール実行を許可してよいか」を複数レイヤで判定する仕組みです。
まずClaude Code本体の挙動を整理します。公式ドキュメントによれば、bypassPermissions モードは権限プロンプトと安全チェックを広くスキップし、例外として保護パス(.git/, .claude/, シェル設定など)への書き込みだけは必ず確認を求めます。つまりbypassを有効にすると、通常のallow/deny/askルールは評価をスキップされる、というのが実際の挙動です。
一方、自分でエージェントを作る場合は「bypassでも deny だけは効かせたい」「事故防止を優先したい」といった要求が出てくることもあります。以下のサンプルでは、そうしたClaude Code本体よりやや厳しい多層評価パイプラインを学習目的で実装します。評価順は以下のとおりです。
- Denyルール(強制拒否)— ここでdenyなら必ず拒否
- Askルール(強制確認)— ここでaskなら必ず聞く
- ツール固有
checkPermissions()— ツール実装側の判断 - 要ユーザー対話フラグ —
requiresUserInteraction()等で常に聞くべき操作 - パスの安全チェック(
.git/,.claude/, shell configsなどへの書き込み)— 保護パスへの書き込みは要確認 -
bypassPermissionsモードチェック — ここで短絡して許可 - Always-allowルール — 明示的に許可されたツール
- 残ったものはユーザーに対話的に確認
この「deny/ask/safety を bypass より先に評価する」設計は自作エージェント用の防御的バリエーションです。Claude Code本体の bypassPermissions は、保護パスへの書き込み以外は権限層全体をスキップします。記事を参考に自作する際、どちらの方針を採るかは用途に応じて選択してください。
動くサンプルコード
04_permission.ts
// $ npx ts-node 04_permission.ts
// 多層の権限評価パイプライン
// 重要: bypassモードはdeny/ask/safetyより後に評価される(全スキップではない)
type Mode = 'default' | 'auto' | 'bypass'
type Verdict = 'allow' | 'deny' | 'ask'
type PermissionRequest = { tool: string; input: Record<string, unknown> }
const denyList = ['Bash:rm -rf *']
const alwaysAskList = ['Bash:npm publish *']
const alwaysAllowList = ['Read:*', 'Grep:*']
const UNSAFE_PATHS = ['.git/', '.claude/', '.vscode/']
function matchGlob(pattern: string, key: string): boolean {
const re = new RegExp('^' + pattern.replace(/[.+^$()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$')
return re.test(key)
}
const keyOf = (r: PermissionRequest) => `${r.tool}:${JSON.stringify(r.input)}`
// レイヤ1: deny(bypassでも効く強制拒否)
function denyCheck(req: PermissionRequest): Verdict | null {
return denyList.some((p) => matchGlob(p, keyOf(req))) ? 'deny' : null
}
// レイヤ2: 強制ask(bypassでも効く)
function alwaysAskCheck(req: PermissionRequest): Verdict | null {
return alwaysAskList.some((p) => matchGlob(p, keyOf(req))) ? 'ask' : null
}
// レイヤ3: ツール固有の危険判定
function toolSpecificCheck(req: PermissionRequest): Verdict | null {
if (req.tool !== 'Bash') return null
const cmd = String(req.input.command ?? '')
if (/rm\s+-rf\s+\//.test(cmd) || /mkfs/.test(cmd)) return 'deny'
if (/^(ls|cat|echo|pwd)/.test(cmd)) return 'allow'
return null
}
// レイヤ4: パスの安全チェック
// Claude Code本体は「保護パスへの書き込み」のみbypassでも確認する設計。
// 本サンプルは学習目的で、読み取りも含めてやや厳しめに扱う。
const WRITE_TOOLS = new Set(['Write', 'Edit', 'Bash'])
function pathSafetyCheck(req: PermissionRequest): Verdict | null {
const targetPath = String(req.input.file_path ?? req.input.path ?? '')
if (!UNSAFE_PATHS.some((p) => targetPath.includes(p))) return null
return WRITE_TOOLS.has(req.tool) ? 'ask' : null
}
// レイヤ5: bypassモード(上で拒否されなければallow)
function bypassCheck(mode: Mode): Verdict | null {
return mode === 'bypass' ? 'allow' : null
}
// レイヤ6: always-allow
function alwaysAllowCheck(req: PermissionRequest): Verdict | null {
return alwaysAllowList.some((p) => matchGlob(p, keyOf(req))) ? 'allow' : null
}
// レイヤ7: ユーザーに聞く
function askUser(req: PermissionRequest): Verdict {
console.log(`[USER PROMPT] Allow "${req.tool}" with ${JSON.stringify(req.input)}? (simulated: yes)`)
return 'allow'
}
function evaluatePermission(mode: Mode, req: PermissionRequest): Verdict {
return (
denyCheck(req) ??
alwaysAskCheck(req) ??
toolSpecificCheck(req) ??
pathSafetyCheck(req) ??
bypassCheck(mode) ??
alwaysAllowCheck(req) ??
askUser(req)
)
}
// 実行
const tests: Array<[Mode, PermissionRequest]> = [
['default', { tool: 'Read', input: { file_path: '/etc/hosts' } }],
['default', { tool: 'Bash', input: { command: 'rm -rf /tmp' } }],
['default', { tool: 'Bash', input: { command: 'ls /' } }],
['bypass', { tool: 'Bash', input: { command: 'rm -rf /tmp' } }], // bypassでもdeny勝ち
['bypass', { tool: 'Edit', input: { file_path: './.git/config' } }], // 保護パスへの書き込みは確認
['bypass', { tool: 'Read', input: { file_path: './.git/config' } }], // Readはbypassで通過
['bypass', { tool: 'Bash', input: { command: 'npm install' } }], // 何も引っかからなければbypassでallow
]
for (const [mode, req] of tests) {
console.log(`[${mode}] ${req.tool}(${JSON.stringify(req.input)}) -> ${evaluatePermission(mode, req)}`)
}
実行結果
[default] Read({"file_path":"/etc/hosts"}) -> allow
[default] Bash({"command":"rm -rf /tmp"}) -> deny
[default] Bash({"command":"ls /"}) -> allow
[bypass] Bash({"command":"rm -rf /tmp"}) -> deny
[bypass] Edit({"file_path":"./.git/config"}) -> ask
[bypass] Read({"file_path":"./.git/config"}) -> allow
[USER PROMPT] Allow "Bash" with {"command":"npm install"}? (simulated: yes)
[bypass] Bash({"command":"npm install"}) -> allow
ポイント
- 本サンプルではdenyを最優先で評価するため、自作エージェントで「絶対に実行してはいけない操作」をdenyリストで止める発想を学べます(Claude Code本体の
bypassPermissionsはこれを行わず、権限層全体をスキップします) -
.git/や.claude/といった壊すと取り返しのつかないパスへの書き込みは、Claude Code本体でもbypass時に確認が入ります - レイヤを増やしても短絡評価(
??演算子での早期return)なので、該当しないレイヤのコストはゼロに近くなります
5. コンテキストエントロピー管理
概要
長い会話では、重要度の低い情報でcontext windowが埋まってしまいます。エージェントは情報を寿命の異なる3層に分けて管理することで、これを防いでいます。
| 層 | 寿命 | 用途 | 更新タイミング |
|---|---|---|---|
| 短期 | 現在のターン | ツール出力・思考 | 毎ターン |
| セッション | 現在のセッション | TODO・進行状況の要約 | バックグラウンドで定期更新 |
| 長期 | プロジェクト横断 | 設計方針・禁止事項 | 手動(CLAUDE.md等) |
Claude Codeのセッションメモリは、バックグラウンドでフォークされたサブエージェントが会話からキー情報を抽出し、Markdownファイルに書き戻す仕組みになっています。プロンプトにはポインタ(ファイル参照)だけを載せ、本文はモデルが必要なときにReadツールで取りに行く設計です。
動くサンプルコード
05_memory_layers.ts
// $ npx ts-node 05_memory_layers.ts
// 3層メモリの「ポインタ指向」プロンプト構築
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
type Entry = { key: string; value: string; createdAt: number }
class ShortTermMemory {
private entries: Entry[] = []
add(key: string, value: string) {
this.entries.push({ key, value, createdAt: Date.now() })
if (this.entries.length > 20) this.entries.shift()
}
render(): string {
return this.entries.map((e) => `[${e.key}] ${e.value}`).join('\n')
}
}
// セッションメモリはファイル背書き(バックグラウンドで更新される想定)
class SessionMemoryFile {
constructor(private filePath: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, '# Session Memory\n\n')
}
appendTopic(topic: string, content: string) {
fs.appendFileSync(this.filePath, `\n## ${topic}\n${content}\n`)
}
pointer(): string {
const size = fs.statSync(this.filePath).size
return `[SessionMemory @ ${this.filePath} (${size} bytes). Read this file when you need session context.]`
}
}
// 長期メモリ(CLAUDE.mdのようなMarkdownファイル)
class LongTermMemoryFile {
constructor(private filePath: string) {}
pointer(): string {
return `[LongTermMemory @ ${this.filePath}. Read this file for project-level policies.]`
}
}
function estimateContextTokens(promptParts: string[]): number {
const total = promptParts.join('\n').length
return Math.ceil(total / 4)
}
// 実行
const sessionFile = path.join(os.tmpdir(), `session-${Date.now()}.md`)
const short = new ShortTermMemory()
const session = new SessionMemoryFile(sessionFile)
const long = new LongTermMemoryFile('./CLAUDE.md')
// 短期には今ターンのツール出力を入れる
short.add('Read:pkg', '{"name":"demo"}')
short.add('Grep:TODO', '3 matches')
// セッションメモリは別プロセス(サブエージェント)が書く想定
session.appendTopic('Current task', '認証機能を追加中')
session.appendTopic('Decisions', 'JWT + bcryptを採用')
// プロンプトに載せるのは「本文 or ポインタ」の組み合わせ
const promptParts = [
'# Task\n認証を追加',
'# Short-term (本文)\n' + short.render(),
'# Session (ポインタのみ)\n' + session.pointer(),
'# Long-term (ポインタのみ)\n' + long.pointer(),
]
console.log(promptParts.join('\n\n'))
console.log(`\n推定トークン数: ${estimateContextTokens(promptParts)}`)
// クリーンアップ
fs.unlinkSync(sessionFile)
実行結果
session-<timestamp>.md のパスはOSのtmpdirに依存するため、推定トークン数は環境によって前後します。
# Task
認証を追加
# Short-term (本文)
[Read:pkg] {"name":"demo"}
[Grep:TODO] 3 matches
# Session (ポインタのみ)
[SessionMemory @ /var/folders/.../T/session-<ts>.md (97 bytes). Read this file when you need session context.]
# Long-term (ポインタのみ)
[LongTermMemory @ ./CLAUDE.md. Read this file for project-level policies.]
推定トークン数: 86
ポイント
- セッションメモリを本文ではなくポインタで渡すことで、会話が長引いてもcontextを圧迫しません
- モデルが必要と判断した時だけReadツールでファイルをフェッチするため、必要な部分だけcontextに入ります
- バックグラウンド更新にするのは、ユーザーの体感速度を落とさないためです
6. プロンプトキャッシュ最適化
概要
Anthropic APIのプロンプトキャッシュは、プレフィックスが同一のリクエストの入力トークン課金を90%削減します(cache readは通常レートの0.1倍)。これを最大化するため、システムプロンプトを「変わらない部分」と「毎ターン変わる部分」に分離します。さらに、キャッシュが壊れた際に原因を検出・診断する仕組みを持つのがClaude Code流です。
参考資料として、Anthropic公式: Prompt caching もあわせて参照してみてください。
動くサンプルコード
06_prompt_cache.ts
// $ npx ts-node 06_prompt_cache.ts
// 静的/動的分離 + キャッシュブレイク検出
import * as crypto from 'crypto'
const DYNAMIC_BOUNDARY = '__DYNAMIC_BOUNDARY__'
function buildSystemPrompt(staticPart: string, dynamic: string): string {
return `${staticPart}\n${DYNAMIC_BOUNDARY}\n${dynamic}`
}
function splitForCache(prompt: string): { cached: string; uncached: string } {
const idx = prompt.indexOf(DYNAMIC_BOUNDARY)
return idx === -1
? { cached: prompt, uncached: '' }
: { cached: prompt.slice(0, idx), uncached: prompt.slice(idx + DYNAMIC_BOUNDARY.length) }
}
const sha = (s: string) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 8)
type Usage = { cache_read_input_tokens: number }
class CacheBreakDetector {
private prev?: { staticHash: string; cacheReadTokens: number }
record(cached: string, usage: Usage): string {
const staticHash = sha(cached)
if (this.prev) {
const drop = this.prev.cacheReadTokens - usage.cache_read_input_tokens
const ratio = drop / this.prev.cacheReadTokens
if (drop > 2000 && ratio > 0.05) {
const cause =
this.prev.staticHash !== staticHash ? 'static_prompt_changed' : 'unknown'
this.prev = { staticHash, cacheReadTokens: usage.cache_read_input_tokens }
return `BREAK detected: dropped=${drop}, cause=${cause}`
}
}
this.prev = { staticHash, cacheReadTokens: usage.cache_read_input_tokens }
return 'OK'
}
}
const detector = new CacheBreakDetector()
// ターン1: 初回
let prompt = buildSystemPrompt('# Instructions\nYou are helpful.', `time=T+0`)
let { cached } = splitForCache(prompt)
console.log('Turn 1:', detector.record(cached, { cache_read_input_tokens: 50000 }))
// ターン2: 動的部分だけ変える → キャッシュ維持
prompt = buildSystemPrompt('# Instructions\nYou are helpful.', `time=T+1`)
cached = splitForCache(prompt).cached
console.log('Turn 2:', detector.record(cached, { cache_read_input_tokens: 50000 }))
// ターン3: 静的部分を変える → キャッシュブレイク
prompt = buildSystemPrompt('# Instructions\nYou are EXTRA helpful.', `time=T+2`)
cached = splitForCache(prompt).cached
console.log('Turn 3:', detector.record(cached, { cache_read_input_tokens: 10000 }))
実行結果
Turn 1: OK
Turn 2: OK
Turn 3: BREAK detected: dropped=40000, cause=static_prompt_changed
ポイント
-
DYNAMIC_BOUNDARY以降は毎回変えてもOK(キャッシュ対象外)です - 境界より前を変えると即キャッシュブレイクとなり、コストが10倍になります
- 検出器を回しておくと、「なぜ急に遅い/高い?」の原因が自動特定できます
キャッシュブレイクはユーザーに見えにくい問題です。usage.cache_read_input_tokens を毎リクエスト記録して、ダッシュボードで可視化することをおすすめします。
7. フラストレーション検出
概要
ユーザーが怒っている・混乱している状態を軽量な特徴検出で捉え、応答戦略やUIを切り替えるパターンです。LLMを追加で呼ばないので、遅延もコストも増えません。Claude Codeでは「検出後に会話をサポートチームに共有するか確認する」といったUX上のフックに使われている模様です。
具体的な検出アルゴリズム(正規表現のパターン)の実装は、公開情報からは確認できていません。以下のサンプルはこのパターンを自前で作る場合の一例として書いた再現例です。実運用では自社のコーパスに合わせて調整していただくのがよいと思います。
動くサンプルコード
07_frustration.ts
// $ npx ts-node 07_frustration.ts
// 正規表現ベースのフラストレーション検出 + 応答戦略切替(自作例)
type FrustrationLevel = 'none' | 'mild' | 'high'
const HIGH_PATTERNS = [
/違う/i, /そうじゃない/i, /何度言え/i, /やり直/i,
/同じこと/i, /できてない/i, /うまくいかない/i,
/無理/i, /諦め/i,
]
const MILD_PATTERNS = [
/うーん/i, /ちょっと/i, /本当\?/i, /これで合ってる/i,
]
function detect(recentMessages: string[]): FrustrationLevel {
const text = recentMessages.slice(-5).join(' ')
const high = HIGH_PATTERNS.filter((re) => re.test(text)).length
const mild = MILD_PATTERNS.filter((re) => re.test(text)).length
if (high >= 2) return 'high'
if (high >= 1 || mild >= 2) return 'mild'
return 'none'
}
function respond(userMsg: string, history: string[]): string {
const level = detect([...history, userMsg])
switch (level) {
case 'high':
return '[戦略: 謝罪 + やり直し提案]\n申し訳ありません。いったん手を止めて、何をしたいか最初から整理させてください。'
case 'mild':
return '[戦略: 確認質問を増やす]\n念のため確認させてください: <要点の再確認>'
case 'none':
return '[戦略: 通常応答]\n承知しました。'
}
}
const conversations: Array<[string[], string]> = [
[[], 'ファイルを読んで'],
[['それ違う', 'そうじゃない'], 'だから何度言えばわかる'],
[['うーん'], 'これで合ってる?'],
]
for (const [history, msg] of conversations) {
console.log('> history:', history)
console.log('> user :', msg)
console.log(respond(msg, history))
console.log('---')
}
実行結果
> history: []
> user : ファイルを読んで
[戦略: 通常応答]
承知しました。
---
> history: [ 'それ違う', 'そうじゃない' ]
> user : だから何度言えばわかる
[戦略: 謝罪 + やり直し提案]
申し訳ありません。いったん手を止めて、何をしたいか最初から整理させてください。
---
> history: [ 'うーん' ]
> user : これで合ってる?
[戦略: 確認質問を増やす]
念のため確認させてください: <要点の再確認>
---
ポイント
- 軽い検出が大事です。LLM呼び出しを挟むと毎ターン遅延が増えますが、regexなら0.1ms程度で済みます
- 誤検出はある程度許容します。「念のため丁寧になる」だけならコストは低く抑えられます
- より洗練させたい場合は、軽量な感情分類モデル(例: 多言語sentimentのONNXモデル)を埋め込む選択肢もあります
8. アンダーカバーモード
概要
ビルドの種別や実行環境によって、プロンプト・応答・コミットメッセージから特定の情報(内部モデルのコードネームや組織名など)を漏らさないようにするモードです。
このパターン名「アンダーカバーモード」は、Claude Codeの流出コード解析記事(元記事)で紹介された設計アイデアに基づきます。現行バージョン(Claude Code 2.1.116)の公式環境変数一覧 CLAUDE_CODE_* に CLAUDE_CODE_UNDERCOVER は見当たらず、公式ドキュメント化された機能としては確認できません。あくまで自作エージェントに応用できる設計パターンとしてご覧ください。
自分でエージェントを作る際には、CI環境・OSS寄稿モード・デモ用匿名モードなど、用途ごとに振る舞いを切り替えるパターンとして応用できます。以下のサンプルは、そうした切替ロジックの雛形です。
動くサンプルコード
08_undercover.ts
// $ npx ts-node 08_undercover.ts
// 環境変数による振る舞い切替 + ブランド言及の除去
type Env = Record<string, string | undefined>
function isUndercover(env: Env = process.env): boolean {
// 強制ON
if (env.CLAUDE_CODE_UNDERCOVER === '1' || env.CLAUDE_CODE_UNDERCOVER === 'true') return true
// AUTO: 内部repoであると確認できなければON("安全側"に倒す設計)
// ここでは簡略化: GIT_REMOTE_CLASS=internalならOFF、それ以外はON
return env.GIT_REMOTE_CLASS !== 'internal'
}
const BASE_PROMPT = 'あなたはClaudeです。Anthropicによって訓練されました。'
function buildSystemPrompt(env: Env = process.env): string {
if (isUndercover(env)) {
return 'あなたは有能なコーディングアシスタントです。所属や内部名は明かさないでください。'
}
return BASE_PROMPT
}
function redactBrand(text: string, env: Env = process.env): string {
if (!isUndercover(env)) return text
return text
.replace(/Claude/gi, 'アシスタント')
.replace(/Anthropic/gi, 'プロバイダ')
}
function buildToolPrompt(tool: string, env: Env = process.env): string {
const brand = isUndercover(env) ? 'アシスタント' : 'Claude'
return `${brand} が ${tool} ツールを呼び出します。`
}
function demo(label: string, env: Env) {
console.log(`=== ${label} ===`)
console.log('system:', buildSystemPrompt(env))
console.log('tool :', buildToolPrompt('Bash', env))
console.log('redact:', redactBrand('こんにちは、私はClaudeです。Anthropicが作りました。', env))
console.log('')
}
demo('Internal build (undercover OFF)', { GIT_REMOTE_CLASS: 'internal' })
demo('External build (undercover AUTO-ON)', { GIT_REMOTE_CLASS: 'external' })
demo('Force ON via env', { CLAUDE_CODE_UNDERCOVER: '1' })
実行結果
=== Internal build (undercover OFF) ===
system: あなたはClaudeです。Anthropicによって訓練されました。
tool : Claude が Bash ツールを呼び出します。
redact: こんにちは、私はClaudeです。Anthropicが作りました。
=== External build (undercover AUTO-ON) ===
system: あなたは有能なコーディングアシスタントです。所属や内部名は明かさないでください。
tool : アシスタント が Bash ツールを呼び出します。
redact: こんにちは、私は アシスタント です。プロバイダ が作りました。
=== Force ON via env ===
system: あなたは有能なコーディングアシスタントです。所属や内部名は明かさないでください。
tool : アシスタント が Bash ツールを呼び出します。
redact: こんにちは、私は アシスタント です。プロバイダ が作りました。
ポイント
- 切替ロジックを一箇所に集約(
isUndercover()関数)すれば、UI・プロンプト・ログ全てで一貫した振る舞いになります - Default Safe: 「内部環境であると確認できなければON」にすることで、万一の情報漏洩を予防できます
- 強制OFFを用意しないのがポイントです。ユーザーが誤って内部名を公開repoにコミットするのを防ぐ目的があります
まとめ
8つのパターンと対応サンプルを一覧にまとめます。
| # | パターン | サンプル | 学ぶ要点 |
|---|---|---|---|
| 1 | ハーネスパラダイム | 01_harness.ts |
賢さと安全の直交 |
| 2 | ツールコントラクト | 02_tool_contract.ts |
inputSchema.parse + checkPermissions + call の分離 |
| 3 | クエリエンジン | 03_query_engine.ts |
状態マシンによる段階的回復 |
| 4 | パーミッション | 04_permission.ts |
多層短絡評価とbypassの正しい位置づけ |
| 5 | 3層メモリ | 05_memory_layers.ts |
ポインタ参照でcontextを節約 |
| 6 | プロンプトキャッシュ | 06_prompt_cache.ts |
静的/動的境界とブレイク検出 |
| 7 | フラストレーション検出 | 07_frustration.ts |
軽量な特徴検出で応答戦略切替 |
| 8 | アンダーカバー | 08_undercover.ts |
環境フラグで安全側に倒す |
実行方法
mkdir harness-samples && cd harness-samples
npm init -y
npm install -D typescript ts-node @types/node
# 各ファイルをコピーしてから
npx ts-node 01_harness.ts
npx ts-node 02_tool_contract.ts
# ... 以下同様
いずれも1ファイル完結で、外部APIキー不要で動作します。
次の一歩
各パターンをもう少し深く追う場合の調べ方を、3点ほど挙げておきます。
- ループと圧縮: Anthropicが公式に出している Prompt caching docs が一次情報です
-
権限モデル: OSレベルのサンドボックス(macOSの
sandbox-exec、Linuxのseccomp等)と組み合わせると、クライアント側だけの防御より堅牢になります - メモリ階層: RAG/Vector DBを「長期」に、キー・バリューストアを「セッション」に当てはめると、3層設計がそのまま汎用エージェントに適用できます
次回は、このパターンをさらに踏み込んで「動くミニエージェント」として統合する実装詳細を解説する記事を予定しています。小さく作って動かしながら、ご自身のエージェントに合う形へ拡張していってみてください。