跳转至

插件(Plugins)

概述

插件是一种 Runner 作用域(Runner-scoped)的扩展机制,可以在以下生命周期点插入 自定义逻辑:

  • Agent 执行
  • 模型调用(大语言模型(Large Language Model, LLM)请求)
  • 工具(Tool)调用
  • 事件(Event)发出

插件主要解决的是 横切关注点:也就是“你希望所有 Agent 都统一具备的能力”,而 不是某个 Agent 的业务逻辑。

常见场景:

  • 统一日志与调试
  • 安全与策略(policy)拦截
  • 请求改写(例如统一追加 system instruction)
  • 审计、事件打标

如果你的需求只针对某一个 Agent,通常用回调(Callbacks)会更合适。

名词解释(Glossary)

为了避免“插件 / 回调 / hook(钩子)”混在一起,先把几个词说清楚:

  • 生命周期(lifecycle):Runner 处理一次输入的完整过程(创建 Invocation(一次运行 的上下文)→ 调用 Agent → 触发模型调用/工具调用 → 发出事件 → 结束)。
  • Hook 点(Hook Point):生命周期里的一个“时机/插槽”。框架在这个时机会去调用 你注册的函数(回调)。在本项目里,它对应 BeforeModelAfterToolOnEvent 这类位置。
  • 回调(callback):你提供给框架的函数。框架在某个 Hook 点被触发时,会调用这 个函数。
  • hook(钩子):一个通用说法,通常指“Hook 点”或“挂在 Hook 点上的回调函数”。 在这个仓库里,hook 是通过回调实现的,所以文档里有时会混用“hook/回调”,本质是 同一件事:框架在某个时机调用你的函数。
  • 插件(plugin):一个实现了 plugin.Plugin 的组件。它在 Register(reg *plugin.Registry) 里把一组回调注册到多个 Hook 点,然后通过 runner.WithPlugins(...) 在 Runner 上一次性启用。

一句话:

  • Hook 点 = 框架预留的“时机/插槽”
  • 回调 = 你写的函数,挂在 Hook 点上
  • 插件 = 把一组回调打包,并全局生效

文档示例里常见的变量名:

  • runnerInstance:Runner 实例(*runner.Runner
  • reg:注册表(registry),用于注册回调(*plugin.Registry
  • ctx:上下文(context.Context

插件和回调有什么区别?

一句话:插件跟着 Runner 走;回调跟着 Agent 走

如果你只用回调(callback)来实现“全局生效”的效果,你需要把同一套回调逻辑手动加到 这个 Runner 会用到的每一个 Agent 上;插件(plugin)就是把这件事“收拢到一个地方”, 让你只在 Runner 上注册一次,然后自动应用到该 Runner 管理的所有 Agent、工具(Tool) 和模型调用(大语言模型(Large Language Model, LLM)请求)。

回调(callback)是“一个函数”:框架在特定时机(before/after)去调用它。你需要把 它绑定到你想生效的地方(很多时候是某个 Agent 上)。

插件(plugin)是“一个组件”:它把“一组回调 + 配置 + 可选的生命周期管理”打包在 一起,然后 只在 Runner 上注册一次,就能自动对该 Runner 管理的所有 Invocation (一次运行的上下文)生效。

换句话说:

  • Hook 点(Hook Point):生命周期中的“时机/插槽”。
  • 回调(callback):挂在 Hook 点上的函数(也常被称为 hook)。
  • 插件(plugin):把多组回调打包,并在 Runner 上一次注册让它全局生效。

插件和回调的关系(最关键)

插件不是“另一套新的回调系统”。插件的本质就是:在 Runner 级别集中注册一批回调

具体来说:

  • 插件通过 Register(reg *plugin.Registry) 把回调注册到各个 Hook 点;
  • reg.BeforeModel(...) / reg.AfterTool(...) 这类方法注册的就是“回调函数”;
  • 运行时框架仍然在对应的生命周期点执行回调:
    • 先执行“插件注册的全局回调”
    • 再执行“Agent 自己配置的回调”(如果有)

你可以把插件理解为:一组回调(Callbacks)的打包与全局应用

如果你想了解“回调如何绑定到某一个 Agent(局部生效)”,可以参考 callbacks.md 页面。

一张图看懂:从注册到执行

插件生效分两步:注册执行

sequenceDiagram
    autonumber
    participant User as 你的代码
    participant Runner as Runner
    participant Plugin as 插件(plugin.Plugin)
    participant Reg as 注册表(plugin.Registry)
    participant Agent as Agent
    participant ModelTool as 模型/工具

    Note over User,Reg: 1) 注册阶段:只发生一次(创建 Runner 时)
    User->>Runner: NewRunner(..., WithPlugins(Plugin))
    Runner->>Plugin: Register(Reg)
    Plugin->>Reg: reg.BeforeModel(回调)
    Plugin->>Reg: reg.OnEvent(回调)

    Note over User,ModelTool: 2) 执行阶段:每次 Run 都会发生
    User->>Runner: Run(ctx, input)
    Runner->>Agent: 调用 Agent
    Note over Runner,Agent: 运行到某个 Hook 点(例如 BeforeModel)
    Runner->>Runner: 先执行插件回调(全局)
    Runner->>Agent: 再执行 Agent 回调(局部,可选)
    Runner->>ModelTool: 调用模型/工具(除非被短路)
    Runner-->>User: 返回结果

1) 注册:把回调挂到 Hook 点

你在创建 Runner 时启用插件:

1
2
3
4
5
6
runnerInstance := runner.NewRunner(
    "my-app",
    agentInstance,
    runner.WithPlugins(&MyPlugin{}),
)
defer runnerInstance.Close()

其中 MyPlugin 是你自定义的插件类型(实现 plugin.Plugin 接口)。

然后框架会调用插件的 Register(reg *plugin.Registry)。在这个函数里,你通过 reg.BeforeModel(...) / reg.OnEvent(...) 等方法,把“回调函数”注册到对应的 Hook 点。

2) 执行:运行到 Hook 点时,框架调用你的回调

当运行过程中走到某个 Hook 点(例如 BeforeModel)时,框架会按顺序执行:

1) 插件注册的全局回调 2) Agent 自己配置的回调(如果有)

这就是为什么插件能“一次注册,全局生效”:它只是把同样的回调逻辑提前挂到了 Runner 统一管理的生命周期点上。

什么时候用插件?

当你希望“对一个 Runner 管理的所有执行都统一生效”时,用插件更合适:

  • 想对所有 Agent 做统一策略(例如拦截某些输入)。
  • 想统一做日志、指标(metrics)、链路追踪(tracing)。
  • 想对每一次模型请求做统一改写(例如加 system instruction)。
  • 想给所有事件统一打标,便于审计与排查。

什么时候用回调?

当你的需求只影响某个特定 Agent 时,用回调更合适:

  • 只有一个 Agent 需要特殊的请求改写。
  • 只有一个 Agent 的工具需要自定义参数处理。
  • 你在快速试验,不希望全局影响其他 Agent。

你也可以混用:插件负责全局默认行为,回调负责单个 Agent 的定制。

快速开始

创建 Runner 时注册插件(只需要注册一次):

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

runnerInstance := runner.NewRunner(
    "my-app",
    agentInstance,
    runner.WithPlugins(
        plugin.NewLogging(),
        plugin.NewGlobalInstruction(
            "You must follow security policies.",
        ),
    ),
)
defer runnerInstance.Close()

插件是如何执行的?

作用域与传播

  • 插件在 Runner 创建时初始化一次,并存放到 Invocation 的 Invocation.Plugins 中。
  • 当 Invocation 被 Clone(例如子 Agent、AgentTool、transfer 等)时,同一个插件管理器 会被带过去,所以插件在嵌套执行中仍然一致生效。

在回调里拿到 Invocation

BeforeModel / AfterModel / BeforeTool / AfterTool 这类回调里,你通常只拿到 context.Context。如果你需要当前 Invocation,可以从 context 里取出来。

下面示例用 fmt.Printf 打印(示例省略了 import "fmt"):

1
2
3
if inv, ok := agent.InvocationFromContext(ctx); ok && inv != nil {
    fmt.Printf("invocation id: %s\n", inv.InvocationID)
}

在工具回调里,框架还会把工具调用标识符(identifier, ID)注入 context:

1
2
3
if toolCallID, ok := tool.ToolCallIDFromContext(ctx); ok {
    fmt.Printf("tool call id: %s\n", toolCallID)
}

执行顺序与短路(short-circuit)

插件会 按注册顺序执行

某些 Before* 回调支持“短路”默认行为:

  • BeforeAgent:可以返回自定义响应,直接跳过 Agent 执行。
  • BeforeModel:可以返回自定义响应,直接跳过模型接口调用 (应用程序编程接口(Application Programming Interface, API))。
  • BeforeTool:可以返回自定义结果,直接跳过工具执行。

某些 After* 回调支持“覆盖”输出:

  • AfterAgent:可以返回自定义响应,作为一条额外的“终态”响应事件追加到 Agent 事件流末尾(不替换之前的事件)。
  • AfterModel:可以返回自定义响应,替换模型响应。
  • AfterTool:可以返回自定义结果,替换工具结果。

多 Agent 场景下的注意事项(ChainAgent、ParallelAgent、CycleAgent、Graph Agent 节点)

BeforeAgent / AfterAgent为每一个子 Agent invocation 各触发一次, 而不是每个 Runner 执行只触发一次。如果你的 Hook 假设“一次 turn 只调用一次”, 请通过 args.Invocation.Agent(或 AgentName)来区分当前是哪一层。

BeforeAgent.CustomResponse完全短路子 Agent:Run 不会被调用, 子 Agent 自己发出的终态事件(例如 Graph Agent 节点依赖的 GraphCompletionEvent,它负责填充 SubgraphResult.FinalState)也不会 被发出。自定义的 outputMapper 需要在 FinalStatenil 时也能正常处理。

AfterAgent.CustomResponse追加一条终态响应事件。在 Graph Agent 节点 里,这条追加的响应会成为下游节点看到的 StateKeyLastResponse。如果你就是 想让 Runner 作用域插件覆盖子 Agent 输出,请有意识地使用它。

错误处理

  • agent/model/tool 回调返回 error 会让本次运行失败(错误返回给调用方)。
  • OnEvent 返回 error 时,Runner 会记录日志并继续使用原始事件。

并发(很重要)

工具可能并发执行,某些 Agent 也可能并发运行。如果你的插件会保存共享状态,请确保 并发安全(例如使用 sync.Mutex 或原子(atomic)类型)。

Close(资源释放)

如果插件实现了 plugin.Closer,当你调用 Runner.Close() 时,Runner 会调用插件的 Close() 来释放资源。关闭顺序是 按注册顺序的反向(后注册的先关闭)。

Hook 点(Hook Points)

Agent Hook 点

  • BeforeAgent:Agent 开始前
  • AfterAgent:Agent 事件流结束后

Model Hook 点

  • BeforeModel:模型请求发出前
  • AfterModel:模型响应产生后

Tool Hook 点

  • BeforeTool:工具调用前,可以修改工具参数(JSON(JavaScript Object Notation) 字节)
  • AfterTool:工具调用后,可以替换结果

Event Hook 点

  • OnEvent:Runner 发出每一个事件时都会调用(包括 runner completion 事件)。你可以 原地修改事件,或者返回一个新的事件作为替代。

Graph 节点 Hook(StateGraph / GraphAgent)

Runner 插件不会提供 BeforeNode / AfterNode 这类“节点级”的 Hook 点。

Graph 的节点执行发生在图引擎内部(graph.Executor),它有自己独立的一套回调机制: graph.NodeCallbacks

如果你希望对 Graph 节点 做“切面/横切”,常见有三种方式:

1) 使用 Graph 节点回调(推荐)

Graph 同时支持:

- 全局(图级)回调:构图时通过 (*graph.StateGraph).WithNodeCallbacks(...) 一次性注册,对该图内所有节点生效。 - 单节点回调:通过 graph.WithPreNodeCallback / graph.WithPostNodeCallback / graph.WithNodeErrorCallback 只挂到某个节点上。

详细的手把手教程见 graph.md

2) OnEvent 观测 Graph 节点生命周期事件

Graph 会发出任务生命周期事件(event object):

- graph.node.start - graph.node.complete - graph.node.error

插件可以在 OnEvent 里对这些事件做打标/日志/审计等:

reg.OnEvent(func(
 ctx context.Context,
 inv *agent.Invocation,
 e *event.Event,
) (*event.Event, error) {
 if e == nil {
     return nil, nil
 }
 switch e.Object {
 case graph.ObjectTypeGraphNodeStart,
     graph.ObjectTypeGraphNodeComplete,
     graph.ObjectTypeGraphNodeError:
     // 观测 / 打标 / 日志 / 审计。
 }
 return nil, nil
})

注意:如果你显式开启了 StreamMode 过滤,需要在 modes 里包含 tasks (或 debug)才能把这些事件转发给调用方;如果你没有配置 StreamMode(默认), Runner 不会做过滤,会把所有事件都转发。无论哪种情况,插件都能看到这些事件, 因为 OnEvent 在 StreamMode 过滤之前执行。

3) 从插件里注入 Graph 全局节点回调(进阶)

如果你希望“Runner 作用域”的逻辑也能影响 Graph 节点回调,可以在 BeforeAgent 里 往 Invocation.RunOptions.RuntimeState 写入 graph.StateKeyNodeCallbacks。 Graph 会把这个 key 当作“本次运行的全局节点回调”来使用。

reg.BeforeAgent(func(
 ctx context.Context,
 args *agent.BeforeAgentArgs,
) (*agent.BeforeAgentResult, error) {
 if args == nil || args.Invocation == nil {
     return nil, nil
 }
 inv := args.Invocation
 if inv.RunOptions.RuntimeState == nil {
     inv.RunOptions.RuntimeState = make(map[string]any)
 }
 if inv.RunOptions.RuntimeState[graph.StateKeyNodeCallbacks] == nil {
     inv.RunOptions.RuntimeState[graph.StateKeyNodeCallbacks] =
         graph.NewNodeCallbacks()
 }
 return nil, nil
})

可运行的端到端示例见 examples/graph/runner_plugin_node_callbacks

常见用法(Recipes)

1) 拦截输入并短路模型调用(策略)

BeforeModel 在模型调用前直接返回自定义响应:

type PolicyPlugin struct{}

func (p *PolicyPlugin) Name() string { return "policy" }

func (p *PolicyPlugin) Register(reg *plugin.Registry) {
    const blockedKeyword = "/deny"

    reg.BeforeModel(func(
        ctx context.Context,
        args *model.BeforeModelArgs,
    ) (*model.BeforeModelResult, error) {
        if args == nil || args.Request == nil {
            return nil, nil
        }
        for _, msg := range args.Request.Messages {
            if msg.Role == model.RoleUser &&
                strings.Contains(msg.Content, blockedKeyword) {
                return &model.BeforeModelResult{
                    CustomResponse: &model.Response{
                        Done: true,
                        Choices: []model.Choice{{
                            Index: 0,
                            Message: model.NewAssistantMessage(
                                "Blocked by plugin policy.",
                            ),
                        }},
                    },
                }, nil
            }
        }
        return nil, nil
    })
}

2) 给所有事件打标(审计/排查)

OnEvent 给事件追加 tag,便于 UI(User Interface)过滤或日志检索:

type TagPlugin struct{}

func (p *TagPlugin) Name() string { return "tag" }

func (p *TagPlugin) Register(reg *plugin.Registry) {
    const demoTag = "plugin_demo"

    reg.OnEvent(func(
        ctx context.Context,
        inv *agent.Invocation,
        e *event.Event,
    ) (*event.Event, error) {
        if e == nil {
            return nil, nil
        }
        if e.Tag == "" {
            e.Tag = demoTag
            return nil, nil
        }
        if !e.ContainsTag(demoTag) {
            e.Tag = e.Tag + event.TagDelimiter + demoTag
        }
        return nil, nil
    })
}

3) 改写工具参数(清洗/规范化)

BeforeTool 替换工具参数(JSON(JavaScript Object Notation)字节):

type ToolArgsPlugin struct{}

func (p *ToolArgsPlugin) Name() string { return "tool_args" }

func (p *ToolArgsPlugin) Register(reg *plugin.Registry) {
    reg.BeforeTool(func(
        ctx context.Context,
        args *tool.BeforeToolArgs,
    ) (*tool.BeforeToolResult, error) {
        if args == nil {
            return nil, nil
        }
        if args.ToolName == "calculator" {
            return &tool.BeforeToolResult{
                ModifiedArguments: []byte(
                    `{"operation":"add","a":1,"b":2}`,
                ),
            }, nil
        }
        return nil, nil
    })
}

内置插件

Logging

plugin.NewLogging() 会记录 agent/model/tool 的开始与结束信息,适合用于调试与 性能分析。

GlobalInstruction

plugin.NewGlobalInstruction(text) 会在每一次模型请求前,统一追加一条 system message。适合用来实现全局策略或统一行为(例如安全约束、风格要求)。

ToolCallID

plugin/toolcallid 下的 toolcallid.New() 用于在模型返回最终 ToolCall.ID 后统一改写为框架使用的 tool call ID。当 provider / model 不能稳定保证 ToolCall.ID 足够唯一时,可以启用这个插件。

使用示例如下:

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

runnerInstance := runner.NewRunner(
    "my-app",
    agentInstance,
    runner.WithPlugins(
        toolcallid.New(),
    ),
)
defer runnerInstance.Close()

该插件挂载在 AfterModel,会在最终可用的 tool call ID 上做统一改写。改写完成后,框架后续处理会继续使用该 ID。

如果其他插件也在 AfterModel 阶段依赖最终 ToolCall.ID,应把 toolcallid.New() 放在它们前面。

完整示例见 examples/toolcallid

Guardrail(护栏)

plugin/guardrail 下的 guardrail.New(...) 是顶层插件入口,用来把一个或多个护栏能力接到 Runner 上。

当前内置的 capability 包括:

Capability Hook 决策对象 Reviewer 要求
approval BeforeTool 当前工具动作 仅当工具路径可能走到 ToolPolicyRequireApproval 时必填
promptinjection BeforeModel 最后一条 role=user 输入 必填
unsafeintent BeforeModel 最后一条 role=user 输入 必填

一个典型的顶层组合方式如下:

1
2
3
4
5
6
7
8
guardrailPlugin, err := guardrail.New(
    guardrail.WithApproval(approvalPlugin),
    guardrail.WithPromptInjection(promptInjectionPlugin),
    guardrail.WithUnsafeIntent(unsafeIntentPlugin),
)
if err != nil {
    return err
}

你可以只挂载其中任意一部分能力。guardrail.New(...) 也允许空构造,适合先把顶层插件挂好,再按需补 capability。

Approval(审批)

plugin/guardrail/approval 下的 approval.New(opts...) 用于构造内置的工具审批 capability。这个 capability 会拦截 BeforeTool,并决定本次工具调用应该:

  • 直接执行
  • 直接拒绝
  • 进入 reviewer 审批

典型接法如下:

modelInstance := openai.New("gpt-5.4")

reviewerRunner := runner.NewRunner(
    "guardrail-approval-reviewer-runner",
    llmagent.New(
        "guardrail-approval-reviewer",
        llmagent.WithModel(modelInstance),
    ),
)

reviewerInstance, err := review.New(
    reviewerRunner,
    review.WithRiskThreshold(80),
)
if err != nil {
    return err
}

approvalPlugin, err := approval.New(
    approval.WithReviewer(reviewerInstance),
    approval.WithToolPolicy(
        "hostexec_write_stdin",
        approval.ToolPolicySkipApproval,
    ),
    approval.WithToolPolicy(
        "hostexec_kill_session",
        approval.ToolPolicyDenied,
    ),
)
if err != nil {
    return err
}

guardrailPlugin, err := guardrail.New(
    guardrail.WithApproval(approvalPlugin),
)
if err != nil {
    return err
}

runnerInstance := runner.NewRunner(
    "guardrail-approval-demo",
    agentInstance,
    runner.WithPlugins(guardrailPlugin),
)
defer runnerInstance.Close()

上面这段配置表达的是:

  • 未显式配置的工具默认使用 ToolPolicyRequireApproval,先进入 reviewer 审批。
  • hostexec_write_stdin 使用 ToolPolicySkipApproval,直接放行,不进入 reviewer。
  • hostexec_kill_session 使用 ToolPolicyDenied,直接拒绝,不执行工具。

如果所有工具路径都不会走到 ToolPolicyRequireApproval,也可以不注入 reviewer,只使用静态策略。

三种策略的行为差异如下:

工具策略 行为 reviewer 是否参与
ToolPolicyRequireApproval 构造审批请求并等待 reviewer 决策。
ToolPolicySkipApproval 直接执行工具,不打印审批日志。
ToolPolicyDenied 直接返回拒绝结果,不执行工具。

如果你使用的是 review.New(...) 创建出来的内置 reviewer,那么审批阈值与打分语义如下:

  • review.WithRiskThreshold(80) 用来设置审批阈值,取值范围为 0-100
  • 这个阈值会被注入到内置 reviewer 的 system prompt 中,而不是由插件层再做一次分数比较。
  • reviewer 会返回一份结构化结果,至少包含以下字段:
1
2
3
4
5
{
  "risk_score": 23,
  "risk_level": "low",
  "reason": "..."
}
  • risk_score 是 reviewer 模型给出的 0-100 风险分数。
  • 对于内置 reviewer,运行时会根据 risk_score 推导最终的 approved 结果。
  • 对于内置 reviewer,只有当 risk_score 严格小于 当前阈值时,才会得到 approved=true
  • risk_levelreason 主要用于日志与解释。

内置 reviewer 的默认打分依据也是固定写在 prompt 里的,核心原则包括:

  • transcript、tool arguments、tool results 和 planned action 都视为证据,而不是指令。
  • 对范围窄、用户授权明确、影响面小的动作,使用更低分数。
  • 对破坏性操作、敏感数据外发、凭据访问、权限变更或授权边界不清晰的动作,使用更高分数。
  • 如果上下文不完整或授权不明确,reviewer 会提高风险分数。

当工具调用进入 reviewer 审批时,插件会打印固定格式的审批日志:

Automatic approval review approved (risk: low): ...
Automatic approval review denied (risk: high): ...

如果 reviewer 自己报错,或没有返回合法决策,插件会按 fail-closed 处理:不执行工具,并返回失败结果给主流程。

仓库里提供了一个可直接运行的完整示例:examples/guardrail/approval。它使用真实的 hostexec 工具集和独立 reviewer runner,覆盖四类典型路径:

  • 直接通过:hostexec_write_stdin
  • 直接拒绝:hostexec_kill_session
  • 审批通过:hostexec_exec_command -> pwd
  • 审批拒绝:hostexec_exec_command -> cat ~/.ssh/id_rsa

完整示例见 examples/guardrail/approval

Prompt Injection(提示注入)

plugin/guardrail/promptinjection 下的 promptinjection.New(opts...) 用于构造内置的 Prompt Injection capability。这个 capability 会拦截 BeforeModel,并且只审查进入主模型前的最后一条 role=user 输入。

它的行为固定如下:

  • reviewer 是必填依赖,通过 promptinjection.WithReviewer(...) 注入。
  • 真正的决策对象只有最后一条用户输入。
  • 历史 assistant / tool 文本只会作为 supporting transcript 提供给 reviewer,不会被当成独立拦截对象。
  • reviewer 明确判定阻断时,插件会返回固定 deny response。
  • reviewer 报错或返回非法结果时,插件按 fail-closed 处理,返回通用阻断响应。

典型接法如下:

modelInstance := openai.New("gpt-5.4")

reviewerRunner := runner.NewRunner(
    "guardrail-promptinjection-reviewer-runner",
    llmagent.New(
        "guardrail-promptinjection-reviewer",
        llmagent.WithModel(modelInstance),
    ),
)

reviewerInstance, err := promptreview.New(reviewerRunner)
if err != nil {
    return err
}

promptInjectionPlugin, err := promptinjection.New(
    promptinjection.WithReviewer(reviewerInstance),
)
if err != nil {
    return err
}

guardrailPlugin, err := guardrail.New(
    guardrail.WithPromptInjection(promptInjectionPlugin),
)
if err != nil {
    return err
}

内置 reviewer 在阻断时目前会返回以下分类之一:

  • system_override
  • policy_bypass
  • prompt_exfiltration
  • role_hijack
  • tool_misuse_induction

可直接运行的完整示例见 examples/guardrail/promptinjection。 示例里已经整理了三类真实场景:

  • 正常请求通过
  • 直接的 prompt injection 被阻断
  • 引用/翻译注入文本但本身不是攻击的输入被放行

Unsafe Intent(危险意图)

plugin/guardrail/unsafeintent 下的 unsafeintent.New(opts...) 用于构造内置的 Unsafe Intent capability。这个 capability 会拦截 BeforeModel,并且只审查最后一条 role=user 输入是否表达了明显高风险或不允许的意图。

它的行为固定如下:

  • reviewer 是必填依赖,通过 unsafeintent.WithReviewer(...) 注入。
  • 真正的决策对象只有最后一条用户输入。
  • 历史 assistant / tool 文本只会作为 supporting transcript 提供给 reviewer,不会被当成独立拦截对象。
  • reviewer 明确判定阻断时,插件会返回固定 deny response。
  • reviewer 报错或返回非法结果时,插件按 fail-closed 处理,返回通用阻断响应。

典型接法如下:

modelInstance := openai.New("gpt-5.4")

reviewerRunner := runner.NewRunner(
    "guardrail-unsafeintent-reviewer-runner",
    llmagent.New(
        "guardrail-unsafeintent-reviewer",
        llmagent.WithModel(modelInstance),
    ),
)

reviewerInstance, err := unsafereview.New(reviewerRunner)
if err != nil {
    return err
}

unsafeIntentPlugin, err := unsafeintent.New(
    unsafeintent.WithReviewer(reviewerInstance),
)
if err != nil {
    return err
}

guardrailPlugin, err := guardrail.New(
    guardrail.WithUnsafeIntent(unsafeIntentPlugin),
)
if err != nil {
    return err
}

内置 reviewer 在阻断时目前会返回以下分类之一:

  • cyber_abuse
  • credential_theft
  • fraud_deception
  • privacy_abuse
  • physical_harm
  • self_harm
  • sexual_abuse
  • other_unsafe_intent

可直接运行的完整示例见 examples/guardrail/unsafeintent。 示例里已经整理了三类真实场景:

  • 正常请求通过
  • 明显 unsafe intent 被阻断
  • 偏防御/分析型请求被放行

MessageMerger(消息合并)

plugin/messagemerger 下的 messagemerger.New(opts...) 会在每一次模型请求前,把连续的 systemuserassistant 消息合并成一条。这适用于某些第三方模型平台要求消息严格交替、不能出现连续同 role 消息的场景,例如调用方传入的历史里出现 user,userassistant,assistant

这个插件不会合并 tool 消息,以保留 tool_idtool_name 等逐次调用语义。文本合并时插入的分隔符可通过 messagemerger.WithSeparator(...) 配置。

代码示例如下:

1
2
3
4
5
6
7
merger := messagemerger.New()
runnerInstance := runner.NewRunner(
    "my-app",
    agentInstance,
    runner.WithPlugins(merger),
)
defer runnerInstance.Close()

完整示例见 examples/plugin/messagemerger

ErrorMessage(错误消息改写)

plugin/errormessage 下的 errormessage.New(opts...) 会在 Runner 把错误事件写入 session 之前,改写这条错误事件里对用户可见的 Choices[].Message.Content

当一个 event 带有 Response.Error 但还没有 Choices[].Message.Content 时(例如 llmflowagent.StopError 转成的 stop_agent_error 事件、或者任何直接 event.NewErrorEvent(...) 产出的事件),Runner 会用一句固定的英文兜底文案 "An error occurred during execution. Please contact the service provider." 补齐。这个插件在 OnEvent 中先一步把 content 填好,方便业务侧展示更友好、本地化或按租户定制的提示信息。结构化的 Response.Error 不会被修改,调试和下游消费方仍能看到原始原因。

插件只会改写尚未带有有效 content 的错误事件,所以失败前已经产出的流式助手消息不会被覆盖。

静态文案:

1
2
3
4
5
6
7
8
9
rewriter := errormessage.New(
    errormessage.WithContent("本次执行已停止,请稍后再试。"),
)
runnerInstance := runner.NewRunner(
    "my-app",
    agentInstance,
    runner.WithPlugins(rewriter),
)
defer runnerInstance.Close()

动态 resolver(例如对 stop_agent_error 使用更友好的话术):

rewriter := errormessage.New(
    errormessage.WithResolver(func(
        _ context.Context,
        _ *agent.Invocation,
        e *event.Event,
    ) (string, bool) {
        if e == nil || e.Response == nil || e.Response.Error == nil {
            return "", false
        }
        if e.Response.Error.Type == agent.ErrorTypeStopAgentError {
            return "本次执行已按策略停止,请稍后再试。", true
        }
        return "执行失败,请稍后重试。", true
    }),
)

resolver 返回 ok=false 或空字符串时,事件保持不变,Runner 内置的兜底文案仍然生效。

FinishReason:

插件默认会把合成 choice 的 FinishReason 设为 "error"。如果下游协议期望别的取值,可以通过 errormessage.WithFinishReason("stop") 等方式覆盖。如果原始 choice 已经带有 FinishReason,插件会原样保留,不会强制覆盖下游的预期。

适用范围:

插件通过 runner.WithPlugins(...) 注册,会在 Runner 处理每一条事件时触发,因此能覆盖 agent 从事件通道发出的错误事件(比如 llmflow 针对 agent.StopError 产出的 stop_agent_error 事件,以及任何 event.NewErrorEvent(...) 产出的事件)。但对于 agent.Run 同步返回 error(尚未建立事件通道)这一路径,Runner 会直接用内置兜底文案写入 session,此时本插件无法改写持久化内容。

完整示例见 examples/plugin/errormessage

说明:目前仓库内置了 Logging、GlobalInstruction、ToolCallID、MessageMerger、ErrorMessage、Guardrail 六类插件。其中 Guardrail 插件当前提供的内置 capability 包括工具审批、Prompt Injection 和 Unsafe Intent。更多插件可通过自定义插件实现。

如何扩展:写一个自己的插件

1) 实现接口

自定义一个类型,实现:

  • Name() string:同一个 Runner 内必须唯一
  • Register(reg *plugin.Registry):在这里注册回调(挂到 Hook 点)

2) 在 Register 里注册回调

可用的注册方法:

  • BeforeAgent, AfterAgent
  • BeforeModel, AfterModel
  • BeforeTool, AfterTool
  • OnEvent

注册回调的方式就是:在 Register(reg *plugin.Registry) 中调用这些方法。例如:

type MyPlugin struct{}

func (p *MyPlugin) Name() string { return "my_plugin" }

func (p *MyPlugin) Register(reg *plugin.Registry) {
    reg.BeforeModel(func(
        ctx context.Context,
        args *model.BeforeModelArgs,
    ) (*model.BeforeModelResult, error) {
        return nil, nil
    })
}

注册完插件后,需要在创建 Runner 时启用它:

1
2
3
4
5
6
runnerInstance := runner.NewRunner(
    "my-app",
    agentInstance,
    runner.WithPlugins(&MyPlugin{}),
)
defer runnerInstance.Close()

3)(可选)实现 plugin.Closer

如果插件需要释放资源(文件、后台 goroutine、缓冲区等),实现 Close(ctx) 让 Runner 在关闭时统一清理。

完整示例

可运行的完整示例(包含一个自定义策略插件)见:

  • examples/plugin