ADK Go 源码深度解读 03:Tool 体系,从 functiontool 到 agent-as-tool
系列第三篇。Agent 的能力边界由工具决定。本篇拆解 ADK Go 的工具系统:公开的
tool.Tool接口为何只有三个方法?functiontool.New[Args, Result]如何用泛型 + jsonschema-go 从 Go 类型自动推断 JSON Schema?Toolset为何能每次调用返回不同工具?第二篇多次提到的agent-as-tool——agenttool.New与构造期自动装配的SingleTurnTool/TaskAgentTool到底怎么把一个 agent 包装成工具?
一、Tool 接口的「冰山设计」:3 个公开方法背后
先看 tool.Tool 公开接口——它出乎意料地简单:
// Tool defines the interface for a callable tool.
type Tool interface {
Name() string // 工具名(LLM 调用时用)
Description() string // 描述(LLM 据此决定是否调用)
IsLongRunning() bool // 是否长时运行(影响并发与重入处理)
}
只有三个方法,而且没有 Run()?那工具怎么执行?这就是 ADK 的**「冰山设计」**——公开接口故意做得极小,真正的执行能力藏在 internal/toolinternal 的扩展接口里:
// FunctionTool 是「能跑的工具」——绝大多数工具实现这个
type FunctionTool interface {
tool.Tool
Declaration() *genai.FunctionDeclaration // 生成给 LLM 看的 schema
Run(ctx agent.Context, args any) (result map[string]any, err error) // 实际执行
}
// StreamingFunctionTool 是「流式工具」——边算边吐 token
type StreamingFunctionTool interface {
tool.Tool
Declaration() *genai.FunctionDeclaration
RunStream(ctx agent.Context, args any) iter.Seq2[string, error]
}
// RequestProcessor 是「能改请求的工具」——特殊工具(如 transfer)
type RequestProcessor interface {
ProcessRequest(ctx agent.Context, req *model.LLMRequest) error
}
🔑 为什么公开接口这么小?
因为
internal/是 Go 的私有包,外部无法实现这些扩展接口——这保证了所有可执行的工具都必须通过 ADK 的构造器创建(functiontool.New、agenttool.New等),框架因此能在每个工具外围统一织入:Schema 生成、HITL 确认、panic 恢复、tracing、回调链。如果你尝试手写一个type myTool struct{}实现三个方法就挂到Config.Tools,运行时它会被当作「不可执行工具」而静默忽略。这与第 02 篇 agent 的 sealed 模式(internal() *agent)是同一套哲学。
1.1 三个扩展接口的职责分工
| 扩展接口 | 谁实现 | 做什么 |
|---|---|---|
FunctionTool |
functiontool / agenttool / mcptoolset / 内置工具 | 最常见:生成 schema + 单次执行。LLM 调一次工具 = 调一次 Run |
StreamingFunctionTool |
需要边算边输出的工具 | 返回 iter.Seq2[string, error],逐段产出(如长时间生成任务) |
RequestProcessor |
需要在 LLM 请求阶段干预的工具 | 把工具自身的 schema 注入 LLMRequest.Tools,或做特殊请求改写 |
注意一个工具可以同时实现多个扩展接口。例如 functionTool 同时实现 FunctionTool 和 RequestProcessor——前者提供执行能力,后者负责把自己的 FunctionDeclaration 打包进请求。
1.2 工具如何被装配进 LLM 请求
工具从「Config.Tools」到「LLM 能看到的 function 列表」,经过 ToolsRequestProcessor。对每个工具,它判断实现的扩展接口:
工具装配流程(ToolsRequestProcessor 内部)
Config.Tools []tool.Tool (静态工具)
│
▼ 对每个 tool t:
┌─────────────────────────────────────────────────┐
│ t 是否实现 RequestProcessor? │
│ 是 → 调 t.ProcessRequest(ctx, req) │ ← 工具自己负责打包
│ 否 → toolutils.PackTool(req, t) 通用兜底 │ ← 框架帮它打包
│ (PackTool 会查 FunctionTool 接口) │
└─────────────────────────────────────────────────┘
│
▼
LLMRequest.Tools[name] = 工具实例 (map,供后续 handleFunctionCalls 按 name 查找执行)
关键点:LLMRequest.Tools 是一个 map[string]Tool,而不是单纯的 schema 列表。schema 是给 LLM 看的(决定何时调用、传什么参数),map 里的工具实例是给框架用的(收到 LLM 的 function_call 后按名查到实例,调它的 Run)。这就是为什么「只实现三个公开方法的假工具」会失效——它没法把自己塞进这个 map。
二、functiontool.New:泛型 Schema 推断魔法 ⭐
绝大多数工具都用 functiontool.New 创建。它的核心卖点是:你写一个 Go 函数,它自动推断出符合 LLM 调用规范的 JSON Schema。先看一个最小例子(本系列统一用 OpenAI):
// 1. 定义输入/输出的 Go 结构体
type WeatherInput struct {
City string `json:"city"` `jsonschema:"the city to query"`
}
type WeatherOutput struct {
Temp float64 `json:"temp_celsius"`
Desc string `json:"description"`
}
// 2. 写一个 handler 函数(签名固定:func(agent.Context, Args) (Result, error))
func getWeather(ctx agent.Context, in WeatherInput) (WeatherOutput, error) {
return WeatherOutput{Temp: 22.5, Desc: "sunny in " + in.City}, nil
}
// 3. 一行包装成 tool.Tool
weatherTool, err := functiontool.New(functiontool.Config{
Name: "get_weather",
Description: "Returns current weather for a city.",
}, getWeather)
就这样——WeatherInput 的字段、类型、描述,被自动转换成 LLM 能理解的 JSON Schema。LLM 调用 get_weather({"city":"Zurich"}) 时,框架会把它反序列化成 WeatherInput,调你的 handler,再把 WeatherOutput 序列化回 JSON 返回给 LLM。
2.1 New 函数内部做了什么
看源码(精简后)。核心是两件事:类型校验和Schema 推断:
func New[TArgs, TResults any](cfg Config, handler Func[TArgs, TResults]) (tool.Tool, error) {
// ① 类型校验:TArgs 必须是 struct 或 map(或指向它们的指针)
var zeroArgs TArgs
argsType := reflect.TypeOf(zeroArgs)
for argsType != nil && argsType.Kind() == reflect.Pointer {
argsType = argsType.Elem() // 解指针
}
if argsType == nil || (argsType.Kind() != reflect.Struct && argsType.Kind() != reflect.Map) {
return nil, fmt.Errorf("input must be a struct or a map...")
}
// ② Schema 推断:用 jsonschema-go 库从类型反射出 schema
ischema, err := resolvedSchema[TArgs](cfg.InputSchema) // 输入 schema
oschema, err := resolvedSchema[TResults](cfg.OutputSchema) // 输出 schema
return &functionTool[TArgs, TResults]{
cfg: cfg, handler: handler,
inputSchema: ischema, outputSchema: oschema,
requireConfirmation: cfg.RequireConfirmation, // HITL 见第七节
requireConfirmationProvider: confirmWrapper,
}, nil
}
func resolvedSchema[T any](override *jsonschema.Schema) (*jsonschema.Resolved, error) {
if override != nil { // ③ 用户显式给了 schema 就用它
return override.Resolve(nil)
}
schema, err := jsonschema.For[T](nil) // ④ 否则从类型自动推断
return schema.Resolve(nil)
}
💡 关键库:jsonschema-go
注意第 ④ 步用的不是手写 reflect,而是
github.com/google/jsonschema-go的jsonschema.For[T]()。这个库专门做「Go 类型 → JSON Schema」,它能识别jsontag(字段名映射)、jsonschematag(描述、约束),还支持omitempty、嵌套结构、枚举、oneOf 等复杂场景。ADK 没有重新发明轮子,而是复用了这个高质量库。这也意味着:你熟悉的标准 Go JSON tag,在这里全部生效。
2.2 Func 签名:泛型约束
handler 的类型是 Func[TArgs, TResults any],定义只有一行,但信息量大:
// Func represents a Go function that can be wrapped in a tool.
type Func[TArgs, TResults any] func(agent.Context, TArgs) (TResults, error)
这意味着 handler 必须是 func(agent.Context, TArgs) (TResults, error) 这个签名。常见疑问:
必须第一个参数是 agent.Context吗? 是的。如果不需要上下文,用_ agent.Context。Context 提供State()、Actions()、ToolConfirmation()等能力(见第七节 HITL)。TArgs 可以是 struct{}(空结构体)吗? 可以,表示「无参数工具」。但注意源码第 ① 步要求必须是 struct/map,不能是基础类型(如string)。TResults 可以是 struct{}吗? 可以。返回struct{}时,Run最终返回{"result": {}}。
三、Schema 推断细节:tag 规则与覆盖
3.1 两个 tag 的作用
jsonschema-go 主要看两个 tag:
| tag | 作用 | 示例 |
|---|---|---|
json:"name" |
JSON 字段名(LLM 调用时用的 key) | City string \json:"city"`→ LLM 传{"city": ...}` |
jsonschema:"描述或约束" |
字段的 description / 枚举 / 数值范围等 | \`jsonschema:"the city to query"\` → schema 的 description 字段 |
type OrderInput struct {
Item string `json:"item" jsonschema:"name of the food item"`
Quantity int `json:"quantity" jsonschema:"quantity ordered,minimum=1,maximum=99"`
Size string `json:"size,omitempty" jsonschema:"size of the order,enum=small,enum=medium,enum=large"`
}
推断出的 JSON Schema 大致是:
{
"type": "object",
"properties": {
"item": {"type": "string", "description": "name of the food item"},
"quantity": {"type": "integer", "minimum": 1, "maximum": 99},
"size": {"type": "string", "enum": ["small", "medium", "large"]}
},
"required": ["item", "quantity"] // size 有 omitempty → 非必需
}
3.2 用 InputSchema 手动覆盖
自动推断有时不够(比如需要 $ref、复杂 oneOf、或与外部 schema 对齐)。这时用 Config.InputSchema / Config.OutputSchema 手动传 *jsonschema.Schema:
⚠️ 覆盖关系
源码
resolvedSchema逻辑:override 非空就用 override,否则才推断。但注意注释// TODO: check if override schema is compatible with T——目前框架不校验你给的 schema 和 Go 类型是否兼容。如果你的 schema 声明了字段但 Go 类型没有,或类型对不上,Run时反序列化会失败。所以手动覆盖是「双刃剑」,尽量用自动推断 + tag。
四、Run 方法:从 map[string]any 到强类型往返
LLM 传来的参数是 JSON,到 Go 这边是 map[string]any。但你的 handler 要的是强类型 TArgs。functionTool.Run 负责这个往返转换。看核心流程(精简后):
func (f *functionTool[TArgs, TResults]) Run(ctx agent.Context, args any) (map[string]any, error) {
// 0. panic 兜底:handler 崩了不能炸掉整个 agent run
defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic in tool %q: %v", f.Name(), r) } }()
// ① map[string]any → TArgs(借助 inputSchema 做类型转换/校验)
m := args.(map[string]any)
input, err := typeutil.ConvertToWithJSONSchema[map[string]any, TArgs](m, f.inputSchema)
// ② HITL 确认检查(见第七节)
if confirmation := ctx.ToolConfirmation(); confirmation != nil { ... }
// ③ 调用真正的 handler
output, err := f.handler(ctx, input)
// ④ TResults → map[string]any(回程序列化)
resp, err := typeutil.ConvertToWithJSONSchema[TResults, map[string]any](output, f.outputSchema)
return resp, nil
}
🔑 三个容易被忽略的细节
① panic 兜底:handler 里哪怕
nil pointer dereference,也不会让整个 agent run 崩溃——它被 recover 成一个 error 返回给 LLM,LLM 还有机会换参数重试。这就是 ADK 工具调用的健壮性来源。② ConvertToWithJSONSchema:不是简单
json.Unmarshal。它借助 schema 做类型宽松转换(比如 LLM 传"42"但字段是int,能纠正)。这降低了 LLM 输出不精确导致的失败率。③ 返回必须是 map:源码注释引用了 Python 实现——
if not isinstance(function_result, dict): function_result = {'result': function_result}。如果TResults不是 struct(少见),框架会包成{"result": output}。
五、Toolset:动态工具集与过滤器
到目前为止,工具都是静态的——构造时确定。Toolset 解决的是动态工具场景:每次 agent 被激活时,工具列表可能不同。
5.1 Toolset 接口
type Toolset interface {
Name() string
// Tools 每次被调用,可能返回不同的工具列表
Tools(ctx agent.ReadonlyContext) ([]Tool, error)
}
注意参数是 ReadonlyContext——只读上下文。Tools() 可以根据当前会话状态(用户身份、权限、上下文)返回不同的工具子集。典型场景:
权限控制:普通用户看到 3 个工具,管理员多看到「删除」「审核」工具。 按需加载:MCP toolset 每次 Tools()都连服务器拉最新工具列表。上下文相关:「订单已创建」状态才暴露「支付」工具。
5.2 FilterToolset:声明式过滤
最常用的 Toolset 包装器是 tool.FilterToolset,它用谓词 Predicate 过滤:
type Predicate func(ctx agent.ReadonlyContext, tool Tool) bool
// AllowedToolsPredicate:只放行指定名字的工具
func AllowedToolsPredicate(allowedTools []string) Predicate { ... }
// FilterToolset:把一个 toolset + 谓词 → 新 toolset
func FilterToolset(toolset Toolset, predicate Predicate) Toolset { ... }
用法(配合 mcptoolset 演示,MCP 工具集是 Toolset 最典型的实现):
mcpSet, err := mcptoolset.New(mcptoolset.Config{
Endpoint: "http://localhost:8080/mcp",
})
if err != nil { return err }
// 只让 agent 看到 read_file / list_files 两个工具,屏蔽 write/delete
safeSet := tool.FilterToolset(mcpSet, tool.AllowedToolsPredicate([]string{
"read_file", "list_files",
}))
a, err := llmagent.New(llmagent.Config{
Name: "reader", Model: model,
Toolsets: []tool.Toolset{safeSet}, // 注意是 Toolsets 不是 Tools
})
5.3 agent 如何同时使用 Tools 和 Toolsets
第 02 篇讲过 Config.Tools 和 Config.Toolsets 是两个字段。运行时,ToolsRequestProcessor 会把它们合并:
Config.Tools + Config.Toolsets → 最终工具集
Config.Tools []tool.Tool (静态工具)
│
▼ 直接加入
┌─────────────────────────────────────────┐
│ 对每个 tool 调 ProcessRequest/PackTool │
└─────────────────────────────────────────┘
│
Config.Toolsets []tool.Toolset (动态工具集)
│
▼ 对每个 toolset 调 t.Tools(ctx) 拿到 []Tool
▼ 再对每个 tool 调 ProcessRequest/PackTool
│
▼
LLMRequest.Tools[name] (合并后的最终 map)
注意:Toolset 的 Tools() 每次调用都执行——
所以 MCP 工具列表的更新、权限过滤都是「运行时实时」的。
六、agent-as-tool:把 agent 包装成工具的三条路径 ⭐
第 02 篇讲 installTaskTools 时提到,SingleTurn/Task 子 agent 会被自动包装成工具。本节揭开这个「包装」的实现。ADK 里有三条路径把 agent 变成工具,适用于不同场景:
6.1 路径对比
| 路径 | 构造方式 | 何时发生 | 底层工具 | 谁用 |
|---|---|---|---|---|
| A. agenttool.New | 用户显式调用 | 写代码时手动 | agenttool.agentTool |
用户想手动把某 agent 挂成工具(如 multipletools 示例) |
| B. installTaskTools | 框架自动装配 | llmagent.New 构造末尾 |
SingleTurnTool / TaskAgentTool |
子 agent 设了 Mode=SingleTurn/Task 时自动触发 |
| C. workflow AgentNode | 节点执行 | workflow 运行时 | 不包装成 function,而是图节点 | workflow 内部协作(第 09 篇详讲) |
6.2 路径 A:agenttool.New(显式包装)
最直接的方式——手动把任意 agent 包成一个工具:
// New 把任意 agent 包成 tool.Tool
func New(agent agent.Agent, cfg *Config) tool.Tool {
return &agentTool{agent: agent, skipSummarization: cfg == nil || !cfg.SkipSummarization}
}
// Declaration:用 agent 的 InputSchema 生成 function 声明
// 如果 agent 没 InputSchema,用一个默认的 {"request": string} 参数
func (t *agentTool) Declaration() *genai.FunctionDeclaration {
return workflowinternal.MakeFunctionDeclaration(t.agent)
}
// Run:核心是「新建一个 runner 跑子 agent」
func (t *agentTool) Run(toolCtx agent.Context, args any) (map[string]any, error) {
// ① 把 args 转成 Content(子 agent 的输入)
content := genai.NewContentFromText(inputText, genai.RoleUser)
// ② ⭐ 新建一个独立的 session + runner 跑这个子 agent
sessionService := session.InMemoryService()
r, _ := runner.New(runner.Config{
Agent: t.agent, SessionService: sessionService, ...,
})
// ③ 把父级 state(非 _adk 内部键)拷给子 session,保证上下文延续
subSession, _ := sessionService.Create(toolCtx, &session.CreateRequest{
State: stateMap, // 过滤掉 _adk 前缀的内部 state
})
// ④ 跑子 agent,收集最后一个有内容的 event
for event, err := range r.Run(toolCtx, ..., content, ...) {
if event.LLMResponse.Content != nil { lastEvent = event }
}
// ⑤ 把子 agent 的最终文本返回(如有 OutputSchema 则校验后返回结构化)
return map[string]any{"result": outputText}, nil
}
🔑 agenttool 的关键设计:独立 session 隔离
注意第 ② 步——
agenttool.Run给子 agent 新建了一个 InMemory session,而不是复用父 agent 的会话。这意味着:
历史隔离:子 agent 看不到父 agent 的完整对话历史,只看到传入的 args 转成的单条消息。 状态继承但有过滤:第 ③ 步拷贝父级 state 时,过滤掉 _adk前缀的内部 state(如_adk_session_id),只带业务 state。这避免了内部框架状态污染子 agent。结果提取:取最后一个有 Content 的 event 作为结果。如果子 agent 有 OutputSchema,会用 utils.ValidateOutputSchema校验后返回结构化 map。这就是「agent-as-tool」与「transfer」的根本区别——transfer 是控制权移交(共享历史),agenttool 是函数调用(隔离历史)。第 08 篇会深入对比。
6.3 路径 B:installTaskTools 的自动装配(回顾第 02 篇)
第 02 篇讲过 installTaskTools 会按子 agent 的 Mode 调用 workflowinternal.NewSingleTurnTool 或 NewTaskAgentTool。现在看 SingleTurnTool 的实现,对比 agenttool 的差异:
func (t *SingleTurnTool) Run(toolCtx agent.Context, args any) (map[string]any, error) {
margs := args.(map[string]any)
// ① 校验入参(按 InputSchema)
var nodeInput any
if t.funcDeclaration.Parameters != nil {
utils.ValidateMapOnSchema(margs, t.funcDeclaration.Parameters, true)
nodeInput = margs
} else {
nodeInput = margs["request"]
}
// ② ⭐ 关键差异:不新建 runner,而是把 agent 包成 AgentNode,用 workflow 引擎跑
node, _ := workflow.NewAgentNode(t.agent, workflow.NodeConfig{})
result, err := workflow.RunNode[any](toolCtx, node, nodeInput, workflow.WithUseSubBranch())
return map[string]any{"result": result}, nil
}
💡 agenttool vs SingleTurnTool 的执行差异
两者都把 agent 包装成工具,但执行模型不同:
agenttool:新建独立 runner + InMemory session(完全隔离)。 SingleTurnTool:用 workflow.RunNode + WithUseSubBranch,在当前 invocation 里开一个子分支执行(共享部分上下文,通过 Branch 机制隔离历史)。这个差异决定了它们的使用场景:
agenttool适合「完全独立的无状态调用」,SingleTurnTool(由installTaskTools自动装配)适合「需要继承部分父上下文的委托」。Branch/IsolationScope 的细节在第 07、08 篇展开。
6.4 一个完整的多 agent-as-tool 示例
下面这个例子综合演示路径 A(agenttool)和路径 B(installTaskTools 自动装配),全部用 OpenAI:
// --- 定义工具函数 ---
type GeocodeInput struct { City string `json:"city"` }
type GeocodeOutput struct { Lat, Lon float64 }
func geocode(_ agent.Context, in GeocodeInput) (GeocodeOutput, error) {
return GeocodeOutput{Lat: 47.37, Lon: 8.54}, nil // Zurich
}
geoTool, _ := functiontool.New(functiontool.Config{
Name: "geocode", Description: "city → lat/lon",
}, geocode)
// --- 路径 B-1:ModeSingleTurn 子 agent(installTaskTools 自动包成工具)---
// 无需手动 agenttool.New,设 Mode 即可
weatherAgent, _ := llmagent.New(llmagent.Config{
Name: "weather_agent", Model: model,
Mode: llmagent.ModeSingleTurn, // ⭐ 触发 installTaskTools
Tools: []tool.Tool{geoTool},
Instruction: "自主调 geocode 后报告天气,不与用户对话。",
})
// --- 路径 A:agenttool.New 显式包装(适合想把任意 agent 挂成工具)---
// 比如把一个 remote agent 或 custom agent 挂到 root
poemAgent, _ := llmagent.New(llmagent.Config{/* ... */})
poemAsTool := agenttool.New(poemAgent, nil) // 手动包装
// --- root:同时用自动装配和显式包装 ---
root, _ := llmagent.New(llmagent.Config{
Name: "root", Model: model,
SubAgents: []agent.Agent{weatherAgent}, // SingleTurn 自动变工具
Tools: []tool.Tool{poemAsTool}, // agenttool 显式挂载
})
// 构造后 root.Tools 实际包含:[poemAsTool, weather_agent(SingleTurnTool自动注入)]
七、HITL 确认:三种粒度的拦截
Human-in-the-Loop(人在环上)确认是工具系统的重要能力——某些工具(如「删除订单」「转账」)执行前必须人工批准。ADK 提供三种粒度:
7.1 粒度对比
| 粒度 | 配置方式 | 适用场景 |
|---|---|---|
| 工具级(静态) | functiontool.Config{RequireConfirmation: true} |
工具永远需要确认 |
| 工具级(动态) | Config.RequireConfirmationProvider func(args) bool |
根据参数决定(如「金额>1000 才确认」) |
| Toolset 级 | tool.WithConfirmation(ts, true, provider) |
整个工具集的所有工具统一确认 |
| 工具内部 | handler 里返回 tool.ErrConfirmationRequired |
运行时根据业务逻辑主动暂停 |
7.2 工作机制:sentinel error 短路
所有粒度最终都走同一条短路路径。看 functionTool.Run 里的 HITL 检查:
// 情况 1:已经收到用户的确认回应(重新进入 Run)
if confirmation := ctx.ToolConfirmation(); confirmation != nil {
if !confirmation.Confirmed {
return nil, fmt.Errorf("error tool %q %w", f.Name(), tool.ErrConfirmationRejected)
}
// Confirmed=true → 继续往下执行 handler
} else {
// 情况 2:首次调用,判断是否需要确认
requireConfirmation := f.requireConfirmation
if f.requireConfirmationProvider != nil {
requireConfirmation = f.requireConfirmationProvider(input) // 动态判断
}
if requireConfirmation {
ctx.RequestConfirmation("请批准...", nil) // ⭐ 发起确认请求
ctx.Actions().SkipSummarization = true
return nil, fmt.Errorf("... %w", tool.ErrConfirmationRequired) // ⭐ sentinel 短路
}
}
// 不需要确认 → 正常执行 handler
output, err := f.handler(ctx, input)
HITL 确认的「暂停-恢复」时序
step N:
LLM → call delete_order({id:123})
framework → functionTool.Run(ctx, args)
└─ ctx.RequestConfirmation(...) 发起请求
└─ return ErrConfirmationRequired ⇧ 短路,本 step 结束
event 携带 RequestedInput → 用户收到"是否确认删除?"
(用户在下一轮 invocation 回应 function_response)
step N+1(新一轮 invocation):
用户 → function_response(delete_order, {confirmed:true})
framework → functionTool.Run(ctx, args)
└─ ctx.ToolConfirmation() 非空且 Confirmed=true
└─ 正常执行 handler → 删除订单 → 返回结果
🔑 sentinel error 的妙处
tool.ErrConfirmationRequired是一个 sentinel error——它不是真正的失败,而是「需要暂停等人」的信号。框架捕获它后,会把当前 step 优雅地暂停(yield 带RequestedInput的事件),等下一轮用户回应。retryandreflect插件也会特意跳过它(errors.Is(err, tool.ErrConfirmationRequired)),不对它做重试。这种「用 error 表达控制流」的设计,让 HITL 能干净地嵌入现有 step-loop,无需特殊分支。
7.3 Toolset 级确认:WithConfirmation 包装器
当整个工具集都需要确认时(比如一批 MCP 工具全是写操作),用 tool.WithConfirmation:
// WithConfirmation 把一个 toolset 里所有可执行工具包上确认逻辑
func WithConfirmation(ts Toolset, requireConfirmation bool, provider ConfirmationProvider) Toolset {
return &confirmationToolset{toolset: ts, requireConfirmation: ..., requireConfirmationProvider: ...}
}
// confirmationToolset.Tools() 会把每个 runnableTool 包成 confirmationTool
// confirmationTool.Run 复用与 functionTool 相同的 HITL 检查逻辑
八、内置工具速览与长时运行工具
8.1 内置工具家族
tool/ 目录下的内置工具,覆盖了常见场景:
| 包 | 工具 | 用途 |
|---|---|---|
functiontool |
泛型包装器 | 最常用,本篇主角 |
agenttool |
agenttool.New | 把 agent 包成工具(路径 A) |
mcptoolset |
MCP 工具集 | 接入 MCP 协议的外部工具服务器 |
skilltoolset |
SKILL.md 工具 | 把 SKILL.md 协议文件暴露成工具 |
loadmemorytool |
load_memory | 从长期记忆加载相关内容 |
preloadmemorytool |
preload_memory | 预加载记忆到上下文 |
loadartifactstool |
load_artifacts | 加载命名产物(文件/数据) |
exitlooptool |
exit_loop | loopagent 里跳出循环 |
8.2 IsLongRunning:长时运行工具的特殊处理
tool.Tool.IsLongRunning() 返回 true 的工具,框架会特殊对待。看 functionTool.Declaration 如何给 LLM 提示:
if f.cfg.IsLongRunning {
instruction := "NOTE: This is a long-running operation. " +
"Do not call this tool again if it has already returned some intermediate or pending status."
decl.Description += "\n\n" + instruction // 给 LLM 的提醒
}
长时运行工具的语义:它可能先返回一个 resource id(表示任务已提交),稍后才真正完成。框架通过这个标记:
在 Declaration 里给 LLM 加提示,避免它重复调用同一个 pending 任务。 配合 Event.LongRunningToolIDs,让前端 UI 能显示「任务进行中」状态。在第 06 篇会讲: platform.RunTasks并发执行工具时,长时运行工具的重入策略与普通工具不同。
💡 什么时候设 IsLongRunning
当工具的语义是「启动一个异步任务并立即返回」时(如提交训练任务、发邮件、触发 CI),设
IsLongRunning: true。典型的同步工具(查天气、算数学、读数据库)不要设——会让 LLM 困惑。它更多是个「给框架和 UI 的提示标记」,不改变 Run 的执行方式。
九、小结与动手清单
核心要点回顾
🎯 本篇五个关键认知
冰山接口设计: tool.Tool公开只有 3 个方法,执行能力藏在internal/toolinternal的扩展接口(FunctionTool/StreamingFunctionTool/RequestProcessor)。这强制所有工具走构造器创建,框架得以统一织入 panic 恢复、HITL、tracing。functiontool.New 的魔法:用 jsonschema-go的jsonschema.For[T]()从 Go 类型推断 Schema,识别json+jsonschematag。Run借助 schema 做map[string]any ↔ 强类型的宽松转换,且 handler panic 会被 recover 成 error。Toolset 是动态工具的入口: Tools(ctx)每次调用可能返回不同工具。FilterToolset做声明式过滤,WithConfirmation做批量确认包装。MCP/Skill 工具集都是 Toolset 实现。agent-as-tool 三条路径:agenttool.New(显式,独立 session)、installTaskTools(按 Mode 自动装配,用 workflow 子分支)、workflow AgentNode(图节点)。前两者本篇讲透,第三者留到第 09 篇。 HITL 靠 sentinel error 短路: tool.ErrConfirmationRequired不是真失败,而是「暂停等人」信号。三粒度(静态 flag / 动态 Provider / Toolset 包装)最终都走这条短路路径,干净嵌入 step-loop。
动手清单(建议 1-2 天)
用 functiontool 写 5 个不同 schema 的工具:分别覆盖「无参数」「枚举字段」「嵌套结构」「数值范围约束」「可选字段(omitempty)」。每个都打印 Declaration()看推断出的 schema 是否符合预期。对比 agenttool vs installTaskTools:写一个 SingleTurn 子 agent,分别用 (a) SubAgents + Mode:ModeSingleTurn(自动装配)和 (b)Tools: []tool.Tool{agenttool.New(child, nil)}(显式包装)两种方式挂到 root。用BeforeModelCallbacks打印 root 的工具列表,对比差异。体验 HITL 暂停恢复:写一个 RequireConfirmation: true的工具,在 Web UI 里观察它如何暂停、如何等待用户确认后再继续。用 FilterToolset 过滤 MCP 工具:起一个 MCP server(或用 mock),用 AllowedToolsPredicate只放行部分工具,验证过滤生效。触发 handler panic:在 handler 里故意写 var p *int; *p = 1,观察它被 recover 成 error 返回给 LLM,而不会炸掉 agent run。
下一篇预告
第 **04 篇「Runner 引擎:一次 invocation 的总调度」**将拆解 runner.Run 的完整流程——工具如何与 step-loop 衔接?两条执行路径(Node vs Agent)的分叉点在哪?findAgentToRun 如何从 session 历史推断「这次该谁接管」?理解了 Runner,你就把 02 篇(agent 构造)、03 篇(工具)和执行引擎串成了完整闭环。
ADK Go 源码深度解读 · 03 · Tool 体系
核心源码:tool/tool.go · tool/functiontool/function.go · tool/agenttool/agent_tool.go · internal/toolinternal/tool.go · internal/workflowinternal/single_turn_tool.go
配套示例:examples/tools/multipletools · examples/multiagent/collaboration(agent-as-tool)
本系列代码示例统一使用 OpenAI 模型 · 基于 google/adk-go v2 源码整理
夜雨聆风