跳转至

Tool 工具使用文档

Tool 工具系统是 tRPC-Agent-Go 框架的核心组件,为 Agent 提供了与外部服务和功能交互的能力。框架支持多种工具类型,包括函数工具和基于 MCP(Model Context Protocol)标准的外部工具集成。

概述

🎯 核心特性

  • 🔧 多类型工具:支持函数工具(Function Tools)和 MCP 标准工具
  • 🌊 流式响应:支持实时流式响应和普通响应两种模式
  • ⚡ 并行执行:工具调用支持并行执行以提升性能
  • 🔄 MCP 协议:完整支持 STDIO、SSE、Streamable HTTP 三种传输方式
  • 🔁 单次调用重试:支持在 LLMAgent / Graph ToolsNode 上为单次 CallableTool 调用配置重试
  • 🛠️ 配置支持:提供配置选项和过滤器支持
  • 🧹 参数修复:可选启用 agent.WithToolCallArgumentsJSONRepairEnabled(true),对 tool_callsarguments 做一次尽力 JSON 修复,提升工具执行与外部解析的鲁棒性

核心概念

🔧 Tool(工具)

Tool 是单个功能的抽象,实现 tool.Tool 接口。每个 Tool 提供特定的能力,如数学计算、搜索、时间查询等。

1
2
3
4
5
6
7
8
type Tool interface {
    Declaration() *Declaration  // 返回工具元数据
}

type CallableTool interface {
    Call(ctx context.Context, jsonArgs []byte) (any, error)
    Tool
}

建议(务必配置 name 与 description)

  • name(必填):用于让模型精确定位要调用的工具。请保证 稳定、唯一、语义明确(建议使用 snake_case),不要在不同工具/不同 ToolSet 之间重名。
  • description(必填):用于让模型理解“这个工具做什么/何时该用/有什么约束”。没有清晰的描述会显著降低 tool call 的命中率与稳定性。

对于 Function Tool:通过 function.WithName(...) / function.WithDescription(...) 配置;对于自定义 Tool:在 Declaration() 返回的 tool.Declaration 中设置 Name / Description

🛡️ Tool Metadata 与权限策略

Tool Metadata 是可选的执行行为描述,不改变核心 tool.Tool 接口。

当宿主、策略或 UI 需要判断一个工具是否只读、是否具有破坏性、是否可并发、是否主要用于搜索/读取、是否会访问外部世界,或是否声明了建议的结果大小上限时,可以让工具实现 metadata。

type ToolMetadata struct {
    ReadOnly        bool
    Destructive     bool
    ConcurrencySafe bool
    SearchOrRead    bool
    OpenWorld       bool
    MaxResultSize   int
}

type MetadataProvider interface {
    ToolMetadata() tool.ToolMetadata
}

直接使用 MCP ToolSet 暴露出来的工具,也会从显式 MCP annotations 生成 metadata:

MCP annotation ToolMetadata 字段
readOnlyHint ReadOnly
destructiveHint Destructive
openWorldHint OpenWorld

框架只映射 MCP server 显式返回的 hint。如果 MCP server 没有返回 destructiveHintopenWorldHintToolMetadata 会保持 Go 零值 (false)。这与 MCP 规范的默认 hint 语义不同:MCP 中非只读工具的 destructiveHint 默认是 trueopenWorldHint 默认也是 true。 由于 ToolMetadata 使用普通 bool 字段,无法区分“未设置”和“显式 false”。如果你的 permission policy 需要遵循 MCP 默认语义,请对非只读 MCP 工具采用更保守的策略,除非业务侧还有额外的可信信号。

MCP annotations 没有与 SearchOrReadConcurrencySafe 对应的字段。 框架也不会把 readOnlyHintidempotentHint 推断为并发安全信号。

权限策略发生在模型已经发起 tool call、框架完成 JSON 修复和 before-tool callbacks 参数改写、并且即将真正执行工具之前:

runner.Run(ctx, userID, sessionID, message,
    agent.WithToolPermissionPolicyFunc(
        func(ctx context.Context, req *tool.PermissionRequest) (tool.PermissionDecision, error) {
            if req.Metadata.Destructive {
                return tool.AskPermission("destructive tools require approval"), nil
            }
            return tool.AllowPermission(), nil
        },
    ),
)

工具也可以实现 tool.PermissionChecker 来声明自己的强约束。工具级检查先于本次运行的 policy 执行,遇到第一个非 allow 决策就停止。没有实现 checker 的工具,在配置了本次运行 policy 时仍然会被 policy 检查;如果工具 checker 和本次运行 policy 都不存在,则保持旧行为,默认允许执行。

决策语义:

  • tool.AllowPermission():执行工具。
  • tool.DenyPermission(reason):不执行工具,并向模型返回结构化的 denied 工具结果。
  • tool.AskPermission(reason):不执行工具,并向模型返回结构化的 approval_required 工具结果。

如果你的应用有真正的审批 UI,请在 policy 内部完成询问,并在用户同意后返回 tool.AllowPermission()。框架不会为 ask 自行发明一套 UI 交互流程。

几个机制的边界需要区分清楚:

  • agent.WithToolFilter(...):控制哪些工具对模型可见。
  • agent.WithToolExecutionFilter(...):让部分已可见的 tool call 留给调用方外部执行。
  • agent.WithToolPermissionPolicy(...):对框架即将执行的每个工具做权限判断。
  • Tool callbacks 与 guardrail plugins 仍然适合做鉴权、审计、自动审批评估等流程。简单确定性的 allow/deny/ask 判断,优先使用 permission policy。

📦 ToolSet(工具集)

ToolSet 是一组相关工具的集合,实现 tool.ToolSet 接口。ToolSet 负责管理工具的生命周期、连接和资源清理。

type ToolSet interface {
    // 返回当前工具集内的工具
    Tools(context.Context) []tool.Tool

    // 释放工具集持有的资源
    Close() error

    // 返回该工具集的名称,用于标识与冲突处理
    Name() string
}

Tool 与 ToolSet 的关系:

  • 一个 "Tool" = 一个具体功能(如计算器)
  • 一个 "ToolSet" = 一组相关的 Tool(如 MCP 服务器提供的所有工具)
  • Agent 可以同时使用多个 Tool 和多个 ToolSet

🌊 流式工具支持

框架支持流式工具,提供实时响应能力:

// 流式工具接口
type StreamableTool interface {
    StreamableCall(ctx context.Context, jsonArgs []byte) (*StreamReader, error)
    Tool
}

// 流式数据单元
type StreamChunk struct {
    Content  any      `json:"content"`
    Metadata Metadata `json:"metadata,omitempty"`
}

流式工具特点:

  • 🚀 实时响应:数据逐步返回,无需等待完整结果
  • 📊 大数据处理:适用于日志查询、数据分析等场景
  • 用户体验:提供即时反馈和进度显示

工具类型说明

工具类型 定义 集成方式
Function Tools 直接调用 Go 函数实现的工具 Tool 接口,进程内调用
Agent Tool (AgentTool) 将任意 Agent 包装为可调用工具 Tool 接口,支持流式内部转发
DuckDuckGo Tool 基于 DuckDuckGo API 的搜索工具 Tool 接口,HTTP API
MCP ToolSet 基于 MCP 协议的外部工具集 ToolSet 接口,支持多种传输方式

📖 相关文档:Agent 间协作相关的 Agent Tool 和 Transfer Tool 请参考 多 Agent 系统文档

Function Tools 函数工具

Function Tools 通过 Go 函数直接实现工具逻辑,是最简单直接的工具类型。

基本用法

import "trpc.group/trpc-go/trpc-agent-go/tool/function"

// 1. 定义工具函数
func calculator(ctx context.Context, req struct {
    Operation string  `json:"operation" jsonschema:"description=运算类型,例如 add/multiply"`
    A         float64 `json:"a" jsonschema:"description=第一个操作数"`
    B         float64 `json:"b" jsonschema:"description=第二个操作数"`
}) (map[string]interface{}, error) {
    switch req.Operation {
    case "add":
        return map[string]interface{}{"result": req.A + req.B}, nil
    case "multiply":
        return map[string]interface{}{"result": req.A * req.B}, nil
    default:
        return nil, fmt.Errorf("unsupported operation: %s", req.Operation)
    }
}

// 2. 创建工具
calculatorTool := function.NewFunctionTool(
    calculator,
    function.WithName("calculator"),
    function.WithDescription("执行数学运算"),
)

// 3. 集成到 Agent
agent := llmagent.New("math-assistant",
    llmagent.WithModel(model),
    llmagent.WithTools([]tool.Tool{calculatorTool}))

Input Schema(入参 schema)与字段描述

Function Tool 的入参 req 会自动生成对应的 JSON Schema(用于模型理解参数结构)。建议通过 struct tag 补充字段描述:

  • 字段名:使用 json:"..." 作为 schema 的字段名。
  • 字段描述(推荐):使用 jsonschema:"description=..." 写入 schema 的 properties.<field>.description
  • 注意jsonschema tag 内部使用英文逗号 , 作为分隔符,因此 description 内容中不能包含 ,,否则会被误解析成多个 tag。
  • 兼容:也支持 description:"..." 作为字段描述(用于历史代码);若同时配置 jsonschema:"description=..."description:"...",以 jsonschema 中的 description 为准。
  • 更灵活的 schema:如果想完全自定义入参 schema(例如需要更复杂的 JSON Schema 结构/约束),可使用 function.WithInputSchema(customInputSchema) 跳过自动生成。

流式工具示例

// 1. 定义输入输出结构
type weatherInput struct {
    Location string `json:"location" jsonschema:"description=查询地点,例如城市名或经纬度"`
}

type weatherOutput struct {
    Weather string `json:"weather"`
}

// 2. 实现流式工具函数
func getStreamableWeather(input weatherInput) *tool.StreamReader {
    stream := tool.NewStream(10)
    go func() {
        defer stream.Writer.Close()

        // 模拟逐步返回天气数据
        result := "Sunny, 25°C in " + input.Location
        for i := 0; i < len(result); i++ {
            chunk := tool.StreamChunk{
                Content: weatherOutput{
                    Weather: result[i : i+1],
                },
                Metadata: tool.Metadata{CreatedAt: time.Now()},
            }

            if closed := stream.Writer.Send(chunk, nil); closed {
                break
            }
            time.Sleep(10 * time.Millisecond) // 模拟延迟
        }
    }()

    return stream.Reader
}

// 3. 创建流式工具
weatherStreamTool := function.NewStreamableFunctionTool[weatherInput, weatherOutput](
    getStreamableWeather,
    function.WithName("get_weather_stream"),
    function.WithDescription("流式获取天气信息"),
)

// 4. 使用流式工具
reader, err := weatherStreamTool.StreamableCall(ctx, jsonArgs)
if err != nil {
    return err
}

// 接收流式数据
for {
    chunk, err := reader.Recv()
    if err == io.EOF {
        break // 流结束
    }
    if err != nil {
        return err
    }

    // 处理每个数据块
    fmt.Printf("收到数据: %v\n", chunk.Content)
}
reader.Close()

在 Tool 实现里获取 Tool Call ID

当模型发出一条 tool_call 后,框架会在真正执行工具前,把这次调用的 tool_call_id 注入到工具执行的 context.Context 中。

这意味着:在你自己的 Tool 实现里,框架支持直接读取这条 ID。

这个能力对以下场景特别有用:

  • 同名工具并发调用时,为每次调用生成不冲突的状态键
  • 给日志、监控、埋点、trace 打上稳定的工具调用标识
  • 当工具内部再触发一个子 Agent 时,把“这个子 Agent 来自哪条 tool_call”传给 UI 或上层编排逻辑

当前这套机制适用于:

  • LLMAgent 中的普通函数工具
  • LLMAgent 中的流式工具
  • GraphAgent 的工具执行节点
  • Tool callbacks / plugins(回调参数里也会带 ToolCallID

最直接的用法

在工具实现中调用 tool.ToolCallIDFromContext(ctx) 即可:

const defaultToolCallID = "default"

type searchArgs struct {
    Query string `json:"query"`
}

func searchDocs(
    ctx context.Context,
    args searchArgs,
) (map[string]any, error) {
    toolCallID, ok := tool.ToolCallIDFromContext(ctx)
    if !ok || toolCallID == "" {
        toolCallID = defaultToolCallID
    }

    log.Printf(
        "tool_call_id=%s query=%s",
        toolCallID,
        args.Query,
    )

    return map[string]any{
        "tool_call_id": toolCallID,
        "query":        args.Query,
    }, nil
}

如果你只是想在 Tool 里打印日志、做指标、写 Invocation State,通常到这里就够了。

完整可运行示例可参考 examples/toolcallid

当 Tool 内部还要拉起子 Agent 时

这里要先区分两个概念:

  • tool_call_id:模型发出的“这一条工具调用”的 ID
  • InvocationID / ParentInvocationID:Agent 执行树里的父子调用关系

如果你的目标是“让 UI 把子 Agent 的输出挂到主 Agent 的某条工具调用下面”, 推荐把这两层信息都保留下来:

  1. tool.ToolCallIDFromContext(ctx) 取到当前 tool_call_id
  2. agent.InvocationFromContext(ctx) 取到父 Invocation
  3. parentInv.Clone(...) 创建子 Invocation
  4. tool_call_id 放进子 Invocation 的 RunOptions.RuntimeState
  5. UI 侧同时使用:
    • InvocationID / ParentInvocationID 建立调用树
    • 你传下去的 tool_call_id 绑定“来源于哪条 tool_call”

示例(假设 childAgent 已经是一个可运行的子 Agent 实例):

const runtimeStateParentToolCallID = "display.parent_tool_call_id"
const defaultToolCallID = "default"

type delegateArgs struct {
    Message string `json:"message"`
}

func runChildAgentInsideTool(
    ctx context.Context,
    args delegateArgs,
) (string, error) {
    toolCallID, ok := tool.ToolCallIDFromContext(ctx)
    if !ok || toolCallID == "" {
        toolCallID = defaultToolCallID
    }

    parentInv, ok := agent.InvocationFromContext(ctx)
    if !ok || parentInv == nil {
        return "", errors.New("missing parent invocation")
    }

    childRunOptions := parentInv.RunOptions
    childRunOptions.RuntimeState = make(
        map[string]any,
        len(parentInv.RunOptions.RuntimeState)+1,
    )
    for key, value := range parentInv.RunOptions.RuntimeState {
        childRunOptions.RuntimeState[key] = value
    }
    childRunOptions.RuntimeState[
        runtimeStateParentToolCallID
    ] = toolCallID

    childInv := parentInv.Clone(
        agent.WithInvocationAgent(childAgent),
        agent.WithInvocationMessage(
            model.NewUserMessage(args.Message),
        ),
        agent.WithInvocationRunOptions(childRunOptions),
    )

    childCtx := agent.NewInvocationContext(ctx, childInv)
    eventCh, err := agent.RunWithPlugins(
        childCtx,
        childInv,
        childAgent,
    )
    if err != nil {
        return "", err
    }

    var final string
    for ev := range eventCh {
        if ev.Response != nil && len(ev.Response.Choices) > 0 {
            msg := ev.Response.Choices[0].Message
            if msg.Content != "" {
                final = msg.Content
            }
        }
        // Child events naturally carry:
        // - ev.InvocationID       == childInv.InvocationID
        // - ev.ParentInvocationID == parentInv.InvocationID
        //
        // Your renderer can build the invocation tree from these two
        // fields, and read runtimeStateParentToolCallID from the child
        // invocation path to attach that subtree back to the original
        // tool-call card.
    }

    return final, nil
}

写入前先复制一份 RuntimeStateInvocation.Clone(...) 不会对 map 做深拷贝;如果直接复用并写入,就会连父 Invocation 一起改掉。

子 Agent 内部如果还需要继续读取这个“来源 tool_call_id”,可以直接从 运行时状态里拿:

1
2
3
4
toolCallID, ok := agent.GetRuntimeStateValueFromContext[string](
    ctx,
    runtimeStateParentToolCallID,
)

推荐实践

  • 如果你只需要“这一条工具调用”的标识,直接用 tool.ToolCallIDFromContext(ctx)
  • 如果你要表达“子 Agent 是谁触发的”,不要只依赖 tool_call_id 一个字段;调用树请优先看 InvocationID / ParentInvocationID
  • 如果 UI 还要回挂到“具体哪条工具卡片”,再把 tool_call_id 通过 RuntimeState 或自定义事件元数据显式传下去
  • 如果你用的是 AgentTool,框架已经会用 Invocation.Clone(...) 维护父子 Invocation 关系;UI 侧通常已经能看到清晰的父子调用树。 只有在你还想把子树额外绑定回某条 tool card 时,才需要再传 tool_call_id

一个容易忽略的细节

框架会在执行工具前把 tool_call_id 注入到 context。 但如果你的 BeforeTool 回调主动返回了一个全新的裸 Context (没有保留原值),那后续工具代码里就拿不到这个 ID 了。

因此,如果你会在回调里替换 context,记得把已有的 context value 一并透传。

内置工具类型

Tool 调用重试

当工具调用可能因为瞬时问题失败时,可以为它配置重试,例如:

  • 下游网络波动;
  • 临时超时;
  • 外部服务偶发异常。

这项能力默认关闭。当前仅对 CallableTool 生效,StreamableTool 暂不支持。开启后,框架只会重试当前这次工具调用,不会重跑整个 Agent 或整轮 Graph 工作流。

基本配置

1
2
3
4
5
6
7
policy := &tool.RetryPolicy{
    MaxAttempts:     3,
    InitialInterval: 200 * time.Millisecond,
    BackoffFactor:   2.0,
    MaxInterval:     2 * time.Second,
    Jitter:          true,
}

常用字段:

  • MaxAttempts:总尝试次数,包含第一次调用
  • InitialInterval:第二次尝试前的初始等待时间。
  • BackoffFactor:失败后退避倍数。
  • MaxInterval:等待时间上限。
  • Jitter:是否开启抖动。

默认判定规则

如果未提供 RetryOn,框架会使用 tool.DefaultRetryOn(...)

默认规则比较保守,只会重试常见的瞬时错误,例如:

  • io.EOF
  • io.ErrUnexpectedEOF
  • net.Error 中的 timeout / temporary 错误

context.Canceledcontext.DeadlineExceeded 以及结果级失败,默认不会重试。

自定义重试条件

如果默认规则不够,可以通过 RetryOn 自定义判定逻辑。推荐先复用 tool.DefaultRetryOn(...),再补充自己的条件:

policy := &tool.RetryPolicy{
    MaxAttempts:     2,
    InitialInterval: 200 * time.Millisecond,
    BackoffFactor:   2.0,
    MaxInterval:     time.Second,
    RetryOn: func(ctx context.Context, info *tool.RetryInfo) (bool, error) {
        retry, err := tool.DefaultRetryOn(ctx, info)
        if err != nil || retry {
            return retry, err
        }
        if info.ResultError {
            return true, nil
        }
        return false, nil
    },
}

tool.RetryInfo 里会带上当前调用的信息,例如工具名、当前是第几次尝试、原始错误、结果级失败标记等,方便你在 RetryOn 中做判断。

在 LLMAgent 中启用

1
2
3
4
5
6
agent := llmagent.New(
    "assistant",
    llmagent.WithModel(modelInstance),
    llmagent.WithTools([]tool.Tool{myTool}),
    llmagent.WithToolCallRetryPolicy(policy),
)

可运行示例:

在 Graph 中启用

1
2
3
4
5
sg.AddToolsNode(
    "tools",
    tools,
    graph.WithToolCallRetryPolicy(policy),
)

可运行示例:

DuckDuckGo 搜索工具

DuckDuckGo 工具基于 DuckDuckGo Instant Answer API,提供事实性、百科类信息搜索功能。

基础用法

1
2
3
4
5
6
7
8
9
import "trpc.group/trpc-go/trpc-agent-go/tool/duckduckgo"

// 创建 DuckDuckGo 搜索工具
searchTool := duckduckgo.NewTool()

// 集成到 Agent
searchAgent := llmagent.New("search-assistant",
    llmagent.WithModel(model),
    llmagent.WithTools([]tool.Tool{searchTool}))

高级配置

import (
    "net/http"
    "time"
    "trpc.group/trpc-go/trpc-agent-go/tool/duckduckgo"
)

// 自定义配置
searchTool := duckduckgo.NewTool(
    duckduckgo.WithBaseURL("https://api.duckduckgo.com"),
    duckduckgo.WithUserAgent("my-app/1.0"),
    duckduckgo.WithHTTPClient(&http.Client{
        Timeout: 15 * time.Second,
    }),
)

Claude Code ToolSet

tool/claudecode 提供了一组面向代码工作的 ToolSet,用于在框架内部暴露与 Claude Code 接近的工具接口。它覆盖文件读写、代码检索、命令执行和网页获取等能力,可以直接挂接到 LLMAgent 或其他运行时。如果你的目标是调用本地 Claude Code CLI,并消费 CLI 的执行轨迹与工具事件,请参考 Claude Code Agent 使用指南

从能力组成上看,claudecode 默认会提供一组代码工作流工具,包括 BashTaskStopTaskOutputReadGlobGrepWebFetchWebSearch。在非只读模式下,还会额外提供 WriteEditNotebookEdit

下表列出了当前 claudecode 工具集中的主要工具及其用途:

工具名 说明
Bash 执行本地 Shell 命令。
TaskStop 停止由 Bash 以后台模式启动的任务。
TaskOutput 读取后台任务的当前输出或最终输出。
Read 读取文件内容。
Glob 按路径模式查找文件。
Grep 按内容搜索仓库。
WebFetch 抓取指定 URL 的页面内容。
WebSearch 进行开放式网页搜索。
Write 创建文件或用完整内容覆盖文件,仅在非只读模式下暴露。
Edit 对已有文本文件做局部替换,仅在非只读模式下暴露。
NotebookEdit 按 cell 粒度编辑 .ipynb 文件,仅在非只读模式下暴露。

基本用法

import (
    "log"
    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/tool"
    "trpc.group/trpc-go/trpc-agent-go/tool/claudecode"
)

toolSet, err := claudecode.NewToolSet(
    claudecode.WithBaseDir("."),
)
if err != nil {
    log.Fatal(err)
}
defer toolSet.Close()

agent := llmagent.New(
    "claude-style-agent",
    llmagent.WithToolSets([]tool.ToolSet{toolSet}),
)

llmagent.WithToolSets(...) 会以 ToolSet 形式接入这组工具;如果调用 Tools(),则会得到展开后的单个工具列表。

常用配置

tool/claudecode 的配置重点围绕工作目录、只读模式和 Web 能力展开:

Option 说明
WithName(name) 覆盖 ToolSet 名称,默认值为 claudecode
WithBaseDir(dir) 指定工具集的基础目录。文件、检索和命令执行都会以此为基准。
WithReadOnly(readOnly) 启用只读模式后,不再暴露 WriteEditNotebookEdit
WithMaxFileSize(size) 限制单个文件可读取的最大尺寸。
WithWebFetchOptions(opts) 配置 WebFetch 的域名策略、超时与内容处理方式。
WithWebSearchOptions(opts) 配置 WebSearch 的后端、分页参数与请求选项。

WithBaseDir 定义了 ReadWriteEditGlobGrep 等文件相关工具的工作范围,也决定了 Bash 的默认执行目录。启用只读模式后,工具集只保留读取、检索、命令执行和 Web 相关能力;关闭只读模式后,会额外暴露 WriteEditNotebookEdit

Todo 工具

Todo 工具为 Agent 提供一份结构化、可跨轮持久化的任务清单。模型通过 todo_write 发布或更新当前计划;清单会被持久化到 session、随 tool result 事件返回给前端,并在每次写入后默认追加一段简短提示,督促模型边推进边更新状态。

适合下面这些场景:

  • 任务跨多个非平凡步骤,模型容易漏掉某一步;
  • 用户或下游 UI 需要在 Agent 工作过程中看到可见的进度;
  • 同一会话可能中途暂停(例如向用户追问信息),后续再续上原计划继续推进。

基础用法

import (
    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/tool"
    "trpc.group/trpc-go/trpc-agent-go/tool/todo"
)

todoTool := todo.New()

agent := llmagent.New("todo-assistant",
    llmagent.WithModel(model),
    llmagent.WithInstruction(todo.DefaultToolPrompt),
    llmagent.WithTools([]tool.Tool{todoTool}),
)

todo.DefaultToolPrompt 是开箱即用的 system instruction 片段,告诉模型何时调用 todo_write 以及如何撰写条目;你也可以替换成自己的版本,下文的运行时校验规则不受影响。

强制完成

默认情况下,todo_write 只是建议性工具:即使清单里还有未完成项,模型仍可能直接结束回复。若希望 Agent 必须完成清单后才能输出最终回复,可以安装 todoenforcer extension:

import (
    "trpc.group/trpc-go/trpc-agent-go/agent/extension/todoenforcer"
    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/tool/todo"
)

agent := llmagent.New("todo-assistant",
    llmagent.WithModel(model),
    llmagent.WithInstruction(todo.DefaultToolPrompt),
    llmagent.WithExtensions(todoenforcer.New()),
)

该 extension 会自动贡献 todo_writetodo_declare_blocker,不要再通过 WithTools 额外传入 todo.New()。如果需要复用 tool/todo 的选项(例如 WithStateKeyPrefixWithClearOnAllDoneWithNudgeHook),先构造 todo.New(...),再通过 todoenforcer.WithTodoTool(...) 传入。todo_declare_blocker 用于声明客观阻塞,例如缺少权限、凭据、基础设施或必须由用户决策的信息。

工具返回结构

todo_write 返回的是结构化结果而不是自由文本,因此终端、AG-UI、自研 HTTP 前端等任何调用方都可以直接消费:

1
2
3
4
5
type Output struct {
    Message  string `json:"message"`             // 默认 nudge + hook 输出
    Todos    []Item `json:"todos"`               // 本次写入后的当前清单
    OldTodos []Item `json:"oldTodos,omitempty"`  // 本次写入前的清单
}

TodosOldTodos 会直接挂在 tool result 事件上,AG-UI 客户端(或任何消费 tool_call_result 的前端)解码后即可渲染最新状态和 diff,不需要回查 session。examples/todo/ 中的终端示例内联了一个小型 ASCII 格式化函数;富前端可以按自己的风格渲染同一份结构化数据。

跨轮持久化与 Branch 隔离

每次写入都会作为 session state delta 发布,因此清单可以跨 Runner.Run 调用持久化;当后端 session service 是持久化实现时,跨进程同样有效。

清单按 Invocation.Branch 分键存储;当调用方没有显式设置 branch 时,框架会自动把它补成该 Agent 的名称(因此子 Agent 或 agent-tool 会读写自己的清单,不会覆盖父 Agent 的计划)。对应上面的基础接入(llmagent.New("todo-assistant", ...)),服务端读取应当写成:

1
2
3
items, err := todo.GetTodos(sess, "todo-assistant") // 即 agent 名称
// items == nil + err == nil → 还没有清单
// len(items) == 0           → 清单已被显式清空

如果你配置了自定义 branch(例如通过 WithInvocationBranch,或把当前 Agent 作为子 Agent 运行在父 Agent 下),就传入对应的 branch 值。只有当 Invocation.Branch 被显式置为 "" 时,传空字符串才会命中顶层槽位。

输入校验

每次调用都会经过下面这组检查;任意一条不满足都会让本次调用直接失败,session 状态不会被修改:

  • todos 字段必填且必须是数组。缺失字段或字面量 null 会被直接拒绝,不会被当作"清空全部"。要清空清单请显式发送 {"todos": []}
  • 每个条目必须包含非空的 content、非空的 activeForm,以及合法的 statuspending / in_progress / completed)。
  • 同一时刻最多一个条目处于 in_progress
  • content 在清单内必须唯一(精确字符串匹配,不做 trim / 大小写归一 / Unicode 归一)。

风格性建议——例如"实际工作期间始终恰好一个 in_progress"、条目措辞、何时值得调用 todo_write 等——放在 todo.DefaultToolPrompt 里,不在上述检查中强制;因此你可以根据自身业务或所用模型族调整 prompt,不需要改工具。

自定义

todoTool := todo.New(
    // 把清单存到自定义 state-key 命名空间下;默认布局已经按 branch
    // 隔离,大多数场景不需要改这个。
    todo.WithStateKeyPrefix("temp:plan"),
    // 默认情况下,所有条目变成 completed 后清单会被清空,让下一轮
    // 规划从零开始。设为 false 可以保留已完成条目。
    todo.WithClearOnAllDone(false),
    // 替换每次写入后默认追加的 nudge 文案。
    todo.WithNudgeMessage("继续推进;完成一项就更新一项。"),
    // 根据本次提交的清单和上一份清单的 diff 追加额外提示
    // (例如自定义 prompt、埋点、指标计算)。Hook 期望是只读的。
    todo.WithNudgeHook(func(ctx context.Context, oldTodos, submitted []todo.Item) string {
        return ""
    }),
)

基础工具的完整可运行示例(包含多轮暂停/续接场景与 ASCII 渲染器)见 examples/todo/。带强制完成对照的示例见 examples/todoenforcer/

MCP Tools 协议工具

MCP(Model Context Protocol)是一个开放协议,标准化了应用程序向 LLM 提供上下文的方式。MCP 工具基于 JSON-RPC 2.0 协议,为 Agent 提供了与外部服务的标准化集成能力。

MCP ToolSet 特点:

  • 🔗 统一接口:所有 MCP 工具都通过 mcp.NewMCPToolSet() 创建
  • 🚀 多种传输:支持 STDIO、SSE、Streamable HTTP 三种传输方式
  • 🔧 工具过滤:支持包含/排除特定工具
  • 显式初始化:通过 (*mcp.ToolSet).Init(ctx),可以在应用启动阶段提前发现 MCP 连接/工具加载错误并快速失败

基本用法

import "trpc.group/trpc-go/trpc-agent-go/tool/mcp"

// 创建 MCP 工具集(以 STDIO 为例)
mcpToolSet := mcp.NewMCPToolSet(
    mcp.ConnectionConfig{
        Transport: "stdio",           // 传输方式
        Command:   "go",              // 执行命令
        Args:      []string{"run", "./stdio_server/main.go"},
        Timeout:   10 * time.Second,
    },
    mcp.WithToolFilterFunc(tool.NewIncludeToolNamesFilter("echo", "add")), // 可选:工具过滤
)

// (可选但推荐)显式初始化 MCP:建立连接 + 初始化会话 + 列工具
if err := mcpToolSet.Init(ctx); err != nil {
    log.Fatalf("初始化 MCP 工具集失败: %v", err)
}

// 集成到 Agent
agent := llmagent.New("mcp-assistant",
    llmagent.WithModel(model),
    llmagent.WithToolSets([]tool.ToolSet{mcpToolSet}))

MCP Annotations 与权限 Metadata

当远端 MCP server 在 tools/list 中返回 tool annotations 时,直接通过 MCP ToolSet 暴露的工具会实现 tool.MetadataProvider。Permission policy 可以直接读取 req.Metadata.ReadOnlyreq.Metadata.Destructivereq.Metadata.OpenWorld,无需再解析 MCP 专属结构。

这个映射基于 tools/list 返回的工具快照。ToolSet 刷新工具列表时,会 重新构造框架内的工具列表,而不是原地修改已有 mcpTool 实例。如果未来 支持基于 MCP ToolListChangedNotification 的原地热更新,需要重新评估 metadata 读取的线程安全性。

ToolSet 生命周期与所有权

ToolSet 接口里显式提供了 Close(),这意味着它持有的连接、会话和缓存 等资源需要由创建它的一方负责释放。

几个容易混淆的边界:

  • llmagent.WithToolSets(...) 只是把 ToolSet 挂到 Agent 上使用, 不会转移其所有权。
  • LLMAgentAddToolSet(...)RemoveToolSet(...)SetToolSets(...) 只会更新 Agent 当前暴露的工具集合, 不会自动调用旧 ToolSetClose()
  • runner.NewRunner(...)runner.NewRunnerWithAgentFactory(...) 也不会因为 Agent 使用了某个 ToolSet,就在 Runner.Close() 时 自动回收它。

推荐的使用方式:

  • 长生命周期 ToolSet:在应用启动时创建并可选执行 Init(ctx), 多次请求复用;应用退出时统一 Close()
  • 按请求创建的 ToolSet:只在当前 run 内使用;当前 run 结束后由 调用方显式清理。

如果你只是希望 ToolSet 在每次执行时重新获取最新工具列表,优先使用 llmagent.WithRefreshToolSetsOnRun(true)。这会在每次 run 前重新调用 ToolSet.Tools(ctx),但不会为你重建或关闭 ToolSet 实例本身。

传输方式配置

MCP ToolSet 通过 Transport 字段支持三种传输方式:

1. STDIO 传输

通过标准输入输出与外部进程通信,适用于本地脚本和命令行工具。

mcpToolSet := mcp.NewMCPToolSet(
    mcp.ConnectionConfig{
        Transport: "stdio",
        Command:   "python",
        Args:      []string{"-m", "my_mcp_server"},
        Timeout:   10 * time.Second,
    },
)
if err := mcpToolSet.Init(ctx); err != nil {
    return fmt.Errorf("初始化 STDIO MCP 工具集失败: %w", err)
}

2. SSE 传输

使用 Server-Sent Events 进行通信,支持实时数据推送和流式响应。

mcpToolSet := mcp.NewMCPToolSet(
    mcp.ConnectionConfig{
        Transport: "sse",
        ServerURL: "http://localhost:8080/sse",
        Timeout:   10 * time.Second,
        Headers: map[string]string{
            "Authorization": "Bearer your-token",
        },
    },
)
if err := mcpToolSet.Init(ctx); err != nil {
    return fmt.Errorf("初始化 SSE MCP 工具集失败: %w", err)
}

3. Streamable HTTP 传输

使用标准 HTTP 协议进行通信,支持普通 HTTP 和流式响应。

mcpToolSet := mcp.NewMCPToolSet(
    mcp.ConnectionConfig{
        Transport: "streamable_http",  // 注意:使用完整名称
        ServerURL: "http://localhost:3000/mcp",
        Timeout:   10 * time.Second,
    },
)
if err := mcpToolSet.Init(ctx); err != nil {
    return fmt.Errorf("初始化 Streamable MCP 工具集失败: %w", err)
}

按请求 HTTP Header

对 SSE 和 Streamable HTTP 传输,可以使用上游 MCP 客户端提供的 mcp.WithHTTPBeforeRequest,在每一次 MCP HTTP 请求发出前,从当前调用 的 context 动态注入 header。一个长生命周期 MCP ToolSet 服务多个已登录 用户时,可以用它按用户注入 token、JWT 或业务签名。把 hook 通过 WithMCPOptions 传入即可:

import (
    tmcp "trpc.group/trpc-go/trpc-mcp-go"
    toolmcp "trpc.group/trpc-go/trpc-agent-go/tool/mcp"
)

mcpToolSet := toolmcp.NewMCPToolSet(
    toolmcp.ConnectionConfig{
        Transport: "streamable_http",
        ServerURL: "http://localhost:3000/mcp",
    },
    toolmcp.WithMCPOptions(tmcp.WithHTTPBeforeRequest(
        func(ctx context.Context, req *http.Request) error {
            token, ok := tokenFromContext(ctx)
            if !ok {
                return nil
            }
            req.Header.Set("Authorization", "Bearer "+token)
            return nil
        },
    )),
)

ConnectionConfig.Headers 提供的静态 header 会先生效;之后每次 HTTP 请求都会走一遍 before-request hook,hook 内对同名 header 的 Set 会 覆盖静态值。

如果你是通过 llmagent.WithToolSets(...) 挂载这个 ToolSet,并且希望 initialize / tools/list 也能拿到请求级 header,建议同时配合 llmagent.WithRefreshToolSetsOnRun(true),或者先手动 toolSet.Tools(ctx), 再通过 llmagent.WithTools(...) 注入。

会话重连支持

MCP ToolSet 支持自动会话重连,当服务器重启或会话过期时自动恢复连接。

1
2
3
4
5
6
7
8
9
// SSE/Streamable HTTP 传输支持会话重连
sseToolSet := mcp.NewMCPToolSet(
    mcp.ConnectionConfig{
        Transport: "sse",
        ServerURL: "http://localhost:8080/sse",
        Timeout:   10 * time.Second,
    },
    mcp.WithSessionReconnect(3), // 启用会话重连,最多尝试3次
)

重连特性:

  • 🔄 自动重连:检测到连接断开或会话过期时自动重建会话
  • 🎯 独立重试:每次工具调用独立计数,不会因早期失败影响后续调用
  • 🛡️ 保守策略:仅针对明确的连接/会话错误触发重连,避免配置错误导致的无限循环

MCP 工具的动态发现与更新(LLMAgent 配置项)

对于 MCP 工具集,服务器端的工具列表是可以变化的(例如在运行 过程中新增了一个 MCP 工具)。如果希望 LLMAgent 在每次调用 时自动看到最新的工具列表,可以在使用 WithToolSets 的同时, 开启 llmagent.WithRefreshToolSetsOnRun(true)

LLMAgent 配置示例

import (
    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/model/openai"
    "trpc.group/trpc-go/trpc-agent-go/tool"
    "trpc.group/trpc-go/trpc-agent-go/tool/mcp"
)

// 1. 创建 MCP 工具集(可以是 STDIO、SSE 或 Streamable HTTP)
mcpToolSet := mcp.NewMCPToolSet(connectionConfig)

// 2. 创建 LLMAgent,并开启 ToolSets 的自动刷新
agent := llmagent.New(
    "mcp-assistant",
    llmagent.WithModel(openai.New("gpt-4o-mini")),
    llmagent.WithToolSets([]tool.ToolSet{mcpToolSet}),
    llmagent.WithRefreshToolSetsOnRun(true),
)

当启用 WithRefreshToolSetsOnRun(true) 时:

  • LLMAgent 在每次执行前构造工具列表时,会再次调用 ToolSet.Tools(ctx),其中 ctx 为本次执行的上下文;
  • 如果 MCP 服务器新增或删除了工具,该 Agent 下一次执行 时, 会自动使用更新后的工具列表。
  • 如果你在“非执行期”获取工具(例如直接调用 agent.Tools()), LLMAgent 会使用 context.Background()

这个配置项的侧重点是动态发现工具。如果你还需要基于 context.Context 在初始化或工具发现阶段做更细粒度的控制,同时又不希望 在每次执行时刷新工具列表,可以参考 examples/mcptool/http_headers 示例,手动调用 toolSet.Tools(ctx),然后配合 WithTools 使用。

常见误区:

  • WithRefreshToolSetsOnRun(true) 刷新的是工具列表,不是 ToolSet 实例本身;它不会自动新建、替换或关闭 ToolSet
  • tools/call 会使用本次 run 的上下文,但如果你在执行期外直接调用 agent.Tools(),ToolSet 看到的是 context.Background()
  • 如果你需要让 initialize/tools/list 也严格使用某个自定义上下文 (例如每次请求不同的认证头、追踪字段),更稳妥的做法通常是手动 toolSet.Tools(ctx),再通过 WithTools(...) 注入。

MCP Broker(按需发现 MCP)

除了直接把远端 MCP 工具展开成一等 Tool 之外,框架还提供了另一种 接入方式:tool/mcpbroker

mcpbroker 的核心思路是:

  • 不在一开始把远端 MCP 的全部工具都暴露给模型
  • 先只暴露少量 broker 工具
  • 让模型在需要时再逐步发现和调用远端 MCP 能力

这类模式更适合长尾工具很多、但单次请求只会命中少量工具的场景。

什么时候用 MCP Broker

推荐使用 mcpbroker 的场景:

  • 某个 MCP 服务工具很多,不希望每轮都把完整工具表面塞给模型
  • 某些能力是“储备能力”或“长尾能力”,并不是高频调用
  • 需要通过 Skill、System Prompt、User Prompt 等增量信息,动态连接某个远端 MCP endpoint
  • 希望用较小、较稳定的初始工具面换取更低的上下文压力

更适合继续使用 mcp.NewMCPToolSet() 的场景:

  • 高频、稳定、已知能力
  • 希望把远端工具直接升格成一等 Tool
  • 更看重调用路径更短、约束更强、工具调用成功率更高

这两种方式可以组合使用:

  • 高频热点能力继续使用 MCP ToolSet
  • 低频长尾能力放进 mcpbroker

与 MCP ToolSet 的区别

两者的主要区别是“工具暴露时机”不同:

  • MCP ToolSet
    • 在初始化或运行时先 initialize + tools/list
    • 把远端每个 MCP tool 直接变成 Agent 可见 Tool
    • 把远端 MCP 工具显式声明的安全 annotations 映射到 PermissionRequest.Metadata
  • mcpbroker
    • 初始只暴露 4 个 broker 工具
    • 模型先发现 server,再发现 tool,再检查指定 tool 的 schema,最后再调用
    • 暴露的是 mcp_call 等 broker 工具,远端 tool annotations 不会自动进入 PermissionRequest.Metadata

可以把它理解为:

  • MCP ToolSet:直接挂远端工具
  • mcpbroker:按需发现远端工具

典型 trade-off:

  • MCP ToolSet:更快、更强约束,但工具面更大
  • mcpbroker:更省上下文、更适合长尾与动态能力,但多一步 discovery,整体可能更慢

基本接入方式

import (
    "time"

    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/tool/mcp"
    "trpc.group/trpc-go/trpc-agent-go/tool/mcpbroker"
)

broker := mcpbroker.New(
    mcpbroker.WithServers(map[string]mcp.ConnectionConfig{
        "local_stdio_code": {
            Transport:   "stdio",
            Command:     "go",
            Args:        []string{"run", "./stdio_server/main.go"},
            Timeout:     10 * time.Second,
            Description: "Project management, documentation, and calendar tools.",
        },
    }),
    mcpbroker.WithAllowAdHocHTTP(true),
)

agent := llmagent.New(
    "assistant",
    llmagent.WithModel(model),
    llmagent.WithTools(broker.Tools()),
)

Server Description(服务描述)

ConnectionConfig 上的 Description 字段为 MCP server 提供一段能力摘要, 帮助模型在 mcp_list_servers 阶段判断该去哪个 server 探索。 mcp_list_servers 的返回值会包含这个 description:

1
2
3
4
5
6
7
8
9
{
  "servers": [
    {
      "name": "local_stdio_code",
      "transport": "stdio",
      "description": "Project management, documentation, and calendar tools."
    }
  ]
}

这类似于 OpenAI tool namespace 的 description:模型在第一步 mcp_list_servers 时就能根据描述决定"去哪个 server 看",而不需要 逐个 mcp_list_tools 试探。server 数量越多、名字越不直观时, description 的价值越大。

这个字段是可选的。如果不填,输出中不会出现 description,对现有行为 没有任何影响。

当前 mcpbroker工具层接入,不会像 Skill 那样自动向 System Prompt 注入策略提示。如果你希望模型更稳定地理解:

  • 什么时候先列 server
  • 什么时候先 mcp_list_tools
  • 什么时候再 mcp_inspect_tools
  • 什么时候再 mcp_call

通常仍然建议在业务侧 instruction 里补少量高层 guidance。

模型可见的 4 个工具

当前模型只会看到这 4 个工具:

  • mcp_list_servers
  • mcp_list_tools
  • mcp_inspect_tools
  • mcp_call

推荐的调用顺序通常是:

  1. mcp_list_servers():查看 broker 已知的命名 MCP server
  2. mcp_list_tools(selector):查看某个 server 或远端 URL 的轻量工具目录
  3. mcp_inspect_tools(selector, tools[]):只展开指定工具的 schema
  4. mcp_call(selector, arguments):调用具体 MCP tool

这意味着模型不再一开始就看到完整远端工具集,而是先通过 broker 渐进式探索。

Selector 心智模型

mcpbroker 的核心输入不是 server_name + tool_name + url 的混合对象, 而是统一使用 selector

  • mcp_list_tools 中:
    • 命名 server:local_stdio_code
    • 动态 URL:https://example.com/mcp
  • mcp_call 中:
    • 命名 tool:local_stdio_code.add
    • 动态 URL tool:https://example.com/mcp.add

如果某个 ad-hoc HTTP endpoint 会让点分隔 selector 产生歧义,也支持:

  • https://example.com/mcp#tool=add

真正的 MCP tool 参数统一放在:

  • mcp_call(..., arguments={...})

而不是放在顶层字段里。

渐进式发现方式

  • 先用 mcp_list_tools 获取轻量摘要
  • 只有准备调用某个具体 tool 时,再用 mcp_inspect_tools 只展开该 tool 的 schema

这和“先把完整 schema 全部塞给模型”相比,更适合上下文预算紧张的场景。

动态 URL 与 Skill 场景

mcpbroker 支持 ad-hoc HTTP MCP:

1
2
3
broker := mcpbroker.New(
    mcpbroker.WithAllowAdHocHTTP(true),
)

WithAllowAdHocHTTP(true) 会让 HTTP(S) MCP 的 selector 成为模型可控输入。 生产环境里,建议先在业务侧对 URL、域名和路径做 allowlist 或其它校验, 再把 ad-hoc HTTP 当成可信集成路径使用。

这类动态连接通常需要先有一个“信息来源”告诉模型:

  • 这个 MCP endpoint 存在
  • 它大概能做什么
  • 该用什么 URL 去连接

这个信息来源可以是:

  • System Prompt
  • User Prompt
  • Skill
  • 知识库

也就是说,mcpbroker 解决的是“如何连、如何看、如何调”, 而不是“模型为什么会想到去连这个 MCP”。

这也让 mcpbroker 很适合与 Skill 配合使用。有些 Skill 只在自身 场景下需要某个专用 MCP 能力,这类 MCP 工具不一定要在整个会话中 长期作为全局工具暴露;还有些 Skill 会在加载后提供一个增量出现的 远端 MCP endpoint。此时 Skill 可以负责提供“这个 MCP 存在、能做什么、 该连哪个 URL”的信息来源,而 mcpbroker 负责动态连接以及渐进式暴露 工具和 schema。

完整示例可参考:

  • examples/mcpbroker/basic

其中包含:

  • 本地命名 MCP server
  • Skill 提供远端 streamable HTTP MCP endpoint
  • 模型通过 skill_load -> mcp_list_tools -> mcp_inspect_tools -> mcp_call 动态连接远端 MCP

鉴权 Hook(Per-Run Header 注入)

对于 HTTP 型 MCP,mcpbroker 还提供两类运行时扩展点:

  • WithHTTPHeaderInjector(...)
  • WithErrorInterceptor(...)

适用场景:

  • 不希望让模型直接携带 Authorization
  • 需要根据当前用户、租户、workspace,在每次调用时动态注入 token
  • 希望在远端返回 401/403 时,由业务层把底层错误包装成更友好的错误信息

示例:

broker := mcpbroker.New(
    mcpbroker.WithAllowAdHocHTTP(true),
    mcpbroker.WithHTTPHeaderInjector(func(ctx context.Context, req *mcpbroker.HeaderInjectRequest) (map[string]string, error) {
        token, _ := resolveUserTokenFromContext(ctx, req.BaseURL)
        if token == "" {
            return nil, nil
        }
        return map[string]string{
            "Authorization": "Bearer " + token,
        }, nil
    }),
    mcpbroker.WithErrorInterceptor(func(ctx context.Context, req *mcpbroker.BrokerErrorRequest) (*mcpbroker.BrokerErrorDecision, error) {
        if isUnauthorized(req.Err) {
            return &mcpbroker.BrokerErrorDecision{
                Handled:   true,
                WrapError: fmt.Errorf("当前用户需要先在宿主系统完成授权,然后再重试"),
            }, nil
        }
        return nil, nil
    }),
)

这里的设计重点是:

  • 模型只负责选择 selector
  • 业务代码负责从 ctx 中识别当前用户,并注入 HTTP Header
  • mcpbroker 本身不管理复杂的 OAuth session 状态机

开启 WithAllowAdHocHTTP(true) 后,URL selector 可能来自模型可见上下文。 两类职责建议分开处理:

  • HTTPHeaderInjector 只决定是否、向谁注入敏感 Header。可以基于 req.IsAdHocreq.BaseURL 做粗粒度判断,例如对未知 BaseURL 直接返回 nil,避免向不可信目标泄露 token。
  • 出站 URL allowlist / 域名校验等网络出站策略放到 WithClientOptionsProvider 返回的 tmcp.WithHTTPBeforeRequest 里实现, 详见下一节。

完整示例可参考:

  • examples/mcpbroker/authhooks

底层 Client 选项(WithClientOptionsProvider

除 Header 注入与错误拦截外,可用 WithClientOptionsProvider解析目标并完成 WithHTTPHeaderInjector 之后、创建底层 MCP 客户端之前,由业务返回 trpc-mcp-go 原生的 tmcp.ClientOption / tmcp.StdioClientOption

  • 顺序:默认的 HTTP Header(含注入结果)先应用,再追加 provider 返回的 HTTP options,便于在 WithHTTPBeforeRequest 等钩子里做 URL 校验、审计或覆盖 Header。
  • ClientOptionsRequest:携带 SelectorServerNameOriginmcpbroker.OriginCode / mcpbroker.OriginAdhoc)、TargetTypePhase(如 mcpbroker.PhaseListTools)、ToolName(调用阶段)、以及本次调用的配置副本 Config
  • 返回值nil, nil 表示不追加;返回 error 会在建连前失败(适合 URL policy 拒绝等契约)。
  • stdio:命名 stdio 目标时,向 ClientOptions.Stdio 传入选项即可(例如 WithStdioCapabilities)。

框架不内置易误杀的 URL 全局拒绝列表;若需限制 ad-hoc 出站,可在 provider 里结合 tmcp.WithHTTPBeforeRequest 自行实现策略。

示例(仅说明形态,按业务调整):

mcpbroker.New(
    mcpbroker.WithAllowAdHocHTTP(true),
    mcpbroker.WithClientOptionsProvider(func(ctx context.Context, req *mcpbroker.ClientOptionsRequest) (*mcpbroker.ClientOptions, error) {
        if req.Origin == mcpbroker.OriginAdhoc {
            return &mcpbroker.ClientOptions{
                HTTP: []tmcp.ClientOption{
                    tmcp.WithHTTPBeforeRequest(func(ctx context.Context, httpReq *http.Request) error {
                        if !hostAllowed(httpReq.URL) {
                            return fmt.Errorf("MCP endpoint not allowed: %s", httpReq.URL.Host)
                        }
                        return nil
                    }),
                },
            }, nil
        }
        return nil, nil
    }),
)

Agent 工具 (AgentTool)

AgentTool 允许把一个现有的 Agent 以工具的形式暴露给上层 Agent 使用。相比普通函数工具,AgentTool 的优势在于:

  • ✅ 复用:将复杂 Agent 能力作为标准工具复用
  • 🌊 流式:可选择将子 Agent 的流式事件“内联”转发到父流程
  • 🧭 控制:通过选项控制是否跳过工具后的总结补全、是否进行内部转发

基本用法

import (
    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/model"
    "trpc.group/trpc-go/trpc-agent-go/tool"
    agenttool "trpc.group/trpc-go/trpc-agent-go/tool/agent"
)

// 1) 定义一个可复用的子 Agent(可配置为流式)
mathAgent := llmagent.New(
    "math-specialist",
    llmagent.WithModel(modelInstance),
    llmagent.WithInstruction("你是数学专家..."),
    llmagent.WithGenerationConfig(model.GenerationConfig{Stream: true}),
)

// 2) 包装为 Agent 工具
mathTool := agenttool.NewTool(
    mathAgent,
    agenttool.WithSkipSummarization(false), // 可选,默认 false,当设置为 true 时会跳过外层模型总结,在 tool.response 后直接结束本轮
    agenttool.WithStreamInner(true),        // 开启:把子 Agent 的流式事件转发给父流程
    agenttool.WithInnerTextMode(agenttool.InnerTextModeExclude), // 隐藏子 Agent 正文,仅保留内部进度
    agenttool.WithResponseMode(agenttool.ResponseModeFinalOnly),  // 工具结果只返回子 Agent 最后一条完整 assistant 消息
)

// 3) 在父 Agent 中使用该工具
parent := llmagent.New(
    "assistant",
    llmagent.WithModel(modelInstance),
    llmagent.WithGenerationConfig(model.GenerationConfig{Stream: true}),
    llmagent.WithTools([]tool.Tool{mathTool}),
)

流式内部转发详解

WithStreamInner(true) 时,AgentTool 会把子 Agent 在运行时产生的事件直接转发到父流程的事件流中:

  • 转发的事件本质是子 Agent 里的 event.Event,包含增量内容(choice.Delta.Content
  • 为避免重复,子 Agent 在结束时产生的“完整大段内容”不会再次作为转发事件打印;但会被聚合到最终 tool.response 的内容里,供下一次 LLM 调用作为工具消息使用
  • UI 层建议:展示“转发的子 Agent 增量内容”,但默认不重复打印最终聚合的 tool.response 内容(除非用于调试)
  • 通过 WithInnerTextMode(agenttool.InnerTextModeExclude),你可以保留内部 tool 进度,同时隐藏子 Agent 的 assistant 正文。这在外层协调者还会继续总结时尤其有用。

示例:仅在需要时显示工具片段,避免重复输出

if ev.Response != nil && ev.Object == model.ObjectTypeToolResponse {
    // 工具响应(包含聚合后的内容),默认不打印,避免和子 Agent 转发的内容重复
    // ...仅在调试或需要展示工具细节时再打印
}

// 子 Agent 转发的流式增量(作者不是父 Agent)
if ev.Author != parentName && ev.Response != nil &&
    len(ev.Response.Choices) > 0 {
    if delta := ev.Response.Choices[0].Delta.Content; delta != "" {
        fmt.Print(delta)
    }
}

工具结果响应模式

AgentTool 内部始终是以事件流的形式运行子 Agent。响应模式只控制 AgentTool 最终作为“工具结果”返回给父 Agent 的内容;它不会改变 子 Agent 的 session 写入、事件过滤键,也不会改变内部流式转发行为。

默认情况下,AgentTool 保持历史兼容行为:子 Agent 产生的每一条非空 assistant 消息都会被追加到同一个工具结果字符串里。这个行为对存量调用方最安全, 但长任务子 Agent 往往会输出进度、草稿或中间结论,这些中间 assistant 内容也会 进入父 Agent 看到的 tool.response

当子 Agent 的目标是“隔离上下文、独立完成任务、只把最终答案交给父 Agent” 时,可以使用 WithResponseMode(agenttool.ResponseModeFinalOnly)

1
2
3
4
childTool := agenttool.NewTool(
    childAgent,
    agenttool.WithResponseMode(agenttool.ResponseModeFinalOnly),
)

在 final-only 模式下,AgentTool 会:

  • 忽略 partial assistant 增量
  • 忽略非 assistant 消息、空 assistant 消息以及 tool 消息
  • 返回子 Agent 最后一条完整 assistant 消息
  • 如果子 Agent 结束时没有完整 assistant 消息,则返回空字符串
  • 子 Agent 报错时仍返回 agent error: ...

它和 WithSkipSummarization(true) 不是一回事。WithSkipSummarization 控制的是父流程拿到工具响应后,是否再调用一次外层模型做总结; WithResponseMode 控制的是工具响应本身应该包含哪些子 Agent 内容。

组合示例:

1
2
3
4
5
6
7
childTool := agenttool.NewTool(
    childAgent,
    // 不把子 Agent 的中间推理/进度 assistant 文本拼进工具结果。
    agenttool.WithResponseMode(agenttool.ResponseModeFinalOnly),
    // 父流程拿到 tool.response 后直接结束本轮,不再额外总结。
    agenttool.WithSkipSummarization(true),
)

子 Agent 上下文可见性

AgentTool 会复用当前 invocation 和 session 来运行子 Agent。为了避免把几个 相近概念混在一起,可以先按下面的职责理解:

概念 作用 不负责什么
FilterKey 标记事件属于哪个会话视图;内容处理器用它决定哪些历史消息会进入模型请求 不是权限边界,也不是独立存储
Branch 记录 Agent 执行链路,主要用于 trace、跨 Agent 消息投影等场景 一般不直接决定 AgentTool 能读哪些历史
HistoryScope AgentTool 生成子 Agent FilterKey 的策略 不改变工具结果如何裁剪,也不改变子 Agent 可用工具
MessageFilterMode LLMAgent/GraphAgent 的高层历史过滤预设,组合了时间维度和 FilterKey 维度 普通 AgentTool 使用者通常不需要直接配置

由于历史命名原因,BranchFilterMode 这个名字里有 Branch,但对当前版本事件, 它比较的是 Event.FilterKey 与当前 invocation 的 FilterKey。在 FilterKey 出现前写入的旧版本事件,为了兼容仍可能回退使用 Event.Branch

AgentTool 目前有两个历史作用域:

  • HistoryScopeIsolated(默认):子 Agent 使用独立的 FilterKey,例如 math-specialist-<uuid>(每次调用都会生成新的子 key)。在正常 Runner 生成的事件下, 子 Agent 只会看到本次工具调用参数,不会继承父 Agent 的历史。子 Agent 的事件仍会写入 同一个 session,但位于独立视图下。
    • 如果你希望“同一个 AgentTool 多次调用时,子 Agent 能看到自己上一次的执行历史”,可以 在 HistoryScopeIsolated 下开启 WithPersistentHistory*(见下文选项说明)。此时子 key 会改为稳定值(例如 agenttool:math-specialist:default),从而让子 Agent 的上下文连续。
  • HistoryScopeParentBranch:子 Agent 使用父 key 的子 key,例如 assistant/math-specialist-<uuid>。默认的 prefix 匹配会把祖先和子孙都视为同一 上下文链路,因此子 Agent 可以看到父 Agent 历史,父 Agent 后续也可能看到这个 子 Agent 的事件。这个模式适合共享上下文的协作链路,不是“只读取父历史但不污染 父历史”的快照隔离。

换句话说:

  • 想让子 Agent 做独立工作,只把最终答案作为工具结果交回父 Agent:使用默认 HistoryScopeIsolated,必要时把上下文显式放进工具参数。
  • 想让子 Agent 基于父 Agent 的历史继续编辑、优化或续写,并接受父子事件处在同一 上下文链路中:使用 HistoryScopeParentBranch

HistoryScope 只控制历史可见性。下面这些行为不会因为切换 HistoryScope 而改变:

  • WithResponseMode 仍然只控制工具结果里返回哪些子 Agent assistant 内容。
  • WithSkipSummarization 仍然只控制父流程是否在工具结果后追加一次外层总结调用。
  • 子 Agent 仍通过 Invocation.Clone(...) 继承当前 invocation 的 session、plugins、 RunOptions 等运行上下文;如果需要真正后台隔离,请启动独立的应用运行流程。
  • 如果业务手动追加了空 FilterKey 事件,这类事件按兼容规则可能被多个视图看到; 自定义事件建议始终设置带 app 前缀的明确 FilterKey

选项说明

  • WithSkipSummarization(bool):

    • false(默认):允许在工具结果后继续一次 LLM 调用进行总结/回答
    • true:外层 Flow 在 tool.response 后直接结束本轮(不再额外总结)
  • WithStreamInner(bool):

    • true:把子 Agent 的事件直接转发到父流程(强烈建议父/子 Agent 都开启 GenerationConfig{Stream: true}
    • false:按“仅可调用工具”处理,不做内部事件转发
  • WithInnerTextMode(InnerTextMode):

    • InnerTextModeInclude:实际默认行为,开启内部转发时继续展示子 Agent 的 assistant 文本
    • InnerTextModeExclude:隐藏子 Agent 的 assistant 文本,但继续保留内部 tool call、tool.done,以及聚合后的最终工具响应
  • WithResponseMode(ResponseMode):

    • ResponseModeDefault(默认):保持历史兼容行为,把子 Agent 的 assistant 消息拼接成工具结果
    • ResponseModeFinalOnly:只把子 Agent 最后一条完整 assistant 消息作为工具结果返回
  • WithPersistentHistory() / WithPersistentHistoryKey(string) / WithPersistentHistoryKeyFunc(...):

    • 作用:在 HistoryScopeIsolated 下使用稳定的子 FilterKey,让子 Agent 能在同一个 session 内跨多次 AgentTool 调用读取自己的历史(而不是每次都从“全新子 key”开始)。
    • 默认 key:agenttool:<toolName>:default(刻意不含 /,避免和父 key 形成前缀关系导致父侧误入上下文)。
    • 高级用法:用 WithPersistentHistoryKey(...)WithPersistentHistoryKeyFunc(...) 把不同任务分到不同 key, 防止多个并发/多任务把历史混在一起。
    • 注意:
      • 这不是权限边界;如果父 Agent 侧配置了 BranchFilterModeAll / 空 key,或你把子 key 设计成 parent/... 这类 前缀关系,父 Agent 仍可能在后续请求里看到子事件。
      • 子 Agent 是否真的能看到“更早历史”,还取决于子 Agent 自身的 MessageFilterMode / timeline 过滤(默认会包含历史; 如果你把子 Agent 配成只看当前 request/invocation,则稳定 key 也不会带来跨次可见)。
      • 目前仅 agenttool.NewTool(agent) 支持;agenttool.NewDynamicTool() 会忽略该配置(dynamic 设计上是短生命周期、无记忆的)。
      • HistoryScopeParentBranch 不兼容:当同时开启时,框架会忽略 persistent history,并沿用 parent/child-uuid 语义。
    • 完整示例:见 examples/agenttool/(通过 -persistent-child-history / -persistent-child-key 体验)。
  • WithHistoryScope(HistoryScope):
    • HistoryScopeIsolated(默认):子调用使用独立 FilterKey,通常只读取本次工具参数,不继承父历史。
    • HistoryScopeParentBranch:子调用使用 父键/子名-UUID(Universally Unique Identifier,通用唯一识别码) 形式的分层 FilterKey。父子事件处于同一上下文链路,prefix 匹配下可互相进入上下文。典型场景:基于上一轮产出进行“编辑/优化/续写”。

示例:

1
2
3
4
5
6
7
8
child := agenttool.NewTool(
    childAgent,
    agenttool.WithSkipSummarization(false),
    agenttool.WithStreamInner(true),
    agenttool.WithInnerTextMode(agenttool.InnerTextModeExclude),
    agenttool.WithResponseMode(agenttool.ResponseModeFinalOnly),
    agenttool.WithHistoryScope(agenttool.HistoryScopeParentBranch),
)

注意事项

  • 事件完成信号:工具响应事件会被标记 RequiresCompletion=true,Runner 会自动发送完成信号,无需手工处理
  • 内容去重:如果已转发子 Agent 的增量内容,默认不要再把最终 tool.response 的聚合内容打印出来
  • “只看进度”体验:当你希望用户看到内部进度、但不想重复看到子 Agent 正文时,可组合使用 WithStreamInner(true)WithInnerTextMode(agenttool.InnerTextModeExclude)
  • 模型兼容性:一些模型要求工具调用后必须跟随工具消息,AgentTool 已自动填充聚合后的工具内容满足此要求
  • 子 Agent 上下文隔离和工具结果裁剪是两件事: WithHistoryScope(agenttool.HistoryScopeIsolated) 控制子 Agent 能读到什么; WithResponseMode(agenttool.ResponseModeFinalOnly) 控制父 Agent 作为工具结果收到什么。
  • HistoryScopeParentBranch 是共享上下文链路,不是快照隔离。如果不希望子 Agent 的详细事件在父 Agent 后续上下文中出现,保持默认 HistoryScopeIsolated,并把必要上下文放进工具参数。
  • WithSkipSummarization(true) 只会跳过额外的外层总结型 LLM 调用,不会把 tool.response 变成 assistant final response;如果你需要真正的终止信号,仍应持续消费到 runner.completion

动态 AgentTool

agenttool.NewTool(agent) 适合工具背后已经有一个明确的专家 Agent:开发者先把 Agent、模型、工具、skills、权限等配置好,再把它包装成父 Agent 的一个工具。

当你的应用无法提前穷举所有专家角色,而是希望父 Agent 在每次调用时按任务临时选择 工具子集或指定本次执行指令,可以使用 agenttool.NewDynamicTool()。它会向模型暴露 一个默认名为 dynamic_agent 的工具;模型调用它时,不是在创建任意 Go 对象,也不是 选择某个已注册 Agent,而是在代码定义的边界内运行一次短生命周期的子 Agent invocation。

典型接入方式如下:

dynamicAgent := agenttool.NewDynamicTool()

parent := llmagent.New(
    "assistant",
    llmagent.WithModel(modelInstance),
    llmagent.WithTools([]tool.Tool{
        readFileTool,
        searchCodeTool,
        dynamicAgent,
    }),
)

默认情况下,dynamic_agent 的能力边界来自父 Agent 当前可用的业务工具。模型可以通过 tools 字段把子 Agent 本次可用工具收窄到其中一部分;如果不传 tools,则允许使用 边界内的全部工具。dynamic_agent 自身、transfer_to_agent 以及调用方执行的外部工具 不会进入这个子 Agent 的可选工具面。

模型侧默认可见的参数包括:

1
2
3
4
5
{
  "request": "分析这段代码是否存在权限绕过风险,必要上下文如下:...",
  "instruction": "你是安全审计专家,只输出风险点和修复建议",
  "tools": ["read_file", "search_code"]
}
  • request:必填,描述子 Agent 本次要完成的任务。默认历史作用域是 HistoryScopeIsolated,因此建议把完成任务需要的上下文写进 request
  • instruction:可选,作为本次子 Agent invocation 的角色、约束或执行指令。
  • tools:可选,精确指定本次允许子 Agent 使用哪些工具名。传空数组表示本次不授予 任何业务工具。

如果默认从父 Agent 派生能力面不符合业务边界,可以在代码侧显式设置模板 Agent 或最大 能力面:

workerTemplate := llmagent.New(
    "worker-template",
    llmagent.WithModel(workerModel),
    llmagent.WithInstruction("你是一个只处理单个任务的执行型 Agent。"),
)

dynamicAgent := agenttool.NewDynamicTool(
    // 可选:定义子 Agent 的模型、executor、callbacks、权限策略等执行边界。
    agenttool.WithTemplateAgent(workerTemplate),
    // 可选:限制模型最多只能从这些工具里选择。
    agenttool.WithCapabilityTools([]tool.Tool{readFileTool, searchCodeTool}),
)

WithTemplateAgent 是代码侧边界,不是模型参数。模型不能通过 dynamic_agent 选择任意 Agent、模型或 executor;它只能在开发者配置好的边界内,为这一次调用填写 requestinstruction,并按需选择 tools/skills 子集。

常用选项:

  • WithName(name):修改模型可见工具名。仅对 NewDynamicTool 生效;普通 NewTool(agent) 的工具名始终来自被包装 Agent 的 Info().Name
  • WithTemplateAgent(agent):设置动态子 Agent 的模板,常用于固定模型、executor、 callbacks、权限策略等执行边界。
  • WithCapabilityTools(tools):设置模型可选择的最大工具集合。未设置时默认从父 Agent 本轮有效业务工具派生。显式设置后,这些工具名会被枚举进 tools 字段的 schema,模型从 已知集合中选择而非猜测字符串(父派生工具面与 WithCapabilityProvider 在每次调用时 解析,不会在此枚举)。
  • WithCapabilitySkills(repo):设置模型可选择的最大 skill 仓库。未设置时默认从父 Agent 本轮有效 skill 仓库派生。
  • WithExposeToolSelection(false):不向模型暴露 tools 字段。子 Agent 仍使用代码边界内 的工具面,但模型不能进一步收窄。
  • WithExposeSkillSelection(true):向模型暴露 skills 字段。默认关闭,因为 skill 是否 可执行通常依赖部署环境和 code executor。
  • WithExposeInstruction(false):不向模型暴露 instruction 字段。
  • WithRequestDescription / WithInstructionDescription / WithToolsDescription / WithSkillsDescription:按业务语义调整字段描述,帮助模型更 稳定地填写参数。

使用 skills 时需要额外注意 executor 环境。Dynamic AgentTool 会校验模型选择的 skill 是否在边界仓库中;如果子 Agent 没有可用 code executor,而选中的 skill 可能需要执行代码, 工具结果会带上提示。生产环境中更推荐在代码侧通过 WithCapabilitySkills 和模板 Agent 先定义清楚可执行范围,再决定是否把 skills 字段暴露给模型。

Dynamic AgentTool 与另外两种多 Agent 机制的边界不同:

机制 模型选择什么 生命周期 控制权
agenttool.NewTool(agent) 一个固定的工具入口 每次工具调用 返回工具结果给父 Agent
transfer_to_agent 一个已注册 sub-agent 当前轮继续由目标 Agent 处理 控制权移交
agenttool.NewDynamicTool() 本次调用的 requestinstruction 和 tools/skills 子集 每次工具调用 返回工具结果给父 Agent

如果同一个专家 Agent 同时通过 WithSubAgentsagenttool.NewTool(agent) 暴露给父 Agent,模型会看到两条不同路径:transfer_to_agent 和普通 AgentTool。框架可以运行, 但开发者应在 instruction 或工具 description 中明确何时使用哪一种,或者只保留一种入口。 dynamic_agent 子调用内部不会获得 transfer_to_agent,但普通 AgentTool 会被视作业务工具; 如果不希望动态子 Agent 再调用其他 AgentTool,可以用 WithCapabilityTools 或运行时 ToolFilter 收窄边界。

工具集成与使用

创建 Agent 与工具集成

import (
    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/tool"
    "trpc.group/trpc-go/trpc-agent-go/tool/function"
    "trpc.group/trpc-go/trpc-agent-go/tool/duckduckgo"
    "trpc.group/trpc-go/trpc-agent-go/tool/mcp"
)

// 创建函数工具
calculatorTool := function.NewFunctionTool(calculator,
    function.WithName("calculator"),
    function.WithDescription("执行基础数学运算"))

timeTool := function.NewFunctionTool(getCurrentTime,
    function.WithName("current_time"),
    function.WithDescription("获取当前时间"))

// 创建内置工具
searchTool := duckduckgo.NewTool()

// 创建 MCP 工具集(不同传输方式的示例)
stdioToolSet := mcp.NewMCPToolSet(
    mcp.ConnectionConfig{
        Transport: "stdio",
        Command:   "python",
        Args:      []string{"-m", "my_mcp_server"},
        Timeout:   10 * time.Second,
    },
)

sseToolSet := mcp.NewMCPToolSet(
    mcp.ConnectionConfig{
        Transport: "sse",
        ServerURL: "http://localhost:8080/sse",
        Timeout:   10 * time.Second,
    },
)

streamableToolSet := mcp.NewMCPToolSet(
    mcp.ConnectionConfig{
        Transport: "streamable_http",
        ServerURL: "http://localhost:3000/mcp",
        Timeout:   10 * time.Second,
    },
)

// 创建 Agent 并集成所有工具
agent := llmagent.New("ai-assistant",
    llmagent.WithModel(model),
    llmagent.WithInstruction("你是一个有帮助的AI助手,可以使用多种工具协助用户"),
    // 添加单个工具(Tool 接口)
    llmagent.WithTools([]tool.Tool{
        calculatorTool, timeTool, searchTool,
    }),
    // 添加工具集(ToolSet 接口)
    llmagent.WithToolSets([]tool.ToolSet{
        stdioToolSet, sseToolSet, streamableToolSet,
    }),
)

MCP 工具过滤器

MCP 工具集支持在创建时过滤工具。推荐使用统一的 tool.FilterFunc 接口:

import (
    "trpc.group/trpc-go/trpc-agent-go/tool"
    "trpc.group/trpc-go/trpc-agent-go/tool/mcp"
)

// ✅ 推荐:使用统一的过滤接口
includeFilter := tool.NewIncludeToolNamesFilter("get_weather", "get_news", "calculator")
excludeFilter := tool.NewExcludeToolNamesFilter("deprecated_tool", "slow_tool")

// 应用过滤器
toolSet := mcp.NewMCPToolSet(
    connectionConfig,
    mcp.WithToolFilterFunc(includeFilter),
)

运行时工具过滤

  • 方式一:运行时工具过滤允许在每次 runner.Run 调用时动态控制工具可用性,无需修改 Agent 配置。这是一个"软约束"机制,用于优化 token 消耗和实现基于角色的工具访问控制。针对所有agent生效
  • 方式二:通过llmagent.WithToolFilter配置运行时过滤function, 只对当前agent生效

核心特性:

  • 🎯 Per-Run 控制:每次调用独立配置,不影响 Agent 定义
  • 💰 成本优化:减少发送给 LLM 的工具描述,降低 token 消耗
  • 🛡️ 智能保护:框架工具(transfer_to_agentknowledge_searchagentic_knowledge_search、可选的 await_user_reply)自动保留,永不被过滤
  • 🔧 灵活定制:支持内置过滤器和自定义 FilterFunc

除了“规则过滤(Tool Filter)”,框架还提供 Tool Search:在每次主模型调用前,先做一次“工具选择”,把候选工具集压缩到 TopK(例如 3 个),再交给主模型执行,从而进一步降低 token(尤其是 PromptTokens)。

需要注意的 trade-off:

  • 耗时:Tool Search 会引入额外步骤(额外 LLM 调用、以及/或 embedding + 向量检索),端到端耗时可能增加。
  • Prompt Caching:每轮传给主模型的工具列表会变化,可能降低部分平台的 prompt caching 命中率。

和 Tool Filter 的区别:

  • Tool Filter:你(或业务)通过规则决定“允许/禁止哪些工具”(访问控制/成本控制),更偏策略与安全。
  • Tool Search:框架根据“当前用户问题”自动挑选相关工具,更偏自动化与成本优化。

它们可以组合使用:先用 Tool Filter 做权限/白名单,再用 Tool Search 在剩余工具里做 TopK 选择。

两种策略:

  • LLM Search:把候选工具列表(name + description)拼进 prompt,让 LLM 直接输出“应该使用哪些工具”。
    • 优点:不依赖向量库;实现简单。
    • 缺点:每轮都会把工具列表放进 prompt,开销随工具数量/描述长度近似线性增长。
  • Knowledge Search:先用 LLM 做 query rewrite,再用 embedding + 向量检索做语义匹配。
    • 优点:不需要每轮把完整工具列表塞进 LLM;并且 tool embedding 会在同一 ToolKnowledge 实例内缓存,后续轮/后续请求可以复用。
    • 注意:每轮仍需要对 query 做 embedding(固定开销之一)。

Tool Search 既可以作为 Runner plugin 使用,也可以作为单个 Agent 的 callback 使用。

方案 A:Runner Plugin

import (
    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/plugin/toolsearch"
    "trpc.group/trpc-go/trpc-agent-go/runner"
)

ts, err := toolsearch.New(modelInstance,
    toolsearch.WithMaxTools(3),
    toolsearch.WithFailOpen(), // 可选:search 失败时退回到“全部工具可用”
)
if err != nil { /* handle */ }

ag := llmagent.New("assistant",
    llmagent.WithModel(modelInstance),
    llmagent.WithTools(allTools), // 仍然注册“全量工具”,Tool Search 会挑 TopK
)

r := runner.NewRunner("app", ag,
    runner.WithPlugins(ts),
)

方案 B:Per-Agent BeforeModel Callback

通过 modelCallbacks.RegisterBeforeModel(...) 注册 Tool Search 的 callback (会在主模型调用前自动重写 req.Tools):

    import (
        "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
        "trpc.group/trpc-go/trpc-agent-go/plugin/toolsearch"
        "trpc.group/trpc-go/trpc-agent-go/model"
    )

modelCallbacks := model.NewCallbacks()
tc, err := toolsearch.New(modelInstance,
    toolsearch.WithMaxTools(3),
    toolsearch.WithFailOpen(), // 可选:search 失败时退回到“全部工具可用”
)
if err != nil { /* handle */ }
modelCallbacks.RegisterBeforeModel(tc.Callback())

agent := llmagent.New("assistant",
    llmagent.WithModel(modelInstance),
    llmagent.WithTools(allTools), // 仍然注册“全量工具”,Tool Search 会在每次调用前挑 TopK
    llmagent.WithModelCallbacks(modelCallbacks),
)

需要先创建 ToolKnowledge(embedding + vector store),再通过 toolsearch.WithToolKnowledge(...) 启用 Knowledge Search:

    import (
        "trpc.group/trpc-go/trpc-agent-go/plugin/toolsearch"
        openaiembedder "trpc.group/trpc-go/trpc-agent-go/knowledge/embedder/openai"
        vectorinmemory "trpc.group/trpc-go/trpc-agent-go/knowledge/vectorstore/inmemory"
    )

toolKnowledge, err := toolsearch.NewToolKnowledge(
    openaiembedder.New(openaiembedder.WithModel(openaiembedder.ModelTextEmbedding3Small)),
    toolsearch.WithVectorStore(vectorinmemory.New()),
)
if err != nil { /* handle */ }

tc, err := toolsearch.New(modelInstance,
    toolsearch.WithMaxTools(3),
    toolsearch.WithToolKnowledge(toolKnowledge),
    toolsearch.WithFailOpen(),
)
if err != nil { /* handle */ }
modelCallbacks.RegisterBeforeModel(tc.Callback())
Token 统计(可选)

Tool Search 的 token usage 会写入 context,可用于打点与成本分析:

1
2
3
4
5
import "trpc.group/trpc-go/trpc-agent-go/plugin/toolsearch"

if usage, ok := toolsearch.ToolSearchUsageFromContext(ctx); ok && usage != nil {
    // usage.PromptTokens / usage.CompletionTokens / usage.TotalTokens
}

基本用法

1. 排除特定工具(Exclude Filter)

使用黑名单方式排除不需要的工具:

1
2
3
4
5
6
7
import "trpc.group/trpc-go/trpc-agent-go/tool"

// 排除 text_tool,其他工具都可用
filter := tool.NewExcludeToolNamesFilter("text_tool", "dangerous_tool")
eventChan, err := runner.Run(ctx, userID, sessionID, message,
    agent.WithToolFilter(filter),
)

2. 只允许特定工具(Include Filter)

使用白名单方式只允许指定的工具:

// 方式一:
// 只允许使用计算器和时间工具
filter := tool.NewIncludeToolNamesFilter("calculator", "time_tool")
eventChan, err := runner.Run(ctx, userID, sessionID, message,
    agent.WithToolFilter(filter),
)

// 方式二:
agent := llmagent.New("ai-assistant",
    llmagent.WithModel(model),
    llmagent.WithInstruction("你是一个有帮助的AI助手,可以使用多种工具协助用户"),
    // 添加单个工具(Tool 接口)
    llmagent.WithTools([]tool.Tool{
        calculatorTool, timeTool, searchTool,
    }),
    // 添加工具集(ToolSet 接口)
    llmagent.WithToolSets([]tool.ToolSet{
        stdioToolSet, sseToolSet, streamableToolSet,
    }),
    llmagent.WithToolFilter(filter),
)

3. 自定义过滤逻辑(Custom FilterFunc)

实现自定义过滤函数以支持复杂的过滤逻辑:

// 方式一:
// 自定义过滤函数:只允许名称以 "safe_" 开头的工具
filter := func(ctx context.Context, t tool.Tool) bool {
    declaration := t.Declaration()
    if declaration == nil {
        return false
    }
    return strings.HasPrefix(declaration.Name, "safe_")
}

eventChan, err := runner.Run(ctx, userID, sessionID, message,
    agent.WithToolFilter(filter),
)

// 方式二:
agent := llmagent.New("ai-assistant",
    llmagent.WithModel(model),
    llmagent.WithInstruction("你是一个有帮助的AI助手,可以使用多种工具协助用户"),
    // 添加单个工具(Tool 接口)
    llmagent.WithTools([]tool.Tool{
        calculatorTool, timeTool, searchTool,
    }),
    // 添加工具集(ToolSet 接口)
    llmagent.WithToolSets([]tool.ToolSet{
        stdioToolSet, sseToolSet, streamableToolSet,
    }),
    llmagent.WithToolFilter(filter),
)

4. Agent 粒度过滤(Per-Agent Filtering)

通过 agent.InvocationFromContext 实现不同 Agent 使用不同工具:

// 为不同 Agent 定义允许的工具
agentAllowedTools := map[string]map[string]bool{
    "math-agent": {
        "calculator": true,
    },
    "time-agent": {
        "time_tool": true,
    },
}

// 自定义过滤函数:根据当前 Agent 名称过滤
filter := func(ctx context.Context, t tool.Tool) bool {
    declaration := t.Declaration()
    if declaration == nil {
        return false
    }
    toolName := declaration.Name

    // 从 context 获取当前 Agent 信息
    inv, ok := agent.InvocationFromContext(ctx)
    if !ok || inv == nil {
        return true // fallback: 允许所有工具
    }

    agentName := inv.AgentName

    // 检查该工具是否在当前 Agent 的允许列表中
    allowedTools, exists := agentAllowedTools[agentName]
    if !exists {
        return true // fallback: 允许所有工具
    }

    return allowedTools[toolName]
}

eventChan, err := runner.Run(ctx, userID, sessionID, message,
    agent.WithToolFilter(filter),
)

完整示例: 参见 examples/toolfilter/ 目录

智能过滤机制

框架会自动区分用户工具框架工具,只过滤用户工具:

工具分类 包含的工具 是否被过滤
用户工具 通过 WithTools 注册的工具
通过 WithToolSets 注册的工具
✅ 受过滤控制
框架工具 transfer_to_agent(多 Agent 协调)
knowledge_search(知识库检索)
agentic_knowledge_search
await_user_reply(开启后的一次性追问路由)
❌ 永不过滤,自动保留

示例:

// Agent 注册了多个工具
agent := llmagent.New("assistant",
    llmagent.WithTools([]tool.Tool{
        calculatorTool,  // 用户工具
        textTool,        // 用户工具
    }),
    llmagent.WithSubAgents([]agent.Agent{subAgent1, subAgent2}), // 自动添加 transfer_to_agent
    llmagent.WithKnowledge(kb),                                   // 自动添加 knowledge_search
    llmagent.WithAwaitUserReplyTool(true),                        // 自动添加 await_user_reply
)

// 运行时过滤:只允许 calculator
filter := tool.NewIncludeToolNamesFilter("calculator")
runner.Run(ctx, userID, sessionID, message,
    agent.WithToolFilter(filter),
)

// 实际发送给 LLM 的工具:
// ✅ calculator        - 用户工具,在允许列表中
// ❌ textTool          - 用户工具,被过滤
// ✅ transfer_to_agent - 框架工具,自动保留
// ✅ knowledge_search  - 框架工具,自动保留
// ✅ await_user_reply  - 框架工具,自动保留

await_user_reply 处理跨轮追问

await_user_reply 是一个可选框架工具。当某个 Agent 可能向用户补问信息,并且 你希望下一条用户消息继续回到这个 Agent 时,可以开启 llmagent.WithAwaitUserReplyTool(true)

它需要和 runner.WithAwaitUserReplyRouting(true) 搭配使用:

profileAgent := llmagent.New("profile-agent",
    llmagent.WithAwaitUserReplyTool(true),
    llmagent.WithInstruction(`
如果你必须向用户补一个缺失字段,先调用 await_user_reply,
再提出问题。
`),
)

r := runner.NewRunner(
    "crm-app",
    profileAgent,
    runner.WithAwaitUserReplyRouting(true),
)

这条路由是一次性的:Runner 会在下一条用户消息到来时消费它,然后自动清掉。

注意事项

⚠️ 安全提示: 运行时工具过滤是"软约束",主要用于优化和用户体验。工具内部仍需实现自己的鉴权逻辑:

手动执行工具(中断 tool_calls)

默认情况下,当模型返回 tool_calls 时,框架会自动执行工具,然后把工具结果再发回给模型继续推理。

在一些系统里,你可能希望由调用方(例如客户端、上游服务,或外部工具运行时,例如 Model Context Protocol (MCP))来执行工具。此时可以使用 agent.WithExternalTools(...)agent.WithToolExecutionFilter(...) 来中断工具的自动执行。

核心区别:

  • agent.WithToolFilter(...) 控制工具可见性(模型能看到/能调用哪些工具)
  • agent.WithToolExecutionFilter(...) 控制工具执行(模型请求后,框架是否自动执行)
  • agent.WithAdditionalTools(...) 为本次运行追加临时可见工具
  • agent.WithExternalTools(...) 追加临时可见工具,并声明这些工具由调用方执行

基本流程

  1. 使用 WithExternalTools 发起一次 runner.Run,让模型看到调用方工具
  2. 从事件里读取模型返回的 tool_calls
  3. 调用方在外部执行工具
  4. 通过 role=tool 的消息把结果回填,模型继续输出最终答案
type declarationOnlyTool struct {
    decl *tool.Declaration
}

func (t *declarationOnlyTool) Declaration() *tool.Declaration {
    return t.decl
}

externalSearch := &declarationOnlyTool{
    decl: &tool.Declaration{
        Name:        "external_search",
        Description: "Search a caller-owned system.",
        InputSchema: &tool.Schema{
            Type: "object",
            Properties: map[string]*tool.Schema{
                "query": {Type: "string"},
            },
            Required: []string{"query"},
        },
    },
}

// 第一步:模型会返回 tool_calls,但工具不会被框架执行。
ch, err := r.Run(ctx, userID, sessionID, model.NewUserMessage("search ..."),
    agent.WithExternalTools([]tool.Tool{externalSearch}),
)

// 第二步:从事件里提取 tool_call_id + arguments(此处省略)。
toolCallID := "call_123"
toolResultJSON := `{"status":"ok","data":"..."}`

// 第三/四步:用 role=tool 回填工具结果,模型继续输出。
toolMsg := model.NewToolMessage(toolCallID, "external_search", toolResultJSON)
ch, err = r.Run(ctx, userID, sessionID, toolMsg,
    agent.WithExternalTools([]tool.Tool{externalSearch}),
)

如果工具已经通过 llmagent.WithTools(...) 注册在 Agent 上,只是想在某次 运行中改成由调用方执行,可以继续使用 agent.WithToolExecutionFilter(...)WithExternalTools 更适合 AG-UI、浏览器、移动端或上游服务在每次请求中动态声明 工具的场景。AG-UI runner 默认会把请求里的 input.Tools 映射为 WithExternalTools。外部工具与已有工具同名时,已有工具优先,外部声明不会覆盖或拦截它。这里的已有工具包括 Agent 上注册的工具,以及通过 WithAdditionalTools 追加的工具。

完整示例: examples/toolinterrupt/

1
2
3
4
5
6
7
8
9
func sensitiveOperation(ctx context.Context, req Request) (Result, error) {
    // ✅ 必须:工具内部鉴权
    if !hasPermission(ctx, req.UserID, "sensitive_operation") {
        return nil, fmt.Errorf("permission denied")
    }

    // 执行操作
    return performOperation(req)
}

原因: LLM 可能从上下文或记忆中知道工具的存在和用法,并尝试调用。工具过滤减少了这种可能性,但不能完全防止。

并行工具执行

1
2
3
4
5
6
7
// 启用并行工具执行(可选,用于性能优化)
agent := llmagent.New("ai-assistant",
    llmagent.WithModel(model),
    llmagent.WithTools(tools),
    llmagent.WithToolSets(toolSets),
    llmagent.WithEnableParallelTools(true), // 启用并行执行
)

Graph 工作流下也可以在工具节点开启并行:

stateGraph.AddToolsNode("tools", tools, graph.WithEnableParallelTools(true))

并行执行效果:

# 并行执行(启用时)
Tool 1: get_weather     [====] 50ms
Tool 2: get_population  [====] 50ms
Tool 3: get_time       [====] 50ms
总时间: ~50ms(同时执行)

# 串行执行(默认)
Tool 1: get_weather     [====] 50ms
Tool 2: get_population       [====] 50ms
Tool 3: get_time                  [====] 50ms
总时间: ~150ms(依次执行)

运行时 ToolSet 动态管理

WithToolSets 是一种静态配置方式:在创建 Agent 时一次性注入 ToolSet。很多实际场景下,你希望在运行时动态增删 ToolSet,而不必重建 Agent。

LLMAgent 提供了三个与 ToolSet 相关的运行时方法:

  • AddToolSet(toolSet tool.ToolSet) —— 按 ToolSet.Name() 添加或替换同名 ToolSet
  • RemoveToolSet(name string) bool —— 按名称移除所有同名 ToolSet,返回是否确实删除
  • SetToolSets(toolSets []tool.ToolSet) —— 以给定切片整体替换当前所有 ToolSet

这些方法是并发安全的,并会自动重新计算:

  • 聚合后的工具列表(显式 WithTools 工具 + ToolSet 工具 + 知识检索工具 + Skills 工具)
  • “用户工具”跟踪信息(用于前文介绍的智能过滤机制)

需要特别注意:

  • AddToolSet 替换同名 ToolSet 时,不会自动 Close() 被替换掉的旧实例。
  • RemoveToolSet 删除 ToolSet 时,不会自动 Close() 被移除的实例。
  • SetToolSets 整体替换时,不会自动 Close() 旧切片里的实例。

如果这些 ToolSet 是由你创建的,你仍然需要在合适的时机显式回收它们。

典型使用方式:

// 1. 初始只挂基础工具
agent := llmagent.New("dynamic-assistant",
    llmagent.WithModel(model),
    llmagent.WithTools([]tool.Tool{calculatorTool}),
)

// 2. 运行时挂载一个 MCP ToolSet
mcpToolSet := mcp.NewMCPToolSet(connectionConfig)
if err := mcpToolSet.Init(ctx); err != nil {
    return fmt.Errorf("初始化 MCP ToolSet 失败: %w", err)
}
agent.AddToolSet(mcpToolSet)

// 3. 从配置中心下发一整套 ToolSet(声明式控制)
toolSetsFromConfig := []tool.ToolSet{mcpToolSet, fileToolSet}
agent.SetToolSets(toolSetsFromConfig)

// 4. 按名称下线某个 ToolSet(例如回滚某个集成)
removed := agent.RemoveToolSet(mcpToolSet.Name())
if !removed {
    log.Printf("未找到 ToolSet %q", mcpToolSet.Name())
}

运行时 ToolSet 更新会自动与前文的工具过滤机制协同工作:

  • 通过 WithTools 和所有 ToolSet(包括动态添加的 ToolSet)注册的工具都视为用户工具,会受到 WithToolFilter 以及每次调用的运行时过滤控制。
  • 框架工具(transfer_to_agentknowledge_searchagentic_knowledge_search、可选的 await_user_reply)仍然 永远不被过滤,始终对 Agent 可用。

Tool Call 参数自动修复

部分模型在生成 tool_calls 时,可能产出非严格 JSON 的参数(例如对象 key 未加引号、尾逗号等),从而导致工具执行或外部解析失败。

Tool Call 参数自动修复功能适用于调用方需要在框架外部解析 toolCall.Function.Arguments,或工具严格要求入参为合法 JSON 的场景。

runner.Run 中启用 agent.WithToolCallArgumentsJSONRepairEnabled(true) 后,框架会尽力修复 toolCall.Function.Arguments

1
2
3
ch, err := r.Run(ctx, userID, sessionID, model.NewUserMessage("..."),
    agent.WithToolCallArgumentsJSONRepairEnabled(true),
)

快速开始

环境准备

# 设置 API 密钥
export OPENAI_API_KEY="your-api-key"

简单示例

package main

import (
    "context"
    "fmt"

    "trpc.group/trpc-go/trpc-agent-go/runner"
    "trpc.group/trpc-go/trpc-agent-go/agent/llmagent"
    "trpc.group/trpc-go/trpc-agent-go/model/openai"
    "trpc.group/trpc-go/trpc-agent-go/model"
    "trpc.group/trpc-go/trpc-agent-go/tool/function"
)

func main() {
    // 1. 创建简单工具
    calculatorTool := function.NewFunctionTool(
        func(ctx context.Context, req struct {
            Operation string  `json:"operation" jsonschema:"description=运算类型,例如 add/multiply"`
            A         float64 `json:"a" jsonschema:"description=第一个操作数"`
            B         float64 `json:"b" jsonschema:"description=第二个操作数"`
        }) (map[string]interface{}, error) {
            var result float64
            switch req.Operation {
            case "add":
                result = req.A + req.B
            case "multiply":
                result = req.A * req.B
            default:
                return nil, fmt.Errorf("unsupported operation")
            }
            return map[string]interface{}{"result": result}, nil
        },
        function.WithName("calculator"),
        function.WithDescription("简单计算器"),
    )

    // 2. 创建模型和 Agent
    llmModel := openai.New("deepseek-v4-flash")
    agent := llmagent.New("calculator-assistant",
        llmagent.WithModel(llmModel),
        llmagent.WithInstruction("你是一个数学助手"),
        llmagent.WithTools([]tool.Tool{calculatorTool}),
        llmagent.WithGenerationConfig(model.GenerationConfig{Stream: true}), // 启用流式输出
    )

    // 3. 创建 Runner 并执行
    r := runner.NewRunner("math-app", agent)

    ctx := context.Background()
    userMessage := model.NewUserMessage("请计算 25 乘以 4")

    eventChan, err := r.Run(ctx, "user1", "session1", userMessage)
    if err != nil {
        panic(err)
    }

    // 4. 处理响应
    for event := range eventChan {
        if event.Error != nil {
            fmt.Printf("错误: %s\n", event.Error.Message)
            continue
        }

        // 显示工具调用
        if len(event.Response.Choices) > 0 && len(event.Response.Choices[0].Message.ToolCalls) > 0 {
            for _, toolCall := range event.Response.Choices[0].Message.ToolCalls {
                fmt.Printf("🔧 调用工具: %s\n", toolCall.Function.Name)
                fmt.Printf("   参数: %s\n", string(toolCall.Function.Arguments))
            }
        }

        // 显示流式内容
        if len(event.Response.Choices) > 0 {
            fmt.Print(event.Response.Choices[0].Delta.Content)
        }

        if event.Done {
            break
        }
    }
}

运行示例

# 进入工具示例目录
cd examples/tool
go run .

# 进入 MCP 工具示例目录
cd examples/mcp_tool

# 启动外部服务器
cd streamalbe_server && go run main.go &

# 运行主程序
go run main.go -model="deepseek-v4-flash"

总结

Tool 工具系统为 tRPC-Agent-Go 提供了丰富的扩展能力,支持函数工具、DuckDuckGo 搜索工具和 MCP 协议工具。