diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index e5fc055..dc59975 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -59,6 +59,30 @@ func isContinuationPlaceholder(m agentAPI.Message) bool { (m.Content == continuationPlaceholder || m.Content == replyDeliveredPlaceholder) } +// toolOutputForQuery 返回用于相关性计算的工具输出**有效内容**。 +// +// 为什么要过 Cleaner 而不是直接用原始 result:ContextPolicy=prune 的入参是 +// **相关性查询向量**——它决定保留/归档哪些上下文事件。原始工具输出里混着 +// ANSI 转义、base64、JSON 包装等噪声,直接拿去向量化会让打分失真。 +// 而 ToolDef.Cleaner 的契约本就写着“仅在向量化/jieba/蒸馏时调用”,裁剪正是 +// 在向量化,所以这里必须过它(此前只在构建事件向量时用了,裁剪查询漏了)。 +// +// Cleaner 未注册或 RPC 失败时回退原文(清洗是计算层优化,不能因此丢内容); +// 返回空串时也回退——空串会让查询向量退化成零向量,裁剪就失去判据。 +func (a *Agent) toolOutputForQuery(toolName, raw string) string { + if a.stageHost == nil { + return raw + } + cleaner := a.stageHost.ToolDefCleaner(toolName) + if cleaner == nil { + return raw + } + if cleaned := cleaner(raw); cleaned != "" { + return cleaned + } + return raw +} + // dropContinuationPlaceholders 移除此前由本机制插入的 user 占位。 // // 为什么必须移除而不仅仅是“不再追加”:`msgs` 在循环外创建、循环内只增不减, @@ -393,7 +417,9 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri if topK < 1 { topK = 1 } - a.context.Prune(result, topK, a.docStore) + // 查询向量取**清洗后**的有效内容,否则噪声(ANSI/base64/JSON + // 包装)会把相关性打分带偏,裁掉本该保留的事件。 + a.context.Prune(a.toolOutputForQuery(tc.Name, result), topK, a.docStore) } } diff --git a/internal/agent/core/prunequery_test.go b/internal/agent/core/prunequery_test.go new file mode 100644 index 0000000..a88b71c --- /dev/null +++ b/internal/agent/core/prunequery_test.go @@ -0,0 +1,65 @@ +package core + +import ( + "testing" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// ContextPolicy=prune 的查询向量必须取**清洗后**的有效内容。 +// +// 裁剪的入参是相关性查询向量,它决定保留/归档哪些上下文事件。原始工具输出里 +// 混着 ANSI 转义、base64、JSON 包装等噪声,直接向量化会让打分失真,裁掉本该 +// 保留的事件。ToolDef.Cleaner 的契约本就写着「仅在向量化/jieba/蒸馏时调用」, +// 裁剪正是在向量化——此前只在构建事件向量时用了它,裁剪查询漏了。 +func TestToolOutputForQueryAppliesCleaner(t *testing.T) { + host := NewStageHost() + called := 0 + if err := host.RegisterTool("demo_tool", sdk.ToolDef{ + Name: "demo_tool", + Cleaner: func(s string) string { + called++ + return "cleaned:" + s + }, + }, func(map[string]interface{}) (interface{}, error) { return nil, nil }); err != nil { + t.Fatalf("RegisterTool: %v", err) + } + + a := &Agent{stageHost: host} + raw := "\x1b[31mresult\x1b[0m" + + got := a.toolOutputForQuery("demo_tool", raw) + if called != 1 { + t.Fatalf("Cleaner 应被调用恰好一次,实际 %d", called) + } + if got != "cleaned:"+raw { + t.Fatalf("查询应使用清洗结果,实际 %q", got) + } + + // 未注册 Cleaner 的工具:回退原文。 + if got := a.toolOutputForQuery("no_such_tool", raw); got != raw { + t.Fatalf("无 Cleaner 应回退原文,实际 %q", got) + } + + // 无 StageHost(如裸 Agent):不能 panic,回退原文。 + if got := (&Agent{}).toolOutputForQuery("demo_tool", raw); got != raw { + t.Fatalf("nil stageHost 应回退原文,实际 %q", got) + } +} + +// Cleaner 返回空串时必须回退原文:空串会让查询向量退化成零向量, +// 所有事件相关性相同,裁剪就失去判据(等于随机裁)。 +func TestToolOutputForQueryEmptyCleanFallsBack(t *testing.T) { + host := NewStageHost() + if err := host.RegisterTool("t", sdk.ToolDef{ + Name: "t", + Cleaner: func(string) string { return "" }, + }, func(map[string]interface{}) (interface{}, error) { return nil, nil }); err != nil { + t.Fatalf("RegisterTool: %v", err) + } + + a := &Agent{stageHost: host} + if got := a.toolOutputForQuery("t", "raw"); got != "raw" { + t.Fatalf("Cleaner 返回空应回退原文,实际 %q", got) + } +} diff --git a/plan.md b/plan.md index 64d90ce..3a9725a 100644 --- a/plan.md +++ b/plan.md @@ -1319,11 +1319,23 @@ SDK 仓 `v1.0.0` / `v1.1.0`。main 的版本路牌现为 `1.2.0`(尚无 tag) 2. StageAfterToolcall 检查当前 tool 的 ContextPolicy 3. prune 时执行 RelevanceContext.Prune 4. 默认 none +5. **prune 的查询向量必须取插件 Cleaner 清洗后的有效内容**(后补) + +**为什么第 5 条是必需的**:Prune 的入参是**相关性查询向量**,它决定保留/归档 +哪些上下文事件。刚上线时直接传原始 result,于是 ANSI 转义、base64、JSON 包装 +等噪声全被编进查询向量,打分失真、裁掉本该保留的事件。 +而 ToolDef.Cleaner 的契约本就写着「仅在向量化/jieba/蒸馏时调用」——裁剪正是 +在向量化,所以这是回归契约,不是新功能。 +回退规则:Cleaner 未注册 / RPC 失败 / 返回空串,都回退原文(返回空串会让查询 +向量退化成零向量,所有事件相关性相同,等于随机裁)。 **验证**: - [x] qq_get_message 加 prune 后上下文精简(QQ 插件已声明 `ContextPolicy: "prune"`) - [x] git commit -m "feat(ctx): context policy for tool results"(772a494) +- [x] `TestToolOutputForQueryAppliesCleaner`:Cleaner 被调用且用其结果; + 无 Cleaner / nil stageHost 均回退原文 +- [x] `TestToolOutputForQueryEmptyCleanFallsBack`:空串回退(防零向量) ### 13.9 llmsproxy 上下文溢出感知 @@ -1411,10 +1423,16 @@ settings.*、lifecycle.*、arena.alloc/free 自身)不属于此列:它们不 `attachments_ref`,模板序列化后 `putValueInArena`。 4. ✅ ~~`knowledge.add(name, content)`~~ —— 已修。新增 `content_ref` (内容是 JSON 字符串,读出后需再解一层)。 -5. **反向结果**:插件反向调内核读大结果时仍内联(正向已有 `ResultRef`)。 - 这条范围比前四条大:需要把整个内核→插件的 Rust 应答路径改成 - “大结果写段 + 返回 ref”,涉及 `callCore` 的返回处理与所有读大结果的 - method(`doc.query` / `llm.chat` 等)。未做。 +5. **反向结果:不做(已核实为低价值)**。 + 原以为涉及 `doc.query` / `llm.chat` 等返回大结果的 method。核实后: + - **根本不存在 `llm.chat`**——`llm.*` 只映射 listSources/setSource/ + currentSource(切换 LLM 源),外部插件无法调 LLM。 + - 唯一可能返回大结果的是 `doc.query`(`CapDocMemory`),而**没有任何 + 外部插件用它**(全部 example 扫描:只有 recoverydiag 用了 + `Knowledge().Add`)。 + - `withheldCapabilities` 表已明确列出「刻意不给外部插件」的一批内核机制。 + 结论:它优化的是一条外部插件几乎不用、且已被能力门限制的路径, + 投入产出不成立。**不做**,而不是留成永久 TODO。 **协议版本已 bump 到 2**(§13.6/§13.13 的 payload 承载变更)。 不再靠文档提醒,而是让错配在握手上**显式失败**: