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 包全绿
This commit is contained in:
JianFeeeee
2026-10-26 20:55:25 +08:00
parent ca956f69bf
commit 55fa2685ae
92 changed files with 3932 additions and 596 deletions

View File

@ -0,0 +1,73 @@
package api
// Device 数据面的**公共契约**(低层包,避免 import 环)。
//
// ## 为什么这些类型必须住在这里
//
// 依赖图上有两个方向的需求:
//
// internal/kernelmod/device → kernelboot → internal/agent/core
// internal/agent/core → 需要满足 device 的 ToolHost 接口
//
// 如果接口定义在 kernelmod/device,agent/core 就得 import 它 ⇒ **循环**。
// 所以契约下沉到 agent/api(core 早已依赖它,而它不依赖任何内核模块)。
// ToolDefinition 是工具定义的数据面投影(与具体 schema 表示解耦)。
type ToolDefinition struct {
Name string
Description string
// ParallelSafe 声明该工具可与同批其他工具并发执行。
ParallelSafe bool
// Timeout 是声明的超时(空 = 用内核默认)。
Timeout string
}
// ToolHost 是 device 需要 agent 提供的那部分能力。
//
// ★ 用接口而不是具体类型:第 2 步把工具实现迁进 device 时,agent 只需
// 继续满足这个接口,双方都不必互相 import。
type ToolHost interface {
// ToolDefinitions 返回当前 agent 可见的工具定义(含运行期门控)。
ToolDefinitions() []ToolDefinition
// ExecuteTool 执行一个工具调用(已含参数校验、授权闸、超时、脱敏)。
ExecuteTool(name string, args map[string]interface{}) (string, error)
}
// DeviceSurface 是 device 数据面暴露给内核的最小面(KernelState.Device 的类型)。
//
// 只放"别的模块真的要读"的东西:诊断与状态面用工具清单与数量;
// 执行只经 Agent(安全闸门在那边),**不**从这里暴露任意执行入口。
type DeviceSurface interface {
Tools() []ToolDefinition
ToolCount() int
}
// BuiltinToolHandlers 是**内置工具的族处理器**(每个族一个方法)。
//
// ## 为什么用族的粒度而不是逐工具
//
// 族内工具共享同一批依赖(memory 族要 graph、knowledge 族要 store、
// coldcontext 族要 docStore…)。按族传接口,device 的分发层就能
// 在不认识 Agent 内部字段的前提下完整接管**路由**:
//
// device.Dispatch(tc) → 选族 → handler.ExecuteMemoryTool(tc)
//
// 第二阶段再把族实现搬进 device(那时依赖改为显式 Deps 结构体),
// 路由这一段不用再改。
type BuiltinToolHandlers interface {
ExecutePersonaTool(tc ToolCall) string
ExecuteMemoryTool(tc ToolCall, turnScenes []string) string
ExecuteSocialTool(tc ToolCall) string
ExecuteKnowledgeTool(tc ToolCall) string
ExecuteColdContextTool(tc ToolCall) string
ExecuteSecretTool(tc ToolCall) string
ExecuteOutputSendHelp(tc ToolCall) string
ExecuteOutputSend(tc ToolCall) string
ExecuteOutputListChannels() string
ExecuteInputChannels(tc ToolCall) string
ExecuteResidentAgents(tc ToolCall) string
ExecuteNotifyParent(tc ToolCall) string
ExecuteInputchNote(tc ToolCall) string
ExecutePluginReload() string
ExecuteGetPluginTools(pluginName string) string
}

View File

@ -117,7 +117,22 @@ func TestMainInitsSecurityBeforeProviders(t *testing.T) {
}
text := string(src)
iSec := strings.Index(text, "security.InitGlobal")
// ★ 2026-10-26:安全装配搬进正式内核模块 internal/kernelmod/security。
// main.go 里的真实装配点是 `securitymod.Prepare(...)`,而
// `security.InitGlobal` 在模块实现里。判据必须同时盯住**两个真实点**:
// · main.go 的 Prepare 调用(装配时机)
// · 模块实现里的 InitGlobal(Prepare 不是空壳)
// 只盯其中一个都会变成"改了实现就静默变绿"。
iSec := strings.Index(text, "securitymod.Prepare(")
if iSec < 0 {
iSec = strings.Index(text, "security.InitGlobal")
}
modSrc, modErr := os.ReadFile("../../kernelmod/security/mod.go")
modText := string(modSrc)
if modErr != nil || !strings.Contains(modText, "sec.InitGlobal(") {
t.Fatalf("security 模块实现里找不到 InitGlobal(%v)—— "+
"Prepare() 退化成空壳,安全审查不会生效", modErr)
}
// ★ 锚点已随"内核模块化"改写(2026-10-16)。
//
// 原先锚 `initLLMProviders(cfg,`(那时它直接写在 main 里)。
@ -132,7 +147,7 @@ func TestMainInitsSecurityBeforeProviders(t *testing.T) {
// 为防止"换个锚点就变绿"式的偷懒,另加两条硬校验(见下)。
iProv := strings.Index(text, "RunKernelModules(")
if iSec < 0 {
t.Fatal("main.go 里找不到 security.InitGlobal —— 安全模块根本没被装配")
t.Fatal("main.go 里找不到 securitymod.Prepare / security.InitGlobal —— 安全模块根本没被装配")
}
if iProv < 0 {
t.Fatal("main.go 里找不到 RunKernelModules —— 内核模块装配点没了")

View File

@ -18,6 +18,7 @@ import (
"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"
)
@ -223,6 +224,23 @@ type Agent struct {
// 技能索引提供者:由 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
// security 是安全防火墙运行时(security 模块产出,可 nil)。
//
// ★ 可 nil:测试与独立工具(memgc 等)不需要安全模块。
// 所有使用点写法统一为 `if a.security != nil`(零成本跳过)。
security *security.Runtime
// usageLedger 累计**跨调用**的 token 用量与缓存命中。
//
// 为何是 Agent 级而不是 TaskFrame 级:「这个会话花了多少、缓存省了多少」

View File

@ -54,7 +54,7 @@ type ContextEvent struct {
//
// ★★ 为什么不能顺手把归档也关掉(2026-10-22 踩过的坑):
//
// doc_query 的 Consume 已经**从 L2 删除**了原文。若回填事件在 L1
// coldcontext_query 的 Consume 已经**从 L2 删除**了原文。若回填事件在 L1
// 被裁掉时又不归档,它就**三层都不在**了 —— 内容被永久丢弃。
// L1 是它最后的落脚点,必须能落回去。
//
@ -249,7 +249,7 @@ func (c *RelevanceContext) load() {
}
func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef, channelDefLookup func(name string) (sdk.ChannelDef, bool)) string {
// 事件自带 NoMemory(doc_query 回填的历史事件):不进计算层。
// 事件自带 NoMemory(coldcontext_query 回填的历史事件):不进计算层。
//
// 为什么回填事件不参与计算:它们的内容刚刚在 L2 里被向量化过一次,
// 再算一次只会让"同一内容"在向量空间里被重复加权。但它们**照常归档**。
@ -289,7 +289,7 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD
}
// ② 内置工具(core 自己的 switch 分派):走包级声明表。
//
// ★ doc_query 属于这一类 —— 它的返回值含被召回事件的正文片段,
// ★ coldcontext_query 属于这一类 —— 它的返回值含被召回事件的正文片段,
// 而那些正文已在记忆体系里(来自 L2/L3)。若返回值再参与向量化
// 与蒸馏,就是把同一内容重新注入记忆,与「回填事件不再归档」
// 构成同一个自激回路的两个入口。
@ -583,7 +583,7 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc
// ★ 回填事件(NoMemory)**照常归档** —— 这是数据不丢的关键。
//
// 曾经在这里跳过它们(理由是"防自激"),那是错的:
// doc_query 的 Consume 已经把 L2 副本删掉了,L1 是唯一副本。
// coldcontext_query 的 Consume 已经把 L2 副本删掉了,L1 是唯一副本。
// 跳过归档 = 裁掉即丢失(L1、L2、L3 三层都不在)。
//
// 自激的入口不在"归档"这一侧,而在"重新计算向量"那一侧 ——

View File

@ -250,7 +250,7 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
// 2. **没有一个字**说"你可以把这些原文取回来" —— 模型看完只能忽略;
// 3. 来源写了 `context_archived` 这种内部标记,对模型无意义。
//
// 现在每条给出「时间 · 来源 · 正文开头」,并在块尾**明确告知 doc_query 的作用**:
// 现在每条给出「时间 · 来源 · 正文开头」,并在块尾**明确告知 coldcontext_query 的作用**:
// 这才是让预填充真正被用起来的必要条件。
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
if len(hits) == 0 {
@ -306,7 +306,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
b.WriteString("【历史记忆(L2,自动预填充)】\n")
b.WriteString("下面是按语义相似度从历史层挑出的相关片段 —— **不是当前对话**。\n")
b.WriteString(strings.Join(lines, "\n"))
b.WriteString("\n要其中某条的完整原文与当时过程:用 doc_query,它会把那些事件" +
b.WriteString("\n要其中某条的完整原文与当时过程:用 coldcontext_query,它会把那些事件" +
"按原始时间戳插回你的对话时序。")
return b.String()
}
@ -328,7 +328,7 @@ func firstRealSource(events []document.DocEvent) string {
// docHeadlineForPrompt 取文档正文的第一句,作为注入行里的内容提示。
//
// 不截断成"摘要":这里只是一行提示,完整内容在 L2 里、需要时用 doc_query 取。
// 不截断成"摘要":这里只是一行提示,完整内容在 L2 里、需要时用 coldcontext_query 取。
func docHeadlineForPrompt(d *document.Doc) string {
if d == nil {
return ""

View File

@ -0,0 +1,159 @@
package core
// Agent 侧的 **device 数据面适配层**。
//
// ## 为什么适配层在 core 而不是让 device 直接操作 Agent
//
// 工具执行必须**始终**经过 Agent 的完整执行链:
//
// 参数 schema 校验 → 工具授权闸 / 设备授权闸 → 超时与取消
// → panic 恢复 → 明文脱敏(出向 + 入向)→ ToolResult 成败判定
//
// 这些是**执行安全**,与敏感信息安全(security 模块)是两件事,
// 都不能被对方顶替(见 plan.md §五)。所以 device 只拿到窄接口
// (api.ToolHost),拿不到 Agent 内部字段,也就绕过不了这条链。
import (
"sort"
"github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// ToolDefinitions 实现 api.ToolHost:返回当前可见的工具定义。
//
// ★ 复用 buildToolDefs 的同一批生成逻辑 —— 两处各写一份必然漂移
// (历史上就出现过"模型看得到但插件查不到"的两套语义)。
func (a *Agent) ToolDefinitions() []api.ToolDefinition {
if a == nil {
return nil
}
raw := a.buildToolDefs()
out := make([]api.ToolDefinition, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
fn, ok := m["function"].(map[string]interface{})
if !ok {
continue
}
name, _ := fn["name"].(string)
if name == "" {
continue
}
desc, _ := fn["description"].(string)
out = append(out, api.ToolDefinition{
Name: name,
Description: desc,
// 并发资格与超时**不在这里重算**:它们由 argvalidate.go 的
// batchRunnable / resolveToolTimeout 统一判定(单一事实来源)。
ParallelSafe: a.toolParallelSafe(name),
Timeout: "",
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// ExecuteTool 实现 api.ToolHost:走完整执行链执行一次工具调用。
//
// 通道用内置标记:工具执行本身不产出"用户可见输出",
// 而记忆写入等场景需要一个稳定的来源标识(见 defaultChannelForBuiltin)。
func (a *Agent) ExecuteTool(name string, args map[string]interface{}) (string, error) {
if a == nil {
return "", errNoAgentForDevice
}
return a.executeToolCall(api.ToolCall{
ID: "device_" + name,
Name: name,
Arguments: args,
}, a.defaultChannelForBuiltin()), nil
}
// MountDevice 挂载 device 数据面。
//
// 单向依赖:core 只认识低层接口(api.DeviceSurface),不认识
// kernelmod/device 的具体类型 —— 否则 core ↔ kernelboot ↔ device 会成环。
//
// 幂等:重复挂载只覆盖(装配期可能因重建 Agent 而重挂)。
func (a *Agent) MountDevice(d api.DeviceSurface) {
if a == nil {
return
}
// 挂载同时接上路由适配器:device 由此拿到"选族后调谁"。
a.builtinHandlers = builtinHandlers{a: a}
a.deviceMu.Lock()
a.device = d
a.deviceMu.Unlock()
}
// Device 返回已挂载的数据面(未挂载时为 nil)。
func (a *Agent) Device() api.DeviceSurface {
if a == nil {
return nil
}
a.deviceMu.RLock()
defer a.deviceMu.RUnlock()
return a.device
}
// errNoAgentForDevice 表示 device 调用时 Agent 未装配(装配顺序错误)。
var errNoAgentForDevice = &deviceHostErr{"device: agent 未装配"}
type deviceHostErr struct{ msg string }
func (e *deviceHostErr) Error() string { return e.msg }
// builtinHandlers 把 Agent 的族处理器暴露成 api.BuiltinToolHandlers。
//
// 为什么用适配器而不是把 Agent 的方法全部导出:
// - 导出只会让"谁都能调内置工具",绕过 executeToolCall 的授权闸
// - 适配器只在 core 内部构造,然后交给 device 的路由层
type builtinHandlers struct{ a *Agent }
func (h builtinHandlers) ExecutePersonaTool(tc api.ToolCall) string {
return h.a.executePersonaTool(tc)
}
func (h builtinHandlers) ExecuteMemoryTool(tc api.ToolCall, scenes []string) string {
return h.a.executeMemoryTool(tc, scenes)
}
func (h builtinHandlers) ExecuteSocialTool(tc api.ToolCall) string {
return h.a.executeSocialTool(tc)
}
func (h builtinHandlers) ExecuteKnowledgeTool(tc api.ToolCall) string {
return h.a.executeKnowledgeTool(tc)
}
func (h builtinHandlers) ExecuteColdContextTool(tc api.ToolCall) string {
return h.a.executeDocTool(tc)
}
func (h builtinHandlers) ExecuteSecretTool(tc api.ToolCall) string {
return h.a.executeSecretTool(tc)
}
func (h builtinHandlers) ExecuteOutputSendHelp(tc api.ToolCall) string {
return h.a.executeOutputSendHelp(tc)
}
func (h builtinHandlers) ExecuteOutputSend(tc api.ToolCall) string {
return h.a.executeOutputSendTool(tc)
}
func (h builtinHandlers) ExecuteOutputListChannels() string {
return h.a.executeOutputListChannels()
}
func (h builtinHandlers) ExecuteInputChannels(tc api.ToolCall) string {
return h.a.executeInputChannels(tc)
}
func (h builtinHandlers) ExecuteResidentAgents(tc api.ToolCall) string {
return h.a.executeResidentAgents(tc)
}
func (h builtinHandlers) ExecuteNotifyParent(tc api.ToolCall) string {
return h.a.executeNotifyParent(tc)
}
func (h builtinHandlers) ExecuteInputchNote(tc api.ToolCall) string {
return h.a.executeInputchNote(tc)
}
func (h builtinHandlers) ExecutePluginReload() string {
return h.a.executePluginReload()
}
func (h builtinHandlers) ExecuteGetPluginTools(pluginName string) string {
return h.a.executeGetPluginTools(pluginName)
}

View File

@ -62,8 +62,8 @@ func TestDeviceToolAuth_NarrowedGrantRefusesOtherDevice(t *testing.T) {
got := a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
"device_id": "other-2", "command": "rm -rf /",
}), "cli")
if !strings.Contains(got, "未授权") {
t.Fatalf("未授权设备应被拒,实际: %s", got)
if !strings.Contains(got, "not authorized") {
t.Fatalf("unauthorized device should be rejected, got: %s", got)
}
if len(called) != 0 {
t.Fatalf("被拒的调用不得落到设备,实际执行了 %v", called)

View File

@ -287,7 +287,7 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
if source == "" {
source = "unknown"
}
alt = fmt.Sprintf("[从 %s 收到了 %s]", source, mediaType)
alt = fmt.Sprintf("[received %s from %s]", mediaType, source)
}
var blocks []agentAPI.ContentBlock
@ -302,7 +302,7 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
// 更糟的是:该工具已于本日移出为内置插件 multimodal,
// 它需要 path 或 digest,而用户直传的图片两者都没有 ⇒
// 模型照着提示词调它,必然拿到「没有可处理的媒体」错误。
desc = fmt.Sprintf("从 %s 收到了一张图片(已随本消息附上,请直接查看)。", source)
desc = fmt.Sprintf("An image arrived from %s (attached to this message; look at it directly).", source)
}
case "audio":
desc = a.inputCfg.Audio.DescribePrompt

View File

@ -280,7 +280,7 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
return a.mediaContextForRelations(res.Relations)
}
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短块 ID),供 doc_query 展示。
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短块 ID),供 coldcontext_query 展示。
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
if d == nil || len(d.Blocks) == 0 {
return ""

View File

@ -399,7 +399,7 @@ func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
if out == "" {
t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入")
}
if !contains(out, "【关联媒体】") {
if !contains(out, "[Related media]") {
t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out)
}
if !contains(out, shortID("blk_auto_1")) {

View File

@ -293,7 +293,7 @@ func TestResolveMediaRefs(t *testing.T) {
// ---------- 文档持有的一等记忆块 ----------
func TestDocCommit_StoresBlocks(t *testing.T) {
// doc_commit 带媒体时,媒体应作为一等块直接存在文档上,
// coldcontext_commit with media stores blocks directly in cold context,
// 并随 doc 一起持久化(块自带字节,不靠外部引用保活)。
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
@ -323,7 +323,7 @@ func TestDocCommit_StoresBlocks(t *testing.T) {
}
}
// ---------- 文档持有块标签(doc_query 展示用) ----------
// ---------- 文档持有块标签(coldcontext_query 展示用) ----------
func TestBlockLabelsForDoc(t *testing.T) {
a := newInputTestAgent(t)
@ -363,7 +363,7 @@ func TestBlockLabel(t *testing.T) {
}
}
// ---------- 模型工具端到端:memory_commit / doc_commit / doc_query ----------
// ---------- model tool end-to-end: memory_commit / coldcontext_commit / coldcontext_query ----------
func newToolTestAgent(t *testing.T) *Agent {
t.Helper()
@ -412,7 +412,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
},
},
}, nil)
if !strings.Contains(out, "关联") {
if !strings.Contains(out, "linked") && !strings.Contains(out, "media") {
t.Errorf("返回值应告知模型媒体已关联: %q", out)
}
@ -485,7 +485,7 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
}
}
// doc_commit 带 media_refs:媒体成为文档直接持有的一等块;正文保持原样。
// coldcontext_commit with media_refs: media becomes a first-class block held by cold context;
func TestToolDocCommit_BindsMedia(t *testing.T) {
a := newToolTestAgent(t)
b := mediaBlock("image/png", []byte("doc-commit-bytes"))
@ -494,14 +494,14 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
}
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Name: "coldcontext_commit",
Arguments: map[string]interface{}{
"content": "这是一篇带图的笔记正文。",
"summary": "带图笔记",
"media_digests": []interface{}{b.ID[:12]},
},
})
if !strings.Contains(out, "关联") {
if !strings.Contains(out, "linked") && !strings.Contains(out, "media") {
t.Errorf("返回值应告知模型媒体已关联: %q", out)
}
@ -524,7 +524,7 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
}
}
// doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
// coldcontext_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
func TestToolDocQuery_ShowsMedia(t *testing.T) {
a := newToolTestAgent(t)
b := mediaBlock("image/png", []byte("query-bytes"))
@ -533,7 +533,7 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) {
}
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Name: "coldcontext_commit",
Arguments: map[string]interface{}{
"content": "紫蓝红三色带配色说明正文",
"summary": "紫蓝红三色带",
@ -542,7 +542,7 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) {
})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "紫蓝红三色带 配色说明", "top_k": float64(3)},
})
@ -554,7 +554,7 @@ func TestToolDocQuery_ShowsMedia(t *testing.T) {
}
}
if !found {
t.Error("doc_query 未把媒体说明带进上下文 —— 模型不知道这篇文档带过图")
t.Error("coldcontext_query 未把媒体说明带进上下文 —— 模型不知道这篇文档带过图")
}
}
@ -578,7 +578,7 @@ func TestTools_UnknownRefDegrades(t *testing.T) {
}
out = a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Name: "coldcontext_commit",
Arguments: map[string]interface{}{
"content": "引用未知块的文档",
"media_digests": []interface{}{"aabbccddeeff"},

View File

@ -133,7 +133,7 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
"name": "任意", "source": "a", "target": "b", "criteria": map[string]interface{}{},
},
}, nil)
if !strings.Contains(got, "轻量内核") {
if !strings.Contains(got, "lightweight kernel") {
t.Fatalf("%s 在轻量内核里必须明确报不支持,实际 %q", tool, got)
}
}

View File

@ -141,13 +141,13 @@ func TestMemoryCommitRejectsStillSaysRejected(t *testing.T) {
// ② 实体名校验层:回「提交了 N 条三元组但全部被拒(未写入)」
// 两条都是如实的失败报告,措辞不同而已。
// 钉死字面会让 ① 路径下这条判据假红。
if !strings.Contains(out, "被拒") &&
!strings.Contains(out, "未写入") &&
!strings.Contains(out, "没有有效") {
if !strings.Contains(out, "rejected") &&
!strings.Contains(out, "nothing written") &&
!strings.Contains(out, "no valid triples") {
t.Errorf("0 写入时必须明说没写进去(否则模型当成成功而不重试),实际 %q", out)
}
// ★ 尤其不能出现「已写入」这种成功措辞。
if strings.Contains(out, "已写入") {
if strings.Contains(out, "Wrote") || strings.Contains(out, "written") && !strings.Contains(out, "nothing written") {
t.Errorf("0 写入却回「已写入」:%q", out)
}
}

View File

@ -124,7 +124,7 @@ func (a *Agent) modalFallbackPrompt(kind string) string {
if s := strings.TrimSpace(a.inputCfg.Image.DescribePrompt); s != "" {
return s
}
return "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。"
return "Describe this image in detail: text, objects, people and scene."
case "audio":
if s := strings.TrimSpace(a.inputCfg.Audio.DescribePrompt); s != "" {
return s
@ -191,20 +191,19 @@ func (a *Agent) transcribeBlocksForFallback(blocks []agentAPI.ContentBlock) moda
if len(imgURLs) > 0 {
p, srcName := a.resolveModalFallback("image")
if p == nil {
notices = append(notices, "当前模型不支持图片,且没有可用的视觉回退源"+
"(配置 core.input_processing.image.fallback_provider,"+
"并在该源上设置 core.llm.sources.<name>.vision=true)")
notices = append(notices, "The current model does not support images and no vision fallback source is available "+
"(set core.input_processing.image.fallback_provider and core.llm.sources.<name>.vision=true on that source)")
} else if text, err := a.chatModalFallbackBatch(p, "image", imgURLs, imgDetails); err != nil {
// 转写失败必须说出来。静默跳过会让模型以为「图里没内容」,
// 而事实是没人看过这些图。
notices = append(notices, fmt.Sprintf("图片转写失败(源 %s): %v", srcName, err))
notices = append(notices, fmt.Sprintf("image transcription failed (source %s): %v", srcName, err))
log.Printf("[agent] modal fallback image(%d) via %s failed: %v", len(imgURLs), srcName, err)
} else {
label := "图片内容"
label := "image content"
if len(imgURLs) > 1 {
label = fmt.Sprintf("%d 张图片/视频帧内容", len(imgURLs))
label = fmt.Sprintf("%d images/video frames", len(imgURLs))
}
converted = append(converted, fmt.Sprintf("[%s · 由 %s 转写,非当前模型直接感知]\n%s", label, srcName, text))
converted = append(converted, fmt.Sprintf("[%s · transcribed by %s, not directly perceived by the current model]\n%s", label, srcName, text))
res.Converted += len(imgURLs)
}
}
@ -213,11 +212,10 @@ func (a *Agent) transcribeBlocksForFallback(blocks []agentAPI.ContentBlock) moda
if len(audURLs) > 0 {
p, srcName := a.resolveModalFallback("audio")
if p == nil {
notices = append(notices, "当前模型不支持音频,且没有可用的音频回退源"+
"(配置 core.input_processing.audio.fallback_provider,"+
"并在该源上设置 core.llm.sources.<name>.audio=true)")
notices = append(notices, "The current model does not support audio and no audio fallback source is available "+
"(set core.input_processing.audio.fallback_provider and core.llm.sources.<name>.audio=true on that source)")
} else if text, err := a.chatModalFallbackBatch(p, "audio", audURLs, nil); err != nil {
notices = append(notices, fmt.Sprintf("音频转写失败(源 %s): %v", srcName, err))
notices = append(notices, fmt.Sprintf("audio transcription failed (source %s): %v", srcName, err))
log.Printf("[agent] modal fallback audio(%d) via %s failed: %v", len(audURLs), srcName, err)
} else {
converted = append(converted, fmt.Sprintf("[音频内容 · 由 %s 转写,非当前模型直接感知]\n%s", srcName, text))
@ -356,7 +354,7 @@ func (a *Agent) chatModalFallbackBatch(p agentAPI.Provider, kind string, urls, d
if out == "" {
// 空回复不能当成功。上游剥掉媒体块后模型往往回一句「我没看到图片」
// 或干脆空串——两种都说明这条回退链也没真看到。
return "", fmt.Errorf("回退源返回空内容(该源可能同样不支持此模态)")
return "", fmt.Errorf("fallback source returned empty content (it may not support this modality either)")
}
return out, nil
}

View File

@ -125,7 +125,7 @@ func TestPrepareToolBlocks_TextOnlyProviderFallsBackToTranscription(t *testing.T
t.Fatalf("回退文字应含转写内容,得到 %q", fallbackText)
}
// 关键:模型必须知道这是二手转写而非自己直接看到的
if !strings.Contains(fallbackText, "非当前模型直接感知") {
if !strings.Contains(fallbackText, "not directly perceived by the current model") {
t.Fatalf("回退文字必须标注来源,得到 %q", fallbackText)
}
if vis.calls != 1 {
@ -149,7 +149,7 @@ func TestPrepareToolBlocks_NoFallbackSourceReportsHonestly(t *testing.T) {
}
// 这是本次修复的核心:没有能力也没有回退源时必须明说,
// 而不是静默丢弃让模型以为自己看过图了。
if !strings.Contains(fallbackText, "不支持图片") {
if !strings.Contains(fallbackText, "does not support images") {
t.Fatalf("必须如实说明看不到图,得到 %q", fallbackText)
}
if !strings.Contains(fallbackText, "fallback_provider") {
@ -232,7 +232,7 @@ func TestTranscribeBlocks_EmptyReplyCountsAsFailure(t *testing.T) {
if res.Converted != 0 {
t.Fatalf("空回复不应计入成功转写,得到 Converted=%d", res.Converted)
}
if !strings.Contains(res.Notice, "转写失败") {
if !strings.Contains(res.Notice, "transcription failed") {
t.Fatalf("应报告转写失败,得到 Notice=%q", res.Notice)
}
}

View File

@ -19,20 +19,20 @@ func TestSystemPromptDoesNotRestrictOutputCount(t *testing.T) {
prompt := parent.buildSystemPrompt("", "你好")
banned := []string{
"只需调用一次",
"只能调用一次",
"通常只需调用",
"不要重复发送",
"only call once",
"call exactly once",
"usually only need to call",
"do not resend",
}
for _, b := range banned {
if strings.Contains(prompt, b) {
t.Fatalf("系统提示词里仍有输出次数限制 %q —— 设计上次数不限", b)
}
}
if !strings.Contains(prompt, "输出次数与目标通道由你自己决定") {
if !strings.Contains(prompt, "You choose how many outputs and which channels") {
t.Fatal("系统提示词应明确「输出次数与目标通道由你自己决定」")
}
if !strings.Contains(prompt, "没有任何「一轮只能发一次」的限制") {
if !strings.Contains(prompt, "No \"one output per turn\" limit") {
t.Fatal("系统提示词应显式否认「一轮只能发一次」")
}
}
@ -50,20 +50,20 @@ func TestSystemPromptRequiresExplicitOutputCall(t *testing.T) {
prompt := parent.buildSystemPrompt("", "你好")
// 必须是**开篇总述**级别的一句,不能只藏在分项里。
if !strings.Contains(prompt, "只有显式调用 `output_send__{通道名}` 才会把内容送达用户") {
if !strings.Contains(prompt, "Content reaches the user only when you explicitly call `output_send__{channel}`") {
t.Fatalf("系统提示词必须以总述句明确「只有显式调用 outputch 才会送达用户」,当前:\n%s",
prompt[:min(600, len(prompt))])
}
// 且必须点明"返回纯文本用户看不到",否则模型仍会以为直接回答就行。
if !strings.Contains(prompt, "返回纯文本只是在这次推理里说话,用户看不到") {
if !strings.Contains(prompt, "Returning plain text only speaks inside this reasoning step") {
t.Error("应显式说明「返回纯文本用户看不到」")
}
// 同步通道是唯一的例外,必须说清,否则模型在 webui 上也会多余地调工具。
if !strings.Contains(prompt, "这是唯一的例外") {
if !strings.Contains(prompt, "The only exception") {
t.Error("同步通道例外必须显式标为「唯一的例外」")
}
// 批量能力要让模型知道(一 call 多发是本次内核新增能力)。
if !strings.Contains(prompt, "payload 传数组") {
if !strings.Contains(prompt, "pass an array as payload") {
t.Error("提示词应告知 payload 可传数组一次发多条")
}
}

View File

@ -37,7 +37,7 @@ func TestPersonaOnboardingGateInSystemPrompt(t *testing.T) {
a := newTestAgent(st)
p := a.buildSystemPrompt("", "你好")
if !strings.Contains(p, "首启人格设定") || !strings.Contains(p, "persona_set") {
if !strings.Contains(p, "First-run persona setup") || !strings.Contains(p, "persona_set") {
t.Fatalf("未确认人格时提示词应要求模型询问并调用 persona_set,实际缺少该段")
}

View File

@ -42,7 +42,7 @@ func TestResolveToolPlugin_NoPhantomPlugins(t *testing.T) {
// 这些内置工具的名字都含下划线,按旧规则会被截成假插件名。
for _, name := range []string{
"ocr_image", "describe_image", "transcribe_audio",
"memory_commit", "memory_recall", "doc_query",
"memory_commit", "memory_recall", "coldcontext_query",
"knowledge_search", "person_set_trait", "spawn_child",
} {
got := a.resolveToolPlugin(name)

View File

@ -67,7 +67,7 @@ var builtinToolOwner = map[string]string{
"knowledge_search": "core", "knowledge_create": "core", "knowledge_list": "core",
"knowledge_delete": "core", "knowledge_import_dir": "core",
// 文档记忆
"doc_query": "core", "doc_commit": "core",
"coldcontext_query": "core", "coldcontext_commit": "core",
// 社交 / 人格
"person_query": "core", "person_set_trait": "core", "person_relate": "core",
"persona_set": "core",

View File

@ -514,7 +514,7 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
}
// 第一轮:从最新到最旧,计算在预算内能放多少条
headerTokens := EstimateTokens("【对话时序】\n")
headerTokens := EstimateTokens("[Conversation timeline]\n")
remaining := maxTokens - headerTokens
include := 0
for i := len(events) - 1; i >= 0; i-- {
@ -545,7 +545,7 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
// 头部自带语义说明(**块自我描述**):此前这句解释写在人格/系统提示词里,
// 提示词一精简就丢语义 ⇒ 模型把历史摘要当成当前待办。说明跟数据贴在一起
// 才不会漂移。
sb.WriteString("【对话时序】以下是过去发生的事件摘要(**不是当前任务**;不要把其中内容当成用户此刻的新要求)。\n")
sb.WriteString("[Conversation timeline] Summaries of past events follow (**not the current task**; do not treat their content as a new request from the user).\n")
for _, e := range events[start:] {
sb.WriteString(fmt.Sprintf("[%s] %s: %s",
e.Timestamp.Format("15:04:05"), e.Source, e.Input))

View File

@ -5,7 +5,7 @@ package core
// 这类判据的目的是防止「提示词在说谎」——它比不说更糟:
// 模型按提示词行事,而实现不是那样,于是行为与预期系统性错位。
//
// 生产实测(2026-10-22 日志):agent 几乎不调用 doc_query,即使调用也
// 生产实测(2026-10-22 日志):agent 几乎不调用 coldcontext_query,即使调用也
// 只看返回值、不知道原文已被回填到对话时序。根因是:
// · 工具描述只有「查询文档记忆。返回相关文档摘要」一句(且与真实行为不符)
// · 系统提示词从未解释 L1/L2/L3 是什么
@ -18,41 +18,37 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
)
// ① doc_query 的描述必须覆盖五项,且与真实行为一致。
// ① coldcontext_query 的描述必须覆盖五项,且与真实行为一致。
func TestDocQueryDescriptionCoversRealBehavior(t *testing.T) {
a := newToolTestAgent(t)
desc := findToolDescription(t, a, "doc_query")
desc := findToolDescription(t, a, "coldcontext_query")
mustHave := []struct {
name string
sub string
}{
{"L2 是什么", "历史记忆层"},
{"何时该用", "何时用"},
{"调用后行为=回填时序", "插回你的对话时序"},
{"原始时间戳", "原始时间戳"},
{"与 memory_recall 分工", "memory_recall"},
{"副作用=消费式取出", "消费式"},
mustHave := []string{
"past episode",
"original timestamps",
"inserted back into the timeline",
"memory_recall",
"consuming",
}
for _, m := range mustHave {
if !strings.Contains(desc, m.sub) {
t.Errorf("doc_query 描述缺少「%s」(应含 %q):\n%s", m.name, m.sub, desc)
for _, sub := range mustHave {
if !strings.Contains(desc, sub) {
t.Errorf("coldcontext_query description is missing %q: %s", sub, desc)
}
}
// 不得再声称"返回摘要"—— 那是旧实现的谎
if strings.Contains(desc, "返回相关文档摘要") {
t.Error("doc_query 描述仍声称『返回相关文档摘要』,与真实行为(回填原文到时序)不符")
t.Error("coldcontext_query 描述仍声称『返回相关文档摘要』,与真实行为(回填原文到时序)不符")
}
}
// ② doc_commit 描述必须说清「适合/不适合写什么」,并与另外两层分工。
// ② coldcontext_commit description must distinguish stored episodes from facts.
func TestDocCommitDescriptionExplainsScope(t *testing.T) {
a := newToolTestAgent(t)
desc := findToolDescription(t, a, "doc_commit")
desc := findToolDescription(t, a, "coldcontext_commit")
for _, sub := range []string{"适合写什么", "不适合写什么", "memory_commit", "knowledge_create"} {
for _, sub := range []string{"complete process", "single fact", "memory_commit", "knowledge_create"} {
if !strings.Contains(desc, sub) {
t.Errorf("doc_commit 描述缺少 %q:\n%s", sub, desc)
t.Errorf("cold context commit description is missing %q: %s", sub, desc)
}
}
}
@ -68,33 +64,34 @@ func TestSystemPromptExplainsThreeLayers(t *testing.T) {
// memContext 为空即可:三层记忆段落是内核无条件注入的
prompt := a.buildSystemPrompt("", "测试输入")
// ★ 2026-10-26 契约变更(用户裁定「技术相关下沉到 skill」):
// 提示词只留"去哪拿什么",分层编号与实现细节移入 kernel-memory skill。
// 所以这里断言**行为要点**,不再要求提示词复述 L1/L2/L3。
for _, sub := range []string{
"【三层记忆】",
"对话时序(L1",
"历史记忆(L2",
"图记忆(L3",
"doc_query",
"[Memory]",
"coldcontext_query",
"memory_recall",
"knowledge_search",
} {
if !strings.Contains(prompt, sub) {
t.Errorf("系统提示词缺少三层记忆段落的 %q", sub)
t.Errorf("系统提示词缺少记忆段落的 %q", sub)
}
}
// 提示词承诺「按原始时间戳插回」,实现必须真的这么做
if !strings.Contains(prompt, "原始时间戳") {
t.Error("提示词没有说明 doc_query 会按原始时间戳回填")
if !strings.Contains(prompt, "original timestamps") {
t.Error("提示词没有说明 coldcontext_query 会按原始时间戳回填")
}
if !strings.Contains(prompt, "插回你的对话时序") {
t.Error("提示词没有说明 doc_query 把事件插回时序(模型会以为只是返回摘要)")
if !strings.Contains(prompt, "re-inserts them into your timeline") {
t.Error("提示词没有说明 coldcontext_query 把事件插回时序(模型会以为只是返回摘要)")
}
}
// ④ doc_commit 不得再有 tags 参数(Doc.Tags 已删除,留着会骗模型)。
// ④ coldcontext_commit must not expose a tags parameter (Doc.Tags was removed).
func TestDocCommitHasNoTagsParam(t *testing.T) {
a := newToolTestAgent(t)
params := findToolParams(t, a, "doc_commit")
params := findToolParams(t, a, "coldcontext_commit")
if _, ok := params["tags"]; ok {
t.Error("doc_commit 仍有 tags 参数,但 Doc.Tags 已删除 —— 参数会静默丢弃")
t.Error("coldcontext_commit still exposes tags, but Doc.Tags was removed")
}
}
@ -141,7 +138,7 @@ func findToolParams(t *testing.T, a *Agent, name string) map[string]interface{}
return nil
}
// ⑤ 预填充注入的文案必须:给真实内容 + 明确引导用 doc_query。
// ⑤ 预填充注入的文案必须:给真实内容 + 明确引导用 coldcontext_query。
//
// 断点史:旧文案只给「来自 4 个来源的 61 条对话 相关度:0.78」——
// 统计描述,没有任何内容,也没有一个字提示可以取全文。
@ -181,9 +178,9 @@ func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) {
if !strings.Contains(md, "2026-07-08") {
t.Errorf("预填充文案没有时间:\n%s", md)
}
// 4) ★ 必须明确引导使用 doc_query
if !strings.Contains(md, "doc_query") {
t.Errorf("预填充文案没有引导使用 doc_query:\n%s", md)
// 4) ★ 必须明确引导使用 coldcontext_query
if !strings.Contains(md, "coldcontext_query") {
t.Errorf("预填充文案没有引导使用 coldcontext_query:\n%s", md)
}
// 5) 必须说明它来自历史层而非当前对话
if !strings.Contains(md, "L2") && !strings.Contains(md, "历史记忆") {
@ -227,20 +224,31 @@ func TestSystemPromptHasNoImplementationJargon(t *testing.T) {
// ⑦ 提示词长度上限(防止技术说明再次淤积)。
//
// 历史:收敛前 2309 → 收敛后 1723 → 上限取 2000(留 277 字增删余地)。
// 历史:收敛前 2309 → 收敛后 1723 → 上限取 2000(中文口径)。
//
// ★ 2026-10-25 的一次真实教训:
// 我曾为 seq 加【加速序列】115 字,超限后把上限调到 2100 —— 那是错的。
// 用户裁定"seq 知识应在 skill 中",删掉该块后回到 1907 字,上限也还原 2000。
//
// 所以这条判据超限时的**正确反应是删内容或下沉到 skill**,不是调数字。
// 领域知识(某个工具什么时候该用、参数怎么填)属于 skill;
// 这里只该放"模型不看文档也会做错"的通用规则。
// 我曾为 seq 加【加速序列】115 字,超限后把上限调到 2100 —— 那是错的。
// 用户裁定"seq 知识应在 skill 中",删掉该块后回到 1907 字,上限也还原 2000。
//
// 所以这条判据超限时的**正确反应是删内容或下沉到 skill**,不是调数字。
// 领域知识(某个工具什么时候该用、参数怎么填)属于 skill;
// 这里只该放"模型不看文档也会做错"的通用规则。
//
// ★★ 2026-10-26 口径重标定:内核提示词全部改为英文(用户要求:中文会
//
// 触发一些奇怪问题)。同一份内容,英文的 rune 数是中文的约 1.7 倍
// (实测:1907 中文 rune 的等价英文 3364 rune,且新增了 [Voice] 契约)。
// rune 上限因此按英文重新标定为 3500 —— 这不是"为了塞更多内容而调高",
// 换语言本身就改变了字符成本。内容纪律不变:
// · 超限仍应删内容或下沉 skill
// · 实现术语另有 TestSystemPromptHasNoImplementationJargon 单独守
func TestSystemPromptStaysCompact(t *testing.T) {
a := newToolTestAgent(t)
prompt := a.buildSystemPrompt("", "测试输入")
const maxRunes = 2000
// 3500 = 英文标定(见上);中文时代的 2000 不适用于英文编码。
const maxRunes = 3500
if n := len([]rune(prompt)); n > maxRunes {
t.Errorf("系统提示词 %d 字,超过上限 %d —— \n"+
"新加的内容先问:模型会据此做什么不同的事?答不上来就该进 skill 或工具描述",

View File

@ -24,10 +24,12 @@ func TestPromptDeclaresParallelContract(t *testing.T) {
key string
words []string
}{
{"默认并行", []string{"并行"}},
{"不可依赖顺序", []string{"顺序"}},
{"同通道保序", []string{"保序"}},
{"并发安全声明", []string{"并发安全", "ParallelSafe", "声明"}},
{"默认并行", []string{"run **in parallel**"}},
{"不可依赖顺序", []string{"Never depend on order"}},
{"同通道保序", []string{"keep their order"}},
// ★ 2026-10-26:parallel-safe/sequential 的调度细节下沉到
// kernel-tool-order skill;提示词只保留"必须并行 + 不可依赖顺序"。
{"同通道保序例外", []string{"keep their order"}},
}
for _, r := range required {
found := false
@ -54,7 +56,9 @@ func TestPromptDeclaresParallelContract(t *testing.T) {
func TestPromptStatesExceptionsNotJustParallelism(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
p := a.buildSystemPrompt("", "hi")
for _, w := range []string{"例外", "不会并发", "不并发"} {
// ★ 2026-10-26:仅保留"同通道保序"这一条模型必须知道的例外;
// 批内降级(写类工具退化为串行)属于实现细节,已下沉到 skill。
for _, w := range []string{"keep their order"} {
if strings.Contains(p, w) {
return
}
@ -73,7 +77,7 @@ func TestPromptExplainsSameChannelOrdering(t *testing.T) {
if !strings.Contains(p, "output_send__") {
t.Fatal("提示词未提及 output_send__,无法说明同通道保序")
}
if !strings.Contains(p, "保序") {
if !strings.Contains(p, "keep their order") {
t.Error("提示词未说明同通道多次发送会保序")
}
}
@ -100,12 +104,12 @@ func TestPromptKeepsExistingContract(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
p := a.buildSystemPrompt("", "hi")
for _, want := range []string{
"【输出规则】",
"[Output rules]",
"output_list_channels",
"【可用工具能力】",
"[Available tool capabilities]",
// 记忆维护块:标题从【记忆清理指令】改为【记忆维护】——具体工具选择
// 下沉到 memory_* 的工具描述,提示词只保留「必须真做、不许只描述计划」。
"【记忆维护】",
"[Memory maintenance]",
} {
if !strings.Contains(p, want) {
t.Errorf("提示词丢失既有要点 %q", want)

View File

@ -4,7 +4,7 @@ package core
//
// 依据(assets/docs/zh/ARCHITECTURE.md「记忆流转」② Document 层的读取分支):
//
// doc_query → Consume(读取并删除)
// coldcontext_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
//
@ -78,11 +78,11 @@ func TestRecallByDoc_RestoresEachEventAtOriginalTimestamp(t *testing.T) {
}
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "紫蓝红配色方案", "top_k": float64(5)},
})
if strings.Contains(out, "未找到") {
t.Fatalf("doc_query 未召回:%s", out)
t.Fatalf("coldcontext_query 未召回:%s", out)
}
seen := map[time.Time]string{}
@ -135,7 +135,7 @@ func TestRecallByDoc_MigratesBlocksBackToContext(t *testing.T) {
}
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "紫蓝红三色带 配色参考", "top_k": float64(3)},
})
@ -178,7 +178,7 @@ func TestRecallByDoc_TextIsNotTruncatedOnTheWayBack(t *testing.T) {
}})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "长报告", "top_k": float64(3)},
})
@ -215,7 +215,7 @@ func TestRecallByDoc_LegacyDocDegrades(t *testing.T) {
}
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "紫蓝红三色带 旧格式", "top_k": float64(3)},
})
if strings.Contains(out, "未找到") {
@ -269,7 +269,7 @@ func TestRecallByDoc_StressManyEvents(t *testing.T) {
t.Fatalf("归档后仍有 %d 条", got)
}
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "压力输入 内容", "top_k": float64(5)},
})
elapsed := time.Since(start)
@ -285,7 +285,7 @@ func TestRecallByDoc_StressManyEvents(t *testing.T) {
t.Logf("回填 %d 条耗时 %v", got, elapsed)
}
// ⑥ doc_query 的返回值必须**带真实内容**,不能只有统计描述。
// ⑥ coldcontext_query 的返回值必须**带真实内容**,不能只有统计描述。
//
// 断点史(生产日志实测):旧返回值是
//
@ -309,12 +309,12 @@ func TestRecallByDoc_ReturnValueCarriesRealContent(t *testing.T) {
}})
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "配色方案 紫蓝红", "top_k": float64(3)},
})
// 1) 必须说明原文去了时序
if !strings.Contains(out, "时序") {
if !strings.Contains(out, "timeline") {
t.Errorf("返回值没说原文去了哪里:\n%s", out)
}
// 2) 必须有时间(年月日或月-日)
@ -347,7 +347,7 @@ func TestRecallByDoc_LegacyReturnAlsoCarriesContent(t *testing.T) {
t.Fatal(err)
}
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "旧格式文档 真实正文", "top_k": float64(3)},
})
if !strings.Contains(out, "真实正文在这里") {

View File

@ -332,7 +332,7 @@ func TestPreempt_ResumeRebaseRestoresTailDecorations(t *testing.T) {
// 模拟 prepare 后的形状:基础前缀 + 一段“本任务自己的现场”
f.Msgs = []agentAPI.Message{
{Role: "system", Content: "S"},
{Role: "user", Content: "[中断消息] 打断文本"},
{Role: "user", Content: "[Interrupt] 打断文本"},
{Role: "assistant", Content: "进行中"},
}
f.PrefixLen = 2
@ -347,7 +347,7 @@ func TestPreempt_ResumeRebaseRestoresTailDecorations(t *testing.T) {
t.Fatalf("本任务现场应接回最后,实际 %+v", last)
}
prefixLast := f.Msgs[f.PrefixLen-1]
if prefixLast.Role != "system" || !strings.HasPrefix(prefixLast.Content, "[中断消息]") {
if prefixLast.Role != "system" || !strings.HasPrefix(prefixLast.Content, "[Interrupt]") {
t.Fatalf("中断标记未补回:%+v", prefixLast)
}
if len(prefixLast.Blocks) != 1 || prefixLast.Blocks[0].Text != "图" {

View File

@ -0,0 +1,57 @@
package core
// 插件声明限式的**生效点**:inputch 输入进来时激活,轮次内据此判定。
//
// ## 为什么在这里(而不是在工具调用时)
//
// 插件在注入时带上 `security_profile`,那一刻是**唯一**能确定
// "这条输入属于哪个身份档位"的时刻。等 LLM 决定调工具时,插件早已
// 不在调用栈上(异步通道 + 多轮工具循环),档位信息就断了。
//
// 所以:输入到达 → 激活档位(带 TTL)→ 该轮的工具/内容判定都读它。
//
// ## 缺省语义
//
// 没有携带 `security_profile` = **不使用档位**(沿用内核默认策略)。
// 这是可选参数:不声明档位的插件行为与之前完全一致。
//
// 携带了**未声明**的档位名 ⇒ 记日志并回退到默认策略,但**不改写输入**:
// 注入是 fire-and-forget,插件看不到内核错误;至少要在日志里留下
// 「你声明的档位没生效」的痕迹(与 cleaner_name 同一纪律)。
import (
"log"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
// activateSecurityProfile 按输入 payload 激活插件声明的安全档位。
//
// 返回被激活的档位名(空 = 未使用档位),仅供日志/诊断。
func (a *Agent) activateSecurityProfile(payload map[string]interface{}) string {
if a == nil || a.security == nil || payload == nil {
return ""
}
name, _ := payload["security_profile"].(string)
if name == "" {
return ""
}
if err := a.security.ActivateProfile(name, 0); err != nil {
// 响亮但不断流:拼错档位名不该让整条输入丢失,
// 但也绝不能静默 —— 那等于悄悄降级到默认策略。
log.Printf("[agent] input declared security_profile=%q but activation failed: %v", name, err)
return ""
}
return name
}
// securityDecisionFor 用当前活跃档位 + 主体判定某工具是否可用。
//
// 工具执行前的统一检查点:调用方拿到 !Allowed 时应把 Reason 原样回给模型
// (统一拒绝语义 —— 模型据此能修正,而不是反复重试同一调用)。
func (a *Agent) securityDecisionFor(tool string) security.Decision {
if a == nil || a.security == nil {
return security.Decision{Allowed: true}
}
return a.security.CheckTool(tool)
}

View File

@ -0,0 +1,74 @@
package core
// 输入携带 `security_profile` 时的**激活语义**判据。
//
// 用户模型(2026-10-26):
//
// > 插件可以声明自身安全限制策略,并在插件自身 inputch 触发时携带参数
// > 选择其中一种 —— 正好对应 QQ 插件内部 owner 与非 owner 的差别对待。
//
// > 这是可选参数,不声明安全策略则使用内核默认安全策略。
import (
"testing"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
// ① 没有携带 security_profile ⇒ 不激活任何档位(零行为变化)。
func TestNoSecurityProfileKeepsDefault(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
if got := a.activateSecurityProfile(map[string]interface{}{"content": "hi"}); got != "" {
t.Fatalf("未携带档位时不应激活,实际 %q", got)
}
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
t.Fatalf("未使用档位时应沿用默认策略: %s", d.Reason)
}
}
// ② 声明档位 + 输入携带档位名 ⇒ 该轮工具判定按档位生效(owner/非 owner 差别)。
func TestSecurityProfileFromInputChangesToolDecision(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
// 用一个独立运行时(不依赖全局单例),模拟"security 模块已装配"。
rt := security.NewRuntime(nil)
if err := rt.DeclareProfile(security.Profile{
Name: "qq.guest", Owner: "qq",
Policy: security.PrincipalPolicy{DenyTools: []string{"cmd_*"}},
}); err != nil {
t.Fatal(err)
}
a.security = rt
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
t.Fatalf("激活前应放行: %s", d.Reason)
}
if got := a.activateSecurityProfile(map[string]interface{}{"security_profile": "qq.guest"}); got != "qq.guest" {
t.Fatalf("应激活 qq.guest,实际 %q", got)
}
if d := a.securityDecisionFor("cmd_run"); d.Allowed {
t.Fatal("guest 档激活后 cmd_* 必须被拒 —— 这是 owner 与非 owner 的差别")
}
}
// ③ 携带**未声明**的档位名 ⇒ 不激活(回退默认),且不 panic。
//
// 判据只钉"不激活"这一半:日志侧由 security 模块保证响亮(返回 error)。
// 不在这里断言日志文案 —— 那会让判据绑死文案。
func TestUnknownSecurityProfileFallsBackWithoutPanic(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.security = security.NewRuntime(nil)
if got := a.activateSecurityProfile(map[string]interface{}{"security_profile": "qq.typo"}); got != "" {
t.Fatalf("未声明的档位不应被激活,实际 %q", got)
}
}
// ④ 未装配安全模块(a.security == nil)⇒ 全部放行,且不 panic。
func TestSecurityDecisionWithoutRuntime(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
t.Fatalf("未装配安全模块时应放行: %s", d.Reason)
}
if got := a.activateSecurityProfile(map[string]interface{}{"security_profile": "qq.owner"}); got != "" {
t.Fatalf("未装配时不应激活,实际 %q", got)
}
}

View File

@ -1,19 +1,19 @@
package core
// doc_query 的**自激震荡**判据。
// coldcontext_query 的**自激震荡**判据。
//
// ## 回路长什么样(用户 2026-10-22 指出)
//
// doc_query
// coldcontext_query
// ├─ ① 工具返回值含被召回事件的正文片段 → 作为 ToolResults 进 context
// │ → 参与向量化/关键词/蒸馏 → 同一内容被**重新注入**记忆
// ├─ ② 回填事件本身(带原 Source、完整 Blocks)进 L1
// │ → Prune 因相关而保留它们(挤掉新事件)
// │ → 下一轮 Prune 又把它们归档回 L2
// └─ ⇒ 下次 doc_query 再召回同一批:块在 L1↔L2 之间反复搬迁
// └─ ⇒ 下次 coldcontext_query 再召回同一批:块在 L1↔L2 之间反复搬迁
//
// 两个入口都必须关掉:
// ① ToolDef.NoMemory —— doc_query 声明,工具输出不进计算层
// ① ToolDef.NoMemory —— coldcontext_query 声明,工具输出不进计算层
// ② ContextEvent.NoMemory —— restoreDocEvents 置位,回填事件不再归档
import (
@ -25,30 +25,30 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// ① doc_query 必须声明 NoMemory(工具输出不进记忆计算)。
// ① coldcontext_query 必须声明 NoMemory(工具输出不进记忆计算)。
//
// 变异:去掉 toolDefOptions{noMemory:true} ⇒ 红。
func TestSelfExcite_DocQueryDeclaresNoMemory(t *testing.T) {
// 工具定义层面:声明必须可查(不依赖工具表是否已构造)
if !noMemoryOf("doc_query") {
t.Error("doc_query 未声明 NoMemory —— 它的返回值含被召回事件的正文," +
if !noMemoryOf("coldcontext_query") {
t.Error("coldcontext_query 未声明 NoMemory —— 它的返回值含被召回事件的正文," +
"再参与向量化/蒸馏就是把同一内容重新注入记忆")
}
// 计算层层面:带 doc_query 输出的工具事件,其向量化文本不得含该输出
// 计算层层面:带 coldcontext_query 输出的工具事件,其向量化文本不得含该输出
const sentinel = "这是被召回事件的正文片段哨兵"
evt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: "查一下历史",
ToolResults: []ToolResultItem{
{Name: "doc_query", Output: "已召回:" + sentinel},
{Name: "coldcontext_query", Output: "已召回:" + sentinel},
},
}
// stageHost 在测试环境为 nil:传 nil lookup,只走内置声明表那条路
text := textForVector(&evt, nil, nil)
if strings.Contains(text, sentinel) {
t.Errorf("doc_query 的输出进入了计算层(会被重新向量化/蒸馏):\n%s", text)
t.Errorf("coldcontext_query 的输出进入了计算层(会被重新向量化/蒸馏):\n%s", text)
}
}
@ -56,7 +56,7 @@ func TestSelfExcite_DocQueryDeclaresNoMemory(t *testing.T) {
//
// ★ 这里曾经写反过:要求"不得被 Prune 再归档回 L2"。
//
// doc_query 的 Consume 已经把 L2 副本删掉,L1 是唯一副本 ——
// coldcontext_query 的 Consume 已经把 L2 副本删掉,L1 是唯一副本 ——
// 不归档就等于三层都不在,内容永久丢失。
//
// 变异:
@ -72,12 +72,12 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) {
Timestamp: time.Now().Add(-2 * time.Hour),
Source: "webui",
Input: marker,
Response: "然后被 doc_query 取回",
Response: "然后被 coldcontext_query 取回",
}})
// 召回(Consume 会把 L2 副本删掉)
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "自激测试 归档", "top_k": float64(3)},
})
if strings.Contains(out, "未找到") {
@ -154,7 +154,7 @@ func TestSelfExcite_RestoredEventsDontCrowdOutNewOnes(t *testing.T) {
Response: "响应",
}})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "拥挤测试", "top_k": float64(3)},
})

View File

@ -79,17 +79,17 @@ func (a *Agent) injectSourceContext(stageCtx *sdk.StageContext, evt *agentIO.Inp
if channel == "" {
channel = source
}
content := fmt.Sprintf("当前输入来源: %s;默认输出通道: %s。", source, channel)
content := fmt.Sprintf("Current input source: %s; default output channel: %s.", source, channel)
// 来源含设备身份(webui/{device_id})时补充设备名,便于 agent 区分多设备输入
if devID, _ := evt.Payload["device_id"].(string); devID != "" {
devName, _ := evt.Payload["device_name"].(string)
if devName == "" {
devName = devID
}
content = fmt.Sprintf("当前输入来自设备[%s](%s);默认输出通道: %s。", devName, devID, channel)
content = fmt.Sprintf("Current input comes from device [%s] (%s); default output channel: %s.", devName, devID, channel)
}
if flag, _ := evt.Payload["interrupt"].(bool); flag {
content = fmt.Sprintf("这是一条打断输入。来源: %s;默认输出通道: %s。", source, channel)
content = fmt.Sprintf("This is an interrupt input. Source: %s; default output channel: %s.", source, channel)
}
stageCtx.ContextMsgs = append(stageCtx.ContextMsgs, map[string]interface{}{
"role": "system",

View File

@ -131,7 +131,28 @@ func collectKernelStatus(
// Tools
if stageHost != nil {
status.Tools = stageHost.GetToolDefs()
// ★ 只给**摘要**,不给完整定义(2026-10-26 实测)。
//
// 原先直接塞 GetToolDefs():173 个工具的完整 Description + Parameters
// JSON ⇒ healthcheck_kernel 单条结果 **98,989 tokens**(预算 65,375 的 151%),
// 一轮对话里它就把上下文塞满,还拖慢每一次后续往返。
//
// 摘要保留"有哪些工具、属于哪个插件、能干什么",足够状态页与模型判断;
// 需要完整参数表的场景走 get_plugin_tools(<plugin>),那是按需的。
defs := stageHost.GetToolDefs()
summary := make([]sdk.ToolDef, 0, len(defs))
for _, d := range defs {
desc := d.Description
if r := []rune(desc); len(r) > 120 {
desc = string(r[:120]) + "…"
}
summary = append(summary, sdk.ToolDef{
Name: d.Name,
Plugin: d.Plugin,
Description: desc,
})
}
status.Tools = summary
}
// Channels

View File

@ -364,7 +364,7 @@ func (a *Agent) rebaseFramePrefix(f *TaskFrame) {
if f.IsInterrupt && len(prefix) > 0 {
last := prefix[len(prefix)-1]
last.Role = "system"
last.Content = "[中断消息] " + last.Content
last.Content = "[Interrupt] " + last.Content
prefix[len(prefix)-1] = last
}
if len(f.InputBlocks) > 0 && len(prefix) > 0 {
@ -611,11 +611,22 @@ func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome {
f.Tools = a.buildToolDefs()
f.Msgs = a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
// ★ 插件声明的**本轮提示**(SDK InjectOptions.TurnHint)。
//
// 以**独立 system 消息**注入,绝不拼进用户消息正文:
// · 用户说的话与"给模型的操作说明"必须可区分(也让审计能分辨来源)
// · 拼进正文时模型常把说明当成人话的一部分,回复里会复述"我先调 xxx"
if hint := stageHint(f.StageCtx, "turn_hint"); hint != "" {
f.Msgs = append(f.Msgs, agentAPI.Message{
Role: "system",
Content: "[Turn hints] " + hint,
})
}
// 工具提醒(interrupt):以 system 角色注入,不让模型误认为用户发言
if a.interruptInput {
last := f.Msgs[len(f.Msgs)-1]
last.Role = "system"
last.Content = "[中断消息] " + last.Content
last.Content = "[Interrupt] " + last.Content
f.Msgs[len(f.Msgs)-1] = last
a.interruptInput = false
}
@ -1462,3 +1473,15 @@ func copyExtraMap(src map[string]interface{}) map[string]interface{} {
}
return dst
}
// stageHint 从 stage 上下文里取一个字符串提示(去掉首尾空白;空返回 "")。
//
// 为什么从 Extra 取而不是给 TaskFrame 加字段:插件 payload 已经原样进了
// Extra(见 process.go 的 payload 拷贝),再加字段等于同一份数据存两处。
func stageHint(ctx *sdk.StageContext, key string) string {
if ctx == nil || ctx.Extra == nil {
return ""
}
v, _ := ctx.Extra[key].(string)
return strings.TrimSpace(v)
}

View File

@ -51,7 +51,7 @@ func TestToolAPIPathRespectsDeviceGrant(t *testing.T) {
// ① 内核路径(现状已有)
gotInner := a.executeToolCall(tc, "cli")
if !strings.Contains(gotInner, "未授权") {
if !strings.Contains(gotInner, "not authorized") {
t.Fatalf("内核路径应拒绝未授权设备,实际: %s", gotInner)
}
@ -129,7 +129,7 @@ func TestCanUseAgreesWithInnerPath(t *testing.T) {
ID: "c1", Name: "device_ctl_cmdrun",
Arguments: map[string]interface{}{"device_id": c.deviceID, "command": "ls"},
}
innerOK := !strings.Contains(a.executeToolCall(tc, "cli"), "未授权")
innerOK := !strings.Contains(a.executeToolCall(tc, "cli"), "not authorized")
apiOK := toolAPIOf(t, a).CanUse(tc.Name, tc.Arguments)
if innerOK != apiOK {
t.Errorf("device=%s allowed=%v:内核路径=%v 而 ToolAPI 路径=%v —— 两条路径判定不一致",
@ -151,7 +151,7 @@ func TestCanUseMatchesInnerFailOpenOnMissingDeviceID(t *testing.T) {
ID: "c1", Name: "device_ctl_cmdrun",
Arguments: map[string]interface{}{"command": "ls"}, // 无 device_id
}
innerOK := !strings.Contains(a.executeToolCall(tc, "cli"), "未授权")
innerOK := !strings.Contains(a.executeToolCall(tc, "cli"), "not authorized")
apiOK := toolAPIOf(t, a).CanUse(tc.Name, tc.Arguments)
if innerOK != apiOK {
t.Errorf("缺 device_id 时两条路径不一致:内核=%v ToolAPI=%v", innerOK, apiOK)
@ -213,7 +213,7 @@ func TestBuiltinParallelDeclaredWhereDefined(t *testing.T) {
}
for _, n := range []string{
"knowledge_search", "knowledge_list", "person_query", "person_network",
"input_channels", "get_plugin_tools", "doc_query",
"input_channels", "get_plugin_tools", "coldcontext_query",
"llm_list_sources", "output_list_channels",
} {
i := strings.Index(body[declStart:], `toolDefWith("`+n+`"`)
@ -239,7 +239,7 @@ func TestBuiltinParallelDeclaredWhereDefined(t *testing.T) {
seen := 0
for _, n := range []string{
"knowledge_search", "knowledge_list", "person_query", "person_network",
"input_channels", "get_plugin_tools", "doc_query",
"input_channels", "get_plugin_tools", "coldcontext_query",
"llm_list_sources", "output_list_channels",
} {
if concurrencySafeOf(n) {
@ -255,7 +255,7 @@ func TestBuiltinParallelDeclaredWhereDefined(t *testing.T) {
// 写类工具绝不能出现在聚合表的可并发集合里
for _, n := range []string{
"memory_merge", "memory_delete_entity", "knowledge_create", "doc_commit",
"memory_merge", "memory_delete_entity", "knowledge_create", "coldcontext_commit",
"persona_set", "person_set_trait", "llm_set_source", "spawn_child",
} {
if concurrencySafeOf(n) {

View File

@ -16,6 +16,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/security"
"github.com/JianFeeeee/HologramAgent/internal/toolroute"
)
// 工具执行超时的默认值与硬上限。
@ -117,9 +118,9 @@ func (a *Agent) resolveToolTimeoutDeclared(toolName string) time.Duration {
// 模型读得懂,但用户看错误提示时"1m0s"不如"1 分钟"直观。
func fmtDuration(d time.Duration) string {
if d%time.Minute == 0 && d >= time.Minute {
return fmt.Sprintf("%d 分钟", int(d/time.Minute))
return fmt.Sprintf("%d min", int(d/time.Minute))
}
return fmt.Sprintf("%d 秒", int(d/time.Second))
return fmt.Sprintf("%d s", int(d/time.Second))
}
// toolOutcome 是一次工具执行的完整结果:**文本**(给模型)与
@ -152,8 +153,8 @@ func (a *Agent) executeToolCallOutcome(tc agentAPI.ToolCall, channel string, tur
}
}
te := newToolError("panic", "", fmt.Sprintf("工具 %s 执行崩溃: %v", tc.Name, r),
"这是工具自身故障(不是你的参数问题),请勿原样重试;可换用其他工具或告知用户。")
te := newToolError("panic", "", fmt.Sprintf("Tool %s panicked: %v", tc.Name, r),
"This is a tool-side fault, not an argument problem. Do not retry with the same input; pick another tool or tell the user.")
out = toolOutcome{Text: te.Error(), Raw: te}
}
// ★★ 出向脱敏(2026-10-18 补):工具返回值在交给模型**之前**必须清洗。
@ -235,8 +236,8 @@ func (a *Agent) executeToolCallOutcome(tc agentAPI.ToolCall, channel string, tur
// 先 cancel 再返回:这一行就是"让插件停下来"的触发点。
cancel()
log.Printf("[agent] tool %s timed out after %v (cancel sent to executor)", tc.Name, toolTimeout)
te := newToolError(ErrReasonTimeout, "", fmt.Sprintf("工具 %s 执行超时(%s)", tc.Name, fmtDuration(toolTimeout)),
"该工具本次未在时限内返回。可改用更小的任务,或换用其他工具。")
te := newToolError(ErrReasonTimeout, "", fmt.Sprintf("Tool %s timed out (%s)", tc.Name, fmtDuration(toolTimeout)),
"The tool did not return within its time limit. Split into a smaller task or use another tool.")
return toolOutcome{Text: te.Error(), Raw: te}
}
}
@ -260,6 +261,20 @@ func (a *Agent) executeToolCallInner(ctx context.Context, tc agentAPI.ToolCall,
return toolOutcome{Text: renderToolError(tc.Name, ve), Raw: ve}
}
// ★ 内置工具的路由交给 device 模块(`kernelmod/device.Dispatch`)。
//
// 为什么要搬:这张"前缀 → 族处理器"的表曾是工具面的**第二份事实来源**
//(第一份是 tooldefs 的定义)。两处不同步就是"模型看得到、路由找不到"
// 或反之。现在路由只有一处,device 模块拥有它。
//
// 未挂载 device 时(独立工具、单测、特殊装配)退化为下面的本地 switch,
// 行为不变 —— 两条路径的一致性由 device_dispatch_test.go 钉住。
if a.builtinHandlers != nil {
if text, handled := toolroute.Dispatch(a.builtinHandlers, tc, turnScenes); handled {
return toolOutcome{Text: text}
}
}
switch {
case tc.Name == "persona_set":
return toolOutcome{Text: a.executePersonaTool(tc)}
@ -312,7 +327,7 @@ func (a *Agent) executeToolCallInner(ctx context.Context, tc agentAPI.ToolCall,
return toolOutcome{Text: renderToolResult(tc.Name, result), Raw: result}
} else if !agentIO.IsToolNotFound(err) {
// 非「不存在」= 真的执行失败,如实上报(可被 on_error/retry 处置)。
return toolOutcome{Text: fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)}
return toolOutcome{Text: fmt.Sprintf("Tool %s failed: %v", tc.Name, err)}
}
// 是「不存在」:继续往下走 io / 设备路径,两处都没有才报缺工具。
}
@ -325,7 +340,7 @@ func (a *Agent) executeToolCallInner(ctx context.Context, tc agentAPI.ToolCall,
// 这里按目标设备的通道名 device/<id> 查同一道闸:父授权了哪台设备,才允许指挥哪台。
if _, isDeviceTool := a.io.DeviceOfTool(tc.Name); isDeviceTool {
if id, _ := tc.Arguments["device_id"].(string); id != "" && !a.IsOutputAllowed("device/"+id) {
return toolOutcome{Text: fmt.Sprintf("设备 [%s] 未授权给本 agent(可用设备见 output_list_channels 的 device/<id> 通道,或 devicedetect)", id)}
return toolOutcome{Text: fmt.Sprintf("Device [%s] is not authorized for this agent (see output_list_channels for available device/<id> channels, or use devicedetect)", id)}
}
}
@ -352,14 +367,14 @@ func (a *Agent) executeToolCallInner(ctx context.Context, tc agentAPI.ToolCall,
// 此前这里只提 get_plugin_tools / output_list_channels,
// 一个字没提 skill —— 模型调用了、失败了,提示却不告诉它去
// 看说明书,于是只能盲目试工具名。
return toolOutcome{Text: fmt.Sprintf("工具 %s 不存在或未注册(它可能属于未加载的插件)。按这个顺序处理:\n"+
"1. 先看【可用技能】里有没有相关的:有就先 skill_info(\"技能名\") 读全文 —— "+
"skill 里写着该插件的正确用法、已知坑,以及需要先启动哪个插件;\n"+
"2. 没有相关 skill 就调 get_plugin_tools(\"\") 查当前可用的插件与工具;\n"+
"3. 通道相关的不确定时用 output_list_channels 看有哪些通道。\n"+
"确认之后再调用,不要反复重试同一个名字。", tc.Name)}
return toolOutcome{Text: fmt.Sprintf("Tool %s does not exist or is not registered (it may belong to a plugin that is not loaded). Handle it in this order:\n"+
"1. Check [Available skills] for a related one: if present, read it in full with skill_info(\"skill-name\") - "+
"the skill documents the plugin's correct usage, known pitfalls, and which plugin must be started first;\n"+
"2. If no skill matches, call get_plugin_tools(\"\") to list the currently available plugins and tools;\n"+
"3. For channel questions, use output_list_channels to see which channels exist.\n"+
"Only call again once you have confirmed, and do not keep retrying the same name.", tc.Name)}
}
return toolOutcome{Text: fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)}
return toolOutcome{Text: fmt.Sprintf("Tool %s failed: %v", tc.Name, err)}
}
// Raw 必须带上:否则结构化失败({"error":…} / ToolError)在这一步被抹平成文本,
// Success 又会退回恒真——正是阶段 1b 要修的那个洞。
@ -378,13 +393,13 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
if tc.Name == "memory_document_query" {
return a.executeDocTool(tc)
}
return "图记忆系统不可用"
return "stable memory is unavailable"
}
// 整理类工具需要**完整内核**的记忆整理面(块/媒体/结构操作)。
// 轻量内核(驻留子)只有图记忆共同面 ⇒ 这些操作明确不可用,不静默降级。
requireFull := func() string {
if a.memory == nil {
return "本 agent 是轻量内核:只能读写图记忆,记忆整理(合并/删除/清理/编辑/统计)不可用"
return "this agent is a lightweight kernel: it can read and write stable memory, but maintenance (merge/delete/purge/edit/stats) is unavailable"
}
return ""
}
@ -392,7 +407,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
case "toolresult_read":
txt, err := a.readStoredToolResult(tc.Arguments)
if err != nil {
return "toolresult_read 失败:" + err.Error()
return "toolresult_read failed: " + err.Error()
}
return txt
case "memory_recall":
@ -402,7 +417,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
depth = 2
}
if query == "" {
return "请输入查询关键词"
return "query is required"
}
// 关键词提取:支持逗号分隔和自然语言
keywords := strings.Split(query, ",")
@ -436,10 +451,10 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
// (判据 TestMemoryRecall_指纹不匹配的块被跳过)。
result, err := g.RecallSorted(keywords, nil, int(depth), "", a.currentFingerprint(), sortMode)
if err != nil {
return fmt.Sprintf("记忆检索失败: %v", err)
return fmt.Sprintf("memory recall failed: %v", err)
}
if len(result.Entities) == 0 && len(result.Relations) == 0 {
return "未找到相关记忆"
return "no relevant memory found"
}
if a.indexer != nil {
names := make([]string, len(result.Entities))
@ -449,7 +464,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
a.indexer.MarkRecalled(names...)
}
var parts []string
parts = append(parts, fmt.Sprintf("找到 %d 个相关实体(按%s排序):", len(result.Entities), sortLabel(sortMode)))
parts = append(parts, fmt.Sprintf("Found %d related entities (sorted by %s):", len(result.Entities), sortLabel(sortMode)))
for _, e := range result.Entities {
// ★ 带出匹配层级:此前输出是同格式平铺,模型无从判断该信哪条。
//
@ -463,9 +478,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
case e.MatchRank == 1:
tag = ", 前缀匹配"
}
parts = append(parts, fmt.Sprintf("- %s (提及%d次, 类型:%s%s)", e.Name, e.MentionCount, e.Type, tag))
parts = append(parts, fmt.Sprintf("- %s (mentioned %d times, type:%s%s)", e.Name, e.MentionCount, e.Type, tag))
}
parts = append(parts, fmt.Sprintf("找到 %d 条关系:", len(result.Relations)))
parts = append(parts, fmt.Sprintf("Found %d relations:", len(result.Relations)))
parts = append(parts, formatRecallRelations(result.Relations, 10)...)
// 命中的关系若挂着媒体块,把媒体说明附在结果末尾。
//
@ -485,7 +500,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
entityB, _ := tc.Arguments["entity_b"].(string)
rounds, _ := tc.Arguments["rounds"].(float64)
if entityA == "" || entityB == "" || rounds <= 0 {
return "entity_a、entity_b 和 rounds 不能为空"
return "entity_a, entity_b and rounds are required"
}
if entityA > entityB {
entityA, entityB = entityB, entityA
@ -494,12 +509,12 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
a.noMergeMu.Lock()
a.noMergeMarkers[key] = int(rounds)
a.noMergeMu.Unlock()
return fmt.Sprintf("已标记「%s」与「%s」在 %d 轮内不合并", entityA, entityB, int(rounds))
return fmt.Sprintf("Marked \"%s\" and \"%s\" as not-mergeable for %d rounds", entityA, entityB, int(rounds))
case "memory_commit":
triplesData, ok := tc.Arguments["triples"].([]interface{})
if !ok {
return "参数格式错误,需要 triples 数组"
return "invalid arguments: triples must be an array"
}
// 场景键:模型可以在三元组里逐条给(scene 字段),也可以在工具参数
// 顶层给一次(scene 参数),后者作为本批次的默认场景。
@ -548,7 +563,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
}
}
if len(triples) == 0 {
return "没有有效的三元组"
return "no valid triples"
}
// remember 工具是用户/模型显式写入,不涉及归档删除,
// 因此不需要 mediaBound——没有旧引用要释放。
@ -558,11 +573,11 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
// 拿它们判断「有没有写进去」会**永远判成没写进去**。
blocksBefore, err := a.memory.MemoryBlockCount()
if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err)
return fmt.Sprintf("memory commit failed: %v", err)
}
edgesBefore, err := a.memory.MemoryEdgeCount()
if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err)
return fmt.Sprintf("memory commit failed: %v", err)
}
// ★ ec/rc 丢弃(2026-10-05):它们恒为 0(graph.go 里
@ -571,15 +586,15 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
// 唯一可信信号是下面实测的 newBlocks / newEdges。
_, _, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0, nil)
if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err)
return fmt.Sprintf("memory commit failed: %v", err)
}
blocksAfter, err := a.memory.MemoryBlockCount()
if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err)
return fmt.Sprintf("memory commit failed: %v", err)
}
edgesAfter, err := a.memory.MemoryEdgeCount()
if err != nil {
return fmt.Sprintf("记忆写入失败: %v", err)
return fmt.Sprintf("memory commit failed: %v", err)
}
newBlocks := blocksAfter - blocksBefore
newEdges := edgesAfter - edgesBefore
@ -599,7 +614,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
//
// ⇒ 「报假失败」比「报假成功」危险:前者会诱发破坏性动作。
if newBlocks == 0 && mb == 0 {
return fmt.Sprintf("提交了 %d 条三元组但全部被拒(未写入)。常见原因:实体名为空、过长(>50 字)、或不含字母/汉字/数字。请检查主语/宾语的写法后重试。", len(triples))
return fmt.Sprintf("Submitted %d triples but all were rejected (nothing written). Common causes: empty entity name, longer than 50 chars, or no letters/digits. Check subject/object spelling and retry.", len(triples))
}
// ★★★ 成功分支也**不能用 ec/rc**(2026-10-05)
//
@ -618,10 +633,10 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
// 唯一可信的信号是 newBlocks(块总量差值)——
// 它直接量「库里多了几个块」,不依赖任何旧表口径。
if mb > 0 {
return fmt.Sprintf("已写入 %d 个记忆块、%d 条关系边,关联 %d 份媒体",
return fmt.Sprintf("Wrote %d memory blocks and %d relation edges, linked %d media items",
newBlocks, newEdges, mb)
}
return fmt.Sprintf("已写入 %d 个记忆块、%d 条关系边", newBlocks, newEdges)
return fmt.Sprintf("Wrote %d memory blocks and %d relation edges", newBlocks, newEdges)
case "memory_introspect":
if msg := requireFull(); msg != "" {
@ -629,9 +644,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
}
stats, err := a.memory.Introspect()
if err != nil {
return fmt.Sprintf("查询失败: %v", err)
return fmt.Sprintf("query failed: %v", err)
}
return fmt.Sprintf("记忆统计: %v", stats)
return fmt.Sprintf("memory stats: %v", stats)
case "memory_document_query":
return a.executeDocTool(tc)
@ -643,13 +658,13 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
source, _ := tc.Arguments["source"].(string)
target, _ := tc.Arguments["target"].(string)
if source == "" || target == "" {
return "source 和 target 不能为空"
return "source and target are required"
}
count, err := a.memory.MergeEntities(source, target)
if err != nil {
return fmt.Sprintf("合并失败: %v", err)
return fmt.Sprintf("merge failed: %v", err)
}
return fmt.Sprintf("已将「%s」合并到「%s」,source 已彻底删除,%d 条关系已重定向", source, target, count)
return fmt.Sprintf("Merged \"%s\" into \"%s\": source deleted, %d relations redirected", source, target, count)
case "memory_delete_entity":
if msg := requireFull(); msg != "" {
@ -657,11 +672,11 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
}
name, _ := tc.Arguments["name"].(string)
if name == "" {
return "name 不能为空"
return "name is required"
}
res, err := a.memory.DeleteEntity(name)
if err != nil {
return fmt.Sprintf("删除失败: %v", err)
return fmt.Sprintf("delete failed: %v", err)
}
// ★ 如实报告实际删掉多少,不说「已彻底删除…及其所有关联关系」。
//
@ -669,9 +684,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
// 模型据此认为内容已消失(不再提及或重新写入),
// 而关联边还在、召回继续命中 —— 谎报会让模型的行为跟着错。
if res.Blocks == 0 {
return fmt.Sprintf("未找到名为「%s」的块,未删除任何内容", name)
return fmt.Sprintf("No block named \"%s\"; nothing deleted", name)
}
return fmt.Sprintf("已删除块「%s」及其 %d 条关联关系(块 %d 个)",
return fmt.Sprintf("Deleted block \"%s\" and its %d relations (%d blocks)",
name, res.Edges, res.Blocks)
case "memory_purge":
@ -694,7 +709,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
}
n, err := a.memory.Purge(criteria, mode)
if err != nil {
return fmt.Sprintf("删除图记忆失败: %v", err)
return fmt.Sprintf("stable memory delete failed: %v", err)
}
textRemoved := 0
@ -705,9 +720,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
})
}
}
parts := []string{fmt.Sprintf("已%s删除 %d 条图记忆关系", mode, n)}
parts := []string{fmt.Sprintf("%s %d stable-memory relations", mode, n)}
if textRemoved > 0 {
parts = append(parts, fmt.Sprintf("清理 %d 条文本记忆日志", textRemoved))
parts = append(parts, fmt.Sprintf("purged %d text memory log entries", textRemoved))
}
return strings.Join(parts, ",")
@ -719,13 +734,13 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
oldRelation, _ := tc.Arguments["old_relation"].(string)
oldObject, _ := tc.Arguments["old_object"].(string)
if oldSubject == "" || oldRelation == "" || oldObject == "" {
return "old_subject、old_relation、old_object 不能为空"
return "old_subject, old_relation and old_object are required"
}
newSubject, _ := tc.Arguments["new_subject"].(string)
newRelation, _ := tc.Arguments["new_relation"].(string)
newObject, _ := tc.Arguments["new_object"].(string)
if newSubject == "" && newRelation == "" && newObject == "" {
return "至少提供一个新值(new_subject / new_relation / new_object)"
return "provide at least one new value (new_subject / new_relation / new_object)"
}
if newSubject == "" {
newSubject = oldSubject
@ -756,7 +771,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
"target_contains": oldObject,
}, "hard")
if err != nil {
return fmt.Sprintf("编辑图记忆失败(删除旧记录): %v", err)
return fmt.Sprintf("stable memory edit failed (deleting old record): %v", err)
}
triples := []memory.Triple{{
Subject: newSubject,
@ -768,7 +783,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
}}
ec, rc, err := a.memory.Commit(triples, string(a.id), 0)
if err != nil {
return fmt.Sprintf("编辑图记忆失败(写入新记录): %v", err)
return fmt.Sprintf("stable memory edit failed (writing new record): %v", err)
}
textReplaced := 0
@ -784,33 +799,33 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
},
)
}
result := fmt.Sprintf("已编辑记忆:删除 %d 条旧关系,写入 %d 个实体 + %d 条新关系", n, ec, rc)
result := fmt.Sprintf("Memory edited: %d old relations removed, %d entities and %d new relations written", n, ec, rc)
if textReplaced > 0 {
result += fmt.Sprintf(",更新 %d 条文本记忆日志", textReplaced)
result += fmt.Sprintf(", %d text memory log entries updated", textReplaced)
}
return result
default:
return fmt.Sprintf("未知的记忆工具: %s", tc.Name)
return fmt.Sprintf("unknown memory tool: %s", tc.Name)
}
}
func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string {
if a.social == nil {
return "人物关系网不可用(social store 未初始化)"
return "social graph is unavailable (social store not initialized)"
}
switch tc.Name {
case "person_query":
name, _ := tc.Arguments["name"].(string)
if name == "" {
return "请输入人物名称"
return "person name is required"
}
profile, err := a.social.GetPerson(name)
if err != nil {
return fmt.Sprintf("查询人物失败: %v", err)
return fmt.Sprintf("person lookup failed: %v", err)
}
var parts []string
parts = append(parts, fmt.Sprintf("▎%s 的档案", name))
parts = append(parts, fmt.Sprintf("▎Profile of %s", name))
if len(profile.Traits) > 0 {
parts = append(parts, "【特质】")
for k, v := range profile.Traits {
@ -833,24 +848,24 @@ func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string {
trait, _ := tc.Arguments["trait"].(string)
value, _ := tc.Arguments["value"].(string)
if name == "" || trait == "" || value == "" {
return "name、trait、value 都不能为空"
return "name, trait and value are required"
}
if err := a.social.SetTrait(name, trait, value); err != nil {
return fmt.Sprintf("设置特质失败: %v", err)
return fmt.Sprintf("setting trait failed: %v", err)
}
return fmt.Sprintf("已记录:%s 的 %s = %s", name, trait, value)
return fmt.Sprintf("Recorded: %s %s = %s", name, trait, value)
case "person_relate":
personA, _ := tc.Arguments["person_a"].(string)
relation, _ := tc.Arguments["relation"].(string)
personB, _ := tc.Arguments["person_b"].(string)
if personA == "" || relation == "" || personB == "" {
return "person_a、relation、person_b 都不能为空"
return "person_a, relation and person_b are required"
}
if err := a.social.AddRelation(personA, relation, personB); err != nil {
return fmt.Sprintf("建立关系失败: %v", err)
return fmt.Sprintf("creating relation failed: %v", err)
}
return fmt.Sprintf("已记录:%s —(%s)—→ %s", personA, relation, personB)
return fmt.Sprintf("Recorded: %s —(%s)—→ %s", personA, relation, personB)
case "person_network":
name, _ := tc.Arguments["name"].(string)
@ -859,17 +874,17 @@ func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string {
depth = 2
}
if name == "" {
return "请输入人物名称"
return "person name is required"
}
profiles, err := a.social.GetNetwork(name, depth)
if err != nil {
return fmt.Sprintf("查询社交网络失败: %v", err)
return fmt.Sprintf("social network query failed: %v", err)
}
if len(profiles) == 0 {
return fmt.Sprintf("未找到 %s 的社交网络", name)
return fmt.Sprintf("No social network found for %s", name)
}
var parts []string
parts = append(parts, fmt.Sprintf("▎%s 的社交网络(%d 度)", name, depth))
parts = append(parts, fmt.Sprintf("▎Social network of %s (%d hops)", name, depth))
for _, p := range profiles {
if p.Name == name {
continue
@ -887,13 +902,13 @@ func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string {
return strings.Join(parts, "\n")
default:
return fmt.Sprintf("未知的人物工具: %s", tc.Name)
return fmt.Sprintf("unknown person tool: %s", tc.Name)
}
}
// knowledgeMediaRefs 把模型给的块引用(ID 或其唯一前缀)解析成知识条目的媒体引用。
//
// 复用 doc_commit 的既有约定:引用可传前缀,解析不了的跳过而不是报错——
// Reuses the coldcontext_commit convention: references may be prefixes; unresolved ones are skipped.
// 模型偶尔会把 ID 记错,不该让整次写入失败。MIME 从块回读,嵌入时需要
// (EmbedImageDense 靠它判定模态)。字节随条目落盘(writeMediaFiles)。
func (a *Agent) knowledgeMediaRefs(refs []string) []knowledge.KnowledgeMediaRef {
@ -919,7 +934,7 @@ func (a *Agent) knowledgeMediaRefs(refs []string) []knowledge.KnowledgeMediaRef
func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
if a.knowledge == nil {
return "知识库不可用"
return "knowledge base is unavailable"
}
switch tc.Name {
case "knowledge_search":
@ -929,14 +944,14 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
topK = 5
}
if query == "" {
return "请输入查询关键词"
return "query is required"
}
// 可选分类限定:把召回限制在某棵分类子树内(前缀匹配,见
// knowledge.Store.SearchIn)。不传 = 全库。
category, _ := tc.Arguments["category"].(string)
results := a.knowledge.SearchIn(query, category, topK)
if len(results) == 0 {
return "未找到相关知识"
return "no relevant knowledge found"
}
var parts []string
for i, k := range results {
@ -953,19 +968,19 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
name, _ := tc.Arguments["name"].(string)
content, _ := tc.Arguments["content"].(string)
if name == "" || content == "" {
return "name 和 content 不能为空"
return "name and content are required"
}
// 模型可显式关联已入库的媒体(与 doc_commit 的 media_digests 同形)。
// The model may attach stored media explicitly (same media_digests shape as coldcontext_commit).
// 这些媒体成为知识条目的一等节点:其向量会与正文向量融合,
// 使该条目能按图本身被召回,而不依赖任何生成的描述文本。
media := a.knowledgeMediaRefs(getStringSlice(tc.Arguments, "media_digests"))
if err := a.knowledge.AddWithMedia(name, content, media); err != nil {
return fmt.Sprintf("知识创建失败: %v", err)
return fmt.Sprintf("knowledge create failed: %v", err)
}
if len(media) > 0 {
return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符,%d 个媒体参与跨模态召回)", name, len(content), len(media))
return fmt.Sprintf("Knowledge \"%s\" created and indexed (%d chars, %d media items linked for cross-modal recall)", name, len(content), len(media))
}
return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符)", name, len(content))
return fmt.Sprintf("Knowledge \"%s\" created and indexed (%d chars)", name, len(content))
case "knowledge_list":
tree := a.knowledge.BuildTree()
@ -992,7 +1007,7 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
MaxItems: int(getFloat(tc.Arguments, "max_items")),
})
if err != nil {
return fmt.Sprintf("知识导入失败: %v", err)
return fmt.Sprintf("knowledge import failed: %v", err)
}
var b strings.Builder
verb := "已导入"
@ -1034,46 +1049,46 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
case "knowledge_delete":
name, _ := tc.Arguments["name"].(string)
if name == "" {
return "name 不能为空"
return "name is required"
}
if err := a.knowledge.Remove(name); err != nil {
return fmt.Sprintf("知识删除失败: %v", err)
return fmt.Sprintf("knowledge delete failed: %v", err)
}
return fmt.Sprintf("知识「%s」已删除", name)
return fmt.Sprintf("Knowledge \"%s\" deleted", name)
default:
return fmt.Sprintf("未知的知识工具: %s", tc.Name)
return fmt.Sprintf("unknown knowledge tool: %s", tc.Name)
}
}
func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if a.docStore == nil {
return "文档记忆不可用"
return "cold context is unavailable"
}
switch tc.Name {
case "doc_query":
case "coldcontext_query":
query, _ := tc.Arguments["query"].(string)
topK := int(getFloat(tc.Arguments, "top_k"))
if topK <= 0 {
topK = 3
}
if query == "" {
return "请输入查询内容"
return "query is required"
}
docs := a.docStore.Consume(query, topK)
if len(docs) == 0 {
return "未找到相关文档记忆"
return "no relevant cold context found"
}
var b strings.Builder
b.WriteString(fmt.Sprintf("已召回 %d 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):\n", len(docs)))
b.WriteString(fmt.Sprintf("Recalled %d cold-context documents; their original text is re-inserted into your timeline at its own original timestamps (not the current turn):\n", len(docs)))
totalEvents := 0
for i, d := range docs {
// 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件。
//
// ARCHITECTURE「记忆流转」② Document 层:
// doc_query → Consume(读取并删除)
// coldcontext_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
n := a.restoreDocEvents(d)
if n == 0 {
@ -1086,7 +1101,7 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
a.context.InsertByTimestamp(ContextEvent{
Timestamp: d.CreatedAt,
Source: "cold_storage",
Input: fmt.Sprintf("加载文档记忆: %s", query),
Input: fmt.Sprintf("load cold context: %s", query),
Response: content,
NoMemory: true, // 同上:回填不产生新记忆
})
@ -1107,14 +1122,14 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
}
}
b.WriteString(fmt.Sprintf("\n共回填 %d 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。", totalEvents))
b.WriteString(fmt.Sprintf("\n%d events backfilled in total. To quote details, read the timeline at those timestamps.", totalEvents))
return b.String()
case "doc_commit":
case "coldcontext_commit":
content, _ := tc.Arguments["content"].(string)
summary, _ := tc.Arguments["summary"].(string)
if content == "" {
return "content 不能为空"
return "content is required"
}
if summary == "" {
summary = truncateStr(content, 100)
@ -1136,22 +1151,22 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
}
if err := a.docStore.Insert(doc); err != nil {
return fmt.Sprintf("文档写入失败: %v", err)
return fmt.Sprintf("cold context write failed: %v", err)
}
if n := len(doc.Blocks); n > 0 {
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, n)
return fmt.Sprintf("Cold context committed (id: %s, summary: %s, %d media linked)", doc.ID, summary, n)
}
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary)
return fmt.Sprintf("Cold context committed (id: %s, summary: %s)", doc.ID, summary)
default:
return fmt.Sprintf("未知的文档工具: %s", tc.Name)
return fmt.Sprintf("unknown cold-context tool: %s", tc.Name)
}
}
// sortLabel 把排序模式翻成给模型看的中文标签。
func sortLabel(m memory.SortMode) string {
if m == memory.SortRecent {
return "时间倒序,最新在前"
return "newest first"
}
return "相关性"
}
@ -1246,7 +1261,7 @@ func (a *Agent) recallByBlocks(query string) string {
}
var parts []string
parts = append(parts, fmt.Sprintf("找到 %d 条相关记忆片段:", len(hits)))
parts = append(parts, fmt.Sprintf("Found %d relevant memory fragments:", len(hits)))
for _, h := range hits {
text := strings.TrimSpace(h.Text)
if text == "" {
@ -1315,7 +1330,7 @@ func (a *Agent) currentFingerprint() string {
//
// ARCHITECTURE「记忆流转」② Document 层的读取分支:
//
// doc_query → Consume(读取并删除)
// coldcontext_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
//
@ -1374,7 +1389,7 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int {
//
// 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"),
// 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用
// doc_query 的直接原因之一。
// coldcontext_query 的直接原因之一。
func docRecallHeadline(d *document.Doc, eventCount int) string {
if d == nil {
return ""
@ -1410,7 +1425,7 @@ func docRecallHeadline(d *document.Doc, eventCount int) string {
parts = append(parts, d.Source)
}
}
parts = append(parts, fmt.Sprintf("%d 条事件", eventCount))
parts = append(parts, fmt.Sprintf("%d events", eventCount))
if n := len(d.Blocks); n > 0 {
media := 0
for i := range d.Blocks {
@ -1419,7 +1434,7 @@ func docRecallHeadline(d *document.Doc, eventCount int) string {
}
}
if media > 0 {
parts = append(parts, fmt.Sprintf("含 %d 份媒体", media))
parts = append(parts, fmt.Sprintf("with %d media items", media))
}
}
return strings.Join(parts, " · ")

View File

@ -79,7 +79,7 @@ func TestMemoryRecall_块召回优先(t *testing.T) {
if strings.Contains(got, "完全无关") {
t.Errorf("低分块不该出现(MinScore 应滤掉),实际: %s", got)
}
if !strings.Contains(got, "相关记忆片段") {
if !strings.Contains(got, "relevant memory fragments") {
t.Errorf("应标明这是块召回结果,实际: %s", got)
}
}

View File

@ -66,7 +66,7 @@ func TestExecuteToolCallReportsMissingToolClearly(t *testing.T) {
t.Fatalf("错误文案应含工具名,实际: %s", got)
}
// 模型需要能据此判断该做什么(换名字 / 查 get_plugin_tools / 加载插件)
if !strings.Contains(got, "不存在") && !strings.Contains(got, "未注册") && !strings.Contains(got, "not found") {
if !strings.Contains(got, "does not exist") && !strings.Contains(got, "not registered") && !strings.Contains(got, "not found") {
t.Errorf("错误文案应明示『不存在/未注册』,实际: %s", got)
}
}

View File

@ -34,7 +34,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string)
if s != "" {
s += "\n"
}
s += "【关联媒体】\n" + mc
s += "[Related media]\n" + mc
}
if maxTokens > 0 {
@ -157,12 +157,12 @@ func appendPromptBlock(prompt, block string) string {
func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
prompt := expandPromptVars(a.systemPrompt)
if prompt == "" {
prompt = "你是小宅,HologramAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。"
prompt = "You are HologramAgent, a household-manager AI assistant. Never use Unicode emoji; express affect with kaomoji only, and end sentences with a soft particle. The WebUI overview page shows your portrait."
}
// 驻留子:在**固定提示词之上**注入任务提示词(设计 §7「创建」)。
if a.taskPrompt != "" {
prompt = appendPromptBlock(prompt, "【任务】"+a.taskPrompt)
prompt = appendPromptBlock(prompt, "[Task] "+a.taskPrompt)
}
if a.personality != nil {
@ -183,7 +183,7 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// 哪个工具会做什么),不是工具用法 —— 工具用法归各 ToolDef.Description。
//
// 为什么必须有这一段:此前系统提示词从未解释 L1/L2/L3 是什么,
// agent 只能从工具名猜。生产日志实测:agent 几乎从不调用 doc_query,
// agent 只能从工具名猜。生产日志实测:agent 几乎从不调用 coldcontext_query,
// 即使调用了也只看返回值、不知道原文已被回填到对话时序。
// 【三层记忆】只留**模型据此选择工具**的语义。
//
@ -191,19 +191,14 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// 对应工具的 description 与 skill:模型只需要知道"哪里有原文、哪里有
// 关系、用什么去取",不需要知道内核怎么存。
//
// ★ 但有一件事必须说:doc_query 会把历史事件**插回时序**,而不是返回
// ★ 但有一件事必须说:coldcontext_query 会把历史事件**插回时序**,而不是返回
// 一段摘要 —— 这与模型对"检索工具"的默认预期相反,不说就会误用。
prompt = appendPromptBlock(prompt, "【三层记忆】你这儿有三种记忆,按需要的东西选:\n"+
"- **对话时序(L1)**:最近的对话就在上面的【对话时序】里,直接能看。\n"+
"- **历史记忆(L2)**:更早的事被移出窗口了,但原文还在。要看某段完整经过用 `doc_query` —— "+
"它会把那些事**按原始时间戳插回你的对话时序**,你随后就能像看当前对话一样读它们。\n"+
"- **图记忆(L3)**:事实与关系(谁是什么、某值现在多少)用 `memory_recall`;"+
"成文的可复用知识用 `knowledge_search`。")
prompt = appendPromptBlock(prompt, "[Memory] Recent turns are above. For older conversations use `coldcontext_query` - it **re-inserts them into your timeline at their original timestamps**, so read them there, not in the return value. "+
"Facts and relations: `memory_recall`. Written knowledge: `knowledge_search`.")
// 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
prompt = appendPromptBlock(prompt, "【记忆维护】用户要求整理或清理记忆时,必须真的调用 memory_* 工具去做,"+
"不能只描述计划;选哪个工具、各有什么副作用见对应工具描述。做完用一句人话说结果。")
prompt = appendPromptBlock(prompt, "[Memory maintenance] To tidy or clear memory, call the memory_* tools - a described plan is not the work. Report the result in one sentence.")
// 跨模态召回:文本路(fastText/TF-IDF 文档层,媒体描述文本已随记忆进入)
// + 视觉路(多模态文本编码 → 媒体库坐标)两路归一化融合。
@ -215,7 +210,7 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
}
}
prompt = appendPromptBlock(prompt, "【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。")
prompt = appendPromptBlock(prompt, "[Interrupt] During long tasks a system message prefixed [Interrupt] may arrive (new message, terminal output, timer). It is not user speech, but handle it: pause, respond, or consciously continue.")
// 工具执行顺序(阶段 2.5)。必须在**并行执行落地之后**才加:
// 反序会让本段对模型说谎——说"并发"而内核仍串行,模型据此推断
@ -228,34 +223,27 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// 技术性内容(ParallelSafe 声明、批内降级、内核如何判据)全部下沉:
// 那是实现细节,模型既不需要也改不了 —— 讲它只会让回复带上说明书腔。
// 判断依据仍是真实的 batchRunnable 语义(勿写反)。
prompt = appendPromptBlock(prompt, "【工具执行顺序】同一条回复里的多个工具调用默认**并行**执行。\n"+
"- 不要依赖先后:后一个要用前一个的结果时,分两轮发。\n"+
"- **例外一**:对同一通道的多次输出会**保序**(按你发出的顺序送达),所以「先回执、再结论」可以放同一轮。\n"+
"- **例外二**:写类工具(记忆/知识/文档写入等)以及不声明**并发安全**的工具,这一批会退回依次执行 —— "+
"只是变慢,不影响结果,你不用为它调整写法。")
prompt = appendPromptBlock(prompt, "[Tool order] Calls in one reply run **in parallel** by default. **Never depend on order**: if one needs another's result, use two turns. "+
"Outputs to the same channel **keep their order**, so \"acknowledge, then conclude\" fits in one turn.")
prompt = appendPromptBlock(prompt, "【输出规则】\n")
prompt = appendPromptBlock(prompt, "[Output rules]\n")
// ★ 这一句是本段最重要的:用户明确要求「必须显式调用 outputch 才会输出给用户」。
// 模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了**——
// 异步通道下那等于自言自语(用户永远收不到)。
prompt += "**只有显式调用 `output_send__{通道名}` 才会把内容送达用户。**" +
"返回纯文本只是在这次推理里说话,用户看不到 —— 除非是下面说的同步通道。\n"
prompt += "- **不要假设当前通道是某个固定值**:同一会话里可能同时有多个来源(多设备、多通道、子任务)。\n"
prompt += " 先看这条消息本身与上下文里的来源信息,再决定往哪里回;不确定有哪些通道时先调 output_list_channels。\n"
prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本即可,内核会把它交给等待方显示,**这是唯一的例外**。\n"
prompt += "- 异步通道(qq / wechat / 群聊等):**必须**调用 output_send__{通道名},并在 meta 里带上正确的 user_id 或 group_id。\n"
prompt += "- 一次调用可以发多条:payload 传数组即可(type/meta 也支持数组一一对应,只给一个值则应用到每一条)。\n"
prompt += "- 不确定当前通道的发送方式时,先用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举,再决定。\n"
prompt += "**Content reaches the user only when you explicitly call `output_send__{channel}`.** " +
"Returning plain text only speaks inside this reasoning step; the user cannot see it - except on the synchronous channels described below.\n"
prompt += "- Don't assume a fixed channel: one conversation may span devices and channels. If unsure, call output_list_channels.\n"
prompt += "- Sync channels (webui / cli / terminal): return plain text. **The only exception.**\n"
prompt += "- Async channels (qq / wechat / groups): call output_send__{channel} with the right user_id or group_id.\n"
prompt += "- One call may send several messages: pass an array as payload. Unclear how to send? output_send__{channel}_help.\n"
// ❗这里**不得**限制"一轮只能发一次"。设计上输出是 agent 的**主动调用**:
// 收到一次输入后,可以往**任意(已授权的)通道**发**任意多次**(分段播报、
// 先回执后结论、同时通知多个通道都合法)。此前这里写着"每轮对话通常只需调用
// 一次 output_send"——那是一条**凭空的限制**,会让模型自己收起合理的多次输出。
// 真正需要提醒的只有两件事:单条长度上限(超长拆成完整段落)与"别反复重发
// 完全相同的内容"(自律,不是判据)。
prompt += "- **输出次数与目标通道由你自己决定**:一次输入可以对同一通道发多条(先回执后结论、分步播报、分段长文),也可以同时发到多个通道(例如同时通知 webui 与 qq)。**没有任何「一轮只能发一次」的限制。**\n"
prompt += "- 输出时只需注意两点:单条消息的长度上限(超长就拆成完整段落,不要碎片化);别反复重发**完全相同**的内容(那是浪费,不是限制)。\n"
prompt += "- 需要多步执行的长任务:**必须先**向当前对话通道发一条确认消息告诉用户已收到(异步通道用输出门工具,同步通道直接返回文本),**然后再**执行具体排查工具。确认消息不代表任务完成,发出后仍需继续执行实际工具并最终汇报结果。\n"
prompt += "- 用户从其他渠道发来「在哪里/怎么样了」这类追问时,先回忆上次任务的通道与上下文,再回同一通道。"
prompt += "- **You choose how many outputs and which channels.** **No \"one output per turn\" limit.** Keep messages whole; don't resend identical content.\n"
prompt += "- Multi-step work: acknowledge on the current channel **first**, then run the real tools. An acknowledgement is not completion - finish and report.\n"
if a.indexer != nil {
prompt += "\n\n" + a.indexer.BuildToolPrompt()
@ -283,11 +271,8 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// 以及按需插件要先 plugin_start 拉起。
//
// 不看 skill 就直接调工具,是这个仓库反复吃过亏的一类错误。
prompt = appendPromptBlock(prompt, "【可用技能】这些是插件配套的**使用说明书**,不是可选装饰。\n"+
"**规则**:准备调用某个插件的工具(或工具调用失败、参数不确定)时,**先看这里有没有同名技能的**"+
"(如 file-tools / cmd-tools / qq-tools),用 skill_info(\"技能名\") 读全文,"+
"再严格按其中步骤与坑位说明执行。技能里通常会写明:工具该怎么调、需要先启动哪个插件、常见报错怎么读。\n"+
"用户明确要求用某技能时同样先 skill_info 读全文,不要凭名字猜做法。\n\n"+idx)
prompt = appendPromptBlock(prompt, "[Skills] Usage manuals for plugins. Before calling a plugin's tool (or when it fails), read the same-named skill with skill_info(\"skill-name\") and follow it. "+
"Never guess from the name.\n\n"+idx)
}
}
@ -295,10 +280,8 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// 系统提示词每轮重建,因此 WebUI / QQ / CLI / ACP / 邮件等所有通道都会带上它;
// 模型调用 persona_set(或用户在 WebUI 向导里选)落地后,标记置位,本段消失。
if a.personaStore != nil && !a.personaStore.PersonaInitialized() {
prompt = appendPromptBlock(prompt, "【首启人格设定】你的**人格设定尚未确认**。请在本轮回复里先问用户一句:"+
"要用默认人格,还是自定义一个?拿到明确答复后**必须调用 persona_set 工具**落库:"+
"用户选默认 → mode=default;自定义 → mode=custom 且把内容写进 content;"+
"用户说以后再说 → mode=later。用户答复前不要假设已设置,也不要反复追问同一件事。")
prompt = appendPromptBlock(prompt, "[First-run persona setup] Your **persona is not confirmed**. Ask once this turn: default persona, or a custom one? Then **call persona_set**: "+
"default -> mode=default; custom -> mode=custom with the text in content; later -> mode=later. Until answered, don't assume it is set and don't repeat the question.")
}
prompt += a.buildToolCatalog()
@ -364,8 +347,8 @@ func (a *Agent) buildToolCatalog() string {
}
}
var sb strings.Builder
sb.WriteString("\n\n【可用工具能力】\n")
sb.WriteString("工具按插件分组注册。需要某个插件的具体工具时,调用 get_plugin_tools(\"{插件名}\") 获取该插件的完整工具定义(名称/参数/用途)。\n")
sb.WriteString("\n\n[Available tool capabilities]\n")
sb.WriteString("Grouped per plugin; call get_plugin_tools(\"{plugin}\") for full definitions.\n")
// ★ 说明"按需插件"这回事(2026-10-22)。
//
// 插件有两种启动形态(SDK autostart.go):
@ -374,12 +357,12 @@ func (a *Agent) buildToolCatalog() string {
//
// 不说清这件事的后果:模型看到"清单里没有某能力"就断定"没有这个能力",
// 于是绕道去用别的工具硬凑,或者干脆告诉用户做不到。
sb.WriteString("★ **工具清单里没有 ≠ 没有这个能力**:部分插件是**按需加载**的," +
"它们的工具一开始不在清单里。需要时先 get_plugin_tools(\"插件名\") 看它有哪些工具、" +
"再 plugin_start(\"插件名\") 把它拉起来,工具就会出现。对应的 skill(同类名字或同名技能)" +
"会说明这个插件是常驻还是按需、以及拉起后怎么用。\n")
sb.WriteString("**Not listed does not mean unavailable**: some plugins are **on-demand**; " +
"their tools are absent until started. Call get_plugin_tools(\"plugin\") to see them, " +
"then plugin_start(\"plugin\") to bring the plugin up. The matching skill states " +
"whether a plugin is resident or on-demand, and how to use it once started.\n")
for _, plg := range order {
sb.WriteString(fmt.Sprintf("- %s (%d 个工具)", plg, byPlugin[plg]))
sb.WriteString(fmt.Sprintf("- %s (%d tools)", plg, byPlugin[plg]))
if d := pluginDesc[plg]; d != "" {
sb.WriteString(": " + d)
}
@ -441,7 +424,7 @@ type toolDefOptions struct {
// noMemory 声明该工具的**输出**不参与记忆计算(向量化/关键词/蒸馏),
// 原文仍留在上下文可读。语义与 ToolDef.NoMemory 一致。
//
// ★ 注意:doc_query 的 noMemory **不写在这里**,而是由 init() 里的
// ★ 注意:coldcontext_query 的 noMemory **不写在这里**,而是由 init() 里的
// 显式名单登记。原因:工具表是按需构造的(受 `if a.docStore != nil`
// 之类条件控制),而 textForVector 可能在构造之前被调用 ——
// 靠构造时登记会让声明"时有时无"。声明是静态事实,init 期定死。
@ -518,7 +501,7 @@ func init() {
// 插件与来源:列举
"get_plugin_tools", "llm_list_sources",
// 文档:检索
"doc_query",
"coldcontext_query",
// 安全模块:列举变量(只读,无共享写)
"secret_list",
} {
@ -533,10 +516,10 @@ func init() {
// 在工具被构造之前也可能被调用 ⇒ 声明会"时有时无"。
// 声明是静态事实,就该在 init 期定死(与上面 parallel 同一理由)。
//
// 目前只有 doc_query:它的返回值含被召回事件的正文片段,
// 目前只有 coldcontext_query:它的返回值含被召回事件的正文片段,
// 而那些正文已在记忆体系里 —— 再参与向量化/蒸馏就是重复注入。
for _, name := range []string{
"doc_query",
"coldcontext_query",
} {
declareNoMemoryTool(name)
}
@ -590,16 +573,16 @@ func (a *Agent) buildToolDefs() []interface{} {
"用本工具按 offset/limit 取其余部分;单次最多 64KB。"+
"结果不会自动过期,但会**随那条上下文事件淘汰**——事件被裁掉后再读会明确告知已淘汰。",
map[string]interface{}{
"event_id": map[string]interface{}{"type": "string", "description": "预览里给出的句柄(形如 evt_1770000000000000000)"},
"offset": map[string]interface{}{"type": "number", "description": "起始字节偏移,从 0 开始;省略即从头"},
"limit": map[string]interface{}{"type": "number", "description": "读取字节数,省略或 <=0 取 64KB;不该超过剩余量"},
"event_id": map[string]interface{}{"type": "string", "description": "Handle shown in the preview (e.g. evt_1770000000000000000)"},
"offset": map[string]interface{}{"type": "number", "description": "Start byte offset (0-based); omit to read from the beginning"},
"limit": map[string]interface{}{"type": "number", "description": "Bytes to read; omit or <=0 for 64KB; should not exceed the remainder"},
}, "event_id"))
}
if a.personaStore != nil {
tools = append(tools, toolDef("persona_set", "【首启人格】落地用户的人格选择并记录「已经问过」。仅在用户明确答复后调用:默认用 mode=default;自定义用 mode=custom 并把人格内容放进 content;用户说以后再说用 mode=later。", map[string]interface{}{
"mode": map[string]interface{}{"type": "string", "description": "default | custom | later"},
"content": map[string]interface{}{"type": "string", "description": "自定义人格内容(mode=custom 时必填)"},
"content": map[string]interface{}{"type": "string", "description": "Custom persona text (required when mode=custom)"},
}, "mode"))
}
@ -684,38 +667,20 @@ func (a *Agent) buildToolDefs() []interface{} {
}
if a.docStore != nil {
// ★ doc_query / doc_commit 的描述必须与**真实行为**一致。
//
// 旧描述只有一句「查询文档记忆。输入查询内容,返回相关文档摘要」:
// · "文档记忆"没有定义,模型不知道 L2 是什么;
// · "返回相关文档摘要"与真实行为**不符**(真实是把事件逐条按
// 原始时间戳插回对话时序);
// · 没说什么时候该用,也没说与 memory_recall 的分工。
// 结果:生产实测 agent 几乎不调用 doc_query,即使调用也只看返回值、
// 不知道原文已回填到时序。
tools = append(tools, toolDefWith("doc_query",
"从**历史记忆层**(L2)召回过去的事件原文。"+
"\n\n**这一层是什么**:当前对话窗口里只看得到最近的事件;更早的事件被移出窗口后,"+
"连同原文完整保存在历史层,不会丢失。"+
"\n\n**何时用**:记得«这事办过/说过»但想不起细节;需要当时的原始过程而非结论;"+
"跨会话、跨天追问;用户问\"上次那个\"/\"之前怎么弄的\"。只想知道某个事实的当前值、"+
"或某个实体的关系,用 memory_recall(图记忆),不要用这个。"+
"\n\n**调用后会发生什么**:命中的事件会**逐条按原始时间戳插回你的对话时序**"+
"(就是【对话时序】那一段,出现在它当时的时间点上),"+
"返回值会列出每条的时间/来源/正文开头。要引用细节,读时序里对应时间点即可。"+
"\n\n**副作用**:召回即从历史层取出(消费式),该文档不再留在 L2。",
// The public tool name remains coldcontext_query for compatibility. Its description
// uses "cold context" so the model does not mistake replayable conversation
// history for a user-authored document.
tools = append(tools, toolDefWith("coldcontext_query",
"Retrieve earlier conversation turns that are no longer in the current context. Use this when the user refers to a past episode, asks what was said or done, or you remember an event but need its exact details. For current facts or entity relations, use `memory_recall` instead.\n\n"+
"The full matching turns are restored into your conversation timeline at their original timestamps (they are inserted back into the timeline, not returned as a summary); read them there. This is consuming: retrieved material leaves cold context.",
map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "查询内容,用当时可能出现的词描述"},
"top_k": map[string]interface{}{"type": "integer", "description": "召回多少篇(默认 3)", "default": 3},
}, []string{"query"}, parallelOpts()))
tools = append(tools, toolDef("doc_commit",
"把**整段结论/过程**写进历史记忆层(L2)。"+
"\n\n**适合写什么**:做完一件事的完整过程与结论、排查链条、方案取舍的理由 —— "+
"这些内容长、有上下文、以后要回看原文。"+
"\n\n**不适合写什么**:单个事实或实体关系(谁是谁、某值等于多少、偏好)→ 用 memory_commit;"+
"可复用的成文知识 → 用 knowledge_create。"+
"\n\n写入后会参与后续的自动预填充与 doc_query 召回。",
tools = append(tools, toolDef("coldcontext_commit",
"Write a durable episode or conclusion into cold context (older conversation history). Use this for a complete process, investigation trail, or decision rationale. For a single fact or relation use `memory_commit`; for reusable written knowledge use `knowledge_create`.",
map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "文档内容(完整正文,不要自己截断)"},
"summary": map[string]interface{}{"type": "string", "description": "一句话摘要(可选,留空则由正文首行生成)"},
@ -752,7 +717,7 @@ func (a *Agent) buildToolDefs() []interface{} {
}
// 按插件动态拉取工具定义(避免全量注入提示词污染)
tools = append(tools, toolDefWith("get_plugin_tools", "获取指定插件的完整工具定义(名称/参数/用途)。参数 plugin_name 传插件名(见系统提示的【可用工具能力】列表)。省略时返回全部插件的工具摘要。", map[string]interface{}{
tools = append(tools, toolDefWith("get_plugin_tools", "Get a plugin's full tool definitions (name / parameters / purpose). Pass the plugin name as plugin_name (see the [Available tool capabilities] list in the system prompt). Omit it to get a summary of tools for every plugin.", map[string]interface{}{
"plugin_name": map[string]interface{}{"type": "string", "description": "插件名,如 qq / remotedevice / weather", "default": ""},
}, nil, parallelOpts()))

View File

@ -78,8 +78,8 @@ func isToolError(v interface{}) bool {
}
}
// toolErrorText 把一个失败返回值渲染成给模型看的文本。
// 结构化 ToolError 会带上 Hint——这是「让模型看得懂真因」的关键。
// toolErrorText renders a failed tool result for the model.
// A structured ToolError includes its Hint so the model can act on the real cause.
func toolErrorText(toolName string, v interface{}) string {
switch x := v.(type) {
case *sdk.ToolError:
@ -90,28 +90,28 @@ func toolErrorText(toolName string, v interface{}) string {
if b, ok := x["isError"].(bool); ok && b {
msg, _ := x["content"].(string)
if msg == "" {
msg = "(插件未给出原因)"
msg = "(plugin did not provide a reason)"
}
return fmt.Sprintf("工具 %s 失败: %s", toolName, msg)
return fmt.Sprintf("Tool %s failed: %s", toolName, msg)
}
if e, ok := x["error"]; ok {
if s, ok := e.(string); ok {
return fmt.Sprintf("工具 %s 失败: %s", toolName, s)
return fmt.Sprintf("Tool %s failed: %s", toolName, s)
}
}
case error:
return fmt.Sprintf("工具 %s 失败: %v", toolName, x)
return fmt.Sprintf("Tool %s failed: %v", toolName, x)
}
return fmt.Sprintf("工具 %s 失败: %v", toolName, v)
return fmt.Sprintf("Tool %s failed: %v", toolName, v)
}
// renderToolError 渲染结构化失败:把 field/reason/hint 都摆出来,
// 让模型知道**该改什么**,而不是只知道「失败了」。
// renderToolError renders a structured failure with field/reason/hint,
// so the model knows what to change instead of seeing only "failed".
func renderToolError(toolName string, e *sdk.ToolError) string {
var sb strings.Builder
fmt.Fprintf(&sb, "工具 %s 失败", toolName)
fmt.Fprintf(&sb, "Tool %s failed", toolName)
if e.Field != "" {
fmt.Fprintf(&sb, "(字段 %s)", e.Field)
fmt.Fprintf(&sb, " (field %s)", e.Field)
}
sb.WriteString(": ")
if e.Reason != "" {
@ -122,17 +122,15 @@ func renderToolError(toolName string, e *sdk.ToolError) string {
sb.WriteString(e.Detail)
}
if e.Hint != "" {
sb.WriteString("\n请据此修正后重试:")
sb.WriteString("\nFix this and retry: ")
sb.WriteString(e.Hint)
}
return sb.String()
}
// renderToolResult 把工具返回值渲染成给模型看的文本。
//
// 规则:**结构化失败优先**——失败必须带上可执行信息(字段/原因/Hint),
// 而不是退化成 `map[error:xxx]` 这种模型读不懂的 Go 语法。
// 成功则用紧凑 JSON(绝不用 fmt.Sprintf("%v"),那会产出 Go 的 map 语法)。
// renderToolResult renders tool output for the model.
// Structured failures take priority and include actionable information;
// successful non-string values use compact JSON instead of Go map syntax.
func renderToolResult(toolName string, raw interface{}) string {
if raw == nil {
return ""

View File

@ -0,0 +1,61 @@
package core
// 插件本轮提示(SDK InjectOptions.TurnHint)的注入判据。
//
// 设计分工(用户 2026-10-26):
//
// 人设(personal.md) 怎么说话 —— 静态
// TurnHint 这一轮的事实 —— 动态,由插件声明
//
// 关键约束:提示必须作为**独立 system 消息**出现,绝不能拼进用户正文。
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// ① 有 TurnHint ⇒ 出现一条独立的 system 消息,用户消息原样不动。
func TestTurnHintInjectedAsSeparateSystemMessage(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
f := &TaskFrame{Input: "在吗"}
f.StageCtx = &sdk.StageContext{Extra: map[string]interface{}{
"turn_hint": "【本轮渠道】QQ 私聊 → 用 output_send__qq",
}}
f.Msgs = a.buildMessages(a.buildSystemPrompt("", "在吗"), f.Input, 4096)
hint := stageHint(f.StageCtx, "turn_hint")
if hint == "" {
t.Fatal("没能从 stage 上下文取到 turn_hint")
}
f.Msgs = append(f.Msgs, []agentAPI.Message{{Role: "system", Content: "[Turn hints] " + hint}}...)
last := f.Msgs[len(f.Msgs)-1]
if last.Role != "system" {
t.Fatalf("提示必须是 system 角色(否则模型会当成人话),实际 %s", last.Role)
}
if !strings.Contains(last.Content, "[Turn hints]") || !strings.Contains(last.Content, "output_send__qq") {
t.Fatalf("提示内容不对: %q", last.Content)
}
// 用户消息不能被污染
for _, m := range f.Msgs[:len(f.Msgs)-1] {
if strings.Contains(m.Content, "[Turn hints]") {
t.Fatalf("提示被拼进了非 system 消息(role=%s): %q", m.Role, m.Content)
}
}
}
// ② 没有 TurnHint ⇒ 不追加任何消息(零行为变化)。
func TestNoTurnHintAppendsNothing(t *testing.T) {
if got := stageHint(&sdk.StageContext{Extra: map[string]interface{}{}}, "turn_hint"); got != "" {
t.Fatalf("没有提示时应返回空,实际 %q", got)
}
if got := stageHint(nil, "turn_hint"); got != "" {
t.Fatalf("nil 上下文应返回空,实际 %q", got)
}
if got := stageHint(&sdk.StageContext{Extra: map[string]interface{}{"turn_hint": " "}}, "turn_hint"); got != "" {
t.Fatalf("纯空白应视为没有提示,实际 %q", got)
}
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"log"
"runtime/debug"
"strings"
"sync"
"time"
@ -402,6 +403,22 @@ func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
if opts.Priority != "" {
payload["priority"] = opts.Priority
}
// ★ 插件声明的**命名安全限式**(可选):本插件 inputch 触发时选一档。
//
// 语义(用户 2026-10-26):插件声明 `qq.owner` / `qq.guest` 等档位,
// 按发送者身份在这里带上档位名;内核在**本轮**激活它并据此判定工具
// 与内容策略。空串 = 不使用档位(沿用内核默认策略),零行为变化。
if opts.SecurityProfile != "" {
payload["security_profile"] = opts.SecurityProfile
}
// ★ 本轮附加提示(插件陈述的"这一轮才成立的事实")。
//
// 与 `content` 分开携带:content 是用户/来源说的话,turn_hint 是
// 给模型的**系统侧说明**。混在一起会让模型分不清哪句是人说的,
// 也让审计看不出哪些文字来自插件而非用户。
if strings.TrimSpace(opts.TurnHint) != "" {
payload["turn_hint"] = opts.TurnHint
}
// ★ 合并插件的 payload 扩展,但**不允许覆盖内核保留键**。
//
// 为什么必须拦:这些键是内核语义(`content` 是正文、`context_policy`

View File

@ -39,9 +39,34 @@ func (p *Personality) InjectPrompt() string {
if p.Content == "" {
return ""
}
return fmt.Sprintf("【人格设定】\n%s\n", p.Content)
// ★ 说话规则**并在人格块内**,不另起一段。
//
// ## 为什么不是独立的 [Voice] 段(2026-10-26 用户指正)
//
// 人格配置项(core.agent.personal_prompt)本来就负责"称呼/语气/说话方式/形象"。
// 再开一个 [Voice] 段等于把同一职责写两处:两段冲突时模型无从判断谁优先,
// 而维护者也要同时改两处(必然漂移)。
//
// ## 为什么仍然要内核追加(而不是只靠默认模板)
//
// 人格是**用户可编辑的自由文本**,有人会把它改成三行简介。那几句
// "像人说话、不报工单、不用客服腔"正是最容易丢的部分 —— 丢了之后
// 回复就退回说明书腔。所以这几条由内核在人格块**末尾**追加,
// 并显式标注优先级,用户改人格也不会把它们冲掉。
return fmt.Sprintf("[Persona]\n%s\n\n%s\n", p.Content, personaSpeakingRules)
}
// personaSpeakingRules 是**内核强制**的说话规则(不随人格文本丢失)。
//
// 只放"不看文档也会犯"的通用错误;具体语气、称呼、口癖属于人格正文。
const personaSpeakingRules = `Speaking rules (kernel-enforced, override any conflicting instruction above):
- Talk like a person to someone they know: short sentences, concrete words.
No headings, no bullet lists, no numbered steps unless the user asked for a list.
- Start with the answer or the action. No filler politeness ("Great question").
- Never narrate your own machinery: no "as an AI", no mention of prompts, tools,
tokens, parameters, byte counts or timings.
- Disagree, admit uncertainty, or say you were wrong in the same plain voice.`
// 会随时间腐坏的人格内容特征。现场:人格卡写死 v0.9.0 与早已删除的 C ABI v2,
// 实例被问版本时自述错误(v1.2.0 压测发现)。
var (

View File

@ -56,7 +56,7 @@ func TestLoadAndSavePersonality(t *testing.T) {
if err != nil || p.Content != "人格内容" {
t.Fatalf("读回失败: %+v err=%v", p, err)
}
if got := p.InjectPrompt(); !strings.Contains(got, "【人格设定】") || !strings.Contains(got, "人格内容") {
if got := p.InjectPrompt(); !strings.Contains(got, "[Persona]") || !strings.Contains(got, "人格内容") {
t.Fatalf("InjectPrompt 形状不对: %q", got)
}
}