From aba17707014c63c0f5e7d9a21b35c049381adf38 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 10:36:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(lightkernel):=20=E4=BC=A0=E7=BB=9F=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E8=90=BD=E5=88=B0=E5=AE=9E=E5=A4=84=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=AD=90=E4=B8=8D=E5=81=9A=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E6=80=A7=E8=A3=81=E5=89=AA=EF=BC=88=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E6=8E=89=E8=A3=81=E5=89=AA=E4=BC=B0=E7=AE=97=E7=9A=84=E5=AD=97?= =?UTF-8?q?=E8=8A=82/rune=20=E5=8D=95=E4=BD=8D=E6=B7=B7=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户指出实现不自洽:"子 agent 是传统上下文,没有裁剪"。查证属实:子仍走 `formatMergedTimeline` 的预算裁剪、也可能走 `pruneOnInput`(既裁剪又向 doc 记忆归档)。 即"动态上下文"(父专属能力)漏进了轻量内核。 ## 三处闸门(按 isLightKernel() 判) 1. **拼装预算**:新增 `contextTokenBudget(b)` —— 父用动态上下文的 `ContextTokens`, 子用**整个窗口** `MaxContext`。两个调用点都改(`stepPrepare`、`rebaseFramePrefix`; 漏掉后者时 prepare 之后的重建仍会裁,实测就是这么被抓出来的)。 2. **pruneOnInput**:轻量内核前置返回 —— 不做按相关度裁剪、不向 doc 记忆归档。 3. doc 记忆 / 整理流水线:早已由 `a.memory == nil` 关闭(N2c)。 ## "不裁"的准确含义 不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个硬上限约束; 且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent ⇒ **丢事件的决定权在父**(压缩/回收/销毁),不在内核。文档 §16.0.0 记录三处闸门表。 ## 顺带修掉的既有 bug(单位混用) `formatMergedTimeline` 逐事件估算原来是 `len(e.Source)+len(e.Input)+40` 再 ×2 —— `len()` 是**字节**,而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍: 实测 2384 字的中文事件被估成 14398 token > 8192,于是窗口还有余量也提前 break、 把更早的事件整段丢掉。改为统一走 `EstimateTokens`(对父同样生效:中文长会话不再被过早裁剪)。 ## 测试 - 新增 `TestLightKernel_TraditionalContextNoTrimming`:用**记录模型实收消息**的 provider 断言"更早的事件仍在"(为什么不看 TaskFrame:`prepareInputTask` 只做前半段, 消息在 `runTaskSteps`/`stepPrepare` 才拼出来 —— 我第一版断言就打在了空帧上); 填充量取"超过动态份额、但仍在窗口内",从而能区分父/子两种行为。 - 新增 `TestFullKernel_StillUsesDynamicContext` 作对照(父仍用动态份额)。 全量 go test ./... 37 包 ok / 0 FAIL;-race 干净。 --- docs/zh/resident-subagent-design.md | 19 ++++++ internal/agent/core/eventloop.go | 5 ++ internal/agent/core/memoryface.go | 23 +++++++ internal/agent/core/process.go | 8 ++- internal/agent/core/resident_test.go | 95 ++++++++++++++++++++++++++++ internal/agent/core/task.go | 4 +- 6 files changed, 149 insertions(+), 5 deletions(-) diff --git a/docs/zh/resident-subagent-design.md b/docs/zh/resident-subagent-design.md index 0e6cd2d..c0da8a0 100644 --- a/docs/zh/resident-subagent-design.md +++ b/docs/zh/resident-subagent-design.md @@ -456,6 +456,25 @@ type GraphMemory interface { - 轻量 profile 另外不接线的装配:doc 记忆 / context 动态上下文(`pruneOnInput` 等)/ 蒸馏 / 归档 / 关系复审 / 实体合并定时器 / consolidation / 人格门禁。 +### 16.0.0 传统上下文的实现口径(子 vs 父) + +"传统上下文"不是一句口号,它对应三处**代码闸门**(都按 `isLightKernel()` 判): + +| 能力 | 父(完整内核) | 子(轻量内核) | 闸门位置 | +|---|---|---|---| +| 时间线拼装预算 | `budget.ContextTokens`(动态上下文算出的份额,≈窗口 32%~53%) | **整个窗口** `budget.MaxContext` | `contextTokenBudget()`(`stepPrepare` 与 `rebaseFramePrefix` 两处) | +| 按相关度裁剪 + 向 doc 记忆归档 | 通道/注入点声明 `context_policy=prune` 时执行 | **不执行** | `pruneOnInput()` 前置返回 | +| doc 记忆 / 记忆整理流水线 | 有 | 无(`a.memory == nil` ⇒ 既有 22 处关卡自动关闭) | `memoryface.go` / `tooldefs.go` | + +**"不裁"的准确含义**:不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个 +**硬上限**约束;而且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent —— +**丢事件的决定权在父,不在内核**(父可压缩/回收/销毁)。 + +**顺带修掉的既有 bug**:`formatMergedTimeline` 逐事件估算原用 `len()`(**字节**)再 ×2, +而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍,窗口还有余量也提前 break、 +把更早事件整段丢掉(实测 2384 字中文事件被估成 14398 token > 8192)。已改为统一的 +`EstimateTokens`。这条 bug 对父同样有效(中文长会话会被过早裁剪)。 + ### 16.0.1 工具面(已实现) | 工具 | 谁用 | 作用 | diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index 26e1548..7579a76 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -370,6 +370,11 @@ func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int { if a.context == nil || !a.pruneDeclared(evt) { return 0 } + // **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文, + // 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。 + if a.isLightKernel() { + return 0 + } topK := a.maxContextSize - 1 if topK < 1 { topK = 1 diff --git a/internal/agent/core/memoryface.go b/internal/agent/core/memoryface.go index 6094a3f..022e0db 100644 --- a/internal/agent/core/memoryface.go +++ b/internal/agent/core/memoryface.go @@ -20,6 +20,29 @@ package core import "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +// isLightKernel 报告本 agent 是不是**轻量内核**(驻留子)。 +// +// 轻量内核 = 传统上下文 + 图记忆(作用域化):它**没有**动态上下文能力 +// (预算裁剪 / 按相关度裁剪并向 doc 记忆归档),那些是父 agent 专属的。 +func (a *Agent) isLightKernel() bool { return a != nil && a.parentID != "" } + +// contextTokenBudget 返回拼装时间线时可用的 token 预算。 +// +// 完整内核:用动态上下文算出来的 ContextTokens(按相关度/预算**策略性**裁时间线) +// 轻量内核:**用整个窗口** —— 传统上下文只受"模型能收多少"这个**硬上限**约束, +// 不做任何策略性裁剪(不按相关度挑、不向 doc 记忆归档); +// 而且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent +// 决策(压缩/回收/销毁)—— 丢事件的决定权在父,不在内核。 +func (a *Agent) contextTokenBudget(b TokenBudget) int { + if a.isLightKernel() { + if b.MaxContext > 0 { + return b.MaxContext + } + return defaultMaxContextTokens + } + return b.ContextTokens +} + // GraphMemory 是任意 agent 都能用的图记忆面。 // // 实现者: diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 267498c..b78f929 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -331,11 +331,13 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string { include := 0 for i := len(events) - 1; i >= 0; i-- { e := events[i] - est := len(e.Source) + len(e.Input) + 40 + // ❗单位必须与 EstimateTokens 一致(rune×2)。这里曾用 `len()`(**字节**)再 ×2: + // CJK 一字 3 字节 ⇒ 中文事件被高估 3 倍,窗口还有余量也会提前 break, + // 把更早的事件整段丢掉(实测:2384 字的中文事件被估成 14398 token > 8192)。 + estTokens := EstimateTokens(e.Source) + EstimateTokens(e.Input) + 40 if e.Response != "" { - est += 120 + estTokens += 120 } - estTokens := est * 2 if remaining-estTokens < 0 && include > 0 { break } diff --git a/internal/agent/core/resident_test.go b/internal/agent/core/resident_test.go index ad6c505..a4219b5 100644 --- a/internal/agent/core/resident_test.go +++ b/internal/agent/core/resident_test.go @@ -5,11 +5,13 @@ package core // 设计 docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。 import ( + "context" "fmt" "os" "path/filepath" "strconv" "strings" + "sync" "testing" "time" @@ -377,3 +379,96 @@ func TestResident_E2EAndStress(t *testing.T) { t.Logf("压力通过:%d 个驻留子 × %d 轮(父→子 L4 与普通输入各半)+ 双向汇报", nResidents, roundsEach) } + +// ---- 传统上下文:轻量内核不做动态上下文的裁剪 ---- + +// captureProvider 记录模型**实际收到**的消息。 +// +// 为什么不直接看 TaskFrame:`prepareInputTask` 只做前半段(去重/通道/阶段/落上下文), +// 消息是在 `runTaskSteps` 的 stepPrepare 里才拼出来的;而且断言"模型看到了什么" +// 本来就比断言内核内部字段更接近事实。 +type captureProvider struct { + countingProvider + mu sync.Mutex + calls int + messages []agentAPI.Message +} + +func (p *captureProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) { + p.mu.Lock() + p.calls++ + if req != nil { + p.messages = append([]agentAPI.Message(nil), req.Messages...) + } + p.mu.Unlock() + return &agentAPI.CompletionResponse{Content: "ok"}, nil +} + +func (p *captureProvider) chatText() (int, string) { + p.mu.Lock() + defer p.mu.Unlock() + var b strings.Builder + for _, m := range p.messages { + b.WriteString(m.Content) + b.WriteString("\n") + } + return p.calls, b.String() +} + +// 子 agent 的上下文是**传统上下文**:累积的事件全部交给模型, +// 内核**不得**按动态上下文的预算静默丢弃(那是父 agent 的能力)。 +// 装不下时由 contextfull 上报父决策,而不是自己丢。 +func TestLightKernel_TraditionalContextNoTrimming(t *testing.T) { + provider := &captureProvider{} + parent, _, dir := newRootWith(t, provider) + spawnTestResident(t, parent, dir, "child-1") + child := parent.residents["child-1"].agent + + if !child.isLightKernel() { + t.Fatal("驻留子必须被识别为轻量内核") + } + + // 前提:动态上下文的份额 < 窗口(否则测不出区别)。 + b := ComputeTokenBudget(child.provider, child.systemPrompt) + if b.MaxContext <= b.ContextTokens { + t.Fatalf("前提不成立:窗口(%d) 应大于动态上下文份额(%d)", b.MaxContext, b.ContextTokens) + } + // 填充量:**超过动态份额、但仍在窗口内**。 + // ⇒ 完整内核会因预算把最早那条裁掉;轻量内核不该裁(只受窗口硬上限约束)。 + filler := strings.Repeat("填", b.ContextTokens/2+200) + if EstimateTokens(filler) <= b.ContextTokens { + t.Fatalf("测试前提不成立:填充(%d token) 应超过动态份额(%d)", EstimateTokens(filler), b.ContextTokens) + } + child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: "最早的事件标记EARLY"}) + child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: filler}) + child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: "最新的事件标记LATE"}) + + child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "本轮输入"}) + + waitFor(t, "子发出 LLM 请求", func() bool { + n, _ := provider.chatText() + return n >= 1 + }) + _, got := provider.chatText() + if !strings.Contains(got, "最早的事件标记EARLY") { + t.Fatalf("传统上下文:更早的事件不得被预算裁掉(属于父 agent 的动态上下文能力);实收消息长度=%d", len(got)) + } + if !strings.Contains(got, "最新的事件标记LATE") { + t.Fatal("最新事件必须在内") + } +} + +// 对照:完整内核(根 agent)仍走动态上下文(按预算裁时间线)。 +func TestFullKernel_StillUsesDynamicContext(t *testing.T) { + parent, _, _ := newRootForResidents(t) + if parent.isLightKernel() { + t.Fatal("根 agent 不是轻量内核") + } + b := ComputeTokenBudget(parent.provider, "sys") + if got := parent.contextTokenBudget(b); got != b.ContextTokens { + t.Fatalf("完整内核应使用动态上下文的 ContextTokens(%d),实际 %d", b.ContextTokens, got) + } + if b.MaxContext <= b.ContextTokens { + t.Fatalf("前提不成立:窗口(%d) 应大于动态上下文份额(%d)", b.MaxContext, b.ContextTokens) + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 00e822e..2d7139a 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -273,7 +273,7 @@ func (a *Agent) rebaseFramePrefix(f *TaskFrame) { budget := ComputeTokenBudget(a.provider, a.systemPrompt) memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens) sysPrompt := a.buildSystemPrompt(memContext, f.Input) - prefix := a.buildMessages(sysPrompt, f.Input, budget.ContextTokens) + prefix := a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget)) // 重建会丢掉 prepare 段对尾部消息的两处改写,这里等价地补回。 if f.IsInterrupt && len(prefix) > 0 { @@ -497,7 +497,7 @@ func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome { sysPrompt := a.buildSystemPrompt(memContext, f.Input) f.Tools = a.buildToolDefs() - f.Msgs = a.buildMessages(sysPrompt, f.Input, budget.ContextTokens) + f.Msgs = a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget)) // 工具提醒(interrupt):以 system 角色注入,不让模型误认为用户发言 if a.interruptInput { last := f.Msgs[len(f.Msgs)-1]