feat(io): InjectOptions.Payload 业务字段扩展,透传到本轮 stage

## 为什么需要

QQ 的「对方正在输入中」必须只在**自己引起的** before_output 里发,且要
知道发给谁。但两条路都断了:

	on_input 的 stageCtx  是局部变量(stageCtxFromInput),runStage 完就丢
	before_output 的 stageCtx 是内核**新建**的(emitResponse)
	⇒ 两者**不共享 Extra**

## 第一版是错的(用户指出)

我先加了 `InjectOptions.Meta` —— 那是把同一件事做了两遍。用户直接问:
「为什么又额外定义了 injectMeta,而不是包含在 inputch 的请求体中?」

查证后的真实边界:`InputEvent.Payload` **本就是通道送入内核的请求体**,
身份信息本该在那里。但 `IOInjector` 接口全是
`(source, channel, text, opts)`,插件无法给 payload 加字段 —— 所以
payload 需要一个**扩展口**,而不是一个平行的新概念。

改名为 `Payload` 还有一个实际好处:所有注入路径(HTTP/CLI/ACP)共用
同一套机制,不是注入专属。

## 内核怎么处理

	注入点 opts.Payload
	  → 与真 payload 合并(**内核保留键不可覆盖**)
	  → emitResponse 把非保留键原样透传到 stageCtx.Extra

保留键清单 `reservedPayloadKeys` 覆盖注入路径自身写入的键
(no_memory/context_policy/recall_policy/scene_policy/cleaner_name/
priority/type/content)与内核读取的语义键。允许覆盖意味着插件能伪造
"这条不记记忆"或"这条要立即打断"——那是权限提升,不是便利。

拦截方式是**跳过**而非拒绝整次注入:插件可能只想带一个业务字段,
因顺手多写一个保留键就让整条注入失败,代价太大。

## 判据(4 条,变异 2/2 咬)

 · 插件字段原样进 payload
 · 保留键不可覆盖(逐个验证 11 个键)
 · 空 Payload 不改变旧行为
 · 保留字清单必须覆盖注入路径自己写入的键(否则新键=新提权面)

## 附带修掉的编译破坏

`internal/plugin/lua_surface_test.go` 用 `z != sdk.InjectOptions{}` 比较
零值 —— 结构体含 map 就**不可比较**,加了 Payload 后直接编译失败。
改为逐字段验证。
This commit is contained in:
JianFeeeee
2026-10-18 17:40:07 +08:00
parent 40de84facc
commit 70f7bea4b9
5 changed files with 218 additions and 0 deletions

View File

@ -389,6 +389,36 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string, turnUsage
Phase: sdk.StageBeforeOutput,
Extra: map[string]interface{}{"output_channel": ch},
}
// ★ 把输入事件的 payload **原样透传**给 before_output / after_output。
//
// ## 为什么必须在这里透传(2026-10-18)
//
// 本函数**新建**了 stageCtx,而 `on_input` 的 stageCtx 是局部变量
// (stageCtxFromInput)—— 两者**不共享 Extra**。
//
// 后果:插件在 before_output 里只能读全局状态猜“这轮是谁引起的”,
// 并发时会串号(QQ 的「对方正在输入中」就踩到这个:A 的指示打到 B 身上)。
//
// ## 为什么是“透传 payload”而不是新造一个字段
//
// `InputEvent.Payload` 本就是通道送入内核的请求体,身份信息本就该在那里。
// 我第一版加了 `InjectOptions.Meta` 专用的透传键 —— 那是把同一件事做了两遍,
// 而且只服务注入路径,HTTP/CLI/ACP 无法共用。
//
// 现在:插件用 `InjectOptions.Payload` 声明业务字段(内核不解释),
// 这里把它连同 `input_source` 一起带到 stageCtx.Extra。
// 任何注入路径都能用同一套机制。
if evt != nil {
stageCtx.Extra["input_source"] = evt.Source
for k, v := range evt.Payload {
// 内核语义键不往 Extra 里塞:它们的归宿是内核自己的处理逻辑,
// 不是插件可见的上下文(塞了反而干扰插件读 Extra)。
if agentIO.IsReservedPayloadKey(k) {
continue
}
stageCtx.Extra[k] = v
}
}
a.runStage(sdk.StageBeforeOutput, stageCtx)
response = stageCtx.FinalText

View File

@ -339,6 +339,19 @@ func offloadTaskPrompt() string {
- **必须把回复发到用户原本的通道**。面向 qq、wechat 等异步通道时,
纯文本返回会被丢弃 —— 必须显式调用 output_send__{通道名},否则用户收不到,
而你会以为已经回过了。
- ★ **只能发给“这条消息的发送者本人”,必须用私聊。绝对不要发到群聊。**
这是硬限制(2026-10-18 现场事故):一个临时助手把本该私聊给用户的
进度播报发到了群里。在群里发言是**公开可见**的,而你只是一个临时分诊
助手 —— 你看不到群里的上下文、分不清每句话是回给谁的。
具体做法:调 output_send__{通道} 时,meta 里用 user_id(那个人的号),
**不要用 group_id**。即使消息来自群聊,你的回复也走私聊。
(如果你认为这件事必须在群里回,那就走【报忙碌】:让 主 agent 自己发。
它有完整上下文和用户身份判定。)
- 不要向用户暴露"我是被临时拉起的助手"这类内部细节,用主 agent 的口吻回复。
- 拿不准属于哪一类时,选【报忙碌】。宁可让用户稍后得到准确答复,
也不要给出错误的直接回答。`