乐于分享
好东西不私藏

ADK Go 源码深度解读 03:Tool 体系,从 functiontool 到 agent-as-tool

ADK Go 源码深度解读 03:Tool 体系,从 functiontool 到 agent-as-tool

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[stringerror]
}

// RequestProcessor 是「能改请求的工具」——特殊工具(如 transfer)
type RequestProcessor interface {
    ProcessRequest(ctx agent.Context, req *model.LLMRequest) error
}

🔑 为什么公开接口这么小?

因为 internal/ 是 Go 的私有包,外部无法实现这些扩展接口——这保证了所有可执行的工具都必须通过 ADK 的构造器创建functiontool.Newagenttool.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 同时实现 FunctionToolRequestProcessor——前者提供执行能力,后者负责把自己的 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[TArgsTResults 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-gojsonschema.For[T]()。这个库专门做「Go 类型 → JSON Schema」,它能识别 json tag(字段名映射)、jsonschema tag(描述、约束),还支持 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 要的是强类型 TArgsfunctionTool.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.ToolsConfig.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.NewSingleTurnToolNewTaskAgentTool。现在看 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 的执行方式。


九、小结与动手清单

核心要点回顾

🎯 本篇五个关键认知

  1. 冰山接口设计tool.Tool 公开只有 3 个方法,执行能力藏在 internal/toolinternal 的扩展接口(FunctionTool/StreamingFunctionTool/RequestProcessor)。这强制所有工具走构造器创建,框架得以统一织入 panic 恢复、HITL、tracing。
  2. functiontool.New 的魔法:用 jsonschema-gojsonschema.For[T]() 从 Go 类型推断 Schema,识别 json + jsonschema tag。Run 借助 schema 做 map[string]any ↔ 强类型 的宽松转换,且 handler panic 会被 recover 成 error。
  3. Toolset 是动态工具的入口Tools(ctx) 每次调用可能返回不同工具。FilterToolset 做声明式过滤,WithConfirmation 做批量确认包装。MCP/Skill 工具集都是 Toolset 实现。
  4. agent-as-tool 三条路径:agenttool.New(显式,独立 session)、installTaskTools(按 Mode 自动装配,用 workflow 子分支)、workflow AgentNode(图节点)。前两者本篇讲透,第三者留到第 09 篇。
  5. HITL 靠 sentinel error 短路tool.ErrConfirmationRequired 不是真失败,而是「暂停等人」信号。三粒度(静态 flag / 动态 Provider / Toolset 包装)最终都走这条短路路径,干净嵌入 step-loop。

动手清单(建议 1-2 天)

  1. 用 functiontool 写 5 个不同 schema 的工具:分别覆盖「无参数」「枚举字段」「嵌套结构」「数值范围约束」「可选字段(omitempty)」。每个都打印 Declaration() 看推断出的 schema 是否符合预期。
  2. 对比 agenttool vs installTaskTools:写一个 SingleTurn 子 agent,分别用 (a) SubAgents + Mode:ModeSingleTurn(自动装配)和 (b) Tools: []tool.Tool{agenttool.New(child, nil)}(显式包装)两种方式挂到 root。用 BeforeModelCallbacks 打印 root 的工具列表,对比差异。
  3. 体验 HITL 暂停恢复:写一个 RequireConfirmation: true 的工具,在 Web UI 里观察它如何暂停、如何等待用户确认后再继续。
  4. 用 FilterToolset 过滤 MCP 工具:起一个 MCP server(或用 mock),用 AllowedToolsPredicate 只放行部分工具,验证过滤生效。
  5. 触发 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 源码整理