spill

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 1, 2026 License: MIT Imports: 26 Imported by: 0

README

spill —— 大结果落盘插件

超过阈值的工具返回值写进本地文件,返回值改写为「摘要 + 预览 + 可下载的 spill id/URL」, 避免把几十兆内容塞进模型上下文。同时对外提供一组宿主 API(Put / Create / CreatePath) 和一个探索工具 spill_explore

  • 链上位置:最后安装,位于洋葱最内层——返回时第一个被唤醒,拿到的是未经改写的原始结果, 才能按真实大小判断是否落盘。装在 audit 之外会让审计记到大结果原文。
  • 判定依据:序列化后(wire)总字节,不是 payload 字节(JSON 转义会膨胀、base64 膨胀 4/3, 按 payload 判定属于低估)。估算恒 ≥ 真实字节,宁可略低于阈值的结果也落盘。

配置

[spill]
dir             = "/home/work/app/data/spill"  # 落盘目录
threshold_bytes = 32768                        # 落盘阈值,单位字节 = 32KiB;省略取 64KiB
ttl             = "6h"                         # 落盘文件保留时长(时长字符串)= 6 小时;省略取 30m
gc_interval     = "5m"                         # 回收扫描周期(时长字符串)= 5 分钟;省略取 5m
preview_bytes   = 2048                         # 摘要里保留的预览字节数 = 2KiB;省略取 2048
max_file_mib    = 64                           # 单文件上限,单位 MiB;省略取 64
max_total_mib   = 512                          # 目录总量上限,单位 MiB;省略取 512
on_error        = "deny"                       # 落盘失败时:deny(默认)| warn
字段
  • dir(默认 <系统临时目录>/mcp-toolify/spill):落盘目录。启动时权限收紧为 0700, 目录不属于本进程或收紧失败则拒绝启动;校验通过后以句柄形式持有(os.OpenRoot), 启动之后把路径换成软链既改不了写入位置也读不出别处的文件。宿主也可以用 spill.SetDefaultDir(dir)(须在 Install 之前)给一个运行期才知道的目录兜底 ——配置里显式写的 dir 优先。多个部署建议各用独立目录。
  • threshold_bytes(默认 65536 = 64KiB):结果的序列化后总字节超过它就落盘。不能为负; 0 表示取默认值——没有「关闭落盘」的写法(一个能关掉 spill 的开关只会在上下文被打满时 才被发现它开着)。
  • ttl(默认 30m,duration 字符串):落盘文件的保留时长,从最后一次写入算起。到期由 回收协程删除。必须是正数:0 意味着刚落盘就算过期、结果永远取不回来。
  • gc_interval(默认 5m,duration 字符串):回收扫描周期。进程退出时只停回收协程、 不删文件(正在下载的结果不该因一次重启消失),残留文件由下次启动的首轮扫描清掉。 回收只动「本进程创建的 / id 属主是本副本的 / 无属主信息且已超 ttl + 24h 的」文件, 多个部署共享同一 dir 时不会互删。
  • preview_bytes(默认 2048):改写后的摘要里保留多少字节预览。文本段取前若干字节; 非文本段只写一行描述(类型 + MIME + 字节数),预览里不会出现 base64 原文。
  • max_file_mib(默认 64,单位 MiB):单个 spill 文件上限,超了直接失败(走 on_error)。
  • max_total_mib(默认 512,单位 MiB):落盘目录总量上限,超了先按最旧优先淘汰, 腾不出空间才失败。配 max_file_mib > max_total_mib 会启动失败——那样单个文件就能顶穿总量 上限,任何一次大结果都落不下来。

单位按量级分工:threshold_bytes / preview_bytes 量的是「一份结果多大」(KiB 级,用字节), max_file_mib / max_total_mib 量的是「最多占多少盘」(用 MiB)。四个字段都只接受整数, 没有 "64MiB" 这种带单位的写法(配成字符串会解码失败)。MiB 上限太大以致换算成字节溢出 int64 时启动会失败,而不是静默把磁盘保护关掉。

  • on_error(默认 deny):落盘失败怎么办。deny = fail-closed,这次调用返回错误, 绝不把超过阈值的结果原样返回warn = 保留原结果 + 打告警日志(此时上下文会被撑一次)。 没有「静默」这一档。注意 deny 的文案会劝退重试:落盘发生在返回路径上,工具已经执行过了

下载端点

GET /spill/<id>,由基座套 token 鉴权(Registry.Route),并在此之上做属主校验: 落盘时记下 Subject.Token(用途名)与有身份时的 Subject.ID,非属主一律 404 (不是 403,也不回显属主地址——避免存在性与拓扑泄漏)。

  • 摘要里的绝对 URL 依赖基座的 PublicBaseURL,它必须是本副本可直连的地址;配成负载 均衡入口会让下载随机落到非属主副本。没配则摘要里只给本地路径,不给 URL。
  • 多副本:id 内嵌产出该文件的副本地址。请求打到别的副本时,若该地址在 peer 白名单内则反向 代理到属主副本(带环路保护头),否则 404。
  • 已知风险:副本间转发走明文 http:// 且把调用方的 Bearer token 原样带给属主副本。 副本跨机部署时请给 peer 之间加 TLS,或改用副本间的内部凭据。

spill_explore 工具

插件自带的只读工具(labels:capability=read / risk=none / plugin=spill),让模型不下载 整份内容也能探索。token 规则按工具名 spill_explore 放行它;多副本下按 id 做 owner 路由。

入参:idopline_offsetlimitpatternjq_exprdepthmax_bytes。 五种 op

  • statop 省略时的默认):id / name / format / size / modified / download。
  • read:从 line_offset(0 基)起读 limit 行,返回 content / next_line_offset / eof / truncated
  • grep:逐行正则匹配,返回 行号:内容(最多 2000 行)。
  • schema:推断结构(json 解析整份、jsonl 取首行样本),depth 控制展开层数(默认 2)。 text 不支持。
  • jq:对 json / jsonl 执行 jq 表达式(jsonl 逐行执行)。text 不支持。

单次返回字节上限:max_bytes 默认 1MiB、最大 8MiB;单行最长 4MiB(超长行直接报错而不是静默 截断)。中间件自动落盘的内容格式是 json

宿主 API

工具自己产出大结果、不想经中间件自动落盘时用它们(import .../plugins/spill)。 未 Install(或已 OnStop)时一律返回 ErrNotInstalled——刻意不「按需自动初始化」, 那会悄悄往系统临时目录写业务数据、且与配置给的目录不是同一个。

格式只有三种:FormatJSON / FormatJSONL / FormatText(决定下载的 Content-Type,也决定 spill_explore 能做哪些 op)。

  • Put(name, format, data) (id, err):一次写完。
  • Create(name, format) (*Writer, err)增量写入Create 返回时 id 就已确定,可以先交给 调用方、后台协程持续追加;写到一半也能被 spill_explore 读到。Writer 不是并发安全的。
  • PutFor(sub, ...) / CreateFor(sub, ...):同上,但把内容绑定到调用主体(同一 token 用途名 +有身份时同一个人才能下载)。在工具处理函数里能拿到 *runtime.Call 时优先用它们—— Put / Create 写的内容是共享的,任何通过 token 认证的调用方都能下载。
  • CreatePath(format) (id, path, err):只返回文件路径,给「只接受文件名」的第三方写入方 (日志库、批量执行框架)。代价是本插件管不到写入过程:没有单文件上限,内容按共享处理。 写入方顺带产生的兄弟文件(<path>.wf 之类)与本份内容共用 id、会一起被 TTL 回收, 但只有 path 这一个文件能被下载与探索。
  • Open(id) (io.ReadSeekCloser, Info, err):宿主侧只读回取(例如把上一步输出喂给下一步), 不必绕回 HTTP。
  • URLFor(id) string:对外下载地址;没有可用的 PublicBaseURL 时返回空串(不是错误), 拼进文案前请判空。
  • SetDefaultDir(dir):见上文 dir

必须知道的语义

  • 错误态结果(IsError)同样落盘:一条几十兆的错误堆栈对上下文的伤害和成功结果没有区别。 改写后保留 IsError,模型仍然知道这次调用失败了。
  • structuredContent 必然被丢掉:生成的工具会把同一份 payload 同时放进 ContentstructuredContent,只改 Content 的话大结果照样过线进模型。
  • 不落 tools/list 等非 tools/call 的结果:把工具清单换成下载链接等于把它藏起来。
  • 落盘内容是工具结果原文,会在磁盘上留 ttl 那么久。工具返回值里若有敏感数据,目录权限 (0700)、属主校验与 ttl 就是它的全部保护——把 ttl 配得很长要想清楚这一点。
  • 落盘成功时本次调用的 Call.Meta 里会写下 spill.id(常量 spill.MetaID),未落盘时不写。

启动日志

生效配置只能从启动日志确认([mcp] spill: 前缀):on_error / threshold_bytes(序列化后字节) / ttl / gc_interval / preview_bytes / max_file_mib / max_total_mib / dir, 以及一行下载鉴权粒度声明——基座默认不信任身份头(Subject.ID 为空),此时同一 token 用途名的调用方之间可以互相下载 /spill/ 结果。

Documentation

Overview

Package spill 是大结果落盘插件:超过阈值的工具返回值写进本地文件,返回值改写为 摘要 + 可下载的 spill id/URL,避免把几十兆内容塞进模型上下文。

安装位置:应**最后**安装,位于洋葱最内层——返回时第一个被唤醒,拿到的是未经改写的 原始结果,才能按真实大小判断是否落盘。装在 audit 之外会让审计记到大结果原文。

使用方必须知道的七条:

  1. 判定按**序列化后(wire)总字节**算,不是 payload 字节:JSON 转义会让文本膨胀、 base64 会让二进制膨胀 4/3,按 payload 判定是**低估**(fail-open)。估算走零分配 的长度计算,覆盖 TextContent 之外的 ImageContent / AudioContent / EmbeddedResource / ResourceLink 与 structuredContent;无法序列化的值按「超大」 处理。只看文本的实现挡不住一张 8MB base64 图片。
  2. 错误态结果(IsError)**同样落盘**:一条几十兆的错误堆栈对上下文的伤害和成功结果 没有区别。改写后的结果保持 IsError 不变。
  3. 落盘失败绝不静默。默认 on_error = "deny"(fail-closed):宁可让这次调用失败, 也不把超过阈值的结果原样返回——那正是本插件要防的事。显式配成 "warn" 才降级为 「保留原结果 + 打告警日志」,此时上下文会被大结果撑一次。
  4. 磁盘有硬上限:max_file_mib(单文件)与 max_total_mib(目录总量),单位是 MiB。超单文件 上限直接失败(走 on_error),超总量上限先按最旧优先淘汰,腾不出空间才失败。 落盘文件按 ttl 到期回收(gc_interval 为扫描周期),进程退出时只停回收协程、 不删文件(正在下载的结果不该因一次重启消失),残留文件由下次启动的首轮扫描清掉。 回收只动「本进程创建的 / id 属主是本副本的 / 无属主信息且已超 ttl+24h 的」文件, 多个部署共享同一 dir 时不会互删;仍建议每个部署用独立 dir。
  5. 下载端点 /spill/<id> 由基座套 token 鉴权(Registry.Route),并在此之上做**属主 校验**:落盘时记下 Subject.Token(用途名)与有身份时的 Subject.ID,非属主一律 404(不是 403,也不回显属主地址——避免存在性与拓扑泄漏)。
  6. 多副本部署:id 内嵌产出该文件的副本地址,请求打到别的副本时,若该地址在 peer 白名单内则反向代理到属主副本(带环路保护头,Authorization 原样透传),否则 404。 摘要里的绝对 URL 依赖 PublicBaseURL,它必须是**本副本可直连的地址**,配成负载 均衡入口会让下载随机落到非属主副本。 **已知风险(复审 M-3,与基座 owner_routing 的既有约定一致,本插件不单独改)**: 副本间转发走明文 http://,且把调用方的 Bearer token 原样带给属主副本 —— 内网抓包即得该 token。副本跨机部署时请给 peer 之间加 TLS,或改用副本间的 内部凭据替代透传调用方 token。
  7. 落盘内容是工具结果原文,会以文件形式留在磁盘上 ttl 那么久。工具返回值里若有 敏感数据,落盘目录的权限(启动时收紧为 0700,目录不属于本进程或收紧失败则拒绝 启动)、属主校验与 ttl 就是它的全部保护。目录在校验通过后被以句柄形式持有 (os.OpenRoot),启动之后把路径换成软链既改不了写入位置、也读不出别处的文件。

Index

Constants

View Source
const (
	// OnErrorDeny 为默认值:落盘失败即返回错误,不把超大结果交给模型。
	OnErrorDeny = "deny"
	// OnErrorWarn 保留原结果,但必然打出告警日志(不存在「静默」这一档)。
	OnErrorWarn = "warn"
)

落盘失败时的处置策略(对应 spill on_error)。

View Source
const MetaID = "spill.id"

MetaID 是本插件写进 Call.Meta 的键:本次调用落盘后的 spill id(未落盘时不写)。

关于「跨插件 key 该由谁声明」的澄清(基座 runtime/call.go 里的纪律要求跨插件读取的 key 在基座声明,而核心不允许 import 插件,两者看似冲突):那条纪律管的是**基座自己 参与的** key(如 DenyResult 写的 denied_by / deny_reason,基座写、插件读,必须由基座 声明常量)。插件自己产生的 key 属于插件私有命名空间,由插件导出常量、并按 「插件名.」前缀避免撞名;读取方是插件或宿主,它们**可以** import 本插件拿到这个常量 (只有核心不允许 import 插件)。不愿意 import 的读取方按字符串约定 "spill.id" 读。

View Source
const Name = "spill"

Name 是插件名,与 required_plugins 里的写法一致。

Variables

View Source
var ErrNotInstalled = errors.New("spill 插件尚未安装(或已停止):请先 spill.Install(r)")

ErrNotInstalled 表示还没有 Install(或已经 OnStop)就调用了写入/读取 API。

刻意不「按需自动初始化一个默认 store」:那会在没人配置落盘目录时悄悄往系统临时 目录写业务数据,且与配置认领制给出的目录不是同一个——宿主会看到 spill_explore 找不到自己刚写的文件。

Functions

func CreatePath

func CreatePath(f Format) (id, path string, err error)

CreatePath 在 spill 存储里预留一份内容,返回 id 与可直接写入的**文件路径**。

给路径而不是 io.Writer,是因为宿主侧真正的写入方常常是只接受文件名的第三方 (批量执行框架、日志库)。它们会自己创建/截断这个路径,所以这类内容里没有本 插件的文件头,格式记在文件名后缀里。

与 Create 的差别(选用判据):

  • Create 走本插件的 Writer:有单文件上限、内容可绑定属主(CreateFor)。
  • CreatePath 交出路径后本插件管不到写入过程:**没有**单文件上限(越界要靠 调用方自己或 ulimit 兜底),内容按共享处理(任何通过 token 认证的调用方 都能下载,见 Put 的说明)。

写入方顺带产生的兄弟文件(<path>.wf 之类)与本份内容共用同一个 id,会一起被 TTL 回收;但只有 path 这一个文件能被下载与 spill_explore 探索。

func Install

func Install(r *runtime.Registry) error

Install 安装 spill 插件。应**最后**安装,使其位于洋葱最内层——返回时第一个被唤醒, 拿到未经改写的原始结果才能按真实大小判断是否落盘。

func Put

func Put(name string, f Format, data []byte) (id string, err error)

Put 把一段宿主产生的内容写入 spill 存储,返回可用于下载与探索的 id。

典型用法是「工具自己产出了一份大结果,不想经中间件的自动落盘」:例如把一次 SQL 查询结果导成 CSV、把批量接口的返回摊平成 jsonl 交给 agent 逐行探索。

属主:写入方不是一次 MCP 调用,没有可比对的调用主体,因此这类内容是**共享**的 ——任何通过 `/spill/<id>` token 认证的调用方都能下载(见 Info.Shared)。 只该给某一个人看的内容不要用它写;需要按人隔离时用 PutFor 传入 Subject。

func PutFor

func PutFor(sub *runtime.Subject, name string, f Format, data []byte) (id string, err error)

PutFor 与 Put 相同,但把内容绑定到某个调用主体:只有同一个 token 用途名 (主体有身份时还要求同一个人)才能下载,与中间件自动落盘的属主判定一致。

在工具处理函数里能拿到 *runtime.Call 时优先用它——那时「这份内容是谁的」是确定的。

func SetDefaultDir

func SetDefaultDir(dir string)

SetDefaultDir 设置「配置里没写 dir 时」使用的落盘目录,必须在 Install 之前调用。

为什么需要它:落盘目录常常只有**运行期**才知道——GDP 之类的框架把它算成 `<应用根目录>/data/spill`,容器里又可能是挂进来的卷。静态配置文件写不出这个值, 而缺了它就会退回系统临时目录:那是可预测路径、重启即清,工具结果原文不该落在那。

配置里显式写的 dir 优先:部署方在配置文件里写下的东西必须赢过代码里的默认值, 否则「我明明配了却不生效」。传空串清除。

func URLFor

func URLFor(id string) string

URLFor 返回该 id 的对外下载地址;未配置可用的 PublicBaseURL 时为空串。

空串不是错误:单副本本地场景没有对外地址,调用方仍可用 spill_explore 探索。 把空串直接拼进给模型的文案会得到 "下载:" 这种半句话,请自行判空。

Types

type Config

type Config struct {
	Spill Section `toml:"spill"`
}

Config 对应配置文件的 spill 段。

用具名字段结构体、且字段集与文档一致(基座的配置认领制要求):用 map 兜底解码会让 段内拼错的键被判为「已解码」,`[spill] threshhold_bytes = ...` 于是被完全吞掉、插件按默认 阈值上线;少声明一个文档承诺的字段,则部署方照文档写全反而启动失败。

type Format

type Format string

Format 是宿主写入内容的格式声明。它决定下载时的 Content-Type,也决定 spill_explore 能对它做哪些操作(jsonl 可逐行 jq、text 只能 read/grep)。

const (
	FormatJSON  Format = "json"  // 单个 JSON 值(对象或数组)
	FormatJSONL Format = "jsonl" // 一行一个 JSON 值,可增量追加
	FormatText  Format = "text"  // 纯文本,按行读/grep
)

三种格式。刻意只有这三种:它们对应「一份 JSON」「一行一条记录」「纯文本日志」 这三类真实用法,多出来的格式没有对应的探索方式。

type Info

type Info struct {
	// ID 是内容 id(内嵌产出副本的地址,见 runtime.NewOwnedID)。
	ID string
	// Name 是宿主写入时给的名字,仅用于人读与下载文件名。
	Name string
	// Format 是写入时声明的格式。
	Format Format
	// Size 是 payload 字节数(不含内部头部)。
	Size int64
	// ModTime 是最后一次写入时间;TTL 从它开始算。
	ModTime time.Time
	// Shared 为真表示这份内容没有绑定调用主体,任何通过 token 认证的调用方都能下载。
	Shared bool
}

Info 是一份 spill 内容的元信息。

func Open

func Open(id string) (io.ReadSeekCloser, Info, error)

Open 按 id 打开一份 spill 内容(只读),返回的 ReadSeekCloser 只覆盖 payload。

宿主一般不需要它 —— agent 侧用 spill_explore 或下载端点。它存在是为了让宿主的 后续处理(例如把上一步的输出喂给下一步)不必绕回 HTTP。

type Section

type Section struct {
	// Dir 是落盘目录;省略则用 <系统临时目录>/mcp-toolify/spill。
	Dir string `toml:"dir"`
	// ThresholdBytes 是落盘阈值(字节):结果总字节超过它即落盘。省略或 0 取 64KiB。
	ThresholdBytes int `toml:"threshold_bytes"`
	// TTL 是落盘文件的保留时长(duration 字符串);省略取 30m。
	TTL string `toml:"ttl"`
	// GCInterval 是回收扫描周期(duration 字符串);省略取 5m。
	GCInterval string `toml:"gc_interval"`
	// PreviewBytes 是摘要里保留的预览字节数;省略或 0 取 2048。
	PreviewBytes int `toml:"preview_bytes"`
	// MaxFileMiB 是单个 spill 文件的上限(**MiB**);省略或 0 取 64。
	MaxFileMiB int64 `toml:"max_file_mib"`
	// MaxTotalMiB 是落盘目录的总量上限(**MiB**);省略或 0 取 512。
	MaxTotalMiB int64 `toml:"max_total_mib"`
	// OnError 是落盘失败时的处置策略:deny(默认,fail-closed)| warn。
	OnError string `toml:"on_error"`
}

Section 是 spill 段的字段集,即本插件的对外配置契约。

字段名自带单位,因为这些数字的量级差得很远:阈值/预览是「一份结果多大」(KiB 级, 用字节),磁盘上限是「这个部署最多占多少盘」(MiB 级,用 MiB)。写成统一的裸字节 会让 67108864 这类值必须心算,改的时候还容易多打或少打一个 0。

ttl / gc_interval 是**字符串**("30m"、"2h"):TOML 没有 duration 类型, 而 time.Duration 是 int64,写 ttl = "30m" 会直接解码失败、写 ttl = 1800 又会被 当成 1800 纳秒。字符串 + time.ParseDuration 是唯一不会被误读的写法。

type Writer

type Writer struct {
	// contains filtered or unexported fields
}

Writer 是一份正在写入的 spill 内容。

func Create

func Create(name string, f Format) (*Writer, error)

Create 创建一份可**增量写入**的 spill 内容,返回的 Writer 边写边可被 spill_explore 读到(status=running 时就能读,不必等写完)。

这是异步任务(批量探测、长时间导出)该用的入口:先拿到 id 交给调用方, 后台协程持续追加。Writer 不是并发安全的,多个协程写同一份内容请自行串行化。

func CreateFor

func CreateFor(sub *runtime.Subject, name string, f Format) (*Writer, error)

CreateFor 与 Create 相同,但把内容绑定到某个调用主体(见 PutFor)。

func (*Writer) Close

func (w *Writer) Close() error

Close 结束写入。多次调用是安全的(第二次起是 no-op)。

func (*Writer) ID

func (w *Writer) ID() string

ID 返回这份内容的 id(Create 返回时即已确定,可以先交给调用方再慢慢写)。

func (*Writer) Write

func (w *Writer) Write(p []byte) (int, error)

Write 追加内容。超出单文件上限时返回错误并停止写入(已写部分仍可读)。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL