Files
HomeAgent/internal/agent/core/stream_index_test.go
JianFeeeee f2ec46480e refactor(core)!: N0 无状态化 —— 删除 Agent.currentOutputChannel,通道只跟输入事件/帧走
驻留式子 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(只剩描述历史的注释)
2026-09-13 08:59:15 +08:00

88 lines
2.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"context"
"encoding/json"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// 回归:并行多工具调用的流式分片必须按上游 index 字段分桶累积,
// 不能用 Go range 序号(每 chunk 单元素时恒为 0导致全部污染到同一桶
func TestAccumulateStreamParallelToolCallsByIndex(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mk := func(idx int, id, name, raw string) agentAPI.StreamChunk {
return agentAPI.StreamChunk{
ToolCalls: []agentAPI.ToolCall{{
ID: id, Type: "function", Name: name, RawArguments: raw, StreamIndex: idx,
}},
}
}
ch := make(chan agentAPI.StreamChunk, 32)
chunks := []agentAPI.StreamChunk{
{Content: ""},
// tool_call 0: spawn_child 参数较长,分多片
mk(0, "call_a", "spawn_child", "{\"task\":"),
mk(0, "", "", "\"调查大模型排名\"}"),
// tool_call 1: browser_render
mk(1, "call_b", "browser_render", "{\"url\":"),
mk(1, "", "", "\"https://example.com\"}"),
// tool_call 2: cmd_run
mk(2, "call_c", "cmd_run", "{\"command\":\"uname -a\"}"),
// tool_call 3: skill_list
mk(3, "call_d", "skill_list", "{}"),
{Done: true, FinishReason: "tool_calls"},
}
for _, ck := range chunks {
ch <- ck
}
close(ch)
resp, err := accumulateStream(ctx, ch, nil, "cli")
if err != nil {
t.Fatalf("accumulateStream: %v", err)
}
if len(resp.ToolCalls) != 4 {
t.Fatalf("expected 4 tool calls, got %d: %+v", len(resp.ToolCalls), resp.ToolCalls)
}
want := map[string]string{
"spawn_child": `{"task":"调查大模型排名"}`,
"browser_render": `{"url":"https://example.com"}`,
"cmd_run": `{"command":"uname -a"}`,
"skill_list": `{}`,
}
for _, tc := range resp.ToolCalls {
raw, _ := json.Marshal(tc.Arguments)
got := string(raw)
exp, ok := want[tc.Name]
if !ok {
t.Errorf("unexpected tool %q args=%s", tc.Name, got)
continue
}
delete(want, tc.Name)
if tc.Name == "skill_list" {
// 无参工具的合法空对象 {},只需确认没被污染成乱码
continue
}
if len(tc.Arguments) == 0 {
t.Errorf("tool %q has EMPTY arguments (index pollution regression)", tc.Name)
continue
}
var wantMap map[string]interface{}
json.Unmarshal([]byte(exp), &wantMap)
gotB, _ := json.Marshal(wantMap)
if got != string(gotB) {
t.Errorf("tool %q args = %s, want %s", tc.Name, got, exp)
}
}
if len(want) > 0 {
t.Errorf("missing tool calls: %v", want)
}
if resp.FinishReason != "tool_calls" {
t.Errorf("finish reason = %q, want tool_calls", resp.FinishReason)
}
}