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

@ -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
}
// 未处理(如轻量内核不裁剪):落到下面按普通错误处理。
}