mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
驻留式子 agent 设计(docs/zh/resident-subagent-design.md)的里程碑 N0。 ## 问题 `a.currentOutputChannel` 是 **agent 级可变字段**,只在 prepare 段写入,而被打断任务 恢复时**不重新 prepare**(resumeTask 只 rebase 前缀)。于是中断任务 prepare 时把它 覆盖成自己的通道,被恢复的任务再把回复发到**中断任务的通道**上——两个任务串台。 后果不只是标签错:工具提示词里那句"当前输入来源通道是 X,对应输出门工具是 output_send__X"会诱导模型**把回复主动发到错误的通道**。 ## 两处一起改(用户指出的两件事) 1. **内核不应持有"当前通道"**:通道是随输入事件带进来的,路由发生在**进内核之前**, 输出是 agent 的**主动调用**。删除该字段,改为一律从输入事件推导 (`outputChannelOf(evt)`)或读本任务的帧(`f.OutputChannel`)。 2. **提示词不应预设 outputch**:删掉"当前输入来源通道是 X → 用 output_send__X"那两行, 改为"不要假设当前通道是固定值;先看消息本身与上下文的来源信息,不确定时先调 output_list_channels"。 ## 改动面(把通道一路显式传下去,而不是读共享状态) - `agent.go`:删字段 - `task.go`:新增 `outputChannelOf` / `isCriticalChannel`;帧记录通道; 安全点与 setCritical 用帧/事件推导;步骤内事件标签改用 `f.OutputChannel`; `executeToolCall(f.CurTool, f.OutputChannel)`;`callLLMWithFallback(..., f.OutputChannel)` - `process.go`:`chatStreamWithFallback` / `accumulateStream` 增加 channel 参数 (增量事件的 channel 标签由此而来) - `stage.go`:`runStage` 从 `ctx.Extra["output_channel"]` 读(发起方写入) - `eventloop.go`:`emitResponse` 用 `outputChannelOf(evt)`;stageCtx 带上通道 - `spawn.go` / `toolcall.go`:`executeSpawnChild` 的 parentChannel 由调用方(帧)传入 (子任务完成通知要回到**发起这次 spawn 的那个任务**的通道) - `distill.go`:删掉 consolidation 路径里的赋值 - `tooldefs.go`:删掉提示词里的通道预设 ## 验收 - `scheduler_channel_routing_test.go`(N0 守卫):中断任务跑过之后,被恢复任务的 输出通道仍是它自己的(改前实测为 cli,期望 qq) - `TestCriticalSection_ConsolidationMarked`:补上推导链 「输入事件 → 通道 → isCriticalChannel → scheduler.critical」的集成断言 - 全仓 `go test ./...` 37 包 ok / 0 FAIL;`-race ./internal/agent/...` 干净 - 残留 `currentOutputChannel` 引用为 0(只剩描述历史的注释)
168 lines
5.4 KiB
Go
168 lines
5.4 KiB
Go
package core
|
||
|
||
// M4 验收测试:临界区语义显式化 + 抢占延迟到安全点 + 批次不再被中断放弃。
|
||
//
|
||
// 设计依据 docs/zh/input-scheduler-design.md §4.3(临界区)、§11.1(P5/P6)。
|
||
//
|
||
// 关键结构事实:让位检查**只在 step 之间**进行,因此任何正在执行的 step
|
||
// (工具 RPC、ONNX、CAS 落盘)天然不可抢占——中断只能等它返回。
|
||
|
||
import (
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
// P5/P6:工具执行期间到达的高优先级中断不得立即抢占;必须等工具返回后的安全点。
|
||
func TestPreempt_DeferredDuringToolExec(t *testing.T) {
|
||
sh := NewStageHost()
|
||
entered := make(chan struct{})
|
||
release := make(chan struct{})
|
||
var once sync.Once
|
||
sh.RegisterTool("t_slow", sdk.ToolDef{Name: "t_slow", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||
once.Do(func() { close(entered) })
|
||
<-release
|
||
return "slow-done", nil
|
||
})
|
||
|
||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||
{Content: "", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_slow")}}, // 低优先级任务调用慢工具
|
||
{Content: "low-done"}, // 恢复后收尾
|
||
{Content: "intr-done"}, // 中断任务
|
||
}}
|
||
a := New(AgentConfig{
|
||
ID: "crit",
|
||
Provider: sp,
|
||
ProviderManager: agentAPI.NewProviderManager(),
|
||
IO: agentIO.NewIOManager(),
|
||
StageHost: sh,
|
||
})
|
||
|
||
if _, _ = enqueueQueued(t, a, "qq", "低优先级任务"); true {
|
||
}
|
||
lt, _, _ := a.sched.nextRef()
|
||
done := make(chan struct{})
|
||
go func() { a.executeNewTask(lt); close(done) }()
|
||
|
||
select {
|
||
case <-entered:
|
||
case <-time.After(3 * time.Second):
|
||
t.Fatal("慢工具未被调用")
|
||
}
|
||
|
||
// 工具执行中注入 L4 中断。
|
||
intrEvt, _ := textEvent("cli", "紧急打断")
|
||
intrEvt.Payload["interrupt"] = true
|
||
if !a.sched.requestKernelPreempt(intrEvt) {
|
||
t.Fatal("L4 应 arm 让位信号")
|
||
}
|
||
// 关键断言:信号已 arm,但任务仍在工具里 —— 绝不能挂起。
|
||
if !a.sched.preemptGrantedFor() {
|
||
t.Fatal("让位信号应已 arm")
|
||
}
|
||
if a.DumpScheduler().Running == nil {
|
||
t.Fatal("工具执行中不得挂起(StepToolExec 是临界区)")
|
||
}
|
||
if len(a.DumpScheduler().SuspendStack) != 0 {
|
||
t.Fatal("工具执行中 suspendStack 应为空")
|
||
}
|
||
|
||
// 放行工具 → 工具返回后的安全点才挂起。
|
||
close(release)
|
||
select {
|
||
case <-done:
|
||
case <-time.After(3 * time.Second):
|
||
t.Fatal("工具返回后未挂起")
|
||
}
|
||
|
||
snap := a.DumpScheduler()
|
||
if len(snap.SuspendStack) != 1 {
|
||
t.Fatalf("工具返回后 suspendStack=%d,期望 1", len(snap.SuspendStack))
|
||
}
|
||
if snap.SuspendStack[0].Frame.Step != StepToolAfter {
|
||
t.Fatalf("应在工具执行后的安全点挂起(StepToolAfter),实际 %v", snap.SuspendStack[0].Frame.Step)
|
||
}
|
||
if len(snap.PendingInterrupts) != 1 {
|
||
t.Fatalf("中断请求不得丢失,pendingInterrupts=%d", len(snap.PendingInterrupts))
|
||
}
|
||
}
|
||
|
||
// _consolidation_ 整任务视为不可抢占(它直接改图库)。
|
||
func TestCriticalSection_ConsolidationMarked(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
|
||
// 判定:只有记忆整理通道是整任务临界区。
|
||
if isCriticalChannel("cli") {
|
||
t.Fatal("普通通道不应被判为临界区")
|
||
}
|
||
if !isCriticalChannel(channelConsolidation) {
|
||
t.Fatal("记忆整理必须是不可抢占临界区")
|
||
}
|
||
|
||
// 集成:标志的推导链「输入事件 → 通道 → isCriticalChannel → scheduler.critical」
|
||
// 必须成立(N0 之后通道只从事件推导,不再有内核可变字段)。
|
||
for _, c := range []struct {
|
||
channel string
|
||
want bool
|
||
}{
|
||
{"cli", false},
|
||
{channelConsolidation, true},
|
||
} {
|
||
evt, _ := textEvent("tc", "x")
|
||
evt.OutputChannel = c.channel
|
||
a.sched.setCritical(isCriticalChannel(outputChannelOf(evt)))
|
||
if got := a.sched.inCritical(); got != c.want {
|
||
t.Fatalf("通道 %q → critical=%v,期望 %v", c.channel, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 新语义:没有抢占时,同批的多个工具必须全部执行——不再有「中断放弃剩余批」。
|
||
func TestBatch_NotAbandonedWithoutPreemption(t *testing.T) {
|
||
sh := NewStageHost()
|
||
var mu sync.Mutex
|
||
var ran []string
|
||
reg := func(name string) {
|
||
sh.RegisterTool(name, sdk.ToolDef{Name: name, Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||
mu.Lock()
|
||
ran = append(ran, name)
|
||
mu.Unlock()
|
||
return name + "-out", nil
|
||
})
|
||
}
|
||
reg("t_a")
|
||
reg("t_b")
|
||
|
||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||
{Content: "", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_a"), tc("c2", "t_b")}},
|
||
{Content: "全部完成"},
|
||
}}
|
||
a := New(AgentConfig{
|
||
ID: "batch",
|
||
Provider: sp,
|
||
ProviderManager: agentAPI.NewProviderManager(),
|
||
IO: agentIO.NewIOManager(),
|
||
StageHost: sh,
|
||
})
|
||
|
||
if _, _ = enqueueQueued(t, a, "cli", "跑两个工具"); true {
|
||
}
|
||
tt, _, _ := a.sched.nextRef()
|
||
a.executeNewTask(tt)
|
||
|
||
if len(ran) != 2 || ran[0] != "t_a" || ran[1] != "t_b" {
|
||
t.Fatalf("同批工具应全部按序执行,实际 %v", ran)
|
||
}
|
||
snap := a.DumpScheduler()
|
||
if len(snap.SuspendStack) != 0 || len(snap.PendingInterrupts) != 0 {
|
||
t.Fatalf("无抢占时不应有挂起或待处理中断:%+v", snap)
|
||
}
|
||
if snap.Stats.Executed != 1 {
|
||
t.Fatalf("Executed=%d,期望 1", snap.Stats.Executed)
|
||
}
|
||
}
|