Current section
Files
Jump to
Current section
Files
lib/cmdc/plugin/builtin/patch_tool_calls.ex
defmodule CMDC.Plugin.Builtin.PatchToolCalls do
@moduledoc """
P1 悬空工具调用修复插件。
在 `before_request` 事件中扫描消息序列,检测无对应 `tool_result` 的 assistant
`tool_calls`(悬空调用),并通过 `emit {:synthetic_tool_results, list}` 通知
Agent 追加合成响应,确保消息序列在发送给 LLM 前的有效性。
## 与内置 `Repair` 模块的关系
| 层次 | 模块 | 时机 | 触发 |
|------|------|------|------|
| L1 内部 | Agent 内建修复器 | run_turn 开始 | 自动(每 turn) |
| L2 Plugin | `PatchToolCalls` | before_request | Plugin Pipeline |
内建修复器处理 Agent 内部的孤立调用(含重定位、去重);`PatchToolCalls` 作为
Plugin 层的额外保护,可自定义合成消息内容,并在 Pipeline 中由用户显式启用。
## emit 事件协议
- `{:synthetic_tool_results, [{call_id, content, is_error}]}` — 需补充的合成结果列表
Agent 收到此 emit 后将为每个 call_id 追加 `tool_result` 消息到 `state.messages`。
## 配置
{CMDC.Plugin.Builtin.PatchToolCalls,
synthetic_content: "Error: Tool call was interrupted or not executed.",
is_error: true
}
"""
@behaviour CMDC.Plugin
require Logger
@default_synthetic_content "Error: Tool call was interrupted or not executed."
# ==========================================================================
# Plugin Callbacks
# ==========================================================================
@impl true
def init(opts) do
state = %{
synthetic_content: Keyword.get(opts, :synthetic_content, @default_synthetic_content),
is_error: Keyword.get(opts, :is_error, true)
}
{:ok, state}
end
@impl true
def priority, do: 120
@impl true
def describe, do: "P1 悬空工具调用修复:自动补充合成 tool_result"
@impl true
def handle_event({:before_request, messages}, state, ctx) do
orphaned = find_orphaned_calls(messages)
if orphaned == [] do
{:continue, state}
else
Logger.warning(
"[PatchToolCalls] 检测到 #{length(orphaned)} 个悬空工具调用,自动补充合成响应 session=#{ctx.session_id}"
)
synthetic =
Enum.map(orphaned, fn call_id ->
{call_id, state.synthetic_content, state.is_error}
end)
{:emit, {:synthetic_tool_results, synthetic}, state}
end
end
def handle_event(_event, state, _ctx), do: {:continue, state}
# ==========================================================================
# 私有 — 悬空调用检测
# ==========================================================================
defp find_orphaned_calls(messages) do
result_ids =
messages
|> Enum.filter(&(&1.role == :tool_result))
|> Enum.map(& &1.call_id)
|> Enum.filter(&is_binary/1)
|> MapSet.new()
messages
|> Enum.filter(&assistant_with_tool_calls?/1)
|> Enum.flat_map(&extract_call_ids/1)
|> Enum.reject(&MapSet.member?(result_ids, &1))
|> Enum.uniq()
end
defp assistant_with_tool_calls?(%{role: :assistant, tool_calls: tcs})
when is_list(tcs) and tcs != [],
do: true
defp assistant_with_tool_calls?(_), do: false
defp extract_call_ids(%{tool_calls: tcs}) do
tcs
|> Enum.map(fn
%{call_id: cid} when is_binary(cid) -> cid
%{"call_id" => cid} when is_binary(cid) -> cid
_ -> nil
end)
|> Enum.reject(&is_nil/1)
end
end