mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
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:
@ -153,6 +153,76 @@ func (a *Agent) pruneByQuery(query string) int {
|
||||
return a.context.PruneWithProtected(query, topK, a.docStore, a.effectiveProtectedCount())
|
||||
}
|
||||
|
||||
// pruneForResume 是按「指定保护条数」执行的裁剪,供任务恢复入口使用。
|
||||
//
|
||||
// 与 pruneByQuery 的唯一差别是 protected 的来源:
|
||||
//
|
||||
// pruneByQuery protected = a.effectiveProtectedCount()(全局末尾 N 条)
|
||||
// pruneForResume protected = 调用方给的条数(该任务帧自己的现场)
|
||||
//
|
||||
// 为什么恢复要用后者:被挂起任务的现场在 f.Msgs[PrefixLen:] 里,
|
||||
// 可能是一整批工具结果。若按全局末尾 N 条保护,那些结果可能落在保护范围外
|
||||
// 被归档——恢复后的任务引用的内容已不在上下文里,等于自相矛盾。
|
||||
func (a *Agent) pruneForResume(query string, protected int) int {
|
||||
if a == nil || a.context == nil || a.isLightKernel() {
|
||||
return 0
|
||||
}
|
||||
if protected < 0 {
|
||||
protected = 0
|
||||
}
|
||||
// ★ 不能直接复用 contextTopK():它内部会把 topK 抬到
|
||||
// effectiveProtectedCount()+1(全局末尾保护),而恢复场景的保护范围是
|
||||
// **本帧现场**,两者不是同一个数。直接用会得到「按全局保护算出来的
|
||||
// 下限 ≥ 实际条数」⇒ 裁不动(实测 protected=8、上下文 20 条时
|
||||
// topK 被抬到 20,Prune 空转)。
|
||||
//
|
||||
// 这里按**本帧保护条数**重新推导:先算 token 预算能装几条,
|
||||
// 再保证下限是 protected+1(否则 Prune 会把 protected 之外的裁光)。
|
||||
topK := a.contextTopKForProtected(protected)
|
||||
return a.context.PruneWithProtected(query, topK, a.docStore, protected)
|
||||
}
|
||||
|
||||
// contextTopKForProtected 是按**指定保护条数**推导保留条数的版本。
|
||||
//
|
||||
// 与 contextTopK 的唯一差别是下限:后者用 effectiveProtectedCount()(全局末尾
|
||||
// 保护),这里用调用方给的 protected。恢复场景必须用后者——保护范围是本帧现场。
|
||||
func (a *Agent) contextTopKForProtected(protected int) int {
|
||||
if protected < 0 {
|
||||
protected = 0
|
||||
}
|
||||
hardCap := a.maxContextSize - 1
|
||||
if hardCap < protected+1 {
|
||||
hardCap = protected + 1 // 保护范围本身超过运维上限时,保护优先
|
||||
}
|
||||
if hardCap < 1 {
|
||||
hardCap = 1
|
||||
}
|
||||
|
||||
budget := a.computeTokenBudget()
|
||||
tokens := budget.ContextTokens
|
||||
if tokens <= 0 {
|
||||
return hardCap
|
||||
}
|
||||
avg := 0
|
||||
if a.context != nil {
|
||||
if n := a.context.Len(); n > 0 {
|
||||
avg = a.accumulatedTokens() / n
|
||||
}
|
||||
}
|
||||
if avg <= 0 {
|
||||
avg = defaultAvgEventTokens
|
||||
}
|
||||
topK := tokens / avg
|
||||
if topK < protected+1 {
|
||||
// 预算装不下「保护条数 + 1」⇒ 宁可暂时超页也不破坏现场(同 contextTopK 的取舍)。
|
||||
topK = protected + 1
|
||||
}
|
||||
if topK > hardCap {
|
||||
topK = hardCap
|
||||
}
|
||||
return topK
|
||||
}
|
||||
|
||||
// contextTopK 推导裁剪后应保留的**条数**。
|
||||
//
|
||||
// ★ 为什么不能直接用 maxContextSize(2026-10-01 跑分实测出的根因):
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
)
|
||||
@ -95,17 +96,36 @@ func (a *Agent) overflowRatioNow() float64 {
|
||||
return float64(a.accumulatedTokens()) / float64(max)
|
||||
}
|
||||
|
||||
// maybeHandleContextOverflow 是本地预判的入口:**每次发 LLM 请求之前**调用,
|
||||
// 覆盖轮内 tool 回环(现状只在轮首检查,轮内撑爆时才发现,浪费一整轮工具调用)。
|
||||
// enforceContextBudget 是**上下文容量保护**的唯一实现(基础设施)。
|
||||
//
|
||||
// 返回 true 表示已处理(本帧应当重跑),调用方需要重建 f.Msgs —— 因为裁剪
|
||||
// 之后挂起帧里那份超限请求不能复用(见设计文档约束三)。
|
||||
// ## 为什么它是基础设施,而不是某个组件的功能
|
||||
//
|
||||
// 上下文会在**每个注入点**增长:新消息、中断注入、任务恢复(中断任务的结果
|
||||
// 已提交进 a.context,重建出的前缀更大)、以及轮内工具回灌。因此"装不下就裁"
|
||||
// 必须在这些入口都成立,而不是只在某一条路径上碰巧被调用。
|
||||
//
|
||||
// <20><>史(2026-10-25 前的三个半成品入口,现已全部收敛到这里):
|
||||
//
|
||||
// task.go 发 LLM 前 maybeHandleContextOverflow(有恢复预算)
|
||||
// task.go 上游报错 handleUpstreamContextFull(**直调内部,绕过预算**)
|
||||
// resident.go 轮首 checkContextFull(只对驻留子生效,根 agent no-op)
|
||||
//
|
||||
// 三个入口、三套判据、三份防护差异 —— 这就是"额外造的一套机制"。
|
||||
//
|
||||
// ## force 的语义
|
||||
//
|
||||
// force 不是"另一条路径",只是**判定已被外部短路**:
|
||||
//
|
||||
// force=false 本地水位判定:积累量 > ratio × 窗口
|
||||
// force=true 外部已确认超页(如 provider 返回 ErrContextFull)
|
||||
//
|
||||
// 之后的一切(恢复预算、裁剪、raise L4)完全相同 —— 这正是"复用语义"的落点。
|
||||
//
|
||||
// 返回 handled 表示本帧需要重跑(f.Msgs 已过期,必须重建);
|
||||
// 若 f.Terminal == terminalError 则表示恢复预算耗尽,调用方应直接终止。
|
||||
//
|
||||
// 幂等:未超页时零开销(一次 token 估算 + 一次比较),可安全地在热路径调用。
|
||||
func (a *Agent) maybeHandleContextOverflow(f *TaskFrame, query string) (handled bool, pruned int) {
|
||||
// 诊断留痕:超页判据在**每次 LLM 请求**都会走,而生产上从未见它触发过。
|
||||
// 没有这行日志时,「没触发」和「没执行」无法区分(2026-10-01 T10 排查
|
||||
// 花了 10 轮工具调用就卡在这里)。只记比值与判定,不记内容。
|
||||
func (a *Agent) enforceContextBudget(f *TaskFrame, query string, force bool) (handled bool, pruned int) {
|
||||
light := a != nil && a.isLightKernel()
|
||||
noCtx := a == nil || a.context == nil
|
||||
var ratio float64
|
||||
@ -117,22 +137,34 @@ func (a *Agent) maybeHandleContextOverflow(f *TaskFrame, query string) (handled
|
||||
checked := a.overflowStat.Checked
|
||||
a.overflowStat.Checked++
|
||||
a.overflowStat.Unlock()
|
||||
// 诊断留痕:这条判据在每个注入点都会走,而生产上很少触发。
|
||||
// 没有这行日志时,「没触发」和「没执行」无法区分(2026-10-01 T10
|
||||
// 排查花了 10 轮工具调用才卡在这里)。只记比值与判定,不记内容。
|
||||
if checked%20 == 0 {
|
||||
log.Printf("[agent] overflow check #%d: ratio=%.2f window=%d events=%d "+
|
||||
"(threshold=%.2f lightKernel=%v noContext=%v)",
|
||||
"(threshold=%.2f lightKernel=%v noContext=%v force=%v)",
|
||||
checked+1, ratio, a.contextWindowTokens(), a.contextLenSafe(),
|
||||
overflowRatio, light, noCtx)
|
||||
overflowRatio, light, noCtx, force)
|
||||
}
|
||||
}
|
||||
if noCtx || light {
|
||||
// 轻量内核(驻留子)不做按相关度裁剪,见 pruneByQuery 的同款理由。
|
||||
return false, 0
|
||||
}
|
||||
if ratio < overflowRatio {
|
||||
// 唯一判据点:本地水位 OR 外部明确报错。
|
||||
if !force && ratio < overflowRatio {
|
||||
return false, 0
|
||||
}
|
||||
// 恢复预算:同一帧内裁剪仍裁不下 ⇒ 说明 topK 与窗口脱钩(见 pruneByQuery),
|
||||
if force {
|
||||
// 上游报错是一次**独立的上报**(与本地水位分开计数),
|
||||
// 计数放在这里而不是调用方:它是判据的一部分,不是调用方的副作用。
|
||||
a.overflowStat.Lock()
|
||||
a.overflowStat.Upstream++
|
||||
a.overflowStat.Unlock()
|
||||
}
|
||||
// 恢复预算:同一帧内裁完仍超 ⇒ 说明 topK 与窗口脱钩(见 pruneByQuery),
|
||||
// 再裁一次也是同样结果。超预算后**明确终止**,不静默重试。
|
||||
// 这条防护对所有入口一致生效 —— 包括上游报错那条(此前它绕过了这里)。
|
||||
if f != nil {
|
||||
if f.OverflowRecover >= overflowRecoverBudget {
|
||||
log.Printf("[agent] context overflow: recover budget exhausted (%d/%d), "+
|
||||
@ -150,6 +182,94 @@ func (a *Agent) maybeHandleContextOverflow(f *TaskFrame, query string) (handled
|
||||
return true, a.handleContextOverflow(query, ratio)
|
||||
}
|
||||
|
||||
// maybeHandleContextOverflow 保留为发 LLM 请求前的薄封装(历史调用点)。
|
||||
//
|
||||
// 它现在只是 enforceContextBudget 的一个调用点,不再持有独立判据。
|
||||
func (a *Agent) maybeHandleContextOverflow(f *TaskFrame, query string) (handled bool, pruned int) {
|
||||
return a.enforceContextBudget(f, query, false)
|
||||
}
|
||||
|
||||
// enforceResumeContextBudget 是**任务恢复**入口的容量保护。
|
||||
//
|
||||
// 与通用 enforceContextBudget 的区别只有两点,但都很关键:
|
||||
//
|
||||
// 1. **query 取任务帧的最新有效上下文**,不是 f.Input。
|
||||
// 被恢复的任务可能已挂起很久,它的 f.Input 与当前对话早已不同步;
|
||||
// 拿旧 Input 去算相关性,会裁掉"对该任务重要、但字面上不匹配旧输入"
|
||||
// 的事件——恢复出来的任务反而失去了它自己的现场。
|
||||
// 这里的做法是取该帧**尾部最后一条有内容的消息**(f.Msgs 的末条),
|
||||
// 那才是它此刻真正要继续做的东西。
|
||||
//
|
||||
// 2. **保护范围是该任务帧自己的尾部**,不是全局上下文的末尾 N 条。
|
||||
// PruneWithProtected 默认保护的 c.events 末 N 条是"最近的对话",
|
||||
// 而被挂起的任务在现场里可能攒了整批工具结果——那些必须一起保住,
|
||||
// 否则恢复后 f.Msgs[PrefixLen:] 引用的内容已被归档,任务自相矛盾。
|
||||
//
|
||||
// 判据仍是容量(不看声明):声明式裁剪归 pruneOnInput / stepToolAfter,
|
||||
// 那是业务策略;这里是"装不下就得裁"的基础设施。
|
||||
func (a *Agent) enforceResumeContextBudget(f *TaskFrame) (handled bool, pruned int) {
|
||||
if f == nil {
|
||||
return false, 0
|
||||
}
|
||||
// 水位判定(与通用入口同一套判据,只是 query/保护范围不同)。
|
||||
if !a.overBudgetLocked() {
|
||||
return false, 0
|
||||
}
|
||||
// 恢复预算:同一帧不能无限裁。
|
||||
if f.OverflowRecover >= overflowRecoverBudget {
|
||||
log.Printf("[agent] resume overflow: recover budget exhausted (%d/%d), terminating frame",
|
||||
f.OverflowRecover, overflowRecoverBudget)
|
||||
a.markOverflowAborted(a.overflowRatioNow(), a.context.Len())
|
||||
f.Err = fmt.Errorf("上下文超限:恢复时已裁剪 %d 次仍超出窗口,本轮终止", f.OverflowRecover)
|
||||
f.Terminal = terminalError
|
||||
return true, 0
|
||||
}
|
||||
f.OverflowRecover++
|
||||
|
||||
query := resumeQueryFrom(f)
|
||||
// 保护该任务帧自己的现场条数(尾部消息 = 本任务攒下的工具轮产物)。
|
||||
protected := len(f.Msgs) - f.PrefixLen
|
||||
if protected < 0 {
|
||||
protected = 0
|
||||
}
|
||||
before := a.context.Len()
|
||||
a.pruneForResume(query, protected)
|
||||
after := a.context.Len()
|
||||
if after >= before {
|
||||
log.Printf("[agent] resume overflow: no reduction (%d→%d events), not raising L4", before, after)
|
||||
return false, 0
|
||||
}
|
||||
pruned = before - after
|
||||
log.Printf("[agent] resume overflow: pruned=%d (%d→%d events), protected=%d frame tail msgs",
|
||||
pruned, before, after, protected)
|
||||
a.raiseContextOverflow(pruned)
|
||||
return true, pruned
|
||||
}
|
||||
|
||||
// overBudgetLocked 是纯判据(无副作用),供各入口复用。
|
||||
//
|
||||
// 命名带 Locked 是历史遗留,实际不加锁;调用方在调度器单线程上下文里使用。
|
||||
func (a *Agent) overBudgetLocked() bool {
|
||||
if a == nil || a.context == nil || a.isLightKernel() {
|
||||
return false
|
||||
}
|
||||
return a.overflowRatioNow() >= overflowRatio
|
||||
}
|
||||
|
||||
// resumeQueryFrom 取任务帧的**最新有效上下文**作为相关性查询。
|
||||
//
|
||||
// 从尾部往回找第一条有实质内容的消息:恢复时 f.Msgs 的最后一条
|
||||
// 可能是 tool 结果、continuation 占位或空的 assistant 消息,
|
||||
// 直接拿它算相关性会退化成无意义查询。
|
||||
func resumeQueryFrom(f *TaskFrame) string {
|
||||
for i := len(f.Msgs) - 1; i >= 0; i-- {
|
||||
if c := strings.TrimSpace(f.Msgs[i].Content); c != "" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return f.Input
|
||||
}
|
||||
|
||||
// handleContextOverflow 执行「裁剪 + L4 打断」。
|
||||
//
|
||||
// 关键取舍:裁剪在**这里同步做完**,L4 只负责打断与告知。理由:
|
||||
@ -240,23 +360,15 @@ func overflowNotice(pruned int) string {
|
||||
"被裁内容仍可检索,但需显式查询;查不到不等于不存在。", pruned)
|
||||
}
|
||||
|
||||
// handleUpstreamContextFull 处理上游返回的 ErrContextFull:与本地预判走**同一条**
|
||||
// 路径(裁剪 → L4),避免两条路径行为不一致。
|
||||
// isUpstreamContextFull 判定上游错误是否表示「上下文装不下」。
|
||||
//
|
||||
// 必须在 provider fallback **之前**调用:fallback 会换 provider 重发同一个
|
||||
// 超限请求,换谁都一样超。
|
||||
//
|
||||
// 返回 true 表示已裁剪,调用方应重试本轮;false 表示裁不出东西,应终止。
|
||||
func (a *Agent) handleUpstreamContextFull(llmErr error) bool {
|
||||
// 它只做**判定**,不做处理:处理一律交给 enforceContextBudget(force=true)。
|
||||
// 此前这里叫 handleUpstreamContextFull,自己又调了一遍 handleContextOverflow ——
|
||||
// 那等于在中断语义之外另造一套裁剪,绕过恢复预算,且与 raise L4 抢占的
|
||||
// 任务互相抢跑(2026-10-25 实测)。
|
||||
func isUpstreamContextFull(llmErr error) bool {
|
||||
var pe *agentAPI.ProviderError
|
||||
if !errors.As(llmErr, &pe) || pe.Kind != agentAPI.ErrContextFull {
|
||||
return false
|
||||
}
|
||||
pruned := a.handleContextOverflow("", 0)
|
||||
a.overflowStat.Lock()
|
||||
a.overflowStat.Upstream++
|
||||
a.overflowStat.Unlock()
|
||||
return pruned > 0
|
||||
return errors.As(llmErr, &pe) && pe.Kind == agentAPI.ErrContextFull
|
||||
}
|
||||
|
||||
// contextLenSafe 是 nil 安全的上下文条数(诊断日志用)。
|
||||
|
||||
@ -155,12 +155,18 @@ func TestOverflow_T6_中断消息含认知提示(t *testing.T) {
|
||||
func TestOverflow_上游ErrContextFull走同路径(t *testing.T) {
|
||||
a := newOverflowAgent(2000, 2, 20, 400) // 单条装得下,总量超页
|
||||
|
||||
// 累积不足以下次超页也没关系:上游已经明说装不下了
|
||||
if !a.handleUpstreamContextFull(&agentAPI.ProviderError{
|
||||
// 累积不足以下次超页也没关系:上游已经明说装不下了。
|
||||
// ★ 关键:走的是**同一个** enforceContextBudget(force=true),
|
||||
// 不是另一条独立路径。这正是「contextfull 复用中断裁剪语义」的落点。
|
||||
if !isUpstreamContextFull(&agentAPI.ProviderError{
|
||||
StatusCode: 400,
|
||||
Kind: agentAPI.ErrContextFull,
|
||||
Message: "context_length_exceeded",
|
||||
}) {
|
||||
t.Fatal("上游 ErrContextFull 应被识别")
|
||||
}
|
||||
handled, _ := a.enforceContextBudget(nil, "", true)
|
||||
if !handled {
|
||||
t.Fatal("上游 ErrContextFull 应被接住并返回 true(可恢复)")
|
||||
}
|
||||
a.overflowStat.Lock()
|
||||
@ -176,9 +182,8 @@ func TestOverflow_上游ErrContextFull走同路径(t *testing.T) {
|
||||
|
||||
// 非上下文类错误不得被误接。
|
||||
func TestOverflow_上游其他错误不误接(t *testing.T) {
|
||||
a := newOverflowAgent(1000, 2, 20, 400)
|
||||
for _, kind := range []agentAPI.ErrKind{agentAPI.ErrCredential, agentAPI.ErrTransient, agentAPI.ErrUnknown} {
|
||||
if a.handleUpstreamContextFull(&agentAPI.ProviderError{
|
||||
if isUpstreamContextFull(&agentAPI.ProviderError{
|
||||
StatusCode: 500, Kind: kind, Message: "boom",
|
||||
}) {
|
||||
t.Fatalf("kind=%v 不应被当作上下文超限接住", kind)
|
||||
@ -572,3 +577,153 @@ func TestOverflow_单条超预算则终止(t *testing.T) {
|
||||
t.Fatal("应记一次 Aborted")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 裁剪是**基础设施**:各注入/恢复入口都必须能触发它 ──
|
||||
|
||||
// 任务恢复入口(rebaseFramePrefix)必须做容量裁剪。
|
||||
//
|
||||
// 为什么需要它:中断任务跑完会把它的输入/输出提交进 a.context,
|
||||
// 于是恢复时重建出的前缀**可能比被挂起时更大**。不在这里裁,这份更大的
|
||||
// 前缀会被原样发出去,下一轮再靠 stepLLM 的闸门补救——那时已经付过
|
||||
// 一次超限请求的代价。
|
||||
//
|
||||
// 变异:删掉 rebaseFramePrefix 末尾的 enforceContextBudget 调用 ⇒ 变红。
|
||||
func TestOverflow_任务恢复入口触发裁剪(t *testing.T) {
|
||||
a := newOverflowAgent(2000, 2, 20, 400) // 总量远超窗口
|
||||
f := &TaskFrame{
|
||||
Input: "恢复后的输入",
|
||||
PrefixLen: 1,
|
||||
Msgs: []agentAPI.Message{
|
||||
{Role: "system", Content: "sys"},
|
||||
{Role: "user", Content: "tail"},
|
||||
},
|
||||
}
|
||||
before := a.context.Len()
|
||||
|
||||
a.rebaseFramePrefix(f)
|
||||
|
||||
after := a.context.Len()
|
||||
if after >= before {
|
||||
t.Fatalf("任务恢复后应触发容量裁剪(上下文 %d → %d 未减少)", before, after)
|
||||
}
|
||||
if f.OverflowRecover == 0 {
|
||||
t.Fatal("恢复入口的裁剪应计入该帧的恢复预算(否则同一帧可无限裁)")
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复时相关性必须按**本帧最新上下文**算,不能拿旧的 f.Input。
|
||||
//
|
||||
// 被恢复的任务可能已挂起很久,f.Input 与当前对话早已不同步;
|
||||
// 拿旧 Input 当 query,会裁掉"对该任务重要、但字面上不匹配旧输入"的事件,
|
||||
// 恢复出来的任务反而失去了自己的现场。
|
||||
//
|
||||
// 判据:resumeQueryFrom 取的是 Msgs 尾部第一条有内容的消息,
|
||||
// 而不是 Input。
|
||||
func TestOverflow_恢复用本帧最新上下文作查询(t *testing.T) {
|
||||
f := &TaskFrame{
|
||||
Input: "很久以前用户说的话",
|
||||
PrefixLen: 1,
|
||||
Msgs: []agentAPI.Message{
|
||||
{Role: "system", Content: "sys"},
|
||||
{Role: "tool", Content: "最新的工具结果:部署已完成"},
|
||||
},
|
||||
}
|
||||
got := resumeQueryFrom(f)
|
||||
if got != "最新的工具结果:部署已完成" {
|
||||
t.Fatalf("恢复查询应取本帧最新有效上下文,实际取到 %q", got)
|
||||
}
|
||||
if got == f.Input {
|
||||
t.Fatal("不得回退到旧的 f.Input —— 那与当前任务已不同步")
|
||||
}
|
||||
}
|
||||
|
||||
// 尾部全是空消息时必须回退到 Input(不能返回空查询)。
|
||||
func TestOverflow_恢复查询回退(t *testing.T) {
|
||||
f := &TaskFrame{
|
||||
Input: "原始输入",
|
||||
Msgs: []agentAPI.Message{{Role: "assistant", Content: " "}},
|
||||
}
|
||||
if got := resumeQueryFrom(f); got != "原始输入" {
|
||||
t.Fatalf("全空时应回退到 Input,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复裁剪必须保护**本帧自己的现场**,而不是全局末尾 N 条。
|
||||
//
|
||||
// 这是最容易写错的一点:PruneWithProtected 的默认保护是 c.events 末尾 N 条
|
||||
// ("最近的对话"),而被挂起任务的现场可能是一整批工具结果,未必在末尾。
|
||||
// 保护错了,恢复后的任务引用的内容已被归档,任务自相矛盾。
|
||||
//
|
||||
// 变异:把 pruneForResume 的 protected 改成 a.effectiveProtectedCount() ⇒ 变红。
|
||||
func TestOverflow_恢复裁剪保护本帧现场(t *testing.T) {
|
||||
// 上下文 20 条,帧现场声明 8 条(模拟攒下的工具轮产物)。
|
||||
a := newOverflowAgent(2000, 2, 20, 400)
|
||||
before := a.context.Len()
|
||||
|
||||
frameTail := 8
|
||||
a.pruneForResume("部署已完成", frameTail)
|
||||
|
||||
// ⚠️ 判据必须看**事件数**,不能看返回值:pruneForResume 返回的是
|
||||
// 「写入 docStore 的条数」,docStore==nil 时它恒为 0,而裁剪其实发生了。
|
||||
// (handleContextOverflow 的注释里记过同一个坑。)
|
||||
after := a.context.Len()
|
||||
// 保留条数必须覆盖被保护的部分:裁剪后条数不得少于 protected。
|
||||
if after < frameTail {
|
||||
t.Fatalf("裁剪后仅剩 %d 条,少于本帧保护的 %d 条 —— 现场被破坏", after, frameTail)
|
||||
}
|
||||
if after >= before {
|
||||
t.Fatalf("应减少事件数(%d → %d)", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// 上游 ErrContextFull 与本地预判必须走**同一个**处理函数。
|
||||
//
|
||||
// 这条守的是"复用中断裁剪语义"本身:一旦有人再拆出独立路径,
|
||||
// 恢复预算 / 轻量内核判断这些防护就会在其中一条上缺失。
|
||||
//
|
||||
// 变异:恢复 handleUpstreamContextFull(直调 handleContextOverflow)⇒ 变红。
|
||||
func TestOverflow_上游与本地共用同一个处理函数(t *testing.T) {
|
||||
a := newOverflowAgent(2000, 2, 20, 400) // 总量超页,但下面要看 force 的效果
|
||||
|
||||
// force=true 时**必须**进入处理,即使水位判定不成立。
|
||||
// 这正是"上游说满了"的语义:不信本地估算,直接裁。
|
||||
//
|
||||
// 造一个"水位很低但上游报满"的场景:空上下文 ⇒ ratio=0,
|
||||
// 本地判据绝不会触发,只有 force 能让它进入处理分支。
|
||||
empty := &Agent{
|
||||
io: agentIO.NewIOManager(),
|
||||
provider: &fakeOverflowProvider{window: 100000},
|
||||
context: NewRelevanceContext("", nil),
|
||||
}
|
||||
empty.context.Append(ContextEvent{Input: "短", Response: "短"})
|
||||
a = empty
|
||||
if a.overflowRatioNow() >= overflowRatio {
|
||||
t.Fatal("前置:水位应低于阈值")
|
||||
}
|
||||
handled, pruned := a.enforceContextBudget(nil, "", true)
|
||||
if !handled {
|
||||
t.Fatal("force=true 应在水位不足时仍然处理(上游已明确报错)")
|
||||
}
|
||||
_ = pruned
|
||||
}
|
||||
|
||||
// 恢复预算对**所有**入口一致生效——包括上游报错那条。
|
||||
//
|
||||
// 这条是 T5 的补强。旧实现里 handleUpstreamContextFull 直调内部函数,
|
||||
// 绕过了 f.OverflowRecover 检查,于是同一帧可以反复 raise L4;
|
||||
// 而文档 §四.1 明确「L4 遇 L4 不能抢占」,反复触发只会排队堆积。
|
||||
//
|
||||
// 变异:把 force 分支移到预算检查之前 ⇒ 变红。
|
||||
func TestOverflow_恢复预算对所有入口生效(t *testing.T) {
|
||||
a := newOverflowAgent(2000, 2, 20, 400)
|
||||
f := &TaskFrame{Input: "x"}
|
||||
f.OverflowRecover = overflowRecoverBudget // 预算已耗尽
|
||||
|
||||
handled, _ := a.enforceContextBudget(f, "", true)
|
||||
if !handled {
|
||||
t.Fatal("预算耗尽也算「已处理」——调用方据此直接终止,不应再重跑")
|
||||
}
|
||||
if f.Terminal != terminalError {
|
||||
t.Fatal("预算耗尽必须置 terminalError,否则会静默重试")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
@ -21,6 +22,21 @@ type StageHost struct {
|
||||
tools map[string]sdk.ToolHandler
|
||||
toolPlugins map[string]string
|
||||
stages map[sdk.Stage][]stageEntry
|
||||
|
||||
// cancelable 记录本工具的执行体是否**接收取消信号**。
|
||||
//
|
||||
// 为什么需要单独一张表:公开的 ToolHandler 签名 `func(args) (any, error)`
|
||||
// 没有 ctx,而且这个签名**不打算改**——它是插件作者的契约,改它等于强迫
|
||||
// 100 处调用点与全部存量插件跟着改,只为了内核能停下一个自己发起超时的调用。
|
||||
//
|
||||
// 真正需要 ctx 的只有**内核与插件之间的那一层**:proc 桥把 ctx 传到
|
||||
// CallContext,由它向子进程发 call.cancel。插件进程内部的 handler 仍然
|
||||
// 是旧签名,取消由模板运行时接住(让阻塞中的 handler 拿到 context.Canceled)。
|
||||
//
|
||||
// 所以这里只记「这个工具能否安全地走带 ctx 的执行路径」,而 ctx 本身
|
||||
// 由 ExecuteToolCtx 经 context 在当前 goroutine 上传递(见 toolExecCtxKey),
|
||||
// 不进 handler 签名。
|
||||
cancelable map[string]bool
|
||||
}
|
||||
|
||||
// stageEntry 把 stage handler 与它的归属插件绑定。
|
||||
@ -38,6 +54,7 @@ func NewStageHost() *StageHost {
|
||||
return &StageHost{
|
||||
tools: make(map[string]sdk.ToolHandler),
|
||||
toolPlugins: make(map[string]string),
|
||||
cancelable: make(map[string]bool),
|
||||
stages: make(map[sdk.Stage][]stageEntry),
|
||||
}
|
||||
}
|
||||
@ -53,6 +70,10 @@ func (h *StageHost) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolH
|
||||
}
|
||||
h.tools[name] = handler
|
||||
h.toolPlugins[name] = def.Plugin
|
||||
// 所有工具都视为可取消:ctx 只在**内核内部**传递(经 goroutine 本地的
|
||||
// executeCtx),handler 签名不变。对不消费 ctx 的执行体而言这只是个
|
||||
// 空操作,不存在"误取消"的风险。
|
||||
h.cancelable[name] = true
|
||||
h.toolDefs = append(h.toolDefs, def)
|
||||
return nil
|
||||
}
|
||||
@ -142,7 +163,71 @@ func (h *StageHost) findLocked(name string) (sdk.ToolDef, bool) {
|
||||
return sdk.ToolDef{}, false
|
||||
}
|
||||
|
||||
// executeCtxKey 是**内核内部**传递执行上下文的私有键(放在 args map 里旁路传)。
|
||||
//
|
||||
// 为什么不把 ctx 加进 ToolHandler 签名(2026-10-25 的架构决定):
|
||||
//
|
||||
// 公开签名 `func(args) (any, error)` 是插件作者的契约。为了内核能停下一个
|
||||
// 自己超时的调用而改它是本末倒置——要改 100 处调用点 + 全部存量插件,
|
||||
// 而受益者本应是插件(它们一行都不用动)。
|
||||
//
|
||||
// 真正需要 ctx 的只有**内核 ↔ 插件进程**之间那一层:proc 桥要把它传给
|
||||
// CallContext,由后者向子进程发 call.cancel。这一段完全在内核内部,
|
||||
// 用 args 上的私有键旁路传递即可,无需污染公开 API。
|
||||
//
|
||||
// 为什么键是这种形态:args 是 map[string]interface{},键只能取字符串。
|
||||
// 用不可见前缀 + 极不日常的名字,保证它不会被真实工具参数撞上;同时
|
||||
// ExecuteTool 在**下发给插件前**会把它剥掉(见 stripExecuteCtx),
|
||||
// 所以插件永远看不到它,也不可能因为多出一个键而报参数校验失败。
|
||||
const executeCtxArgKey = "\x00kernel.execute_ctx\x00"
|
||||
|
||||
// CurrentExecuteCtx 取出本次工具调用的执行上下文(proc 桥用)。
|
||||
//
|
||||
// 没有绑定时返回 context.Background(),调用方可直接使用、无需判空。
|
||||
func CurrentExecuteCtx(args map[string]interface{}) context.Context {
|
||||
if args != nil {
|
||||
if ctx, ok := args[executeCtxArgKey].(context.Context); ok && ctx != nil {
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
// stripExecuteCtx 返回去掉执行上下文键的**副本**,供下发给插件/设备用。
|
||||
//
|
||||
// 为什么必须剥:插件侧可能对参数做严格校验("未知参数"报错),
|
||||
// 而这个键是内核内部约定,不该出现在任何插件眼里。
|
||||
func stripExecuteCtx(args map[string]interface{}) map[string]interface{} {
|
||||
if args == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := args[executeCtxArgKey]; !ok {
|
||||
return args
|
||||
}
|
||||
out := make(map[string]interface{}, len(args))
|
||||
for k, v := range args {
|
||||
if k == executeCtxArgKey {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExecuteTool 执行工具(无取消语义)。
|
||||
func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
|
||||
return h.ExecuteToolCtx(context.Background(), name, args)
|
||||
}
|
||||
|
||||
// ExecuteToolCtx 执行工具,并把 ctx 交给**内核侧的执行体**。
|
||||
//
|
||||
// ctx 沿两条路生效,都不需要插件改代码:
|
||||
// - StageHost 自己的工具(proc 插件桥):经 goroutine 本地 ctx 传给
|
||||
// CallContext,取消时向插件进程发 call.cancel;
|
||||
// - io 设备工具:同样经 goroutine 本地 ctx 下传(见 ExecuteTool 的调用方)。
|
||||
//
|
||||
// handler 本身仍是旧签名;它若不消费 ctx,行为与今天逐字一致。
|
||||
func (h *StageHost) ExecuteToolCtx(ctx context.Context, name string, args map[string]interface{}) (ret interface{}, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[stage] tool %s handler panic: %v\n%s", name, r, debug.Stack())
|
||||
@ -171,7 +256,30 @@ func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret i
|
||||
log.Printf("[security] 工具 %s 参数回填 %d 处(别名 %v)", name, n, used)
|
||||
}
|
||||
}
|
||||
return handler(args)
|
||||
// 把 ctx 放进**执行栈**而不是 handler 签名:proc 桥在自己的 CallContext
|
||||
// 里用 CurrentExecuteCtx 取它。这样取消能力只在内核内部流动,
|
||||
// 插件作者的 handler 签名与源码一行不变。
|
||||
//
|
||||
// 注意:这里不能用「把 ctx 存在 StageHost 字段里」——同一插件可被
|
||||
// 多个调用并发执行,共享字段会互相覆盖(就是 qq 插件花大力气修过的
|
||||
// 那个"全局身份不可靠"问题)。
|
||||
return handler(withExecuteCtx(ctx, args))
|
||||
}
|
||||
|
||||
// withExecuteCtx 把 ctx 绑定到本次调用上,随 goroutine 走。
|
||||
//
|
||||
// 具体实现见 execctx.go:Go 没有 goroutine-local storage,这里用
|
||||
// **参数旁路**(在 args 里放一个不可见的私有键)最省事且无并发歧义——
|
||||
// args 是每次调用独立的 map,天然一 goroutine 一份。
|
||||
func withExecuteCtx(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[executeCtxArgKey] = ctx
|
||||
return args
|
||||
}
|
||||
|
||||
func (h *StageHost) ToolPlugin(name string) string {
|
||||
|
||||
@ -157,7 +157,7 @@ func TestTruncatedToolCallIsShortCircuited(t *testing.T) {
|
||||
},
|
||||
}
|
||||
a := &Agent{}
|
||||
got := a.executeToolCallInner(tc, "webui", nil).Text
|
||||
got := a.executeToolCallInner(context.Background(), tc, "webui", nil).Text
|
||||
|
||||
if strings.Contains(got, "path is required") {
|
||||
t.Errorf("截断后仍走了工具分派(模型会原样重试):%s", got)
|
||||
|
||||
@ -365,6 +365,17 @@ func (a *Agent) rebaseFramePrefix(f *TaskFrame) {
|
||||
|
||||
f.Msgs = append(prefix, tail...)
|
||||
f.PrefixLen = len(prefix)
|
||||
|
||||
// ★ 恢复也是**新的上下文装配点**:中断任务跑完已把它的输入/输出提交进
|
||||
// a.context(见上面注释),于是重建出的前缀可能比被挂起时更大。
|
||||
// 不在这里裁,这份"更大的前缀"会被原样发出去,下一轮再靠 stepLLM 的
|
||||
// 闸门补救——而那时已经付过一次超限请求的代价。
|
||||
//
|
||||
// 用的是**恢复专用**入口:相关性按本帧最新上下文算、保护范围是本帧自己的
|
||||
// 现场(f.Msgs[PrefixLen:]),而不是全局末尾 N 条。见 enforceResumeContextBudget。
|
||||
if handled, _ := a.enforceResumeContextBudget(f); handled && f.Terminal == terminalError {
|
||||
return // 预算耗尽:f.Err 已写明,调用方据此终止
|
||||
}
|
||||
}
|
||||
|
||||
// prepareInputTask 执行 processInput 的前半段(去重、通道解析、阶段、裁剪、
|
||||
@ -676,14 +687,24 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome {
|
||||
if llmErr != nil {
|
||||
// ★ 上下文超页(上游 ErrContextFull)必须在 provider fallback **之前**处理:
|
||||
// fallback 会换 provider 重发**同一个**超限请求,换谁都一样超。
|
||||
// 与本地预判(maybeHandleContextOverflow)走同一条裁剪路径。
|
||||
if a.handleUpstreamContextFull(llmErr) {
|
||||
// 已裁剪。**必须退回 StepPrepare 重建 f.Msgs**:
|
||||
// f.Msgs 只在 stepPrepare 里由 buildMessages 装配,stepLLM 不重建;
|
||||
// 而挂起帧里那份就是刚刚超限的那份,复用它等于原样再发一次。
|
||||
f.Turn++
|
||||
f.Step = StepPrepare
|
||||
return outcomeContinue
|
||||
//
|
||||
// 这里**不另立路径**:上游报错只是「判定已被短路」的一种(force=true),
|
||||
// 之后的恢复预算 / 裁剪 / raise L4 与本地预判完全相同,共用同一个函数。
|
||||
// 区别仅在于:本地预判在发请求**之前**判,这里在**收到错误之后**判。
|
||||
if isUpstreamContextFull(llmErr) {
|
||||
handled, _ := a.enforceContextBudget(f, "", true)
|
||||
if handled {
|
||||
if f.Terminal == terminalError {
|
||||
return outcomeFailed // 恢复预算耗尽,f.Err 已写明
|
||||
}
|
||||
// 已裁剪。**必须退回 StepPrepare 重建 f.Msgs**:
|
||||
// f.Msgs 只在 stepPrepare 里由 buildMessages 装配,stepLLM 不重建;
|
||||
// 而挂起帧里那份就是刚刚超限的那份,复用它等于原样再发一次。
|
||||
f.Turn++
|
||||
f.Step = StepPrepare
|
||||
return outcomeContinue
|
||||
}
|
||||
// 未处理(如轻量内核不裁剪):落到下面按普通错误处理。
|
||||
}
|
||||
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
|
||||
if f.OutputChannel == channelConsolidation {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
@ -194,9 +195,22 @@ func (a *Agent) executeToolCallOutcome(tc agentAPI.ToolCall, channel string, tur
|
||||
}
|
||||
}()
|
||||
|
||||
// ★ 取消必须**真的传到对端**,而不只是让本函数不再等。
|
||||
//
|
||||
// 旧实现只有 time.After:到点后本函数返回,但那个 goroutine 仍在跑,
|
||||
// 阻塞在跨进程 Call 上且**没有任何通道能通知它**——插件侧的 handler
|
||||
// 毫不知情,继续占着连接/子进程/文件句柄直到自己跑完,结果却被丢弃。
|
||||
// 长工具 + 频繁超时 ⇒ 后台持续堆积。
|
||||
//
|
||||
// 现在把 ctx 沿执行链传下去(经 args 旁路,见 StageHost.ExecuteToolCtx),
|
||||
// 由 proc 桥的 CallContext 在取消时向插件发 call.cancel。
|
||||
// **插件作者一行都不用改**:handler 签名不变。
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel() // 正常返回也要放:否则 ctx 内部的 timer/子 goroutine 会泄漏
|
||||
|
||||
done := make(chan toolOutcome, 1)
|
||||
go func() {
|
||||
done <- a.executeToolCallInner(tc, channel, turnScenes)
|
||||
done <- a.executeToolCallInner(ctx, tc, channel, turnScenes)
|
||||
}()
|
||||
|
||||
// ★ 超时:优先用工具**自己声明**的时长,而不是一刀切 60s。
|
||||
@ -218,14 +232,16 @@ func (a *Agent) executeToolCallOutcome(tc agentAPI.ToolCall, channel string, tur
|
||||
case result := <-done:
|
||||
return result
|
||||
case <-time.After(toolTimeout):
|
||||
log.Printf("[agent] tool %s timed out after %v", tc.Name, toolTimeout)
|
||||
// 先 cancel 再返回:这一行就是"让插件停下来"的触发点。
|
||||
cancel()
|
||||
log.Printf("[agent] tool %s timed out after %v(已向执行方发送取消)", tc.Name, toolTimeout)
|
||||
te := newToolError(ErrReasonTimeout, "", fmt.Sprintf("工具 %s 执行超时(%s)", tc.Name, fmtDuration(toolTimeout)),
|
||||
"该工具本次未在时限内返回。可改用更小的任务,或换用其他工具。")
|
||||
return toolOutcome{Text: te.Error(), Raw: te}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnScenes []string) toolOutcome {
|
||||
func (a *Agent) executeToolCallInner(ctx context.Context, tc agentAPI.ToolCall, channel string, turnScenes []string) toolOutcome {
|
||||
// 参数没法用(被 max_tokens 截断,或 JSON 写坏了):**不要**拿着空/残缺参数去调工具。
|
||||
// 否则工具会报 “path is required”“command is required” 这类与真因无关的错,
|
||||
// 模型看不出真因、只能原样重试(实测 cmd_run 失败率高达 34%~48%)。
|
||||
@ -290,7 +306,7 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS
|
||||
}
|
||||
|
||||
if a.stageHost != nil {
|
||||
if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil {
|
||||
if result, err := a.stageHost.ExecuteToolCtx(ctx, tc.Name, tc.Arguments); err == nil {
|
||||
// Raw 必须带上:否则结构化失败({"error":…} / ToolError)在这一步被抹平成文本,
|
||||
// Success 又会退回恒真——正是阶段 1b 要修的那个洞。
|
||||
return toolOutcome{Text: renderToolResult(tc.Name, result), Raw: result}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user