mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
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:
@ -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 级:「这个会话花了多少、缓存省了多少」
|
||||
|
||||
@ -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 三层都不在)。
|
||||
//
|
||||
// 自激的入口不在"归档"这一侧,而在"重新计算向量"那一侧 ——
|
||||
|
||||
@ -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 ""
|
||||
|
||||
159
internal/agent/core/device_host.go
Normal file
159
internal/agent/core/device_host.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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")) {
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 可传数组一次发多条")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,实际缺少该段")
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 或工具描述",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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, "真实正文在这里") {
|
||||
|
||||
@ -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 != "图" {
|
||||
|
||||
57
internal/agent/core/security_profile.go
Normal file
57
internal/agent/core/security_profile.go
Normal 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)
|
||||
}
|
||||
74
internal/agent/core/security_profile_test.go
Normal file
74
internal/agent/core/security_profile_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)},
|
||||
})
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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, " · ")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()))
|
||||
|
||||
|
||||
@ -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 ""
|
||||
|
||||
61
internal/agent/core/turn_hint_test.go
Normal file
61
internal/agent/core/turn_hint_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user