learn-claude-code笔记4: Hooks — 挂在循环上,不写进循环里

s04: Hooks --- 挂在循环上,不写进循环里

本节关注点:扩展点不侵入循环。

问题

s03 的 Agent 有权限检查了。但每次加一个新检查,比如"记录每次 bash 调用"、"操作后自动 git add",都要修改 agent_loop 函数。

你想扩展的是 Agent 的行为,但你改的却是循环本身。

循环应该是一个稳定的核心,扩展应该挂在外面。

解决方案

s03 的循环和权限逻辑完全保留。唯一的变动是把 check_permission() 从循环体内移到了 hook 上,循环不再直接调用任何检查函数,改为 trigger_hooks("PreToolUse", block),由注册表决定跑什么。

四个事件,覆盖一个完整的 agent cycle:

事件 触发时机 典型用途
UserPromptSubmit 用户输入提交后、进入 LLM 前 输入验证、注入上下文
PreToolUse 工具执行前 权限检查、日志记录
PostToolUse 工具执行后 副作用(自动 git add 等)、输出检查
Stop 循环即将退出时 收尾清理、决定是否继续循环

扩展通过 register_hook() 添加,循环只调用 trigger_hooks()。

工作原理

钩子系统

一个字典,事件名映射到回调列表。

  • PreToolUse 返回非 None 时,本次工具执行被阻止;
  • Stop 返回非 None 时,循环继续
  • UserPromptSubmit 和 PostToolUse 的返回值不参与控制流。
python 复制代码
# -- s04 新增:钩子系统(s03 的权限逻辑现在使用钩子)--

HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}

# 注册hook
def register_hook(event: str, callback):
    HOOKS[event].append(callback)

# 触发hook
def trigger_hooks(event: str, *args):
    for callback in HOOKS[event]:
        result = callback(*args)
        if result is not None:  # 钩子返回结果会阻止此次工具调用。
            return result
    return None

定义 hook 函数并注册

UserPromptSubmit

UserPromptSubmit 在用户输入提交后、进入 LLM 前触发。 以下 hook 记录当前工作目录:

python 复制代码
# UserPromptSubmit 钩子:在用户输入到达 LLM 之前记录
def context_inject_hook(query: str):
    print(f"\033[90m[钩子] UserPromptSubmit:正在 {WORKDIR} 中工作\033[0m")
    return None
python 复制代码
register_hook("UserPromptSubmit", context_inject_hook)
PreToolUse / PostToolUse

PreToolUse / PostToolUse,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook,再加一个日志 hook 和一个大输出提醒:

python 复制代码
# s03 权限检查逻辑,现在包装为钩子
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
DESTRUCTIVE_COMMAND_WORD = re.compile(
    r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
)
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]


def contains_destructive_command(command: str) -> bool:
    return bool(DESTRUCTIVE_COMMAND_WORD.search(command))


def permission_hook(block):
    """PreToolUse:s03 的 check_permission() 逻辑移到这里。"""
    if block.name == "bash":
        command = block.input.get("command", "")
        for pattern in DENY_LIST:
            if pattern in command:
                print(f"\n\033[31m[已阻止] '{pattern}'\033[0m")
                return "权限被拒绝列表阻止"
        if contains_destructive_command(command) or any(
            kw in command for kw in DESTRUCTIVE
        ):
            print(f"\n\033[33m[权限] 潜在破坏性命令\033[0m")
            print(f"   工具:{block.name}({block.input})")
            choice = input("   允许?[y/N] ").strip().lower()
            if choice not in ("y", "yes"):
                return "权限被用户拒绝"
    if block.name in ("read_file", "write_file", "edit_file"):
        path = block.input.get("path", "")
        if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
            print(f"\n\033[33m[权限] 访问工作区外的路径\033[0m")
            print(f"   工具:{block.name}({block.input})")
            choice = input("   允许?[y/N] ").strip().lower()
            if choice not in ("y", "yes"):
                return "权限被用户拒绝"
    return None

def log_hook(block):
    """PreToolUse:记录每次工具调用。"""
    args_preview = str(list(block.input.values())[:2])[:60]
    print(f"\033[90m[钩子] {block.name}({args_preview})\033[0m")
    return None

def large_output_hook(block, output):
    """PostToolUse:输出过大时警告。"""
    if len(str(output)) > 100000:
        print(f"\033[33m[钩子] {block.name} 输出过大:{len(str(output))} 字符\033[0m")
    return None
python 复制代码
register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
Stop

Stop 在循环即将退出时触发。以下 hook 打印收尾统计:

python 复制代码
# Stop 钩子:循环即将退出时打印摘要
def summary_hook(messages: list):
    tool_count = sum(1 for m in messages
                     for b in (m.get("content") if isinstance(m.get("content"), list) else [])
                     if isinstance(b, dict) and b.get("type") == "tool_result")
    print(f"\033[90m[钩子] Stop:本次会话使用了 {tool_count} 次工具调用\033[0m")
    return None
python 复制代码
register_hook("Stop", summary_hook)

四个 hook 覆盖了 agent cycle 的关键节点:输入→执行前→执行后→退出。循环只负责调用 trigger_hooks(),具体逻辑全在 hook 回调里。

Aegnt 循环中触发hook

python 复制代码
# -- 代理循环:与 s03 结构相同,但没有硬编码检查 --
# s03: if not check_permission(block): ...
# s04: if trigger_hooks("PreToolUse", block): ...

def agent_loop(messages: list):
    while True:
        response = client.messages.create(
            model=MODEL, system=SYSTEM, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )
        messages.append({"role": "assistant", "content": response.content})

        tool_calls = [
            block for block in response.content if block.type == "tool_use"
        ]
        if not tool_calls:
            force = trigger_hooks("Stop", messages)
            if force:
                messages.append({"role": "user", "content": force})
                continue
            return

        results = []
        for block in tool_calls:
            # s04 变更:钩子替代硬编码的 check_permission()
            blocked = trigger_hooks("PreToolUse", block)
            if blocked:
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": str(blocked)})
                continue

            handler = TOOL_HANDLERS.get(block.name)
            output = handler(**block.input) if handler else f"未知:{block.name}"

            trigger_hooks("PostToolUse", block, output)  # s04:后置钩子

            results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})

        messages.append({"role": "user", "content": results})

启动入口,UserPromptSubmit 在用户输入提交后、进入 LLM 前触发。

python 复制代码
if __name__ == "__main__":
    print("s04:钩子 - 扩展逻辑放在钩子上,循环保持简洁")
    print("输入问题,按回车发送。输入 q 退出。\n")

    history = []
    while True:
        try:
            # \001/\002 告诉 Readline ANSI 转义序列的显示宽度为零。
            query = input("\001\033[36m\002s04 >> \001\033[0m\002")
        except (EOFError, KeyboardInterrupt):
            break
        if query.strip().lower() in ("q", "exit", ""):
            break
        trigger_hooks("UserPromptSubmit", query)
        history.append({"role": "user", "content": query})
        agent_loop(history)
        for block in history[-1]["content"]:
            if getattr(block, "type", None) == "text":
                print(block.text)
        print()

每步输出打印

text 复制代码
s04:钩子 - 扩展逻辑放在钩子上,循环保持简洁
输入问题,按回车发送。输入 q 退出。

s04 >> 阅读 readme文档
[钩子] UserPromptSubmit:正在 H:\AI\Agent\Coding Agent\learn-claude-code\s04_hooks 中工作
[钩子] glob(['README*'])
[钩子] read_file(['README.md'])
[钩子] Stop:本次会话使用了 2 次工具调用
已读取 README 文档。以下是文档的主要内容:
...
相关推荐
程序员大狐狸2 小时前
Ultra X7 358H 笔记本电脑,本地三个大模型接入 DeepSeek Harness 桌面版的过程与方法
人工智能·llm
草上飞95272 小时前
零知识证明能证明算对了,却证明不了没看见
人工智能·深度学习·llm
lucas_AI2 小时前
Agent 工具箱越大越难选?Tracer 砍掉八成工具 schema,选得反而更准
llm·agent
用户460381291942 小时前
Function Calling 选对了工具,却总在参数上翻车?从 JSON Mode 到 Structured Outputs 一次讲透
llm
自养Agent2 小时前
Agent Harness 从用到会造:大厂 RAG 为什么从不只用向量检索
人工智能·llm
学IT的周星星2 小时前
记忆系统:让 Agent 记住你是谁、上次聊到哪
运维·服务器·人工智能·windows·大模型·llm·agent
yuanyxh2 小时前
我的工作流
agent·claude·deepseek
badhope2 小时前
AI Agent安全治理实战:从API密钥泄露到沙箱隔离的完整防线
后端·llm·agent
武子康3 小时前
旧工具结果写进新回合:两次消费之间的打断窗口
人工智能·后端·agent