Current section

Files

Jump to
cmdc lib cmdc plugin builtin large_result_offload.ex
Raw

lib/cmdc/plugin/builtin/large_result_offload.ex

defmodule CMDC.Plugin.Builtin.LargeResultOffload do
@moduledoc """
大工具结果自动 offload 到 backend。
## 设计目标
防止单个工具一次返回 200KB+ 的 SQL / scrape 结果直接炸掉 LLM 上下文。
## 工作流程
- `:after_tool` hook 检查 result 字符长度
- 超阈值 `tool_token_limit_before_evict`(默认 20000 tokens ≈ 80KB)时:
1. 写 backend `/large_tool_results/<call_id>` 保存完整内容
2. emit `:large_result_offloaded` 事件(业务订阅 + telemetry 桥接)
3. 通过 `:replace_tool_result` action 替换 raw_result 为 preview + 路径
- Agent 看到的 tool result 是 head/tail preview + 文件路径引导
- Agent 可调 `read_file(file_path, offset, limit)` 分页读取完整内容
## 配置
{LargeResultOffload, [
backend: backend, # 必填,CMDC.Backend.t()
tool_token_limit_before_evict: 20_000, # 默认 20K tokens (~80KB chars)
excluded_tools: ["ls", "glob", "grep", "read_file",
"edit_file", "write_file"]
]}
## 排除工具
默认排除的工具自身已有 truncation 或返回小,不需要 offload。
"""
@behaviour CMDC.Plugin
require Logger
@default_token_limit 20_000
@default_chars_per_token 4
@default_excluded_tools ~w(ls glob grep read_file edit_file write_file)
# ==========================================================================
# CMDC.Plugin behaviour
# ==========================================================================
@impl true
def init(opts) do
backend = Keyword.get(opts, :backend)
token_limit = Keyword.get(opts, :tool_token_limit_before_evict, @default_token_limit)
excluded = Keyword.get(opts, :excluded_tools, @default_excluded_tools)
char_threshold = token_limit * @default_chars_per_token
state = %{
backend: backend,
char_threshold: char_threshold,
excluded_tools: MapSet.new(excluded)
}
{:ok, state}
end
@impl true
def priority, do: 80
@impl true
def describe do
%{
name: "LargeResultOffload",
version: "0.4.1",
description: "自动把大工具结果 offload 到 backend,防止炸 LLM",
events: [:after_tool],
actions: [:continue, :replace_tool_result, :emit]
}
end
@impl true
def handle_event({:after_tool, tool_name, call_id, result}, state, ctx) do
handle_after_tool(tool_name, call_id, result, state, ctx)
end
def handle_event(_event, state, _ctx), do: {:continue, state}
# ==========================================================================
# 私有
# ==========================================================================
defp handle_after_tool(tool_name, call_id, result, state, ctx) do
cond do
MapSet.member?(state.excluded_tools, tool_name) ->
{:continue, state}
not result_too_large?(result, state.char_threshold) ->
{:continue, state}
is_nil(state.backend) ->
Logger.warning("[LargeResultOffload] backend 未配置,跳过 offload")
{:continue, state}
true ->
do_offload(tool_name, call_id, result, state, ctx)
end
end
defp result_too_large?({:ok, content}, threshold) when is_binary(content) do
byte_size(content) > threshold
end
defp result_too_large?({:error, _}, _threshold), do: false
defp result_too_large?(content, threshold) when is_binary(content),
do: byte_size(content) > threshold
defp result_too_large?(_, _), do: false
defp do_offload(tool_name, call_id, result, state, _ctx) do
content = extract_content(result)
file_path = "/large_tool_results/#{sanitize_id(call_id)}"
case CMDC.Backend.write(state.backend, file_path, content) do
%{error: nil} ->
emit_and_remind(tool_name, call_id, file_path, content, state)
%{error: reason} ->
Logger.warning("[LargeResultOffload] backend write 失败: #{inspect(reason)}")
{:continue, state}
end
end
defp extract_content({:ok, c}) when is_binary(c), do: c
defp extract_content(c) when is_binary(c), do: c
defp extract_content(other), do: inspect(other)
defp sanitize_id(id) when is_binary(id), do: String.replace(id, ~r/[^a-zA-Z0-9_-]/, "_")
defp sanitize_id(id), do: id |> to_string() |> sanitize_id()
defp emit_and_remind(tool_name, call_id, file_path, content, state) do
preview = head_tail_preview(content)
size = byte_size(content)
replacement = """
[大结果已 offload] 工具 `#{tool_name}` 返回了 #{size} bytes 内容,已保存到:
#{file_path}
使用 `read_file(file_path: "#{file_path}", offset: 0, limit: 100)` 分页读取。
Head + tail preview:
#{preview}
"""
emit =
{:large_result_offloaded,
%{
tool: tool_name,
call_id: call_id,
file_path: file_path,
bytes: size,
offloaded_at: DateTime.utc_now()
}}
# 用 :replace_tool_result action 直接替换 raw_result,
# LLM 看到的是 preview + 路径引导,原 200KB 内容不进 message list 但已存 backend
{:replace_tool_result, {:ok, replacement}, state, [emit]}
end
# 取 head 5 行 + tail 5 行,中间标记 "..." truncation
defp head_tail_preview(content) do
lines = String.split(content, "\n")
total = length(lines)
if total <= 10 do
content |> String.slice(0, 2000)
else
head = lines |> Enum.take(5) |> Enum.join("\n")
tail = lines |> Enum.take(-5) |> Enum.join("\n")
"#{head}\n\n... [#{total - 10} 行已省略] ...\n\n#{tail}"
end
end
end