Files
HomeAgent/internal/agent/core/eventloop.go
JianFeeeee 55fa2685ae feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由
security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点

device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
  device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层

SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
  不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice

提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
  skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory

修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
  区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
  明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
  tool_calls 导致'模型不调工具'的真因)

测试:go build ./... 通过;go test ./... 60 包全绿
2026-10-26 20:55:25 +08:00

599 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"fmt"
"log"
"runtime/debug"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/events"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// eventLoop 已由 scheduler.go 的 schedulerLoop 取代(M2)。
//
// 原实现直接在 select 里处理 inputCh/selfInputCh,没有任何可枚举的队列、
// 无法承载优先级与抢占;现在任务先入就绪队列,由选择函数 pickTaskIndex 决定下一个。
// 兼容性说明:M2 全部任务为 LevelBackground,因此行为等价于原先的 FIFO。
func (a *Agent) interceptLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] interceptLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.interceptLoop()
}
}()
for {
select {
case evt := <-a.io.InputInterruptChan():
text, _ := evt.Payload["content"].(string)
stop, _ := evt.Payload["stop"].(bool)
if text == "" && !stop {
// 没有内容也不是停止指令:没有可处理的东西(旧行为)。
//
// 注意:**不能**把“空内容”一律当成空操作。客户端停止按钮
// 本来就不带消息(/chat/interrupt 收 body 空的 {}),
// 旧代码在这里 continue 掉,于是停止按钮毫无反应,
// 而且接口还回 200 骗调用方——已实测:HTTP 200 但内核零日志、
// 生成继续跑到自然结束。
continue
}
if stop {
// 停止:①立即结束当前 LLM 推理;②登记短路配额。
//
// 注意这里**只 arm、不 take**:takeStop 必须由 stepLLM 去消费,
// 它才是决定“取消后不重跑”的那个人。曾经写成
//
// if n := armStop(); n > 0 || takeStop() { ... }
//
// 这个 `||` 在 queued=0 时会短路到 takeStop(),把标记先消费掉,
// 于是 stepLLM 永远看不到它 → 取消后照样重跑一轮。
// 实测:停止被正确记录(`stop requested ... queued=0`)但生成仍跑到自然结束。
// pending 必须算上**停在输入 channel 里**的那一段:停止时
// 调度器多在半路忙当前任务,其余消息还没被 pumpInbox 搬进队列,
// 只数 sched.queue 会得到 0,配额随之失效(实测过)。
pending := 0
if a.io != nil {
pending = a.io.PendingInputs()
}
n := a.sched.armStop(pending)
log.Printf("[agent] stop requested by %s/%s (queued=%d will be short-circuited at pre-action)",
evt.Source, evt.OutputChannel, n)
a.cancelCurrentLLM()
if text == "" {
// 纯停止:不进中断队列、不产生新任务。旧实现把空停止当成一条
// 中断入队,取消后会以空内容重跑一轮,停下之后又“活着”。
continue
}
// 带注释的停止(/stop 说句话):注释本身仍作为中断处理,
// 走下面的正常路径——用户想看模型对被停下话题的回应。
}
log.Printf("[agent] interrupt from %s/%s: %s", evt.Source, evt.OutputChannel, truncateStr(text, 80))
clone := &agentIO.InputEvent{
RequestID: evt.RequestID,
Source: evt.Source,
Type: evt.Type,
Payload: map[string]interface{}{},
OutputChannel: evt.OutputChannel,
}
for k, v := range evt.Payload {
clone.Payload[k] = v
}
clone.Payload["interrupt"] = true
clone.Payload["interrupt_source"] = evt.Source
clone.Payload["interrupt_channel"] = evt.OutputChannel
// 决策交给调度器:requestPreempt 总会登记中断(进中断队列或 immediate,
// 因而不会丢),仅当它会真抢占时才告诉我“该取消可取消的步骤”。
// 本 goroutine 不碰任何帧——只写中断队列与让位信号。
//
// 级别由来源声明(InjectOptions.Priority → payload["priority"]);
// 未声明一律 L1。L4(“立即打断”)只有内核级插件能声明,
// 外部插件即便报了 L4 也会被夹到 L3;内核自身另有 raiseKernelInterrupt。
level := interruptLevel(evt, a.isKernelLevelSource(evt.Source))
if a.sched.requestPreempt(clone, level) {
a.cancelCurrentLLM()
}
case <-a.ctx.Done():
return
}
}
}
// cancelCurrentLLM 取消正在进行的 LLM 请求(若有)。
//
// 只有 LLM 流式步骤是可取消的;工具 RPC / ONNX / CAS 在 v1 是临界区,
// 取消对它们无效——让位信号会等它们自然结束后的安全点(设计文档 D2)。
func (a *Agent) cancelCurrentLLM() {
a.llmMu.Lock()
cancel := a.cancelLLM
a.llmMu.Unlock()
if cancel != nil {
cancel()
log.Printf("[agent] LLM request cancelled by preemption")
}
}
// channelConsolidation 标记记忆整理类自输入:无记忆路径处理,
// 不写入对话上下文、不向任何输出通道 emit 响应。
const channelConsolidation = "_consolidation_"
// selfInputMsg 自循环输入消息。channel 决定处理路径:
// - channelConsolidation:记忆整理,无记忆(不污染上下文/知识库)
// - 其他值(如 "cli"、"webui"):正常输入路径,写入上下文并 emit 响应
// (典型场景:子 Agent 完成通知,需让父 Agent 感知并可回复用户)
type selfInputMsg struct {
text string
channel string
}
// selfEvent 把内核自循环消息归一成输入事件。
func selfEvent(msg selfInputMsg) *agentIO.InputEvent {
if msg.channel == "" {
msg.channel = channelConsolidation // 兼容空值:默认走整理路径
}
return &agentIO.InputEvent{
Source: "system",
Type: "text",
Payload: map[string]interface{}{"content": msg.text},
OutputChannel: msg.channel,
}
}
func (a *Agent) handleSelfInput(msg selfInputMsg) {
_, _ = a.runInputTask(selfEvent(msg))
}
func (a *Agent) handleInput(evt *agentIO.InputEvent) {
switch evt.Type {
case "text", "image", "audio":
_, _ = a.runInputTask(evt)
case "event":
log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
case "command":
cmd, _ := evt.Payload["command"].(string)
log.Printf("[agent] command from %s: %s", evt.Source, cmd)
default:
log.Printf("[agent] unknown event type from %s: %s", evt.Source, evt.Type)
}
}
// inputPayload 是一次输入在「模态」这个维度上的全部内容。
//
// 拆出这个结构,是为了让 processInput 只有一条主干:模态不再决定走哪个函数,
// 只决定这里的字段填不填。此前 text 与 image/audio 各有一个 process 函数,
// 媒体那条缺了去重、no_memory、通道 Cleaner、中断语义、EventRawInput 五项——
// 不是因为媒体不需要,而是复制粘贴之后文本那条继续演进、媒体那条没跟上。
type inputPayload struct {
// text 是进 LLM 与记忆的文本。纯媒体输入时它是 mediaToBlocks 给的 alt 文案。
text string
// blocks 非空表示本轮带多模态内容,随当前轮的 message 一起发给模型。
blocks []agentAPI.ContentBlock
// mediaType 供插件在 stage 里判断本轮媒体的模态。
mediaType string
// captureTool 是媒体落进 CAS 时记录的来源标签。
captureTool string
// airInterrupt 表示这是一次**空中断**:内核发的 L4,没有正文。
//
// 它的唯一用途是触发一次语义相关性裁剪(contextoverflow 的原始设计)。
// 空正文的普通输入不是空中断(见 isAirInterrupt)。
airInterrupt bool
}
// resolveInput 把 InputEvent 归一成 inputPayload。
//
// 三种来源在这里合流:
// 1. evt.Type 是 image/audio —— 用户直接发的媒体,payload 里是 data/url;
// 2. evt.Type 是 text 且 payload 带 media_blocks —— 插件经 IOInjector 的
// InjectInputMedia / InjectInputMediaSync / InjectInterruptMedia 注入的
// 媒体,块已经是成品;
// 3. 纯文本。
//
// 第 2 种此前无处可去:注入方把块放进 payload,而文本路径不看这个键,
// 于是插件注入的媒体到 payload 就断了,且不报错。
func (a *Agent) resolveInput(evt *agentIO.InputEvent) (inputPayload, bool) {
switch evt.Type {
case "image", "audio":
blocks, alt := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
return inputPayload{
text: alt,
blocks: blocks,
mediaType: evt.Type,
captureTool: "input_" + evt.Type,
}, true
}
text, _ := evt.Payload["content"].(string)
blocks, mediaType := injectedBlocks(evt.Payload)
// ★ 空中断(kernel/overflow):**没有正文也要算有效输入**。
//
// 这是 contextoverflow 的原始设计:L4 中断本身不携带任何消息,
// 它唯一的用途是"把当前任务抢占下来,让语义相关性裁剪发生"。
// 空 content 在这里曾被判成无效输入直接丢弃 —— 于是那个 L4 变成了
// 一条什么也不做的空转任务。
//
// 判定条件刻意窄:必须是内核发出的中断,且确实无正文、无媒体。
// 普通输入的空内容仍然算无效(否则客户端空包会白白唤醒一轮)。
if text == "" && len(blocks) == 0 && isAirInterrupt(evt) {
return inputPayload{text: "", airInterrupt: true}, true
}
// 文本与媒体都空才算无效输入:只带图不带字是合法的(插件注入常这样)。
if text == "" && len(blocks) == 0 {
return inputPayload{}, false
}
return inputPayload{
text: text,
blocks: blocks,
mediaType: mediaType,
captureTool: "inject_" + evt.Source,
}, true
}
// injectedBlocks 取出 payload 里插件注入的多模态块。
//
// 两种静态类型都要认:内核内部注入直接给 []agentAPI.ContentBlock,
// 而经公共 SDK 的 IOInjector 过来的是 []pubsdk.ContentBlock。两者字段完全一致,
// 但 Go 不会自动转换,只认一种的后果是另一种被静默丢弃。
func injectedBlocks(payload map[string]interface{}) ([]agentAPI.ContentBlock, string) {
var blocks []agentAPI.ContentBlock
switch v := payload["media_blocks"].(type) {
case []agentAPI.ContentBlock:
blocks = v
case []pubsdk.ContentBlock:
blocks = make([]agentAPI.ContentBlock, 0, len(v))
for _, b := range v {
nb := agentAPI.ContentBlock{Type: b.Type, Text: b.Text}
if b.ImageURL != nil {
nb.ImageURL = &agentAPI.ImageURL{URL: b.ImageURL.URL, Detail: b.ImageURL.Detail}
}
if b.AudioURL != nil {
nb.AudioURL = &agentAPI.AudioURL{URL: b.AudioURL.URL}
}
blocks = append(blocks, nb)
}
}
if len(blocks) == 0 {
return nil, ""
}
// 模态由块自身判定,注入方不必额外声明。图优先:一次注入里图片是主体。
mediaType := ""
for _, b := range blocks {
if b.ImageURL != nil {
return blocks, "image"
}
if b.AudioURL != nil {
mediaType = "audio"
}
}
return blocks, mediaType
}
func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string, source string) ([]agentAPI.ContentBlock, string) {
data, _ := payload["data"].(string)
mime, _ := payload["mime"].(string)
url, _ := payload["url"].(string)
alt, _ := payload["alt"].(string)
if alt == "" {
if source == "" {
source = "unknown"
}
alt = fmt.Sprintf("[received %s from %s]", mediaType, source)
}
var blocks []agentAPI.ContentBlock
desc := ""
switch mediaType {
case "image":
desc = a.inputCfg.Image.DescribePrompt
if desc == "" {
// ★★ 2026-10-06:原文写的是「请使用 describe_image 工具查看详情」,
// 但下面已经**内联**了 image_url block —— 模型直接能看。
// 更糟的是:该工具已于本日移出为内置插件 multimodal,
// 它需要 path 或 digest,而用户直传的图片两者都没有 ⇒
// 模型照着提示词调它,必然拿到「没有可处理的媒体」错误。
desc = fmt.Sprintf("An image arrived from %s (attached to this message; look at it directly).", source)
}
case "audio":
desc = a.inputCfg.Audio.DescribePrompt
if desc == "" {
// 同上:audio_url block 已内联。不要指示调 transcribe_audio
// (它现在需要 path/digest);若模型本身不处理音频,
// 应如实告知用户,而不是调一个注定失败的工具。
desc = fmt.Sprintf("从 %s 收到了一段音频(已随本消息附上)。"+
"若你能直接处理音频请转写;若不能,请如实告知,不要编造内容。", source)
}
}
blocks = append(blocks, agentAPI.ContentBlock{Type: "text", Text: desc})
if data != "" || url != "" {
imgURL := url
if data != "" {
if mime == "" {
mime = "image/png"
}
imgURL = "data:" + mime + ";base64," + data
}
if mediaType == "image" {
blocks = append(blocks, agentAPI.ContentBlock{
Type: "image_url",
ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "auto"},
})
} else if mediaType == "audio" {
blocks = append(blocks, agentAPI.ContentBlock{
Type: "audio_url",
AudioURL: &agentAPI.AudioURL{URL: imgURL},
})
}
}
return blocks, alt
}
// emitSkippedReply 给被跳过任务的调用方一个终态。
//
// 为什么要单独一条路径而不是复用 emitResponse:跳过意味着“我们没有处理这条输入”,
// 不应对外发 agent_output 事件(否则 WebUI 聊天记录会凭空多出一条空消息),
// 但必须写 ResponseCh——否则 cli/clawhub 这类无超时的同步注入会永久挂起。
//
// ❗异步来源(qq / wechat / rss 等)**没有 ResponseCh**,于是这里以前是直接 return。
// 后果是任务被丢弃时**完全无声**:用户什么都没收到、日志里也没痕迹,
// 他只会以为消息丢了。转投子被回收/销毁时队列里的积压正落在这个盲区里
// (父可随时对子 reclaim/destroy,而子手上可能还握着几条 QQ 消息)。
// 现在至少留一条带来源与通道的日志,让“这条消息为什么没回”可被追溯。
//
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建,调用方超时离开后仍可写入。
func (a *Agent) emitSkippedReply(evt *agentIO.InputEvent, reason string) {
if evt == nil {
return
}
if evt.ResponseCh == nil {
// 无可回执的通道:不静默。异步来源本就靠 agent 主动 output_send,
// 丢弃后没有任何东西会告诉用户,因此这条日志是唯一的线索。
log.Printf("[agent] %s: dropping input on channel without receipt support (source=%s channel=%s request=%s reason=%s)",
a.id, evt.Source, evt.OutputChannel, evt.RequestID, reason)
return
}
ch := evt.OutputChannel
if ch == "" {
ch = evt.Source
}
payload := map[string]interface{}{
"content": "",
"request_id": evt.RequestID,
"skipped": true,
"reason": reason,
}
select {
case evt.ResponseCh <- &agentIO.OutputEvent{
RequestID: evt.RequestID,
Target: evt.Source,
Type: "text",
Payload: payload,
Done: true,
OutputChannel: ch,
}:
default:
}
}
// emitResponse 发送终态回执(不变量 I5:每任务恰一次)。
//
// turnUsage 是本轮任务内全部 LLM 调用的用量合计,由调用方从 TaskFrame 传入。
//
// 为何要它:同步注入方(WebUI 的 /v1/chat/completions、cli.sock、
// clawhubadapter)的回包就出自这里,而此前这里**只从插件侧**读 TokenUsage
// (下面新建的 stageCtx),agent 自己算出来的用量从未传进去 ⇒ 回包没有 usage。
// 实测过:部署实例的 /v1/chat/completions 回包里 "usage" 字段根本不存在。
//
// 口径:按 OpenAI 语义报**本次请求**(turnUsage),不是会话累计 ——
// 会话累计在链事件里的 usage_session(见 task.go 的记账处),两者不可互换,
// 否则第二次请求会报出翻倍的数字。
func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string, turnUsage agentAPI.TokenUsage) {
// 通道一律从**输入事件**推导(内核不持有"当前通道")。
ch := outputChannelOf(evt)
stageCtx := &sdk.StageContext{
FinalText: response,
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
payload := map[string]interface{}{
"content": response,
"request_id": evt.RequestID,
}
if stageCtx.ReasoningContent != "" {
payload["reasoning_content"] = stageCtx.ReasoningContent
}
// 用量优先级:插件显式设置的赢(那是既有契约,也是此前唯一能拿到 usage 的路径),
// 插件没设则回落到 agent 自己的本轮记账。
//
// ⚠️ 两条路都必须归一成 map[string]interface{}:消费方 handler_openai.go 用
// `.(map[string]interface{})` 取值,而 Go 的 map 类型断言是**精确匹配** ——
// 插件给的 map[string]int 会断言失败,usage 在回包里静默变 nil,两边都不报错。
// (这正是链事件里那个 "发了但没人收到" 的同款坑,已实证。)
// SDK 侧 TokenUsage 的类型仍是 map[string]int(公开接口不动),
// 转换只发生在这一个出口。
switch {
case stageCtx.TokenUsage != nil:
payload["usage"] = usageMapFromInts(stageCtx.TokenUsage)
case !turnUsage.IsZero():
payload["usage"] = turnUsageMap(turnUsage)
}
if evt.ResponseCh != nil {
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建。按不变量 I5(每任务恰一次
// 终态)这里永远写得进去;但一旦哪天写出第二次,阻塞会卡死**调度器 goroutine**
// (整个 agent 停摆),而丢弃只是丢一条回执——与 emitSkippedReply 对称。
select {
case evt.ResponseCh <- &agentIO.OutputEvent{
RequestID: evt.RequestID,
Target: evt.Source,
Type: "text",
Payload: payload,
Done: true,
OutputChannel: ch,
}:
default:
log.Printf("[agent] ResponseCh full, terminal receipt dropped (request=%s, possible invariant I5 violation)", evt.RequestID)
}
}
out := map[string]interface{}{
"content": response,
"channel": ch,
"source": evt.Source,
}
if stageCtx.ReasoningContent != "" {
out["reasoning_content"] = stageCtx.ReasoningContent
}
a.publishEvent(events.EventAgentOutput, out)
stageCtx.Phase = sdk.StageAfterOutput
a.runStage(sdk.StageAfterOutput, stageCtx)
}
// pruneOnInput 按声明的上下文策略裁剪上下文,返回归档的事件数。
//
// 默认**不裁剪**:ContextPolicy 必须在注入点(payload 的 context_policy)
// 或通道定义(ChannelDef.ContextPolicy)上显式声明为 prune 才会裁剪。
//
// 为什么把无条件裁剪改成需声明:裁剪会把低相关事件归档到文档记忆并从上下文里
// 移走,是破坏性的。此前每条输入都裁一次,于是「谁把上下文裁了」在排查时无从
// 得知;而插件注入的内容也会被不相关的内容挤掉。按来源/注入点声明后,触发条件
// 是可枚举、可审计的。
//
// 查询向量取**清洗后**的输入(通道 Cleaner 的输出),与工具侧同一套语义:
// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏,裁掉本该保留的事件。
//
// 实际执行交由 memoryPass(与召回共用入口、query、预算与审计)。
func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
if !a.pruneDeclared(evt) {
return 0
}
return a.memoryPass(cleanInput, "input:"+evt.Source, true, false, a.sceneKeysFor(evt, "")).Archived
}
// pruneDeclared 判定这次输入是否显式声明了裁剪。
//
// 优先级:注入点声明的(payload)> 通道声明的(ChannelDef)> 默认不裁剪。
// 注入点是更窄的声明面,同一通道下的不同注入可以有不同意图。
func (a *Agent) pruneDeclared(evt *agentIO.InputEvent) bool {
if p, ok := evt.Payload["context_policy"].(string); ok && p != "" {
return p == pubsdk.ContextPolicyPrune
}
if a.io != nil {
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok {
return chDef.ContextPolicy == pubsdk.ContextPolicyPrune
}
}
return false
}
// recallDeclared 判定这次输入是否要触发记忆召回(注入)。
//
// 与 pruneDeclared **正交**:prune 管“踢出去”(归档低相关 L0 事件),
// recall 管“取进来”(把 L2/L3 相关记忆注入本轮)。
//
// 默认值与 prune 刻意相反:召回是只读增量、日常对话本就需要,所以**默认 auto**;
// 只有显式声明 recall_policy=none(如中断通知的 meta 文本)才关闭。
// 优先级同 prune:注入点(payload)> 通道(ChannelDef)> 默认 auto。
func (a *Agent) recallDeclared(evt *agentIO.InputEvent) bool {
if evt == nil {
return true
}
if p, ok := evt.Payload["recall_policy"].(string); ok && p != "" {
return p != pubsdk.RecallPolicyNone
}
if a.io != nil {
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.RecallPolicy != "" {
return chDef.RecallPolicy != pubsdk.RecallPolicyNone
}
}
return true
}
// cleanInputFor 解析这条输入在计算层应当使用的清洗文本。
//
// 优先级:注入点声明的 cleaner(payload.cleaner_name,引用某个已注册的通道
// cleaner)> 按 source 查到的通道 cleaner > 原文。
//
// 声明的 cleaner 名字查不到时**记日志并回退**,而不是静默当没声明:
// 注入是 fire-and-forget 的,插件那边看不到错误;至少要在内核日志里留下
// 「你声明的清洗没生效」的痕迹,否则排查时只能看到「记忆里的内容很脏」。
func (a *Agent) cleanInputFor(evt *agentIO.InputEvent, input string) string {
if a.io == nil {
return input
}
if name, ok := evt.Payload["cleaner_name"].(string); ok && name != "" {
if chDef, ok := a.io.GetInputChannelDef(name); ok && chDef.Cleaner != nil {
return chDef.Cleaner(input)
}
log.Printf("[agent] injection declared cleaner_name=%q but no Cleaner was registered for that channel, falling back", name)
}
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
return chDef.Cleaner(input)
}
return input
}
// isAirInterrupt 判定一个输入事件是否为「空中断」。
//
// 空中断 = 内核发起、type=interrupt、payload 里没有 content。
// 用途见 contextoverflow 的中断向量(VectorContextOverflow):
// 它只是一次调度信号,用来把当前任务抢占下来,好在任务边界做语义相关性裁剪。
//
// 为什么必须由内核来源限定:插件也能注入 interrupt,但插件的中断**总是**
// 带正文("某设备上线了""某任务失败了")。把它们的空包也当空中断,
// 会让"插件发了个没内容的中断"变成一次无声的裁剪,行为不可预测。
func isAirInterrupt(evt *agentIO.InputEvent) bool {
if evt == nil || evt.Type != "interrupt" {
return false
}
if v, _ := evt.Payload["kernel"].(bool); !v {
return false
}
text, _ := evt.Payload["content"].(string)
return text == ""
}