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(只剩描述历史的注释)
88 lines
2.6 KiB
Go
88 lines
2.6 KiB
Go
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)
|
||
}
|
||
}
|