《AI Agent 高级编程》5.3 读 Claude Agent SDK:subagent 与 hook 调度(含 TS SDK 对照)

打开本机 claude-agent-sdk 0.2.165 源码读 subagent 与 hook 调度:subagent 是 asdict 后的 AgentDefinition 随 initialize 送到 CLI,hook 注册成 callback_id,由 CLI 反向发 control_request 回调 Python。附 inspect 核对与 TS SDK 对照。

5.3 读 Claude Agent SDK:subagent 与 hook 调度(含 TS SDK 对照)

第 3 章用 Claude Agent SDK 搭了研究任务,讲了 subagent / hooks / 权限怎么用。本节往下钻一层:它为什么和你前面手写的 run() 完全不是一回事。答案会让你有点意外——Python 侧的 claude_agent_sdk 里没有 agent loop;循环跑在另一个进程(claude CLI)里,Python 只是个通过 stdio 说话的客户端。subagent 与 hook 都不是「在 Python 里调用的函数」,而是跨进程的注册与回调。

本节把「subagent 和 hook 到底怎么被调度」推进到源码级:读出 AgentDefinition 如何序列化送进 CLI、HookMatcher 如何变成 callback_id 再被 CLI 反向回调,用 inspect 核对 Python 侧 API 表面(不连活模型),并对照 TS SDK 的类型定义。

5.3.1 定位源码(Python 与 TS 两条线)

/tmp/agentbook/venv3/bin/python -c "import importlib.metadata as m; print(m.version('claude-agent-sdk'))"
/tmp/agentbook/venv3/bin/python -c "import claude_agent_sdk as c; print(c.__file__)"
0.2.165
/tmp/agentbook/venv3/lib/python3.14/site-packages/claude_agent_sdk/__init__.py

Python 侧关键文件:claude_agent_sdk/types.py(91 KB,类型与 ClaudeAgentOptions)、client.py(ClaudeSDKClient 连接逻辑)、_internal/query.py(控制协议,hook 分发就在这里)。TS 侧本节读 @anthropic-ai/claude-agent-sdk 的类型声明,本机临时装了 0.3.296 用于对照(BRIEF 给的是 Node SDK 口径,见 §5.3.8 说明)。

5.3.2 它不是一个 loop:SDK 是 CLI 的客户端

先摆一个事实:ClaudeAgentOptions 里没有任何「循环」参数,ClaudeSDKClient 的方法表也全是连接与查询管理——connect / query / receive_messages / interrupt / set_permission_mode / disconnect。真正的 agent loop(模型调用、工具执行、迭代)跑在 CLI 进程里。Python 的 query() 只是:

# claude_agent_sdk/query.py:11
async def query(
    *,
    prompt: str | AsyncIterable[dict[str, Any]],
    options: ClaudeAgentOptions | None = None,
    transport: Transport | None = None,
) -> AsyncIterator[Message]:
    """Query Claude Code for one-shot or unidirectional streaming interactions."""

它 yield 的是一串消息流(AssistantMessage / ResultMessage / StreamEvent …),不是「循环的每一轮」。所以本节的问题不是「循环怎么写」,而是「subagent 和 hook 这两个扩展点,怎么跨进程被调度」。

红线:claude-agent-sdk 的 query() / ClaudeSDKClient 会 spawn 本机 claude CLI,即真实调用并消耗额度。本节只读源码、只核对 API 表面,不执行任何会连活模型的调用。

5.3.3 subagent 声明:AgentDefinition

subagent 在 Python 侧就是一个 dataclass,定义在 claude_agent_sdk/types.py:107:

# claude_agent_sdk/types.py:107
@dataclass
class AgentDefinition:
    """Agent definition configuration."""
    description: str
    prompt: str
    tools: list[str] | None = None
    disallowedTools: list[str] | None = None
    model: str | None = None
    skills: list[str] | None = None
    memory: Literal["user", "project", "local"] | None = None
    mcpServers: list[str | dict[str, Any]] | None = None
    initialPrompt: str | None = None
    maxTurns: int | None = None
    background: bool | None = None
    effort: EffortLevel | int | None = None
    permissionMode: PermissionMode | None = None

它挂在 ClaudeAgentOptions.agents 上(types.py:2291),注释点明了用途:

# claude_agent_sdk/types.py:2291
agents: dict[str, AgentDefinition] | None = None
"""Programmatically define custom subagents invokable via the Agent tool."""

关键认知:subagent 不是 Python 里调用的对象,而是一份声明——名字(dict 的 key)+ 一段系统提示(prompt)+ 工具白名单。真正「调用」它的是 CLI 里的 Agent 工具:主 agent 在推理时决定派一个 subagent,CLI 用它注册好的定义起一个新的子会话。

5.3.4 声明怎么送到 CLI:asdict 后过滤 None

ClaudeSDKClient 连接时把 agents 转成普通 dict 再交给 Query(claude_agent_sdk/client.py:197):

# claude_agent_sdk/client.py:197
# Convert agents to dict format for initialize request
agents_dict: dict[str, dict[str, Any]] | None = None
if self.options.agents:
    agents_dict = {
        name: {k: v for k, v in asdict(agent_def).items() if v is not None}
        for name, agent_def in self.options.agents.items()
    }

两个细节:asdict 把 dataclass 拍平成 dict;if v is not None 把未设字段剔掉——因为 CLI 侧看到的是「没这个键」而不是「这个键是 null」。这份 agents_dict 随后传进 Query(...)(client.py:217),并在 initialize 控制请求里发给 CLI(_internal/query.py:344):

# claude_agent_sdk/_internal/query.py:344
if self._agents:
    request["agents"] = self._agents

所以 subagent 的「注册」发生在会话初始化那一刻,之后 CLI 才可能用 Agent 工具把它们唤起来。

5.3.5 hook 调度:注册 callback_id,CLI 反向回调

这是本节最核心的机制。HookMatcher(types.py:605)长这样:

# claude_agent_sdk/types.py:605
@dataclass
class HookMatcher:
    matcher: str | None = None       # 工具名或 "Write|Edit" 这种模式
    hooks: list[HookCallback] = field(default_factory=list)
    timeout: float | None = None     # 该 matcher 内所有 hook 的超时(秒)

HookCallback 是一个异步函数(types.py:594):入参 (input, tool_use_id, context),出参 HookJSONOutput。

Python 的函数对象没法直接跨进程送过去。SDK 的做法是用 ID 注册:初始化时给每个回调分配一个 callback_id,把 ID 发给 CLI,Python 侧留一张 callback_id -> 函数 的表。分发逻辑在 _internal/query.py:318:

# claude_agent_sdk/_internal/query.py:318
hooks_config: dict[str, Any] = {}
if self.hooks:
    for event, matchers in self.hooks.items():
        if matchers:
            hooks_config[event] = []
            for matcher in matchers:
                callback_ids = []
                for callback in matcher.get("hooks", []):
                    callback_id = f"hook_{self.next_callback_id}"
                    self.next_callback_id += 1
                    self.hook_callbacks[callback_id] = callback
                    callback_ids.append(callback_id)
                hook_matcher_config: dict[str, Any] = {
                    "matcher": matcher.get("matcher"),
                    "hookCallbackIds": callback_ids,
                }
                ...
                hooks_config[event].append(hook_matcher_config)

送到 CLI 的是 hooks_config(event -> [{matcher, hookCallbackIds}]),函数本体留在 Python 进程的 self.hook_callbacks 里。

当 CLI 里的某个事件命中 matcher,CLI 会反过来发一条 control_request(子类型 hook_callback)给 Python;Python 收到后查表、调用、把结果回发。分发入口在 _internal/query.py:640:

# claude_agent_sdk/_internal/query.py:640
elif subtype == "hook_callback":
    hook_callback_request: SDKHookCallbackRequest = request_data
    callback_id = hook_callback_request["callback_id"]
    callback = self.hook_callbacks.get(callback_id)
    if not callback:
        raise Exception(f"No hook callback found for ID: {callback_id}")

    hook_output = await callback(
        request_data.get("input"),
        request_data.get("tool_use_id"),
        {"signal": None},  # TODO: Add abort signal support
    )
    response_data = _convert_hook_output_for_cli(hook_output)

这就是整个 hook 调度的闭环:注册 ID → 送 CLI → CLI 命中事件 → 反发 control_request → Python 查表调用 → 回发结果。hook 不是「被 SDK 在循环里同步调用」,而是由 CLI 按事件驱动的反向 RPC。这也解释了为什么 hook 能拦住工具调用:CLI 在真正执行工具之前发 PreToolUse 回调,等 Python 返回 permissionDecision 才决定是否执行。

5.3.6 hook 的输入输出类型

HookEvent 是一个字面量联合(types.py:284),Python 侧列了 10 种:PreToolUse / PostToolUse / PostToolUseFailure / UserPromptSubmit / Stop / SubagentStop / PreCompact / Notification / SubagentStart / PermissionRequest。SubagentStartHookInput(types.py:404)带了 subagent 的身份:

# claude_agent_sdk/types.py:404
class SubagentStartHookInput(BaseHookInput):
    hook_event_name: Literal["SubagentStart"]
    agent_id: str
    agent_type: str

hook 的返回值 SyncHookJSONOutput(types.py:541)能控制执行:continue_(是否继续)、decision("block" 拦下)、systemMessage、以及事件专属的 hookSpecificOutput。比如 PreToolUseHookSpecificOutput(types.py:437)可带 permissionDecision: "allow" | "deny" | "ask" | "defer" 与 updatedInput——hook 能在工具执行前改写参数或直接否决。

注意 Python 与 CLI 的字段名差异:Python 用 async_ / continue_(避开关键字),送到 CLI 前由 _convert_hook_output_for_cli(_internal/query.py:113)翻成 async / continue。这是跨进程协议在 Python 里必须交的税。

5.3.7 用 inspect 核对 API 表面(不连活模型)

按红线,本节不跑 query(),但用 inspect 核对 Python 侧的真实签名,证明上面引用的字段与调用形状没错:

import inspect, dataclasses
import claude_agent_sdk as c

print(inspect.signature(c.query))
print([f.name for f in dataclasses.fields(c.AgentDefinition)])
print([f.name for f in dataclasses.fields(c.HookMatcher)])
opts = [f.name for f in dataclasses.fields(c.ClaudeAgentOptions)]
print('agents' in opts, 'hooks' in opts, 'can_use_tool' in opts)

真跑输出:

(*, prompt: str | AsyncIterable[dict[str, Any]], options: ClaudeAgentOptions | None = None, transport: Transport | None = None) -> AsyncIterator[Message]
['description', 'prompt', 'tools', 'disallowedTools', 'model', 'skills', 'memory', 'mcpServers', 'initialPrompt', 'maxTurns', 'background', 'effort', 'permissionMode']
['matcher', 'hooks', 'timeout']
True True True

三点与源码一致:query 只吃 prompt / options / transport;AgentDefinition 的字段顺序与 §5.3.3 贴的源码逐字对应;HookMatcher 只有 matcher / hooks / timeout 三个字段。

5.3.8 TS SDK 对照

同一套机制在 TS 侧有一一对应的类型。本节对照的是 @anthropic-ai/claude-agent-sdk 的类型声明(本机装的版本号 0.3.296;BRIEF 表里另给了 @anthropic-ai/sdk 0.133.0 作为 Node 基础 SDK 的口径)。AgentDefinition(sdk.d.ts:38)与 Python 侧同构:

// node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:38
export declare type AgentDefinition = {
    description: string;
    tools?: string[];
    disallowedTools?: string[];
    prompt: string;
    model?: string;
    mcpServers?: AgentMcpServerSpec[];
    skills?: string[];
    ...
};

hook 三个类型(sdk.d.ts:968):

// node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:968
export declare type HookCallback = (input: HookInput, toolUseID: string | undefined, options: {
    signal: AbortSignal;
}) => Promise<HookJSONOutput>;

export declare interface HookCallbackMatcher {
    matcher?: string;
    hooks: HookCallback[];
    timeout?: number;
}

export declare type HookEvent = 'PreToolUse' | 'PostToolUse' | ... | 'SubagentStop' | ...;

HookCallbackMatcher 与 Python 的 HookMatcher 字段名完全相同(matcher / hooks / timeout);Options 上同样有 hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>(sdk.d.ts:1735)与 agents?: Record<string, AgentDefinition>(sdk.d.ts:1581)。唯一的结构性差异是 hook 回调的第三个参数:TS 直接拿到真实的 AbortSignal,Python 目前是 {"signal": None} 占位(_internal/query.py:651 的 TODO)——Python 侧的中断信号支持还没补齐,这是跨语言移植时要留意的坑。

下面这段 TS 用可擦除语法写了一个 PreToolUse hook 与一个 subagent 声明,并且只做类型自检、不发起 query(node 与 tsc --noEmit 均在本机通过):

import { query } from '@anthropic-ai/claude-agent-sdk';
import type {
  AgentDefinition, HookCallback, HookCallbackMatcher, Options,
} from '@anthropic-ai/claude-agent-sdk';

const auditBash: HookCallback = async (input, toolUseID) => {
  if (input.hook_event_name === 'PreToolUse') {
    console.log('hook sees', input.tool_name, 'toolUseID=', toolUseID);
  }
  return { continue: true };
};

const bashGuard: HookCallbackMatcher = {
  matcher: 'Bash',
  hooks: [auditBash],
  timeout: 10,
};

const reviewer: AgentDefinition = {
  description: 'Reviews a diff for correctness',
  prompt: 'You review code and report defects.',
  tools: ['Read', 'Grep'],
  model: 'haiku',
};

const options: Options = {
  hooks: { PreToolUse: [bashGuard] },
  agents: { reviewer },
};

const build = (prompt: string) => query({ prompt, options });
console.log(typeof build, Object.keys(options.agents ?? {}));

真跑输出(只构造 options、不连活模型):

function [ 'reviewer' ]

query 是函数、agents 里有 reviewer——对象装配正确,但这段代码没有真的调用 query(),一旦调用就会 spawn 真实 CLI 并消耗额度。

5.3.9 与自研 loop 的调度对照

维度自研 run()(卷一)Claude Agent SDK
循环位置你的 Python 进程里CLI 进程里
subagent你自己写派发逻辑AgentDefinition 声明 + Agent 工具
扩展点改 run() 代码hook:注册 ID,CLI 反向回调
hook 时机你自己在循环里插CLI 在工具执行前后发回调
中断你自己判interrupt() 控制请求 + hook 的 AbortSignal

这套 SDK 替你做了什么:把 agent loop、工具执行、权限、subagent 派发、会话持久化全部收进 CLI,你只写「声明」与「回调」。你又让渡了什么:循环不在你手里——它跑在另一个进程、由 CLI 实现;Python 侧只能通过消息流与控制请求观测和干预,hook 回调还受跨进程协议约束(async_ → async 的字段翻译、signal 尚未补齐)。这也正是第 6 章要拿 durable execution 来补的洞:循环在别人进程里,崩溃恢复就得靠 CLI 的 session 机制,而不是你的检查点。

阅读导航:上一节:5.2 读 PydanticAI:run 循环与重试调用链 · 下一节:6.1 为什么需要持久化执行:崩溃/重启/长任务改造 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距