feat: refresh course through workflow and goal loops

This commit is contained in:
Haoran
2026-07-30 19:14:04 +08:00
parent 2dd1852d9e
commit cb8fae1bdd
125 changed files with 10882 additions and 7661 deletions

View File

@@ -1,255 +0,0 @@
# s10: System Prompt — Assembled at Runtime, Never Hardcoded
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20
> *"prompt is assembled, not hardcoded"* — Sections + on-demand assembly + caching.
>
> **Harness Layer**: Prompt — assembled at runtime, never hardcoded.
---
## The Problem
From s01 to s09, the system prompt was always one hardcoded line:
```python
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks."
```
That worked for s01 — only bash, read, write. But by s09, the agent has memory, compression, skill loading. The prompt needs to describe more and more capabilities:
```python
SYSTEM = (
f"You are a coding agent at {WORKDIR}. "
"Use tools to solve tasks. Act, don't explain. "
"Before starting any multi-step task, use todo_write. "
"Skills are available via list_skills and load_skill. "
"Relevant memories are injected below when available. "
# ... add a capability, add a line
)
```
Three problems:
1. **Switching projects requires rewriting the entire prompt** — no way to know what to change and what to keep
2. **One change can break others** — adding a tool description might conflict with earlier instructions
3. **Every request carries everything** — even when the current conversation doesn't need certain sections, they waste tokens
The system prompt should be a configuration assembled at runtime based on current state: which tools are enabled, which context is visible, which memories are relevant, and which content must remain stable to hit prompt cache.
---
## The Solution
![System Prompt Overview](images/system-prompt-overview.en.svg)
s10 focuses on prompt assembly. It builds on the s08-s09 capabilities but doesn't re-implement compression or memory. The core change: split the hardcoded `SYSTEM` into independent sections, assemble them at runtime based on real state, and cache the result.
Four sections, two loading strategies:
| Section | Strategy | Content | Condition |
|---------|----------|---------|-----------|
| identity | always | who you are, how to work | always present |
| tools | always | available tool list | `enabled_tools` |
| workspace | always | working directory | always present |
| memory | on-demand | relevant memory content | whether `.memory/MEMORY.md` exists |
Key design: whether a section loads depends on real state (tools exist, files exist), not keywords in messages.
---
## How It Works
### PROMPT_SECTIONS: Topic-Keyed Fragments
Split the monolithic string into a dictionary, each key is a topic:
```python
PROMPT_SECTIONS = {
"identity": "You are a coding agent. Act, don't explain.",
}
```
Each section is maintained independently. Changing `tools` doesn't affect `identity`; adding `memory` doesn't touch `workspace`.
### assemble_system_prompt: On-Demand Assembly
Not every section is needed every turn. No memory files? Loading the memory section just wastes tokens. Assembly is based on real state in context:
```python
def assemble_system_prompt(context: dict) -> str:
sections = []
# Always loaded
sections.append(PROMPT_SECTIONS["identity"])
# Dynamic — tools and workspace from context
tools = ", ".join(context.get("enabled_tools", []))
if tools:
sections.append(f"Available tools: {tools}.")
sections.append(f"Working directory: {context.get("workspace", WORKDIR)}")
# On-demand — based on real state, not keywords
memories = context.get("memories", "")
if memories:
sections.append(f"Relevant memories:\n{memories}")
return "\n\n".join(sections)
```
"Always loaded" sections are needed every turn: identity, tools, workspace. "On-demand" sections are only useful under specific conditions.
Why not load everything? Tokens have cost (system prompt is billed every turn), and fewer instructions means more focused output (irrelevant instructions are noise).
### get_system_prompt: Cache to Avoid Re-Assembly
When context hasn't changed (multiple LLM calls in the same turn with the same context), re-assembling is wasteful. Use deterministic serialization to detect changes and return cached result:
```python
def get_system_prompt(context: dict) -> str:
global _last_context_key, _last_prompt
key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)
if key == _last_context_key and _last_prompt:
return _last_prompt
_last_context_key = key
_last_prompt = assemble_system_prompt(context)
return _last_prompt
```
`json.dumps` instead of `hash()`: Python's built-in `hash()` has process randomization (unsuitable for stable cache keys) and throws `unhashable type` on nested dicts/lists.
Note: this cache only avoids redundant string assembly within a process. It's not the same as CC's API prompt cache, which uses `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` to separate static and dynamic parts — the static parts hit global cache and don't invalidate when dynamic content changes.
### context: Real State, Not Keyword Guessing
Context reflects the actual runtime state:
```python
def update_context(context: dict, messages: list) -> dict:
memories = ""
if MEMORY_INDEX.exists():
content = MEMORY_INDEX.read_text().strip()
if content:
memories = content
return {
"enabled_tools": list(TOOL_HANDLERS.keys()),
"workspace": str(WORKDIR),
"memories": memories,
}
```
`enabled_tools` lists actually registered tools. `memories` checks whether `.memory/MEMORY.md` exists. Section loading is based on this real state, not searching for keywords in messages.
### Putting It Together
```python
def agent_loop(messages: list, context: dict):
system = get_system_prompt(context)
while True:
response = client.messages.create(
model=MODEL, system=system, messages=messages,
tools=TOOLS, max_tokens=8000)
# ... tool execution ...
context = update_context(context, messages)
system = get_system_prompt(context)
```
At the start of each loop iteration, get the system prompt. If context changed, re-assemble; if not, return cached version.
---
## Changes From s09
| Component | Before (s09) | After (s10) |
|-----------|-------------|-------------|
| prompt | Hardcoded SYSTEM string | PROMPT_SECTIONS + assemble_system_prompt |
| caching | None | get_system_prompt (json.dumps detection + cache) |
| new functions | — | assemble_system_prompt, get_system_prompt, update_context |
| tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged |
| loop | Uses fixed SYSTEM | Uses get_system_prompt(context) |
---
## Try It
```sh
cd learn-claude-code
python s10_system_prompt/code.py
```
What to watch for:
1. Output shows which sections were loaded (`[assembled] sections: ...` label)
2. Cache hits show `[cache hit]` during continued conversation
3. Creating `.memory/MEMORY.md` makes the memory section appear on the next turn
Try these prompts:
1. `Read the file README.md` (observe the three always-loaded sections)
2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"` (write a memory index)
3. `Read the file code.py` (observe whether the memory section appears)
---
## What's Next
System prompts can now be assembled at runtime. But the agent still crashes on errors. Network hiccups, API rate limits, truncated output, context overflow — these aren't bugs, they're normal.
s11 Error Recovery → four recovery paths. Upgrade tokens, compress context, exponential backoff, switch models.
<details>
<summary>Deep Dive Into CC Source Code</summary>
> The following is based on analysis of CC source code `constants/prompts.ts` (914 lines), `constants/systemPromptSections.ts` (68 lines), `context.ts` (189 lines), `utils/api.ts` (718 lines), `utils/systemPrompt.ts` (123 lines), and `bootstrap/state.ts`.
### How many sections does CC's system prompt have?
The count varies based on feature flags, output style, KAIROS/Proactive mode, user type, token budget, etc. Roughly two categories:
**Static sections** (always loaded): identity, system, doing_tasks, actions, using_tools, tone_style, output_efficiency, etc.
**Dynamic sections** (loaded by state): session_guidance, memory, ant_model_override, env_info_simple, language, output_style, mcp_instructions, scratchpad, frc, summarize_tool_results, numeric_length_anchors, token_budget, brief, etc.
`mcp_instructions` is the only volatile section (created via `DANGEROUS_uncachedSystemPromptSection()`), because MCP servers can connect and disconnect between turns.
### Assembly Function
```typescript
getSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise<string[]>
```
Returns `string[]` (each element is a section), separated by `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` between static and dynamic parts.
### cache scope
When global cache boundary is enabled, static sections are merged into one global cache block, and dynamic sections don't use global cache (`cacheScope: null`). Only paths without boundary or skipping global cache fall back to org scope.
The teaching version's cache only avoids redundant string assembly. CC's three-layer cache:
1. **lodash memoize**: `getSystemContext` and `getUserContext` cached per session (`context.ts`)
2. **Section registry cache**: `STATE.systemPromptSectionCache` caches dynamic section results, cleared on `/clear` or `/compact`
3. **API-level cache**: `splitSysPromptPrefix()` (`api.ts`) splits prompt into blocks with different cache scopes via boundary
### getUserContext vs getSystemContext
| | getSystemContext | getUserContext |
|---|---|---|
| Content | gitStatus, cacheBreaker | CLAUDE.md content, currentDate |
| Injection | appended to system prompt array | prepended as `<system-reminder>` user message |
| When skipped | custom system prompt | always runs |
### How modes change the prompt
- **CLAUDE_CODE_SIMPLE**: entire prompt is 2 lines
- **Proactive/KAIROS**: compact prompt replaces all standard sections
- **Coordinator**: coordinator-specific prompt fully replaces default
- **Agent mode**: agent-defined prompt replaces or appends to default
### Total size
Standard interactive mode system prompt core is ~20-30KB text. CLAUDE_CODE_SIMPLE is ~150 characters. User context (CLAUDE.md) and system context (git status) add on top.
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->

View File

@@ -1,11 +1,11 @@
# s10: System Prompt — 実行時アセンブリ、ハードコードなし
# s10: Context Assembly — 実行時にモデル入力を組み立てる
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20
> *"prompt は組み立てるもの、固定するものではない"* — セグメント + オンデマンド結合 + キャッシュ。
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21 → s22
> *"モデル入力は組み立てるもの、固定するものではない"* — 安定セクション + 実行時状態 + キャッシュ。
>
> **Harness レイヤー**: プロンプト — 実行時組み立て、ハードコードなし
> **Harness レイヤー**: コンテキスト組み立て — 安定した指示と動的状態をモデル入力にまとめる
---
@@ -44,7 +44,7 @@ System prompt は、実行時の現在状態に基づいて組み立てられる
![System Prompt Overview](images/system-prompt-overview.ja.svg)
s10 は prompt アセンブリ機構に焦点を当てる。s08-s09 の能力を背景とするが、圧縮や記憶システムは再実装しない。核心の変更:ハードコードされた `SYSTEM` を独立セクションに分割し、実行時に実際の状態に基づいてオンデマンドで組み立て、結果をキャッシュして再組み立てを回避
s10 はコンテキスト管理とエラー回復をつなぐ短い橋渡しセッションである。新しいストレージを追加せず、s08 と s09 も統合しない。両者の出力がモデル境界でどう合流するかを示す:ハードコードされた `SYSTEM` を独立セクションに分割し、実際の実行時状態から組み立て、結果をキャッシュする
4 つのセクション、2 つの読み込み戦略:
@@ -88,7 +88,7 @@ def assemble_system_prompt(context: dict) -> str:
tools = ", ".join(context.get("enabled_tools", []))
if tools:
sections.append(f"Available tools: {tools}.")
sections.append(f"Working directory: {context.get("workspace", WORKDIR)}")
sections.append(f"Working directory: {context.get('workspace', WORKDIR)}")
# オンデマンド — 実際の状態に基づく、キーワードではない
memories = context.get("memories", "")
@@ -178,6 +178,8 @@ cd learn-claude-code
python s10_system_prompt/code.py
```
安全上の注意:この集中教材はモデルが渡す `bash` 文字列を `shell=True` で実行し、s03 の permission gate を省略している。破棄可能な workspace だけで実行し、本番 harness では permission と sandbox の境界を戻すこと。
観察のポイント:
1. 出力にロードされたセクションが表示される(`[assembled] sections: ...` ラベル)
@@ -194,62 +196,8 @@ python s10_system_prompt/code.py
## 次へ
System prompt を実行時に組み立てられるようになった。しかし Agent はエラーでまだクラッシュする。ネットワークの不安定性、API レート制限、出力の切り詰め、コンテキスト超過、これらはバグではなく日常。
モデル入力を実行時に組み立てられるようになった。しかし Agent はエラーでまだクラッシュする。ネットワークの不安定性、API レート制限、出力の切り詰め、コンテキスト超過、これらはバグではなく日常。
s11 Error Recovery → 4 つのリカバリパス。token のアップグレード、コンテキスト圧縮、指数バックオフ、モデル切り替え。
<details>
<summary>CC ソースコードの詳細</summary>
> 以下は CC ソースコード `constants/prompts.ts`914 行)、`constants/systemPromptSections.ts`68 行)、`context.ts`189 行)、`utils/api.ts`718 行)、`utils/systemPrompt.ts`123 行)、`bootstrap/state.ts` の分析に基づく。
### CC の system prompt にはいくつのセクションがあるか?
数は固定されておらず、feature flag、output style、KAIROS/Proactive モード、ユーザータイプ、token 予算などに影響される。大まかに 2 つのカテゴリ:
**静的セクション**常にロードidentity、system、doing_tasks、actions、using_tools、tone_style、output_efficiency など。
**動的セクション**状態に応じてロードsession_guidance、memory、ant_model_override、env_info_simple、language、output_style、mcp_instructions、scratchpad、frc、summarize_tool_results、numeric_length_anchors、token_budget、brief など。
`mcp_instructions` は唯一の揮発性セクション(`DANGEROUS_uncachedSystemPromptSection()` で作成。MCP server はターン間で接続・切断可能なため。
### 組み立て関数
```typescript
getSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise<string[]>
```
`string[]`(各要素がセクション)を返却。`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` で静的/動的部分を分離。
### cache scope
global cache boundary が有効な場合、静的セクションは 1 つの global cache block にマージされ、動的セクションは global cache を使用しない(`cacheScope: null`。boundary なしまたは global cache をスキップするパスでのみ org scope にフォールバック。
教学版のキャッシュは文字列の再組み立てを回避するのみ。CC の 3 層キャッシュ:
1. **lodash memoize**: `getSystemContext``getUserContext` がセッション中キャッシュ(`context.ts`
2. **セクション登録キャッシュ**: `STATE.systemPromptSectionCache` が動的セクションの結果をキャッシュ、`/clear``/compact` でクリア
3. **API レベルキャッシュ**: `splitSysPromptPrefix()``api.ts`)が boundary を通じて異なる cache scope のブロックに分割
### getUserContext vs getSystemContext
| | getSystemContext | getUserContext |
|---|---|---|
| 内容 | gitStatus、cacheBreaker | CLAUDE.md 内容、currentDate |
| 注入方式 | system prompt 配列に追加 | `<system-reminder>` ユーザーメッセージとして先頭に配置 |
| スキップ条件 | カスタム system prompt 時 | 常に実行 |
### モードによる prompt の変化
- **CLAUDE_CODE_SIMPLE**: prompt 全体が 2 行のみ
- **Proactive/KAIROS**: コンパクト版 prompt が標準セクション全体を置換
- **Coordinator**: コーディネータ専用 prompt がデフォルトを完全に置換
- **Agent モード**: Agent 定義の prompt がデフォルトを置換または追加
### 総サイズ
標準インタラクティブモードの system prompt コアは約 20-30KB テキスト。CLAUDE_CODE_SIMPLE は約 150 文字。ユーザーコンテキストCLAUDE.mdとシステムコンテキストgit statusがこれに加算。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->

View File

@@ -1,23 +1,23 @@
# s10: System Prompt — 运行时组装,不硬编码
# s10: Context Assembly — Build Model Input at Runtime
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20
> *"prompt 是组装出来的, 不是写死的"* — 分段 + 按需拼接 + 缓存。
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21 → s22
> *"Model input is assembled, not hardcoded"* — Stable sections + runtime state + caching.
>
> **Harness 层**: 提示 — 运行时组装, 不硬编码。
> **Harness Layer**: Context assembly — turn stable instructions and dynamic state into model input.
---
## 问题
## The Problem
s01 s09system prompt 都是一行硬编码:
From s01 to s09, the system prompt was always one hardcoded line:
```python
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks."
```
s01 够用,只有 bashreadwrite 三个工具。但到 s09Agent 已经有记忆、有压缩、有技能加载。prompt 该提的能力越来越多:
That worked for s01 — only bash, read, write. But by s09, the agent has memory, compression, skill loading. The prompt needs to describe more and more capabilities:
```python
SYSTEM = (
@@ -26,44 +26,44 @@ SYSTEM = (
"Before starting any multi-step task, use todo_write. "
"Skills are available via list_skills and load_skill. "
"Relevant memories are injected below when available. "
# ... 加一个能力就多一段
# ... add a capability, add a line
)
```
三个问题:
Three problems:
1. **换项目要重写整个 prompt**,不知道哪些该改、哪些该留
2. **修改一处可能影响全局**,加一段工具描述可能跟前面的指令冲突
3. **每次请求都带全部内容**,即使当前对话用不到某些段落也浪费 token
1. **Switching projects requires rewriting the entire prompt** — no way to know what to change and what to keep
2. **One change can break others** — adding a tool description might conflict with earlier instructions
3. **Every request carries everything** — even when the current conversation doesn't need certain sections, they waste tokens
System prompt 应该是运行时根据当前状态组装的配置:哪些工具启用、哪些上下文可见、哪些记忆相关、哪些内容必须保持稳定以命中 prompt cache
The system prompt should be a configuration assembled at runtime based on current state: which tools are enabled, which context is visible, which memories are relevant, and which content must remain stable to hit prompt cache.
---
## 解决方案
## The Solution
![System Prompt Overview](images/system-prompt-overview.svg)
![System Prompt Overview](images/system-prompt-overview.en.svg)
s10 聚焦 prompt 组装机制。以 s08-s09 的能力为背景,但不重复实现压缩和记忆系统。核心变动:把硬编码的 `SYSTEM` 拆成独立段落section运行时根据真实状态按需拼接缓存结果避免重复组装。
s10 is a short bridge between context management and recovery. It does not add another storage system or merge s08 with s09. It shows where their outputs meet the model boundary: split the hardcoded `SYSTEM` into independent sections, assemble them from real runtime state, and cache the result.
四个 section,两种加载策略:
Four sections, two loading strategies:
| Section | 加载策略 | 内容 | 判断依据 |
|---------|---------|------|---------|
| identity | 始终 | 你是谁、怎么做事 | 始终存在 |
| tools | 始终 | 可用工具列表 | `enabled_tools` |
| workspace | 始终 | 工作目录 | 始终存在 |
| memory | 按需 | 相关记忆内容 | `.memory/MEMORY.md` 是否存在 |
| Section | Strategy | Content | Condition |
|---------|----------|---------|-----------|
| identity | always | who you are, how to work | always present |
| tools | always | available tool list | `enabled_tools` |
| workspace | always | working directory | always present |
| memory | on-demand | relevant memory content | whether `.memory/MEMORY.md` exists |
关键设计section 是否加载取决于真实状态(工具是否存在、文件是否存在),不是消息里的关键词。
Key design: whether a section loads depends on real state (tools exist, files exist), not keywords in messages.
---
## 工作原理
## How It Works
### PROMPT_SECTIONS: 分段定义
### PROMPT_SECTIONS: Topic-Keyed Fragments
把一大段字符串拆成字典,每个 key 是一个主题:
Split the monolithic string into a dictionary, each key is a topic:
```python
PROMPT_SECTIONS = {
@@ -71,26 +71,26 @@ PROMPT_SECTIONS = {
}
```
每个 section 独立维护。修改 `tools` 不影响 `identity`,新增 `memory` 不动 `workspace`
Each section is maintained independently. Changing `tools` doesn't affect `identity`; adding `memory` doesn't touch `workspace`.
### assemble_system_prompt: 按需拼接
### assemble_system_prompt: On-Demand Assembly
不是所有 section 每次都需要。当前没有记忆文件,加载 memory section 只是浪费 token。根据 context 的真实状态决定加载哪些:
Not every section is needed every turn. No memory files? Loading the memory section just wastes tokens. Assembly is based on real state in context:
```python
def assemble_system_prompt(context: dict) -> str:
sections = []
# 始终加载
# Always loaded
sections.append(PROMPT_SECTIONS["identity"])
# 从 context 动态获取 tools workspace
# Dynamic — tools and workspace from context
tools = ", ".join(context.get("enabled_tools", []))
if tools:
sections.append(f"Available tools: {tools}.")
sections.append(f"Working directory: {context.get("workspace", WORKDIR)}")
sections.append(f"Working directory: {context.get('workspace', WORKDIR)}")
# 按需加载 — 基于真实状态,不是关键词
# On-demand — based on real state, not keywords
memories = context.get("memories", "")
if memories:
sections.append(f"Relevant memories:\n{memories}")
@@ -98,13 +98,13 @@ def assemble_system_prompt(context: dict) -> str:
return "\n\n".join(sections)
```
"始终加载"的是每轮都需要的:身份、工具、工作目录。"按需加载"的只在特定条件下才有用。
"Always loaded" sections are needed every turn: identity, tools, workspace. "On-demand" sections are only useful under specific conditions.
为什么不全加载token 有成本system prompt 每轮计费),信息越少 LLM 越专注(无关指令是噪音)。
Why not load everything? Tokens have cost (system prompt is billed every turn), and fewer instructions means more focused output (irrelevant instructions are noise).
### get_system_prompt: 缓存避免重复拼接
### get_system_prompt: Cache to Avoid Re-Assembly
上下文没变时(同一轮对话的多次 LLM 调用context 相同),重新拼接是浪费。用确定性序列化检测变化,命中缓存直接返回:
When context hasn't changed (multiple LLM calls in the same turn with the same context), re-assembling is wasteful. Use deterministic serialization to detect changes and return cached result:
```python
def get_system_prompt(context: dict) -> str:
@@ -117,13 +117,13 @@ def get_system_prompt(context: dict) -> str:
return _last_prompt
```
`json.dumps` 而不是 `hash()`Python 内置 `hash()` 有进程随机化,不适合做稳定 cache key而且遇到 list/dict 会报 `unhashable type`
`json.dumps` instead of `hash()`: Python's built-in `hash()` has process randomization (unsuitable for stable cache keys) and throws `unhashable type` on nested dicts/lists.
注意:这里的缓存只是"避免重复拼接字符串",和 CC 的 API prompt cache 不是一回事。CC 的 prompt cache 通过 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分,静态部分命中 global cache不因动态内容变化而失效。
Note: this cache only avoids redundant string assembly within a process. It's not the same as CC's API prompt cache, which uses `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` to separate static and dynamic parts — the static parts hit global cache and don't invalidate when dynamic content changes.
### context: 真实状态,不是关键词猜测
### context: Real State, Not Keyword Guessing
context 反映当前运行态的真实状态:
Context reflects the actual runtime state:
```python
def update_context(context: dict, messages: list) -> dict:
@@ -139,9 +139,9 @@ def update_context(context: dict, messages: list) -> dict:
}
```
`enabled_tools` 列出实际注册的工具。`memories` 检查 `.memory/MEMORY.md` 是否存在。section 加载基于这些真实状态,不在消息里搜关键词。
`enabled_tools` lists actually registered tools. `memories` checks whether `.memory/MEMORY.md` exists. Section loading is based on this real state, not searching for keywords in messages.
### 合起来跑
### Putting It Together
```python
def agent_loop(messages: list, context: dict):
@@ -150,106 +150,54 @@ def agent_loop(messages: list, context: dict):
response = client.messages.create(
model=MODEL, system=system, messages=messages,
tools=TOOLS, max_tokens=8000)
# ... 工具执行 ...
# ... tool execution ...
context = update_context(context, messages)
system = get_system_prompt(context)
```
每轮循环开头拿一次 system promptcontext 变了就重新组装,没变就返回缓存。
At the start of each loop iteration, get the system prompt. If context changed, re-assemble; if not, return cached version.
---
## 相对 s09 的变更
## Changes From s09
| 组件 | 之前 (s09) | 之后 (s10) |
|------|-----------|-----------|
| prompt | 硬编码 SYSTEM 字符串 | PROMPT_SECTIONS + assemble_system_prompt |
| 缓存 | 无 | get_system_promptjson.dumps 检测 + 缓存) |
| 新函数 | — | assemble_system_prompt, get_system_prompt, update_context |
| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |
| 循环 | 用固定 SYSTEM | get_system_prompt(context) |
| Component | Before (s09) | After (s10) |
|-----------|-------------|-------------|
| prompt | Hardcoded SYSTEM string | PROMPT_SECTIONS + assemble_system_prompt |
| caching | None | get_system_prompt (json.dumps detection + cache) |
| new functions | — | assemble_system_prompt, get_system_prompt, update_context |
| tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged |
| loop | Uses fixed SYSTEM | Uses get_system_prompt(context) |
---
## 试一下
## Try It
```sh
cd learn-claude-code
python s10_system_prompt/code.py
```
观察重点:
Safety note: this focused teaching script still executes the model's `bash` string with `shell=True` and omits s03's permission gate. Run it only in a disposable workspace; production harnesses must restore permission and sandbox boundaries.
1. 输出中能看到哪些 section 被加载了(`[assembled] sections: ...` 标签)
2. 连续对话时,缓存命中显示 `[cache hit]`
3. 创建 `.memory/MEMORY.md` 文件后,下一轮 memory section 自动加载
What to watch for:
试试这些 prompt
1. Output shows which sections were loaded (`[assembled] sections: ...` label)
2. Cache hits show `[cache hit]` during continued conversation
3. Creating `.memory/MEMORY.md` makes the memory section appear on the next turn
1. `Read the file README.md`(观察始终加载的三个 section
2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"`(写入记忆索引)
3. `Read the file code.py`(观察 memory section 是否出现)
Try these prompts:
1. `Read the file README.md` (observe the three always-loaded sections)
2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"` (write a memory index)
3. `Read the file code.py` (observe whether the memory section appears)
---
## 接下来
## What's Next
System prompt 可以运行时组装了,但 Agent 碰到错误还是会崩。网络抖动、API 限流、输出被截断、上下文超限,这些不是 bug是常态。
The model input can now be assembled at runtime. But the agent still crashes on errors. Network hiccups, API rate limits, truncated output, context overflow — these aren't bugs, they're normal.
s11 Error Recovery → 四条恢复路径。升级 token、压缩上下文、指数退避、切换模型。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `constants/prompts.ts`914 行)、`constants/systemPromptSections.ts`68 行)、`context.ts`189 行)、`utils/api.ts`718 行)、`utils/systemPrompt.ts`123 行)、`bootstrap/state.ts` 的分析。
### CC 的 system prompt 有多少 section
数量不固定,受 feature flag、output style、KAIROS/Proactive 模式、用户类型、token 预算等影响。大致分两类:
**静态 section**始终加载identity、system、doing_tasks、actions、using_tools、tone_style、output_efficiency 等。
**动态 section**按状态加载session_guidance、memory、ant_model_override、env_info_simple、language、output_style、mcp_instructions、scratchpad、frc、summarize_tool_results、numeric_length_anchors、token_budget、brief 等。
`mcp_instructions` 是唯一的易失性 section通过 `DANGEROUS_uncachedSystemPromptSection()` 创建),因为 MCP server 可以在轮次间连接和断开。
### 组装函数
```typescript
getSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise<string[]>
```
返回 `string[]`(每个元素是一个 section`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分。
### cache scope
启用 global cache boundary 时,静态 section 合并成一个 global cache block动态 section 不使用 global cache`cacheScope: null`)。没有 boundary 或跳过 global cache 的路径才会走 org scope。
教学版的缓存只避免重复拼接字符串。CC 的三层缓存:
1. **lodash memoize**`getSystemContext``getUserContext` 在会话中缓存(`context.ts`
2. **section 注册缓存**`STATE.systemPromptSectionCache` 缓存动态 section 结果,`/clear``/compact` 时清除
3. **API 级缓存**`splitSysPromptPrefix()``api.ts`)把 prompt 按 boundary 分成不同 cache scope 的块
### getUserContext vs getSystemContext
| | getSystemContext | getUserContext |
|---|---|---|
| 内容 | gitStatus、cacheBreaker | CLAUDE.md 内容、currentDate |
| 注入方式 | 追加到 system prompt 数组 | 前置为 `<system-reminder>` 用户消息 |
| 何时跳过 | 自定义 system prompt 时 | 始终运行 |
### 模式如何改变 prompt
- **CLAUDE_CODE_SIMPLE**:整个 prompt 只有 2 行
- **Proactive/KAIROS**:用紧凑版 prompt 替换所有标准 section
- **Coordinator**:用协调器专用 prompt 完全替换
- **Agent 模式**Agent 定义的 prompt 替换或追加到默认 prompt
### 总大小
标准交互模式下 system prompt 核心约 20-30KB 文本。CLAUDE_CODE_SIMPLE 约 150 字符。用户上下文CLAUDE.md和系统上下文git status在此基础上累加。
</details>
s11 Error Recovery → four recovery paths. Upgrade tokens, compress context, exponential backoff, switch models.
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->

View File

@@ -0,0 +1,203 @@
# s10: Context Assembly — 在运行时组装模型输入
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21 → s22
> *"模型输入是组装出来的,不是写死的"* — 稳定分段 + 运行时状态 + 缓存。
>
> **Harness 层**: 上下文组装 — 把稳定指令和动态状态组成模型输入。
---
## 问题
从 s01 到 s09system prompt 都是一行硬编码:
```python
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks."
```
s01 够用,只有 bash、read、write 三个工具。但到 s09Agent 已经有记忆、有压缩、有技能加载。prompt 该提的能力越来越多:
```python
SYSTEM = (
f"You are a coding agent at {WORKDIR}. "
"Use tools to solve tasks. Act, don't explain. "
"Before starting any multi-step task, use todo_write. "
"Skills are available via list_skills and load_skill. "
"Relevant memories are injected below when available. "
# ... 加一个能力就多一段
)
```
三个问题:
1. **换项目要重写整个 prompt**,不知道哪些该改、哪些该留
2. **修改一处可能影响全局**,加一段工具描述可能跟前面的指令冲突
3. **每次请求都带全部内容**,即使当前对话用不到某些段落也浪费 token
System prompt 应该是运行时根据当前状态组装的配置:哪些工具启用、哪些上下文可见、哪些记忆相关、哪些内容必须保持稳定以命中 prompt cache。
---
## 解决方案
![System Prompt Overview](images/system-prompt-overview.svg)
s10 是上下文管理与错误恢复之间的一节短桥接课。它不增加新的存储系统,也不把 s08 与 s09 合并;它只展示两者的输出如何在模型边界汇合:把硬编码的 `SYSTEM` 拆成独立段落section运行时根据真实状态按需拼接并缓存结果。
四个 section两种加载策略
| Section | 加载策略 | 内容 | 判断依据 |
|---------|---------|------|---------|
| identity | 始终 | 你是谁、怎么做事 | 始终存在 |
| tools | 始终 | 可用工具列表 | `enabled_tools` |
| workspace | 始终 | 工作目录 | 始终存在 |
| memory | 按需 | 相关记忆内容 | `.memory/MEMORY.md` 是否存在 |
关键设计section 是否加载取决于真实状态(工具是否存在、文件是否存在),不是消息里的关键词。
---
## 工作原理
### PROMPT_SECTIONS: 分段定义
把一大段字符串拆成字典,每个 key 是一个主题:
```python
PROMPT_SECTIONS = {
"identity": "You are a coding agent. Act, don't explain.",
}
```
每个 section 独立维护。修改 `tools` 不影响 `identity`,新增 `memory` 不动 `workspace`
### assemble_system_prompt: 按需拼接
不是所有 section 每次都需要。当前没有记忆文件,加载 memory section 只是浪费 token。根据 context 的真实状态决定加载哪些:
```python
def assemble_system_prompt(context: dict) -> str:
sections = []
# 始终加载
sections.append(PROMPT_SECTIONS["identity"])
# 从 context 动态获取 tools 和 workspace
tools = ", ".join(context.get("enabled_tools", []))
if tools:
sections.append(f"Available tools: {tools}.")
sections.append(f"Working directory: {context.get('workspace', WORKDIR)}")
# 按需加载 — 基于真实状态,不是关键词
memories = context.get("memories", "")
if memories:
sections.append(f"Relevant memories:\n{memories}")
return "\n\n".join(sections)
```
"始终加载"的是每轮都需要的:身份、工具、工作目录。"按需加载"的只在特定条件下才有用。
为什么不全加载token 有成本system prompt 每轮计费),信息越少 LLM 越专注(无关指令是噪音)。
### get_system_prompt: 缓存避免重复拼接
上下文没变时(同一轮对话的多次 LLM 调用context 相同),重新拼接是浪费。用确定性序列化检测变化,命中缓存直接返回:
```python
def get_system_prompt(context: dict) -> str:
global _last_context_key, _last_prompt
key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)
if key == _last_context_key and _last_prompt:
return _last_prompt
_last_context_key = key
_last_prompt = assemble_system_prompt(context)
return _last_prompt
```
`json.dumps` 而不是 `hash()`Python 内置 `hash()` 有进程随机化,不适合做稳定 cache key而且遇到 list/dict 会报 `unhashable type`
注意:这里的缓存只是"避免重复拼接字符串",和 CC 的 API prompt cache 不是一回事。CC 的 prompt cache 通过 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分,静态部分命中 global cache不因动态内容变化而失效。
### context: 真实状态,不是关键词猜测
context 反映当前运行态的真实状态:
```python
def update_context(context: dict, messages: list) -> dict:
memories = ""
if MEMORY_INDEX.exists():
content = MEMORY_INDEX.read_text().strip()
if content:
memories = content
return {
"enabled_tools": list(TOOL_HANDLERS.keys()),
"workspace": str(WORKDIR),
"memories": memories,
}
```
`enabled_tools` 列出实际注册的工具。`memories` 检查 `.memory/MEMORY.md` 是否存在。section 加载基于这些真实状态,不在消息里搜关键词。
### 合起来跑
```python
def agent_loop(messages: list, context: dict):
system = get_system_prompt(context)
while True:
response = client.messages.create(
model=MODEL, system=system, messages=messages,
tools=TOOLS, max_tokens=8000)
# ... 工具执行 ...
context = update_context(context, messages)
system = get_system_prompt(context)
```
每轮循环开头拿一次 system prompt。context 变了就重新组装,没变就返回缓存。
---
## 相对 s09 的变更
| 组件 | 之前 (s09) | 之后 (s10) |
|------|-----------|-----------|
| prompt | 硬编码 SYSTEM 字符串 | PROMPT_SECTIONS + assemble_system_prompt |
| 缓存 | 无 | get_system_promptjson.dumps 检测 + 缓存) |
| 新函数 | — | assemble_system_prompt, get_system_prompt, update_context |
| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |
| 循环 | 用固定 SYSTEM | 用 get_system_prompt(context) |
---
## 试一下
```sh
cd learn-claude-code
python s10_system_prompt/code.py
```
安全说明:这份聚焦教学脚本仍用 `shell=True` 执行模型给出的 `bash` 字符串,并省略了 s03 的权限闸门。只在可丢弃工作区运行;生产 harness 必须恢复权限与沙箱边界。
观察重点:
1. 输出中能看到哪些 section 被加载了(`[assembled] sections: ...` 标签)
2. 连续对话时,缓存命中显示 `[cache hit]`
3. 创建 `.memory/MEMORY.md` 文件后,下一轮 memory section 自动加载
试试这些 prompt
1. `Read the file README.md`(观察始终加载的三个 section
2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"`(写入记忆索引)
3. `Read the file code.py`(观察 memory section 是否出现)
---
## 接下来
模型输入可以在运行时组装了,但 Agent 碰到错误还是会崩。网络抖动、API 限流、输出被截断、上下文超限,这些不是 bug是常态。
s11 Error Recovery → 四条恢复路径。升级 token、压缩上下文、指数退避、切换模型。
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
s10: System Prompt — Runtime prompt assembly with caching.
s10: Context Assembly — Runtime model-input assembly with caching.
Run: python s10_system_prompt/code.py
Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
@@ -55,7 +55,7 @@ def assemble_system_prompt(context: dict) -> str:
tools = ", ".join(context.get("enabled_tools", []))
if tools:
sections.append(f"Available tools: {tools}.")
sections.append(f"Working directory: {context.get("workspace", WORKDIR)}")
sections.append(f"Working directory: {context.get('workspace', WORKDIR)}")
# Conditional — memory loaded when MEMORY.md exists and has content
memories = context.get("memories", "")
@@ -199,7 +199,7 @@ def agent_loop(messages: list, context: dict):
if __name__ == "__main__":
print("s10: system prompt — runtime assembly")
print("s10: context assembly — runtime model input")
print("Enter a question, press Enter to send. Type q to quit.\n")
history = []
context = update_context({}, [])