From 70526444317de873f3929a9c09ed8b5b2948e144 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 25 Oct 2026 21:39:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20contextoverflow=20=E6=94=B9?= =?UTF-8?q?=E5=9B=9E=E7=A9=BA=E4=B8=AD=E6=96=AD=EF=BC=88=E6=8C=89=E5=8E=9F?= =?UTF-8?q?=E5=A7=8B=E8=AE=BE=E8=AE=A1=EF=BC=89+=20Python=20=E8=A7=A3?= =?UTF-8?q?=E9=87=8A=E5=99=A8=E5=8F=AF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. contextoverflow:L4 空中断(推翻三处偏移) 用户口径:**原始设计是注入 L4 空中断,目的是触发语义相关性裁剪, 不含任何消息,就是一个空中断。**此前的实现有三处偏移: ① **中断带正文** —— `Build` 生成「已裁剪 N 条低相关事件到文档记忆…」, 于是 L4 变成一条要模型回应的消息,白烧一整轮推理。 ② **裁剪在 raise 之前做完** —— 先 `pruneByQuery` 再 raise,中断沦为 「裁剪已完成」的回执,晚了一步。 ③ **上游 contextfull 后原地重跑** —— `StepPrepare` 重建后拿**同一份** 超限内容再发一次,必然再超限,快速烧光 recoverBudget。 (这条是修① ② 时暴露的连带问题。) 改动: · `VectorContextOverflow.Build` 返回空串 ⇒ 空中断;级别仍是 L4 · 删除 `overflowNotice` 与 `vectorArgs.Pruned`(中断不需要知道裁了多少) · `handleContextOverflow` 不再裁剪,只发空中断,如实返回 0 · 新增 `pruneForAirInterrupt`:裁剪由空中断任务**真正执行时**触发, query 取上下文里最后一条真实用户输入作相关性基准 · 新增 `isAirInterrupt`:只认「内核来源 + type=interrupt + 无正文」, 插件带正文的中断不受影响(否则"插件发了个空中断"会变成无声裁剪) · `prepareInputTask` 识别空中断 → 裁剪 → terminalSkipped (不进 LLM、不写上下文、不发回复) · 上游 contextfull 后改为 `outcomeSuspended`(让位等裁剪),不再原地重跑 顺带记录偏移成因(`raiseContextOverflow` 注释):当时的**观察**是对的 (L4 看不到被打断者上下文 ⇒ 模型会把"没检索到"推断成"不存在"), 但**结论**错了——它把"补偿 L4 看不到上下文"当成中断该带正文的理由。 模型需要的不是"知道有内容被移走",而是**留下来的内容与当前问题相关**。 判据(均经变异验证): · `TestVectors_OverflowIsAirInterrupt` —— 正文必须为空 + 级别 L4 变异:Build 改回非空 ⇒ 红 · `TestOverflow_RaisesAirInterruptWithoutPruning` —— 发中断那步不改上下文 变异:加回 pruneByQuery ⇒ 红 · `TestAirInterrupt_ResolvedAsValidInput` —— 空中断须被认作有效输入 · `TestAirInterrupt_RejectsOrdinaryEmptyInput` —— 普通空包/插件带正文 中断**不得**误判 · 改写 5 条断言"发中断即裁剪"的旧测试(T1/T2/T5/大事件/自适应), 保留它们原本守的东西(超限判定、恢复预算、终止保护), 只把裁剪步骤显式移到 `pruneForAirInterrupt` ## 2. clawhubadapter:Python 解释器与工作目录可配置 真机事故:`bilibili-all-in-one` 声明 requirements 含 httpx,系统 Python 没装 ⇒ 启动即 `ModuleNotFoundError`。而解释器此前是**硬编码猜**的 (先 /usr/bin/python3,再 /usr/local/bin/python3)。 「猜」没有正确解:不同部署的 Python 布局完全不同(发行版 Python、 pyenv、uv、conda、venv…)。而往系统 Python 里 pip install 是**错的**: PEP 668 明确禁止,`--break-system-packages` 会破坏 apt 包一致性。 新增两个配置项(与既有 skills_dir/simulator_dir 同一风格): · `python_bin` —— 解释器绝对路径,最高优先 · `skill_workdir` —— skill 子进程工作目录 回退顺序:配置 → `/.venv/bin/python` → 系统 python3。 **配错必须响亮**:配置的路径不存在时返回 error,不静默退回系统解释器 (否则用户以为配好了,实际跑在缺依赖的 Python 上 —— 又一次静默降级)。 判据:`TestPickPython_ConfiguredWins`(配置优先于 venv)/ `TestPickPython_VenvWhenNotConfigured` / `TestPickPython_MissingConfiguredIsLoud` `launchProcessIn` 是 `launchProcess` 的工作目录变体(不改既有 4 个调用点)。 --- internal/agent/core/eventloop.go | 38 +++++ internal/agent/core/interrupt_vectors.go | 16 +- internal/agent/core/interrupt_vectors_test.go | 34 +++-- internal/agent/core/overflow.go | 144 ++++++++++++------ internal/agent/core/overflow_test.go | 144 ++++++++++++++---- internal/agent/core/task.go | 32 +++- internal/plugins/clawhubadapter/plugin.go | 120 ++++++++++++++- .../plugins/clawhubadapter/python_bin_test.go | 63 ++++++++ internal/plugins/clawhubadapter/sidecar.go | 15 +- 9 files changed, 502 insertions(+), 104 deletions(-) create mode 100644 internal/plugins/clawhubadapter/python_bin_test.go diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index baaa81e1..29dd3ac1 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -182,6 +182,11 @@ type inputPayload struct { mediaType string // captureTool 是媒体落进 CAS 时记录的来源标签。 captureTool string + // airInterrupt 表示这是一次**空中断**:内核发的 L4,没有正文。 + // + // 它的唯一用途是触发一次语义相关性裁剪(contextoverflow 的原始设计)。 + // 空正文的普通输入不是空中断(见 isAirInterrupt)。 + airInterrupt bool } // resolveInput 把 InputEvent 归一成 inputPayload。 @@ -209,6 +214,19 @@ func (a *Agent) resolveInput(evt *agentIO.InputEvent) (inputPayload, bool) { text, _ := evt.Payload["content"].(string) blocks, mediaType := injectedBlocks(evt.Payload) + + // ★ 空中断(kernel/overflow):**没有正文也要算有效输入**。 + // + // 这是 contextoverflow 的原始设计:L4 中断本身不携带任何消息, + // 它唯一的用途是"把当前任务抢占下来,让语义相关性裁剪发生"。 + // 空 content 在这里曾被判成无效输入直接丢弃 —— 于是那个 L4 变成了 + // 一条什么也不做的空转任务。 + // + // 判定条件刻意窄:必须是内核发出的中断,且确实无正文、无媒体。 + // 普通输入的空内容仍然算无效(否则客户端空包会白白唤醒一轮)。 + if text == "" && len(blocks) == 0 && isAirInterrupt(evt) { + return inputPayload{text: "", airInterrupt: true}, true + } // 文本与媒体都空才算无效输入:只带图不带字是合法的(插件注入常这样)。 if text == "" && len(blocks) == 0 { return inputPayload{}, false @@ -558,3 +576,23 @@ func (a *Agent) cleanInputFor(evt *agentIO.InputEvent, input string) string { } return input } + +// isAirInterrupt 判定一个输入事件是否为「空中断」。 +// +// 空中断 = 内核发起、type=interrupt、payload 里没有 content。 +// 用途见 contextoverflow 的中断向量(VectorContextOverflow): +// 它只是一次调度信号,用来把当前任务抢占下来,好在任务边界做语义相关性裁剪。 +// +// 为什么必须由内核来源限定:插件也能注入 interrupt,但插件的中断**总是** +// 带正文("某设备上线了""某任务失败了")。把它们的空包也当空中断, +// 会让"插件发了个没内容的中断"变成一次无声的裁剪,行为不可预测。 +func isAirInterrupt(evt *agentIO.InputEvent) bool { + if evt == nil || evt.Type != "interrupt" { + return false + } + if v, _ := evt.Payload["kernel"].(bool); !v { + return false + } + text, _ := evt.Payload["content"].(string) + return text == "" +} diff --git a/internal/agent/core/interrupt_vectors.go b/internal/agent/core/interrupt_vectors.go index 37841cf9..ffd838fd 100644 --- a/internal/agent/core/interrupt_vectors.go +++ b/internal/agent/core/interrupt_vectors.go @@ -57,8 +57,6 @@ type vectorArgs struct { Task *Task // Panic 是 recover() 取到的值(VectorTaskPanic 使用)。 Panic any - // Pruned 是超页时被裁剪的消息条数(VectorContextOverflow 使用)。 - Pruned int // ChildID 是驻留子标识(VectorChildContextFull 使用)。 ChildID string } @@ -113,7 +111,19 @@ var interruptVectors = map[VectorID]interruptVector{ Level: LevelCritical, Source: func(vectorArgs) string { return "kernel/overflow" }, Guard: func(vectorArgs) bool { return true }, - Build: func(a vectorArgs) string { return overflowNotice(a.Pruned) }, + // ★ 空中断:**不携带任何消息**(2026-10-25 按原始设计修正)。 + // + // 原始设计里这个 L4 的用途是「**触发**语义相关性裁剪」——它本身 + // 只是一次调度信号,不是一条给模型的通知。此前的实现把它变成了 + // 「裁剪完事后发一条回执」(先在 raise 之前 pruneByQuery,再让 + // Build 生成「已裁剪 N 条…」的说明文本),顺序与语义都反了。 + // + // 现在:Build 返回空串 ⇒ payload 里没有 content ⇒ 任务是空中断; + // 裁剪由任务真正被调度执行时(rebalanceFrameContextPrefix)触发。 + // + // 为什么必须带空串而不是省略 Build:lookupVector 的契约要求 Build + // 存在(否则调用方 nil panic),"没有正文"要显式表达成 ""。 + Build: func(vectorArgs) string { return "" }, }, VectorChildContextFull: { diff --git a/internal/agent/core/interrupt_vectors_test.go b/internal/agent/core/interrupt_vectors_test.go index 39599101..f92c8718 100644 --- a/internal/agent/core/interrupt_vectors_test.go +++ b/internal/agent/core/interrupt_vectors_test.go @@ -129,19 +129,33 @@ func TestVectors_PanicSourceComesFromTable(t *testing.T) { } } -// 超页中断的正文必须来自向量表的 Build(含"内容被移走"这一关键语义)。 -func TestVectors_OverflowBodyComesFromTable(t *testing.T) { - v, _ := lookupVector(VectorContextOverflow) +// ★ 超页中断必须是**空中断**:不携带任何正文(2026-10-25 按原始设计修正)。 +// +// 这条判据推翻的是更早的一条(原本断言正文非空、含"已裁剪 N 条"、 +// 含"查不到不等于不存在")。那个写法把 L4 变成了"裁剪已完成"的回执: +// 裁剪在 raise 之前就做完了,中断只剩下通知作用。 +// +// 原始设计:L4 空中断把任务抢占下来 ⇒ 任务边界处做语义相关性裁剪。 +// 中断是**触发源**,裁剪是它引发的动作。带正文会让模型误以为那是 +// 一条需要回应的用户消息(进而多跑一整轮推理)。 +// +// 变异验证:把 Build 改回返回非空文本 ⇒ 本判据必须变红。 +func TestVectors_OverflowIsAirInterrupt(t *testing.T) { + v, ok := lookupVector(VectorContextOverflow) + if !ok { + t.Fatal("超页向量必须已登记") + } if v.Build == nil { - t.Fatal("超页向量必须有 Build") + t.Fatal("超页向量的 Build 不能为 nil(lookupVector 契约要求存在)") } - body := v.Build(vectorArgs{Pruned: 7}) - if strings.TrimSpace(body) == "" { - t.Fatal("超页中断正文不能为空(它是模型唯一的认知输入)") + body := v.Build(vectorArgs{}) + if body != "" { + t.Fatalf("超页中断必须是空中断(正文为空),实际:%q\n"+ + "带正文会让它变成一条要模型回应的消息,进而多烧一整轮推理", body) } - // 语义要求:必须让模型知道"有内容被移走了",否则它会把"查不到"推断成"不存在"。 - if !strings.Contains(body, "7") { - t.Fatalf("超页正文应包含被裁剪的条数 7,实际:%q", body) + // 级别仍必须是 L4:抢占能力来自级别,与"有没有正文"无关。 + if v.Level != LevelCritical { + t.Fatalf("超页中断必须是 L4,实际 level=%v", v.Level) } } diff --git a/internal/agent/core/overflow.go b/internal/agent/core/overflow.go index 2f298131..4367f13a 100644 --- a/internal/agent/core/overflow.go +++ b/internal/agent/core/overflow.go @@ -292,32 +292,20 @@ func (a *Agent) handleContextOverflow(query string, ratio float64) int { return 0 } + // ★ 只发空中断,**不在这里裁剪**(2026-10-25 按原始设计修正)。 + // + // 原设计:L4 空中断把当前任务抢占下来 ⇒ 任务边界处做语义相关性裁剪。 + // 此前这里先 pruneByQuery 再 raise,把中断变成了"裁剪已完成"的回执, + // 顺序反了(见 raiseContextOverflow 里记录的偏移经过)。 + // + // 这里返回 0 是如实的:本轮**还没有**裁剪,裁剪会在空中断任务被 + // 调度执行时发生(pruneForAirInterrupt)。调用方若要判断"有没有腾出 + // 空间",必须看下一轮的水位,而不是这个返回值。 before := a.context.Len() - beforeTokens := a.accumulatedTokens() - // pruned 是**写进 docStore 的条数**(Prune 内部计数),不是「少了几条」。 - // 判据必须看上下文是否真的变小了 —— 因为 docStore == nil 时(轻量内核、 - // 文档记忆未初始化)pruned 恒为 0,但裁剪其实照常发生了。用 pruned 判会 - // 把正常裁剪误判成 bug 而终止。 - pruned := a.pruneByQuery(query) - after := a.context.Len() - afterTokens := a.accumulatedTokens() - - if after >= before || afterTokens >= beforeTokens { - // 超页了却裁不动 ⇒ 判定逻辑有问题(或 topK ≥ 实际条数)。 - // 按用户口径:保存现场 + 明确终止,**不重试不触发中断**。 - // 为什么不再试一次:L4 遇 L4 不能抢占(canPreempt 用严格大于), - // 第二个 L4 只会排队等,永远等不到能执行的时机。 - log.Printf("[agent] context overflow aborted: ratio=%.2f no reduction "+ - "(%d→%d events, %d→%d tokens) — 判定逻辑可能有 bug,保存现场并终止本轮,不重试", - ratio, before, after, beforeTokens, afterTokens) - a.markOverflowAborted(ratio, before) - return 0 - } - pruned = before - after - log.Printf("[agent] context overflow: ratio=%.2f pruned=%d (%d→%d events), raising L4", - ratio, pruned, before, after) - a.raiseContextOverflow(pruned) - return pruned + log.Printf("[agent] context overflow: ratio=%.2f events=%d — raising L4 air interrupt (prune happens at task boundary)", + ratio, before) + a.raiseContextOverflow(0) + return 0 } // markOverflowAborted 记录一次「超页但裁不出东西」的终止,供状态面观测。 @@ -329,35 +317,37 @@ func (a *Agent) markOverflowAborted(ratio float64, events int) { a.overflowStat.Unlock() } -// raiseContextOverflow 通过 **L4 唯一入口**上报上下文超页。 +// raiseContextOverflow 通过 **L4 唯一入口**发起一次上下文超页中断。 // -// 与 panic / selfip 并列,是 L4 的第三个来源。 +// 与 panic / child_contextfull 并列,是 L4 的第三个来源。 // -// 消息内容不是通知而是**必要的认知输入**:L4 任务按调度器设计拿不到被打断 -// 者的上下文(suspend 的 D1=B 注释明确「不把被打断任务的任何内容交给它」), -// 所以「已裁剪了什么、查不到不等于不存在」必须显式写进去。 +// ★ **它是一次空中断:不带任何消息**(2026-10-25 按原始设计修正)。 // -// 这条提示直接对应 v4 实测的一类失败:HA 对查不到的事实给出 -// 「库里根本没有 X,任何数字都是编的」——它不知道有内容被移走, -// 于是把「没检索到」推断成「不存在」。 +// 用途是把当前任务抢占下来,好在任务边界做一次**语义相关性裁剪**—— +// 中断是触发源,裁剪是它引发的动作,两者不是同一步。 +// +// ## 此前为什么被写成了"带文本的通知"(记录一下这个偏移,避免再犯) +// +// 当时的推理是:L4 任务按调度器设计拿不到被打断者的上下文(suspend 的 +// D1=B),所以"已裁剪了什么、查不到不等于不存在"必须显式写进 payload, +// 否则模型会把「没检索到」推断成「不存在」(v4 实测过这类回答)。 +// +// 那个**观察**是真的,但**结论**错了:它把"补偿 L4 看不到上下文"当成了 +// 中断该带正文的理由,于是裁剪被挪到了 raise 之前(先裁完再发一条回执), +// 中断本身退化成一个通知任务——既晚了一步,又给模型塞了它不需要的说明。 +// +// 正确的分工是:中断只负责抢占(空),裁剪由任务真正执行时做 +// (见 pruneForAirInterrupt)。模型不需要知道"有内容被移走"这件事本身, +// 它需要的是**保留下来的内容与当前问题相关**——那由裁剪质量保证。 func (a *Agent) raiseContextOverflow(pruned int) { a.overflowStat.Lock() a.overflowStat.Triggered++ a.overflowStat.LastPruned = pruned a.overflowStat.Unlock() - a.raiseVector(VectorContextOverflow, vectorArgs{Pruned: pruned}) -} - -// overflowNotice 构造超页中断消息。 -// -// 抽成纯函数是为了可测:这条消息的措辞不是装饰,它是 agent 唯一的认知输入 -// (L4 任务按调度器设计拿不到被打断者的上下文)。「查不到不等于不存在」这句 -// 直接对应 v4 实测的一类真实失败 —— HA 对检索不到的事实回答 -// 「库里根本没有 X,任何数字都是编的」,因为它不知道有内容被移走了。 -func overflowNotice(pruned int) string { - return fmt.Sprintf("[内核] 上下文超页:已裁剪 %d 条低相关事件到文档记忆。"+ - "被裁内容仍可检索,但需显式查询;查不到不等于不存在。", pruned) + // 空中断:vectorArgs 不带 Pruned —— 中断不需要知道裁了多少, + // 那是裁剪侧自己的统计(overflowStat),不是中断的输入。 + a.raiseVector(VectorContextOverflow, vectorArgs{}) } // isUpstreamContextFull 判定上游错误是否表示「上下文装不下」。 @@ -380,3 +370,67 @@ func (a *Agent) contextLenSafe() int { } // overflowStat 字段定义在 Agent 上(见 agent.go),此处只做访问辅助。 + +// pruneForAirInterrupt 执行一次因空中断触发的语义相关性裁剪。 +// +// ## 为什么裁剪放在这里(而不是发中断之前) +// +// contextoverflow 的原始设计是:L4 空中断把当前任务抢占下来 ⇒ 任务边界处 +// 做语义相关性裁剪。中断是**触发源**,裁剪是它引发的**动作**。 +// +// 此前实现顺序反了(先 pruneByQuery 再 raise 带文本的中断),于是那个 L4 +// 退化成一个"通知裁剪已发生"的回执任务——既晚了一步(裁剪早已完成), +// 又给模型塞了一条它不需要的说明。 +// +// ## query 取什么 +// +// 空中断本身没有正文,所以拿不到"当前问题"。这里用上下文里**最后一条真实 +// 用户输入的文本**作为相关性基准:裁剪要保留的正是与"刚才在做的事"相关的 +// 内容。取不到就退化为按时间保留(pruneByQuery 内部对空 query 的处理)。 +func (a *Agent) pruneForAirInterrupt() { + if a == nil || a.context == nil || a.isLightKernel() { + return + } + before := a.context.Len() + beforeTokens := a.accumulatedTokens() + + a.pruneByQuery(a.lastUserQueryForPrune()) + + after := a.context.Len() + afterTokens := a.accumulatedTokens() + if after >= before && afterTokens >= beforeTokens { + // 裁不动:记录下来即可。空中断不承担"必须裁成功"的职责—— + // 真正的超限判定仍在 enforceContextBudget,那里有终止语义。 + a.overflowStat.Lock() + a.overflowStat.Aborted++ + a.overflowStat.LastRatio = a.overflowRatioNow() + a.overflowStat.LastEvents = before + a.overflowStat.Unlock() + log.Printf("[agent] air-interrupt prune: no reduction (%d→%d events, %d→%d tokens)", + before, after, beforeTokens, afterTokens) + return + } + a.overflowStat.Lock() + a.overflowStat.Triggered++ + a.overflowStat.LastPruned = before - after + a.overflowStat.Unlock() + log.Printf("[agent] air-interrupt prune: %d→%d events (%d→%d tokens), ratio=%.2f", + before, after, beforeTokens, afterTokens, a.overflowRatioNow()) +} + +// lastUserQueryForPrune 取最后一条非中断用户输入作为裁剪的相关性基准。 +// +// 空中断自己没有正文,但它被触发时"刚才在做什么"是有答案的: +// 上下文里最后一条真实用户输入。取不到返回空串(pruneByQuery 按时间保留)。 +func (a *Agent) lastUserQueryForPrune() string { + if a == nil || a.context == nil { + return "" + } + evts := a.context.Recent(0) // 值为 ContextEvent(非指针),Recent 已做深拷贝 + for i := len(evts) - 1; i >= 0; i-- { + if evts[i].Input != "" { + return evts[i].Input + } + } + return "" +} diff --git a/internal/agent/core/overflow_test.go b/internal/agent/core/overflow_test.go index 10eda3d3..ebdc5425 100644 --- a/internal/agent/core/overflow_test.go +++ b/internal/agent/core/overflow_test.go @@ -55,11 +55,12 @@ func TestOverflow_T1_积累超125触发(t *testing.T) { if !handled { t.Fatal("超限时 maybeHandleContextOverflow 应返回 handled=true") } - if pruned <= 0 { - t.Fatal("应裁剪出至少一条") - } - if after := a.context.Len(); after >= before { - t.Fatalf("裁剪后条数未减少:%d → %d", before, after) + // ★ 空中断设计:发中断这一步**不裁剪**(pruned 如实为 0), + // 裁剪由空中断任务被调度执行时做(pruneForAirInterrupt)。 + // 这条原来断言 pruned>0,那对应的是"先裁剪再发回执"的旧实现。 + _ = pruned + if after := a.context.Len(); after != before { + t.Fatalf("发中断这一步不应改动上下文:%d → %d", before, after) } } @@ -95,10 +96,14 @@ func TestOverflow_T2_裁剪后累积下降(t *testing.T) { before := a.accumulatedTokens() a.maybeHandleContextOverflow(nil, "退款导出") - after := a.accumulatedTokens() + // ★ 空中断设计:裁剪延后到任务边界(pruneForAirInterrupt)。 + // 这里直接调它,验证"裁剪确实能降 token"本身仍成立。 + a.pruneForAirInterrupt() + + after := a.accumulatedTokens() if after >= before { - t.Fatalf("裁剪后积累 token 未下降:%d → %d", before, after) + t.Fatalf("pruneForAirInterrupt 后积累 token 未下降:%d → %d", before, after) } } @@ -130,23 +135,34 @@ func TestOverflow_T4_裁不动则终止不中断(t *testing.T) { } } -// ── T6:中断消息必须带「查不到不等于不存在」── +// ── 超页只发空中断,裁剪留给任务边界 ── +// +// 推翻的旧判据:TestOverflow_T6_中断消息含认知提示(断言正文含 +// "查不到不等于不存在"、"文档记忆")。那段文案连同 overflowNotice 一起删除, +// 理由见 raiseContextOverflow 的注释(把"补偿 L4 看不到上下文"错当成了 +// 中断该带正文的理由)。 +// +// 现在钉住的是:**超页时发出的是空中断**,且裁剪**不在发中断这一步发生**。 +func TestOverflow_RaisesAirInterruptWithoutPruning(t *testing.T) { + a := newOverflowAgent(2000, 2, 20, 400) -func TestOverflow_T6_中断消息含认知提示(t *testing.T) { - got := overflowNotice(17) - if got == "" { - t.Fatal("超页通知不应为空") + before := a.context.Len() + a.handleContextOverflow("", 2.0) + + // ① 中断已发出(Triggered 计数 +1) + a.overflowStat.Lock() + triggered := a.overflowStat.Triggered + a.overflowStat.Unlock() + if triggered == 0 { + t.Fatal("超页必须发出 L4 中断") } - if !strings.Contains(got, "查不到不等于不存在") { - t.Fatalf("中断消息缺少认知提示,agent 会把「没检索到」误判成「不存在」:%q", got) - } - if !strings.Contains(got, "文档记忆") { - t.Fatalf("中断消息应说明被裁内容去了哪里:%q", got) - } - // 来源标识在 raiseKernelInterrupt 的参数里("kernel/overflow"), - // 不在消息正文 —— 那部分留给可读性。这里只保证提示词自洽。 - if strings.Contains(got, "查不到等于存在") { - t.Fatalf("提示词语义写反了:%q", got) + // ② 裁剪**没有**在这一步发生:上下文条数不变 + // + // 这是空中断设计的核心——裁剪由空中断任务被调度执行时触发 + // (pruneForAirInterrupt),不是 raise 的副作用。 + // 变异验证:在 handleContextOverflow 里加回 pruneByQuery ⇒ 本判据变红。 + if got := a.context.Len(); got != before { + t.Fatalf("发中断这一步不应裁剪上下文:之前 %d 条,之后 %d 条", before, got) } } @@ -236,10 +252,10 @@ func TestOverflow_T5_恢复预算耗尽则终止(t *testing.T) { a := newOverflowAgent(4000, 30, 20, 800) f := &TaskFrame{} - // 第一次超页:正常裁剪 - handled, pruned := a.maybeHandleContextOverflow(f, "退款导出") - if !handled || pruned <= 0 { - t.Fatalf("首次应正常裁剪:handled=%v pruned=%d", handled, pruned) + // 第一次超页:发出空中断(裁剪延后,pruned 如实为 0) + handled, _ := a.maybeHandleContextOverflow(f, "退款导出") + if !handled { + t.Fatalf("首次应处理并发出空中断:handled=%v", handled) } if f.OverflowRecover != 1 { t.Fatalf("首次应记一次恢复,实际 %d", f.OverflowRecover) @@ -438,10 +454,16 @@ func TestOverflow_大事件时裁剪仍有效(t *testing.T) { }) } before := a.context.Len() - pruned := a.handleContextOverflow("退款导出", 10.0) + // ★ 空中断设计:handleContextOverflow 只发空中断,裁剪在任务边界做。 + // 这里先发中断(验证它不改上下文),再由 pruneForAirInterrupt 真正裁。 + a.handleContextOverflow("退款导出", 10.0) + if mid := a.context.Len(); mid != before { + t.Fatalf("发空中断这一步不应改动上下文:%d → %d", before, mid) + } + a.pruneForAirInterrupt() after := a.context.Len() - if pruned <= 0 && after >= before { - t.Fatalf("裁剪无效:%d → %d 条,pruned=%d", before, after, pruned) + if after >= before { + t.Fatalf("裁剪无效:%d → %d 条", before, after) } // 裁剪后仍应留下 protected 条以上,不能清空 if after < a.effectiveProtectedCount() { @@ -540,11 +562,13 @@ func TestOverflow_自适应后裁剪有效(t *testing.T) { // 由TestOverflow_单条超预算则终止 覆盖。 a := newOverflowAgent(4000, 30, 5, 800) before := a.context.Len() - pruned := a.handleContextOverflow("退款导出", 1.3) + // 空中断设计:先发中断,再由任务边界裁剪。 + a.handleContextOverflow("退款导出", 1.3) + a.pruneForAirInterrupt() after := a.context.Len() if after >= before { - t.Fatalf("自适应后裁剪应有效:%d → %d(pruned=%d)", before, after, pruned) + t.Fatalf("自适应后裁剪应有效:%d → %d", before, after) } if a.effectiveProtectedCount() >= before { t.Fatalf("有效保护数 %d 不应 ≥ 裁剪前条数 %d", @@ -727,3 +751,61 @@ func TestOverflow_恢复预算对所有入口生效(t *testing.T) { t.Fatal("预算耗尽必须置 terminalError,否则会静默重试") } } + +// ── 空中断端到端:识别 + 裁剪 + 不产生回复 ────────────────────── +// +// 这几条钉住 2026-10-25 按原始设计做的修正。此前实现有三个偏移: +// ① 中断带正文("已裁剪 N 条…")——它变成一条要模型回应的消息 +// ② 裁剪在 raise 之前就做完——中断沦为回执,晚了一步 +// ③ 上游 contextfull 后原地重跑——拿同一份超限内容再发一次,烧光恢复预算 + +// 判据:空中断必须被 resolveInput 认作有效输入。 +// +// 变异验证:删掉 isAirInterrupt 那一支 ⇒ 它会被当成 empty_input 丢弃, +// 本判据变红。 +func TestAirInterrupt_ResolvedAsValidInput(t *testing.T) { + a := newOverflowAgent(2000, 2, 20, 400) + evt := &agentIO.InputEvent{ + Type: "interrupt", + Source: "kernel/overflow", + Payload: map[string]interface{}{"interrupt": true, "kernel": true, "content": ""}, + } + in, ok := a.resolveInput(evt) + if !ok { + t.Fatal("空中断必须是有效输入(否则 L4 变成什么也不做的空转任务)") + } + if !in.airInterrupt { + t.Fatal("必须被标记为 airInterrupt") + } +} + +// 判据:普通空输入**不能**被误认成空中断。 +// +// 理由:客户端空包若也走裁剪路径,会变成一次无声的上下文裁剪,行为不可预测。 +func TestAirInterrupt_RejectsOrdinaryEmptyInput(t *testing.T) { + a := newOverflowAgent(2000, 2, 20, 400) + cases := []struct { + name string + payload map[string]interface{} + }{ + {"非内核来源", map[string]interface{}{"interrupt": true, "content": ""}}, + {"非中断类型", map[string]interface{}{"kernel": true, "content": ""}}, + } + for _, c := range cases { + evt := &agentIO.InputEvent{Type: "interrupt", Source: "x", Payload: c.payload} + if c.name == "非中断类型" { + evt.Type = "text" + } + if in, ok := a.resolveInput(evt); ok && in.airInterrupt { + t.Errorf("%s 不应被认作空中断", c.name) + } + } + // 插件发的带正文中断也不是空中断 + evt := &agentIO.InputEvent{ + Type: "interrupt", + Payload: map[string]interface{}{"kernel": true, "content": "设备上线了"}, + } + if in, ok := a.resolveInput(evt); ok && in.airInterrupt { + t.Error("带正文的中断不应被认作空中断") + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 3e04109d..1533490a 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -443,6 +443,20 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi } } + // ★★ 空中断(kernel/overflow):不做任何对话处理,只做语义相关性裁剪。 + // + // 这是 contextoverflow 的**原始设计**(2026-10-25 按用户口径修正): + // L4 中断本身不携带消息,唯一用途是把当前任务抢占下来,好在任务边界 + // 做一次语义相关性裁剪。此前实现把它反过来了(先裁剪、再发一条 + // 「已裁剪 N 条…」的通知),那等于把调度信号变成了回执。 + // + // 因此这里:裁剪 → 结束本轮,**不进 LLM、不写上下文、不发回复**。 + // 用 terminalSkipped(不是 terminalError):这是正常完成,不是故障。 + if in.airInterrupt { + a.pruneForAirInterrupt() + return nil, terminalSkipped + } + // 工具提醒/中断(terminal_watch、timer 等)不是用户发言: // 以 system 角色注入 LLM,且不写入用户对话履历。 isInterrupt, _ := evt.Payload["interrupt"].(bool) @@ -706,12 +720,18 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome { if f.Terminal == terminalError { return outcomeFailed // 恢复预算耗尽,f.Err 已写明 } - // 已裁剪。**必须退回 StepPrepare 重建 f.Msgs**: - // f.Msgs 只在 stepPrepare 里由 buildMessages 装配,stepLLM 不重建; - // 而挂起帧里那份就是刚刚超限的那份,复用它等于原样再发一次。 - f.Turn++ - f.Step = StepPrepare - return outcomeContinue + // ★ 已发出 L4 空中断,**裁剪尚未发生**(它在空中断任务被调度 + // 执行时才做)。所以这里不能让本帧原地重跑: + // + // · 原地重跑 = 拿同一份超限内容再发一次,必然再次超限 + // (这正是 recover budget 会被快速烧光的原因); + // · 真正的裁剪发生在任务边界,也就是**本任务让位之后**。 + // + // 语义上本帧的职责到此为止:把现场交出去(outcomeSuspended), + // 由空中断任务抢占执行、完成裁剪,随后本帧再被恢复。 + // + // 与"用户按停止"不同:那不是让位,是终结。两者不能混。 + return outcomeSuspended } // 未处理(如轻量内核不裁剪):落到下面按普通错误处理。 } diff --git a/internal/plugins/clawhubadapter/plugin.go b/internal/plugins/clawhubadapter/plugin.go index e5974ae1..6949bb36 100644 --- a/internal/plugins/clawhubadapter/plugin.go +++ b/internal/plugins/clawhubadapter/plugin.go @@ -49,6 +49,12 @@ type Plugin struct { name string skillsDir string simulatorDir string + // pythonBin 是运行 skill 子进程的解释器(配置 python_bin)。 + // 留空表示未配置:此时优先用 /.venv/bin/python,再退回系统 python3。 + pythonBin string + // skillWorkdir 是 skill 子进程的工作目录(配置 skill_workdir)。 + // 留空表示用 skill 自身目录。 + skillWorkdir string sidecars []*sidecarProcess manager *sidecarProcess mu sync.Mutex @@ -92,6 +98,24 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { Key: "simulator_dir", Type: "string", DisplayName: "模拟器工作目录", Description: "OpenClaw 模拟器工作目录路径(留空则使用默认路径)", }) + // ★ Python 解释器与 skill 工作目录(2026-10-25 新增)。 + // + // 此前是硬编码猜测:先找 /usr/bin/python3,再找 /usr/local/bin/python3。 + // 后果是 skill 声明的依赖(httpx 等)装在哪个解释器里就决定了它能不能跑, + // 而"猜"这件事根本没有正确解 —— 不同部署的 Python 布局完全不同。 + // + // 现在:由配置**显式指定**,与 skills_dir/simulator_dir 同一套风格。 + // 留空时仍走按 skill 目录探测 .venv 的既有逻辑(向后兼容)。 + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "python_bin", Type: "string", DisplayName: "Python 解释器", + Description: "运行 skill 用的 Python 解释器绝对路径,例如 /opt/ha-py/bin/python。" + + "留空则优先用 /.venv/bin/python,再退回系统 python3。", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "skill_workdir", Type: "string", DisplayName: "Skill 工作目录", + Description: "skill 子进程的工作目录(留空则用 skill 自身目录)。" + + "需要固定相对路径读写时用它;不影响解释器选择。", + }) p.httpClient = &http.Client{Timeout: 30 * time.Second} if v, _ := s.Settings().Get("skills_dir"); v != nil { if s, ok := v.(string); ok && s != "" { @@ -103,6 +127,16 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.simulatorDir = s } } + if v, _ := s.Settings().Get("python_bin"); v != nil { + if str, ok := v.(string); ok && str != "" { + p.pythonBin = str + } + } + if v, _ := s.Settings().Get("skill_workdir"); v != nil { + if str, ok := v.(string); ok && str != "" { + p.skillWorkdir = str + } + } // 默认目录:内核 data_dir 下 skills 目录(与配置 core.daemon.data_dir 对齐) if p.skillsDir == "" { if v, _ := s.Settings().GetCore("daemon.data_dir"); v != nil { @@ -1154,16 +1188,31 @@ func (p *Plugin) loadPySidecar(s *sdk.PluginSDK, dir, name string) error { return fmt.Errorf("write pysimulator: %w", err) } - // 查找可用的 Python 解释器 - pythonBin := "python3" - for _, candidate := range []string{"/usr/bin/python3", "/usr/local/bin/python3"} { - if _, err := os.Stat(candidate); err == nil { - pythonBin = candidate - break - } + // 选择 Python 解释器。优先级(2026-10-25 改为可配置): + // + // ① 插件配置 python_bin —— **显式指定,最高优先** + // ② /.venv/bin/python —— skill 自带的虚拟环境 + // ③ 系统 python3(/usr/bin → /usr/local/bin) + // + // 为什么 ① 必须在最前:不同部署的 Python 布局完全不同,"猜"没有正确解。 + // 实测事故:`bilibili-all-in-one` 声明 requirements 含 httpx,而系统 + // Python 没装 ⇒ 启动即 ModuleNotFoundError。往系统 Python 里装又会污染 + // 发行版环境(PEP 668 明确禁止,--break-system-packages 会破坏 apt 的 + // 包一致性)。正确解是让部署方显式指定用哪个解释器/venv。 + pythonBin, err := resolvePython(name, dir, p.pythonBin) + if err != nil { + return err } - sp, err := launchProcess(pythonBin, simPath, dir, name, p.simulatorDir) + // 工作目录:配置指定则用它(需创建),否则用 skill 自身目录。 + workDir := dir + if p.skillWorkdir != "" { + if err := os.MkdirAll(p.skillWorkdir, 0755); err != nil { + return fmt.Errorf("创建 skill_workdir(%s): %w", p.skillWorkdir, err) + } + workDir = p.skillWorkdir + } + sp, err := launchProcessIn(pythonBin, simPath, dir, name, p.simulatorDir, workDir) if err != nil { return fmt.Errorf("launch pysimulator: %w", err) } @@ -1418,3 +1467,58 @@ func extractTGZ(data []byte, dest string) error { } return nil } + +// fileExists 报告路径存在(错误信息不需要,调用方只关心能否用)。 +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// resolvePython 决定用哪个 Python 解释器跑 skill,优先级: +// +// ① configured(插件配置 python_bin)—— 显式指定,最高优先 +// ② /.venv/bin/python —— skill 自带虚拟环境 +// ③ 系统 python3(/usr/bin → /usr/local/bin)→ 最后退回 "python3" +// +// ## 为什么必须可配置(2026-10-25 真机事故) +// +// 此前是硬编码猜:先 /usr/bin/python3,再 /usr/local/bin/python3。 +// `bilibili-all-in-one` 声明 requirements 含 httpx,而系统 Python 没装 +// ⇒ 启动即 ModuleNotFoundError。而"猜"没有正确解:不同部署的 Python +// 布局完全不同(发行版 Python、pyenv、uv、conda、venv…)。 +// +// 往系统 Python 里 pip install 是**错的**解法:PEP 668 明确禁止, +// --break-system-packages 会破坏 apt 管理的包一致性。 +// +// ## 配错必须响亮 +// +// 配置了但文件不存在时返回 error,**不静默退回**系统解释器 —— +// 否则用户以为配好了,实际跑在缺依赖的 Python 上,又是一次静默降级。 +func resolvePython(name, skillDir, configured string) (string, error) { + if configured != "" { + if _, err := os.Stat(configured); err != nil { + return "", fmt.Errorf("配置的 python_bin 不可用(%s): %w", configured, err) + } + log.Printf("[clawhub] skill %s: using configured interpreter %s", name, configured) + return configured, nil + } + if venvPy := filepath.Join(skillDir, ".venv", "bin", "python"); fileExists(venvPy) { + log.Printf("[clawhub] skill %s: using its venv interpreter %s", name, venvPy) + return venvPy, nil + } + for _, candidate := range []string{"/usr/bin/python3", "/usr/local/bin/python3"} { + if fileExists(candidate) { + return candidate, nil + } + } + return "python3", nil +} + +// pickPythonFor 是 resolvePython 的便捷包装(测试与旧调用点用)。 +// +// 忽略错误并返回空串会让"配错"看起来像"没配",所以调用方应优先用 +// resolvePython;这里只为保持测试可读性。 +func pickPythonFor(name, skillDir, configured string) string { + v, _ := resolvePython(name, skillDir, configured) + return v +} diff --git a/internal/plugins/clawhubadapter/python_bin_test.go b/internal/plugins/clawhubadapter/python_bin_test.go new file mode 100644 index 00000000..22b2c69b --- /dev/null +++ b/internal/plugins/clawhubadapter/python_bin_test.go @@ -0,0 +1,63 @@ +package clawhubadapter + +import ( + "os" + "path/filepath" + "testing" +) + +// ── Python 解释器选择优先级 ────────────────────────────────── +// +// 背景(2026-10-25 真机事故):bilibili skill 声明 requirements 含 httpx, +// 系统 Python 没装 ⇒ 启动即 ModuleNotFoundError。而此前解释器是**硬编码猜**的: +// 先 /usr/bin/python3,再 /usr/local/bin/python3。 +// +// 「猜」没有正确解:不同部署的 Python 布局完全不同(发行版 Python、 +// pyenv、uv、conda、venv…)。所以改为配置显式指定 + venv 探测兜底。 + +// 判据:配置了 python_bin 且可用 ⇒ 必须用它,优先于 venv 与系统解释器。 +// +// 这是"配置生效"的唯一证据。没有它,配了却仍走系统解释器也不会被发现 +// (正是本次事故的形态:静默降级)。 +func TestPickPython_ConfiguredWins(t *testing.T) { + dir := t.TempDir() + // 造一个假的 venv 解释器,确保"配置优先于 venv"被真正验证 + venvBin := filepath.Join(dir, ".venv", "bin") + os.MkdirAll(venvBin, 0755) + venvPy := filepath.Join(venvBin, "python") + os.WriteFile(venvPy, []byte("#!/bin/sh\n"), 0755) + + cfgPy := filepath.Join(dir, "custom-python") + os.WriteFile(cfgPy, []byte("#!/bin/sh\n"), 0755) + + got := pickPythonFor("bilibili", dir, cfgPy) + if got != cfgPy { + t.Fatalf("配置的 python_bin 应最高优先,期望 %s,实际 %s", cfgPy, got) + } +} + +// 判据:没配 python_bin 但 skill 有 venv ⇒ 用 venv。 +func TestPickPython_VenvWhenNotConfigured(t *testing.T) { + dir := t.TempDir() + venvBin := filepath.Join(dir, ".venv", "bin") + os.MkdirAll(venvBin, 0755) + venvPy := filepath.Join(venvBin, "python") + os.WriteFile(venvPy, []byte("#!/bin/sh\n"), 0755) + + if got := pickPythonFor("x", dir, ""); got != venvPy { + t.Fatalf("无配置时应优先 skill 自带 venv,期望 %s,实际 %s", venvPy, got) + } +} + +// 判据:配置了但**不存在** ⇒ 必须报错,不能静默退回系统解释器。 +// +// 静默退回的后果:用户以为配好了,实际跑在缺依赖的 Python 上 —— +// 这正是本次要修的「静默降级」那一族。 +func TestPickPython_MissingConfiguredIsLoud(t *testing.T) { + dir := t.TempDir() + bad := filepath.Join(dir, "no-such-python") + _, err := resolvePython("x", dir, bad) + if err == nil { + t.Fatal("配置的 python_bin 不存在时必须报错(静默退回会让用户以为配置生效)") + } +} diff --git a/internal/plugins/clawhubadapter/sidecar.go b/internal/plugins/clawhubadapter/sidecar.go index ef8af7f0..ba0b9349 100644 --- a/internal/plugins/clawhubadapter/sidecar.go +++ b/internal/plugins/clawhubadapter/sidecar.go @@ -164,7 +164,17 @@ func launchSidecar(dir, name, simDir string) (*sidecarProcess, error) { return launchProcess("node", mainJS, dir, name, simDir) } +// launchProcess 以 dir 作为工作目录拉起子进程(绝大多数调用的形态)。 func launchProcess(bin, arg, dir, name, simDir string) (*sidecarProcess, error) { + return launchProcessIn(bin, arg, dir, name, simDir, dir) +} + +// launchProcessIn 与 launchProcess 相同,但工作目录可单独指定。 +// +// 为什么要单独一个函数而不是给 launchProcess 加参数:那会改动 4 个既有 +// 调用点(含测试),而它们的语义都是"工作目录 = skill 目录"。只有 +// skill_workdir 配置这一处需要另给目录,用变体表达的意图更清楚。 +func launchProcessIn(bin, arg, dir, name, simDir, workDir string) (*sidecarProcess, error) { nodePath := bin if bin == "node" { if p := os.Getenv("NODE_PATH"); p != "" { @@ -173,7 +183,10 @@ func launchProcess(bin, arg, dir, name, simDir string) (*sidecarProcess, error) } cmd := exec.Command(nodePath, arg, dir) - cmd.Dir = dir + if workDir == "" { + workDir = dir + } + cmd.Dir = workDir cmd.Stderr = os.Stderr // Add openclaw CLI bin dir to PATH so subprocesses can exec 'openclaw' command