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]