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,20 +1,20 @@
# s04: Hooks — 挂在循环上,不写进循环里
# s04: Hooks — Hang on the Loop, Don't Write into It
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 → s21 → s22
> *"挂在循环上, 不写进循环里"* — hook 在工具执行前后注入扩展逻辑。
> *"Hang on the loop, don't write into it"* — Hooks inject extension logic before and after tool execution.
>
> **Harness **: hook — 扩展点不侵入循环。
> **Harness Layer**: HooksExtension points that don't invade the loop.
---
## 问题
## The Problem
s03 Agent 有权限检查了。但每次加一个新检查,比如"记录每次 bash 调用"、"操作后自动 git add",都要修改 `agent_loop` 函数。
The s03 Agent has permission checks. But every new check, "log every bash call", "auto git add after writes", requires modifying the `agent_loop` function.
循环很快就变成了这样:
The loop quickly becomes this:
```python
def agent_loop(messages):
@@ -23,40 +23,40 @@ def agent_loop(messages):
for block in response.content:
if block.type != "tool_use":
continue
log_to_file(block) # 加一行
check_permission(block) # 加一行
notify_slack(block) # 又加一行
log_to_file(block) # added a line
check_permission(block) # added a line
notify_slack(block) # added another line
output = execute(block)
auto_git_add(block) # 再加一行
# ... 很快循环就认不出来了
auto_git_add(block) # yet another line
# ... the loop is unrecognizable
```
你想扩展的是 Agent 的行为,但你改的却是循环本身。循环应该是一个稳定的核心,扩展应该挂在外面。
What you want to extend is the Agent's behavior, but what you're modifying is the loop itself. The loop should be a stable core; extensions should hang on the outside.
---
## 解决方案
## The Solution
![Hooks Overview](images/hooks-overview.svg)
![Hooks Overview](images/hooks-overview.en.svg)
s03 的循环和权限逻辑完全保留。唯一的变动是把 `check_permission()` 从循环体内移到了 hook 上,循环不再直接调用任何检查函数,改为 `trigger_hooks("PreToolUse", block)`,由注册表决定跑什么。
The s03 loop and permission logic are fully preserved. The only change is moving `check_permission()` from inside the loop body onto a hook. The loop no longer directly calls any check function. Instead it calls `trigger_hooks("PreToolUse", block)`, and the registry decides what to run.
四个事件,覆盖一个完整的 agent cycle
Four events, covering a complete agent cycle:
| 事件 | 触发时机 | 典型用途 |
|------|---------|---------|
| UserPromptSubmit | 用户输入提交后、进入 LLM | 输入验证、注入上下文 |
| PreToolUse | 工具执行前 | 权限检查、日志记录 |
| PostToolUse | 工具执行后 | 副作用(自动 git add 等)、输出检查 |
| Stop | 循环即将退出时 | 收尾清理CC 还支持强制续跑) |
| Event | Trigger Timing | Typical Use |
|-------|---------------|-------------|
| UserPromptSubmit | After user input, before entering LLM | Input validation, context injection |
| PreToolUse | Before tool execution | Permission checks, logging |
| PostToolUse | After tool execution | Side effects (auto git add etc.), output checking |
| Stop | When the loop is about to exit | Cleanup (CC also supports force continuation) |
扩展通过 `register_hook()` 添加,循环只调用 `trigger_hooks()`
Extensions are added via `register_hook()`. The loop only calls `trigger_hooks()`.
---
## 工作原理
## How It Works
**hook 注册表**:一个字典,事件名映射到回调列表。
**Hook registry**: a dict mapping event names to callback lists.
```python
HOOKS = {
@@ -72,14 +72,14 @@ def register_hook(event: str, callback):
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None: # 返回值 ≠ None → hook 说"停"
if result is not None: # return value ≠ None → hook says "stop"
return result
return None
```
教学版中PreToolUse 的非 None 返回值会阻止本次工具执行Stop 的非 None 返回值会强制续跑。UserPromptSubmit PostToolUse 的返回值未被使用。
In the teaching version, PreToolUse returning non-None means block execution; Stop returning non-None means force continuation. UserPromptSubmit and PostToolUse return values are unused.
**UserPromptSubmit**,用户输入提交后、进入 LLM 前触发。CC 中可以拦截或修改输入,教学版只做日志演示:
**UserPromptSubmit**, triggers after user input, before entering the LLM. CC can intercept or modify input; the teaching version only logs:
```python
def context_inject_hook(query: str) -> str | None:
@@ -90,19 +90,19 @@ def context_inject_hook(query: str) -> str | None:
register_hook("UserPromptSubmit", context_inject_hook)
```
在主循环中,用户输入后立即触发:
In the main loop, triggered right after user input:
```python
query = input("s04 >> ")
trigger_hooks("UserPromptSubmit", query) # ← 进入 LLM 之前
trigger_hooks("UserPromptSubmit", query) # ← before entering LLM
history.append({"role": "user", "content": query})
agent_loop(history)
```
**PreToolUse / PostToolUse**,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook再加一个日志 hook 和一个大输出提醒:
**PreToolUse / PostToolUse**, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder:
```python
# PreToolUse: 权限检查s03 的逻辑,从循环移到 hook
# PreToolUse: permission check (s03 logic, moved from loop to hook)
def permission_hook(block):
if block.name == "bash":
for pattern in DENY_LIST:
@@ -116,11 +116,11 @@ def permission_hook(block):
return "Permission denied by user"
return None
# PreToolUse: 日志
# PreToolUse: logging
def log_hook(block):
print(f"[HOOK] {block.name}(...)")
# PostToolUse: 大文件提醒
# PostToolUse: large output reminder
def large_output_hook(block, output):
if len(str(output)) > 100000:
print(f"[HOOK] ⚠ Large output from {block.name}")
@@ -130,7 +130,7 @@ register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
```
**Stop**,循环即将退出时触发(`stop_reason != "tool_use"`)。教学版用于打印收尾统计:
**Stop**, triggers when the loop is about to exit (`stop_reason != "tool_use"`). The teaching version prints a cleanup summary:
```python
def summary_hook(messages: list) -> str | None:
@@ -144,11 +144,11 @@ def summary_hook(messages: list) -> str | None:
register_hook("Stop", summary_hook)
```
agent_loop 中,退出前触发:
In agent_loop, triggered before exit:
```python
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages) # ← 退出之前
force = trigger_hooks("Stop", messages) # ← before exiting
if force:
# hook returned a message → inject it and continue
messages.append({"role": "user", "content": force})
@@ -156,7 +156,7 @@ if response.stop_reason != "tool_use":
return
```
**循环里只改了一处**s03 直接调用 `check_permission(block)`s04 改为 `trigger_hooks("PreToolUse", block)`
**Only one change in the loop**: s03 directly called `check_permission(block)`, s04 replaces it with `trigger_hooks("PreToolUse", block)`:
```python
for block in response.content:
@@ -164,7 +164,7 @@ for block in response.content:
continue
# s03: if not check_permission(block): ...
# s04: hook 替代硬编码
# s04: hooks replace hardcoding
blocked = trigger_hooks("PreToolUse", block)
if blocked:
results.append({"type": "tool_result", "tool_use_id": block.id,
@@ -180,104 +180,104 @@ for block in response.content:
"content": output})
```
四个 hook 覆盖了 agent cycle 的关键节点:输入→执行前→执行后→退出。循环只负责调用 trigger_hooks(),具体逻辑全在 hook 回调里。
Four hooks cover the critical nodes of the agent cycle: input → before execution → after execution → exit. The loop only calls trigger_hooks(); all logic lives in hook callbacks.
---
## 相对 s03 的变更
## Changes from s03
| 组件 | 之前 (s03) | 之后 (s04) |
|------|-----------|-----------|
| 扩展方式 | check_permission() 硬编码在循环里 | HOOKS 注册表 + trigger_hooks() |
| 新函数 | — | register_hook, trigger_hooks |
| hook 回调 | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |
| 循环 | 直接调用 check_permission() | 调用 trigger_hooks("PreToolUse", ...) |
| 退出控制 | 无 | trigger_hooks("Stop", ...) 可阻止退出 |
| 输入拦截 | 无 | trigger_hooks("UserPromptSubmit", ...) 可注入上下文 |
| Component | Before (s03) | After (s04) |
|-----------|-------------|-------------|
| Extension method | check_permission() hardcoded in the loop | HOOKS registry + trigger_hooks() |
| New functions | — | register_hook, trigger_hooks |
| Hook callbacks | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |
| Loop | Directly calls check_permission() | Calls trigger_hooks("PreToolUse", ...) |
| Exit control | None | trigger_hooks("Stop", ...) can prevent exit |
| Input interception | None | trigger_hooks("UserPromptSubmit", ...) can inject context |
---
## 试一下
## Try It
```sh
cd learn-claude-code
python s04_hooks/code.py
```
试试这些 prompt
Try these prompts:
1. `Read the file README.md`(应该直接通过,观察 hook 日志)
2. `Create a file called test.txt`(通过后观察 PostToolUse 是否触发)
3. `Delete all temporary files in /tmp`bash + rm 触发权限 hook
1. `Read the file README.md` (should pass directly, observe hook logs)
2. `Create a file called test.txt` (after creation, observe if PostToolUse fires)
3. `Delete all temporary files in /tmp` (bash + rm triggers permission hook)
观察重点:每次工具执行前,是否出现了 `[HOOK]` 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的?
What to watch for: Before each tool execution, does the `[HOOK]` log appear? When permission is denied, was it intercepted by a hook or hardcoded in the loop?
---
## 接下来
## What's Next
Agent 现在能安全执行操作了。但它有没有停下来想过"我应该先做什么,再做什么"?给它一个复杂任务,它是一上来就动手,还是先列个计划?
The Agent can now safely execute operations. But does it ever stop to think "what should I do first, and what next?" Given a complex task, does it jump straight in, or plan first?
s05 TodoWrite → 给 Agent 一个计划工具。先列清单,再做。
s05 TodoWrite: Give the Agent a planning tool. Make a list first, then execute.
<details>
<summary>深入 CC 源码</summary>
<summary>Dive into CC Source Code</summary>
> 以下基于 CC 源码 `toolHooks.ts`650 行)、`hooks.ts``stopHooks.ts``coreTypes.ts` 的完整分析。
> The following is based on a complete analysis of CC source code `toolHooks.ts` (650 lines), `hooks.ts`, `stopHooks.ts`, and `coreTypes.ts`.
### 一、Hook 事件:不止这 4 个,而是 27
### 1. Hook Events: Not Just 4, but 27
教学版只讲了 PreToolUse PostToolUseCC 实际有 27 hook 事件(`coreTypes.ts:25-53`
The teaching version covers only PreToolUse and PostToolUse. CC actually has 27 hook events (`coreTypes.ts:25-53`):
| 类别 | 事件 |
|------|------|
| 工具相关 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
| 会话相关 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
| 用户交互 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
| 子 Agent | `SubagentStart`, `SubagentStop` |
| 压缩相关 | `PreCompact`, `PostCompact` |
| 团队相关 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
| 其他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
| Category | Events |
|----------|--------|
| Tool-related | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
| Session-related | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
| User interaction | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
| Sub-agents | `SubagentStart`, `SubagentStop` |
| Compaction-related | `PreCompact`, `PostCompact` |
| Team-related | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
| Other | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
教学版只讲 4 个核心事件(UserPromptSubmitPreToolUsePostToolUseStop),因为它们覆盖了一个完整 agent cycle 的关键节点。其他 23 个都是同样的模式。
The teaching version covers only 4 core events (UserPromptSubmit, PreToolUse, PostToolUse, Stop) because they cover every critical node of a complete agent cycle. The other 23 follow the same pattern.
### 二、HookResult 常用字段摘录
### 2. HookResult Common Fields
CC `HookResult``types/hooks.ts:260-275`)有 14 个字段,以下是常用字段:
CC's `HookResult` (`types/hooks.ts:260-275`) has 14 fields. Common ones:
| 字段 | 类型 | 用途 |
|------|------|------|
| `message` | Message | 可选 UI 消息 |
| `blockingError` | HookBlockingError | 阻塞错误 → 注入对话让模型自纠 |
| `outcome` | success/blocking/non_blocking_error/cancelled | 执行结果 |
| `preventContinuation` | boolean | 阻止后续执行 |
| `stopReason` | string | 停止原因描述 |
| `permissionBehavior` | allow/deny/ask/passthrough | hook 返回权限决策 |
| `updatedInput` | Record | 修改工具输入 |
| `additionalContext` | string | 附加上下文 |
| `updatedMCPToolOutput` | unknown | MCP 工具输出修改 |
| Field | Type | Purpose |
|-------|------|---------|
| `message` | Message | Optional UI message |
| `blockingError` | HookBlockingError | Blocking error → injected into conversation for model self-correction |
| `outcome` | success/blocking/non_blocking_error/cancelled | Execution result |
| `preventContinuation` | boolean | Prevent subsequent execution |
| `stopReason` | string | Stop reason description |
| `permissionBehavior` | allow/deny/ask/passthrough | Hook returns permission decision |
| `updatedInput` | Record | Modify tool input |
| `additionalContext` | string | Additional context |
| `updatedMCPToolOutput` | unknown | MCP tool output modification |
### 三、关键不变式Hook 'allow' 不能绕过 deny/ask 规则
### 3. Key Invariant: Hook 'allow' Cannot Bypass deny/ask Rules
这是 CC 权限系统最重要的安全设计(`toolHooks.ts:325-331`**hook 返回 allow 时,仍然要检查 settings.json deny/ask 规则**。即使用户的 hook 脚本说"允许",如果在 settings.json 中禁用了这个工具,操作仍然会被阻止。
This is the most important security design in CC's permission system (`toolHooks.ts:325-331`): **when a hook returns allow, it still checks settings.json deny/ask rules.** Even if the user's hook script says "allow", if the tool is disabled in settings.json, the operation is still blocked.
教学版没有这个层次,只把 PreToolUse 的非 None 返回值解释为阻止本次工具执行。这在教学场景中够了,但在生产环境中会形成安全漏洞。
The teaching version doesn't have this layer; hooks returning non-None directly interrupt. This is sufficient for teaching, but would create a security vulnerability in production.
### 四、stopHookActive 机制
### 4. stopHookActive Mechanism
CC Stop hooks 有一个防无限循环机制(`query.ts:212,1300``stopHookActive` 状态字段。当 stop hooks 产生 blockingError 时,循环带 `stopHookActive: true` 重入下一轮。后续迭代中 stop hooks 看到这个标志就不会再次触发。这防止了一个永不停机的 bug模型自纠后 stop hook 再次报错 → 模型再自纠 → stop hook 再报错...
CC's Stop hooks have an infinite-loop prevention mechanism (`query.ts:212,1300`): the `stopHookActive` state field. When stop hooks produce a blockingError, the loop re-enters with `stopHookActive: true`. Subsequent iterations see this flag and don't trigger stop hooks again. This prevents a never-stopping bug: model self-corrects → stop hook errors again → model self-corrects again → stop hook errors again...
### 五、hook_stopped_continuation
### 5. hook_stopped_continuation
PostToolUse hooks 返回 `preventContinuation: true` 时,会产生一个 `hook_stopped_continuation` 附件(`toolHooks.ts:117-130`)。query.tsL1388-1393)检测到后设置 `shouldPreventContinuation = true`,循环退出。这是 "hook 优雅地让 Agent 停机" 的机制,不是崩溃,是完成。
When PostToolUse hooks return `preventContinuation: true`, a `hook_stopped_continuation` attachment is produced (`toolHooks.ts:117-130`). query.ts (L1388-1393) detects it and sets `shouldPreventContinuation = true`, causing the loop to exit. This is the mechanism for "hooks gracefully shut down the Agent" — not a crash, but a completion.
### 教学版的简化是刻意的
### Teaching Version Simplifications Are Intentional
- 27 个事件 → 4 个(UserPromptSubmit/PreToolUse/PostToolUse/Stop):覆盖 agent cycle 关键节点
- 14 个字段 → 简单的返回值None = 继续,非 None = 阻止/续跑):心智负担降到最低
- Hook allow vs deny/ask 不变式 → 省略:教学版没有 settings.json
- stopHookActive → 省略:教学版 Stop hook 只做简单续跑,不涉及防无限循环机制
- 27 events → 4 (UserPromptSubmit/PreToolUse/PostToolUse/Stop): covers agent cycle critical nodes
- 14 fields → simple return values (None = continue, non-None = interrupt/continue): minimal cognitive load
- Hook allow vs deny/ask invariant → omitted: teaching version has no settings.json layer
- stopHookActive → omitted: teaching version Stop hook only does simple continuation, no infinite-loop prevention needed
</details>
<!-- translation-sync: zh@v1, en@v0, ja@v0 -->
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->