mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 背景 仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑), gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。 但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源 不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。 ## 关键区分(这是本次改动安全的前提) 两个 module 是**不同**的,影响面差一个数量级: | module | 谁依赖 | 改它的后果 | |---|---|---| | `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 | | `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 | ★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0), 只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串** (`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。 ⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。 SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配, 否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。 ## 改动 - `go.mod` / `go.sum`:两个 module 路径 + require/replace - 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径 - SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板 - 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等) ## 明确**保留** gitcode 的地方(镜像机制,不能动) - `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job) - `gitcode.com/api/v5/...`(release 附件上传) - git remote `gitcode`(镜像拉取/推送) - `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维) ## 验证 - `go build ./...` / `go vet ./...` 绿 - `go test ./... -count=1` → 48 包 ok / 0 FAIL - hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`) - 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0 - 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好 ## 顺带修正 `hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」—— module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**: 离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
402 lines
15 KiB
Go
402 lines
15 KiB
Go
package core
|
||
|
||
import (
|
||
"log"
|
||
"strconv"
|
||
"time"
|
||
|
||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||
)
|
||
|
||
// sceneKeysFor 推导本轮输入的**当前场景**。
|
||
//
|
||
// 场景是“这场面正在发生”的机器可读描述,用于把带条件的记忆(规则/约定)
|
||
// 取回来。优先级:
|
||
// 1. 注入点显式声明(payload.scene)——插件最清楚自己在什么场面里
|
||
// 2. 通道(evt.Source → chan:qq)
|
||
// 3. 工具(tool:qq_get_message)——工具输出触发的召回只知道这一步
|
||
//
|
||
// 多个场景是**并列命中**(取回任一场景的记忆),不是交集:
|
||
// 「在 QQ 上」与「刚取回消息正文」是两个都能独立成立的触发条件。
|
||
func (a *Agent) sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string {
|
||
var keys []string
|
||
// 通道/注入点声明不参与场面识别时,**连派生场景键也不给**。
|
||
// 只停掉指纹采集而留着声明路,等于给「不参与场面」这个口子开了后门。
|
||
if a.sceneSuppressed(evt) {
|
||
return nil
|
||
}
|
||
seen := make(map[string]bool)
|
||
add := func(k string) {
|
||
// 显式声明的场景键来自插件,大小写/空白/标点都不可控;归一化后再去重,
|
||
// 否则「chan:QQ」与「chan:qq」会变成两个场景,各自只召回一半记忆。
|
||
k = memory.NormalizeSceneKey(k)
|
||
if k == "" || seen[k] {
|
||
return
|
||
}
|
||
seen[k] = true
|
||
keys = append(keys, k)
|
||
}
|
||
|
||
if evt != nil && evt.Payload != nil {
|
||
switch v := evt.Payload["scene"].(type) {
|
||
case string:
|
||
add(v)
|
||
case []string:
|
||
for _, s := range v {
|
||
add(s)
|
||
}
|
||
case []interface{}:
|
||
for _, item := range v {
|
||
if s, ok := item.(string); ok {
|
||
add(s)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if evt != nil {
|
||
add(memory.ChannelScene(evt.Source))
|
||
}
|
||
if toolName != "" {
|
||
add(memory.ToolScene(toolName))
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// mergeSceneKeys 把「声明路」与「涌现场景」两路合并成一个**无重复**的场景集合。
|
||
//
|
||
// 为什么需要它:两路各自都去重过(sceneKeysFor 内部有 seen、resolveTurnScenes
|
||
// 内部也有),但**两路之间**没有共同的 seen。而声明路与通道派生路会产出
|
||
// 同一个键(chan:qq 既是声明的、也是从 evt.Source 派生的)——现网日志实测到
|
||
// `scenes=[chan:qq chan:qq]`。
|
||
//
|
||
// 功能上 RecallByScene 内部会再去重,所以这不是 bug,但有两个实际代价:
|
||
// 日志里的 scenes=[...] 会误导排查;每次白走一遍前缀匹配。
|
||
func mergeSceneKeys(declared, emergent []string) []string {
|
||
out := make([]string, 0, len(declared)+len(emergent))
|
||
seen := make(map[string]bool, len(declared)+len(emergent))
|
||
for _, k := range declared {
|
||
if k == "" || seen[k] {
|
||
continue
|
||
}
|
||
seen[k] = true
|
||
out = append(out, k)
|
||
}
|
||
for _, k := range emergent {
|
||
if k == "" || seen[k] {
|
||
continue
|
||
}
|
||
seen[k] = true
|
||
out = append(out, k)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// memoryPassOut 是一次记忆操作(取进来 / 踢出去)的结果。
|
||
type memoryPassOut struct {
|
||
// Archived 是被归档进文档记忆的低相关 L0 事件数(prune 的输出)。
|
||
Archived int
|
||
// RecallText 是可注入 prompt 的记忆索引文本(recall 的输出,空串表示无)。
|
||
RecallText string
|
||
}
|
||
|
||
// memoryPass 是「取进来(召回)」与「踢出去(裁剪)」的**唯一入口**。
|
||
//
|
||
// prune 与 recall 是两根正交的声明轴(默认值刻意相反:裁剪是破坏性的、
|
||
// 默认关;召回是只读增量、默认开),但两者都建立在**同一份清洗后的 query**
|
||
// 之上。调用点只负责解析声明,这里统一做三件各写一遍就会写歪的事:
|
||
//
|
||
// 1. 同一 query:prune 与 recall 用同一个查询向量来源,避免「裁错事件、
|
||
// 召回错记忆」——原始内容里的 ANSI/base64/JSON 噪声会把相关性打分带偏。
|
||
// 2. 同一次预算:召回文本按 token 预算截断只做一次(见 recallText)。
|
||
// 3. 同一条审计:谁(trigger)据什么触发了哪种操作都落一条日志,
|
||
// 否则又是一个「幕后发生、查不出是谁」的机制(对齐 prune 的设计初衷)。
|
||
//
|
||
// 边界:prune 与 recall 目前仍走**各自的相关性空间**(prune 用 L0 事件的
|
||
// 稠密/词向量给已有事件打分,recall 用图 + TF-IDF 实体索引)。真正的
|
||
// 「一次打分」要先统一打分空间(后续步骤);这里统一的是**入口、query、
|
||
// 预算与审计**——这已是「一个过程」的可审计外壳,剩下的差在打分空间。
|
||
func (a *Agent) memoryPass(query, trigger string, prune, recall bool, scenes []string) memoryPassOut {
|
||
var out memoryPassOut
|
||
if a == nil || (!prune && !recall) {
|
||
return out
|
||
}
|
||
if prune {
|
||
out.Archived = a.pruneByQuery(query)
|
||
}
|
||
if recall && query != "" {
|
||
out.RecallText = a.recallTextFor(query, trigger, scenes)
|
||
}
|
||
if out.Archived > 0 || out.RecallText != "" {
|
||
log.Printf("[agent] memory pass (%s): archived=%d recalled=%d chars",
|
||
trigger, out.Archived, len(out.RecallText))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// pruneByQuery 按相关性把低相关 L0 事件归档进文档记忆,返回归档数。
|
||
//
|
||
// **不做声明判定**——声明已由调用方(pruneOnInput / stepToolAfter)解析,
|
||
// 这里只负责执行。放在 memoryPass 内部是为了让裁剪与召回共享入口。
|
||
func (a *Agent) pruneByQuery(query string) int {
|
||
if a == nil || a.context == nil {
|
||
return 0
|
||
}
|
||
// **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文,
|
||
// 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。
|
||
if a.isLightKernel() {
|
||
return 0
|
||
}
|
||
topK := a.contextTopK()
|
||
return a.context.PruneWithProtected(query, topK, a.docStore, a.effectiveProtectedCount())
|
||
}
|
||
|
||
// contextTopK 推导裁剪后应保留的**条数**。
|
||
//
|
||
// ★ 为什么不能直接用 maxContextSize(2026-10-01 跑分实测出的根因):
|
||
// max_context_size 是**条数**、与窗口 token 毫无换算关系。实测 50k 窗口下
|
||
// prompt 峰值达窗口 7 倍、每轮都在「超限→修剪→再超限」——容量 30 条对上
|
||
// 实际占用毫无约束力。而只调 context_window 无效:改窗口不会改这30。
|
||
//
|
||
// 正确做法:用已按窗口算好的 ContextTokens 预算**反推条数**。
|
||
// 一个事件平均占多少 token 由实际数据决定,不用常量猜:
|
||
// 取本 agent 当前积累的实际平均(accumulatedTokens / 事件数),
|
||
// 至少给 1 个 token 的下限(极短事件不会让除零或把预算除成 0)。
|
||
//
|
||
// max_context_size 仍作为**上限**保留:它是运维给的「活跃条数」硬约束,
|
||
// 预算算出来的条数超过它时以它为准(否则改了配置不生效会让人困惑)。
|
||
func (a *Agent) contextTopK() int {
|
||
hardCap := a.maxContextSize - 1 // 运维配置的上限(历史语义:-1 因为要留 protected)
|
||
if hardCap < 1 {
|
||
hardCap = 1
|
||
}
|
||
|
||
budget := a.computeTokenBudget()
|
||
tokens := budget.ContextTokens
|
||
if tokens <= 0 {
|
||
return hardCap
|
||
}
|
||
|
||
// 平均事件 token:优先用实际积累,没有积累时退回保守值。
|
||
avg := 0
|
||
if a.context != nil {
|
||
if n := a.context.Len(); n > 0 {
|
||
avg = a.accumulatedTokens() / n
|
||
}
|
||
}
|
||
if avg <= 0 {
|
||
avg = defaultAvgEventTokens
|
||
}
|
||
|
||
topK := tokens / avg
|
||
if topK < 1 {
|
||
topK = 1
|
||
}
|
||
if topK > hardCap {
|
||
topK = hardCap
|
||
}
|
||
// ★ 下限必须是 protectedCount+1,否则裁剪会**无效**:Prune 里
|
||
// keepCount = topK - len(protected),若 topK <= protected 则 keep 为空、
|
||
// 全部事件被归档,裁完一轮上下文还是超页(实测 topK 被算成 1 时)。
|
||
//
|
||
// 什么时候会算成 1:单条事件就超过整个 ContextTokens 预算(实测 v4 的
|
||
// 工具大回执型事件平均 48000 token,而预算只有 40000)。此时正确的做法
|
||
// 不是「只留 1 条」(等于清空记忆),而是至少留够 protected 条 ——
|
||
// 宁可暂时超页,等 budget 或事件尺寸回到正常区间再裁。
|
||
if min := a.effectiveProtectedCount() + 1; topK < min {
|
||
topK = min
|
||
}
|
||
if topK > hardCap {
|
||
topK = hardCap
|
||
}
|
||
return topK
|
||
}
|
||
|
||
// protectedContextCount 返回当前保护条数(最近 N 条永不换出)。
|
||
func (a *Agent) protectedContextCount() int {
|
||
if a == nil || a.context == nil {
|
||
return defaultProtectedCount
|
||
}
|
||
if n := a.context.protectedCount; n > 0 {
|
||
return n
|
||
}
|
||
return defaultProtectedCount
|
||
}
|
||
|
||
// defaultAvgEventTokens 是「没有积累样本时」用于反推的事件平均 token。
|
||
//
|
||
// 取值依据:一条运维叙事事件通常 1500-3000 token(含工具回灌),
|
||
// 保守取 2000 —— 偏大意味着预算算出的条数偏少、裁得更狠,
|
||
// 宁可多裁也不要让积累量重新涨过窗口。
|
||
const defaultAvgEventTokens = 2000
|
||
|
||
// ──────────────────────────────────────────────
|
||
// 场面指纹:场景**涌现**的原料
|
||
//
|
||
// 场景不是谁声明的,而是从交互流里长出来的。长出来的原料就是每轮可观察的
|
||
// 场面指纹——在哪个通道、跟谁、在做什么、聊什么、什么时段。全部取自运行时
|
||
// 已有量,不需要模型配合,也不需要人工标注。
|
||
// ──────────────────────────────────────────────
|
||
|
||
// sceneSuppressed 报告本次输入是否被声明为**不参与场面识别**。
|
||
//
|
||
// 读取面与其它记忆声明完全一致:先看注入点 payload(单次覆盖),
|
||
// 再看通道定义(ChannelDef.ScenePolicy),都没声明 = 参与(保持既有行为)。
|
||
// 优先级与 pruneDeclared / recallDeclared 同构。
|
||
//
|
||
// 为什么要一个显式开关:场面指纹只要 evt.Source != "" 就无条件产出一个 chan
|
||
// 特征,于是内核自循环(system)、心跳(timer)、内部状态汇报(kernel)这类
|
||
// **纯信噪通道**也在撑场面——它们每次触发都让一个不相干的场景长出来或变强,
|
||
// 而召回时又会把「内核在跑定时器」当成「用户在这类场景下说过的话」取回。
|
||
func (a *Agent) sceneSuppressed(evt *agentIO.InputEvent) bool {
|
||
if evt == nil {
|
||
return false
|
||
}
|
||
if p, ok := evt.Payload["scene_policy"].(string); ok && p != "" {
|
||
return p == pubsdk.ScenePolicyNone
|
||
}
|
||
if a.io != nil {
|
||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.ScenePolicy != "" {
|
||
return chDef.ScenePolicy == pubsdk.ScenePolicyNone
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// sceneFeaturesFor 采集一轮交互的场面指纹。
|
||
//
|
||
// 特征权重由种类决定(见 memory.SituationFeature.Weight):通道与对象是
|
||
// 「同一个场面」最强的同一性信号,工具是行为信号,话题是软信号。
|
||
func (a *Agent) situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature {
|
||
// 声明不参与场面识别:连时段特征都不产——一个不参与的面孔
|
||
// 不该在 situation_evidence / scene_features 里留下任何足迹。
|
||
if a.sceneSuppressed(evt) {
|
||
return nil
|
||
}
|
||
var feats []memory.SituationFeature
|
||
if evt != nil {
|
||
if evt.Source != "" {
|
||
feats = append(feats, memory.SituationFeature{Kind: "chan", Value: evt.Source})
|
||
}
|
||
// 对话对象:插件在 payload 里给的群/用户标识(有则用,无则退化为仅有通道)
|
||
for _, k := range []string{"peer", "peer_id", "group_id", "user_id", "chat_id"} {
|
||
if v, ok := evt.Payload[k]; ok {
|
||
if s := payloadString(v); s != "" {
|
||
// 群与私聊要能区分:同一 id 在两种场景下不是同一个对象
|
||
kind := "peer"
|
||
if k == "group_id" {
|
||
kind = "peer_group"
|
||
}
|
||
feats = append(feats, memory.SituationFeature{Kind: kind, Value: s})
|
||
break
|
||
}
|
||
}
|
||
}
|
||
// 时段:弱信号。人的记忆确实带时间气味(「早上那件事」),
|
||
// 但它不该主导场面判定,所以权重最低。
|
||
feats = append(feats, memory.SituationFeature{Kind: "part", Value: partOfDay(time.Now())})
|
||
}
|
||
if tool != "" {
|
||
feats = append(feats, memory.SituationFeature{Kind: "tool", Value: tool})
|
||
}
|
||
// 话题:取清洗后输入的内容词做软特征(最多 3 个)。
|
||
if cleanInput != "" {
|
||
for i, kw := range memory.ExtractKeywords(memory.CleanText(cleanInput)) {
|
||
if i >= 3 {
|
||
break
|
||
}
|
||
feats = append(feats, memory.SituationFeature{Kind: "topic", Value: kw})
|
||
}
|
||
}
|
||
return feats
|
||
}
|
||
|
||
// payloadString 从 payload 值里取字符串(可能是 string / float64 / json.Number)。
|
||
func payloadString(v interface{}) string {
|
||
switch t := v.(type) {
|
||
case string:
|
||
return t
|
||
case float64:
|
||
if t == float64(int64(t)) {
|
||
return strconv.FormatInt(int64(t), 10)
|
||
}
|
||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||
case int64:
|
||
return strconv.FormatInt(t, 10)
|
||
case int:
|
||
return strconv.Itoa(t)
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// partOfDay 把时刻归成时段(场面指纹里最弱的一维)。
|
||
func partOfDay(t time.Time) string {
|
||
switch h := t.Hour(); {
|
||
case h < 6:
|
||
return "night"
|
||
case h < 12:
|
||
return "morning"
|
||
case h < 18:
|
||
return "afternoon"
|
||
default:
|
||
return "evening"
|
||
}
|
||
}
|
||
|
||
// resolveTurnScenes 解析本轮的场景集合,**同时走主动与被动两条路**:
|
||
//
|
||
// 主动(声明):注入点/通道/工具声明了"这是哪个场面" → 场景存在化并喂入
|
||
// 本轮指纹(声明场景因此慢慢学会自己认自己)
|
||
// 被动(涌现):场面指纹聚类 → 同类指纹重复出现时自己长出场景
|
||
//
|
||
// 返回结果的 Primary 用于**写**(优先细粒度的涌现场景,首次交互退到声明场景
|
||
// 兜底),Keys 用于**读**(两条路的并集,去重)。
|
||
//
|
||
// 解析会**写库**(场景强化/长出),所以必须一轮一次:多调一次就多给场景记
|
||
// 一次强度,"工具调得多"会被误读成"这个场面更常出现"。
|
||
func (a *Agent) resolveTurnScenes(f *TaskFrame, tool string) memory.TurnScene {
|
||
var out memory.TurnScene
|
||
if a == nil || a.memory == nil {
|
||
return out
|
||
}
|
||
if f != nil && f.sceneDone {
|
||
return f.turnScene
|
||
}
|
||
|
||
declared := a.sceneKeysFor(evtOf(f), tool)
|
||
feats := a.situationFeaturesFor(evtOf(f), cleanInputOf(f), tool)
|
||
sig := memory.NewSituation(feats...)
|
||
|
||
turn, err := a.memory.EnterSceneWithHint(sig, declared)
|
||
if err != nil {
|
||
log.Printf("[agent] scene enter failed: %v", err)
|
||
// 出错时至少把声明场景交给召回,不让整条召回链一起失效
|
||
turn = memory.TurnScene{Keys: declared}
|
||
if len(declared) > 0 {
|
||
turn.Primary = declared[0]
|
||
}
|
||
}
|
||
if turn.Emergent {
|
||
log.Printf("[agent] 场景涌现/命中: %q(指纹 %v)", turn.Primary, sig.Keys())
|
||
} else if len(turn.DeclaredCreated) > 0 {
|
||
log.Printf("[agent] 声明场景成立: %v(指纹 %v)", turn.DeclaredCreated, sig.Keys())
|
||
}
|
||
if f != nil {
|
||
f.turnScene = turn
|
||
f.Scene = turn.Primary
|
||
f.sceneDone = true
|
||
}
|
||
return turn
|
||
}
|
||
|
||
// cleanInputOf 安全取出清洗后输入(f 为 nil 时为空)。
|
||
func cleanInputOf(f *TaskFrame) string {
|
||
if f == nil {
|
||
return ""
|
||
}
|
||
return f.CleanInput
|
||
}
|