Files
HomeAgent/internal/agent/core/agent.go
JianFeeeee 82932821c0 feat(security): 每 agent 一个策略实例 —— sessionProfile / longtimeProfile 两层
## 用户裁定

> 每个 agent 一个,分为 sessionProfile 和 longtimeProfile

## 收敛前的缺陷

档位挂在 `Runtime.active`(**进程级单例**)上,多 agent 并发时串档:

	主 agent 轮次 A:注入 qq.guest  →  active = "qq.guest"
	驻留子 轮次 B:注入 qq.owner    →  active = "qq.owner"     ← 覆盖 A
	主 agent 轮次 A 继续执行工具     →  判定按 owner 走        ← 串档

这不是理论风险:驻留子 / rtchild 与主 agent **同进程并发**(plan.md 十一)。

## 切分原则:共享注册表 + per-agent 执行视图

**不能整份复制 Runtime** —— `profiles` / `principals` / `scopes` 是插件
声明出来的注册表,每个 agent 各持一份就会变成「插件往 A 声明了档位,
B 看不见」,那是最难查的一类缺陷。

	共享(进程级 1 份)              per-agent(N 份)
	────────────────────────        ───────────────────
	gate / policy / content          sessionProfile   ← 轮次级
	profiles / principals            longtimeProfile  ← agent 生命期
	scopes / eventPolicy

## 两层档位语义(用户裁定)

	sessionProfile  —— 属于**这一轮**。输入携带它,End() 即清。
	                   严格 per-round:同一用户下一条消息要重新声明。
	longtimeProfile —— 属于**这个 agent 的一段生命**。设置后跨轮保留,
	                   直到显式改掉或 agent 销毁。

**优先级 session > longtime**,这个顺序本身是安全语义:

	session 是"这一轮的事实",必须能盖住 long-time 默认值 ——
	否则访客发一条消息也无法在长期为 owner 的 agent 上收窄权限。

	反向(longtime 盖 session)= 提权:长期 owner 的 agent 上,
	访客声明的 guest 被吞掉 ⇒ 访客拿到 owner 权限。

## 改动

① 新增 `internal/security/agentscope.go`:

	rt.ForAgent(agentID, isChild) *AgentScope   // 幂等
	sc.BeginRound(RoundInput)                   // security 自己解析档位
	sc.SetLongtimeProfile(name) / LongtimeProfile()
	sc.EffectiveProfile()                        // session > longtime
	sc.CheckTool(tool)                           // 全局基线 + 档位 + 主体策略
	rt.ReleaseAgent(agentID)                     // 含 longtime 档一起清

② `Runtime` 新增 `scopesByAgent` 表;判定拆出 `checkToolWithProfile(p, profile, tool)`
   与 `checkToolForWithProfile(...)` —— **档位由调用方显式传入**,
   不再一律读全局 `r.active`(否则 per-agent 视图白做)。

③ `agent/core`:`securityScope()` 用 `sync.Once` 缓存本 agent 的视图;
   `Stop()` 里 `ReleaseSecurityScope()`。

④ `Round.End()`:per-agent 路径只清 **session**,**保留** longtime;
   旧进程级路径仍恢复进入前的值。

## 判据(3 个变异全部实测咬住)

| 判据 | 变异 |
|---|---|
| `TestAgentScopesDoNotShareSessionProfile` | ForAgent 退化为共享视图 ⇒ 红 |
| `TestLongtimeProfileSurvivesRounds` | 优先级写反 / longtime 不跨轮 ⇒ 红 |
| `TestReleaseAgentDropsLongtimeProfile` | ReleaseAgent 不删视图 ⇒ 红 |
| `TestLongtimeProfileRejectsUndeclared` | 未声明档位被接受 ⇒ 红 |

第一条同时钉:① 各自档位是自己的 ② **行为上**主 agent 仍被 guest 收窄、
子 agent 不受影响(只断言字符串相等会漏掉判定链没接上的情形)。

## 实测

- 真实并发探针:3 个 agent(guest / owner / guest)各 200 轮并发,
  `-race` 干净,**零串档**
- build / vet / go test ./...(62 ok / 0 fail)/ -race 全绿

## 未变的约束

档位与主体策略**只能收窄**:`CheckToolFor` 内部先跑全局策略,
所以父的黑名单对子依然有效,子无法提权。
2026-10-31 07:24:03 +08:00

750 lines
32 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 (
"context"
"log"
"strings"
"sync"
"time"
agentPkg "github.com/JianFeeeee/HologramAgent/internal/agent"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
"github.com/JianFeeeee/HologramAgent/internal/memory/social"
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
"github.com/JianFeeeee/HologramAgent/internal/plugin"
"github.com/JianFeeeee/HologramAgent/internal/security"
"github.com/JianFeeeee/HologramAgent/internal/tracker"
"github.com/JianFeeeee/HologramAgent/pkg/types"
)
// ContextEvent 和 RelevanceContext 定义在 context.go
// Agent — 单 agent,不区分会话/实例
//
// 并发现状(M3a 起):所有任务状态只由 **schedulerLoop goroutine** 独占读写,
// 因此不再有保护整轮执行的互斥量——挂起不能持锁(见 docs/zh/input-scheduler-design.md §8.1 I3)。
// 仍需跨 goroutine 保护的是:childMu/llmMu/lastInputMu/noMergeMu 与各子系统自己的锁;
// interceptLoop 只允许触碰 preemptionRequest 与 cancelLLM(经 llmMu)。
type Agent struct {
id types.AgentID
provider agentAPI.Provider
providerManager *agentAPI.ProviderManager
// preferredProvider 是本 agent 优先使用的模型源名("" = 总默认源)。
// 语义见 AgentConfig.PreferredProvider:优先而非独占。
preferredProvider string
io *agentIO.IOManager
memory *memory.GraphDB
// graph 是本 agent 的**图记忆共同面**(根 = 同一个 GraphDB;驻留子 = LightMemory)。
// 整理面仍走 memory 字段(子为 nil ⇒ 既有的 nil 关卡自动禁用整理面)。
graph GraphMemory
// kernelSource/parentID/taskPrompt/dataDir:驻留子相关的层级信息(见 AgentConfig)。
kernelSource string
parentID string
taskPrompt string
dataDir string
// isRealtime 标记本 agent 是 **rtchild**(实时子 agent,plan.md 十一)。
//
// 它是 agent **类型标签**,与通道侧的 CapRT 是两个不同结构:
// · CapRT —— “这个**通道**能承载 rt 流”(插件声明)
// · isRealtime —— “这个 **agent** 是 rtchild”(内核构造时定)
// rt 通道的划拨要两侧都满足(见 Agent.AssignRealtime)。
//
// 与 kernelSource 同类:都是“这个 agent 在层级/类型上是谁”的不可变标记。
isRealtime bool
// 驻留子(父侧):登记表 + 子侧钩子。
residentMu sync.Mutex
residents map[string]*residentChild
// rtchild(实时子 agent,plan.md 十一):登记表 + 单槽输入。
//
// ★ 为何与 residents 分开而不复用:两者的语义**相反**:
// residents:有任务队列、有残余可回灌、父可 pull 状态面(不丢活)
// rtChildren:无队列(单槽覆盖)、无残余、最新者胜(低延迟)
// 混在一张表里会让"该不该转残余"这类判断变成条件分叉。
rtMu sync.Mutex
rtChildren map[string]*rtChild
// rtClosedTranscripts 是**已回收** rtchild 的上下文留档(id → 全文)。
//
// 为什么必须留档:交接是「推信号 + 拉状态」两步(见 CloseRTChild),
// 推完中断后实例已出登记表 —— 若不留档,主 agent 收到"可调用 rt_transcript
// 取回原文"的提示时,原文已经取不到了。
//
// ★ 键是 id,所以**同名复用会撞键**。串线的真因在读侧:RTTranscript 曾
// "先查留档、命中即返回",于是新实例读到的永远是上一通已回收的原文
// (2026-10-30 实测复现)。现在改为**在跑实例优先**(见 RTTranscript)。
//
// ★ 刻意**不**在 Spawn 时 delete 这份留档:留档存在的意义就是"回收后
// 主 agent 按需取回原文",提前删掉等于让推了信号却拉不到正文。
// 同名键只在下一次 CloseRTChild 写入时被自然覆写。
rtClosedTranscripts map[string][]agentAPI.Message
// rtSlot 是**本 agent 作为 rtchild 时**的输入槽(作为父时无用)。
rtSlot *rtSlot
// rtMaxTools 是本 agent 作为 rtchild 时的工具批数上限(0 = 不限)。
rtMaxTools int
// 子侧:向父发消息(L3)与 contextfull 上报(父侧内核级事件)的钩子。
notifyParent func(text string)
onContextFull func()
// ctxFullSignaled / ctxFullMu:contextfull 只上报一次的去重标记。
//
// ★ 必须加锁(2026-10-30,-race 实测抓到真实竞争):
// 读它的是**子自己的调度器 goroutine**(checkContextFull,stepPrepare 里),
// 而写它的是**父的 goroutine**(CompressResident → resetContextFull)。
// 这两个 goroutine 并发读写同一 bool,是无锁数据竞争。
ctxFullMu sync.Mutex
ctxFullSignaled bool
// 子侧:inputch 处理表(子持有,父 pull)。
tableMu sync.Mutex
inputchTable []InputchRecord
inputchPending *InputchRecord
currentInputch string
indexer *memory.Indexer
tracker *tracker.Tracker
context *RelevanceContext
systemPrompt string
ctx context.Context
cancel context.CancelFunc
// 文档记忆(第二层)
coldcontextStore *coldcontext.Store
// 知识库
knowledge *knowledge.Store
// 人物特质与关系网
social *social.SocialStore
// 文本记忆(原始对话日志)
textMem *text.Memory
// 媒体存储句柄已随 CAS 淘汰(块自带负载,见 plan.md)。
// 人格设定(内容来自启动时载入的人格文件/配置项)
personality *agentPkg.Personality
// 人格落库面:首启门禁与 persona_set 工具使用(见 persona.go)。
// 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。
personaStore PersonaStore
// 被授权的输出通道集合(空 = 完整授权,见 AgentConfig.AllowedOutputs)。
allowedOutputs []string
// toolResultWarnTokens 是「单条工具结果过大」的告警阈值(0 = 用默认)。
// ⚠️ 方案 B 只**统计与报告**,绝不裁剪(见 toolresult_budget.go 的理由)。
toolResultWarnTokens int
// toolResultReporter 报告超限;nil 时用 logReporter。
toolResultReporter toolResultReporter
// 插件注册表(用于 plgreload)
pluginReg *plugin.Registry
pluginDir string
// 定期心跳蒸馏
distillInterval time.Duration
// 三个独立心跳任务间隔
archiveInterval time.Duration // 冷文档归档
reviewInterval time.Duration // 关系复审
mergeInterval time.Duration // 实体合并检测
// 上下文裁剪:活跃上下文最大条数,超出按相关性裁剪
maxContextSize int
// overflowStat 统计上下文超页处理(触发次数、裁剪条数、终止次数)。
// 放 Agent 上而非全局:多实例并存时各自独立(跑分台就要实例隔离)。
overflowStat struct {
sync.Mutex
Checked int // 判据执行次数(诊断:区分「没触发」与「没执行」)
Triggered int // L4 上报次数
Aborted int // 裁不出东西的终止次数
Upstream int // 上游 ErrContextFull 次数
LastPruned int // 上次裁剪条数
LastRatio float64
LastEvents int
}
// ctxTuning 是上下文预算的可调参数(零值 = 历史默认)。
//
// 从配置读入(core.agent.context.*),使不同窗口的实例可以各自调优,
// 而不是所有实例共用一套写死的 0.8 / 600000 / 1:3。
ctxTuning ContextTuning
// 当前请求的输出通道(mutex 保护,process() 内独占)
// 阶段管道:插件消息流编辑
stageHost *StageHost
eventBus *events.Bus
pluginHealth *pluginHealthTracker
// 自循环输入通道:核心内部任务(记忆消歧、系统维护、子 Agent 通知),
// 不经过 IO 层。每条消息携带目标输出通道:
// "_consolidation_" = 记忆整理(无记忆路径,不写入上下文、不 emit 响应)
// 其他 = 正常处理(写入上下文、emit 响应到该通道)
selfInputCh chan selfInputMsg
// 子任务异步执行
childMu sync.Mutex
childNextID int64
// childTasks 记录子任务状态:运行中 / 结果 / 是否已交付。
//
// 为什么保留结果而不是“读到即删”:完成通知会写进持久上下文
// (formatMergedTimeline 每轮都重新注入),模型之后还会再查。若读到即删,
// 第二次查询就得到“不存在或已过期”这个**永久失败信号**——模型据此认为
// 任务未完成,会无限重试/汇报(实测单轮 35 次工具调用、持续 514 秒)。
childTasks map[string]*childTaskState
// childSeq 给完成的任务排个序,用于有界淘汰。
childSeq int64
// 输入调度器:就绪队列、任务抽象与快照(见 scheduler.go)。
// M2 起取代 eventLoop 的隐式 channel 排队。
sched *scheduler
// 工具轮次硬上限(0 = 不限);见 AgentConfig.MaxToolTurns。
maxToolTurns int
// offload 是积压任务自动转投的参数(见 offload.go)。
offload OffloadOptions
// 进行中的 LLM 请求取消函数,interceptLoop 可调用以在请求中打断
cancelLLM context.CancelFunc
llmMu sync.Mutex
// 模型思考模式(thinking/reasoning)
thinkingEnabled bool
pendingToolBlocks []interface{} // 插件工具通过 SetToolBlocks 注入的多模态块(type agentAPI.ContentBlock),process.go 消费后追加到 tool message
// 启动时间
startTime time.Time
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
pendingMedia map[string]interface{}
// pendingMediaBlocks 累积本轮已捕获的媒体块。
//
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner。
// 由 schedulerLoop goroutine 独占读写。
pendingMediaBlocks []memory.MemoryBlock
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
interruptInput bool
// 非文本输入处理配置
inputCfg types.InputProcessingConfig
// noMergeMarkets 记录被标记"禁止合并"的实体对,key="entityA||entityB"(字典序),
// 每次 reorgGraph 扫描到对应实体对时计数减一,归零后自动移除。
noMergeMarkers map[string]int
noMergeMu sync.Mutex
// 输入去重:防 webui/GUI 断线重连导致的消息重放
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
lastInput map[string]time.Time
lastInputMu sync.Mutex
// 词嵌入模型,用于实体语义相似度计算
embedder *memory.StaticEmbedder
// multimodalSpace 是统一多模态向量空间(可选)。实现可以是内嵌 ONNX,
// 也可以是外部 API 客户端;两者共享同一套 L0/L2/L3 向量缓存与检索基础设施。
multimodalSpace vector.MultimodalEmbedder
// embeddingProvider 是配置里指定的统一向量空间 provider 名;
// embeddingError 是打开/适配失败的原因(成功时为空)。
// 二者只用于状态报告:区分「没配」「配了但打不开」「已启用」。
embeddingProvider string
embeddingError string
// fusionCfg 控制文本路与视觉路的跨模态融合权重,可按模型实测结果配置。
fusionCfg CrossModalFusionConfig
// 技能索引提供者:由 skillmgr 插件实现,向 system prompt 注入轻量技能索引
skillIndex SkillIndexProvider
// device 是挂载上来的工具/通道数据面(kernelmod/device 产出)。
// 类型是低层接口:core 不认识具体模块,避免 core ↔ kernelboot 成环。
device agentAPI.DeviceSurface
// deviceMu 只保护 device 字段(挂载是装配期动作,读取可能来自状态面)。
// ★ 刻意不复用整轮执行的锁:Agent 按设计**没有**保护整轮的互斥量
//(挂起不能持锁,见 input-scheduler-design §8.1 I3)。
deviceMu sync.RWMutex
// builtinHandlers 是交给 device 路由层的族处理器适配器(装配期构造一次)。
builtinHandlers agentAPI.BuiltinToolHandlers
// securityScopeView / securityScopeOnce 是本 agent 的**策略执行视图**
// (security 按 agentID 持有,见 security_profile.go 的 securityScope)。
//
// ★ 为什么是 per-agent 而不是全局:轮次档位(sessionProfile)是该 agent
// 独有的状态。放在共享单例上,主 agent 的 guest 会被驻留子的 owner 覆盖
// —— 实测可复现(两者同进程并发)。
securityScopeView *security.AgentScope
securityScopeOnce sync.Once
// security 是安全防火墙运行时(security 模块产出,可 nil)。
//
// ★ 可 nil:测试与独立工具(memgc 等)不需要安全模块。
// 所有使用点写法统一为 `if a.security != nil`(零成本跳过)。
security *security.Runtime
// usageLedger 累计**跨调用**的 token 用量与缓存命中。
//
// 为何是 Agent 级而不是 TaskFrame 级:「这个会话花了多少、缓存省了多少」
// 不是单次调用的属性,必须跨轮次、跨任务累积才有意义。
// 单次用量一直在 StageCtx 与 LLM chain 事件里,缺的正是这个落点。
usageLedger usageLedger
}
// SkillIndexProvider 提供已加载技能的精炼索引,供 buildSystemPrompt 注入。
// 实现方(skillmgr)需线程安全并快速返回(每次 LLM 调用都会调用)。
type SkillIndexProvider interface {
// SkillIndex 返回多行文本的技能索引,每行形如 "name vX.Y - description";
// 无技能时返回空串。
SkillIndex() string
}
type AgentConfig struct {
ID types.AgentID
SystemPrompt string
Provider agentAPI.Provider
ProviderManager *agentAPI.ProviderManager
// PreferredProvider 是本 agent **优先使用**的模型源名(空 = 用总默认源)。
//
// ## 为什么需要它(用户 2026-10-30 要求)
//
// 「要求支持为主 agent、子 agent、驻留式子 agent、rt agent 可各自配置模型源,
// **默认使用总模型源(default)**」
//
// 之前四类 agent 共用 providerManager 的同一套顺序:谁都没法单独指定。
// 但它们的负载完全不同 —— 主 agent 要强推理、rtchild 要极低首字延迟、
// 驻留子要长时间挂着省成本。用同一个源对三者都是妥协。
//
// ## 语义:**优先,不是独占**
//
// 它把该源排到候选链**最前面**,其余源仍作为回退(与原有多源回退语义一致)。
// 硬锁定成唯一源会让"这个源挂了"直接变成"这个 agent 完全不工作",
// 而回退链正是本仓为此建立的机制。
//
// 名字不存在时**响亮回退到默认源并记日志**(不静默):配置拼错要能看见。
PreferredProvider string
IO *agentIO.IOManager
Memory *memory.GraphDB
// LightMemory 是**轻量内核**的图记忆装配(驻留子用;读 temp∪main,只写 temp)。
//
// 给了它就意味着这是轻量内核:`Memory` 必须为 nil,
// 于是记忆整理面(块/媒体/流水线/整理工具)全部不可达(见 memoryface.go)。
LightMemory *memory.LightMemory
Indexer *memory.Indexer
Tracker *tracker.Tracker
DocStore *coldcontext.Store
Knowledge *knowledge.Store
SocialStore *social.SocialStore
TextMemory *text.Memory
// 媒体存储句柄已随 CAS 淘汰(块自带负载,见 plan.md)。
MultimodalSpace vector.MultimodalEmbedder
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
// 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。
EmbeddingProvider string
EmbeddingError string
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
Personality *agentPkg.Personality
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
PluginReg *plugin.Registry
// KernelSource 是本 agent 的"上级"(驻留子的父)。
//
// 设计 §6.1:某个 agent 的 L4 只属于它的**内核** —— 根 agent 的内核是内核自身与
// 内核级插件;驻留子的内核是**父 agent**。因此子的 KernelSource = 父 ⇒ 只有父
// 能在子的阶梯上产生 L4(父的"发送消息")。
KernelSource string
// ParentID 是父 agent 的 id(空 = 根 agent)。子用它判断自己是不是驻留子。
ParentID string
// DataDir 是本 agent 的数据目录;创建驻留子时用它派生 temp 图记忆路径。
DataDir string
// TaskPrompt 是在固定提示词之上注入的**任务提示词**(驻留子创建时给定)。
TaskPrompt string
// AllowedOutputs 是本 agent **被授权的输出通道集合**(设计 §4.4 / R2)。
//
// nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。
// 父 agent 创建驻留子时用它收窄子的输出能力。
AllowedOutputs []string
// Security 是安全防火墙运行时(security 模块产出,可 nil)。
//
// ## 为什么它是**装配期字段**而不是事后 Set
//
// 这个字段曾经只能靠 `SetSecurity()` 注入,而生产装配链里**没有任何
// 地方调用它** —— 唯一的调用方是 4 条测试。于是 `securityDecisionFor`
// 在生产中恒走 nil 分支、恒放行,而策略表本身完全正确:这是
// 「机制 ≠ 接线」的教科书样本(GUI 无拒绝记录,看起来像"没人违规")。
//
// ★ 根因在**依赖图**,不在这行赋值:`agent.Requires()` 没声明
// `FacSecurity`,图就没有理由把 security 的产物交给 agent(只有声明了
// 的 device 拿到了)。所以修复必须同时改三处:
// ① kernelmod/agent 的 Requires() 加 FacSecurity
// ② 这里加字段 + New() 赋值 + buildAgent 传 k.Security
// ③ 装配级判据(删 ① 或 ② 必须红)
// 只做 ②不做 ① 会让代码读 `k.Security` 而图不保证它已就绪 ——
// 今天恰好对(security 在第 1 层)是运气,不是设计。
//
// ## 子 agent 必须继承
//
// 驻留子 / rtchild 各自 `New(AgentConfig{...})`,不继承就等于子 agent
// 完全没有闸门(而它们恰是"能指挥设备、能写记忆"的高风险执行体)。
// 见 resident.go / rtchild.go 里 `Security: a.security`。
Security *security.Runtime
// IsRealtime 标记本 agent 是 **rtchild**(实时子 agent,plan.md 十一)。
//
// 置 true 后:
// · 只有它能被划入带 CapRT 的 rt 通道(Agent.AssignRealtime 校验);
// · 打断不走四级中断阶梯,而走单槽覆盖(plan.md 十一.三 B)。
//
// ★ 这不是权限位,而是**类型标记**:另外两类子 agent 的设计目标函数
// 与 rt 相反(公平 vs 最新者胜),混用会两边都坏。
IsRealtime bool
PluginDir string
DistillInterval time.Duration
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval
MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
// CtxTuning 是上下文预算的可调参数。零值 ⇒ 使用历史默认(与硬编码时代一致)。
//
// 为何必须能从配置传进来:这些阈值原本写死在 ComputeTokenBudget 里,
// 界面改不了、不同窗口的实例也没法各自调优(详见 ContextTuning 的注释)。
CtxTuning ContextTuning
ContextSavePath string // 上下文持久化路径,空则不持久化
EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用
Embedder *memory.StaticEmbedder // 共享词嵌入实例;nil 时按 EmbeddingModelPath 自建
StageHost *StageHost
EventBus *events.Bus
ThinkingEnabled bool
SkillIndexProvider SkillIndexProvider
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
// MaxToolTurns 是单个任务允许的工具轮次上限(0 = 不限)。
// 设计文档 D6:主循环必须有硬上限,否则模型不停调用就永不完结。
MaxToolTurns int
// Offload 是「积压任务自动转投给驻留子」的参数(见 offload.go)。
//
// 默认关闭(Enabled=false):它让**内核替父做决策**,是设计 §7
//「决策在父的模型手里」的刻意例外,因此必须由部署方显式打开。
Offload OffloadOptions
}
func New(cfg AgentConfig) *Agent {
ctx, cancel := context.WithCancel(context.Background())
if cfg.DistillInterval <= 0 {
cfg.DistillInterval = 30 * time.Minute
}
if cfg.ArchiveInterval <= 0 {
cfg.ArchiveInterval = cfg.DistillInterval
}
if cfg.ReviewInterval <= 0 {
cfg.ReviewInterval = cfg.DistillInterval
}
if cfg.MergeInterval <= 0 {
cfg.MergeInterval = cfg.DistillInterval
}
if cfg.MaxContextSize <= 0 {
cfg.MaxContextSize = 30
}
embedder := cfg.Embedder
if embedder == nil {
embedder = memory.NewStaticEmbedder(strings.Split(cfg.EmbeddingModelPath, ",")...)
}
if cfg.DocStore != nil {
// TF-IDF 内置为 fallback,无需外部注入
}
if cfg.Knowledge != nil {
cfg.Knowledge.SetVectorizer(embedder)
cfg.Knowledge.ReindexWithVectorizer(embedder)
}
rc := NewRelevanceContext(cfg.ContextSavePath, embedder)
// 裁剪保留条数:从配置传入(0 时保持 NewRelevanceContext 的历史默认)。
if cfg.CtxTuning.ProtectedCount > 0 {
rc.protectedCount = cfg.CtxTuning.ProtectedCount
}
if cfg.StageHost != nil {
rc.SetToolDefLookup(cfg.StageHost.ToolDef)
}
if cfg.IO != nil {
rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef)
}
// 注入稠密多模态向量空间(可选):配置后文档检索、L0 相关性裁剪、
// 跨模态检索全部共享同一向量空间,取代稀疏 fastText 语义路。
// 未配置时退化到 TF-IDF/fastText 稀疏检索,保持既有行为。
if cfg.MultimodalSpace != nil && cfg.MultimodalSpace.Loaded() {
rc.SetDenseSpace(cfg.MultimodalSpace)
// 场景召回的相关性判定用同一空间:块里存的就是这个空间的向量,
// 所以"相关"只是一次余弦比较(缺了它就只能按时间倒序灌旧事实)。
if cfg.Indexer != nil {
cfg.Indexer.SetDenseSpace(cfg.MultimodalSpace)
}
if cfg.DocStore != nil {
cfg.DocStore.SetDenseSpace(cfg.MultimodalSpace)
cfg.DocStore.BuildDenseIndex(cfg.MultimodalSpace)
}
// 知识库接入同一多模态空间:媒体作为一等节点参与稠密召回,
// 于是「按图搜知识」「按文搜含图知识」成立。
//
// 稀疏两路(词向量 + TF-IDF)**保持启用**且仍是主召回路径:多模态
// 空间未配置时知识库行为与此前逐字一致(退化为 0.5/0.5 两路融合)。
if cfg.Knowledge != nil {
// 顺序要紧:先接线再重建。ReindexDense 会尝试从 .dense.json 缓存恢复,
// 恢复不了才重算,最后把结果落盘。若先重建后接线,首次启动算出的
// 向量会被丢掉而不落盘。
cfg.Knowledge.SetDenseSpace(cfg.MultimodalSpace)
// 媒体字节随知识条目自带(CAS 淘汰后):denseFor 直接读条目目录里的
// 媒体文件,无需 mediaGet/mediaPut 接线。
built, skipped := cfg.Knowledge.ReindexDense()
if built > 0 || skipped > 0 {
log.Printf("[knowledge] multimodal dense index: built %d, skipped %d (rest hit cache)", built, skipped)
}
}
}
a := &Agent{
id: cfg.ID,
startTime: time.Now(),
provider: cfg.Provider,
preferredProvider: cfg.PreferredProvider,
providerManager: cfg.ProviderManager,
io: cfg.IO,
memory: cfg.Memory,
graph: graphMemoryOf(cfg),
kernelSource: cfg.KernelSource,
parentID: cfg.ParentID,
taskPrompt: cfg.TaskPrompt,
dataDir: cfg.DataDir,
isRealtime: cfg.IsRealtime,
indexer: cfg.Indexer,
tracker: cfg.Tracker,
context: rc,
systemPrompt: cfg.SystemPrompt,
ctx: ctx,
cancel: cancel,
coldcontextStore: cfg.DocStore,
knowledge: cfg.Knowledge,
social: cfg.SocialStore,
textMem: cfg.TextMemory,
// mediaStore 已随 CAS 淘汰:块自带负载。知识层的媒体也已自带字节
// (见 knowledge 的媒体侧车),不再需要外部 getter/putter 接线。
personality: cfg.Personality,
personaStore: cfg.PersonaStore,
allowedOutputs: cfg.AllowedOutputs,
pluginReg: cfg.PluginReg,
pluginDir: cfg.PluginDir,
distillInterval: cfg.DistillInterval,
archiveInterval: cfg.ArchiveInterval,
reviewInterval: cfg.ReviewInterval,
mergeInterval: cfg.MergeInterval,
maxContextSize: cfg.MaxContextSize,
ctxTuning: cfg.CtxTuning,
stageHost: cfg.StageHost,
skillIndex: cfg.SkillIndexProvider,
eventBus: cfg.EventBus,
selfInputCh: make(chan selfInputMsg, 64),
childTasks: make(map[string]*childTaskState),
sched: newScheduler(256),
maxToolTurns: cfg.MaxToolTurns,
offload: cfg.Offload,
pluginHealth: newPluginHealthTracker(),
thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing,
embedder: embedder,
multimodalSpace: cfg.MultimodalSpace,
embeddingProvider: cfg.EmbeddingProvider,
embeddingError: cfg.EmbeddingError,
fusionCfg: cfg.FusionCfg,
// ★ 安全运行时在**构造期**落位(不再依赖事后 SetSecurity)。
// 为 nil 表示调用方显式不启用(独立工具/单测),不是"忘了接线"。
security: cfg.Security,
noMergeMarkers: make(map[string]int),
lastInput: make(map[string]time.Time),
}
// 输入路由:inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent
// (设计 §4.1「路由发生在进内核之前」)。io 层不认识 agent,所以在这里把路由器
// 注入进去:插件注入输入时先问它,被别的 agent 接管就不再进本内核队列。
if a.io != nil {
a.io.SetInputRouter(a.routeInputByOwner)
}
return a
}
// SetSkillIndexProvider 注入技能索引提供者(skillmgr 插件加载后由 main 接线)。
func (a *Agent) SetSkillIndexProvider(p SkillIndexProvider) { a.skillIndex = p }
func (a *Agent) Start() {
go a.schedulerLoop()
go a.interceptLoop()
go a.distillLoop()
go a.archiveLoop()
go a.mergeLoop()
go a.reviewLoop()
go a.offloadLoop()
a.reembedStaleMedia()
a.migrateLegacyGraphMedia()
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
}
func (a *Agent) Stop() {
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
a.StopResidents()
// ★ 释放本 agent 的策略执行视图(2026-10-31)。
//
// 视图里存着 **longtimeProfile**(跨轮保留的档位)。不释放的话,
// 同名 agent 重建会继承上一代的 long-time 档 —— 那时可能是
// "上一个任务留下的访客权限"被新一代默默沿用。
// 与主体策略撤销(revokeChildPolicy)同一纪律。
a.ReleaseSecurityScope()
// 停机前给待办任务补终态。运行中的任务会经 cancel → LLM 失败 → emitResponse
// 自然拿到终态,但**从未运行**(排队/待处理)与**已挂起**的任务不会有任何人
// 回它们;带 ResponseCh 的同步注入方(cli / clawhubadapter 均无超时)会永久挂起
// (设计 §7 I5、§11.3 X2/X4)。必须在 cancel 之前做:cancel 会让调度器直接 return。
a.drainPendingInterrupts("agent_stopped")
a.cancel()
}
// drainPendingInterrupts 给排队/待处理/已挂起任务中带同步回执通道的调用方补一条
// skipped 终态(复用 emitSkippedReply:非阻塞写,不对外发 agent_output 事件)。
func (a *Agent) drainPendingInterrupts(reason string) {
if a.sched == nil {
return
}
pending := a.sched.pendingEvents()
if len(pending) == 0 {
return
}
for _, evt := range pending {
a.emitSkippedReply(evt, reason)
}
log.Printf("[agent] %s: shutting down, filled skipped terminal state for %d pending task(s)", a.id, len(pending))
}
// graphMemoryOf 决定本 agent 的图记忆共同面实现。
//
// - 轻量内核(给了 LightMemory):用 LightMemory,**整理面保持 nil**;
// - 完整内核:直接用主图库(*memory.GraphDB 天然满足 GraphMemory)。
func graphMemoryOf(cfg AgentConfig) GraphMemory {
if cfg.LightMemory != nil {
return cfg.LightMemory
}
if cfg.Memory == nil {
return nil
}
return cfg.Memory
}
// graphMem 返回本 agent 的图记忆**共同面**。
//
// `graph` 显式为 nil 时回落到 `memory` —— 这样"只设 memory 的构造"
// (大量既有测试直接用 Agent 字面量)照常工作,不需要同时维护两个字段。
// 轻量内核则显式设 graph=LightMemory 且 memory=nil:共同面走 LightMemory,
// 整理面因 memory==nil 而全部不可达。
func (a *Agent) graphMem() GraphMemory {
if a.graph != nil {
return a.graph
}
if a.memory == nil {
return nil
}
return a.memory
}
func (a *Agent) ID() types.AgentID { return a.id }
// IsOutputAllowed 报告某个输出通道是否被授权给本 agent。
//
// 默认(未配置白名单)= **完整授权**;这是"默认完整授权、父可收窄"的落点。
func (a *Agent) IsOutputAllowed(channel string) bool {
if len(a.allowedOutputs) == 0 {
return true
}
for _, c := range a.allowedOutputs {
if c == channel {
return true
}
}
return false
}
// ResolveOutputTarget 解析输出通道的投递目标(agent + inputch)。
//
// ok=false 表示该输出通道由传输层(device 通道,如 qq/webui)自行处理。
func (a *Agent) ResolveOutputTarget(channel string) (agentIO.OutputTarget, bool) {
if a.io == nil || a.io.ChannelRegistry() == nil {
return agentIO.OutputTarget{}, false
}
return a.io.ChannelRegistry().ResolveOutputTarget(channel)
}
// isDuplicateInput 判断是否为短窗口内的重复输入(防 webui/GUI 断线重连消息重放)。
// key=source+"|"+content;窗口内重复返回 true 并刷新时间戳(持续轰炸时保持拦截)。
const duplicateInputWindow = 10 * time.Second
func (a *Agent) isDuplicateInput(source, content string) bool {
a.lastInputMu.Lock()
defer a.lastInputMu.Unlock()
now := time.Now()
key := source + "|" + content
if last, ok := a.lastInput[key]; ok && now.Sub(last) < duplicateInputWindow {
a.lastInput[key] = now
return true
}
a.lastInput[key] = now
// 顺带清理过期项,防止 map 无限增长
for k, t := range a.lastInput {
if now.Sub(t) > duplicateInputWindow {
delete(a.lastInput, k)
}
}
return false
}
// IsDuplicateInput 导出包装,供测试验证去重行为。
func (a *Agent) IsDuplicateInput(source, content string) bool {
return a.isDuplicateInput(source, content)
}
// SelfInputChan 返回自循环输入通道(只读,供内部测试验证)
func (a *Agent) SelfInputChan() <-chan selfInputMsg {
return a.selfInputCh
}
// injectSelf 向自循环通道发送记忆整理类内部任务(无记忆路径)。
// 线程安全,不阻塞发送者(通道缓冲 64)。
func (a *Agent) injectSelf(task string) {
a.injectSelfChannel(selfInputMsg{
text: task,
channel: channelConsolidation,
})
}
// injectSelfChannel 向自循环通道发送一条带目标通道标志的消息。
// channel == "_consolidation_" 走无记忆整理路径;其他值走正常处理路径。
func (a *Agent) injectSelfChannel(msg selfInputMsg) {
select {
case a.selfInputCh <- msg:
default:
log.Printf("[agent] self input channel full, dropping task: %s", truncateStr(msg.text, 80))
}
}