fix(core): 裁剪收归基础设施,contextfull 只作为触发信号

## 问题

同一件事(上下文装不下)有三套实现、三份防护差异:

    task.go 发 LLM 前   maybeHandleContextOverflow(有恢复预算)
    task.go 上游报错     handleUpstreamContextFull(**直调内部,绕过预算**)
    resident.go 轮首     checkContextFull(只对驻留子生效)

其中 handleUpstreamContextFull 是"另起炉灶":它自己又调一遍
handleContextOverflow,绕过 f.OverflowRecover 预算——而文档 §四.1 明确
「L4 遇 L4 不能抢占,反复触发只会排队堆积」。

## 改法

裁剪成为**基础设施**,contextfull 只是「判定已被外部短路」的一种入参:

    enforceContextBudget(f, query, force)
      force=false  本地水位:积累量 > ratio × 窗口
      force=true   外部已确认超页(provider 返回 ErrContextFull)

之后的一切(恢复预算、裁剪、raise L4)完全相同。handleUpstreamContextFull
删除,只剩 isUpstreamContextFull 这个纯判定。

## 任务恢复入口补裁剪

中断任务跑完会把它的输入/输出提交进 a.context,恢复时重建出的前缀可能
**比被挂起时更大**。不在这里裁,这份更大的前缀会被原样发出去。

恢复用的是专用入口 enforceResumeContextBudget,两处语义与通用入口不同:

1. **query 取本帧最新有效上下文**(resumeQueryFrom 从 f.Msgs 尾部往回找),
   不是 f.Input。被挂起的任务可能隔了很久,f.Input 早已与当前对话脱节;
   拿旧 Input 算相关性,会裁掉"对该任务重要、但字面上不匹配旧输入"的事件。

2. **保护范围是本帧自己的现场**(len(f.Msgs)-f.PrefixLen),不是全局末尾
   N 条。被挂起任务的现场可能是一整批工具结果,若落在保护范围外被归档,
   恢复后的任务引用的内容已不在上下文里,任务自相矛盾。

为此新增 pruneForResume + contextTopKForProtected——不能复用 contextTopK,
它内部会把 topK 抬到 effectiveProtectedCount()+1(全局保护),两套下限
打架会让恢复场景「裁不动」(实测 protected=8、20 条上下文时 topK 被抬到 20)。

## 不变的部分

声明式裁剪(业务策略)语义原样保留:
  · 中断/消息注入 —— pruneOnInput,判据 payload.context_policy / ChannelDef
  · 工具内容注入   —— stepToolAfter,判据 ToolDef.ContextPolicy
两者走 memoryPass → pruneByQuery,与容量保护共用底层,但职责正交。

判据:任务恢复入口必须裁、恢复查询取本帧最新、保护本帧现场、
恢复预算对所有入口生效。变异验证:恢复不裁/保护退回全局/上游绕过预算,
三种坏法均被抓。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JianFeeeee
2026-10-25 10:06:56 +08:00
parent f5a1366770
commit 50508c83b5
8 changed files with 564 additions and 47 deletions

View File

@ -1,6 +1,7 @@
package io
import (
"context"
"errors"
"fmt"
"log"
@ -738,7 +739,17 @@ func (m *IOManager) DeviceOfTool(name string) (string, bool) {
return "", false
}
// ExecuteTool 执行设备工具(无取消语义)。
func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
return m.ExecuteToolCtx(context.Background(), name, args)
}
// ExecuteToolCtx 执行设备工具,并把 ctx 传给设备。
//
// ctx 同样不进 Device.Execute 的签名(那是插件作者的契约),而是**旁路**
// 放进 args(键与 core.executeCtxArgKey 一致)。内置插件若要消费它,
// 在自己的 handler 里取一次即可;不取就与今天完全一致。
func (m *IOManager) ExecuteToolCtx(ctx context.Context, name string, args map[string]interface{}) (ret interface{}, err error) {
m.mu.RLock()
type nameDevice struct {
name string
@ -765,7 +776,7 @@ func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret i
parent := m.parent
m.mu.RUnlock()
if parent != nil {
ret, err := parent.ExecuteTool(name, args)
ret, err := parent.ExecuteToolCtx(ctx, name, args)
if err == nil {
return ret, nil
}
@ -781,7 +792,7 @@ func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret i
err = fmt.Errorf("tool %s execute panic: %v", name, r)
}
}()
return candidates[0].dev.Execute(name, args)
return candidates[0].dev.Execute(name, bindExecCtx(ctx, args))
}
func (m *IOManager) ListDevices() []Device {
@ -1125,3 +1136,27 @@ func (m *IOManager) ClearToolBlocks(callID string) {
delete(m.toolPendingBlocks, callID)
m.toolBlocksMu.Unlock()
}
// execCtxArgKey 是内核内部传递执行上下文的旁路键。
//
// ⚠️ 必须与 core.executeCtxArgKey 逐字一致(理由见 proc/execctx.go:
// 复制常量是为了避免 core ↔ io 的导入环,不一致的表现只是"取消不生效")。
const execCtxArgKey = "\x00kernel.execute_ctx\x00"
// bindExecCtx 把 ctx 放到 args 里随调用下传。
//
// 为什么不改 Device.Execute 签名:那会让全部内置插件跟着改(同一个理由
// 见 StageHost.ExecuteToolCtx 的注释)。旁路键对不消费它的插件完全不可见——
// 但**必须**在真正下发给插件前剥掉,否则严格校验参数的插件会报未知参数。
// 剥除由各插件自己的入口决定:core 侧统一下发前已剥(见 StageHost),
// io 侧保持原样,因为设备插件直接读 args、不会做严格白名单校验。
func bindExecCtx(ctx context.Context, args map[string]interface{}) map[string]interface{} {
if ctx == nil {
return args
}
if args == nil {
args = make(map[string]interface{}, 1)
}
args[execCtxArgKey] = ctx
return args
}