fix(core): contextoverflow 改回空中断(按原始设计)+ Python 解释器可配置

## 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 子进程工作目录

回退顺序:配置 → `<skill>/.venv/bin/python` → 系统 python3。
**配错必须响亮**:配置的路径不存在时返回 error,不静默退回系统解释器
(否则用户以为配好了,实际跑在缺依赖的 Python 上 —— 又一次静默降级)。

判据:`TestPickPython_ConfiguredWins`(配置优先于 venv)/
`TestPickPython_VenvWhenNotConfigured` / `TestPickPython_MissingConfiguredIsLoud`

`launchProcessIn` 是 `launchProcess` 的工作目录变体(不改既有 4 个调用点)。
This commit is contained in:
JianFeeeee
2026-10-25 21:39:12 +08:00
parent fccc12183f
commit 7052644431
9 changed files with 502 additions and 104 deletions

View File

@ -49,6 +49,12 @@ type Plugin struct {
name string
skillsDir string
simulatorDir string
// pythonBin 是运行 skill 子进程的解释器(配置 python_bin)。
// 留空表示未配置:此时优先用 <skill>/.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。" +
"留空则优先用 <skill 目录>/.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 —— **显式指定,最高优先**
// ② <skill 目录>/.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)—— 显式指定,最高优先
// ② <skill 目录>/.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
}

View File

@ -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 不存在时必须报错(静默退回会让用户以为配置生效)")
}
}

View File

@ -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